Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. |
| `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. |
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
Expand Down
92 changes: 92 additions & 0 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ pub struct JoinPolicyConfig {
pub version: String,
}

/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for
/// WebSocket close-frame delivery after the final delayed cancellation.
pub const MAX_DRAIN_JITTER_MS: u64 = 20_000;

/// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
Expand All @@ -60,6 +64,20 @@ pub struct Config {
/// `0` (the default) disables bounded-staleness replica routing; see
/// [`buzz_db::DbConfig::replica_read_max_age_ms`].
pub replica_read_max_age_ms: u64,

/// Upper bound, in milliseconds, of the per-connection random delay applied
/// when sending the `1012 Service Restart` close frame during graceful
/// shutdown (`BUZZ_DRAIN_JITTER_MS`). Each live connection is closed after
/// an independent delay drawn uniformly from `[1, drain_jitter_ms]` when
/// jitter is enabled, which
/// spreads client reconnects across the window instead of releasing the
/// whole pod's sockets in one instant (the reconnect thundering herd that
/// drives DB pool-timeout bursts on rolling deploys).
///
/// Default `0` reproduces the previous all-at-once close. Values above
/// [`MAX_DRAIN_JITTER_MS`] are capped, leaving headroom under the relay's
/// 30-second hard-drain timeout for close-frame delivery.
pub drain_jitter_ms: u64,
/// Redis connection URL used by the pub/sub manager.
pub redis_url: String,
/// Maximum connections in the shared Redis pool. Defaults to 16.
Expand Down Expand Up @@ -453,6 +471,25 @@ impl Config {
Err(_) => 0,
};

// Drain jitter: 0 = off (default). Clamp oversized values so every
// delayed close is initiated with ten seconds left in the relay's
// hard-drain budget. An empty/whitespace-only value is treated as unset
// (jitter off), matching the sibling vars in this file — so setting the
// var to "" is a valid kill switch, not a crashloop.
let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") {
Ok(raw) if raw.trim().is_empty() => 0,
Ok(raw) => raw
.trim()
.parse::<u64>()
.map_err(|_| {
ConfigError::InvalidValue(
"BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(),
)
})?
.min(MAX_DRAIN_JITTER_MS),
Err(_) => 0,
};

let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());

Expand Down Expand Up @@ -934,6 +971,7 @@ impl Config {
database_url,
read_database_url,
replica_read_max_age_ms,
drain_jitter_ms,
redis_url,
redis_pool_size,
db_pool_size,
Expand Down Expand Up @@ -1267,6 +1305,60 @@ mod tests {
}
}

#[test]
fn drain_jitter_defaults_off_and_rejects_junk() {
let _guard = ENV_MUTEX.lock().unwrap();
let previous = std::env::var_os("BUZZ_DRAIN_JITTER_MS");

std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
let unset = Config::from_env().expect("config").drain_jitter_ms;

std::env::set_var("BUZZ_DRAIN_JITTER_MS", "20000");
let set = Config::from_env().expect("config").drain_jitter_ms;

std::env::set_var("BUZZ_DRAIN_JITTER_MS", "60000");
let capped = Config::from_env().expect("config").drain_jitter_ms;

std::env::set_var("BUZZ_DRAIN_JITTER_MS", "0");
let zero = Config::from_env().expect("config").drain_jitter_ms;

std::env::set_var("BUZZ_DRAIN_JITTER_MS", "soon");
let junk = Config::from_env();

std::env::set_var("BUZZ_DRAIN_JITTER_MS", "");
let empty = Config::from_env()
.expect("empty is a valid kill switch")
.drain_jitter_ms;

std::env::set_var("BUZZ_DRAIN_JITTER_MS", " ");
let blank = Config::from_env()
.expect("whitespace-only is a valid kill switch")
.drain_jitter_ms;

if let Some(value) = previous {
std::env::set_var("BUZZ_DRAIN_JITTER_MS", value);
} else {
std::env::remove_var("BUZZ_DRAIN_JITTER_MS");
}

assert_eq!(unset, 0, "drain jitter must default off");
assert_eq!(set, MAX_DRAIN_JITTER_MS);
assert_eq!(
capped, MAX_DRAIN_JITTER_MS,
"oversized jitter leaves close-frame flush headroom"
);
assert_eq!(zero, 0, "explicit 0 is off");
assert!(
junk.is_err(),
"an unparsable jitter must fail loudly, not silently disable"
);
assert_eq!(
empty, 0,
"an empty value is treated as unset — a kill switch, not a crashloop"
);
assert_eq!(blank, 0, "a whitespace-only value is treated as unset");
}

#[test]
fn audit_logging_defaults_on_and_accepts_explicit_off() {
let _guard = ENV_MUTEX.lock().unwrap();
Expand Down
96 changes: 88 additions & 8 deletions crates/buzz-relay/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
/// Shared mutable subscription map for a single WebSocket connection.
pub(crate) type ConnectionSubscriptions = Arc<Mutex<HashMap<String, Vec<Filter>>>>;

/// Request for the writer to flush a restart close and report the result.
pub(crate) struct RestartClose {
pub(crate) flushed: tokio::sync::oneshot::Sender<bool>,
}

/// Maximum outbound data frames buffered into the websocket sink before one flush.
const MAX_WS_SEND_BATCH: usize = 64;

Expand Down Expand Up @@ -161,6 +166,11 @@ async fn handle_active_connection(
// even when the data buffer is full.
let (ctrl_tx, ctrl_rx) = mpsc::channel::<WsMessage>(8);

// Dedicated restart-close channel carries a flush acknowledgement. Keeping
// ordinary control frames unchanged avoids coupling heartbeat/ban traffic
// to graceful-shutdown delivery tracking.
let (restart_tx, restart_rx) = mpsc::channel::<RestartClose>(1);

let backpressure_count = Arc::new(AtomicU8::new(0));
let subscriptions = Arc::new(Mutex::new(HashMap::new()));

Expand Down Expand Up @@ -205,6 +215,7 @@ async fn handle_active_connection(
conn_id,
tx.clone(),
ctrl_tx.clone(),
Some(restart_tx),
cancel.clone(),
conn.tenant.community(),
Arc::clone(&backpressure_count),
Expand All @@ -215,7 +226,7 @@ async fn handle_active_connection(
let (ws_send, ws_recv) = socket.split();

let send_cancel = cancel.child_token();
let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, send_cancel));
let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel));

let missed_pongs = Arc::new(AtomicU8::new(0));
let heartbeat_cancel = cancel.clone();
Expand Down Expand Up @@ -297,15 +308,17 @@ async fn send_loop(
ws_send: futures_util::stream::SplitSink<WebSocket, WsMessage>,
data_rx: mpsc::Receiver<WsMessage>,
ctrl_rx: mpsc::Receiver<WsMessage>,
restart_rx: mpsc::Receiver<RestartClose>,
cancel: CancellationToken,
) {
send_loop_inner(ws_send, data_rx, ctrl_rx, cancel).await;
send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await;
}

async fn send_loop_inner<S>(
mut ws_send: S,
mut data_rx: mpsc::Receiver<WsMessage>,
mut ctrl_rx: mpsc::Receiver<WsMessage>,
mut restart_rx: mpsc::Receiver<RestartClose>,
cancel: CancellationToken,
) where
S: Sink<WsMessage> + Unpin,
Expand All @@ -319,9 +332,21 @@ async fn send_loop_inner<S>(
}

tokio::select! {
// Biased: cancel > control > data. Cancel must win immediately
// so backpressure-triggered shutdown isn't starved by queued data.
// Biased: restart > cancel > ordinary control > data. A restart
// command owns shutdown delivery and must flush its 1012 before
// cancellation can fall back to an unacknowledged close.
biased;
Some(restart) = restart_rx.recv() => {
let sent = ws_send
.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame {
code: axum::extract::ws::close_code::RESTART,
reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"),
})))
.await
.is_ok();
let _ = restart.flushed.send(sent);
break;
}
_ = cancel.cancelled() => {
// Drain any queued control frames before closing. A ban
// disconnect queues its `OK false "blocked: …"` reason frame on
Expand Down Expand Up @@ -797,7 +822,8 @@ mod tests {
}

let (sink, state) = MockSink::new(Some(1));
send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await;
let (_restart_tx, restart_rx) = mpsc::channel(1);
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await;

let state = state.lock().expect("mock sink poisoned");
assert_eq!(state.flush_count, 1);
Expand All @@ -817,7 +843,8 @@ mod tests {
.expect("queue data frame");

let (sink, state) = MockSink::new(Some(1));
send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await;
let (_restart_tx, restart_rx) = mpsc::channel(1);
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await;

let state = state.lock().expect("mock sink poisoned");
assert_eq!(state.flush_count, 1);
Expand All @@ -842,7 +869,8 @@ mod tests {
.expect("queue control frame");

let (sink, state) = MockSink::new(Some(2));
send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await;
let (_restart_tx, restart_rx) = mpsc::channel(1);
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await;

let state = state.lock().expect("mock sink poisoned");
assert_eq!(state.flush_count, 2);
Expand All @@ -852,6 +880,57 @@ mod tests {
);
}

#[tokio::test]
async fn send_loop_acknowledges_restart_after_flushing_exactly_one_1012() {
let (_data_tx, data_rx) = mpsc::channel(1);
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
let (restart_tx, restart_rx) = mpsc::channel(1);
let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel();
restart_tx
.send(RestartClose {
flushed: flushed_tx,
})
.await
.expect("queue restart close");

let (sink, state) = MockSink::new(None);
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await;

assert_eq!(flushed_rx.await, Ok(true));
let state = state.lock().expect("mock sink poisoned");
assert_eq!(state.flush_count, 1, "ack follows the close flush");
assert_eq!(state.messages.len(), 1, "writer exits after restart close");
match &state.messages[0] {
WsMessage::Close(Some(close)) => {
assert_eq!(close.code, axum::extract::ws::close_code::RESTART);
assert_eq!(close.reason.as_str(), "relay restarting");
}
other => panic!("expected one 1012 restart close, got {other:?}"),
}
}

#[tokio::test]
async fn send_loop_reports_restart_flush_failure() {
let (_data_tx, data_rx) = mpsc::channel(1);
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
let (restart_tx, restart_rx) = mpsc::channel(1);
let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel();
restart_tx
.send(RestartClose {
flushed: flushed_tx,
})
.await
.expect("queue restart close");

let (sink, state) = MockSink::new(Some(1));
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await;

assert_eq!(flushed_rx.await, Ok(false));
let state = state.lock().expect("mock sink poisoned");
assert_eq!(state.flush_count, 1);
assert_eq!(state.messages.len(), 1, "no fallback close is appended");
}

#[tokio::test]
async fn send_loop_flushes_queued_control_before_close_on_cancel() {
// A ban disconnect queues its `OK false "blocked: …"` reason frame on
Expand All @@ -871,7 +950,8 @@ mod tests {
cancel.cancel();

let (sink, state) = MockSink::new(None);
send_loop_inner(sink, data_rx, ctrl_rx, cancel).await;
let (_restart_tx, restart_rx) = mpsc::channel(1);
send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, cancel).await;

let state = state.lock().expect("mock sink poisoned");
assert_eq!(
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,7 @@ mod tests {
conn_id,
tx,
ctrl_tx,
None,
CancellationToken::new(),
buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()),
Arc::new(AtomicU8::new(0)),
Expand Down Expand Up @@ -2098,6 +2099,7 @@ mod tests {
conn_id,
tx,
ctrl_tx,
None,
CancellationToken::new(),
buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()),
Arc::new(AtomicU8::new(0)),
Expand Down Expand Up @@ -2423,6 +2425,7 @@ mod tests {
conn_id,
tx,
ctrl_tx,
None,
CancellationToken::new(),
community_id,
Arc::new(AtomicU8::new(0)),
Expand Down
Loading
Loading