Timeline of PENDING occurrence rows swept every 300 seconds by a DynamoDB scheduler, with one stranded row before the lookback window

DynamoDB scheduler: how we decide when a recurring event fires

Our DynamoDB scheduler never sleeps until a run time. It writes every instant of a recurring rule as a row up to 90 days ahead, and a sweep that runs every 300 seconds on a single leader fires the rows whose time has passed, through one DynamoDB transaction that claims the row and emits the event. An event therefore runs at its fire_at instant rounded up to the next 300-second tick, plus the delivery time of a durable queue, and it fires once across the whole worker fleet.

This post covers the five stages of that path in our backend's calendar module, the window math that decides which rows exist, the transaction that makes each fire single, and a 6 to 24 hour gap where a row is written but never fired.

The short version

  • A rule is expanded into one PENDING row per instant, from 24 hours back to 90 days ahead, with a cap of 1,000 instants per pass.
  • A leader-gated tick every 300 seconds queries seven hourly buckets of a sparse index and fires the rows whose fire_at has passed.
  • One TransactWriteItems call with two writes emits the event and flips the row from PENDING to FIRED, so two workers can never fire the same occurrence.
  • Materialize reaches back 24 hours but the tick reaches back six, so a row whose instant is 6 to 24 hours old at schedule time is never fired.

Why a sleep loop is the wrong scheduler

The first scheduler in most codebases is a loop that sleeps and checks what is due. We wrote about how that loop multiplies in how codebases grow three schedulers nobody designed. It fails in three specific ways: it keeps no record of what it fired, it fires once per process instead of once per fleet, and it loses its place on every restart.

DynamoDB has no primitive for "run this at 07:30". So the design writes the instants down as data and sweeps that data on a fixed tick. No process holds a timer for a specific event.

ApproachWhat decides the run timeFires once per fleetSurvives a restartRecord of what fired
Sleep loop in the app processAn in-memory timerNo, once per instanceNoNone
Cron on every hostThe host crontabNo, once per hostYesLogs only
Materialize and sweep (ours)fire_at on a stored row, checked by the tickYes, by a conditional claimYes, rows are durableEvery occurrence row, with its status

The five stages from schedule call to handler

Five stages of the DynamoDB scheduler: schedule, materialize, tick, durable delivery, handler
The five stages. Stage 3 is the only stage that reads the clock to decide when something runs. Stages 1 and 2 happen in one call; stages 3 to 5 happen later, in the worker.

Stages 1 and 2 run inside the API call; stages 3 to 5 run later in the worker. Stage 3 is the only one that reads the clock to decide whether something runs.

The schedule call stores the rule, not a time: a recurrence, an anchor_at UTC instant that is both the first run and the wall-clock template for later runs, an IANA timezone, an optional cron string and an until_at hard stop. The event id is source_module#external_ref, so a repeat call is a no-op. If either half is missing, the id falls back to a fresh UUID and every call creates a new event, which is the easiest way to get duplicates by accident.

Materializing instants into a 90-day window

Materialize turns one rule into concrete instants and writes one occurrence row per instant. The expander is a pure function with no clock and no database: it takes a rule, a now and a window. That makes DST and month-end cases plain unit tests, with no time mocking.

after   = max(anchor_at - 1s, now - 24h)   # never replay more than 24 hours
horizon = now + 90 days                    # materialize_horizon_days
keep    = after < fire_at <= horizon
Time axis showing the materialize window from now minus 24 hours to now plus 90 days
The materialize window. The left edge is the later of two values, which stops the calendar from replaying weeks of missed history when an old rule is first expanded.

The left edge is the later of two values. Without the 24-hour floor, expanding an old daily rule for the first time would write, and then fire, weeks of missed history.

Expansion rules

HOURLY steps in fixed UTC hours and ignores DST. DAILY and coarser rules add N units to the anchor in local time and convert back, so the local hour survives DST changes. MONTHLY clamps the day, so an anchor on the 31st runs on April 30 and February 28. CRON tests each minute against the five fields in the event's timezone.

One pass stops at 1,000 instants. A cron that fires every minute reaches that cap after about 17 hours of horizon, and the next top-up continues from the last row.

The row and the sparse index

Each occurrence id is <fire_at_iso>#<event_id>, written with put_if_absent, so one (event, instant) pair can exist once. A PENDING row also gets gsi1pk = DUE#YYYY-MM-DDTHH, the UTC hour of its fire_at. Rows skipped by a holiday policy are written as SKIPPED without that key, so they show in the UI and never fire.

Only PENDING rows carry the DUE key, so the index is sparse and holds only work still to do. The tick never scans the base table; it queries a handful of hour partitions.

The tick: a 300-second sweep behind a DynamoDB lease

Flow chart of one scheduler tick: lease, seven hourly buckets, filter, claim and emit, six-hour top-up
One iteration of the scheduler loop. The lease makes the fleet single-fire, the seven buckets keep the sweep bounded, and the filter is the decision "is it time yet".

Every worker runs this loop, and only the lease holder gets past the first step. The lease is a row in a DynamoDB locks table rather than a Redis lock, so the scheduler depends only on a store the rest of the platform already uses.

One failure here is silent. If the ARGOS_TABLE_LOCKS environment variable is not set, no worker ever wins the lease, no tick drains, and nothing in the calendar raises an error. Events simply stop firing.

The seven hourly buckets, the current UTC hour and the six before it, are the catch-up budget after an outage. A row older than that is in no swept bucket and stays PENDING on the base table.

The claim: one transaction, two writes

For each due row the scheduler binds the row's tenant and runs one TransactWriteItems call. The first write puts a cal.event_fired event row on the durable lane. The second updates the occurrence to FIRED, removes its DUE keys and carries the condition status = PENDING.

One TransactWriteItems with two writes; a second worker with a stale lease fails the condition and both writes roll back
The claim. The condition on the second write protects the first.
# Simplified: the emit and the claim commit together or not at all.
client.transact_write_items(TransactItems=[
    {"Put": {"TableName": EVENTS_TABLE,
             "Item": event_fired_row(occ)}},          # lane = DURABLE
    {"Update": {"TableName": CAL_TABLE, "Key": occ.key,
        "UpdateExpression": "SET #s = :fired, fired_at = :now "
                            "REMOVE gsi1pk, gsi1sk",
        "ConditionExpression": "#s = :pending",
        "ExpressionAttributeNames": {"#s": "status"},
        "ExpressionAttributeValues": {":fired": {"S": "FIRED"},
            ":pending": {"S": "PENDING"}, ":now": {"S": now_iso}}}},
])

If a second worker with a stale lease reaches the same row, its condition fails and DynamoDB rolls back both writes, so no second event row exists. The same write drops the row from the DUE index, and an occurrence can never be FIRED without its event row. We lean on the same conditional-write pattern for device commands with durable acks over MQTT.

If the transaction does not commit, the row stays PENDING and the next tick retries it. A FAILED status exists in the enum, but no code path sets it today.

From event row to handler

The event row reaches handlers through the relay described in our outbox on DynamoDB: DynamoDB Streams, EventBridge Pipes, then an SQS FIFO queue. A worker binds the tenant from the envelope, claims {event_id}:{subscription} in an idempotency inbox and runs each matching durable subscription. Subscriptions filter on layer_code, so a module sees only its own layer, and tenant binding follows the same tenant isolation rules as the rest of the backend.

Delivery is at least once. The calendar guarantees the occurrence fired once. It does not guarantee your handler ran once, so every handler must be idempotent.

The six-hour top-up keeps 84 to 90 days of rows ahead

Every six hours the loop runs two passes under separate leases. reconcile_declared writes every module's declared specs with put_if_absent, so a spec that ships in a deploy becomes rows without an API call. materialize_horizon reads every recurring event from the ACTIVE_EVENTS index and runs materialize again.

Pending rows at time T and at T plus six hours, with six hours of new rows added at the far end
The top-up. The horizon slides forward six hours at a time, and rows that already exist are not written again.

Because the row id starts with fire_at, rewriting an existing instant is a no-op, and only instants that newly entered the horizon are added. A recurring event therefore always has 84 to 90 days of rows ahead of it. After an outage, every instant still in the future is intact; instants that passed during the outage are caught up only if they fall inside the six-hour lookback.

Where it fails today: the 6 to 24 hour gap

Materialize backfills 24 hours. The tick looks back six. Any rule whose anchor is between those two numbers at schedule time gets a PENDING row that no tick will ever read. This came out of documenting the firing path against the code, and here is the exact case:

Timeline for October 23: the 02:00Z occurrence falls outside the tick lookback and is stranded; the October 24 occurrence fires
The first day is lost. Materialize reaches back 24 hours, the tick reaches back six, and the gap between those two numbers is where a row is stranded.

The stranded row still shows in list_occurrences, so it looks scheduled, and no recovery tool exists today. Until the backfill and the lookback agree, anchor recurring events in the future.

Two smaller gaps sit next to it. A ONCE event anchored more than 90 days out is outside the window and is not on ACTIVE_EVENTS, so the top-up never revisits it and it never fires. And the interval settings exist, but the loop hardcodes both intervals.

What this lets EverBowl™ do

EverBowl™ learns how a dog eats and drinks over months. Per-day work on that record, such as the daily summary of how a dog's day went, runs on this calendar, because it is the only scheduler our lints allow. DAILY rules expand in local time, so a 07:30 IST job runs at 07:30 local every day, and the claim transaction means each day produces one record, not two.

The honest limit is timing and the first day. A job runs within one 300-second tick of its instant, not to the second, and a job anchored 6 to 24 hours in the past loses its first day. EverBowl does not diagnose; the value is the record.

Conclusion

If you build recurring jobs on DynamoDB, store the rule, materialize instants into a bounded window, and let one leased sweep fire them through a conditional transaction. Keep the expander pure so DST and month-end cases stay unit tests. Make the backfill window and the sweep lookback the same number, or you will strand rows the way we do today. Make every handler idempotent, because the calendar fires once but delivery is at least once. More posts on how our backend is built live in the Software category.

FAQs

How do you schedule recurring jobs in DynamoDB?

DynamoDB has no timers, so store each future run as a row with its run time in the key. A periodic sweep queries the rows that are due and claims each one with a conditional write. A sparse GSI keyed by hour keeps the sweep from scanning the table.

How does the scheduler avoid firing an event twice?

Two layers. A DynamoDB lease lets only one worker sweep per tick, and the claim is a transaction whose update requires status = PENDING. If two workers race, the second condition fails and both of its writes roll back.

What happens to scheduled events during an outage?

Future instants are already stored as rows, so none are lost. Instants that passed during the outage fire on the first tick back only if they are within the six-hour lookback; older ones stay PENDING.

How do you cancel a recurring event?

cancel_event marks the event cancelled, removes it from the top-up index, and moves every PENDING occurrence to SKIPPED with its index key removed. Rows that already fired do not change, and the event row stays for audit.

References

Read more