From bd5f7bfd3aba61cd0429557e2a24378c475ad16b Mon Sep 17 00:00:00 2001 From: Malin Date: Sat, 22 Aug 2026 13:35:28 +0200 Subject: [PATCH] fix: batch row-pruning job, lower retention to 30 days, fix leaked geo-lookup timeout --- server.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/server.js b/server.js index 52628b7..bbc61a3 100644 --- a/server.js +++ b/server.js @@ -36,11 +36,21 @@ DB.exec(` last_seen INTEGER NOT NULL DEFAULT (unixepoch()), block_count INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS daily_summary ( + day TEXT NOT NULL, + site_id TEXT NOT NULL DEFAULT '', + bot_type TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, site_id, bot_type, action, country) + ); CREATE INDEX IF NOT EXISTS idx_recv ON bots(received_at DESC); CREATE INDEX IF NOT EXISTS idx_ip ON bots(ip_masked); CREATE INDEX IF NOT EXISTS idx_site ON bots(site_id); CREATE INDEX IF NOT EXISTS idx_bot_type ON bots(bot_type); CREATE INDEX IF NOT EXISTS idx_action ON bots(action); + CREATE INDEX IF NOT EXISTS idx_daily_day ON daily_summary(day); `); // Migrations – silently ignored if columns already exist @@ -52,14 +62,50 @@ DB.exec(` let _cache = null, _cacheTs = 0; -// ── Row pruning (90 days) ───────────────────────────────────────────────────── +// ── Row pruning (30 days, aggregate-then-delete, batched) ────────────────────── +// +// Rows older than 30 days are rolled up into daily_summary (day/site/bot_type/ +// action/country counts - enough to keep historical numbers, no raw per-IP/ +// per-request logs) then deleted. Both steps run in small batches yielded via +// setImmediate rather than one giant synchronous DELETE - better-sqlite3 is +// fully synchronous, and a single multi-hundred-thousand-row DELETE blocked +// the entire event loop for long enough that the server stopped responding to +// any request (confirmed live 2026-08-22: this exact pattern, previously a +// 90-day/single-statement prune against a table that had grown past 1.4M +// rows, was the root cause of bots.honeypot.es's 504s - fixed by lowering the +// retention window to match what /api/v1/stats actually reads (max 30 days) +// and batching the cleanup. -const PRUNE_AGE = 90 * 86400; // 90 days in seconds +const PRUNE_AGE = 30 * 86400; // 30 days in seconds +const PRUNE_BATCH = 2000; + +const stmtCountOld = DB.prepare('SELECT COUNT(*) n FROM bots WHERE received_at < ?'); +const stmtSummarizeBatch = DB.prepare(` + INSERT INTO daily_summary (day, site_id, bot_type, action, country, count) + SELECT strftime('%Y-%m-%d', received_at, 'unixepoch'), site_id, bot_type, action, country, COUNT(*) + FROM bots + WHERE id IN (SELECT id FROM bots WHERE received_at < ? ORDER BY id LIMIT ?) + GROUP BY 1, 2, 3, 4, 5 + ON CONFLICT (day, site_id, bot_type, action, country) + DO UPDATE SET count = count + excluded.count +`); +const stmtDeleteBatch = DB.prepare( + 'DELETE FROM bots WHERE id IN (SELECT id FROM bots WHERE received_at < ? ORDER BY id LIMIT ?)' +); + +function pruneOldRowsBatch(cutoff) { + if (stmtCountOld.get(cutoff).n === 0) { + _cache = null; + return; + } + stmtSummarizeBatch.run(cutoff, PRUNE_BATCH); + stmtDeleteBatch.run(cutoff, PRUNE_BATCH); + setImmediate(() => pruneOldRowsBatch(cutoff)); +} function pruneOldRows() { const cutoff = Math.floor(Date.now() / 1000) - PRUNE_AGE; - DB.prepare('DELETE FROM bots WHERE received_at < ?').run(cutoff); - _cache = null; + pruneOldRowsBatch(cutoff); } pruneOldRows(); // on startup setInterval(pruneOldRows, 6 * 3600 * 1000); // every 6 hours @@ -132,7 +178,7 @@ function enrichIP(rowId, ip) { if ((enrichCache.get(ip) || 0) > now) return; enrichCache.set(ip, now + 3_600_000); - http.get( + const req = http.get( `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,countryCode,as`, { timeout: 5000 }, res => { @@ -151,7 +197,13 @@ function enrichIP(rowId, ip) { } catch {} }); } - ).on('error', () => enrichCache.delete(ip)); + ); + // The `timeout` option above only emits a 'timeout' event - it does not + // abort the request by itself, so without this handler a slow/unreachable + // ip-api.com leaves the socket open indefinitely instead of the intended + // 5s cutoff (found alongside the pruning fix above, 2026-08-22). + req.on('timeout', () => req.destroy()); + req.on('error', () => enrichCache.delete(ip)); } // Background enrichment of unenriched rows