fix(backups): сохранить идентичность фоновой задачи

This commit is contained in:
Denozordec committed 2026-10-01 15:44:02 +07:00
1 parent 30bd479a6f
commit 4aee2a34f1
13 files changed
+157 -12

No files matched your search

@@ -19,6 +19,13 @@ for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19)
}
assert.equal(created, 2); assert.ok(eventCalls >= 2)
const requestId = "11111111-1111-4111-8111-111111111111", before = created
const repeated = await Promise.all([post({ requestId, serverIds: ["1"], notes: " manual " }), post({ requestId, serverIds: [1], notes: "manual" })])
assert.ok(repeated.every(response => response.statusCode === 202 && response.json().jobId === requestId))
await new Promise(resolve => setImmediate(resolve)); assert.equal(created, before + 1)
assert.equal((await post({ requestId, serverIds: ["1"], notes: "manual" })).statusCode, 202); assert.equal(created, before + 1)
assert.equal((await post({ requestId, serverIds: ["2"], notes: "manual" })).statusCode, 409)
assert.equal((await post({ requestId, serverIds: ["1"], notes: "other" })).statusCode, 409)
const success = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(success.statusCode, 200); assert.deepEqual(success.json(), { filename: "config.rsc", serverName: "router" }); assert.equal(restored, 1)
restoreFail = true; const failure = await app.inject({ method: "POST", url: "/api/backups/backup-1/restore" }); assert.equal(failure.statusCode, 400); assert.equal(failure.json().error, "import failed")
assert.equal(permissionForRequest("POST", "/api/backups/create"), "mm:backups:write"); assert.equal(permissionForRequest("POST", "/api/backups/backup-1/restore"), "mm:backups:write"); assert.equal(permissionForRequest("GET", "/api/backups/jobs/job"), "mm:backups:read")
+15 -2
View File
@@ -94,13 +94,20 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => {
const inputIds = req.body.serverIds.map((x) => String(x))
const notes = req.body.notes?.trim() || undefined
const previous = req.body.requestId ? backupJobs.get(req.body.requestId) : undefined
if (previous) {
if (JSON.stringify(previous.serverIds) !== JSON.stringify(inputIds) || previous.notes !== notes) return reply.status(409).send({ error: "Request ID уже относится к другой задаче" })
return reply.status(202).send({ jobId: previous.id, status: previous.status, total: previous.total, completed: previous.completed, serverIds: previous.serverIds })
}
const existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id)))
const ids = [...new Set(inputIds)]
if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" })
const jobId = randomUUID()
const jobId = req.body.requestId ?? randomUUID()
const job: BackupJob = {
id: jobId,
serverIds: ids,
notes,
status: "queued",
requestedAt: new Date().toISOString(),
total: ids.length,
@@ -108,8 +115,13 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
created: [],
failures: [],
}
const raced = backupJobs.get(jobId)
if (raced) {
if (JSON.stringify(raced.serverIds) !== JSON.stringify(ids) || raced.notes !== notes) return reply.status(409).send({ error: "Request ID уже относится к другой задаче" })
return reply.status(202).send({ jobId, status: raced.status, total: raced.total, completed: raced.completed, serverIds: raced.serverIds })
}
backupJobs.set(jobId, job)
await event({
void event({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
@@ -151,6 +163,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
status: job.status,
total: job.total,
completed: job.completed,
serverIds: job.serverIds,
})
})
@@ -4,7 +4,7 @@ import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
import { backupJobResponseSchema } from "@mmapp/contracts/backups"
import { processBackupJob, recordBackupEvent } from "./backup-job-execution.js"
const item = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual" as const, storage: "local" as const }
const job = (): BackupJobStatusResponse => ({ id: "job", status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] })
const job = (): BackupJobStatusResponse => ({ id: "job", serverIds: ["1", "2"], status: "queued", requestedAt: "2026-10-01T00:00:00Z", total: 2, completed: 0, created: [], failures: [] })
test("event failure preserves done and actual created files", async () => {
const row = job(), warnings: unknown[] = []
await processBackupJob(row, ["1", "2"], undefined, { create: async id => ({ ...item, id, serverId: Number(id) }), event: input => recordBackupEvent(input, async () => { throw new Error("event unavailable") }, error => { warnings.push(error) }) })
@@ -16,7 +16,7 @@ test("one failed device keeps partial files and completes every target", async (
assert.equal(row.status, "done"); assert.equal(row.completed, 2); assert.equal(row.created.length, 1); assert.deepEqual(row.failures, [{ serverId: "2", error: "device unavailable" }]); backupJobResponseSchema.parse(row)
})
test("S3 upload error remains a local copy and a warning rather than full success", async () => {
const row = job(); row.total = 1
const row = job(); row.total = 1; row.serverIds = ["1"]
await processBackupJob(row, ["1"], undefined, { create: async () => ({ ...item, uploadError: "S3 unavailable" }), event: async input => { assert.equal(input.level, "warning"); assert.match(input.message, /ошибок загрузки: 1/) } })
assert.equal(row.status, "done"); assert.equal(row.created[0].storage, "local"); assert.equal(row.created[0].uploadError, "S3 unavailable"); backupJobResponseSchema.parse(row)
})