fix: batch row-pruning job, lower retention to 30 days, fix leaked geo-lookup timeout

This commit is contained in:
2026-08-22 13:35:28 +02:00
parent 5a823501f2
commit bd5f7bfd3a
+58 -6
View File
@@ -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