You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
So a run whose jitter landed negative happens before its slot, and the next slot is then computed
from that earlier moment — landing only minutes ahead instead of a day.
jitter ±90 around that, floored at now + 60s → next ∈ [23:37, 01:30]
Every outcome of step 3 is above the floor, so it does not merely risk running again — it always
does, within about two hours. If that second run also lands before midnight the cycle repeats.
Live evidence, with a prediction you can check tonight
The one cron trigger on this account, GET /v1/triggers:
nextRunAt is before midnight. So when it fires at 23:36 tonight, step 2 computes the next slot
as Aug-9 00:00 — 24 minutes later — and it will browse Facebook again before ~01:30. Nothing is
broken-looking; failureCount stays 0 and no error is recorded, because each individual run
succeeds.
It is silent and self-concealing: every run succeeds, so the only symptom is a workload the
owner did not ask for and a lastRunAt that looks reasonable in isolation.
Every automatic run costs BYOK tokens and a browser session on the user's machine.
The cause, stated plainly
The trigger stores one time and uses it for two different things: when to fire (jittered) and where we are in the schedule (the slot). Jitter is presentation; the slot is the state. Deriving
the next slot from the fire time lets presentation mutate state.
Fix
Compute the next slot from the SLOT, never from now. Keep the un-jittered scheduled time
alongside the jittered one — a next_slot_at column beside next_run_at, or store the slot and
apply jitter at dispatch — so:
slot(n+1) = nextRunAt(schedule, slot(n)) // pure cron, jitter-free
fire(n+1) = applyJitter(slot(n+1), jitter) // presentation only
Then a −24m run still fires at 23:36 and the next slot is still Aug-9 00:00, a full day later.
Cheaper stop-gap if the column is unwelcome: compute from max(now, scheduledSlot) where the slot is
recoverable from nextRunAt minus the applied offset — but that means storing the offset, which is
the column again. The column is the honest version.
Also worth deciding: whether jitter should be symmetric at all. +0..j never moves a run into
the previous period and removes this class entirely; the anti-pattern-defence argument for jitter is
served just as well by a one-sided delay.
Acceptance
A run whose jitter is negative does not shorten the interval to the next run.
@daily fires once per day, whatever the jitter, with a test that steps a trigger through
several negative-jitter runs and asserts the slot advances by exactly one period each time.
Existing rows recover on their next run rather than needing a backfill.
A negatively-jittered run makes the next slot minutes away, so "@daily" fires twice in one night
The mechanism
lib/triggers.ts:473, after a cron trigger runs:nowis the moment the sweep fired it — the jittered time, not the scheduled slot. AndapplyJitter(:159) is symmetric:So a run whose jitter landed negative happens before its slot, and the next slot is then computed
from that earlier moment — landing only minutes ahead instead of a day.
Concretely, with
@dailyandjitterMinutes: 90:nextRunAt("@daily", now = 23:36)→ 00:00 tonight — 24 minutes awaynow + 60s→ next ∈ [23:37, 01:30]Every outcome of step 3 is above the floor, so it does not merely risk running again — it always
does, within about two hours. If that second run also lands before midnight the cycle repeats.
Live evidence, with a prediction you can check tonight
The one cron trigger on this account,
GET /v1/triggers:nextRunAtis before midnight. So when it fires at 23:36 tonight, step 2 computes the next slotas Aug-9 00:00 — 24 minutes later — and it will browse Facebook again before ~01:30. Nothing is
broken-looking;
failureCountstays 0 and no error is recorded, because each individual runsucceeds.
Why it matters more than a scheduling nicety
run_browseagainst a real logged-in account. "Daily" that is sometimes twice-a-nightis the mass-action-frequency class [browser][trust] Trust & permission model for browser agents (design prerequisite) #75 cares about, arriving through a bug rather than a policy.
owner did not ask for and a
lastRunAtthat looks reasonable in isolation.The cause, stated plainly
The trigger stores one time and uses it for two different things: when to fire (jittered) and
where we are in the schedule (the slot). Jitter is presentation; the slot is the state. Deriving
the next slot from the fire time lets presentation mutate state.
Fix
Compute the next slot from the SLOT, never from
now. Keep the un-jittered scheduled timealongside the jittered one — a
next_slot_atcolumn besidenext_run_at, or store the slot andapply jitter at dispatch — so:
Then a −24m run still fires at 23:36 and the next slot is still Aug-9 00:00, a full day later.
Cheaper stop-gap if the column is unwelcome: compute from
max(now, scheduledSlot)where the slot isrecoverable from
nextRunAtminus the applied offset — but that means storing the offset, which isthe column again. The column is the honest version.
Also worth deciding: whether jitter should be symmetric at all.
+0..jnever moves a run intothe previous period and removes this class entirely; the anti-pattern-defence argument for jitter is
served just as well by a one-sided delay.
Acceptance
@dailyfires once per day, whatever the jitter, with a test that steps a trigger throughseveral negative-jitter runs and asserts the slot advances by exactly one period each time.
Files:
workers/api/src/lib/triggers.ts:156-166,473,workers/api/src/routes/triggers.ts:255,316,workers/api/src/lib/cron-time.ts(nextRunAt).