mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## 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 <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
11 KiB
Rust
321 lines
11 KiB
Rust
//! 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<Inner>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Inner {
|
|
permits: Arc<Semaphore>,
|
|
ceiling: usize,
|
|
state: Mutex<LimitState>,
|
|
}
|
|
|
|
#[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<GatePermit> {
|
|
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<OwnedSemaphorePermit>,
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|