Skip to content

Route lag-tolerant reads to an optional Postgres read replica#2084

Merged
tlongwell-block merged 5 commits into
mainfrom
eva/reader-pool-routing
Jul 19, 2026
Merged

Route lag-tolerant reads to an optional Postgres read replica#2084
tlongwell-block merged 5 commits into
mainfrom
eva/reader-pool-routing

Conversation

@tlongwell-block

@tlongwell-block tlongwell-block commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

What

When READ_DATABASE_URL is set (e.g. an Aurora cluster-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:

  1. FTS search → replica. The dedicated search pool prefers the replica URL. Search is lag-tolerant by construction and is the canary for replica traffic. Correctness backstop for free: replica FTS hits are re-fetched from the writer canonical store (get_events_by_ids) and re-authorized, so search can never surface an event the writer doesn't have.
  2. Db::read() seam. Optional second PgPool with writer fallback when unconfigured. Zero call-site churn.
  3. Cursor-bearing pages → replica. Channel scroll-back and thread pagination pass a keyset cursor over immutable history; cursor pages route to read(), head fetches (cursor: None) stay on the writer. Terminal-page writer verification for forward thread pagination: an under-limit replica 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, reader instance_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), search replica=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 clean origin/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:

  • Head fetch → writer (fresh present, marker absent). Cursor walk of full channel history (11 pages) → replica (marker surfaced, zero writer-only leakage).
  • Search → replica, dispositively: a writer-only event with a confirmed writer-side FTS match is missed by relay search (query ran on the replica); replica-only FTS hits are dropped by the writer re-fetch.
  • Thread forward pagination under maximal lag (replica has zero thread rows, every page under-fills): all 14/14 writer-only replies collected, correct EOF, no false truncation — terminal-page re-verification working live.
  • Playwright GUI: fresh-built bundle, scroll-back perf spec against the live relay — cursor pages fired through the real IntersectionObserver → keyset path; pg_stat_database deltas during the run show the replica serving the cursor pages.
  • Flag-off control: same binary, no 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

  • Enablement (Helm): the chart injects READ_DATABASE_URL as an optional secretKeyRef on the relay Deployment. Add the key to the release secret (chart-managed or secrets.existingSecret) to enable routing; omit it and the env is unset = routing disabled = exactly prior behavior. Render tests cover both secret modes. Documented in values.yaml, examples/secret-sample.yaml, and .env.example.
  • No transparent mid-query failover: if the replica dies while serving a routed cursor/search query, that in-flight query errors rather than silently retrying on the writer. Subsequent cursor traffic returns to the writer via fence closure; search remains bound to its configured endpoint. Deliberate: retry-on-writer would double worst-case load exactly when the database tier is already unhealthy.
  • Replica connection ceiling: with a reader configured, a pod can open up to 20 (read pool) + 10 (search pool, SQLx default) = 30 connections to the replica endpoint, alongside writer 20 + audit 5. Capacity-check max_connections on 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.)

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
tlongwell-block requested a review from a team as a code owner July 18, 2026 19:15

@tlongwell-block tlongwell-block left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d and others added 4 commits July 18, 2026 16:35
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)
@tlongwell-block
tlongwell-block merged commit 29c4888 into main Jul 19, 2026
35 checks passed
@tlongwell-block
tlongwell-block deleted the eva/reader-pool-routing branch July 19, 2026 02:45
kalvinnchau pushed a commit that referenced this pull request Jul 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant