fix(api): исключить повторное создание задачи выпуска

This commit is contained in:
Denozordec committed 2026-10-01 14:16:10 +07:00
1 parent 299f1da90b
commit 97526bf06f
12 files changed
+133 -27

No files matched your search

@@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { acceptCertificateIssueRequest, IssueRequestError, type IssueRecordInput } from "./certificate-issue-request.js"
const request = { requestId: "11111111-1111-4111-8111-111111111111", serverId: "1", certName: "cert", domainNames: ["test.example"] }
function repository() {
const jobs = new Map<string, IssueRecordInput>(); const queued: string[] = []
return { jobs, queued, deps: { getServer: async () => ({}), getJob: async (id: string) => jobs.get(id) ?? null, createJob: async (row: IssueRecordInput) => { if (jobs.has(row.id)) return false; jobs.set(row.id, row); return true }, queue: (id: string) => { queued.push(id) } } }
}
test("lost acknowledgment retry returns same job without duplicate create or queue", async () => {
const repo = repository()
const first = await acceptCertificateIssueRequest(request, repo.deps)
const second = await acceptCertificateIssueRequest(request, repo.deps)
assert.deepEqual(first, second); assert.equal(repo.jobs.size, 1); assert.deepEqual(repo.queued, [request.requestId])
})
test("concurrent matching requests produce one job and one queue dispatch", async () => {
const repo = repository(); const results = await Promise.all([acceptCertificateIssueRequest(request, repo.deps), acceptCertificateIssueRequest(request, repo.deps)])
assert.deepEqual(results[0], results[1]); assert.equal(repo.jobs.size, 1); assert.equal(repo.queued.length, 1)
})
test("request ID collision with different target is conflict, never new operation", async () => {
const repo = repository(); await acceptCertificateIssueRequest(request, repo.deps)
for (const patch of [{ serverId: "2" }, { certName: "other" }, { domainNames: ["other.example"] }, { keyType: "ec256" as const }, { trustStore: ["api"] as const }]) {
await assert.rejects(() => acceptCertificateIssueRequest({ ...request, ...patch, ...(patch.trustStore ? { trustStore: [...patch.trustStore] } : {}) }, repo.deps), error => error instanceof IssueRequestError && error.status === 409)
}
assert.equal(repo.jobs.size, 1); assert.equal(repo.queued.length, 1)
})
test("missing server is rejected; legacy requests without requestId remain independent", async () => {
const repo = repository(); await assert.rejects(() => acceptCertificateIssueRequest(request, { ...repo.deps, getServer: async () => null }), error => error instanceof IssueRequestError && error.status === 404)
assert.equal(repo.jobs.size, 0)
const { requestId: unused, ...legacy } = request; void unused
const first = await acceptCertificateIssueRequest(legacy, repo.deps), second = await acceptCertificateIssueRequest(legacy, repo.deps)
assert.notEqual(first.jobId, second.jobId); assert.equal(repo.jobs.size, 2)
})
@@ -0,0 +1,32 @@
import { randomUUID } from "node:crypto"
import type { CertificateIssueRequest } from "@mmapp/contracts/certificates"
import { parseJsonArray } from "../db/json.js"
export type IssueRecordInput = { id: string; serverId: string; certName: string; domainNames: string[]; keyType: string; trustStore: string; source: "manual" }
type ExistingIssue = { serverId: number | string; certName: string; domainNames: unknown; keyType: string; trustStore: string; source: string }
export class IssueRequestError extends Error {
constructor(message: string, readonly status: number) { super(message) }
}
export async function acceptCertificateIssueRequest(input: CertificateIssueRequest, deps: {
getServer: (id: string) => Promise<unknown | null>
getJob: (id: string) => Promise<ExistingIssue | null>
createJob: (row: IssueRecordInput) => Promise<boolean>
queue: (id: string) => void
}) {
const id = input.requestId ?? randomUUID()
const row: IssueRecordInput = { id, serverId: String(input.serverId), certName: input.certName, domainNames: input.domainNames, keyType: input.keyType ?? "rsa2048", trustStore: (input.trustStore ?? ["www", "api"]).join(","), source: "manual" }
const matches = (existing: ExistingIssue) => String(existing.serverId) === row.serverId && existing.certName === row.certName && JSON.stringify(parseJsonArray(existing.domainNames)) === JSON.stringify(row.domainNames) && existing.keyType === row.keyType && existing.trustStore === row.trustStore && existing.source === "manual"
const previous = await deps.getJob(id)
if (previous) {
if (!matches(previous)) throw new IssueRequestError("requestId уже относится к другому выпуску", 409)
return { jobId: id }
}
if (!await deps.getServer(row.serverId)) throw new IssueRequestError("Сервер не найден", 404)
const created = await deps.createJob(row)
if (!created) {
const existing = await deps.getJob(id)
if (!existing || !matches(existing)) throw new IssueRequestError("requestId уже относится к другому выпуску", 409)
} else deps.queue(id)
return { jobId: id }
}
@@ -141,6 +141,13 @@ export async function createIssueJobRecord(input: {
})
}
export async function createIssueJobRecordIfAbsent(input: { id: string; serverId: string; certName: string; domainNames: string[]; keyType: string; trustStore: string; source: "manual" }) {
const rows = await db.insert(certificateIssueJobs).values({
...input, serverId: Number(input.serverId), status: "queued", step: "queued", requestedAt: new Date().toISOString(),
}).onConflictDoNothing({ target: certificateIssueJobs.id }).returning({ id: certificateIssueJobs.id })
return rows.length > 0
}
export async function updateIssueJobRecord(
id: string,
patch: Partial<{