screentinker/server/services/heartbeat.js
ScreenTinker 29a8896aa8 fix(#142): global device_status_log retention sweep + STATUS_LOG_RETENTION_DAYS
The per-device insert-time prune (deviceSocket.js) only ever touches a device
that is actively inserting, so it misses two paths: removed/idle devices whose
rows linger forever, and heartbeat.js's offline_timeout insert that bypasses
logDeviceStatus entirely. The reporter's 1.2M-row bloat accumulated UNDER a 7-day
per-device prune for exactly this reason.

- pruneStatusLog() (db/database.js): a GLOBAL time-range sweep across ALL devices,
  modeled on the play_logs prune. Run once on startup (recovers a bloated table
  right after deploy) and on the heartbeat interval (services/heartbeat.js).
- STATUS_LOG_RETENTION_DAYS env, default 3 (lower than the old hardcoded 7d; the
  dashboard only shows a 24h uptime window, so 2-3d is ample for diagnostics).
- Deliberately NO per-device row cap: Step 3's throttle already bounds how fast a
  storming device can generate status rows, so a cap would add sweep complexity
  for little gain (noted for later if needed).
- NO VACUUM / auto_vacuum here (kept off the hot path); space reclaim is left as a
  separate decision (see report).

test: deterministic in-process unit test proves the sweep deletes over-retention
rows across all devices — including a device absent from the devices table and an
offline_timeout row — while keeping recent rows; idempotent on an empty table.

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

102 lines
3.3 KiB
JavaScript

const { db, pruneStatusLog } = require('../db/database');
const config = require('../config');
const { deviceRoom, emitToWorkspace } = require('../lib/socket-rooms');
// Track connected device sockets: deviceId -> { socketId, lastHeartbeat }
const deviceConnections = new Map();
function startHeartbeatChecker(io) {
// #142: sweep stale device_status_log rows once at startup (recovers a bloated
// table immediately after a deploy), then again on each interval below.
pruneStatusLog();
setInterval(() => {
const now = Date.now();
const dashboardNs = io.of('/dashboard');
// Check database for devices that should be offline
const onlineDevices = db.prepare("SELECT id, last_heartbeat FROM devices WHERE status = 'online'").all();
for (const device of onlineDevices) {
const conn = deviceConnections.get(device.id);
const lastBeat = conn ? conn.lastHeartbeat : (device.last_heartbeat ? device.last_heartbeat * 1000 : 0);
if (now - lastBeat > config.heartbeatTimeout) {
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
.run(device.id);
deviceConnections.delete(device.id);
// Notify dashboard (workspace-scoped via the device's room).
emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', {
device_id: device.id,
status: 'offline',
telemetry: null
});
console.log(`Device ${device.id} marked offline (heartbeat timeout)`);
try {
db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(device.id, 'offline_timeout');
} catch (_) {}
}
}
// Cleanup: delete unclaimed provisioning devices older than 24 hours
// Keep imported devices (they have user_id set) so users can re-pair them
db.prepare(`
DELETE FROM devices WHERE status = 'provisioning'
AND user_id IS NULL
AND created_at < strftime('%s','now') - (365 * 86400)
`).run();
// Cleanup: prune play logs older than 90 days
db.prepare(`
DELETE FROM play_logs WHERE started_at < strftime('%s','now') - (90 * 86400)
`).run();
// #142: global device_status_log retention sweep (all devices, incl. removed/idle
// and the offline_timeout insert path that bypasses the per-device prune).
pruneStatusLog();
// Cleanup: expired team invites
db.prepare(`
DELETE FROM team_invites WHERE expires_at < strftime('%s','now')
`).run();
// Cleanup: expired workspace invites
db.prepare(`
DELETE FROM workspace_invites WHERE expires_at < strftime('%s','now')
`).run();
}, config.heartbeatInterval);
}
function registerConnection(deviceId, socketId) {
deviceConnections.set(deviceId, { socketId, lastHeartbeat: Date.now() });
}
function updateHeartbeat(deviceId) {
const conn = deviceConnections.get(deviceId);
if (conn) conn.lastHeartbeat = Date.now();
}
function removeConnection(deviceId) {
deviceConnections.delete(deviceId);
}
function getConnection(deviceId) {
return deviceConnections.get(deviceId);
}
function getAllConnections() {
return deviceConnections;
}
module.exports = {
startHeartbeatChecker,
registerConnection,
updateHeartbeat,
removeConnection,
getConnection,
getAllConnections
};