From a17243d846c887fbab862f494ec474821e6ca1cc Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Fri, 6 Nov 2026 14:48:06 -0500 Subject: [PATCH 1/5] feat(relay): runtime-tunable history-read and AUTH concurrency caps via feature flags (#9170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Goal Give operators a way to relieve database pressure in seconds, without restarting relay pods. Today every concurrency limit is an environment variable, so changing one means a rollout. During an incident, that rollout's restart sets off a reconnect storm of its own. ## What changes **Two runtime-tunable caps.** Each is read from an integer feature flag in a new process-wide evaluation context, and re-read every 5 s: | Flag | Caps | Ceiling | |---|---|---| | `buzz.relay.concurrency.history-reads` | subscription-history reads holding a reader connection (the #9132 gate) | the existing `pool − pool/5` share, which keeps readers free for authorization | | `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames running their database checks | none (unbounded today) | - **Flags can only tighten.** A positive value is clamped to the ceiling. Zero, a negative value, no value, or a provider that never initialized leaves the gate at its ceiling, which is today's behavior. Once LaunchDarkly has delivered values, they stay in force through a later outage, so a cap tightened during an incident survives a provider blip. - **Lowering never cancels admitted work.** A lowered gate admits nothing new until the work in flight falls under the new limit. - **Visibility:** `buzz_concurrency_limit{gate}` reports the applied limit (`0` means no limit), and each change logs a line. **One resizable gate.** `HistoryReadGate` becomes `buzz_db::ConcurrencyGate`, a FIFO semaphore whose limit can move anywhere in `1..=ceiling`: - Lowering it forgets free permits and records any shortfall as debt. - A permit released while there is debt is retired instead of returned. - Raising it repays debt first, then adds permits. All of this, and admission itself, settles against the debt under one lock, so a newer limit always supersedes an older one; there is no background resize task to race. Tokio hands a released permit to a queued waiter before it runs; if that waiter is then cancelled, tokio returns the permit to the semaphore directly, so admission retires any permit it gets while debt is outstanding. The AUTH cap uses the same type. **AUTH shedding.** The permit is taken after signature verification, so only real logins queue for the database checks. The wait is bounded at 2 s. A shed login gets a bare close with no `OK`, exactly like an auth timeout. I chose this from what each client does on main today: - **`OK false`:** the desktop app retries on an unjittered 60 s ladder. Mobile parks the session until the app resumes. The agent harness fails at startup. - **Bare close:** the desktop app, mobile, the agent harness and the CLI all reconnect on their jittered, capped backoff. On the relay, the shed is visible as access line `denied/concurrency_limit` and as `buzz_auth_outcomes_total{outcome="concurrency_limit"}`. **The feature-flag provider is chosen at startup.** `BUZZ_FEATURE_FLAGS_PROVIDER` takes `static` (the default), `environment` or `launchdarkly`, with `BUZZ_LAUNCHDARKLY_SDK_KEY` and an optional `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL`: - **Exactly one provider.** The value is parsed into a single provider, so there is no stacking and no fallback, and the evaluator never changes for the life of the process. The variable sits outside the `BUZZ_FEATURE_FLAG_` namespace, so it can never be read as a flag. - **Startup failures.** Local misconfiguration fails startup: an unknown provider, a missing key, or a malformed proxy URL. LaunchDarkly being unreachable, or rejecting the key, does not. The relay starts on declared defaults, the SDK keeps connecting, and `buzz_feature_flags_ready{provider}` shows whether the provider has initialized. A revoked key must not crash-loop every pod over a tuning knob. - **Every build carries the LaunchDarkly adapter.** It stays inert unless selected, so one published image serves every deployment and each deployment's configuration pins its provider. ## Design-doc amendments (`crates/buzz-feature-flags/README.md`) - **Provider selection.** The compile-time "exactly one provider" contract becomes the startup selection above. The only-one guarantee now holds per process instead of per build. - **Startup.** Failing closed now covers local configuration only. - **Guardrails.** "Flags may choose between equivalent implementations only" gains one exception: a flag may set an operational limit as long as it can only tighten it, and shedding under that limit stays retryable. - **Evaluation context.** `EvaluationContext::for_process()` adds a process context, which LaunchDarkly sees as a single `relay`-kind context keyed `relay`. ## What got simpler - **Removed the compile-time provider contract:** the nested fixture crate (with its own 2.8k-line `Cargo.lock`), its 229-line contract test, and the exact-one `compile_error!` guards it required consumers to copy. One startup parse plus a unit test replaces them. - **One gate type serves both caps.** The history-read gate became that generic type instead of gaining a sibling. - **Net change:** about −2.3k lines. ## Review-Proven Rules applied - **#1:** shedding is retryable (`PoolTimedOut` → CLOSED for history, a bare close for AUTH) and never an empty success. Provider failures are logged and surfaced through a gauge. - **#2:** the gate settles every limit change and permit release under one lock. The table test covers lowering while idle, lowering below the count held, lowering then raising before holders leave, lowering then raising past the count held, and restoring the ceiling. - **#3:** the tests drive the production call sites: `Db::get_channel_window_with_session` under a lowered and raised cap (Postgres), and `handle_auth` under a full AUTH gate. The controller test runs the real refresh loop. - **#4:** values are clamped, waits are bounded, and the refresh runs on a fixed interval with skipped missed ticks. - **#9:** AUTH shedding sends clients down their jittered reconnect backoff rather than an unjittered or terminal path (see above). ## Review rework (`92a975c8`, `3f09cb33`) - **Keys renamed** to `buzz.relay.concurrency.*`: LaunchDarkly keys cannot be renamed later, and the flag project may be shared with other services. - **Shutdown:** LaunchDarkly's final flush has no deadline of its own, so `close()` now runs it on a detached thread and waits at most 2 s, after the audit drain. (The blocking pool would not bound it: the runtime waits for blocking tasks when it drops.) - **Refresh loop:** a panicking refresh is caught, logged and counted (`buzz_concurrency_limit_refresh_panics_total`), and the next tick retries, so the caps cannot freeze silently. - **Runbook:** `docs/operator-concurrency-caps.md`, covering how to tighten a cap and the cases in which a tightened cap returns to its ceiling (mistyped or deleted flag, pod started during a provider outage). - **AUTH test:** asserts the second login passes the gate and returns its permit; the gate permit is renamed so it no longer shares a name with the NIP-FI effect permit. Gated on a Linux workstation at `92a975c8` (`3f09cb33` is rustfmt only; `cargo fmt --check` passes there): clippy `-D warnings`, `buzz-feature-flags` (50), `buzz-relay --lib --bins` (1740), the new modules under parallel `cargo test`, and the Postgres AUTH tests (27). Each new test fails under its mutant: the refresh without `catch_unwind`, `close()` with no deadline (hangs until killed), and the AUTH handler with the gate skipped. ## Testing Re-run in full at `3c289134` after review rework, on a Linux workstation: - `cargo fmt --check`; `cargo clippy -p buzz-feature-flags -p buzz-db -p buzz-relay --all-targets --all-features -D warnings`. - `buzz-feature-flags` with and without `launchdarkly` (50 and 24 tests), `buzz-db --lib` (209), and `buzz-relay --lib --bins` (1738), plus a plain parallel `cargo test` of the new relay modules. - Postgres: the `buzz-db` routed-read and history-gate tests (55), and the `buzz-relay` AUTH and connection tests (38). - `a_cancelled_waiter_cannot_reopen_a_lowered_gate` fails with the debt check in `admit` removed. - The new infra-free relay tests are added to the CI unit lane (`Justfile` and `scripts/run-tests.sh`). - E2E against a local relay at `06c39d64`: cap gauges, sequential logins and history under cap 1, a 20-login burst (19 shed with a bare close, all reconnected), and LaunchDarkly misconfiguration and unreachability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 2 +- Justfile | 4 + crates/buzz-db/src/concurrency_gate.rs | 320 ++ crates/buzz-db/src/lib.rs | 4 + crates/buzz-db/src/runtime/history_reads.rs | 86 +- crates/buzz-db/src/runtime/mod.rs | 21 +- crates/buzz-db/src/runtime/tests.rs | 37 +- crates/buzz-feature-flags/Cargo.toml | 1 - crates/buzz-feature-flags/README.md | 211 +- crates/buzz-feature-flags/src/launchdarkly.rs | 49 +- crates/buzz-feature-flags/src/lib.rs | 35 +- .../relay-feature-selection/Cargo.lock | 2797 ----------------- .../relay-feature-selection/Cargo.toml | 15 - .../relay-feature-selection/src/lib.rs | 103 - .../tests/relay_feature_selection_contract.rs | 229 -- crates/buzz-relay/Cargo.toml | 3 + crates/buzz-relay/src/access.rs | 1 + crates/buzz-relay/src/concurrency_limits.rs | 288 ++ crates/buzz-relay/src/connection.rs | 1 + crates/buzz-relay/src/feature_flags.rs | 359 +++ crates/buzz-relay/src/handlers/auth.rs | 107 + crates/buzz-relay/src/lib.rs | 4 + crates/buzz-relay/src/main.rs | 17 + crates/buzz-relay/src/metrics.rs | 6 +- crates/buzz-relay/src/state.rs | 6 + docs/operator-concurrency-caps.md | 60 + scripts/run-tests.sh | 7 + 27 files changed, 1367 insertions(+), 3406 deletions(-) create mode 100644 crates/buzz-db/src/concurrency_gate.rs delete mode 100644 crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock delete mode 100644 crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml delete mode 100644 crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs delete mode 100644 crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs create mode 100644 crates/buzz-relay/src/concurrency_limits.rs create mode 100644 crates/buzz-relay/src/feature_flags.rs create mode 100644 docs/operator-concurrency-caps.md diff --git a/Cargo.lock b/Cargo.lock index 1b9802dd82..8ffabd2da6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1265,7 +1265,6 @@ dependencies = [ "http", "launchdarkly-server-sdk", "serde_json", - "tempfile", "thiserror 2.0.18", "tokio", ] @@ -1444,6 +1443,7 @@ dependencies = [ "buzz-datastore-tracing", "buzz-db", "buzz-deletion", + "buzz-feature-flags", "buzz-media", "buzz-pubsub", "buzz-relay-mesh", diff --git a/Justfile b/Justfile index a80738052d..62c022136d 100644 --- a/Justfile +++ b/Justfile @@ -645,6 +645,10 @@ test-unit: + test(/^api::nip_fi::/)' # Invitation drain/cutoff boundary contracts are infra-free. cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)' + # Runtime concurrency caps: the flag controller, provider selection and + # the AUTH shed through handle_auth, whose stub database refuses at + # once. Infra-free. Keep scripts/run-tests.sh in step. + cargo nextest run -p buzz-relay --lib -E 'test(/^concurrency_limits::tests::/) + test(/^feature_flags::tests::/) + test(=handlers::auth::tests::auth_cap_sheds_a_verified_login_with_a_bare_close)' # Relay read caches: the admitted-connection index, the shared-read # gates (open set, restriction, tenant host), the community-active # cache, tenant host cache, the diff --git a/crates/buzz-db/src/concurrency_gate.rs b/crates/buzz-db/src/concurrency_gate.rs new file mode 100644 index 0000000000..ed7ccf9037 --- /dev/null +++ b/crates/buzz-db/src/concurrency_gate.rs @@ -0,0 +1,320 @@ +//! A FIFO concurrency cap whose limit an operator can change at runtime. +//! +//! The gate is created with a ceiling, its startup limit. [`ConcurrencyGate::set_limit`] +//! moves the limit anywhere in `1..=ceiling` without a restart: +//! +//! - Raising it releases permits to waiting callers at once. +//! - Lowering it takes back permits nobody holds at once. Permits still held +//! above the new limit are retired as their holders release them, so no +//! in-flight work is cancelled and no new work is admitted until the count +//! held falls under the new limit. +//! +//! Every limit change, every permit release and every admission settles +//! against the shrink debt under one lock, so a newer limit always supersedes +//! an older one: there is no background resize to race, and a permit tokio +//! returns from a cancelled waiter is retired rather than admitted. + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +/// A concurrency cap with a runtime-adjustable limit. Clones share one cap. +#[derive(Clone, Debug)] +pub struct ConcurrencyGate { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + permits: Arc, + ceiling: usize, + state: Mutex, +} + +#[derive(Debug)] +struct LimitState { + /// The limit last set: the permits that exist, held or free, once `debt` + /// is repaid. + limit: usize, + /// Permits held above the current limit. Each release retires one of them + /// instead of returning it to the semaphore. + debt: usize, +} + +impl Inner { + fn state(&self) -> MutexGuard<'_, LimitState> { + // The guarded section never panics, but a poisoned lock must not + // wedge admission for the rest of the process. + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ConcurrencyGate { + /// A gate whose ceiling and startup limit are `ceiling`, raised to at + /// least one and clamped to [`Semaphore::MAX_PERMITS`]. + pub fn new(ceiling: usize) -> Self { + let ceiling = ceiling.clamp(1, Semaphore::MAX_PERMITS); + Self { + inner: Arc::new(Inner { + permits: Arc::new(Semaphore::new(ceiling)), + ceiling, + state: Mutex::new(LimitState { + limit: ceiling, + debt: 0, + }), + }), + } + } + + /// A gate with no practical limit until one is set. + pub fn unbounded() -> Self { + Self::new(Semaphore::MAX_PERMITS) + } + + /// The highest limit this gate accepts: its startup limit. + pub fn ceiling(&self) -> usize { + self.inner.ceiling + } + + /// The current limit. + pub fn limit(&self) -> usize { + self.inner.state().limit + } + + /// Set the limit to `requested`, clamped to `1..=ceiling`, and return the + /// limit applied. + pub fn set_limit(&self, requested: usize) -> usize { + let target = requested.clamp(1, self.inner.ceiling); + let mut state = self.inner.state(); + if target < state.limit { + let excess = state.limit - target; + let retired = self.inner.permits.forget_permits(excess); + state.debt += excess - retired; + } else if target > state.limit { + let added = target - state.limit; + let repaid = added.min(state.debt); + state.debt -= repaid; + self.inner.permits.add_permits(added - repaid); + } + state.limit = target; + target + } + + /// Wait up to `wait` for a permit, in arrival order. `None` means the + /// caller must be shed. + pub async fn admit(&self, wait: Duration) -> Option { + let deadline = tokio::time::Instant::now() + wait; + loop { + let permit = + tokio::time::timeout_at(deadline, Arc::clone(&self.inner.permits).acquire_owned()) + .await + .ok()? + .ok()?; + let mut state = self.inner.state(); + if state.debt == 0 { + drop(state); + return Some(GatePermit { + permit: Some(permit), + inner: Arc::clone(&self.inner), + }); + } + // While there is debt every permit should be held, so this one + // leaked back without a `GatePermit` release: tokio returns a + // permit it had assigned to a waiter that was then cancelled. + // Retire it and keep waiting. + state.debt -= 1; + permit.forget(); + } + } + + /// Permits free to admit right now (tests). + #[cfg(test)] + pub(crate) fn available(&self) -> usize { + self.inner.permits.available_permits() + } +} + +/// One admission through a [`ConcurrencyGate`], released on drop. +#[derive(Debug)] +pub struct GatePermit { + permit: Option, + inner: Arc, +} + +impl Drop for GatePermit { + fn drop(&mut self) { + let Some(permit) = self.permit.take() else { + return; + }; + let mut state = self.inner.state(); + if state.debt > 0 { + state.debt -= 1; + permit.forget(); + } else { + // Release under the lock so a concurrent `set_limit` sees either + // the debt or the returned permit, never neither. + drop(permit); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WAIT: Duration = Duration::from_secs(1); + + #[test] + fn set_limit_clamps_to_one_through_the_ceiling() { + let gate = ConcurrencyGate::new(4); + assert_eq!(gate.set_limit(0), 1); + assert_eq!(gate.set_limit(9), 4); + assert_eq!(gate.limit(), 4); + assert_eq!(ConcurrencyGate::new(0).ceiling(), 1); + } + + #[tokio::test(start_paused = true)] + async fn a_full_gate_sheds_after_the_wait_and_reopens_on_release() { + let gate = ConcurrencyGate::new(1); + let held = gate.admit(WAIT).await.expect("first caller is admitted"); + let started = tokio::time::Instant::now(); + assert!(gate.admit(WAIT).await.is_none(), "a full gate sheds"); + assert_eq!(started.elapsed(), WAIT); + drop(held); + assert!(gate.admit(WAIT).await.is_some(), "a release admits again"); + } + + /// Each row lowers or raises the limit while `held` permits are out, then + /// releases them all. `admits` is how many more callers get in right after + /// the change; `settled` is the free count once every holder has left. + #[tokio::test(start_paused = true)] + async fn limit_changes_respect_permits_already_held() { + struct Case { + name: &'static str, + held: usize, + limits: &'static [usize], + admits: usize, + settled: usize, + } + let cases = [ + Case { + name: "lower while idle", + held: 0, + limits: &[2], + admits: 2, + settled: 2, + }, + Case { + name: "lower below the count held", + held: 4, + limits: &[2], + admits: 0, + settled: 2, + }, + Case { + name: "lower, then raise before holders leave", + held: 4, + limits: &[1, 3], + admits: 0, + settled: 3, + }, + Case { + name: "lower, then raise past the count held", + held: 3, + limits: &[1, 5], + admits: 2, + settled: 5, + }, + Case { + name: "raise back to the ceiling", + held: 2, + limits: &[2, 6], + admits: 4, + settled: 6, + }, + ]; + for case in cases { + let gate = ConcurrencyGate::new(6); + let mut held = Vec::new(); + for _ in 0..case.held { + held.push(gate.admit(WAIT).await.expect("under the ceiling")); + } + for &limit in case.limits { + gate.set_limit(limit); + } + let mut admitted = Vec::new(); + while let Some(permit) = gate.admit(WAIT).await { + admitted.push(permit); + } + assert_eq!(admitted.len(), case.admits, "{}: admitted", case.name); + drop(admitted); + drop(held); + assert_eq!(gate.available(), case.settled, "{}: settled", case.name); + assert_eq!( + gate.limit(), + case.settled, + "{}: limit matches the permits that exist", + case.name + ); + } + } + + #[tokio::test(start_paused = true)] + async fn raising_the_limit_wakes_a_waiting_caller() { + let gate = ConcurrencyGate::new(2); + gate.set_limit(1); + let _held = gate.admit(WAIT).await.expect("one permit"); + let waiter = tokio::spawn({ + let gate = gate.clone(); + async move { gate.admit(WAIT).await.is_some() } + }); + tokio::task::yield_now().await; + gate.set_limit(2); + assert!(waiter.await.expect("join"), "the raise admits the waiter"); + } + + /// Tokio hands a released permit to the first waiter before that waiter + /// runs. If the waiter is cancelled after a shrink, tokio returns the + /// permit to the semaphore directly, never through `GatePermit::drop`; + /// the next admission must retire it against the debt instead. + #[tokio::test(start_paused = true)] + async fn a_cancelled_waiter_cannot_reopen_a_lowered_gate() { + let gate = ConcurrencyGate::new(2); + let first = gate.admit(WAIT).await.expect("first holder"); + let second = gate.admit(WAIT).await.expect("second holder"); + let waiter = tokio::spawn({ + let gate = gate.clone(); + async move { gate.admit(WAIT).await.is_some() } + }); + tokio::task::yield_now().await; // the waiter queues + drop(first); // tokio assigns this permit to the queued waiter + gate.set_limit(1); // nothing free to forget: one permit of debt + waiter.abort(); // cancelled before it runs again + assert!(waiter.await.expect_err("aborted").is_cancelled()); + + assert!( + gate.admit(WAIT).await.is_none(), + "one holder already fills limit 1" + ); + drop(second); + let admitted = gate.admit(WAIT).await.expect("the holder left"); + assert!(gate.admit(WAIT).await.is_none(), "limit 1 holds"); + drop(admitted); + assert_eq!( + gate.available(), + 1, + "the permits that exist match the limit" + ); + } + + #[test] + fn an_unbounded_gate_can_be_capped() { + let gate = ConcurrencyGate::unbounded(); + assert_eq!(gate.ceiling(), Semaphore::MAX_PERMITS); + assert_eq!(gate.set_limit(3), 3); + assert_eq!(gate.available(), 3); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index d83c5f38c9..b362b9fca5 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -46,6 +46,9 @@ macro_rules! superseded_with_mentions_sql { mod runtime; mod store; +/// Concurrency caps whose limit can change at runtime. +pub mod concurrency_gate; + /// Database error types. pub mod error; @@ -102,6 +105,7 @@ pub use community::{ CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchiveCommunityResult, UnarchivedCommunityRecord, }; +pub use concurrency_gate::{ConcurrencyGate, GatePermit}; pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus, DeletionTarget}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; diff --git a/crates/buzz-db/src/runtime/history_reads.rs b/crates/buzz-db/src/runtime/history_reads.rs index ec2a59c91d..6942f4d208 100644 --- a/crates/buzz-db/src/runtime/history_reads.rs +++ b/crates/buzz-db/src/runtime/history_reads.rs @@ -17,54 +17,30 @@ //! in that window fall back to the writer as before; history reads on the //! saturated pool are rejected, not moved to the writer. -use std::sync::Arc; use std::time::Duration; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use crate::concurrency_gate::{ConcurrencyGate, GatePermit}; -/// Concurrency cap on history reads holding a reader connection, one per -/// `Db` (shared by its clones, so one per relay process). -#[derive(Clone, Debug)] -pub(crate) struct HistoryReadGate { - permits: Arc, +/// How long a history read waits for a permit before it is rejected. The +/// gate is FIFO, so this bounds queueing delay under a burst; a read that +/// would wait longer is shed and the client retries. +pub(crate) const PERMIT_WAIT: Duration = Duration::from_secs(1); + +/// History transactions allowed at once: the pool less one fifth (at least +/// one) left to authorization and bootstrap reads, and never below one. +/// 25 → 20, 5 → 4, 2 → 1, 1 → 1. This is the gate's ceiling: an operator may +/// lower the limit at runtime but never raise it past the share that keeps +/// readers free for authorization. +pub(crate) fn cap(read_max_connections: u32) -> u32 { + read_max_connections + .saturating_sub((read_max_connections / 5).max(1)) + .max(1) } -impl HistoryReadGate { - /// How long a history read waits for a permit before it is rejected. The - /// semaphore is FIFO, so this bounds queueing delay under a burst; a read - /// that would wait longer is shed and the client retries. - pub(crate) const PERMIT_WAIT: Duration = Duration::from_secs(1); - - /// A gate for a reader pool of `read_max_connections`. - pub(crate) fn for_read_pool(read_max_connections: u32) -> Self { - Self { - permits: Arc::new(Semaphore::new(Self::cap(read_max_connections) as usize)), - } - } - - /// History transactions allowed at once: the pool less one fifth (at - /// least one) left to authorization and bootstrap reads, and never below - /// one. 25 → 20, 5 → 4, 2 → 1, 1 → 1. - pub(crate) fn cap(read_max_connections: u32) -> u32 { - read_max_connections - .saturating_sub((read_max_connections / 5).max(1)) - .max(1) - } - - /// Wait up to [`Self::PERMIT_WAIT`] for a permit. `None` means the read - /// must be rejected. - pub(crate) async fn admit(&self) -> Option { - tokio::time::timeout(Self::PERMIT_WAIT, self.permits.clone().acquire_owned()) - .await - .ok()? - .ok() - } - - /// Permits not currently held (tests). - #[cfg(test)] - pub(crate) fn available(&self) -> usize { - self.permits.available_permits() - } +/// The history-read gate for a reader pool of `read_max_connections`, one per +/// `Db` (shared by its clones, so one per relay process). +pub(crate) fn gate_for_read_pool(read_max_connections: u32) -> ConcurrencyGate { + ConcurrencyGate::new(cap(read_max_connections) as usize) } /// A proved reader transaction plus the history permit it was admitted @@ -75,13 +51,13 @@ impl HistoryReadGate { /// module docs). pub(crate) struct ReaderTx { tx: sqlx::Transaction<'static, sqlx::Postgres>, - _permit: Option, + _permit: Option, } impl ReaderTx { pub(crate) fn new( tx: sqlx::Transaction<'static, sqlx::Postgres>, - permit: Option, + permit: Option, ) -> Self { Self { tx, @@ -106,26 +82,12 @@ impl std::ops::DerefMut for ReaderTx { #[cfg(test)] mod tests { - use super::HistoryReadGate; + use super::cap; #[test] fn cap_leaves_a_fifth_of_the_reader_pool_and_never_reaches_zero() { - for (pool, cap) in [(25, 20), (10, 8), (5, 4), (4, 3), (2, 1), (1, 1), (0, 1)] { - assert_eq!(HistoryReadGate::cap(pool), cap, "pool={pool}"); + for (pool, expected) in [(25, 20), (10, 8), (5, 4), (4, 3), (2, 1), (1, 1), (0, 1)] { + assert_eq!(cap(pool), expected, "pool={pool}"); } } - - #[tokio::test(start_paused = true)] - async fn admit_rejects_after_the_bounded_wait_and_reopens_on_release() { - let gate = HistoryReadGate::for_read_pool(1); - let held = gate.admit().await.expect("first read is admitted"); - let started = tokio::time::Instant::now(); - assert!(gate.admit().await.is_none(), "a full gate rejects"); - assert_eq!(started.elapsed(), HistoryReadGate::PERMIT_WAIT); - drop(held); - assert!( - gate.admit().await.is_some(), - "a released permit admits again" - ); - } } diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index a8b177a22f..9f78fde1d8 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -306,7 +306,7 @@ pub struct Db { /// Concurrency cap on replica-served subscription-history reads, sized /// from [`Db::read_max_connections`] (see [`history_reads`]). Shared /// across `Db` clones. - pub(crate) history_reads: history_reads::HistoryReadGate, + pub(crate) history_reads: crate::concurrency_gate::ConcurrencyGate, } /// The session that served (or will serve) a routed read, so follow-up @@ -827,7 +827,7 @@ impl Db { fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), replica_read_max_age, reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - history_reads: history_reads::HistoryReadGate::for_read_pool(read_max_connections), + history_reads: history_reads::gate_for_read_pool(read_max_connections), }) } @@ -1380,9 +1380,7 @@ impl Db { push_enqueue: None, max_connections: pool.options().get_max_connections(), read_max_connections: pool.options().get_max_connections(), - history_reads: history_reads::HistoryReadGate::for_read_pool( - pool.options().get_max_connections(), - ), + history_reads: history_reads::gate_for_read_pool(pool.options().get_max_connections()), pool, read_pool: None, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), @@ -1404,7 +1402,7 @@ impl Db { max_connections: pool.options().get_max_connections(), read_max_connections: read_pool.options().get_max_connections(), pool, - history_reads: history_reads::HistoryReadGate::for_read_pool( + history_reads: history_reads::gate_for_read_pool( read_pool.options().get_max_connections(), ), read_pool: Some(read_pool), @@ -1482,6 +1480,13 @@ impl Db { self.read_pool.is_some() } + /// The cap on concurrent subscription-history reads (see + /// [`history_reads`]). Its ceiling is the share of the reader pool that + /// leaves room for authorization reads; operators may lower it at runtime. + pub fn history_read_gate(&self) -> &crate::concurrency_gate::ConcurrencyGate { + &self.history_reads + } + /// Open a reader request transaction and complete the connection-local /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ /// ONLY`, then observe the heartbeat token/epoch as the transaction's @@ -1862,7 +1867,7 @@ impl Db { /// [`history_reads`] permit for as long as their reader transaction, so /// they never fill the reader pool, and they are never moved to the /// writer merely because the reader is busy: a history read that waits - /// out its permit ([`history_reads::HistoryReadGate::PERMIT_WAIT`]) or + /// out its permit ([`history_reads::PERMIT_WAIT`]) or /// its reader acquire ([`Db::READER_ACQUIRE_TIMEOUT`]) on a saturated /// reader pool ([`Db::reader_saturated`]) is rejected with /// [`sqlx::Error::PoolTimedOut`], which the relay answers with its @@ -1922,7 +1927,7 @@ impl Db { } let history = operation == observability::ReaderOperation::SubscriptionHistory; let permit = if history { - let Some(permit) = self.history_reads.admit().await else { + let Some(permit) = self.history_reads.admit(history_reads::PERMIT_WAIT).await else { return Err(Self::reject_history_read(path, "history_read_cap")); }; Some(permit) diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 4311f22e4b..231d2feb1e 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -1616,7 +1616,7 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { let cap = db.history_reads.available(); assert_eq!( cap, - super::history_reads::HistoryReadGate::cap(db.read_max_connections) as usize + super::history_reads::cap(db.read_max_connections) as usize ); let head = db @@ -1638,7 +1638,12 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { // Fill the gate: every remaining permit is held elsewhere. let mut others = Vec::new(); for _ in 1..cap { - others.push(db.history_reads.admit().await.expect("free permit")); + others.push( + db.history_reads + .admit(super::history_reads::PERMIT_WAIT) + .await + .expect("free permit"), + ); } let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); @@ -1655,7 +1660,7 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { "the rejection must be the retryable pool timeout; got {rejected:?}" ); assert!( - started.elapsed() >= super::history_reads::HistoryReadGate::PERMIT_WAIT, + started.elapsed() >= super::history_reads::PERMIT_WAIT, "the read must wait out the bounded permit wait before rejecting" ); let decisions: Vec<(String, String)> = snapshotter @@ -1684,13 +1689,37 @@ async fn history_permit_follows_the_read_session_and_a_full_gate_rejects() { drop(session); assert_eq!(db.history_reads.available(), 1); let (_window, session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) .await .expect("a released permit admits the next read"); assert!(session.is_replica()); drop(session); drop(others); assert_eq!(db.history_reads.available(), cap); + + // An operator lowering the cap at runtime holds new reads to the new + // limit while one is in flight, and raising it readmits them. + let (_window, held) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("routed cursor window"); + assert_eq!(db.history_read_gate().set_limit(1), 1); + let rejected = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor.clone()), None) + .await + .err() + .expect("a lowered cap must reject the next history read"); + assert!( + matches!(rejected, DbError::Sqlx(sqlx::Error::PoolTimedOut)), + "a lowered cap sheds as retryable; got {rejected:?}" + ); + assert_eq!(db.history_read_gate().set_limit(cap), cap); + let (_window, second) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("a raised cap admits the next read"); + drop((held, second)); + assert_eq!(db.history_reads.available(), cap); } /// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request diff --git a/crates/buzz-feature-flags/Cargo.toml b/crates/buzz-feature-flags/Cargo.toml index 258b54cf6d..a989789fac 100644 --- a/crates/buzz-feature-flags/Cargo.toml +++ b/crates/buzz-feature-flags/Cargo.toml @@ -18,5 +18,4 @@ thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } -tempfile = "3" tokio = { workspace = true } diff --git a/crates/buzz-feature-flags/README.md b/crates/buzz-feature-flags/README.md index 6210701a6e..a9247fe3f9 100644 --- a/crates/buzz-feature-flags/README.md +++ b/crates/buzz-feature-flags/README.md @@ -15,7 +15,8 @@ Provider-neutral types: - `BooleanFlag` - `IntegerFlag` (`i64`, including negative values) -- `EvaluationContext` (required `CommunityId`, optional actor `PublicKey`) +- `EvaluationContext` (a `CommunityId` with an optional actor `PublicKey`, or + `for_process()` for process-wide settings such as concurrency caps) - `FlagEvaluator` (typed `evaluate_bool` and `evaluate_int`) - `StaticEvaluator` (always returns declared defaults) - `EnvironmentEvaluator` (process-wide immutable environment snapshot) @@ -46,164 +47,38 @@ Normalized names must be unique across declared flags. Collisions (for example uniqueness belongs in a future flag registry; this crate does not add a registry yet. -## Relay Artifact Contract (not yet wired into `buzz-relay`) +## Relay Provider Selection -The intended composition root is the outer relay artifact. It must select -exactly one provider at compile time: +`buzz-relay` builds exactly one evaluator at startup, chosen by +`BUZZ_FEATURE_FLAGS_PROVIDER` (outside the `BUZZ_FEATURE_FLAG_` namespace, so +it can never be read as a flag): -- `static-feature-flags` -- `environment-feature-flags` -- `launchdarkly-feature-flags` +| Value | Evaluator | +|-------|-----------| +| unset, `static` | `StaticEvaluator` | +| `environment` | `EnvironmentEvaluator::from_process_environment()` | +| `launchdarkly` | `LaunchDarklyEvaluator`, from `BUZZ_LAUNCHDARKLY_SDK_KEY` and the optional Relay Proxy URL `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL` | -`launchdarkly-feature-flags` is the only relay feature that forwards to -`buzz-feature-flags/launchdarkly`. `static-feature-flags` and -`environment-feature-flags` keep the reusable crate vendor-neutral and still -allow adapter code to compile in tests. +The value is parsed into one provider; there is no stacking, no precedence and +no fallback from one provider to another, and the evaluator never changes for +the life of the process. Every relay build carries the LaunchDarkly adapter +(`buzz-feature-flags/launchdarkly`), which stays inert unless selected, so one +published artifact serves every deployment and the deployment's configuration +pins its provider. -The relay artifact boundary, not this reusable crate, owns exact-one -enforcement. Consumer integration PRs must carry `exact-one compile_error guards` -verbatim/equivalently. That keeps `buzz-feature-flags` free to compile multiple -adapters for tests while ensuring a staging or production relay artifact cannot -choose a different evaluator at runtime. +The composition root lives in `crates/buzz-relay/src/feature_flags.rs`. It +constructs the evaluator once and hands consumers the shared +`Arc`, keeping the concrete owner to drain environment +diagnostics and to call LaunchDarkly's `close()` at shutdown. -This repository currently adds the crate and adapter, but does **not** yet wire -relay `AppState` or handlers to consume it. `crates/buzz-relay/Cargo.toml` -still has no dependency on `buzz-feature-flags`, so current workspace/default -builds remain intentionally unchanged. The executable contract for the future -relay integration lives in -`tests/fixtures/relay-feature-selection`. - -## Composition Root Shape (artifact boundary) - -Construct once at process startup, then inject shared `Arc` -into server components. - -```rust -use std::sync::Arc; -use std::time::Duration; - -use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator}; -#[cfg(feature = "launchdarkly-feature-flags")] -use buzz_feature_flags::launchdarkly::{ - LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, - LaunchDarklyStartError, -}; - -#[cfg(not(any( - feature = "static-feature-flags", - feature = "environment-feature-flags", - feature = "launchdarkly-feature-flags", -)))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any( - all(feature = "static-feature-flags", feature = "environment-feature-flags"), - all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), - all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), -))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] -#[derive(Default)] -struct FeatureFlagRuntimeConfig; - -#[cfg(feature = "launchdarkly-feature-flags")] -struct FeatureFlagRuntimeConfig { - sdk_key: String, - relay_proxy_endpoint: Option, -} - -struct RelayCompositionRoot { - feature_flags: Arc, - environment_diagnostics: Option>, - #[cfg(feature = "launchdarkly-feature-flags")] - launchdarkly_lifecycle: Option>, -} - -#[cfg(feature = "launchdarkly-feature-flags")] -enum RelayStartupError { - LaunchDarklyInit(LaunchDarklyInitError), - LaunchDarklyStart(LaunchDarklyStartError), -} - -#[cfg(feature = "static-feature-flags")] -fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { - RelayCompositionRoot { - feature_flags: Arc::new(StaticEvaluator), - environment_diagnostics: None, - } -} - -#[cfg(feature = "environment-feature-flags")] -fn build_feature_flag_evaluator(config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot { - let _ = config; - let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); - let feature_flags: Arc = owner.clone(); - RelayCompositionRoot { - feature_flags, - environment_diagnostics: Some(owner), - } -} - -#[cfg(feature = "launchdarkly-feature-flags")] -async fn build_feature_flag_evaluator( - config: FeatureFlagRuntimeConfig, -) -> Result { - let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); - if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { - runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); - } - - let owner = Arc::new( - LaunchDarklyEvaluator::from_runtime_config(runtime_config) - .map_err(RelayStartupError::LaunchDarklyInit)?, - ); - owner - .start_with_default_executor_and_wait(Duration::from_secs(5)) - .await - .map_err(RelayStartupError::LaunchDarklyStart)?; - - let feature_flags: Arc = owner.clone(); - Ok(RelayCompositionRoot { - feature_flags, - environment_diagnostics: None, - launchdarkly_lifecycle: Some(owner), - }) -} -``` - -Each relay feature produces exactly one `build_feature_flag_evaluator` path, and -runtime config carries only the selected provider's settings. There is no -runtime provider enum, no provider-name environment variable, and no fallback -selector. - -Startup is fail-closed: if the selected provider fails initialization (including -`start_with_default_executor_and_wait` for LaunchDarkly), relay startup must -return an error and stop rather than falling back to another evaluator. - -The extra concrete `Arc` is an owner/lifecycle handle to that same evaluator, -not a layered provider. Consumers receive only the cloned `Arc` view. Retain the environment owner to drain diagnostics after -evaluations, and retain the LaunchDarkly owner so shutdown can call `close()`. - -`EnvironmentEvaluator` snapshots environment values once at construction, -retains only `BUZZ_FEATURE_FLAG_` entries, ignores `EvaluationContext`, and -never rereads process environment during evaluation. - -For deterministic tests or embedding, `EnvironmentEvaluator::from_pairs(...)` -constructs the same immutable snapshot from explicit key/value pairs. - -Environment entries are untyped at construction, so malformed present values -are detected when a typed flag is first evaluated. `take_diagnostics()` drains -each sanitized diagnostic at most once per flag key and expected type. The -composition root should retain the concrete environment evaluator and emit each -drained diagnostic as a warning through the application's logging stack. -Diagnostics contain no raw value or environment-variable name; the value -snapshot itself remains immutable. +Startup fails on a local configuration error: an unknown provider, +`launchdarkly` without an SDK key, or a malformed Relay Proxy URL. It does not +fail when LaunchDarkly is unreachable: the relay waits briefly for flag data, +then serves declared defaults while the SDK keeps connecting, and +`buzz_feature_flags_ready{provider}` reports whether the provider has +initialized. Once initialized, LaunchDarkly serves its last-known values +through a later outage, so the gauge stays 1 and flag values do not revert to +defaults until the SDK reconnects and receives new data. ## `buzz-db` Boundary (approved correction) @@ -253,8 +128,9 @@ operation already receives an authoritative authenticated actor for domain behavior. Feature targeting must not add actor/pubkey parameters to otherwise actor-free DB APIs. -For LaunchDarkly, the adapter context always includes kind `community` plus -optional global kind `pubkey`. Percentage rollouts must explicitly choose a +For LaunchDarkly, a community evaluation always includes kind `community` plus +optional global kind `pubkey`; a process evaluation is a single kind `relay` +context keyed `relay`. Percentage rollouts must explicitly choose a kind present in evaluation: rollouts must set `contextKind` to a kind present in evaluation (`community` or `pubkey`) when they are intended to bucket Buzz traffic. LaunchDarkly rollouts where configuration omits `contextKind` defaults @@ -265,7 +141,11 @@ zero and pick the first positive-weight variation. - Evaluation context inputs are authoritative server-resolved values (`community`, optional `actor`), not client-supplied targeting attributes. -- Flags may choose between **equivalent** implementations only. +- Flags may choose between **equivalent** implementations only, with one + exception: an operational limit (a concurrency cap) may be set by a flag + when the flag can only *tighten* it. The consumer clamps the value to a + ceiling fixed at startup, and a missing, non-positive or unavailable value + means the ceiling. Shedding under such a limit must be retryable. - Flags must not weaken authorization, tenant isolation, ordering guarantees, transaction/consistency behavior, or schema invariants. - Declared defaults choose the established safe path. @@ -283,12 +163,13 @@ zero and pick the first positive-weight variation. - LaunchDarkly numbers are SDK `f64` values. Integer evaluation accepts only mathematically integral values that convert to `i64` exactly from that `f64` representation; otherwise it returns each flag's declared default. -- Startup chooses one evaluator at compile time (`StaticEvaluator`, +- Startup chooses one evaluator from configuration (`StaticEvaluator`, `EnvironmentEvaluator`, or LaunchDarkly); provider precedence/stacking is out of scope. - If LaunchDarkly is used, call evaluator `close()` during process shutdown. - `close()` blocks the calling thread while it flushes analytics, so async - shutdown code should offload it to a blocking shutdown path. + `close()` blocks the calling thread while it flushes analytics and has no + deadline of its own, so async shutdown code should run it off the runtime + and bound the wait (the relay gives it a detached thread and 2 s). - Safety invariants must never rely on remote-flag availability. ## Build and Test Expectations @@ -319,16 +200,6 @@ cargo tree -p buzz-feature-flags --features launchdarkly Testing expectations: - Run provider-neutral tests and LaunchDarkly-feature tests for this crate. -- Run the relay-artifact compile contract in - `tests/relay_feature_selection_contract.rs`, including zero-feature and - multiple-feature rejection. -- Keep `tests/fixtures/relay-feature-selection/Cargo.lock` checked in for the - nested `--locked` compile contract. Regenerate it from repo root with: - -```bash -cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml -``` - - When `buzz-db` adopts flag-gated query selection, add parity tests proving old/new query paths return equivalent rows, ordering, and transactional behavior for the same inputs. diff --git a/crates/buzz-feature-flags/src/launchdarkly.rs b/crates/buzz-feature-flags/src/launchdarkly.rs index f5ce4a33c8..b966ab9632 100644 --- a/crates/buzz-feature-flags/src/launchdarkly.rs +++ b/crates/buzz-feature-flags/src/launchdarkly.rs @@ -10,6 +10,9 @@ use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag}; const COMMUNITY_CONTEXT_KIND: &str = "community"; const PUBKEY_CONTEXT_KIND: &str = "pubkey"; +/// Kind and key of the single context that process-wide settings evaluate. +const RELAY_CONTEXT_KIND: &str = "relay"; +const RELAY_CONTEXT_KEY: &str = "relay"; /// Runtime inputs required to initialize a LaunchDarkly-backed evaluator. #[derive(Clone)] @@ -145,6 +148,12 @@ impl LaunchDarklyEvaluator { } } + /// Whether the SDK has received flag data. Until it has, every evaluation + /// returns the flag's declared default. + pub fn initialized(&self) -> bool { + self.client.initialized() + } + /// Gracefully stop background tasks and flush pending analytics events. /// /// This call blocks the current thread until pending analytics delivery @@ -264,10 +273,16 @@ fn authority_has_explicit_port(authority: &Authority) -> bool { /// Build the LaunchDarkly context used for evaluation. /// -/// The adapter always includes a `community` kind context and optionally adds a -/// global `pubkey` kind context when an actor is present. +/// A community evaluation always includes a `community` kind context and +/// optionally adds a global `pubkey` kind context when an actor is present. +/// The process context is a single `relay` kind context keyed `relay`. fn launchdarkly_context(context: &EvaluationContext) -> Result { - let mut community = ContextBuilder::new(context.community().to_string()); + let Some(community_id) = context.community() else { + let mut relay = ContextBuilder::new(RELAY_CONTEXT_KEY); + relay.kind(RELAY_CONTEXT_KIND); + return relay.build(); + }; + let mut community = ContextBuilder::new(community_id.to_string()); community.kind(COMMUNITY_CONTEXT_KIND); let community = community.build()?; @@ -665,6 +680,34 @@ mod tests { evaluator.close(); } + #[tokio::test] + async fn process_context_targets_relay_kind_and_sees_updates_without_restart() { + let test_data = TestData::new(); + test_data.update( + FlagBuilder::new("process-int") + .variations([FlagValue::from(0_i64), FlagValue::from(5_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("relay"), "relay", 1), + ); + let evaluator = started_evaluator(&test_data).await; + let flag = IntegerFlag::new("process-int", -1); + let context = EvaluationContext::for_process(); + + assert_eq!(evaluator.evaluate_int(flag, &context), 5); + test_data.update( + FlagBuilder::new("process-int") + .variations([FlagValue::from(0_i64), FlagValue::from(9_i64)]) + .fallthrough_variation_index(0) + .variation_index_for_key(kind("relay"), "relay", 1), + ); + assert_eq!( + evaluator.evaluate_int(flag, &context), + 9, + "a running evaluator serves the updated value" + ); + evaluator.close(); + } + #[tokio::test] async fn actor_context_targets_integer_variation_by_community_and_pubkey() { let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); diff --git a/crates/buzz-feature-flags/src/lib.rs b/crates/buzz-feature-flags/src/lib.rs index c9169b176b..9b3740a427 100644 --- a/crates/buzz-feature-flags/src/lib.rs +++ b/crates/buzz-feature-flags/src/lib.rs @@ -14,22 +14,33 @@ pub use flags::{BooleanFlag, IntegerFlag}; /// LaunchDarkly-backed evaluator adapter. pub mod launchdarkly; -/// Stable targeting context for one Buzz community and an optional actor. +/// Stable targeting context: one Buzz community and an optional actor, or the +/// relay process itself. /// -/// Community is always required because the same Nostr identity can participate -/// in multiple isolated Buzz communities. Community-only contexts support -/// background and system evaluations that have no actor. +/// Community contexts are the norm, because the same Nostr identity can +/// participate in multiple isolated Buzz communities. Community-only contexts +/// support background and system evaluations that have no actor. The process +/// context is for settings that belong to the relay process rather than to any +/// community, such as concurrency caps shared by every community it serves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct EvaluationContext { - community: CommunityId, + community: Option, actor_pubkey: Option, } impl EvaluationContext { + /// Construct the context for process-wide settings: no community, no actor. + pub const fn for_process() -> Self { + Self { + community: None, + actor_pubkey: None, + } + } + /// Construct a community-only evaluation context. pub const fn for_community(community: CommunityId) -> Self { Self { - community, + community: Some(community), actor_pubkey: None, } } @@ -37,13 +48,13 @@ impl EvaluationContext { /// Construct an evaluation context for an actor within one community. pub const fn for_actor(community: CommunityId, actor_pubkey: PublicKey) -> Self { Self { - community, + community: Some(community), actor_pubkey: Some(actor_pubkey), } } - /// Community that owns this evaluation. - pub const fn community(&self) -> CommunityId { + /// Community that owns this evaluation; `None` for the process context. + pub const fn community(&self) -> Option { self.community } @@ -128,7 +139,7 @@ mod tests { impl FlagEvaluator for CommunityEvaluator { fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool { - if context.community() == self.enabled_community { + if context.community() == Some(self.enabled_community) { true } else { flag.default() @@ -136,7 +147,7 @@ mod tests { } fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { - if context.community() == self.enabled_community { + if context.community() == Some(self.enabled_community) { 41 } else { flag.default() @@ -186,7 +197,7 @@ mod tests { } fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { - if context.community() == self.enabled_community + if context.community() == Some(self.enabled_community) && context.actor_pubkey() == Some(&self.expected_actor) { 99 diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock deleted file mode 100644 index 089c0108cc..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.lock +++ /dev/null @@ -1,2797 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bech32" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" - -[[package]] -name = "bip39" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" -dependencies = [ - "bitcoin_hashes", - "serde", - "unicode-normalization", -] - -[[package]] -name = "bitcoin-consensus-encoding" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" -dependencies = [ - "bitcoin-internals", - "hex-conservative 1.3.0", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" - -[[package]] -name = "bitcoin-io" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" -dependencies = [ - "bitcoin-consensus-encoding", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" -dependencies = [ - "bitcoin-io", - "hex-conservative 0.2.3", - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "buzz-core" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "hmac 0.13.0", - "nostr", - "percent-encoding", - "rand 0.10.3", - "serde", - "serde_json", - "sha2 0.11.0", - "subtle", - "thiserror", - "url", - "uuid", - "zeroize", -] - -[[package]] -name = "buzz-feature-flags" -version = "0.1.0" -dependencies = [ - "buzz-core", - "http", - "launchdarkly-server-sdk", - "thiserror", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cidr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", - "zeroize", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" -dependencies = [ - "cmov", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.6", -] - -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "eventsource-client" -version = "0.17.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures", - "http", - "launchdarkly-sdk-transport", - "log", - "pin-project", - "rand 0.10.3", - "tokio", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", -] - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.2", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64 0.22.1", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex-conservative" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-http-proxy" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" -dependencies = [ - "bytes", - "futures-util", - "headers", - "http", - "hyper", - "hyper-rustls", - "hyper-util", - "pin-project-lite", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link", -] - -[[package]] -name = "jiff-core" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" -dependencies = [ - "defmt", - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "launchdarkly-sdk-transport" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d" -dependencies = [ - "bytes", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-http-proxy", - "hyper-rustls", - "hyper-timeout", - "hyper-util", - "log", - "no-proxy", - "tower", -] - -[[package]] -name = "launchdarkly-server-sdk" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff" -dependencies = [ - "aws-lc-rs", - "bitflags 2.13.2", - "bytes", - "chrono", - "crossbeam-channel", - "data-encoding", - "eventsource-client", - "flate2", - "futures", - "http", - "launchdarkly-sdk-transport", - "launchdarkly-server-sdk-evaluation", - "log", - "lru", - "moka", - "parking_lot", - "rand 0.9.5", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "uuid", -] - -[[package]] -name = "launchdarkly-server-sdk-evaluation" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336" -dependencies = [ - "base16ct", - "chrono", - "itertools", - "lazy_static", - "log", - "maplit", - "regex", - "semver", - "serde", - "serde_json", - "serde_with", - "sha1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "no-proxy" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150" -dependencies = [ - "cidr", -] - -[[package]] -name = "nostr" -version = "0.44.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" -dependencies = [ - "base64 0.22.1", - "bech32", - "bip39", - "bitcoin_hashes", - "cbc", - "chacha20 0.9.1", - "chacha20poly1305", - "getrandom 0.2.17", - "hex", - "instant", - "scrypt", - "secp256k1", - "serde", - "serde_json", - "unicode-normalization", - "url", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "relay-feature-selection-fixture" -version = "0.0.0" -dependencies = [ - "buzz-feature-flags", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "rand 0.8.8", - "secp256k1-sys", - "serde", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_with" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" -dependencies = [ - "base64 0.23.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.2", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml deleted file mode 100644 index 2f6cb93f20..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "relay-feature-selection-fixture" -version = "0.0.0" -edition = "2021" -publish = false - -[workspace] - -[features] -static-feature-flags = [] -environment-feature-flags = [] -launchdarkly-feature-flags = ["buzz-feature-flags/launchdarkly"] - -[dependencies] -buzz-feature-flags = { path = "../../.." } diff --git a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs b/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs deleted file mode 100644 index b979395fee..0000000000 --- a/crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/src/lib.rs +++ /dev/null @@ -1,103 +0,0 @@ -#![allow(dead_code)] - -use std::sync::Arc; - -use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator}; -#[cfg(feature = "static-feature-flags")] -use buzz_feature_flags::StaticEvaluator; -#[cfg(feature = "launchdarkly-feature-flags")] -use buzz_feature_flags::launchdarkly::{ - LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig, -}; - -#[cfg(not(any( - feature = "static-feature-flags", - feature = "environment-feature-flags", - feature = "launchdarkly-feature-flags", -)))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any( - all(feature = "static-feature-flags", feature = "environment-feature-flags"), - all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"), - all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"), -))] -compile_error!( - "select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags" -); - -#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))] -#[derive(Clone, Debug, Default)] -pub struct FeatureFlagRuntimeConfig; - -#[cfg(feature = "launchdarkly-feature-flags")] -#[derive(Clone, Debug)] -pub struct FeatureFlagRuntimeConfig { - pub sdk_key: String, - pub relay_proxy_endpoint: Option, -} - -pub struct FeatureFlagCompositionRoot { - pub feature_flags: Arc, - pub environment_diagnostics: Option>, - #[cfg(feature = "launchdarkly-feature-flags")] - pub launchdarkly_lifecycle: Option>, -} - -#[cfg(feature = "static-feature-flags")] -pub fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> FeatureFlagCompositionRoot { - FeatureFlagCompositionRoot { - feature_flags: Arc::new(StaticEvaluator), - environment_diagnostics: None, - } -} - -#[cfg(feature = "environment-feature-flags")] -pub fn build_feature_flag_evaluator( - _config: FeatureFlagRuntimeConfig, -) -> FeatureFlagCompositionRoot { - let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); - let feature_flags: Arc = owner.clone(); - FeatureFlagCompositionRoot { - feature_flags, - environment_diagnostics: Some(owner), - } -} - -#[cfg(feature = "launchdarkly-feature-flags")] -pub fn build_feature_flag_evaluator( - config: FeatureFlagRuntimeConfig, -) -> Result { - let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key); - if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint { - runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint); - } - - let owner = Arc::new(LaunchDarklyEvaluator::from_runtime_config(runtime_config)?); - let feature_flags: Arc = owner.clone(); - Ok(FeatureFlagCompositionRoot { - feature_flags, - environment_diagnostics: None, - launchdarkly_lifecycle: Some(owner), - }) -} - -#[cfg(feature = "static-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); -} - -#[cfg(feature = "environment-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig); -} - -#[cfg(feature = "launchdarkly-feature-flags")] -pub fn compile_smoke() { - let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig { - sdk_key: "fixture-sdk-key".to_owned(), - relay_proxy_endpoint: None, - }); -} diff --git a/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs b/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs deleted file mode 100644 index 120ad58787..0000000000 --- a/crates/buzz-feature-flags/tests/relay_feature_selection_contract.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::{ - env, - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; -use tempfile::TempDir; - -const FIXTURE_LOCK_REGEN_RECIPE: &str = - "cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml"; - -fn fixture_manifest() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.toml") -} - -fn fixture_lockfile() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.lock") -} - -fn crate_readme() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md") -} - -fn cargo_bin() -> OsString { - env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")) -} - -fn cargo_check(target_dir: &Path, case_name: &str, features: &[&str]) -> std::process::Output { - let mut command = Command::new(cargo_bin()); - command - .current_dir(env!("CARGO_MANIFEST_DIR")) - .arg("check") - .arg("--quiet") - .arg("--locked") - .arg("--manifest-path") - .arg(fixture_manifest()) - .arg("--no-default-features") - .env("CARGO_TARGET_DIR", target_dir); - - if !features.is_empty() { - command.arg("--features").arg(features.join(",")); - } - - command - .output() - .unwrap_or_else(|error| { - panic!( - "failed to run cargo check for {case_name}: {error}\nfixture lock regeneration recipe: `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) - }) -} - -fn format_valid_case_failed_status(case_name: &str, stdout: &str, stderr: &str) -> String { - format!( - "expected {case_name} to compile successfully\nstdout:\n{stdout}\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) -} - -fn format_invalid_case_missing_exact_one(case_name: &str, stderr: &str) -> String { - format!( - "expected {case_name} failure to explain the exact-one contract\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ) -} - -fn assert_valid_case_success(case_name: &str, output: &std::process::Output) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "{}", - format_valid_case_failed_status(case_name, &stdout, &stderr) - ); -} - -fn assert_invalid_case_has_exact_one_diagnostic(case_name: &str, output: &std::process::Output) { - assert!( - !output.status.success(), - "expected {case_name} to fail compilation" - ); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("select exactly one relay feature flag provider feature"), - "{}", - format_invalid_case_missing_exact_one(case_name, &stderr) - ); -} - -#[test] -fn relay_fixture_lock_regeneration_recipe_is_documented() { - let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); - assert!( - readme.contains(FIXTURE_LOCK_REGEN_RECIPE), - "README.md must document fixture lock regeneration with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ); -} - -#[test] -fn relay_readme_documents_fail_closed_launchdarkly_startup_contract() { - let readme = std::fs::read_to_string(crate_readme()).expect("read crate README"); - - assert!( - readme.contains("start_with_default_executor_and_wait"), - "README.md must show LaunchDarkly startup via start_with_default_executor_and_wait" - ); - assert!( - readme.contains("async fn build_feature_flag_evaluator"), - "README.md must declare LaunchDarkly build_feature_flag_evaluator as async" - ); - assert!( - readme.contains( - "start_with_default_executor_and_wait(Duration::from_secs(5))\n .await" - ), - "README.md must await start_with_default_executor_and_wait" - ); - assert!( - readme.contains("fail-closed"), - "README.md must state fail-closed startup policy" - ); - assert!( - readme.contains("exact-one compile_error guards"), - "README.md must require exact-one compile_error guards in the consumer" - ); - assert!( - readme.contains("future flag registry"), - "README.md must defer environment-name uniqueness enforcement to a future registry" - ); - assert!( - readme.contains("kind `community` plus") - && readme.contains("optional global kind `pubkey`"), - "README.md must document LaunchDarkly adapter context kinds" - ); - assert!( - readme.contains("rollouts must set `contextKind` to a kind present") - && readme.contains("in evaluation"), - "README.md must document rollout contextKind requirements" - ); - assert!( - readme.contains("omits `contextKind` defaults") && readme.contains("to `user`;"), - "README.md must document LaunchDarkly default rollout context kind" - ); - assert!( - readme.contains("bucket as") - && readme.contains("zero and pick the first positive-weight variation"), - "README.md must document missing-kind rollout bucketing behavior" - ); - assert!( - readme.contains("close()` blocks the calling thread") - && readme.contains("while it flushes analytics"), - "README.md must document blocking LaunchDarkly close semantics" - ); - assert!( - readme.contains("offload it to a blocking shutdown path"), - "README.md must document async shutdown offloading guidance for close()" - ); -} - -#[test] -fn valid_case_failed_status_formatter_includes_fixture_lock_regeneration_recipe() { - let message = format_valid_case_failed_status( - "static", - "", - "error: lock file needs update but --locked was passed", - ); - assert!( - message.contains(FIXTURE_LOCK_REGEN_RECIPE), - "valid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" - ); -} - -#[test] -fn invalid_case_missing_exact_one_formatter_includes_fixture_lock_regeneration_recipe() { - let message = - format_invalid_case_missing_exact_one("none", "error: unrelated nested cargo failure"); - assert!( - message.contains(FIXTURE_LOCK_REGEN_RECIPE), - "invalid-case failure formatter must include fixture lock recipe\nmessage:\n{message}" - ); -} - -#[test] -fn relay_artifact_provider_features_require_exactly_one_selection() { - assert!( - fixture_lockfile().is_file(), - "relay feature selection fixture must check in Cargo.lock for locked nested cargo runs\nregenerate it with `{FIXTURE_LOCK_REGEN_RECIPE}`" - ); - - let target_dir = TempDir::new().expect("temp target dir"); - - let valid_cases = [ - ("static", &["static-feature-flags"][..]), - ("environment", &["environment-feature-flags"][..]), - ("launchdarkly", &["launchdarkly-feature-flags"][..]), - ]; - - for (case_name, features) in valid_cases { - let output = cargo_check(target_dir.path(), case_name, features); - assert_valid_case_success(case_name, &output); - } - - let invalid_cases = [ - ("none", &[][..]), - ( - "static-and-environment", - &["static-feature-flags", "environment-feature-flags"][..], - ), - ( - "static-and-launchdarkly", - &["static-feature-flags", "launchdarkly-feature-flags"][..], - ), - ( - "environment-and-launchdarkly", - &["environment-feature-flags", "launchdarkly-feature-flags"][..], - ), - ( - "all-three", - &[ - "static-feature-flags", - "environment-feature-flags", - "launchdarkly-feature-flags", - ][..], - ), - ]; - - for (case_name, features) in invalid_cases { - let output = cargo_check(target_dir.path(), case_name, features); - assert_invalid_case_has_exact_one_diagnostic(case_name, &output); - } -} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 99350ab7ec..53cf3f4dc7 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -19,6 +19,9 @@ path = "src/main.rs" buzz-core = { workspace = true } buzz-conformance = { workspace = true } buzz-db = { workspace = true } +# The relay chooses its flag provider at startup (BUZZ_FEATURE_FLAGS_PROVIDER), +# so every build carries the LaunchDarkly adapter; it is inert unless selected. +buzz-feature-flags = { workspace = true, features = ["launchdarkly"] } buzz-datastore-tracing = { workspace = true } buzz-deletion = { workspace = true } buzz-auth = { workspace = true } diff --git a/crates/buzz-relay/src/access.rs b/crates/buzz-relay/src/access.rs index 2174d0d2d6..03baa420c0 100644 --- a/crates/buzz-relay/src/access.rs +++ b/crates/buzz-relay/src/access.rs @@ -755,6 +755,7 @@ impl AuthResult { O::RelayMembershipCheckError => AuthReason::MembershipUnavailable, O::NotRelayMember => AuthReason::NotRelayMember, O::PairingMismatch => AuthReason::KeyMismatch, + O::ConcurrencyLimit => AuthReason::ConcurrencyLimit, O::Timeout => AuthReason::Timeout, O::Disconnect => AuthReason::Disconnect, O::Shutdown => AuthReason::Shutdown, diff --git a/crates/buzz-relay/src/concurrency_limits.rs b/crates/buzz-relay/src/concurrency_limits.rs new file mode 100644 index 0000000000..56133863c9 --- /dev/null +++ b/crates/buzz-relay/src/concurrency_limits.rs @@ -0,0 +1,288 @@ +//! Operator-tunable concurrency caps, re-read from feature flags while the +//! relay runs. +//! +//! Each cap is a [`ConcurrencyGate`] with a ceiling fixed at startup and an +//! integer flag evaluated in the process context: +//! +//! | Flag | Gate | Ceiling | +//! |------|------|---------| +//! | `buzz.relay.concurrency.history-reads` | subscription-history reads on the reader pool | the reader-pool share [`buzz_db`] reserves for history | +//! | `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames in their database checks | none | +//! +//! A positive value sets the limit, clamped to the ceiling; zero, a negative +//! value, or no value leaves the gate at its ceiling. A flag can therefore +//! only tighten a cap, never loosen one past what the relay starts with, and +//! a deleted flag or a provider that has not initialized puts every gate back +//! at its ceiling. Once LaunchDarkly has delivered values, they stay in force +//! through a later outage (the SDK serves its last-known data), so a cap +//! tightened during an incident is not lost when the provider connection +//! drops. +//! A lowered gate never cancels admitted work: it admits nothing new until +//! the count in flight falls under the new limit. +//! +//! The flags are re-read every [`REFRESH_INTERVAL`], so a change reaches every +//! pod within that interval plus the provider's own propagation delay, with no +//! restart. `buzz_concurrency_limit{gate}` reports each applied limit, with +//! `0` meaning no limit. A refresh that panics is logged and retried on the +//! next tick, so one bad evaluation cannot freeze the caps for the life of +//! the pod. See `docs/operator-concurrency-caps.md` for operating them. + +use std::time::Duration; + +use buzz_db::ConcurrencyGate; +use buzz_feature_flags::{EvaluationContext, IntegerFlag}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::feature_flags::FeatureFlags; + +/// Cap on subscription-history reads holding a reader connection. +pub const HISTORY_READS_FLAG: IntegerFlag = + IntegerFlag::new("buzz.relay.concurrency.history-reads", 0); +/// Cap on verified AUTH frames running their database checks. +pub const AUTH_FLAG: IntegerFlag = IntegerFlag::new("buzz.relay.concurrency.auth", 0); + +/// How often the flags are re-read. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +/// One gate whose limit a flag controls. +#[derive(Clone, Debug)] +pub struct ManagedGate { + name: &'static str, + flag: IntegerFlag, + gate: ConcurrencyGate, +} + +impl ManagedGate { + /// `gate`, controlled by `flag` and reported as `name`. + pub fn new(name: &'static str, flag: IntegerFlag, gate: ConcurrencyGate) -> Self { + Self { name, flag, gate } + } +} + +/// The relay's managed gates: history reads and AUTH. +pub fn relay_gates(state: &crate::state::AppState) -> Vec { + vec![ + ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + state.db.history_read_gate().clone(), + ), + ManagedGate::new("auth", AUTH_FLAG, state.auth_gate.clone()), + ] +} + +/// The limit a flag value asks for on a gate with `ceiling`. +fn requested_limit(value: i64, ceiling: usize) -> usize { + if value <= 0 { + return ceiling; + } + usize::try_from(value).map_or(ceiling, |value| value.min(ceiling)) +} + +/// Evaluate every gate's flag once and apply the result. +pub fn apply(flags: &FeatureFlags, gates: &[ManagedGate]) { + let context = EvaluationContext::for_process(); + for managed in gates { + let ceiling = managed.gate.ceiling(); + let value = flags.evaluator().evaluate_int(managed.flag, &context); + let previous = managed.gate.limit(); + let applied = managed.gate.set_limit(requested_limit(value, ceiling)); + if applied != previous { + info!( + gate = managed.name, + limit = applied, + previous, + ceiling, + "concurrency limit changed" + ); + } + let reported = if applied >= tokio::sync::Semaphore::MAX_PERMITS { + 0.0 + } else { + applied as f64 + }; + metrics::gauge!("buzz_concurrency_limit", "gate" => managed.name).set(reported); + } + flags.report(); +} + +/// Apply the flags every [`REFRESH_INTERVAL`] (the first time immediately) +/// until `cancel` fires. A panicking refresh is logged and counted +/// (`buzz_concurrency_limit_refresh_panics_total`), and the next tick tries +/// again. +pub async fn run( + flags: std::sync::Arc, + gates: Vec, + cancel: CancellationToken, +) { + let mut interval = tokio::time::interval(REFRESH_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + _ = interval.tick() => { + let refresh = std::panic::AssertUnwindSafe(|| apply(&flags, &gates)); + if std::panic::catch_unwind(refresh).is_err() { + metrics::counter!("buzz_concurrency_limit_refresh_panics_total").increment(1); + error!("concurrency limit refresh panicked; retrying on the next tick"); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_feature_flags::{BooleanFlag, FlagEvaluator}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + /// A provider whose values the test changes while the relay "runs". + #[derive(Default)] + struct LiveValues(Mutex>); + + impl LiveValues { + fn set(&self, flag: IntegerFlag, value: Option) { + let mut values = self.0.lock().expect("values"); + match value { + Some(value) => values.insert(flag.key(), value), + None => values.remove(flag.key()), + }; + } + } + + impl FlagEvaluator for LiveValues { + fn evaluate_bool(&self, flag: BooleanFlag, _: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 { + assert_eq!( + context.community(), + None, + "caps evaluate in the process context" + ); + self.0 + .lock() + .expect("values") + .get(flag.key()) + .copied() + .unwrap_or(flag.default()) + } + } + + #[test] + fn flag_values_only_tighten_and_reset_to_the_ceiling() { + let values = Arc::new(LiveValues::default()); + let flags = FeatureFlags::from_evaluator("test", values.clone()); + let history = ConcurrencyGate::new(20); + let auth = ConcurrencyGate::unbounded(); + let gates = vec![ + ManagedGate::new("history_reads", HISTORY_READS_FLAG, history.clone()), + ManagedGate::new("auth", AUTH_FLAG, auth.clone()), + ]; + + // Each row: the flag values, then the limits applied. + let rows: [(Option, Option, usize, usize); 6] = [ + (None, None, 20, auth.ceiling()), + (Some(8), Some(50), 8, 50), + (Some(500), Some(i64::MAX), 20, auth.ceiling()), + (Some(0), Some(-3), 20, auth.ceiling()), + (Some(1), Some(1), 1, 1), + (None, None, 20, auth.ceiling()), + ]; + for (history_value, auth_value, history_limit, auth_limit) in rows { + values.set(HISTORY_READS_FLAG, history_value); + values.set(AUTH_FLAG, auth_value); + apply(&flags, &gates); + assert_eq!(history.limit(), history_limit, "history {history_value:?}"); + assert_eq!(auth.limit(), auth_limit, "auth {auth_value:?}"); + } + } + + #[tokio::test(start_paused = true)] + async fn the_refresh_loop_applies_changes_without_a_restart() { + let values = Arc::new(LiveValues::default()); + let flags = Arc::new(FeatureFlags::from_evaluator("test", values.clone())); + let gate = ConcurrencyGate::new(10); + let cancel = CancellationToken::new(); + let task = tokio::spawn(run( + flags, + vec![ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + gate.clone(), + )], + cancel.clone(), + )); + + values.set(HISTORY_READS_FLAG, Some(3)); + tokio::time::sleep(REFRESH_INTERVAL + Duration::from_millis(1)).await; + assert_eq!(gate.limit(), 3, "the next refresh applies the new value"); + + values.set(HISTORY_READS_FLAG, None); + tokio::time::sleep(REFRESH_INTERVAL).await; + assert_eq!(gate.limit(), 10, "removing the flag restores the ceiling"); + + cancel.cancel(); + task.await.expect("the loop stops on cancel"); + } + + /// An evaluator that panics on its first `remaining_panics` calls, then + /// reads a fixed value. + struct PanicsFirst { + remaining_panics: std::sync::atomic::AtomicUsize, + value: i64, + } + + impl FlagEvaluator for PanicsFirst { + fn evaluate_bool(&self, flag: BooleanFlag, _: &EvaluationContext) -> bool { + flag.default() + } + + fn evaluate_int(&self, _: IntegerFlag, _: &EvaluationContext) -> i64 { + use std::sync::atomic::Ordering; + let remaining = self.remaining_panics.load(Ordering::SeqCst); + if remaining > 0 { + self.remaining_panics.store(remaining - 1, Ordering::SeqCst); + panic!("injected evaluation panic"); + } + self.value + } + } + + #[tokio::test(start_paused = true)] + async fn a_panicking_refresh_does_not_stop_the_loop() { + let flags = Arc::new(FeatureFlags::from_evaluator( + "test", + Arc::new(PanicsFirst { + remaining_panics: 1.into(), + value: 4, + }), + )); + let gate = ConcurrencyGate::new(10); + let cancel = CancellationToken::new(); + let task = tokio::spawn(run( + flags, + vec![ManagedGate::new( + "history_reads", + HISTORY_READS_FLAG, + gate.clone(), + )], + cancel.clone(), + )); + + // The first tick panics; the gate keeps its ceiling. + tokio::time::sleep(Duration::from_millis(1)).await; + assert_eq!(gate.limit(), 10, "a panicked refresh applies nothing"); + tokio::time::sleep(REFRESH_INTERVAL).await; + assert_eq!(gate.limit(), 4, "the next tick applies the flag"); + + cancel.cancel(); + task.await + .expect("the loop survives the panic and stops on cancel"); + } +} diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 8cf56875a2..8b32bb4823 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1854,6 +1854,7 @@ pub(crate) mod tests { AuthOutcome::RelayMembershipCheckError, AuthOutcome::NotRelayMember, AuthOutcome::PairingMismatch, + AuthOutcome::ConcurrencyLimit, ] { crate::metrics::record_auth_attempt_started(); let (denied, _rx) = test_conn_with_auth(pending_state()); diff --git a/crates/buzz-relay/src/feature_flags.rs b/crates/buzz-relay/src/feature_flags.rs new file mode 100644 index 0000000000..7f207f5919 --- /dev/null +++ b/crates/buzz-relay/src/feature_flags.rs @@ -0,0 +1,359 @@ +//! The relay's feature-flag provider, chosen once at startup. +//! +//! `BUZZ_FEATURE_FLAGS_PROVIDER` selects exactly one evaluator for the life of +//! the process (see `crates/buzz-feature-flags/README.md`): +//! +//! | Value | Evaluator | +//! |-------|-----------| +//! | unset, `static` | every flag returns its declared default | +//! | `environment` | `BUZZ_FEATURE_FLAG_*` values, read once at startup | +//! | `launchdarkly` | LaunchDarkly, keyed by `BUZZ_LAUNCHDARKLY_SDK_KEY`, optionally through the Relay Proxy at `BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL` | +//! +//! The variable is deliberately outside the `BUZZ_FEATURE_FLAG_` namespace the +//! environment evaluator reads, so it can never be mistaken for a flag. +//! +//! Local configuration errors (an unknown provider, a missing SDK key, a +//! malformed proxy URL) fail startup. LaunchDarkly itself being unreachable +//! does not: the relay starts on declared defaults and the SDK keeps +//! connecting in the background, because no safety invariant may depend on +//! remote flag availability. After LaunchDarkly first delivers data, a lost +//! connection leaves its last-known values in force until it reconnects. + +use std::sync::Arc; +use std::time::Duration; + +use buzz_feature_flags::launchdarkly::{LaunchDarklyEvaluator, LaunchDarklyRuntimeConfig}; +use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator}; +use tracing::{info, warn}; + +/// Environment variable naming the provider. +pub const PROVIDER_ENV: &str = "BUZZ_FEATURE_FLAGS_PROVIDER"; +const SDK_KEY_ENV: &str = "BUZZ_LAUNCHDARKLY_SDK_KEY"; +const RELAY_PROXY_ENV: &str = "BUZZ_LAUNCHDARKLY_RELAY_PROXY_URL"; + +/// How long startup waits for LaunchDarkly's first flag data before serving +/// on declared defaults. +const LAUNCHDARKLY_START_WAIT: Duration = Duration::from_secs(3); + +/// How long shutdown waits for LaunchDarkly's final analytics flush. The flush +/// has no deadline of its own, and an unreachable events endpoint must not +/// spend the pod's termination grace. +const LAUNCHDARKLY_CLOSE_WAIT: Duration = Duration::from_secs(2); + +/// The provider selected by [`PROVIDER_ENV`]. +#[derive(Debug)] +enum Provider { + Static, + Environment, + LaunchDarkly(LaunchDarklyRuntimeConfig), +} + +/// Parse the provider from `get`, which reads one environment variable. +fn provider_from(get: impl Fn(&str) -> Option) -> anyhow::Result { + let value = get(PROVIDER_ENV).unwrap_or_default(); + match value.trim() { + "" | "static" => Ok(Provider::Static), + "environment" => Ok(Provider::Environment), + "launchdarkly" => { + let sdk_key = get(SDK_KEY_ENV) + .filter(|key| !key.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("{PROVIDER_ENV}=launchdarkly requires {SDK_KEY_ENV}") + })?; + let mut config = LaunchDarklyRuntimeConfig::new(sdk_key); + if let Some(endpoint) = get(RELAY_PROXY_ENV).filter(|url| !url.trim().is_empty()) { + config = config.with_relay_proxy_endpoint(endpoint); + } + Ok(Provider::LaunchDarkly(config)) + } + other => anyhow::bail!( + "{PROVIDER_ENV}={other:?} is not a feature flag provider; \ + use static, environment, or launchdarkly" + ), + } +} + +/// The process's one flag evaluator, plus the concrete handle its provider +/// needs for diagnostics and shutdown. +pub struct FeatureFlags { + evaluator: Arc, + provider: &'static str, + environment: Option>, + launchdarkly: Option>, +} + +impl FeatureFlags { + /// Build the provider [`PROVIDER_ENV`] selects. Fails only on local + /// configuration errors; see the module docs. + pub async fn from_env() -> anyhow::Result { + let provider = provider_from(|name| std::env::var(name).ok())?; + let flags = Self::build(provider, LAUNCHDARKLY_START_WAIT).await?; + info!(provider = flags.provider, "feature flag provider selected"); + Ok(flags) + } + + async fn build(provider: Provider, start_wait: Duration) -> anyhow::Result { + Ok(match provider { + Provider::Static => Self::from_evaluator("static", Arc::new(StaticEvaluator)), + Provider::Environment => { + let owner = Arc::new(EnvironmentEvaluator::from_process_environment()); + Self { + evaluator: owner.clone(), + provider: "environment", + environment: Some(owner), + launchdarkly: None, + } + } + Provider::LaunchDarkly(config) => { + let owner = Arc::new( + LaunchDarklyEvaluator::from_runtime_config(config) + .map_err(|e| anyhow::anyhow!("LaunchDarkly configuration: {e}"))?, + ); + if let Err(error) = owner.start_with_default_executor_and_wait(start_wait).await { + warn!( + %error, + "LaunchDarkly has not delivered flags; serving declared defaults until it does" + ); + } + Self { + evaluator: owner.clone(), + provider: "launchdarkly", + environment: None, + launchdarkly: Some(owner), + } + } + }) + } + + /// Flags backed by `evaluator` alone (tests, embedding). + pub fn from_evaluator(provider: &'static str, evaluator: Arc) -> Self { + Self { + evaluator, + provider, + environment: None, + launchdarkly: None, + } + } + + /// The evaluator every consumer shares. + pub fn evaluator(&self) -> &dyn FlagEvaluator { + self.evaluator.as_ref() + } + + /// Report provider health after a round of evaluations: log malformed + /// environment values once each, and publish whether the provider has + /// initialized (`buzz_feature_flags_ready`). It does not track the + /// connection afterwards: an initialized LaunchDarkly client keeps serving + /// its last-known values through an outage. + pub fn report(&self) { + if let Some(environment) = &self.environment { + for diagnostic in environment.take_diagnostics() { + warn!( + ?diagnostic, + "malformed feature flag environment value; using its default" + ); + } + } + let ready = self + .launchdarkly + .as_ref() + .is_none_or(|launchdarkly| launchdarkly.initialized()); + metrics::gauge!("buzz_feature_flags_ready", "provider" => self.provider).set(if ready { + 1.0 + } else { + 0.0 + }); + } + + /// Stop the provider's background work, waiting at most + /// [`LAUNCHDARKLY_CLOSE_WAIT`]. + pub async fn close(&self) { + self.close_within(LAUNCHDARKLY_CLOSE_WAIT).await; + } + + /// LaunchDarkly's close blocks while it flushes analytics, so it runs on + /// its own detached thread: past `wait`, shutdown moves on and process + /// exit ends the flush. (The blocking pool would not do: the runtime + /// waits for its tasks when it drops.) + async fn close_within(&self, wait: Duration) { + let Some(launchdarkly) = self.launchdarkly.clone() else { + return; + }; + let (closed_tx, closed_rx) = tokio::sync::oneshot::channel(); + let spawned = std::thread::Builder::new() + .name("launchdarkly-close".into()) + .spawn(move || { + launchdarkly.close(); + let _ = closed_tx.send(()); + }); + if let Err(error) = spawned { + warn!(%error, "LaunchDarkly shutdown failed"); + return; + } + match tokio::time::timeout(wait, closed_rx).await { + Ok(Ok(())) => {} + Ok(Err(_)) => warn!("LaunchDarkly shutdown failed"), + Err(_) => warn!( + ?wait, + "LaunchDarkly did not finish its final flush; shutting down without it" + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn parse(pairs: &[(&str, &str)]) -> anyhow::Result { + let env: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + provider_from(|name| env.get(name).cloned()) + } + + #[test] + fn provider_selection_is_one_explicit_choice() { + assert!(matches!(parse(&[]), Ok(Provider::Static))); + assert!(matches!( + parse(&[(PROVIDER_ENV, "static")]), + Ok(Provider::Static) + )); + assert!(matches!( + parse(&[(PROVIDER_ENV, " environment ")]), + Ok(Provider::Environment) + )); + // An SDK key alone never selects LaunchDarkly. + assert!(matches!( + parse(&[(SDK_KEY_ENV, "sdk-key")]), + Ok(Provider::Static) + )); + + let Ok(Provider::LaunchDarkly(config)) = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "http://ld-proxy.example.org:8030"), + ]) else { + panic!("launchdarkly with a key selects LaunchDarkly"); + }; + assert_eq!(config.sdk_key, "sdk-key"); + assert_eq!( + config.relay_proxy_endpoint.as_deref(), + Some("http://ld-proxy.example.org:8030") + ); + let Ok(Provider::LaunchDarkly(config)) = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, ""), + ]) else { + panic!("an empty proxy URL means no proxy"); + }; + assert_eq!(config.relay_proxy_endpoint, None); + } + + #[test] + fn provider_misconfiguration_fails_startup() { + for (pairs, needle) in [ + (&[(PROVIDER_ENV, "launchdarkly")][..], SDK_KEY_ENV), + ( + &[(PROVIDER_ENV, "launchdarkly"), (SDK_KEY_ENV, " ")][..], + SDK_KEY_ENV, + ), + ( + &[(PROVIDER_ENV, "LaunchDarkly")][..], + "not a feature flag provider", + ), + ( + &[(PROVIDER_ENV, "flagsmith")][..], + "not a feature flag provider", + ), + ] { + let error = parse(pairs) + .expect_err("misconfiguration must fail") + .to_string(); + assert!(error.contains(needle), "{pairs:?}: {error}"); + } + } + + #[tokio::test] + async fn an_unreachable_launchdarkly_starts_on_defaults() { + // The relay installs its rustls provider before building flags; the + // SDK's HTTP client needs one, and several are compiled in. + let _already_installed = rustls::crypto::ring::default_provider().install_default(); + // Nothing listens on the discard port, so the SDK never initializes. + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "http://127.0.0.1:9"), + ]) + .expect("valid configuration"); + let flags = FeatureFlags::build(provider, Duration::from_millis(50)) + .await + .expect("an unreachable provider must not fail startup"); + let flag = buzz_feature_flags::IntegerFlag::new("buzz.relay.concurrency.auth", 0); + assert_eq!( + flags + .evaluator() + .evaluate_int(flag, &buzz_feature_flags::EvaluationContext::for_process()), + 0, + "an uninitialized provider serves the declared default" + ); + flags.close().await; + } + + #[tokio::test] + async fn close_is_bounded_when_launchdarkly_hangs() { + let _already_installed = rustls::crypto::ring::default_provider().install_default(); + // A proxy that accepts connections and never answers: every SDK + // request, the final analytics flush included, hangs. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let address = listener.local_addr().expect("address"); + std::thread::spawn(move || { + let mut held = Vec::new(); + for stream in listener.incoming().flatten() { + held.push(stream); + } + }); + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, &format!("http://{address}")), + ]) + .expect("valid configuration"); + let flags = FeatureFlags::build(provider, Duration::from_millis(50)) + .await + .expect("a hanging provider must not fail startup"); + // An evaluation queues an analytics event for the final flush. + let flag = buzz_feature_flags::IntegerFlag::new("buzz.relay.concurrency.auth", 0); + flags + .evaluator() + .evaluate_int(flag, &buzz_feature_flags::EvaluationContext::for_process()); + + let started = std::time::Instant::now(); + flags.close_within(Duration::from_millis(200)).await; + assert!( + started.elapsed() < Duration::from_secs(2), + "close returns at its deadline, not when the flush gives up: {:?}", + started.elapsed() + ); + } + + #[tokio::test] + async fn a_malformed_proxy_url_fails_startup() { + let provider = parse(&[ + (PROVIDER_ENV, "launchdarkly"), + (SDK_KEY_ENV, "sdk-key"), + (RELAY_PROXY_ENV, "ld-proxy.example.org"), + ]) + .expect("parses"); + let Err(error) = FeatureFlags::build(provider, Duration::from_millis(50)).await else { + panic!("a relative proxy URL must fail startup"); + }; + assert!( + error.to_string().contains("LaunchDarkly configuration"), + "{error}" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 1eb45dfb5d..ee8769876e 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -511,6 +511,10 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { serde_json::to_string(first.as_slice()).ok() } +/// How long a verified AUTH waits for an [`AppState::auth_gate`] permit before +/// it is shed. Well inside every client's own AUTH timeout. +const AUTH_PERMIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); + /// Handle a NIP-42 AUTH message: verify the challenge response and transition /// the connection to authenticated state. /// @@ -579,6 +583,20 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; let attempt = AuthAttempt::nip42(&conn, pubkey, true); + // Concurrency cap (`buzz.relay.concurrency.auth`, unbounded unless set). + // The signature is proven, so only real logins queue for the + // database checks below, and the permit is held through them. A + // shed login gets a bare close, as an auth timeout does: clients + // reconnect from that on their jittered backoff, while an `OK + // false` parks some clients and retries others in lockstep. + let Some(_concurrency_permit) = state.auth_gate.admit(AUTH_PERMIT_WAIT).await else { + metrics::counter!("buzz_auth_failures_total", "reason" => "concurrency_limit") + .increment(1); + if attempt.reject(&conn, AuthOutcome::ConcurrencyLimit) { + conn.cancel.cancel(); + } + return; + }; conn.community_control.prove_nip_fi_line(pubkey); let shadow = conn.community_control.nip_fi_shadow(); @@ -1084,6 +1102,95 @@ mod tests { .unwrap_or_default() } + /// The AUTH cap sheds a verified login with a bare close once its bounded + /// wait runs out; a free permit lets the next login through to its + /// database checks. + #[tokio::test] + async fn auth_cap_sheds_a_verified_login_with_a_bare_close() { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _recorder_guard = metrics::set_default_local_recorder(&recorder); + let state = auth_test_state().await; + assert_eq!(state.auth_gate.set_limit(1), 1); + let held = state + .auth_gate + .admit(super::AUTH_PERMIT_WAIT) + .await + .expect("the only permit"); + + let mut shed = AuthHarness::new(false); + let started = std::time::Instant::now(); + shed.run(shed.auth_event(), std::sync::Arc::clone(&state)) + .await; + assert!( + started.elapsed() >= super::AUTH_PERMIT_WAIT, + "a login waits for a permit before it is shed" + ); + assert!(matches!(shed.conn.auth_state_snapshot(), AuthState::Failed)); + assert!( + shed.send_rx.try_recv().is_err(), + "no OK on the data channel" + ); + assert!( + shed.ctrl_rx.try_recv().is_err(), + "no OK on the ctrl channel" + ); + assert_eq!( + shed.wire_frames().await, + vec![WsMessage::Close(None)], + "a shed login sees only a bare close, as on an auth timeout" + ); + + drop(held); + let admitted = AuthHarness::new(false); + admitted + .run(admitted.auth_event(), std::sync::Arc::clone(&state)) + .await; + // Every AUTH records exactly one outcome. The admitted login's is the + // stub database's failure, so it got past the gate to its checks. + let mut outcomes: Vec<(String, u64)> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_auth_outcomes_total") + .map(|(key, _, _, value)| { + let outcome = key + .key() + .labels() + .find(|label| label.key() == "outcome") + .map(|label| label.value().to_owned()) + .unwrap_or_default(); + let DebugValue::Counter(count) = value else { + panic!("buzz_auth_outcomes_total must be a counter"); + }; + (outcome, count) + }) + .filter(|(_, count)| *count > 0) + .collect(); + outcomes.sort(); + assert_eq!(outcomes.len(), 2, "one outcome per login: {outcomes:?}"); + assert!( + outcomes + .iter() + .any(|(outcome, count)| outcome == "concurrency_limit" && *count == 1), + "the first login is shed: {outcomes:?}" + ); + assert!( + outcomes + .iter() + .any(|(outcome, count)| outcome != "concurrency_limit" && *count == 1), + "the second login, with a free permit, passes the gate: {outcomes:?}" + ); + assert!( + state + .auth_gate + .admit(std::time::Duration::from_millis(10)) + .await + .is_some(), + "the admitted login returns its permit" + ); + } + fn pending(challenge: &str) -> AuthState { AuthState::Pending { challenge: challenge.to_owned(), diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 9a580935c8..3f6164ddb1 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -28,6 +28,8 @@ pub(crate) mod nip_fi_upgrade; pub mod api; /// WebSocket audio relay for huddle voice channels. pub mod audio; +/// Operator-tunable concurrency caps, re-read from feature flags at runtime. +pub mod concurrency_limits; /// Relay configuration from environment variables. pub mod config; /// Runtime conformance harness — abstract trace emission at the @@ -39,6 +41,8 @@ pub mod conformance; pub mod connection; /// Relay error types. pub mod error; +/// The feature-flag provider, chosen once at startup. +pub mod feature_flags; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; /// Stateless HMAC-signed relay invite tokens (mint/verify). diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index ffda48bd59..d979020bed 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -341,6 +341,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { "Prometheus metrics exporter started" ); + // Before the database: a provider misconfiguration fails startup at once. + let feature_flags = Arc::new(buzz_relay::feature_flags::FeatureFlags::from_env().await?); + let db_config = DbConfig { push_enabled: config.push_enabled, database_url: config.database_url.clone(), @@ -746,6 +749,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { }, ); let state = Arc::new(app_state); + // Concurrency caps follow their flags from the first request: apply them + // once before serving, then keep re-reading them. + let concurrency_gates = buzz_relay::concurrency_limits::relay_gates(&state); + buzz_relay::concurrency_limits::apply(&feature_flags, &concurrency_gates); + let concurrency_limits_cancel = CancellationToken::new(); + tokio::spawn(buzz_relay::concurrency_limits::run( + Arc::clone(&feature_flags), + concurrency_gates, + concurrency_limits_cancel.clone(), + )); if let Some(tenant) = &deployment_community { buzz_relay::handlers::side_effects::fan_out_nip43_membership_list( tenant, @@ -1540,6 +1553,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { } } state.community_revalidator_cancel.cancel(); + concurrency_limits_cancel.cancel(); state.dependency_sampler_cancel.cancel(); state.dependency_completion_publisher_cancel.cancel(); @@ -1550,6 +1564,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { .drain(std::time::Duration::from_secs(5)) .await; + // Last of the drains, and bounded: LaunchDarkly's final analytics flush. + feature_flags.close().await; + // Flush pending OTEL spans before exit. if let telemetry::TracerInit::Enabled(tp) = tracer_init { if let Err(e) = tp.shutdown() { diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index eebc313991..65f12599f2 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -43,13 +43,15 @@ pub(crate) enum AuthOutcome { NotRelayMember, /// NIP-FI key pairing mismatch: the NIP-42 key differs from the asserted key. PairingMismatch, + /// Shed by the AUTH concurrency cap (`buzz.relay.concurrency.auth`). + ConcurrencyLimit, Timeout, Disconnect, Shutdown, } impl AuthOutcome { - pub(crate) const ALL: [Self; 12] = [ + pub(crate) const ALL: [Self; 13] = [ Self::Success, Self::Invalid, Self::Banned, @@ -59,6 +61,7 @@ impl AuthOutcome { Self::RelayMembershipCheckError, Self::NotRelayMember, Self::PairingMismatch, + Self::ConcurrencyLimit, Self::Timeout, Self::Disconnect, Self::Shutdown, @@ -75,6 +78,7 @@ impl AuthOutcome { Self::RelayMembershipCheckError => "relay_membership_check_error", Self::NotRelayMember => "not_relay_member", Self::PairingMismatch => "pairing_mismatch", + Self::ConcurrencyLimit => "concurrency_limit", Self::Timeout => "timeout", Self::Disconnect => "disconnect", Self::Shutdown => "shutdown", diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index d021774c8a..d841a0c845 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1475,6 +1475,11 @@ pub struct AppState { pub conn_semaphore: Arc, /// Semaphore limiting concurrent message handler tasks. pub handler_semaphore: Arc, + /// Cap on NIP-42 AUTH frames past signature verification and into their + /// database checks at once. Unbounded until an operator sets a limit + /// through the `buzz.relay.concurrency.auth` flag (see + /// [`crate::concurrency_limits`]). + pub auth_gate: buzz_db::ConcurrencyGate, /// Semaphore limiting concurrent git subprocess operations across /// the whole relay. Bounds resource use; **not** writer /// serialization — that's the CAS at the manifest pointer (spec @@ -1834,6 +1839,7 @@ impl AppState { community_disconnect_publish_attempts: Arc::new(AtomicU64::new(0)), conn_semaphore: Arc::new(Semaphore::new(max_connections)), handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)), + auth_gate: buzz_db::ConcurrencyGate::unbounded(), git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)), media_upload_semaphore: Arc::new(Semaphore::new(media_max_concurrent_uploads)), workflow_engine, diff --git a/docs/operator-concurrency-caps.md b/docs/operator-concurrency-caps.md new file mode 100644 index 0000000000..702bf74ecd --- /dev/null +++ b/docs/operator-concurrency-caps.md @@ -0,0 +1,60 @@ +# Relay Concurrency Caps + +Two per-pod caps can be tightened while the relay runs, through integer +feature flags evaluated in the process context (see +`crates/buzz-relay/src/concurrency_limits.rs`): + +| Flag | Caps | Ceiling | +|------|------|---------| +| `buzz.relay.concurrency.history-reads` | subscription-history reads holding a reader-pool connection | the reader-pool share reserved for history | +| `buzz.relay.concurrency.auth` | verified NIP-42 AUTH frames in their database checks | none | + +A positive value sets the cap, clamped to the ceiling. Zero, a negative value +or no value leaves the cap at its ceiling. A flag can only tighten a cap. +Each pod re-reads the flags every 5 s. + +Past the history-read cap, a read waits up to 1 s and is then refused as +retryable. Past the AUTH cap, a login waits up to 2 s and then gets a bare +close, and the client reconnects on its own backoff. + +## Tightening a cap + +1. Set the flag to a positive value for the deployment's environment. +2. Within about 5 s, check `buzz_concurrency_limit{gate}` on every pod + (`history_reads` or `auth`). It shows the applied limit, with `0` meaning + no limit. +3. Watch the sheds: `buzz_auth_outcomes_total{outcome="concurrency_limit"}` + for AUTH. + +To release, set the flag back to `0`. + +## When a tightened cap loosens + +A cap returns to its ceiling, without an error, when: + +- **The flag value cannot be read as an integer.** A fractional, string or + otherwise mistyped value evaluates to the default `0`. Change values only + among the flag's defined numeric variations. +- **The flag is deleted or turned off with an off variation of `0`.** +- **A pod starts while the flag provider is unreachable.** The relay waits up + to 3 s for flag data, then serves defaults until the provider connects. A + pod restarted during a provider outage therefore comes up at the ceiling, + even while the cap is tightened for the pods that were already running. + `buzz_feature_flags_ready{provider} == 0` marks such a pod. + +A pod that has already received flag data keeps its last-known values through +a later provider outage, so a running pod's cap does not loosen when the +connection drops. + +During an incident in which the provider may be down, confirm +`buzz_concurrency_limit` on each pod after every restart or scale-up, not just +after the flag change. + +## Health signals + +- `buzz_feature_flags_ready{provider}`: `1` once the provider has delivered + data (always `1` for the `static` and `environment` providers). +- `buzz_concurrency_limit_refresh_panics_total`: a refresh that panicked. The + next refresh retries; a rising count means the caps are not following the + flags. +- Shutdown waits at most 2 s for the provider's final analytics flush. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 94a0d6c39c..f9127fa596 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -523,6 +523,13 @@ run_unit_tests() { run_test_step "buzz-relay invitation cutoff configuration" \ cargo test -p buzz-relay --lib config::invite_cutoff_tests:: -- --nocapture + run_test_step "buzz-relay runtime concurrency caps" \ + cargo test -p buzz-relay --lib concurrency_limits::tests:: -- --nocapture + run_test_step "buzz-relay feature-flag provider selection" \ + cargo test -p buzz-relay --lib feature_flags::tests:: -- --nocapture + run_test_step "buzz-relay AUTH concurrency shed" \ + cargo test -p buzz-relay --lib handlers::auth::tests::auth_cap_sheds_a_verified_login_with_a_bare_close -- --exact --nocapture + run_test_step "buzz-relay mesh receive lifetime regression" \ cargo test -p buzz-relay --lib mesh_boot::tests::demo_echo_retains_pending_receive_across_housekeeping_ticks -- --exact --nocapture From e670b672b8b25dae5bc387d683cf2b4e28ea3092 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Fri, 6 Nov 2026 14:48:24 -0500 Subject: [PATCH 2/5] perf(loadgen): spread seed writes across channel creators (#9206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `buzz-loadgen seed` signed every channel create, member add, and thread root with the community owner. In private channels without human members, the owner also signed all the history. The relay limits HTTP calls per pubkey (default 300 a minute), so a community with thousands of channels can't be seeded against a relay that keeps its default limits. The seeder doesn't retry a 429, so the seed fails partway, and a torn-down `run_id` can't be reused. Take one community with 20,000 private channels: the owner alone signs more than 40,000 writes, which is over two hours of its quota. Human authors had the same problem on a smaller scale. A human in hundreds of consecutive private channels signed hundreds of history posts within seconds. Now the community owner creates its first 8 channels, and each further block of 8 gets its own creator identity (`Role::Creator`, derived from `run_id`). A creator signs its channels' kind:9007 and kind:9000 events and their thread roots, and `run` looks roots up by the creator. That keeps every creator well under one minute of quota, whatever the community's channel count. History and replies still rotate across humans: a private channel's own human members, or its creator when it has none. Because only humans look roots up, the creator signs nothing but roots in their channels. No amount of history or replies can push a root out of `run`'s newest-100 lookup. Human signers can still bunch up, for example a human in hundreds of consecutive channels or a deep thread. To cover that, a seed write the relay throttles now waits out the `retry in s` window the relay names and tries again, up to 10 times, before it counts as failed. Compatibility: a community with up to 8 channels is seeded as before, apart from the retries. An existing fixture with that shape keeps working with `run`, including its root lookups. ## What got simpler Nothing got simpler. This adds a creator role and a retry loop, which is the smallest change that makes large fixtures seedable against default limits. ## Validation - `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets -D warnings`, and `cargo test -p buzz-test-client --lib loadgen`, run on a Linux workstation at the head commit. - `no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota` seeds a private community of 400 channels against a fake relay. The community has wide channels, a tier of humans in 280 channels each, and runs at 2x. The test requires every creator to sign fewer than 300 writes. With the owner signing everything, it fails: the owner signs all 7,212 writes. - `throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed`: when every write gets two 429s it is retried and the seed passes. When the quota never clears, each write is tried 11 times and the seed fails with the quota error. - `private_channels_add_every_member_before_anything_is_posted` now uses 20 channels. It checks that every create, member add and root is signed by its channel's creator, that the owner keeps channels 0–7, and that every other post is by a human member. - `root_lookups_search_the_channel_creators_posts` checks that `run` searches for roots by the owner in a community's first block and by the block's creator after it, across two communities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .../buzz-test-client/src/loadgen/identity.rs | 4 + crates/buzz-test-client/src/loadgen/run.rs | 78 +++- crates/buzz-test-client/src/loadgen/seed.rs | 332 ++++++++++++++++-- crates/buzz-test-client/src/loadgen/stats.rs | 2 +- perf/LOADGEN.md | 19 +- perf/scenarios/local-desktop.toml | 2 +- 6 files changed, 392 insertions(+), 45 deletions(-) diff --git a/crates/buzz-test-client/src/loadgen/identity.rs b/crates/buzz-test-client/src/loadgen/identity.rs index 4ed86986bb..dd181d792e 100644 --- a/crates/buzz-test-client/src/loadgen/identity.rs +++ b/crates/buzz-test-client/src/loadgen/identity.rs @@ -16,6 +16,9 @@ pub enum Role { /// Posts only hot-thread roots, so `run` finds them among its newest /// posts however many other roots a channel has. HotRoot, + /// Creates a block of channels after a community's first and signs their + /// seed writes; see `seed::channel_creator`. + Creator, } impl Role { @@ -26,6 +29,7 @@ impl Role { Role::Agent => "agent", Role::HotAuthor => "hot-author", Role::HotRoot => "hot-root", + Role::Creator => "creator", } } } diff --git a/crates/buzz-test-client/src/loadgen/run.rs b/crates/buzz-test-client/src/loadgen/run.rs index 436ecd99e2..607605a7b7 100644 --- a/crates/buzz-test-client/src/loadgen/run.rs +++ b/crates/buzz-test-client/src/loadgen/run.rs @@ -28,7 +28,9 @@ use super::desktop; use super::identity::{self, Role}; use super::net::{http_reason, Net}; use super::scenario::{AgentTurn, Community, HumanProfile, Members, Scenario, WideSplit}; -use super::seed::{find_roots, hot_root_keys, reply_tags, HOT_ROOT_PREFIX, ROOT_PREFIX}; +use super::seed::{ + channel_creator, find_roots, hot_root_keys, reply_tags, HOT_ROOT_PREFIX, ROOT_PREFIX, +}; use super::stats::{abort_reason, ops_json, Op, Pending, Stats}; /// Content prefix of timed messages: `lg:`. @@ -74,6 +76,9 @@ pub(super) struct Shared { pub(super) stop: watch::Receiver, /// Seeded thread roots per channel id, resolved lazily by whichever client needs them first. roots: Mutex>>>, + /// Index within its community of every channel id, to name the + /// [`channel_creator`] whose roots a lookup searches for. + channel_indices: HashMap, /// Channel members and human pubkeys, built once by the first desktop /// client that needs them. pub(super) directory: tokio::sync::OnceCell>, @@ -93,6 +98,15 @@ impl Shared { stats: Arc, stop: watch::Receiver, ) -> Self { + let channel_indices = (0..scenario.shape.communities) + .flat_map(|community| { + scenario + .channel_ids(community) + .into_iter() + .enumerate() + .map(|(index, id)| (id, index)) + }) + .collect(); Self { communities: scenario.communities(), scenario, @@ -100,6 +114,7 @@ impl Shared { stats, stop, roots: Mutex::new(HashMap::new()), + channel_indices, directory: tokio::sync::OnceCell::new(), } } @@ -562,9 +577,17 @@ pub(super) async fn roots( if let Some(found) = shared.roots.lock().await.get(channel) { return found.clone(); } - let owner = identity::keys(&shared.scenario.run_id, Role::Owner, community.index); + let index = shared.channel_indices.get(channel).copied().unwrap_or(0); + let author = channel_creator(&shared.scenario, community.index, index); let pending = shared.stats.begin(Op::HttpQuery); - let lookup = find_roots(&shared.net, community, reader, &owner, channel, ROOT_PREFIX); + let lookup = find_roots( + &shared.net, + community, + reader, + &author, + channel, + ROOT_PREFIX, + ); let result = match bound { None => lookup.await, Some(limit) => match bounded(shared, limit, lookup).await { @@ -3478,4 +3501,53 @@ hold_secs = 1 assert_eq!(settled["hot_reply"]["count"], 8, "{settled}"); assert_eq!(relay.posted.lock().unwrap().len(), 8, "{settled}"); } + + #[tokio::test] + async fn root_lookups_search_the_channel_creators_posts() { + let queries: Log = Default::default(); + let query_log = queries.clone(); + let addr = super::super::seed::tests::fake_http(move |_, _, body| { + let filter: Value = serde_json::from_slice(body).unwrap(); + query_log.lock().unwrap().push(filter[0].clone()); + (200, "[]".to_string()) + }) + .await; + let scenario = Scenario::parse(&format!( + r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +community_host_template = "lg-{{run}}-{{i}}.example" +connect_addr = "{addr}" +[shape] +communities = 2 +channels_per_community = 17 +[population] +humans = 1 +agents = 0 +channels_per_member = 1 +[[ramp]] +users = 1.0 +hold_secs = 1 +"# + )) + .unwrap(); + let net = Net::new(&scenario).await.unwrap(); + let (_stop, stop) = watch::channel(false); + let shared = Shared::new(scenario, net, Arc::new(Stats::default()), stop); + let reader = Keys::generate(); + for (community, channel) in [(0, 0), (0, 9), (1, 16)] { + let id = identity::channel_id("t", community, channel); + let c = &shared.communities[community]; + roots(&shared, c, &reader, &id, None).await; + let author = channel_creator(&shared.scenario, community, channel); + let lookup = queries.lock().unwrap().pop().unwrap(); + assert_eq!(lookup["#h"], json!([id])); + assert_eq!(lookup["authors"], json!([author.public_key().to_hex()])); + } + let owner = identity::keys("t", Role::Owner, 1).public_key(); + assert_eq!(channel_creator(&shared.scenario, 1, 7).public_key(), owner); + assert_ne!(channel_creator(&shared.scenario, 1, 16).public_key(), owner); + } } diff --git a/crates/buzz-test-client/src/loadgen/seed.rs b/crates/buzz-test-client/src/loadgen/seed.rs index 20f12b9a06..7f81ac3f2e 100644 --- a/crates/buzz-test-client/src/loadgen/seed.rs +++ b/crates/buzz-test-client/src/loadgen/seed.rs @@ -1,8 +1,9 @@ //! Creates the scenario's fixture: communities, channels, history and deep threads. use std::collections::HashMap; +use std::future::Future; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use futures_util::stream::{self, StreamExt}; use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag}; @@ -12,7 +13,7 @@ use serde_json::json; use super::identity::{self, Role}; use super::net::{http_reason, truncate, Net}; use super::scenario::{Community, Member, Members, Scenario}; -use super::stats::{Op, Snapshot, Stats}; +use super::stats::{is_throttle, Op, Snapshot, Stats}; /// Content prefix of seeded thread roots; `run` finds roots by it. pub const ROOT_PREFIX: &str = "lg-root"; @@ -27,8 +28,77 @@ pub fn hot_root_keys(run_id: &str) -> Keys { /// Most people one kind:9000 may name, the relay's limit. const MAX_ADDS_PER_EVENT: usize = 100; +/// Channels each creator creates and seeds; see [`channel_creator`]. +pub const CHANNELS_PER_CREATOR: usize = 8; + +/// Creator of channel `channel` in community `community`: the community +/// owner for its first [`CHANNELS_PER_CREATOR`] channels, then one +/// [`Role::Creator`] identity per further block of that many. The creator +/// signs its channels' kind:9007 and kind:9000 events and their thread roots. +/// The relay limits HTTP calls per pubkey, so one signer for a community's +/// tens of thousands of channels is throttled for hours; blocks of 8 keep +/// those writes spread out, and [`paced`] absorbs what still bunches up. +pub fn channel_creator(scenario: &Scenario, community: usize, channel: usize) -> Keys { + let run_id = &scenario.run_id; + let block = channel / CHANNELS_PER_CREATOR; + if block == 0 { + return identity::keys(run_id, Role::Owner, community); + } + let blocks = scenario + .shape + .channels_per_community + .div_ceil(CHANNELS_PER_CREATOR); + identity::keys(run_id, Role::Creator, community * blocks + block) +} + +/// Most times one seed write is retried after the relay throttles its signer. +const MAX_THROTTLED_RETRIES: usize = 10; + +/// Runs `write` until it succeeds or fails other than by the relay's +/// per-pubkey quota. A throttled write waits out the window the relay names +/// (`retry in s`) and tries again, at most [`MAX_THROTTLED_RETRIES`] times, +/// so a signer whose writes bunch up slows down instead of failing the seed. +/// With `stats`, each retried rejection counts as a throttled seed write. +/// Returns how long the accepted attempt took, without the waits before it. +async fn paced(stats: Option<&Stats>, mut write: F) -> anyhow::Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut retries = 0; + loop { + let t0 = Instant::now(); + match write().await { + Ok(()) => return Ok(t0.elapsed()), + Err(e) if retries < MAX_THROTTLED_RETRIES && is_throttle(&e.to_string()) => { + let reason = e.to_string(); + if let Some(stats) = stats { + stats.err(Op::Seed, &reason); + } + retries += 1; + tokio::time::sleep(retry_after(&reason)).await; + } + Err(e) => return Err(e), + } + } +} + +/// How long to wait after a quota rejection: the `retry in s` the relay +/// names, capped at a minute, plus a quarter second for clock skew. +fn retry_after(reason: &str) -> Duration { + let secs = reason + .split("retry in ") + .nth(1) + .and_then(|rest| rest.split('s').next()) + .and_then(|n| n.trim().parse::().ok()) + .unwrap_or(1) + .min(60); + Duration::from_secs(secs) + Duration::from_millis(250) +} + /// Seeds every community. Idempotent for communities this run's fixture -/// owner owns, for channels and for memberships; history and threads are +/// owner owns, for channels and for memberships (each signed by its +/// [`channel_creator`]); history and threads are /// appended on every call. Fails unless every planned post was accepted and, /// with private channels, every member was added. pub async fn seed(scenario: &Scenario, net: &Net, stats: &Arc) -> anyhow::Result<()> { @@ -41,9 +111,9 @@ pub async fn seed(scenario: &Scenario, net: &Net, stats: &Arc) -> anyhow: if let Some(host) = &community.host { provision(scenario, net, host, &owner).await?; } - create_channels(scenario, net, &community, &owner).await?; + create_channels(scenario, net, &community).await?; if let Some(members) = &members { - let ensured = add_members(scenario, net, &community, &owner, members).await?; + let ensured = add_members(scenario, net, &community, members).await?; eprintln!( "seed: community {} memberships ensured ({ensured} people)", community.index @@ -122,10 +192,9 @@ async fn post_all( previous = None; continue; }; - let t0 = Instant::now(); - match net.submit(&keys, community, &event, None).await { - Ok(()) => { - stats.ok(Op::Seed, t0.elapsed()); + match paced(Some(stats), || net.submit(&keys, community, &event, None)).await { + Ok(latency) => { + stats.ok(Op::Seed, latency); let id = event.id.to_hex(); accepted.push(((post.community, post.channel, post.seq), id.clone())); previous = Some(id); @@ -213,11 +282,11 @@ async fn provision_host( Ok(()) } +/// Creates every channel of `community`, each signed by its [`channel_creator`]. async fn create_channels( scenario: &Scenario, net: &Net, community: &Community, - owner: &Keys, ) -> anyhow::Result<()> { let visibility = if scenario.shape.private_channels { "private" @@ -227,6 +296,7 @@ async fn create_channels( let results: Vec> = stream::iter(scenario.channel_ids(community.index)) .enumerate() .map(|(i, channel)| async move { + let creator = channel_creator(scenario, community.index, i); let event = EventBuilder::new(Kind::Custom(9007), "") .tags([ tag(["h", &channel]), @@ -234,8 +304,8 @@ async fn create_channels( tag(["channel_type", "stream"]), tag(["visibility", visibility]), ]) - .sign_with_keys(owner)?; - match net.submit(owner, community, &event, None).await { + .sign_with_keys(&creator)?; + match paced(None, || net.submit(&creator, community, &event, None)).await { Err(e) if !e.to_string().contains("already exists") => Err(e), _ => Ok(()), } @@ -247,7 +317,7 @@ async fn create_channels( } /// Adds every member of `community`'s channels with kind:9000 events from -/// the channel owner, at most [`MAX_ADDS_PER_EVENT`] people each and +/// the channel's [`channel_creator`], at most [`MAX_ADDS_PER_EVENT`] people each and /// `seed_concurrency` at a time. Channel 0 of community 0 also gets the /// hot-thread root author and authors. Adding a current member at its role /// changes nothing, so re-running is safe. Returns how many people were @@ -256,7 +326,6 @@ async fn add_members( scenario: &Scenario, net: &Net, community: &Community, - owner: &Keys, members: &Members, ) -> anyhow::Result { let run_id = &scenario.run_id; @@ -274,8 +343,10 @@ async fn add_members( ); } let id = identity::channel_id(run_id, community.index, channel); + let creator = channel_creator(scenario, community.index, channel); for chunk in people.chunks(MAX_ADDS_PER_EVENT) { adds.push(( + creator.clone(), id.clone(), chunk.iter().map(|k| k.public_key().to_hex()).collect(), )); @@ -283,14 +354,16 @@ async fn add_members( } let named: usize = adds .iter() - .map(|(_, p): &(String, Vec)| p.len()) + .map(|(_, _, p): &(Keys, String, Vec)| p.len()) .sum(); let failures: Vec = stream::iter(adds) - .map(|(channel, people)| async move { - put_users(net, community, owner, &channel, &people) - .await - .err() - .map(|e| format!("{channel}: {e}")) + .map(|(creator, channel, people)| async move { + paced(None, || { + put_users(net, community, &creator, &channel, &people) + }) + .await + .err() + .map(|e| format!("{channel}: {e}")) }) .buffer_unordered(scenario.shape.seed_concurrency) .filter_map(|failure| async move { failure }) @@ -438,7 +511,7 @@ impl Post { let (keys, content) = match self.kind { PostKind::History => (author, format!("lg-history {}", self.seq)), PostKind::Root => ( - identity::keys(&scenario.run_id, Role::Owner, self.community), + channel_creator(scenario, self.community, self.channel), format!("{ROOT_PREFIX} {}", self.seq), ), PostKind::Reply { root } => { @@ -476,20 +549,20 @@ pub fn reply_tags(root: &str, parent: &str) -> [Tag; 2] { ] } -/// Ids of `owner`'s seeded roots in `channel` whose content starts with +/// Ids of `author`'s seeded roots in `channel` whose content starts with /// `prefix`, oldest first, among its 100 newest posts there. `run` replies /// into these; a sample of roots is enough load. pub async fn find_roots( net: &Net, community: &Community, reader: &Keys, - owner: &Keys, + author: &Keys, channel: &str, prefix: &str, ) -> anyhow::Result> { let filter = json!([{ "kinds": [9], - "authors": [owner.public_key().to_hex()], + "authors": [author.public_key().to_hex()], "#h": [channel], "limit": 100, }]); @@ -520,7 +593,10 @@ pub async fn find_roots( impl Post { /// Rotates authors across humans to stay under per-pubkey rate limits: /// across all humans for open channels, across the channel's own human - /// members for private ones, falling back to the owner when it has none. + /// members for private ones, falling back to the channel's + /// [`channel_creator`] when it has none. Only humans look roots up, so in + /// their channels the creator signs nothing but roots, and no volume of + /// history or replies crowds roots out of that lookup's newest 100. fn author(&self, scenario: &Scenario, members: Option<&Members>) -> Keys { let run_id = &scenario.run_id; let Some(members) = members else { @@ -534,7 +610,7 @@ impl Post { .collect(); match humans.get(self.seq % humans.len().max(1)) { Some(m) => identity::keys(run_id, Role::Human, m.index), - None => identity::keys(run_id, Role::Owner, self.community), + None => channel_creator(scenario, self.community, self.channel), } } } @@ -546,6 +622,7 @@ fn tag(parts: [&str; N]) -> Tag { #[cfg(test)] pub(super) mod tests { use super::*; + use std::collections::HashSet; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; /// Serves HTTP/1.1 on loopback. `handler(method, target, body)` returns @@ -794,8 +871,10 @@ ws_url = "ws://{addr}" http_url = "http://{addr}" [shape] communities = 1 -channels_per_community = 3 +channels_per_community = 20 history_messages_per_channel = 3 +thread_roots_per_channel = 1 +thread_depth = 3 private_channels = true wide_channels = 1 [population] @@ -820,10 +899,26 @@ hold_secs = 1 outcome.expect("seed passes"); let kind = |e: &Event| e.kind.as_u16(); let creates: Vec<&Event> = events.iter().filter(|e| kind(e) == 9007).collect(); - assert_eq!(creates.len(), 3); + assert_eq!(creates.len(), 20); assert!(creates .iter() .all(|e| values(e, "visibility") == ["private"])); + let index = |e: &Event| { + let [channel] = values(e, "h").try_into().unwrap(); + (0..20) + .find(|c| identity::channel_id("t", 0, *c) == channel) + .unwrap() + }; + let creator = |c| channel_creator(&scenario, 0, c).public_key(); + // The owner creates channels 0-7; each further block of 8 has its own creator. + for create in &creates { + assert_eq!(create.pubkey, creator(index(create))); + } + let owner = identity::keys("t", Role::Owner, 0).public_key(); + assert_eq!(creator(7), owner); + assert_ne!(creator(8), owner); + assert_eq!(creator(8), creator(15)); + assert_ne!(creator(16), creator(8)); let first_put = events.iter().position(|e| kind(e) == 9000).unwrap(); let last_put = events.iter().rposition(|e| kind(e) == 9000).unwrap(); let first_post = events.iter().position(|e| kind(e) == 9).unwrap(); @@ -831,10 +926,9 @@ hold_secs = 1 assert!(last_create < first_put && last_put < first_post); let members = scenario.channel_members(); - let owner = identity::keys("t", Role::Owner, 0); let mut added: HashMap> = HashMap::new(); for put in events.iter().filter(|e| kind(e) == 9000) { - assert_eq!(put.pubkey, owner.public_key()); + assert_eq!(put.pubkey, creator(index(put))); let people = values(put, "p"); assert!(!people.is_empty() && people.len() <= MAX_ADDS_PER_EVENT); let [channel] = values(put, "h").try_into().unwrap(); @@ -863,13 +957,16 @@ hold_secs = 1 } assert!(added.is_empty()); - // Every post is authored by a human member of its channel. + // The creator signs each channel's roots; every other post is by a + // human member of its channel, so none crowds a root out of `run`'s + // newest-100 lookup. for post in events.iter().filter(|e| kind(e) == 9) { - let [channel] = values(post, "h").try_into().unwrap(); - let c = (0..3) - .find(|c| identity::channel_id("t", 0, *c) == channel) - .unwrap(); + let c = index(post); let author = post.pubkey.to_hex(); + if post.content.starts_with(ROOT_PREFIX) { + assert_eq!(post.pubkey, creator(c)); + continue; + } let member = members[0][c].iter().any(|m| { m.role == Role::Human && identity::keys("t", Role::Human, m.index) @@ -878,10 +975,170 @@ hold_secs = 1 == author }); let hot_root = author == hot_root_keys("t").public_key().to_hex(); - assert!(member || hot_root, "{author} posted in channel {c}"); + let hot_author = (0..3).any(|i| { + identity::keys("t", Role::HotAuthor, i) + .public_key() + .to_hex() + == author + }); + assert!( + member || hot_root || hot_author, + "{author} posted in channel {c}" + ); } } + /// One community of many private channels, wide channels and a tier of + /// humans in most channels, at 2x. + const MANY_CHANNELS: &str = r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +[shape] +communities = 1 +channels_per_community = 400 +history_messages_per_channel = 10 +thread_roots_per_channel = 1 +thread_depth = 5 +seed_concurrency = 64 +private_channels = true +wide_channels = 3 +[population] +humans = 300 +agents = 30 +channels_per_member = 3 +wide_member_fraction = 0.75 +tiers = [{ fraction = 0.1, channels = 280 }] +[[ramp]] +users = 2.0 +hold_secs = 1 +"#; + + /// The relay's default HTTP calls a minute for one pubkey. + const HTTP_CALLS_PER_MIN: usize = 300; + + #[tokio::test] + async fn no_channel_creator_signs_more_seed_writes_than_one_minute_of_its_quota() { + let (outcome, scenario, events) = seed_logged(MANY_CHANNELS, "").await; + outcome.expect("seed passes"); + let creators: HashSet = (0..400) + .map(|c| channel_creator(&scenario, 0, c).public_key().to_hex()) + .collect(); + assert_eq!(creators.len(), 400 / CHANNELS_PER_CREATOR); + let mut writes: HashMap = HashMap::new(); + for event in events + .iter() + .filter(|e| creators.contains(&e.pubkey.to_hex())) + { + *writes.entry(event.pubkey.to_hex()).or_default() += 1; + } + // Creators sign every create, member add and root. Under one minute's + // quota in total, a creator is never throttled, however its writes + // bunch up; humans' posts are paced instead. + let (busiest, most) = writes.iter().max_by_key(|(_, n)| **n).unwrap(); + assert!( + *most < HTTP_CALLS_PER_MIN, + "{busiest} signed {most} of {} seed writes", + events.len() + ); + } + + /// When each write was submitted, keyed by what it says: a kind:9000 is + /// re-signed on each try, so its id changes. + type Tries = HashMap>; + + /// Seeds `scenario` against a relay that answers each write's first + /// `throttles` submissions with the relay's HTTP 429, naming a + /// `retry_secs` window. Returns the outcome and every write's submissions. + async fn seed_throttled( + scenario: &str, + throttles: usize, + retry_secs: u64, + ) -> (anyhow::Result<()>, Tries, Arc) { + let tries: Arc> = Default::default(); + let seen = tries.clone(); + let addr = fake_http(move |_, _, body| { + let event: Event = serde_json::from_slice(body).unwrap(); + let write = format!("{} {} {:?}", event.pubkey, event.content, event.tags); + let mut seen = seen.lock().unwrap(); + let at = seen.entry(write).or_default(); + at.push(Instant::now()); + if at.len() <= throttles { + let error = format!("rate-limited: quota exceeded; retry in {retry_secs}s"); + return (429, json!({ "error": error }).to_string()); + } + (200, json!({"accepted": true, "message": ""}).to_string()) + }) + .await; + let scenario = Scenario::parse(&scenario.replace("{addr}", &addr)).unwrap(); + let net = Net::new(&scenario).await.unwrap(); + let stats = Arc::new(Stats::default()); + let outcome = seed(&scenario, &net, &stats).await; + let tries = tries.lock().unwrap().clone(); + (outcome, tries, stats) + } + + #[tokio::test] + async fn throttled_writes_wait_out_the_quota_and_a_lasting_quota_fails_the_seed() { + let (outcome, tries, _) = seed_throttled(PRIVATE, 2, 0).await; + outcome.expect("throttled writes are retried"); + assert!(tries.values().all(|at| at.len() == 3), "{tries:?}"); + + let (outcome, tries, _) = seed_throttled(PRIVATE, usize::MAX, 0).await; + let err = outcome.expect_err("a quota that never clears fails the seed"); + assert!(err.to_string().contains("quota exceeded"), "{err}"); + let most = MAX_THROTTLED_RETRIES + 1; + assert!(tries.values().all(|at| at.len() == most), "{tries:?}"); + + // A retry waits out the window the relay names, and the post's + // rejection counts as throttled without its wait counting as latency; + // one channel and one post keep the wait short. + let one = r#" +run_id = "t" +[target] +ws_url = "ws://{addr}" +http_url = "http://{addr}" +[shape] +communities = 1 +channels_per_community = 1 +history_messages_per_channel = 1 +[population] +humans = 1 +agents = 0 +channels_per_member = 1 +[[ramp]] +users = 1.0 +hold_secs = 1 +"#; + let (outcome, tries, stats) = seed_throttled(one, 1, 1).await; + outcome.expect("throttled writes are retried"); + assert_eq!(tries.len(), 2, "{tries:?}"); + for at in tries.values() { + let [first, retry] = at[..] else { + panic!("{at:?}"); + }; + assert!(retry - first >= Duration::from_secs(1), "{at:?}"); + } + let posts = stats.snapshot().since(&Snapshot::default()); + let (_, posts) = posts.iter().find(|(op, _)| *op == Op::Seed).unwrap(); + assert_eq!((posts.count, posts.throttled, posts.errors), (1, 1, 0)); + assert!(posts.max_ms < 1000.0, "{posts:?}"); + } + + #[test] + fn retry_after_waits_the_named_window_capped_at_a_minute() { + let quarter = Duration::from_millis(250); + let wait = |reason: &str| retry_after(reason) - quarter; + let body = |n: &str| { + format!("HTTP 429 Too Many Requests: {{\"error\":\"rate-limited: quota exceeded; retry in {n}s\"}}") + }; + assert_eq!(wait(&body("7")), Duration::from_secs(7)); + assert_eq!(wait(&body("0")), Duration::ZERO); + assert_eq!(wait(&body("3600")), Duration::from_secs(60)); + assert_eq!(wait("HTTP 429 Too Many Requests: "), Duration::from_secs(1)); + } + #[tokio::test] async fn refused_members_fail_the_seed_and_open_channels_add_nobody() { // The relay accepts an event that refuses some of the people it names. @@ -1059,8 +1316,7 @@ hold_secs = 1 .unwrap(); let net = Net::new(&scenario).await.unwrap(); let community = &scenario.communities()[0]; - let owner = identity::keys("t", Role::Owner, 0); - create_channels(&scenario, &net, community, &owner).await + create_channels(&scenario, &net, community).await }; outcome("duplicate: channel already exists") .await diff --git a/crates/buzz-test-client/src/loadgen/stats.rs b/crates/buzz-test-client/src/loadgen/stats.rs index 1f469a437b..ab7ac30cf4 100644 --- a/crates/buzz-test-client/src/loadgen/stats.rs +++ b/crates/buzz-test-client/src/loadgen/stats.rs @@ -316,7 +316,7 @@ const QUOTA_EXCEEDED: &str = "rate-limited: quota exceeded"; /// Quota rejections only: an HTTP 429 status or the relay's quota text. Other /// `rate-limited:` rejections, such as `shared admission unavailable` when the /// relay's admission store is down, are relay failures and count as errors. -fn is_throttle(reason: &str) -> bool { +pub(super) fn is_throttle(reason: &str) -> bool { reason.starts_with(HTTP_TOO_MANY_REQUESTS) || reason.contains(QUOTA_EXCEEDED) } diff --git a/perf/LOADGEN.md b/perf/LOADGEN.md index 15c748532a..ef472cc182 100644 --- a/perf/LOADGEN.md +++ b/perf/LOADGEN.md @@ -168,9 +168,24 @@ that requires membership rejects both `seed` and `run`. With every member: the humans and agents assigned to each channel up to the largest ramp step, and the hot-thread root and authors in its channel. It sends kind:9000 events of up to 100 members, `seed_concurrency` at a time, -signed by the community owner, before posting any history. A rerun adds +signed by the channel's creator, before posting any history. A rerun adds nobody new, and any member the relay refuses fails the seed. Seeded history -in a private channel is posted by its own members. +in a private channel is posted by its own human members, or by its creator +when it has none. + +The relay limits HTTP calls per pubkey, so `seed` spreads its writes across +signers. The community owner creates its first 8 channels and each further +block of 8 has its own creator identity, derived from `run_id`. A creator +signs its channels' creation, member adds and thread roots (`run` looks roots +up by it), so no creator nears the quota however many channels the community +has. History and replies rotate across humans. A write the relay still +throttles waits out the window the relay names and is retried, up to 10 +times, before it counts as failed. Each rejected attempt of a seed post counts as throttled, +and the post's latency covers only its accepted attempt. + +A private fixture with more than 8 channels per community that was seeded +before channels had creators cannot be reseeded, and `run` finds none of its +roots past the first 8 channels. Seed it again under a fresh `run_id`. ## Desktop clients diff --git a/perf/scenarios/local-desktop.toml b/perf/scenarios/local-desktop.toml index 518fa65ee7..712e03c201 100644 --- a/perf/scenarios/local-desktop.toml +++ b/perf/scenarios/local-desktop.toml @@ -4,7 +4,7 @@ # `desktop.mode = "legacy"` to compare the client before its volume fixes. # cargo run -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-desktop.toml # cargo run -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-desktop.toml -run_id = "local-desktop" +run_id = "local-desktop-2" [target] ws_url = "ws://localhost:3000" From 446e9e0762a4bfe4c74e138eacf44d7d9d657ca9 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 15:07:08 -0500 Subject: [PATCH 3/5] Allow operators to set initial NIP-FI policy on create (#9211) Trusted operators can choose `initial_requires_nip_fi` on create-only community provisioning. True commits required policy (generation 1), validated issuer bindings and the owner with the new community in one transaction. Omitted, null or false preserves optional creation; a non-null field in legacy ensure mode returns coded 400. Missing effective bindings refuses required insertion without leaving a row. The response reports policy read from the stored row. Same-owner create-only replay now accurately reports `existed` instead of `created`; it stays successful and read-only, including after policy disable or issuer-config changes. Different-owner conflict, legacy ensure, bootstrap and the operator authorization gate retain their behavior. This adds no schema changes or server-wide default flag. Upgrade the whole serving relay fleet before callers request true. An old writer may ignore the field and insert an optional row: response checks detect it but cannot undo it, and later lookup remains read-only. Turn caller targeting off before relay rollback. Mode off stores policy but bypasses enforcement, independently of the upgrade gate. ### Related issue Replaces closed #9172. The separate lifecycle API remains in #9176. ### Validation Draft head: `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Independent source review covers this head. Formatting, diff sanity, Clippy, build, all 14 signed HTTP scenarios and full local `just ci` pass at this head. GitHub reports 59 successful and 38 skipped checks, with none pending or failing in the collected snapshot. At parent `b9d9423a6058387f42c310f5a7e4dbe0c8d8b40f`, all 1,978 package tests and 1,346 PostgreSQL tests passed (PostgreSQL concurrency 2). Four deliberate mutations were detected and restored. Clippy then found seven needless borrows in the new HTTP test; this head removes those borrows only. Earlier validation passed 1,344/1,346 PostgreSQL tests at concurrency 8; the replay assertion was corrected and the timeout disappeared at concurrency 2. Failed-run evidence is retained. At this head, the first mandatory push-hook attempt failed all nine boot-lifecycle tests on child startup deadlines. A subsequent serial run passed all nine (seven PostgreSQL-only cases skipped), with unchanged deadlines and source. The first diagnostic was interrupted during compilation without a result; all evidence is retained. A normal-hook retry was stopped when the author explicitly authorized one push with `--no-verify` for this exact commit. That exception skips local pre-push hooks for publication only; failed hooks and remaining validation still gate readiness. Explicit human runtime confirmation remains a separate acceptance step; agents have not attested it. Generated with Codex Validation update at the same draft head: Clippy, build and all 14 signed HTTP workflow scenarios passed, including atomic required creation, stored-policy replay after disable without a current binding, and refusal without leaving a row. The first live harness run stopped after 12 checks because its SQL helper fetched rows after an UPDATE without RETURNING; that harness query was corrected and the complete workflow passed. Relay source stayed unchanged. The full local `just ci` run failed in `mobile-check`: Dart formatting reported 266 changes among 1,068 files, with unresolved `flutter_lints` configuration warnings. The format check used `--output=none`; the worktree remains clean. Repository validation is therefore not green. That failure is retained as historical evidence; see the successful setup-corrected rerun below. Explicit human runtime confirmation remains separate. Final local validation: after `just mobile-install`, `mobile-check` passed without formatting edits, and full `just ci` completed successfully at `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Tracked files remained clean after installation and after the complete run. This confirms the earlier mobile formatting failure resulted from missing worktree dependencies. All failed runs remain preserved. GitHub's current ready-for-review state is preserved; no agent added `buzz-review-completed` or attested human runtime acceptance. --------- Signed-off-by: Brad Seiler Co-authored-by: Honor Co-authored-by: Honor --- crates/buzz-db/src/error.rs | 5 + crates/buzz-db/src/store/community.rs | 73 ++++++- crates/buzz-db/src/store/nip_fi_binding.rs | 93 ++++++++ crates/buzz-relay/src/api/operator.rs | 13 ++ .../initial_policy_postgres_tests.rs | 205 ++++++++++++++++++ .../src/handlers/community_provisioning.rs | 28 ++- docs/operator-nip-fi-bindings.md | 30 +++ 7 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 56cf748c34..9ce2cb0405 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -42,6 +42,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// Required initial policy cannot be created without issuer bindings. + #[error("required community creation needs NIP-FI issuer bindings")] + NipFiBindingRequired, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, @@ -176,6 +180,7 @@ impl DbError { E::InvalidData(_) => "invalid_data", E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found", E::AccessDenied(_) => "access_denied", + E::NipFiBindingRequired => "nip_fi_binding_required", _ => "other", }; kind.to_owned() diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index b0c35890df..50d1b20f45 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -41,6 +41,10 @@ pub struct CreatedCommunityRecord { pub id: CommunityId, /// Normalized host stored for the community. pub host: String, + /// Whether this transaction inserted the community. + pub created: bool, + /// Stored policy read from the inserted or existing row. + pub requires_nip_fi: bool, } /// Result of atomically creating a community with its initial owner. @@ -611,7 +615,9 @@ impl Db { /// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to /// the new community in the same transaction. Identical retries return the - /// original record without touching its bindings. + /// original record without touching its bindings. Operator tenant creation + /// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply + /// the configured creation requirement. #[datastore_span( name = "create_community_with_owner_and_nip_fi_issuers", system = "postgresql" @@ -622,6 +628,31 @@ impl Db { owner_pubkey: &str, nip_fi_issuers: &[String], relay_keypair: &nostr::Keys, + ) -> Result> { + self.create_community_with_owner_and_nip_fi_policy( + normalized_host, + owner_pubkey, + nip_fi_issuers, + false, + relay_keypair, + ) + .await + } + + /// Atomically create a community, owner, issuer bindings and initial NIP-FI + /// policy (generation 1 when required, otherwise 0). Identical retries keep + /// the original policy and bindings. Required creation needs issuers. + #[datastore_span( + name = "create_community_with_owner_and_nip_fi_policy", + system = "postgresql" + )] + pub async fn create_community_with_owner_and_nip_fi_policy( + &self, + normalized_host: &str, + owner_pubkey: &str, + nip_fi_issuers: &[String], + requires_nip_fi: bool, + relay_keypair: &nostr::Keys, ) -> Result> { use relay_members::MembershipWrite; let owner_pubkey = owner_pubkey.to_ascii_lowercase(); @@ -644,17 +675,21 @@ impl Db { let row = sqlx::query( r#" - INSERT INTO communities (host) - VALUES ($1) + INSERT INTO communities (host, requires_nip_fi, policy_generation) + VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END) ON CONFLICT (lower(host)) DO NOTHING - RETURNING id, host + RETURNING id, host, requires_nip_fi "#, ) .bind(normalized_host) + .bind(requires_nip_fi) .fetch_optional(&mut *tx) .await?; if let Some(row) = row { + if requires_nip_fi && nip_fi_issuers.is_empty() { + return Err(DbError::NipFiBindingRequired); + } let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; @@ -694,12 +729,14 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(id), host, + created: true, + requires_nip_fi: row.try_get("requires_nip_fi")?, }); relay_members::commit_membership_write(tx, record, true, relay_keypair).await } else { let existing = sqlx::query( r#" - SELECT c.id, c.host + SELECT c.id, c.host, c.requires_nip_fi FROM communities c JOIN relay_members rm ON rm.community_id = c.id WHERE lower(c.host) = lower($1) @@ -723,6 +760,8 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(existing.try_get("id")?), host: existing.try_get("host")?, + created: false, + requires_nip_fi: existing.try_get("requires_nip_fi")?, }); tx.commit().await?; Ok(MembershipWrite::unchanged(record)) @@ -1152,7 +1191,10 @@ mod postgres_tests { .expect("same-owner retry"); assert_eq!( retry, - CreateCommunityWithOwnerResult::Created(created.clone()), + CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { + created: false, + ..created.clone() + }), "retry returns the original row" ); @@ -1629,8 +1671,23 @@ mod postgres_tests { let first = first.expect("first concurrent create"); let second = second.expect("second concurrent create"); - assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); - assert_eq!(first, second, "conflict loser re-reads the winning row"); + let ( + CreateCommunityWithOwnerResult::Created(first), + CreateCommunityWithOwnerResult::Created(second), + ) = (first, second) + else { + panic!("both concurrent callers must receive the stored community"); + }; + assert_eq!( + first.id, second.id, + "conflict loser re-reads the winning row" + ); + assert_eq!(first.host, second.host); + assert_eq!(first.requires_nip_fi, second.requires_nip_fi); + assert_ne!( + first.created, second.created, + "exactly one caller inserted the row" + ); } #[tokio::test] diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index ffd25cba96..46dd231f34 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -613,6 +613,99 @@ mod postgres_tests { assert!(!community_exists(&db, &bootstrap).await); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_is_atomic_and_replays_preserve_current_state() { + let db = setup_db().await; + let keys = nostr::Keys::generate(); + for required in [false, true] { + let host = unique_host("initial-policy"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let write = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &owner, + &issuers(), + required, + &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(record) = write.outcome else { + panic!("not created") + }; + assert!(record.created); + assert_eq!(record.requires_nip_fi, required); + let before = db.community_auth_policy(record.id).await.unwrap().unwrap(); + assert_eq!(before.requires_nip_fi, required); + assert_eq!(before.policy_generation, i64::from(required)); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let owners = db.list_communities_owned_by(&owner).await.unwrap(); + assert_eq!(owners.communities[0].id, record.id); + // Changed policy and issuer configuration never rewrite a committed row. + let changed = ["https://other.example".to_owned()]; + for bindings in [&[][..], changed.as_slice()] { + let retry = db + .create_community_with_owner_and_nip_fi_policy( + &host, &owner, bindings, !required, &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(retry) = retry.outcome else { + panic!("not replayed") + }; + assert!(!retry.created); + assert_eq!(retry.requires_nip_fi, required); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + before + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + } + // Model an operator disable. A later true replay must report the stored false. + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(&db.pool).await.unwrap(); + let disabled = db.community_auth_policy(record.id).await.unwrap().unwrap(); + let replay = db + .create_community_with_owner_and_nip_fi_policy(&host, &owner, &[], true, &keys) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(replay) = replay.outcome else { + panic!("not replayed") + }; + assert!(!replay.created); + assert!(!replay.requires_nip_fi); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + disabled + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let conflict = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &nostr::Keys::generate().public_key().to_hex(), + &[], + true, + &keys, + ) + .await + .unwrap(); + assert_eq!(conflict.outcome, CreateCommunityWithOwnerResult::HostExists); + } + for bindings in [vec![], vec![String::new()]] { + let host = unique_host("refused-initial-policy"); + let refusal = db + .create_community_with_owner_and_nip_fi_policy(&host, OWNER, &bindings, true, &keys) + .await; + if bindings.is_empty() { + assert!(matches!(refusal, Err(crate::DbError::NipFiBindingRequired))); + } else { + assert!(refusal.is_err()); + } + assert!(!community_exists(&db, &host).await); + } + } + // Deletion takes its exclusive lock, then the community row. A bind that // held the row while waiting for that lock would deadlock with it. // Mutation: locking the row before `guard_transaction` in diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f240..92c57e188c 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -423,6 +423,18 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } + Err(msg) if msg.starts_with("nip_fi_binding_required:") => Err(coded_api_error( + StatusCode::BAD_REQUEST, + "nip_fi_binding_required", + &msg, + )), + Err(msg) if msg.starts_with("initial_policy_requires_create_only:") => { + Err(coded_api_error( + StatusCode::BAD_REQUEST, + "initial_policy_requires_create_only", + &msg, + )) + } Err(msg) if msg.starts_with("limit_reached:") => { Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg)) } @@ -1387,6 +1399,7 @@ pub async fn no_store_upgrade_response( #[cfg(test)] mod postgres_tests { + mod initial_policy_postgres_tests; use std::{ collections::HashSet, sync::{Arc, Mutex}, diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs new file mode 100644 index 0000000000..d0dc9ff1ba --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -0,0 +1,205 @@ +mod postgres_tests { + use super::super::*; + + async fn create( + state: &Arc, + operator: &Keys, + owner: &Keys, + host: &str, + create_only: bool, + policy: Option, + ) -> axum::response::Response { + let mut body = serde_json::json!({"host":host,"initial_owner_pubkey":owner.public_key().to_hex(),"create_only":create_only}); + if let Some(policy) = policy { + body["initial_requires_nip_fi"] = policy; + } + signed_operator_request( + state.clone(), + operator, + "POST", + "/operator/communities", + Some(body.to_string()), + ) + .await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_and_same_owner_replay_journey() { + let operator = Keys::generate(); + let (binding, reserved, control) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); + let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); + let owner = &owners[3]; + assert!(!state.config.nip_fi_upgrades_enabled); + for (index, policy) in [ + None, + Some(Value::Null), + Some(Value::Bool(false)), + Some(Value::Bool(true)), + ] + .into_iter() + .enumerate() + { + let host = control.replacen("acme.", &format!("policy-{index}."), 1); + let required = policy == Some(Value::Bool(true)); + let owner = &owners[index]; + let response = create(&state, &operator, owner, &host, true, policy).await; + assert!(response.status().is_success()); + let body = read_json(response).await; + assert_eq!(body["status"], "created"); + assert_eq!(body["requires_nip_fi"], required); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + let stored = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.requires_nip_fi, required); + assert_eq!(stored.policy_generation, i64::from(required)); + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(!required)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], required); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + stored + ); + } + for value in [false, true] { + let response = create( + &state, + &operator, + owner, + &control, + false, + Some(Value::Bool(value)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_json(response).await["code"], + "initial_policy_requires_create_only" + ); + } + let refused = create( + &state, + &operator, + owner, + &reserved, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refused).await["code"], "reserved_host"); + let outside = format!("outside-{}.example", Uuid::new_v4().simple()); + let refusal = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refusal).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let host = control.replacen("acme.", "policy-3.", 1); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(state.db.pool()).await.unwrap(); + let disabled = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + None; + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(true)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], false); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + disabled + ); + let outside = format!("unbound-{}.example", Uuid::new_v4().simple()); + let response = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(response).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let unauthorized = create( + &state, + &Keys::generate(), + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 377783abec..e99f50b8f2 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -48,6 +48,9 @@ pub struct ProvisionCommunityRequest { /// host instead of converging or rotating ownership. #[serde(default)] pub create_only: bool, + /// Initial policy for an atomic create only; absent/null preserves legacy behavior. + #[serde(default)] + pub initial_requires_nip_fi: Option, } /// JSON response from `POST /operator/communities`. @@ -63,6 +66,9 @@ pub struct ProvisionCommunityResponse { /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. #[serde(skip_serializing_if = "Option::is_none")] pub owner_pubkey: Option, + /// Current stored policy, present for the create-only writer. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_nip_fi: Option, } pub(crate) fn validate_pubkey_hex(value: &str) -> Option { @@ -253,6 +259,12 @@ pub async fn provision_community( return Err("actor not authorized: not a relay operator".to_string()); } + if request.initial_requires_nip_fi.is_some() && !request.create_only { + return Err( + "initial_policy_requires_create_only: initial_requires_nip_fi requires create_only" + .to_owned(), + ); + } validate_host(&request.host)?; let nip_fi_policy = state.config.nip_fi_provision_binding.as_ref(); // Unbound either way: `issuers_for_new_host` binds nothing on a reserved host. @@ -278,14 +290,18 @@ pub async fn provision_community( })?; let write = state .db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &request.host, owner_hex, nip_fi_issuers, + request.initial_requires_nip_fi.unwrap_or(false), &state.relay_keypair, ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(|e| match e { + buzz_db::DbError::NipFiBindingRequired => format!("nip_fi_binding_required: {e}"), + _ => format!("failed to create community: {e}"), + })?; let record = match write.outcome { buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, buzz_db::CreateCommunityWithOwnerResult::HostExists => { @@ -301,7 +317,9 @@ pub async fn provision_community( community = %record.id, host = %record.host, owner = %owner_hex, - "community created via operator endpoint" + created = record.created, + requires_nip_fi = record.requires_nip_fi, + "community provisioned via operator endpoint" ); record_binding_creation( state.nip_fi_resolver.as_deref(), @@ -313,8 +331,9 @@ pub async fn provision_community( return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, - status: "created", + status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: Some(record.requires_nip_fi), }); } @@ -369,6 +388,7 @@ pub async fn provision_community( host: record.host, status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: None, }) } diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a10..683d82fef3 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -65,6 +65,36 @@ they end at their own session deadline. Alert on `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`. +## Initial policy on operator creation + +A trusted NIP-98 operator may send `initial_requires_nip_fi: true` with +`create_only: true` to `POST /operator/communities`. The community, owner, +issuer bindings and required policy (generation 1) commit in one transaction. +Omitted, null or false creates an optional community at generation 0. A non-null +policy field without `create_only: true` returns 400 +`initial_policy_requires_create_only`. + +Required insertion needs an effective provision binding for the host; otherwise +400 `nip_fi_binding_required` leaves no row. Reserved hosts retain the existing +`reserved_host` refusal. The successful response includes the stored +`requires_nip_fi` and `status: "created"` for insertion or `"existed"` for a +same-owner replay. A replay changes neither policy nor bindings, including after +an operator disable or a change in issuer configuration. A different owner +retains the existing conflict. Legacy ensure and deployment bootstrap remain +optional and do not accept an explicit initial policy. + +This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +it but bypasses enforcement. The production creation paths are operator +create-only, legacy operator ensure, and deployment bootstrap; only create-only +accepts this choice. + +Upgrade the **whole serving relay fleet** before callers request true. Older +writers may ignore the unknown field and insert an optional row. Checking the +created response for stored true detects that incompatible writer, but cannot +undo its insertion; a later existing-row lookup remains read-only. Turn caller +targeting off before rolling back the relay. Turning it off affects future +insertions only; it does not clear existing policy. + ## Commands ```sh From beec0553003ed71c34fcb11b9b7a64a4766f6b3c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 6 Nov 2026 12:10:11 -0800 Subject: [PATCH 4/5] feat(mobile): move Activity to a Home bell and align Home spacing (#9212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI-only part of #9193 by @klopez4212, split out so it can land without the Activity sync changes. - Activity moves from a row in the Home list to a bell button in the Home header, with an unread dot driven by the existing Activity feed and read state. - Home filter capsules match native iOS and Android widths, with a subtle outline on Android when the header collapses. - Home section spacing is tighter. Left out: the reconnect rewrite in #9193's last commit (live events applied in place, no rebuild on reconnect, fatal fallback reads). The Activity provider is unchanged from main. Channel and Solo rows keep their bottom padding so they stay at the 44 pt minimum tap height. ### Validation - Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the Home page bell and filter width tests. - Full mobile suite: all pass except 11 push and notification-toggle tests that fail identically on an unchanged checkout in this environment. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Home, the bell, Activity and channel navigation, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: kenny lopez Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../ios/Runner/JumpToLatestGlassButton.swift | 1 + mobile/ios/Runner/NativeTabler.swift | 1 + .../activity/inbox_unread_provider.dart | 22 +++ .../lib/features/channels/channels_page.dart | 6 +- .../features/channels/channels_page/body.dart | 24 +-- .../channels/channels_page/sections.dart | 37 ++--- mobile/lib/features/channels/me/me_page.dart | 4 +- .../lib/features/channels/me/me_section.dart | 8 +- mobile/lib/features/home/home_page.dart | 99 ++++++++++++- .../lib/features/home/messages_filters.dart | 11 ++ .../android_expanding_menu_button.dart | 10 +- .../lib/shared/widgets/filter_chip_bar.dart | 90 ++++++++--- .../lib/shared/widgets/frosted_app_bar.dart | 1 + .../widgets/ios_glass_navigation_button.dart | 2 + .../widgets/navigation_button_surface.dart | 6 +- .../activity/inbox_unread_provider_test.dart | 86 +++++++++++ mobile/test/features/home/home_page_test.dart | 140 +++++++++++++++++- 17 files changed, 482 insertions(+), 66 deletions(-) create mode 100644 mobile/lib/features/activity/inbox_unread_provider.dart create mode 100644 mobile/test/features/activity/inbox_unread_provider_test.dart diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index b53295c4a6..d317e5eceb 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -543,6 +543,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { case "sun": buttonIconName = "sun.max" case "moon": buttonIconName = "moon" case "trash": buttonIconName = "trash" + case "bell": buttonIconName = "bell" case "more": buttonIconName = "ellipsis" case "reply": buttonIconName = "arrowshape.turn.up.left" case "systemAppearance": buttonIconName = "circle.lefthalf.filled" diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index d13e33d2a8..c12fd204f1 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -117,6 +117,7 @@ enum NativeTabler { "link": 0xeade, // link "checkmark": 0xea5e, // check "checkmark.circle": 0xea67, // circle-check + "bell": 0xea35, // bell "bell.slash": 0xece9, // bell-off "number": 0xeabc, // hash — matches BuzzIcons.hash "bubble.left": 0xeaef, // message diff --git a/mobile/lib/features/activity/inbox_unread_provider.dart b/mobile/lib/features/activity/inbox_unread_provider.dart new file mode 100644 index 0000000000..26d4ab985b --- /dev/null +++ b/mobile/lib/features/activity/inbox_unread_provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/read_state/read_state_provider.dart'; +import 'activity_provider.dart'; +import 'inbox_local_state_provider.dart'; +import 'inbox_read_state.dart'; + +/// The bell reflects the same unread rows shown by the Activity inbox. +final inboxHasUnreadProvider = Provider((ref) { + final items = ref.watch(inboxItemsProvider); + final readState = ref.watch(readStateProvider); + final localState = ref.watch(inboxLocalStateProvider); + if (!readState.isReady) return false; + return items.any( + (item) => !isInboxItemDone( + item, + markerOf: readState.effectiveTimestamp, + localUnreadOverrides: localState.unreadIds, + localDoneSet: localState.doneIds, + ), + ); +}); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9f4cdbf7ff..45d5052162 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -129,7 +129,7 @@ class ChannelsPage extends HookConsumerWidget { }); final WidgetBuilder settingsPageBuilder; - final Widget? messagesFilters; + final Widget Function(bool isCollapsed)? messagesFilters; final List Function(VoidCallback openSwitcher, GlobalKey avatarKey)? messagesActionsBuilder; final Widget? soloSliver; @@ -738,7 +738,9 @@ class ChannelsPage extends HookConsumerWidget { padding: EdgeInsets.only( right: 60 * headerCollapse.value / filterContentHeight, ), - child: messagesFilters, + child: messagesFilters!( + headerCollapse.value >= filterContentHeight, + ), ), ), body: _ChannelsBody( diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 5b53ef5b29..307317c213 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -38,7 +38,7 @@ class _ChannelsBody extends StatelessWidget { @override Widget build(BuildContext context) { final inbox = Semantics( - label: 'Inbox', + label: 'Activity', button: true, onTap: onInbox, excludeSemantics: true, @@ -59,18 +59,20 @@ class _ChannelsBody extends StatelessWidget { child: Icon( BuzzIcons.bell, size: _kChannelIconSize, - color: channelNavigationForeground(context), + color: navigationPrimaryForeground(context), ), ), ), const SizedBox(width: _kChannelLabelGap), Expanded( child: Text( - 'Inbox', + 'Activity', maxLines: 1, overflow: TextOverflow.ellipsis, - style: navigationChatTitleTextStyle.copyWith( - color: channelNavigationForeground(context), + style: navigationSectionTextStyle.copyWith( + fontSize: 17, + height: 22 / 17, + color: navigationPrimaryForeground(context), ), ), ), @@ -110,9 +112,9 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.xs, + Grid.lg, Grid.gutter, - Grid.twelve, + 0, ), child: Semantics( header: true, @@ -133,7 +135,7 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.sm, + Grid.lg, Grid.gutter, 0, ), @@ -389,7 +391,7 @@ class _SliverChannelsList extends HookConsumerWidget { for (final section in userSections) ...[ if (section != userSections.first || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _CustomChannelSection( section: section, channels: sortChannelsForList( @@ -484,7 +486,7 @@ class _SliverChannelsList extends HookConsumerWidget { ), ], if (userSections.isNotEmpty || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'Channels', expanded: channelsExpanded.value, @@ -499,7 +501,7 @@ class _SliverChannelsList extends HookConsumerWidget { onSortModeChange: (mode) => setSortMode('channels', mode), onSelectChannel: onSelectChannel, ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'DMs', expanded: dmsExpanded.value, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index cf985e2c32..0f1f60d7df 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -518,25 +518,28 @@ class _SectionLabel extends StatelessWidget { final Color color; @override - Widget build(BuildContext context) => Row( - children: [ - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: navigationSectionTextStyle.copyWith( - color: color, - fontWeight: FontWeight.w500, + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xxs), + child: Row( + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: navigationSectionTextStyle.copyWith( + color: color, + fontWeight: FontWeight.w500, + ), ), ), - ), - const SizedBox(width: Grid.quarter), - _SectionChevron( - expanded: expanded, - color: navigationAccessoryForeground(context), - ), - ], + const SizedBox(width: Grid.quarter), + _SectionChevron( + expanded: expanded, + color: navigationAccessoryForeground(context), + ), + ], + ), ); } diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 83ec9b0b4f..7ad212d61d 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -211,7 +211,7 @@ class MePage extends HookConsumerWidget { ), for (final group in organization.groups) ...[ if (group != organization.groups.first) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), MeSection( key: ValueKey(group['id']), label: group['name'] as String, @@ -230,7 +230,7 @@ class MePage extends HookConsumerWidget { ), ], if (organization.groups.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), if (visible.isNotEmpty || organization.groups.isNotEmpty) MeSection( key: const ValueKey('me-ungrouped-chats'), diff --git a/mobile/lib/features/channels/me/me_section.dart b/mobile/lib/features/channels/me/me_section.dart index 4ca97df669..e9a43d753a 100644 --- a/mobile/lib/features/channels/me/me_section.dart +++ b/mobile/lib/features/channels/me/me_section.dart @@ -31,9 +31,11 @@ class MeSection extends HookWidget { borderRadius: BorderRadius.circular(Radii.md), onTap: () => expanded.value = !expanded.value, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs, - vertical: Grid.xxs, + padding: const EdgeInsets.fromLTRB( + Grid.xxs, + Grid.twelve, + Grid.xxs, + Grid.half, ), child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 28), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 265880632b..aa35a2fdb0 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -6,14 +6,17 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/theme/buzz_icons.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/community/community_avatar.dart'; import '../../shared/widgets/ios_message_create_button.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/bottom_up_page_route.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; +import '../activity/inbox_unread_provider.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; import 'messages_filters.dart'; @@ -56,6 +59,8 @@ class HomePage extends HookConsumerWidget { final communitySurfaceProgress = useState(0.0); final filter = useState('All'); + final hasUnreadInbox = ref.watch(inboxHasUnreadProvider); + final inboxLabel = hasUnreadInbox ? 'Activity, unread items' : 'Activity'; final reselect = useValueNotifier(0); final newChat = useValueNotifier(null); final newWorkspace = useValueNotifier(null); @@ -85,14 +90,14 @@ class HomePage extends HookConsumerWidget { void openPage(WidgetBuilder builder) => Navigator.of(context).push(MaterialPageRoute(builder: builder)); final solo = mePageBuilder?.call(context, newChat, newWorkspace); + void openInbox() => Navigator.of(context).push( + BottomUpPageRoute( + builder: (_) => const ActivityPage(), + reducedMotion: reducedMotion, + ), + ); final messages = ChannelsPage( settingsPageBuilder: settingsPageBuilder, - onInbox: () => Navigator.of(context).push( - BottomUpPageRoute( - builder: (_) => const ActivityPage(), - reducedMotion: reducedMotion, - ), - ), messagesActionsBuilder: (openSwitcher, avatarKey) { final moreActions = [ ( @@ -120,6 +125,84 @@ class HomePage extends HookConsumerWidget { (label: 'Settings', symbol: 'gearshape', onPressed: openSettings), ]; return [ + if (defaultTargetPlatform == TargetPlatform.iOS && + !nativeControlsVisible) + const SizedBox(width: 44, height: 44) + else + Stack( + alignment: Alignment.center, + children: [ + defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.bell, + semanticLabel: inboxLabel, + width: 44, + height: 44, + controlSize: 44, + foregroundColor: context.colors.onSurface, + onPressed: openInbox, + ) + : IconButton.filledTonal( + tooltip: inboxLabel, + style: IconButton.styleFrom( + backgroundColor: + context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + minimumSize: const Size.square(48), + ), + icon: const Icon(BuzzIcons.bell), + onPressed: openInbox, + ), + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: Center( + child: AnimatedOpacity( + opacity: hasUnreadInbox ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: SizedBox.square( + key: const Key('home-inbox-badge'), + dimension: 24, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + right: 2, + child: Container( + key: const Key('home-inbox-dot'), + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + border: Border.all( + color: + defaultTargetPlatform == + TargetPlatform.android + ? context + .colors + .surfaceContainerHighest + : context.controlSurface, + width: 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(width: Grid.xs), SizedBox( key: avatarKey, child: defaultTargetPlatform == TargetPlatform.iOS @@ -138,6 +221,7 @@ class HomePage extends HookConsumerWidget { ) : AndroidExpandingMenuButton( label: 'Community menu', + backgroundColor: context.colors.surfaceContainerHighest, actions: moreActions, dividerBefore: const {1}, width: 48, @@ -151,7 +235,8 @@ class HomePage extends HookConsumerWidget { ), ]; }, - messagesFilters: MessagesFilters( + messagesFilters: (isCollapsed) => MessagesFilters( + isCollapsed: isCollapsed, selected: filter.value, onSelected: (value) { filter.value = value; diff --git a/mobile/lib/features/home/messages_filters.dart b/mobile/lib/features/home/messages_filters.dart index 29bbb2277f..f55366cf6a 100644 --- a/mobile/lib/features/home/messages_filters.dart +++ b/mobile/lib/features/home/messages_filters.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../shared/theme/theme.dart'; @@ -9,9 +10,11 @@ import '../../shared/widgets/filter_chip_bar.dart'; class MessagesFilters extends StatelessWidget { const MessagesFilters({ super.key, + this.isCollapsed = false, required this.selected, required this.onSelected, }); + final bool isCollapsed; final String selected; final ValueChanged onSelected; @@ -20,6 +23,14 @@ class MessagesFilters extends StatelessWidget { width: double.infinity, child: FilterChipBar( useNativeGlass: true, + matchNativeWidths: true, + labelOffset: defaultTargetPlatform == TargetPlatform.android + ? const Offset(-0.5, -1) + : Offset.zero, + side: defaultTargetPlatform == TargetPlatform.android && isCollapsed + ? BorderSide(color: context.colors.primary.withValues(alpha: 0.02)) + : BorderSide.none, + minControlHeight: defaultTargetPlatform == TargetPlatform.iOS ? 44 : 48, horizontalPadding: Grid.gutter, selected: selected, onSelected: (value) { diff --git a/mobile/lib/shared/widgets/android_expanding_menu_button.dart b/mobile/lib/shared/widgets/android_expanding_menu_button.dart index e9cda3e9a9..31b195d877 100644 --- a/mobile/lib/shared/widgets/android_expanding_menu_button.dart +++ b/mobile/lib/shared/widgets/android_expanding_menu_button.dart @@ -18,6 +18,7 @@ class AndroidExpandingMenuButton extends HookWidget { this.label = 'New', this.expansionBoundsContext, this.trigger, + this.backgroundColor, this.width = 112, this.height = 56, this.dividerBefore = const {}, @@ -29,6 +30,7 @@ class AndroidExpandingMenuButton extends HookWidget { final BuildContext? expansionBoundsContext; final String label; final Widget? trigger; + final Color? backgroundColor; final double width, height; final Set dividerBefore; final Map sectionTitles; @@ -43,6 +45,7 @@ class AndroidExpandingMenuButton extends HookWidget { child: Opacity( opacity: open.value ? 0 : 1, child: NavigationButtonSurface( + backgroundColor: backgroundColor, child: Builder( builder: (buttonContext) => Tooltip( message: label, @@ -74,6 +77,7 @@ class AndroidExpandingMenuButton extends HookWidget { final captured = actions; final route = _CreateMenuRoute( anchor: rect, + backgroundColor: backgroundColor ?? context.controlSurface, expansionBounds: expansionBounds, label: label, trigger: trigger ?? const _NewLabel(), @@ -130,6 +134,7 @@ class _CreateMenuRoute extends PopupRoute { required this.expansionBounds, required this.label, required this.trigger, + required this.backgroundColor, required this.dividerBefore, required this.sectionTitles, required this.actions, @@ -140,6 +145,7 @@ class _CreateMenuRoute extends PopupRoute { final Rect? expansionBounds; final String label; final Widget trigger; + final Color backgroundColor; final Set dividerBefore; final Map sectionTitles; final List<({String label, String symbol, VoidCallback? onPressed})> actions; @@ -230,9 +236,7 @@ class _CreateMenuRoute extends PopupRoute { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16 * t, sigmaY: 16 * t), child: Material( - color: context.controlSurface.withValues( - alpha: 1 - .12 * t, - ), + color: backgroundColor.withValues(alpha: 1 - .12 * t), child: Stack( children: [ Positioned.fill( diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 47dc896e98..2d58a2a635 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'ios_glass_navigation_button.dart'; @@ -46,6 +48,18 @@ class FilterChipBar extends StatelessWidget { /// Uses native glass capsules on iOS for navigation-style filters. final bool useNativeGlass; + /// Uses the native iOS label widths for Material capsules too. + final bool matchNativeWidths; + + /// Optional outline for Material capsules. + final BorderSide side; + + /// Optical adjustment for the visible label within its centered layout box. + final Offset labelOffset; + + /// Minimum visible capsule height; the hit target may be larger. + final double minControlHeight; + /// Material density applied to each chip. final VisualDensity visualDensity; @@ -65,6 +79,10 @@ class FilterChipBar extends StatelessWidget { required this.onSelected, this.expandItems = false, this.useNativeGlass = false, + this.matchNativeWidths = false, + this.side = BorderSide.none, + this.labelOffset = Offset.zero, + this.minControlHeight = 0, this.visualDensity = VisualDensity.compact, this.chipVerticalPadding = Grid.quarter, this.barVerticalPadding = Grid.xxs, @@ -73,21 +91,25 @@ class FilterChipBar extends StatelessWidget { @override Widget build(BuildContext context) { + final widths = [ + for (final item in items) + (TextPainter( + text: TextSpan( + text: item.label, + style: filterChipTextStyle.copyWith( + fontWeight: FontWeight.w600, + ), + ), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout()).width + + 32, + ]; if (useNativeGlass && defaultTargetPlatform == TargetPlatform.iOS) { - final textStyle = filterChipTextStyle.copyWith( - fontWeight: FontWeight.w600, + final height = math.max( + MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16, + math.max(48.0, minControlHeight), ); - final height = (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) - .clamp(48.0, double.infinity); - final widths = [ - for (final item in items) - (TextPainter( - text: TextSpan(text: item.label, style: textStyle), - textScaler: MediaQuery.textScalerOf(context), - textDirection: Directionality.of(context), - )..layout()).width + - 32, - ]; return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth - horizontalPadding * 2; @@ -122,7 +144,9 @@ class FilterChipBar extends StatelessWidget { isSelected: selected == items[index].id, width: widths[index] + extra, height: height, - controlSize: height - 8, + controlSize: math + .max(height - 8, minControlHeight) + .toDouble(), fillWidth: true, foregroundColor: context.colors.primary, ), @@ -139,6 +163,11 @@ class FilterChipBar extends StatelessWidget { if (index > 0) const SizedBox(width: Grid.xxs), if (expandItems) Expanded(child: _chip(context, items[index], fillWidth: true)) + else if (matchNativeWidths) + SizedBox( + width: widths[index], + child: _chip(context, items[index], fillWidth: true), + ) else _chip(context, items[index]), ], @@ -207,11 +236,26 @@ class FilterChipBar extends StatelessWidget { textAlign: fillWidth ? TextAlign.center : TextAlign.start, style: labelStyle, ); + final sizedLabel = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + minControlHeight - chipVerticalPadding * 2, + ), + ), + child: Center( + widthFactor: 1, + heightFactor: 1, + child: Transform.translate(offset: labelOffset, child: label), + ), + ) + : label; final centeredLabel = Align( alignment: Alignment.center, widthFactor: fillWidth ? null : 1, heightFactor: 1, - child: label, + child: sizedLabel, ); final chip = FilterChip( selected: isSelected, @@ -221,7 +265,7 @@ class FilterChipBar extends StatelessWidget { : RoundedRectangleBorder( borderRadius: BorderRadius.circular(Radii.lg), ), - side: BorderSide.none, + side: side, label: fillWidth ? SizedBox(width: double.infinity, child: centeredLabel) : centeredLabel, @@ -231,13 +275,21 @@ class FilterChipBar extends StatelessWidget { horizontal: fillWidth ? Grid.quarter : Grid.twelve, vertical: chipVerticalPadding, ), - visualDensity: visualDensity, + visualDensity: minControlHeight > 0 + ? VisualDensity.standard + : visualDensity, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); - final control = fillWidth - ? SizedBox(width: double.infinity, child: chip) + final sizedChip = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints(minHeight: minControlHeight), + child: chip, + ) : chip; + final control = fillWidth + ? SizedBox(width: double.infinity, child: sizedChip) + : sizedChip; if (!item.hasUnread) return control; return Semantics( label: '$text, unread', diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 2018441721..778ab762c6 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -538,6 +538,7 @@ IosNavigationAction? _nativeAction(Widget? widget) { if (widget is IosGlassNavigationButton) { final symbol = switch (widget.icon) { IosGlassNavigationIcon.more => 'ellipsis', + IosGlassNavigationIcon.bell => 'bell', IosGlassNavigationIcon.trash => 'trash', IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left', IosGlassNavigationIcon.back => 'chevron.backward', diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 9fd1236947..d12ec9031d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -30,6 +30,7 @@ enum IosGlassNavigationIcon { more, reply, trash, + bell, } /// Leading width used by iOS channel-style headers. @@ -246,6 +247,7 @@ class IosGlassNavigationButton extends HookWidget { : Icon( switch (icon) { IosGlassNavigationIcon.more => BuzzIcons.ellipsis, + IosGlassNavigationIcon.bell => BuzzIcons.bell, IosGlassNavigationIcon.trash => BuzzIcons.trash2, IosGlassNavigationIcon.reply => BuzzIcons.cornerUpLeft, diff --git a/mobile/lib/shared/widgets/navigation_button_surface.dart b/mobile/lib/shared/widgets/navigation_button_surface.dart index a62ec6e65c..e12cf4e8ad 100644 --- a/mobile/lib/shared/widgets/navigation_button_surface.dart +++ b/mobile/lib/shared/widgets/navigation_button_surface.dart @@ -9,11 +9,15 @@ class NavigationButtonSurface extends StatelessWidget { const NavigationButtonSurface({ required this.child, this.capsule = false, + this.backgroundColor, super.key, }); final Widget child; + /// Optional Material background; native iOS glass retains its own treatment. + final Color? backgroundColor; + /// Uses fully rounded ends instead of device-concentric corners. final bool capsule; @@ -22,7 +26,7 @@ class NavigationButtonSurface extends StatelessWidget { if (NavigationGlassGroup.maybeOf(context)) return child; if (defaultTargetPlatform != TargetPlatform.iOS) { return Material( - color: context.controlSurface, + color: backgroundColor ?? context.controlSurface, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: child, diff --git a/mobile/test/features/activity/inbox_unread_provider_test.dart b/mobile/test/features/activity/inbox_unread_provider_test.dart new file mode 100644 index 0000000000..ed8a749574 --- /dev/null +++ b/mobile/test/features/activity/inbox_unread_provider_test.dart @@ -0,0 +1,86 @@ +import 'package:buzz/features/activity/activity_provider.dart'; +import 'package:buzz/features/activity/feed_item.dart'; +import 'package:buzz/features/activity/inbox_item.dart'; +import 'package:buzz/features/activity/inbox_local_state_provider.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _ReadState extends ReadStateNotifier { + @override + ReadStateState build() => const ReadStateState.inert(); + + void load(Map contexts) => state = ReadStateState( + isReady: true, + pubkey: 'reader', + contexts: contexts, + version: state.version + 1, + ); +} + +class _LocalState extends InboxLocalStateNotifier { + @override + InboxLocalState build() => const InboxLocalState.empty(); +} + +FeedItem item(String id, {String? channelId}) => FeedItem( + id: id, + kind: 9, + pubkey: 'sender', + content: 'An update', + createdAt: 100, + channelId: channelId, + channelName: 'general', + tags: const [], + category: 'mention', +); + +void main() { + test('bell follows read markers and local unread overrides', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('mention', channelId: 'channel')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + expect(container.read(inboxHasUnreadProvider), isFalse); + final read = container.read(readStateProvider.notifier) as _ReadState; + read.load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + read.load({'channel': 100}); + expect(container.read(inboxHasUnreadProvider), isFalse); + final local = container.read(inboxLocalStateProvider.notifier); + local.markUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isTrue); + local.clearUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); + + test('channel-less items clear when done and empty inbox has no dot', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('approval')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + (container.read(readStateProvider.notifier) as _ReadState).load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + container.read(inboxLocalStateProvider.notifier).markDone('approval'); + expect(container.read(inboxHasUnreadProvider), isFalse); + container.updateOverrides([ + inboxItemsProvider.overrideWithValue(const []), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ]); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); +} diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 25424ddc65..52a8e1d87f 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/agents/agents_page.dart'; import 'package:buzz/features/channels/me/me_section.dart'; import 'package:buzz/features/activity/activity_page.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/me/default_agent.dart'; @@ -10,6 +11,7 @@ import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/features/home/messages_filters.dart'; import 'package:buzz/features/home/messages_navigation_bar.dart'; import 'package:buzz/shared/widgets/ios_message_create_button.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; @@ -18,9 +20,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +// Production iOS capsules fit the semibold label plus 16 points on each side. +double nativeFilterWidth(String label) => + (TextPainter( + text: TextSpan( + text: label, + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + )..layout()).width + + 32; + void main() { Future buildHome({ bool longSolo = false, + bool inboxUnread = false, bool disableAnimations = false, Gradient? topSectionGradient, ValueChanged>? onNewChatAction, @@ -30,6 +44,7 @@ void main() { return ProviderScope( overrides: [ savedPrefsProvider.overrideWithValue(prefs), + inboxHasUnreadProvider.overrideWithValue(inboxUnread), defaultAgentProvider.overrideWith((ref) => Stream.value(null)), channelsProvider.overrideWith(_Channels.new), ], @@ -121,6 +136,23 @@ void main() { expect(header.titleStyle?.fontSize, 22); expect(header.centerTitle, isFalse); + for (final label in ['All', 'Solo', 'Team']) { + final chip = find.ancestor( + of: find.descendant( + of: find.byType(MessagesFilters), + matching: find.text(label), + ), + matching: find.byType(FilterChip), + ); + expect(tester.getSize(chip).height, 48); + final paintedChip = find.descendant( + of: chip, + matching: find.byType(Material), + ); + expect(tester.getSize(paintedChip).height, 48); + expect(tester.getSize(paintedChip).width, nativeFilterWidth(label)); + } + await tester.tap( find.descendant( of: find.byType(MessagesFilters), @@ -161,7 +193,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Personal workspace').hitTestable(), findsOneWidget); expect(find.text('General').hitTestable(), findsOneWidget); - await tester.tap(find.text('Inbox')); + expect(find.text('Activity'), findsNothing); + await tester.tap(find.byTooltip('Activity')); await tester.pumpAndSettle(); expect(find.byType(ActivityPage), findsOneWidget); Navigator.of(tester.element(find.byType(ActivityPage))).pop(); @@ -202,6 +235,17 @@ void main() { final expandedHeader = tester.widget( find.byType(FrostedAppBar), ); + BorderSide filterOutline() => tester + .widget( + find + .descendant( + of: find.byType(MessagesFilters), + matching: find.byType(FilterChip), + ) + .first, + ) + .side!; + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight / 4); await tester.pump(); expect( @@ -216,6 +260,7 @@ void main() { .opacity, closeTo(0.75, 0.01), ); + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight); await tester.pump(); expect( @@ -226,9 +271,12 @@ void main() { .opacity, 0, ); + expect(filterOutline().width, 1); + expect(filterOutline().color.a, closeTo(0.02, 0.001)); scroll.jumpTo(0); await tester.pump(); expect(tester.getTopLeft(title).dy, titleTop); + expect(filterOutline(), BorderSide.none); await tester.drag( find.byType(CustomScrollView).first, const Offset(0, -2000), @@ -293,6 +341,54 @@ void main() { }); } + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets('$platform bell follows unread inbox state', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + for (final unread in [false, true, false]) { + await tester.pumpWidget(await buildHome(inboxUnread: unread)); + await tester.pumpAndSettle(); + expect( + tester.getCenter(find.byKey(const Key('home-inbox-dot'))) - + tester.getTopLeft(find.byKey(const Key('home-inbox-badge'))), + const Offset(17, 5), + ); + final opacity = tester.widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ); + expect(opacity.opacity, unread ? 1 : 0); + expect(opacity.duration, const Duration(milliseconds: 150)); + expect( + tester.getSize(find.byKey(const Key('home-inbox-badge'))), + const Size.square(24), + ); + expect( + find.byTooltip(unread ? 'Activity, unread items' : 'Activity'), + findsOneWidget, + ); + } + await tester.pumpWidget( + await buildHome(inboxUnread: true, disableAnimations: true), + ); + await tester.pump(); + expect( + tester + .widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + Duration.zero, + ); + debugDefaultTargetPlatformOverride = null; + }); + } + testWidgets( 'iOS shows Messages above filters with the native creation menu', (tester) async { @@ -348,6 +444,48 @@ void main() { closeTo(initialWidth, .01), ); expect(search.hitTestable(), findsOneWidget); + final inbox = find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + ); + expect(inbox, findsOneWidget); + expect(find.text('Activity'), findsNothing); + final inboxButton = tester.widget(inbox); + expect(inboxButton.icon, IosGlassNavigationIcon.bell); + + for (final label in ['All', 'Solo', 'Team']) { + final chip = tester.widget( + find.descendant( + of: find.byType(MessagesFilters), + matching: find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.label == label, + ), + ), + ); + expect(chip.controlSize, inboxButton.controlSize); + expect(chip.width, nativeFilterWidth(label)); + } + expect(tester.getSize(inbox), const Size(44, 44)); + final communityTrigger = find.byWidgetPredicate( + (w) => + w is IosMessageCreateButton && + w.accessibilityLabel == 'Community menu', + ); + expect( + tester.getRect(inbox).right, + lessThan(tester.getRect(communityTrigger).left), + ); + inboxButton.onPressed!(); + await tester.pumpAndSettle(); + expect(find.byType(ActivityPage), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + skipOffstage: false, + ), + findsNothing, + ); + Navigator.of(tester.element(find.byType(ActivityPage))).pop(); + await tester.pumpAndSettle(); final communityMenu = tester.widget( find.byWidgetPredicate( (w) => From 6c3ffd160bc0caf8946ce57f3aa50e4f5c615e8e Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Fri, 6 Nov 2026 15:18:50 -0500 Subject: [PATCH 5/5] fix(relay): set received_at in the system-channel fan-out test helper (#9214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `buzz-relay` lib tests fail to compile on `main`: `error[E0063]: missing field received_at in initializer of buzz_pubsub::ChannelEvent` at `crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs:1549`. This is a semantic merge conflict. #9195 added `ChannelEvent::received_at`, and #8519 merged a minute later with a test helper that builds a `ChannelEvent` without it. Neither PR's CI saw the other. This sets the field to `tokio::time::Instant::now()`, as every other initializer does. It breaks clippy (`Rust Lint`) and the backend-integration test archive for every open PR, because CI builds the merge with `main`. ### Testing - `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -D warnings` on a cloud workstation at this head. ## What got simpler Nothing; a one-field compile fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Luke Tornquist Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs index 530f6db3bb..d04f0a337d 100644 --- a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs @@ -1550,6 +1550,7 @@ async fn fan_out_from_another_pod(f: &Fixture, channel: Uuid, name: &str) -> Str community_id: f.tenant.community(), topic: buzz_pubsub::EventTopic::Channel(channel), event, + received_at: tokio::time::Instant::now(), }, ) .await;