fix(backups): сохранить достоверность плановых операций при сбоях
This commit is contained in:
1 parent
98b4c39a00
commit
5ce080baf3
10 files changed
+158
-142
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 }) }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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А с явными внешними ограничениями.
|
||||
Reference in new issue
Block a user