postgres read-your-writes / LSN-gated routing harness
postgres read-your-writes / LSN-gated routing harness
This is the harness behind the read-your-writes post.
It runs a digest-pinned PostgreSQL 16 primary and one streaming replica and measures the stale-read window you get when an app writes to the primary and then immediately reads from the replica, plus the fix: routing reads on the WAL LSN so a user always sees their own writes while the replica still absorbs most of the read traffic.
The replica runs with recovery_min_apply_delay = 250ms, so it deliberately
holds every commit for 250ms before applying it. That makes the lag
deterministic — the stale window is reproducible instead of lumpy — which is
the whole point of pinning it.
Two experiments, both against the real primary + replica:
- A. The stale window — seed
user_counter(user_id, val), then for each trialUPDATEa row on the primary, waitDmilliseconds, andSELECTit back from the replica. A read is stale if the replica’s value isn’t the one just written.Dis swept over{0, 50, 100, 200, 250, 300, 500, 1000}ms. Expect stale% high below the 250ms apply boundary and dropping to zero above it — a cliff. - B. The gate — a mixed read/write workload over many users, run twice on the
same seeded op stream:
- naive: every read goes to the replica.
- gated: after each write, capture
pg_current_wal_lsn()per user; before a read, checkpg_last_wal_replay_lsn() >= that LSN. Caught up → replica. Behind → fall back to the primary. (The per-user LSN dict stands in for the Redis you’d use in production.)
Writes touch a small hot set of users; “own-recent” reads hit the user that was just written (the read-your-writes worst case), “browse” reads hit anyone. The headline is the contrast between the two modes on identical traffic.
These are laptop measurements demonstrating the mechanism, not capacity-planning
numbers. The apply delay is pinned to 250ms so the cliff lands in a predictable
place; on a real replica the lag is whatever your network, write volume, and
hot_standby_feedback make it, and it moves around.
Run it
Docker with Compose v2, plus Python 3.9+.
cd benchmarks/postgres-read-your-writes
docker compose up -d --wait # primary on :55442, replica on :55444
python3 -m venv /tmp/ryw-venv && source /tmp/ryw-venv/bin/activate
pip install -r requirements.txt
python benchmark.py | tee results/summary_console.txt
docker compose down -v
The replica container base-backups from the primary on first boot (see
replica-entrypoint.sh), writes standby.signal + primary_conninfo via
pg_basebackup -R, and pins recovery_min_apply_delay. primary-init.sh opens
a replication pg_hba line and creates the replicator role. Ports are bound to
127.0.0.1 only.
Override hosts/ports/results with PRIMARY_HOST, PRIMARY_PORT, REPLICA_HOST,
REPLICA_PORT, RESULTS_DIR, SEED.
Results
experiment_a_stale_window.csv— stale% and observed lag per read-after-write delay.experiment_b_gate.csv— naive vs gated: stale%, replica-served%, primary-fallback%.summary.txt— human-readable key numbers.run_metadata.csv— postgres version, image digest, apply delay, trial counts, seed, docker/python versions, date.attempts/— non-reproducing or superseded runs, kept for honesty.
The mechanism (a replica applies WAL behind the primary, so a read right after a write can miss it) is not specific to the 250ms number; that’s just the knob that makes the window land where the benchmark can see it every time.