Clustering
How Voltro scales workflows, crons, and reactivity across multiple instances — the built-in @effect/cluster runtime, schedule claims, and per-dialect CDC.
Clustering is built in. There is no plugin to install and no config flag to flip: run more than one instance of your app against the same database and they form a cluster automatically. Durable workflows resume on any surviving instance, each cron fires exactly once across the whole fleet, and writes on one instance become reactive subscriptions on every other.
@effect/cluster is a core dependency of @voltro/workflow — the CLI wires it for you when voltro dev or voltro start boots an app on a SQL store. You scale out by running N instances; the coordination machinery is already there.
How it's enabled
There is nothing to enable in code. The wiring is automatic and driven by the store dialect:
| Store dialect | Runner storage | Horizontal scale-out |
|---|---|---|
postgres, mysql, mariadb, mssql |
sql |
yes — workflows resume on any instance |
sqlite |
memory |
no — durable replay within one process |
memory (dev default) |
— | single process |
On a SQL store, the runtime composes the dialect's SqlClient → @effect/cluster SingleRunner → @effect/workflow ClusterWorkflowEngine into one layer and provides it to your program. sqlite falls back to in-process durable replay (no cross-instance scale-out) because @effect/cluster's runner-lock acquisition has no SQLite branch.
You do not call the cluster layer yourself — the CLI does. The boot log tells you which mode resolved:
[voltro:dev] workflow engine: cluster-sql, dialect=mariadbTwo settings tune this for demanding topologies: VOLTRO_WORKFLOW_SHARD_LOCK keeps cross-pod handoff correct on a Galera / Percona XtraDB cluster (see Galera and multi-primary clusters), and VOLTRO_WORKFLOW_RUNNER_STORAGE opts out of SQL runner storage when you don't need handoff (see Single-process runner storage).
File-backed SQLite ownership
A local sqlite workflow engine owns its journal for the engine's lifetime. A second engine pointed at the same database now fails startup before consuming workflow messages, instead of executing the first engine's active work again. Stop the first engine before starting its replacement. For concurrent executing replicas, use a database with shared SQL runner coordination.
Ownership uses a separate <database>.voltro-workflow-lock SQLite file and the operating system's native lock lifecycle. Normal shutdown, boot failure and process death release the lock; the empty sidecar remains on disk. Do not delete that file while an engine is running. The guard does not hold a write transaction on the application database, so ordinary data reads, writes and dashboard observers remain possible.
This protection applies to local file-backed sqlite engines wired by normal dev/serve boot. Lower-level hosts receive the same guard when they pass dialectId: 'sqlite' to workflowEngineLayer. It does not establish distributed ownership for remote Turso, in-memory databases or other hosts that omit dialect metadata.
The deployment contract
A real multi-instance deployment has to give each pod a routable identity. This is the entire operational surface of clustering, and it is what the framework's Helm baseline injects:
- Routable runner host (
POD_IP). When a workflow that started on a now-dead pod has to resume elsewhere, the surviving pod addresses the dead pod's runner by the address it advertised. The default islocalhost, which is unreachable cross-pod. Inject the Kubernetes downward-APIstatus.podIPasPOD_IP(or setVOLTRO_WORKFLOW_RUNNER_HOST) so each pod advertises its real address. The runtime warns at boot if a SQL-backed runner is still onlocalhost,GET /_voltro/inspect/clustercarries it aslocalhostRisk, andvoltro clusterprints a redlocalhost runnerline for the affected replica — so a fleet that lost the downward-API mapping shows it on every surface, not only in the boot log nobody re-reads. - Fixed runner port.
SingleRunnerotherwise picks a random port, which breaks restart-resume because the runner table still references the previous process's port. Pin it viaVOLTRO_WORKFLOW_RUNNER_PORT(default34000) so a restarted pod inherits the same cluster identity. A value that is not a port (1..65535) is ignored with a warning and the default is used — including thetcp://…Kubernetes injects for a Service namedvoltro-workflow-runner. - Unique, stable
server_idper pod (MariaDB CDC). The binlog reader needs aserver_idthat is unique across the fleet and stable across reschedules — duplicate ids silently break binlog streams. The runtime derives it by hashing a per-pod replica id (POD_NAME→ FNV-1a). With aStatefulSetthe pod name is ordinal and deterministic. - Replication user (MariaDB). The binlog CDC reader connects with a dedicated user holding
REPLICATION SLAVE, REPLICATION CLIENT, against a server configuredbinlog_format=ROW,binlog_row_image=FULL,gtid_strict_mode=ON.
The relevant downward-API wiring, from the framework's Helm baseline:
env:
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } } # → stable per-pod CDC server_id
- name: POD_IP
valueFrom: { fieldRef: { fieldPath: status.podIP } } # → routable workflow-runner host
- name: VOLTRO_WORKFLOW_RUNNER_HOST
value: "$(POD_IP)"
- name: VOLTRO_WORKFLOW_RUNNER_PORT
value: "34000"
- name: DB_DIALECT
value: "mariadb"
- name: CDC
value: "1"That is the whole contract: stable per-pod identity (POD_NAME → server_id), routable runner host (POD_IP), a pinned runner port, the shared DB_URL, and CDC on. No application code changes between one instance and four.
Galera and multi-primary clusters
Multi-primary MariaDB (Galera) and Percona XtraDB are first-class and keep full cross-pod handoff — no proxy, no single-writer endpoint, no loss of durability. This is handled automatically; the paragraphs below are the why for operators who need it.
By default @effect/cluster coordinates which runner owns which shard with session advisory locks (GET_LOCK on MySQL/MariaDB, pg_advisory_lock on Postgres). Advisory locks are node-local — a lock held on one Galera node is invisible on the others, and Galera never replicates them. So on a Galera cluster whose app pods reach the database through a load-balancing Service (connections spread across nodes), advisory-lock coordination can't see itself: pods split-brain shard ownership, and the runner-storage bootstrap can wedge before it creates cluster_runners (the pod stays un-Ready while a shard-lock refresher errors against a cluster_runners table it thinks is missing).
Voltro resolves this with the VOLTRO_WORKFLOW_SHARD_LOCK setting (auto | row | advisory, default auto):
auto— probe the live connection (@@wsrep_on) and switch to the row-lease path on a Galera / PXC cluster, keep advisory locks everywhere else. No operator config needed.row— force the row-lease path.SqlRunnerStoragecoordinates shard ownership with a certified conditional upsert on thecluster_lockstable (INSERT … ON DUPLICATE KEY UPDATE … WHERE acquired_at < <expiry>) instead ofGET_LOCK. Galera certifies that write across all nodes — the same reason the cronadvisoryLocktier uses a claims row, not a session lock — so shard ownership is coordinated correctly no matter which node a connection lands on. Cross-pod handoff works: a dead pod's lease ages out and a surviving pod takes over the shard.advisory— force the classicGET_LOCKpath (single-primary only).
The boot log reports the resolved mode, and voltro cluster status shows it per instance (shard-lock=row):
[voltro:serve] shard-lock coordination: row-based (dialect=mariadb, mode=auto, wsrep/Galera cluster detected)Two honest caveats. Row-lease failover is expiry-based, not instant — a dead pod's shards are reclaimed after the lease timeout (seconds), where advisory locks release the instant a connection drops. That's why auto keeps the faster advisory path on a single-primary server and only pays the timeout on Galera. And MySQL Group Replication (and other non-wsrep multi-primary topologies) aren't auto-detected — set VOLTRO_WORKFLOW_SHARD_LOCK=row explicitly there.
Single-process runner storage
If a deployment genuinely does not need cross-pod handoff — dev, a single-region single-replica service, or one where each pod owning its own workflows is acceptable — set VOLTRO_WORKFLOW_RUNNER_STORAGE=memory to skip the SQL runner storage entirely:
VOLTRO_WORKFLOW_RUNNER_STORAGE=memoryThis is the engine sqlite uses. It skips SqlRunnerStorage (no cluster_runners / cluster_locks, no advisory locks) while durability is unaffected — run/message/reply state still persists via SqlMessageStorage. The tradeoff is the point: no cross-pod handoff — a workflow started on a now-dead pod will not resume on a surviving one. Prefer the row-lease coordination above for multi-replica deployments that need handoff; reach for memory only when they don't.
VOLTRO_WORKFLOW_RUNNER_STORAGE=sql is rejected for local or embedded SQLite-family connections. Direct remote Turso primaries use SQL runner storage with row locks automatically. An unrecognized VOLTRO_WORKFLOW_SHARD_LOCK value still fails boot; an advisory preference on a SQLite-family SQL runner resolves to its supported row-lock mechanism.
Durable workflows resume on any instance
Workflow durability lives in @effect/cluster's engine, not in any Voltro table. Each workflow's journal (steps, idempotency keys, signals) is persisted to the cluster's storage on the shared database. When a pod dies mid-step:
- Its runner lease stops being renewed.
- Another runner reclaims the dead runner's shards.
- The reclaiming runner reads the workflow journal, fast-forwards past completed steps, and resumes from the unfinished step.
A completed step replays its persisted result without running its side effects again. An interrupted step whose result was not persisted runs again on the surviving runner. Any side effects it performed before the crash can therefore repeat: make external writes idempotent, or use a transactional outbox for effects that must be coupled to database state. This is why the POD_IP runner host matters: the reclaiming pod must be able to reach the workflow's runner address to take it over.
A runner that is alive but cannot use its database gives its shards up the same way. Its heartbeat and its shard locks ride one reserved connection, apart from the app's pool, so a process whose pool has stopped handing out connections would otherwise keep heart-beating and keep every shard it owns — and a run started on one of those shards would sit at pending. Each runner therefore probes its pool on the lock-refresh cadence (VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT, 10 s by default). When the pool has not served a query for three of those intervals, the runner releases every shard lock, stops its heartbeat and logs workflow runner: its connection pool has not served a query for …; another runner takes the shards once the lease runs out. The runner takes part again once its pool has answered for three intervals in a row; a single answer from a pool that frees a slot for a moment is not a recovery. Readiness reports the same pool through its SELECT 1 ping (see production hardening).
@effect/cluster is dialect-agnostic here — its SqlRunnerStorage and SqlMessageStorage dispatch internally via sql.onDialectOrElse({ mssql, mysql, sqlite, orElse: postgres }). MariaDB rides the mysql branch (GET_LOCK, ON DUPLICATE KEY UPDATE, native RETURNING since 10.5).
mssql only:
@effect/cluster's mssql storage has two driver-level bugs the framework fixes with a patch that a plain install can't carry. Runvoltro add mssqlonce, thenpnpm install— see SQL Server → Workflow cluster. No-op on every other dialect.
Crons fire once cluster-wide
Schedule coordination is app-wide. On a postgres store the default is advisoryLock; you can opt into cluster, or force single for a one-instance deployment with app.config.ts (scheduling: { coordination }). Individual *.cron.tsx files stay topology-agnostic.
coordination |
Exactly-once mechanism | When |
|---|---|---|
single |
none — always fires | dev, single pod |
advisoryLock |
claims row in _voltro_schedule_claims |
multi-instance, default on a SQL store |
cluster |
@effect/cluster shard ownership (ClusterCron) |
when you already run the workflow engine and want one ownership model |
advisoryLock is the lightweight tier. Every replica computes the same deterministic scheduledAt from the cron expression (not its own Date.now(), so clock skew is irrelevant), derives a claim key ${scheduleName}@${secondBucket}, and races to INSERT a row into _voltro_schedule_claims. The primary-key conflict makes exactly one replica win; the rest lose cleanly and skip. The second-precision bucket makes claims self-expiring — a crashed winner doesn't block the next firing, because the next firing is a new bucket and therefore a new key. This is deliberately a claims row, not a session-level lock, because a connection pool can hand a GET_LOCK/pg_advisory_lock connection to another query before the unlock.
cluster rides the same sharding machinery as durable workflows: ClusterCron fires on exactly one runner (the shard owner) and migrates ownership on runner death. The in-app timer is disarmed in this mode (@effect/cluster owns the clock); the firing is recorded with coordinationOutcome: 'cluster'. It needs a SQL store with cluster storage (everything except sqlite/memory), and degrades to single with a warning when that's unavailable.
Overlap policy (onOverlap: 'skip' | 'queue' | 'parallel') is also cluster-aware: skip checks for a status='running' row from any pod, not just the local one, so a long run on pod A suppresses a new firing on pod B.
Cross-instance reactivity (per dialect)
A write on instance A has to become a reactive subscription delta on instance B. The transport is change-data-capture, and it is not Postgres-only — each dialect uses its native change feed:
| Dialect | Transport | Latency |
|---|---|---|
postgres |
LISTEN/NOTIFY |
fast |
mariadb |
binlog CDC (ROW image, GTID) | push-based, per-replica reader |
mysql |
inline emit only (no binlog CDC) | single-process |
sqlite |
in-process bus | single-process |
On MariaDB the binlog is the message bus — each replica tails it itself, resuming from its own _voltro_cdc_offsets row keyed by replica id. There is no Redis, NATS, or external broker. CDC is on by default for any SQL dialect (CDC=1); set CDC=0 to fall back to single-process inline emit. The boot log reports the resolved transport:
[voltro:dev] sql dialect resolved: mariadb — CDC: binlog CDC (ROW), RETURNING: native (INSERT/DELETE); UPDATE then SELECTObservability
- Dashboard. Workflow runs, steps, and schedule runs stream live to the devtools/cloud dashboard.
- Tables. Introspection rows live on the shared database — query them directly:
_voltro_workflow_start_contexts— durable starter subject/trace/source plus parent execution id and parent-close policy, loaded by whichever runner first executes the workflow or child workflow._voltro_workflow_runs,_voltro_workflow_run_steps,_voltro_workflow_run_events— workflow execution history (these are fire-and-forget introspection; durability itself lives in the cluster engine's own tables)._voltro_schedule_runs— every firing, withreplicaId(which instance fired it) andcoordinationOutcome(single/wonLock/cluster/ …)._voltro_schedule_claims— the live exactly-once claim rows foradvisoryLockschedules.
- Logs and traces.
voltro logs --tail 100shows the resolved dialect, CDC flavor, coordinator, and runner identity at boot, plus every firing and resume.voltro tracescorrelates a workflow's steps across pods.
voltro cluster status
voltro cluster status renders the clustering snapshot of every running api — discovered the same way as voltro logs / voltro traces (the runtime registry), then merged with the live coordination state each instance reads from the shared store:
voltro cluster status # pretty table of every instance
voltro cluster status --process myApi # restrict to one registered api
voltro cluster status --format json # machine-readable, pipe to jqFor each instance it shows the replicaId, the runner address (host:port), the resolved dialect and clustering tier (full / inline-only / single-process / none), the CDC flavor, the coordination kind, and — on the MariaDB CDC path — the binlog server_id. A SQL-backed runner advertising localhost / 127.0.0.1 is flagged loudly: other pods can't reach it, so cross-pod workflow resume silently breaks (inject POD_IP via the K8s downward API or set VOLTRO_WORKFLOW_RUNNER_HOST). A footer summarises the shared coordination state — active replicas seen in recent runs, current schedule claims, and in-flight schedule + workflow runs. The same view is on the dashboard's Cluster panel.
Quick "which instance is running what" query:
SELECT "replicaId", count(*)
FROM "_voltro_schedule_runs"
WHERE status = 'running'
GROUP BY "replicaId";The multi-dialect story
Clustering works on every SQL dialect, with one capability gradient:
postgres/mariadb/mssql— full clustering: durable workflow resume across instances,advisoryLockorclustercrons, native cross-instance reactivity (LISTEN/NOTIFYon postgres, binlog CDC on mariadb).mysql— durable workflow resume and schedule coordination work; cross-instance reactivity is inline-only (no binlog CDC path), so subscriptions don't fan out across instances.sqlite— durable workflow replay within a single process (runnerStorage: 'memory'); no horizontal scale-out.memory— single-process dev store; no clustering.
The same app code runs unchanged across all of them — the runtime resolves the right runner storage, coordinator, and CDC transport from the dialect at boot.
clusterPlugin()— deliberately not built. A convenience marker plugin (declaring an app "expects multi-instance", optionally pinning the runner host/port) was considered and rejected: clustering is already automatic for any SQL store with N instances, so there is no behaviour for such a plugin to toggle. Its only knobs — runner host/port — are env-driven (VOLTRO_WORKFLOW_RUNNER_HOST/VOLTRO_WORKFLOW_RUNNER_PORT) and injected by the deployment (K8s downward API), which is where pod identity belongs. The idea is on record but will not ship.
Anti-patterns
- Assuming clustering is Postgres-only. It isn't. MariaDB is a first-class clustering target (binlog CDC), and multi-replica MariaDB deployments run it in production.
wal_level=logicalis a Postgres detail, not a clustering requirement. - Leaving the runner host on
localhostin multi-pod deployments. Resume-on-another-pod silently breaks — pods can't reach alocalhostrunner. InjectPOD_IP. The runtime warns about this at boot. - Reusing a
server_idacross MariaDB pods. Duplicateserver_idsilently breaks binlog streams. Derive it from a stable per-podPOD_NAME(the framework does this for you). - Session-level locks for cron coordination.
advisoryLockuses a claims row, notpg_advisory_lock/GET_LOCK, precisely because pooled connections make session locks unreliable. Don't reach for session locks.
Remote Turso primaries
Both ordinary development and compiled production startup use the dialect's resolved connection capability. A direct remote Turso primary uses shared SQL runner registration and database-clock row leases. Replicas sharing that primary arbitrate workflow shards; after an executor is killed, an existing peer can resume unfinished work once ownership transfers. Completed steps remain recorded, while an unfinished step may run again: external effects still need idempotency.
Local Turso files and file-backed embedded replicas retain memory runner storage and single-process workflow limits. An embedded replica's sync URL does not make its local journal an authoritative shared runner store. VOLTRO_WORKFLOW_RUNNER_STORAGE=memory remains an explicit single-process override. The Cluster panel shows the actual runner storage and row lock mode rather than inferring memory storage from the Turso dialect name.
When upgrading an existing remote Turso deployment from memory runners, stop or drain those workers before starting SQL-runner workers against the same journal. Do not overlap the old independent memory owners with the new coordinated owners. The existing journal is retained; normal idempotency rules still apply to unfinished work.