screentinker/server/lib/log-coalescer.js
ScreenTinker 4bda49cf60 fix(#146) E: log/write self-protection — coalesced logs, batched telemetry, bounded maps
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>
2026-06-30 21:34:01 -05:00

45 lines
1.6 KiB
JavaScript

'use strict';
// #146 Item E — coalescing log buffer. Under a storm, high-frequency lines (loop-lag
// band, per-request OTA checks, repetitive "Device reconnected") turn console.log —
// which is a SYNCHRONOUS stdout write — into its own event-loop hog. This dedups by key
// + counts, flushing ONE summarized line per key per interval:
// `[loop-lag] band=critical (x47 in 30s)`
// Trading a little bounded RAM for loop safety is explicitly desired. The buffer is
// BOUNDED (MAX_KEYS): if it fills, we flush immediately rather than grow.
const MAX_KEYS = 500;
// key -> { count, sample (the latest full line), warn }
const buf = new Map();
let flushMs = 30000;
let timer = null;
// record(key, line, {warn}) — `key` collapses repeats; `line` is the human text to emit.
function record(key, line, opts = {}) {
let e = buf.get(key);
if (!e) {
if (buf.size >= MAX_KEYS) flush(); // bounded: never grow past MAX_KEYS
e = { count: 0, sample: line, warn: !!opts.warn };
buf.set(key, e);
}
e.count += 1;
e.sample = line; // keep the most recent detail
e.warn = e.warn || !!opts.warn;
}
function flush() {
for (const [, e] of buf) {
const line = e.count > 1 ? `${e.sample} (x${e.count} in ${Math.round(flushMs / 1000)}s)` : e.sample;
(e.warn ? console.warn : console.log)(line);
}
buf.clear();
}
function start(ms) {
if (ms) flushMs = ms;
if (!timer) { timer = setInterval(flush, flushMs); if (timer.unref) timer.unref(); }
}
function reset() { buf.clear(); } // tests
module.exports = { record, flush, start, reset, _size: () => buf.size };