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 scheduledAtnot 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. Stale rows are pruned lazily.
  • 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.

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'  (coordination is moot — the platform fires once)

When the platform scheduler drives the firing (the external trigger), the platform guarantees once-only, so coordination doesn't apply — the run is tagged external.

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 fire endpoint

(lostLock firings are not recorded — see above.) The dashboard surfaces this per run; see the dashboard doc.