From f1aac49dfc51e63f02eec44400ffb89b20b4db48 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 7 Oct 2026 23:12:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D1=81=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D1=8D=D1=88=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- .../src/services/system-database-backup.ts | 2 + .../services/system-database-cache.test.ts | 55 +++++++++++++++++++ backend/src/services/system-database-cache.ts | 20 +++++++ .../system-database-restore-execution.test.ts | 9 +++ .../system-database-restore-execution.ts | 2 + .../src/services/traffic-flow-analytics.ts | 4 +- backend/src/services/traffic-flow-classify.ts | 4 +- backend/src/services/traffic-flow-engine.ts | 12 +++- backend/src/services/traffic-flow-facts.ts | 4 +- backend/src/services/traffic-flow-ifindex.ts | 4 +- backend/src/services/traffic-flow-map-hops.ts | 4 +- backend/src/services/traffic-flow-ripe.ts | 13 +++-- docs/ux-ui-external-acceptance.md | 2 +- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 12 +++- docs/ux-ui-stage-12.md | 12 +++- docs/ux-ui-test-server.md | 4 ++ 18 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 backend/src/services/system-database-cache.test.ts create mode 100644 backend/src/services/system-database-cache.ts diff --git a/backend/package.json b/backend/package.json index beef4b63..27428c77 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,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/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && 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" + "test:system-database": "tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && 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", diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts index 9c353bd9..5ae4d02e 100644 --- a/backend/src/services/system-database-backup.ts +++ b/backend/src/services/system-database-backup.ts @@ -10,6 +10,7 @@ import { withOperationLock } from "../db/operation-lock.js" import { executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js" import { restartSchedulerAfterDatabaseOperation, stopSchedulerForDatabaseOperation } from "./scheduler.js" import { pauseRipeBackgroundWork, resumeRipeBackgroundWork } from "./traffic-flow-ripe.js" +import { invalidateSystemDatabaseCaches } from "./system-database-cache.js" import { restartTrafficFlowListenerAfterDatabaseOperation, stopTrafficFlowListenerForDatabaseOperation, @@ -83,5 +84,6 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise writeFile(file, data), run: async args => { await spawnCollected("pg_restore", args) }, remove: file => rm(file, { force: true }), + invalidate: invalidateSystemDatabaseCaches, })) } diff --git a/backend/src/services/system-database-cache.test.ts b/backend/src/services/system-database-cache.test.ts new file mode 100644 index 00000000..c05b1d19 --- /dev/null +++ b/backend/src/services/system-database-cache.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { invalidateSystemDatabaseCaches } from "./system-database-cache.js" +import { applyRingSnapshot, snapshotRings } from "./traffic-flow-engine.js" +import { rememberServerIfaces, ifaceCacheHas } from "./traffic-flow-ifindex.js" +import { DatabaseBackgroundWork } from "./database-background-work.js" +import { executeDatabaseOperation } from "./system-database-operation.js" +import { executeSystemDatabaseRestore } from "./system-database-restore-execution.js" +import { + disableRipePersistForTests, seedRipeCacheForTests, lookupRipeCached, + pauseRipeBackgroundWork, resumeRipeBackgroundWork, enqueueRipeMisses, ripeFetchCountForTests, +} from "./traffic-flow-ripe.js" + +test("restore discards old RIPE, interface and live data without reopening RIPE admission", async () => { + disableRipePersistForTests() + seedRipeCacheForTests({ prefix: "8.8.8.0/24", asn: 15169, country: "US", lat: null, lng: null, + holder: "old database", ok: true, fetchedAt: Date.now() }) + rememberServerIfaces(1, [{ ".id": "*1", name: "ether1" }]) + applyRingSnapshot([{ key: "old database", inBps: [1_000_000], outBps: [1_000_000] }]) + assert.equal(lookupRipeCached("8.8.8.8")?.holder, "old database") + assert.equal(ifaceCacheHas(1), true) + assert.equal(snapshotRings().length, 1) + await pauseRipeBackgroundWork() + try { + const count = ripeFetchCountForTests() + invalidateSystemDatabaseCaches() + assert.equal(lookupRipeCached("8.8.8.8"), null) + assert.equal(ifaceCacheHas(1), false) + assert.equal(snapshotRings().length, 0) + enqueueRipeMisses(["8.8.8.8"]) + assert.equal(ripeFetchCountForTests(), count) + } finally { resumeRipeBackgroundWork() } +}) + +test("background drain precedes restore and consumers restart with invalidated caches", async () => { + const work = new DatabaseBackgroundWork(), calls: string[] = [] + let release!: () => void + work.track(new Promise(r => { release = r })) + rememberServerIfaces(1, [{ ".id": "*1", name: "old-interface" }]) + const operation = executeDatabaseOperation(() => executeSystemDatabaseRestore(Buffer.from("PGDMPfixture"), "fixture", { + allocate: async () => "fixture.dump", write: async () => {}, remove: async () => {}, + run: async () => { calls.push("restore") }, + invalidate: () => { calls.push("invalidate"); invalidateSystemDatabaseCaches() }, + }), { + stopListener: () => { calls.push("stop listener") }, + stopScheduler: async () => { await work.drain(); calls.push("drained") }, + startListener: () => { assert.equal(ifaceCacheHas(1), false); calls.push("restart listener") }, + startScheduler: () => { calls.push("restart scheduler") }, + }) + await Promise.resolve(); await Promise.resolve() + assert.equal(calls.includes("restore"), false) + assert.equal(ifaceCacheHas(1), true) + release(); await operation + assert.deepEqual(calls, ["stop listener", "drained", "restore", "invalidate", "restart listener", "restart scheduler"]) +}) diff --git a/backend/src/services/system-database-cache.ts b/backend/src/services/system-database-cache.ts new file mode 100644 index 00000000..0f8de7c3 --- /dev/null +++ b/backend/src/services/system-database-cache.ts @@ -0,0 +1,20 @@ +import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js" +import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js" +import { invalidateRipeCache } from "./traffic-flow-ripe.js" +import { invalidateIfaceCache } from "./traffic-flow-ifindex.js" +import { invalidateFlowClassificationCache } from "./traffic-flow-classify.js" +import { invalidateFlowAnalyticsCache } from "./traffic-flow-analytics.js" +import { invalidateFlowMapHopsCache } from "./traffic-flow-map-hops.js" +import { resetEngineAfterDatabaseRestore } from "./traffic-flow-engine.js" + +/** Synchronous invalidation after confirmed restore and before consumers restart. */ +export function invalidateSystemDatabaseCaches(): void { + invalidateTrafficFlowSettingsCache() + invalidateFlowCatalogCache() + invalidateRipeCache() + invalidateIfaceCache() + invalidateFlowClassificationCache() + invalidateFlowAnalyticsCache() + invalidateFlowMapHopsCache() + resetEngineAfterDatabaseRestore() +} diff --git a/backend/src/services/system-database-restore-execution.test.ts b/backend/src/services/system-database-restore-execution.test.ts index f1fcf48e..d8c29fb5 100644 --- a/backend/src/services/system-database-restore-execution.test.ts +++ b/backend/src/services/system-database-restore-execution.test.ts @@ -7,3 +7,12 @@ test("invalid archive never allocates or runs command",async()=>{const f=fixture test("restore runs one transactional command and cleans file after success",async()=>{const f=fixture();assert.deepEqual(await executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),{});assert.deepEqual(f.calls,["allocate","write","run","remove"])}) test("write or restore failure always cleans temporary file and never retries",async()=>{for(const step of ["write","run"]){const f=fixture();if(step==="write")f.setWrite();else f.setRun();await assert.rejects(executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),/failed/);assert.deepEqual(f.calls,step==="write"?["allocate","write","remove"]:["allocate","write","run","remove"])}}) test("cleanup failure keeps confirmed restore result; main failure is preserved",async()=>{const f=fixture();f.setRemove();assert.deepEqual(await executeSystemDatabaseRestore(dump,"postgres://fixture",f.deps),{cleanupError:"Не удалось удалить временный файл восстановления"});const bad=fixture();bad.setRun();bad.setRemove();await assert.rejects(executeSystemDatabaseRestore(dump,"postgres://fixture",bad.deps),/restore failed; Не удалось удалить/)}) + +test("cache invalidation follows confirmed restore and is skipped on process failure", async () => { + const success=fixture() + await executeSystemDatabaseRestore(dump,"postgres://fixture",{...success.deps,invalidate:()=>{success.calls.push("invalidate")}}) + assert.deepEqual(success.calls,["allocate","write","run","invalidate","remove"]) + const failure=fixture();failure.setRun() + await assert.rejects(executeSystemDatabaseRestore(dump,"postgres://fixture",{...failure.deps,invalidate:()=>{failure.calls.push("invalidate")}}),/restore failed/) + assert.equal(failure.calls.includes("invalidate"),false) +}) diff --git a/backend/src/services/system-database-restore-execution.ts b/backend/src/services/system-database-restore-execution.ts index f411937b..f65927ed 100644 --- a/backend/src/services/system-database-restore-execution.ts +++ b/backend/src/services/system-database-restore-execution.ts @@ -4,6 +4,7 @@ interface Dependencies { write:(path:string,buffer:Buffer)=>Promise run:(args:string[])=>Promise remove:(path:string)=>Promise + invalidate?:()=>void } export function validateSystemDatabaseRestoreArchive(buffer:Buffer):void{ if(buffer.length>512*1024*1024)throw new Error("Файл бэкапа слишком большой") @@ -16,6 +17,7 @@ export async function executeSystemDatabaseRestore(buffer:Buffer,databaseUrl:str try{ await deps.write(file,buffer) await deps.run(["--single-transaction","--clean","--if-exists","-d",databaseUrl,file]) + deps.invalidate?.() }catch(error){failed=true;failure=error} finally{try{await deps.remove(file)}catch{cleanupError="Не удалось удалить временный файл восстановления"}} if(failed){const message=failure instanceof Error?failure.message:"Восстановление PostgreSQL не подтверждено";throw new Error(message+(cleanupError?"; "+cleanupError:""))} diff --git a/backend/src/services/traffic-flow-analytics.ts b/backend/src/services/traffic-flow-analytics.ts index 8b09dd4b..3e0dfc22 100644 --- a/backend/src/services/traffic-flow-analytics.ts +++ b/backend/src/services/traffic-flow-analytics.ts @@ -152,7 +152,9 @@ export async function buildFlowAnalytics(q: FlowAnalyticsQuery): Promise