From ac4fa13b8e4d947071d57deb6918dcf12bf74961 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:32:16 -0400 Subject: [PATCH] perf(relay): serve relay-membership checks from the read replica (#4124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Route `Db::is_relay_member` — the membership check that runs on every authenticated HTTP request and WS AUTH — through the standard `route_read` machinery on the bounded arm, instead of adding a bespoke cache (replaces #3844). - `crates/buzz-db/src/relay_members.rs`: add `is_relay_member_on(&mut PgConnection, ...)` executor seam; the pool version delegates to it. - `crates/buzz-db/src/lib.rs`: `Db::is_relay_member` now routes via `route_read("relay_membership", RoutePredicate::Bounded)` — replica only on a proved fresh session, writer on any route rejection, writer re-run on replica query error. Exactly the shape of every other routed read. This is the one permission read served from the replica, by explicit product decision (Tyler accepted ≤1s bounded staleness on reads we choose): the fleet-wide fence guarantee (`BUZZ_REPLICA_READ_MAX_AGE_MS`, deploy target 1s) is an order of magnitude tighter than the 10s TTL proposed in #3844 and needs no invalidation machinery. Staleness is symmetric for admits and revokes. `BUZZ_REPLICA_READ_MAX_AGE_MS` unset = writer-only = kill switch. It is not precedent for routing other permission reads. ## Validation At this exact commit (`git rev-parse HEAD` confirmed in the same shell, rustc 1.95): - `cargo test -p buzz-db` — 94 passed, 0 failed - PG-gated suite single-threaded — **151 passed, 2 failed**; the 2 failures are the per-owner-limit tests broken on main by #3829 (limit 3→5, tests still seed 3) — they fail identically at base `19d57b0d4` in a pristine control checkout; separate trivial fix to follow - New PG-gated test `is_relay_member_is_bounded_routed_and_fails_closed` — divergent writer/replica fixtures prove: budget unset ⇒ writer; budget set + fresh proof ⇒ replica; over-budget entry ⇒ writer - clippy `-D warnings` + fmt clean; pre-push hooks green (desktop check/test, rust tests, tauri checks) - **Live-local pass** (TESTING.md, release binary, `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, fresh DB): - writer-only (no `READ_DATABASE_URL`): member accepted, outsider 403 `relay_membership_required`; metrics `route_decision{path="relay_membership",decision="writer",reason="disabled"}` - replica configured + `BUZZ_REPLICA_READ_MAX_AGE_MS=1000`: member accepted / outsider denied via `decision="replica",reason="fresh"`; admit visible to the routed check within ~1.2s; revoke enforced within ~1.2s - reader outage mid-flight (TCP proxy killed): member send still succeeds in <200ms via `decision="writer",reason="reader_acquire_timeout"`; outsider still denied — fails closed, no availability loss Reviewed by Wren: 9/10 minimalness, 9/10 elegance, 9.5/10 correctness at this SHA. Supersedes the 10s-cache approach in PR 3844, which should be closed unmerged once this lands. Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-db/src/lib.rs | 99 ++++++++++++++++++++++++++++- crates/buzz-db/src/relay_members.rs | 14 +++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index b74d56c9f..9b2687674 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -3999,8 +3999,33 @@ impl Db { } /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, community, pubkey).await + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => { + relay_members::is_relay_member(&self.pool, community, pubkey).await + } + } } /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. @@ -7414,6 +7439,78 @@ mod tests { drop_scratch_db(&admin, writer, &wname).await; } + /// Routed relay-membership check: budget unset ⇒ writer; budget set + + /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ + /// writer. Divergent membership rows prove which pool answered. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + /// Community separation across every routed seam, verified on /// REPLICA-SERVED reads. /// diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index bbd034915..402229cde 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -29,10 +29,22 @@ pub struct RelayMember { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { + let mut conn = pool.acquire().await?; + is_relay_member_on(&mut conn, community, pubkey).await +} + +/// [`is_relay_member`] on a specific session — the replica-routing path runs +/// the lookup on the exact reader connection whose heartbeat observation +/// proved fence coverage. +pub(crate) async fn is_relay_member_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, +) -> Result { let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(pool) + .fetch_optional(conn) .await?; Ok(row.is_some()) }