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 start

Verify in the boot log:

[voltro:dev] read replicas: 1 configured, ryw policy 'fallback', dialect=postgres, region=unknown

That'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-1

The framework auto-detects the current process's region from these env vars (in priority order):

  1. VOLTRO_REGION — explicit override
  2. AWS_REGION — AWS ECS / Lambda / EC2
  3. FLY_REGION — Fly.io
  4. RAILWAY_REGION — Railway
  5. GCP_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=on

On 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 start

The 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 get triggers 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=wait on 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.perf

It 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=redis without REDIS_URL — the framework warns + falls back to memory store. Multi-instance deployments silently lose RYW coherence.
  • Don't set DB_REPLICA_REGIONS without DB_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 wait policy with high write-rate subjects — every read after a write polls the replica with a sub-second timeout. The tail latency is bounded by rywMaxWaitMs, 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.