fix(netflow): выполнять сброс данных одной транзакцией и сохранять результат
This commit is contained in:
1 parent
e2aae3897d
commit
c9b0f9266e
12 files changed
+150
-57
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { deleteTrafficFlowData, executeTrafficFlowPurge } from "./traffic-flow-purge-execution.js"
|
||||
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 { servers } from "../db/schema.js"
|
||||
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import {
|
||||
getTrafficFlowSettingsRow,
|
||||
listHostPeers,
|
||||
resetFlowIngestCounters,
|
||||
invalidateTrafficFlowSettingsCache,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { applicationName } from "./traffic-flow-apps.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
@@ -514,50 +515,23 @@ export async function flushPendingForTests(): Promise<void> {
|
||||
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
|
||||
|
||||
/** Удаляет сессии и minute/daily rollup. Ключи WG и пиры JH не трогает. */
|
||||
/** Удаляет данные одним transaction; ключи и пиры не меняются. */
|
||||
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
|
||||
return await withAdvisoryLock(PURGE_LOCK_KEY, async () => {
|
||||
wantListen = false
|
||||
await stopWorkerProcessAsync()
|
||||
resetEngineForTests()
|
||||
lastHeartbeat = null
|
||||
state = { bound: false, address: null }
|
||||
const deleted = {
|
||||
buckets: await tableCount("flow_buckets"),
|
||||
minuteStats: await tableCount("flow_minute_stats"),
|
||||
minuteDims: await tableCount("flow_minute_dims"),
|
||||
dailyDims: await tableCount("flow_daily_dims"),
|
||||
hourFacts: await tableCount("flow_hour_facts"),
|
||||
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,
|
||||
}
|
||||
})
|
||||
return withAdvisoryLock(PURGE_LOCK_KEY, () => executeTrafficFlowPurge({
|
||||
stop: async () => {
|
||||
wantListen = false
|
||||
await stopWorkerProcessAsync()
|
||||
resetEngineForTests()
|
||||
lastHeartbeat = null
|
||||
state = { bound: false, address: null }
|
||||
},
|
||||
deleteData: () => deleteTrafficFlowData(() => pool.connect()),
|
||||
invalidate: invalidateTrafficFlowSettingsCache,
|
||||
maintain: () => dropExpiredPartitions(pool),
|
||||
restart: startTrafficFlowListener,
|
||||
}))
|
||||
}
|
||||
|
||||
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 } : {}) }
|
||||
}
|
||||
Reference in new issue
Block a user