diff --git a/backend/package.json b/backend/package.json index 37b765cb..cc1b6c5c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts", - "test:backups": "tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", + "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", diff --git a/backend/src/services/backup-prune.ts b/backend/src/services/backup-prune.ts new file mode 100644 index 00000000..5f1e9115 --- /dev/null +++ b/backend/src/services/backup-prune.ts @@ -0,0 +1,8 @@ +export class BackupPruneError extends Error { + constructor(public readonly pruned: number, cause: unknown) { super("Очистка резервных копий прервана после " + pruned + " удалений: " + (cause instanceof Error ? cause.message : String(cause)), { cause }); this.name = "BackupPruneError" } +} +export async function pruneBackupRecords(ids: string[], remove: (id: string) => Promise): Promise { + let pruned = 0 + for (const id of ids) { try { if (await remove(id)) pruned++ } catch (cause) { throw new BackupPruneError(pruned, cause) } } + return pruned +} diff --git a/backend/src/services/backup-scheduler-collector.ts b/backend/src/services/backup-scheduler-collector.ts index d8af2f7f..f29cede9 100644 --- a/backend/src/services/backup-scheduler-collector.ts +++ b/backend/src/services/backup-scheduler-collector.ts @@ -1,136 +1,6 @@ import { appendEvent } from "../modules/events/service/events-service.js" -import type { BackupsRunSnapshot } from "../types/scheduler-run-snapshot.js" -import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" -import { - getBackupScheduleSettings, - isBackupDue, - pruneBackupsForServer, - resolveBackupServerIds, - runBackupForServer, - touchBackupScheduleRunMeta, -} from "./backup-service.js" - -let collecting = false - -export function getBackupSchedulerCollectorState(): { running: boolean } { - return { running: collecting } -} - -export async function collectScheduledBackupsOnce(): Promise { - const sampledAt = new Date().toISOString() - const settings = await getBackupScheduleSettings() - const snapshot: BackupsRunSnapshot = { - v: SCHEDULER_RUN_SNAPSHOT_VERSION, - job: "backups", - sampledAt, - due: false, - created: 0, - failures: 0, - pruned: 0, - errors: [], - } - - if (collecting) { - snapshot.skipped = true - return snapshot - } - - if (!settings.enabled) { - snapshot.skipped = true - return snapshot - } - - const due = isBackupDue(new Date(), settings, settings.lastRunAt) - snapshot.due = due - if (!due) { - return snapshot - } - - if (settings.format !== "rsc") { - snapshot.skipped = true - snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"] - await touchBackupScheduleRunMeta({ - lastRunAt: sampledAt, - lastDurationMs: 0, - lastError: snapshot.errors[0], - }) - return snapshot - } - - collecting = true - const started = Date.now() - const serverIds = await resolveBackupServerIds(settings) - - await appendEvent({ - level: "info", - eventType: "backups.job.started", - sourceModule: "backups", - title: "Запущен плановый бэкап", - message: `Серверов в очереди: ${serverIds.length}`, - entityType: "backup_job", - entityId: "scheduler", - payload: { serverIds, scheduled: true }, - }) - - try { - for (const id of serverIds) { - try { - await runBackupForServer(id, "auto") - snapshot.created += 1 - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - snapshot.failures += 1 - snapshot.errors?.push(`${id}: ${message}`) - } - } - - for (const id of serverIds) { - snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount) - } - - await touchBackupScheduleRunMeta({ - lastRunAt: sampledAt, - lastDurationMs: Date.now() - started, - lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null, - }) - - await appendEvent({ - level: snapshot.failures > 0 ? "warning" : "info", - eventType: "backups.job.done", - sourceModule: "backups", - title: snapshot.failures > 0 ? "Плановый бэкап завершен с ошибками" : "Плановый бэкап завершен", - message: `Создано: ${snapshot.created}, ошибок: ${snapshot.failures}`, - entityType: "backup_job", - entityId: "scheduler", - payload: { - total: serverIds.length, - completed: snapshot.created + snapshot.failures, - failures: snapshot.errors, - pruned: snapshot.pruned, - }, - }) - - return snapshot - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - snapshot.fatalError = message - snapshot.errors?.push(message) - await touchBackupScheduleRunMeta({ - lastRunAt: sampledAt, - lastDurationMs: Date.now() - started, - lastError: message, - }) - await appendEvent({ - level: "critical", - eventType: "backups.job.failed", - sourceModule: "backups", - title: "Плановый бэкап прерван", - message, - entityType: "backup_job", - entityId: "scheduler", - }) - return snapshot - } finally { - collecting = false - } -} +import { createBackupSchedulerCollector } from "./backup-scheduler-run.js" +import { getBackupScheduleSettings, isBackupDue, pruneBackupsForServer, resolveBackupServerIds, runBackupForServer, touchBackupScheduleRunMeta } from "./backup-service.js" +const collector = createBackupSchedulerCollector({ settings: getBackupScheduleSettings, due: isBackupDue, resolveServers: resolveBackupServerIds, create: runBackupForServer, prune: pruneBackupsForServer, metadata: touchBackupScheduleRunMeta, event: appendEvent }) +export const getBackupSchedulerCollectorState = collector.state +export const collectScheduledBackupsOnce = collector.collect diff --git a/backend/src/services/backup-scheduler-run.test.ts b/backend/src/services/backup-scheduler-run.test.ts new file mode 100644 index 00000000..7eb0b3d4 --- /dev/null +++ b/backend/src/services/backup-scheduler-run.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import type { AppendEventBody } from "@mmapp/contracts/events" +import { createBackupSchedulerCollector } from "./backup-scheduler-run.js" +import { BackupPruneError, pruneBackupRecords } from "./backup-prune.js" +type Dependencies = Parameters[0] +const settings = { enabled: true, frequency: "daily" as const, hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc" as const, serverIds: [] } +function fixture(patch: Partial = {}) { + const events: AppendEventBody[] = [], created: string[] = [], cleaned: string[] = [], metadata: unknown[] = [] + const deps: Dependencies = { settings: async () => settings, due: () => true, resolveServers: async () => ["1", "2"], create: async id => { created.push(id); return {} }, prune: async id => { cleaned.push(id); return 0 }, metadata: async value => { metadata.push(value) }, event: async value => { events.push(value) }, now: () => new Date("2026-10-01T00:00:00Z"), ...patch } + return { collector: createBackupSchedulerCollector(deps), events, created, cleaned, metadata } +} +test("settings/catalog failure releases lock and preserves truthful fatal setup snapshot", async () => { + for (const phase of ["settings", "catalog"]) { + let fail = true + const f = fixture(phase === "settings" ? { settings: async () => { if (fail) throw new Error("settings unavailable"); return settings } } : { resolveServers: async () => { if (fail) throw new Error("catalog unavailable"); return ["1"] } }) + const first = await f.collector.collect(); assert.match(first.fatalError ?? "", /unavailable/); assert.equal(first.created, 0); assert.equal(f.collector.state().running, false) + if (phase === "settings") assert.equal(f.metadata.length, 0) + fail = false; const second = await f.collector.collect(); assert.equal(second.fatalError, undefined); assert.ok(second.created > 0); assert.equal(f.collector.state().running, false) + } +}) +test("concurrent collection is skipped before reading settings again", async () => { + let calls = 0, release!: () => void; const gate = new Promise(resolve => release = resolve) + const f = fixture({ settings: async () => { calls++; await gate; return settings } }) + const running = f.collector.collect(); assert.equal(f.collector.state().running, true) + const skipped = await f.collector.collect(); assert.equal(skipped.skipped, true); assert.equal(calls, 1) + release(); assert.equal((await running).created, 2); assert.equal(f.collector.state().running, false) +}) +test("start/done event failure does not cancel confirmed backups or metadata", async () => { + const f = fixture({ event: async () => { throw new Error("event DB unavailable") } }), result = await f.collector.collect() + assert.equal(result.created, 2); assert.equal(result.failures, 0); assert.equal(result.fatalError, undefined); assert.equal(result.eventErrors?.length, 2); assert.equal(f.metadata.length, 1); assert.equal(f.cleaned.length, 2); assert.equal(f.collector.state().running, false) +}) +test("device and upload failures are distinct while every target is attempted", async () => { + const f = fixture({ create: async id => { if (id === "2") throw new Error("RouterOS offline"); return { uploadError: "S3 denied" } } }), result = await f.collector.collect() + assert.equal(result.created, 1); assert.equal(result.failures, 1); assert.equal(result.uploadFailures, 1); assert.equal(result.fatalError, undefined) + assert.match(result.errors?.join(";") ?? "", /S3 denied.*RouterOS offline/); assert.equal(f.events.at(-1)?.level, "warning"); assert.equal(f.events.at(-1)?.payload?.completed, 2) +}) +test("partial pruning counts acknowledged deletions and continues the other device", async () => { + const f = fixture({ prune: async id => { if (id === "1") throw new BackupPruneError(2, new Error("S3 unavailable")); return 3 } }), result = await f.collector.collect() + assert.equal(result.created, 2); assert.equal(result.failures, 0); assert.equal(result.pruned, 5); assert.equal(result.pruneFailures, 1); assert.equal(result.fatalError, undefined); assert.equal(f.events.at(-1)?.eventType, "backups.job.done") +}) +test("metadata failure preserves result and the in-process attempt prevents repeating the same slot", async () => { + const f = fixture({ metadata: async () => { throw new Error("metadata DB unavailable") }, due: (_now, _settings, previous) => previous !== "2026-10-01T00:00:00.000Z" }) + const first = await f.collector.collect(); assert.equal(first.created, 2); assert.equal(first.failures, 0); assert.equal(first.metadataError, "metadata DB unavailable"); assert.equal(first.fatalError, undefined) + const second = await f.collector.collect(); assert.equal(second.due, false); assert.equal(second.created, 0); assert.equal(f.created.length, 2); assert.equal(f.collector.state().running, false) +}) +test("disabled, not due and unsupported format keep creation side effects off", async () => { + for (const patch of [{ settings: async () => ({ ...settings, enabled: false }) }, { due: () => false }, { settings: async () => ({ ...settings, format: "backup" as const }), metadata: async () => { throw new Error("metadata denied") } }]) { + const f = fixture(patch), result = await f.collector.collect(); assert.equal(result.created, 0); assert.equal(f.created.length, 0); assert.equal(f.cleaned.length, 0); assert.equal(f.collector.state().running, false) + } +}) +test("prune helper retains partial acknowledged count and retries already missing rows", async () => { + const calls: string[] = [] + await assert.rejects(pruneBackupRecords(["1", "2", "3"], async id => { calls.push(id); if (id === "2") throw new Error("delete failed"); return {} }), error => error instanceof BackupPruneError && error.pruned === 1) + assert.deepEqual(calls, ["1", "2"]); assert.equal(await pruneBackupRecords(["1", "2", "3"], async id => id === "1" ? null : {}), 2) +}) diff --git a/backend/src/services/backup-scheduler-run.ts b/backend/src/services/backup-scheduler-run.ts new file mode 100644 index 00000000..c5d0fff0 --- /dev/null +++ b/backend/src/services/backup-scheduler-run.ts @@ -0,0 +1,61 @@ +import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups" +import type { AppendEventBody } from "@mmapp/contracts/events" +import { SCHEDULER_RUN_SNAPSHOT_VERSION, type BackupsRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { BackupPruneError } from "./backup-prune.js" +export function createBackupSchedulerCollector(deps: { + settings: () => Promise + due: (now: Date, settings: BackupScheduleSettingsDto, lastRunAt?: string | null) => boolean + resolveServers: (settings: BackupScheduleSettingsDto) => Promise + create: (id: string, kind: "auto") => Promise<{ uploadError?: string | null }> + prune: (id: string, keepCount: number) => Promise + metadata: (patch: { lastRunAt: string; lastDurationMs: number; lastError: string | null }) => Promise + event: (event: AppendEventBody) => Promise + now?: () => Date +}) { + let collecting = false + let lastAttemptAt: string | undefined + const now = deps.now ?? (() => new Date()) + const message = (error: unknown) => error instanceof Error ? error.message : String(error) + async function collect(): Promise { + const sampledAt = now().toISOString(), started = now().getTime() + const snapshot: BackupsRunSnapshot = { v: SCHEDULER_RUN_SNAPSHOT_VERSION, job: "backups", sampledAt, due: false, created: 0, failures: 0, pruned: 0, errors: [] } + if (collecting) return { ...snapshot, skipped: true } + collecting = true + let attempted = false + const event = async (body: AppendEventBody) => { try { await deps.event(body) } catch (error) { (snapshot.eventErrors ??= []).push(body.eventType + ": " + message(error)) } } + const metadata = async () => { + try { await deps.metadata({ lastRunAt: sampledAt, lastDurationMs: Math.max(0, now().getTime() - started), lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null }) } + catch (error) { snapshot.metadataError = message(error); snapshot.errors?.push("Метаданные расписания: " + snapshot.metadataError) } + } + try { + const settings = await deps.settings() + if (!settings.enabled) return { ...snapshot, skipped: true } + const storedTime = settings.lastRunAt ? Date.parse(settings.lastRunAt) : NaN + const previous = lastAttemptAt && (!Number.isFinite(storedTime) || Date.parse(lastAttemptAt) >= storedTime) ? lastAttemptAt : settings.lastRunAt + snapshot.due = deps.due(now(), settings, previous) + if (!snapshot.due) return snapshot + attempted = true; lastAttemptAt = sampledAt + if (settings.format !== "rsc") { snapshot.skipped = true; snapshot.errors?.push("Формат backup не поддерживается, используйте rsc"); await metadata(); return snapshot } + const ids = await deps.resolveServers(settings) + await event({ level: "info", eventType: "backups.job.started", sourceModule: "backups", title: "Запущен плановый бэкап", message: "Серверов в очереди: " + ids.length, entityType: "backup_job", entityId: "scheduler", payload: { serverIds: ids, scheduled: true } }) + for (const id of ids) { + try { const result = await deps.create(id, "auto"); snapshot.created++; if (result.uploadError) { snapshot.uploadFailures = (snapshot.uploadFailures ?? 0) + 1; snapshot.errors?.push(id + ": загрузка S3: " + result.uploadError) } } + catch (error) { snapshot.failures++; snapshot.errors?.push(id + ": " + message(error)) } + } + for (const id of ids) { + try { snapshot.pruned += await deps.prune(id, settings.keepCount) } + catch (error) { if (error instanceof BackupPruneError) snapshot.pruned += error.pruned; snapshot.pruneFailures = (snapshot.pruneFailures ?? 0) + 1; snapshot.errors?.push(id + ": очистка: " + message(error)) } + } + await metadata() + const partial = !!snapshot.errors?.length || !!snapshot.eventErrors?.length + await event({ level: partial ? "warning" : "info", eventType: "backups.job.done", sourceModule: "backups", title: partial ? "Плановый бэкап завершен с ограничениями" : "Плановый бэкап завершен", message: "Создано: " + snapshot.created + ", ошибок устройств: " + snapshot.failures + ", ошибок загрузки: " + (snapshot.uploadFailures ?? 0) + ", ошибок очистки: " + (snapshot.pruneFailures ?? 0), entityType: "backup_job", entityId: "scheduler", payload: { total: ids.length, completed: snapshot.created + snapshot.failures, errors: snapshot.errors, eventErrors: snapshot.eventErrors, metadataError: snapshot.metadataError, pruned: snapshot.pruned } }) + return snapshot + } catch (error) { + snapshot.fatalError = message(error); snapshot.errors?.push(snapshot.fatalError) + if (attempted) await metadata() + await event({ level: "critical", eventType: "backups.job.failed", sourceModule: "backups", title: "Плановый бэкап прерван", message: snapshot.fatalError, entityType: "backup_job", entityId: "scheduler" }) + return snapshot + } finally { collecting = false } + } + return { collect, state: () => ({ running: collecting }) } +} diff --git a/backend/src/services/backup-service.ts b/backend/src/services/backup-service.ts index 1807b9ef..d413a755 100644 --- a/backend/src/services/backup-service.ts +++ b/backend/src/services/backup-service.ts @@ -8,6 +8,7 @@ import type { PutBackupStorageSettings, } from "@mmapp/contracts/backups" import { mapBackupEntry } from "./backup-dto.js" +import { pruneBackupRecords } from "./backup-prune.js" import { importBackupOnce } from "./backup-restore.js" import { deleteStoredBackup } from "./backup-deletion.js" import { planBackupStorageUpdate } from "./backup-storage-update.js" @@ -448,10 +449,7 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number) .orderBy(desc(backupEntries.createdAt)) if (rows.length <= keepCount) return 0 const toDelete = rows.slice(keepCount) - for (const hit of toDelete) { - await deleteBackupRecord(hit.id) - } - return toDelete.length + return pruneBackupRecords(toDelete.map(row => row.id), deleteBackupRecord) } export async function readBackupContent(meta: BackupMeta): Promise { diff --git a/backend/src/types/scheduler-run-snapshot.ts b/backend/src/types/scheduler-run-snapshot.ts index 9ccb5d40..e6936b4a 100644 --- a/backend/src/types/scheduler-run-snapshot.ts +++ b/backend/src/types/scheduler-run-snapshot.ts @@ -222,6 +222,10 @@ export interface BackupsRunSnapshot { created: number failures: number pruned: number + uploadFailures?: number + pruneFailures?: number + metadataError?: string + eventErrors?: string[] errors?: string[] fatalError?: string } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5fcebe83..75eded09 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,7 +226,7 @@ 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/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А.32 — backup scheduler cleanup, затем профильная приёмка backups. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. 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А ещё не приняты. +Актуальная точка продолжения: 11А.33 — профильная локальная приёмка backups. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status требует исправления в 11Б, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. 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А ещё не приняты. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 7a221adf..b27f552d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.31.2.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: визуальные состояния, далее 11А.32 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.32; пользователи/контейнеры/сертификаты приняты локально; резервные копии: scheduler проверен, далее приёмка 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11А.32 — достоверность плановых резервных копий (2026-10-01) + +- Lock берётся до чтения настроек и освобождается через finally при любом выходе. Ошибка настроек/каталога не блокирует последующие запуски; параллельный вызов не повторяет настройку и создание. +- Подтверждённые created/pruned сохраняются при ошибках журнала, S3, очистки и метаданных. Upload/prune/event/metadata ошибки раздельны. Очистка считает только подтверждённые удаления, сохраняет частичный счётчик при остановке. +- При отказе записи lastRunAt повтор в том же слоте подавляется в текущем процессе. Это не межпроцессная/долговечная идемпотентность: рестарт и несколько экземпляров требуют отдельного решения. Зависший внешний запрос не ограничен новым timeout. +- 8 новых scheduler/prune unit, весь backend test:backups, backend/contracts build и ESLint изменённых файлов прошли. Реальные PostgreSQL/RouterOS/S3 не проверены. +- Обнаружено отдельное ограничение общего scheduler dispatcher: возвращённый snapshot с ошибками пока записывается как status=ok, отказ общего журнала может переобозначить запуск. Исправление общего журнала/статуса входит в 11Б; локальная приёмка backups не доказывает достоверность этого общего экрана. +- **Следующий блок 11А.33:** общая профильная регрессия резервных копий и локальная приёмка 11А с явными внешними ограничениями. + ## Блок 11А.31.2.2 — визуальные состояния S3, partial job и initial error (2026-10-01) - Проверены ПК/390/320: S3 отказ доступа с актуальным backend receipt, partial upload при созданной локальной копии, первоначальный отказ истории. Unknown не превращается в empty/нулевые KPI, старая проверка S3 не остаётся успешной после подтверждённой ошибки. Добавлены 9 PNG. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index dea3c7c0..a1703142 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -461,3 +461,13 @@ | Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) | | Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) | + + +## Блок 11А.32 — достоверность плановых резервных копий (2026-10-01) + +- Lock берётся до чтения настроек и освобождается через finally при любом выходе. Ошибка настроек/каталога не блокирует последующие запуски; параллельный вызов не повторяет настройку и создание. +- Подтверждённые created/pruned сохраняются при ошибках журнала, S3, очистки и метаданных. Upload/prune/event/metadata ошибки раздельны. Очистка считает только подтверждённые удаления, сохраняет частичный счётчик при остановке. +- При отказе записи lastRunAt повтор в том же слоте подавляется в текущем процессе. Это не межпроцессная/долговечная идемпотентность: рестарт и несколько экземпляров требуют отдельного решения. Зависший внешний запрос не ограничен новым timeout. +- 8 новых scheduler/prune unit, весь backend test:backups, backend/contracts build и ESLint изменённых файлов прошли. Реальные PostgreSQL/RouterOS/S3 не проверены. +- Обнаружено отдельное ограничение общего scheduler dispatcher: возвращённый snapshot с ошибками пока записывается как status=ok, отказ общего журнала может переобозначить запуск. Исправление общего журнала/статуса входит в 11Б; локальная приёмка backups не доказывает достоверность этого общего экрана. +- **Следующий блок 11А.33:** общая профильная регрессия резервных копий и локальная приёмка 11А с явными внешними ограничениями.