screentinker/server/services/loop-lag.js
ScreenTinker 59489b3b20 #240: stop the morning wave buying itself a blocking checkpoint
Bold reported loop lag that grew with uptime and reset on restart. The
signature they saw — mean = p50 = p99 = max, identical to two decimals —
is not a fixed cost paid on every cycle. It is what an IntervalHistogram
window reports when it recorded exactly ONE delay: the mean is the raw
value, and every percentile returns the bucket ceiling above it.
Reproduced against their exact numbers (1329.07 / 1329.59). So the loop
took one long turn that swallowed the sampling second, episodically —
which is what they later confirmed independently.

The turn is ours, and it is now measured rather than theorised. Probing
the real worker against a real WAL with one reader mid-transaction: a
single main-thread write blocked for 4,936ms behind the worker's
wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE
is the blocking form and its locks are held ACROSS connections, so moving
it to a worker kept the fsync off the loop but not the lock; and it does
not throw when it cannot get those locks, it returns busy=1 having sat on
SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled
loop for zero benefit, and silent.

It was reached far too easily. The rule was "escalate if the WAL grew
across three consecutive 15s runs" — which any sustained 45-second write
burst satisfies. A customer's fleet powering on in the morning does it
daily.

Two gates, because either alone leaves the hole open. A size FLOOR, so a
WAL in the lower half of its budget can't buy a blocking checkpoint it has
nothing to reclaim from. And a COOLDOWN, because the floor alone fixes
nothing for Bold — their WAL already sits at 6.2MB against a 16MB
high-water, above any sane floor, so every burst would still escalate.
However long the pressure lasts, our own maintenance may now stall the
loop at most once per window. The high-water rule bypasses both and is
untouched: a runaway WAL is the one case worth blocking for, so the
"WAL cannot grow forever" invariant is exactly as strong as before.

A busy TRUNCATE now says so in the log instead of reading like a success.

Also softened the adjacent path: when the worker is declared
unrecoverable, engageFallback() re-arms inline autocheckpoint on the main
connection — a state that is STICKY for the life of the process, i.e.
exactly the shape of "degrades with uptime, a restart fixes it". It used
to also run an unconditional main-thread TRUNCATE on the way in; that now
happens only when the WAL is genuinely over high-water, and the fallback
state is served on /api/status rather than being inferable only from a log
line that may have rolled.

Telemetry, so the next report is self-explanatory: loop_lag carries
`samples` (~50 when healthy, 1 when a single turn swallowed the second),
`tick_gap_ms` measured on the WALL CLOCK independently of the histogram,
and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling
can no longer miss an episode.

Band semantics are deliberately unchanged. A one-sample window during a
real stall is the correct trigger for the shed valve; suppressing it would
blind the protection at exactly the moment it is needed.

Separately, device_telemetry gets the age sweep it never had. The
per-heartbeat row cap only ever trims the device whose heartbeat is being
handled, so a device that STOPS reporting leaves its rows behind forever.
The new sweep is per-device (rides idx_telemetry_device rather than
scanning), chunked and yielding like the device_status_log one, and
defaults to 30 days to match the uptime report's own default window — so
it cannot remove rows that report would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 20:22:21 -05:00

184 lines
10 KiB
JavaScript

// #142 — Event-loop lag telemetry (the data subsystem; ships before the throttle).
//
// Continuously samples event-loop delay via perf_hooks.monitorEventLoopDelay()
// (a C++-backed histogram — cheap). Each window we read mean/p50/p99/max, persist
// a row to the bounded `event_loop_lag` table, and recompute a coarse load BAND
// (normal | elevated | critical) from the window p99.
//
// The band is consumed by the reconnect throttle (#142 step 3), but this module
// has standalone value: getLag() is surfaced on /api/status and band changes are
// logged, so site connectivity/lag is diagnosable independent of any throttling.
//
// Band transitions are deliberately asymmetric (see nextBand): jump UP immediately
// when an up-threshold is crossed (tighten fast), step DOWN only one level at a
// time after lagReleaseSamples consecutive calm samples below a deadband (release
// slow). This avoids band flap from transient blips.
const { monitorEventLoopDelay } = require('perf_hooks');
const { db } = require('../db/database');
const config = require('../config');
const { chunkedDelete } = require('../lib/chunked-prune'); // #146 Item E: chunked lag prune
const logCoalescer = require('../lib/log-coalescer'); // #146 Item E: coalesced band lines
const NS_PER_MS = 1e6;
// A band releases only once p99 falls below this fraction of the band's entry
// threshold — the deadband that stops small fluctuations from flapping the band.
const DEADBAND = 0.5;
const LEVEL = { normal: 0, elevated: 1, critical: 2 };
let histogram = null;
let band = 'normal';
let calmSamples = 0;
// #240: `samples` and the tick-gap fields exist to make ONE window's numbers
// interpretable. An IntervalHistogram window that recorded a single delay reports
// mean = p50 = p99 = max (the mean is the raw value, the percentiles are the bucket
// ceiling above it) — indistinguishable, from the numbers alone, from a fixed cost
// paid on every cycle. It is the opposite: one long loop turn. `samples` is the
// histogram's record count for the window (~50 at a 20ms resolution when healthy,
// 1 when a single turn swallowed the whole second), and tick_gap_ms is the
// WALL-CLOCK gap between consecutive sampler runs — ground truth for whether the
// loop is actually late, measured independently of the histogram.
let current = {
mean_ms: 0, p50_ms: 0, p99_ms: 0, max_ms: 0, samples: 0,
tick_gap_ms: 0, worst_tick_gap_ms: 0, worst_tick_at: 0,
band: 'normal', sampled_at: 0,
};
let lastSampleAt = 0; // wall clock of the previous sample() run
let worstTickGapMs = 0; // largest gap seen since process start...
let worstTickAt = 0; // ...and when (epoch seconds). Survives coarse polling.
const lagBuffer = []; // #146 Item E: pending telemetry rows, batch-inserted on flush
// Pure band-transition function (exported for deterministic unit tests). Given the
// current band, the window p99 (ms), and the running calm-sample count, returns the
// next [band, calmSamples]. Up is immediate (may skip a level); down is one step
// per release window, gated by a deadband.
function nextBand(cur, p99, calm) {
const level = LEVEL[cur] ?? 0;
// UP — immediate, tighten fast (normal can jump straight to critical).
if (p99 >= config.lagCriticalMs && level < LEVEL.critical) return ['critical', 0];
if (p99 >= config.lagElevatedMs && level < LEVEL.elevated) return ['elevated', 0];
// DOWN — slow, one step, only below the current band's deadband.
if (level === LEVEL.critical && p99 <= config.lagCriticalMs * DEADBAND) {
const c = calm + 1;
return c >= config.lagReleaseSamples ? ['elevated', 0] : ['critical', c];
}
if (level === LEVEL.elevated && p99 <= config.lagElevatedMs * DEADBAND) {
const c = calm + 1;
return c >= config.lagReleaseSamples ? ['normal', 0] : ['elevated', c];
}
// Hold (inside deadband, or already normal): reset the calm counter.
return [cur, 0];
}
// A sampling window that recorded NOTHING leaves the histogram empty, and an empty
// IntervalHistogram reports `mean` as NaN (its percentiles return a floor instead, which is
// why only the mean was ever affected). NaN survives every arithmetic step here and only
// becomes visible at the edge, where JSON.stringify turns it into `null` — so /api/status
// served `mean_ms: null` to anything reading it, and no error was raised anywhere. Zero is the
// honest value: no samples means no measured delay. Applied to every field so a future change
// to the histogram source cannot reintroduce this one field at a time.
const round2 = (x) => Math.round(x * 100) / 100;
const metric = (x) => (Number.isFinite(x) ? round2(x) : 0);
function sample() {
// #240: measure the sampler's OWN lateness first. This interval is armed for
// lagSampleIntervalMs, so any excess is loop delay that the histogram cannot
// misreport — if the histogram claims seconds of lag while this stays at the
// interval, the block did not happen where the histogram says it did.
const nowMs = Date.now();
const tickGap = lastSampleAt ? nowMs - lastSampleAt : config.lagSampleIntervalMs;
lastSampleAt = nowMs;
if (tickGap > worstTickGapMs) { worstTickGapMs = tickGap; worstTickAt = Math.floor(nowMs / 1000); }
const p99 = histogram.percentile(99) / NS_PER_MS;
const snap = {
mean_ms: metric(histogram.mean / NS_PER_MS),
p50_ms: metric(histogram.percentile(50) / NS_PER_MS),
p99_ms: metric(p99),
max_ms: metric(histogram.max / NS_PER_MS),
samples: histogram.count, // MUST be read before reset()
};
histogram.reset();
const prev = band;
[band, calmSamples] = nextBand(band, snap.p99_ms, calmSamples);
current = {
...snap,
tick_gap_ms: tickGap,
worst_tick_gap_ms: worstTickGapMs,
worst_tick_at: worstTickAt,
band,
sampled_at: Math.floor(nowMs / 1000),
};
// #146 Item E: BUFFER the telemetry row (batch-inserted on the flush interval) instead
// of a synchronous INSERT per sample — under DB contention (a bloated table slowing
// writes) a per-sample INSERT is itself a per-tick loop cost. Bounded: drop the oldest
// if the buffer overflows (never let telemetry grow unbounded and cook the loop).
lagBuffer.push({ ...snap, sampled_at: current.sampled_at, band });
if (lagBuffer.length > config.lagBufferMax) lagBuffer.splice(0, lagBuffer.length - config.lagBufferMax);
// Observable: a band CHANGE logs immediately; a repeated "still at band X" line is
// COALESCED (one summarized line per flush) so a sustained-critical storm can't turn
// logging into its own loop hog. Healthy steady state stays quiet.
if (band !== prev) {
// #240: samples + tick gap ride along on the band line — without them a one-sample
// window reads as a permanent per-cycle cost to whoever finds this in the logs.
console.log(`[loop-lag] band=${band} (was ${prev}) mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms samples=${snap.samples} tick_gap=${tickGap}ms`);
} else if (band !== 'normal') {
// #146 P3.7: coalesce repeats and carry the PEAK p99 over the window (not a random
// sample's) — the peak is the number that matters during an incident.
logCoalescer.record(`loop-lag:${band}`, `[loop-lag] band=${band}`, { peak: snap.p99_ms, peakUnit: 'ms' });
}
// #143 global pressure valve — log ONLY the band edge (open/close), not per shed
// message. When critical, deviceSocket sheds non-essential acks (it reads getBand()).
if (band === 'critical' && prev !== 'critical') {
console.warn(`[shed] global valve OPEN — loop-lag critical (p99=${snap.p99_ms}ms); shedding non-essential device messages (content-acks). reconnects + dashboard still processed.`);
} else if (prev === 'critical' && band !== 'critical') {
console.log(`[shed] global valve CLOSED — loop-lag recovered (band=${band}, p99=${snap.p99_ms}ms)`);
}
}
// #146 Item E: flush buffered telemetry rows in ONE batched transaction.
const _insLag = db.prepare('INSERT INTO event_loop_lag (sampled_at, mean_ms, p50_ms, p99_ms, max_ms, band) VALUES (?, ?, ?, ?, ?, ?)');
function flushLag() {
if (!lagBuffer.length) return;
const rows = lagBuffer.splice(0);
try {
db.transaction((rs) => { for (const r of rs) _insLag.run(r.sampled_at, r.mean_ms, r.p50_ms, r.p99_ms, r.max_ms, r.band); })(rows);
} catch (_) { /* table may not exist on a partially-migrated DB — drop the batch */ }
}
// #146 Item E: chunked prune (rides idx_event_loop_lag_sampled) so this table can never
// repeat the status_log bloat-then-freeze. Async; callers fire-and-forget.
const _delLag = db.prepare('DELETE FROM event_loop_lag WHERE rowid IN (SELECT rowid FROM event_loop_lag WHERE sampled_at < ? LIMIT ?)');
async function pruneLag() {
try {
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.lagTelemetryRetentionDays * 86400);
const { deleted } = await chunkedDelete((lim) => _delLag.run(cutoff, lim).changes, { batch: config.statusLogPruneBatch });
if (deleted > 0) console.log(`[loop-lag] pruned ${deleted} sample(s) older than ${config.lagTelemetryRetentionDays}d`);
} catch (_) { /* ignore */ }
}
function startLoopLagMonitor() {
if (histogram) return; // idempotent
histogram = monitorEventLoopDelay({ resolution: config.lagResolutionMs });
histogram.enable();
logCoalescer.start(config.logCoalesceFlushMs); // #146 Item E: start the coalesced-log flusher
const t1 = setInterval(sample, config.lagSampleIntervalMs);
const t3 = setInterval(flushLag, config.lagFlushMs); // #146 Item E: batch-insert buffered telemetry
pruneLag().catch(() => {}); // sweep stale rows on boot (chunked, async)
const t2 = setInterval(() => pruneLag().catch(() => {}), config.lagPruneIntervalMs);
// Don't keep the process alive on these timers (matters for tests / clean exit).
for (const t of [t1, t2, t3]) if (t.unref) t.unref();
}
function getBand() { return band; }
function getLag() { return { ...current }; }
module.exports = { startLoopLagMonitor, getBand, getLag, nextBand };
// Exported for tests: the NaN-from-an-empty-window case is invisible in normal operation
// (it only surfaces after JSON serialisation) so it needs to be assertable directly.
module.exports._metric = metric;