fix(backups): сохранить идентичность фоновой задачи
This commit is contained in:
1 parent
30bd479a6f
commit
4aee2a34f1
13 files changed
+157
-12
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { backupJobCheckpointSchema, createBackupJobCheckpoint, readBackupJobCheckpoint, backupJobStorageKey } from "./backup-job-checkpoint"
|
||||
const id = "11111111-1111-4111-8111-111111111111"
|
||||
test("checkpoint freezes a canonical request before acknowledgment", () => {
|
||||
const ids = ["1", "2"], saved = createBackupJobCheckpoint({ serverIds: ids, notes: " manual " }, id); ids.push("3")
|
||||
assert.deepEqual(saved.request.serverIds, ["1", "2"]); assert.equal(saved.request.notes, "manual"); assert.equal(saved.accepted, false); assert.deepEqual(readBackupJobCheckpoint(JSON.stringify(saved)), saved)
|
||||
})
|
||||
test("corrupt or foreign checkpoints cannot be restored", () => {
|
||||
assert.throws(() => readBackupJobCheckpoint("bad")); assert.throws(() => createBackupJobCheckpoint({ serverIds: ["1", "1"] }, id))
|
||||
const saved = createBackupJobCheckpoint({ serverIds: ["1"] }, id)
|
||||
const job = { id: "other", status: "queued", serverIds: ["1"], total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
|
||||
assert.equal(backupJobCheckpointSchema.safeParse({ ...saved, accepted: true, job }).success, false)
|
||||
})
|
||||
test("storage keys preserve backend scope and absent storage stays absent", () => {
|
||||
assert.notEqual(backupJobStorageKey("one"), backupJobStorageKey("two")); assert.equal(readBackupJobCheckpoint(null), null)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod"
|
||||
import { backupJobResponseSchema, createBackupRequestSchema } from "@mmapp/contracts/backups"
|
||||
import type { CreateBackupRequest } from "@mmapp/contracts/backups"
|
||||
const requestSchema = createBackupRequestSchema.extend({ requestId: z.string().uuid() })
|
||||
export const backupJobCheckpointSchema = z.object({ request: requestSchema, accepted: z.boolean(), job: backupJobResponseSchema.nullable() }).superRefine((value, ctx) => {
|
||||
const job = value.job
|
||||
if (job && (!value.accepted || job.id !== value.request.requestId || JSON.stringify(job.serverIds) !== JSON.stringify(value.request.serverIds.map(String)) || (job.notes ?? "") !== (value.request.notes ?? ""))) ctx.addIssue({ code: "custom", message: "Наблюдение относится к другой задаче" })
|
||||
})
|
||||
export type BackupJobCheckpoint = z.infer<typeof backupJobCheckpointSchema>
|
||||
export function createBackupJobCheckpoint(request: CreateBackupRequest, requestId: string): BackupJobCheckpoint {
|
||||
return backupJobCheckpointSchema.parse({ request: { ...request, requestId, serverIds: request.serverIds.map(String), notes: request.notes?.trim() || undefined }, accepted: false, job: null })
|
||||
}
|
||||
export function readBackupJobCheckpoint(value: string | null): BackupJobCheckpoint | null { return value ? backupJobCheckpointSchema.parse(JSON.parse(value)) : null }
|
||||
export function backupJobStorageKey(backendUrl: string) { return "mmapp:backup-job:" + backendUrl }
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { pollBackupJob, backupJobOutcome, waitForBackupPoll } from "./backup-job-poll"
|
||||
import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
|
||||
const target = { serverIds: ["1"], notes: "manual" }
|
||||
const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
|
||||
const file = { 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 }
|
||||
test("serial queued/running reads complete only on terminal observation", async () => {
|
||||
let reads = 0, inFlight = 0, maximum = 0
|
||||
const job = await pollBackupJob("base", "job", target, { signal: new AbortController().signal, wait: async () => {}, readJob: async () => { inFlight++; maximum = Math.max(maximum, inFlight); await Promise.resolve(); const status = ++reads < 3 ? "running" : "done"; inFlight--; return { ...baseJob, status, ...(status === "done" ? { completed: 1, created: [file], finishedAt: "2026-10-01T00:01:00Z" } : {}) } } })
|
||||
assert.equal(reads, 3); assert.equal(maximum, 1); assert.equal(backupJobOutcome(job).fullSuccess, true)
|
||||
})
|
||||
test("partial device and upload errors never become full success", () => {
|
||||
for (const patch of [{ created: [], failures: [{ serverId: "1", error: "offline" }] }, { created: [{ ...file, uploadError: "S3 unavailable" }], failures: [] }]) {
|
||||
assert.equal(backupJobOutcome({ ...baseJob, status: "done", completed: 1, ...patch }).fullSuccess, false)
|
||||
}
|
||||
})
|
||||
test("foreign target is rejected before onJob", async () => {
|
||||
let observations = 0
|
||||
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, onJob: () => { observations++ }, readJob: async () => ({ ...baseJob, serverIds: ["2"] }) }), /не соответствует/)
|
||||
assert.equal(observations, 0)
|
||||
})
|
||||
test("read failure or exhausted observation budget is not a failed job", async () => {
|
||||
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, readJob: async () => { throw new Error("GET unavailable") } }), /GET unavailable/)
|
||||
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, attempts: 1, readJob: async () => baseJob }), /Время наблюдения/)
|
||||
})
|
||||
test("cancellation interrupts delay and suppresses late observation", async () => {
|
||||
const abort = new AbortController(), wait = waitForBackupPoll(10_000, abort.signal); abort.abort(); await assert.rejects(wait)
|
||||
const late = new AbortController(); let calls = 0
|
||||
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } }))
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BackupJobStatusResponse, CreateBackupRequest } from "@mmapp/contracts/backups"
|
||||
import { getBackupJob } from "@/shared/api/backups"
|
||||
export function waitForBackupPoll(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) { reject(signal.reason); return }
|
||||
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) }
|
||||
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms)
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
})
|
||||
}
|
||||
export async function pollBackupJob(baseUrl: string, jobId: string, target: CreateBackupRequest, options: {
|
||||
signal: AbortSignal; onJob?: (job: BackupJobStatusResponse) => void; readJob?: typeof getBackupJob; wait?: typeof waitForBackupPoll; attempts?: number; intervalMs?: number
|
||||
}) {
|
||||
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)])
|
||||
const readJob = options.readJob ?? getBackupJob, wait = options.wait ?? waitForBackupPoll, attempts = options.attempts ?? 150
|
||||
for (let index = 0; index < attempts; index++) {
|
||||
signal.throwIfAborted()
|
||||
const job = await readJob(baseUrl, jobId, { signal }); signal.throwIfAborted()
|
||||
if (job.id !== jobId || JSON.stringify(job.serverIds) !== JSON.stringify(target.serverIds.map(String)) || (job.notes ?? "") !== (target.notes?.trim() ?? "")) throw new Error("Ответ задачи не соответствует выбранным серверам или заметке")
|
||||
options.onJob?.(job)
|
||||
if (job.status === "done" || job.status === "failed") return job
|
||||
if (index + 1 < attempts) await wait(options.intervalMs ?? 1200, signal)
|
||||
}
|
||||
throw new Error("Время наблюдения истекло. Задача может продолжаться; повторите проверку той же задачи")
|
||||
}
|
||||
export function backupJobOutcome(job: BackupJobStatusResponse) {
|
||||
const uploadFailures = job.created.filter(item => item.uploadError)
|
||||
return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures }
|
||||
}
|
||||
Reference in new issue
Block a user