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>
tlongwell-block added a commit that referenced this pull request Jul 30, 2026
…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>
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