Files
buzz/crates/buzz-db/src/runtime/tests.rs
ravarora2 beb76406c1 feat(relay): add detailed readiness metrics (#7149)
## Summary

This PR makes relay readiness failures diagnosable without weakening the
existing fail-closed readiness contract. It distinguishes Postgres pool
acquisition from query execution, Redis pool acquisition,
deletion-catalog validation, and the overall two-second deadline;
exports a bounded Prometheus contract for rollout dashboards; and fixes
the concurrency, shutdown, and listener-boundary semantics needed for
those signals to be trustworthy.

## Why

The previous `/_readiness` implementation exposed only an aggregate
ready/not-ready result. During a rollout, operators could not tell
whether a pod was blocked on:

- acquiring a Postgres connection;
- executing the Postgres readiness query;
- acquiring a Redis connection;
- validating the deletion catalog; or
- the shared readiness deadline.

Adding metrics to the existing handler also exposed three correctness
hazards that this PR resolves:

1. the same handler is mounted on both the public application listener
and the private Kubernetes health listener, so public requests could
otherwise distort rollout telemetry;
2. concurrent probes can finish out of order, allowing an older result
to overwrite newer current-state gauges; and
3. a probe started before SIGTERM can finish afterward, incorrectly
return `200 ready`, and resurrect ready gauges while the process is
draining.

## Behavior

### Readiness evaluation

- Postgres, Redis, and deletion-catalog checks still run under one
shared two-second deadline.
- Postgres distinguishes pool acquisition timeout/error from query
timeout/error.
- Redis distinguishes pool acquisition timeout/error. This does not
claim a Redis command round trip.
- The deletion catalog distinguishes operation timeout/error.
- Multiple failures are reported as `multiple_dependencies_failed`;
exhaustion of the shared deadline is reported as `overall_timeout` when
no more specific completed outcome wins.
- Readiness remains fail-closed: every dependency must succeed for `200
{"status":"ready"}`.

### Ordered publication and shutdown

`ReadinessCoordinator` is process-owned and uses one mutex as the
linearization point for probe generations, current-state publication,
and terminal shutdown.

- Every completed dependency attempt may contribute its truthful counter
and duration observation.
- Only the newest admissible probe generation may publish current-state
gauges.
- An older, slower probe cannot overwrite a newer probe's gauges.
- `begin_shutdown()` and probe commit serialize through the same
coordinator.
- Once shutdown commits, an in-flight probe cannot return ready or
publish ready/current dependency gauges, even if its dependency work
later succeeds.

Shutdown without dependency evaluation records only:

- `buzz_readiness_checks_total{reason="shutting_down"}`; and
- `buzz_readiness_state{check="overall"} = 0`.

It does **not** fabricate dependency failures, dependency state changes,
or zero-duration latency samples. If shutdown wins after an in-flight
evaluation actually ran, those completed dependency attempts may remain
as attempt telemetry, but they cannot overwrite shutdown-dominant
current state.

### Listener and response contract

- The private health listener's `/_readiness` route is the sole
authority for rollout readiness telemetry.
- The public application listener retains `/_readiness` for
compatibility, evaluates the same dependencies, and preserves the
existing response shape, but it emits no `buzz_readiness_*` metrics.
- Ready responses remain `200 {"status":"ready"}`.
- Shutdown responses remain `503 {"status":"shutting_down"}`.
- Failed private-health responses include the bounded `reason` plus
`postgres`, `redis`, and `deletion_catalog` booleans.
- Failed public compatibility responses retain the dependency booleans
but omit the new detailed reason.
- No header, query parameter, path value, or other request-controlled
value becomes a metric label.

## Prometheus contract

The final schema is intentionally capped at **99 raw Prometheus series
per pod**.

| Metric | Type | Labels | Raw series/pod |
|---|---|---|---:|
| `buzz_readiness_checks_total` | counter | `reason` | 12 |
| `buzz_readiness_dependency_checks_total` | counter | `dependency`,
typed `outcome` | 11 |
| `buzz_readiness_check_duration_seconds` | histogram | `check` | 72 |
| `buzz_readiness_state` | gauge | `check` | 4 |
| **Total** |  |  | **99** |

### Closed label sets

`reason`:

```text
ready
shutting_down
postgres_pool_timeout
postgres_pool_error
postgres_query_timeout
postgres_query_error
redis_pool_timeout
redis_pool_error
deletion_catalog_timeout
deletion_catalog_error
overall_timeout
multiple_dependencies_failed
```

Valid `dependency` / `outcome` pairs are enforced by typed enums:

- `postgres`: `success | pool_timeout | pool_error | operation_timeout |
operation_error`
- `redis`: `success | pool_timeout | pool_error`
- `deletion_catalog`: `success | operation_timeout | operation_error`

`check` is `overall | postgres | redis | deletion_catalog`.

The readiness histogram has 15 configured buckets concentrated around
the two-second deadline:

```text
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5,
0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, +Inf
```

The contract deliberately removes the redundant overall `result` label
and the histogram `outcome` label. Pod, ReplicaSet, version, rollout,
raw error, SQL, URL, tenant, user, community, pubkey, and
request-controlled values are prohibited application labels;
infrastructure enrichment can supply deployment identity outside the
application metric.

## Postgres production seam and CI

The real `Db::readiness_check` path now supports deterministic
production-seam testing while preserving the same acquisition/query
implementation used by the relay. The isolated PostgreSQL lane
automatically discovers and executes three ignored integration tests
covering:

- a held sole connection causing pool timeout, followed by recovery
after release;
- closed-pool acquisition error;
- acquisition success followed by query timeout;
- classified query error;
- cancellation while waiting for a connection;
- cancellation during an in-flight query; and
- eventual pool recovery with waiter/in-flight state balanced.

This prevents the central SQLx/Postgres behavior from being merely
compiled but never executed in CI.

## Tests and verification

- `cargo fmt --all -- --check`
- `./scripts/test-postgres-test-discovery.sh`
- complete `cargo test -p buzz-db`
- focused `cargo test -p buzz-relay readiness`
- real-router `cargo test -p buzz-relay real_health_route_`
- controlled PostgreSQL execution of all three ignored readiness tests
- production health router -> real `GET /_readiness` -> Prometheus
render assertions
- public-router requests proving zero rollout telemetry
- deterministic out-of-order A/B probe tests
- SIGTERM-during-probe tests proving shutdown dominance
- exported metric name/type/exact-label/bucket assertions
- exported raw-series allowlist and 99-series ceiling assertion

The route-to-scrape regression test uses the production Prometheus
builder and verifies the `2`, `2.5`, and `+Inf` readiness buckets. It
fails if the health recording call, health route, public/health
boundary, generation fence, shutdown fence, or bucket override is
removed.

## Risk assessment

**Medium.** This changes the live readiness publication path and adds
synchronization around probe commit/shutdown. The risk is bounded by:

- preserving the existing dependencies and shared two-second deadline;
- keeping readiness fail-closed;
- using a short, process-local mutex only at begin/commit linearization
points, not across dependency awaits;
- retaining the public compatibility endpoint while isolating its
telemetry;
- enforcing typed, low-cardinality labels and an exported series
ceiling; and
- covering the production router, Prometheus exposition, real PostgreSQL
seam, concurrency ordering, and shutdown races.

## Operational notes

- Dashboards should treat `buzz_readiness_state` as the latest sampled
current state, not as an event stream.
- `shutting_down` should be excluded from dependency-failure alerts
because no dependency failure is implied.
- Do not use `default_zero()` for missing current-state data;
missing/stale is unknown, not healthy.
- Use histogram buckets, heatmaps, max, or average until the Datadog
distribution metadata confirms percentiles are enabled; do not title a
widget p95 before that live readback.

## Non-goals

This PR does not add the broader process-startup lifecycle,
worker/listener supervision, shutdown coordinator, WebSocket/huddle
handoff, client recovery telemetry, dashboard mutations, Datadog
configuration changes, or deployment changes. Those remain separate
follow-up work.

## References

- [Staging dev relay image
runbook](https://github.com/block/buzz/blob/main/docs/staging-dev-relay-images.md)
- Readiness telemetry contract: `deploy/charts/buzz/README.md`

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-09-01 18:31:30 -04:00

2930 lines
110 KiB
Rust

use super::*;
use crate::{relay_members, thread};
use buzz_core::CommunityId;
use sqlx::{Connection, PgPool};
use uuid::Uuid;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
async fn setup_db() -> Db {
let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into());
let pool = PgPool::connect(&database_url)
.await
.expect("connect to test DB");
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") {
crate::migration::run_migrations(&pool)
.await
.expect("apply migration schema");
}
Db::from_pool(pool)
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn readiness_check_distinguishes_pool_exhaustion_from_success() {
let database_url = crate::test_support::database_url();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect size-one readiness test pool");
let held = pool
.acquire()
.await
.expect("hold the only readiness test connection");
let db = Db::from_pool(pool);
let exhausted = db
.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_millis(25))
.await;
assert_eq!(exhausted, DbReadinessOutcome::PoolTimeout);
drop(held);
let recovered = db
.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
.await;
assert_eq!(recovered, DbReadinessOutcome::Success);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn readiness_check_classifies_closed_pool_query_timeout_and_query_error() {
let database_url = crate::test_support::database_url();
let closed_pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect closed readiness test pool");
closed_pool.close().await;
let closed = Db::from_pool(closed_pool)
.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
.await;
assert_eq!(closed, DbReadinessOutcome::PoolError);
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect query classification test pool");
let db = Db::from_pool(pool);
let timed_out = db
.readiness_check_sql(
tokio::time::Instant::now() + std::time::Duration::from_millis(25),
"SELECT pg_sleep(0.2)",
)
.await;
assert_eq!(timed_out, DbReadinessOutcome::QueryTimeout);
let query_error = db
.readiness_check_sql(
tokio::time::Instant::now() + std::time::Duration::from_secs(1),
"SELECT 1 / 0",
)
.await;
assert_eq!(query_error, DbReadinessOutcome::QueryError);
assert_eq!(
db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
.await,
DbReadinessOutcome::Success,
"query failures must return the acquired connection to the pool"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn readiness_check_cancellation_balances_waiter_and_inflight_connection() {
let database_url = crate::test_support::database_url();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect cancellation readiness test pool");
let held = pool
.acquire()
.await
.expect("hold sole connection before waiter cancellation");
let db = Db::from_pool(pool);
let waiting_db = db.clone();
let waiting = tokio::spawn(async move {
waiting_db
.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(5))
.await
});
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
waiting.abort();
assert!(waiting
.await
.expect_err("waiting check must be cancelled")
.is_cancelled());
drop(held);
assert_eq!(
db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
.await,
DbReadinessOutcome::Success,
"cancelled pool waiter must not consume the released connection"
);
let querying_db = db.clone();
let querying = tokio::spawn(async move {
querying_db
.readiness_check_sql(
tokio::time::Instant::now() + std::time::Duration::from_secs(5),
"SELECT pg_sleep(5)",
)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
querying.abort();
assert!(querying
.await
.expect_err("querying check must be cancelled")
.is_cancelled());
let recovered = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let outcome = db
.readiness_check(
tokio::time::Instant::now() + std::time::Duration::from_millis(250),
)
.await;
match outcome {
DbReadinessOutcome::Success => break outcome,
DbReadinessOutcome::PoolTimeout => tokio::task::yield_now().await,
unexpected => panic!(
"cancelled in-flight query produced unexpected recovery outcome: {unexpected:?}"
),
}
}
})
.await
.expect("cancelled in-flight query must return or replace its connection");
assert_eq!(recovered, DbReadinessOutcome::Success);
}
async fn make_community(pool: &PgPool) -> Uuid {
let id = Uuid::new_v4();
let host = format!("communities-of-channels-{}.example", id.simple());
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(host)
.execute(pool)
.await
.expect("insert community");
id
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion() {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
let db = setup_db().await;
let community = CommunityId::from_uuid(make_community(&db.pool).await);
let keys = Keys::generate();
let d_tag = format!("read-state:{}", "b".repeat(32));
let tags = vec![
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
Tag::parse(["t", "read-state"]).expect("t tag"),
];
let base = Timestamp::now().as_secs();
let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A")
.tags(tags.clone())
.custom_created_at(Timestamp::from(base))
.sign_with_keys(&keys)
.expect("sign A");
let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X")
.tags(tags.clone())
.custom_created_at(Timestamp::from(base + 1))
.sign_with_keys(&keys)
.expect("sign X");
let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B")
.tags(tags.clone())
.custom_created_at(Timestamp::from(base + 2))
.sign_with_keys(&keys)
.expect("sign B");
let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C")
.tags(tags)
.custom_created_at(Timestamp::from(base + 3))
.sign_with_keys(&keys)
.expect("sign C");
async fn legacy_insert(
pool: &PgPool,
community: CommunityId,
event: &nostr::Event,
d_tag: &str,
) -> std::result::Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
sqlx::query(
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \
VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.bind(event.pubkey.to_bytes())
.bind(event.created_at.as_secs() as f64)
.bind(buzz_core::kind::KIND_READ_STATE as i32)
.bind(serde_json::to_value(&event.tags).expect("serialize tags"))
.bind(&event.content)
.bind(event.sig.serialize().as_slice())
.bind(d_tag)
.execute(pool)
.await
}
legacy_insert(&db.pool, community, &a, &d_tag)
.await
.expect("legacy insert A");
let duplicate = legacy_insert(&db.pool, community, &a, &d_tag)
.await
.expect("legacy duplicate A remains idempotent");
assert_eq!(duplicate.rows_affected(), 0);
sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
)
.bind(community.as_uuid())
.bind("c".repeat(64))
.bind(a.id.as_bytes().as_slice())
.bind(a.created_at.as_secs() as f64)
.execute(&db.pool)
.await
.expect("insert live mention");
// Emulate the pre-PR replacement path after migration 0007: soft-delete
// the live row, then insert B without any application watermark write.
sqlx::query(
"UPDATE events SET deleted_at=NOW() \
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
)
.bind(community.as_uuid())
.bind(keys.public_key().to_bytes())
.bind(&d_tag)
.execute(&db.pool)
.await
.expect("legacy soft-delete A");
let mentions_after_delete: i64 = sqlx::query_scalar(
"SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2",
)
.bind(community.as_uuid())
.bind(a.id.as_bytes().as_slice())
.fetch_one(&db.pool)
.await
.expect("count mentions after delete");
assert_eq!(mentions_after_delete, 0);
let stale_mention = sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
)
.bind(community.as_uuid())
.bind("d".repeat(64))
.bind(a.id.as_bytes().as_slice())
.bind(a.created_at.as_secs() as f64)
.execute(&db.pool)
.await
.expect("stale post-commit mention is skipped");
assert_eq!(stale_mention.rows_affected(), 0);
legacy_insert(&db.pool, community, &b, &d_tag)
.await
.expect("legacy insert B");
let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag)
.await
.expect("live duplicate B is skipped");
assert_eq!(duplicate_b.rows_affected(), 0);
sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
)
.bind(community.as_uuid())
.bind("e".repeat(64))
.bind(b.id.as_bytes().as_slice())
.bind(b.created_at.as_secs() as f64)
.execute(&db.pool)
.await
.expect("insert B mention");
// Exercise the new Rust hard-delete path independently. An in-flight
// mention holds KEY SHARE on B, so replacement by C must block, then
// complete after the mention commits and remove both B and its mention.
let mut rust_mention_tx = db
.pool
.begin()
.await
.expect("begin Rust mention transaction");
sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING",
)
.bind(community.as_uuid())
.bind("e".repeat(64))
.bind(b.id.as_bytes().as_slice())
.bind(b.created_at.as_secs() as f64)
.execute(&mut *rust_mention_tx)
.await
.expect("hold B live-event key-share lock");
let replace_db = db.clone();
let replace_d_tag = d_tag.clone();
let replace_c = c.clone();
let replace_task = tokio::spawn(async move {
replace_db
.replace_parameterized_event(community, &replace_c, &replace_d_tag, None)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!replace_task.is_finished(),
"Rust hard delete should wait for mention lock"
);
rust_mention_tx
.commit()
.await
.expect("release Rust mention lock");
let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task)
.await
.expect("Rust hard delete deadlocked with mention insert")
.expect("replacement task panicked")
.expect("replace B with C");
assert!(replaced.1, "C must replace B");
let b_mentions: i64 = sqlx::query_scalar(
"SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2",
)
.bind(community.as_uuid())
.bind(b.id.as_bytes().as_slice())
.fetch_one(&db.pool)
.await
.expect("count B mentions after Rust replacement");
assert_eq!(b_mentions, 0);
sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
)
.bind(community.as_uuid())
.bind("f".repeat(64))
.bind(c.id.as_bytes().as_slice())
.bind(c.created_at.as_secs() as f64)
.execute(&db.pool)
.await
.expect("insert C mention");
// Exercise legacy UPDATE-trigger deletion with the same barrier. While
// deletion waits on C's KEY SHARE lock, an exact replay must already be
// a zero-row trigger no-op; it must not wait for deletion or resurrect C.
let mut legacy_mention_tx = db
.pool
.begin()
.await
.expect("begin legacy mention transaction");
sqlx::query(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING",
)
.bind(community.as_uuid())
.bind("f".repeat(64))
.bind(c.id.as_bytes().as_slice())
.bind(c.created_at.as_secs() as f64)
.execute(&mut *legacy_mention_tx)
.await
.expect("hold C live-event key-share lock");
let delete_pool = db.pool.clone();
let delete_pubkey = keys.public_key().to_bytes();
let delete_d_tag = d_tag.clone();
let delete_task = tokio::spawn(async move {
sqlx::query(
"UPDATE events SET deleted_at=NOW() \
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
)
.bind(community.as_uuid())
.bind(delete_pubkey)
.bind(delete_d_tag)
.execute(&delete_pool)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!delete_task.is_finished(),
"legacy delete should wait for mention lock"
);
let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag)
.await
.expect("concurrent exact C replay is skipped");
assert_eq!(replay_while_delete_waits.rows_affected(), 0);
legacy_mention_tx
.commit()
.await
.expect("release legacy mention lock");
tokio::time::timeout(std::time::Duration::from_secs(2), delete_task)
.await
.expect("legacy delete deadlocked with mention insert")
.expect("delete task panicked")
.expect("legacy NIP-09 delete C");
let payloads: i64 = sqlx::query_scalar(
"SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
)
.bind(community.as_uuid())
.bind(keys.public_key().to_bytes())
.bind(&d_tag)
.fetch_one(&db.pool)
.await
.expect("count retained payloads");
assert_eq!(
payloads, 0,
"legacy soft deletes must not retain NIP-RS payloads"
);
// Opposite commit order: deletion has committed before exact replay.
// Equality remains an observable zero-row no-op, never a resurrection.
let replay_c = legacy_insert(&db.pool, community, &c, &d_tag)
.await
.expect("post-delete exact C replay is skipped");
assert_eq!(replay_c.rows_affected(), 0);
let payloads_after_exact_replay: i64 = sqlx::query_scalar(
"SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
)
.bind(community.as_uuid())
.bind(keys.public_key().to_bytes())
.bind(&d_tag)
.fetch_one(&db.pool)
.await
.expect("count payloads after exact replay");
assert_eq!(payloads_after_exact_replay, 0);
let replay = legacy_insert(&db.pool, community, &x, &d_tag).await;
assert!(
replay.is_err(),
"database guard must reject A < X < C replay"
);
let watermark: (chrono::DateTime<chrono::Utc>, Vec<u8>) = sqlx::query_as(
"SELECT created_at, event_id FROM parameterized_event_watermarks \
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
)
.bind(community.as_uuid())
.bind(keys.public_key().to_bytes())
.bind(&d_tag)
.fetch_one(&db.pool)
.await
.expect("read C watermark");
assert_eq!(watermark.0.timestamp(), base as i64 + 3);
assert_eq!(watermark.1, c.id.as_bytes().as_slice());
}
// ---- Read-replica routing ------------------------------------------------
//
// These tests pin the routing contract of `Db::read()` and the two routed
// methods. A second scratch database stands in for the replica; the
// fixtures are deliberately DIVERGENT (rows that exist in only one of the
// two databases) so every assertion observes which pool actually served
// the query instead of trusting the routing code's word for it.
async fn admin_url() -> String {
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into())
}
/// Create a fresh scratch database on the same server and optionally run migrations.
async fn create_scratch_db_through(
admin: &PgPool,
prefix: &str,
target: Option<i64>,
) -> (PgPool, String) {
let name = format!("{}_{}", prefix, Uuid::new_v4().simple());
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
.execute(admin)
.await
.expect("create scratch db");
let base = admin_url().await;
// Swap the database path segment of the admin URL for the scratch name.
let scratch_url = {
let idx = base.rfind('/').expect("db url has a path segment");
format!("{}/{}", &base[..idx], name)
};
let pool = PgPool::connect(&scratch_url)
.await
.expect("connect scratch db");
match target {
Some(target) => migration::run_migrations_through(&pool, target)
.await
.expect("migrate scratch db through target"),
None => migration::run_migrations(&pool)
.await
.expect("migrate scratch db"),
}
(pool, name)
}
/// Create a fresh scratch database on the same server and run all migrations.
/// Returns (pool, db_name); callers should `drop_scratch_db` when done.
async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) {
create_scratch_db_through(admin, prefix, None).await
}
async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) {
pool.close().await;
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
"DROP DATABASE IF EXISTS {name} WITH (FORCE)"
)))
.execute(admin)
.await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn push_gateway_profile_migration_converges_brownfield_authority() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin database");
let (pool, name) = create_scratch_db_through(&admin, "push_profile", Some(42)).await;
let installation_id = Uuid::new_v4();
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO push_gateway_installations(\
id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \
token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \
VALUES($1, $2, $3, 0, 'buzz-ios-production', $4, $5, 1, $6)",
)
.bind(installation_id)
.bind(vec![1_u8])
.bind(vec![2_u8; 33])
.bind(vec![3_u8])
.bind(vec![4_u8; 32])
.bind(now + chrono::Duration::days(1))
.execute(&pool)
.await
.expect("insert legacy production installation");
sqlx::query(
"INSERT INTO push_gateway_delegations(\
id, installation_id, relay_pubkey, endpoint_epoch, generation, not_before, expires_at) \
VALUES($1, $2, $3, 1, 1, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(installation_id)
.bind(vec![5_u8; 32])
.bind(now)
.bind(now + chrono::Duration::hours(1))
.execute(&pool)
.await
.expect("insert delegation for legacy installation");
migration::run_migrations(&pool)
.await
.expect("apply dogfood-only migration");
let legacy_installations: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations")
.fetch_one(&pool)
.await
.expect("count legacy installations");
let legacy_delegations: i64 =
sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations")
.fetch_one(&pool)
.await
.expect("count legacy delegations");
assert_eq!(legacy_installations, 0);
assert_eq!(legacy_delegations, 0);
sqlx::query(
"INSERT INTO push_gateway_installations(\
id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \
token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \
VALUES($1, $2, $3, 0, 'buzz-ios-dogfood', $4, $5, 1, $6)",
)
.bind(Uuid::new_v4())
.bind(vec![6_u8])
.bind(vec![7_u8; 33])
.bind(vec![8_u8])
.bind(vec![9_u8; 32])
.bind(now + chrono::Duration::days(1))
.execute(&pool)
.await
.expect("dogfood installation is accepted after migration");
let sandbox = sqlx::query(
"INSERT INTO push_gateway_installations(\
id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \
token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \
VALUES($1, $2, $3, 0, 'buzz-ios-sandbox', $4, $5, 1, $6)",
)
.bind(Uuid::new_v4())
.bind(vec![10_u8])
.bind(vec![11_u8; 33])
.bind(vec![12_u8])
.bind(vec![13_u8; 32])
.bind(now + chrono::Duration::days(1))
.execute(&pool)
.await;
assert!(sandbox.is_err(), "legacy sandbox profile must be rejected");
drop_scratch_db(&admin, pool, &name).await;
admin.close().await;
}
/// Insert identical community + channel rows into a database so the same
/// (community, channel) ids resolve in both writer and replica.
async fn seed_community_channel(
pool: &PgPool,
community: Uuid,
channel: Uuid,
author: &nostr::Keys,
) {
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community)
.bind(format!("replica-routing-{}.example", community.simple()))
.execute(pool)
.await
.expect("insert community");
crate::channel::create_channel_with_id(
pool,
CommunityId::from_uuid(community),
channel,
&format!("replica-routing-{channel}"),
crate::channel::ChannelType::Stream,
crate::channel::ChannelVisibility::Open,
None,
author.public_key().to_bytes().as_slice(),
None,
)
.await
.expect("create channel");
}
fn signed_event_at(keys: &nostr::Keys, content: &str, secs: u64) -> nostr::Event {
nostr::EventBuilder::new(nostr::Kind::Custom(9), content)
.custom_created_at(nostr::Timestamp::from(secs))
.sign_with_keys(keys)
.expect("sign event")
}
async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &nostr::Event) {
let ts = chrono::DateTime::from_timestamp(ev.created_at.as_secs() as i64, 0).expect("valid ts");
event::insert_event_with_thread_metadata(
pool,
CommunityId::from_uuid(community),
ev,
Some(channel),
Some(event::ThreadMetadataParams {
event_id: ev.id.as_bytes(),
event_created_at: ts,
channel_id: channel,
parent_event_id: None,
parent_event_created_at: None,
root_event_id: None,
root_event_created_at: None,
depth: 0,
broadcast: true,
}),
)
.await
.expect("insert top-level event");
}
async fn insert_thread_reply(
pool: &PgPool,
community: Uuid,
channel: Uuid,
root: &nostr::Event,
reply: &nostr::Event,
) {
let reply_ts =
chrono::DateTime::from_timestamp(reply.created_at.as_secs() as i64, 0).expect("valid ts");
let root_ts =
chrono::DateTime::from_timestamp(root.created_at.as_secs() as i64, 0).expect("valid ts");
event::insert_event_with_thread_metadata(
pool,
CommunityId::from_uuid(community),
reply,
Some(channel),
Some(event::ThreadMetadataParams {
event_id: reply.id.as_bytes(),
event_created_at: reply_ts,
channel_id: channel,
parent_event_id: Some(root.id.as_bytes()),
parent_event_created_at: Some(root_ts),
root_event_id: Some(root.id.as_bytes()),
root_event_created_at: Some(root_ts),
depth: 1,
broadcast: false,
}),
)
.await
.expect("insert reply");
}
/// Composite thread cursor: 8-byte BE seconds + raw event id.
fn thread_cursor(reply: &crate::thread::ThreadReply) -> Vec<u8> {
let mut cur = reply.created_at.timestamp().to_be_bytes().to_vec();
cur.extend_from_slice(&reply.event_id);
cur
}
#[tokio::test]
async fn read_falls_back_to_writer_when_no_replica_configured() {
// Pure wiring test — connect_lazy never touches the network.
let pool = sqlx::PgPool::connect_lazy(TEST_DB_URL).expect("lazy pool");
let db = Db::from_pool(pool);
assert!(!db.has_read_pool());
assert!(
std::ptr::eq(db.read(), &db.pool),
"read() must be the writer pool when no replica is configured"
);
assert!(db.read_pool_stats().is_none());
}
#[test]
fn read_budget_zero_disables_and_large_values_clamp_to_staleness() {
assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off");
assert_eq!(
read_budget_from_ms(1000),
Some(std::time::Duration::from_millis(1000))
);
assert_eq!(
read_budget_from_ms(10_000_000),
Some(replica_fence::FENCE_STALENESS),
"budgets above the staleness gate clamp to it"
);
}
/// Truth table for [`RoutePredicate::for_query`]: the strongest sound
/// predicate per query shape, and — the deploy-day default row — that
/// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset)
/// forces `Bounded` even for covered-eligible shapes, so the zero
/// budget fails the new seams closed (Dawn's covered-at-zero-budget
/// catch, design doc rev 5).
#[test]
fn for_query_predicate_truth_table() {
let community = CommunityId::from_uuid(Uuid::new_v4());
let channel = Uuid::new_v4();
let until = chrono::Utc::now();
let pinned_with_until = {
let mut q = event::EventQuery::for_community(community);
q.channel_id = Some(channel);
q.until = Some(until);
q
};
let pinned_no_until = {
let mut q = event::EventQuery::for_community(community);
q.channel_id = Some(channel);
q
};
let unpinned_with_until = {
let mut q = event::EventQuery::for_community(community);
q.until = Some(until);
q
};
let global_only = {
let mut q = event::EventQuery::for_community(community);
q.global_only = true;
q.until = Some(until);
q
};
// Deploy-day default: budget unset ⇒ Bounded regardless of shape.
// The zero budget then fails Bounded closed, so the new seams
// record writer/disabled — merging with no env var set is a no-op.
assert!(
matches!(
RoutePredicate::for_query(&pinned_with_until, false),
RoutePredicate::Bounded
),
"budget unset must not reach the covered arm even when eligible"
);
// Budget set + channel pin + until ⇒ the strongest predicate.
assert!(matches!(
RoutePredicate::for_query(&pinned_with_until, true),
RoutePredicate::BoundedOrCovered { .. }
));
// Missing either covered precondition ⇒ Bounded.
assert!(matches!(
RoutePredicate::for_query(&pinned_no_until, true),
RoutePredicate::Bounded
));
assert!(matches!(
RoutePredicate::for_query(&unpinned_with_until, true),
RoutePredicate::Bounded
));
// global_only implies `channel_id = None`, so the channel-pin
// precondition fails and no covered arm is possible — `for_query`
// never inspects `global_only` itself; the row holds because
// constructor 1 (channel pin) returns None for an unpinned query.
assert!(matches!(
RoutePredicate::for_query(&global_only, true),
RoutePredicate::Bounded
));
}
/// The pre-existing cursor paths are NOT budget-gated: a channel-window
/// cursor page still derives `Covered` with no `routing_enabled` input
/// at all — at B=0 today it routes covered, and that status quo is
/// intentionally unchanged by the `for_query` gate (Max's matrix row:
/// old paths route at budget-unset; only the new seams go dark).
#[test]
fn channel_cursor_predicate_is_not_budget_gated() {
let channel = Uuid::new_v4();
let cursor = Some((chrono::Utc::now(), vec![1u8; 32]));
assert!(matches!(
RoutePredicate::from_channel_cursor(channel, &cursor),
RoutePredicate::Covered { .. }
));
// Head fetch (no cursor) is bounded — gated by the budget.
assert!(matches!(
RoutePredicate::from_channel_cursor(channel, &None),
RoutePredicate::Bounded
));
}
/// D5 wiring: `read_pool_stats().max` must be the READER pool's own
/// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the
/// operator's utilisation signal and inheriting the writer's max hides
/// reader saturation by exactly the sizing ratio. Pure wiring test:
/// `connect_lazy` never touches the network, but it does spawn the
/// pool reaper task, which needs a Tokio runtime — hence
/// `#[tokio::test]` despite the test body itself never awaiting.
#[tokio::test]
async fn read_pool_stats_reports_reader_ceiling_not_writer() {
let writer = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect_lazy(TEST_DB_URL)
.expect("lazy writer pool");
let reader = sqlx::postgres::PgPoolOptions::new()
.max_connections(40)
.connect_lazy(TEST_DB_URL)
.expect("lazy reader pool");
let db = Db::from_pools(writer, reader);
assert_eq!(db.pool_stats().max, 20);
assert_eq!(
db.read_pool_stats().expect("read pool configured").max,
40,
"reader gauge must report the reader's own ceiling"
);
}
/// D4 wiring: the reader pool is built lazily with `min_connections(0)`
/// and the short reader acquire timeout — construction must succeed
/// with no replica listening (reader-down at boot must not crash the
/// relay), and `read_max_connections` must honour
/// `DbConfig::read_max_connections` over the writer sizing.
/// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task,
/// which needs a Tokio runtime even though nothing is dialed.
#[tokio::test]
async fn connect_read_pool_is_lazy_and_independently_sized() {
let config = DbConfig {
max_connections: 20,
read_max_connections: Some(7),
..DbConfig::default()
};
// Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at
// construction time.
let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7)
.expect("lazy construction must not dial the replica");
assert_eq!(pool.options().get_max_connections(), 7);
assert_eq!(pool.options().get_min_connections(), 0);
assert_eq!(
pool.options().get_acquire_timeout(),
Db::READER_ACQUIRE_TIMEOUT
);
}
/// Channel window: head fetch (no cursor) reads the WRITER; cursor pages
/// read the REPLICA. Divergent fixtures prove which pool served each.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "routing_w").await;
let (replica, rname) = create_scratch_db(&admin, "routing_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
// Shared history (both databases): m1 < m2 < m3.
let base = 1_700_000_000u64;
let m1 = signed_event_at(&author, "m1", base);
let m2 = signed_event_at(&author, "m2", base + 10);
let m3 = signed_event_at(&author, "m3", base + 20);
for pool in [&writer, &replica] {
for ev in [&m1, &m2, &m3] {
insert_top_level(pool, community, channel, ev).await;
}
}
// Lag: the newest event exists only on the writer.
let fresh = signed_event_at(&author, "fresh-writer-only", base + 30);
insert_top_level(&writer, community, channel, &fresh).await;
// Marker: exists only on the "replica" (unphysical for a real replica,
// but it makes replica-served pages unambiguous).
let marker = signed_event_at(&author, "replica-only-marker", base + 5);
insert_top_level(&replica, community, channel, &marker).await;
let db = Db::from_pools(writer.clone(), replica.clone());
// Open the fence through "now": the fixture's history is far in the
// past, so every cursor falls below the fence and routing is
// eligible. Fence-gating itself is pinned by the fence tests below.
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
// Head fetch (cursor: None) → writer: sees `fresh`, never `marker`.
let head = db
.get_channel_window(cid, channel, 2, None, None)
.await
.expect("head window");
let head_contents: Vec<String> = head
.rows
.iter()
.map(|r| r.stored_event.event.content.clone())
.collect();
assert_eq!(
head_contents,
vec!["fresh-writer-only".to_string(), "m3".to_string()],
"head fetch must be served by the writer"
);
// Cursor page → replica: sees `marker`, never `fresh`.
let cursor = head.next_cursor.expect("has_more implies next_cursor");
let page2 = db
.get_channel_window(cid, channel, 10, Some(cursor), None)
.await
.expect("cursor window");
let page2_contents: Vec<String> = page2
.rows
.iter()
.map(|r| r.stored_event.event.content.clone())
.collect();
assert_eq!(
page2_contents,
vec![
"m2".to_string(),
"replica-only-marker".to_string(),
"m1".to_string()
],
"cursor page must be served by the replica"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Fail-closed on a mid-request replica failure (Dawn, review of
/// 1b0aa0dfa): a replica-routed page whose query errors *after* the
/// proof (the live shape is a hot-standby recovery conflict — 40001 /
/// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`)
/// must be re-run on the writer and served, never surfaced as an error
/// the writer could have answered. Degraded capacity, never holes.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn replica_window_failure_falls_back_to_writer() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "fb_w").await;
let (replica, rname) = create_scratch_db(&admin, "fb_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let m1 = signed_event_at(&author, "m1", base);
let m2 = signed_event_at(&author, "m2", base + 10);
let m3 = signed_event_at(&author, "m3", base + 20);
for pool in [&writer, &replica] {
for ev in [&m1, &m2, &m3] {
insert_top_level(pool, community, channel, ev).await;
}
}
let marker = signed_event_at(&author, "replica-only-marker", base + 5);
insert_top_level(&replica, community, channel, &marker).await;
let db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
let head = db
.get_channel_window(cid, channel, 1, None, None)
.await
.expect("head window");
let cursor = head.next_cursor.expect("has_more implies next_cursor");
// Guard against a vacuous pass: the cursor page must actually be
// replica-eligible before we break the replica.
let healthy = db
.get_channel_window(cid, channel, 10, Some(cursor.clone()), None)
.await
.expect("healthy cursor window");
assert!(
healthy
.rows
.iter()
.any(|r| r.stored_event.event.content == "replica-only-marker"),
"fixture must route the cursor page to the replica while healthy"
);
// Break the replica AFTER the proof point: the heartbeat table stays
// intact (the observation succeeds), the page query then fails.
sqlx::query("DROP TABLE events CASCADE")
.execute(&replica)
.await
.expect("drop replica events");
let page = db
.get_channel_window(cid, channel, 10, Some(cursor), None)
.await
.expect("replica failure must fall back to the writer, not error");
let contents: Vec<&str> = page
.rows
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(
contents,
vec!["m2", "m1"],
"fallback page must be the writer's answer (no replica marker)"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// [`replica_window_failure_falls_back_to_writer`] for the thread-replies
/// path: a replica-routed thread page whose query errors after the proof
/// re-runs on the writer instead of surfacing an error.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn replica_thread_failure_falls_back_to_writer() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "fbt_w").await;
let (replica, rname) = create_scratch_db(&admin, "fbt_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let root = signed_event_at(&author, "root", base);
for pool in [&writer, &replica] {
insert_top_level(pool, community, channel, &root).await;
}
let replies: Vec<nostr::Event> = (1..=3)
.map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64))
.collect();
for pool in [&writer, &replica] {
for reply in &replies {
insert_thread_reply(pool, community, channel, &root, reply).await;
}
}
// Replica-only divergent reply between r2 and r3 marks replica serves.
let ghost = signed_event_at(&author, "replica-only-ghost", base + 25);
insert_thread_reply(&replica, community, channel, &root, &ghost).await;
let db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
let page1 = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None)
.await
.expect("head page");
let cur = thread_cursor(page1.last().expect("page 1 non-empty"));
// Healthy: the full page after r2 is the replica's [ghost].
let healthy = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur))
.await
.expect("healthy replica page");
assert_eq!(
healthy[0].stored_event.event.content, "replica-only-ghost",
"fixture must route the cursor page to the replica while healthy"
);
sqlx::query("DROP TABLE events CASCADE")
.execute(&replica)
.await
.expect("drop replica events");
let page = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur))
.await
.expect("replica failure must fall back to the writer, not error");
assert_eq!(
page[0].stored_event.event.content, "r3",
"fallback page must be the writer's answer"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Mid-request degradation of the held session (Dawn, review of
/// 1b0aa0dfa): when the proved replica transaction dies between the page
/// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader
/// connection, the same tx-fatal shape as a recovery-conflict cancel),
/// [`ReadSession::query_events`] must re-run the query on the writer and
/// permanently degrade the session instead of surfacing the error.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn read_session_degrades_to_writer_when_replica_connection_dies() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "deg_w").await;
let (replica, rname) = create_scratch_db(&admin, "deg_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let m1 = signed_event_at(&author, "m1", base);
let m2 = signed_event_at(&author, "m2", base + 10);
for pool in [&writer, &replica] {
for ev in [&m1, &m2] {
insert_top_level(pool, community, channel, ev).await;
}
}
// Writer-only row proves the degraded aux ran on the writer.
let fresh = signed_event_at(&author, "fresh-writer-only", base + 20);
insert_top_level(&writer, community, channel, &fresh).await;
let db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
let head = db
.get_channel_window(cid, channel, 1, None, None)
.await
.expect("head window");
let cursor = head.next_cursor.expect("has_more implies next_cursor");
let (_window, mut session) = db
.get_channel_window_with_session(cid, channel, 10, Some(cursor), None)
.await
.expect("routed cursor window");
assert!(
session.is_replica(),
"fixture must route this page to the replica"
);
// Kill the reader's backend out from under the held transaction.
sqlx::query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
WHERE datname = $1 AND pid <> pg_backend_pid()",
)
.bind(&rname)
.execute(&admin)
.await
.expect("terminate replica backends");
let mut aux = EventQuery::for_community(cid);
aux.channel_id = Some(channel);
let rows = session
.query_events(&aux)
.await
.expect("session must degrade to the writer, not error");
assert!(
rows.iter()
.any(|se| se.event.content == "fresh-writer-only"),
"degraded aux must be served by the writer"
);
assert!(
!session.is_replica(),
"the session must be permanently degraded to the writer"
);
drop(session);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request
/// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first
/// statement was the heartbeat observation — so a row committed on the
/// replica *after* the proof must be invisible to every follow-up
/// statement in the same request (page, participants, aux). This
/// distinguishes the transaction contract from mere connection reuse:
/// autocommit statements on the same backend advance their snapshot
/// per statement and WOULD see the mid-request row.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn routed_request_holds_one_snapshot_across_page_and_aux() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "snap_w").await;
let (replica, rname) = create_scratch_db(&admin, "snap_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let m1 = signed_event_at(&author, "m1", base);
let m2 = signed_event_at(&author, "m2", base + 10);
for pool in [&writer, &replica] {
for ev in [&m1, &m2] {
insert_top_level(pool, community, channel, ev).await;
}
}
let db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
// Head page on the writer yields the cursor for a replica-routed page.
let head = db
.get_channel_window(cid, channel, 1, None, None)
.await
.expect("head window");
let cursor = head.next_cursor.expect("has_more implies next_cursor");
// Route the cursor page to the replica and HOLD the session.
let (window, mut session) = db
.get_channel_window_with_session(cid, channel, 10, Some(cursor), None)
.await
.expect("routed cursor window");
assert!(
session.is_replica(),
"fixture must route this page to the replica"
);
assert_eq!(window.rows.len(), 1, "page after m2 is [m1]");
// Mid-request: a new event commits on the replica (stands in for
// replay advancing between the page and the aux closure).
let mid = signed_event_at(&author, "mid-request-commit", base + 5);
insert_top_level(&replica, community, channel, &mid).await;
// A fresh autocommit statement on ANOTHER session sees it — the row
// is really there (control for the assertion below).
let mut control = EventQuery::for_community(cid);
control.channel_id = Some(channel);
let visible_elsewhere = event::query_events(&replica, &control)
.await
.expect("control query");
assert!(
visible_elsewhere
.iter()
.any(|se| se.event.content == "mid-request-commit"),
"control: the mid-request row must be committed and visible to a new snapshot"
);
// The held request session must NOT see it: its snapshot was
// anchored by the heartbeat observation, before the commit.
let mut aux = EventQuery::for_community(cid);
aux.channel_id = Some(channel);
let in_request = session.query_events(&aux).await.expect("aux query");
assert!(
!in_request
.iter()
.any(|se| se.event.content == "mid-request-commit"),
"request transaction must hold the proof-time snapshot; a \
mid-request commit leaking in means the aux ran outside the \
request transaction (autocommit connection reuse)"
);
// Rows from the proof-time snapshot are still served.
assert!(
in_request.iter().any(|se| se.event.content == "m1"),
"proof-time rows must remain visible in the request snapshot"
);
drop(session);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Head gate (Predicate A): with the budget unset, a head fetch reads
/// the writer even over an open fence; with a budget set and a fresh
/// proved entry, the head page is served by the replica session
/// (bounded staleness accepted); with a budget the fence entry exceeds,
/// the head page falls back to the writer.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn head_fetch_routes_by_configured_budget() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "head_w").await;
let (replica, rname) = create_scratch_db(&admin, "head_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let shared = signed_event_at(&author, "shared", base);
for pool in [&writer, &replica] {
insert_top_level(pool, community, channel, &shared).await;
}
// Divergent heads prove which pool served the fetch.
let fresh = signed_event_at(&author, "fresh-writer-only", base + 30);
insert_top_level(&writer, community, channel, &fresh).await;
let marker = signed_event_at(&author, "replica-only-marker", base + 20);
insert_top_level(&replica, community, channel, &marker).await;
let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
let head_contents = |w: &thread::ChannelWindow| -> Vec<String> {
w.rows
.iter()
.map(|r| r.stored_event.event.content.clone())
.collect()
};
// Budget unset (rollout default): head → writer, fence open or not.
let head = db
.get_channel_window(cid, channel, 2, None, None)
.await
.expect("head, gate off");
assert_eq!(
head_contents(&head),
vec!["fresh-writer-only".to_string(), "shared".to_string()],
"head routing must default off"
);
// Budget set, entry fresh (just recorded): head → replica.
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
let head = db
.get_channel_window(cid, channel, 2, None, None)
.await
.expect("head, gate on");
assert_eq!(
head_contents(&head),
vec!["replica-only-marker".to_string(), "shared".to_string()],
"a fresh proved entry within budget must serve the head from the replica"
);
// Entry older than the budget: head falls back to the writer.
db.fence().close();
db.fence().force_open_for_tests_at(
chrono::Utc::now(),
std::time::Instant::now() - std::time::Duration::from_secs(10),
);
let head = db
.get_channel_window(cid, channel, 2, None, None)
.await
.expect("head, entry too old");
assert_eq!(
head_contents(&head),
vec!["fresh-writer-only".to_string(), "shared".to_string()],
"an over-budget entry must fail the head gate closed"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// End-to-end deploy-default proof for the NEW routed seams: with the
/// budget unset, a covered-eligible query (channel-pinned + `until`)
/// through [`Db::query_events_routed`] is served by the WRITER — the
/// `for_query` gate keeps the covered arm dark (rev 5). With the budget
/// set and a fresh proved entry, the same query routes to the replica.
/// Divergent fixtures prove which pool served each read.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "qer_w").await;
let (replica, rname) = create_scratch_db(&admin, "qer_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let shared = signed_event_at(&author, "shared", base);
for pool in [&writer, &replica] {
insert_top_level(pool, community, channel, &shared).await;
}
let writer_only = signed_event_at(&author, "writer-only", base + 10);
insert_top_level(&writer, community, channel, &writer_only).await;
let replica_only = signed_event_at(&author, "replica-only", base + 20);
insert_top_level(&replica, community, channel, &replica_only).await;
let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
// Covered-eligible shape: channel-pinned with an `until` upper
// bound below the (now) fence wall.
let q = {
let mut q = EventQuery::for_community(cid);
q.channel_id = Some(channel);
q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0);
q
};
let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet<String> {
evs.iter().map(|e| e.event.content.clone()).collect()
};
// Deploy default: budget unset ⇒ writer, even though the shape is
// covered-eligible and the fence is open.
let rows = db
.query_events_routed("test_routed", &q)
.await
.expect("routed query, gate off");
assert!(
contents(&rows).contains("writer-only"),
"budget unset must serve the writer"
);
assert!(
!contents(&rows).contains("replica-only"),
"budget unset must not reach the replica via the covered arm"
);
// Budget set ⇒ the covered arm serves it from the replica.
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
let rows = db
.query_events_routed("test_routed", &q)
.await
.expect("routed query, gate on");
assert!(
contents(&rows).contains("replica-only"),
"budget set + covered-eligible must route to the replica"
);
assert!(!contents(&rows).contains("writer-only"));
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// COUNT is bounded-only (rev 5 deletion-visibility rule): a
/// covered-eligible shape must NOT let a count take the covered arm.
/// With the budget unset the count reads the WRITER even with an open
/// fence; with the budget set and a fresh entry it reads the replica
/// under the bounded arm. Divergent row counts prove the serving pool.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn count_events_routed_is_bounded_only() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "cnt_w").await;
let (replica, rname) = create_scratch_db(&admin, "cnt_r").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
// Writer: 2 rows. Replica: 1 row.
for (i, content) in ["a", "b"].iter().enumerate() {
let ev = signed_event_at(&author, content, base + i as u64);
insert_top_level(&writer, community, channel, &ev).await;
}
let ev = signed_event_at(&author, "c", base);
insert_top_level(&replica, community, channel, &ev).await;
let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
// Covered-eligible shape on purpose: pinned + until. A count must
// ignore that eligibility.
let q = {
let mut q = EventQuery::for_community(cid);
q.channel_id = Some(channel);
q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0);
q
};
// Budget unset ⇒ bounded arm disabled ⇒ writer.
let n = db
.count_events_routed("test_count", &q)
.await
.expect("count, gate off");
assert_eq!(n, 2, "budget unset must count on the writer");
// Budget set + fresh entry ⇒ bounded arm ⇒ replica.
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
let n = db
.count_events_routed("test_count", &q)
.await
.expect("count, gate on");
assert_eq!(n, 1, "budget set must count on the replica (bounded)");
// Entry older than the budget ⇒ bounded fails ⇒ writer. Covered
// would still hold here (upper <= wall) — proving count never
// consults it.
db.fence().close();
db.fence().force_open_for_tests_at(
chrono::Utc::now(),
std::time::Instant::now() - std::time::Duration::from_secs(10),
);
let n = db
.count_events_routed("test_count", &q)
.await
.expect("count, entry too old");
assert_eq!(
n, 2,
"an over-budget entry must fail the count closed to the writer, \
even when the covered arm would admit the shape"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Routed relay-membership check: budget unset ⇒ writer; budget set +
/// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒
/// writer. Divergent membership rows prove which pool answered.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn is_relay_member_is_bounded_routed_and_fails_closed() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "mem_w").await;
let (replica, rname) = create_scratch_db(&admin, "mem_r").await;
let community = Uuid::new_v4();
for pool in [&writer, &replica] {
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community)
.bind(format!("member-routing-{}.example", community.simple()))
.execute(pool)
.await
.expect("insert community");
}
let cid = CommunityId::from_uuid(community);
let writer_only = "aa".repeat(32);
let replica_only = "bb".repeat(32);
relay_members::add_relay_member(&writer, cid, &writer_only, "member", None)
.await
.expect("seed writer member");
relay_members::add_relay_member(&replica, cid, &replica_only, "member", None)
.await
.expect("seed replica member");
let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
// Budget unset ⇒ bounded arm disabled ⇒ writer.
assert!(
db.is_relay_member(cid, &writer_only)
.await
.expect("gate off"),
"budget unset must answer from the writer"
);
assert!(!db.is_relay_member(cid, &replica_only).await.unwrap());
// Budget set + fresh entry ⇒ replica.
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
assert!(
db.is_relay_member(cid, &replica_only)
.await
.expect("gate on"),
"budget set must answer from the replica"
);
assert!(!db.is_relay_member(cid, &writer_only).await.unwrap());
// Entry older than the budget ⇒ fail closed to the writer. Close
// first so no prior fresh entry can be the one proved (matches the
// count test; today `force_open_for_tests_at` also clears the ring).
db.fence().close();
db.fence().force_open_for_tests_at(
chrono::Utc::now(),
std::time::Instant::now() - std::time::Duration::from_secs(10),
);
assert!(
db.is_relay_member(cid, &writer_only)
.await
.expect("entry too old"),
"an over-budget entry must fail closed to the writer"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Community separation across every routed seam, verified on
/// REPLICA-SERVED reads.
///
/// The pre-existing feed/event scoping tests prove the shared SQL
/// builders confine rows to one community, but they exercise those
/// builders through the WRITER wrapper. `_on` variants are
/// executor-only refactors, so scoping *should* be identical — this
/// test refuses to take that on faith and re-proves it through the
/// routed executor, on a snapshot the replica actually served.
///
/// Construction: two communities A and B exist in BOTH databases with
/// the same ids. The replica additionally holds a `replica-only` row in
/// each — divergent fixtures, so any row bearing that content proves
/// the replica (not the writer) served the read. Every assertion
/// requests A and demands B's rows never appear, including B's
/// `replica-only` row, which is the one a leaky predicate would surface.
/// The routed fallback must cost ONE reader acquire budget, even when the
/// Aurora capability cache is cold.
///
/// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the
/// capability probe used to `acquire()` from the pool itself and return
/// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a
/// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against
/// a ~150ms documented bound. Boot priming
/// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping
/// SUCCEEDED — and a reader that is unavailable at boot is exactly the
/// case the bound is specified for, so the two failures are correlated.
///
/// The fixture reproduces that state deliberately: a size-1 reader whose
/// sole connection is established and then HELD (so every further acquire
/// must time out), with `reader_aurora_identity` asserted cold. It routes
/// through `count_events_routed` rather than calling `proved_reader`
/// directly, because `buzz_db_route_decision` is emitted by `route_read`
/// — a direct call would prove the timing but never emit the label.
///
/// Timing uses an upper bound of 2x the budget minus a margin: it must
/// fail for two stacked budgets (~300ms) while tolerating scheduler
/// jitter on one (~150ms). Asserting a lower bound too would pin the
/// budget's own value, which `reader_acquire_timeout_is_the_documented_budget`
/// already covers.
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Postgres"]
async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed, wname) = create_scratch_db(&admin, "one_budget").await;
seed.close().await;
let base = admin_url().await;
let scratch_url = {
let idx = base.rfind('/').expect("db url has a path segment");
format!("{}/{}", &base[..idx], wname)
};
// `Db::new` so the writer arms the floor guard and the reader is the
// real lazy `connect_read_pool` pool (min_connections=0, 150ms
// acquire timeout). Reader is sized 1 so holding one connection
// saturates it.
let mut db = Db::new(&DbConfig {
database_url: scratch_url.clone(),
read_database_url: Some(scratch_url),
max_connections: 4,
read_max_connections: Some(1),
..DbConfig::default()
})
.await
.expect("connect armed Db with size-1 lazy reader");
db.fence().force_open_for_tests(chrono::Utc::now());
db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5)));
let read_pool = db.read_pool.clone().expect("reader pool configured");
// Establish and hold the reader's only connection: saturated.
let held = read_pool
.acquire()
.await
.expect("establish the reader's sole connection");
assert_eq!(
db.read_max_connections, 1,
"reader max must report 1 for this fixture to test saturation"
);
assert_eq!(
read_pool.size(),
1,
"the sole reader connection is established and held"
);
// The bug is only observable with the capability cache cold; if a
// future change primes it here, this fixture would silently stop
// discriminating.
assert!(
db.reader_aurora_identity.get().is_none(),
"Aurora capability must be UNPRIMED (post-boot-ping-failure state)"
);
let recorder = metrics_util::debugging::DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4()));
// The recorder is installed thread-locally, so it must stay installed
// across the `.await` — hence the guard form rather than
// `with_local_recorder`, whose closure cannot host an await. The
// `current_thread` flavor keeps the route decision on this thread; on
// a multi-thread runtime the emit could land on a worker where no
// local recorder is installed and the label assertions would vacuously
// see an empty snapshot.
let start = std::time::Instant::now();
let count = {
let _guard = metrics::set_default_local_recorder(&recorder);
db.count_events_routed("one_budget_probe", &query).await
}
.expect("writer fallback still answers the read");
let elapsed = start.elapsed();
assert_eq!(count, 0, "writer answered on an empty scratch database");
assert!(
elapsed < Duration::from_millis(250),
"routed fallback must spend ONE {}ms acquire budget, not two; took {}ms",
Db::READER_ACQUIRE_TIMEOUT.as_millis(),
elapsed.as_millis()
);
let reasons: std::collections::HashMap<(String, String), u64> = snapshotter
.snapshot()
.into_vec()
.into_iter()
.filter(|(key, ..)| key.key().name() == "buzz_db_route_decision")
.map(|(key, _, _, value)| {
let metrics_util::debugging::DebugValue::Counter(n) = value else {
panic!("buzz_db_route_decision must be a counter");
};
let labels: Vec<_> = key.key().labels().collect();
let get = |name: &str| {
labels
.iter()
.find(|l| l.key() == name)
.map(|l| l.value().to_owned())
.unwrap_or_default()
};
((get("decision"), get("reason")), n)
})
.collect();
assert_eq!(
reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())),
Some(&1),
"saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}"
);
// `reader_validation_error` would mean we misclassified a timeout as a
// broken reader, and `pool_busy` is the retired name — neither may
// appear in ANY emitted label.
assert!(
!reasons
.keys()
.any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"),
"no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}"
);
drop(held);
drop_scratch_db(&admin, db.pool.clone(), &wname).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn routed_reads_are_confined_to_the_requested_community() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "sep_w").await;
let (replica, rname) = create_scratch_db(&admin, "sep_r").await;
let author = nostr::Keys::generate();
let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4());
let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4());
for pool in [&writer, &replica] {
seed_community_channel(pool, comm_a, chan_a, &author).await;
seed_community_channel(pool, comm_b, chan_b, &author).await;
}
// A p-tag mention is what makes a row eligible for the mentions and
// needs-action feeds. Kind 9 satisfies mentions + activity;
// needs-action admits only approval/reminder kinds, so each
// community also gets a kind-46010 row.
let mentioned = nostr::Keys::generate();
let mentioned_hex = mentioned.public_key().to_hex();
let mentioned_bytes = mentioned.public_key().to_bytes();
let tagged_kind = |kind: u16, content: &str, secs: u64| {
nostr::EventBuilder::new(nostr::Kind::Custom(kind), content)
.tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")])
.custom_created_at(nostr::Timestamp::from(secs))
.sign_with_keys(&author)
.expect("sign event")
};
let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs);
let base = 1_700_000_000u64;
// Shared rows (both DBs) + replica-only rows (divergence) per community.
let a_shared = tagged("a-shared", base);
let b_shared = tagged("b-shared", base + 1);
for pool in [&writer, &replica] {
insert_top_level(pool, comm_a, chan_a, &a_shared).await;
insert_mentions(
pool,
CommunityId::from_uuid(comm_a),
&a_shared,
Some(chan_a),
)
.await
.expect("mentions a-shared");
insert_top_level(pool, comm_b, chan_b, &b_shared).await;
insert_mentions(
pool,
CommunityId::from_uuid(comm_b),
&b_shared,
Some(chan_b),
)
.await
.expect("mentions b-shared");
}
let a_replica_only = tagged("a-replica-only", base + 10);
let b_replica_only = tagged("b-replica-only", base + 11);
insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await;
insert_mentions(
&replica,
CommunityId::from_uuid(comm_a),
&a_replica_only,
Some(chan_a),
)
.await
.expect("mentions a-replica-only");
insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await;
insert_mentions(
&replica,
CommunityId::from_uuid(comm_b),
&b_replica_only,
Some(chan_b),
)
.await
.expect("mentions b-replica-only");
// Needs-action fixtures: approval kind, replica-only in BOTH
// communities, so the assertion below is replica-served on A and
// must still not see B's.
let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20);
let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21);
insert_top_level(&replica, comm_a, chan_a, &a_approval).await;
insert_mentions(
&replica,
CommunityId::from_uuid(comm_a),
&a_approval,
Some(chan_a),
)
.await
.expect("mentions a-approval");
insert_top_level(&replica, comm_b, chan_b, &b_approval).await;
insert_mentions(
&replica,
CommunityId::from_uuid(comm_b),
&b_approval,
Some(chan_b),
)
.await
.expect("mentions b-approval");
let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
let cid_a = CommunityId::from_uuid(comm_a);
let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet<String> {
evs.iter().map(|e| e.event.content.clone()).collect()
};
// Every routed seam must (a) have been served by the replica —
// proven by a divergent row absent from the writer — and (b) contain
// no row belonging to community B. All B fixtures are named `b-*`,
// so the leak check is a single prefix scan.
let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| {
let got = contents(rows);
assert!(
got.contains(marker),
"{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}"
);
assert!(
!got.iter().any(|c| c.starts_with("b-")),
"{seam}: community B rows leaked into a community A read; got {got:?}"
);
};
// 1. Generic query — covered arm (channel-pinned + `until`).
let mut q = EventQuery::for_community(cid_a);
q.channel_id = Some(chan_a);
q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0);
let rows = db
.query_events_routed("sep_query", &q)
.await
.expect("routed query");
assert_a_only(&rows, "a-replica-only", "query_events_routed");
// 2. Generic query — bounded arm (no channel pin at all, so a
// missing community predicate could not be masked by the pin).
let unpinned = EventQuery::for_community(cid_a);
let rows = db
.query_events_routed_bounded("sep_query_bounded", &unpinned)
.await
.expect("routed bounded query");
assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded");
// 3. COUNT — bounded-only. Community A holds 3 rows on the replica
// (shared + replica-only + approval) but only 1 on the writer,
// and 3 more exist in community B. Exactly 3 proves the read was
// both replica-served and community-confined.
let count = db
.count_events_routed("sep_count", &unpinned)
.await
.expect("routed count");
assert_eq!(
count, 3,
"count must see A's three replica rows only — not B's, not the writer's one"
);
// 4. By-ID hydration — ids carry no channel pin, and B's ids are
// requested alongside A's. Only A's may hydrate.
let ids: Vec<&[u8]> = vec![
a_shared.id.as_bytes(),
a_replica_only.id.as_bytes(),
b_shared.id.as_bytes(),
b_replica_only.id.as_bytes(),
];
let rows = db
.get_events_by_ids_routed("sep_by_ids", cid_a, &ids)
.await
.expect("routed by-ids");
assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed");
// 5-7. All three feed builders, each given BOTH channels as
// accessible — so only the community predicate can exclude B.
let both = [chan_a, chan_b];
let rows = db
.query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50)
.await
.expect("routed mentions");
assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed");
let rows = db
.query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50)
.await
.expect("routed needs action");
assert_a_only(
&rows,
"a-approval-replica-only",
"query_feed_needs_action_routed",
);
let rows = db
.query_feed_activity_routed("sep_feed", cid_a, &both, None, 50)
.await
.expect("routed activity");
assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed");
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet
/// used) must still let [`Db::spawn_fence_probe`] verify the writer's
/// floor guard and spawn — reader-down or reader-idle at boot must not
/// disable fence probing.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn lazy_reader_pool_still_spawns_fence_probe() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed, wname) = create_scratch_db(&admin, "lazy_w").await;
seed.close().await;
let writer_url = {
let base = admin_url().await;
let idx = base.rfind('/').expect("db url has a path segment");
format!("{}/{}", &base[..idx], wname)
};
// `Db::new` (not `from_pools`) so the WRITER pool arms the
// `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the
// floor guard on a writer connection, and `create_scratch_db`'s
// plain `PgPool::connect` never arms it. The reader is still the
// lazy `connect_read_pool` pool this test is about.
let db = Db::new(&DbConfig {
database_url: writer_url.clone(),
read_database_url: Some(writer_url),
max_connections: 2,
..DbConfig::default()
})
.await
.expect("connect armed Db with lazy reader");
let spawned = db
.spawn_fence_probe()
.await
.expect("floor-guard verification must pass on the migrated writer");
assert!(spawned, "a configured (lazy) reader must spawn the probe");
drop_scratch_db(&admin, db.pool.clone(), &wname).await;
}
/// Thread replies: head fetch reads the writer; a FULL cursor page is
/// served by the replica; an UNDER-limit cursor page (candidate terminal
/// page) is re-run on the writer so a lagged replica can never truncate
/// the tail into a false EOF.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_replies_cursor_pages_route_to_replica_with_writer_terminal_verification() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "routing_tw").await;
let (replica, rname) = create_scratch_db(&admin, "routing_tr").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let root = signed_event_at(&author, "root", base);
for pool in [&writer, &replica] {
insert_top_level(pool, community, channel, &root).await;
}
// Writer holds replies r1..r5; the lagged replica only has r1..r3.
let replies: Vec<nostr::Event> = (1..=5)
.map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64))
.collect();
for reply in &replies {
insert_thread_reply(&writer, community, channel, &root, reply).await;
}
for reply in &replies[..3] {
insert_thread_reply(&replica, community, channel, &root, reply).await;
}
let db = Db::from_pools(writer.clone(), replica.clone());
// Open the fence through "now" — fixture history is far in the past.
db.fence().force_open_for_tests(chrono::Utc::now());
let cid = CommunityId::from_uuid(community);
// Page 1 (no cursor) → writer.
let page1 = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None)
.await
.expect("page 1");
let contents: Vec<&str> = page1
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(contents, vec!["r1", "r2"], "head page from writer");
// Page 2: replica serves a FULL page (r3 exists there) — but wait:
// replica has r1..r3, page after r2 with limit 2 returns only [r3]
// (under limit) → terminal-verification re-runs on the writer, which
// returns [r3, r4]. A lag-truncated EOF must never surface.
let cur2 = thread_cursor(page1.last().expect("page 1 non-empty"));
let page2 = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, Some(&cur2))
.await
.expect("page 2");
let contents: Vec<&str> = page2
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(
contents,
vec!["r3", "r4"],
"under-limit replica page must be re-verified on the writer"
);
// Full-page replica serve: with limit 1, the page after r2 is [r3] —
// exactly `limit` rows, so the replica result stands. Prove it came
// from the replica with a replica-only divergent reply.
let ghost = signed_event_at(&author, "replica-only-ghost", base + 25);
insert_thread_reply(&replica, community, channel, &root, &ghost).await;
let page_replica = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2))
.await
.expect("full replica page");
let contents: Vec<&str> = page_replica
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(
contents,
vec!["replica-only-ghost"],
"a full cursor page must be served by the replica"
);
// Same query with no replica configured reads the writer and cannot
// see the ghost.
let db_writer_only = Db::from_pool(writer.clone());
let page_writer = db_writer_only
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2))
.await
.expect("writer-only page");
let contents: Vec<&str> = page_writer
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(contents, vec!["r3"], "unset replica falls back to writer");
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Channel DESC scrollback, out-of-order commit adversary: the replica is
/// missing a MIDDLE row (`m2`) because a transaction with an older
/// client-signed `created_at` committed late and has not replayed yet.
/// The replica's cursor page would be `[m1]` — silently skipping `m2`
/// forever, since the next cursor advances past it. The fence must route
/// any cursor above it to the writer.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn channel_cursor_above_fence_stays_on_writer_preventing_middle_hole() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "fence_cw").await;
let (replica, rname) = create_scratch_db(&admin, "fence_cr").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let m1 = signed_event_at(&author, "m1", base);
let m2 = signed_event_at(&author, "m2-late-commit", base + 10);
let m3 = signed_event_at(&author, "m3", base + 20);
let m4 = signed_event_at(&author, "m4", base + 30);
for ev in [&m1, &m2, &m3, &m4] {
insert_top_level(&writer, community, channel, ev).await;
}
// Replica replayed everything EXCEPT the late-committed m2.
for ev in [&m1, &m3, &m4] {
insert_top_level(&replica, community, channel, ev).await;
}
let db = Db::from_pools(writer.clone(), replica.clone());
let cid = CommunityId::from_uuid(community);
// Head page (writer): [m4, m3]; cursor lands on m3 (base+20).
let head = db
.get_channel_window(cid, channel, 2, None, None)
.await
.expect("head window");
let cursor = head.next_cursor.expect("has_more implies next_cursor");
// Fence closed → cursor page must come from the writer: m2 present.
let contents = |w: &thread::ChannelWindow| -> Vec<String> {
w.rows
.iter()
.map(|r| r.stored_event.event.content.clone())
.collect()
};
let page_closed = db
.get_channel_window(cid, channel, 10, Some(cursor.clone()), None)
.await
.expect("cursor page, fence closed");
assert_eq!(
contents(&page_closed),
vec!["m2-late-commit".to_string(), "m1".to_string()],
"fence closed: cursor pages route to the writer"
);
// Fence open but BELOW the cursor timestamp (covers base+5 only):
// the cursor (base+20) is not covered → writer again.
db.fence()
.force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 5, 0).expect("ts"));
let page_below = db
.get_channel_window(cid, channel, 10, Some(cursor.clone()), None)
.await
.expect("cursor page, fence below cursor");
assert_eq!(
contents(&page_below),
vec!["m2-late-commit".to_string(), "m1".to_string()],
"cursor above the fence must stay on the writer"
);
// Counterfactual pinning the hazard: were the fence (wrongly) open
// through now, the replica would serve the page WITHOUT m2 — the
// permanent-skip hole this fence exists to prevent.
db.fence().force_open_for_tests(chrono::Utc::now());
let page_hazard = db
.get_channel_window(cid, channel, 10, Some(cursor), None)
.await
.expect("cursor page, fence wrongly open");
assert_eq!(
contents(&page_hazard),
vec!["m1".to_string()],
"fixture models the inversion: an over-open fence would skip m2"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Thread ASC pagination, out-of-order commit adversary: the replica
/// holds a FULL page whose newest row (`r4`) has a later key than a
/// not-yet-replayed row (`r3`). The old under-limit check alone would
/// serve `[r4]` and the client cursor would advance past `r3` forever.
/// The fence rule (full AND tail ≤ fence) must send that page to the
/// writer instead.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_full_replica_page_above_fence_is_reverified_on_writer() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "fence_tw").await;
let (replica, rname) = create_scratch_db(&admin, "fence_tr").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&writer, community, channel, &author).await;
seed_community_channel(&replica, community, channel, &author).await;
let base = 1_700_000_000u64;
let root = signed_event_at(&author, "root", base);
for pool in [&writer, &replica] {
insert_top_level(pool, community, channel, &root).await;
}
let replies: Vec<nostr::Event> = (1..=4)
.map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64))
.collect();
for reply in &replies {
insert_thread_reply(&writer, community, channel, &root, reply).await;
}
// Replica replayed r1, r2, r4 — the late-committed r3 is missing.
for reply in [&replies[0], &replies[1], &replies[3]] {
insert_thread_reply(&replica, community, channel, &root, reply).await;
}
let db = Db::from_pools(writer.clone(), replica.clone());
let cid = CommunityId::from_uuid(community);
// Fence covers r2 (base+20) but not r3/r4.
db.fence()
.force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 20, 0).expect("ts"));
// Page after r2 with limit 1: the replica would return the FULL page
// [r4] — but its tail is above the fence, so the writer re-runs it
// and returns [r3]. No skip.
let page1 = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None)
.await
.expect("head page");
let cur = thread_cursor(page1.last().expect("head page non-empty"));
let page = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur))
.await
.expect("cursor page");
let contents: Vec<&str> = page
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(
contents,
vec!["r3"],
"a full replica page above the fence must be re-run on the writer"
);
// Counterfactual: an over-open fence would serve the replica's [r4],
// skipping r3 permanently.
db.fence().force_open_for_tests(chrono::Utc::now());
let hazard = db
.get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur))
.await
.expect("hazard page");
let contents: Vec<&str> = hazard
.iter()
.map(|r| r.stored_event.event.content.as_str())
.collect();
assert_eq!(
contents,
vec!["r4"],
"fixture models the inversion: an over-open fence would skip r3"
);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
/// Commit-time floor guard (migration 0021), exact held-transaction
/// adversary: a channel-bearing row whose `created_at` is older than the
/// floor at COMMIT time must abort the transaction — the guard runs
/// inside commit processing with `clock_timestamp()`, so holding the
/// transaction open cannot outrun it. channel_id-NULL rows are
/// structurally exempt, and sessions without the GUC are unaffected.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (pool, name) = create_scratch_db(&admin, "floor_guard").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&pool, community, channel, &author).await;
let insert_raw = |ev: nostr::Event, channel_id: Option<Uuid>| {
let pool = pool.clone();
async move {
let mut tx = pool.begin().await.expect("begin");
// Arm the guard for this transaction only (the relay's
// writer pool arms it per connection; tests are explicit).
sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)")
.bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string())
.execute(&mut *tx)
.await
.expect("arm guard");
sqlx::query(
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, \
content, sig, received_at, channel_id) \
VALUES ($1, $2, $3, to_timestamp($4), 9, '[]', $5, $6, NOW(), $7)",
)
.bind(community)
.bind(ev.id.as_bytes().as_slice())
.bind(ev.pubkey.to_bytes().as_slice())
.bind(ev.created_at.as_secs() as f64)
.bind(&ev.content)
.bind(ev.sig.serialize().as_slice())
.bind(channel_id)
.execute(&mut *tx)
.await
.expect("insert inside tx (guard is deferred to commit)");
// Hold the transaction "open" past the insert, then commit —
// the deferred guard must still see the stale created_at.
sqlx::query("SELECT pg_sleep(0.05)")
.execute(&mut *tx)
.await
.expect("hold tx");
tx.commit().await
}
};
let now_secs = chrono::Utc::now().timestamp() as u64;
let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64;
// Old channel-bearing row → COMMIT aborts with check_violation.
let old = signed_event_at(&author, "old-held-tx", now_secs - floor - 60);
let err = insert_raw(old, Some(channel))
.await
.expect_err("below-floor channel row must abort at COMMIT");
let code = match &err {
sqlx::Error::Database(db_err) => db_err.code().map(|c| c.to_string()),
other => panic!("expected database error, got {other:?}"),
};
assert_eq!(
code.as_deref(),
Some("23514"),
"guard raises check_violation"
);
// Fresh channel-bearing row → commits.
let fresh = signed_event_at(&author, "fresh", now_secs);
insert_raw(fresh, Some(channel))
.await
.expect("fresh row commits under the armed guard");
// Old row WITHOUT a channel (push lease / profile shapes) →
// structurally exempt, commits.
let old_global = signed_event_at(&author, "old-global", now_secs - floor - 60);
insert_raw(old_global, None)
.await
.expect("channel_id-NULL rows are exempt from the floor");
// Unarmed session (no GUC) → guard inert; backfills stay possible
// (and must hold the fence closed, per the migration header).
let old_backfill = signed_event_at(&author, "old-backfill", now_secs - floor - 60);
insert_top_level(&pool, community, channel, &old_backfill).await;
drop_scratch_db(&admin, pool, &name).await;
}
#[test]
fn writer_pool_safety_hook_is_single_and_composed() {
let source = include_str!("mod.rs");
let connect_pool = source
.split("async fn connect_writer_pool")
.nth(1)
.and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next())
.expect("connect_writer_pool source block");
assert_eq!(
connect_pool.matches(".after_connect(").count(),
1,
"SQLx replaces after_connect hooks; writer safety must use exactly one"
);
assert!(connect_pool.contains("buzz.created_at_floor"));
assert!(connect_pool.contains("SHOW transaction_isolation"));
assert!(connect_pool.contains("'lock_timeout'"));
assert!(connect_pool.contains("'idle_in_transaction_session_timeout'"));
assert!(connect_pool.contains("'statement_timeout'"));
assert!(!connect_pool.contains("arm_floor_guard"));
assert!(!connect_pool.contains("_arm_floor_guard"));
assert!(!connect_pool.contains("allow(unused_variables)"));
let reader_doc = source
.split("fn connect_read_pool")
.next()
.and_then(|prefix| prefix.rsplit("/// Connect the read-replica").next())
.expect("reader pool documentation");
assert!(reader_doc.contains("replica sessions are"));
assert!(reader_doc.contains("read-only"));
assert!(!reader_doc.contains("Db::connect_writer_pool"));
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn writer_pool_rejects_non_read_committed_database_default() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed_pool, name) = create_scratch_db(&admin, "writer_isolation").await;
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER DATABASE {name} SET default_transaction_isolation = 'repeatable read'"
)))
.execute(&admin)
.await
.expect("set unsafe database default");
seed_pool.close().await;
let base = admin_url().await;
let idx = base.rfind('/').expect("db url has a path segment");
let scratch_url = format!("{}/{}", &base[..idx], name);
let error = Db::new(&DbConfig {
database_url: scratch_url,
max_connections: 1,
min_connections: 1,
acquire_timeout_secs: 1,
..DbConfig::default()
})
.await
.expect_err("writer pool must reject pinned-snapshot database defaults");
assert!(
error.to_string().contains("requires READ COMMITTED")
|| error.to_string().contains("pool timed out"),
"unexpected isolation rejection: {error}"
);
sqlx::query(sqlx::AssertSqlSafe(format!(
"DROP DATABASE {name} WITH (FORCE)"
)))
.execute(&admin)
.await
.expect("drop isolation test database");
}
/// Session-timeout environment overrides retain PostgreSQL's `0 = disabled`
/// semantics and ignore invalid values.
#[test]
fn session_timeout_env_overlay_zero_passthrough_and_invalid_fallback() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let keys = [
"BUZZ_DB_LOCK_TIMEOUT_MS",
"BUZZ_DB_IDLE_TXN_TIMEOUT_MS",
"BUZZ_DB_STATEMENT_TIMEOUT_MS",
];
let previous: Vec<_> = keys.iter().map(std::env::var_os).collect();
let read = |config: DbConfig| {
(
config.lock_timeout_ms,
config.idle_txn_timeout_ms,
config.statement_timeout_ms,
)
};
for key in keys {
std::env::remove_var(key);
}
let unset = read(DbConfig::default().with_session_timeouts_from_env());
std::env::set_var("BUZZ_DB_LOCK_TIMEOUT_MS", "2000");
std::env::set_var("BUZZ_DB_IDLE_TXN_TIMEOUT_MS", "30000");
std::env::set_var("BUZZ_DB_STATEMENT_TIMEOUT_MS", "10000");
let overridden = read(DbConfig::default().with_session_timeouts_from_env());
for key in keys {
std::env::set_var(key, "0");
}
let zero = read(DbConfig::default().with_session_timeouts_from_env());
for key in keys {
std::env::set_var(key, "not-a-number");
}
let junk = read(DbConfig::default().with_session_timeouts_from_env());
for (key, value) in keys.iter().zip(previous) {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
let defaults = (DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_IDLE_TXN_TIMEOUT_MS, 0);
assert_eq!(unset, defaults, "unset env must keep the defaults");
assert_eq!(overridden, (2000, 30000, 10000));
assert_eq!(zero, (0, 0, 0), "explicit 0 must disable each timeout");
assert_eq!(junk, defaults, "junk env must keep the defaults");
}
/// The production writer constructor installs all three timeout GUCs, bounds
/// ordinary lock waits, and exempts the intentional migration lock wait.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn session_timeouts_install_through_db_new_and_bound_lock_waits() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed_pool, name) = create_scratch_db(&admin, "session_timeouts").await;
seed_pool.close().await;
let base = admin_url().await;
let idx = base.rfind('/').expect("db url has a path segment");
let scratch_url = format!("{}/{}", &base[..idx], name);
let db = Db::new(&DbConfig {
database_url: scratch_url.clone(),
max_connections: 2,
lock_timeout_ms: 500,
idle_txn_timeout_ms: 60_000,
statement_timeout_ms: 0,
..DbConfig::default()
})
.await
.expect("connect Db with session timeouts");
let (lock, idle, statement): (String, String, String) = sqlx::query_as(
"SELECT current_setting('lock_timeout'), \
current_setting('idle_in_transaction_session_timeout'), \
current_setting('statement_timeout')",
)
.fetch_one(&db.pool)
.await
.expect("read effective GUCs");
assert_eq!(lock, "500ms");
assert_eq!(idle, "1min");
assert_eq!(statement, "0");
let mut holder = db.pool.acquire().await.expect("holder connection");
sqlx::raw_sql("BEGIN; LOCK TABLE events IN ACCESS EXCLUSIVE MODE")
.execute(&mut *holder)
.await
.expect("hold relation lock");
let waited = std::time::Instant::now();
let mut waiter_txn = db.pool.begin().await.expect("waiter transaction");
let error = sqlx::query("LOCK TABLE events IN ACCESS SHARE MODE")
.execute(&mut *waiter_txn)
.await
.expect_err("waiter must time out, not park");
drop(waiter_txn);
let code = match &error {
sqlx::Error::Database(db_error) => db_error.code().map(|code| code.to_string()),
other => panic!("expected database error, got {other:?}"),
};
assert_eq!(code.as_deref(), Some("55P03"));
assert!(waited.elapsed() < std::time::Duration::from_secs(5));
let mut advisory_holder = PgPool::connect(&scratch_url)
.await
.expect("advisory holder pool")
.acquire()
.await
.expect("advisory holder conn")
.detach();
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut advisory_holder)
.await
.expect("hold schema advisory lock");
let release = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(1_500)).await;
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(crate::deletion::SCHEMA_DESTRUCTION_LOCK_KEY)
.execute(&mut advisory_holder)
.await;
let _ = advisory_holder.close().await;
});
db.migrate()
.await
.expect("migrate must wait out the advisory holder");
release.await.expect("release task");
let _ = sqlx::query("ROLLBACK").execute(&mut *holder).await;
drop(holder);
drop_scratch_db(&admin, db.pool.clone(), &name).await;
}
/// The armed writer pool (`Db::new`) must enforce the floor end-to-end
/// through the public insert APIs, and the session GUC must be verifiably
/// set on pooled connections.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn armed_pool_rejects_old_channel_inserts_through_public_api() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed_pool, name) = create_scratch_db(&admin, "floor_pool").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&seed_pool, community, channel, &author).await;
// Connect a Db the production way: after_connect arms the guard.
let base = admin_url().await;
let idx = base.rfind('/').expect("db url has a path segment");
let scratch_url = format!("{}/{}", &base[..idx], name);
let db = Db::new(&DbConfig {
database_url: scratch_url,
max_connections: 2,
..DbConfig::default()
})
.await
.expect("connect armed Db");
let cid = CommunityId::from_uuid(community);
// Perci nit: assert the effective session value, not the intent.
let effective: String = sqlx::query_scalar("SHOW buzz.created_at_floor")
.fetch_one(&db.pool)
.await
.expect("SHOW guard GUC");
assert_eq!(
effective,
crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string(),
"writer pool must arm the floor guard on every connection"
);
let isolation: String = sqlx::query_scalar("SHOW transaction_isolation")
.fetch_one(&db.pool)
.await
.expect("SHOW writer isolation");
assert_eq!(
isolation, "read committed",
"the same writer after_connect hook must enforce the isolation premise"
);
let now_secs = chrono::Utc::now().timestamp() as u64;
let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64;
// insert_event (single INSERT, autocommit): old channel row rejected.
let old = signed_event_at(&author, "old-direct", now_secs - floor - 60);
let err = event::insert_event(&db.pool, cid, &old, Some(channel))
.await
.expect_err("armed pool must reject below-floor channel inserts");
assert!(
err.to_string().contains("below the replica-fence floor"),
"unexpected error: {err}"
);
// insert_event_with_thread_metadata (multi-statement tx): same.
let old2 = signed_event_at(&author, "old-thread-meta", now_secs - floor - 90);
let ts =
chrono::DateTime::from_timestamp(old2.created_at.as_secs() as i64, 0).expect("valid ts");
let err = event::insert_event_with_thread_metadata(
&db.pool,
cid,
&old2,
Some(channel),
Some(event::ThreadMetadataParams {
event_id: old2.id.as_bytes(),
event_created_at: ts,
channel_id: channel,
parent_event_id: None,
parent_event_created_at: None,
root_event_id: None,
root_event_created_at: None,
depth: 0,
broadcast: true,
}),
)
.await
.expect_err("armed pool must reject below-floor thread-metadata inserts");
assert!(
err.to_string().contains("below the replica-fence floor"),
"unexpected error: {err}"
);
// Fresh events pass through both APIs.
let fresh = signed_event_at(&author, "fresh-direct", now_secs);
event::insert_event(&db.pool, cid, &fresh, Some(channel))
.await
.expect("fresh insert passes the armed guard");
drop_scratch_db(&admin, seed_pool, &name).await;
// db pool still holds connections to the dropped DB; close it.
db.pool.close().await;
}
/// `spawn_fence_probe` must verify the floor guard before letting the
/// probe run — catalog shape AND observed behavior — and refuse on
/// sabotage. This is the production gate for a relay running with
/// `BUZZ_AUTO_MIGRATE` off: an armed GUC with no enforcing trigger must
/// never yield an open fence.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn fence_probe_refuses_to_start_without_verified_floor_guard() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (seed_pool, wname) = create_scratch_db(&admin, "fence_gate_w").await;
let (replica_pool, rname) = create_scratch_db(&admin, "fence_gate_r").await;
seed_pool.close().await;
replica_pool.close().await;
let base = admin_url().await;
let idx = base.rfind('/').expect("db url has a path segment");
let writer_url = format!("{}/{}", &base[..idx], wname);
let replica_url = format!("{}/{}", &base[..idx], rname);
// Healthy schema: verification passes, probe starts. A SEPARATE Db
// instance, because its background probe legitimately opens its own
// fence (the heartbeat probe is writer-side only) — the refusal
// assertions below must run against a fence whose spawns were all
// refused.
let db_healthy = Db::new(&DbConfig {
database_url: writer_url.clone(),
read_database_url: Some(replica_url.clone()),
max_connections: 2,
..DbConfig::default()
})
.await
.expect("connect armed Db with replica");
assert!(
db_healthy
.spawn_fence_probe()
.await
.expect("verification passes"),
"probe must start on a verified schema"
);
let db = Db::new(&DbConfig {
database_url: writer_url,
read_database_url: Some(replica_url),
max_connections: 2,
..DbConfig::default()
})
.await
.expect("connect armed Db with replica");
// Sabotage A: catalog-shaped no-op — same trigger, gutted function
// body. Catalog check alone would pass; behavior check must refuse.
sqlx::query(
"CREATE OR REPLACE FUNCTION events_created_at_floor_guard() RETURNS trigger \
LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END $$",
)
.execute(&db.pool)
.await
.expect("gut the guard function");
let err = db
.spawn_fence_probe()
.await
.expect_err("inert guard body must refuse the probe");
assert!(
err.to_string().contains("floor guard is inert"),
"unexpected error: {err}"
);
// Sabotage B: trigger dropped entirely (the BUZZ_AUTO_MIGRATE=off /
// 0021-unapplied shape). Catalog check must refuse.
sqlx::query("DROP TRIGGER events_created_at_floor ON events")
.execute(&db.pool)
.await
.expect("drop the guard trigger");
let err = db
.spawn_fence_probe()
.await
.expect_err("missing trigger must refuse the probe");
assert!(
err.to_string().contains("missing or mis-shaped"),
"unexpected error: {err}"
);
// In both refusal states the fence never opened.
assert!(
db.fence().verified_through().is_none(),
"fence must remain closed when verification refuses the probe"
);
db_healthy.pool.close().await;
if let Some(rp) = &db_healthy.read_pool {
rp.close().await;
}
db.pool.close().await;
if let Some(rp) = &db.read_pool {
rp.close().await;
}
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
"DROP DATABASE IF EXISTS {wname} WITH (FORCE)"
)))
.execute(&admin)
.await;
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
"DROP DATABASE IF EXISTS {rname} WITH (FORCE)"
)))
.execute(&admin)
.await;
}
/// The `UPDATE OF` arm of the floor guard (Perci's second structural
/// hole): an old row legitimately admitted with `channel_id` NULL must
/// not be movable into keyset windows, and a channel row's `created_at`
/// must not be movable below the fence — through raw SQL, at COMMIT.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (pool, name) = create_scratch_db(&admin, "floor_upd").await;
let author = nostr::Keys::generate();
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&pool, community, channel, &author).await;
let now_secs = chrono::Utc::now().timestamp() as u64;
let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64;
// Seed via unarmed session: one old channel-NULL row, one fresh
// channel row.
let old_null = signed_event_at(&author, "old-null", now_secs - floor - 120);
insert_top_level(&pool, community, channel, &old_null).await;
sqlx::query("UPDATE events SET channel_id = NULL WHERE community_id = $1 AND id = $2")
.bind(community)
.bind(old_null.id.as_bytes().as_slice())
.execute(&pool)
.await
.expect("detach channel (unarmed seed)");
let fresh = signed_event_at(&author, "fresh-row", now_secs);
insert_top_level(&pool, community, channel, &fresh).await;
// Armed transaction, deferred to COMMIT (the production shape).
let run_armed_update = |sql: &'static str, id: Vec<u8>, age: Option<u64>| {
let pool = pool.clone();
async move {
let mut tx = pool.begin().await.expect("begin");
sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)")
.bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string())
.execute(&mut *tx)
.await
.expect("arm guard");
let q = sqlx::query(sql).bind(community).bind(id);
let q = match age {
Some(a) => q.bind(a as f64),
None => q,
};
q.execute(&mut *tx)
.await
.expect("update inside tx (deferred)");
tx.commit().await
}
};
// channel-NULL → channel-bearing on an old row: COMMIT must abort.
let err = run_armed_update(
"UPDATE events SET channel_id = community_id WHERE community_id = $1 AND id = $2",
old_null.id.as_bytes().to_vec(),
None,
)
.await
.expect_err("moving an old channel-NULL row into a channel must abort at COMMIT");
assert!(
matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")),
"unexpected error: {err}"
);
// created_at rewrite below the floor on a channel row: COMMIT must abort.
let err = run_armed_update(
"UPDATE events SET created_at = clock_timestamp() - make_interval(secs => $3::double precision) \
WHERE community_id = $1 AND id = $2",
fresh.id.as_bytes().to_vec(),
Some(floor + 120),
)
.await
.expect_err("rewriting created_at below the floor must abort at COMMIT");
assert!(
matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")),
"unexpected error: {err}"
);
drop_scratch_db(&admin, pool, &name).await;
}