Route lag-tolerant reads to an optional Postgres read replica - #2084
Conversation
Server-only change, zero client impact. When READ_DATABASE_URL is set
(e.g. an Aurora cluster-ro- endpoint), three read classes move off the
writer:
1. FTS search — the dedicated search pool prefers the replica URL.
Search is lag-tolerant by construction and serves as the canary for
replica traffic.
2. Db::read() seam — an optional second PgPool on Db with writer
fallback when unconfigured. Unset env means byte-for-byte today's
behavior.
3. Cursor-bearing pages — channel scroll-back and thread pagination
pass a keyset cursor over immutable history, so cursor pages route
to the replica while head fetches (cursor: None) stay on the writer.
Forward thread pagination gets terminal-page verification: an
under-limit replica page is a candidate EOF, so that one page is
re-run on the writer to guarantee a lagged replica can never
truncate a thread tail into a false EOF.
Observability: buzz_db_read_pool_{size,idle,active,max} Prometheus
gauges (emitted only when the replica pool exists) and startup log
markers ('writer + read replica', search 'replica=true').
Tests: divergent writer/replica scratch databases make routing
observable — a writer-only fresh event and a replica-only marker prove
which pool served each page, covering head-to-writer, cursor-to-replica,
and the writer terminal-page re-run.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
tlongwell-block
left a comment
There was a problem hiding this comment.
Blocking correctness issue in both cursor routes: Postgres replication preserves commit/WAL order, while these keysets paginate by client-signed created_at (plus id). Those orders are not monotonic (ordinary concurrent/offline clients can commit out of timestamp order; ingest permits ±15 minutes at handlers/ingest.rs:1405-1411). Therefore a lagged replica can contain a later-key row while missing an earlier-key row that is already on the writer.
Concrete thread case (lib.rs:1910-1922, ordered ASC in thread.rs:414,427): writer has r1@1, r2@2, r3@3, r4@4. r4 committed before r3, and the replica has replayed through r4 but not r3. Head on writer with limit=2 returns r1,r2. Cursor page on replica returns r4; with limit=1 it is full, so this code accepts it. The next cursor is >r4 and r3 is permanently skipped. The under-limit/EOF verification never runs.
The divergent test does not cover this: it models lag as a timestamp-ordered prefix (lib.rs:5368-5376), then uses a replica-only ghost (explicitly unphysical) to prove the full-page branch. Add the out-of-order-commit fixture above and it should fail.
The same assumption exists for channel scrollback (lib.rs:1961-1966, DESC keyset at thread.rs:605-612): a recently committed event with an older signed timestamp can be absent on the replica while an older WAL-prefix row beyond it fills the page, advancing the cursor past the missing event. In addition, history is not immutable here: events.deleted_at is mutable (thread.rs:596; soft deletes in event.rs:697+), so lag can resurrect deleted rows/summaries.
Safest minimal correction for this PR: keep thread cursor pages writer-bound, and either keep channel pages writer-bound too or introduce an authoritative overlap/fence protocol before routing them. Search + the optional pool seam remain sound wins. Terminal writer verification alone only proves EOF, not completeness of full pages.
Cursor pages routed to the read replica were only safe if every row the page could contain had already replayed there. Commit order is not created_at order: a transaction carrying an older client-signed created_at can commit late, and a replica page paginating past that gap skips the row permanently (the keyset cursor never revisits it). Close that hole with a two-part proof: 1. Commit-time floor (migration 0021): a DEFERRABLE INITIALLY DEFERRED constraint trigger on events re-checks, inside COMMIT processing and against clock_timestamp() (not the transaction-frozen now()), that channel-bearing rows are no older than buzz.created_at_floor seconds. The relay's writer pool arms the GUC on every connection (after_connect), turning the ingest-time +/-900s envelope into a storage invariant that every insert path inherits — including the writers that bypass ingest_event entirely (workflow sink, side effects, moderation notices, audio, git transport, replace_*). channel_id-NULL rows (push leases, discovery snapshots) are structurally exempt; they never appear in keyset windows. Startup fails closed if any events partition lacks the trigger. 2. Ordered LSN handshake (replica_fence): on one pinned writer connection, three separately-awaited statements sample S = clock_timestamp(), then scan pg_stat_activity (plus pg_prepared_xacts) for the oldest open transaction, then capture L = pg_current_wal_lsn() last. Once the replica reports pg_last_wal_replay_lsn() >= L, every transaction falls into exactly one of three buckets — replayed, bounded by oldest_xact_start, or floor-guarded after S — so the fence advances to min(oldest_xact_start, S) - floor - margin with no timeout assumptions at all. Everything fails closed: probe errors, masked pg_stat_activity rows (an unprivileged view masks backend_type itself — detected before any backend-type filter), NULL replay LSN, a not-in-recovery 'replica', non-advancing replay, and probe staleness all close the fence and route every cursor page back to the writer. Routing now consults the fence: channel windows serve a cursor from the replica only when the fence covers the cursor timestamp; thread pages only when the page is full AND its tail is at or below the fence (under-limit terminal pages still re-verify on the writer). Adversarial fixtures pin both inversions (DESC middle hole, ASC full-page skip), the held-transaction commit abort, the armed-pool end-to-end rejection with SHOW verification, the masked-activity fail-closed path, and the NULL-replay-LSN breaker. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…ATEs Three blockers from Perci's adversarial review of 3c8b2b0: 1. The fence could open without migration 0021. The probe was spawned before the migration decision, and the partition-coverage check only ran inside migrate() — so a relay with BUZZ_AUTO_MIGRATE off (the production configuration) would arm the GUC, enforce nothing, and let the LSN handshake open the fence over an unenforced floor. The probe now starts after the migration decision, gated on an unconditional two-part verification: catalog shape (parent + every partition: right function, deferrable, initially deferred, row-level AFTER, INSERT and UPDATE arms) and observed behavior (a rolled-back probe transaction through the armed pool must see check_violation on each adversary — a catalog-shaped no-op function body cannot pass). Any failure: loud error, no probe, fence closed, writer-only routing. 2. The INSERT-only guard was not a table invariant: an UPDATE could move a legitimately-admitted old channel-NULL row into keyset windows, or rewrite a channel row's created_at below the fence. The trigger is now INSERT OR UPDATE OF created_at, channel_id (safe to edit 0021 in place: it has never shipped off this branch). Cross-partition created_at rewrites run as DELETE+INSERT and hit the INSERT arm on the destination partition; in-partition rewrites hit the UPDATE arm. 3. CI integration lanes died on fresh setup: pgschema emits partition children standalone, cloning the parent trigger onto them, and attach-schema-partitions.sql then failed ATTACH with 'trigger already exists' (PostgreSQL recreates inherited triggers during ATTACH). The attach script now drops the cloned floor-guard trigger before each events ATTACH, same as it already did for the push-match trigger. Reproduced the failure with the old script and verified the fix end-to-end against pgschema apply + attach + coverage query. New fixtures: probe refuses to start on a gutted (catalog-identical) guard function and on a dropped trigger, fence stays closed in both refusal states; armed UPDATE adversaries (channel-NULL -> channel, created_at rewrite) abort at COMMIT with 23514. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The relay parses READ_DATABASE_URL but the chart never injected it, so the reader-routing feature was unreachable through the documented production deploy path. Add it to the Deployment env as an optional secretKeyRef (absent key = routing disabled = current behavior), document the key in values.yaml, the secret sample, and .env.example, and add render tests for both the existingSecret and chart-managed secret paths. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> * origin/main: test(desktop): fix Escape-before-mount race in channel browser e2e (#2101) style(desktop): match PendingInviteGate to startup interstitials (#2097) Remove additional agents gallery (#2098) fix(agents): stop the reply loop and the premature kickoff closer (#2094) chore(release): release Buzz Desktop version 0.4.18 (#2091) fix(desktop): recover first community joins (#2087) fix(chart): support CPU-only relay autoscaling (#2086) feat(chart): add relay horizontal autoscaling (#2077)
…y-membership-check * origin/main: fix(desktop): stop DMs from firing duplicate desktop notifications (#2105) release(helm): buzz chart 0.1.6 (#2109) Route lag-tolerant reads to an optional Postgres read replica (#2084) Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
…der routing (#3268) ## Summary Expands read-replica usage on the relay per the Rev 2 design (thread 39d1e174 in #buzz-read-only-replica-usage): replaces the Aurora-incompatible WAL-LSN read-side fence observation with a portable **heartbeat token**, makes the freshness proof **snapshot-local to the serving reader session**, and adds a default-off bounded-staleness gate for head fetches. ### Probe (writer side, unchanged ordering) The ordered writer scan is kept verbatim — `S = clock_timestamp()` → masked-visibility `pg_stat_activity` oldest-xact scan — and now ends by committing a heartbeat token **last** on the same pinned connection (single-row `UPDATE replica_heartbeat ... RETURNING token, epoch`, migration `0026`). The single-row update serializes all pods' probes, so tokens are globally commit-ordered; the three-bucket completeness argument carries over unchanged. Ring retains `(token, committed_at, fence_wall)`; epoch change resets the ring; a same-epoch token regression (restore adversary) clears the ring **and rotates the epoch on the writer** so pre-rewind readers fail the epoch check. Cadence 1s. ### Routing (snapshot-local proof) Every routed read opens `BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY` on a reader session and observes the heartbeat as the transaction's **first statement** — the proof's snapshot is exactly the snapshot the page, participants batch, and bridge aux closure read from (`ReadSession` carries the open transaction; drop = rollback). Fail-closed everywhere: begin failure, missing heartbeat row, epoch mismatch, token below the ring, over-budget entry → writer. - **Predicate B (cursor pages, default-on):** same completeness math as the existing fence — cursor timestamp must be ≤ the proved wall; thread candidate-terminal and above-wall pages re-run on the writer. - **Predicate A (head fetches, default-OFF):** gated by `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` (0 = off, clamped to fence staleness). Bounded-stale head semantics are an explicit product decision — **do not enable anywhere without Tyler's backdated-event-semantics acceptance.** ### Observability `buzz_db_route_decision{path, decision, reason}` across all five paths, `buzz_db_replica_heartbeat_age_seconds` gauge, and per-decision debug logs carrying the proved token plus backend identity: `addr:port pid=N`, prefixed with `aurora_db_instance_identifier()` when the endpoint supports it (probed once per process on an autocommit checkout; SQLSTATE 42883 caches a definitive false; identity is evidence, never a routing gate). ## Review & verification - **Wren:** full review 9/9/9 at `5f81b10e5`; identity delta re-review approved at exact head `fedb46368` (90/90 unit, clippy `-D warnings`, fmt independently reproduced). - **Max:** local E2E **PASS** at `5f81b10e5` — isolated PG17 writer + two streaming standbys behind HAProxy; paused-reader legs split exactly as designed (6 replica/fresh + 6 writer/stale), recovery clean, RR snapshot hardening observed in runtime logs. Evidence: `WORK_LOGS/2026-07-28_REPLICA_HEARTBEAT_REPLACEMENT_SHA_E2E.md`. - **My gates at head (same shell):** buzz-db 90 unit + 143 Postgres-gated green (scratch DBs); buzz-relay 767/768 — lone red is the pre-existing `mesh_demo` flake, red on base; clippy `-D warnings` + fmt clean. - Key regression tests: `routed_request_holds_one_snapshot_across_page_and_aux` (mutation-verified both directions), same-epoch rotation, capability-probe negative, head-gate truth table, divergent-fixture routing suite. ## Post-merge plan Merge publishes the immutable `sha-*` main image → bb-public PR pins it → ArgoCD sync → Max runs the production cursor-routing canary on Aurora (positive identity branch proven live). Head gate stays off. Client `since`-widening ships separately. --- ## Update — full read routing (Rev 6, commit `9fa3c9c0b`) Extends routing to the remaining read seams per `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` Rev 6 (thread cf5deba4 in #buzz-read-only-replica-usage). ### New routed seams — ALL deploy-default dark The new seams (`query_events_routed`, `query_events_routed_bounded`, `count_events_routed`, `get_events_by_ids_routed`, `query_feed_{mentions,needs_action,activity}_routed`) are **Bounded-only** and gated on `BUZZ_REPLICA_READ_MAX_AGE_MS`: unset ⇒ every new seam records `writer/disabled` and merging is a no-op. COUNT and feed/by-ids never take the covered arm (deletion visibility: covered bounds insert-completeness only). **Note:** the pre-existing cursor paths (channel windows, thread pages) are *not* gated by this env var — they route at B=0 today and that status quo is unchanged. ### Reader pool (D4/D5) - Lazy pool (`connect_lazy`, `min_connections(0)`): reader-down at boot can't crash the relay; a warn-only boot ping is the only boot-time visibility, and it primes the Aurora identity capability cache. Priming is an optimization only — the routed path itself spends a single acquire budget regardless (see the single-checkout fix below), because a boot ping that *fails* is correlated with exactly the reader-unavailable case the budget bound exists for. - `READER_ACQUIRE_TIMEOUT` = 150ms; a miss fails closed to the writer with reason **`reader_acquire_timeout`** — named for the mechanism, not a diagnosis. The budget includes cold connects and sqlx's `size` counts in-flight dials, so this metric alone does not distinguish contention from slow connection establishment (see `proved_reader` doc-comment for the runbook guidance; the pool gauges are 10s samples and are for capacity planning). - `BUZZ_DB_READ_POOL_SIZE` sizes the reader independently (invalid/0 inherits writer sizing); `read_pool_stats().max` reports the reader's own ceiling. ### Community isolation (formal-model question) Isolation is structural — an explicit `community_id = $n` predicate compiled into every query builder; no RLS, no session-GUC tenant state (zero `CREATE POLICY` across migrations). The `_on` variants reuse the exact same builders with only the executor swapped. Proven by a seven-seam two-community divergent-fixture test (`routed_reads_are_confined_to_the_requested_community`), mutation-tested by Dawn: all four single-predicate stubs killed; the mention-join feeds are defended in depth (three independent predicates) so only complete removal leaks there. ### Accepted limitation D6: client-side staleness on bounded reads (up to `_MS`) is accepted product behavior when the gate is enabled; gate stays off at merge. ## Update — single acquire budget per routed read (commit `dd26caa9f`) Max found (and Dawn independently reproduced, 302–330ms measured) that the boot-unavailable cold path spent **two** stacked `READER_ACQUIRE_TIMEOUT` budgets: the Aurora capability probe did its own `pool.acquire()` before `begin_with` acquired again. Fixed by acquiring **once** per routed read — the capability probe runs on the held connection (`reader_aurora_capability_on`) and the read-only `REPEATABLE READ` transaction begins on that same connection (`Transaction::begin` accepts a `PoolConnection` at `'static` via sqlx's `MaybePoolConnection`). Reason codes unchanged; capability still never negatively cached. Measured routed fallback: ~150ms (one budget). Ships with a PG-gated regression fixture (`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`, authored by Dawn): size-1 reader saturated, capability cache asserted cold, routes through `count_events_routed`, asserts writer answer + one-budget elapsed + `writer/reader_acquire_timeout` label (mutation-tested: `pool_busy` and `reader_validation_error` mutants both killed). It fails at 330ms on the previous commit and passes on this one. ### Verification at `dd26caa9f` (pinned toolchain rustc 1.95.0 via repo `bin/`; ambient rustc 1.89 fails sqlx resolution — always prepend `bin/`) - buzz-db: 94 unit + **150/151** PG-gated serial (`--test-threads=1`; suite is not parallel-safe against one Postgres). The 1 red is `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` — a **pre-existing** same-key-same-database advisory-lock collision with any live relay pointed at the shared `buzz` DB (the relay re-arms `USAGE_METRICS_LOCK_KEY` on a 300s tick). It passes on a private scratch DB with the relay still running — verified this run. Remedy is a scratch `TEST_DATABASE_URL`, **not** terminating lock holders: inspect `pg_locks`/`pg_stat_activity` and resolve the owning process first; an idle advisory holder may be a live dev relay. Pre-existing, not a #3268 blocker — filed as #3619. - buzz-relay: 773/773; clippy `-D warnings` + fmt clean. (`mesh_demo` echo test is a known pre-existing Redis-dependent flake — Dawn confirmed it fails identically on the base commit, untouched by this PR.) - Dawn: independent re-verification at `dd26caa9f` — byte-identical diff application confirmed, suite reproduced, and fixture proven to still discriminate (fails at 320ms with only the production fix reverted). Gate clear. Max: independent rig pass at `dd26caa9f` — 94/94 unit, **15/15 routing/fallback matrix** (cursor behavior, default-dark seams, seven-seam confinement, one-snapshot, dead-reader/no-URL fallback, hard-delete fail-closed, reader max), 773/773 relay, private-DB lock control. Gate clear; recommends merge + staged bb-block rollout (no-reader-URL no-op → cursor-only → observe `buzz_db_route_decision` → conservative nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`; rollback is config-only). ## Known gap — CI does not run the PG-backed fixtures (#3622) Dawn found post-sign-off (confirmed by Max and Eva at `dd26caa9f`): **every Postgres-backed fixture in this PR is `#[ignore]`d and no CI job selects it.** `Unit Tests` runs `cargo nextest run -p buzz-db --lib` (`Justfile:279-285`, ignored tests excluded); the only `--run-ignored ignored-only` invocations (`ci.yml:690`, `:702`) filter to `relay_invite::tests`. So the one-budget regression, the seven-seam isolation fixture, and the fence/floor-guard/fallback tests have zero automated execution — the verification record above is exhaustive but manual, at this exact SHA. Not introduced by this PR (the `#[ignore]` + narrow-filter pattern predates it; 34 ignored tests total) and not a merge blocker: all new seams are dark until `BUZZ_REPLICA_READ_MAX_AGE_MS` is set. But it changes the rollout gate — **do not set a nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS` in bb-block until CI enforces these fixtures.** Filed as #3622 (explicit CI selection against the existing backend-integration Postgres archive; ordering note: #3619 must land first or be excluded, since widening the filter would select the colliding usage-metrics lock test). ## Live redteam results (Max, real Helm + PG17 streaming replication at `dd26caa9f`) — #3643, #3644 Max deployed this exact SHA via the repo chart against a physical writer/standby pair and attacked it. **The PR's fenced routing passed every fault**: healthy baseline routes proven on the real standby; paused WAL replay moved head/cursor reads to `writer/stale`; B=0 moved routed reads to `writer/disabled`; reader-URL removal rolled back cleanly. The redteam also found a **pre-existing** production risk this PR does not create and does not fix: NIP-50 search builds its own eager, unfenced pool straight from `READ_DATABASE_URL` (`main.rs:389-402`, introduced by #2084, present in the deployed bb-block `sha-dd222a5` and bb-public `sha-22be8bb` images, and both production ESOs already template `READ_DATABASE_URL`). Measured: ~30s search stall on reader loss, acknowledged-but-unsearchable writes under replica lag (unaffected by `BUZZ_REPLICA_READ_MAX_AGE_MS`), fatal eager connect blocking pod startup when the reader is down, and `replica=true` FTS even at B=0. Filed as **#3643** (fix: pin FTS to the writer, or fence it like the routed seams) with **#3644** for the writer-pointing-reader-URL telemetry gap. Aurora's cluster-ro writer-fallback DNS softens the outage modes in prod but not the lag mode. **Revised ladder consequence:** step 2 ("add `READ_DATABASE_URL`, budget unset") is NOT a no-op with today's binary — it hands FTS an unfenced replica pool. Rollout order is now: merge (still a true no-op — bb-block/bb-public already run the unfenced-FTS code and this PR only adds dark seams) → fix #3643 → CI enforcement #3622 → then reader URL + budget per the original ladder, re-running Max's three deployment tests (reader-down latency, reader-down cold boot, paused-replay read-your-own-write search). ## Activation gate (consolidated) — merge is clear; ALL of the below precede any nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS` Six independent verification passes at `dd26caa9f` (Dawn ×2 incl. byte-level + defang check, Max matrix + live Helm redteam, Wren full CRUD regression + greenfield 0026 + 151/151 PG, Eva). Every induced fault was either handled correctly by this PR's machinery or traced to pre-existing main code. Remaining work gates **activation, not merge**: 1. **#3643** — unfenced NIP-50 FTS pool (pre-existing #2084; live in prod today; also explains the blackhole search hang Wren observed — the routed path was measured bounded at 151-152ms against a silent endpoint, sqlx `inner.rs:252-255`). 2. **#3651** — reader `statement_timeout`: statements after acquire are unbounded; mid-transaction blackhole reproduced hanging ≥15s (Dawn). Small fix via `.after_connect` on the reader pool. 3. **#3622** — CI enforcement of the PG fixtures (with #3619 ordering). 4. **Recovery-conflict cancellation live test** — standby cancels a routed read mid-snapshot → complete writer page, never partial (#3651's fix also bounds the cancellation-never-arrives shape). 5. **DDL replication lag test** — migration on writer + paused replay + budget on → fallback, not client-visible SQL errors. 6. **Reader-tx soak** — 30-60min mixed load; no idle-in-tx accumulation on the standby. Then Max's three deployment tests (reader-down latency, reader-down cold boot, paused-replay read-your-own-write search) re-run on the fixed binary before the first nonzero budget on bb-block. --------- Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
What
When
READ_DATABASE_URLis set (e.g. an Auroracluster-ro-endpoint), three lag-tolerant read classes move off the writer — server-only, zero client changes, and unset env is byte-for-byte today's behavior:get_events_by_ids) and re-authorized, so search can never surface an event the writer doesn't have.Db::read()seam. Optional secondPgPoolwith writer fallback when unconfigured. Zero call-site churn.read(), head fetches (cursor: None) stay on the writer. Terminal-page writer verification for forward thread pagination: an under-limitreplica page is a candidate EOF, so that one page re-runs on the writer — a lagged replica can never truncate a thread tail into a false EOF (per Perci's review of the design).Scaling then becomes knobs we already have in bb-public (
num_read_replicas, readerinstance_class, Aurora reader autoscaling as follow-up) — capacity without touching the writer or the app.Observability
buzz_db_read_pool_{size,idle,active,max}Prometheus gauges (emitted only when the replica pool exists), startup log markers (Postgres connected (writer + read replica), searchreplica=true).Testing
Unit/integration — divergent writer/replica fixtures make routing observable: a writer-only "fresh" event and a replica-only "marker" prove which pool served each page (head→writer, cursor→replica, writer terminal-page re-run, writer-fallback-when-unset).
buzz-db: 80 unit + 106 Postgres-backed integration, all green at this head.buzz-relay: 687 passed, 1 failed —mesh_demo::demo_join_forwarded_arm_round_trips_echo, network-dependent, reproduces on cleanorigin/main(ca384d0) in the same environment; pre-existing, not this change.Live local (TESTING.md-style HARD pass, real data) — restored a production-scale dump (598 MB, 780K events, sha256-verified) into a writer + replica pair, then deliberately diverged them (fresh relay writes land only on the writer; a marker row injected only into the replica — a frozen replica is a maximally-lagged reader). Relay with
READ_DATABASE_URL, verified at the wire via CLI + raw/query:pg_stat_databasedeltas during the run show the replica serving the cursor pages.READ_DATABASE_URL— no read-pool gauges, no replica reads, head + 10-page walk fully served by the writer.Context
Part 1 of the read-scaling plan discussed in buzz-bb-block-deploy (Eva + Perci + Tyler). Head-fetch offload (overlap gate + SUBACK + cold-state writer bypass) stacks on the
Db::read()seam later; nothing here blocks it.Deployment & operational tradeoffs
READ_DATABASE_URLas an optionalsecretKeyRefon the relay Deployment. Add the key to the release secret (chart-managed orsecrets.existingSecret) to enable routing; omit it and the env is unset = routing disabled = exactly prior behavior. Render tests cover both secret modes. Documented invalues.yaml,examples/secret-sample.yaml, and.env.example.max_connectionson the reader instance class against pod count before rollout.(Both tradeoffs surfaced in Max's independent red-team review; the chart gap it found is fixed in
c27baf384.)