From 405c984fee3396d4b6cbf427608bff8130546215 Mon Sep 17 00:00:00 2001 From: Aswinmcw Date: Fri, 28 Aug 2026 09:23:49 +0000 Subject: [PATCH] fix(poller): stop 15-minute watches polling every 30 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A watch whose interval is exactly 900s was checked every other cron tick, so the site's shortest and default setting ran at 30 minutes, not 15. The cron period and the shortest allowed interval are both 900s, and `last_polled_at` is stamped when a poll *finishes* — a few seconds into the tick. So the gap the due-query measures between two ticks is always 900 minus the execution time, i.e. always just under the interval: tick 09:15:35 -> poll completes, last_polled_at = 09:15:39 tick 09:30:35 -> 09:30:35 - 09:15:39 = 896 >= 900 is false -> skipped tick 09:45:35 -> 1796 >= 900 -> polled Production logs show exactly this: cron firing reliably every 886-900s, but "polling 1 watches" on only every second invocation for hours. migrations/0004 shows the original author knew — it set the column default to 840 ("14 min") precisely so a row would always be due on the next tick. But MIN_POLL_INTERVAL_SECONDS is 900 and the IntervalPicker's "15 min" preset sends 900, so every watch created through the site got the value that breaks. The FAQ meanwhile promises "The default is every 15 minutes". Fixes it in the due-query rather than by tuning magic interval values: a watch is due once `poll_interval_seconds - POLL_TICK_GRACE_SECONDS` has elapsed. 60s covers the 3-5s execution lag and the ~14s of observed cron jitter, and sits far enough below one tick that nothing can be polled twice — verified at T+5s, T+5min, T+839s (all not due) and T+840s (due). Also aligns DEFAULT_POLL_INTERVAL_SECONDS with MIN (900). A default below the enforced minimum was a trap, and the grace now handles the tick boundary that 840 was working around. Existing 840 rows are unaffected — they poll every tick either way. Verified against the production row at the exact skipped tick (T+896s): old query returns 0 watches due, new query returns 1. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/db.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index 5f351ab..d32d03e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -55,10 +55,19 @@ export interface NewWatch { pollIntervalSeconds?: number; } -export const DEFAULT_POLL_INTERVAL_SECONDS = 840; +export const DEFAULT_POLL_INTERVAL_SECONDS = 15 * 60; export const MIN_POLL_INTERVAL_SECONDS = 15 * 60; export const MAX_POLL_INTERVAL_SECONDS = 12 * 60 * 60; +// Slack allowed when deciding whether a watch is due. The poller runs on a +// fixed */15 cron, and `last_polled_at` is stamped when a poll *finishes* — a +// few seconds into the tick. So the measured gap between two ticks is always a +// little under 900s, and a watch whose interval is exactly 900 would miss every +// other tick and run at 30 minutes instead of 15. Observed in production: ticks +// 886-900s apart, polls completing 3-5s in. 60s absorbs both, and stays well +// under one tick so nothing can be polled twice. +export const POLL_TICK_GRACE_SECONDS = 60; + // Ceiling on how many shipments one account can have in flight (pending + // active). Bounds the mail a single account can generate and keeps one user // from monopolising the poller, which handles 50 watches per 15-minute tick. @@ -194,16 +203,19 @@ export async function listDueWatches( batchSize: number, ): Promise { // A watch is due when it's been longer than its own poll_interval_seconds - // since the last poll. Never-polled rows fire immediately. + // (less POLL_TICK_GRACE_SECONDS) since the last poll. Never-polled rows fire + // immediately. The grace exists because the cron period and the shortest + // allowed interval are both 15 minutes: without it, an interval of exactly + // 900s lands just short on every tick and halves the real polling rate. const res = await db .prepare( `SELECT * FROM watches WHERE status='active' - AND (last_polled_at IS NULL OR (? - last_polled_at) >= poll_interval_seconds) + AND (last_polled_at IS NULL OR (? - last_polled_at) >= poll_interval_seconds - ?) ORDER BY last_polled_at ASC NULLS FIRST LIMIT ?`, ) - .bind(now, batchSize) + .bind(now, POLL_TICK_GRACE_SECONDS, batchSize) .all(); return res.results ?? []; }