perf(api): ретенция статистики, устранение N+1 и пагинация списков

- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
This commit is contained in:
Denozordec
2026-09-20 19:20:30 +07:00
parent 454c5009d1
commit 7a3f1fad25
23 changed files with 462 additions and 85 deletions
+12
View File
@@ -14,6 +14,8 @@ export {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -45,6 +47,7 @@ export {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -57,6 +60,7 @@ export {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -121,6 +125,8 @@ import {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -151,6 +157,7 @@ import {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -162,6 +169,7 @@ import {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -216,6 +224,8 @@ export const repos = {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
listPolicySets,
@@ -240,6 +250,7 @@ export const repos = {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -248,6 +259,7 @@ export const repos = {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
+28
View File
@@ -39,6 +39,34 @@ export function listIpListEntries(db: Db, listId: string) {
.all()
}
/** Entries for many lists in one query (agent policy hot path). */
export function mapIpListEntriesByListIds(
db: Db,
listIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of listIds) map.set(id, [])
if (listIds.length === 0) return map
const rows = db
.select({ listId: ipListEntries.listId, cidr: ipListEntries.cidr })
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.all()
for (const r of rows) map.get(r.listId)?.push(r.cidr)
return map
}
/** List names for many ids in one query. */
export function mapIpListNames(db: Db, ids: string[]): Map<string, string> {
if (ids.length === 0) return new Map()
const rows = db
.select({ id: ipLists.id, name: ipLists.name })
.from(ipLists)
.where(inArray(ipLists.id, ids))
.all()
return new Map(rows.map((r) => [r.id, r.name]))
}
/** Entry counts for many lists in one query. */
export function countEntriesByListIds(
db: Db,
+20
View File
@@ -319,6 +319,26 @@ export function listResolvedForRule(db: Db, ruleId: string) {
.all()
}
/** Resolved CIDRs for many hostname rules in one query. */
export function mapResolvedCidrsByRuleIds(
db: Db,
ruleIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of ruleIds) map.set(id, [])
if (ruleIds.length === 0) return map
const rows = db
.select({
ruleId: policyRuleResolved.ruleId,
cidr: policyRuleResolved.cidr,
})
.from(policyRuleResolved)
.where(inArray(policyRuleResolved.ruleId, ruleIds))
.all()
for (const r of rows) map.get(r.ruleId)?.push(r.cidr)
return map
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.transaction((tx) => {
tx.delete(policyRuleResolved)
+13 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
import { and, eq, desc, sql, inArray, lt } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agentIpBlockStats,
@@ -38,6 +38,18 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
.run()
}
/**
* Retention: drop raw samples recorded before the cutoff ISO timestamp.
* Lifetime totals live on the agent row; per-IP/per-port aggregates are kept.
*/
export function deleteStatsSamplesBefore(db: Db, cutoffIso: string): number {
const result = db
.delete(agentStatsSamples)
.where(lt(agentStatsSamples.recordedAt, cutoffIso))
.run()
return result.changes
}
export type IpHitInput = { ip: string; packets: number }
/** Gap after which a new presence report counts as a re-hit (left EVOFW_HITS). */