Read replicas
Transparent read-replica routing with read-your-writes (RYW) consistency, multi-instance RYW via Redis, region-aware replica selection, and the per-dialect adapter surface.
The framework's DataStore can wrap a primary connection + a list
of read replicas behind one transparent surface. Writes go to the
primary. Reads query to a replica chosen by power-of-two-choices,
EXCEPT when the same subject just wrote — RYW (read-your-writes)
pins their next reads to the primary for a TTL window so they
never see stale data after their own changes.
This is completely opt-in. Without DB_REPLICA_URLS set, the
framework returns the primary store directly — no ReplicatedDataStore
wrapper, no per-call routing overhead, no cost.
Quick start
Add replica URLs:
DB_PRIMARY_URL=postgresql://app:app@primary.db.acme.com/app
DB_REPLICA_URLS=postgresql://app:app@replica.db.acme.com/app
voltro startVerify in the boot log:
[voltro:dev] read replicas: 1 configured, ryw policy 'fallback', dialect=postgres, region=unknownThat's it. Reads now flow to the replica unless RYW pins them back. Writes always go to primary.
Read-your-writes (RYW)
After a mutation commits, the subject who wrote is pinned to primary for the next 30 seconds (configurable). This guarantees they see their own change on the next read, even if the replica hasn't caught up yet.
How it's implemented under the hood per dialect:
| Dialect | Capture surface | Probe surface |
|---|---|---|
| Postgres | pg_current_wal_lsn() |
pg_last_wal_replay_lsn() |
| MySQL / MariaDB | @@global.gtid_executed (MySQL) / @@global.gtid_current_pos (MariaDB) |
same, on the replica |
| MSSQL | end_of_log_lsn from sys.dm_hadr_database_replica_states (AG only) |
last_hardened_lsn from the same DMV |
| SQLite | n/a (single-process — replicas not applicable) |
The framework calls capturePrimaryPosition() immediately after
every successful write, stores it keyed by the subject, and consults
the cache on every subsequent read by that subject. No position →
fall through to the replica selector.
RYW policies
Three modes via RYW_POLICY env or rywPolicy: in config:
| Policy | Behaviour |
|---|---|
fallback (default) |
Pin to primary for the TTL window. Simple, low-tail-latency, no replica wait. |
wait |
Poll the replica until it catches up (max rywMaxWaitMs). Higher consistency at cost of latency. |
off |
No RYW — every read goes to a replica regardless of write history. For analytics workloads where staleness is OK. |
For most apps, fallback is correct: it prevents the "I just edited
this, why is it gone" UX without paying the replica-poll cost. Reach
for wait only when stronger consistency outweighs latency.
Replica selection
By default the framework uses power-of-two-choices (P2C): pick two replicas at random, send the read to the one with fewer in-flight queries. Standard load-balancer trick — robust under skew, no global LRU/LFU state needed.
Region-aware selection (multi-region)
When replicas span regions, set DB_REPLICA_REGIONS to a
comma-separated list aligned with DB_REPLICA_URLS:
DB_REPLICA_URLS=postgres://repl-use1.acme/,postgres://repl-euw1.acme/
DB_REPLICA_REGIONS=us-east-1,eu-west-1The framework auto-detects the current process's region from these env vars (in priority order):
VOLTRO_REGION— explicit overrideAWS_REGION— AWS ECS / Lambda / EC2FLY_REGION— Fly.ioRAILWAY_REGION— RailwayGCP_REGION/CLOUD_RUN_REGION— Google Cloud
The selector then wraps P2C with a locality preference: same-region replicas are tried first, other-region replicas only when same-region is empty or unavailable. Boot log confirms:
[voltro:dev] read replicas: 2 configured, ryw policy 'fallback', dialect=postgres, region=us-east-1, replica-locality=onOn a wrong-region read, you pay 80-150ms cross-region latency. On a same-region read, 1-2ms. The selector does its best to keep you in the second bucket.
If DB_REPLICA_REGIONS count mismatches DB_REPLICA_URLS count,
the framework warns + disables locality (degrades to plain P2C
across all replicas — correct but slower).
Multi-instance RYW: Redis
The default RYW store is a per-process Map. Multi-instance
deployments (k8s with replicas: 3, multi-pod ECS, etc.) need a
shared store, otherwise a write on instance A doesn't pin reads on
instance B.
Wire Redis:
RYW_STORE=redis REDIS_URL=redis://host:6379
voltro startThe Redis store keeps a local mirror per instance, kept warm via
pub/sub — reads stay sync (Map.get), writes propagate to peers
within one Redis round-trip. Sub-millisecond on localhost, 1-2ms
across a VPC.
What you get:
- Cross-instance coherent RYW: A's write pins B's reads for the TTL.
- Server-side TTL via PSETEX — entries expire automatically even
when no
gettriggers lazy eviction. - Self-publish loopback filtering: an instance ignores its own pub/sub broadcasts to avoid double-applying.
What you don't get:
- Strong consistency. Between the moment A commits + the moment B
receives the pub/sub broadcast, B's reads could still hit a
stale replica. The window is one Redis round-trip — small but
nonzero. For strict-consistency workloads use
RYW_POLICY=waiton top of Redis store.
Performance
Routing overhead is sub-microsecond per call. The micro-bench in
packages/runtime/src/replicatedDataStore.perf.test.ts measures
~10µs per routing decision including the AsyncLocalStorage context
push/pop. The actual SQL query dominates wall-clock by orders of
magnitude.
If you see latency regressions, run that test in your fork:
pnpm -F @voltro/runtime test -- replicatedDataStore.perfIt logs the per-call cost and fails if you've accidentally turned the routing path into an O(n²) walk or added an unnecessary allocation per query.
Dialect support matrix
| Dialect | Routing | RYW | Region-aware | E2E validated |
|---|---|---|---|---|
| Postgres | ✓ | streaming-replication LSN | ✓ | docker-compose (Phase 4 / C10) |
| MySQL 8+ | ✓ | GTID | ✓ | docker-compose (Phase 5) |
| MariaDB 10.6+ | ✓ | GTID (via mysql adapter) | ✓ | shares MySQL adapter |
| MSSQL 2019+ | ✓ AG only | AG LSN | ✓ | adapter validated, AG cluster setup is operator-managed |
| SQLite | n/a | n/a | n/a | single-process, no replication possible |
MSSQL routing requires Always-On Availability Group cluster setup (certs + endpoints + listener + Pacemaker on Linux OR Windows Failover Clustering). That's a deployment-grade infrastructure setup, not a framework concern; the adapter validates against any SQL Server but real replica routing needs AG. Without AG, both capture + probe return the zero-LSN sentinel and the framework gracefully degrades to "treat every read like a primary read" (safe, just slower than it could be).
Anti-patterns
- Don't run
RYW_STORE=rediswithoutREDIS_URL— the framework warns + falls back to memory store. Multi-instance deployments silently lose RYW coherence. - Don't set
DB_REPLICA_REGIONSwithoutDB_REPLICA_URLS— the region map is paired by order with the URL list. Without URLs the region list has nothing to bind to and is ignored. - Don't use
waitpolicy with high write-rate subjects — every read after a write polls the replica with a sub-second timeout. The tail latency is bounded byrywMaxWaitMs, but for hot subjects this can starve. - Don't manually pin reads to a specific replica from handler code. The framework's routing layer is the single decision point — if you bypass it, you lose RYW correctness + observability counters.