Files
MikrotikManager/backend/src/db/partitions.ts
T

251 lines
11 KiB
TypeScript

import {prepareReviewedPartitionGuardInheritance} from "./partition-guard-inheritance.js"
import pg, { type Pool } from "pg"
import { isPartitionWriteClient, withPartitionWrite } from "../services/partition-write-transaction.js"
type PartitionQueries=Pick<Pool,"query">
const MAX_PARTITION_OPERATIONS=2000
/** CREATE/ALTER/DROP share the exact backend admitted before relation access.
* A query-shaped client is never evidence of transaction ownership. */
async function withPartitionDdl<T>(target:PartitionQueries,work:(client:PartitionQueries)=>Promise<T>):Promise<T>{
if(isPartitionWriteClient(target))return work(target)
if(!(target instanceof pg.Pool))throw Error("Partition DDL requires a Pool or a live authenticated partition writer client")
// Delay the flow runtime import until an actual pool is used. Importing the
// partition catalogue itself must not initialize the configured DB pool.
const {flowWritePool,flowWriteContext}=await import("../services/traffic-flow-write-transaction.js")
if(target===flowWritePool){
const context=flowWriteContext()
if(context){
const query=(async(...args:unknown[])=>{
await context.authorize();if(!context.active||flowWriteContext()!==context)throw Error("Flow partition writer context is closed")
const result=await Reflect.apply(context.client.query,context.client,args)
await context.authorize();if(!context.active||flowWriteContext()!==context)throw Error("Flow partition writer context is closed")
return result
})as Pool["query"]
return work({query})
}
}
return withPartitionWrite(owner=>work(owner.client),{connect:()=>target.connect()})
}
function validatePartitionInput(parent:string,kind:PartitionKind,ts:Date):void{
if(!/^[a-z_][a-z\d_]{0,49}$/.test(parent)||!["day","week","month"].includes(kind)||!(ts instanceof Date)||!Number.isFinite(ts.getTime())||ts.getUTCFullYear()<100||ts.getUTCFullYear()>9998)throw Error("Invalid bounded partition identifier, kind or UTC instant")
const spec=PARTITION_SPECS.find(spec=>spec.parent===parent)
if(spec&&spec.kind!==kind)throw Error("Partition kind does not match the parent catalogue")
}
export type PartitionKind = "day" | "week" | "month"
export interface PartitionSpec {
parent: string
kind: PartitionKind
keepDays: number
}
/** Leaf-only: PG forbids storage params on partitioned parents (SQLSTATE 42809). */
const FACT_LEAF_STORAGE =
"fillfactor = 70, autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_cost_limit = 2000"
const FACT_PARENTS = new Set(["flow_hour_facts", "flow_daily_facts"])
export const PARTITION_SPECS: PartitionSpec[] = [
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
{ parent: "flow_hour_facts", kind: "day", keepDays: 3 },
{ parent: "flow_daily_facts", kind: "month", keepDays: 420 },
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
]
const WEEK_SLACK_DAYS = 7
async function resolveKeepDays(pool: Pick<Pool,"query">): Promise<Map<string, number>> {
const map = new Map(PARTITION_SPECS.map((s) => [s.parent, s.keepDays]))
const configured:{parents:string[];value:unknown}[]=[]
try {
const traffic = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM traffic_settings WHERE id = 1`,
)
if(traffic.rows.length)configured.push({parents:["traffic_samples"],value:traffic.rows[0].retention_days})
const uptime = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM uptime_settings WHERE id = 1`,
)
if(uptime.rows.length)configured.push({parents:["uptime_probe_samples","uptime_resource_samples"],value:uptime.rows[0].retention_days})
const path = await pool.query<{ retention_days: number }>(
`SELECT retention_days FROM internet_path_settings WHERE id = 1`,
)
if(path.rows.length)configured.push({parents:["internet_path_snapshots"],value:path.rows[0].retention_days})
} catch {
/* settings may be absent mid-migration */
}
for(const setting of configured){const days=Number(setting.value);if(!Number.isSafeInteger(days)||days<1||days>3660)throw Error("Invalid bounded partition retention setting");for(const parent of setting.parents)map.set(parent,days+WEEK_SLACK_DAYS)}
return map
}
function utcDate(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
}
function addUtcDays(d: Date, n: number): Date {
const x = utcDate(d)
x.setUTCDate(x.getUTCDate() + n)
return x
}
function startOfWeekUtc(d: Date): Date {
const x = utcDate(d)
const day = x.getUTCDay() || 7
x.setUTCDate(x.getUTCDate() - (day - 1))
return x
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
}
function fmtDay(d: Date): string {
return d.toISOString().slice(0, 10)
}
function partName(parent: string, kind: PartitionKind, start: Date): string {
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
return `${parent}_m_${String(start.getUTCFullYear()).padStart(4,"0")}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
}
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
if (kind === "day") {
const start = utcDate(ts)
return { start, end: addUtcDays(start, 1) }
}
if (kind === "week") {
const start = startOfWeekUtc(ts)
return { start, end: addUtcDays(start, 7) }
}
const start = startOfMonthUtc(ts)
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
return { start, end }
}
export async function ensurePartitionFor(
pool: Pick<Pool, "query">,
parent: string,
kind: PartitionKind,
ts: Date,
): Promise<string> {
validatePartitionInput(parent,kind,ts)
const { start, end } = rangeFor(kind, ts)
validatePartitionInput(parent,kind,start);validatePartitionInput(parent,kind,end)
const name = partName(parent, kind, start)
// Bounds describe UTC instants, independently of the PostgreSQL session zone.
// A date-only literal would move a timestamptz boundary by that zone's offset.
const from = start.toISOString()
const to = end.toISOString()
await withPartitionDdl(pool,async client=>{
const finishInheritance=await prepareReviewedPartitionGuardInheritance(client,parent,name)
await client.query(`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`)
if (FACT_PARENTS.has(parent))await client.query(`ALTER TABLE ${name} SET (${FACT_LEAF_STORAGE})`)
await finishInheritance()
})
return name
}
export async function ensurePartitionsBetween(
pool: PartitionQueries,
parent: string,
kind: PartitionKind,
from: Date,
to: Date,
): Promise<void> {
validatePartitionInput(parent,kind,from);validatePartitionInput(parent,kind,to)
const start = from.getTime() <= to.getTime() ? from : to
const end = from.getTime() <= to.getTime() ? to : from
const planned:Date[]=[]
for (let t = new Date(start.getTime()); t <= end; ) {
planned.push(t);if(planned.length*(FACT_PARENTS.has(parent)?2:1)>MAX_PARTITION_OPERATIONS)throw Error("Partition range exceeds the bounded maintenance budget")
if (kind === "day") t = addUtcDays(t, 1)
else if (kind === "week") t = addUtcDays(t, 7)
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
}
for(const t of planned)await ensurePartitionFor(pool,parent,kind,t)
}
export async function ensurePartitionsAround(pool: PartitionQueries, around = new Date()): Promise<void> {
validatePartitionInput("server_snapshots","week",around)
const keepDays = await resolveKeepDays(pool)
const planned:{parent:string;kind:PartitionKind;at:Date}[]=[]
let cost=0
for (const spec of PARTITION_SPECS) {
const keep = keepDays.get(spec.parent) ?? spec.keepDays
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
const start = addUtcDays(around, -keep)
const end = addUtcDays(around, daysAhead)
for (let t = new Date(start.getTime()); t < end; ) {
planned.push({parent:spec.parent,kind:spec.kind,at:t});cost+=FACT_PARENTS.has(spec.parent)?2:1;if(cost>MAX_PARTITION_OPERATIONS)throw Error("Partition catalogue exceeds the bounded maintenance budget")
if (spec.kind === "day") t = addUtcDays(t, 1)
else if (spec.kind === "week") t = addUtcDays(t, 7)
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
}
}
for(const item of planned)await ensurePartitionFor(pool,item.parent,item.kind,item.at)
}
export interface ExpiredPartition { parent: string; name: string }
/** One bounded retention manifest is shared by preview and the actual cleanup. */
export async function listExpiredPartitions(pool: Pick<Pool,"query">, around = new Date()): Promise<ExpiredPartition[]> {
validatePartitionInput("server_snapshots","week",around)
const keepDays = await resolveKeepDays(pool)
const expired: ExpiredPartition[] = []
for (const spec of PARTITION_SPECS) {
const keep = keepDays.get(spec.parent) ?? spec.keepDays
const cutoff = addUtcDays(around, -keep)
const { rows } = await pool.query<{ relname: string }>(
`SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = $1 AND p.relnamespace='public'::regnamespace AND c.relnamespace=p.relnamespace
ORDER BY c.relname LIMIT 1001`,
[spec.parent],
)
if(rows.length>1000)throw new Error("Превышен предел разделов для обслуживания")
for (const row of rows) {
const m = row.relname.match(new RegExp(`^${spec.parent}_([dwm])_(\\d{8}|\\d{6})$`))
if (!m) continue
if(m[1]!==spec.kind[0])continue
const stamp = m[2]
let start: Date
if (stamp.length === 6) {
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
} else {
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
}
if(!Number.isFinite(start.getTime())||partName(spec.parent,spec.kind,start)!==row.relname)continue
if (start < cutoff) expired.push({parent:spec.parent,name:row.relname})
if(expired.length>1000)throw new Error("Превышен предел истёкших разделов")
}
}
return expired
}
export async function dropExpiredPartitions(pool: PartitionQueries, around = new Date(), beforeDrop?: (partition: ExpiredPartition) => Promise<void>): Promise<void> {
const expired=await listExpiredPartitions(pool,around)
for(const partition of expired){
const quoted=partition.name.replace(/"/g,'""')
await withPartitionDdl(pool,async client=>{
await beforeDrop?.(partition)
await client.query(`DROP TABLE IF EXISTS "public"."${quoted}"`)
})
}
}
export function specForParent(parent: string): PartitionSpec | undefined {
return PARTITION_SPECS.find((s) => s.parent === parent)
}