fix(poller): stop 15-minute watches polling every 30 minutes - #21
Merged
Conversation
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) <noreply@anthropic.com>
Aswincloud-Bot
approved these changes
Aug 28, 2026
Aswincloud-Bot
left a comment
There was a problem hiding this comment.
Auto-approved: @Aswinmcw is a member of @Aswincloud/admins.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported from the field: alerts felt like they arrived every half hour. They did.
Cause
The cron period and the shortest allowed interval are both 900s, and
last_polled_atis stamped when a poll finishes — a few seconds into the tick. So the gap the due-query measures between two consecutive ticks is always 900 minus the execution time: always just under the interval.Production logs, six hours of them — cron firing reliably,
polling 1 watcheson every second invocation:Inter-tick gaps were 886–900s throughout, so the schedule was never the problem.
migrations/0004shows this was known: it set the column default to 840 ("14 min") specifically so a row would always be due on the next tick. ButMIN_POLL_INTERVAL_SECONDSis 900 and the IntervalPicker's "15 min" preset sends 900 — so every watch created through the site got the value that breaks, while the FAQ promises "The default is every 15 minutes."Fix
In the due-query, not by tuning magic interval values:
POLL_TICK_GRACE_SECONDS = 60— sized from production: 3–5s execution lag, ~14s of observed cron jitter. Far enough below one 900s tick that nothing can fire twice.Also aligns
DEFAULT_POLL_INTERVAL_SECONDSwithMIN(900). A default below the enforced minimum was a trap, and the grace now covers the boundary 840 was working around. Existing 840 rows are unaffected — they poll every tick either way.Longer intervals are unchanged beyond being at most 60s early: a 30-minute watch still needs two ticks, a 12-hour watch still needs 48.
Verification
Replayed against the live production row at the exact skipped tick:
And the other direction, confirming no double-poll inside a tick:
tsc --noEmitclean; poller bundles at 49.61 KiB.Note on rollout
This code ships in the poller worker. The poller's "Deploy default branch" trigger currently runs
npx wrangler versions upload -c wrangler.poller.jsonc, which stages a version without releasing it — so merging this will not put it live. Either restore that trigger's deploy command tonpx wrangler deploy -c wrangler.poller.jsonc, or runnpm run deploy:polleronce.