mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Don't let telemetry/logging cook the loop under a storm.
- lib/log-coalescer.js: dedup+count high-frequency lines, flush ONE summarized line per
key per window ("[loop-lag] band=critical (x47 in 30s)"). Bounded buffer (auto-flush
at MAX_KEYS). Applied to the loop-lag "still loaded" line (band CHANGES stay immediate),
the per-request OTA check line, and "Device reconnected".
- loop-lag: event_loop_lag rows are BUFFERED and batch-inserted on a flush interval
(was a synchronous INSERT per sample); the buffer is bounded (drop-oldest). Its
retention prune now rides the Item-A chunkedDelete so this table can never repeat the
status_log bloat-then-freeze. /api/status still reads in-memory current (real-time
band unaffected).
- Bounded the previously un-evicted per-device Maps: content-ack limiter gets an idle
sweep (started in server.js); status-log-writer.lastWritten is capped (drop-oldest;
it only suppresses a redundant consecutive row, so eviction is safe).
Tests: N identical lines -> one counted line; single line verbatim; coalescer buffer
bounded under a distinct-key flood; content-ack Map swept of idle buckets.
loop-lag-integration updated for the batched-insert cadence. Suite 266/266.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.7 KiB
JavaScript
88 lines
3.7 KiB
JavaScript
// #146 — batched, coalescing writer for device_status_log.
|
|
//
|
|
// Before: every device status transition (online/offline/offline_timeout) did its
|
|
// own synchronous INSERT on the hot path (deviceSocket.logDeviceStatus + the
|
|
// heartbeat checker). Under a reconnect storm that is one row PER flap, which both
|
|
// (a) bloats the table — it reached 1.1M rows in prod — and (b) makes each write
|
|
// slower as the table grows, lagging status processing further. A textbook feedback
|
|
// loop, the connection-layer twin of the OTA loop #144 contained.
|
|
//
|
|
// After: transitions are buffered in memory and flushed on an interval. The buffer
|
|
// keeps only the LATEST (net) status per device, so a device that flaps
|
|
// online->offline->online within a flush window collapses to at most one row — and
|
|
// if it ends where it started, zero rows. devices.status (the dashboard's source of
|
|
// truth) is still updated immediately by the callers; only the AUDIT log is batched,
|
|
// so coalescing storm noise loses nothing the uptime view needs.
|
|
//
|
|
// State is in-memory and resets on restart (like the throttle / breaker buckets).
|
|
|
|
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
|
|
const pending = new Map(); // deviceId -> latest desired status (net state)
|
|
const lastWritten = new Map(); // deviceId -> last status actually inserted
|
|
let timer = null;
|
|
|
|
const insertStmt = () => db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)');
|
|
// Per-device age prune — the #146 fix for the old hardcoded 7-day window in
|
|
// deviceSocket.js (now a single source of truth: config.statusLogRetentionDays).
|
|
const pruneDeviceStmt = () =>
|
|
db.prepare("DELETE FROM device_status_log WHERE device_id = ? AND timestamp < strftime('%s','now') - ?");
|
|
|
|
// Record a transition. Cheap and allocation-light: just remembers the latest state.
|
|
function record(deviceId, status) {
|
|
if (!deviceId || !status) return;
|
|
pending.set(deviceId, status);
|
|
}
|
|
|
|
// Write all buffered transitions whose net state differs from what's on disk.
|
|
// Returns the number of rows actually inserted (for tests/observability).
|
|
function flush() {
|
|
if (pending.size === 0) return 0;
|
|
const batch = [];
|
|
for (const [deviceId, status] of pending) {
|
|
if (lastWritten.get(deviceId) !== status) batch.push([deviceId, status]);
|
|
}
|
|
pending.clear();
|
|
if (batch.length === 0) return 0;
|
|
|
|
try {
|
|
const ins = insertStmt();
|
|
const prune = pruneDeviceStmt();
|
|
const ageSec = Math.round(config.statusLogRetentionDays * 86400);
|
|
const writeAll = db.transaction((rows) => {
|
|
for (const [deviceId, status] of rows) {
|
|
ins.run(deviceId, status);
|
|
lastWritten.set(deviceId, status);
|
|
prune.run(deviceId, ageSec);
|
|
}
|
|
});
|
|
writeAll(batch);
|
|
// #146 Item E: bound lastWritten so it can't grow unbounded over churned device_ids.
|
|
// It only suppresses a redundant consecutive same-status row, so evicting the oldest
|
|
// entries is safe (worst case: one extra row later). Keep it to the newest ~5k ids.
|
|
if (lastWritten.size > 5000) {
|
|
const excess = lastWritten.size - 5000;
|
|
let i = 0;
|
|
for (const k of lastWritten.keys()) { if (i++ >= excess) break; lastWritten.delete(k); }
|
|
}
|
|
return batch.length;
|
|
} catch (_) {
|
|
// table might not exist yet (early boot) — drop silently, same as the old path
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
if (timer) return timer;
|
|
timer = setInterval(flush, config.statusLogFlushMs);
|
|
if (timer.unref) timer.unref(); // don't keep the process alive on the flush timer
|
|
return timer;
|
|
}
|
|
|
|
// Test-only: force a synchronous flush and clear coalescing memory.
|
|
function flushNow() { return flush(); }
|
|
function __reset() { pending.clear(); lastWritten.clear(); }
|
|
|
|
module.exports = { record, flush, flushNow, start, __reset };
|