fix(settings): сохранять результат операций базы при вторичных сбоях
This commit is contained in:
1 parent
738c98b9d3
commit
92b65a30ce
15 files changed
+159
-32
No files matched your search
@@ -105,14 +105,15 @@ export default function SettingsPage() {
|
||||
if (!systemDbAvailable) return
|
||||
setDbBackupBusy(true)
|
||||
try {
|
||||
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
|
||||
const { blob, filename, warnings } = await downloadSystemDatabaseBackup(backendUrl)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("Бэкап базы приложения скачан")
|
||||
toast.success("Бэкап базы приложения подготовлен к скачиванию")
|
||||
for (const warning of Object.values(warnings)) if (warning) toast.warning(warning)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
|
||||
} finally {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts",
|
||||
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts",
|
||||
"test:certificates": "tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
|
||||
"test:system-database": "tsx src/services/system-database-restore-execution.test.ts"
|
||||
"test:system-database": "tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
|
||||
@@ -3,13 +3,13 @@ type Client = {
|
||||
release: (destroy?: boolean) => void
|
||||
}
|
||||
/** Nonblocking session lock: a second destructive operation is rejected rather than queued. */
|
||||
export async function withTrafficFlowPurgeLock<T>(connect: () => Promise<Client>, key: number, run: () => Promise<T>): Promise<T> {
|
||||
export async function withOperationLock<T>(connect: () => Promise<Client>, key: number, run: () => Promise<T>, busyMessage = "Сброс NetFlow уже выполняется"): Promise<T> {
|
||||
const client = await connect()
|
||||
let owned = false, destroy = false
|
||||
try {
|
||||
destroy = true
|
||||
const result = await client.query("SELECT pg_try_advisory_lock($1) AS acquired", [key])
|
||||
if (result.rows[0]?.acquired === false) { destroy = false; throw new Error("Сброс NetFlow уже выполняется") }
|
||||
if (result.rows[0]?.acquired === false) { destroy = false; throw new Error(busyMessage) }
|
||||
if (result.rows[0]?.acquired !== true) throw new Error("Блокировка сброса NetFlow не подтверждена")
|
||||
owned = true; destroy = false
|
||||
return await run()
|
||||
@@ -24,3 +24,5 @@ export async function withTrafficFlowPurgeLock<T>(connect: () => Promise<Client>
|
||||
client.release(destroy)
|
||||
}
|
||||
}
|
||||
|
||||
export const withTrafficFlowPurgeLock = withOperationLock
|
||||
@@ -86,6 +86,7 @@ export async function buildApp(opts?: {
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
exposedHeaders: ["Content-Disposition", "X-System-Database-Outcome"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import {SignJWT} from "jose"
|
||||
process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test"
|
||||
const {default:authPlugin,requireAuth}=await import("./auth.js")
|
||||
const {createSystemDatabaseRoutes}=await import("../routes/system-database.js")
|
||||
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
||||
let exports=0,restores=0
|
||||
await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;return {filename:"fixture.dump",buffer:Buffer.from("PGDMPfixture")}},restoreBackup:async()=>{restores++;return {}},event:async()=>undefined as never}),{prefix:"/api"})
|
||||
for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){
|
||||
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
|
||||
for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"content-type":"application/octet-stream"},...(method==="POST"?{payload:Buffer.from("PGDMPfixture")}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403)}
|
||||
}
|
||||
assert.equal(exports,1);assert.equal(restores,1)
|
||||
for(const [method,url] of [["GET","/api/system/database/backup"],["POST","/api/system/database/restore"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
|
||||
assert.equal(exports,1);assert.equal(restores,1);await app.close();console.log("System DB JWT: actual backup/restore handlers require settings admin before operations")
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import {createSystemDatabaseRoutes} from "./system-database.js"
|
||||
import {withOperationLock} from "../db/operation-lock.js"
|
||||
const app=Fastify(),dump=Buffer.from("PGDMPfixture")
|
||||
let exports=0,restores=0,events=0,eventFails=true,coreFails=false,held=false
|
||||
let gate:Promise<void>|undefined
|
||||
let entered:(()=>void)|undefined
|
||||
const lock=async()=>({query:async(sql:string)=>{if(sql.includes("pg_try")){const acquired=!held;if(acquired)held=true;return {rows:[{acquired}]}}held=false;return {rows:[{released:true}]}},release:()=>{}})
|
||||
await app.register(createSystemDatabaseRoutes({exportBackup:async()=>{exports++;if(coreFails)throw Error("dump failed");return {filename:"fixture.dump",buffer:dump,listenerError:"listener warning"}},restoreBackup:async()=>withOperationLock(lock,1,async()=>{restores++;entered?.();if(gate)await gate;if(coreFails)throw Error("restore failed");return {schedulerError:"scheduler warning",cleanupError:"cleanup warning"}},"Операция с базой приложения уже выполняется"),event:async()=>{events++;if(eventFails)throw Error("event fixture secret");return undefined as never}}),{prefix:"/api"})
|
||||
const restore=()=>app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:dump})
|
||||
let response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,200);assert.deepEqual(response.rawPayload,dump);let warnings=JSON.parse(decodeURIComponent(response.headers["x-system-database-outcome"] as string));assert.equal(warnings.listenerError,"listener warning");assert.match(warnings.eventError,/событие/);assert.equal(response.body.includes("secret"),false)
|
||||
response=await restore();assert.equal(response.statusCode,200);assert.equal(response.json().ok,true);assert.equal(response.json().cleanupError,"cleanup warning");assert.match(response.json().eventError,/событие/)
|
||||
coreFails=true;const before=events;response=await restore();assert.equal(response.statusCode,400);assert.equal(response.json().ok,undefined);assert.equal(events,before);response=await app.inject({method:"GET",url:"/api/system/database/backup"});assert.equal(response.statusCode,500);assert.equal(events,before)
|
||||
coreFails=false;eventFails=false;let release!:()=>void;gate=new Promise<void>(resolve=>release=resolve);const started=new Promise<void>(resolve=>entered=resolve);const first=restore();await started;const runs=restores;response=await restore();assert.equal(response.statusCode,409);assert.equal(restores,runs);release();assert.equal((await first).statusCode,200);gate=undefined
|
||||
response=await app.inject({method:"POST",url:"/api/system/database/restore",headers:{"content-type":"application/octet-stream"},payload:Buffer.alloc(0)});assert.equal(response.statusCode,400);assert.equal(restores,runs)
|
||||
assert.equal(exports,2);await app.close();console.log("System DB HTTP: confirmed binary/restore, separate audit warnings, failures, nonqueued conflict and no retry")
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
restoreSystemDatabaseBackup,
|
||||
} from "../services/system-database-backup.js"
|
||||
|
||||
const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
interface Dependencies {
|
||||
exportBackup: typeof exportSystemDatabaseBackup
|
||||
restoreBackup: typeof restoreSystemDatabaseBackup
|
||||
event: typeof appendEvent
|
||||
}
|
||||
export function createSystemDatabaseRoutes(deps: Dependencies): FastifyPluginAsync { return async (app) => {
|
||||
app.addContentTypeParser(
|
||||
"application/octet-stream",
|
||||
{ parseAs: "buffer", bodyLimit: 512 * 1024 * 1024 },
|
||||
@@ -16,8 +21,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
app.get("/system/database/backup", async (_req, reply) => {
|
||||
try {
|
||||
const { filename, buffer } = await exportSystemDatabaseBackup()
|
||||
await appendEvent({
|
||||
const { filename, buffer, ...warnings } = await deps.exportBackup()
|
||||
let eventError: string | undefined
|
||||
try {
|
||||
await deps.event({
|
||||
level: "info",
|
||||
eventType: "system.database.backup",
|
||||
sourceModule: "system",
|
||||
@@ -30,12 +37,15 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
sizeBytes: buffer.length,
|
||||
},
|
||||
})
|
||||
} catch { eventError = "Не удалось записать событие резервного копирования" }
|
||||
const outcome = { ...warnings, ...(eventError ? { eventError } : {}) }
|
||||
reply.header("X-System-Database-Outcome", encodeURIComponent(JSON.stringify(outcome)))
|
||||
reply.header("Content-Type", "application/octet-stream")
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||
return reply.send(buffer)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return reply.status(500).send({ error: message })
|
||||
return reply.status(message.includes("уже выполняется") ? 409 : 500).send({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,8 +55,10 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.status(400).send({ error: "Ожидается тело запроса с файлом pg_dump (custom, PGDMP)" })
|
||||
}
|
||||
try {
|
||||
const outcome = await restoreSystemDatabaseBackup(body)
|
||||
await appendEvent({
|
||||
const outcome = await deps.restoreBackup(body)
|
||||
let eventError: string | undefined
|
||||
try {
|
||||
await deps.event({
|
||||
level: "warning",
|
||||
eventType: "system.database.restore",
|
||||
sourceModule: "system",
|
||||
@@ -58,7 +70,8 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
sizeBytes: body.length,
|
||||
},
|
||||
})
|
||||
return reply.send({ ok: true, ...outcome })
|
||||
} catch { eventError = "Не удалось записать событие восстановления" }
|
||||
return reply.send({ ok: true, ...outcome, ...(eventError ? { eventError } : {}) })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const status = message.includes("уже выполняется") ? 409 : 400
|
||||
@@ -67,4 +80,6 @@ const systemDatabaseRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
}
|
||||
|
||||
export default systemDatabaseRoutes
|
||||
}
|
||||
|
||||
export default createSystemDatabaseRoutes({ exportBackup: exportSystemDatabaseBackup, restoreBackup: restoreSystemDatabaseBackup, event: appendEvent })
|
||||
@@ -5,7 +5,9 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { executeSystemDatabaseRestore, validateSystemDatabaseRestoreArchive, type SystemDatabaseRestoreOutcome } from "./system-database-restore-execution.js"
|
||||
import { env } from "../config.js"
|
||||
import { withAdvisoryLock } from "../db/index.js"
|
||||
import { pool } from "../db/index.js"
|
||||
import { withOperationLock } from "../db/operation-lock.js"
|
||||
import { executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js"
|
||||
import { refreshScheduler, stopScheduler } from "./scheduler.js"
|
||||
import {
|
||||
startTrafficFlowListener,
|
||||
@@ -34,7 +36,7 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(err.trim() || `${command} завершился с кодом ${code}`))
|
||||
reject(new Error(err.trim().replaceAll(env.DATABASE_URL, "[DATABASE_URL]") || `${command} завершился с кодом ${code}`))
|
||||
return
|
||||
}
|
||||
resolve(Buffer.concat(chunks))
|
||||
@@ -44,20 +46,16 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer
|
||||
})
|
||||
}
|
||||
|
||||
async function withDatabaseOperation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => {
|
||||
await stopTrafficFlowListener()
|
||||
stopScheduler()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await startTrafficFlowListener()
|
||||
await refreshScheduler()
|
||||
}
|
||||
})
|
||||
async function withDatabaseOperation<T extends object>(fn: () => Promise<T>): Promise<T & DatabaseOperationWarnings> {
|
||||
return withOperationLock(() => pool.connect(), BACKUP_LOCK_KEY, () => executeDatabaseOperation(fn, {
|
||||
stopListener: stopTrafficFlowListener,
|
||||
stopScheduler,
|
||||
startListener: startTrafficFlowListener,
|
||||
startScheduler: refreshScheduler,
|
||||
}), "Операция с базой приложения уже выполняется")
|
||||
}
|
||||
|
||||
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
|
||||
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer } & DatabaseOperationWarnings> {
|
||||
return await withDatabaseOperation(async () => {
|
||||
const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL])
|
||||
return {
|
||||
@@ -67,7 +65,7 @@ export async function exportSystemDatabaseBackup(): Promise<{ filename: string;
|
||||
})
|
||||
}
|
||||
|
||||
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<SystemDatabaseRestoreOutcome> {
|
||||
export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<SystemDatabaseRestoreOutcome & DatabaseOperationWarnings> {
|
||||
validateSystemDatabaseRestoreArchive(buffer)
|
||||
return await withDatabaseOperation(() => executeSystemDatabaseRestore(buffer, env.DATABASE_URL, {
|
||||
allocate: async () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {test} from "node:test"
|
||||
import {executeDatabaseOperation} from "./system-database-operation.js"
|
||||
function fixture(fail: string[] = []) {
|
||||
const calls:string[]=[]
|
||||
const step=(name:string)=>async()=>{calls.push(name);if(fail.includes(name))throw Error(name+" failed")}
|
||||
return {calls,deps:{stopListener:step("stop listener"),stopScheduler:step("stop scheduler"),startListener:step("start listener"),startScheduler:step("start scheduler")},run:async()=>{await step("command")();return {cleanupError:"cleanup warning"}}}
|
||||
}
|
||||
test("confirmed command survives independent restart failures",async()=>{
|
||||
for(const fail of [[],["start listener"],["start scheduler"],["start listener","start scheduler"]]){
|
||||
const f=fixture(fail);const result=await executeDatabaseOperation(f.run,f.deps)
|
||||
assert.equal(result.cleanupError,"cleanup warning");assert.equal(!!result.listenerError,fail.includes("start listener"));assert.equal(!!result.schedulerError,fail.includes("start scheduler"))
|
||||
assert.deepEqual(f.calls,["stop listener","stop scheduler","command","start listener","start scheduler"])
|
||||
}
|
||||
})
|
||||
test("core failure survives restart failures without command retry",async()=>{
|
||||
const f=fixture(["command","start listener","start scheduler"])
|
||||
await assert.rejects(executeDatabaseOperation(f.run,f.deps),/command failed/)
|
||||
assert.equal(f.calls.filter(s=>s==="command").length,1);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"])
|
||||
})
|
||||
test("stop failure prevents command and still attempts both restarts",async()=>{
|
||||
for(const step of ["stop listener","stop scheduler"]){const f=fixture([step,"start listener"]);await assert.rejects(executeDatabaseOperation(f.run,f.deps),new RegExp(step+" failed"));assert.equal(f.calls.includes("command"),false);assert.deepEqual(f.calls.slice(-2),["start listener","start scheduler"])}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
export type DatabaseOperationWarnings = { listenerError?: string; schedulerError?: string }
|
||||
interface Dependencies {
|
||||
stopListener: () => Promise<void> | void
|
||||
stopScheduler: () => Promise<void> | void
|
||||
startListener: () => Promise<void> | void
|
||||
startScheduler: () => Promise<void> | void
|
||||
}
|
||||
/** Restart both consumers independently; secondary errors cannot erase a completed command. */
|
||||
export async function executeDatabaseOperation<T extends object>(run: () => Promise<T>, deps: Dependencies): Promise<T & DatabaseOperationWarnings> {
|
||||
let result: T | undefined, failed = false, failure: unknown
|
||||
const warnings: DatabaseOperationWarnings = {}
|
||||
try {
|
||||
await deps.stopListener()
|
||||
await deps.stopScheduler()
|
||||
result = await run()
|
||||
} catch (error) { failed = true; failure = error }
|
||||
finally {
|
||||
try { await deps.startListener() } catch { warnings.listenerError = "Не удалось перезапустить приём NetFlow" }
|
||||
try { await deps.startScheduler() } catch { warnings.schedulerError = "Не удалось перезапустить планировщик" }
|
||||
}
|
||||
if (failed) throw failure
|
||||
return { ...result!, ...warnings }
|
||||
}
|
||||
+3
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-03
|
||||
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.1 (af5dbb0). Следующий блок 11Б.3.6.2.2.2: отделить подтверждённый результат backup/restore от ошибок перезапуска listener/scheduler и appendEvent; проверить HTTP/JWT/concurrency через DI. Далее 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
|
||||
Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.2.2. Следующий блок 11Б.3.6.2.3: отдельная форма DB с source/active guards, синхронным lock и review неизвестного результата. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
|
||||
|
||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||
|
||||
@@ -298,3 +298,5 @@ REST запрос → ожидание → подтверждённый output /
|
||||
Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма.
|
||||
|
||||
Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI.
|
||||
|
||||
2026-10-03: завершён 11Б.3.6.2.2.2, подтверждённый backup/restore отделён от restart/audit warnings; nonqueued concurrency, HTTP/JWT/API/UI проверки прошли локально. Далее DB форма 11Б.3.6.2.3.
|
||||
@@ -28,7 +28,7 @@
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.2.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11Б.3.6.2.2.2 — подтверждённая операция и вторичные сбои (2026-10-03)
|
||||
|
||||
- Core backup/restore отделён от restart listener/scheduler: оба restart выполняются независимо, их errors — warnings после подтверждённой команды; первичная ошибка не подменяется вторичной. Ошибка stop предотвращает команду и запускает попытки восстановления потребителей. Cleanup warning сохранён.
|
||||
- appendEvent failure не отменяет готовый бинарный backup или ok:true restore. Restore возвращает eventError, backup — percent-encoded strict metadata header; CORS exposes filename/outcome. Client читает warnings отдельно, malformed metadata не уничтожает подтверждённый PGDMP и даёт предупреждение. UI сообщает подготовку скачивания, не подтверждает сохранение файла на диск.
|
||||
- System DB теперь использует nonblocking pg_try_advisory_lock вместо общей blocking функции: конкурентный запрос получает 409 и не ставится в очередь. Общий helper получил нейтральное имя withOperationLock, NetFlow alias/default сохранены. DATABASE_URL редактируется из точного совпадения stderr команды; прежние event rows не изменялись.
|
||||
- 4 restore DI + 3 lifecycle DI, HTTP binary/audit/core failure/concurrency, реальные signed JWT gates, 4 API unit группы и 2 browser receipt сценария прошли; backend build, typecheck и профильный lint прошли. Реальный PG dump/restore/lock overlap и UDP stop acknowledgment не проверены; тесты не обращались к реальной БД.
|
||||
- Следующий блок 11Б.3.6.2.3: отдельная форма system DB, admin/source/active guards, общий синхронный lock, review неизвестного результата, адаптивность. Затем Alerts/Releases/probe scheduling и приёмка 11Б/12.
|
||||
|
||||
## Блок 11Б.3.6.2.2.1 — выполнение восстановления PostgreSQL (2026-10-03)
|
||||
|
||||
- Restore executor вынесен в pure DI helper. Production pg_restore получает --single-transaction вместе с --clean/--if-exists; validate size/PGDMP prefix выполняется до lock/остановки сборщиков и повторно в executor. Команда не повторяется, временный файл удаляется при write/run error. Cleanup failure после успешной команды — отдельный cleanupError, не отменяет её подтверждённый результат; основная ошибка не подменяется cleanup error.
|
||||
|
||||
@@ -224,3 +224,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
|
||||
Завершён 11Б.3.6.2.1: system DB export PGDMP/leaf filename и restore strict receipt/limits/abort без повтора; DATABASE_URL удалён из новых event payload. 3 unit группы/typecheck/lint/backend и frontend builds прошли. UI/backend partial/restore transaction не приняты. Далее 11Б.3.6.2.2 — backend transaction/outcomes/DI HTTP/JWT, затем 11Б.3.6.2.3 форма.
|
||||
|
||||
Завершён 11Б.3.6.2.2.1: DI restore executor/--single-transaction/cleanup warning, file choose без фиктивного success, confirmation до receipt. 4 DI/3 API/2 browser tests, backend/frontend builds/typecheck/lint прошли; реальный PG restore не запускался. System DB не принят полностью. Следующий точный шаг 11Б.3.6.2.2.2 — backend core/restart/event partial outcomes + DI HTTP/JWT/concurrency; затем 11Б.3.6.2.3 source/lock/unknown UI.
|
||||
|
||||
Завершён 11Б.3.6.2.2.2: core/restart/event outcomes, nonqueued lock/409, DI HTTP/JWT/concurrency/API и browser checks прошли; реальная БД не запускалась. Далее 11Б.3.6.2.3 — отдельная DB форма с source/active/admin/sync lock/unknown review.
|
||||
@@ -26,3 +26,15 @@ test("restore needs literal receipt; malformed/false/204/HTTP error never retry"
|
||||
calls=0;globalThis.fetch=async()=>{calls++;return Response.json({error:"restore unknown"},{status:503})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/restore unknown/);assert.equal(calls,1)
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
|
||||
test("backup warning header is strict and malformed metadata preserves valid archive with warning",async()=>{
|
||||
const original=globalThis.fetch;try{
|
||||
const warnings={listenerError:"listener warning",schedulerError:"scheduler warning",eventError:"event warning"}
|
||||
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":encodeURIComponent(JSON.stringify(warnings))}})
|
||||
assert.deepEqual((await downloadSystemDatabaseBackup("https://backend.test")).warnings,warnings)
|
||||
for(const header of ["%","false",encodeURIComponent(JSON.stringify({listenerError:4})),encodeURIComponent(JSON.stringify({error:"failed"}))]){
|
||||
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":header}})
|
||||
const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(await result.blob.text(),"PGDMPfixture");assert.match(result.warnings.eventError!,/не подтверждены/)
|
||||
}
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
@@ -4,6 +4,8 @@ const MAX_RESTORE_BYTES=512*1024*1024
|
||||
const magic=[80,71,68,77,80]
|
||||
const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional(),cleanupError:z.string().min(1).optional()}).strict()
|
||||
export type SystemDatabaseRestoreReceipt=z.output<typeof receiptSchema>
|
||||
const warningSchema=receiptSchema.omit({ok:true})
|
||||
export type SystemDatabaseWarnings=z.output<typeof warningSchema>
|
||||
type Options={signal?:AbortSignal}
|
||||
const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000)
|
||||
function parseFilename(value:string|null):string{
|
||||
@@ -21,12 +23,17 @@ async function confirmDump(blob:Blob):Promise<void>{
|
||||
const head=new Uint8Array(await blob.slice(0,magic.length).arrayBuffer())
|
||||
if(!magic.every((value,index)=>head[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
|
||||
}
|
||||
export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string}>{
|
||||
export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string;warnings:SystemDatabaseWarnings}>{
|
||||
const activeSignal=signal(options)
|
||||
const response=await requestBlob(baseUrl,"/api/system/database/backup",{signal:activeSignal})
|
||||
const blob=await response.blob()
|
||||
await confirmDump(blob);activeSignal.throwIfAborted()
|
||||
return {blob,filename:parseFilename(response.headers.get("Content-Disposition"))}
|
||||
const rawWarnings=response.headers.get("X-System-Database-Outcome")
|
||||
let warnings:SystemDatabaseWarnings={}
|
||||
if(rawWarnings){
|
||||
try{warnings=warningSchema.parse(JSON.parse(decodeURIComponent(rawWarnings)))}catch{warnings={eventError:"Бэкап подготовлен, но дополнительные результаты не подтверждены"}}
|
||||
}
|
||||
return {blob,filename:parseFilename(response.headers.get("Content-Disposition")),warnings}
|
||||
}
|
||||
export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,options?:Options):Promise<SystemDatabaseRestoreReceipt>{
|
||||
if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413)
|
||||
|
||||
Reference in new issue
Block a user