mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Make relay readiness process-local (#7341)
## Summary Make Kubernetes readiness depend only on the relay process lifecycle, so a shared Postgres or Redis slowdown cannot withdraw every pod at once. Move dependency checks to one fixed-cadence loop per pod. The loop permits only one evaluation at a time, emits metrics after each sample, and caches a timestamped report. `/_status` reads that cache without calling Postgres, Redis, or the deletion catalog. It reports `not_yet_sampled`, `fresh`, or `stale`. Each completed sample also writes a Unix timestamp gauge, so Datadog can compute sample age at query time. Fail closed when the community lifecycle lookup errors. An inconclusive tenant-state lookup no longer admits a socket to AUTH or REQ handling. ### Related issue None found. This addresses the elevated relay HTTP 500 and reconnect incident investigated on 2026-09-03. ### Tradeoff The readiness gauge now reports the latest private-probe observation, not the shutdown transition itself. After shutdown starts, a scrape can still see `1` until the next readiness probe refreshes the gauge to `0`. Kubernetes routing is unaffected because it uses the readiness response from the authoritative process state. A terminal `0` was never guaranteed: even a transition-owned gauge could update in process and then exit before Prometheus scraped it. If no scrape occurs before exit, both designs miss the terminal sample. The new design adds at most the interval until the next readiness probe when a scrape occurs during shutdown. Dependency status is now sampled every 30 seconds rather than on demand. A slow evaluation delays the next sample instead of stacking more work. The status payload exposes the cached report age. The completion timestamp stays unchanged when sampling stops, so `time() - timestamp` crosses the stale threshold without a server aging loop. Outcome counters and duration histograms remain cumulative and cannot provide no-data detection. ### Testing Gimli exercised exact SHA `edff1ec4a5362e132e367bf7449d2b0a68f1e9a9` on an isolated Blox stack. Repeated `/_status` bursts caused no dependency calls, metrics advanced without status traffic, stale status remained visible, and lifecycle lookup failures admitted no AUTH or REQ handling. Follow-up verification at `fdbb394ed598c94cde85ed01cb11e9de0ee8f00d` confirmed sampler-owned outcome and duration emission without status traffic and bound the real startup seam. The final timestamp-gauge change is covered by targeted red/green tests and the same boot regression at `c0f36e22b527c2fd6a63a548d036d006ffa17171`. ### Complexity Readiness remains one process-local sample per probe. Dependency I/O now has one owner and one execution path; request volume cannot increase dependency-check load. One completion timestamp replaces the prior server-side age update path; Datadog computes age without another loop. Community admission also returns to one fail-closed rule for inactive and unknown tenant state. Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: tornquist <tornquist@squareup.com> Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
2
.github/workflows/_ci-relay.yml
vendored
2
.github/workflows/_ci-relay.yml
vendored
@@ -198,6 +198,8 @@ jobs:
|
||||
with:
|
||||
name: desktop-e2e-relay
|
||||
path: target/ci
|
||||
- name: Restore relay executable permission
|
||||
run: chmod +x ./target/ci/buzz-relay
|
||||
- name: PostgreSQL-backed tests
|
||||
env:
|
||||
BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres
|
||||
|
||||
@@ -644,7 +644,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
|
||||
| GET | `/.well-known/nostr.json` | NIP-05 identity |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/_liveness` | Liveness probe |
|
||||
| GET | `/_readiness` | Readiness probe |
|
||||
| GET | `/_readiness` | Readiness probe — local process lifecycle only |
|
||||
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
|
||||
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
|
||||
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
|
||||
|
||||
2
Justfile
2
Justfile
@@ -472,7 +472,7 @@ test-unit:
|
||||
# because they live in the binary target; the nested
|
||||
# `tests::postgres_tests::` stays in the PostgreSQL lane.
|
||||
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
|
||||
-E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
-E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
# ACP author-gate and queue tests protect the trust boundary between
|
||||
# relay events and agent prompts. They are infra-free; ignored lifecycle
|
||||
# tests remain excluded and run in their dedicated integration lanes.
|
||||
|
||||
@@ -45,7 +45,8 @@ pub mod operator_listener;
|
||||
pub mod protocol;
|
||||
/// Durable NIP-PL matcher and delivery worker.
|
||||
pub mod push_runtime;
|
||||
mod readiness;
|
||||
/// Readiness-probe telemetry and the per-pod dependency sampler behind `/_status`.
|
||||
pub mod readiness;
|
||||
/// Axum router construction.
|
||||
pub mod router;
|
||||
/// Shared application state.
|
||||
|
||||
@@ -227,6 +227,10 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
|
||||
|
||||
let usage_interval_secs = usage_metrics_interval_secs();
|
||||
let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs);
|
||||
let dependency_sample_completion_republish_interval =
|
||||
buzz_relay::readiness::dependency_sample_completion_republish_interval(
|
||||
usage_idle_timeout_secs,
|
||||
);
|
||||
let (boot, ()) = boot.run_required(
|
||||
StartupPhase::MetricsBind,
|
||||
|| relay_metrics::try_install(config.metrics_port, usage_idle_timeout_secs),
|
||||
@@ -244,6 +248,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
|
||||
info!(
|
||||
port = config.metrics_port,
|
||||
idle_timeout_secs = usage_idle_timeout_secs,
|
||||
completion_republish_secs = dependency_sample_completion_republish_interval.as_secs(),
|
||||
"Prometheus metrics exporter started"
|
||||
);
|
||||
|
||||
@@ -455,7 +460,13 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
|
||||
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
|
||||
};
|
||||
let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with readiness handler
|
||||
let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with AppState
|
||||
// One-time bootstrap gate, deliberately before AppState and therefore before
|
||||
// the health listener binds. Post-start Redis failures are dependency
|
||||
// failures and must never move readiness; never having connected at all is
|
||||
// a broken deployment, not a blip.
|
||||
buzz_relay::state::verify_redis_command_path(&redis_health_pool).await?;
|
||||
info!("Redis command path connected");
|
||||
let pubsub = Arc::new(
|
||||
PubSubManager::new(&config.redis_url, redis_pool)
|
||||
.await
|
||||
@@ -1100,6 +1111,16 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
|
||||
));
|
||||
}
|
||||
|
||||
// Per-pod dependency diagnostics runtime: one seam starts the dependency
|
||||
// sampler and its independent completion-epoch republisher together.
|
||||
{
|
||||
let diagnostics_state = Arc::clone(&state);
|
||||
buzz_relay::readiness::start_dependency_sampler_and_completion_publisher(
|
||||
diagnostics_state,
|
||||
dependency_sample_completion_republish_interval,
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-pod connection-control consumer: receive disconnect commands from
|
||||
// Redis pub/sub (published by the pod that recorded a ban) and close any
|
||||
// matching local sockets. A member's live connections may land on any pod,
|
||||
@@ -1273,6 +1294,8 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
|
||||
|
||||
serve(router, health_router, Arc::clone(&state)).await?;
|
||||
state.community_revalidator_cancel.cancel();
|
||||
state.dependency_sampler_cancel.cancel();
|
||||
state.dependency_completion_publisher_cancel.cancel();
|
||||
|
||||
// Signal the audit worker to stop accepting, flush buffered entries, and
|
||||
// exit. Uses a CancellationToken so it works regardless of how many
|
||||
|
||||
@@ -290,6 +290,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric
|
||||
metrics::set_global_recorder(recorder)
|
||||
.map_err(|_error| MetricsInstallError::RecorderConflict)?;
|
||||
describe_readiness_metrics();
|
||||
describe_community_admission_metrics();
|
||||
describe_db_pool_metrics();
|
||||
describe_auth_metrics();
|
||||
initialize_auth_metric_series();
|
||||
@@ -306,24 +307,42 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) {
|
||||
.unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}"));
|
||||
}
|
||||
|
||||
/// Register the frozen readiness metric descriptions with the active recorder.
|
||||
/// Register the frozen readiness and dependency-diagnostic metric descriptions.
|
||||
///
|
||||
/// The two `buzz_readiness_*` probe families describe local process lifecycle.
|
||||
/// The dependency families keep their names for dashboard continuity but are
|
||||
/// published by the per-pod dependency sampler, not by the Kubernetes probe or
|
||||
/// by an `/_status` request — a shared-dependency failure no longer deroutes
|
||||
/// the pod, and nobody has to read the endpoint for the metrics to move.
|
||||
pub(crate) fn describe_readiness_metrics() {
|
||||
metrics::describe_counter!(
|
||||
"buzz_readiness_checks_total",
|
||||
"Kubernetes health-listener readiness probes by terminal bounded reason"
|
||||
"Kubernetes health-listener readiness probes by lifecycle reason (ready, shutting_down)"
|
||||
);
|
||||
metrics::describe_counter!(
|
||||
"buzz_readiness_dependency_checks_total",
|
||||
"Completed readiness dependency attempts by dependency and bounded outcome"
|
||||
"Completed dependency-sampler attempts by dependency and bounded outcome"
|
||||
);
|
||||
metrics::describe_histogram!(
|
||||
"buzz_readiness_check_duration_seconds",
|
||||
metrics::Unit::Seconds,
|
||||
"Completed readiness check duration without outcome label multiplication"
|
||||
"Completed dependency-sampler check duration without outcome label multiplication"
|
||||
);
|
||||
metrics::describe_gauge!(
|
||||
"buzz_readiness_state",
|
||||
"Latest publishable readiness state by check, where 1 is ready and 0 is not ready"
|
||||
"Latest private readiness-probe observation, where 1 is ready and 0 is shutting down"
|
||||
);
|
||||
metrics::describe_gauge!(
|
||||
"buzz_readiness_dependency_sample_completed_timestamp_seconds",
|
||||
"Unix time the cached /_status dependency report completed, absent until the first sample completes; sampler completion advances it and the publisher re-emits it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Register the bounded community-admission contract.
|
||||
pub(crate) fn describe_community_admission_metrics() {
|
||||
metrics::describe_counter!(
|
||||
"buzz_community_admission_checks_total",
|
||||
"Durable community-active checks at socket admission by bounded outcome"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -541,7 +560,17 @@ pub(crate) fn readiness_test_recorder() -> (
|
||||
metrics_exporter_prometheus::PrometheusRecorder,
|
||||
metrics_exporter_prometheus::PrometheusHandle,
|
||||
) {
|
||||
let recorder = configured_prometheus_builder(300).build_recorder();
|
||||
readiness_test_recorder_with_idle_timeout(300)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn readiness_test_recorder_with_idle_timeout(
|
||||
gauge_idle_timeout_secs: u64,
|
||||
) -> (
|
||||
metrics_exporter_prometheus::PrometheusRecorder,
|
||||
metrics_exporter_prometheus::PrometheusHandle,
|
||||
) {
|
||||
let recorder = configured_prometheus_builder(gauge_idle_timeout_secs).build_recorder();
|
||||
let handle = recorder.handle();
|
||||
(recorder, handle)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -182,10 +182,99 @@ impl Drop for CommunityConnectionGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Message reported when the one-time Redis bootstrap gate rejects startup.
|
||||
///
|
||||
/// Bounded and stable so operators and the boot regression test can match on
|
||||
/// it without parsing the underlying driver error.
|
||||
pub const REDIS_BOOTSTRAP_FAILURE: &str = "Redis command path unavailable at startup";
|
||||
|
||||
/// Budget for the one-time bootstrap PING. A refused port answers immediately;
|
||||
/// this only bounds a blackholed address, where hanging forever would be worse
|
||||
/// than exiting.
|
||||
const REDIS_BOOTSTRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Keep the Redis driver's per-command timeout outside the relay-owned startup
|
||||
/// budget so [`REDIS_BOOTSTRAP_TIMEOUT`] remains the one authoritative bound.
|
||||
const REDIS_BOOTSTRAP_DRIVER_TIMEOUT: std::time::Duration =
|
||||
REDIS_BOOTSTRAP_TIMEOUT.saturating_mul(2);
|
||||
|
||||
/// Proves once, during startup, that the Redis command path this pod will serve
|
||||
/// from can actually be reached.
|
||||
///
|
||||
/// `deadpool_redis` pools dial lazily and `PubSubManager::new` only allocates
|
||||
/// channels, so without this nothing in boot ever opened a command connection:
|
||||
/// a relay came up against a dead Redis, bound its health listener, and — since
|
||||
/// readiness reports local lifecycle only — advertised ready forever. Binding
|
||||
/// that listener is a one-way latch, so the check has to happen before it, and
|
||||
/// it is deliberately a *startup* gate: once serving, a Redis blip is a
|
||||
/// dependency failure and must never change readiness.
|
||||
pub async fn verify_redis_command_path(pool: &deadpool_redis::Pool) -> anyhow::Result<()> {
|
||||
let ping = async {
|
||||
let connection = pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}"))?;
|
||||
// This one-shot connection is removed from the pool so extending its
|
||||
// driver timeout cannot leak into normal serving traffic. The relay's
|
||||
// outer timeout below must bound both lazy checkout and PING.
|
||||
let mut connection = deadpool_redis::Connection::take(connection);
|
||||
connection.set_response_timeout(REDIS_BOOTSTRAP_DRIVER_TIMEOUT);
|
||||
redis::cmd("PING")
|
||||
.query_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{REDIS_BOOTSTRAP_FAILURE}: {error}"))
|
||||
};
|
||||
|
||||
match tokio::time::timeout(REDIS_BOOTSTRAP_TIMEOUT, ping).await {
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"{REDIS_BOOTSTRAP_FAILURE}: no response within {REDIS_BOOTSTRAP_TIMEOUT:?}"
|
||||
)),
|
||||
Ok(result) => result.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded outcome of the durable community-active check run when a socket is
|
||||
/// admitted.
|
||||
///
|
||||
/// `outcome` is the only dimension. Community, tenant, connection, and error
|
||||
/// text are request-controlled and deliberately absent from the label set.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AdmissionOutcome {
|
||||
Active,
|
||||
Inactive,
|
||||
CheckError,
|
||||
}
|
||||
|
||||
impl AdmissionOutcome {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Inactive => "inactive",
|
||||
Self::CheckError => "check_error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_admission_check(outcome: AdmissionOutcome) {
|
||||
metrics::counter!(
|
||||
"buzz_community_admission_checks_total",
|
||||
"outcome" => outcome.label(),
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
/// Registers a socket, durably revalidates its community, then runs it.
|
||||
///
|
||||
/// The ordering is the archival admission invariant: archive-before-query is
|
||||
/// observed by the query, while archive-after-registration sees the token.
|
||||
///
|
||||
/// Admission is fail-closed: only an affirmative `Ok(true)` may serve. Both
|
||||
/// `Ok(false)` and a lookup `Err` cancel, because neither proves this tenant is
|
||||
/// currently admitted, and `docs/multi-tenant-relay.md` I5
|
||||
/// (`Inv_AdmissionFence`) grants capability only to an actor *currently*
|
||||
/// admitted to that community. The two are still told apart in telemetry
|
||||
/// (`buzz_community_admission_checks_total{outcome}`) so an operator can
|
||||
/// separate archival from database pressure.
|
||||
pub(crate) async fn run_registered_community_connection<Check, CheckFuture, Run, RunFuture>(
|
||||
registry: &CommunityConnectionRegistry,
|
||||
connection_id: Uuid,
|
||||
@@ -201,9 +290,29 @@ pub(crate) async fn run_registered_community_connection<Check, CheckFuture, Run,
|
||||
{
|
||||
let cancel = control.cancel.clone();
|
||||
let _guard = registry.register(connection_id, community_id, control.clone());
|
||||
if !matches!(check_active().await, Ok(true)) {
|
||||
cancel.cancel();
|
||||
return;
|
||||
match check_active().await {
|
||||
Ok(true) => record_admission_check(AdmissionOutcome::Active),
|
||||
Ok(false) => {
|
||||
record_admission_check(AdmissionOutcome::Inactive);
|
||||
cancel.cancel();
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
// A lookup failure is not an answer, so it cannot authorize one.
|
||||
// Admitting here would begin serving AUTH and REQ for a tenant
|
||||
// whose lifecycle is unknown, and the adjacent host-binding seam
|
||||
// already refuses on exactly this evidence (see
|
||||
// `router::nip11_or_ws_handler`). The client sees an ordinary dial
|
||||
// failure and retries.
|
||||
record_admission_check(AdmissionOutcome::CheckError);
|
||||
tracing::warn!(
|
||||
%community_id,
|
||||
%error,
|
||||
"community active check failed; refusing the socket"
|
||||
);
|
||||
cancel.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return;
|
||||
@@ -719,8 +828,14 @@ pub struct AppState {
|
||||
pub audio_rooms: Arc<AudioRoomManager>,
|
||||
/// Set to `true` on SIGTERM — readiness probe returns 503.
|
||||
pub shutting_down: Arc<AtomicBool>,
|
||||
/// Orders readiness gauge publication against terminal shutdown.
|
||||
pub(crate) readiness: Arc<crate::readiness::ReadinessCoordinator>,
|
||||
/// Cached shared-dependency evaluation behind the diagnostic `/_status`
|
||||
/// endpoint, owned by [`crate::readiness::run_dependency_sampler`]. Never
|
||||
/// consulted by a Kubernetes probe, and never evaluated by a request.
|
||||
pub(crate) dependency_diagnostics: Arc<crate::readiness::DependencyDiagnostics>,
|
||||
/// Stops only the periodic dependency sampler during graceful shutdown.
|
||||
pub dependency_sampler_cancel: CancellationToken,
|
||||
/// Stops only the completion-epoch publisher during graceful shutdown.
|
||||
pub dependency_completion_publisher_cancel: CancellationToken,
|
||||
/// Process start time — used by `/_status` endpoint.
|
||||
pub started_at: Instant,
|
||||
/// Shared, community-scoped NIP-98 replay prevention.
|
||||
@@ -946,7 +1061,9 @@ impl AppState {
|
||||
git_pack_cache,
|
||||
audio_rooms: Arc::new(AudioRoomManager::new()),
|
||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||
readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()),
|
||||
dependency_diagnostics: Arc::new(crate::readiness::DependencyDiagnostics::default()),
|
||||
dependency_sampler_cancel: CancellationToken::new(),
|
||||
dependency_completion_publisher_cancel: CancellationToken::new(),
|
||||
started_at: Instant::now(),
|
||||
nip98_replay,
|
||||
gif_http_client,
|
||||
@@ -990,21 +1107,21 @@ impl AppState {
|
||||
)
|
||||
}
|
||||
|
||||
/// Atomically closes readiness publication before exposing shutdown to
|
||||
/// the relay's other fast-path lifecycle checks.
|
||||
/// Withdraws this pod from routing. The lifecycle flag is authoritative for
|
||||
/// `/_readiness`; the private probe publishes its sampled observation to
|
||||
/// the readiness gauge on its next request.
|
||||
pub fn begin_shutdown(&self) {
|
||||
self.readiness.begin_shutdown();
|
||||
self.shutting_down.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_readiness_evaluator(
|
||||
pub(crate) fn set_dependency_evaluator(
|
||||
&mut self,
|
||||
evaluator: Arc<dyn crate::readiness::ReadinessEvaluator>,
|
||||
evaluator: Arc<dyn crate::readiness::DependencyEvaluator>,
|
||||
) {
|
||||
self.readiness = Arc::new(crate::readiness::ReadinessCoordinator::with_evaluator(
|
||||
evaluator,
|
||||
));
|
||||
self.dependency_diagnostics = Arc::new(
|
||||
crate::readiness::DependencyDiagnostics::with_evaluator(evaluator),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers
|
||||
@@ -2096,6 +2213,144 @@ pub(crate) mod tests {
|
||||
assert!(!started_during.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// Reads one `buzz_community_admission_checks_total` series by exact label set.
|
||||
fn admission_counter(
|
||||
snapshot: &[(
|
||||
metrics_util::CompositeKey,
|
||||
Option<metrics::Unit>,
|
||||
Option<metrics::SharedString>,
|
||||
metrics_util::debugging::DebugValue,
|
||||
)],
|
||||
outcome: &str,
|
||||
) -> Option<u64> {
|
||||
snapshot.iter().find_map(|(key, _, _, value)| {
|
||||
let labels = key
|
||||
.key()
|
||||
.labels()
|
||||
.map(|label| (label.key(), label.value()))
|
||||
.collect::<Vec<_>>();
|
||||
if key.key().name() != "buzz_community_admission_checks_total"
|
||||
|| labels != [("outcome", outcome)]
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match value {
|
||||
metrics_util::debugging::DebugValue::Counter(count) => Some(*count),
|
||||
_ => panic!("community admission checks must be a counter"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Admission is fail-closed on both non-affirmative outcomes. A confirmed
|
||||
/// `Ok(false)` and a lookup `Err` are different diagnoses — the counter
|
||||
/// keeps them apart — but neither is proof of current admission, and
|
||||
/// `docs/multi-tenant-relay.md` I5 (`Inv_AdmissionFence`) grants read or
|
||||
/// membership capability only to an actor *currently* admitted to that
|
||||
/// community. Serving AUTH/REQ on an unproven tenant lifecycle is the
|
||||
/// failure this guards.
|
||||
#[test]
|
||||
fn neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("current-thread runtime");
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
let (inactive_cancel, inactive_started, error_cancel, error_started, active_started) =
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let registry = CommunityConnectionRegistry::new();
|
||||
let community = CommunityId::from_uuid(Uuid::from_u128(0xa));
|
||||
|
||||
let inactive_cancel = CancellationToken::new();
|
||||
let inactive_started = Arc::new(AtomicBool::new(false));
|
||||
let started = Arc::clone(&inactive_started);
|
||||
run_registered_community_connection(
|
||||
®istry,
|
||||
Uuid::new_v4(),
|
||||
community,
|
||||
CommunityConnectionControl::new(inactive_cancel.clone()),
|
||||
|| async { Ok(false) },
|
||||
move |_| async move { started.store(true, Ordering::SeqCst) },
|
||||
)
|
||||
.await;
|
||||
|
||||
let error_cancel = CancellationToken::new();
|
||||
let error_started = Arc::new(AtomicBool::new(false));
|
||||
let started = Arc::clone(&error_started);
|
||||
run_registered_community_connection(
|
||||
®istry,
|
||||
Uuid::new_v4(),
|
||||
community,
|
||||
CommunityConnectionControl::new(error_cancel.clone()),
|
||||
|| async { Err(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut)) },
|
||||
move |_| async move { started.store(true, Ordering::SeqCst) },
|
||||
)
|
||||
.await;
|
||||
|
||||
let active_started = Arc::new(AtomicBool::new(false));
|
||||
let started = Arc::clone(&active_started);
|
||||
run_registered_community_connection(
|
||||
®istry,
|
||||
Uuid::new_v4(),
|
||||
community,
|
||||
CommunityConnectionControl::new(CancellationToken::new()),
|
||||
|| async { Ok(true) },
|
||||
move |_| async move { started.store(true, Ordering::SeqCst) },
|
||||
)
|
||||
.await;
|
||||
|
||||
(
|
||||
inactive_cancel,
|
||||
inactive_started,
|
||||
error_cancel,
|
||||
error_started,
|
||||
active_started,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
assert!(
|
||||
inactive_cancel.is_cancelled(),
|
||||
"a confirmed-inactive community must still cancel its socket"
|
||||
);
|
||||
assert!(
|
||||
!inactive_started.load(Ordering::SeqCst),
|
||||
"a confirmed-inactive community must never start the socket body"
|
||||
);
|
||||
assert!(
|
||||
error_cancel.is_cancelled(),
|
||||
"a failed active check must cancel its socket, not admit it"
|
||||
);
|
||||
assert!(
|
||||
!error_started.load(Ordering::SeqCst),
|
||||
"a failed active check must never start serving AUTH/REQ on an unproven tenant"
|
||||
);
|
||||
assert!(active_started.load(Ordering::SeqCst));
|
||||
|
||||
let snapshot = snapshotter.snapshot().into_vec();
|
||||
assert_eq!(admission_counter(&snapshot, "inactive"), Some(1));
|
||||
assert_eq!(admission_counter(&snapshot, "check_error"), Some(1));
|
||||
assert_eq!(admission_counter(&snapshot, "active"), Some(1));
|
||||
|
||||
let label_sets = snapshot
|
||||
.iter()
|
||||
.filter(|(key, _, _, _)| key.key().name() == "buzz_community_admission_checks_total")
|
||||
.map(|(key, _, _, _)| {
|
||||
key.key()
|
||||
.labels()
|
||||
.map(|label| label.key().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(label_sets.len(), 3, "outcome is the only dimension");
|
||||
assert!(
|
||||
label_sets.iter().all(|labels| labels == &["outcome"]),
|
||||
"admission telemetry must never carry community, tenant, or error labels: {label_sets:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revalidation_continues_after_one_community_lookup_failure() {
|
||||
let registry = CommunityConnectionRegistry::new();
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::{
|
||||
io::{Read as _, Write as _},
|
||||
net::{TcpListener, TcpStream},
|
||||
process::{Child, Command, ExitStatus, Output, Stdio},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -10,11 +11,14 @@ use std::{
|
||||
use serde_json::Value;
|
||||
|
||||
use buzz_relay::lifecycle::StartupPhase;
|
||||
use buzz_relay::state::REDIS_BOOTSTRAP_FAILURE;
|
||||
|
||||
const VALID_RELAY_PRIVATE_KEY: &str =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const CHILD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_CAPTURE_BYTES: u64 = 1024 * 1024;
|
||||
/// Bound on every wait for the relay to export a metric family.
|
||||
const METRICS_SCRAPE_DEADLINE: Duration = Duration::from_secs(8);
|
||||
|
||||
struct RelayProcess {
|
||||
child: Option<Child>,
|
||||
@@ -156,20 +160,27 @@ fn scrape_metrics(port: u16) -> std::io::Result<String> {
|
||||
}
|
||||
|
||||
fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
wait_for_scraped_metric(process, port, "buzz_audit_enabled")
|
||||
}
|
||||
|
||||
/// Polls the relay's own `/metrics` until `needle` appears, bounded by
|
||||
/// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a
|
||||
/// timeout, so the panic names the real cause.
|
||||
fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String {
|
||||
let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE;
|
||||
loop {
|
||||
assert!(
|
||||
process.try_wait().is_none(),
|
||||
"relay exited before its metrics endpoint became usable"
|
||||
"relay exited before exporting {needle}"
|
||||
);
|
||||
if let Ok(response) = scrape_metrics(port) {
|
||||
if response.contains("buzz_audit_enabled") {
|
||||
if response.contains(needle) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"relay metrics did not become scrapeable within 8s"
|
||||
"relay did not export {needle} within {METRICS_SCRAPE_DEADLINE:?}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -500,3 +511,334 @@ fn successful_main_emits_complete_lifecycle_without_startup_metrics() {
|
||||
assert_terminal(&events, "metrics_bind", "succeeded", None);
|
||||
assert_terminal(&events, "process_telemetry", "succeeded", None);
|
||||
}
|
||||
|
||||
/// Boot gates that need a live Postgres to reach the code under test. Named
|
||||
/// `postgres_tests` so `.config/nextest.toml`'s `postgres-ci` default filter
|
||||
/// discovers them structurally; the wrapper hands each test its own database
|
||||
/// through `DATABASE_URL`.
|
||||
mod postgres_tests {
|
||||
use super::*;
|
||||
|
||||
const REDIS_BOOTSTRAP_BUDGET: Duration = Duration::from_secs(5);
|
||||
const REDIS_BOOTSTRAP_SCHEDULING_SLACK: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A TCP peer that completes Redis's metadata handshake, then reads and
|
||||
/// holds PING without replying. This distinguishes a bounded checkout +
|
||||
/// PING from a refused connection, which returns before the bootstrap
|
||||
/// timeout is exercised.
|
||||
struct HangingRedisPeer {
|
||||
redis_url: String,
|
||||
request_received: mpsc::Receiver<Vec<u8>>,
|
||||
stop: mpsc::Sender<()>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HangingRedisPeer {
|
||||
fn spawn() -> Self {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake Redis peer");
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set fake Redis listener nonblocking");
|
||||
let port = listener.local_addr().expect("fake Redis address").port();
|
||||
let (request_tx, request_received) = mpsc::channel();
|
||||
let (stop, stop_rx) = mpsc::channel();
|
||||
let worker = thread::spawn(move || loop {
|
||||
if stop_rx.try_recv().is_ok() {
|
||||
return;
|
||||
}
|
||||
let (mut stream, _) = match listener.accept() {
|
||||
Ok(accepted) => accepted,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
Err(error) => panic!("accept fake Redis connection: {error}"),
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||
.expect("bound fake Redis read");
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0_u8; 4096];
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => return,
|
||||
Ok(read) => {
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
assert!(
|
||||
request.len() <= 4096,
|
||||
"fake Redis peer received an oversized request"
|
||||
);
|
||||
let setinfo_commands = request
|
||||
.windows(b"SETINFO".len())
|
||||
.filter(|window| *window == b"SETINFO")
|
||||
.count();
|
||||
if setinfo_commands >= 2 {
|
||||
// redis-rs pipelines CLIENT SETINFO lib-name
|
||||
// and lib-ver while establishing a connection.
|
||||
// Complete that handshake so the relay reaches
|
||||
// its explicit bootstrap PING, then hold it.
|
||||
stream
|
||||
.write_all(b"+OK\r\n+OK\r\n")
|
||||
.expect("reply to Redis client handshake");
|
||||
request.clear();
|
||||
continue;
|
||||
}
|
||||
if request
|
||||
.windows(b"PING".len())
|
||||
.any(|window| window == b"PING")
|
||||
{
|
||||
let _ = request_tx.send(request);
|
||||
let _ = stop_rx.recv_timeout(CHILD_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
if stop_rx.try_recv().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => panic!("read fake Redis request: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
redis_url: format!("redis://127.0.0.1:{port}"),
|
||||
request_received,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
fn redis_url(&self) -> &str {
|
||||
&self.redis_url
|
||||
}
|
||||
|
||||
fn assert_request_received(&self, logs: &str) {
|
||||
let request = self
|
||||
.request_received
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.unwrap_or_else(|_| panic!("relay must reach the fake Redis peer: {logs}"));
|
||||
assert!(!request.is_empty(), "fake Redis peer read an empty request");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HangingRedisPeer {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop.send(());
|
||||
if let Some(worker) = self.worker.take() {
|
||||
worker.join().expect("fake Redis peer must not panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_closed_port() -> u16 {
|
||||
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port");
|
||||
let port = reserved.local_addr().expect("reserved address").port();
|
||||
drop(reserved);
|
||||
port
|
||||
}
|
||||
|
||||
/// Runs the relay until it exits on its own, or kills it once `timeout`
|
||||
/// passes. Unlike `run_relay`, a relay that keeps serving is a result to
|
||||
/// assert on rather than a panic, which is the whole point here.
|
||||
fn run_until_exit(environment: &[(&str, &str)], timeout: Duration) -> (bool, Output) {
|
||||
let mut process = RelayProcess::spawn(environment);
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if process.try_wait().is_some() {
|
||||
return (true, process.wait(Duration::from_secs(2)));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
(false, process.terminate())
|
||||
}
|
||||
|
||||
/// Redis is required for pub/sub fan-out, presence, and typing, but nothing
|
||||
/// in boot ever opened a command connection: `deadpool_redis` pools dial
|
||||
/// lazily and `PubSubManager::new` only allocates channels, so "Redis
|
||||
/// pub/sub connected" was logged against a dead port. A relay could
|
||||
/// therefore boot with Redis unreachable, bind its health listener, and —
|
||||
/// now that readiness answers from local lifecycle alone — advertise ready
|
||||
/// for the rest of its life. The bootstrap gate is the one-time proof that
|
||||
/// the command path has connected at least once, and it has to land before
|
||||
/// the listener binds, because binding is the one-way latch that makes this
|
||||
/// pod routable.
|
||||
///
|
||||
/// The git conformance probe is disabled so the only remaining startup-fatal
|
||||
/// gate is the one under test.
|
||||
#[test]
|
||||
#[ignore = "requires PostgreSQL"]
|
||||
fn unreachable_redis_fails_boot_before_the_health_listener_binds() {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.expect("postgres lane provides DATABASE_URL for each test process");
|
||||
let redis_url = format!("redis://127.0.0.1:{}", reserve_closed_port());
|
||||
let metrics_port = reserve_closed_port().to_string();
|
||||
let health_port = reserve_closed_port();
|
||||
let health_port_value = health_port.to_string();
|
||||
|
||||
let (exited, output) = run_until_exit(
|
||||
&[
|
||||
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
|
||||
("BUZZ_METRICS_PORT", &metrics_port),
|
||||
("BUZZ_HEALTH_PORT", &health_port_value),
|
||||
("DATABASE_URL", &database_url),
|
||||
("REDIS_URL", &redis_url),
|
||||
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
|
||||
],
|
||||
CHILD_TIMEOUT,
|
||||
);
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
assert!(
|
||||
!logs.contains("Health probe listener started"),
|
||||
"the Redis bootstrap gate must run before the health listener binds: {logs}"
|
||||
);
|
||||
assert!(
|
||||
exited && !output.status.success(),
|
||||
"an unreachable Redis command path must be startup-fatal: {logs}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains(REDIS_BOOTSTRAP_FAILURE),
|
||||
"the failure must name the gate that rejected boot: {logs}"
|
||||
);
|
||||
assert!(
|
||||
TcpListener::bind(("0.0.0.0", health_port)).is_ok(),
|
||||
"the health port must never have been bound"
|
||||
);
|
||||
}
|
||||
|
||||
/// A peer that accepts the socket but withholds its Redis response exercises
|
||||
/// the outer timeout around both lazy pool checkout and PING. Removing or
|
||||
/// narrowing that timeout makes this test kill a still-running relay at the
|
||||
/// deadline instead of observing a startup failure.
|
||||
#[test]
|
||||
#[ignore = "requires PostgreSQL"]
|
||||
fn hanging_redis_peer_times_out_before_the_health_listener_binds() {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.expect("postgres lane provides DATABASE_URL for each test process");
|
||||
let redis_peer = HangingRedisPeer::spawn();
|
||||
let metrics_port = reserve_closed_port().to_string();
|
||||
let health_port = reserve_closed_port();
|
||||
let health_port_value = health_port.to_string();
|
||||
let timeout = REDIS_BOOTSTRAP_BUDGET + REDIS_BOOTSTRAP_SCHEDULING_SLACK;
|
||||
let started_at = Instant::now();
|
||||
|
||||
let (exited, output) = run_until_exit(
|
||||
&[
|
||||
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
|
||||
("BUZZ_METRICS_PORT", &metrics_port),
|
||||
("BUZZ_HEALTH_PORT", &health_port_value),
|
||||
("DATABASE_URL", &database_url),
|
||||
("REDIS_URL", redis_peer.redis_url()),
|
||||
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
|
||||
],
|
||||
timeout,
|
||||
);
|
||||
let elapsed = started_at.elapsed();
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
redis_peer.assert_request_received(&logs);
|
||||
|
||||
assert!(
|
||||
elapsed >= REDIS_BOOTSTRAP_BUDGET,
|
||||
"the fake peer must hold the request through the bootstrap budget: {elapsed:?}: {logs}"
|
||||
);
|
||||
assert!(
|
||||
exited && elapsed < timeout && !output.status.success(),
|
||||
"the outer bootstrap timeout must terminate the relay within scheduling slack: {elapsed:?}: {logs}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains(REDIS_BOOTSTRAP_FAILURE),
|
||||
"the timeout must report the bounded Redis bootstrap failure: {logs}"
|
||||
);
|
||||
assert!(
|
||||
!logs.contains("Health probe listener started"),
|
||||
"the health listener must not bind before Redis bootstrap succeeds: {logs}"
|
||||
);
|
||||
assert!(
|
||||
TcpListener::bind(("0.0.0.0", health_port)).is_ok(),
|
||||
"the health port must never have been bound"
|
||||
);
|
||||
}
|
||||
|
||||
/// Startup is the only owner of dependency evaluation. `/_status` just reads
|
||||
/// the cache, a readiness probe records no dependency attempt at all, and no
|
||||
/// request path may start a check — so if `main` stops spawning the sampler,
|
||||
/// this pod evaluates Postgres, Redis, and the deletion catalog exactly
|
||||
/// never: the dependency families stay absent from its scrape and `/_status`
|
||||
/// answers `not_yet_sampled` for the pod's whole life.
|
||||
///
|
||||
/// No in-process test can fail on that, because each one drives `sample`
|
||||
/// itself. This one boots the real binary against real dependencies and
|
||||
/// reads only the relay's own `/metrics` — no probe, no `/_status`, nothing
|
||||
/// that could evaluate a dependency on the test's behalf. The first tick
|
||||
/// fires immediately, so the wait is bounded by
|
||||
/// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep.
|
||||
///
|
||||
/// The completion-timestamp gauge is the needle because `sample` writes it
|
||||
/// last, after the cache already serves the report: observing it proves the
|
||||
/// whole cycle ran, with no window where the counters have landed but the
|
||||
/// timestamp has not.
|
||||
#[test]
|
||||
#[ignore = "requires PostgreSQL"]
|
||||
fn startup_owns_the_dependency_sampler() {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.expect("postgres lane provides DATABASE_URL for each test process");
|
||||
let redis_url = std::env::var("REDIS_URL")
|
||||
.expect("the postgres lane runs alongside Redis and exports REDIS_URL");
|
||||
let metrics_port = reserve_closed_port();
|
||||
let metrics_port_value = metrics_port.to_string();
|
||||
let health_port_value = reserve_closed_port().to_string();
|
||||
let bind_addr = format!("127.0.0.1:{}", reserve_closed_port());
|
||||
|
||||
let mut process = RelayProcess::spawn(&[
|
||||
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
|
||||
("BUZZ_METRICS_PORT", &metrics_port_value),
|
||||
("BUZZ_HEALTH_PORT", &health_port_value),
|
||||
("BUZZ_BIND_ADDR", &bind_addr),
|
||||
("DATABASE_URL", &database_url),
|
||||
("REDIS_URL", &redis_url),
|
||||
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
|
||||
]);
|
||||
let scrape = wait_for_scraped_metric(
|
||||
&mut process,
|
||||
metrics_port,
|
||||
"buzz_readiness_dependency_sample_completed_timestamp_seconds",
|
||||
);
|
||||
let output = process.terminate();
|
||||
let logs = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
assert!(
|
||||
scrape.contains("buzz_readiness_dependency_checks_total{"),
|
||||
"the same cycle must publish its per-dependency outcomes: {scrape}"
|
||||
);
|
||||
assert!(
|
||||
scrape.contains("buzz_readiness_check_duration_seconds"),
|
||||
"a completed evaluation must publish its latency too: {scrape}"
|
||||
);
|
||||
assert!(
|
||||
!scrape.contains("buzz_readiness_checks_total{"),
|
||||
"no readiness probe was sent, so the sampler alone produced this: {scrape}"
|
||||
);
|
||||
assert!(
|
||||
logs.contains("Health probe listener started"),
|
||||
"the sampler must be owned by a relay that finished booting: {logs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,11 @@ SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base
|
||||
|
||||
Object storage is contacted during relay startup only when
|
||||
`BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is
|
||||
startup-fatal, so Kubernetes readiness never opens. If an operator explicitly
|
||||
disables that probe through `relay.extraEnv`, `/_readiness` does not test object
|
||||
storage; configuration is still parsed strictly, but reachability and addressing
|
||||
errors surface on the first storage operation.
|
||||
startup-fatal, so the process exits and Kubernetes readiness never opens. If an
|
||||
operator explicitly disables that probe through `relay.extraEnv`, configuration
|
||||
is still parsed strictly, but reachability and addressing errors surface on the
|
||||
first storage operation. `/_readiness` tests no external dependency in either
|
||||
case — see the readiness contract below.
|
||||
|
||||
### Early-startup telemetry contract
|
||||
|
||||
@@ -125,26 +126,114 @@ These phases intentionally do not emit metrics. Most run before the Prometheus
|
||||
exporter exists, and one uniform log-only contract preserves every phase's real
|
||||
event time and failure without assigning an eventual scrape time to earlier work.
|
||||
|
||||
### Readiness contract
|
||||
|
||||
**`/_readiness` reports local process lifecycle only.** It performs no
|
||||
Postgres, Redis, or deletion-catalog I/O: `shutting_down` returns 503, and any
|
||||
other state returns 200. Shared dependencies are shared by every replica, so
|
||||
gating the probe on them removed the whole deployment from the load balancer at
|
||||
once and left a reconnect burst with nowhere to land. There is no separate
|
||||
"starting" state — the health listener does not bind until the database,
|
||||
migrations, Redis, and pub/sub are up, so a process that can answer has booted.
|
||||
|
||||
Shared-dependency health moved to **`/_status`** on the same private health
|
||||
listener, under a `dependencies` object carrying the `postgres`, `redis`,
|
||||
`deletion_catalog`, and aggregate `reason` fields the readiness body used to
|
||||
return. Do not wire `/_status` to a Kubernetes probe.
|
||||
|
||||
**`/_status` is a cached read.** It performs no Postgres, Redis, or
|
||||
deletion-catalog I/O of its own. One background loop per pod evaluates the
|
||||
three dependencies **every 30 seconds**, awaiting each evaluation before taking
|
||||
the next tick, so a pod never has more than one evaluation in flight no matter
|
||||
how often — or how rarely — the endpoint is read. The loop publishes the
|
||||
dependency metrics below and caches the report `/_status` serves. Its first
|
||||
cycle runs at startup, and each evaluation is bounded by a two-second budget.
|
||||
|
||||
Every `dependencies` object therefore states how old its report is:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `sample: "not_yet_sampled"` | the first cycle has not completed; no `postgres`/`redis`/`deletion_catalog`/`reason` fields are present, because there is no observation to report |
|
||||
| `sample: "fresh"` | the report is at most two cadences (60s) old |
|
||||
| `sample: "stale"` | the report outlived two cadences, so the sampler missed at least one cycle — read the verdict as history, not as current state |
|
||||
| `sample_age_seconds` | age of the report at request time (absent when `not_yet_sampled`) |
|
||||
| `sample_interval_seconds` | the sampling cadence, `30` |
|
||||
|
||||
Polling `/_status` more often than the cadence returns the same cached report;
|
||||
it does not make the data fresher and adds no dependency load.
|
||||
|
||||
### Readiness telemetry contract
|
||||
|
||||
Only requests served by the private health listener (`BUZZ_HEALTH_PORT`) emit
|
||||
rollout readiness telemetry. The compatibility `/_readiness` route on the public
|
||||
app listener returns health but does not change these metrics.
|
||||
rollout telemetry. The compatibility `/_readiness` route on the public app
|
||||
listener returns the same lifecycle answer but does not change these metrics.
|
||||
|
||||
| Metric | Type | Labels | Source |
|
||||
|--------|------|--------|--------|
|
||||
| `buzz_readiness_checks_total` | counter | `reason` ∈ {`ready`, `shutting_down`} | `/_readiness` |
|
||||
| `buzz_readiness_state` | gauge | `check="overall"`; latest private probe observation, 1 ready or 0 shutting down | `/_readiness` |
|
||||
| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | dependency sampler |
|
||||
| `buzz_readiness_check_duration_seconds` | histogram | `check` only | dependency sampler |
|
||||
| `buzz_readiness_dependency_sample_completed_timestamp_seconds` | gauge | none; Unix time the cached report completed | completion publisher |
|
||||
|
||||
The three dependency families keep their `buzz_readiness_*` names for dashboard
|
||||
continuity, but nothing about them is request-driven any more: the 30-second
|
||||
sampler publishes them whether or not anyone reads `/_status`, so a quiet
|
||||
endpoint no longer produces a flat dashboard during the outage it exists to
|
||||
explain.
|
||||
|
||||
`buzz_readiness_dependency_sample_completed_timestamp_seconds` carries **when
|
||||
the cached report completed**, in Unix seconds. The relay sampler is the only
|
||||
owner allowed to advance that epoch, and it writes it immediately after the
|
||||
cache is replaced. A separate bounded publisher re-emits the stored epoch often
|
||||
enough to survive local gauge idle-timeout; republishing never advances the
|
||||
timestamp. If a republish races a newer completion, one scrape can briefly see
|
||||
the older epoch, but the republish path verifies after writing and repairs to the
|
||||
newer stored epoch before that republish call returns. So the value stands still
|
||||
when sampling stops, and the time elapsed since it was written is whatever the
|
||||
reader computes at read time.
|
||||
|
||||
Following the `buzz_storage_sweep_age_seconds` convention, the series is not
|
||||
emitted until the first sample completes: its absence means "not yet sampled",
|
||||
not "fresh".
|
||||
|
||||
That is the whole server-side contract. Freshness alerting is built from this
|
||||
gauge in the monitoring provider, and the monitor query, thresholds, and
|
||||
per-pod tag grouping belong with the deployment's monitor configuration rather
|
||||
than in this chart — they depend on the provider's query grammar and on the
|
||||
tags its agent attaches, neither of which this repo owns.
|
||||
|
||||
Two properties of the neighboring families are worth knowing when building
|
||||
that alerting. `buzz_readiness_dependency_checks_total` and
|
||||
`buzz_readiness_check_duration_seconds` are cumulative, so a sampler that stops
|
||||
leaves their last values exported and scraped indefinitely: those series stay
|
||||
present and flat rather than disappearing. And per-report freshness for a human
|
||||
reading a single pod is already on the `sample`, `sample_age_seconds`, and
|
||||
`sample_interval_seconds` fields of `/_status` above.
|
||||
|
||||
The schema has a ceiling of 87 raw Prometheus series per pod: 2 probe reasons,
|
||||
11 valid dependency/outcome pairs, 72 histogram series, and 2 gauges (overall lifecycle + completion timestamp). Do not
|
||||
add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, user,
|
||||
community, pubkey, header, query, or other request-controlled labels. A
|
||||
readiness probe records no dependency attempt or latency sample at all.
|
||||
The readiness gauge is not a monotonic lifecycle mirror: shutdown changes the
|
||||
authoritative lifecycle flag, and the next private readiness probe observes and
|
||||
publishes that state.
|
||||
|
||||
### Community admission telemetry
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `buzz_readiness_checks_total` | counter | `reason` from the closed readiness-reason set |
|
||||
| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` |
|
||||
| `buzz_readiness_check_duration_seconds` | histogram | `check` only |
|
||||
| `buzz_readiness_state` | gauge | `check` only; latest publishable generation |
|
||||
| `buzz_community_admission_checks_total` | counter | `outcome` ∈ {`active`, `inactive`, `check_error`} |
|
||||
|
||||
The schema has a ceiling of 99 raw Prometheus series per pod: 12 overall
|
||||
reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 4 gauges.
|
||||
Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant,
|
||||
user, community, pubkey, header, query, or other request-controlled labels.
|
||||
Shutdown without dependency evaluation increments only
|
||||
`buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall
|
||||
state to zero; it does not fabricate dependency failures or latency samples.
|
||||
Counts the durable community-active check run before a socket is admitted.
|
||||
Admission is fail-closed: only `active` serves. `inactive` (a confirmed
|
||||
archival answer) and `check_error` (the lookup itself failed, so the tenant
|
||||
lifecycle is unknown) both refuse the socket before any AUTH or REQ frame is
|
||||
read; the client sees an ordinary dial failure and retries. The two outcomes
|
||||
stay distinct so a rise in `check_error` reads as database pressure rather than
|
||||
archival. `outcome` is the only dimension — community and error text are
|
||||
request-controlled and must never become labels.
|
||||
|
||||
### Operation-aware database pool acquisition contract
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ The relay health listener exposes intrinsic build identity at `/_status`:
|
||||
"source_sha": "<40-character-source-sha>",
|
||||
"id": "github-actions:<run-id>:<attempt>",
|
||||
"url": "https://github.com/block/buzz/actions/runs/<run-id>/attempts/<attempt>"
|
||||
},
|
||||
"dependencies": {
|
||||
"sample": "fresh",
|
||||
"sample_interval_seconds": 30,
|
||||
"sample_age_seconds": 12,
|
||||
"postgres": true,
|
||||
"redis": true,
|
||||
"deletion_catalog": true,
|
||||
"reason": "ready"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -54,6 +63,16 @@ The relay health listener exposes intrinsic build identity at `/_status`:
|
||||
Non-CI builds report stable `unknown` or `local` fallback values instead of
|
||||
claiming provenance they do not have.
|
||||
|
||||
`dependencies` is a cached diagnostic snapshot of shared-dependency health. A
|
||||
per-pod background loop evaluates the dependencies every 30 seconds; the
|
||||
endpoint only reads the latest report and never contacts a dependency itself,
|
||||
so polling it costs nothing. `sample` is always present and reports whether
|
||||
that cached verdict is `fresh`, `stale`, or `not_yet_sampled` — before the
|
||||
first cycle completes the health fields are absent rather than defaulted.
|
||||
`/_readiness` does not consult any of this — see
|
||||
[the readiness contract](../deploy/charts/buzz/README.md#readiness-contract) —
|
||||
so this endpoint must never be wired to a Kubernetes probe.
|
||||
|
||||
## Helm digest pinning
|
||||
|
||||
Buzz chart `0.1.8` and newer accept an immutable image digest:
|
||||
|
||||
@@ -155,9 +155,9 @@ run_unit_tests() {
|
||||
run_test_step "buzz-acp unit tests" \
|
||||
cargo test -p buzz-acp --lib -- --nocapture
|
||||
|
||||
# Mirror the three infra-free relay handler modules in `just test-unit`'s
|
||||
# nextest expression. Keep the side-effects filter pinned to `::tests::` so
|
||||
# it does not select the sibling Postgres-backed test module.
|
||||
# Mirror the relay filters from `just test-unit`: the three handler modules,
|
||||
# storage-snapshot helpers, readiness and router unit suites, and the single
|
||||
# scoped admission regression in state::tests.
|
||||
run_test_step "buzz-relay channel authorization tests" \
|
||||
cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture
|
||||
|
||||
@@ -169,6 +169,15 @@ run_unit_tests() {
|
||||
|
||||
run_test_step "buzz-relay storage snapshot tests" \
|
||||
cargo test -p buzz-relay --lib storage_sweep::tests:: -- --nocapture
|
||||
|
||||
run_test_step "buzz-relay readiness tests" \
|
||||
cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture
|
||||
|
||||
run_test_step "buzz-relay router tests" \
|
||||
cargo test -p buzz-relay --lib router::tests:: -- --nocapture
|
||||
|
||||
run_test_step "buzz-relay admission regression test" \
|
||||
cargo test -p buzz-relay --lib state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket -- --nocapture
|
||||
}
|
||||
|
||||
# ---- DB / integration tests (infra required) --------------------------------
|
||||
|
||||
Reference in New Issue
Block a user