Files
buzz/crates/buzz-db/src/observability.rs
Luke Tornquist 113a33b7e4 Add database pressure observability (#6700)
## Why

Database pressure currently collapses several distinct delays into one
symptom. This adds the evidence layer needed to distinguish pool
acquisition wait, logical database operation time, advisory-lock wait,
and selected transaction duration before changing timeout or retry
policy.

This is the phase 2 Lane A observability bundle for
[#26](https://github.com/TheSentinel454/buzz/issues/26),
[#28](https://github.com/TheSentinel454/buzz/issues/28), and
[#33](https://github.com/TheSentinel454/buzz/issues/33). It is stacked
on #6668.

## What

- Record explicit reader/writer checkout wait and acquisition outcomes
with `buzz_db_pool_acquire_wait_seconds` and
`buzz_db_pool_acquisitions_total`.
- Extend the compile-time `#[datastore_span(name = "...")]` seam with
`buzz_db_operation_duration_seconds`, so operation labels remain static
source literals instead of request data.
- Route correctness-critical replacement, membership, push-gate,
deletion, and migration/schema-safety advisory locks through one
observer without changing their SQL, order, scope, or blocking behavior.
- Measure six internally owned transaction lifetimes with
`buzz_db_transaction_duration_seconds`, starting after `BEGIN` succeeds
and ending after explicit commit/rollback or scope exit.
- Emit root slow-operation warnings at 500 ms, logging the first slow
completion and then 1/100 per call site with only `operation`,
`outcome`, and `elapsed_ms`.
- Document names, units, fixed label vocabularies, measurement
boundaries, and blind spots in this PR description.

Fixed labels are deliberately small:

- `pool_role`: `writer`, `reader`
- `lock_type`: `replacement`, `membership`, `push_gate`, `deletion`,
`migration_schema_safety`
- `outcome`: `success`, `error`, `timeout` where SQLx/PostgreSQL can
distinguish it accurately
- `operation`: compile-time datastore names plus the six closed
transaction operation names documented in the runbook

No metric or slow warning contains community IDs, event IDs, event
kinds, coordinates, d-tags, SQL/query text, query IDs, returned errors,
or event content.

## Coverage boundaries

- Operation duration is the complete annotated logical function body,
not pure SQL execution; it may include implicit checkout, lock wait,
nested operations, and application work. Cancelled futures do not reach
its completion hook.
- Pool timing covers explicit helper checkouts, including proved-reader
routing and selected writer-owned transactions. Implicit SQLx checkout
through `&PgPool` remains folded into operation duration.
- Lock timing covers application-side blocking locks in the five named
families. Trigger/stored-procedure locks, channel-TTL locking, the usage
try-lock, and the audit service session lock remain outside this slice.
- Transaction timing covers only the six wholly owned boundaries
documented in the runbook. It excludes pool wait, `BEGIN`, asynchronous
rollback cleanup after an early return, and caller-owned
`Db::begin_transaction` lifetime.

## Relationship to #6229

#6229 is the incident-driven timeout precursor. This PR does not add or
change `statement_timeout`, `lock_timeout`,
`idle_in_transaction_session_timeout`, retries, audit durability, or
client-visible conflicts. It provides the missing distributions needed
to evaluate those policies later and intentionally leaves #6229's open
audit retry/durability finding untouched.

The branches overlap in `crates/buzz-db/src/lib.rs` and
`crates/buzz-db/src/migration.rs`, so a later rebase may need textual
conflict resolution, but the behavior is complementary rather than
duplicated.

## Risk assessment

Moderate-low. The primary risk is instrumentation overhead and added
static series. Cardinality is source-bounded, slow logs are
sampled/redacted root events, and the lock/transaction changes wrap
existing awaits without changing policy or ordering.

## Verification

Author workstation: `buzz-tornquist-db-pressure-observability`
(`2010927`), exact head `d7cf833e26c528adfcde3917ded80daf6f4ddac9`,
parent `6f50e6b2b2a996349149af61d35bdd6a355f77fd`.

- `cargo fmt --all --check` — passed
- `cargo clippy -p buzz-datastore-tracing -p buzz-db -p buzz-audit -p
buzz-search -p buzz-relay --all-targets -- -D warnings` — passed
- `cargo test -p buzz-datastore-tracing --quiet` — 4 passed
- `cargo test -p buzz-db --quiet` — 109 passed, 200 ignored
- `cargo test -p buzz-audit -p buzz-search --quiet` — 16 passed, 25
ignored
- `cargo test -p buzz-relay --lib --quiet -- --test-threads=1` — 906
passed, 48 ignored
- Native PostgreSQL focused tests for pool success/timeout/error, lock
success/contention/timeout/error, replacement, membership serialization,
push ordering, deletion fencing, migration/schema exclusion, and reader
fallback — 8 passed

The default-parallel relay run passed once; subsequent runs exposed the
existing load-sensitive
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` 504 at
the end of the suite. That test passes in isolation and the full relay
suite passes serially.

Independent exact-head review workstation:
`buzz-tornquist-db-pressure-observability-review` (`2013067`).
Formatting, the same all-target clippy command, datastore
instrumentation tests, DB unit tests, source privacy guards, and
diff/non-goal audits passed; no review findings.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
2026-08-25 13:00:22 -04:00

637 lines
22 KiB
Rust

//! Bounded-cardinality database pressure instrumentation primitives.
//!
//! Label values come only from the closed enums in this module. Callers must
//! never derive labels from tenant data, events, SQL text, or query identifiers.
use std::future::Future;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PoolRole {
Writer,
Reader,
}
impl PoolRole {
#[cfg(test)]
pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Writer => "writer",
Self::Reader => "reader",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum LockType {
Replacement,
Membership,
PushGate,
Deletion,
MigrationSchemaSafety,
}
impl LockType {
#[cfg(test)]
pub(crate) const ALL: [Self; 5] = [
Self::Replacement,
Self::Membership,
Self::PushGate,
Self::Deletion,
Self::MigrationSchemaSafety,
];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Replacement => "replacement",
Self::Membership => "membership",
Self::PushGate => "push_gate",
Self::Deletion => "deletion",
Self::MigrationSchemaSafety => "migration_schema_safety",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Outcome {
Success,
Error,
Timeout,
}
impl Outcome {
#[cfg(test)]
pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Timeout => "timeout",
}
}
fn from_sqlx_error(error: &sqlx::Error) -> Self {
match error {
sqlx::Error::PoolTimedOut => Self::Timeout,
sqlx::Error::Database(database) if database.code().as_deref() == Some("55P03") => {
Self::Timeout
}
_ => Self::Error,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TransactionOperation {
ReplaceParameterizedEvent,
ReplaceAddressableEvent,
PublishNip43MembershipLocked,
AcceptPushLeaseEvent,
BeginCommunityDeletionQuiescing,
FenceCommunityDeletion,
}
impl TransactionOperation {
#[cfg(test)]
pub(crate) const ALL: [Self; 6] = [
Self::ReplaceParameterizedEvent,
Self::ReplaceAddressableEvent,
Self::PublishNip43MembershipLocked,
Self::AcceptPushLeaseEvent,
Self::BeginCommunityDeletionQuiescing,
Self::FenceCommunityDeletion,
];
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::ReplaceParameterizedEvent => "replace_parameterized_event",
Self::ReplaceAddressableEvent => "replace_addressable_event",
Self::PublishNip43MembershipLocked => "publish_nip43_membership_locked",
Self::AcceptPushLeaseEvent => "accept_push_lease_event",
Self::BeginCommunityDeletionQuiescing => "begin_community_deletion_quiescing",
Self::FenceCommunityDeletion => "fence_community_deletion",
}
}
}
pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) {
metrics::histogram!(
"buzz_db_pool_acquire_wait_seconds",
"pool_role" => role.as_str(),
"outcome" => outcome.as_str(),
)
.record(elapsed.as_secs_f64());
metrics::counter!(
"buzz_db_pool_acquisitions_total",
"pool_role" => role.as_str(),
"outcome" => outcome.as_str(),
)
.increment(1);
}
pub(crate) async fn acquire(
pool: &sqlx::PgPool,
role: PoolRole,
) -> sqlx::Result<sqlx::pool::PoolConnection<sqlx::Postgres>> {
let started = Instant::now();
let result = pool.acquire().await;
let outcome = result
.as_ref()
.map(|_| Outcome::Success)
.unwrap_or_else(Outcome::from_sqlx_error);
record_pool_acquire(role, outcome, started.elapsed());
result
}
pub(crate) async fn begin_transaction(
pool: &sqlx::PgPool,
operation: TransactionOperation,
) -> sqlx::Result<(sqlx::Transaction<'static, sqlx::Postgres>, TransactionTimer)> {
let connection = acquire(pool, PoolRole::Writer).await?;
let transaction = sqlx::Transaction::begin(connection, None).await?;
Ok((transaction, TransactionTimer::start(operation)))
}
pub(crate) async fn observe_advisory_lock<T, F>(lock_type: LockType, future: F) -> sqlx::Result<T>
where
F: Future<Output = sqlx::Result<T>>,
{
let started = Instant::now();
let result = future.await;
let outcome = result
.as_ref()
.map(|_| Outcome::Success)
.unwrap_or_else(Outcome::from_sqlx_error);
metrics::histogram!(
"buzz_db_advisory_lock_wait_seconds",
"lock_type" => lock_type.as_str(),
"outcome" => outcome.as_str(),
)
.record(started.elapsed().as_secs_f64());
metrics::counter!(
"buzz_db_advisory_lock_acquisitions_total",
"lock_type" => lock_type.as_str(),
"outcome" => outcome.as_str(),
)
.increment(1);
result
}
pub(crate) struct TransactionTimer {
operation: TransactionOperation,
started: Instant,
outcome: Outcome,
}
impl TransactionTimer {
pub(crate) fn start(operation: TransactionOperation) -> Self {
Self {
operation,
started: Instant::now(),
outcome: Outcome::Error,
}
}
pub(crate) async fn observe<T, E, F>(mut self, future: F) -> Result<T, E>
where
F: Future<Output = Result<T, E>>,
{
let result = future.await;
if result.is_ok() {
self.outcome = Outcome::Success;
}
result
}
}
impl Drop for TransactionTimer {
fn drop(&mut self) {
metrics::histogram!(
"buzz_db_transaction_duration_seconds",
"operation" => self.operation.as_str(),
"outcome" => self.outcome.as_str(),
)
.record(self.started.elapsed().as_secs_f64());
}
}
#[cfg(test)]
mod tests {
use super::{
acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole,
TransactionOperation, TransactionTimer,
};
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
#[test]
fn label_vocabularies_are_closed_and_documented() {
assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]);
assert_eq!(
LockType::ALL.map(LockType::as_str),
[
"replacement",
"membership",
"push_gate",
"deletion",
"migration_schema_safety",
]
);
assert_eq!(
Outcome::ALL.map(Outcome::as_str),
["success", "error", "timeout"]
);
assert_eq!(
TransactionOperation::ALL.map(TransactionOperation::as_str),
[
"replace_parameterized_event",
"replace_addressable_event",
"publish_nip43_membership_locked",
"accept_push_lease_event",
"begin_community_deletion_quiescing",
"fence_community_deletion",
]
);
}
#[tokio::test(flavor = "current_thread")]
async fn transaction_timer_observe_classifies_result_outcomes() {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let _guard = metrics::set_default_local_recorder(&recorder);
let success = TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent)
.observe(async { Ok::<_, &str>("committed") })
.await;
assert_eq!(success, Ok("committed"));
let error = TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent)
.observe(async { Err::<(), _>("rollback") })
.await;
assert_eq!(error, Err("rollback"));
let keys = snapshotter
.snapshot()
.into_vec()
.into_iter()
.map(|(key, ..)| {
let labels = key
.key()
.labels()
.map(|label| (label.key().to_owned(), label.value().to_owned()))
.collect::<BTreeMap<_, _>>();
(key.key().name().to_owned(), labels)
})
.collect::<BTreeSet<_>>();
for (operation, outcome) in [
("replace_parameterized_event", "success"),
("accept_push_lease_event", "error"),
] {
assert!(keys.contains(&(
"buzz_db_transaction_duration_seconds".to_owned(),
[
("operation".to_owned(), operation.to_owned()),
("outcome".to_owned(), outcome.to_owned()),
]
.into_iter()
.collect(),
)));
}
}
#[tokio::test(flavor = "current_thread")]
async fn primitives_record_fixed_success_error_and_timeout_labels() {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let _guard = metrics::set_default_local_recorder(&recorder);
record_pool_acquire(
PoolRole::Writer,
Outcome::Success,
Duration::from_millis(12),
);
record_pool_acquire(
PoolRole::Reader,
Outcome::Timeout,
Duration::from_millis(34),
);
let lock_ok: sqlx::Result<()> =
observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await;
assert!(lock_ok.is_ok());
let lock_error: sqlx::Result<()> =
observe_advisory_lock(LockType::Membership, async { Err(sqlx::Error::PoolClosed) })
.await;
assert!(lock_error.is_err());
let committed: Result<(), ()> =
TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent)
.observe(async { Ok(()) })
.await;
assert!(committed.is_ok());
let rolled_back: Result<(), ()> =
TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent)
.observe(async { Err(()) })
.await;
assert!(rolled_back.is_err());
let snapshot = snapshotter.snapshot().into_vec();
let keys = snapshot
.iter()
.map(|(key, ..)| {
let labels = key
.key()
.labels()
.map(|label| (label.key().to_owned(), label.value().to_owned()))
.collect::<BTreeMap<_, _>>();
(key.key().name().to_owned(), labels)
})
.collect::<BTreeSet<_>>();
for expected in [
(
"buzz_db_pool_acquire_wait_seconds",
[("outcome", "success"), ("pool_role", "writer")],
),
(
"buzz_db_pool_acquire_wait_seconds",
[("outcome", "timeout"), ("pool_role", "reader")],
),
(
"buzz_db_pool_acquisitions_total",
[("outcome", "success"), ("pool_role", "writer")],
),
(
"buzz_db_pool_acquisitions_total",
[("outcome", "timeout"), ("pool_role", "reader")],
),
(
"buzz_db_advisory_lock_wait_seconds",
[("lock_type", "replacement"), ("outcome", "success")],
),
(
"buzz_db_advisory_lock_wait_seconds",
[("lock_type", "membership"), ("outcome", "error")],
),
(
"buzz_db_advisory_lock_acquisitions_total",
[("lock_type", "replacement"), ("outcome", "success")],
),
(
"buzz_db_advisory_lock_acquisitions_total",
[("lock_type", "membership"), ("outcome", "error")],
),
(
"buzz_db_transaction_duration_seconds",
[
("operation", "replace_parameterized_event"),
("outcome", "success"),
],
),
(
"buzz_db_transaction_duration_seconds",
[
("operation", "accept_push_lease_event"),
("outcome", "error"),
],
),
] {
let expected_labels = expected
.1
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect::<BTreeMap<_, _>>();
assert!(
keys.contains(&(expected.0.to_owned(), expected_labels)),
"missing metric series {expected:?}; got {keys:?}"
);
}
for (key, _, _, value) in snapshot {
if key.key().name().ends_with("_seconds") {
let DebugValue::Histogram(samples) = value else {
panic!("seconds metrics must be histograms");
};
assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0));
} else if key.key().name().ends_with("_total") {
let DebugValue::Counter(value) = value else {
panic!("total metrics must be counters");
};
assert_eq!(value, 1);
}
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Postgres"]
async fn pool_acquire_records_success_timeout_and_error_with_wait_time() {
let database_url = std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(Duration::from_millis(75))
.connect(&database_url)
.await
.expect("connect size-one test pool");
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let _guard = metrics::set_default_local_recorder(&recorder);
let held = acquire(&pool, PoolRole::Writer)
.await
.expect("writer acquire succeeds");
let timeout = acquire(&pool, PoolRole::Reader)
.await
.expect_err("reader-labeled checkout times out while pool is saturated");
assert!(matches!(timeout, sqlx::Error::PoolTimedOut));
drop(held);
pool.close().await;
let closed = acquire(&pool, PoolRole::Writer)
.await
.expect_err("closed pool acquire errors");
assert!(matches!(closed, sqlx::Error::PoolClosed));
let mut outcomes = BTreeMap::<(String, String), Vec<f64>>::new();
for (key, _, _, value) in snapshotter.snapshot().into_vec() {
if key.key().name() != "buzz_db_pool_acquire_wait_seconds" {
continue;
}
let DebugValue::Histogram(samples) = value else {
panic!("pool wait must be a histogram");
};
let labels = key.key().labels().collect::<Vec<_>>();
let label = |name: &str| {
labels
.iter()
.find(|label| label.key() == name)
.map(|label| label.value().to_owned())
.unwrap_or_default()
};
outcomes.insert(
(label("pool_role"), label("outcome")),
samples
.into_iter()
.map(|sample| sample.into_inner())
.collect(),
);
}
assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned())));
assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned())));
let timeout_samples = outcomes
.get(&("reader".to_owned(), "timeout".to_owned()))
.expect("reader timeout series");
assert!(
timeout_samples.iter().any(|sample| *sample >= 0.05),
"timeout wait must include the saturated checkout delay: {timeout_samples:?}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Postgres"]
async fn advisory_lock_records_success_contention_timeout_and_error() {
let database_url = std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.connect(&database_url)
.await
.expect("connect advisory-lock test pool");
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let _guard = metrics::set_default_local_recorder(&recorder);
let mut success_tx = pool.begin().await.expect("begin success transaction");
observe_advisory_lock(
LockType::Replacement,
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(0x62757a7a6f627331_i64)
.execute(&mut *success_tx),
)
.await
.expect("uncontended lock succeeds");
success_tx
.rollback()
.await
.expect("rollback success transaction");
let contention_key = 0x62757a7a6f627332_i64;
let mut holder = pool.begin().await.expect("begin lock holder");
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(contention_key)
.execute(&mut *holder)
.await
.expect("holder acquires contention key");
let mut waiter = pool.begin().await.expect("begin lock waiter");
let waiter_task = tokio::spawn(async move {
let result = observe_advisory_lock(
LockType::Deletion,
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(contention_key)
.execute(&mut *waiter),
)
.await;
(waiter, result)
});
tokio::time::sleep(Duration::from_millis(60)).await;
assert!(
!waiter_task.is_finished(),
"waiter must be blocked by holder"
);
holder.commit().await.expect("release contention key");
let (waiter, waited) = waiter_task.await.expect("join lock waiter");
waited.expect("contended lock succeeds after release");
waiter.rollback().await.expect("rollback waiter");
let timeout_key = 0x62757a7a6f627333_i64;
let mut timeout_holder = pool.begin().await.expect("begin timeout holder");
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(timeout_key)
.execute(&mut *timeout_holder)
.await
.expect("holder acquires timeout key");
let mut timeout_waiter = pool.begin().await.expect("begin timeout waiter");
sqlx::query("SET LOCAL lock_timeout = '30ms'")
.execute(&mut *timeout_waiter)
.await
.expect("set test-only lock timeout");
let timed_out = observe_advisory_lock(
LockType::MigrationSchemaSafety,
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(timeout_key)
.execute(&mut *timeout_waiter),
)
.await
.expect_err("lock wait times out");
assert_eq!(
timed_out
.as_database_error()
.and_then(|error| error.code())
.as_deref(),
Some("55P03")
);
timeout_holder
.rollback()
.await
.expect("release timeout key");
let mut aborted = pool.begin().await.expect("begin error transaction");
sqlx::query("SELECT 1 / 0")
.execute(&mut *aborted)
.await
.expect_err("abort transaction before lock");
observe_advisory_lock(
LockType::Membership,
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(0x62757a7a6f627334_i64)
.execute(&mut *aborted),
)
.await
.expect_err("lock statement fails in aborted transaction");
aborted
.rollback()
.await
.expect("rollback aborted transaction");
let mut outcomes = BTreeMap::<(String, String), Vec<f64>>::new();
for (key, _, _, value) in snapshotter.snapshot().into_vec() {
if key.key().name() != "buzz_db_advisory_lock_wait_seconds" {
continue;
}
let DebugValue::Histogram(samples) = value else {
panic!("lock wait must be a histogram");
};
let labels = key.key().labels().collect::<Vec<_>>();
let label = |name: &str| {
labels
.iter()
.find(|label| label.key() == name)
.map(|label| label.value().to_owned())
.unwrap_or_default()
};
outcomes.insert(
(label("lock_type"), label("outcome")),
samples
.into_iter()
.map(|sample| sample.into_inner())
.collect(),
);
}
assert!(outcomes.contains_key(&("replacement".to_owned(), "success".to_owned())));
assert!(outcomes.contains_key(&("membership".to_owned(), "error".to_owned())));
assert!(
outcomes.contains_key(&("migration_schema_safety".to_owned(), "timeout".to_owned()))
);
let contention = outcomes
.get(&("deletion".to_owned(), "success".to_owned()))
.expect("deletion contention series");
assert!(
contention.iter().any(|sample| *sample >= 0.04),
"lock timer must include the holder wait: {contention:?}"
);
}
}