fix(backups): сохранить достоверность плановых операций при сбоях

This commit is contained in:
Denozordec committed 2026-10-01 20:54:18 +07:00
1 parent 98b4c39a00
commit 5ce080baf3
10 files changed
+158 -142

No files matched your search

+1 -1
View File
@@ -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",
+8
View File
@@ -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<unknown>): Promise<number> {
let pruned = 0
for (const id of ids) { try { if (await remove(id)) pruned++ } catch (cause) { throw new BackupPruneError(pruned, cause) } }
return pruned
}
@@ -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<BackupsRunSnapshot> {
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
@@ -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<typeof createBackupSchedulerCollector>[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<Dependencies> = {}) {
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<void>(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)
})
@@ -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<BackupScheduleSettingsDto>
due: (now: Date, settings: BackupScheduleSettingsDto, lastRunAt?: string | null) => boolean
resolveServers: (settings: BackupScheduleSettingsDto) => Promise<string[]>
create: (id: string, kind: "auto") => Promise<{ uploadError?: string | null }>
prune: (id: string, keepCount: number) => Promise<number>
metadata: (patch: { lastRunAt: string; lastDurationMs: number; lastError: string | null }) => Promise<void>
event: (event: AppendEventBody) => Promise<void>
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<BackupsRunSnapshot> {
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 }) }
}
+2 -4
View File
@@ -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<Buffer> {
@@ -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
}
+1 -1
View File
@@ -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А ещё не приняты.
| Экран | ПК | Телефон |
| --- | --- | --- |
+10 -1
View File
@@ -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.
+10
View File
@@ -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А с явными внешними ограничениями.