Skip to content

feat(replica): portable heartbeat-token fence with snapshot-local reader routing - #3268

Open
tlongwell-block wants to merge 11 commits into
mainfrom
eva/replica-heartbeat-fence
Open

feat(replica): portable heartbeat-token fence with snapshot-local reader routing#3268
tlongwell-block wants to merge 11 commits into
mainfrom
eva/replica-heartbeat-fence

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

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.

npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d and others added 10 commits July 27, 2026 16:56
Single CHECK'd-row table whose monotonic token, committed after the
fence's ordered writer scan, gives readers a freshness observation that
Aurora reader endpoints can actually serve (unlike pg_last_wal_replay_lsn,
which they hide). The epoch column detects token resets. Registered as
operator-global: it describes replication topology, not tenant data.

Numbered 0026: version 25 was burned by 0025_users_agent_owner_lookup
(#2615, reverted by #3168). DBs migrated during that window have version
25 recorded with those bytes; a different 0025 would fail their startup
with VersionMismatch. 25 stays vacant as a tombstone.

Migration shape assertions extended; the operator-global allowlist in the
tenant-scoping lint gains the new table.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Pull in ~40 commits landed since work began (per Tyler, merge not
rebase), including the #2615 revert that vacated migration version 25.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/main: (35 commits)
  fix(desktop): keep project Inbox previews compact (#3193)
  Inbox refactor (#2045)
  Fix composer selection formatting and drop overlay (#3172)
  Refine pending message status (#3153)
  feat(admin): show reported message content in report detail (#3149)
  fix(desktop): recover full local storage on startup (#3182)
  Replace mobile reconnect banners with skeleton shimmer (#3143)
  fix(desktop): keep collapsed table separators out of spoilers (#3169)
  chore(deps): update plugin org.jetbrains.kotlin.android to v2.2.21 (#3058)
  resolve findings (#3150)
  Revert "fix(cli,relay): resolve agents by verified owner" (#3168)
  feat(desktop): redesign agent runtime settings (#3093)
  fix(mobile): match markContextRead signature in activity test fake (#3158)
  fix(desktop): use forward slashes for git credential.helper on Windows (#3023)
  fix(mobile): tapping threaded message in Inbox navigates to top level of channel  (#2103)
  fix(mobile): retry channel-sections startup sync when relay rate-limits cold start (#3004)
  chore(desktop): add AgentCreationPreview file-size override to unblock main CI (#3154)
  fix(cli,relay): resolve agents by verified owner (#2615)
  fix(desktop): make the test loader work on Windows (#2758)
  fix(desktop): make lint and unit-test gates work on Windows (#2943)
  ...
The 0025 slot was briefly occupied by 0025_users_agent_owner_lookup
(#2615), but it lived on main for only ~1 hour before the revert
(#3168) and never reached any deployed image (prod rolled dd222a5 ->
sha-137185e, both post-revert or pre-add). No production database has
version 25 recorded, so the number is free; a dev DB migrated inside
that window is already broken on current main (VersionMissing) and is
repaired by deleting its stale version-25 row.

Per Tyler in thread 39d1e174.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
0025 collision resolved: main landed 0025_relay_invites (#3141) while this
branch held 0025_replica_heartbeat. Heartbeat migration renumbered to 0026
(published migration bytes are immutable; two same-version migrations merge
cleanly in git but kill the sqlx migrator on a fresh DB). Also adds the
replica_heartbeat table to schema/schema.sql (desired-state), which the
original commit missed.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…ting

Replace the WAL-LSN read-side observation (pg_last_wal_replay_lsn, which
Aurora reader endpoints hide) with a portable heartbeat token, and make
the freshness proof connection-local per Rev 2.

Probe (replica_fence.rs): the ordered writer-side scan is unchanged and
load-bearing (S = clock_timestamp -> pg_stat_activity oldest-xact scan,
masked-visibility fail-closed) but now ends by committing a heartbeat
token LAST on the same pinned connection (single-row UPDATE ... RETURNING
token, epoch on replica_heartbeat, migration 0026). The single-row update
serializes all pods' probes, so tokens are globally commit-ordered and a
reader session observing token >= M has replayed everything the
three-bucket argument covers for M. Each probe retains
(token, committed_at, fence_wall) in a bounded ring; epoch changes reset
the ring, and a same-epoch token regression (restore adversary) clears it
and rotates the epoch on the writer so pre-rewind readers fail the epoch
check instead of proving stale coverage.

Routing (lib.rs): eligibility is decided per request ON the serving
connection. A routed read acquires a reader session, observes
token/epoch there (observe_heartbeat, with backend identity for live
evidence), resolves the strongest retained entry <= the observation, and
serves the page on that same session. Cursor pages keep today's
completeness math (Predicate B: cursor_ts <= proved fence_wall; thread
pages keep the full-page + tail-below-wall writer re-verification).
Head fetches are Predicate A (bounded staleness): routed only when
BUZZ_REPLICA_HEAD_MAX_AGE_SECS is set (> 0; default off, clamped to the
fence staleness gate) and the proved entry is within budget. Everything
fails closed to the writer: acquire error, missing heartbeat row, epoch
mismatch, token below the ring, over-budget entry.

Aux closure (bridge.rs): get_channel_window_with_session returns the
serving session as a ReadSession, and the window aux closure runs its
hops on that exact connection - hopping pooled reader sessions would
discard the proof.

Observability: buzz_db_route_decision{path, decision, reason} counters
(channel_head/channel_cursor/thread_head/thread_cursor/thread_eof x
replica/writer x fresh/covered/stale/reader_token_behind/
reader_validation_error/uninitialized/disabled), a
buzz_db_replica_heartbeat_age_seconds gauge, and debug logs with the
proved token and backend identity.

Probe cadence moves 5s -> 1s (head budget needs cadence well under B;
cost is one single-row UPDATE tuple of WAL per beat per pod).

Tests: resolve/record/ring/rotation unit coverage; probe + rotation
against private scratch databases; head-gate routing (off by default,
replica within budget, writer when over budget) and the existing
divergent-fixture routing suite updated to the connection-local flow;
config parse coverage for BUZZ_REPLICA_HEAD_MAX_AGE_SECS.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/main:
  fix(buzz-acp): accept id-keyed config options when resolving model switch (#2795)
  fix(desktop): probe legacy Goose install dir on Windows (#3248)
  refactor(desktop): extract install command execution into install_exec (#3251)
  Polish composer activity layout and transitions (#3151)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…ransaction

Wren's review hardening on 17ea2ff: the routed request previously ran
proof + page + participants + aux as sequential autocommit statements on
one held reader connection (the Rev 2 plan shape, sound via standby
snapshot monotonicity). This strengthens the binding from monotone to
exact and makes it robust against any future statement-level
multiplexing between relay and reader:

- proved_reader now opens the request with BEGIN ISOLATION LEVEL
  REPEATABLE READ, READ ONLY (the strongest isolation a hot standby
  supports) and observes the heartbeat as the transaction's FIRST
  statement, so the snapshot the proof was taken against is exactly the
  snapshot every follow-up statement reads from. Begin failure fails
  closed to the writer like every other proof failure.
- RouteDecision/ReadSession carry the open transaction; the page,
  participants batch, and the bridge aux closure all execute inside it.
  Dropping the ReadSession rolls the read-only transaction back and
  returns the connection to the pool.
- New Postgres-gated regression test
  routed_request_holds_one_snapshot_across_page_and_aux distinguishes
  the transaction contract from mere connection reuse: a mid-request
  commit on the replica is visible to a control autocommit session but
  must NOT be visible to the held request session. Mutation-verified:
  swapping the BEGIN to a plain (READ COMMITTED) transaction makes the
  test fail on exactly the leak assertion.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Max's E2E finding #2: inet_server_addr() alone is weaker than the
plan's production identity tuple and cannot distinguish readers behind
one address (or record which backend process served the proof). Blocked
the Aurora canary on this fast-follow.

- observe_heartbeat now reports `addr:port pid=N` (`local pid=N` on unix
  sockets), and prefixes the Aurora instance id (`aurora_server_id() @
  ...`) when the endpoint supports it.
- aurora_server_id() cannot be referenced parse-safely on plain
  Postgres, so support is probed once per process by
  reader_supports_aurora_identity on a plain autocommit checkout —
  never inside the request transaction, where undefined_function would
  abort it. SQLSTATE 42883 caches a definitive false; transient errors
  degrade to the plain tuple for that request and retry later. Identity
  is evidence, never a routing gate.
- New Postgres-gated test: the capability probe answers false (not an
  error) on plain Postgres and leaves the session usable; the existing
  probe test now pins the addr:port/pid shape.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/main:
  Publish symbol-bearing debug relay images (#3250)
  feat(tracing): add datastore tracing plumbing (#2760)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
aurora_server_id is the MySQL-family spelling; Aurora PostgreSQL
exposes aurora_db_instance_identifier() (AWS Aurora PostgreSQL user
guide; awslabs/pg-collector). As written, the capability probe would
have hit 42883 on real Aurora, cached a permanent false, and silently
stripped the instance id from canary evidence — the exact gap the
fast-follow exists to close (Wren, delta review of a472327).

The function name now lives in one const (AURORA_IDENTITY_FN) shared by
the probe and the observation query, pinned by a unit test so the
near-miss cannot recur. The real Aurora canary proves the positive
branch.

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 28, 2026 04:31
* origin/main:
  feat(relay): make Postgres pool size configurable, default 50 (#3191)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

# Conflicts:
#	crates/buzz-relay/src/main.rs
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