fix: batch row-pruning job, lower retention to 30 days, fix leaked geo-lookup timeout
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user