← back to Homesonspec
ops(homesonspec): read-only offload PREFLIGHT GO/NO-GO — dest capacity + live-crawl + DB checks before the gated 155G rsync (TK-10809)
bab4fd97fb4d2e1a78cf1a5adebb88fc9b836989 · 2026-08-30 23:05:37 -0700 · Steve Abrams
Files touched
A ops/lib/offload-preflight.mjsA ops/lib/offload-preflight.test.mjsA ops/offload-preflight.mjs
Diff
commit bab4fd97fb4d2e1a78cf1a5adebb88fc9b836989
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 30 23:05:37 2026 -0700
ops(homesonspec): read-only offload PREFLIGHT GO/NO-GO — dest capacity + live-crawl + DB checks before the gated 155G rsync (TK-10809)
---
ops/lib/offload-preflight.mjs | 44 ++++++++++++++++++
ops/lib/offload-preflight.test.mjs | 82 ++++++++++++++++++++++++++++++++++
ops/offload-preflight.mjs | 91 ++++++++++++++++++++++++++++++++++++++
3 files changed, 217 insertions(+)
diff --git a/ops/lib/offload-preflight.mjs b/ops/lib/offload-preflight.mjs
new file mode 100644
index 00000000..a40cd964
--- /dev/null
+++ b/ops/lib/offload-preflight.mjs
@@ -0,0 +1,44 @@
+// Pure GO/NO-GO logic for the snapshot-offload preflight (TK-10809). NO I/O here —
+// the CLI (ops/offload-preflight.mjs) gathers real numbers (du/df/find/psql) and
+// calls classifyPreflight. Kept pure so the decision is hermetically testable,
+// mirroring classifyForDest in ops/lib/snapshot-verify.mjs.
+
+// Does DEST have room for SRC plus a safety margin? marginPct=5 => need srcBytes*1.05.
+export const hasRoomFor = (freeBytes, srcBytes, marginPct = 5) =>
+ Number(freeBytes) >= Number(srcBytes) * (1 + Number(marginPct) / 100);
+
+// results shape (all gathered read-only by the CLI):
+// { srcExists, srcNonEmpty, srcBytes, destGiven, destParentExists, destFreeBytes,
+// destEqualsSrc, hotCrawl, hotDirs, dbReachable, rawSnapshotCount }
+// Returns { verdict:'GO'|'NO-GO', hardFails:[], warnings:[] }.
+export function classifyPreflight(r, { marginPct = 5, warnMarginPct = 15 } = {}) {
+ const hardFails = [];
+ const warnings = [];
+
+ if (!r.srcExists) hardFails.push("SRC does not exist / is not a directory");
+ else if (!r.srcNonEmpty) hardFails.push("SRC has no snapshot subdirs (nothing to offload)");
+
+ if (r.destEqualsSrc) hardFails.push("--dest equals --src (refusing to copy onto itself)");
+
+ if (r.destGiven) {
+ if (!r.destParentExists) hardFails.push("--dest parent path does not exist");
+ else if (!hasRoomFor(r.destFreeBytes, r.srcBytes, marginPct))
+ hardFails.push(
+ `DEST free (${r.destFreeBytes}B) cannot hold SRC (${r.srcBytes}B) + ${marginPct}% margin`,
+ );
+ else if (!hasRoomFor(r.destFreeBytes, r.srcBytes, warnMarginPct))
+ warnings.push(`DEST space margin < ${warnMarginPct}% — fits, but tight`);
+ }
+
+ // A live crawl writing into SRC mid-copy = torn-snapshot risk. Runbook step 2 is
+ // "quiesce ingestion" — the preflight ENFORCES it as a hard block, not a hint.
+ if (r.hotCrawl)
+ hardFails.push(
+ `live crawl writing to SRC now (${(r.hotDirs || []).join(", ") || "unknown dirs"}) — quiesce ingestion first`,
+ );
+
+ // DB down doesn't block the COPY (verify can run later), but scope is unknown → warn.
+ if (!r.dbReachable) warnings.push("DB unreachable — copy can proceed, but row scope/verify unknown now");
+
+ return { verdict: hardFails.length ? "NO-GO" : "GO", hardFails, warnings };
+}
diff --git a/ops/lib/offload-preflight.test.mjs b/ops/lib/offload-preflight.test.mjs
new file mode 100644
index 00000000..44ca0773
--- /dev/null
+++ b/ops/lib/offload-preflight.test.mjs
@@ -0,0 +1,82 @@
+// Hermetic tests for the pure offload-preflight GO/NO-GO logic (TK-10809).
+// No FS/df/du/psql — numbers are passed in, mirroring classifyForDest's test style.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { hasRoomFor, classifyPreflight } from "./offload-preflight.mjs";
+
+const GB = 1024 ** 3;
+// A baseline all-clear results object; each test overrides only what it exercises.
+const clear = () => ({
+ srcExists: true, srcNonEmpty: true, srcBytes: 155 * GB,
+ destGiven: true, destParentExists: true, destFreeBytes: 200 * GB,
+ destEqualsSrc: false, hotCrawl: false, hotDirs: [],
+ dbReachable: true, rawSnapshotCount: 508974,
+});
+
+test("hasRoomFor respects the margin", () => {
+ assert.equal(hasRoomFor(105, 100, 5), true); // exactly at 5% margin → fits
+ assert.equal(hasRoomFor(104, 100, 5), false); // one byte short of margin → no
+ assert.equal(hasRoomFor(200 * GB, 155 * GB, 5), true);
+ assert.equal(hasRoomFor(150 * GB, 155 * GB, 5), false); // dest smaller than src
+});
+
+test("all-clear → GO with no fails or warnings", () => {
+ const { verdict, hardFails, warnings } = classifyPreflight(clear());
+ assert.equal(verdict, "GO");
+ assert.deepEqual(hardFails, []);
+ assert.deepEqual(warnings, []);
+});
+
+test("insufficient dest space → NO-GO", () => {
+ const r = { ...clear(), destFreeBytes: 150 * GB }; // < 155G + 5%
+ const { verdict, hardFails } = classifyPreflight(r);
+ assert.equal(verdict, "NO-GO");
+ assert.ok(hardFails.some((f) => /cannot hold SRC/.test(f)));
+});
+
+test("tight-but-fits margin → GO with a warning", () => {
+ // 170G over a 155G copy = ~9.7% margin: clears the 5% floor (162.75G) but under 15% (178.25G).
+ const r = { ...clear(), destFreeBytes: 170 * GB };
+ const { verdict, warnings } = classifyPreflight(r);
+ assert.equal(verdict, "GO");
+ assert.ok(warnings.some((w) => /margin < 15%/.test(w)));
+});
+
+test("live crawl mid-write → NO-GO (torn-snapshot risk)", () => {
+ const r = { ...clear(), hotCrawl: true, hotDirs: ["lennar-site", "pulte-site"] };
+ const { verdict, hardFails } = classifyPreflight(r);
+ assert.equal(verdict, "NO-GO");
+ assert.ok(hardFails.some((f) => /quiesce ingestion/.test(f) && /lennar-site/.test(f)));
+});
+
+test("dest == src → NO-GO", () => {
+ const { verdict, hardFails } = classifyPreflight({ ...clear(), destEqualsSrc: true });
+ assert.equal(verdict, "NO-GO");
+ assert.ok(hardFails.some((f) => /equals --src/.test(f)));
+});
+
+test("missing SRC → NO-GO", () => {
+ const { verdict, hardFails } = classifyPreflight({ ...clear(), srcExists: false, srcNonEmpty: false });
+ assert.equal(verdict, "NO-GO");
+ assert.ok(hardFails.some((f) => /SRC does not exist/.test(f)));
+});
+
+test("empty SRC → NO-GO", () => {
+ const { verdict, hardFails } = classifyPreflight({ ...clear(), srcNonEmpty: false });
+ assert.equal(verdict, "NO-GO");
+ assert.ok(hardFails.some((f) => /no snapshot subdirs/.test(f)));
+});
+
+test("DB unreachable → still GO, but warns (copy can proceed)", () => {
+ const r = { ...clear(), dbReachable: false, rawSnapshotCount: null };
+ const { verdict, warnings } = classifyPreflight(r);
+ assert.equal(verdict, "GO");
+ assert.ok(warnings.some((w) => /DB unreachable/.test(w)));
+});
+
+test("no --dest → capacity check skipped, GO", () => {
+ const r = { ...clear(), destGiven: false, destParentExists: false, destFreeBytes: 0, destEqualsSrc: false };
+ const { verdict, hardFails } = classifyPreflight(r);
+ assert.equal(verdict, "GO");
+ assert.deepEqual(hardFails, []);
+});
diff --git a/ops/offload-preflight.mjs b/ops/offload-preflight.mjs
new file mode 100644
index 00000000..8dec4aad
--- /dev/null
+++ b/ops/offload-preflight.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// READ-ONLY preflight GO/NO-GO for the snapshot-store offload (TK-10809). Run this
+// BEFORE ops/offload-snapshots.mjs --apply so you never start a ~155G rsync that
+// can't fit, or copy a snapshot dir a live crawl is still writing to (torn snapshot).
+// It does NOTHING destructive: only stat/du/df/find/psql reads. The gathering is
+// thin; the GO/NO-GO decision is the pure classifyPreflight() (hermetically tested).
+//
+// Usage:
+// node ops/offload-preflight.mjs [--src DIR] [--dest DIR] [--live-min N]
+import { execFileSync } from "node:child_process";
+import { statSync, readdirSync, existsSync } from "node:fs";
+import { isAbsolute, dirname } from "node:path";
+import { classifyPreflight } from "./lib/offload-preflight.mjs";
+
+const argv = process.argv.slice(2);
+const opt = (name, def) => { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
+
+const SRC = opt("src", process.env.SNAPSHOT_DIR ?? "/root/Projects/homesonspec/var/snapshots");
+const DEST = opt("dest"); // optional
+const LIVE_MIN = Number(opt("live-min", "15"));
+const DB = process.env.DATABASE_URL || "postgresql://macstudio3@localhost/homesonspec?host=/tmp";
+
+const sh = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim();
+const human = (n) => { const u = ["B","K","M","G","T"]; let i = 0; n = Number(n); while (n >= 1024 && i < u.length-1){n/=1024;i++;} return `${n.toFixed(1)}${u[i]}`; };
+// nearest existing ancestor of a path (df needs a path that exists)
+const existingAncestor = (p) => { let d = p; while (d && d !== "/" && !existsSync(d)) d = dirname(d); return existsSync(d) ? d : "/"; };
+
+console.log(`\n=== snapshot offload PREFLIGHT (read-only) ===`);
+console.log(`SRC : ${SRC}`);
+console.log(`DEST: ${DEST ?? "(not given — skipping capacity check)"}`);
+console.log(`live-crawl window: ${LIVE_MIN}m\n`);
+
+const r = {
+ srcExists: false, srcNonEmpty: false, srcBytes: 0,
+ destGiven: !!DEST, destParentExists: false, destFreeBytes: 0,
+ destEqualsSrc: !!DEST && DEST === SRC,
+ hotCrawl: false, hotDirs: [],
+ dbReachable: false, rawSnapshotCount: null,
+};
+
+// 1) SRC exists + non-empty
+try { r.srcExists = statSync(SRC).isDirectory(); } catch { r.srcExists = false; }
+if (r.srcExists) {
+ const subdirs = readdirSync(SRC, { withFileTypes: true }).filter((e) => e.isDirectory());
+ r.srcNonEmpty = subdirs.length > 0;
+ // 2) SRC size
+ try { r.srcBytes = Number(sh("du", ["-sk", SRC]).split(/\s+/)[0]) * 1024; } catch {}
+ // 4) live-crawl guard — any builder dir with a write in the last LIVE_MIN minutes
+ for (const d of subdirs) {
+ try {
+ const hit = sh("bash", ["-c", `find ${JSON.stringify(SRC + "/" + d.name)} -type f -mmin -${LIVE_MIN} 2>/dev/null | head -1`]);
+ if (hit) { r.hotCrawl = true; r.hotDirs.push(d.name); }
+ } catch {}
+ }
+}
+
+// 3) DEST capacity (nearest existing ancestor for df)
+if (DEST) {
+ const anc = existingAncestor(DEST);
+ r.destParentExists = existsSync(dirname(DEST)) || existsSync(DEST);
+ try {
+ // df -Pk: portable columns; row 2 col 4 = available 1K-blocks
+ const line = sh("df", ["-Pk", anc]).split("\n").pop();
+ r.destFreeBytes = Number(line.trim().split(/\s+/)[3]) * 1024;
+ } catch {}
+}
+
+// 5) DB reachable + RawSnapshot scope
+try { sh("psql", [DB, "-tAc", "select 1"]); r.dbReachable = true; } catch { r.dbReachable = false; }
+if (r.dbReachable) { try { r.rawSnapshotCount = Number(sh("psql", [DB, "-tAc", 'select count(*) from "RawSnapshot"'])); } catch {} }
+
+// ── report each check ──
+const mark = (ok, warn) => (ok ? "✅" : warn ? "⚠️ " : "❌");
+console.log(`${mark(r.srcExists && r.srcNonEmpty)} SRC dir present + non-empty (${r.srcExists ? (r.srcNonEmpty ? "ok" : "EMPTY") : "MISSING"})`);
+console.log(` SRC size: ${r.srcBytes ? human(r.srcBytes) : "n/a"}`);
+if (DEST) {
+ const fits = r.destFreeBytes >= r.srcBytes * 1.05;
+ console.log(`${mark(r.destParentExists && fits, r.destParentExists && r.destFreeBytes >= r.srcBytes)} DEST capacity (free ${human(r.destFreeBytes)} vs need ${human(r.srcBytes * 1.05)})`);
+ console.log(`${mark(!r.destEqualsSrc)} DEST != SRC`);
+}
+console.log(`${mark(!r.hotCrawl, false)} no live crawl in last ${LIVE_MIN}m ${r.hotCrawl ? "(HOT: " + r.hotDirs.join(", ") + ")" : ""}`);
+console.log(`${mark(r.dbReachable, !r.dbReachable)} DB reachable ${r.rawSnapshotCount != null ? `(RawSnapshot rows: ${r.rawSnapshotCount})` : "(scope unknown)"}`);
+
+// ── verdict (pure) ──
+const { verdict, hardFails, warnings } = classifyPreflight(r);
+console.log("");
+for (const w of warnings) console.log(` ⚠️ ${w}`);
+for (const f of hardFails) console.log(` ❌ ${f}`);
+console.log(`\n=== PREFLIGHT ${verdict} ===`);
+if (verdict === "NO-GO") { console.log("Do NOT run the offload --apply until the ❌ items are cleared."); process.exit(1); }
+console.log("Preconditions clear — safe to run: node ops/offload-snapshots.mjs --dest <vol> --apply --full");
← 235736eb fix(homesonspec): berkeley-building — real self-card isolati
·
back to Homesonspec
·
docs(homesonspec): record completed 13-brand CPG roster + co c3626f01 →