Files
buzz/crates/buzz-db/src/runtime/observability.rs
Luke Tornquist a3730784fc refactor(db): extract domain stores from database runtime (#6987)
## Summary

Finish the remaining database-store extraction tracked by
[TheSentinel454/buzz#2](https://github.com/TheSentinel454/buzz/issues/2)
in one reviewable PR.

This consolidates the previously stacked domain slices after #6782
merged. It preserves the runtime/store boundary established by #6660,
#6668, #6700, and #6782 while separating database runtime infrastructure
from domain-owned persistence:

- `runtime/` owns pool construction and sizing, writer/reader routing,
read sessions and route proofs, transaction infrastructure,
observability primitives, replica fencing, health support, migrations,
and cross-cutting runtime tests.
- `store/` owns domain records, SQL, row parsing, locks and invariants,
`Db` domain methods, focused tests, and logical-operation datastore
spans.
- `lib.rs` remains a 57-line compatibility facade that preserves
existing crate-root paths and `Db` method signatures through re-exports.

Domain coverage includes API tokens, authentication allowlists,
reminders, event queries, threads, reactions, feeds, users and DMs,
push, workflows/runs/approvals, relay membership and invites, product
feedback, moderation/admin moderation, relay admin actions/operators,
git repositories, archived identities, usage, partition maintenance,
deletion, channel membership inherited from merged #6782, and the final
runtime/store layout.

The branch has been rebased onto current `main`. Database changes that
landed there were incorporated rather than overwritten:
`relay_admin_actions.rs` and `relay_operators.rs` now live under
`store/`, their 27 public `Db` wrappers and existing behavior remain
intact, and every wrapper has exactly one fixed-name datastore span.
Concurrent changes to migration, moderation, admin moderation, and error
handling are also retained.

### Exact base and head

- Base: `main` at `ed11c8d8bf0a17402be5cf243724f89471530d2f`
- Head: `codex/issue-2-store-extraction` at
`be24430472d1a87ac5c0d6026c620cd6caea3537`

### Related issue

- Structural tracker:
[TheSentinel454/buzz#2](https://github.com/TheSentinel454/buzz/issues/2)
- Domain trackers:
[#6](https://github.com/TheSentinel454/buzz/issues/6),
[#7](https://github.com/TheSentinel454/buzz/issues/7),
[#12](https://github.com/TheSentinel454/buzz/issues/12),
[#13](https://github.com/TheSentinel454/buzz/issues/13)
- Acceptance trackers:
[#17](https://github.com/TheSentinel454/buzz/issues/17),
[#19](https://github.com/TheSentinel454/buzz/issues/19)

This supersedes #6783, #6784, #6787, #6788, #6789, #6792, #6820, #6794,
#6796, #6797, #6798, #6799, #6804, #6805, #6806, #6808, #6809, #6811,
#6812, #6813, #6814, #6815, and #6890. Their discussions remain
available for review history.

### #17 / #19 acceptance

- Preserves the metric names, fixed labels, transaction/lock timing
boundaries, and privacy/cardinality constraints introduced by #6700.
- Keeps exactly one datastore span per public logical operation,
including the 27 relay-admin wrappers added on `main`.
- Removes `store_ownership.rs`; physical ownership and focused source
guards now enforce the boundary directly.
- Leaves no `impl Db`, domain SQL, focused domain test group, or
datastore span in `lib.rs`.
- Preserves existing public paths such as `buzz_db::channel`,
`buzz_db::event`, and `buzz_db::workflow` through crate-root re-exports
while keeping internal `runtime` and `store` namespaces private.

### Non-goals

- No SQL, schema, locking, transaction, retry, timeout, or
client-visible behavior changes.
- No generic store traits, domain handles, broad `PgExecutor` migration,
new store crate, raw pool accessor, or broader directory reorganization.
- No tracker issues are closed by this PR.

### Risk

The cumulative diff is large but structural. Risk is primarily
module-path, ownership, or conflict-resolution drift. It is mitigated by
preserving public re-exports, comparing the newly moved `main`
implementations to their upstream source, source guards, touched-crate
compilation, PostgreSQL-backed test coverage, and an independent
exact-head review on a separate clean Blox workstation.

### Testing

Author workstation `buzz-tornquist-pr-6987-rebase`, rebased branch
ending at exact head `be24430472d1a87ac5c0d6026c620cd6caea3537`:

- `cargo fmt --all --check`
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`
- `cargo test -p buzz-db --lib` — 113 passed, 240 PostgreSQL tests
intentionally ignored
- `cargo test -p buzz-db --test observability_source` — 2 passed
- PostgreSQL-backed `buzz-db` coverage under native PostgreSQL — 235
passed in the shared serial run; the five shared-state/config-sensitive
cases passed as isolated reruns against fresh schemas, including the two
owner-limit tests with their fixture's
`BUZZ_MAX_COMMUNITIES_PER_OWNER=3`
- `cargo test -p buzz-relay --lib -- --test-threads=1` under native
PostgreSQL/Redis — 991 passed; the three current-month
partition-sensitive identity-archive cases passed after provisioning the
August 2026 test partition; 87 infrastructure-marked tests remained
ignored
- Source/diff guards — relay-admin implementation bodies match current
`main`; all 27 public wrapper signatures are retained; exactly one
datastore span wraps each wrapper; `lib.rs` has zero `impl Db` blocks
and zero datastore spans; no duplicate top-level relay-admin modules or
`store_ownership.rs`; `error.rs` matches current `main`

Independent clean review workstation `buzz-tornquist-pr-6987-review`,
detached at exact head `be24430472d1a87ac5c0d6026c620cd6caea3537`:

- `cargo fmt --all --check`
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`
- `cargo test -p buzz-db --lib` — 113 passed, 240 ignored
- `cargo test -p buzz-db --test observability_source` — 2 passed
- Exact-head ownership/re-export/instrumentation audit — no remaining
actionable findings

---------

Signed-off-by: OpenAI Codex <codex@openai.com>
Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-08-28 15:24:32 -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:?}"
);
}
}