fix(proof-of-play): throttle play_logs writes to prevent runaway bloat

A player stuck in a tight loop (playlist with 0-second item durations)
fires device:play-event 'play_start' ~3x/sec, inserting a play_logs row
each time. Three web players doing this generated ~909k rows (99.9% with
duration_sec=0) and grew the prod DB to 265 MB.

Throttle proof-of-play inserts to at most one per device per 2s (in-memory
lastPlayLogAt map). Skipped cycles create no row; the live dashboard
progress event still fires every time, so the UI is unaffected. The
play_end UPDATE only closes open rows, so throttling play_start is safe.

(Junk rows already pruned in prod: 909k deleted, DB 265 MB -> 9.8 MB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-02 09:52:22 -05:00
parent cbe00d6c85
commit 890ec5790f

View file

@ -18,6 +18,15 @@ const commandQueue = require('../lib/command-queue');
// next checker sweep within heartbeatInterval).
const pendingOfflines = new Map();
const OFFLINE_DEBOUNCE_MS = 5000;
// Proof-of-play write throttle. A player stuck in a tight loop (e.g. a playlist
// with 0-second item durations) fires device:play-event 'play_start' several
// times per second; unthrottled this once bloated play_logs to ~900k rows
// (~3 inserts/sec from a single web player). Cap proof-of-play inserts to at
// most one per device per PLAY_LOG_MIN_GAP_MS. The live dashboard progress
// event is still forwarded every time, so the UI is unaffected. In-memory only.
const lastPlayLogAt = new Map();
const PLAY_LOG_MIN_GAP_MS = 2000;
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
@ -517,10 +526,18 @@ module.exports = function setupDeviceSocket(io) {
if (device_id !== currentDeviceId) return;
try {
if (event === 'play_start') {
db.prepare(`
INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type)
VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist')
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
// Throttle proof-of-play inserts per device so a runaway player
// (0-second items) can't flood play_logs. Skipped cycles simply
// don't create a row; the dashboard progress event below still fires.
const nowMs = Date.now();
const lastMs = lastPlayLogAt.get(device_id) || 0;
if (nowMs - lastMs >= PLAY_LOG_MIN_GAP_MS) {
lastPlayLogAt.set(device_id, nowMs);
db.prepare(`
INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type)
VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist')
`).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown');
}
// Forward to dashboard so it can render a per-device progress bar.
// Server-side timestamp avoids clock-skew between player and dashboard.
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:playback-progress', {