mirror of
https://github.com/block/buzz.git
synced 2026-11-09 19:02:00 +00:00
## Summary The relay no longer runs database aggregate queries to emit usage gauges. Fleet and per-community counts of users, channels, relay members, workflows, git repos, communities, messages, and active users/channels are no longer relay metrics, and neither are the snapshot health gauges that came with them. ### Removed - **Gauges:** `buzz_total_users`, `buzz_total_channels`, `buzz_total_relay_members`, `buzz_total_workflows`, `buzz_total_git_repos`, `buzz_total_active_users`, `buzz_total_messages`, `buzz_total_active_channels`, `buzz_communities_estimated` / `buzz_communities_total`, and every DB-derived `buzz_community_*` series (users, channels, messages, relay members, workflows, git repos, active users, active channels). - **Per-community mode:** `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` and everything it fed: the per-community in-memory gauges, `buzz_community_storage_bytes` / `buzz_community_storage_objects`, `buzz_storage_unmapped_community_bytes`, the community → host map query, and the emission-scope bookkeeping that cleared series for renamed or removed communities. No deployment set it. If one still does, the relay ignores it. - **Health signals:** `buzz_usage_snapshot_available`, `buzz_usage_snapshot_age_seconds`, `buzz_usage_query_skipped_total`, and the `usage_fleet_*` paths of `buzz_db_route_decision`. - **Code and config:** - `buzz-db/src/store/usage.rs`, including its aggregate queries. - The relay's `FleetUsageSchedule` and its cadence/deadline/retry machinery. - The replica-only telemetry route (`Db::route_usage_read`). - `BUZZ_USAGE_METRICS_REPLICA_MAX_AGE_MS` and `DbConfig::usage_metrics_replica_max_age_ms`. If a deployment still sets the env var, the relay now ignores it; startup doesn't fail. - **Reader maintenance pool operation:** `ReaderOperation::Maintenance` and its `close_on_drop` release path existed only for those telemetry reads, so they're removed too. The pool-acquire contract drops from twelve to eleven valid pairs, which lowers the per-pod series ceiling from 216 to 198. - **Dead code:** `Db::active_community_hosts` had no callers. ### Kept - **Leader:** the usage poller's leader lock, plus the leader's invite reaping and fleet storage-snapshot gauges. The session advisory lock (`SessionLeader`, `Db::try_lock_session_leader`) moves to `buzz-db/src/store/leader_lock.rs`, since storage-usage delivery uses it too and it has nothing to do with usage queries. - **In-memory gauges:** pod-local connection, subscription, and online-user gauges. They read process state, not the database. - **Writer maintenance pool operation:** `WriterOperation::Maintenance` stays. Dozens of writer-side maintenance paths across `buzz-db` use it (reapers, retention, the leader lock). The reader variant only ever served the telemetry reads. - **Storage-usage delivery:** unchanged. It reads the storage snapshot for billing, not for metrics. `docs/usage-metrics.md`, `docs/storage-accounting.md`, `.env.example`, and the Helm chart README are updated to match. ## What got simpler This is net deletion (~3.6k lines). One telemetry subsystem is gone: two collection families with independent cadences, availability and age semantics, relay-side deadlines, a dedicated replica routing policy, two config knobs, a per-community emission mode, and a pool-operation variant. Leader election now does only leader work, and it lives in its own module. ## Operational note Dashboards and monitors that read the removed series will go to no data once this rolls out. ## Testing - `cargo fmt --check`, workspace `cargo clippy --all-targets -D warnings`. - `buzz-db` lib, `observability_source`, and doc tests. - `buzz-relay` lib tests. - The Postgres lane for `buzz-db` (lib + tests) and `buzz-relay` (lib). All of these run on a remote workstation at the PR head. 🤖 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>
397 lines
15 KiB
Rust
397 lines
15 KiB
Rust
//! Publish storage metrics from the latest complete worker snapshot.
|
|
//!
|
|
//! The leader checks PostgreSQL on every usage tick. An environment without a
|
|
//! snapshot emits no storage gauges; the first worker publication is picked up
|
|
//! without restarting or reconfiguring the relay. S3 listing belongs only to
|
|
//! the worker. Failed reads retain the last good totals with load health and
|
|
//! the original snapshot age, never a fabricated fresh measurement.
|
|
|
|
use std::time::Duration;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use tokio::sync::Mutex;
|
|
|
|
use buzz_db::Db;
|
|
use buzz_media::BucketSnapshot;
|
|
|
|
/// Storage metrics are read from PostgreSQL unless explicitly disabled.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum StorageMetricsMode {
|
|
/// Read the newest completed snapshot persisted by `buzz-admin`.
|
|
Snapshot,
|
|
/// Neither read snapshots nor emit storage-family metrics.
|
|
Disabled,
|
|
}
|
|
|
|
impl StorageMetricsMode {
|
|
/// Read `BUZZ_STORAGE_METRICS`. Unset enables the database reader.
|
|
/// Legacy `inline`/`on` values also select the reader so existing charts
|
|
/// need no coordinated mode change. `off` and unknown values disable it.
|
|
pub fn from_env() -> Self {
|
|
let value = std::env::var("BUZZ_STORAGE_METRICS").ok();
|
|
parse_storage_metrics_mode(value.as_deref())
|
|
}
|
|
}
|
|
|
|
fn parse_storage_metrics_mode(value: Option<&str>) -> StorageMetricsMode {
|
|
match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
|
|
Some("off") => StorageMetricsMode::Disabled,
|
|
Some("inline") => {
|
|
tracing::warn!("BUZZ_STORAGE_METRICS=inline now reads worker snapshots; relay S3 sweeps are retired");
|
|
StorageMetricsMode::Snapshot
|
|
}
|
|
Some("snapshot" | "external" | "on") | None => StorageMetricsMode::Snapshot,
|
|
Some(other) => {
|
|
tracing::error!(
|
|
value = other,
|
|
"invalid BUZZ_STORAGE_METRICS value; storage metrics disabled"
|
|
);
|
|
StorageMetricsMode::Disabled
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bound the whole read, including pool acquisition and the database query.
|
|
const SNAPSHOT_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// The most recent successfully decoded worker snapshot.
|
|
#[derive(Debug, Clone)]
|
|
struct CachedSnapshot {
|
|
data: BucketSnapshot,
|
|
completed_at_wall: DateTime<Utc>,
|
|
duration: Duration,
|
|
max_objects: u64,
|
|
}
|
|
|
|
/// Cached worker data, shared across usage ticks.
|
|
#[derive(Default)]
|
|
pub struct StorageSweepState {
|
|
cached: Option<CachedSnapshot>,
|
|
// None means no worker snapshot exists. A failed read is distinct from
|
|
// absence and remains observable even before the first successful read.
|
|
persisted_load_ok: Option<bool>,
|
|
}
|
|
|
|
/// Load and emit one snapshot on the existing leader-only usage cadence.
|
|
///
|
|
/// No row is a normal inactive state. Database/decoding failures preserve the
|
|
/// last good cache, emit failed-load health, and propagate for caller logging;
|
|
/// the next usage tick retries. This path never lists S3 or delays startup.
|
|
pub async fn run_storage_metrics_tick(
|
|
db: &Db,
|
|
state: &Mutex<StorageSweepState>,
|
|
mode: StorageMetricsMode,
|
|
) -> anyhow::Result<()> {
|
|
if mode == StorageMetricsMode::Disabled {
|
|
return Ok(());
|
|
}
|
|
let result = refresh_persisted_snapshot(db, state).await;
|
|
if result.is_err() {
|
|
record_persisted_snapshot_load_failure(state).await;
|
|
}
|
|
emit_storage_metrics(state).await;
|
|
result
|
|
}
|
|
|
|
async fn refresh_persisted_snapshot(
|
|
db: &Db,
|
|
state: &Mutex<StorageSweepState>,
|
|
) -> anyhow::Result<()> {
|
|
let stored = tokio::time::timeout(SNAPSHOT_READ_TIMEOUT, db.load_storage_accounting_snapshot())
|
|
.await??;
|
|
let Some(stored) = stored else {
|
|
// A removed row also stops refreshing old metric series. They expire
|
|
// through the exporter's idle timeout; absence never means zero bytes.
|
|
let mut state = state.lock().await;
|
|
state.cached = None;
|
|
state.persisted_load_ok = None;
|
|
return Ok(());
|
|
};
|
|
let snapshot = serde_json::from_value(stored.snapshot)?;
|
|
let duration = Duration::from_millis(u64::try_from(stored.duration_ms)?);
|
|
let max_objects = u64::try_from(stored.max_objects)?;
|
|
anyhow::ensure!(
|
|
max_objects > 0,
|
|
"stored storage snapshot has a zero object cap"
|
|
);
|
|
cache_persisted_snapshot(state, snapshot, stored.completed_at, duration, max_objects).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Replace the cache with a complete snapshot loaded from durable storage.
|
|
async fn cache_persisted_snapshot(
|
|
state: &Mutex<StorageSweepState>,
|
|
snapshot: BucketSnapshot,
|
|
completed_at: DateTime<Utc>,
|
|
duration: Duration,
|
|
max_objects: u64,
|
|
) {
|
|
let mut state = state.lock().await;
|
|
state.cached = Some(CachedSnapshot {
|
|
data: snapshot,
|
|
completed_at_wall: completed_at,
|
|
duration,
|
|
max_objects,
|
|
});
|
|
state.persisted_load_ok = Some(true);
|
|
}
|
|
|
|
async fn record_persisted_snapshot_load_failure(state: &Mutex<StorageSweepState>) {
|
|
state.lock().await.persisted_load_ok = Some(false);
|
|
}
|
|
|
|
async fn emit_storage_metrics(state: &Mutex<StorageSweepState>) {
|
|
let state = state.lock().await;
|
|
let Some(load_ok) = state.persisted_load_ok else {
|
|
return;
|
|
};
|
|
metrics::gauge!("buzz_storage_snapshot_load_ok").set(if load_ok { 1.0 } else { 0.0 });
|
|
emit_cached_storage_metrics(&state);
|
|
}
|
|
|
|
fn emit_cached_storage_metrics(state: &StorageSweepState) {
|
|
let Some(cached) = &state.cached else {
|
|
return;
|
|
};
|
|
let age = Utc::now()
|
|
.signed_duration_since(cached.completed_at_wall)
|
|
.to_std()
|
|
.unwrap_or_default();
|
|
metrics::gauge!("buzz_storage_snapshot_age_seconds").set(age.as_secs_f64());
|
|
metrics::gauge!("buzz_storage_snapshot_duration_seconds").set(cached.duration.as_secs_f64());
|
|
|
|
let snapshot = &cached.data;
|
|
metrics::gauge!("buzz_total_storage_bytes", "kind" => "physical")
|
|
.set(snapshot.physical_bytes as f64);
|
|
metrics::gauge!("buzz_total_storage_objects", "kind" => "physical")
|
|
.set(snapshot.physical_objects as f64);
|
|
metrics::gauge!("buzz_total_storage_bytes", "kind" => "logical")
|
|
.set(snapshot.logical_bytes as f64);
|
|
metrics::gauge!("buzz_total_storage_objects", "kind" => "logical")
|
|
.set(snapshot.logical_objects as f64);
|
|
|
|
metrics::gauge!("buzz_storage_orphan_blob_bytes").set(snapshot.orphan_blob_bytes as f64);
|
|
metrics::gauge!("buzz_storage_orphan_blobs").set(snapshot.orphan_blob_count as f64);
|
|
metrics::gauge!("buzz_storage_orphan_sidecars").set(snapshot.orphan_sidecar_count as f64);
|
|
metrics::gauge!("buzz_storage_multi_variant_shas").set(snapshot.multi_variant_shas as f64);
|
|
metrics::gauge!("buzz_storage_multi_variant_bytes").set(snapshot.multi_variant_bytes as f64);
|
|
metrics::gauge!("buzz_storage_unknown_key_bytes").set(snapshot.unknown_key_bytes as f64);
|
|
metrics::gauge!("buzz_storage_unknown_key_objects").set(snapshot.unknown_key_objects as f64);
|
|
metrics::gauge!("buzz_storage_snapshot_max_objects").set(cached.max_objects as f64);
|
|
metrics::gauge!("buzz_storage_snapshot_cap_utilization")
|
|
.set(snapshot.physical_objects as f64 / cached.max_objects as f64);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod postgres_tests;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use buzz_media::CommunityStorage;
|
|
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
|
use uuid::Uuid;
|
|
|
|
fn snapshot_with(community: Uuid, bytes: u64, objects: u64) -> BucketSnapshot {
|
|
let mut per_community = HashMap::new();
|
|
per_community.insert(community, CommunityStorage { bytes, objects });
|
|
BucketSnapshot {
|
|
physical_bytes: bytes,
|
|
physical_objects: objects,
|
|
logical_bytes: bytes,
|
|
logical_objects: objects,
|
|
per_community,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_and_legacy_chart_values_use_worker_snapshots() {
|
|
for value in [
|
|
None,
|
|
Some("inline"),
|
|
Some("on"),
|
|
Some("snapshot"),
|
|
Some("external"),
|
|
Some(" INLINE "),
|
|
] {
|
|
assert_eq!(
|
|
parse_storage_metrics_mode(value),
|
|
StorageMetricsMode::Snapshot,
|
|
"{value:?}"
|
|
);
|
|
}
|
|
for value in [Some("off"), Some(" OFF "), Some("unknown"), Some("")] {
|
|
assert_eq!(
|
|
parse_storage_metrics_mode(value),
|
|
StorageMetricsMode::Disabled,
|
|
"{value:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn gauge_snapshot(recorder: &DebuggingRecorder) -> std::collections::HashMap<String, f64> {
|
|
recorder
|
|
.snapshotter()
|
|
.snapshot()
|
|
.into_vec()
|
|
.into_iter()
|
|
.filter(|(key, _, _, _)| key.key().name().contains("_storage_"))
|
|
.filter_map(|(key, _, _, value)| match value {
|
|
DebugValue::Gauge(v) => Some((key.key().name().to_owned(), v.into_inner())),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_snapshot_emits_nothing() {
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
let recorder = DebuggingRecorder::new();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(emit_storage_metrics(&state));
|
|
});
|
|
assert!(gauge_snapshot(&recorder).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_reader_never_queries_a_closed_pool() {
|
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
.connect_lazy("postgres://localhost/unused")
|
|
.unwrap();
|
|
pool.close().await;
|
|
let db = Db::from_pool(pool);
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
let recorder = DebuggingRecorder::new();
|
|
let result = metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(run_storage_metrics_tick(
|
|
&db,
|
|
&state,
|
|
StorageMetricsMode::Disabled,
|
|
))
|
|
});
|
|
assert!(result.is_ok());
|
|
assert!(gauge_snapshot(&recorder).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_first_read_is_an_error_instead_of_worker_absence() {
|
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
.connect_lazy("postgres://localhost/unused")
|
|
.unwrap();
|
|
pool.close().await;
|
|
let db = Db::from_pool(pool);
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
let recorder = DebuggingRecorder::new();
|
|
let result = metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(run_storage_metrics_tick(
|
|
&db,
|
|
&state,
|
|
StorageMetricsMode::Snapshot,
|
|
))
|
|
});
|
|
assert!(result.is_err());
|
|
assert_eq!(
|
|
gauge_snapshot(&recorder),
|
|
HashMap::from([("buzz_storage_snapshot_load_ok".into(), 0.0)])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persisted_snapshot_emits_snapshot_health_without_attempt_health() {
|
|
let community = Uuid::from_u128(77);
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
cache_persisted_snapshot(
|
|
&state,
|
|
snapshot_with(community, 100, 25),
|
|
Utc::now() - chrono::Duration::seconds(30),
|
|
Duration::from_secs(12),
|
|
100,
|
|
)
|
|
.await;
|
|
let recorder = DebuggingRecorder::new();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(emit_storage_metrics(&state));
|
|
});
|
|
let values = gauge_snapshot(&recorder);
|
|
assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&1.0));
|
|
assert_eq!(
|
|
values.get("buzz_storage_snapshot_duration_seconds"),
|
|
Some(&12.0)
|
|
);
|
|
assert_eq!(
|
|
values.get("buzz_storage_snapshot_max_objects"),
|
|
Some(&100.0)
|
|
);
|
|
assert_eq!(
|
|
values.get("buzz_storage_snapshot_cap_utilization"),
|
|
Some(&0.25)
|
|
);
|
|
assert!(values["buzz_storage_snapshot_age_seconds"] >= 30.0);
|
|
// Per-community storage is never attributed in relay metrics.
|
|
assert!(!values.contains_key("buzz_community_storage_bytes"));
|
|
assert!(!values.contains_key("buzz_community_storage_objects"));
|
|
assert!(!values.contains_key("buzz_storage_unmapped_community_bytes"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_ok"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_failures"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_duration_seconds"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_age_seconds"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_persisted_snapshot_remains_visible_without_claiming_attempt_success() {
|
|
let community = Uuid::from_u128(78);
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
cache_persisted_snapshot(
|
|
&state,
|
|
snapshot_with(community, 200, 50),
|
|
Utc::now() - chrono::Duration::hours(48),
|
|
Duration::from_secs(20),
|
|
1_000,
|
|
)
|
|
.await;
|
|
|
|
let recorder = DebuggingRecorder::new();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(emit_storage_metrics(&state));
|
|
});
|
|
|
|
let values = gauge_snapshot(&recorder);
|
|
assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&1.0));
|
|
assert_eq!(values.get("buzz_total_storage_bytes"), Some(&200.0));
|
|
assert!(values["buzz_storage_snapshot_age_seconds"] >= 48.0 * 60.0 * 60.0);
|
|
assert!(!values.contains_key("buzz_storage_sweep_ok"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_failures"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_persisted_load_keeps_last_good_totals_and_reports_unhealthy_handoff() {
|
|
let community = Uuid::from_u128(79);
|
|
let state = Mutex::new(StorageSweepState::default());
|
|
cache_persisted_snapshot(
|
|
&state,
|
|
snapshot_with(community, 300, 75),
|
|
Utc::now() - chrono::Duration::minutes(5),
|
|
Duration::from_secs(30),
|
|
1_000,
|
|
)
|
|
.await;
|
|
record_persisted_snapshot_load_failure(&state).await;
|
|
|
|
let recorder = DebuggingRecorder::new();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
futures::executor::block_on(emit_storage_metrics(&state));
|
|
});
|
|
|
|
let values = gauge_snapshot(&recorder);
|
|
assert_eq!(values.get("buzz_storage_snapshot_load_ok"), Some(&0.0));
|
|
assert_eq!(values.get("buzz_total_storage_bytes"), Some(&300.0));
|
|
assert!(values["buzz_storage_snapshot_age_seconds"] >= 5.0 * 60.0);
|
|
assert!(!values.contains_key("buzz_storage_sweep_ok"));
|
|
assert!(!values.contains_key("buzz_storage_sweep_failures"));
|
|
}
|
|
}
|