fix(netflow): выполнять сброс данных одной транзакцией и сохранять результат
This commit is contained in:
1 parent
e2aae3897d
commit
c9b0f9266e
12 files changed
+150
-57
No files matched your search
@@ -27,7 +27,8 @@
|
|||||||
"test:scheduler": "tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
"test:scheduler": "tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.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: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: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:scheduler && 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": "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:scheduler && 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 && npm run test:netflow-settings && npm run test:netflow-purge",
|
||||||
|
"test:netflow-purge": "tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||||
"test:netflow-settings": "tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
"test:netflow-settings": "tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
||||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||||
"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: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",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import Fastify from "fastify"
|
||||||
|
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
||||||
|
let purges=0,records=0,failPurge=false,failRecord=false,conflict=false
|
||||||
|
const result={ok:true as const,deleted:{buckets:1,minuteStats:0,minuteDims:0,dailyDims:0,hourFacts:2,dailyFacts:3},fileBytesBefore:null,fileBytesAfter:null,vacuumed:false,listenerError:"listener unavailable"}
|
||||||
|
const app=Fastify();await app.register(createTrafficFlowPurgeRoutes({purge:async()=>{purges++;if(conflict)throw Error("Сброс уже выполняется");if(failPurge)throw Error("delete unknown");return result},record:async actual=>{records++;assert.deepEqual(actual,result);if(failRecord)throw Error("journal unavailable")}}),{prefix:"/api"})
|
||||||
|
const post=()=>app.inject({method:"POST",url:"/api/traffic/flow/purge"})
|
||||||
|
let response=await post();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),result)
|
||||||
|
failRecord=true;response=await post();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{...result,eventError:"journal unavailable"});assert.equal(purges,2);assert.equal(records,2)
|
||||||
|
failPurge=true;response=await post();assert.equal(response.statusCode,500);assert.equal(records,2);assert.equal(purges,3)
|
||||||
|
conflict=true;response=await post();assert.equal(response.statusCode,409);assert.equal(records,2);assert.equal(purges,4)
|
||||||
|
await app.close();console.log("NetFlow purge HTTP: committed deletion survives event failure, partial warnings preserved, unknown/conflict never repeated")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
export function createTrafficFlowPurgeRoutes(deps: { purge: () => Promise<FlowPurgeDto>; record: (result: FlowPurgeDto) => Promise<void> }): FastifyPluginAsyncZod {
|
||||||
|
return async app => {
|
||||||
|
app.post("/traffic/flow/purge", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await deps.purge()
|
||||||
|
try { await deps.record(result) } catch (error) {
|
||||||
|
return { ...result, eventError: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return reply.status(message.includes("уже выполняется") ? 409 : 500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { env } from "../config.js"
|
|||||||
import {
|
import {
|
||||||
trafficFlowOverlayRequestSchema,
|
trafficFlowOverlayRequestSchema,
|
||||||
} from "@mmapp/contracts/traffic-flow"
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
|
||||||
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
|
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
|
||||||
import {
|
import {
|
||||||
ensureHostKeys,
|
ensureHostKeys,
|
||||||
@@ -155,9 +156,9 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
hostFiles: listTrafficFlowHostFiles,
|
hostFiles: listTrafficFlowHostFiles,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.post("/traffic/flow/purge", async (_req, reply) => {
|
await app.register(createTrafficFlowPurgeRoutes({
|
||||||
try {
|
purge: purgeTrafficFlowStore,
|
||||||
const result = await purgeTrafficFlowStore()
|
record: async result => {
|
||||||
await appendEvent({
|
await appendEvent({
|
||||||
level: "warning",
|
level: "warning",
|
||||||
eventType: "traffic.flow.purge",
|
eventType: "traffic.flow.purge",
|
||||||
@@ -176,13 +177,8 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
vacuumed: result.vacuumed,
|
vacuumed: result.vacuumed,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return reply.send(result)
|
},
|
||||||
} catch (err) {
|
}))
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
const status = message.includes("уже выполняется") ? 409 : 500
|
|
||||||
return reply.status(status).send({ error: message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/traffic/flow/rebuild-facts", async (_req, reply) => {
|
app.post("/traffic/flow/rebuild-facts", async (_req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { deleteTrafficFlowData, executeTrafficFlowPurge } from "./traffic-flow-purge-execution.js"
|
||||||
import { Worker } from "node:worker_threads"
|
import { Worker } from "node:worker_threads"
|
||||||
import { db, dbAll, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
|
import { db, dbAll, pool, withAdvisoryLock } from "../db/index.js"
|
||||||
import { dropExpiredPartitions } from "../db/partitions.js"
|
import { dropExpiredPartitions } from "../db/partitions.js"
|
||||||
import { servers } from "../db/schema.js"
|
import { servers } from "../db/schema.js"
|
||||||
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
listHostPeers,
|
listHostPeers,
|
||||||
resetFlowIngestCounters,
|
invalidateTrafficFlowSettingsCache,
|
||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
import { applicationName } from "./traffic-flow-apps.js"
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
@@ -514,50 +515,23 @@ export async function flushPendingForTests(): Promise<void> {
|
|||||||
await flushPending({ force: true })
|
await flushPending({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tableCount(name: string): Promise<number> {
|
|
||||||
const row = await dbGet<{ n: number }>(`SELECT COUNT(*) AS n FROM ${name}`)
|
|
||||||
return Number(row?.n) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const PURGE_LOCK_KEY = 8_723_103
|
const PURGE_LOCK_KEY = 8_723_103
|
||||||
|
|
||||||
/** Удаляет сессии и minute/daily rollup. Ключи WG и пиры JH не трогает. */
|
/** Удаляет данные одним transaction; ключи и пиры не меняются. */
|
||||||
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
||||||
return await withAdvisoryLock(PURGE_LOCK_KEY, async () => {
|
return withAdvisoryLock(PURGE_LOCK_KEY, () => executeTrafficFlowPurge({
|
||||||
wantListen = false
|
stop: async () => {
|
||||||
await stopWorkerProcessAsync()
|
wantListen = false
|
||||||
resetEngineForTests()
|
await stopWorkerProcessAsync()
|
||||||
lastHeartbeat = null
|
resetEngineForTests()
|
||||||
state = { bound: false, address: null }
|
lastHeartbeat = null
|
||||||
const deleted = {
|
state = { bound: false, address: null }
|
||||||
buckets: await tableCount("flow_buckets"),
|
},
|
||||||
minuteStats: await tableCount("flow_minute_stats"),
|
deleteData: () => deleteTrafficFlowData(() => pool.connect()),
|
||||||
minuteDims: await tableCount("flow_minute_dims"),
|
invalidate: invalidateTrafficFlowSettingsCache,
|
||||||
dailyDims: await tableCount("flow_daily_dims"),
|
maintain: () => dropExpiredPartitions(pool),
|
||||||
hourFacts: await tableCount("flow_hour_facts"),
|
restart: startTrafficFlowListener,
|
||||||
dailyFacts: await tableCount("flow_daily_facts"),
|
}))
|
||||||
}
|
|
||||||
await dbQuery(`DELETE FROM flow_buckets`)
|
|
||||||
await dbQuery(`DELETE FROM flow_minute_stats`)
|
|
||||||
await dbQuery(`DELETE FROM flow_minute_dims`)
|
|
||||||
await dbQuery(`DELETE FROM flow_daily_dims`)
|
|
||||||
await dbQuery(`DELETE FROM flow_hour_facts`)
|
|
||||||
await dbQuery(`DELETE FROM flow_daily_facts`)
|
|
||||||
await resetFlowIngestCounters()
|
|
||||||
await dropExpiredPartitions(pool)
|
|
||||||
try {
|
|
||||||
await startTrafficFlowListener()
|
|
||||||
} catch {
|
|
||||||
/* ingest мог остаться выключенным */
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
deleted,
|
|
||||||
fileBytesBefore: 0,
|
|
||||||
fileBytesAfter: 0,
|
|
||||||
vacuumed: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { peekPendingFlows }
|
export { peekPendingFlows }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { deleteTrafficFlowData, executeTrafficFlowPurge } from "./traffic-flow-purge-execution.js"
|
||||||
|
function database(failAt = "", count: {value: unknown} = {value: "2"}) {
|
||||||
|
const calls: string[] = []; let released=0,committedDeletes=0,pendingDeletes=0,counters=42,pendingCounters=42
|
||||||
|
return {
|
||||||
|
connect:async()=>({ query:async(sql:string)=>{calls.push(sql);if(sql===failAt)throw Error("fixture failure");if(sql.startsWith("SELECT COUNT"))return {rows:[{n:count.value}]};if(sql.startsWith("DELETE"))pendingDeletes++;if(sql.startsWith("UPDATE"))pendingCounters=0;if(sql==="COMMIT"){committedDeletes=pendingDeletes;counters=pendingCounters}if(sql==="ROLLBACK"){pendingDeletes=0;pendingCounters=counters}return {rows:[]}},release:()=>{released++}}),
|
||||||
|
state:()=>({calls,released,committedDeletes,counters}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let db=database();const deleted=await deleteTrafficFlowData(db.connect)
|
||||||
|
assert.deepEqual(deleted,{buckets:2,minuteStats:2,minuteDims:2,dailyDims:2,hourFacts:2,dailyFacts:2});assert.equal(db.state().committedDeletes,6);assert.equal(db.state().counters,0);assert.equal(db.state().released,1)
|
||||||
|
assert.equal(db.state().calls[0],"BEGIN");assert.match(db.state().calls[1],/^LOCK TABLE .* IN ACCESS EXCLUSIVE MODE$/);assert.equal(db.state().calls.at(-1),"COMMIT")
|
||||||
|
for(const failAt of ["DELETE FROM flow_minute_dims", "UPDATE traffic_flow_settings SET packets_received = 0, last_datagram_at = NULL, last_exporter_ip = NULL, last_error = '', updated_at = now() WHERE id = 1", "COMMIT"]){
|
||||||
|
db=database(failAt);await assert.rejects(deleteTrafficFlowData(db.connect),/fixture failure/);assert.equal(db.state().committedDeletes,0);assert.equal(db.state().counters,42);assert.equal(db.state().released,1);assert.equal(db.state().calls.at(-1),"ROLLBACK");assert.equal(db.state().calls.filter(c=>c==="BEGIN").length,1)
|
||||||
|
}
|
||||||
|
for(const bad of [null,undefined,"NaN","9007199254740992",-1,1.5]){db=database("",{value:bad});await assert.rejects(deleteTrafficFlowData(db.connect));assert.equal(db.state().committedDeletes,0);assert.equal(db.state().released,1);assert.equal(db.state().calls.filter(c=>c.startsWith("DELETE")).length,0)}
|
||||||
|
let stops=0,deletes=0,restarts=0,invalidates=0,maintains=0,failDelete=false
|
||||||
|
const deps={stop:async()=>{stops++},deleteData:async()=>{deletes++;if(failDelete)throw Error("delete unknown");return deleted},invalidate:()=>{invalidates++},maintain:async()=>{maintains++;throw Error("partition unavailable")},restart:async()=>{restarts++;throw Error("listener unavailable")}}
|
||||||
|
const result=await executeTrafficFlowPurge(deps);assert.equal(result.ok,true);assert.deepEqual(result.deleted,deleted);assert.equal(result.partitionError,"partition unavailable");assert.equal(result.listenerError,"listener unavailable");assert.equal(result.fileBytesBefore,null);assert.equal(result.fileBytesAfter,null)
|
||||||
|
assert.equal(invalidates,1);assert.equal(deletes,1);assert.equal(restarts,1)
|
||||||
|
failDelete=true;await assert.rejects(executeTrafficFlowPurge(deps),/delete unknown.*не восстановлен/);assert.equal(stops,2);assert.equal(deletes,2);assert.equal(maintains,1);assert.equal(invalidates,1);assert.equal(restarts,2)
|
||||||
|
console.log("NetFlow purge transaction: all tables/counters commit together, rollback/release, safe counts, maintenance/restart partial result without repeated delete")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
const tables = {
|
||||||
|
buckets: "flow_buckets", minuteStats: "flow_minute_stats", minuteDims: "flow_minute_dims",
|
||||||
|
dailyDims: "flow_daily_dims", hourFacts: "flow_hour_facts", dailyFacts: "flow_daily_facts",
|
||||||
|
} as const
|
||||||
|
type Client = { query: (sql: string) => Promise<{ rows: Record<string, unknown>[] }>; release: () => void }
|
||||||
|
export async function deleteTrafficFlowData(connect: () => Promise<Client>): Promise<FlowPurgeDto["deleted"]> {
|
||||||
|
const client = await connect()
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN")
|
||||||
|
await client.query("LOCK TABLE " + Object.values(tables).join(", ") + " IN ACCESS EXCLUSIVE MODE")
|
||||||
|
const deleted: FlowPurgeDto["deleted"] = { buckets: 0, minuteStats: 0, minuteDims: 0, dailyDims: 0 }
|
||||||
|
for (const [field, table] of Object.entries(tables)) {
|
||||||
|
const result = await client.query("SELECT COUNT(*)::bigint AS n FROM " + table)
|
||||||
|
const raw = result.rows[0]?.n
|
||||||
|
if ((typeof raw !== "number" && typeof raw !== "string") || !/^\d+$/.test(String(raw))) throw new Error("Не подтверждён счётчик " + table)
|
||||||
|
const count = Number(raw)
|
||||||
|
if (!Number.isSafeInteger(count) || count < 0) throw new Error("Счётчик вне допустимого диапазона: " + table)
|
||||||
|
deleted[field as keyof typeof deleted] = count
|
||||||
|
}
|
||||||
|
for (const table of Object.values(tables)) await client.query("DELETE FROM " + table)
|
||||||
|
await client.query("UPDATE traffic_flow_settings SET packets_received = 0, last_datagram_at = NULL, last_exporter_ip = NULL, last_error = '', updated_at = now() WHERE id = 1")
|
||||||
|
await client.query("COMMIT")
|
||||||
|
return deleted
|
||||||
|
} catch (error) {
|
||||||
|
// A lost COMMIT response remains unknown; never retry deletion here.
|
||||||
|
try { await client.query("ROLLBACK") } catch { /* preserve the original failure */ }
|
||||||
|
throw error
|
||||||
|
} finally { client.release() }
|
||||||
|
}
|
||||||
|
const message = (error: unknown) => error instanceof Error ? error.message : String(error)
|
||||||
|
export async function executeTrafficFlowPurge(deps: {
|
||||||
|
stop: () => Promise<void>
|
||||||
|
deleteData: () => Promise<FlowPurgeDto["deleted"]>
|
||||||
|
invalidate: () => void
|
||||||
|
maintain: () => Promise<void>
|
||||||
|
restart: () => Promise<void>
|
||||||
|
}): Promise<FlowPurgeDto> {
|
||||||
|
let deleted: FlowPurgeDto["deleted"]
|
||||||
|
try {
|
||||||
|
await deps.stop()
|
||||||
|
deleted = await deps.deleteData()
|
||||||
|
} catch (error) {
|
||||||
|
let restartError: string | undefined
|
||||||
|
try { await deps.restart() } catch (restart) { restartError = message(restart) }
|
||||||
|
throw new Error(message(error) + (restartError === undefined ? "" : "; приём NetFlow не восстановлен: " + restartError))
|
||||||
|
}
|
||||||
|
deps.invalidate()
|
||||||
|
let partitionError: string | undefined, listenerError: string | undefined
|
||||||
|
try { await deps.maintain() } catch (error) { partitionError = message(error) }
|
||||||
|
try { await deps.restart() } catch (error) { listenerError = message(error) }
|
||||||
|
return { ok: true, deleted, fileBytesBefore: null, fileBytesAfter: null, vacuumed: false,
|
||||||
|
...(partitionError !== undefined ? { partitionError } : {}), ...(listenerError !== undefined ? { listenerError } : {}) }
|
||||||
|
}
|
||||||
@@ -256,3 +256,5 @@ REST запрос → ожидание → подтверждённый output /
|
|||||||
Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты.
|
Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты.
|
||||||
|
|
||||||
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
|
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
|
||||||
|
|
||||||
|
Завершён 11Б.3.4.4.4: 6 таблиц/counters purge в общей транзакции, отдельные partition/listener/event warnings и nullable размер файлов. DI/HTTP/API/typecheck/build/lint прошли; реальный PG purge не запускался. Следующий шаг 11Б.3.4.4.5 — форма NetFlow и подтверждённые/частичные/неизвестные операции.
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.4; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11Б.3.4.4.4 — атомарность и результаты purge NetFlow (2026-10-03)
|
||||||
|
|
||||||
|
- Все 6 таблиц и сброс счётчиков выполняются одним PG client в BEGIN/COMMIT, с LOCK TABLE перед чтением счётчиков. Небезопасный/отсутствующий count отклоняется до DELETE; ошибка вызывает попытку ROLLBACK и release. Потерянный ответ COMMIT остаётся неизвестным, автоматического повторного удаления нет.
|
||||||
|
- Ошибки post-commit очистки partitions и запуска listener возвращаются как partitionError/listenerError вместе с подтверждёнными deleted. Ошибка appendEvent возвращает eventError, не маскирует успешный purge. После неудачи core сервер пытается восстановить приём; ошибка восстановления добавляется к исходной.
|
||||||
|
- DTO размера файлов теперь nullable: PG размер не измерялся, вместо выдуманных нулей возвращается null. Старые числовые ответы совместимы; strict frontend reader сохраняет все предупреждения. UI пока ещё не показывает их, его приёмка впереди.
|
||||||
|
- DI transaction/HTTP tests проверили commit 6 таблиц + counters, rollback/release, unsafe counts, maintenance/restart/event failures без повторного DELETE. Первоначальный undefined fixture случайно использовал default count; исправлен сам fixture, повтор прошёл. 5 API tests, полный typecheck, backend build и ESLint прошли; профили добавлены в backend test. Реальный PG purge не запускался, multi-instance ingest/производительность блокировки не приняты.
|
||||||
|
- Далее 11Б.3.4.4.5: форма NetFlow — known/dirty, права, строгий ввод, блокировка действий и явные partial/unknown outcomes, включая 6 счётчиков purge. Затем source/adaptive приёмка.
|
||||||
|
|
||||||
## Блок 11Б.3.4.4.3 — конкурентные настройки и ключи NetFlow (2026-10-03)
|
## Блок 11Б.3.4.4.3 — конкурентные настройки и ключи NetFlow (2026-10-03)
|
||||||
|
|
||||||
- Settings UPDATE изменяет только поля patch, не переносит соседние настройки из старого cache. Первичная вставка singleton использует onConflictDoNothing, параллельная инициализация не падает из-за дубликата.
|
- Settings UPDATE изменяет только поля patch, не переносит соседние настройки из старого cache. Первичная вставка singleton использует onConflictDoNothing, параллельная инициализация не падает из-за дубликата.
|
||||||
|
|||||||
@@ -184,3 +184,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
|
|||||||
Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты.
|
Завершён 11Б.3.4.4.2: private export требует traffic write, GET не создаёт ключи, PUT отделяет сохранение от ошибки запуска listener. HTTP/JWT, backend build, lint и permissions прошли. Далее 11Б.3.4.4.3: конкурентное создание ключей/выборочные patch; purge и UI ещё не приняты.
|
||||||
|
|
||||||
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
|
Завершён 11Б.3.4.4.3: выборочная запись NetFlow patch и условная установка host keys, свежая подтверждённая пара; DI гонка/HTTP/JWT/build/lint прошли. Реальная PG гонка не проверена. Далее 11Б.3.4.4.4 — атомарность purge и частичные результаты.
|
||||||
|
|
||||||
|
Завершён 11Б.3.4.4.4: 6 таблиц/counters purge в общей транзакции, отдельные partition/listener/event warnings и nullable размер файлов. DI/HTTP/API/typecheck/build/lint прошли; реальный PG purge не запускался. Следующий шаг 11Б.3.4.4.5 — форма NetFlow и подтверждённые/частичные/неизвестные операции.
|
||||||
@@ -253,9 +253,12 @@ export const flowPurgeDtoSchema = z.object({
|
|||||||
hourFacts: z.number().int().nonnegative().optional(),
|
hourFacts: z.number().int().nonnegative().optional(),
|
||||||
dailyFacts: z.number().int().nonnegative().optional(),
|
dailyFacts: z.number().int().nonnegative().optional(),
|
||||||
}),
|
}),
|
||||||
fileBytesBefore: z.number().int().nonnegative(),
|
fileBytesBefore: z.number().int().nonnegative().nullable(),
|
||||||
fileBytesAfter: z.number().int().nonnegative(),
|
fileBytesAfter: z.number().int().nonnegative().nullable(),
|
||||||
vacuumed: z.boolean(),
|
vacuumed: z.boolean(),
|
||||||
|
listenerError: z.string().optional(),
|
||||||
|
partitionError: z.string().optional(),
|
||||||
|
eventError: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowFactsRebuildDtoSchema = z.object({
|
export const flowFactsRebuildDtoSchema = z.object({
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ test("NetFlow purge requires confirmed actual counts and never repeats an unknow
|
|||||||
try {
|
try {
|
||||||
const receipt = { ok: true, deleted: { buckets: 0, minuteStats: 0, minuteDims: 0, dailyDims: 0 }, fileBytesBefore: 0, fileBytesAfter: 0, vacuumed: false }
|
const receipt = { ok: true, deleted: { buckets: 0, minuteStats: 0, minuteDims: 0, dailyDims: 0 }, fileBytesBefore: 0, fileBytesAfter: 0, vacuumed: false }
|
||||||
globalThis.fetch = async () => json(receipt); assert.deepEqual(await purgeTrafficFlowData(base), receipt)
|
globalThis.fetch = async () => json(receipt); assert.deepEqual(await purgeTrafficFlowData(base), receipt)
|
||||||
|
const partial = {...receipt, fileBytesBefore: null, fileBytesAfter: null, listenerError: "listener unavailable", partitionError: "partition unavailable", eventError: "journal unavailable"}; globalThis.fetch = async () => json(partial); assert.deepEqual(await purgeTrafficFlowData(base), partial)
|
||||||
for (const raw of [{}, { ...receipt, ok: false }, { ...receipt, deleted: { buckets: -1 } }]) { let calls = 0; globalThis.fetch = async () => { calls++; return json(raw) }; await assert.rejects(() => purgeTrafficFlowData(base)); assert.equal(calls, 1) }
|
for (const raw of [{}, { ...receipt, ok: false }, { ...receipt, deleted: { buckets: -1 } }]) { let calls = 0; globalThis.fetch = async () => { calls++; return json(raw) }; await assert.rejects(() => purgeTrafficFlowData(base)); assert.equal(calls, 1) }
|
||||||
} finally { globalThis.fetch = original }
|
} finally { globalThis.fetch = original }
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user