mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
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>
This commit is contained in:
parent
97d489223f
commit
4bda49cf60
|
|
@ -184,6 +184,10 @@ module.exports = {
|
||||||
otaDownloadMaxPerWindow: parseInt(process.env.OTA_DOWNLOAD_MAX_PER_WINDOW) || 120,
|
otaDownloadMaxPerWindow: parseInt(process.env.OTA_DOWNLOAD_MAX_PER_WINDOW) || 120,
|
||||||
otaDownloadWindowMs: parseInt(process.env.OTA_DOWNLOAD_WINDOW_MS) || 60000,
|
otaDownloadWindowMs: parseInt(process.env.OTA_DOWNLOAD_WINDOW_MS) || 60000,
|
||||||
otaApkRefreshMs: parseInt(process.env.OTA_APK_REFRESH_MS) || 60000,
|
otaApkRefreshMs: parseInt(process.env.OTA_APK_REFRESH_MS) || 60000,
|
||||||
|
// #146 Item E — coalescing log flush + batched event_loop_lag telemetry.
|
||||||
|
logCoalesceFlushMs: parseInt(process.env.LOG_COALESCE_FLUSH_MS) || 30000,
|
||||||
|
lagFlushMs: parseInt(process.env.LAG_FLUSH_MS) || 10000,
|
||||||
|
lagBufferMax: parseInt(process.env.LAG_BUFFER_MAX) || 2000,
|
||||||
// #146 device_status_log write batching (lib/status-log-writer.js). Status
|
// #146 device_status_log write batching (lib/status-log-writer.js). Status
|
||||||
// transitions are buffered and coalesced to the NET state per device per flush,
|
// transitions are buffered and coalesced to the NET state per device per flush,
|
||||||
// so a flapping device writes ~1 row/flush instead of a row per transition —
|
// so a flapping device writes ~1 row/flush instead of a row per transition —
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,22 @@ function check(deviceId, contentId, status, band = 'normal', now = Date.now()) {
|
||||||
return { action: 'pass' };
|
return { action: 'pass' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #146 Item E: evict idle per-device buckets so this Map can't grow unbounded over
|
||||||
|
// churned device_ids (a SNAT flood minting provisioning ids inflated every un-swept
|
||||||
|
// per-device Map). Keyed by winStart age.
|
||||||
|
function sweep(now = Date.now()) {
|
||||||
|
let n = 0;
|
||||||
|
const idle = config.contentAckRateWindowMs * 4;
|
||||||
|
for (const [k, s] of state) if (now - s.winStart > idle) { state.delete(k); n++; }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
let sweepTimer = null;
|
||||||
|
function startSweep() {
|
||||||
|
if (sweepTimer) return sweepTimer;
|
||||||
|
sweepTimer = setInterval(() => sweep(), config.contentAckRateWindowMs * 4);
|
||||||
|
if (sweepTimer.unref) sweepTimer.unref();
|
||||||
|
return sweepTimer;
|
||||||
|
}
|
||||||
|
|
||||||
function reset() { state.clear(); } // tests
|
function reset() { state.clear(); } // tests
|
||||||
module.exports = { check, reset };
|
module.exports = { check, reset, sweep, startSweep, _size: () => state.size };
|
||||||
|
|
|
||||||
44
server/lib/log-coalescer.js
Normal file
44
server/lib/log-coalescer.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
'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 };
|
||||||
|
|
@ -58,6 +58,14 @@ function flush() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
writeAll(batch);
|
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;
|
return batch.length;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// table might not exist yet (early boot) — drop silently, same as the old path
|
// table might not exist yet (early boot) — drop silently, same as the old path
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,7 @@ const otaBreaker = require('./lib/ota-breaker');
|
||||||
otaBreaker.startSweep(); // #144: periodically evict idle breaker buckets so keyed state stays bounded
|
otaBreaker.startSweep(); // #144: periodically evict idle breaker buckets so keyed state stays bounded
|
||||||
require('./lib/reconnect-throttle').startSweep(); // #146: same, for the reconnect throttle's per-device buckets
|
require('./lib/reconnect-throttle').startSweep(); // #146: same, for the reconnect throttle's per-device buckets
|
||||||
require('./lib/flap-limiter').startSweep(); // #146 Item B: evict idle flap-limiter buckets
|
require('./lib/flap-limiter').startSweep(); // #146 Item B: evict idle flap-limiter buckets
|
||||||
|
require('./lib/content-ack-limiter').startSweep(); // #146 Item E: evict idle content-ack buckets
|
||||||
const apkCache = require('./lib/apk-cache');
|
const apkCache = require('./lib/apk-cache');
|
||||||
apkCache.start(); // #146 Item C: resolve APK path/size/mtime once + refresh on interval (no per-request fs)
|
apkCache.start(); // #146 Item C: resolve APK path/size/mtime once + refresh on interval (no per-request fs)
|
||||||
const { getBand } = require('./services/loop-lag'); // #146 Item C: critical-band download shed
|
const { getBand } = require('./services/loop-lag'); // #146 Item C: critical-band download shed
|
||||||
|
|
@ -725,11 +726,12 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #146 Item C: OTA update-check log. One line per check today; Item E coalesces the
|
// #146 Item C/E: OTA update-check log — COALESCED (one summarized line per reason per
|
||||||
// high-frequency lines. Never keys on IP for any decision (SNAT). Kept as a helper so
|
// window) so a poll flood can't turn synchronous stdout writes into a loop hog. Never
|
||||||
// both the offer and no-offer paths log consistently.
|
// keys on IP for any decision (SNAT).
|
||||||
|
const logCoalescer = require('./lib/log-coalescer');
|
||||||
function logOtaCheck(deviceId, client, latest, available, reason) {
|
function logOtaCheck(deviceId, client, latest, available, reason) {
|
||||||
console.log(`[ota] update check: device=${deviceId || 'none'} client=${client || 'unknown'} latest=${latest} update_available=${available} reason=${reason}`);
|
logCoalescer.record(`ota-check:${reason}:${available}`, `[ota] update check: latest=${latest} update_available=${available} reason=${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #146 Item C: GLOBAL download admission (lib/ota-download-guard) — concurrency + rate
|
// #146 Item C: GLOBAL download admission (lib/ota-download-guard) — concurrency + rate
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const config = require('../config');
|
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;
|
const NS_PER_MS = 1e6;
|
||||||
// A band releases only once p99 falls below this fraction of the band's entry
|
// A band releases only once p99 falls below this fraction of the band's entry
|
||||||
|
|
@ -28,6 +30,7 @@ let histogram = null;
|
||||||
let band = 'normal';
|
let band = 'normal';
|
||||||
let calmSamples = 0;
|
let calmSamples = 0;
|
||||||
let current = { mean_ms: 0, p50_ms: 0, p99_ms: 0, max_ms: 0, band: 'normal', sampled_at: 0 };
|
let current = { mean_ms: 0, p50_ms: 0, p99_ms: 0, max_ms: 0, band: 'normal', sampled_at: 0 };
|
||||||
|
const lagBuffer = []; // #146 Item E: pending telemetry rows, batch-inserted on flush
|
||||||
|
|
||||||
// Pure band-transition function (exported for deterministic unit tests). Given the
|
// 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
|
// current band, the window p99 (ms), and the running calm-sample count, returns the
|
||||||
|
|
@ -67,17 +70,20 @@ function sample() {
|
||||||
[band, calmSamples] = nextBand(band, snap.p99_ms, calmSamples);
|
[band, calmSamples] = nextBand(band, snap.p99_ms, calmSamples);
|
||||||
current = { ...snap, band, sampled_at: Math.floor(Date.now() / 1000) };
|
current = { ...snap, band, sampled_at: Math.floor(Date.now() / 1000) };
|
||||||
|
|
||||||
try {
|
// #146 Item E: BUFFER the telemetry row (batch-inserted on the flush interval) instead
|
||||||
db.prepare(
|
// of a synchronous INSERT per sample — under DB contention (a bloated table slowing
|
||||||
'INSERT INTO event_loop_lag (sampled_at, mean_ms, p50_ms, p99_ms, max_ms, band) VALUES (?, ?, ?, ?, ?, ?)'
|
// writes) a per-sample INSERT is itself a per-tick loop cost. Bounded: drop the oldest
|
||||||
).run(current.sampled_at, snap.mean_ms, snap.p50_ms, snap.p99_ms, snap.max_ms, band);
|
// if the buffer overflows (never let telemetry grow unbounded and cook the loop).
|
||||||
} catch (_) { /* table may not exist on a partially-migrated DB */ }
|
lagBuffer.push({ ...snap, sampled_at: current.sampled_at, band });
|
||||||
|
if (lagBuffer.length > config.lagBufferMax) lagBuffer.splice(0, lagBuffer.length - config.lagBufferMax);
|
||||||
|
|
||||||
// Observable: log whenever we're loaded or when the band changes (incl. back to
|
// Observable: a band CHANGE logs immediately; a repeated "still at band X" line is
|
||||||
// normal). Healthy steady state stays quiet.
|
// COALESCED (one summarized line per flush) so a sustained-critical storm can't turn
|
||||||
if (band !== 'normal' || prev !== 'normal') {
|
// logging into its own loop hog. Healthy steady state stays quiet.
|
||||||
const tag = band !== prev ? ` (was ${prev})` : '';
|
if (band !== prev) {
|
||||||
console.log(`[loop-lag] band=${band}${tag} mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
console.log(`[loop-lag] band=${band} (was ${prev}) mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
||||||
|
} else if (band !== 'normal') {
|
||||||
|
logCoalescer.record(`loop-lag:${band}`, `[loop-lag] band=${band} p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #143 global pressure valve — log ONLY the band edge (open/close), not per shed
|
// #143 global pressure valve — log ONLY the band edge (open/close), not per shed
|
||||||
|
|
@ -89,11 +95,24 @@ function sample() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneLag() {
|
// #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 {
|
try {
|
||||||
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.lagTelemetryRetentionDays * 86400);
|
const cutoff = Math.floor(Date.now() / 1000) - Math.round(config.lagTelemetryRetentionDays * 86400);
|
||||||
const n = db.prepare('DELETE FROM event_loop_lag WHERE sampled_at < ?').run(cutoff).changes;
|
const { deleted } = await chunkedDelete((lim) => _delLag.run(cutoff, lim).changes, { batch: config.statusLogPruneBatch });
|
||||||
if (n > 0) console.log(`[loop-lag] pruned ${n} sample(s) older than ${config.lagTelemetryRetentionDays}d`);
|
if (deleted > 0) console.log(`[loop-lag] pruned ${deleted} sample(s) older than ${config.lagTelemetryRetentionDays}d`);
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,12 +120,13 @@ function startLoopLagMonitor() {
|
||||||
if (histogram) return; // idempotent
|
if (histogram) return; // idempotent
|
||||||
histogram = monitorEventLoopDelay({ resolution: config.lagResolutionMs });
|
histogram = monitorEventLoopDelay({ resolution: config.lagResolutionMs });
|
||||||
histogram.enable();
|
histogram.enable();
|
||||||
|
logCoalescer.start(config.logCoalesceFlushMs); // #146 Item E: start the coalesced-log flusher
|
||||||
const t1 = setInterval(sample, config.lagSampleIntervalMs);
|
const t1 = setInterval(sample, config.lagSampleIntervalMs);
|
||||||
pruneLag(); // sweep stale rows on boot
|
const t3 = setInterval(flushLag, config.lagFlushMs); // #146 Item E: batch-insert buffered telemetry
|
||||||
const t2 = setInterval(pruneLag, config.lagPruneIntervalMs);
|
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).
|
// Don't keep the process alive on these timers (matters for tests / clean exit).
|
||||||
if (t1.unref) t1.unref();
|
for (const t of [t1, t2, t3]) if (t.unref) t.unref();
|
||||||
if (t2.unref) t2.unref();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBand() { return band; }
|
function getBand() { return band; }
|
||||||
|
|
|
||||||
57
server/test/log-selfprotect.test.js
Normal file
57
server/test/log-selfprotect.test.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #146 hardening (Item E) — log/write self-protection. The coalescer collapses N
|
||||||
|
// identical high-frequency lines into ONE summarized line, and its buffer is bounded.
|
||||||
|
// The content-ack limiter's per-device Map is now swept (no unbounded growth).
|
||||||
|
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-logself-' + crypto.randomBytes(4).toString('hex'));
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const coalescer = require('../lib/log-coalescer');
|
||||||
|
const contentAck = require('../lib/content-ack-limiter');
|
||||||
|
|
||||||
|
function capture(fn) {
|
||||||
|
const lines = [];
|
||||||
|
const rl = console.log, rw = console.warn;
|
||||||
|
console.log = (...a) => lines.push(a.join(' '));
|
||||||
|
console.warn = (...a) => lines.push(a.join(' '));
|
||||||
|
try { fn(); } finally { console.log = rl; console.warn = rw; }
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('N identical lines in a window emit ONE summarized line with the count', () => {
|
||||||
|
coalescer.reset();
|
||||||
|
for (let i = 0; i < 47; i++) coalescer.record('loop-lag:critical', '[loop-lag] band=critical p99=1502ms');
|
||||||
|
const out = capture(() => coalescer.flush());
|
||||||
|
assert.equal(out.length, 1, 'coalesced to a single line');
|
||||||
|
assert.match(out[0], /x47/, 'shows the count');
|
||||||
|
assert.match(out[0], /band=critical/, 'keeps the sample text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single occurrence logs verbatim (no count suffix)', () => {
|
||||||
|
coalescer.reset();
|
||||||
|
coalescer.record('k', 'one-off line');
|
||||||
|
const out = capture(() => coalescer.flush());
|
||||||
|
assert.deepEqual(out, ['one-off line']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buffer stays BOUNDED under a flood of distinct keys (auto-flush at cap)', () => {
|
||||||
|
coalescer.reset();
|
||||||
|
capture(() => { for (let i = 0; i < 5000; i++) coalescer.record('key-' + i, 'line ' + i); });
|
||||||
|
assert.ok(coalescer._size() <= 500, `buffer bounded (<=500), was ${coalescer._size()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('content-ack limiter Map is swept of idle buckets (no unbounded growth)', () => {
|
||||||
|
contentAck.reset();
|
||||||
|
const now = 0;
|
||||||
|
contentAck.check('dev-a', 'c1', 'ready', 'normal', now);
|
||||||
|
contentAck.check('dev-b', 'c1', 'ready', 'normal', now);
|
||||||
|
assert.ok(contentAck._size() >= 2);
|
||||||
|
const swept = contentAck.sweep(10 * 300000); // far past the idle window
|
||||||
|
assert.ok(swept >= 2, 'idle buckets evicted');
|
||||||
|
assert.equal(contentAck._size(), 0, 'map drained');
|
||||||
|
});
|
||||||
|
|
@ -26,6 +26,7 @@ before(async () => {
|
||||||
env: {
|
env: {
|
||||||
...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test',
|
...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test',
|
||||||
LAG_SAMPLE_INTERVAL_MS: '200', // sample fast
|
LAG_SAMPLE_INTERVAL_MS: '200', // sample fast
|
||||||
|
LAG_FLUSH_MS: '200', // #146 Item E: batch-insert fast so persistence is observable in-test
|
||||||
LAG_TELEMETRY_RETENTION_DAYS: '0.00001', // ~0.86s retention
|
LAG_TELEMETRY_RETENTION_DAYS: '0.00001', // ~0.86s retention
|
||||||
LAG_PRUNE_INTERVAL_MS: '400', // prune often
|
LAG_PRUNE_INTERVAL_MS: '400', // prune often
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const statusLogWriter = require('../lib/status-log-writer');
|
||||||
const { protectSocket } = require('../lib/safe-socket');
|
const { protectSocket } = require('../lib/safe-socket');
|
||||||
const flapLimiter = require('../lib/flap-limiter');
|
const flapLimiter = require('../lib/flap-limiter');
|
||||||
const { resolveIdentity } = require('../lib/device-identity');
|
const { resolveIdentity } = require('../lib/device-identity');
|
||||||
|
const logCoalescer = require('../lib/log-coalescer');
|
||||||
const loopLag = require('../services/loop-lag');
|
const loopLag = require('../services/loop-lag');
|
||||||
|
|
||||||
// Debounce window for marking a device offline on socket disconnect. Brief
|
// Debounce window for marking a device offline on socket disconnect. Brief
|
||||||
|
|
@ -554,7 +555,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' });
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', { device_id, status: 'online' });
|
||||||
// Only log a genuine reconnect (new socket). Same-socket periodic refreshes stay
|
// Only log a genuine reconnect (new socket). Same-socket periodic refreshes stay
|
||||||
// quiet so the log reflects real connection events, not the 45s refresh cadence.
|
// quiet so the log reflects real connection events, not the 45s refresh cadence.
|
||||||
if (!isPlaylistRefresh) console.log(`Device reconnected: ${device_id}`);
|
if (!isPlaylistRefresh) logCoalescer.record('device-reconnected', `Device reconnected: ${device_id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue