Coordination
How N instances of your app avoid double-firing a schedule — single, advisoryLock, and cluster exactly-once strategies.
When more than one instance of your app is running, each one's self timer wants to fire the same schedule at the same instant. Coordination is the gate that decides which instance actually runs it. It's an app-wide setting:
// apps/api/app.config.ts
scheduling: { coordination: 'advisoryLock' }The job definition never mentions coordination — you can move from one box to a fleet without touching a *.cron.tsx file.
The three strategies
| Strategy | Exactly-once across instances? | Needs | Use when |
|---|---|---|---|
single |
No — every instance fires | nothing | One process: PM2 single instance, a single container, local dev |
advisoryLock |
Yes — Postgres arbitrates | Postgres | Multiple instances, no orchestrator: 2–N replicas behind a load balancer |
cluster |
Yes — shard owner fires | Postgres + cluster runner | You already run @effect/cluster for workflows and want schedules on the same fabric |
Default: single on a memory store, advisoryLock on Postgres. You rarely set this explicitly.
single
No gate. The instance's timer fires, the handler runs. Zero coordination overhead.
This is correct only if exactly one instance runs the schedule. Two single instances = two firings. That's not a bug to work around — it's the contract. If you scale past one instance, switch to advisoryLock.
advisoryLock
The portable multi-instance strategy. It needs nothing but the Postgres you already have — no Redis, no orchestrator, no leader election.
How it works. Each firing computes a deterministic key, <name>@<iso-second> (e.g. dailyDigest@2026-05-28T09:00:00), from the cron-derived scheduledAt — not Date.now(), so every replica computes the identical key regardless of clock skew within the firing window. The bucket is second-precision (YYYY-MM-DDTHH:MM:SS, 19 chars), so a 6-field cron like */10 * * * * * gets a distinct key for every firing instant within a minute. Every replica races to INSERT that key into _voltro_schedule_claims. The table's primary key makes exactly one INSERT win; the rest hit the conflict and stand down.
replica A ─┐ ┌─ INSERT dailyDigest@…09:00 → wins, runs
replica B ─┼─ same bucket, same key ─┤
replica C ─┘ └─ PK conflict → stands down (no run row)
- Self-expiring. The key includes the firing instant, so a crashed winner doesn't block the next firing — the next firing instant is a new key.
- A claim does not outlive its bucket. When a replica wins a claim it deletes that schedule's own older rows in the same pass, so the table's steady-state size is a small multiple of the number of schedules rather than a function of uptime. How far back it prunes scales with how far apart that caller's buckets are — a cron keeps roughly an hour of predecessors, a 250 ms coordinated task about a minute. The grace exists because deleting a claim too early is a double fire: a replica that is running late must still find the row that says its bucket was taken.
- Swept, on the scale it fills — on every dialect.
_voltro_schedule_claimsgets a retention policy on both boot paths: rows older than 24 hours byclaimedAtare deleted, tunable withVOLTRO_SCHEDULE_CLAIMS_TTL_HOURS. The announcement at boot names it (retention: N policy(ies) armed). 24 h rather than the 30 days the framework's history tables get, because this is a lock ledger — a claim row answers a question about one firing instant, and nothing reads yesterday's. One row per (schedule, second-bucket) adds up faster than people expect: a deployment with a handful of sub-minute schedules measured 1 557 rows/hour, which a 30-day window would have let reach a million before the first row aged out. If you raise it, the number to reason about is the longest a replica may be paused and still be trusted not to re-fire a bucket it already lost. The sweep is the backstop, not the main bound — it is what cleans up after a schedule you renamed or deleted, which the per-schedule prune above can never revisit. (Through 0.32.0 the whole sweep was registered behind a postgres-only gate, so on the other four dialects it never ran and the boot said nothing about it. Fixed in 0.33.0.) - Looked up by primary key. The existence check the coordinator runs before its
INSERTis a single-row read onid, which is the claim key — so the table's size does not enter the firing path. - Pool-safe. Unlike a session-level
pg_advisory_lock(tied to a connection a pool may reassign), a claims row is durable and connection-independent. - Fail-closed. If the claims table is unreachable, the coordinator logs a warning and declines to fire rather than risk a double-fire. A missing run is recoverable via backfill; a double-fire (two charge emails) often isn't.
Losers don't write a run row — at scale that would be N−1 noise rows per firing. Only the winner's run appears in the dashboard, tagged wonLock.
If your app registers its own bound for a framework table, yours wins. registerRetention({ table, timeColumn, ttlMs }) from a startup outranks the framework's default for the same table — app > plugin > framework, and source defaults to 'app' so you do not have to pass anything. A real disagreement is reported at boot, on the line beside the policy that survived:
! _voltro_schedule_claims: two registrations — kept the app's 1h,
dropped the framework's 24h. An app registration wins over a plugin's,
and a plugin's over a framework default.
Through 0.32.0 this was a silent last-write-wins: a deployment's 1-hour bound was replaced one second later by the framework's default, the startup went on logging bounded to 1h at every boot, and it was found by counting rows. Note the direction — the loser is chosen by WHO registered, not by which TTL is narrower. "Narrower wins" would let a framework default we tighten in a later release silently start deleting your data faster than you asked for.
cluster
If you already run @effect/cluster (for workflows), schedules can ride the same sharding fabric. Each schedule becomes a ClusterCron singleton; the cluster assigns it to exactly one shard owner, and only that runner fires. The in-app self timer is not armed in this mode — the cluster owns the clock, so arming it too would double-fire.
Runs are tagged cluster. Coordination is handled by the cluster's shard assignment, so there's no claims table for cluster-mode schedules.
Caveat — sub-minute lag. A freshly started runner waits for shard assignment before the first firing (~10s observed on a cold runner). For minute-and-up cadences this is invisible. For "every second" it isn't — use self + single/advisoryLock for sub-minute work, or accept the warm-up.
Caveat — Postgres only. cluster (and advisoryLock) need Postgres. On a memory store they fall back to single with a boot warning:
scheduling.coordination=cluster needs store=postgres — falling back to single (dev/memory)
Choosing
Single process? ───────────────────────────────→ single
Multiple instances, no orchestrator? ──────────→ advisoryLock (← the default on Postgres)
Already running @effect/cluster for workflows? → cluster
Letting k8s/EventBridge drive the clock? ──────→ trigger: 'external' (the app claims each occurrence itself)
When the platform scheduler drives the firing (the external trigger), the run is still claimed before it starts, and the row is tagged external.
That is a correction of what this page used to say. It said the platform guarantees once-only, so coordination did not apply — and Kubernetes says the opposite in its own documentation: a CronJob may create a Job twice for one scheduled time, and a Job with a backoffLimit retries. The app therefore derives each firing's identity from the cron expression it declares (the arrival time is snapped back to the nearest occurrence) and claims that instant through the store, whatever coordination the deployment declares — including single, where the injected coordinator gates nothing, because the duplicate there does not come from a second replica but from the same platform delivering twice.
What each run records
Every firing writes a _voltro_schedule_runs row with a coordinationOutcome so you can see, after the fact, why this instance ran it:
coordinationOutcome |
Meaning |
|---|---|
single |
No coordination — single-instance mode |
wonLock |
Won the advisoryLock race |
cluster |
Fired as the cluster shard owner |
external |
Driven by an outside scheduler hitting the trigger endpoint; the occurrence was claimed here |
(lostLock firings are not recorded — see above.) The dashboard surfaces this per run; see the dashboard doc.
The run ledger is swept like every other framework history table — rows older than 30 days by scheduledAt, tunable with VOLTRO_SCHEDULE_RUNS_TTL_HOURS. With one exemption: a queued row is never swept. Under onOverlap: 'queue' that row is a firing's position in the line rather than a record of one, so deleting it drops the work it represents. running is not exempt, so a row a crashed process left behind still ages out.
A missed row written by the boot catch-up goes through the same coordination gate as a firing. Every replica runs the catch-up and each one enumerates the same gap from the same ledger, so without the gate a five-slot gap on ten pods left fifty rows saying the same thing — and the wall of missed rows after a deploy is precisely the signal it exists to give you.