fix(backups): сохранить идентичность фоновой задачи
This commit is contained in:
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)
|
await new Promise(resolve => setImmediate(resolve)); assert.ok(attempt < 19)
|
||||||
}
|
}
|
||||||
assert.equal(created, 2); assert.ok(eventCalls >= 2)
|
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)
|
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")
|
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")
|
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")
|
||||||
|
|||||||
@@ -94,13 +94,20 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
|||||||
app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => {
|
app.post("/backups/create", { schema: { body: createBackupRequestSchema } }, async (req, reply) => {
|
||||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||||
const notes = req.body.notes?.trim() || undefined
|
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 existingServers = new Set((await (deps.listServers ?? listServersRead)()).map((s) => String(s.id)))
|
||||||
const ids = [...new Set(inputIds)]
|
const ids = [...new Set(inputIds)]
|
||||||
if (ids.some(id => !existingServers.has(id))) return reply.status(400).send({ error: "Выбранный сервер отсутствует в каталоге; обновите выбор" })
|
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 = {
|
const job: BackupJob = {
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
serverIds: ids,
|
||||||
|
notes,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
requestedAt: new Date().toISOString(),
|
requestedAt: new Date().toISOString(),
|
||||||
total: ids.length,
|
total: ids.length,
|
||||||
@@ -108,8 +115,13 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
|||||||
created: [],
|
created: [],
|
||||||
failures: [],
|
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)
|
backupJobs.set(jobId, job)
|
||||||
await event({
|
void event({
|
||||||
level: "info",
|
level: "info",
|
||||||
eventType: "backups.job.started",
|
eventType: "backups.job.started",
|
||||||
sourceModule: "backups",
|
sourceModule: "backups",
|
||||||
@@ -151,6 +163,7 @@ export function createBackupsRoutes(deps: { listServers?: () => Promise<Array<{
|
|||||||
status: job.status,
|
status: job.status,
|
||||||
total: job.total,
|
total: job.total,
|
||||||
completed: job.completed,
|
completed: job.completed,
|
||||||
|
serverIds: job.serverIds,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
|
|||||||
import { backupJobResponseSchema } from "@mmapp/contracts/backups"
|
import { backupJobResponseSchema } from "@mmapp/contracts/backups"
|
||||||
import { processBackupJob, recordBackupEvent } from "./backup-job-execution.js"
|
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 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 () => {
|
test("event failure preserves done and actual created files", async () => {
|
||||||
const row = job(), warnings: unknown[] = []
|
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) }) })
|
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)
|
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 () => {
|
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/) } })
|
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)
|
assert.equal(row.status, "done"); assert.equal(row.created[0].storage, "local"); assert.equal(row.created[0].uploadError, "S3 unavailable"); backupJobResponseSchema.parse(row)
|
||||||
})
|
})
|
||||||
+1
-1
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
|
|||||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||||
|
|
||||||
Актуальная точка продолжения: 11А.28 — создание/checkpoint/polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
Актуальная точка продолжения: 11А.28.2 — frontend checkpoint/создание/serial polling резервных копий и подтверждение partial результата, затем остальные операции/URL/приёмка. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
|
||||||
+10
-1
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.27.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: каталоги/API/runner/настройки; далее 11А.28 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.28.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: настройки/идентичность job, далее 11А.28.2 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
|
||||||
|
|
||||||
|
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
|
||||||
|
- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию.
|
||||||
|
- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError.
|
||||||
|
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
|
||||||
|
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
|
||||||
|
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
|
||||||
|
|
||||||
## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
|
## Блок 11А.27.2 — достоверные настройки и последняя проверка S3 (2026-10-01)
|
||||||
|
|
||||||
- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
|
- S3 форма валидирует обязательные Bucket/Access key/Secret key или наличие сохранённого секрета, HTTP(S) Endpoint. Пустое secret поле оставляет существующий ключ; пробельный новый ключ отклоняется. Поля и toggles имеют доступные имена. Dirty настройка имеет статус «Не сохранено», показ/скрытие секрета не считается изменением конфигурации.
|
||||||
|
|||||||
@@ -345,3 +345,12 @@
|
|||||||
- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
|
- Совместимость строгого backup API с PostgreSQL подтверждена mapper unit: raw timestamp преобразуется в ISO того же момента. Null server/0 byte/uploadError сохраняются; invalid дата/размер отклоняются. Сравнение timestamp receipt учитывает представление PostgreSQL, а не только строковое равенство.
|
||||||
- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
|
- 28 data/settings/storage E2E, 11 frontend helper/API unit, 3 DTO + 3 storage update + 3 job fault unit и HTTP/S3 профиль, frontend/backend production build, TypeScript/профильный ESLint прошли. Реальные PostgreSQL/RouterOS/S3 и конкурентные внешние клиенты не проверены.
|
||||||
- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
|
- **Следующий блок 11А.28:** single-flight создание, immutable цель/checkpoint, наблюдение без setInterval гонок и ложного полного успеха при failures/uploadError. Jobs сейчас in-memory: рестарт backend означает потерю наблюдения; автоматический повтор неизвестного POST недопустим. Затем delete/restore/download, URL, scheduler cleanup и визуальная приёмка.
|
||||||
|
|
||||||
|
## Блок 11А.28.1 — идентичность задачи и основа наблюдения (2026-10-01)
|
||||||
|
|
||||||
|
- Create request принимает необязательный UUID requestId; job/ack содержат canonical serverIds, job содержит заметку. Повтор той же цели/заметки в том же backend процессе возвращает прежний job; конфликт ID возвращает 409. Конкурентный повтор после async catalog lookup резервируется одним Map insert, без второй очереди. Старые клиенты без ID получают отдельные UUID.
|
||||||
|
- Событие запуска записывается независимо от очереди/ack, чтобы медленный журнал не держал запуск. GET job контракт проверяет цель, число устройств и принадлежащие ей created/failures. API ack проверяет request ID/все serverIds, неизвестное тело не подтверждает операцию.
|
||||||
|
- Добавлены строгий checkpoint по backend URL и serial polling helper: immutable request до POST, cross-check наблюдения, AbortSignal/срок чтения/наблюдения, read failure/timeout не становятся failed server job. Outcome отличает полный успех от device failures и uploadError.
|
||||||
|
- 16 frontend API/checkpoint/poll unit, backend test:backups с HTTP concurrent retry/conflict/RBAC, backend production build, полный TypeScript/профильный ESLint прошли. UI пока использует старое создание/setInterval; подключение в следующем блоке.
|
||||||
|
- Jobs/idempotency Map существуют только в текущем процессе. После рестарта API может вернуть 404 для ранее принятой операции; автоматический повтор неизвестного POST не реализован и недопустим без durable repository. Реальные DB/RouterOS/S3/рестарт не проверены.
|
||||||
|
- **Следующий блок 11А.28.2:** frontend checkpoint до POST, общий lock формы, GET-only resume/после reload/неизвестного ответа, точный partial результат, поздние ответы источника и профильные E2E. Затем остальные операции/URL/визуальная приёмка.
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -85,8 +85,9 @@ const backupServerIdSchema = z.union([
|
|||||||
z.number().int().positive().safe(),
|
z.number().int().positive().safe(),
|
||||||
])
|
])
|
||||||
export const createBackupRequestSchema = z.object({
|
export const createBackupRequestSchema = z.object({
|
||||||
|
requestId: z.string().uuid().optional(),
|
||||||
serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"),
|
serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().trim().max(500).optional(),
|
||||||
})
|
})
|
||||||
export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"])
|
export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"])
|
||||||
const backupProgressSchema = z.object({
|
const backupProgressSchema = z.object({
|
||||||
@@ -94,11 +95,14 @@ const backupProgressSchema = z.object({
|
|||||||
total: z.number().int().positive().safe(),
|
total: z.number().int().positive().safe(),
|
||||||
completed: z.number().int().nonnegative().safe(),
|
completed: z.number().int().nonnegative().safe(),
|
||||||
})
|
})
|
||||||
export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1) }).superRefine((job, ctx) => {
|
export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1), serverIds: z.array(z.string().min(1)).min(1) }).superRefine((job, ctx) => {
|
||||||
|
if (job.serverIds.length !== job.total || new Set(job.serverIds).size !== job.total) ctx.addIssue({ code: "custom", message: "Цели не соответствуют числу устройств" })
|
||||||
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
|
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
|
||||||
})
|
})
|
||||||
export const backupJobResponseSchema = backupProgressSchema.extend({
|
export const backupJobResponseSchema = backupProgressSchema.extend({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
|
serverIds: z.array(z.string().regex(/^[1-9][0-9]*$/)).min(1),
|
||||||
|
notes: z.string().optional(),
|
||||||
requestedAt: z.string().datetime({ offset: true }),
|
requestedAt: z.string().datetime({ offset: true }),
|
||||||
startedAt: z.string().datetime({ offset: true }).optional(),
|
startedAt: z.string().datetime({ offset: true }).optional(),
|
||||||
finishedAt: z.string().datetime({ offset: true }).optional(),
|
finishedAt: z.string().datetime({ offset: true }).optional(),
|
||||||
@@ -107,6 +111,7 @@ export const backupJobResponseSchema = backupProgressSchema.extend({
|
|||||||
}).superRefine((job, ctx) => {
|
}).superRefine((job, ctx) => {
|
||||||
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
|
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
|
||||||
if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" })
|
if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" })
|
||||||
|
if (job.created.some(item => item.serverId === null || !job.serverIds.includes(String(item.serverId))) || job.failures.some(failure => failure.serverId !== "job" && !job.serverIds.includes(failure.serverId))) ctx.addIssue({ code: "custom", message: "Результат относится к другому устройству" })
|
||||||
const deviceFailures = job.failures.filter(failure => failure.serverId !== "job")
|
const deviceFailures = job.failures.filter(failure => failure.serverId !== "job")
|
||||||
if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" })
|
if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" })
|
||||||
if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" })
|
if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" })
|
||||||
@@ -116,3 +121,5 @@ export const restoreBackupResponseSchema = z.object({ filename: z.string().min(1
|
|||||||
export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() })
|
export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() })
|
||||||
export type CreateBackupJobResponse = z.infer<typeof createBackupJobResponseSchema>
|
export type CreateBackupJobResponse = z.infer<typeof createBackupJobResponseSchema>
|
||||||
export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema>
|
export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema>
|
||||||
|
|
||||||
|
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>
|
||||||
@@ -6,7 +6,7 @@ const base = "https://api.example.test"
|
|||||||
const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
|
const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
|
||||||
const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] }
|
const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] }
|
||||||
const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
|
const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
|
||||||
const job = { id: "job-1", status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] }
|
const job = { id: "job-1", serverIds: ["1", "2"], status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] }
|
||||||
function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) }
|
function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) }
|
||||||
|
|
||||||
test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => {
|
test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => {
|
||||||
@@ -35,7 +35,7 @@ test("settings require a confirmed response and preserve false rather than defau
|
|||||||
})
|
})
|
||||||
test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => {
|
test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => {
|
||||||
const original = globalThis.fetch; let calls = 0, body: unknown
|
const original = globalThis.fetch; let calls = 0, body: unknown
|
||||||
globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", status: "queued", total: 1, completed: 0 }, 202) }
|
globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
|
||||||
try {
|
try {
|
||||||
await assert.rejects(() => createBackupsAsync(base, { serverIds: [] }))
|
await assert.rejects(() => createBackupsAsync(base, { serverIds: [] }))
|
||||||
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] }))
|
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] }))
|
||||||
@@ -44,7 +44,7 @@ test("invalid mutations fail before HTTP; create receives job acknowledgment", a
|
|||||||
assert.equal(calls, 0)
|
assert.equal(calls, 0)
|
||||||
assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1")
|
assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1")
|
||||||
assert.deepEqual(body, { serverIds: ["1"], notes: "manual" })
|
assert.deepEqual(body, { serverIds: ["1"], notes: "manual" })
|
||||||
globalThis.fetch = async () => json({ jobId: "job-1", status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /всех выбранных/);
|
globalThis.fetch = async () => json({ jobId: "job-1", serverIds: ["1", "2"], status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /выбранные серверы/);
|
||||||
globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }))
|
globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }))
|
||||||
} finally { globalThis.fetch = original }
|
} finally { globalThis.fetch = original }
|
||||||
})
|
})
|
||||||
@@ -90,3 +90,9 @@ test("settings acknowledgment must match submitted fields and confirm a new secr
|
|||||||
await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/)
|
await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/)
|
||||||
} finally { globalThis.fetch = original }
|
} finally { globalThis.fetch = original }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("backup request ID is sent and a different acknowledgment is rejected", async () => {
|
||||||
|
const original = globalThis.fetch, requestId = "11111111-1111-4111-8111-111111111111"; let body: Record<string, unknown> = {}
|
||||||
|
globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return json({ jobId: "other", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
|
||||||
|
try { await assert.rejects(() => createBackupsAsync(base, { requestId, serverIds: ["1"] }), /другому запросу/); assert.equal(body.requestId, requestId) } finally { globalThis.fetch = original }
|
||||||
|
})
|
||||||
@@ -25,9 +25,11 @@ function backupJson(baseUrl: string, path: string, options?: RequestInit) {
|
|||||||
export async function listBackups(baseUrl: string, options?: RequestInit) {
|
export async function listBackups(baseUrl: string, options?: RequestInit) {
|
||||||
return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" }))
|
return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" }))
|
||||||
}
|
}
|
||||||
export async function createBackupsAsync(baseUrl: string, payload: { serverIds: string[]; notes?: string }, options?: RequestInit) {
|
export async function createBackupsAsync(baseUrl: string, payload: { requestId?: string; serverIds: string[]; notes?: string }, options?: RequestInit) {
|
||||||
const body = createBackupRequestSchema.parse(payload)
|
const body = createBackupRequestSchema.parse(payload)
|
||||||
const result = createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) }))
|
const result = createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) }))
|
||||||
|
if (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу резервного копирования")
|
||||||
|
if (JSON.stringify(result.serverIds) !== JSON.stringify(body.serverIds.map(String))) throw new Error("Не подтверждены выбранные серверы резервного копирования")
|
||||||
if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов")
|
if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user