Files
buzz/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs
Kalvin C 5a349db91c feat(relay): add opt-in newest-first thread windows (#7823)
## Summary

Add an opt-in, newest-first thread window to the authenticated HTTP
query path.

A filter with `thread_window: true` returns:

- reply rows ordered by `(created_at DESC, id ASC)` with an exact
composite cursor;
- optional bounded reactions, edits, deletions, and delete-of-aux
closure;
- one relay-signed `kind:39007` bounds event bound to the normalized
host, authenticated reader, channel, root, request cursor, kinds, depth,
limit, and aux choice.

Filters without the flag keep the existing oldest-first thread behavior.
Channel windows and `kind:39006` are unchanged. No client opts into this
mode in this PR.

### Why extend NIP-CW

NIP-CW already defines relay-computed, cursor-paged conversation views
over extended NIP-01 filters. Thread windows use the same protocol
ideas: ordinary signed rows, composite keyset pagination, optional aux
closure, and relay-signed bounds. Adding a thread mode keeps those
related wire contracts together instead of creating a second NIP with
duplicated terminology and compatibility rules.

Extending a draft, optional NIP with an additive mode is normal protocol
evolution. Existing fields and meanings are not reinterpreted:

- `top_level: true` remains channel mode;
- `kind:39006` remains channel-window bounds;
- `thread_window: true` selects the new mode;
- `kind:39007` has a distinct identity and root/request binding;
- extension-unaware clients never send the selector;
- legacy thread queries remain unchanged.

NIP-CW documents only the wire contract and relay semantics. Database
plans, replica internals, and rollout procedures remain implementation
concerns.

### Why thread windows use independent modules

The implementation deliberately keeps `thread_window.rs` separate in
`buzz-core`, `buzz-db`, and `buzz-relay` rather than adding branches
throughout the existing channel-window path.

The modes share pagination concepts, but their invariants differ:

- channel mode selects top-level rows; thread mode requires one root and
depths `1..=depth_limit`;
- thread mode accepts only conversation row kinds and rejects
unknown/conflicting fields;
- thread bounds bind the root and authenticated reader because
cross-channel aux visibility can differ by reader;
- thread aux includes the root and requires complete two-hop closure;
- thread mode refreshes writer authorization before selection and before
signing;
- historical cursor pages may use a proved replica transaction, but
fallback must restart both aux hops on the writer;
- legacy thread traversal is oldest-first and must not inherit
descending cursor semantics.

Forcing these rules into channel-window code would create a mode flag
across parsing, SQL predicates, bounds construction, authorization,
replica fallback, and tests. That abstraction would not remove
complexity. It would distribute it. Separate modules keep each contract
local and leave the established channel and legacy paths untouched.

### Technical design

- Strict request parsing normalizes UUIDs, event IDs, kinds, defaults,
and cursors before computing the response binding.
- SQL applies community, root, channel, depth, kind, deletion, and
cursor predicates before `limit + 1`.
- Ordered metadata scanning plus exact event-key lookup avoids the
stale-statistics plan that previously exceeded the four-second SQL
budget on a 10k-reply thread.
- Aux work is bounded by target batches, query count, raw candidate
count, response bytes, SQL timeouts, and one shared HTTP deadline.
- Fresh authorization-set comparison catches both grants and revocations
during closure. The relay returns retryable `503` rather than signing an
incomplete page.
- Replica failure permanently degrades the request to the writer and
restarts closure from its original targets without resetting budgets.
- Migration `0049_thread_window_index.sql` adds the mixed-direction
index used by newest-first scans. It validates a prebuilt index and
bounds startup lock/build time.

### Tradeoffs

- The stale-statistics-safe plan is intentionally less clever. Measured
warm 100k deep-cursor execution increased from roughly 0.30 ms to 4.80
ms, plus about 8 ms planning, in exchange for avoiding a reproduced
multi-second timeout after rapid 10k-thread insertion.
- Bounds are authoritative only for the served page. There is no
cross-page snapshot guarantee, and replica insertion coverage does not
imply latest edit/deletion/aux visibility.
- Strict parsing rejects unsupported constraints instead of silently
serving a different row set.
- Required aux closure fails closed. The relay does not omit aux and
sign plausible-but-incomplete bounds.
- This PR ships relay capability only. Client rendering, retry UX,
live-history overlap, and compatibility fallback remain separate work.

### Related issue

No existing issue found. This work follows the thread-loading design and
review discussion.

### Testing

- `cargo test -p buzz-core`: 258 unit tests and 2 doctests passed.
- `cargo check --workspace --all-targets`: passed at the rebased
implementation head.
- `cargo test -p buzz-db`: 131 non-PostgreSQL tests passed; 275
infrastructure tests were ignored as declared.
- Focused PostgreSQL migration/runtime regressions: 3/3 passed against
an isolated PostgreSQL container, including both corrected
migration-ledger tests.
- `cargo fmt --all --check`: passed.
- Pre-rebase feature validation passed 398/398 PostgreSQL tests,
signed-wire multi-tenant/auth/pagination/aux/revocation probes, clean
Compose boot/reboot, and full core tests.
- Independent HA validation exercised three relay pods and a physical
PostgreSQL standby on the same runtime logic before the final
documentation-only cleanup.
- Independent final-delta review confirmed exactly two changed files,
byte-identical executable migration SQL after comments/diagnostic
normalization, matching prebuild/catalog/recovery instructions, and `git
diff --check`; no runtime receipts were relabeled to the new SHA.
- The pre-push hook ran repository checks but its Rust unit lane exited
100 without exposing an individual failed test in the captured
multiplexed output. The independently run affected-package checks above
passed; current-head repository CI remains required.

Known unrelated baseline behavior remains: the mesh-demo echo test can
return 504, and same-second archive → unarchive can deduplicate its
membership notification. Neither path is changed here.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-09-23 12:01:45 -07:00

318 lines
12 KiB
Rust

use super::*;
use crate::thread_window::{AuxQuery, ScanBudget};
use buzz_core::thread_window::Request;
use nostr::{EventBuilder, Kind, Tag, Timestamp};
fn request(channel: Uuid, root: &nostr::Event, upper: u64) -> Request {
Request::parse(&serde_json::json!({"thread_window":true,"#h":[channel],
"#e":[root.id.to_hex()],"kinds":[9],"limit":50,
"until":upper,"before_id":"00".repeat(32)}))
.unwrap()
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn thread_window_upper_fence_terminal_snapshot_and_fallback() {
let admin = PgPool::connect(&admin_url().await).await.unwrap();
let (writer, wname) = create_scratch_db(&admin, "tw_writer").await;
let (replica, rname) = create_scratch_db(&admin, "tw_replica").await;
let keys = nostr::Keys::generate();
let cid = Uuid::new_v4();
let channel = Uuid::new_v4();
let base = 1_700_000_000;
let root = signed_event_at(&keys, "root", base);
let old = signed_event_at(&keys, "old", base + 10);
let middle = signed_event_at(&keys, "missing-middle", base + 20);
for pool in [&writer, &replica] {
seed_community_channel(pool, cid, channel, &keys).await;
insert_top_level(pool, cid, channel, &root).await;
insert_thread_reply(pool, cid, channel, &root, &old).await;
}
insert_thread_reply(&writer, cid, channel, &root, &middle).await;
let db = Db::from_pools(writer.clone(), replica.clone());
let community = CommunityId::from_uuid(cid);
let mut req = request(channel, &root, base + 30);
// An old delivered tail does not justify an uncovered request upper bound.
db.fence()
.force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 15, 0).unwrap());
let (page, session) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert!(!session.is_replica());
assert_eq!(
page.rows.iter().map(|e| e.event.id).collect::<Vec<_>>(),
[middle.id, old.id]
);
assert!(!page.has_more);
drop(session);
// Counterfactual: over-claiming coverage demonstrably loses the middle row.
db.fence().force_open_for_tests(chrono::Utc::now());
let (page, session) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert!(session.is_replica());
assert_eq!(page.rows.len(), 1);
assert_eq!(page.rows[0].event.id, old.id);
assert!(!page.has_more && page.next_cursor.is_none());
drop(session);
// Default head route remains writer despite an open fence.
req.cursor = None;
let (head, session) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert!(!session.is_replica());
assert_eq!(head.rows[0].event.id, middle.id);
drop(session);
// Complete the reply fixture, then lag *recent* edits and channel-less
// deletions. Coverage of old replies is not a freshness proof for aux.
insert_thread_reply(&replica, cid, channel, &root, &middle).await;
req = request(channel, &root, base + 30);
let (_, mut session) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert!(session.is_replica());
let edit = EventBuilder::new(Kind::Custom(40003), "new edit")
.tags([Tag::parse(["e", &old.id.to_hex()]).unwrap()])
.custom_created_at(Timestamp::from(base + 1000))
.sign_with_keys(&keys)
.unwrap();
let deletion = EventBuilder::new(Kind::Custom(5), "")
.tags([Tag::parse(["e", &edit.id.to_hex()]).unwrap()])
.custom_created_at(Timestamp::from(base + 1001))
.sign_with_keys(&keys)
.unwrap();
for pool in [&writer, &replica] {
event::insert_event(pool, community, &edit, Some(channel))
.await
.unwrap();
event::insert_event(pool, community, &deletion, None)
.await
.unwrap();
}
let targets = [old.id.to_hex(), edit.id.to_hex()];
let query = AuxQuery {
community,
targets: &targets,
kinds: &[5, 40003],
accessible: &[channel],
cursor: None,
};
let mut budget = ScanBudget::default();
let stale = session
.thread_window_aux(&query, &mut budget)
.await
.unwrap();
assert!(
stale.events.is_empty(),
"held snapshot must not advance after page proof"
);
let (_, mut fresh) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert_eq!(
fresh
.thread_window_aux(&query, &mut budget)
.await
.unwrap()
.events
.len(),
2,
"control: recent aux is visible on a fresh snapshot, with no reply timestamp bound"
);
drop(fresh);
sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=$1 AND pid<>pg_backend_pid()")
.bind(&rname).execute(&admin).await.unwrap();
let degraded = session
.thread_window_aux(&query, &mut budget)
.await
.unwrap();
assert_eq!(degraded.events.len(), 2);
assert!(
!session.is_replica(),
"fallback must permanently release the failed snapshot"
);
drop(session);
// The shared router's reader acquisition failure must also reach the writer,
// not reinterpret an unavailable reader as an empty terminal window.
replica.close().await;
let (page, session) = db
.get_thread_window_with_session(community, &req, &mut ScanBudget::default())
.await
.unwrap();
assert!(!session.is_replica());
assert_eq!(page.rows.len(), 2);
assert!(!page.has_more);
drop(session);
drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_schema_thread_window_prebuild_validation_and_old_ledger() {
let admin = PgPool::connect(&admin_url().await).await.unwrap();
let (pool, name) = create_scratch_db_through(&admin, "tw_prebuild", Some(48)).await;
for setup in [
"CREATE INDEX idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at ASC,event_id ASC)",
// Simulate an invalid concurrent-build remnant only in this disposable superuser DB.
"CREATE INDEX idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at DESC,event_id ASC); \
UPDATE pg_index SET indisvalid=false WHERE indexrelid='idx_thread_metadata_window'::regclass",
] {
sqlx::raw_sql(setup).execute(&pool).await.unwrap();
let error = migration::run_migrations(&pool).await.unwrap_err();
assert!(error.to_string().contains("invalid or wrong definition"),
"must reject catalog shape, not merely time out: {error}");
let version: i64 = sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations")
.fetch_one(&pool).await.unwrap();
assert_eq!(version, 48);
sqlx::query("DROP INDEX CONCURRENTLY idx_thread_metadata_window").execute(&pool).await.unwrap();
}
sqlx::query("CREATE INDEX CONCURRENTLY idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at DESC,event_id ASC)")
.execute(&pool).await.unwrap();
let oid: i64 = sqlx::query_scalar("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint")
.fetch_one(&pool)
.await
.unwrap();
migration::run_migrations(&pool).await.unwrap();
assert_eq!(
oid,
sqlx::query_scalar::<_, i64>("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint")
.fetch_one(&pool)
.await
.unwrap()
);
// Model the previous binary's exact embedded ledger, not run_to(48),
// which would still know version 49 and cannot test VersionMissing.
let current = sqlx::migrate::Migrator::new(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations"),
)
.await
.unwrap();
let old = sqlx::migrate::Migrator::with_migrations(
current
.iter()
.filter(|m| m.version <= 48)
.cloned()
.collect(),
);
assert!(matches!(
old.run(&pool).await,
Err(sqlx::migrate::MigrateError::VersionMissing(49))
));
drop_scratch_db(&admin, pool, &name).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() {
let admin = PgPool::connect(&admin_url().await).await.unwrap();
let (pool, name) = create_scratch_db_through(&admin, "tw_prebuild_writer", Some(48)).await;
let community = Uuid::new_v4();
let channel = Uuid::new_v4();
seed_community_channel(&pool, community, channel, &nostr::Keys::generate()).await;
sqlx::query("CREATE INDEX CONCURRENTLY idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at DESC,event_id ASC)")
.execute(&pool).await.unwrap();
let oid: i64 = sqlx::query_scalar("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint")
.fetch_one(&pool)
.await
.unwrap();
// Model an ingestion transaction already holding the table's writer lock.
// It stays held through every control: releasing it would hide the convoy.
let mut writer = pool.begin().await.unwrap();
sqlx::query("LOCK TABLE thread_metadata IN ROW EXCLUSIVE MODE")
.execute(&mut *writer)
.await
.unwrap();
let insert =
"INSERT INTO thread_metadata (community_id, channel_id, event_created_at, event_id) \
VALUES ($1, $2, now(), $3)";
let mut witness = pool.acquire().await.unwrap();
sqlx::query("SET lock_timeout = '200ms'")
.execute(&mut *witness)
.await
.unwrap();
sqlx::query(insert)
.bind(community)
.bind(channel)
.bind(vec![1_u8; 32])
.execute(&mut *witness)
.await
.unwrap();
// Keep the exact migration transaction open after its SQL finishes so a
// second insert observes *all* locks it acquired. This is an explicit
// overlap barrier, not a race against a fast catalog-only migration.
let mut migration_tx = pool.begin().await.unwrap();
let result = sqlx::raw_sql(include_str!(
"../../../../../migrations/0049_thread_window_index.sql"
))
.execute(&mut *migration_tx)
.await;
let concurrent_insert = sqlx::query(insert)
.bind(community)
.bind(channel)
.bind(vec![2_u8; 32])
.execute(&mut *witness)
.await;
migration_tx.rollback().await.unwrap();
sqlx::query(insert)
.bind(community)
.bind(channel)
.bind(vec![3_u8; 32])
.execute(&mut *witness)
.await
.unwrap();
// Also bind the public migrator: the advisory schema/destruction lock,
// ledger write and post-migration catalog checks must work with ingestion.
let production_result = migration::run_migrations(&pool).await;
sqlx::query(insert)
.bind(community)
.bind(channel)
.bind(vec![4_u8; 32])
.execute(&mut *witness)
.await
.unwrap();
let version: i64 = sqlx::query_scalar("SELECT max(version) FROM _sqlx_migrations")
.fetch_one(&mut *witness)
.await
.unwrap();
let final_oid: i64 =
sqlx::query_scalar("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint")
.fetch_one(&mut *witness)
.await
.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT count(*) FROM thread_metadata WHERE community_id=$1")
.bind(community)
.fetch_one(&mut *witness)
.await
.unwrap();
drop(witness);
writer.rollback().await.unwrap();
drop_scratch_db(&admin, pool, &name).await;
assert!(
result.is_ok(),
"valid prebuild must bypass write-conflicting CREATE: {result:?}"
);
assert!(
concurrent_insert.is_ok(),
"migration must allow metadata inserts: {concurrent_insert:?}"
);
assert!(
production_result.is_ok(),
"production migrator must preserve ingestion progress: {production_result:?}"
);
assert_eq!(version, 49);
assert_eq!(final_oid, oid, "prebuild must not be replaced");
assert_eq!(count, 4, "all writer witnesses must persist");
}