feat(snapshot): re-encode captures + disk-pressure retention
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 38s

Camera snapshots were stored RAW — the camera's full-res JPEG straight into
the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB
= ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision
main stream). They dominated the appliance's single backed-up DB file.

Re-encode on capture (snapshot.ts):
- Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at
  SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage —
  ~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable,
  clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition
  keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a
  re-encode error stores the original, never drops the snapshot or blocks the
  (already-open) path. sharp lives in apps/server (owns the capture path), where
  bcrypt already establishes the native-dep pattern.

Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily
mechanism (the re-encode does that). Daily check reads the DB filesystem used%
(statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the
mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of
disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return
space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM),
so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live
disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage
is injectable for tests. None of this touches the signed ledger — snapshots are
unsigned/advisory, referenced only by id.

Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) +
pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP
floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the
komodo env reference. Full workspace build/lint/test green; the prune smoke-verified
on a scratch DB copy (file shrank after VACUUM).

Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is
a separate optional follow-up). Updated entry-exit-points + technology-stack wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-28 17:15:15 +02:00
parent cce99aadfd
commit 96acd6b662
11 changed files with 727 additions and 5 deletions
+18
View File
@@ -20,6 +20,7 @@ import { PrinterMonitor } from "./printer-monitor.js";
import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
import { authRoutes } from "./routes/auth.js";
@@ -283,6 +284,23 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer));
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
const runSnapPrune = async () => {
const res = await pruneSnapshots(db, {}, app.log);
if (res.deletedRows > 0) {
app.log.info(
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
);
}
};
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
snapPruneTimer.unref();
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { snapshots, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
// so the test controls the trigger without touching the real filesystem.
let db: Db;
beforeEach(() => {
({ db } = createTestDb());
});
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
function seed(n: number, bytes = 1000): void {
const t0 = Date.now() - n * 1000;
for (let i = 0; i < n; i++) {
db.insert(snapshots)
.values({
id: `s-${i}`,
direction: "entry",
deviceId: "cam",
identity: `s-${i}`,
contentType: "image/jpeg",
bytes: Buffer.alloc(bytes, 1),
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
})
.run();
}
}
function count(): number {
return db.select().from(snapshots).all().length;
}
function ids(): string[] {
return db.select().from(snapshots).all().map((r) => r.id).sort();
}
/** A fake disk at a given used% on a 1 GB volume. */
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
() => Promise.resolve({ usedPct, totalBytes });
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
highPct: 70,
freeTargetPct: 10,
minKeep: 2,
batch: 5,
...o,
});
describe("pruneSnapshots (disk-pressure)", () => {
it("no-op when disk is below the high-water mark", async () => {
seed(10);
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
expect(res.deletedRows).toBe(0);
expect(res.vacuumed).toBe(false);
expect(count()).toBe(10);
});
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
seed(20, 20 * 1048576);
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
expect(res.vacuumed).toBe(true);
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
// The survivors are the NEWEST (oldest went first).
const survivors = ids();
expect(survivors).toContain(`s-19`); // newest kept
expect(survivors).not.toContain(`s-0`); // oldest pruned
vacuumSpy.mockRestore();
});
it("honors the MIN_KEEP floor even when still over target", async () => {
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
seed(3, 1000);
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
expect(count()).toBe(2);
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
});
it("skips VACUUM when nothing was deleted", async () => {
seed(2); // == minKeep, so nothing to delete even over the mark
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
expect(res.deletedRows).toBe(0);
expect(res.vacuumed).toBe(false);
expect(vacuumSpy).not.toHaveBeenCalled();
vacuumSpy.mockRestore();
});
});
+148
View File
@@ -0,0 +1,148 @@
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<DiskUsage>;
}
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<DiskUsage> {
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<PruneResult> {
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<number>`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<number>`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 };
}
}
+49 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import type { CameraDevice, Snapshot } from "@parking/devices";
import { captureSnapshotShared } from "./snapshot.js";
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
import { silentLogger } from "./test-helpers.js";
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
@@ -91,3 +93,49 @@ describe("captureSnapshotShared", () => {
expect(s1.bytes.equals(s2.bytes)).toBe(false);
});
});
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
describe("encodeForStorage", () => {
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
async function bigJpeg(): Promise<Buffer> {
return sharp({
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
})
.jpeg({ quality: 95 })
.toBuffer();
}
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
const bytes = await bigJpeg();
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
const out = await encodeForStorage(shot, silentLogger());
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
const meta = await sharp(out.bytes).metadata();
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
});
it("never enlarges an already-small image", async () => {
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
.jpeg()
.toBuffer();
const out = await encodeForStorage(
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
silentLogger(),
);
const meta = await sharp(out.bytes).metadata();
expect(meta.width).toBe(640); // withoutEnlargement
expect(meta.height).toBe(360);
});
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
const garbage = Buffer.from("this is not an image");
const out = await encodeForStorage(
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
silentLogger(),
);
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
});
});
+43 -2
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
@@ -27,6 +28,43 @@ import type { VisionClient } from "./vision-client.js";
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
//
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
// a re-encode error stores the original, never drops the snapshot or blocks the open.
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
function cleanType(ct: string): string {
const base = ct.split(";")[0]?.trim();
return base || "image/jpeg";
}
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
* bytes with a cleaned content type, so a snapshot is never lost. */
export async function encodeForStorage(
shot: Snapshot,
logger: FastifyBaseLogger,
): Promise<{ bytes: Buffer; contentType: string }> {
try {
const out = await sharp(shot.bytes, { failOn: "none" })
.rotate() // honor EXIF orientation before we drop the metadata
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
.toBuffer();
return { bytes: out, contentType: "image/jpeg" };
} catch (err) {
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
}
}
interface SnapshotJob {
readonly db: Db;
@@ -67,14 +105,17 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
const shot = await captureSnapshotShared(row.id, camera, { direction });
const id: string = randomUUID();
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
// uses the original full-res `shot`.
const stored = await encodeForStorage(shot, logger);
db.insert(snapshots)
.values({
id,
direction,
deviceId: row.id,
identity,
contentType: shot.contentType,
bytes: shot.bytes,
contentType: stored.contentType,
bytes: stored.bytes,
capturedAt: shot.capturedAt,
})
.run();