import { statfs } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { asc, snapshots, sql, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; // Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable // BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an // old image never affects the chain. They no longer dominate the DB day-to-day (captures are // re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the // filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and // VACUUM to return disk to the OS. // // Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus // OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real // time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches // the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always // wins — we never delete evidence below it, even under pressure (if the disk is full of something // else, that's not ours to fix). export interface SnapshotRetention { /** Prune when the DB's filesystem is at least this % used. */ readonly highPct: number; /** Try to free roughly this % of the disk per run (the delete target). */ readonly freeTargetPct: number; /** Never prune below this many snapshots (the floor). */ readonly minKeep: number; /** Delete oldest in batches of this size (re-checks between batches). */ readonly batch: number; } export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = { highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70), freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10), minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500), batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200), }; /** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */ export interface DiskUsage { readonly usedPct: number; readonly totalBytes: number; } export interface PruneOptions { readonly retention?: SnapshotRetention; /** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */ readonly diskUsage?: () => Promise; } export interface PruneResult { readonly deletedRows: number; readonly freedBytesEst: number; readonly vacuumed: boolean; readonly usedPctBefore: number; /** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */ readonly floorHitWhileOver: boolean; } /** Read the used% + total bytes of the filesystem holding the DB file. */ async function diskUsageForDb(db: Db): Promise { const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite"; const st = await statfs(dirname(resolve(file))); const total = st.blocks * st.bsize; const avail = st.bavail * st.bsize; const usedPct = total > 0 ? (1 - avail / total) * 100 : 0; return { usedPct, totalBytes: total }; } /** * Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When * over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the * minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer. */ export async function pruneSnapshots( db: Db, opts: PruneOptions = {}, logger?: FastifyBaseLogger, ): Promise { const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION; const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db)); let usedPctBefore = 0; try { const disk = await readDisk(); usedPctBefore = disk.usedPct; // The overwhelmingly common case: plenty of headroom → do nothing. if (disk.usedPct < r.highPct) { return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false }; } // Target bytes to free this run (≈ freeTargetPct of the whole disk). const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes; let freedBytesEst = 0; let deletedRows = 0; let floorHitWhileOver = false; // Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or // we'd cross the MIN_KEEP floor — or there are no more rows. for (;;) { const count = db.select({ c: sql`count(*)` }).from(snapshots).get()?.c ?? 0; if (count <= r.minKeep) { floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor break; } if (freedBytesEst >= targetBytes) break; const room = count - r.minKeep; // how many we may still delete before the floor const take = Math.min(r.batch, room); const oldest = db .select({ id: snapshots.id, len: sql`length(${snapshots.bytes})` }) .from(snapshots) .orderBy(asc(snapshots.capturedAt)) .limit(take) .all(); if (oldest.length === 0) break; const ids = oldest.map((o) => o.id); db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run(); deletedRows += oldest.length; freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0); } // Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we // actually deleted something. Non-fatal on failure — pages are still freed for reuse. let vacuumed = false; if (deletedRows > 0) { try { (db.$client as { exec: (sql: string) => void }).exec("VACUUM"); vacuumed = true; } catch (err) { logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`); } } if (floorHitWhileOver) { logger?.warn( `snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` + `after deleting ${deletedRows} — disk pressure is not from snapshots`, ); } return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver }; } catch (err) { logger?.warn(`snapshot prune failed: ${(err as Error).message}`); return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false }; } }