mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## 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>
318 lines
12 KiB
Rust
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");
|
|
}
|