screentinker/server/db/wal-checkpointer-worker.js
ScreenTinker 1bb24e7604 Choose the SQLite driver at runtime, and ship the FFmpeg licence with the binaries
TWO CHANGES, together because they touch the same packager hunks.

1. THE DRIVER.

The BrightSign package used to be MANUFACTURED. scripts/build-server-zip.sh dropped
better-sqlite3 from package.json and then installed db/sqlite-compat.js into
node_modules under that name, so every require resolved to the façade. It worked —
and it shipped a database layer that no test had ever executed. That is the same
shape as the TELEMETRY_COLLECTOR TDZ crash that took production down while 1676
tests and four CI jobs were green: a build-time rewrite cannot be tested by the
build that performs it.

db/sqlite-driver.js now decides at runtime: the native driver when it loads, the
node:sqlite façade otherwise. One artifact, one code path, and — the point — both
branches reachable from a test. ST_SQLITE_DRIVER=node runs the entire suite the way
a player runs it, and a new CI job does exactly that on Node 24 with --omit=optional
so the fallback is reached the same way it is on hardware, not by an env var alone.

better-sqlite3 becomes an optionalDependency, so a host with no compiler installs
cleanly and falls back rather than failing. preflight-deps stops trying to rebuild a
native module on a host that has no toolchain and a working built-in driver — on a
player that was a five-minute node-gyp failure ending in a server that never started.
Asking for the native driver BY NAME (ST_SQLITE_DRIVER=better-sqlite3) still fails
loudly, because a production box that has lost its native module is broken and should
say so rather than quietly running something else.

⚠️ NODE 24 IN PRACTICE. node:sqlite is unflagged only from 23.4; on the 22.x line it
needs --experimental-sqlite and on 20.x it does not exist. So the code probes rather
than comparing versions, the player package pins engines >=24, and the built-in cases
skip on the Node 20 CI job rather than failing there.

Verified on Node 24, both drivers, full suite:
  better-sqlite3   1762 pass / 0 fail
  node:sqlite      1762 pass / 0 fail
and the built payload resolves node:sqlite with no better-sqlite3 present at all.

2. THE LICENCE.

The ffprobe/ffmpeg binaries added in the previous commit are LGPL 2.1 and statically
linked, so the licence text has to travel WITH them — a link on a website is not the
copy the licence asks to accompany the work. The packager now copies
COPYING.LGPLv2.1 and a build README into bin/, and refuses to build if the licence is
missing. legal/third-party.html gains an LGPL section with the written offer required
by section 6 for static linking, and the exact configure line.

It also drops Sharp, which that page still listed although #263 removed it, and names
what actually does the image work now (jimp, @jsquash/webp, @jsquash/avif).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:57:05 -05:00

104 lines
5.4 KiB
JavaScript

// WAL checkpointer WORKER (worker_threads). Runs OFF the main event-loop thread so the
// synchronous, fsync-heavy checkpoint never blocks the loop (the ~60s p99 spike).
//
// CRITICAL: this worker opens its OWN better-sqlite3 Database() handle against the same
// file. better-sqlite3 handles are NOT thread-safe, so the main thread's handle is never
// shared into the worker — only the dbPath STRING is passed via workerData. SQLite WAL is
// designed for multiple connections to the same file, so a second connection checkpointing
// while the main connection writes is safe.
const { workerData, parentPort } = require('worker_threads');
const fs = require('fs');
const { Database } = require('./sqlite-driver');
const { dbPath, intervalMs, highWaterBytes, starvationRuns, starvationFloorBytes, escalateCooldownMs } = workerData;
// Fault injection for TESTS ONLY (env-gated; inert in prod). Exits immediately on start so
// the controller's respawn / autocheckpoint-fallback path can be exercised deterministically.
if (process.env.WAL_CKPT_FAIL_START) process.exit(1);
// Fresh, worker-owned connection (NOT the main handle).
const db = new Database(dbPath);
db.pragma('busy_timeout = 5000'); // wait (on THIS worker thread) through the main writer's brief locks
db.pragma('wal_autocheckpoint = 0'); // this connection must never auto-checkpoint either
const walFile = dbPath + '-wal';
function walBytes() { try { return fs.statSync(walFile).size; } catch { return 0; } }
let lastBytes = 0;
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
let lastTruncateAt = 0; // #240: when we last blocked for a TRUNCATE (0 = never)
let coolingReported = false;
let timer = null;
function tick() {
try {
// PASSIVE never blocks writers, but skips frames pinned by active readers/writers —
// so on its own it can perpetually under-checkpoint. That's what the guard below bounds.
db.pragma('wal_checkpoint(PASSIVE)', { simple: false });
const bytes = walBytes();
// --- STARVATION BOUND (this is where "WAL cannot grow forever" is enforced) ---
// Escalating forces a TRUNCATE, which BLOCKS until it has checkpointed everything and
// truncated the file to 0. #240: "fine here on the worker" was only ever half true —
// the fsync is off the loop, but SQLite's locks are held across CONNECTIONS, so the
// main thread's next statement waits it out in the busy handler. Hence the gates below.
if (bytes > lastBytes) growthRuns++; else growthRuns = 0;
const overHighWater = bytes > highWaterBytes;
// #240: TRUNCATE blocks ACROSS connections — the main thread's next statement waits in
// SQLite's busy handler for the whole checkpoint — so the growth signal alone must not
// be able to spend it. Two gates, because either on its own leaves the hole open:
// FLOOR: a WAL in the lower half of its budget has little to reclaim; blocking for it
// is pure cost. (Ungated, every morning fleet power-on wave bought a loop stall.)
// COOLDOWN: a WAL that already sits ABOVE the floor would otherwise escalate on every
// burst forever. However long the pressure lasts, we stall the loop at most once
// per window and let PASSIVE do the rest.
// overHighWater bypasses both — a runaway WAL is the one case worth blocking for.
const sinceLast = Date.now() - lastTruncateAt;
const starved = growthRuns >= starvationRuns && bytes >= starvationFloorBytes;
const cooling = starved && lastTruncateAt > 0 && sinceLast < escalateCooldownMs;
if (cooling && !overHighWater) {
// Report the transition only — a starved-and-cooling state persists for the whole
// window and this check runs every interval; one line, not a log flood.
if (!coolingReported) {
coolingReported = true;
post(`starvation escalation held off (WAL ${(bytes / 1e6).toFixed(1)}MB, last TRUNCATE ${Math.round(sinceLast / 1000)}s ago) — PASSIVE continues`);
}
lastBytes = bytes;
return;
}
if (overHighWater || starved) {
lastTruncateAt = Date.now();
coolingReported = false;
const r = db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
const after = walBytes();
// #240: TRUNCATE does NOT throw when it can't get the locks — it returns busy=1 having
// sat on SQLite's busy timeout for its full duration. Measured at ~4.9s with a single
// reader mid-transaction, reclaiming nothing, while every main-thread statement waited
// behind it. Say so plainly: a silent 5-second loss is the worst thing this can do.
const busy = Array.isArray(r) && r[0] && r[0].busy === 1;
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB${busy ? ' — BUSY: reclaimed nothing, blocked writers for the busy timeout' : ''}`);
growthRuns = 0;
lastBytes = after;
} else {
lastBytes = bytes;
}
} catch (e) {
post('checkpoint error: ' + (e && e.message));
}
}
function post(log) { try { parentPort && parentPort.postMessage({ log }); } catch (_) {} }
timer = setInterval(tick, intervalMs);
// Clean shutdown: stop the timer, close our connection, exit THIS worker thread.
parentPort && parentPort.on('message', (m) => {
if (m && m.stop) {
if (timer) { clearInterval(timer); timer = null; }
try { db.close(); } catch (_) {}
process.exit(0);
}
});