251 lines
11 KiB
TypeScript
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)
|
|
}
|