screentinker/server/test/status-log-prune.test.js
ScreenTinker 2f7133f8c8 fix(#146) A: non-blocking maintenance — chunked+yield+band-gate all sweeps
The death-spiral amplifier: pruneStatusLog ran a whole-table ROW_NUMBER() sort,
40-48s synchronous on the 1.1M-row incident table, freezing boot -> healthcheck
fail -> restart loop.

- lib/chunked-prune.js: shared chunkedDelete (rowid IN (SELECT ... LIMIT ?) since
  better-sqlite3 has no DELETE...LIMIT) — bounded batch + setImmediate yield between
  batches, optional band-gate. Core invariant: no sync op blocks >~50ms ever.
- pruneStatusLog: rewritten per-device via a loose index-scan seek
  (WHERE device_id > ? ORDER BY device_id LIMIT 1 — O(log n) each), retention +
  newest-cap trimmed in bounded batches, async, re-entrancy-guarded, band-gated on
  the interval / un-gated + fire-and-forget at startup so a bloated table self-heals
  on deploy WITHOUT freezing boot.
- heartbeat.js: maintenance moved off the interval body into async band-gated
  re-entrant runMaintenance(); play_logs + provisioning prunes chunked; offline-marking
  stays synchronous.
- pruneTelemetry: bounded single statement (OFFSET 6000 LIMIT batch), stays sync.
- idx_devices_provisioning so the provisioning prune batch subquery is an index range.

Tests: correctness (per-device cap + retention, independent devices), 300k-row backlog
trims in many batches with max event-loop gap <250ms, band-gate no-op while critical +
startup runs regardless, re-entrancy (concurrent -> once). Existing prune tests updated
to await. Suite 247/247.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:55:05 -05:00

49 lines
2.5 KiB
JavaScript

'use strict';
// #142 step 4 — global device_status_log retention sweep. Deterministic, in-process
// (no server/port). Isolate the DB and set retention BEFORE requiring the module
// (config reads env at load; database.js initialises a DB on load).
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-statusprune-' + crypto.randomBytes(4).toString('hex'));
process.env.STATUS_LOG_RETENTION_DAYS = '2';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { db, pruneStatusLog } = require('../db/database');
test('global sweep deletes rows older than retention across ALL devices, keeps recent', async () => {
db.exec('DELETE FROM device_status_log'); // clean slate
const old = db.prepare("INSERT INTO device_status_log (device_id, status, timestamp) VALUES (?, ?, strftime('%s','now') - ?)");
// 5 days old (> 2d retention): an active device, a device NOT in the devices
// table (removed/idle — what the per-device insert-time prune never revisits),
// and the heartbeat offline_timeout status that bypasses logDeviceStatus.
old.run('live-dev', 'online', 5 * 86400);
old.run('removed-idle-dev', 'offline', 5 * 86400);
old.run('hb-dev', 'offline_timeout', 5 * 86400);
// recent (< retention): must survive, regardless of device existence / status.
old.run('live-dev', 'online', 0);
old.run('hb-dev', 'offline_timeout', 3600);
assert.equal(db.prepare('SELECT COUNT(*) c FROM device_status_log').get().c, 5, 'seeded 5 rows');
const deleted = await pruneStatusLog();
assert.equal(deleted, 3, 'the 3 over-retention rows pruned (incl. removed-idle + offline_timeout paths)');
const remaining = db.prepare('SELECT device_id, status FROM device_status_log ORDER BY device_id').all();
assert.equal(remaining.length, 2);
// both survivors are the recent rows; no old row of any device/status survived
assert.deepEqual(remaining.map(r => r.device_id).sort(), ['hb-dev', 'live-dev']);
const oldestNow = db.prepare("SELECT MIN(timestamp) m FROM device_status_log").get().m;
const cutoff = Math.floor(Date.now() / 1000) - 2 * 86400;
assert.ok(oldestNow >= cutoff, 'no surviving row is older than the retention cutoff');
});
test('sweep is safe and idempotent on an empty/already-clean table', async () => {
db.exec('DELETE FROM device_status_log');
assert.equal(await pruneStatusLog(), 0, 'nothing to delete -> 0, no throw');
});