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>
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.)