From 554a2d8627390a9b6c62e8bf190a88a8119be7e9 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:09:31 -0700 Subject: [PATCH 01/12] feat(relay): add stable artifact snapshots Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/error.rs | 12 + crates/buzz-db/src/lib.rs | 10 +- .../src/store/artifact_postgres_tests.rs | 201 ++++++ crates/buzz-db/src/store/artifact_query.rs | 89 +-- crates/buzz-db/src/store/artifact_snapshot.rs | 461 +++++++++++++ crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-relay/src/api/artifact.rs | 9 + .../src/api/artifact_postgres_tests.rs | 3 + .../snapshot_postgres_tests.rs | 616 ++++++++++++++++++ .../buzz-relay/src/api/artifact_snapshot.rs | 363 +++++++++++ .../api/artifact_snapshot_postgres_tests.rs | 73 +++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/nip11.rs | 1 + docs/nips/NIP-AR.md | 131 ++++ 14 files changed, 1930 insertions(+), 42 deletions(-) create mode 100644 crates/buzz-db/src/store/artifact_snapshot.rs create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/artifact_snapshot.rs create mode 100644 crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e27b319bed4..a3cc3f899b2 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -69,6 +69,18 @@ pub enum DbError { #[error("deletion safety error: {0}")] DeletionSafety(String), + /// An artifact snapshot exceeds its resource limit. + #[error("artifact snapshot limit exceeded")] + ArtifactSnapshotLimit, + + /// Snapshot scope contains a channel with expiring content. + #[error("artifact snapshot scope requires permanent channels")] + ArtifactSnapshotUnsupportedScope, + + /// Captured access or artifact data is no longer valid. + #[error("artifact snapshot must reset")] + ArtifactSnapshotReset, + /// A complete read-state snapshot exceeds its bounded resource budget. #[error("read-state snapshot exceeds event or byte limit")] ReadStateSnapshotTooLarge, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f495983a28d..45b1778ffc2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -65,11 +65,11 @@ pub(crate) use runtime::{ sweep_admitted_communities, ReadSessionInner, RouteDecision, RoutePredicate, }; pub use store::{ - admin_moderation, allowlist, api_token, archived_identities, artifact, channel, - channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding, - operator_listener, partition, personal_read, product_feedback, push, reaction, read_state, - relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable, - storage_accounting, thread, thread_window, usage, user, workflow, + admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_snapshot, + channel, channel_members, community, deletion, dm, event, feed, git_repo, moderation, + nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction, + read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, + replaceable, storage_accounting, thread, thread_window, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index a6ea1d0e09e..1aba26cacf4 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -643,3 +643,204 @@ async fn head_outside_authorized_channel_matches_missing_head() { ArtifactOutcome::Accepted(_) )); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_keeps_captured_revisions_and_invalidates_removed_data() { + let f = Fixture::new().await; + let d = Uuid::new_v4(); + let first = f.revision(d, "create", f.a, None, &f.owner, vec![]); + f.accept(&first, None).await; + let query = artifact::parse_query(&serde_json::json!({"artifact":"current"})).unwrap(); + let reader = f.owner.public_key(); + let capture = || { + f.db.capture_artifact_manifest(f.community, reader.as_bytes(), &query) + }; + let manifest = capture().await.unwrap(); + let newer = f.revision(d, "update", f.a, Some(&first), &f.owner, vec![]); + f.accept(&newer, None).await; + let second = f.revision(Uuid::new_v4(), "create", f.a, None, &f.owner, vec![]); + f.accept(&second, None).await; + let old = + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[first.id.to_hex()], + 8_388_608, + ) + .await + .unwrap(); + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + reader.as_bytes(), + &manifest, + &[first.id.to_hex()], + 1 + ) + .await, + Err(crate::DbError::ArtifactSnapshotLimit) + )); + assert_eq!(old[0].event.id, first.id); + assert_eq!(manifest.entries.len(), 1); + + // A delete and restore cannot make an invalid capture valid again. + let deleted = f.revision(d, "delete", f.a, Some(&newer), &f.owner, vec![]); + f.accept(&deleted, None).await; + let restored = f.revision(d, "restore", f.a, Some(&deleted), &f.owner, vec![]); + f.accept(&restored, None).await; + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); + + let manifest = capture().await.unwrap(); + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(restored.id.as_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); + sqlx::query("UPDATE events SET deleted_at=NULL WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(restored.id.as_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); + + let manifest = capture().await.unwrap(); + sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND pubkey=$2") + .bind(f.community.as_uuid()) + .bind(f.owner.public_key().as_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + sqlx::query("UPDATE channel_members SET removed_at=NULL WHERE community_id=$1 AND pubkey=$2") + .bind(f.community.as_uuid()) + .bind(f.owner.public_key().as_bytes().as_slice()) + .execute(&f.db.pool) + .await + .unwrap(); + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); + + let manifest = capture().await.unwrap(); + let moved = f.revision(d, "move", f.b, Some(&restored), &f.owner, vec![]); + f.accept(&moved, Some(f.a)).await; + let back = f.revision(d, "move", f.a, Some(&moved), &f.owner, vec![]); + f.accept(&back, Some(f.b)).await; + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_fences_a_delete_transaction_in_progress_at_capture() { + let f = Fixture::new().await; + let id = Uuid::new_v4(); + let first = f.revision(id, "create", f.a, None, &f.owner, vec![]); + f.accept(&first, None).await; + let deleted = f.revision(id, "delete", f.a, Some(&first), &f.owner, vec![]); + let mut tx = + f.db.begin_event_write_transaction(f.community) + .await + .unwrap(); + sqlx::query( + "INSERT INTO artifact_revisions(community_id,event_id,artifact_id) VALUES($1,$2,$3)", + ) + .bind(f.community.as_uuid()) + .bind(deleted.id.as_bytes().as_slice()) + .bind(id) + .execute(tx.conn()) + .await + .unwrap(); + crate::event::insert_event_in_transaction(&mut tx, &deleted, Some(f.a)) + .await + .unwrap(); + sqlx::query("UPDATE artifact_heads SET event_id=$3,deleted=true WHERE community_id=$1 AND artifact_id=$2") + .bind(f.community.as_uuid()).bind(id).bind(deleted.id.as_bytes().as_slice()).execute(tx.conn()).await.unwrap(); + let query = artifact::parse_query(&serde_json::json!({"artifact":"current"})).unwrap(); + let manifest = + f.db.capture_artifact_manifest(f.community, f.owner.public_key().as_bytes(), &query) + .await + .unwrap(); + tx.commit().await.unwrap(); + let restored = f.revision(id, "restore", f.a, Some(&deleted), &f.owner, vec![]); + f.accept(&restored, None).await; + assert!(matches!( + f.db.read_artifact_manifest( + f.community, + f.owner.public_key().as_bytes(), + &manifest, + &[], + 0 + ) + .await, + Err(crate::DbError::ArtifactSnapshotReset) + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_bounds_current_candidates_before_reading_payloads() { + let f = Fixture::new().await; + sqlx::query("INSERT INTO artifact_heads(community_id,artifact_id,event_id,channel_id,artifact_type) \ + SELECT $1,gen_random_uuid(),digest(n::text,'sha256'),$2,'buzz.task' FROM generate_series(1,50001) n") + .bind(f.community.as_uuid()).bind(f.a).execute(&f.db.pool).await.unwrap(); + let query = + artifact::parse_query(&serde_json::json!({"artifact":"current","#type":["buzz.task"]})) + .unwrap(); + assert!(matches!( + f.db.capture_artifact_manifest(f.community, f.owner.public_key().as_bytes(), &query) + .await, + Err(crate::DbError::ArtifactSnapshotLimit) + )); +} diff --git a/crates/buzz-db/src/store/artifact_query.rs b/crates/buzz-db/src/store/artifact_query.rs index c0658a0b685..d3e24822afa 100644 --- a/crates/buzz-db/src/store/artifact_query.rs +++ b/crates/buzz-db/src/store/artifact_query.rs @@ -33,43 +33,7 @@ impl Db { } else { "SELECT e.id,e.pubkey,e.created_at,e.kind,e.tags,e.content,e.sig,e.received_at,e.channel_id FROM events e " }); - if query.view == ArtifactView::Current { - qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND NOT a.deleted "); - } - // A system channel is read only by a query whose `#h` names it - // (NIP-CL); malformed `#h` values name no channel. - let named_channels: Vec = query - .tags - .iter() - .filter(|(name, _)| name == "h") - .flat_map(|(_, values)| values.iter().filter_map(|value| value.parse().ok())) - .collect(); - // The explicit kind lets the community/kind index exclude chat before - // the join, even for an unfiltered current-state page. - qb.push("WHERE e.community_id=").push_bind(community.as_uuid()).push(" AND e.kind=45010 AND e.deleted_at IS NULL AND EXISTS (SELECT 1 FROM channels c WHERE c.community_id=e.community_id AND c.id=e.channel_id AND c.deleted_at IS NULL AND (") - .push(crate::store::channel::legacy_channel_type_sql("c.channel_type")) - .push(" OR c.id=ANY(") - .push_bind(named_channels) - .push(")) AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=") - .push_bind(reader.to_vec()).push(" AND m.removed_at IS NULL)))"); - for (name, values) in &query.tags { - // GIN-usable necessary prefilter; retain the positional recheck - // because JSON array containment alone also matches swapped values. - qb.push(" AND ("); - for (index, value) in values.iter().enumerate() { - if index != 0 { - qb.push(" OR "); - } - qb.push("e.tags @> ") - .push_bind(serde_json::json!([[name, value]])); - } - qb.push(")"); - qb.push(" AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) tag WHERE tag->>0=") - .push_bind(name) - .push(" AND tag->>1=ANY(") - .push_bind(values) - .push("))"); - } + predicates(&mut qb, community, reader, query); if count { let n: i64 = qb.build_query_scalar().fetch_one(&mut *tx).await?; tx.commit().await?; @@ -90,3 +54,54 @@ impl Db { Ok((events, 0)) } } + +/// Apply the same visibility and tag rules to pages and snapshot capture. +pub(super) fn predicates( + qb: &mut QueryBuilder, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, +) { + if query.view == ArtifactView::Current { + qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND NOT a.deleted "); + } + // A system channel is read only by a query whose `#h` names it + // (NIP-CL); malformed `#h` values name no channel. + let named_channels: Vec = query + .tags + .iter() + .filter(|(name, _)| name == "h") + .flat_map(|(_, values)| values.iter().filter_map(|value| value.parse().ok())) + .collect(); + // The explicit kind lets the community/kind index exclude chat before + // the join, even for an unfiltered current-state page. + qb.push("WHERE e.community_id=").push_bind(community.as_uuid()).push(" AND e.kind=45010 AND e.deleted_at IS NULL AND EXISTS (SELECT 1 FROM channels c WHERE c.community_id=e.community_id AND c.id=e.channel_id AND c.deleted_at IS NULL AND (") + .push(crate::store::channel::legacy_channel_type_sql("c.channel_type")) + .push(" OR c.id=ANY(") + .push_bind(named_channels) + .push(")) AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=") + .push_bind(reader.to_vec()).push(" AND m.removed_at IS NULL)))"); + tag_predicates(qb, query); +} + +/// Apply exact artifact tag predicates after access is resolved. +pub(super) fn tag_predicates(qb: &mut QueryBuilder, query: &ArtifactQuery) { + for (name, values) in &query.tags { + // GIN-usable necessary prefilter; retain the positional recheck + // because JSON array containment alone also matches swapped values. + qb.push(" AND ("); + for (index, value) in values.iter().enumerate() { + if index != 0 { + qb.push(" OR "); + } + qb.push("e.tags @> ") + .push_bind(serde_json::json!([[name, value]])); + } + qb.push(")"); + qb.push(" AND EXISTS (SELECT 1 FROM jsonb_array_elements(e.tags) tag WHERE tag->>0=") + .push_bind(name) + .push(" AND tag->>1=ANY(") + .push_bind(values) + .push("))"); + } +} diff --git a/crates/buzz-db/src/store/artifact_snapshot.rs b/crates/buzz-db/src/store/artifact_snapshot.rs new file mode 100644 index 00000000000..ad452b1cdee --- /dev/null +++ b/crates/buzz-db/src/store/artifact_snapshot.rs @@ -0,0 +1,461 @@ +//! Stable manifests hold revision identities. Each read uses current authority. +use crate::{Db, DbError, Result}; +use buzz_core::{ + artifact::{ArtifactQuery, ArtifactView}, + CommunityId, StoredEvent, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, QueryBuilder, Row}; +use uuid::Uuid; + +/// Maximum identities in a complete manifest. +pub const MAX_ROWS: usize = 50_000; +/// Maximum serialized internal manifest size. +pub const MAX_BYTES: usize = 16 * 1024 * 1024; + +/// One captured current revision. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Entry { + /// Stable artifact identity. + pub artifact_id: Uuid, + /// Exact signed event identity. + pub revision_id: String, + /// Home at capture. + pub channel_id: Uuid, +} + +/// Internal snapshot state. This is never sent to the reader. +#[derive(Clone, Deserialize, Serialize)] +pub struct Manifest { + /// Public manifest entries. + pub entries: Vec, + // Row versions detect redaction followed by recovery, and access revoke/regrant. + revisions: Vec, + policy: Vec<(Uuid, String, Option)>, + predicates: Vec<(String, Vec)>, + // PostgreSQL snapshot bounds include writes in progress during capture. + xmax: String, + active: Vec, +} + +async fn begin(db: &Db) -> Result> { + let connection = crate::observability::acquire_writer( + &db.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL statement_timeout='2s'") + .execute(&mut *tx) + .await?; + Ok(tx) +} + +async fn policy( + conn: &mut PgConnection, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, +) -> Result)>> { + // Resolve membership once. OR permission predicates over a join can select + // repeated scans for a community whose statistics are still sparse. + let members = sqlx::query("SELECT channel_id,xmin::text AS version FROM channel_members WHERE community_id=$1 AND pubkey=$2 AND removed_at IS NULL LIMIT 50001") + .bind(community.as_uuid()).bind(reader).fetch_all(&mut *conn).await?; + if members.len() > MAX_ROWS { + return Err(DbError::ArtifactSnapshotLimit); + } + let members = members + .into_iter() + .map(|r| { + Ok(( + r.try_get::("channel_id")?, + r.try_get::("version")?, + )) + }) + .collect::>>()?; + let mut qb = QueryBuilder::::new( + "SELECT c.id,c.xmin::text AS version,c.ttl_seconds FROM channels c WHERE c.community_id=", + ); + qb.push_bind(community.as_uuid()) + .push(" AND c.deleted_at IS NULL AND (") + .push(super::channel::legacy_channel_type_sql("c.channel_type")) + .push(" OR c.id=ANY(") + .push_bind( + query + .tags + .iter() + .filter(|(n, _)| n == "h") + .flat_map(|(_, v)| v.iter().filter_map(|s| s.parse::().ok())) + .collect::>(), + ) + .push(")) AND (c.visibility='open' OR c.id=ANY(") + .push_bind(members.keys().copied().collect::>()) + .push(")) ORDER BY c.id LIMIT ") + .push_bind((MAX_ROWS + 1) as i64); + let rows = qb.build().fetch_all(&mut *conn).await?; + if rows.len() > MAX_ROWS { + return Err(DbError::ArtifactSnapshotLimit); + } + let channels = rows + .iter() + .map(|r| r.get::("id")) + .collect::>(); + let homes = current_rows(conn, community, channels, query) + .await? + .into_iter() + .map(|(e, _)| e.channel_id) + .collect::>(); + let rows = rows + .into_iter() + .filter(|r| homes.contains(&r.get::("id"))) + .collect::>(); + if rows + .iter() + .any(|r| r.get::, _>("ttl_seconds").is_some()) + { + return Err(DbError::ArtifactSnapshotUnsupportedScope); + } + rows.into_iter() + .map(|r| { + Ok(( + r.try_get("id")?, + r.try_get("version")?, + members.get(&r.try_get::("id")?).cloned(), + )) + }) + .collect() +} + +async fn body_policy( + conn: &mut PgConnection, + community: CommunityId, + reader: &[u8], + channels: Vec, +) -> Result)>> { + let members = sqlx::query("SELECT channel_id,xmin::text AS version FROM channel_members WHERE community_id=$1 AND pubkey=$2 AND channel_id=ANY($3) AND removed_at IS NULL") + .bind(community.as_uuid()).bind(reader).bind(&channels).fetch_all(&mut *conn).await?; + let members = members + .into_iter() + .map(|r| { + Ok(( + r.try_get::("channel_id")?, + r.try_get::("version")?, + )) + }) + .collect::>>()?; + let rows = sqlx::query("SELECT id,xmin::text AS version,visibility::text,ttl_seconds FROM channels WHERE community_id=$1 AND id=ANY($2) AND deleted_at IS NULL ORDER BY id") + .bind(community.as_uuid()).bind(channels).fetch_all(&mut *conn).await?; + let mut result = vec![]; + for row in rows { + let id = row.try_get::("id")?; + if row.try_get::, _>("ttl_seconds")?.is_some() { + return Err(DbError::ArtifactSnapshotReset); + } + if row.try_get::("visibility")? == "open" || members.contains_key(&id) { + result.push((id, row.try_get("version")?, members.get(&id).cloned())); + } + } + Ok(result) +} + +async fn current_rows( + conn: &mut PgConnection, + community: CommunityId, + channels: Vec, + query: &ArtifactQuery, +) -> Result> { + let mut heads = QueryBuilder::::new( + "SELECT artifact_id,event_id FROM artifact_heads WHERE community_id=", + ); + heads + .push_bind(community.as_uuid()) + .push(" AND NOT deleted AND channel_id=ANY(") + .push_bind(channels) + .push(")"); + if let Some((_, types)) = query.tags.iter().find(|(name, _)| name == "type") { + heads + .push(" AND artifact_type=ANY(") + .push_bind(types) + .push(")"); + } + heads.push(" LIMIT ").push_bind((MAX_ROWS + 1) as i64); + let heads = heads.build().fetch_all(&mut *conn).await?; + if heads.len() > MAX_ROWS { + return Err(DbError::ArtifactSnapshotLimit); + } + let heads = heads + .into_iter() + .map(|r| { + Ok(( + r.try_get::, _>("event_id")?, + r.try_get::("artifact_id")?, + )) + }) + .collect::>>()?; + let mut events = QueryBuilder::::new( + "SELECT e.id,e.channel_id,e.xmin::text AS version FROM events e WHERE e.community_id=", + ); + events + .push_bind(community.as_uuid()) + .push(" AND e.kind=45010 AND e.deleted_at IS NULL AND e.id=ANY(") + .push_bind(heads.keys().cloned().collect::>()) + .push(")"); + super::artifact_query::tag_predicates(&mut events, query); + events.push(" LIMIT ").push_bind((MAX_ROWS + 1) as i64); + let rows = events.build().fetch_all(&mut *conn).await?; + if rows.len() > MAX_ROWS { + return Err(DbError::ArtifactSnapshotLimit); + } + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + let id: Vec = row.try_get("id")?; + let artifact_id = heads + .get(&id) + .copied() + .ok_or_else(|| DbError::InvalidData("snapshot head missing".into()))?; + result.push(( + Entry { + artifact_id, + revision_id: hex::encode(id), + channel_id: row.try_get("channel_id")?, + }, + row.try_get("version")?, + )); + } + result.sort_unstable_by_key(|(entry, _)| entry.artifact_id); + Ok(result) +} + +impl Db { + /// Capture all matching current identities in one bounded database snapshot. + pub async fn capture_artifact_manifest( + &self, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, + ) -> Result { + let mut tx = begin(self).await?; + let snapshot: String = sqlx::query_scalar("SELECT pg_current_snapshot()::text") + .fetch_one(&mut *tx) + .await?; + let parts: Vec<_> = snapshot.split(':').collect(); + let xmax = parts + .get(1) + .ok_or_else(|| DbError::InvalidData("invalid database snapshot".into()))? + .parse::() + .map_err(|_| DbError::InvalidData("invalid database snapshot".into()))?; + let active = parts + .get(2) + .ok_or_else(|| DbError::InvalidData("invalid database snapshot".into()))? + .split(',') + .filter(|v| !v.is_empty()) + .map(|v| v.parse::().map(|n| (n as u32).to_string())) + .collect::, _>>() + .map_err(|_| DbError::InvalidData("invalid database snapshot".into()))?; + let policy = policy(&mut tx, community, reader, query).await?; + let rows = current_rows( + &mut tx, + community, + policy.iter().map(|(id, _, _)| *id).collect(), + query, + ) + .await?; + let (entries, revisions) = rows.into_iter().unzip(); + let manifest = Manifest { + entries, + revisions, + policy, + predicates: query.tags.clone(), + xmax: (xmax as u32).to_string(), + active, + }; + if serde_json::to_vec(&manifest)?.len() > MAX_BYTES { + return Err(DbError::ArtifactSnapshotLimit); + } + tx.commit().await?; + Ok(manifest) + } + + /// Recheck the full manifest and fetch only requested captured revisions. + /// An empty request validates the completion boundary without reading bodies. + pub async fn read_artifact_manifest( + &self, + community: CommunityId, + reader: &[u8], + manifest: &Manifest, + requested: &[String], + body_bytes: usize, + ) -> Result> { + if manifest.entries.len() != manifest.revisions.len() || requested.len() > 100 { + return Err(DbError::InvalidData( + "invalid artifact snapshot read".into(), + )); + } + let selected; + let complete = requested.is_empty(); + let manifest = if complete { + manifest + } else { + let mut entries = vec![]; + let mut revisions = vec![]; + for id in requested { + let index = manifest + .entries + .iter() + .position(|entry| &entry.revision_id == id) + .ok_or_else(|| DbError::InvalidData("revision outside snapshot".into()))?; + entries.push(manifest.entries[index].clone()); + revisions.push(manifest.revisions[index].clone()); + } + let homes = entries + .iter() + .map(|e| e.channel_id) + .collect::>(); + selected = Manifest { + entries, + revisions, + policy: manifest + .policy + .iter() + .filter(|(id, _, _)| homes.contains(id)) + .cloned() + .collect(), + predicates: manifest.predicates.clone(), + xmax: manifest.xmax.clone(), + active: manifest.active.clone(), + }; + &selected + }; + let mut tx = begin(self).await?; + let current_policy = if complete { + let query = ArtifactQuery { + view: ArtifactView::Current, + tags: manifest.predicates.clone(), + limit: 1, + offset: 0, + }; + policy(&mut tx, community, reader, &query) + .await + .map_err(|e| match e { + DbError::ArtifactSnapshotUnsupportedScope => DbError::ArtifactSnapshotReset, + other => other, + })? + } else { + body_policy( + &mut tx, + community, + reader, + manifest.policy.iter().map(|(id, _, _)| *id).collect(), + ) + .await? + }; + if current_policy != manifest.policy { + return Err(DbError::ArtifactSnapshotReset); + } + let ids = manifest + .entries + .iter() + .map(|e| e.artifact_id) + .collect::>(); + let revisions = manifest + .entries + .iter() + .map(|e| { + hex::decode(&e.revision_id) + .map_err(|_| DbError::InvalidData("invalid captured revision".into())) + }) + .collect::>>()?; + // Fetch each bounded set once. A join over correlated arrays can choose + // repeated scans when a new community has no statistics yet. + let heads = sqlx::query("SELECT artifact_id,channel_id,deleted FROM artifact_heads WHERE community_id=$1 AND artifact_id=ANY($2)") + .bind(community.as_uuid()).bind(&ids).fetch_all(&mut *tx).await?; + let heads = heads + .into_iter() + .map(|r| { + Ok(( + r.try_get::("artifact_id")?, + ( + r.try_get::("channel_id")?, + r.try_get::("deleted")?, + ), + )) + }) + .collect::>>()?; + let rows = sqlx::query("SELECT id,channel_id,xmin::text AS version FROM events WHERE community_id=$1 AND id=ANY($2) AND deleted_at IS NULL LIMIT 50001") + .bind(community.as_uuid()).bind(&revisions).fetch_all(&mut *tx).await?; + if rows.len() != manifest.entries.len() { + return Err(DbError::ArtifactSnapshotReset); + } + let visible = rows + .into_iter() + .map(|r| { + Ok(( + hex::encode(r.try_get::, _>("id")?), + ( + r.try_get::("channel_id")?, + r.try_get::("version")?, + ), + )) + }) + .collect::>>()?; + for (entry, version) in manifest.entries.iter().zip(&manifest.revisions) { + if heads.get(&entry.artifact_id) != Some(&(entry.channel_id, false)) + || visible.get(&entry.revision_id) != Some(&(entry.channel_id, version.clone())) + { + return Err(DbError::ArtifactSnapshotReset); + } + } + // age(xid) uses signed transaction distance, including XID wraparound. + // Active capture transactions must also be checked when they commit. + let changed: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM artifact_revisions r LEFT JOIN events e \ + ON e.community_id=r.community_id AND e.id=r.event_id \ + WHERE r.community_id=$1 AND r.artifact_id=ANY($2) \ + AND (age(r.xmin)<=age($3::text::xid) OR r.xmin::text=ANY($4)) \ + AND (e.id IS NULL OR e.deleted_at IS NOT NULL \ + OR e.tags @> '[[\"op\",\"delete\"]]'::jsonb OR e.tags @> '[[\"op\",\"move\"]]'::jsonb))", + ).bind(community.as_uuid()).bind(&ids).bind(&manifest.xmax).bind(&manifest.active) + .fetch_one(&mut *tx).await?; + if changed { + return Err(DbError::ArtifactSnapshotReset); + } + let requested = requested + .iter() + .map(|s| { + let index = manifest + .entries + .iter() + .position(|e| &e.revision_id == s) + .ok_or_else(|| DbError::InvalidData("revision outside snapshot".into()))?; + Ok(revisions[index].clone()) + }) + .collect::>>()?; + if requested.is_empty() { + tx.commit().await?; + return Ok(vec![]); + } + // Check serialized row sizes before transferring content from Postgres. + let bytes: i64 = sqlx::query_scalar( + "SELECT coalesce(sum(octet_length(to_json(content)::text)+octet_length(tags::text)+1024),0)::bigint \ + FROM events WHERE community_id=$1 AND id=ANY($2)", + ).bind(community.as_uuid()).bind(&requested).fetch_one(&mut *tx).await?; + if bytes > body_bytes as i64 { + return Err(DbError::ArtifactSnapshotLimit); + } + let rows = sqlx::query("SELECT id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id FROM events WHERE community_id=$1 AND id=ANY($2) ORDER BY id") + .bind(community.as_uuid()).bind(requested).fetch_all(&mut *tx).await?; + let mut events = vec![]; + for row in rows { + events.push( + crate::event::row_to_stored_event(row)? + .ok_or_else(|| DbError::InvalidData("invalid snapshot event".into()))?, + ); + } + tx.commit().await?; + Ok(events) + } +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 6850b625cf1..353ab9d76ee 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -77,6 +77,8 @@ pub mod workflow; pub mod artifact; mod artifact_query; +/// Stable artifact manifests and current access validation. +pub mod artifact_snapshot; #[cfg(test)] mod artifact_postgres_tests; diff --git a/crates/buzz-relay/src/api/artifact.rs b/crates/buzz-relay/src/api/artifact.rs index bfe636c9086..dd6300d55d8 100644 --- a/crates/buzz-relay/src/api/artifact.rs +++ b/crates/buzz-relay/src/api/artifact.rs @@ -47,6 +47,15 @@ async fn execute( "artifact queries require exactly one filter", )); } + if filters[0].get("artifact").and_then(Value::as_str) == Some("snapshot") { + if count { + return Err(api_error( + StatusCode::BAD_REQUEST, + "snapshots require /query", + )); + } + return super::artifact_snapshot::query(state, tenant, reader, &filters[0]).await; + } let query = buzz_core::artifact::parse_query(&filters[0]) .map_err(|e| api_error(StatusCode::BAD_REQUEST, e))?; // The query's statement_timeout is the enforced execution budget. diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index 82d57dd2f19..01baf591131 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -486,3 +486,6 @@ async fn generic_d_lookup_matches_before_limit() { expected.sort(); assert_eq!(ids, expected, "{body}"); } + +#[path = "artifact_postgres_tests/snapshot_postgres_tests.rs"] +mod snapshot_postgres_tests; diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs new file mode 100644 index 00000000000..12e3470bf6d --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs @@ -0,0 +1,616 @@ +//! Snapshot protocol journeys through the production HTTP router. +use super::*; +use axum::http::StatusCode; +use base64::Engine; +use deadpool_redis::redis; +use sha2::Digest; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_traverses_ten_thousand_tasks_with_archives_and_exact_bodies() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .try_init(); + let f = Fixture::new().await; + let started = std::time::Instant::now(); + let mut events = Vec::new(); + let homes = (0..10_050) + .map(|n| if n == 0 { f.home } else { Uuid::new_v4() }) + .collect::>(); + for chunk in homes[1..].chunks(500) { + sqlx::query( + "INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) \ + SELECT $1,id,id::text,'private','stream',$2 FROM unnest($3::uuid[]) id", + ) + .bind(f.community.as_uuid()) + .bind(f.owner.public_key().as_bytes().as_slice()) + .bind(chunk) + .execute(&f.pool) + .await + .unwrap(); + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) SELECT $1,id,$2,'owner' FROM unnest($3::uuid[]) id") + .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()).bind(chunk) + .execute(&f.pool).await.unwrap(); + } + for (n, home) in homes.iter().enumerate() { + let id = Uuid::new_v4(); + let mut event = f.revision_as(&f.owner, *home, id, "create", None); + let content = json!({"status": if n >= 10_000 { "archived" } else { "open" }, "notes":"x".repeat(4400)}).to_string(); + event = EventBuilder::new(Kind::Custom(45010), content) + .tags(event.tags.clone()) + .sign_with_keys(&f.owner) + .unwrap(); + events.push(event); + } + // Bulk setup avoids measuring one HTTP write per task in the read journey. + for chunk in events.chunks(500) { + let values = chunk.iter().map(|event| json!({"id":event.id.to_hex(),"tags":event.tags, + "content":event.content,"sig":event.sig.to_string(),"created_at":event.created_at.as_secs(),"channel_id":event.tags.iter().find(|t| t.as_slice()[0]=="h").unwrap().as_slice()[1]})).collect::>(); + sqlx::query("INSERT INTO events(community_id,id,pubkey,created_at,kind,tags,content,sig,channel_id) \ + SELECT $1,decode(x.id,'hex'),$2,to_timestamp(x.created_at),45010,x.tags,x.content,decode(x.sig,'hex'),x.channel_id \ + FROM jsonb_to_recordset($3) x(id text,tags jsonb,content text,sig text,created_at bigint,channel_id uuid)") + .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()) + .bind(json!(values)) + .execute(&f.pool).await.unwrap(); + } + sqlx::query("INSERT INTO artifact_heads(community_id,artifact_id,event_id,channel_id,artifact_type) \ + SELECT community_id,(tags->1->>1)::uuid,id,channel_id,'buzz.task' FROM events WHERE community_id=$1 AND kind=45010") + .bind(f.community.as_uuid()).execute(&f.pool).await.unwrap(); + sqlx::query("INSERT INTO artifact_revisions(community_id,event_id,artifact_id) SELECT community_id,event_id,artifact_id FROM artifact_heads WHERE community_id=$1") + .bind(f.community.as_uuid()).execute(&f.pool).await.unwrap(); + let (status, first) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.task"]}}]), + ) + .await; + assert!(status.is_success(), "{status}: {first}"); + let token = first["token"].as_str().unwrap(); + assert_eq!(first["entries"].as_array().unwrap().len(), 1000); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":token}]), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + let (status, result) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"snapshot","action":"complete","token":token}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + assert_eq!(result["code"], "snapshot_reset"); + + let mut conn = f.state.redis_pool.get().await.unwrap(); + let snapshot_key = format!( + "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", + f.community.as_uuid(), + f.owner.public_key().to_hex(), + token + ); + let internal: Vec = redis::cmd("HGET") + .arg("buzz:{artifact-snapshots-v1}:store") + .arg(format!("data:{snapshot_key}")) + .query_async(&mut *conn) + .await + .unwrap(); + let internal: Value = serde_json::from_slice(&internal).unwrap(); + let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":token,"cursor":internal["cursors"][2]}])).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + drop(conn); + let fresh = f.revision(Uuid::new_v4(), "create", None); + f.publish(&fresh).await; + let updated = f.revision( + events[0] + .tags + .iter() + .find(|t| t.as_slice()[0] == "d") + .unwrap() + .as_slice()[1] + .parse() + .unwrap(), + "update", + Some(&events[0]), + ); + f.publish(&updated).await; + let mut entries = first["entries"].as_array().unwrap().clone(); + let mut next = first["nextToken"].clone(); + while let Some(cursor) = next.as_str() { + let request = + json!([{"artifact":"snapshot","action":"page","token":token,"cursor":cursor}]); + let (status, result) = f.request("/query", request.clone()).await; + assert!(status.is_success(), "{status}: {result}"); + let (status, retry) = f.request("/query", request).await; + assert!(status.is_success(), "{status}: {retry}"); + assert_eq!(result, retry); + assert!(serde_json::to_vec(&result).unwrap().len() <= 262_144); + entries.extend(result["entries"].as_array().unwrap().clone()); + next = result["nextToken"].clone(); + } + assert_eq!(entries.len(), 10_050); + let identities = entries + .iter() + .map(|e| e["artifactId"].as_str().unwrap()) + .collect::>(); + assert_eq!(identities.len(), entries.len()); + assert!(!entries.iter().any(|e| e["revisionId"] == fresh.id.to_hex())); + assert!(entries + .iter() + .any(|e| e["revisionId"] == events[0].id.to_hex())); + // A second relay state serves real signed HTTP requests with the same + // shared manifest. It holds no local copy of the capture. + let mut other = bridge_handler_test_state().await.unwrap(); + Arc::make_mut(&mut Arc::get_mut(&mut other).unwrap().config).require_auth_token = true; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/query", listener.local_addr().unwrap()); + let router = crate::router::build_router(other); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + let mut body_count = 0; + for chunk in entries.chunks(100) { + let ids = chunk + .iter() + .map(|e| e["revisionId"].clone()) + .collect::>(); + let (status, result) = signed_query( + &f, + &client, + &url, + json!([{"artifact":"snapshot","action":"bodies","token":token,"revisionIds":ids}]), + ) + .await; + assert!(status.is_success(), "{status}: {result}"); + assert_eq!(result["events"].as_array().unwrap().len(), chunk.len()); + for value in result["events"].as_array().unwrap() { + let event: Event = serde_json::from_value(value.clone()).unwrap(); + event.verify().unwrap(); + } + body_count += chunk.len(); + } + assert_eq!(body_count, 10_050); + let (status, complete) = signed_query( + &f, + &client, + &url, + json!([{"artifact":"snapshot","action":"complete","token":token}]), + ) + .await; + server.abort(); + assert!(status.is_success(), "{status}: {complete}"); + assert_eq!(complete, json!({"version":1,"token":token,"complete":true})); + assert!(started.elapsed().as_secs() < 300); + eprintln!( + "10,050 records, {} bytes per typical signed event, 101 body batches and completion: {:?}", + serde_json::to_vec(&events[0]).unwrap().len(), + started.elapsed() + ); + + let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"bodies","token":token,"revisionIds":[fresh.id.to_hex()]}])).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") + .bind(f.community.as_uuid()).bind(homes[1]).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":token,"cursor":first["nextToken"]}])).await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + assert!(result.get("entries").is_none()); + let mut conn = f.state.redis_pool.get().await.unwrap(); + let key = format!( + "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", + f.community.as_uuid(), + f.owner.public_key().to_hex(), + token + ); + let _: i64 = redis::cmd("HDEL") + .arg("buzz:{artifact-snapshots-v1}:store") + .arg(format!("data:{key}")) + .query_async(&mut *conn) + .await + .unwrap(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":token}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + assert_eq!(result["code"], "snapshot_reset"); + cleanup(&f).await; +} + +async fn signed_query( + f: &Fixture, + client: &reqwest::Client, + url: &str, + body: Value, +) -> (StatusCode, Value) { + let bytes = serde_json::to_vec(&body).unwrap(); + let auth = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{}/query", f.host)]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(sha2::Sha256::digest(&bytes))]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&f.owner) + .unwrap(); + let authorization = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&auth).unwrap()) + ); + let response = client + .post(url) + .header("host", &f.host) + .header("content-type", "application/json") + .header("authorization", authorization) + .body(bytes) + .send() + .await + .unwrap(); + let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); + let body = serde_json::from_slice(&response.bytes().await.unwrap()).unwrap(); + (status, body) +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_rechecks_access_and_exact_query_scope() { + for scenario in ["revoke", "regrant", "recreate", "redact", "predicate"] { + let f = Fixture::new().await; + let id = Uuid::new_v4(); + let event = f.revision_as(&f.owner, f.private, id, "create", None); + f.publish(&event).await; + let (status, capture) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#title":["Review task"]}}]), + ) + .await; + assert!(status.is_success(), "{scenario}: {capture}"); + let token = capture["token"].as_str().unwrap(); + match scenario { + "revoke" | "regrant" => { + sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") + .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + if scenario == "regrant" { + sqlx::query("UPDATE channel_members SET removed_at=NULL WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") + .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + } + } + "recreate" => { + sqlx::query("DELETE FROM channel_members WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") + .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')") + .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + } + "redact" => { + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .execute(&f.pool) + .await + .unwrap(); + } + "predicate" => { + let update = f.revision_as(&f.owner, f.private, id, "update", Some(&event)); + let tags = update + .tags + .iter() + .map(|tag| { + if tag.as_slice()[0] == "title" { + Tag::parse(["title", "Changed task"]).unwrap() + } else { + tag.clone() + } + }) + .collect::>(); + let update = EventBuilder::new(Kind::Custom(45010), &update.content) + .tags(tags) + .sign_with_keys(&f.owner) + .unwrap(); + f.publish(&update).await; + } + _ => unreachable!(), + } + for action in ["bodies", "complete"] { + let mut filter = json!({"artifact":"snapshot","action":action,"token":token}); + if action == "bodies" { + filter["revisionIds"] = json!([event.id.to_hex()]); + } + let (status, result) = f.request("/query", json!([filter])).await; + if scenario == "predicate" && action == "bodies" { + assert!(status.is_success(), "{scenario}: {result}"); + assert_eq!(result["events"][0]["id"], event.id.to_hex()); + } else { + assert_eq!(status, StatusCode::CONFLICT, "{scenario}: {result}"); + assert_eq!(result["code"], "snapshot_reset"); + assert!(result.get("events").is_none()); + } + } + cleanup(&f).await; + } + let f = Fixture::new().await; + let public = f.revision(Uuid::new_v4(), "create", None); + let private = f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None); + f.publish(&public).await; + f.publish(&private).await; + let (_, capture) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.task"]}}]), + ) + .await; + assert_eq!(capture["entries"].as_array().unwrap().len(), 1); + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'member')") + .bind(f.community.as_uuid()).bind(f.private).bind(f.peer.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + let (status, result) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + let (_, owner_capture) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.task"]}}]), + ) + .await; + sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") + .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"bodies", + "token":owner_capture["token"],"revisionIds":[public.id.to_hex()]}]), + ) + .await; + assert!(status.is_success(), "{result}"); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":owner_capture["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + cleanup(&f).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_rejects_matching_ttl_and_accepts_permanent_channel_defaults() { + let f = Fixture::new().await; + let home = Uuid::new_v4(); + let create = EventBuilder::new(Kind::Custom(9007), "") + .tags([ + Tag::parse(["h", &home.to_string()]).unwrap(), + Tag::parse(["name", "Permanent task"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]) + .sign_with_keys(&f.owner) + .unwrap(); + f.publish(&create).await; + let ttl: Option = + sqlx::query_scalar("SELECT ttl_seconds FROM channels WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(home) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(ttl, None); + let event = f.revision_as(&f.owner, home, Uuid::new_v4(), "create", None); + f.publish(&event).await; + sqlx::query("UPDATE channels SET ttl_seconds=60 WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(f.private) + .execute(&f.pool) + .await + .unwrap(); + let (_, permanent) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.task"]}}]), + ) + .await; + assert_eq!(permanent["entries"].as_array().unwrap().len(), 1); + let (_, empty) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.absent"]}}]), + ) + .await; + assert_eq!(empty["entries"], json!([])); + let event = f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None); + f.publish(&event).await; + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current","#type":["buzz.task"]}}]), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}"); + assert_eq!(result["code"], "snapshot_unsupported_scope"); + assert!(result.get("token").is_none()); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":permanent["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + cleanup(&f).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_limits_lock_wait_and_keeps_the_capture_retryable() { + let f = Fixture::new().await; + f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; + let (_, capture) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"start", + "query":{"artifact":"current"}}]), + ) + .await; + let mut lock = f.pool.begin().await.unwrap(); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *lock) + .await + .unwrap(); + let started = std::time::Instant::now(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{result}"); + assert!(started.elapsed().as_secs() < 8); + lock.rollback().await.unwrap(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), + ) + .await; + assert!(status.is_success(), "{result}"); + cleanup(&f).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_tokens_expiry_and_allocation_are_bounded() { + let f = Fixture::new().await; + f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; + let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current"}}]); + let (_, first) = f.request("/query", start.clone()).await; + let (status, second) = f.request("/query", start.clone()).await; + assert!(status.is_success(), "{second}"); + let (status, result) = f.request("/query", start.clone()).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{result}"); + let other = Fixture::new().await; + let (status, result) = other + .request_as( + &f.owner, + "/query", + json!([{"artifact":"snapshot","action":"complete","token":first["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":first["token"],"cursor":"a".repeat(32),"query":{"artifact":"current"}}])).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + let token = first["token"].as_str().unwrap(); + let key = format!( + "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", + f.community.as_uuid(), + f.owner.public_key().to_hex(), + token + ); + let mut conn = f.state.redis_pool.get().await.unwrap(); + let data: Vec = redis::cmd("HGET") + .arg("buzz:{artifact-snapshots-v1}:store") + .arg(format!("data:{key}")) + .query_async(&mut *conn) + .await + .unwrap(); + let mut data: Value = serde_json::from_slice(&data).unwrap(); + data["expires_at"] = json!(0); + let _: i64 = redis::cmd("HSET") + .arg("buzz:{artifact-snapshots-v1}:store") + .arg(format!("data:{key}")) + .arg(serde_json::to_vec(&data).unwrap()) + .arg(format!("expiry:{key}")) + .arg(0) + .query_async(&mut *conn) + .await + .unwrap(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":token}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + let (status, result) = f.request("/query", start).await; + assert!(status.is_success(), "{result}"); + cleanup(&f).await; + f.state.redis_pool.close(); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":second["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{result}"); +} + +async fn cleanup(f: &Fixture) { + let mut conn = f.state.redis_pool.get().await.unwrap(); + let prefix = format!("buzz:{{artifact-snapshots-v1}}:{}:", f.community.as_uuid()); + let _: i64 = redis::Script::new(r#" + for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do + if string.sub(field,1,7) == 'expiry:' then + local id = string.sub(field,8) + if string.sub(id,1,string.len(ARGV[1])) == ARGV[1] then + local size = tonumber(redis.call('HGET',KEYS[1],'size:' .. id)) + redis.call('HINCRBY',KEYS[1],'total',-size) + redis.call('HDEL',KEYS[1],field,'size:' .. id,'owner:' .. id,'data:' .. id,'progress:' .. id) + end + end + end + return 1 + "#).key("buzz:{artifact-snapshots-v1}:store").arg(prefix).invoke_async(&mut *conn).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_new_matching_home_resets_then_a_fresh_capture_completes() { + let f = Fixture::new().await; + f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; + let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current","#type":["buzz.task"]}}]); + let (_, capture) = f.request("/query", start.clone()).await; + let home = Uuid::new_v4(); + let create = EventBuilder::new(Kind::Custom(9007), "") + .tags([ + Tag::parse(["h", &home.to_string()]).unwrap(), + Tag::parse(["name", "New task"]).unwrap(), + Tag::parse(["visibility", "private"]).unwrap(), + ]) + .sign_with_keys(&f.owner) + .unwrap(); + f.publish(&create).await; + f.publish(&f.revision_as(&f.owner, home, Uuid::new_v4(), "create", None)) + .await; + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{result}"); + let (status, fresh) = f.request("/query", start).await; + assert!(status.is_success(), "{fresh}"); + assert_eq!(fresh["entries"].as_array().unwrap().len(), 2); + let (status, result) = f + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":fresh["token"]}]), + ) + .await; + assert!(status.is_success(), "{result}"); + cleanup(&f).await; +} diff --git a/crates/buzz-relay/src/api/artifact_snapshot.rs b/crates/buzz-relay/src/api/artifact_snapshot.rs new file mode 100644 index 00000000000..8a3d9452c93 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_snapshot.rs @@ -0,0 +1,363 @@ +//! Short-lived shared artifact manifests for signed POST /query requests. +use crate::state::AppState; +use axum::{http::StatusCode, Json}; +use buzz_core::{artifact::ArtifactView, TenantContext}; +use buzz_db::artifact_snapshot::{Manifest, MAX_BYTES, MAX_ROWS}; +use deadpool_redis::redis; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; + +const LIFETIME: u64 = 300; +const PAGE_ROWS: usize = 1000; +const PAGE_BYTES: usize = 262_144; +const BODY_ROWS: usize = 100; +const BODY_BYTES: usize = 8_388_608; +const GLOBAL_BYTES: usize = 64 * 1024 * 1024; +const GLOBAL_COUNT: usize = 32; +const STORE_KEY: &str = "buzz:{artifact-snapshots-v1}:store"; +static WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); +type Response = Result, (StatusCode, Json)>; + +#[derive(Deserialize)] +#[serde(tag = "action", deny_unknown_fields)] +enum Request { + #[serde(rename = "start")] + Start { artifact: String, query: Value }, + #[serde(rename = "page")] + Page { + artifact: String, + token: String, + cursor: String, + }, + #[serde(rename = "bodies", rename_all = "camelCase")] + Bodies { + artifact: String, + token: String, + revision_ids: Vec, + }, + #[serde(rename = "complete")] + Complete { artifact: String, token: String }, +} +#[derive(Serialize, Deserialize)] +struct Snapshot { + manifest: Manifest, + // Retain the exact scope and generation with the captured identities. + query: Value, + generation: String, + expires_at: u64, + cursors: Vec, +} + +fn error(status: StatusCode, code: &str) -> (StatusCode, Json) { + (status, Json(json!({"error":code,"code":code}))) +} +fn reset() -> (StatusCode, Json) { + error(StatusCode::CONFLICT, "snapshot_reset") +} +fn invalid() -> (StatusCode, Json) { + error(StatusCode::BAD_REQUEST, "snapshot_invalid") +} +fn unavailable() -> (StatusCode, Json) { + error(StatusCode::SERVICE_UNAVAILABLE, "snapshot_unavailable") +} +fn limit() -> (StatusCode, Json) { + error(StatusCode::PAYLOAD_TOO_LARGE, "snapshot_limit") +} +fn db_error( + e: buzz_db::DbError, + tenant: &TenantContext, + reader: &nostr::PublicKey, +) -> (StatusCode, Json) { + match e { + buzz_db::DbError::ArtifactSnapshotReset => reset(), + buzz_db::DbError::ArtifactSnapshotLimit => limit(), + buzz_db::DbError::ArtifactSnapshotUnsupportedScope => error( + StatusCode::UNPROCESSABLE_ENTITY, + "snapshot_unsupported_scope", + ), + error => { + tracing::warn!(community = %tenant.community().as_uuid(), reader = %reader, %error, "artifact snapshot database operation failed"); + unavailable() + } + } +} + +/// Resource contract advertised through NIP-11. +pub(crate) fn capability() -> Value { + json!({"version":1,"maxRows":MAX_ROWS,"pageRows":PAGE_ROWS,"pageBytes":PAGE_BYTES, + "bodyRows":BODY_ROWS,"bodyBytes":BODY_BYTES,"lifetimeSeconds":LIFETIME,"permanentChannelsOnly":true}) +} + +fn key(tenant: &TenantContext, reader: &nostr::PublicKey, token: &str) -> String { + format!( + "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", + tenant.community().as_uuid(), + reader.to_hex(), + token + ) +} +fn valid_token(token: &str) -> bool { + token.len() == 32 + && token + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn page(snapshot: &Snapshot, token: &str, index: usize) -> Response { + let start = index * PAGE_ROWS; + let end = (start + PAGE_ROWS).min(snapshot.manifest.entries.len()); + let value = json!({"version":1,"token":token,"expiresAt":snapshot.expires_at, + "entries":&snapshot.manifest.entries[start..end],"nextToken":snapshot.cursors.get(index)}); + if serde_json::to_vec(&value).map_err(|_| unavailable())?.len() > PAGE_BYTES { + return Err(limit()); + } + Ok(Json(value)) +} + +/// Run one snapshot operation with bounded concurrency and elapsed work. +pub(super) async fn query( + state: &Arc, + tenant: &TenantContext, + reader: &nostr::PublicKey, + filter: &Value, +) -> Response { + let _permit = WORK.try_acquire().map_err(|_| unavailable())?; + tokio::time::timeout( + Duration::from_secs(8), + execute(state, tenant, reader, filter), + ) + .await + .map_err(|_| unavailable())? +} + +async fn execute( + state: &Arc, + tenant: &TenantContext, + reader: &nostr::PublicKey, + filter: &Value, +) -> Response { + let request: Request = serde_json::from_value(filter.clone()).map_err(|_| invalid())?; + let mut conn = state.redis_pool.get().await.map_err(|_| unavailable())?; + let (now, _): (u64, u64) = redis::cmd("TIME") + .query_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + if let Request::Start { artifact, query } = request { + if artifact != "snapshot" || query.get("limit").is_some() || query.get("offset").is_some() { + return Err(invalid()); + } + let parsed = buzz_core::artifact::parse_query(&query).map_err(|_| invalid())?; + if parsed.view != ArtifactView::Current { + return Err(invalid()); + } + let manifest = state + .db + .capture_artifact_manifest(tenant.community(), reader.as_bytes(), &parsed) + .await + .map_err(|e| db_error(e, tenant, reader))?; + let token = Uuid::new_v4().simple().to_string(); + let pages = manifest.entries.len().div_ceil(PAGE_ROWS).max(1); + let expires_at = now + LIFETIME; + let snapshot = Snapshot { + manifest, + query, + generation: token.clone(), + expires_at, + cursors: (1..pages) + .map(|_| Uuid::new_v4().simple().to_string()) + .collect(), + }; + let data = serde_json::to_vec(&snapshot).map_err(|_| unavailable())?; + if data.len() > MAX_BYTES { + return Err(limit()); + } + let response = page(&snapshot, &token, 0)?; + let snapshot_key = key(tenant, reader, &token); + let principal_key = key(tenant, reader, "active"); + let stored: i64 = redis::Script::new(REGISTER) + .key(STORE_KEY) + .arg(expires_at) + .arg(data.len()) + .arg(GLOBAL_BYTES) + .arg(GLOBAL_COUNT) + .arg(data) + .arg(&snapshot_key) + .arg(principal_key) + .invoke_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + if stored == 0 { + return Err(limit()); + } + return Ok(response); + } + let (artifact, token) = match &request { + Request::Page { + artifact, token, .. + } + | Request::Bodies { + artifact, token, .. + } + | Request::Complete { artifact, token } => (artifact, token), + Request::Start { .. } => return Err(invalid()), + }; + if artifact != "snapshot" || !valid_token(token) { + return Err(invalid()); + } + let snapshot_key = key(tenant, reader, token); + let data: Option> = redis::cmd("HGET") + .arg(STORE_KEY) + .arg(format!("data:{snapshot_key}")) + .query_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + let data = data.ok_or_else(reset)?; + if data.len() > MAX_BYTES { + return Err(unavailable()); + } + let snapshot: Snapshot = serde_json::from_slice(&data).map_err(|_| unavailable())?; + if snapshot.generation != *token || now >= snapshot.expires_at { + return Err(reset()); + } + let requested = match &request { + Request::Bodies { revision_ids, .. } => { + if revision_ids.is_empty() { + return Err(invalid()); + } + if revision_ids.len() > BODY_ROWS { + return Err(limit()); + } + let mut seen = std::collections::HashSet::new(); + for id in revision_ids { + if buzz_core::artifact::event_id(id).is_err() + || !seen.insert(id) + || !snapshot + .manifest + .entries + .iter() + .any(|e| &e.revision_id == id) + { + return Err(invalid()); + } + } + revision_ids.as_slice() + } + _ => &[], + }; + let events = state + .db + .read_artifact_manifest( + tenant.community(), + reader.as_bytes(), + &snapshot.manifest, + requested, + BODY_BYTES, + ) + .await + .map_err(|e| db_error(e, tenant, reader))?; + let pages = snapshot.cursors.len() + 1; + match &request { + Request::Page { cursor, .. } => { + let index = snapshot + .cursors + .iter() + .position(|c| c == cursor) + .ok_or_else(invalid)? + + 1; + let result = page(&snapshot, token, index)?; + let advanced: i64 = redis::Script::new(ADVANCE) + .key(STORE_KEY) + .arg(&snapshot_key) + .arg(index) + .arg(index + 1) + .invoke_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + if advanced == -1 { + return Err(reset()); + } + if advanced == 0 { + return Err(invalid()); + } + Ok(result) + } + Request::Bodies { .. } => { + if events.len() != requested.len() { + return Err(reset()); + } + let events = events.into_iter().map(|e| e.event).collect::>(); + let result = json!({"version":1,"token":token,"events":events}); + if serde_json::to_vec(&result) + .map_err(|_| unavailable())? + .len() + > BODY_BYTES + { + return Err(limit()); + } + Ok(Json(result)) + } + Request::Complete { .. } => { + let valid: i64 = redis::Script::new(COMPLETE) + .key(STORE_KEY) + .arg(&snapshot_key) + .arg(pages) + .invoke_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + match valid { + -1 => Err(reset()), + 1 => Ok(Json(json!({"version":1,"token":token,"complete":true}))), + _ => Err(invalid()), + } + } + Request::Start { .. } => Err(invalid()), + } +} + +// One hash keeps data and accounting in the same eviction unit. +const REGISTER: &str = r#" +local now = tonumber(redis.call('TIME')[1]) +local count, principal, latest = 0, 0, tonumber(ARGV[1]) +for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do + if string.sub(field, 1, 7) == 'expiry:' then + local id = string.sub(field, 8) + local expiry = tonumber(redis.call('HGET', KEYS[1], field)) + if expiry <= now then + local size = tonumber(redis.call('HGET', KEYS[1], 'size:' .. id)) + redis.call('HINCRBY', KEYS[1], 'total', -size) + redis.call('HDEL', KEYS[1], field, 'size:' .. id, 'owner:' .. id, 'data:' .. id, 'progress:' .. id) + else + count = count + 1 + latest = math.max(latest, expiry) + if redis.call('HGET', KEYS[1], 'owner:' .. id) == ARGV[7] then principal = principal + 1 end + end + end +end +if tonumber(ARGV[1]) <= now or count >= tonumber(ARGV[4]) or principal >= 2 + or tonumber(redis.call('HGET', KEYS[1], 'total') or '0') + tonumber(ARGV[2]) > tonumber(ARGV[3]) then return 0 end +redis.call('HSET', KEYS[1], 'data:' .. ARGV[6], ARGV[5], 'progress:' .. ARGV[6], 1, + 'expiry:' .. ARGV[6], ARGV[1], 'size:' .. ARGV[6], ARGV[2], 'owner:' .. ARGV[6], ARGV[7]) +redis.call('HINCRBY', KEYS[1], 'total', ARGV[2]) +redis.call('EXPIREAT', KEYS[1], latest) +return 1 +"#; +const ADVANCE: &str = r#" +local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiry:' .. ARGV[1])) +if not expiry or expiry <= tonumber(redis.call('TIME')[1]) then return -1 end +local progress = tonumber(redis.call('HGET', KEYS[1], 'progress:' .. ARGV[1])) +if tonumber(ARGV[2]) > progress then return 0 end +if tonumber(ARGV[3]) > progress then redis.call('HSET', KEYS[1], 'progress:' .. ARGV[1], ARGV[3]) end +return 1 +"#; +const COMPLETE: &str = r#" +local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiry:' .. ARGV[1])) +if not expiry or expiry <= tonumber(redis.call('TIME')[1]) then return -1 end +if tonumber(redis.call('HGET', KEYS[1], 'progress:' .. ARGV[1])) ~= tonumber(ARGV[2]) then return 0 end +return 1 +"#; + +#[cfg(test)] +#[path = "artifact_snapshot_postgres_tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs new file mode 100644 index 00000000000..e1e0070776b --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs @@ -0,0 +1,73 @@ +//! Atomic quotas and cleanup use the production Redis scripts. +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_shared_byte_and_count_quotas_survive_cleanup() { + let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + let pool = deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let mut conn = pool.get().await.unwrap(); + let store = format!("buzz:{{artifact-snapshots-v1}}:test:{}", Uuid::new_v4()); + let (now, _): (u64, u64) = redis::cmd("TIME").query_async(&mut *conn).await.unwrap(); + for (id, owner, data, expected) in [ + ("a", "one", "123456", 1), + ("b", "two", "12345", 0), + ("b", "two", "1234", 1), + ("c", "three", "1", 0), + ] { + let result: i64 = redis::Script::new(REGISTER) + .key(&store) + .arg(now + 300) + .arg(data.len()) + .arg(10) + .arg(2) + .arg(data) + .arg(id) + .arg(owner) + .invoke_async(&mut *conn) + .await + .unwrap(); + assert_eq!(result, expected); + } + let _: i64 = redis::cmd("HSET") + .arg(&store) + .arg("expiry:a") + .arg(0) + .query_async(&mut *conn) + .await + .unwrap(); + let result: i64 = redis::Script::new(REGISTER) + .key(&store) + .arg(now + 300) + .arg(6) + .arg(10) + .arg(2) + .arg("123456") + .arg("c") + .arg("three") + .invoke_async(&mut *conn) + .await + .unwrap(); + assert_eq!(result, 1); + let bytes: usize = redis::cmd("HGET") + .arg(&store) + .arg("total") + .query_async(&mut *conn) + .await + .unwrap(); + assert_eq!(bytes, 10); + let removed: Option = redis::cmd("HGET") + .arg(&store) + .arg("data:a") + .query_async(&mut *conn) + .await + .unwrap(); + assert_eq!(removed, None); + let _: i64 = redis::cmd("DEL") + .arg(store) + .query_async(&mut *conn) + .await + .unwrap(); +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 89ff79f7e4c..27bba6fee04 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -696,3 +696,4 @@ mod parse_query_tests { } mod artifact; +pub(crate) mod artifact_snapshot; diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d61566efbb7..5c5ba42776d 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -283,6 +283,7 @@ impl RelayInfo { "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", "modes": ["current", "history"], + "snapshot": crate::api::artifact_snapshot::capability(), "websocket_query_extensions": false, "max_tags": buzz_core::artifact::MAX_TAGS, "max_tag_name_bytes": buzz_core::artifact::MAX_TAG_NAME_BYTES, diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index 3f0a8874996..d0bbd52f489 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -106,3 +106,134 @@ Artifact revisions are state changes, not conversation messages. Receiving one M Clients validate content before interpreting it as a known type. Unknown types or malformed content render a safe fallback using the title, type, and home link. Editors MUST preserve unfamiliar fields and annotation tags or decline the edit. References may be unavailable or cyclic; clients must handle them safely without assuming relay validation. Artifact text is untrusted. A type name, relationship tag, or payload cannot authorize a privileged action. Moving, deleting, or revoking access cannot erase copies already downloaded. + +## Stable manifest transport + +The relay advertises `artifacts.snapshot.version = 1` in NIP-11. This +extension uses signed `POST /query` requests. Each request contains one +filter. It uses the same community and membership checks as other queries. +The existing `current` and `history` responses keep their array format. + +Start a capture with: + +```json +[{"artifact":"snapshot","action":"start","query":{"artifact":"current","#type":["buzz.task"]}}] +``` + +The query accepts current-state tag predicates. It does not accept `limit` +or `offset`. The relay captures all matching current identities in one +repeatable-read database transaction. Version 1 supports permanent channels only. A scope with a readable current +artifact in a TTL channel fails before the relay publishes a token. An +unrelated TTL chat does not block a task query. The relay never silently +omits matching artifacts in TTL channels. +Archive status remains client content. +The capture includes archived tasks that match the query. + +Start and page responses have this form: + +```json +{"version":1,"token":"opaque","expiresAt":1792600000,"entries":[{"artifactId":"uuid","revisionId":"event-id","channelId":"uuid"}],"nextToken":"opaque-cursor"} +``` + +`expiresAt` is an integer in epoch seconds. `token` remains constant for the +capture. A token is bound to its reader, community, query, and capture +generation. It grants no access by itself. The cursor names an immutable +page position within that capture. A page request has this form: + +```json +[{"artifact":"snapshot","action":"page","token":"opaque","cursor":"opaque-cursor"}] +``` + +Each response supplies the cursor for the next page. Repeating a cursor +returns the same entries. A `null` `nextToken` marks the final page. The +relay tracks the pages it serves. Completion fails if a page is skipped. +A lost page response can be retried with the same cursor. + +Fetch exact captured bodies with: + +```json +[{"artifact":"snapshot","action":"bodies","token":"opaque","revisionIds":["event-id"]}] +``` + +The response is `{"version":1,"token":"opaque","events":[...]}`. It contains +exactly the requested revision IDs. It never substitutes a newer revision. +The client verifies each signature and its artifact, channel, and revision +against the manifest. The client can reuse a verified body that it already +has. Body durability is a client obligation. The relay does not keep body +receipts. + +Validate the completion boundary with: + +```json +[{"artifact":"snapshot","action":"complete","token":"opaque"}] +``` + +A valid response is `{"version":1,"token":"opaque","complete":true}`. Every request rechecks database authority. Manifest pages and completion +validate the full frozen scope. A body read validates only the requested +captured channels and revisions. A change in another captured channel can +remain undetected until completion; it still prevents absence reconciliation. Access +changes, a missing or redacted captured revision, and deletion or movement +of a captured artifact require a reset. A delete followed by restore, or a +move followed by a move back, still requires a reset. Ordinary post-capture +updates leave the captured revision available. Channel row changes can +conservatively require a reset, including metadata and TTL changes. + +The client starts its active live subscriptions before capture. Loss of the +live socket prevents client completion. Server completion does not prove +that every captured home has a live subscription. Periodic authoritative +captures repair homes outside the active routes. It saves needed bodies before +it reconciles absent identities. Completion alone does not permit deletion +of local writes or live events received after capture. The client uses its +local write boundary to preserve those records. If the live interval is +lost, the client retains local records and starts a new capture. Live +removal hints and periodic captures repair missed access and removal events. +This extension has no durable global change log. + +### Limits and failures + +NIP-11 advertises these fields under `artifacts.snapshot`: + +| Field | Value | +| --- | ---: | +| `version` | 1 | +| `maxRows` | 50000 | +| `pageRows` | 1000 | +| `pageBytes` | 262144 | +| `bodyRows` | 100 | +| `bodyBytes` | 8388608 | +| `lifetimeSeconds` | 300 | +| `permanentChannelsOnly` | true | + +A capture above the row or byte limit fails. It is never truncated. The relay inspects at most 50,000 active memberships, readable channels, +and current candidates per operation. The candidate limit applies after the +immutable type predicate and before other tag predicates. Each internal +manifest is at most 16 MiB. Shared state is limited to 32 active +captures and 64 MiB of stored manifest bytes. Each reader has at most two +captures per community. Each process runs at most four snapshot operations +at once. Each SQL statement has a two-second limit. Each operation has an +eight-second elapsed limit. These limits include access checks. + +A bounded error response contains `error` and `code` with the same value: + +| HTTP status | Code | Client action | +| --- | --- | --- | +| 409 | `snapshot_reset` | Retain local records. Start a new capture with bounded backoff. | +| 413 | `snapshot_limit` | Reduce a body batch or query scope. Never reconcile a partial capture as complete. | +| 422 | `snapshot_unsupported_scope` | Retain local records. Use a scope that contains only permanent channels. | +| 400 | `snapshot_invalid` | Correct the request. | +| 503 | `snapshot_unavailable` | Retry with bounded backoff. Retain local records. | + +A single body above the byte limit fails explicitly. Admission and +membership checks can also return their normal errors. All failures prevent +absence reconciliation. + +Snapshot state uses the existing shared Redis store. It supports requests +across relay processes without session affinity. The complete manifest is +published atomically before its token is returned. The fixed expiry does +not extend on reads. Redis expiry, eviction, or loss requires a reset. A +Redis outage returns a service failure. The relay uses no memory fallback. +One Redis hash holds manifests and their accounting, so eviction removes both. +Each capture has a fixed logical expiry. A bounded allocation step removes +expired captures. The hash expires when its last capture expires. A completed capture remains +replayable until expiry and counts against the resource limits during that +interval. From cdfee769b30e504d19adbc6c8f465e77e4fd8cd7 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:31:32 -0700 Subject: [PATCH 02/12] fix(relay): bound snapshot reads and deletion Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/runtime/migration.rs | 8 +- .../src/store/artifact_postgres_tests.rs | 14 +- crates/buzz-db/src/store/artifact_snapshot.rs | 64 ++++++- .../buzz-deletion/src/artifact_snapshots.rs | 173 ++++++++++++++++++ crates/buzz-deletion/src/lib.rs | 85 +-------- .../snapshot_postgres_tests.rs | 108 +++++++++-- .../buzz-relay/src/api/artifact_snapshot.rs | 145 ++++++++++++--- docs/nips/NIP-AR.md | 7 +- migrations/0067_artifact_revision_lookup.sql | 5 + schema/tables/public/artifact_revisions.sql | 3 + 10 files changed, 484 insertions(+), 128 deletions(-) create mode 100644 crates/buzz-deletion/src/artifact_snapshots.rs create mode 100644 migrations/0067_artifact_revision_lookup.sql diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7cb0879ff87..eed8e8331d2 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 66); + assert_eq!(migrations.len(), 67); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -2676,7 +2676,6 @@ mod postgres_tests { .await .expect("read index shapes") } - let base_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); @@ -2748,14 +2747,15 @@ mod postgres_tests { .await .expect("connect migrated probe database"); MIGRATOR - .run_to(55, &migrated) + .run_to(67, &migrated) .await - .expect("apply migrations 1-55"); + .expect("apply migrations 1-67"); for table in [ "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", + "artifact_revisions", ] { assert_eq!( columns(&desired, table).await, diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 1aba26cacf4..8792256d999 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -657,7 +657,19 @@ async fn artifact_snapshot_keeps_captured_revisions_and_invalidates_removed_data f.db.capture_artifact_manifest(f.community, reader.as_bytes(), &query) }; let manifest = capture().await.unwrap(); - let newer = f.revision(d, "update", f.a, Some(&first), &f.owner, vec![]); + let newer = f.revision( + d, + "update", + f.a, + Some(&first), + &f.owner, + vec![vec![ + "label".into(), + "op".into(), + "delete".into(), + "move".into(), + ]], + ); f.accept(&newer, None).await; let second = f.revision(Uuid::new_v4(), "create", f.a, None, &f.owner, vec![]); f.accept(&second, None).await; diff --git a/crates/buzz-db/src/store/artifact_snapshot.rs b/crates/buzz-db/src/store/artifact_snapshot.rs index ad452b1cdee..a9644b0a9c6 100644 --- a/crates/buzz-db/src/store/artifact_snapshot.rs +++ b/crates/buzz-db/src/store/artifact_snapshot.rs @@ -39,6 +39,58 @@ pub struct Manifest { active: Vec, } +/// One revision and its captured access scope for a bounded Redis body read. +#[derive(Deserialize, Serialize)] +pub struct BodyRecord { + entry: Entry, + revision: String, + policy: (Uuid, String, Option), +} + +impl Manifest { + /// Separate capture metadata from revision records for direct body lookup. + pub fn body_records(&self) -> Result<(Self, Vec)> { + let policies = self + .policy + .iter() + .map(|p| (p.0, p)) + .collect::>(); + let records = self + .entries + .iter() + .zip(&self.revisions) + .map(|(entry, revision)| { + Ok(BodyRecord { + entry: entry.clone(), + revision: revision.clone(), + policy: (*policies.get(&entry.channel_id).ok_or_else(|| { + DbError::InvalidData("missing snapshot authority".into()) + })?) + .clone(), + }) + }) + .collect::>>()?; + let mut metadata = self.clone(); + metadata.entries.clear(); + metadata.revisions.clear(); + metadata.policy.clear(); + metadata.predicates.clear(); + Ok((metadata, records)) + } + + /// Restore only requested records with the original capture boundary. + pub fn with_body_records(mut self, records: Vec) -> Self { + let mut policies = std::collections::BTreeMap::new(); + for record in records { + self.entries.push(record.entry); + self.revisions.push(record.revision); + policies.insert(record.policy.0, record.policy); + } + self.policy = policies.into_values().collect(); + self + } +} + async fn begin(db: &Db) -> Result> { let connection = crate::observability::acquire_writer( &db.pool, @@ -417,9 +469,15 @@ impl Db { WHERE r.community_id=$1 AND r.artifact_id=ANY($2) \ AND (age(r.xmin)<=age($3::text::xid) OR r.xmin::text=ANY($4)) \ AND (e.id IS NULL OR e.deleted_at IS NOT NULL \ - OR e.tags @> '[[\"op\",\"delete\"]]'::jsonb OR e.tags @> '[[\"op\",\"move\"]]'::jsonb))", - ).bind(community.as_uuid()).bind(&ids).bind(&manifest.xmax).bind(&manifest.active) - .fetch_one(&mut *tx).await?; + OR EXISTS(SELECT 1 FROM jsonb_array_elements(e.tags) tag \ + WHERE tag->>0='op' AND tag->>1 IN ('delete','move'))))", + ) + .bind(community.as_uuid()) + .bind(&ids) + .bind(&manifest.xmax) + .bind(&manifest.active) + .fetch_one(&mut *tx) + .await?; if changed { return Err(DbError::ArtifactSnapshotReset); } diff --git a/crates/buzz-deletion/src/artifact_snapshots.rs b/crates/buzz-deletion/src/artifact_snapshots.rs new file mode 100644 index 00000000000..c0e5f870d65 --- /dev/null +++ b/crates/buzz-deletion/src/artifact_snapshots.rs @@ -0,0 +1,173 @@ +//! Redis cleanup includes shared artifact snapshots. +use super::*; + +/// Shared eviction unit for bounded, short-lived artifact snapshots. +pub const ARTIFACT_SNAPSHOT_STORE: &str = "buzz:{artifact-snapshots-v1}:store"; + +/// Remove one community's snapshots and their quota charges atomically. +pub async fn purge_artifact_snapshots( + pool: &deadpool_redis::Pool, + community: buzz_core::CommunityId, +) -> Result { + let mut connection = pool.get().await?; + snapshot_namespace(&mut connection, community, true).await +} + +async fn snapshot_namespace( + connection: &mut deadpool_redis::Connection, + community: buzz_core::CommunityId, + remove: bool, +) -> Result { + Ok(redis::Script::new(r#" +local stored = redis.call('HGET', KEYS[1], 'ids') +if not stored then return 0 end +local ids = cjson.decode(stored) +local retained, count = {}, 0 +for _, id in ipairs(ids) do + if string.sub(id, 1, string.len(ARGV[1])) == ARGV[1] then + count = count + 1 + if ARGV[2] == '1' then + local rows = cjson.decode(redis.call('HGET', KEYS[1], 'rows:' .. id) or '[]') + for _, revision in ipairs(rows) do redis.call('HDEL', KEYS[1], 'body:' .. id .. ':' .. revision) end + local size = tonumber(redis.call('HGET', KEYS[1], 'size:' .. id)) + redis.call('HINCRBY', KEYS[1], 'total', -size) + redis.call('HDEL', KEYS[1], 'expiry:' .. id, 'size:' .. id, 'owner:' .. id, + 'data:' .. id, 'progress:' .. id, 'meta:' .. id, 'rows:' .. id) + end + else + table.insert(retained, id) + end +end +if ARGV[2] == '1' then redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) end +return count +"#).key(ARTIFACT_SNAPSHOT_STORE) + .arg(format!("buzz:{{artifact-snapshots-v1}}:{}:", community.as_uuid())) + .arg(if remove { "1" } else { "0" }) + .invoke_async(&mut **connection).await?) +} + +pub(super) async fn purge_redis_namespace( + pool: &deadpool_redis::Pool, + community: buzz_core::CommunityId, +) -> Result { + let mut connection = pool.get().await?; + let pattern = format!("buzz:{community}:*"); + let mut cursor = 0u64; + let mut deleted = snapshot_namespace(&mut connection, community, true).await?; + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(&pattern) + .arg("COUNT") + .arg(1000) + .query_async(&mut *connection) + .await?; + if !keys.is_empty() { + let count: u64 = redis::cmd("UNLINK") + .arg(&keys) + .query_async(&mut *connection) + .await?; + deleted = deleted.saturating_add(count); + } + if next == 0 { + break; + } + cursor = next; + } + Ok(deleted) +} + +pub(super) fn scan_proves_absence(pages: &[(u64, Vec)]) -> bool { + pages.last().is_some_and(|(cursor, _)| *cursor == 0) + && pages.iter().all(|(_, keys)| keys.is_empty()) +} + +async fn scan_redis_namespace( + connection: &mut deadpool_redis::Connection, + pattern: &str, +) -> Result)>> { + let mut cursor = 0u64; + let mut pages = Vec::new(); + loop { + let page: (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(pattern) + .arg("COUNT") + .arg(1000) + .query_async(&mut **connection) + .await?; + cursor = page.0; + pages.push(page); + if cursor == 0 { + return Ok(pages); + } + } +} + +pub(super) async fn verify_redis_absence( + pool: &deadpool_redis::Pool, + community: buzz_core::CommunityId, +) -> Result<()> { + let mut connection = pool.get().await?; + let pattern = format!("buzz:{community}:*"); + // SCAN is weakly consistent. Two complete empty passes ensure a cursor + // rollover or concurrent expiry cannot make one sparse pass look absent. + let first = scan_redis_namespace(&mut connection, &pattern).await?; + let second = scan_redis_namespace(&mut connection, &pattern).await?; + let snapshots = snapshot_namespace(&mut connection, community, false).await?; + if scan_proves_absence(&first) && scan_proves_absence(&second) && snapshots == 0 { + Ok(()) + } else { + Err(transient( + "logical verification found a Redis namespace key", + )) + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn artifact_snapshot_community_purge_checks_shared_storage() { + let pool = deadpool_redis::Config::from_url( + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()), + ) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let id = format!( + "buzz:{{artifact-snapshots-v1}}:{}:fixture", + community.as_uuid() + ); + let mut conn = pool.get().await.unwrap(); + let _: i64 = redis::Script::new( + r#" +local ids = cjson.decode(redis.call('HGET', KEYS[1], 'ids') or '[]') +table.insert(ids, ARGV[1]) +redis.call('HSET', KEYS[1], 'ids', cjson.encode(ids), 'size:' .. ARGV[1], 10, + 'rows:' .. ARGV[1], '["revision"]', 'body:' .. ARGV[1] .. ':revision', 'body') +redis.call('HINCRBY', KEYS[1], 'total', 10) +return 1 +"#, + ) + .key(ARTIFACT_SNAPSHOT_STORE) + .arg(&id) + .invoke_async(&mut *conn) + .await + .unwrap(); + assert!(verify_redis_absence(&pool, community).await.is_err()); + assert_eq!(purge_redis_namespace(&pool, community).await.unwrap(), 1); + verify_redis_absence(&pool, community).await.unwrap(); + let body: Option = redis::cmd("HGET") + .arg(ARTIFACT_SNAPSHOT_STORE) + .arg(format!("body:{id}:revision")) + .query_async(&mut *conn) + .await + .unwrap(); + assert_eq!(body, None); + } +} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index c3fb598523a..6e1cbe4187e 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -24,6 +24,12 @@ use serde::Serialize; use tokio_util::sync::CancellationToken; use uuid::Uuid; +mod artifact_snapshots; +#[cfg(test)] +use artifact_snapshots::scan_proves_absence; +pub use artifact_snapshots::{purge_artifact_snapshots, ARTIFACT_SNAPSHOT_STORE}; +use artifact_snapshots::{purge_redis_namespace, verify_redis_absence}; + /// Fleet-wide object cap for one observational taxonomy sweep. const DEFAULT_SWEEP_OBJECT_CAP: u64 = 10_000_000; /// Keys per frozen side-table chunk (one `DeleteObjects`-sized unit × 10). @@ -1688,85 +1694,6 @@ async fn publish_disconnect_community( Ok(()) } -async fn purge_redis_namespace( - pool: &deadpool_redis::Pool, - community: buzz_core::CommunityId, -) -> Result { - let mut connection = pool.get().await?; - let pattern = format!("buzz:{community}:*"); - let mut cursor = 0u64; - let mut deleted = 0u64; - loop { - let (next, keys): (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(&pattern) - .arg("COUNT") - .arg(1000) - .query_async(&mut *connection) - .await?; - if !keys.is_empty() { - let count: u64 = redis::cmd("UNLINK") - .arg(&keys) - .query_async(&mut *connection) - .await?; - deleted = deleted.saturating_add(count); - } - if next == 0 { - break; - } - cursor = next; - } - Ok(deleted) -} - -fn scan_proves_absence(pages: &[(u64, Vec)]) -> bool { - pages.last().is_some_and(|(cursor, _)| *cursor == 0) - && pages.iter().all(|(_, keys)| keys.is_empty()) -} - -async fn scan_redis_namespace( - connection: &mut deadpool_redis::Connection, - pattern: &str, -) -> Result)>> { - let mut cursor = 0u64; - let mut pages = Vec::new(); - loop { - let page: (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(pattern) - .arg("COUNT") - .arg(1000) - .query_async(&mut **connection) - .await?; - cursor = page.0; - pages.push(page); - if cursor == 0 { - return Ok(pages); - } - } -} - -async fn verify_redis_absence( - pool: &deadpool_redis::Pool, - community: buzz_core::CommunityId, -) -> Result<()> { - let mut connection = pool.get().await?; - let pattern = format!("buzz:{community}:*"); - // SCAN is weakly consistent. Two complete empty passes ensure a cursor - // rollover or concurrent expiry cannot make one sparse pass look absent. - let first = scan_redis_namespace(&mut connection, &pattern).await?; - let second = scan_redis_namespace(&mut connection, &pattern).await?; - if scan_proves_absence(&first) && scan_proves_absence(&second) { - Ok(()) - } else { - Err(transient( - "logical verification found a Redis namespace key", - )) - } -} - fn sweep_object_cap() -> u64 { std::env::var("BUZZ_DELETION_SWEEP_MAX_OBJECTS") .ok() diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs index 12e3470bf6d..d1775f99c94 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs @@ -559,21 +559,9 @@ async fn artifact_snapshot_tokens_expiry_and_allocation_are_bounded() { } async fn cleanup(f: &Fixture) { - let mut conn = f.state.redis_pool.get().await.unwrap(); - let prefix = format!("buzz:{{artifact-snapshots-v1}}:{}:", f.community.as_uuid()); - let _: i64 = redis::Script::new(r#" - for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do - if string.sub(field,1,7) == 'expiry:' then - local id = string.sub(field,8) - if string.sub(id,1,string.len(ARGV[1])) == ARGV[1] then - local size = tonumber(redis.call('HGET',KEYS[1],'size:' .. id)) - redis.call('HINCRBY',KEYS[1],'total',-size) - redis.call('HDEL',KEYS[1],field,'size:' .. id,'owner:' .. id,'data:' .. id,'progress:' .. id) - end - end - end - return 1 - "#).key("buzz:{artifact-snapshots-v1}:store").arg(prefix).invoke_async(&mut *conn).await.unwrap(); + buzz_deletion::purge_artifact_snapshots(&f.state.redis_pool, f.community) + .await + .unwrap(); } #[tokio::test] @@ -614,3 +602,93 @@ async fn artifact_snapshot_new_matching_home_resets_then_a_fresh_capture_complet assert!(status.is_success(), "{result}"); cleanup(&f).await; } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn artifact_snapshot_deletion_removes_only_its_community_and_fences_capture() { + let f = Fixture::new().await; + let other = Fixture::new().await; + let event = f.revision(Uuid::new_v4(), "create", None); + f.publish(&event).await; + let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current"}}]); + let (status, captured) = f.request("/query", start.clone()).await; + assert!(status.is_success(), "{captured}"); + let (status, retained) = other.request("/query", start.clone()).await; + assert!(status.is_success(), "{retained}"); + let token = captured["token"].as_str().unwrap(); + let snapshot_key = format!( + "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", + f.community.as_uuid(), + f.owner.public_key().to_hex(), + token + ); + let mut conn = f.state.redis_pool.get().await.unwrap(); + let bytes_before: usize = redis::cmd("HGET") + .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) + .arg("total") + .query_async(&mut *conn) + .await + .unwrap(); + let charge: usize = redis::cmd("HGET") + .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) + .arg(format!("size:{snapshot_key}")) + .query_async(&mut *conn) + .await + .unwrap(); + let metadata: Vec = redis::cmd("HGET") + .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) + .arg(format!("meta:{snapshot_key}")) + .query_async(&mut *conn) + .await + .unwrap(); + let metadata: Value = serde_json::from_slice(&metadata).unwrap(); + assert_eq!(metadata["manifest"]["entries"], json!([])); + assert_eq!(metadata["manifest"]["policy"], json!([])); + let tenant = crate::tenant::bind_community(&f.state.db, &f.host) + .await + .unwrap(); + let mut tx = f.pool.begin().await.unwrap(); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") + .bind(f.community.as_uuid().to_string()).execute(&mut *tx).await.unwrap(); + sqlx::query("UPDATE communities SET deletion_state='quiescing' WHERE id=$1") + .bind(f.community.as_uuid()) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + // An already-bound HTTP request must also fail at the external write seam. + let denied = + crate::api::artifact_snapshot::query(&f.state, &tenant, &f.owner.public_key(), &start[0]) + .await; + assert_eq!(denied.unwrap_err().0, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + buzz_deletion::purge_artifact_snapshots(&f.state.redis_pool, f.community) + .await + .unwrap(), + 1 + ); + let bytes_after: usize = redis::cmd("HGET") + .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) + .arg("total") + .query_async(&mut *conn) + .await + .unwrap(); + assert_eq!(bytes_after, bytes_before - charge); + let fields: Vec = redis::cmd("HKEYS") + .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) + .query_async(&mut *conn) + .await + .unwrap(); + assert!(!fields.iter().any(|field| field.contains(&snapshot_key))); + let result = crate::api::artifact_snapshot::query(&f.state, &tenant, &f.owner.public_key(), + &json!({"artifact":"snapshot","action":"bodies","token":token,"revisionIds":[event.id.to_hex()]})).await; + assert_eq!(result.unwrap_err().0, StatusCode::CONFLICT); + let (status, completed) = other + .request( + "/query", + json!([{"artifact":"snapshot","action":"complete","token":retained["token"]}]), + ) + .await; + assert!(status.is_success(), "{completed}"); + cleanup(&other).await; +} diff --git a/crates/buzz-relay/src/api/artifact_snapshot.rs b/crates/buzz-relay/src/api/artifact_snapshot.rs index 8a3d9452c93..aad5a86b6e7 100644 --- a/crates/buzz-relay/src/api/artifact_snapshot.rs +++ b/crates/buzz-relay/src/api/artifact_snapshot.rs @@ -2,7 +2,7 @@ use crate::state::AppState; use axum::{http::StatusCode, Json}; use buzz_core::{artifact::ArtifactView, TenantContext}; -use buzz_db::artifact_snapshot::{Manifest, MAX_BYTES, MAX_ROWS}; +use buzz_db::artifact_snapshot::{BodyRecord, Manifest, MAX_BYTES, MAX_ROWS}; use deadpool_redis::redis; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -16,7 +16,7 @@ const BODY_ROWS: usize = 100; const BODY_BYTES: usize = 8_388_608; const GLOBAL_BYTES: usize = 64 * 1024 * 1024; const GLOBAL_COUNT: usize = 32; -const STORE_KEY: &str = "buzz:{artifact-snapshots-v1}:store"; +const STORE_KEY: &str = buzz_deletion::ARTIFACT_SNAPSHOT_STORE; static WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); type Response = Result, (StatusCode, Json)>; @@ -145,6 +145,13 @@ async fn execute( .await .map_err(|_| unavailable())?; if let Request::Start { artifact, query } = request { + let serving_write = buzz_deletion::acquire_serving_write( + &state.db, + tenant.community(), + "artifact_snapshot", + ) + .await + .map_err(|_| unavailable())?; if artifact != "snapshot" || query.get("limit").is_some() || query.get("offset").is_some() { return Err(invalid()); } @@ -176,18 +183,52 @@ async fn execute( let response = page(&snapshot, &token, 0)?; let snapshot_key = key(tenant, reader, &token); let principal_key = key(tenant, reader, "active"); - let stored: i64 = redis::Script::new(REGISTER) - .key(STORE_KEY) - .arg(expires_at) - .arg(data.len()) - .arg(GLOBAL_BYTES) - .arg(GLOBAL_COUNT) - .arg(data) - .arg(&snapshot_key) - .arg(principal_key) - .invoke_async(&mut *conn) + let (metadata, records) = snapshot + .manifest + .body_records() + .map_err(|e| db_error(e, tenant, reader))?; + let metadata = serde_json::to_vec(&Snapshot { + manifest: metadata, + query: Value::Null, + generation: token.clone(), + expires_at, + cursors: vec![], + }) + .map_err(|_| unavailable())?; + let rows = records + .iter() + .zip(&snapshot.manifest.entries) + .map(|(record, entry)| { + Ok(( + entry.revision_id.clone(), + serde_json::to_string(record).map_err(|_| unavailable())?, + )) + }) + .collect::, _>>()?; + let rows = serde_json::to_vec(&rows).map_err(|_| unavailable())?; + // Charge duplicate records, field names, and Redis object overhead. + let stored_bytes = data.len() + metadata.len() + rows.len() + records.len() * 512; + let registration = async { + redis::Script::new(REGISTER) + .key(STORE_KEY) + .arg(expires_at) + .arg(stored_bytes) + .arg(GLOBAL_BYTES) + .arg(GLOBAL_COUNT) + .arg(data) + .arg(&snapshot_key) + .arg(principal_key) + .arg(metadata) + .arg(rows) + .invoke_async::(&mut *conn) + .await + }; + let stored = serving_write + .protect(registration) .await + .map_err(|_| unavailable())? .map_err(|_| unavailable())?; + serving_write.finish().await.map_err(|_| unavailable())?; if stored == 0 { return Err(limit()); } @@ -207,20 +248,63 @@ async fn execute( return Err(invalid()); } let snapshot_key = key(tenant, reader, token); - let data: Option> = redis::cmd("HGET") - .arg(STORE_KEY) - .arg(format!("data:{snapshot_key}")) - .query_async(&mut *conn) - .await - .map_err(|_| unavailable())?; + let body_ids = match &request { + Request::Bodies { revision_ids, .. } => { + if revision_ids.is_empty() { + return Err(invalid()); + } + if revision_ids.len() > BODY_ROWS { + return Err(limit()); + } + let mut seen = std::collections::HashSet::new(); + if revision_ids + .iter() + .any(|id| buzz_core::artifact::event_id(id).is_err() || !seen.insert(id)) + { + return Err(invalid()); + } + Some(revision_ids) + } + _ => None, + }; + let (data, body_rows) = if let Some(ids) = body_ids { + let mut fields = vec![format!("meta:{snapshot_key}")]; + fields.extend(ids.iter().map(|id| format!("body:{snapshot_key}:{id}"))); + let mut rows: Vec>> = redis::cmd("HMGET") + .arg(STORE_KEY) + .arg(fields) + .query_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + let data = rows.remove(0); + (data, rows) + } else { + let data: Option> = redis::cmd("HGET") + .arg(STORE_KEY) + .arg(format!("data:{snapshot_key}")) + .query_async(&mut *conn) + .await + .map_err(|_| unavailable())?; + (data, vec![]) + }; let data = data.ok_or_else(reset)?; if data.len() > MAX_BYTES { return Err(unavailable()); } - let snapshot: Snapshot = serde_json::from_slice(&data).map_err(|_| unavailable())?; + let mut snapshot: Snapshot = serde_json::from_slice(&data).map_err(|_| unavailable())?; if snapshot.generation != *token || now >= snapshot.expires_at { return Err(reset()); } + if body_ids.is_some() { + let records = body_rows + .into_iter() + .map(|row| { + serde_json::from_slice::(&row.ok_or_else(invalid)?) + .map_err(|_| unavailable()) + }) + .collect::, _>>()?; + snapshot.manifest = snapshot.manifest.with_body_records(records); + } let requested = match &request { Request::Bodies { revision_ids, .. } => { if revision_ids.is_empty() { @@ -320,25 +404,38 @@ async fn execute( const REGISTER: &str = r#" local now = tonumber(redis.call('TIME')[1]) local count, principal, latest = 0, 0, tonumber(ARGV[1]) -for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do - if string.sub(field, 1, 7) == 'expiry:' then - local id = string.sub(field, 8) +local ids = cjson.decode(redis.call('HGET', KEYS[1], 'ids') or '[]') +local retained = {} +for _, id in ipairs(ids) do + local field = 'expiry:' .. id local expiry = tonumber(redis.call('HGET', KEYS[1], field)) if expiry <= now then local size = tonumber(redis.call('HGET', KEYS[1], 'size:' .. id)) redis.call('HINCRBY', KEYS[1], 'total', -size) - redis.call('HDEL', KEYS[1], field, 'size:' .. id, 'owner:' .. id, 'data:' .. id, 'progress:' .. id) + local rows = cjson.decode(redis.call('HGET', KEYS[1], 'rows:' .. id) or '[]') + for _, revision in ipairs(rows) do redis.call('HDEL', KEYS[1], 'body:' .. id .. ':' .. revision) end + redis.call('HDEL', KEYS[1], field, 'size:' .. id, 'owner:' .. id, 'data:' .. id, 'progress:' .. id, 'meta:' .. id, 'rows:' .. id) else + table.insert(retained, id) count = count + 1 latest = math.max(latest, expiry) if redis.call('HGET', KEYS[1], 'owner:' .. id) == ARGV[7] then principal = principal + 1 end end - end end +redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) if tonumber(ARGV[1]) <= now or count >= tonumber(ARGV[4]) or principal >= 2 or tonumber(redis.call('HGET', KEYS[1], 'total') or '0') + tonumber(ARGV[2]) > tonumber(ARGV[3]) then return 0 end redis.call('HSET', KEYS[1], 'data:' .. ARGV[6], ARGV[5], 'progress:' .. ARGV[6], 1, 'expiry:' .. ARGV[6], ARGV[1], 'size:' .. ARGV[6], ARGV[2], 'owner:' .. ARGV[6], ARGV[7]) +local rows = cjson.decode(ARGV[9] or '[]') +local revisions = {} +for _, row in ipairs(rows) do + redis.call('HSET', KEYS[1], 'body:' .. ARGV[6] .. ':' .. row[1], row[2]) + table.insert(revisions, row[1]) +end +redis.call('HSET', KEYS[1], 'meta:' .. ARGV[6], ARGV[8] or '', 'rows:' .. ARGV[6], cjson.encode(revisions)) +table.insert(retained, ARGV[6]) +redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) redis.call('HINCRBY', KEYS[1], 'total', ARGV[2]) redis.call('EXPIREAT', KEYS[1], latest) return 1 diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index d0bbd52f489..7d009888c4b 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -208,7 +208,7 @@ A capture above the row or byte limit fails. It is never truncated. The relay in and current candidates per operation. The candidate limit applies after the immutable type predicate and before other tag predicates. Each internal manifest is at most 16 MiB. Shared state is limited to 32 active -captures and 64 MiB of stored manifest bytes. Each reader has at most two +captures and 64 MiB of charged snapshot storage. Each reader has at most two captures per community. Each process runs at most four snapshot operations at once. Each SQL statement has a two-second limit. Each operation has an eight-second elapsed limit. These limits include access checks. @@ -232,7 +232,10 @@ across relay processes without session affinity. The complete manifest is published atomically before its token is returned. The fixed expiry does not extend on reads. Redis expiry, eviction, or loss requires a reset. A Redis outage returns a service failure. The relay uses no memory fallback. -One Redis hash holds manifests and their accounting, so eviction removes both. +One Redis hash holds manifests, direct body records, and quota charges. +Eviction removes them together. Body reads fetch only the requested records. +A serving write lease protects capture registration. Community deletion +removes its records and quota charges. Each capture has a fixed logical expiry. A bounded allocation step removes expired captures. The hash expires when its last capture expires. A completed capture remains replayable until expiry and counts against the resource limits during that diff --git a/migrations/0067_artifact_revision_lookup.sql b/migrations/0067_artifact_revision_lookup.sql new file mode 100644 index 00000000000..256c7b1f7d7 --- /dev/null +++ b/migrations/0067_artifact_revision_lookup.sql @@ -0,0 +1,5 @@ +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +CREATE INDEX idx_artifact_revisions_artifact + ON artifact_revisions (community_id, artifact_id); diff --git a/schema/tables/public/artifact_revisions.sql b/schema/tables/public/artifact_revisions.sql index 57086018141..5aa24c25724 100644 --- a/schema/tables/public/artifact_revisions.sql +++ b/schema/tables/public/artifact_revisions.sql @@ -6,3 +6,6 @@ CREATE TABLE artifact_revisions ( artifact_id UUID NOT NULL, PRIMARY KEY (community_id, event_id) ); + +CREATE INDEX idx_artifact_revisions_artifact + ON artifact_revisions (community_id, artifact_id); From ab49d158d962f144b1479f405cfa9be0859ed868 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 13:43:44 -0700 Subject: [PATCH 03/12] fix(db): order the snapshot index after join roles Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/runtime/migration.rs | 6 +++--- ...evision_lookup.sql => 0068_artifact_revision_lookup.sql} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename migrations/{0067_artifact_revision_lookup.sql => 0068_artifact_revision_lookup.sql} (100%) diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 90999c487f6..7951a1b6740 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 67); + assert_eq!(migrations.len(), 68); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -2752,9 +2752,9 @@ mod postgres_tests { .await .expect("connect migrated probe database"); MIGRATOR - .run_to(67, &migrated) + .run_to(68, &migrated) .await - .expect("apply migrations 1-67"); + .expect("apply migrations 1-68"); for table in [ "relay_admin_actions", diff --git a/migrations/0067_artifact_revision_lookup.sql b/migrations/0068_artifact_revision_lookup.sql similarity index 100% rename from migrations/0067_artifact_revision_lookup.sql rename to migrations/0068_artifact_revision_lookup.sql From 93ee35a80dc69a04912d046f43500e20d588433f Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 14:52:25 -0700 Subject: [PATCH 04/12] docs(relay): clarify snapshot membership limits Signed-off-by: Daniel Rouleau --- docs/nips/NIP-AR.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index 7d009888c4b..1b0b7a43f46 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -204,9 +204,20 @@ NIP-11 advertises these fields under `artifacts.snapshot`: | `lifetimeSeconds` | 300 | | `permanentChannelsOnly` | true | -A capture above the row or byte limit fails. It is never truncated. The relay inspects at most 50,000 active memberships, readable channels, -and current candidates per operation. The candidate limit applies after the -immutable type predicate and before other tag predicates. Each internal +A capture above the row or byte limit fails. It is never truncated. +Capture, manifest pages, and completion have these independent admission limits: + +- The reader has at most 50,000 active channel memberships in the community. This count applies before query filters. Memberships in archived channels count while active. +- The readable channel candidates contain at most 50,000 channels, before the artifact predicates apply. +- The current artifact candidates contain at most 50,000 records, after the immutable type predicate and before other tag predicates. + +A reader above the membership limit receives HTTP 413 `snapshot_limit`, even +if the query matches few or no artifacts. A narrower artifact query does not +remove this membership limit. Version 1 does not support a reader with 60,000 +active memberships. A client route test at that size does not establish +snapshot support. + +Each internal manifest is at most 16 MiB. Shared state is limited to 32 active captures and 64 MiB of charged snapshot storage. Each reader has at most two captures per community. Each process runs at most four snapshot operations @@ -218,7 +229,7 @@ A bounded error response contains `error` and `code` with the same value: | HTTP status | Code | Client action | | --- | --- | --- | | 409 | `snapshot_reset` | Retain local records. Start a new capture with bounded backoff. | -| 413 | `snapshot_limit` | Reduce a body batch or query scope. Never reconcile a partial capture as complete. | +| 413 | `snapshot_limit` | Retain local records. Reduce a body batch or query scope where applicable. Never reconcile a partial capture as complete. | | 422 | `snapshot_unsupported_scope` | Retain local records. Use a scope that contains only permanent channels. | | 400 | `snapshot_invalid` | Correct the request. | | 503 | `snapshot_unavailable` | Retry with bounded backoff. Retain local records. | From 94d1aaee1f1bdbf5e0d18baa2dc5b4f782e624d4 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:11:43 -0700 Subject: [PATCH 05/12] feat(relay): sync artifacts with a time overlap Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/error.rs | 16 +- crates/buzz-db/src/lib.rs | 10 +- crates/buzz-db/src/runtime/migration.rs | 9 +- crates/buzz-db/src/store/artifact.rs | 5 +- .../src/store/artifact_postgres_tests.rs | 213 ------ crates/buzz-db/src/store/artifact_query.rs | 12 + crates/buzz-db/src/store/artifact_snapshot.rs | 519 ------------- crates/buzz-db/src/store/artifact_sync.rs | 214 ++++++ crates/buzz-db/src/store/channel.rs | 26 + crates/buzz-db/src/store/channel_members.rs | 4 +- crates/buzz-db/src/store/dm.rs | 1 + crates/buzz-db/src/store/event.rs | 2 + crates/buzz-db/src/store/mod.rs | 4 +- .../buzz-db/src/store/relay_admin_actions.rs | 4 +- .../buzz-deletion/src/artifact_snapshots.rs | 173 ----- crates/buzz-deletion/src/lib.rs | 85 ++- crates/buzz-relay/src/api/artifact.rs | 9 +- .../src/api/artifact_postgres_tests.rs | 6 +- .../snapshot_postgres_tests.rs | 694 ------------------ .../sync_access_postgres_tests.rs | 254 +++++++ .../sync_postgres_tests.rs | 333 +++++++++ .../sync_scale_postgres_tests.rs | 132 ++++ .../buzz-relay/src/api/artifact_snapshot.rs | 460 ------------ .../api/artifact_snapshot_postgres_tests.rs | 73 -- crates/buzz-relay/src/api/artifact_sync.rs | 388 ++++++++++ crates/buzz-relay/src/api/mod.rs | 2 +- crates/buzz-relay/src/config.rs | 6 + crates/buzz-relay/src/nip11.rs | 4 +- docs/artifact-sync-deployment.md | 64 ++ docs/nips/NIP-AR.md | 227 +++--- migrations/0068_artifact_revision_lookup.sql | 27 +- migrations/0069_artifact_sync_timestamps.sql | 11 + migrations/0070_artifact_sync_indexes.sql | 35 + schema/tables/public/artifact_heads.sql | 3 + schema/tables/public/artifact_revisions.sql | 4 + schema/tables/public/channel_members.sql | 2 + schema/tables/public/communities.sql | 1 + 37 files changed, 1731 insertions(+), 2301 deletions(-) delete mode 100644 crates/buzz-db/src/store/artifact_snapshot.rs create mode 100644 crates/buzz-db/src/store/artifact_sync.rs delete mode 100644 crates/buzz-deletion/src/artifact_snapshots.rs delete mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs delete mode 100644 crates/buzz-relay/src/api/artifact_snapshot.rs delete mode 100644 crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/artifact_sync.rs create mode 100644 docs/artifact-sync-deployment.md create mode 100644 migrations/0069_artifact_sync_timestamps.sql create mode 100644 migrations/0070_artifact_sync_indexes.sql diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index a3cc3f899b2..977844b7330 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -69,17 +69,13 @@ pub enum DbError { #[error("deletion safety error: {0}")] DeletionSafety(String), - /// An artifact snapshot exceeds its resource limit. - #[error("artifact snapshot limit exceeded")] - ArtifactSnapshotLimit, + /// An artifact sync page exceeds its resource limit. + #[error("artifact sync limit exceeded")] + ArtifactSyncLimit, - /// Snapshot scope contains a channel with expiring content. - #[error("artifact snapshot scope requires permanent channels")] - ArtifactSnapshotUnsupportedScope, - - /// Captured access or artifact data is no longer valid. - #[error("artifact snapshot must reset")] - ArtifactSnapshotReset, + /// Sync scope contains a channel with expiring content. + #[error("artifact sync scope requires permanent channels")] + ArtifactSyncUnsupportedScope, /// A complete read-state snapshot exceeds its bounded resource budget. #[error("read-state snapshot exceeds event or byte limit")] diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 45b1778ffc2..f339467b476 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -65,11 +65,11 @@ pub(crate) use runtime::{ sweep_admitted_communities, ReadSessionInner, RouteDecision, RoutePredicate, }; pub use store::{ - admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_snapshot, - channel, channel_members, community, deletion, dm, event, feed, git_repo, moderation, - nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction, - read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, - replaceable, storage_accounting, thread, thread_window, usage, user, workflow, + admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_sync, channel, + channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding, + operator_listener, partition, personal_read, product_feedback, push, reaction, read_state, + relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable, + storage_accounting, thread, thread_window, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7951a1b6740..38caa318beb 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 68); + assert_eq!(migrations.len(), 70); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -2752,15 +2752,18 @@ mod postgres_tests { .await .expect("connect migrated probe database"); MIGRATOR - .run_to(68, &migrated) + .run_to(70, &migrated) .await - .expect("apply migrations 1-68"); + .expect("apply migrations 1-70"); for table in [ "relay_admin_actions", "relay_admin_outbox", "relay_operator_audit", "artifact_revisions", + "artifact_heads", + "channel_members", + "communities", ] { assert_eq!( columns(&desired, table).await, diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index a8a49ac602b..6f9f46eb174 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -185,14 +185,15 @@ impl Db { } } sqlx::query( - "INSERT INTO artifact_revisions (community_id,event_id,artifact_id) VALUES ($1,$2,$3)", + "INSERT INTO artifact_revisions (community_id,event_id,artifact_id,source_channel_id,modified_at) VALUES ($1,$2,$3,$4,clock_timestamp())", ) .bind(community.as_uuid()) .bind(event.id.as_bytes().as_slice()) .bind(env.id) + .bind(if env.op == ArtifactOp::Move { source } else { None }) .execute(tx.conn()) .await?; - sqlx::query("INSERT INTO artifact_heads (community_id,artifact_id,event_id,channel_id,artifact_type,root,deleted) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (community_id,artifact_id) DO UPDATE SET event_id=EXCLUDED.event_id,channel_id=EXCLUDED.channel_id,root=EXCLUDED.root,deleted=EXCLUDED.deleted") + sqlx::query("INSERT INTO artifact_heads (community_id,artifact_id,event_id,channel_id,artifact_type,root,deleted,modified_at) VALUES ($1,$2,$3,$4,$5,$6,$7,clock_timestamp()) ON CONFLICT (community_id,artifact_id) DO UPDATE SET event_id=EXCLUDED.event_id,channel_id=EXCLUDED.channel_id,root=EXCLUDED.root,deleted=EXCLUDED.deleted,modified_at=clock_timestamp()") .bind(community.as_uuid()).bind(env.id).bind(event.id.as_bytes().as_slice()).bind(env.home).bind(&env.artifact_type).bind(&env.root).bind(env.op == ArtifactOp::Delete).execute(tx.conn()).await?; let (stored, _) = crate::event::insert_event_in_transaction(&mut tx, event, Some(env.home)).await?; diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 8792256d999..a6ea1d0e09e 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -643,216 +643,3 @@ async fn head_outside_authorized_channel_matches_missing_head() { ArtifactOutcome::Accepted(_) )); } - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_keeps_captured_revisions_and_invalidates_removed_data() { - let f = Fixture::new().await; - let d = Uuid::new_v4(); - let first = f.revision(d, "create", f.a, None, &f.owner, vec![]); - f.accept(&first, None).await; - let query = artifact::parse_query(&serde_json::json!({"artifact":"current"})).unwrap(); - let reader = f.owner.public_key(); - let capture = || { - f.db.capture_artifact_manifest(f.community, reader.as_bytes(), &query) - }; - let manifest = capture().await.unwrap(); - let newer = f.revision( - d, - "update", - f.a, - Some(&first), - &f.owner, - vec![vec![ - "label".into(), - "op".into(), - "delete".into(), - "move".into(), - ]], - ); - f.accept(&newer, None).await; - let second = f.revision(Uuid::new_v4(), "create", f.a, None, &f.owner, vec![]); - f.accept(&second, None).await; - let old = - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[first.id.to_hex()], - 8_388_608, - ) - .await - .unwrap(); - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - reader.as_bytes(), - &manifest, - &[first.id.to_hex()], - 1 - ) - .await, - Err(crate::DbError::ArtifactSnapshotLimit) - )); - assert_eq!(old[0].event.id, first.id); - assert_eq!(manifest.entries.len(), 1); - - // A delete and restore cannot make an invalid capture valid again. - let deleted = f.revision(d, "delete", f.a, Some(&newer), &f.owner, vec![]); - f.accept(&deleted, None).await; - let restored = f.revision(d, "restore", f.a, Some(&deleted), &f.owner, vec![]); - f.accept(&restored, None).await; - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); - - let manifest = capture().await.unwrap(); - sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(restored.id.as_bytes().as_slice()) - .execute(&f.db.pool) - .await - .unwrap(); - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); - sqlx::query("UPDATE events SET deleted_at=NULL WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(restored.id.as_bytes().as_slice()) - .execute(&f.db.pool) - .await - .unwrap(); - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); - - let manifest = capture().await.unwrap(); - sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND pubkey=$2") - .bind(f.community.as_uuid()) - .bind(f.owner.public_key().as_bytes().as_slice()) - .execute(&f.db.pool) - .await - .unwrap(); - sqlx::query("UPDATE channel_members SET removed_at=NULL WHERE community_id=$1 AND pubkey=$2") - .bind(f.community.as_uuid()) - .bind(f.owner.public_key().as_bytes().as_slice()) - .execute(&f.db.pool) - .await - .unwrap(); - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); - - let manifest = capture().await.unwrap(); - let moved = f.revision(d, "move", f.b, Some(&restored), &f.owner, vec![]); - f.accept(&moved, Some(f.a)).await; - let back = f.revision(d, "move", f.a, Some(&moved), &f.owner, vec![]); - f.accept(&back, Some(f.b)).await; - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_fences_a_delete_transaction_in_progress_at_capture() { - let f = Fixture::new().await; - let id = Uuid::new_v4(); - let first = f.revision(id, "create", f.a, None, &f.owner, vec![]); - f.accept(&first, None).await; - let deleted = f.revision(id, "delete", f.a, Some(&first), &f.owner, vec![]); - let mut tx = - f.db.begin_event_write_transaction(f.community) - .await - .unwrap(); - sqlx::query( - "INSERT INTO artifact_revisions(community_id,event_id,artifact_id) VALUES($1,$2,$3)", - ) - .bind(f.community.as_uuid()) - .bind(deleted.id.as_bytes().as_slice()) - .bind(id) - .execute(tx.conn()) - .await - .unwrap(); - crate::event::insert_event_in_transaction(&mut tx, &deleted, Some(f.a)) - .await - .unwrap(); - sqlx::query("UPDATE artifact_heads SET event_id=$3,deleted=true WHERE community_id=$1 AND artifact_id=$2") - .bind(f.community.as_uuid()).bind(id).bind(deleted.id.as_bytes().as_slice()).execute(tx.conn()).await.unwrap(); - let query = artifact::parse_query(&serde_json::json!({"artifact":"current"})).unwrap(); - let manifest = - f.db.capture_artifact_manifest(f.community, f.owner.public_key().as_bytes(), &query) - .await - .unwrap(); - tx.commit().await.unwrap(); - let restored = f.revision(id, "restore", f.a, Some(&deleted), &f.owner, vec![]); - f.accept(&restored, None).await; - assert!(matches!( - f.db.read_artifact_manifest( - f.community, - f.owner.public_key().as_bytes(), - &manifest, - &[], - 0 - ) - .await, - Err(crate::DbError::ArtifactSnapshotReset) - )); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_bounds_current_candidates_before_reading_payloads() { - let f = Fixture::new().await; - sqlx::query("INSERT INTO artifact_heads(community_id,artifact_id,event_id,channel_id,artifact_type) \ - SELECT $1,gen_random_uuid(),digest(n::text,'sha256'),$2,'buzz.task' FROM generate_series(1,50001) n") - .bind(f.community.as_uuid()).bind(f.a).execute(&f.db.pool).await.unwrap(); - let query = - artifact::parse_query(&serde_json::json!({"artifact":"current","#type":["buzz.task"]})) - .unwrap(); - assert!(matches!( - f.db.capture_artifact_manifest(f.community, f.owner.public_key().as_bytes(), &query) - .await, - Err(crate::DbError::ArtifactSnapshotLimit) - )); -} diff --git a/crates/buzz-db/src/store/artifact_query.rs b/crates/buzz-db/src/store/artifact_query.rs index d3e24822afa..e1de756c525 100644 --- a/crates/buzz-db/src/store/artifact_query.rs +++ b/crates/buzz-db/src/store/artifact_query.rs @@ -82,6 +82,18 @@ pub(super) fn predicates( .push(")) AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=") .push_bind(reader.to_vec()).push(" AND m.removed_at IS NULL)))"); tag_predicates(qb, query); + if query.view == ArtifactView::Current { + for (_, values) in query.tags.iter().filter(|(name, _)| name == "d") { + qb.push(" AND a.artifact_id=ANY(") + .push_bind( + values + .iter() + .filter_map(|id| id.parse::().ok()) + .collect::>(), + ) + .push(")"); + } + } } /// Apply exact artifact tag predicates after access is resolved. diff --git a/crates/buzz-db/src/store/artifact_snapshot.rs b/crates/buzz-db/src/store/artifact_snapshot.rs deleted file mode 100644 index a9644b0a9c6..00000000000 --- a/crates/buzz-db/src/store/artifact_snapshot.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! Stable manifests hold revision identities. Each read uses current authority. -use crate::{Db, DbError, Result}; -use buzz_core::{ - artifact::{ArtifactQuery, ArtifactView}, - CommunityId, StoredEvent, -}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgConnection, QueryBuilder, Row}; -use uuid::Uuid; - -/// Maximum identities in a complete manifest. -pub const MAX_ROWS: usize = 50_000; -/// Maximum serialized internal manifest size. -pub const MAX_BYTES: usize = 16 * 1024 * 1024; - -/// One captured current revision. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Entry { - /// Stable artifact identity. - pub artifact_id: Uuid, - /// Exact signed event identity. - pub revision_id: String, - /// Home at capture. - pub channel_id: Uuid, -} - -/// Internal snapshot state. This is never sent to the reader. -#[derive(Clone, Deserialize, Serialize)] -pub struct Manifest { - /// Public manifest entries. - pub entries: Vec, - // Row versions detect redaction followed by recovery, and access revoke/regrant. - revisions: Vec, - policy: Vec<(Uuid, String, Option)>, - predicates: Vec<(String, Vec)>, - // PostgreSQL snapshot bounds include writes in progress during capture. - xmax: String, - active: Vec, -} - -/// One revision and its captured access scope for a bounded Redis body read. -#[derive(Deserialize, Serialize)] -pub struct BodyRecord { - entry: Entry, - revision: String, - policy: (Uuid, String, Option), -} - -impl Manifest { - /// Separate capture metadata from revision records for direct body lookup. - pub fn body_records(&self) -> Result<(Self, Vec)> { - let policies = self - .policy - .iter() - .map(|p| (p.0, p)) - .collect::>(); - let records = self - .entries - .iter() - .zip(&self.revisions) - .map(|(entry, revision)| { - Ok(BodyRecord { - entry: entry.clone(), - revision: revision.clone(), - policy: (*policies.get(&entry.channel_id).ok_or_else(|| { - DbError::InvalidData("missing snapshot authority".into()) - })?) - .clone(), - }) - }) - .collect::>>()?; - let mut metadata = self.clone(); - metadata.entries.clear(); - metadata.revisions.clear(); - metadata.policy.clear(); - metadata.predicates.clear(); - Ok((metadata, records)) - } - - /// Restore only requested records with the original capture boundary. - pub fn with_body_records(mut self, records: Vec) -> Self { - let mut policies = std::collections::BTreeMap::new(); - for record in records { - self.entries.push(record.entry); - self.revisions.push(record.revision); - policies.insert(record.policy.0, record.policy); - } - self.policy = policies.into_values().collect(); - self - } -} - -async fn begin(db: &Db) -> Result> { - let connection = crate::observability::acquire_writer( - &db.pool, - crate::observability::WriterOperation::SubscriptionHistory, - ) - .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") - .execute(&mut *tx) - .await?; - sqlx::query("SET LOCAL statement_timeout='2s'") - .execute(&mut *tx) - .await?; - Ok(tx) -} - -async fn policy( - conn: &mut PgConnection, - community: CommunityId, - reader: &[u8], - query: &ArtifactQuery, -) -> Result)>> { - // Resolve membership once. OR permission predicates over a join can select - // repeated scans for a community whose statistics are still sparse. - let members = sqlx::query("SELECT channel_id,xmin::text AS version FROM channel_members WHERE community_id=$1 AND pubkey=$2 AND removed_at IS NULL LIMIT 50001") - .bind(community.as_uuid()).bind(reader).fetch_all(&mut *conn).await?; - if members.len() > MAX_ROWS { - return Err(DbError::ArtifactSnapshotLimit); - } - let members = members - .into_iter() - .map(|r| { - Ok(( - r.try_get::("channel_id")?, - r.try_get::("version")?, - )) - }) - .collect::>>()?; - let mut qb = QueryBuilder::::new( - "SELECT c.id,c.xmin::text AS version,c.ttl_seconds FROM channels c WHERE c.community_id=", - ); - qb.push_bind(community.as_uuid()) - .push(" AND c.deleted_at IS NULL AND (") - .push(super::channel::legacy_channel_type_sql("c.channel_type")) - .push(" OR c.id=ANY(") - .push_bind( - query - .tags - .iter() - .filter(|(n, _)| n == "h") - .flat_map(|(_, v)| v.iter().filter_map(|s| s.parse::().ok())) - .collect::>(), - ) - .push(")) AND (c.visibility='open' OR c.id=ANY(") - .push_bind(members.keys().copied().collect::>()) - .push(")) ORDER BY c.id LIMIT ") - .push_bind((MAX_ROWS + 1) as i64); - let rows = qb.build().fetch_all(&mut *conn).await?; - if rows.len() > MAX_ROWS { - return Err(DbError::ArtifactSnapshotLimit); - } - let channels = rows - .iter() - .map(|r| r.get::("id")) - .collect::>(); - let homes = current_rows(conn, community, channels, query) - .await? - .into_iter() - .map(|(e, _)| e.channel_id) - .collect::>(); - let rows = rows - .into_iter() - .filter(|r| homes.contains(&r.get::("id"))) - .collect::>(); - if rows - .iter() - .any(|r| r.get::, _>("ttl_seconds").is_some()) - { - return Err(DbError::ArtifactSnapshotUnsupportedScope); - } - rows.into_iter() - .map(|r| { - Ok(( - r.try_get("id")?, - r.try_get("version")?, - members.get(&r.try_get::("id")?).cloned(), - )) - }) - .collect() -} - -async fn body_policy( - conn: &mut PgConnection, - community: CommunityId, - reader: &[u8], - channels: Vec, -) -> Result)>> { - let members = sqlx::query("SELECT channel_id,xmin::text AS version FROM channel_members WHERE community_id=$1 AND pubkey=$2 AND channel_id=ANY($3) AND removed_at IS NULL") - .bind(community.as_uuid()).bind(reader).bind(&channels).fetch_all(&mut *conn).await?; - let members = members - .into_iter() - .map(|r| { - Ok(( - r.try_get::("channel_id")?, - r.try_get::("version")?, - )) - }) - .collect::>>()?; - let rows = sqlx::query("SELECT id,xmin::text AS version,visibility::text,ttl_seconds FROM channels WHERE community_id=$1 AND id=ANY($2) AND deleted_at IS NULL ORDER BY id") - .bind(community.as_uuid()).bind(channels).fetch_all(&mut *conn).await?; - let mut result = vec![]; - for row in rows { - let id = row.try_get::("id")?; - if row.try_get::, _>("ttl_seconds")?.is_some() { - return Err(DbError::ArtifactSnapshotReset); - } - if row.try_get::("visibility")? == "open" || members.contains_key(&id) { - result.push((id, row.try_get("version")?, members.get(&id).cloned())); - } - } - Ok(result) -} - -async fn current_rows( - conn: &mut PgConnection, - community: CommunityId, - channels: Vec, - query: &ArtifactQuery, -) -> Result> { - let mut heads = QueryBuilder::::new( - "SELECT artifact_id,event_id FROM artifact_heads WHERE community_id=", - ); - heads - .push_bind(community.as_uuid()) - .push(" AND NOT deleted AND channel_id=ANY(") - .push_bind(channels) - .push(")"); - if let Some((_, types)) = query.tags.iter().find(|(name, _)| name == "type") { - heads - .push(" AND artifact_type=ANY(") - .push_bind(types) - .push(")"); - } - heads.push(" LIMIT ").push_bind((MAX_ROWS + 1) as i64); - let heads = heads.build().fetch_all(&mut *conn).await?; - if heads.len() > MAX_ROWS { - return Err(DbError::ArtifactSnapshotLimit); - } - let heads = heads - .into_iter() - .map(|r| { - Ok(( - r.try_get::, _>("event_id")?, - r.try_get::("artifact_id")?, - )) - }) - .collect::>>()?; - let mut events = QueryBuilder::::new( - "SELECT e.id,e.channel_id,e.xmin::text AS version FROM events e WHERE e.community_id=", - ); - events - .push_bind(community.as_uuid()) - .push(" AND e.kind=45010 AND e.deleted_at IS NULL AND e.id=ANY(") - .push_bind(heads.keys().cloned().collect::>()) - .push(")"); - super::artifact_query::tag_predicates(&mut events, query); - events.push(" LIMIT ").push_bind((MAX_ROWS + 1) as i64); - let rows = events.build().fetch_all(&mut *conn).await?; - if rows.len() > MAX_ROWS { - return Err(DbError::ArtifactSnapshotLimit); - } - let mut result = Vec::with_capacity(rows.len()); - for row in rows { - let id: Vec = row.try_get("id")?; - let artifact_id = heads - .get(&id) - .copied() - .ok_or_else(|| DbError::InvalidData("snapshot head missing".into()))?; - result.push(( - Entry { - artifact_id, - revision_id: hex::encode(id), - channel_id: row.try_get("channel_id")?, - }, - row.try_get("version")?, - )); - } - result.sort_unstable_by_key(|(entry, _)| entry.artifact_id); - Ok(result) -} - -impl Db { - /// Capture all matching current identities in one bounded database snapshot. - pub async fn capture_artifact_manifest( - &self, - community: CommunityId, - reader: &[u8], - query: &ArtifactQuery, - ) -> Result { - let mut tx = begin(self).await?; - let snapshot: String = sqlx::query_scalar("SELECT pg_current_snapshot()::text") - .fetch_one(&mut *tx) - .await?; - let parts: Vec<_> = snapshot.split(':').collect(); - let xmax = parts - .get(1) - .ok_or_else(|| DbError::InvalidData("invalid database snapshot".into()))? - .parse::() - .map_err(|_| DbError::InvalidData("invalid database snapshot".into()))?; - let active = parts - .get(2) - .ok_or_else(|| DbError::InvalidData("invalid database snapshot".into()))? - .split(',') - .filter(|v| !v.is_empty()) - .map(|v| v.parse::().map(|n| (n as u32).to_string())) - .collect::, _>>() - .map_err(|_| DbError::InvalidData("invalid database snapshot".into()))?; - let policy = policy(&mut tx, community, reader, query).await?; - let rows = current_rows( - &mut tx, - community, - policy.iter().map(|(id, _, _)| *id).collect(), - query, - ) - .await?; - let (entries, revisions) = rows.into_iter().unzip(); - let manifest = Manifest { - entries, - revisions, - policy, - predicates: query.tags.clone(), - xmax: (xmax as u32).to_string(), - active, - }; - if serde_json::to_vec(&manifest)?.len() > MAX_BYTES { - return Err(DbError::ArtifactSnapshotLimit); - } - tx.commit().await?; - Ok(manifest) - } - - /// Recheck the full manifest and fetch only requested captured revisions. - /// An empty request validates the completion boundary without reading bodies. - pub async fn read_artifact_manifest( - &self, - community: CommunityId, - reader: &[u8], - manifest: &Manifest, - requested: &[String], - body_bytes: usize, - ) -> Result> { - if manifest.entries.len() != manifest.revisions.len() || requested.len() > 100 { - return Err(DbError::InvalidData( - "invalid artifact snapshot read".into(), - )); - } - let selected; - let complete = requested.is_empty(); - let manifest = if complete { - manifest - } else { - let mut entries = vec![]; - let mut revisions = vec![]; - for id in requested { - let index = manifest - .entries - .iter() - .position(|entry| &entry.revision_id == id) - .ok_or_else(|| DbError::InvalidData("revision outside snapshot".into()))?; - entries.push(manifest.entries[index].clone()); - revisions.push(manifest.revisions[index].clone()); - } - let homes = entries - .iter() - .map(|e| e.channel_id) - .collect::>(); - selected = Manifest { - entries, - revisions, - policy: manifest - .policy - .iter() - .filter(|(id, _, _)| homes.contains(id)) - .cloned() - .collect(), - predicates: manifest.predicates.clone(), - xmax: manifest.xmax.clone(), - active: manifest.active.clone(), - }; - &selected - }; - let mut tx = begin(self).await?; - let current_policy = if complete { - let query = ArtifactQuery { - view: ArtifactView::Current, - tags: manifest.predicates.clone(), - limit: 1, - offset: 0, - }; - policy(&mut tx, community, reader, &query) - .await - .map_err(|e| match e { - DbError::ArtifactSnapshotUnsupportedScope => DbError::ArtifactSnapshotReset, - other => other, - })? - } else { - body_policy( - &mut tx, - community, - reader, - manifest.policy.iter().map(|(id, _, _)| *id).collect(), - ) - .await? - }; - if current_policy != manifest.policy { - return Err(DbError::ArtifactSnapshotReset); - } - let ids = manifest - .entries - .iter() - .map(|e| e.artifact_id) - .collect::>(); - let revisions = manifest - .entries - .iter() - .map(|e| { - hex::decode(&e.revision_id) - .map_err(|_| DbError::InvalidData("invalid captured revision".into())) - }) - .collect::>>()?; - // Fetch each bounded set once. A join over correlated arrays can choose - // repeated scans when a new community has no statistics yet. - let heads = sqlx::query("SELECT artifact_id,channel_id,deleted FROM artifact_heads WHERE community_id=$1 AND artifact_id=ANY($2)") - .bind(community.as_uuid()).bind(&ids).fetch_all(&mut *tx).await?; - let heads = heads - .into_iter() - .map(|r| { - Ok(( - r.try_get::("artifact_id")?, - ( - r.try_get::("channel_id")?, - r.try_get::("deleted")?, - ), - )) - }) - .collect::>>()?; - let rows = sqlx::query("SELECT id,channel_id,xmin::text AS version FROM events WHERE community_id=$1 AND id=ANY($2) AND deleted_at IS NULL LIMIT 50001") - .bind(community.as_uuid()).bind(&revisions).fetch_all(&mut *tx).await?; - if rows.len() != manifest.entries.len() { - return Err(DbError::ArtifactSnapshotReset); - } - let visible = rows - .into_iter() - .map(|r| { - Ok(( - hex::encode(r.try_get::, _>("id")?), - ( - r.try_get::("channel_id")?, - r.try_get::("version")?, - ), - )) - }) - .collect::>>()?; - for (entry, version) in manifest.entries.iter().zip(&manifest.revisions) { - if heads.get(&entry.artifact_id) != Some(&(entry.channel_id, false)) - || visible.get(&entry.revision_id) != Some(&(entry.channel_id, version.clone())) - { - return Err(DbError::ArtifactSnapshotReset); - } - } - // age(xid) uses signed transaction distance, including XID wraparound. - // Active capture transactions must also be checked when they commit. - let changed: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM artifact_revisions r LEFT JOIN events e \ - ON e.community_id=r.community_id AND e.id=r.event_id \ - WHERE r.community_id=$1 AND r.artifact_id=ANY($2) \ - AND (age(r.xmin)<=age($3::text::xid) OR r.xmin::text=ANY($4)) \ - AND (e.id IS NULL OR e.deleted_at IS NOT NULL \ - OR EXISTS(SELECT 1 FROM jsonb_array_elements(e.tags) tag \ - WHERE tag->>0='op' AND tag->>1 IN ('delete','move'))))", - ) - .bind(community.as_uuid()) - .bind(&ids) - .bind(&manifest.xmax) - .bind(&manifest.active) - .fetch_one(&mut *tx) - .await?; - if changed { - return Err(DbError::ArtifactSnapshotReset); - } - let requested = requested - .iter() - .map(|s| { - let index = manifest - .entries - .iter() - .position(|e| &e.revision_id == s) - .ok_or_else(|| DbError::InvalidData("revision outside snapshot".into()))?; - Ok(revisions[index].clone()) - }) - .collect::>>()?; - if requested.is_empty() { - tx.commit().await?; - return Ok(vec![]); - } - // Check serialized row sizes before transferring content from Postgres. - let bytes: i64 = sqlx::query_scalar( - "SELECT coalesce(sum(octet_length(to_json(content)::text)+octet_length(tags::text)+1024),0)::bigint \ - FROM events WHERE community_id=$1 AND id=ANY($2)", - ).bind(community.as_uuid()).bind(&requested).fetch_one(&mut *tx).await?; - if bytes > body_bytes as i64 { - return Err(DbError::ArtifactSnapshotLimit); - } - let rows = sqlx::query("SELECT id,pubkey,created_at,kind,tags,content,sig,received_at,channel_id FROM events WHERE community_id=$1 AND id=ANY($2) ORDER BY id") - .bind(community.as_uuid()).bind(requested).fetch_all(&mut *tx).await?; - let mut events = vec![]; - for row in rows { - events.push( - crate::event::row_to_stored_event(row)? - .ok_or_else(|| DbError::InvalidData("invalid snapshot event".into()))?, - ); - } - tx.commit().await?; - Ok(events) - } -} diff --git a/crates/buzz-db/src/store/artifact_sync.rs b/crates/buzz-db/src/store/artifact_sync.rs new file mode 100644 index 00000000000..32067986e56 --- /dev/null +++ b/crates/buzz-db/src/store/artifact_sync.rs @@ -0,0 +1,214 @@ +//! Indexed artifact changes. Each page reads current authority on the writer. +use crate::{Db, DbError, Result}; +use buzz_core::{artifact::ArtifactQuery, CommunityId, StoredEvent}; +use chrono::{DateTime, Utc}; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::{QueryBuilder, Row}; +use uuid::Uuid; + +/// Maximum changes or requested identities in one page. +pub const PAGE_ROWS: usize = 100; + +/// A stable position within one timestamp window. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Position { + /// Database timestamp of the change. + pub at: DateTime, + /// Change source, which separates equal keys from different tables. + pub source: i32, + /// Stable identity within that source. + pub id: String, +} + +/// A changed identity that requires a current, permission-scoped read. +pub struct Candidate { + /// Position used after this candidate is processed. + pub position: Position, + /// Artifact to revalidate, absent for a removed membership. + pub artifact_id: Option, + /// Removed channel belonging to the authenticated reader's membership. + pub removed_channel: Option, +} + +impl Db { + /// Read server time and the community's access-policy recovery boundary. + pub async fn artifact_sync_boundary( + &self, + community: CommunityId, + ) -> Result<(DateTime, DateTime)> { + sqlx::query_as("SELECT clock_timestamp(),artifact_policy_modified_at FROM communities WHERE id=$1 AND deletion_state='active' AND deleted_at IS NULL") + .bind(community.as_uuid()).fetch_optional(&self.pool).await? + .ok_or_else(|| DbError::InvalidData("artifact sync community unavailable".into())) + } + + /// Find at most one page plus one look-ahead row, without a channel roster. + pub async fn artifact_sync_candidates( + &self, + community: CommunityId, + reader: &[u8], + since: Option>, + until: DateTime, + after: Option<&Position>, + query: &ArtifactQuery, + ) -> Result> { + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET LOCAL statement_timeout='2s'") + .execute(&mut *tx) + .await?; + let types = query + .tags + .iter() + .filter(|(n, _)| n == "type") + .flat_map(|(_, v)| v.iter().cloned()) + .collect::>(); + let named = query + .tags + .iter() + .filter(|(n, _)| n == "h") + .flat_map(|(_, v)| v.iter().filter_map(|id| id.parse::().ok())) + .collect::>(); + let rows = sqlx::query(CANDIDATES) + .bind(community.as_uuid()) + .bind(reader) + .bind(since) + .bind(until) + .bind(after.map(|p| p.at)) + .bind(after.map(|p| p.source)) + .bind(after.map(|p| p.id.as_str())) + .bind(types) + .bind(named) + .bind((PAGE_ROWS + 1) as i64) + .bind(buzz_core::channel::LEGACY_CHANNEL_TYPES.to_vec()) + .fetch_all(&mut *tx) + .await?; + let candidates = rows + .into_iter() + .map(|r| { + Ok(Candidate { + position: Position { + at: r.try_get("at")?, + source: r.try_get("source")?, + id: r.try_get("id")?, + }, + artifact_id: r.try_get("artifact_id")?, + removed_channel: r.try_get("removed_channel")?, + }) + }) + .collect::>>()?; + tx.commit().await?; + Ok(candidates) + } + + /// Return current matching events for bounded known identities. + /// Omitted identities have the same unavailable meaning for every cause. + pub async fn artifact_sync_current( + &self, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, + ids: &[Uuid], + ) -> Result> { + if ids.len() > PAGE_ROWS { + return Err(DbError::ArtifactSyncLimit); + } + if ids.is_empty() { + return Ok(vec![]); + } + let mut tags = query.tags.clone(); + tags.push(("d".into(), ids.iter().map(Uuid::to_string).collect())); + let narrowed = ArtifactQuery { + view: query.view, + tags, + limit: PAGE_ROWS as i64, + offset: 0, + }; + let connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL statement_timeout='2s'") + .execute(&mut *tx) + .await?; + let mut qb = QueryBuilder::new("SELECT e.id,e.pubkey,e.created_at,e.kind,e.tags,e.content,e.sig,e.received_at,e.channel_id FROM events e "); + super::artifact_query::predicates(&mut qb, community, reader, &narrowed); + qb.push(" LIMIT ").push_bind(PAGE_ROWS as i64); + let mut events = Vec::new(); + let mut bytes = 0; + { + let mut rows = qb.build().fetch(&mut *tx); + while let Some(row) = rows.try_next().await? { + if let Some(event) = crate::event::row_to_stored_event(row)? { + bytes += serde_json::to_vec(&event.event) + .map_err(|e| DbError::InvalidData(e.to_string()))? + .len() + + 512; + if bytes > 8_388_608 { + return Err(DbError::ArtifactSyncLimit); + } + events.push(event); + } + } + } + let homes = events + .iter() + .filter_map(|e| e.channel_id) + .collect::>(); + let ttl:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM channels WHERE community_id=$1 AND id=ANY($2) AND ttl_seconds IS NOT NULL)") + .bind(community.as_uuid()).bind(homes).fetch_one(&mut *tx).await?; + if ttl { + return Err(DbError::ArtifactSyncUnsupportedScope); + } + tx.commit().await?; + Ok(events) + } +} + +const CANDIDATES: &str = r#" +WITH readable AS NOT MATERIALIZED ( + SELECT c.id FROM channels c + WHERE c.community_id=$1 AND c.deleted_at IS NULL + AND (c.channel_type::text=ANY($11) OR c.id=ANY($9)) + AND (c.visibility='open' OR EXISTS ( + SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id + AND m.channel_id=c.id AND m.pubkey=$2 AND m.removed_at IS NULL)) +), changes AS ( + SELECT a.modified_at AS at,1 AS source,a.artifact_id::text AS id, + a.artifact_id,NULL::uuid AS removed_channel + FROM artifact_heads a + WHERE a.community_id=$1 AND a.modified_at >= COALESCE($3::timestamptz,'epoch'::timestamptz) + AND a.modified_at <= $4 AND a.channel_id IN (SELECT id FROM readable) + AND (cardinality($8::text[])=0 OR a.artifact_type=ANY($8)) + UNION ALL + SELECT m.sync_modified_at,0,m.channel_id::text,NULL::uuid,m.channel_id + FROM channel_members m WHERE m.community_id=$1 AND m.pubkey=$2 + AND $3::timestamptz IS NOT NULL AND m.sync_modified_at >= $3 + AND m.sync_modified_at <= $4 AND m.removed_at IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM readable r WHERE r.id=m.channel_id) + UNION ALL + SELECT m.sync_modified_at,2,a.artifact_id::text,a.artifact_id,NULL::uuid + FROM channel_members m JOIN artifact_heads a + ON a.community_id=m.community_id AND a.channel_id=m.channel_id + WHERE m.community_id=$1 AND m.pubkey=$2 AND m.removed_at IS NULL + AND $3::timestamptz IS NOT NULL AND m.sync_modified_at >= $3 + AND m.sync_modified_at <= $4 AND a.channel_id IN (SELECT id FROM readable) + AND (cardinality($8::text[])=0 OR a.artifact_type=ANY($8)) + UNION ALL + SELECT r.modified_at,3,encode(r.event_id,'hex'),r.artifact_id,NULL::uuid + FROM artifact_revisions r WHERE r.community_id=$1 + AND r.source_channel_id IS NOT NULL AND r.source_channel_id IN (SELECT id FROM readable) + AND $3::timestamptz IS NOT NULL AND r.modified_at >= $3 AND r.modified_at <= $4 +) +SELECT * FROM changes WHERE $5::timestamptz IS NULL OR (at,source,id)>($5,$6,$7) +ORDER BY at,source,id LIMIT $10 +"#; diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index a14cb9aa026..5fd1e4d7322 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -155,6 +155,7 @@ pub async fn create_channel( INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1, $2, $3, 'owner', $4) ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + sync_modified_at = CASE WHEN channel_members.removed_at IS NOT NULL THEN clock_timestamp() ELSE channel_members.sync_modified_at END, removed_at = NULL, removed_by = NULL, role = EXCLUDED.role @@ -391,6 +392,7 @@ async fn insert_channel_rows( INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1, $2, $3, 'owner', $4) ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + sync_modified_at = CASE WHEN channel_members.removed_at IS NOT NULL THEN clock_timestamp() ELSE channel_members.sync_modified_at END, removed_at = NULL, removed_by = NULL, role = EXCLUDED.role @@ -899,10 +901,26 @@ pub async fn update_channel( .execute(tx.conn()) .await?; } + let policy_changed = if updates.visibility.is_some() || updates.ttl_seconds.is_some() { + sqlx::query_scalar::<_,bool>("SELECT (($3::text IS NOT NULL AND visibility::text IS DISTINCT FROM $3) OR ($4 AND ttl_seconds IS DISTINCT FROM $5::int)) FROM channels WHERE community_id=$1 AND id=$2 FOR UPDATE") + .bind(community_id.as_uuid()).bind(channel_id).bind(updates.visibility.as_deref()) + .bind(updates.ttl_seconds.is_some()).bind(updates.ttl_seconds.flatten()) + .fetch_optional(tx.conn()).await?.unwrap_or(false) + } else { + false + }; let result = q.execute(tx.conn()).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + if policy_changed { + sqlx::query( + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2)", + ) + .bind(community_id.as_uuid()).bind(channel_id) + .execute(tx.conn()) + .await?; + } tx.commit().await?; get_channel_with_operation( @@ -1099,6 +1117,14 @@ pub async fn soft_delete_channel( .execute(tx.conn()) .await?; + if result.rows_affected() > 0 { + sqlx::query( + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2)", + ) + .bind(community_id.as_uuid()).bind(channel_id) + .execute(tx.conn()) + .await?; + } tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 28e64ff61c8..a46c4c28ef5 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -426,6 +426,7 @@ pub async fn insert_auto_membership_in_transaction( INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1, $2, $3, 'member'::member_role, $4) ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + sync_modified_at = CASE WHEN channel_members.removed_at IS NOT NULL THEN clock_timestamp() ELSE channel_members.sync_modified_at END, removed_at = NULL, removed_by = NULL, role = EXCLUDED.role @@ -776,6 +777,7 @@ pub async fn put_member( INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1, $2, $3, $4::member_role, $5) ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + sync_modified_at = CASE WHEN channel_members.removed_at IS NOT NULL THEN clock_timestamp() ELSE channel_members.sync_modified_at END, removed_at = NULL, removed_by = NULL, role = EXCLUDED.role @@ -907,7 +909,7 @@ pub async fn remove_member( let result = sqlx::query( r#" UPDATE channel_members - SET removed_at = NOW(), removed_by = $1 + SET removed_at = clock_timestamp(), sync_modified_at = clock_timestamp(), removed_by = $1 WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL "#, ) diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index 860584aec46..369eb14a9a2 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -192,6 +192,7 @@ pub async fn create_dm( INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1, $2, $3, 'member', $4) ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET + sync_modified_at = CASE WHEN channel_members.removed_at IS NOT NULL THEN clock_timestamp() ELSE channel_members.sync_modified_at END, removed_at = NULL, removed_by = NULL, role = EXCLUDED.role diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index a53b82944b0..b562d591923 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1510,6 +1510,8 @@ pub(crate) async fn soft_delete_event_and_update_thread_in_tx( let deleted = purged > 0 || result.rows_affected() > 0; if deleted { + sqlx::query("UPDATE artifact_heads SET modified_at=clock_timestamp() WHERE community_id=$1 AND artifact_id=(SELECT artifact_id FROM artifact_revisions WHERE community_id=$1 AND event_id=$2) AND event_id=$2") + .bind(community_id.as_uuid()).bind(event_id).execute(tx.conn()).await?; if let Some(pid) = parent_event_id { sqlx::query( "UPDATE thread_metadata \ diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 353ab9d76ee..a38e9d3e37a 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -77,8 +77,8 @@ pub mod workflow; pub mod artifact; mod artifact_query; -/// Stable artifact manifests and current access validation. -pub mod artifact_snapshot; +/// Timestamp-based incremental artifact reads. +pub mod artifact_sync; #[cfg(test)] mod artifact_postgres_tests; diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 305ab18de29..581e004d955 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -707,7 +707,7 @@ pub async fn execute_kick_with_marker( let kick = sqlx::query( r#" UPDATE channel_members - SET removed_at = NOW(), removed_by = $1 + SET removed_at = clock_timestamp(), sync_modified_at = clock_timestamp(), removed_by = $1 WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL "#, ) @@ -1694,7 +1694,7 @@ pub async fn deploy_kick_member( let result = sqlx::query( r#" UPDATE channel_members - SET removed_at = NOW(), removed_by = $1 + SET removed_at = clock_timestamp(), sync_modified_at = clock_timestamp(), removed_by = $1 WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL "#, ) diff --git a/crates/buzz-deletion/src/artifact_snapshots.rs b/crates/buzz-deletion/src/artifact_snapshots.rs deleted file mode 100644 index c0e5f870d65..00000000000 --- a/crates/buzz-deletion/src/artifact_snapshots.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Redis cleanup includes shared artifact snapshots. -use super::*; - -/// Shared eviction unit for bounded, short-lived artifact snapshots. -pub const ARTIFACT_SNAPSHOT_STORE: &str = "buzz:{artifact-snapshots-v1}:store"; - -/// Remove one community's snapshots and their quota charges atomically. -pub async fn purge_artifact_snapshots( - pool: &deadpool_redis::Pool, - community: buzz_core::CommunityId, -) -> Result { - let mut connection = pool.get().await?; - snapshot_namespace(&mut connection, community, true).await -} - -async fn snapshot_namespace( - connection: &mut deadpool_redis::Connection, - community: buzz_core::CommunityId, - remove: bool, -) -> Result { - Ok(redis::Script::new(r#" -local stored = redis.call('HGET', KEYS[1], 'ids') -if not stored then return 0 end -local ids = cjson.decode(stored) -local retained, count = {}, 0 -for _, id in ipairs(ids) do - if string.sub(id, 1, string.len(ARGV[1])) == ARGV[1] then - count = count + 1 - if ARGV[2] == '1' then - local rows = cjson.decode(redis.call('HGET', KEYS[1], 'rows:' .. id) or '[]') - for _, revision in ipairs(rows) do redis.call('HDEL', KEYS[1], 'body:' .. id .. ':' .. revision) end - local size = tonumber(redis.call('HGET', KEYS[1], 'size:' .. id)) - redis.call('HINCRBY', KEYS[1], 'total', -size) - redis.call('HDEL', KEYS[1], 'expiry:' .. id, 'size:' .. id, 'owner:' .. id, - 'data:' .. id, 'progress:' .. id, 'meta:' .. id, 'rows:' .. id) - end - else - table.insert(retained, id) - end -end -if ARGV[2] == '1' then redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) end -return count -"#).key(ARTIFACT_SNAPSHOT_STORE) - .arg(format!("buzz:{{artifact-snapshots-v1}}:{}:", community.as_uuid())) - .arg(if remove { "1" } else { "0" }) - .invoke_async(&mut **connection).await?) -} - -pub(super) async fn purge_redis_namespace( - pool: &deadpool_redis::Pool, - community: buzz_core::CommunityId, -) -> Result { - let mut connection = pool.get().await?; - let pattern = format!("buzz:{community}:*"); - let mut cursor = 0u64; - let mut deleted = snapshot_namespace(&mut connection, community, true).await?; - loop { - let (next, keys): (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(&pattern) - .arg("COUNT") - .arg(1000) - .query_async(&mut *connection) - .await?; - if !keys.is_empty() { - let count: u64 = redis::cmd("UNLINK") - .arg(&keys) - .query_async(&mut *connection) - .await?; - deleted = deleted.saturating_add(count); - } - if next == 0 { - break; - } - cursor = next; - } - Ok(deleted) -} - -pub(super) fn scan_proves_absence(pages: &[(u64, Vec)]) -> bool { - pages.last().is_some_and(|(cursor, _)| *cursor == 0) - && pages.iter().all(|(_, keys)| keys.is_empty()) -} - -async fn scan_redis_namespace( - connection: &mut deadpool_redis::Connection, - pattern: &str, -) -> Result)>> { - let mut cursor = 0u64; - let mut pages = Vec::new(); - loop { - let page: (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(pattern) - .arg("COUNT") - .arg(1000) - .query_async(&mut **connection) - .await?; - cursor = page.0; - pages.push(page); - if cursor == 0 { - return Ok(pages); - } - } -} - -pub(super) async fn verify_redis_absence( - pool: &deadpool_redis::Pool, - community: buzz_core::CommunityId, -) -> Result<()> { - let mut connection = pool.get().await?; - let pattern = format!("buzz:{community}:*"); - // SCAN is weakly consistent. Two complete empty passes ensure a cursor - // rollover or concurrent expiry cannot make one sparse pass look absent. - let first = scan_redis_namespace(&mut connection, &pattern).await?; - let second = scan_redis_namespace(&mut connection, &pattern).await?; - let snapshots = snapshot_namespace(&mut connection, community, false).await?; - if scan_proves_absence(&first) && scan_proves_absence(&second) && snapshots == 0 { - Ok(()) - } else { - Err(transient( - "logical verification found a Redis namespace key", - )) - } -} - -#[cfg(test)] -mod postgres_tests { - use super::*; - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn artifact_snapshot_community_purge_checks_shared_storage() { - let pool = deadpool_redis::Config::from_url( - std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()), - ) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .unwrap(); - let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); - let id = format!( - "buzz:{{artifact-snapshots-v1}}:{}:fixture", - community.as_uuid() - ); - let mut conn = pool.get().await.unwrap(); - let _: i64 = redis::Script::new( - r#" -local ids = cjson.decode(redis.call('HGET', KEYS[1], 'ids') or '[]') -table.insert(ids, ARGV[1]) -redis.call('HSET', KEYS[1], 'ids', cjson.encode(ids), 'size:' .. ARGV[1], 10, - 'rows:' .. ARGV[1], '["revision"]', 'body:' .. ARGV[1] .. ':revision', 'body') -redis.call('HINCRBY', KEYS[1], 'total', 10) -return 1 -"#, - ) - .key(ARTIFACT_SNAPSHOT_STORE) - .arg(&id) - .invoke_async(&mut *conn) - .await - .unwrap(); - assert!(verify_redis_absence(&pool, community).await.is_err()); - assert_eq!(purge_redis_namespace(&pool, community).await.unwrap(), 1); - verify_redis_absence(&pool, community).await.unwrap(); - let body: Option = redis::cmd("HGET") - .arg(ARTIFACT_SNAPSHOT_STORE) - .arg(format!("body:{id}:revision")) - .query_async(&mut *conn) - .await - .unwrap(); - assert_eq!(body, None); - } -} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 6e1cbe4187e..c3fb598523a 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -24,12 +24,6 @@ use serde::Serialize; use tokio_util::sync::CancellationToken; use uuid::Uuid; -mod artifact_snapshots; -#[cfg(test)] -use artifact_snapshots::scan_proves_absence; -pub use artifact_snapshots::{purge_artifact_snapshots, ARTIFACT_SNAPSHOT_STORE}; -use artifact_snapshots::{purge_redis_namespace, verify_redis_absence}; - /// Fleet-wide object cap for one observational taxonomy sweep. const DEFAULT_SWEEP_OBJECT_CAP: u64 = 10_000_000; /// Keys per frozen side-table chunk (one `DeleteObjects`-sized unit × 10). @@ -1694,6 +1688,85 @@ async fn publish_disconnect_community( Ok(()) } +async fn purge_redis_namespace( + pool: &deadpool_redis::Pool, + community: buzz_core::CommunityId, +) -> Result { + let mut connection = pool.get().await?; + let pattern = format!("buzz:{community}:*"); + let mut cursor = 0u64; + let mut deleted = 0u64; + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(&pattern) + .arg("COUNT") + .arg(1000) + .query_async(&mut *connection) + .await?; + if !keys.is_empty() { + let count: u64 = redis::cmd("UNLINK") + .arg(&keys) + .query_async(&mut *connection) + .await?; + deleted = deleted.saturating_add(count); + } + if next == 0 { + break; + } + cursor = next; + } + Ok(deleted) +} + +fn scan_proves_absence(pages: &[(u64, Vec)]) -> bool { + pages.last().is_some_and(|(cursor, _)| *cursor == 0) + && pages.iter().all(|(_, keys)| keys.is_empty()) +} + +async fn scan_redis_namespace( + connection: &mut deadpool_redis::Connection, + pattern: &str, +) -> Result)>> { + let mut cursor = 0u64; + let mut pages = Vec::new(); + loop { + let page: (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(pattern) + .arg("COUNT") + .arg(1000) + .query_async(&mut **connection) + .await?; + cursor = page.0; + pages.push(page); + if cursor == 0 { + return Ok(pages); + } + } +} + +async fn verify_redis_absence( + pool: &deadpool_redis::Pool, + community: buzz_core::CommunityId, +) -> Result<()> { + let mut connection = pool.get().await?; + let pattern = format!("buzz:{community}:*"); + // SCAN is weakly consistent. Two complete empty passes ensure a cursor + // rollover or concurrent expiry cannot make one sparse pass look absent. + let first = scan_redis_namespace(&mut connection, &pattern).await?; + let second = scan_redis_namespace(&mut connection, &pattern).await?; + if scan_proves_absence(&first) && scan_proves_absence(&second) { + Ok(()) + } else { + Err(transient( + "logical verification found a Redis namespace key", + )) + } +} + fn sweep_object_cap() -> u64 { std::env::var("BUZZ_DELETION_SWEEP_MAX_OBJECTS") .ok() diff --git a/crates/buzz-relay/src/api/artifact.rs b/crates/buzz-relay/src/api/artifact.rs index dd6300d55d8..9a60763a887 100644 --- a/crates/buzz-relay/src/api/artifact.rs +++ b/crates/buzz-relay/src/api/artifact.rs @@ -47,14 +47,11 @@ async fn execute( "artifact queries require exactly one filter", )); } - if filters[0].get("artifact").and_then(Value::as_str) == Some("snapshot") { + if filters[0].get("artifact").and_then(Value::as_str) == Some("sync") { if count { - return Err(api_error( - StatusCode::BAD_REQUEST, - "snapshots require /query", - )); + return Err(api_error(StatusCode::BAD_REQUEST, "sync requires /query")); } - return super::artifact_snapshot::query(state, tenant, reader, &filters[0]).await; + return super::artifact_sync::query(state, tenant, reader, &filters[0]).await; } let query = buzz_core::artifact::parse_query(&filters[0]) .map_err(|e| api_error(StatusCode::BAD_REQUEST, e))?; diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index 01baf591131..2519510cf4c 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -120,7 +120,7 @@ impl Fixture { .await .unwrap(); let status = response.status(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + let bytes = axum::body::to_bytes(response.into_body(), 8 * 1024 * 1024) .await .unwrap(); (status, serde_json::from_slice(&bytes).unwrap()) @@ -487,5 +487,5 @@ async fn generic_d_lookup_matches_before_limit() { assert_eq!(ids, expected, "{body}"); } -#[path = "artifact_postgres_tests/snapshot_postgres_tests.rs"] -mod snapshot_postgres_tests; +#[path = "artifact_postgres_tests/sync_postgres_tests.rs"] +mod sync_postgres_tests; diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs deleted file mode 100644 index d1775f99c94..00000000000 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/snapshot_postgres_tests.rs +++ /dev/null @@ -1,694 +0,0 @@ -//! Snapshot protocol journeys through the production HTTP router. -use super::*; -use axum::http::StatusCode; -use base64::Engine; -use deadpool_redis::redis; -use sha2::Digest; - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_traverses_ten_thousand_tasks_with_archives_and_exact_bodies() { - let _ = tracing_subscriber::fmt() - .with_max_level(tracing::Level::WARN) - .try_init(); - let f = Fixture::new().await; - let started = std::time::Instant::now(); - let mut events = Vec::new(); - let homes = (0..10_050) - .map(|n| if n == 0 { f.home } else { Uuid::new_v4() }) - .collect::>(); - for chunk in homes[1..].chunks(500) { - sqlx::query( - "INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) \ - SELECT $1,id,id::text,'private','stream',$2 FROM unnest($3::uuid[]) id", - ) - .bind(f.community.as_uuid()) - .bind(f.owner.public_key().as_bytes().as_slice()) - .bind(chunk) - .execute(&f.pool) - .await - .unwrap(); - sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) SELECT $1,id,$2,'owner' FROM unnest($3::uuid[]) id") - .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()).bind(chunk) - .execute(&f.pool).await.unwrap(); - } - for (n, home) in homes.iter().enumerate() { - let id = Uuid::new_v4(); - let mut event = f.revision_as(&f.owner, *home, id, "create", None); - let content = json!({"status": if n >= 10_000 { "archived" } else { "open" }, "notes":"x".repeat(4400)}).to_string(); - event = EventBuilder::new(Kind::Custom(45010), content) - .tags(event.tags.clone()) - .sign_with_keys(&f.owner) - .unwrap(); - events.push(event); - } - // Bulk setup avoids measuring one HTTP write per task in the read journey. - for chunk in events.chunks(500) { - let values = chunk.iter().map(|event| json!({"id":event.id.to_hex(),"tags":event.tags, - "content":event.content,"sig":event.sig.to_string(),"created_at":event.created_at.as_secs(),"channel_id":event.tags.iter().find(|t| t.as_slice()[0]=="h").unwrap().as_slice()[1]})).collect::>(); - sqlx::query("INSERT INTO events(community_id,id,pubkey,created_at,kind,tags,content,sig,channel_id) \ - SELECT $1,decode(x.id,'hex'),$2,to_timestamp(x.created_at),45010,x.tags,x.content,decode(x.sig,'hex'),x.channel_id \ - FROM jsonb_to_recordset($3) x(id text,tags jsonb,content text,sig text,created_at bigint,channel_id uuid)") - .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()) - .bind(json!(values)) - .execute(&f.pool).await.unwrap(); - } - sqlx::query("INSERT INTO artifact_heads(community_id,artifact_id,event_id,channel_id,artifact_type) \ - SELECT community_id,(tags->1->>1)::uuid,id,channel_id,'buzz.task' FROM events WHERE community_id=$1 AND kind=45010") - .bind(f.community.as_uuid()).execute(&f.pool).await.unwrap(); - sqlx::query("INSERT INTO artifact_revisions(community_id,event_id,artifact_id) SELECT community_id,event_id,artifact_id FROM artifact_heads WHERE community_id=$1") - .bind(f.community.as_uuid()).execute(&f.pool).await.unwrap(); - let (status, first) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.task"]}}]), - ) - .await; - assert!(status.is_success(), "{status}: {first}"); - let token = first["token"].as_str().unwrap(); - assert_eq!(first["entries"].as_array().unwrap().len(), 1000); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":token}]), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); - let (status, result) = f - .request_as( - &f.peer, - "/query", - json!([{"artifact":"snapshot","action":"complete","token":token}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - assert_eq!(result["code"], "snapshot_reset"); - - let mut conn = f.state.redis_pool.get().await.unwrap(); - let snapshot_key = format!( - "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", - f.community.as_uuid(), - f.owner.public_key().to_hex(), - token - ); - let internal: Vec = redis::cmd("HGET") - .arg("buzz:{artifact-snapshots-v1}:store") - .arg(format!("data:{snapshot_key}")) - .query_async(&mut *conn) - .await - .unwrap(); - let internal: Value = serde_json::from_slice(&internal).unwrap(); - let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":token,"cursor":internal["cursors"][2]}])).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); - drop(conn); - let fresh = f.revision(Uuid::new_v4(), "create", None); - f.publish(&fresh).await; - let updated = f.revision( - events[0] - .tags - .iter() - .find(|t| t.as_slice()[0] == "d") - .unwrap() - .as_slice()[1] - .parse() - .unwrap(), - "update", - Some(&events[0]), - ); - f.publish(&updated).await; - let mut entries = first["entries"].as_array().unwrap().clone(); - let mut next = first["nextToken"].clone(); - while let Some(cursor) = next.as_str() { - let request = - json!([{"artifact":"snapshot","action":"page","token":token,"cursor":cursor}]); - let (status, result) = f.request("/query", request.clone()).await; - assert!(status.is_success(), "{status}: {result}"); - let (status, retry) = f.request("/query", request).await; - assert!(status.is_success(), "{status}: {retry}"); - assert_eq!(result, retry); - assert!(serde_json::to_vec(&result).unwrap().len() <= 262_144); - entries.extend(result["entries"].as_array().unwrap().clone()); - next = result["nextToken"].clone(); - } - assert_eq!(entries.len(), 10_050); - let identities = entries - .iter() - .map(|e| e["artifactId"].as_str().unwrap()) - .collect::>(); - assert_eq!(identities.len(), entries.len()); - assert!(!entries.iter().any(|e| e["revisionId"] == fresh.id.to_hex())); - assert!(entries - .iter() - .any(|e| e["revisionId"] == events[0].id.to_hex())); - // A second relay state serves real signed HTTP requests with the same - // shared manifest. It holds no local copy of the capture. - let mut other = bridge_handler_test_state().await.unwrap(); - Arc::make_mut(&mut Arc::get_mut(&mut other).unwrap().config).require_auth_token = true; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}/query", listener.local_addr().unwrap()); - let router = crate::router::build_router(other); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap(); - let mut body_count = 0; - for chunk in entries.chunks(100) { - let ids = chunk - .iter() - .map(|e| e["revisionId"].clone()) - .collect::>(); - let (status, result) = signed_query( - &f, - &client, - &url, - json!([{"artifact":"snapshot","action":"bodies","token":token,"revisionIds":ids}]), - ) - .await; - assert!(status.is_success(), "{status}: {result}"); - assert_eq!(result["events"].as_array().unwrap().len(), chunk.len()); - for value in result["events"].as_array().unwrap() { - let event: Event = serde_json::from_value(value.clone()).unwrap(); - event.verify().unwrap(); - } - body_count += chunk.len(); - } - assert_eq!(body_count, 10_050); - let (status, complete) = signed_query( - &f, - &client, - &url, - json!([{"artifact":"snapshot","action":"complete","token":token}]), - ) - .await; - server.abort(); - assert!(status.is_success(), "{status}: {complete}"); - assert_eq!(complete, json!({"version":1,"token":token,"complete":true})); - assert!(started.elapsed().as_secs() < 300); - eprintln!( - "10,050 records, {} bytes per typical signed event, 101 body batches and completion: {:?}", - serde_json::to_vec(&events[0]).unwrap().len(), - started.elapsed() - ); - - let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"bodies","token":token,"revisionIds":[fresh.id.to_hex()]}])).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); - sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") - .bind(f.community.as_uuid()).bind(homes[1]).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":token,"cursor":first["nextToken"]}])).await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - assert!(result.get("entries").is_none()); - let mut conn = f.state.redis_pool.get().await.unwrap(); - let key = format!( - "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", - f.community.as_uuid(), - f.owner.public_key().to_hex(), - token - ); - let _: i64 = redis::cmd("HDEL") - .arg("buzz:{artifact-snapshots-v1}:store") - .arg(format!("data:{key}")) - .query_async(&mut *conn) - .await - .unwrap(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":token}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - assert_eq!(result["code"], "snapshot_reset"); - cleanup(&f).await; -} - -async fn signed_query( - f: &Fixture, - client: &reqwest::Client, - url: &str, - body: Value, -) -> (StatusCode, Value) { - let bytes = serde_json::to_vec(&body).unwrap(); - let auth = EventBuilder::new(Kind::Custom(27235), "") - .tags([ - Tag::parse(["u", &format!("https://{}/query", f.host)]).unwrap(), - Tag::parse(["method", "POST"]).unwrap(), - Tag::parse(["payload", &hex::encode(sha2::Sha256::digest(&bytes))]).unwrap(), - Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), - ]) - .sign_with_keys(&f.owner) - .unwrap(); - let authorization = format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&auth).unwrap()) - ); - let response = client - .post(url) - .header("host", &f.host) - .header("content-type", "application/json") - .header("authorization", authorization) - .body(bytes) - .send() - .await - .unwrap(); - let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); - let body = serde_json::from_slice(&response.bytes().await.unwrap()).unwrap(); - (status, body) -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_rechecks_access_and_exact_query_scope() { - for scenario in ["revoke", "regrant", "recreate", "redact", "predicate"] { - let f = Fixture::new().await; - let id = Uuid::new_v4(); - let event = f.revision_as(&f.owner, f.private, id, "create", None); - f.publish(&event).await; - let (status, capture) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#title":["Review task"]}}]), - ) - .await; - assert!(status.is_success(), "{scenario}: {capture}"); - let token = capture["token"].as_str().unwrap(); - match scenario { - "revoke" | "regrant" => { - sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") - .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - if scenario == "regrant" { - sqlx::query("UPDATE channel_members SET removed_at=NULL WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") - .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - } - } - "recreate" => { - sqlx::query("DELETE FROM channel_members WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") - .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')") - .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - } - "redact" => { - sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .execute(&f.pool) - .await - .unwrap(); - } - "predicate" => { - let update = f.revision_as(&f.owner, f.private, id, "update", Some(&event)); - let tags = update - .tags - .iter() - .map(|tag| { - if tag.as_slice()[0] == "title" { - Tag::parse(["title", "Changed task"]).unwrap() - } else { - tag.clone() - } - }) - .collect::>(); - let update = EventBuilder::new(Kind::Custom(45010), &update.content) - .tags(tags) - .sign_with_keys(&f.owner) - .unwrap(); - f.publish(&update).await; - } - _ => unreachable!(), - } - for action in ["bodies", "complete"] { - let mut filter = json!({"artifact":"snapshot","action":action,"token":token}); - if action == "bodies" { - filter["revisionIds"] = json!([event.id.to_hex()]); - } - let (status, result) = f.request("/query", json!([filter])).await; - if scenario == "predicate" && action == "bodies" { - assert!(status.is_success(), "{scenario}: {result}"); - assert_eq!(result["events"][0]["id"], event.id.to_hex()); - } else { - assert_eq!(status, StatusCode::CONFLICT, "{scenario}: {result}"); - assert_eq!(result["code"], "snapshot_reset"); - assert!(result.get("events").is_none()); - } - } - cleanup(&f).await; - } - let f = Fixture::new().await; - let public = f.revision(Uuid::new_v4(), "create", None); - let private = f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None); - f.publish(&public).await; - f.publish(&private).await; - let (_, capture) = f - .request_as( - &f.peer, - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.task"]}}]), - ) - .await; - assert_eq!(capture["entries"].as_array().unwrap().len(), 1); - sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'member')") - .bind(f.community.as_uuid()).bind(f.private).bind(f.peer.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - let (status, result) = f - .request_as( - &f.peer, - "/query", - json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - let (_, owner_capture) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.task"]}}]), - ) - .await; - sqlx::query("UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3") - .bind(f.community.as_uuid()).bind(f.private).bind(f.owner.public_key().as_bytes().as_slice()).execute(&f.pool).await.unwrap(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"bodies", - "token":owner_capture["token"],"revisionIds":[public.id.to_hex()]}]), - ) - .await; - assert!(status.is_success(), "{result}"); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":owner_capture["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - cleanup(&f).await; -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_rejects_matching_ttl_and_accepts_permanent_channel_defaults() { - let f = Fixture::new().await; - let home = Uuid::new_v4(); - let create = EventBuilder::new(Kind::Custom(9007), "") - .tags([ - Tag::parse(["h", &home.to_string()]).unwrap(), - Tag::parse(["name", "Permanent task"]).unwrap(), - Tag::parse(["visibility", "private"]).unwrap(), - ]) - .sign_with_keys(&f.owner) - .unwrap(); - f.publish(&create).await; - let ttl: Option = - sqlx::query_scalar("SELECT ttl_seconds FROM channels WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(home) - .fetch_one(&f.pool) - .await - .unwrap(); - assert_eq!(ttl, None); - let event = f.revision_as(&f.owner, home, Uuid::new_v4(), "create", None); - f.publish(&event).await; - sqlx::query("UPDATE channels SET ttl_seconds=60 WHERE community_id=$1 AND id=$2") - .bind(f.community.as_uuid()) - .bind(f.private) - .execute(&f.pool) - .await - .unwrap(); - let (_, permanent) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.task"]}}]), - ) - .await; - assert_eq!(permanent["entries"].as_array().unwrap().len(), 1); - let (_, empty) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.absent"]}}]), - ) - .await; - assert_eq!(empty["entries"], json!([])); - let event = f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None); - f.publish(&event).await; - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current","#type":["buzz.task"]}}]), - ) - .await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}"); - assert_eq!(result["code"], "snapshot_unsupported_scope"); - assert!(result.get("token").is_none()); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":permanent["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - cleanup(&f).await; -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_limits_lock_wait_and_keeps_the_capture_retryable() { - let f = Fixture::new().await; - f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; - let (_, capture) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"start", - "query":{"artifact":"current"}}]), - ) - .await; - let mut lock = f.pool.begin().await.unwrap(); - sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") - .execute(&mut *lock) - .await - .unwrap(); - let started = std::time::Instant::now(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{result}"); - assert!(started.elapsed().as_secs() < 8); - lock.rollback().await.unwrap(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), - ) - .await; - assert!(status.is_success(), "{result}"); - cleanup(&f).await; -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_tokens_expiry_and_allocation_are_bounded() { - let f = Fixture::new().await; - f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; - let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current"}}]); - let (_, first) = f.request("/query", start.clone()).await; - let (status, second) = f.request("/query", start.clone()).await; - assert!(status.is_success(), "{second}"); - let (status, result) = f.request("/query", start.clone()).await; - assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{result}"); - let other = Fixture::new().await; - let (status, result) = other - .request_as( - &f.owner, - "/query", - json!([{"artifact":"snapshot","action":"complete","token":first["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - let (status, result) = f.request("/query", json!([{"artifact":"snapshot","action":"page","token":first["token"],"cursor":"a".repeat(32),"query":{"artifact":"current"}}])).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); - let token = first["token"].as_str().unwrap(); - let key = format!( - "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", - f.community.as_uuid(), - f.owner.public_key().to_hex(), - token - ); - let mut conn = f.state.redis_pool.get().await.unwrap(); - let data: Vec = redis::cmd("HGET") - .arg("buzz:{artifact-snapshots-v1}:store") - .arg(format!("data:{key}")) - .query_async(&mut *conn) - .await - .unwrap(); - let mut data: Value = serde_json::from_slice(&data).unwrap(); - data["expires_at"] = json!(0); - let _: i64 = redis::cmd("HSET") - .arg("buzz:{artifact-snapshots-v1}:store") - .arg(format!("data:{key}")) - .arg(serde_json::to_vec(&data).unwrap()) - .arg(format!("expiry:{key}")) - .arg(0) - .query_async(&mut *conn) - .await - .unwrap(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":token}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - let (status, result) = f.request("/query", start).await; - assert!(status.is_success(), "{result}"); - cleanup(&f).await; - f.state.redis_pool.close(); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":second["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{result}"); -} - -async fn cleanup(f: &Fixture) { - buzz_deletion::purge_artifact_snapshots(&f.state.redis_pool, f.community) - .await - .unwrap(); -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_new_matching_home_resets_then_a_fresh_capture_completes() { - let f = Fixture::new().await; - f.publish(&f.revision(Uuid::new_v4(), "create", None)).await; - let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current","#type":["buzz.task"]}}]); - let (_, capture) = f.request("/query", start.clone()).await; - let home = Uuid::new_v4(); - let create = EventBuilder::new(Kind::Custom(9007), "") - .tags([ - Tag::parse(["h", &home.to_string()]).unwrap(), - Tag::parse(["name", "New task"]).unwrap(), - Tag::parse(["visibility", "private"]).unwrap(), - ]) - .sign_with_keys(&f.owner) - .unwrap(); - f.publish(&create).await; - f.publish(&f.revision_as(&f.owner, home, Uuid::new_v4(), "create", None)) - .await; - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":capture["token"]}]), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT, "{result}"); - let (status, fresh) = f.request("/query", start).await; - assert!(status.is_success(), "{fresh}"); - assert_eq!(fresh["entries"].as_array().unwrap().len(), 2); - let (status, result) = f - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":fresh["token"]}]), - ) - .await; - assert!(status.is_success(), "{result}"); - cleanup(&f).await; -} - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_deletion_removes_only_its_community_and_fences_capture() { - let f = Fixture::new().await; - let other = Fixture::new().await; - let event = f.revision(Uuid::new_v4(), "create", None); - f.publish(&event).await; - let start = json!([{"artifact":"snapshot","action":"start","query":{"artifact":"current"}}]); - let (status, captured) = f.request("/query", start.clone()).await; - assert!(status.is_success(), "{captured}"); - let (status, retained) = other.request("/query", start.clone()).await; - assert!(status.is_success(), "{retained}"); - let token = captured["token"].as_str().unwrap(); - let snapshot_key = format!( - "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", - f.community.as_uuid(), - f.owner.public_key().to_hex(), - token - ); - let mut conn = f.state.redis_pool.get().await.unwrap(); - let bytes_before: usize = redis::cmd("HGET") - .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) - .arg("total") - .query_async(&mut *conn) - .await - .unwrap(); - let charge: usize = redis::cmd("HGET") - .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) - .arg(format!("size:{snapshot_key}")) - .query_async(&mut *conn) - .await - .unwrap(); - let metadata: Vec = redis::cmd("HGET") - .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) - .arg(format!("meta:{snapshot_key}")) - .query_async(&mut *conn) - .await - .unwrap(); - let metadata: Value = serde_json::from_slice(&metadata).unwrap(); - assert_eq!(metadata["manifest"]["entries"], json!([])); - assert_eq!(metadata["manifest"]["policy"], json!([])); - let tenant = crate::tenant::bind_community(&f.state.db, &f.host) - .await - .unwrap(); - let mut tx = f.pool.begin().await.unwrap(); - sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") - .bind(f.community.as_uuid().to_string()).execute(&mut *tx).await.unwrap(); - sqlx::query("UPDATE communities SET deletion_state='quiescing' WHERE id=$1") - .bind(f.community.as_uuid()) - .execute(&mut *tx) - .await - .unwrap(); - tx.commit().await.unwrap(); - // An already-bound HTTP request must also fail at the external write seam. - let denied = - crate::api::artifact_snapshot::query(&f.state, &tenant, &f.owner.public_key(), &start[0]) - .await; - assert_eq!(denied.unwrap_err().0, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!( - buzz_deletion::purge_artifact_snapshots(&f.state.redis_pool, f.community) - .await - .unwrap(), - 1 - ); - let bytes_after: usize = redis::cmd("HGET") - .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) - .arg("total") - .query_async(&mut *conn) - .await - .unwrap(); - assert_eq!(bytes_after, bytes_before - charge); - let fields: Vec = redis::cmd("HKEYS") - .arg(buzz_deletion::ARTIFACT_SNAPSHOT_STORE) - .query_async(&mut *conn) - .await - .unwrap(); - assert!(!fields.iter().any(|field| field.contains(&snapshot_key))); - let result = crate::api::artifact_snapshot::query(&f.state, &tenant, &f.owner.public_key(), - &json!({"artifact":"snapshot","action":"bodies","token":token,"revisionIds":[event.id.to_hex()]})).await; - assert_eq!(result.unwrap_err().0, StatusCode::CONFLICT); - let (status, completed) = other - .request( - "/query", - json!([{"artifact":"snapshot","action":"complete","token":retained["token"]}]), - ) - .await; - assert!(status.is_success(), "{completed}"); - cleanup(&other).await; -} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs new file mode 100644 index 00000000000..aa11cbe5fc3 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -0,0 +1,254 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_redaction_and_query_exit_return_removals() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let id = Uuid::new_v4(); + let first = f.revision(id, "create", None); + let first = EventBuilder::new(Kind::Custom(45010), first.content.clone()) + .tags( + first + .tags + .iter() + .cloned() + .chain([Tag::parse(["project", "alpha"]).unwrap()]), + ) + .sign_with_keys(&f.owner) + .unwrap(); + accept(&f, &first).await; + let scope = json!({"artifact":"current","#type":["buzz.task"],"#project":["alpha"]}); + let (_, page) = f + .request("/query", json!([{"artifact":"sync","query":scope}])) + .await; + assert_eq!(page["changes"][0]["type"], "upsert"); + let changed = f.revision(id, "update", Some(&first)); + accept(&f, &changed).await; + let (status, delta) = f + .request( + "/query", + json!([{"artifact":"sync","query":scope,"checkpoint":page["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {delta}"); + assert_eq!( + delta["changes"], + json!([{ "type":"remove","artifactId":id }]) + ); + let before = sync(&f, None).await; + assert!(buzz_db::event::soft_delete_event_and_update_thread( + &f.pool, + f.community, + changed.id.as_bytes(), + None, + None + ) + .await + .unwrap()); + let after = sync(&f, Some(&before["checkpoint"])).await; + assert_eq!( + after["changes"], + json!([{ "type":"remove","artifactId":id }]) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let id = Uuid::new_v4(); + let first = f.revision_as(&f.owner, f.private, id, "create", None); + accept(&f, &first).await; + sqlx::query("UPDATE artifact_heads SET modified_at='2000-01-01' WHERE community_id=$1") + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .unwrap(); + buzz_db::channel_members::add_member( + &f.pool, + f.community, + f.private, + f.peer.public_key().as_bytes(), + buzz_db::channel_members::MemberRole::Member, + Some(f.owner.public_key().as_bytes()), + ) + .await + .unwrap(); + let (_, page) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + buzz_db::channel_members::remove_member( + &f.pool, + f.community, + f.private, + f.peer.public_key().as_bytes(), + f.owner.public_key().as_bytes(), + ) + .await + .unwrap(); + buzz_db::channel_members::add_member( + &f.pool, + f.community, + f.private, + f.peer.public_key().as_bytes(), + buzz_db::channel_members::MemberRole::Member, + Some(f.owner.public_key().as_bytes()), + ) + .await + .unwrap(); + let (status, delta) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":page["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {delta}"); + assert_eq!(delta["changes"][0]["event"]["id"], first.id.to_hex()); + let changed = f.revision_as(&f.owner, f.private, id, "update", Some(&first)); + accept(&f, &changed).await; + let (status, delta) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":delta["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {delta}"); + assert_eq!(delta["changes"][0]["event"]["id"], changed.id.to_hex()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_ignores_unrelated_ttl_and_unchanged_visibility() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; + let first = sync(&f, None).await; + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.home, + buzz_db::channel::ChannelUpdate { + visibility: Some("open".into()), + name: Some("renamed".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.private, + buzz_db::channel::ChannelUpdate { + ttl_seconds: Some(Some(3600)), + ..Default::default() + }, + ) + .await + .unwrap(); + let next = sync(&f, Some(&first["checkpoint"])).await; + assert!(next["checkpoint"].is_string()); + accept( + &f, + &f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None), + ) + .await; + let (status, body) = f + .request("/query", json!([{"artifact":"sync","query":query()}])) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["code"], "sync_unsupported_scope"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_shrinks_large_pages_and_current_batches_fail_explicitly() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let mut ids = Vec::new(); + for _ in 0..40 { + let id = Uuid::new_v4(); + let base = f.revision(id, "create", None); + let event = EventBuilder::new(Kind::Custom(45010), "x".repeat(260_000)) + .tags(base.tags.clone()) + .sign_with_keys(&f.owner) + .unwrap(); + accept(&f, &event).await; + ids.push(id); + } + let first = sync(&f, None).await; + assert_eq!(first["changes"].as_array().unwrap().len(), 20); + assert!(first["nextCursor"].is_string()); + let (status, last) = f + .request( + "/query", + json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {last}"); + assert_eq!(last["changes"].as_array().unwrap().len(), 20); + assert!(last["checkpoint"].is_string()); + let (status, body) = f + .request( + "/query", + json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":ids}]), + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(body["code"], "sync_limit"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_gate_controls_http_reads_and_capability() { + use tower::ServiceExt; + let _guard = SERIAL.lock().await; + let mut f = fixture().await; + for enabled in [false, true] { + Arc::make_mut(&mut Arc::make_mut(&mut f.state).config).artifact_sync_enabled = enabled; + let response = crate::router::build_router(f.state.clone()) + .oneshot( + axum::http::Request::builder() + .uri("/info") + .header("host", &f.host) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(response.status().is_success()); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let info: Value = serde_json::from_slice(&bytes).unwrap(); + if enabled { + assert_eq!( + info["artifacts"]["sync"], + json!({"version":1,"overlapSeconds":120, + "pageRows":100,"currentRows":100,"responseBytes":8388608, + "permanentChannelsOnly":true,"checkpointLifetimeSeconds":2592000}) + ); + } else { + assert!(info["artifacts"].get("sync").is_none()); + } + let (status, _) = f + .request("/query", json!([{"artifact":"sync","query":query()}])) + .await; + assert_eq!( + status, + if enabled { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + } + ); + } +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs new file mode 100644 index 00000000000..751ca3cd049 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -0,0 +1,333 @@ +use super::*; +use axum::http::StatusCode; +#[path = "sync_access_postgres_tests.rs"] +mod access_postgres_tests; +#[path = "sync_scale_postgres_tests.rs"] +mod scale_postgres_tests; + +async fn fixture() -> Fixture { + let mut f = Fixture::new().await; + let mut state = (*f.state).clone(); + Arc::make_mut(&mut state.config).artifact_sync_enabled = true; + f.state = Arc::new(state); + f +} +static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn query() -> Value { + json!({"artifact":"current","#type":["buzz.task"]}) +} +async fn sync(f: &Fixture, checkpoint: Option<&Value>) -> Value { + let mut request = json!({"artifact":"sync","query":query()}); + if let Some(checkpoint) = checkpoint { + request["checkpoint"] = checkpoint.clone(); + } + let (status, body) = f.request("/query", json!([request])).await; + assert!(status.is_success(), "{status}: {body}"); + body +} +async fn accept(f: &Fixture, event: &Event) { + let envelope = buzz_core::artifact::validate(event).unwrap(); + let source = if envelope.op == buzz_core::artifact::ArtifactOp::Move { + f.state + .db + .artifact_home(f.community, envelope.id) + .await + .unwrap() + } else { + None + }; + assert!(matches!( + f.state + .db + .accept_artifact( + f.community, + event, + &envelope, + source, + &f.state.relay_keypair + ) + .await + .unwrap(), + buzz_db::artifact::ArtifactOutcome::Accepted(_) + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_recovers_late_commit_and_returns_explicit_deletion() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let first = sync(&f, None).await; + assert_eq!(first["changes"], json!([])); + let id = Uuid::new_v4(); + let event = f.revision(id, "create", None); + accept(&f, &event).await; + // Model a stamp assigned before the first read, followed by a later commit. + sqlx::query("UPDATE artifact_heads SET modified_at=$1::timestamptz-interval '119 seconds' WHERE community_id=$2 AND artifact_id=$3") + .bind(first["startedAt"].as_str().unwrap()).bind(f.community.as_uuid()).bind(id).execute(&f.pool).await.unwrap(); + let second = sync(&f, Some(&first["checkpoint"])).await; + assert!(second["changes"] + .as_array() + .unwrap() + .iter() + .any(|c| c["event"]["id"] == event.id.to_hex())); + let deleted = f.revision(id, "delete", Some(&event)); + accept(&f, &deleted).await; + let third = sync(&f, Some(&second["checkpoint"])).await; + assert!(third["changes"] + .as_array() + .unwrap() + .iter() + .any(|c| c == &json!({"type":"remove","artifactId":id}))); + assert_eq!(third["overlapSeconds"], 120); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_pages_equal_timestamps_and_replays_the_same_cursor() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + for _ in 0..103 { + accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; + } + sqlx::query("UPDATE artifact_heads SET modified_at='2000-01-01' WHERE community_id=$1") + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .unwrap(); + let first = sync(&f, None).await; + assert_eq!(first["changes"].as_array().unwrap().len(), 100); + assert!(first["checkpoint"].is_null()); + let request = json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]); + let (status, last) = f.request("/query", request.clone()).await; + assert!(status.is_success(), "{status}: {last}"); + assert_eq!(last["changes"].as_array().unwrap().len(), 3); + assert!(last["nextCursor"].is_null()); + assert!(last["checkpoint"].is_string()); + let (_, replay) = f.request("/query", request).await; + assert_eq!(last, replay); + let ids = first["changes"] + .as_array() + .unwrap() + .iter() + .chain(last["changes"].as_array().unwrap()) + .map(|c| c["artifactId"].as_str().unwrap()) + .collect::>(); + assert_eq!(ids.len(), 103); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_current_has_uniform_unavailable_and_rejects_duplicate_ids() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let id = Uuid::new_v4(); + let event = f.revision_as(&f.owner, f.private, id, "create", None); + accept(&f, &event).await; + let missing = Uuid::new_v4(); + let body = json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id,missing,"local-only"]}]); + let (status, body) = f.request_as(&f.peer, "/query", body).await; + assert!(status.is_success(), "{status}: {body}"); + assert_eq!( + body["changes"], + json!([ + {"type":"remove","artifactId":id},{"type":"remove","artifactId":missing},{"type":"remove","artifactId":"local-only"} + ]) + ); + let (status, _) = f + .request( + "/query", + json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id,id]}]), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, body) = f + .request( + "/query", + json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id]}]), + ) + .await; + assert!(status.is_success(), "{status}: {body}"); + assert_eq!(body["changes"][0]["event"]["id"], event.id.to_hex()); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_grant_finds_old_heads_and_removal_names_only_own_membership() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let id = Uuid::new_v4(); + let event = f.revision_as(&f.owner, f.private, id, "create", None); + accept(&f, &event).await; + sqlx::query("UPDATE artifact_heads SET modified_at='2000-01-01' WHERE community_id=$1") + .bind(f.community.as_uuid()) + .execute(&f.pool) + .await + .unwrap(); + let (_, first) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + assert_eq!(first["changes"], json!([])); + buzz_db::channel_members::add_member( + &f.pool, + f.community, + f.private, + f.peer.public_key().as_bytes(), + buzz_db::channel_members::MemberRole::Member, + Some(f.owner.public_key().as_bytes()), + ) + .await + .unwrap(); + let (_, granted) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + ) + .await; + assert!( + granted["changes"] + .as_array() + .unwrap() + .iter() + .any(|c| c["event"]["id"] == event.id.to_hex()), + "{granted}" + ); + buzz_db::channel_members::remove_member( + &f.pool, + f.community, + f.private, + f.peer.public_key().as_bytes(), + f.owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let (_, removed) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":granted["checkpoint"]}]), + ) + .await; + assert_eq!( + removed["changes"], + json!([{ "type":"removeChannel","channelId":f.private }]) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_move_to_hidden_destination_removes_the_source_view() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let id = Uuid::new_v4(); + let first = f.revision(id, "create", None); + accept(&f, &first).await; + let (_, read) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&first)); + accept(&f, &moved).await; + let (_, delta) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":read["checkpoint"]}]), + ) + .await; + assert_eq!( + delta["changes"], + json!([{ "type":"remove","artifactId":id }]) + ); + assert!(!delta.to_string().contains(&f.private.to_string())); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_policy_changes_reset_without_hidden_channel_ids() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + for _ in 0..101 { + accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; + } + let first = sync(&f, None).await; + assert!(first["nextCursor"].is_string()); + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.home, + buzz_db::channel::ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + let (status, body) = f + .request( + "/query", + json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + body, + json!({"code":"sync_reset","error":"sync_reset","reason":"policy_changed"}) + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_progress_binds_reader_query_and_community() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let first = sync(&f, None).await; + for (reader, scope) in [ + (&f.peer, query()), + ( + &f.owner, + json!({"artifact":"current","#type":["other.task"]}), + ), + ] { + let (status, _) = f + .request_as( + reader, + "/query", + json!([{"artifact":"sync","query":scope,"checkpoint":first["checkpoint"]}]), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + let mut other = fixture().await; + Arc::make_mut(&mut other.state).relay_keypair = f.state.relay_keypair.clone(); + let (status, _) = other + .request_as( + &f.owner, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let expired = crate::api::artifact_sync::expired_checkpoint_for_test( + &f.state, + first["checkpoint"].as_str().unwrap(), + ) + .unwrap(); + let (status, body) = f + .request( + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":expired}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["reason"], "checkpoint_expired"); +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs new file mode 100644 index 00000000000..130b1ab43f5 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs @@ -0,0 +1,132 @@ +use super::*; +use base64::Engine; +use sha2::Digest; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let homes = (0..60_001) + .map(|n| if n == 0 { f.home } else { Uuid::new_v4() }) + .collect::>(); + for chunk in homes[1..].chunks(500) { + sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by,archived_at) SELECT $1,id,id::text,'private','stream',$2,'2000-01-01' FROM unnest($3::uuid[]) id") + .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()).bind(chunk).execute(&f.pool).await.unwrap(); + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role,sync_modified_at) SELECT $1,id,$2,'owner','2000-01-01' FROM unnest($3::uuid[]) id") + .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()).bind(chunk).execute(&f.pool).await.unwrap(); + } + sqlx::query("UPDATE channel_members SET sync_modified_at='2000-01-01' WHERE community_id=$1 AND channel_id=ANY($2)") + .bind(f.community.as_uuid()).bind(vec![f.home,f.private]).execute(&f.pool).await.unwrap(); + sqlx::query("UPDATE channels SET archived_at=NULL WHERE community_id=$1 AND id=ANY($2)") + .bind(f.community.as_uuid()) + .bind(&homes[..10_000]) + .execute(&f.pool) + .await + .unwrap(); + let mut event_bytes = 0; + for chunk in homes[..10_050].chunks(500) { + let values=chunk.iter().map(|home| { + let id=Uuid::new_v4(); + let base=f.revision_as(&f.owner,*home,id,"create",None); + let event=EventBuilder::new(Kind::Custom(45010),json!({"notes":"x".repeat(4400)}).to_string()) + .tags(base.tags.clone()).sign_with_keys(&f.owner).unwrap(); + event_bytes=serde_json::to_vec(&event).unwrap().len(); + json!({"id":event.id.to_hex(),"artifact_id":id,"tags":event.tags,"content":event.content,"sig":event.sig.to_string(),"created_at":event.created_at.as_secs(),"channel_id":home}) + }).collect::>(); + sqlx::query("INSERT INTO events(community_id,id,pubkey,created_at,kind,tags,content,sig,channel_id) SELECT $1,decode(x.id,'hex'),$2,to_timestamp(x.created_at),45010,x.tags,x.content,decode(x.sig,'hex'),x.channel_id FROM jsonb_to_recordset($3) x(id text,tags jsonb,content text,sig text,created_at bigint,channel_id uuid)") + .bind(f.community.as_uuid()).bind(f.owner.public_key().as_bytes().as_slice()).bind(json!(values)).execute(&f.pool).await.unwrap(); + sqlx::query("INSERT INTO artifact_heads(community_id,artifact_id,event_id,channel_id,artifact_type,modified_at) SELECT $1,x.artifact_id,decode(x.id,'hex'),x.channel_id,'buzz.task','2000-01-01' FROM jsonb_to_recordset($2) x(id text,artifact_id uuid,channel_id uuid)") + .bind(f.community.as_uuid()).bind(json!(values)).execute(&f.pool).await.unwrap(); + } + sqlx::raw_sql("ANALYZE artifact_heads; ANALYZE channel_members; ANALYZE channels") + .execute(&f.pool) + .await + .unwrap(); + let mut other = (*bridge_handler_test_state().await.unwrap()).clone(); + let config = Arc::make_mut(&mut other.config); + config.require_auth_token = true; + config.artifact_sync_enabled = true; + other.relay_keypair = f.state.relay_keypair.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/query", listener.local_addr().unwrap()); + let router = crate::router::build_router(Arc::new(other)); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + let started = std::time::Instant::now(); + let mut page = sync(&f, None).await; + let mut ids = std::collections::HashSet::new(); + let mut pages = 0; + loop { + pages += 1; + for change in page["changes"].as_array().unwrap() { + assert_eq!(change["type"], "upsert"); + ids.insert(change["artifactId"].as_str().unwrap().to_owned()); + } + if page["nextCursor"].is_null() { + break; + } + let (status, next) = signed_query( + &f, + &client, + &url, + json!([{"artifact":"sync","query":query(),"cursor":page["nextCursor"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {next}"); + page = next; + } + let cold = started.elapsed(); + assert_eq!(ids.len(), 10_050); + assert_eq!(pages, 101); + let warm = std::time::Instant::now(); + let (status, delta) = signed_query( + &f, + &client, + &url, + json!([{"artifact":"sync","query":query(),"checkpoint":page["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {delta}"); + assert_eq!(delta["changes"], json!([])); + assert!(delta["checkpoint"].is_string()); + eprintln!("10,050 tasks; 60,002 memberships; {event_bytes}-byte events; {pages} cold pages: {cold:?}; empty warm sync: {:?}",warm.elapsed()); + server.abort(); +} + +async fn signed_query( + f: &Fixture, + client: &reqwest::Client, + url: &str, + body: Value, +) -> (StatusCode, Value) { + let bytes = serde_json::to_vec(&body).unwrap(); + let auth = EventBuilder::new(Kind::Custom(27235), "") + .tags([ + Tag::parse(["u", &format!("https://{}/query", f.host)]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(sha2::Sha256::digest(&bytes))]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&f.owner) + .unwrap(); + let authorization = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&auth).unwrap()) + ); + let response = client + .post(url) + .header("host", &f.host) + .header("content-type", "application/json") + .header("authorization", authorization) + .body(bytes) + .send() + .await + .unwrap(); + let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); + let body = serde_json::from_slice(&response.bytes().await.unwrap()).unwrap(); + (status, body) +} diff --git a/crates/buzz-relay/src/api/artifact_snapshot.rs b/crates/buzz-relay/src/api/artifact_snapshot.rs deleted file mode 100644 index aad5a86b6e7..00000000000 --- a/crates/buzz-relay/src/api/artifact_snapshot.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! Short-lived shared artifact manifests for signed POST /query requests. -use crate::state::AppState; -use axum::{http::StatusCode, Json}; -use buzz_core::{artifact::ArtifactView, TenantContext}; -use buzz_db::artifact_snapshot::{BodyRecord, Manifest, MAX_BYTES, MAX_ROWS}; -use deadpool_redis::redis; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::{sync::Arc, time::Duration}; -use uuid::Uuid; - -const LIFETIME: u64 = 300; -const PAGE_ROWS: usize = 1000; -const PAGE_BYTES: usize = 262_144; -const BODY_ROWS: usize = 100; -const BODY_BYTES: usize = 8_388_608; -const GLOBAL_BYTES: usize = 64 * 1024 * 1024; -const GLOBAL_COUNT: usize = 32; -const STORE_KEY: &str = buzz_deletion::ARTIFACT_SNAPSHOT_STORE; -static WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); -type Response = Result, (StatusCode, Json)>; - -#[derive(Deserialize)] -#[serde(tag = "action", deny_unknown_fields)] -enum Request { - #[serde(rename = "start")] - Start { artifact: String, query: Value }, - #[serde(rename = "page")] - Page { - artifact: String, - token: String, - cursor: String, - }, - #[serde(rename = "bodies", rename_all = "camelCase")] - Bodies { - artifact: String, - token: String, - revision_ids: Vec, - }, - #[serde(rename = "complete")] - Complete { artifact: String, token: String }, -} -#[derive(Serialize, Deserialize)] -struct Snapshot { - manifest: Manifest, - // Retain the exact scope and generation with the captured identities. - query: Value, - generation: String, - expires_at: u64, - cursors: Vec, -} - -fn error(status: StatusCode, code: &str) -> (StatusCode, Json) { - (status, Json(json!({"error":code,"code":code}))) -} -fn reset() -> (StatusCode, Json) { - error(StatusCode::CONFLICT, "snapshot_reset") -} -fn invalid() -> (StatusCode, Json) { - error(StatusCode::BAD_REQUEST, "snapshot_invalid") -} -fn unavailable() -> (StatusCode, Json) { - error(StatusCode::SERVICE_UNAVAILABLE, "snapshot_unavailable") -} -fn limit() -> (StatusCode, Json) { - error(StatusCode::PAYLOAD_TOO_LARGE, "snapshot_limit") -} -fn db_error( - e: buzz_db::DbError, - tenant: &TenantContext, - reader: &nostr::PublicKey, -) -> (StatusCode, Json) { - match e { - buzz_db::DbError::ArtifactSnapshotReset => reset(), - buzz_db::DbError::ArtifactSnapshotLimit => limit(), - buzz_db::DbError::ArtifactSnapshotUnsupportedScope => error( - StatusCode::UNPROCESSABLE_ENTITY, - "snapshot_unsupported_scope", - ), - error => { - tracing::warn!(community = %tenant.community().as_uuid(), reader = %reader, %error, "artifact snapshot database operation failed"); - unavailable() - } - } -} - -/// Resource contract advertised through NIP-11. -pub(crate) fn capability() -> Value { - json!({"version":1,"maxRows":MAX_ROWS,"pageRows":PAGE_ROWS,"pageBytes":PAGE_BYTES, - "bodyRows":BODY_ROWS,"bodyBytes":BODY_BYTES,"lifetimeSeconds":LIFETIME,"permanentChannelsOnly":true}) -} - -fn key(tenant: &TenantContext, reader: &nostr::PublicKey, token: &str) -> String { - format!( - "buzz:{{artifact-snapshots-v1}}:{}:{}:{}", - tenant.community().as_uuid(), - reader.to_hex(), - token - ) -} -fn valid_token(token: &str) -> bool { - token.len() == 32 - && token - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) -} - -fn page(snapshot: &Snapshot, token: &str, index: usize) -> Response { - let start = index * PAGE_ROWS; - let end = (start + PAGE_ROWS).min(snapshot.manifest.entries.len()); - let value = json!({"version":1,"token":token,"expiresAt":snapshot.expires_at, - "entries":&snapshot.manifest.entries[start..end],"nextToken":snapshot.cursors.get(index)}); - if serde_json::to_vec(&value).map_err(|_| unavailable())?.len() > PAGE_BYTES { - return Err(limit()); - } - Ok(Json(value)) -} - -/// Run one snapshot operation with bounded concurrency and elapsed work. -pub(super) async fn query( - state: &Arc, - tenant: &TenantContext, - reader: &nostr::PublicKey, - filter: &Value, -) -> Response { - let _permit = WORK.try_acquire().map_err(|_| unavailable())?; - tokio::time::timeout( - Duration::from_secs(8), - execute(state, tenant, reader, filter), - ) - .await - .map_err(|_| unavailable())? -} - -async fn execute( - state: &Arc, - tenant: &TenantContext, - reader: &nostr::PublicKey, - filter: &Value, -) -> Response { - let request: Request = serde_json::from_value(filter.clone()).map_err(|_| invalid())?; - let mut conn = state.redis_pool.get().await.map_err(|_| unavailable())?; - let (now, _): (u64, u64) = redis::cmd("TIME") - .query_async(&mut *conn) - .await - .map_err(|_| unavailable())?; - if let Request::Start { artifact, query } = request { - let serving_write = buzz_deletion::acquire_serving_write( - &state.db, - tenant.community(), - "artifact_snapshot", - ) - .await - .map_err(|_| unavailable())?; - if artifact != "snapshot" || query.get("limit").is_some() || query.get("offset").is_some() { - return Err(invalid()); - } - let parsed = buzz_core::artifact::parse_query(&query).map_err(|_| invalid())?; - if parsed.view != ArtifactView::Current { - return Err(invalid()); - } - let manifest = state - .db - .capture_artifact_manifest(tenant.community(), reader.as_bytes(), &parsed) - .await - .map_err(|e| db_error(e, tenant, reader))?; - let token = Uuid::new_v4().simple().to_string(); - let pages = manifest.entries.len().div_ceil(PAGE_ROWS).max(1); - let expires_at = now + LIFETIME; - let snapshot = Snapshot { - manifest, - query, - generation: token.clone(), - expires_at, - cursors: (1..pages) - .map(|_| Uuid::new_v4().simple().to_string()) - .collect(), - }; - let data = serde_json::to_vec(&snapshot).map_err(|_| unavailable())?; - if data.len() > MAX_BYTES { - return Err(limit()); - } - let response = page(&snapshot, &token, 0)?; - let snapshot_key = key(tenant, reader, &token); - let principal_key = key(tenant, reader, "active"); - let (metadata, records) = snapshot - .manifest - .body_records() - .map_err(|e| db_error(e, tenant, reader))?; - let metadata = serde_json::to_vec(&Snapshot { - manifest: metadata, - query: Value::Null, - generation: token.clone(), - expires_at, - cursors: vec![], - }) - .map_err(|_| unavailable())?; - let rows = records - .iter() - .zip(&snapshot.manifest.entries) - .map(|(record, entry)| { - Ok(( - entry.revision_id.clone(), - serde_json::to_string(record).map_err(|_| unavailable())?, - )) - }) - .collect::, _>>()?; - let rows = serde_json::to_vec(&rows).map_err(|_| unavailable())?; - // Charge duplicate records, field names, and Redis object overhead. - let stored_bytes = data.len() + metadata.len() + rows.len() + records.len() * 512; - let registration = async { - redis::Script::new(REGISTER) - .key(STORE_KEY) - .arg(expires_at) - .arg(stored_bytes) - .arg(GLOBAL_BYTES) - .arg(GLOBAL_COUNT) - .arg(data) - .arg(&snapshot_key) - .arg(principal_key) - .arg(metadata) - .arg(rows) - .invoke_async::(&mut *conn) - .await - }; - let stored = serving_write - .protect(registration) - .await - .map_err(|_| unavailable())? - .map_err(|_| unavailable())?; - serving_write.finish().await.map_err(|_| unavailable())?; - if stored == 0 { - return Err(limit()); - } - return Ok(response); - } - let (artifact, token) = match &request { - Request::Page { - artifact, token, .. - } - | Request::Bodies { - artifact, token, .. - } - | Request::Complete { artifact, token } => (artifact, token), - Request::Start { .. } => return Err(invalid()), - }; - if artifact != "snapshot" || !valid_token(token) { - return Err(invalid()); - } - let snapshot_key = key(tenant, reader, token); - let body_ids = match &request { - Request::Bodies { revision_ids, .. } => { - if revision_ids.is_empty() { - return Err(invalid()); - } - if revision_ids.len() > BODY_ROWS { - return Err(limit()); - } - let mut seen = std::collections::HashSet::new(); - if revision_ids - .iter() - .any(|id| buzz_core::artifact::event_id(id).is_err() || !seen.insert(id)) - { - return Err(invalid()); - } - Some(revision_ids) - } - _ => None, - }; - let (data, body_rows) = if let Some(ids) = body_ids { - let mut fields = vec![format!("meta:{snapshot_key}")]; - fields.extend(ids.iter().map(|id| format!("body:{snapshot_key}:{id}"))); - let mut rows: Vec>> = redis::cmd("HMGET") - .arg(STORE_KEY) - .arg(fields) - .query_async(&mut *conn) - .await - .map_err(|_| unavailable())?; - let data = rows.remove(0); - (data, rows) - } else { - let data: Option> = redis::cmd("HGET") - .arg(STORE_KEY) - .arg(format!("data:{snapshot_key}")) - .query_async(&mut *conn) - .await - .map_err(|_| unavailable())?; - (data, vec![]) - }; - let data = data.ok_or_else(reset)?; - if data.len() > MAX_BYTES { - return Err(unavailable()); - } - let mut snapshot: Snapshot = serde_json::from_slice(&data).map_err(|_| unavailable())?; - if snapshot.generation != *token || now >= snapshot.expires_at { - return Err(reset()); - } - if body_ids.is_some() { - let records = body_rows - .into_iter() - .map(|row| { - serde_json::from_slice::(&row.ok_or_else(invalid)?) - .map_err(|_| unavailable()) - }) - .collect::, _>>()?; - snapshot.manifest = snapshot.manifest.with_body_records(records); - } - let requested = match &request { - Request::Bodies { revision_ids, .. } => { - if revision_ids.is_empty() { - return Err(invalid()); - } - if revision_ids.len() > BODY_ROWS { - return Err(limit()); - } - let mut seen = std::collections::HashSet::new(); - for id in revision_ids { - if buzz_core::artifact::event_id(id).is_err() - || !seen.insert(id) - || !snapshot - .manifest - .entries - .iter() - .any(|e| &e.revision_id == id) - { - return Err(invalid()); - } - } - revision_ids.as_slice() - } - _ => &[], - }; - let events = state - .db - .read_artifact_manifest( - tenant.community(), - reader.as_bytes(), - &snapshot.manifest, - requested, - BODY_BYTES, - ) - .await - .map_err(|e| db_error(e, tenant, reader))?; - let pages = snapshot.cursors.len() + 1; - match &request { - Request::Page { cursor, .. } => { - let index = snapshot - .cursors - .iter() - .position(|c| c == cursor) - .ok_or_else(invalid)? - + 1; - let result = page(&snapshot, token, index)?; - let advanced: i64 = redis::Script::new(ADVANCE) - .key(STORE_KEY) - .arg(&snapshot_key) - .arg(index) - .arg(index + 1) - .invoke_async(&mut *conn) - .await - .map_err(|_| unavailable())?; - if advanced == -1 { - return Err(reset()); - } - if advanced == 0 { - return Err(invalid()); - } - Ok(result) - } - Request::Bodies { .. } => { - if events.len() != requested.len() { - return Err(reset()); - } - let events = events.into_iter().map(|e| e.event).collect::>(); - let result = json!({"version":1,"token":token,"events":events}); - if serde_json::to_vec(&result) - .map_err(|_| unavailable())? - .len() - > BODY_BYTES - { - return Err(limit()); - } - Ok(Json(result)) - } - Request::Complete { .. } => { - let valid: i64 = redis::Script::new(COMPLETE) - .key(STORE_KEY) - .arg(&snapshot_key) - .arg(pages) - .invoke_async(&mut *conn) - .await - .map_err(|_| unavailable())?; - match valid { - -1 => Err(reset()), - 1 => Ok(Json(json!({"version":1,"token":token,"complete":true}))), - _ => Err(invalid()), - } - } - Request::Start { .. } => Err(invalid()), - } -} - -// One hash keeps data and accounting in the same eviction unit. -const REGISTER: &str = r#" -local now = tonumber(redis.call('TIME')[1]) -local count, principal, latest = 0, 0, tonumber(ARGV[1]) -local ids = cjson.decode(redis.call('HGET', KEYS[1], 'ids') or '[]') -local retained = {} -for _, id in ipairs(ids) do - local field = 'expiry:' .. id - local expiry = tonumber(redis.call('HGET', KEYS[1], field)) - if expiry <= now then - local size = tonumber(redis.call('HGET', KEYS[1], 'size:' .. id)) - redis.call('HINCRBY', KEYS[1], 'total', -size) - local rows = cjson.decode(redis.call('HGET', KEYS[1], 'rows:' .. id) or '[]') - for _, revision in ipairs(rows) do redis.call('HDEL', KEYS[1], 'body:' .. id .. ':' .. revision) end - redis.call('HDEL', KEYS[1], field, 'size:' .. id, 'owner:' .. id, 'data:' .. id, 'progress:' .. id, 'meta:' .. id, 'rows:' .. id) - else - table.insert(retained, id) - count = count + 1 - latest = math.max(latest, expiry) - if redis.call('HGET', KEYS[1], 'owner:' .. id) == ARGV[7] then principal = principal + 1 end - end -end -redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) -if tonumber(ARGV[1]) <= now or count >= tonumber(ARGV[4]) or principal >= 2 - or tonumber(redis.call('HGET', KEYS[1], 'total') or '0') + tonumber(ARGV[2]) > tonumber(ARGV[3]) then return 0 end -redis.call('HSET', KEYS[1], 'data:' .. ARGV[6], ARGV[5], 'progress:' .. ARGV[6], 1, - 'expiry:' .. ARGV[6], ARGV[1], 'size:' .. ARGV[6], ARGV[2], 'owner:' .. ARGV[6], ARGV[7]) -local rows = cjson.decode(ARGV[9] or '[]') -local revisions = {} -for _, row in ipairs(rows) do - redis.call('HSET', KEYS[1], 'body:' .. ARGV[6] .. ':' .. row[1], row[2]) - table.insert(revisions, row[1]) -end -redis.call('HSET', KEYS[1], 'meta:' .. ARGV[6], ARGV[8] or '', 'rows:' .. ARGV[6], cjson.encode(revisions)) -table.insert(retained, ARGV[6]) -redis.call('HSET', KEYS[1], 'ids', cjson.encode(retained)) -redis.call('HINCRBY', KEYS[1], 'total', ARGV[2]) -redis.call('EXPIREAT', KEYS[1], latest) -return 1 -"#; -const ADVANCE: &str = r#" -local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiry:' .. ARGV[1])) -if not expiry or expiry <= tonumber(redis.call('TIME')[1]) then return -1 end -local progress = tonumber(redis.call('HGET', KEYS[1], 'progress:' .. ARGV[1])) -if tonumber(ARGV[2]) > progress then return 0 end -if tonumber(ARGV[3]) > progress then redis.call('HSET', KEYS[1], 'progress:' .. ARGV[1], ARGV[3]) end -return 1 -"#; -const COMPLETE: &str = r#" -local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiry:' .. ARGV[1])) -if not expiry or expiry <= tonumber(redis.call('TIME')[1]) then return -1 end -if tonumber(redis.call('HGET', KEYS[1], 'progress:' .. ARGV[1])) ~= tonumber(ARGV[2]) then return 0 end -return 1 -"#; - -#[cfg(test)] -#[path = "artifact_snapshot_postgres_tests.rs"] -mod postgres_tests; diff --git a/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs deleted file mode 100644 index e1e0070776b..00000000000 --- a/crates/buzz-relay/src/api/artifact_snapshot_postgres_tests.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Atomic quotas and cleanup use the production Redis scripts. -use super::*; - -#[tokio::test] -#[ignore = "requires Postgres"] -async fn artifact_snapshot_shared_byte_and_count_quotas_survive_cleanup() { - let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); - let pool = deadpool_redis::Config::from_url(url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .unwrap(); - let mut conn = pool.get().await.unwrap(); - let store = format!("buzz:{{artifact-snapshots-v1}}:test:{}", Uuid::new_v4()); - let (now, _): (u64, u64) = redis::cmd("TIME").query_async(&mut *conn).await.unwrap(); - for (id, owner, data, expected) in [ - ("a", "one", "123456", 1), - ("b", "two", "12345", 0), - ("b", "two", "1234", 1), - ("c", "three", "1", 0), - ] { - let result: i64 = redis::Script::new(REGISTER) - .key(&store) - .arg(now + 300) - .arg(data.len()) - .arg(10) - .arg(2) - .arg(data) - .arg(id) - .arg(owner) - .invoke_async(&mut *conn) - .await - .unwrap(); - assert_eq!(result, expected); - } - let _: i64 = redis::cmd("HSET") - .arg(&store) - .arg("expiry:a") - .arg(0) - .query_async(&mut *conn) - .await - .unwrap(); - let result: i64 = redis::Script::new(REGISTER) - .key(&store) - .arg(now + 300) - .arg(6) - .arg(10) - .arg(2) - .arg("123456") - .arg("c") - .arg("three") - .invoke_async(&mut *conn) - .await - .unwrap(); - assert_eq!(result, 1); - let bytes: usize = redis::cmd("HGET") - .arg(&store) - .arg("total") - .query_async(&mut *conn) - .await - .unwrap(); - assert_eq!(bytes, 10); - let removed: Option = redis::cmd("HGET") - .arg(&store) - .arg("data:a") - .query_async(&mut *conn) - .await - .unwrap(); - assert_eq!(removed, None); - let _: i64 = redis::cmd("DEL") - .arg(store) - .query_async(&mut *conn) - .await - .unwrap(); -} diff --git a/crates/buzz-relay/src/api/artifact_sync.rs b/crates/buzz-relay/src/api/artifact_sync.rs new file mode 100644 index 00000000000..0d20f164413 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_sync.rs @@ -0,0 +1,388 @@ +//! Timestamp sync with signed progress and bounded current-identity reads. +use crate::state::AppState; +use axum::{http::StatusCode, Json}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use buzz_core::{ + artifact::{ArtifactQuery, ArtifactView}, + TenantContext, +}; +use buzz_db::artifact_sync::{Candidate, Position, PAGE_ROWS}; +use chrono::{DateTime, Duration, Utc}; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; +use uuid::Uuid; + +const OVERLAP: i64 = 120; +const CHECKPOINT_DAYS: i64 = 30; +const RESPONSE_BYTES: usize = 8_388_608; +static WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); +type Response = Result, (StatusCode, Json)>; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Request { + artifact: String, + query: Value, + action: Option, + checkpoint: Option, + cursor: Option, + artifact_ids: Option>, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Token { + version: u8, + community: Uuid, + reader: String, + query: Value, + at: DateTime, + policy: DateTime, + since: Option>, + position: Option, + cursor: bool, +} + +fn error(status: StatusCode, code: &str) -> (StatusCode, Json) { + (status, Json(json!({"error":code,"code":code}))) +} +fn invalid() -> (StatusCode, Json) { + error(StatusCode::BAD_REQUEST, "sync_invalid") +} +fn unavailable() -> (StatusCode, Json) { + error(StatusCode::SERVICE_UNAVAILABLE, "sync_unavailable") +} +fn limit() -> (StatusCode, Json) { + error(StatusCode::PAYLOAD_TOO_LARGE, "sync_limit") +} +fn reset(reason: &str) -> (StatusCode, Json) { + ( + StatusCode::CONFLICT, + Json(json!({"error":"sync_reset","code":"sync_reset","reason":reason})), + ) +} +fn db_error(e: buzz_db::DbError) -> (StatusCode, Json) { + match e { + buzz_db::DbError::ArtifactSyncLimit => limit(), + buzz_db::DbError::ArtifactSyncUnsupportedScope => { + error(StatusCode::UNPROCESSABLE_ENTITY, "sync_unsupported_scope") + } + error => { + tracing::warn!(%error,"artifact sync database read failed"); + unavailable() + } + } +} + +/// Bounds advertised to clients that use the incremental protocol. +pub(crate) fn capability() -> Value { + json!({"version":1,"overlapSeconds":OVERLAP,"pageRows":PAGE_ROWS, + "currentRows":PAGE_ROWS,"responseBytes":RESPONSE_BYTES,"permanentChannelsOnly":true, + "checkpointLifetimeSeconds":CHECKPOINT_DAYS*86400}) +} + +fn mac(state: &AppState) -> Result, (StatusCode, Json)> { + let mut digest = Sha256::new(); + digest.update(b"buzz-artifact-sync-v1\0"); + digest.update(state.relay_keypair.secret_key().as_secret_bytes()); + Hmac::::new_from_slice(&digest.finalize()).map_err(|_| unavailable()) +} +fn encode(state: &AppState, token: &Token) -> Result)> { + let data = serde_json::to_vec(token).map_err(|_| unavailable())?; + let mut signature = mac(state)?; + signature.update(&data); + Ok(format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&data), + URL_SAFE_NO_PAD.encode(signature.finalize().into_bytes()) + )) +} +fn decode(state: &AppState, value: &str) -> Result)> { + if value.len() > 200_000 { + return Err(invalid()); + } + let (data, signature) = value.split_once('.').ok_or_else(invalid)?; + let data = URL_SAFE_NO_PAD.decode(data).map_err(|_| invalid())?; + let signature = URL_SAFE_NO_PAD.decode(signature).map_err(|_| invalid())?; + let mut check = mac(state)?; + check.update(&data); + check.verify_slice(&signature).map_err(|_| invalid())?; + serde_json::from_slice(&data).map_err(|_| invalid()) +} + +/// Execute one sync operation without a manifest or server-side cursor state. +#[tracing::instrument(skip_all, fields(community_id = %tenant.community(), reader = %reader))] +pub(super) async fn query( + state: &Arc, + tenant: &TenantContext, + reader: &nostr::PublicKey, + filter: &Value, +) -> Response { + if !state.config.artifact_sync_enabled { + return Err(unavailable()); + } + let _permit = WORK.try_acquire().map_err(|_| unavailable())?; + tokio::time::timeout( + std::time::Duration::from_secs(8), + execute(state, tenant, reader, filter), + ) + .await + .map_err(|_| unavailable())? +} + +async fn execute( + state: &Arc, + tenant: &TenantContext, + reader: &nostr::PublicKey, + filter: &Value, +) -> Response { + let request: Request = serde_json::from_value(filter.clone()).map_err(|_| invalid())?; + if request.artifact != "sync" + || request.query.get("limit").is_some() + || request.query.get("offset").is_some() + { + return Err(invalid()); + } + let parsed = buzz_core::artifact::parse_query(&request.query).map_err(|_| invalid())?; + if parsed.view != ArtifactView::Current { + return Err(invalid()); + } + let (now, policy) = state + .db + .artifact_sync_boundary(tenant.community()) + .await + .map_err(db_error)?; + if request.action.as_deref() == Some("current") { + if request.checkpoint.is_some() || request.cursor.is_some() { + return Err(invalid()); + } + let ids = request.artifact_ids.ok_or_else(invalid)?; + if ids.is_empty() + || ids.len() > PAGE_ROWS + || ids.iter().any(|id| id.is_empty() || id.len() > 256) + || ids.iter().collect::>().len() != ids.len() + { + return Err(invalid()); + } + let valid = ids + .iter() + .filter_map(|id| buzz_core::artifact::canonical_uuid(id).ok()) + .collect::>(); + let resolved = current(state, tenant, reader, &parsed, &valid).await?; + let resolved = resolved + .into_iter() + .filter_map(|value| { + value + .get("artifactId") + .and_then(Value::as_str) + .map(|id| (id.to_owned(), value.clone())) + }) + .collect::>(); + let changes = ids + .iter() + .map(|id| { + resolved + .get(id) + .cloned() + .unwrap_or_else(|| json!({"type":"remove","artifactId":id})) + }) + .collect::>(); + let response = json!({"version":1,"startedAt":now,"changes":changes}); + if serde_json::to_vec(&response) + .map_err(|_| unavailable())? + .len() + > RESPONSE_BYTES + { + return Err(limit()); + } + return Ok(Json(response)); + } + if request.action.is_some() + || request.artifact_ids.is_some() + || (request.checkpoint.is_some() && request.cursor.is_some()) + { + return Err(invalid()); + } + let encoded = request.cursor.as_ref().or(request.checkpoint.as_ref()); + let mut token = if let Some(encoded) = encoded { + let previous = decode(state, encoded)?; + if previous.version != 1 + || previous.community != *tenant.community().as_uuid() + || previous.reader != reader.to_hex() + || previous.query != request.query + || previous.cursor != request.cursor.is_some() + || previous.at > now + { + return Err(invalid()); + } + if previous.at < now - Duration::days(CHECKPOINT_DAYS) { + return Err(reset("checkpoint_expired")); + } + if previous.policy != policy { + return Err(reset("policy_changed")); + } + if previous.cursor { + previous + } else { + Token { + since: Some(previous.at - Duration::seconds(OVERLAP)), + at: now, + position: None, + cursor: true, + ..previous + } + } + } else { + Token { + version: 1, + community: *tenant.community().as_uuid(), + reader: reader.to_hex(), + query: request.query, + at: now, + policy, + since: None, + position: None, + cursor: true, + } + }; + let candidates = state + .db + .artifact_sync_candidates( + tenant.community(), + reader.as_bytes(), + token.since, + token.at, + token.position.as_ref(), + &parsed, + ) + .await + .map_err(db_error)?; + let mut selected = candidates.len().min(PAGE_ROWS); + let resolved = loop { + let ids = candidates[..selected] + .iter() + .filter_map(|c| c.artifact_id) + .collect::>() + .into_iter() + .collect::>(); + match current(state, tenant, reader, &parsed, &ids).await { + Err((StatusCode::PAYLOAD_TOO_LARGE, _)) if selected > 1 => selected /= 2, + result => break result?, + } + }; + let more = candidates.len() > selected; + let candidates = &candidates[..selected]; + let resolved = resolved + .into_iter() + .filter_map(|value| { + value + .get("artifactId") + .and_then(Value::as_str) + .map(|id| (id.to_owned(), value.clone())) + }) + .collect::>(); + let mut changes = Vec::new(); + let mut size = 0; + let mut processed = 0; + let mut emitted = HashSet::new(); + for candidate in candidates { + let change = change(candidate, &resolved)?; + let key = change.to_string(); + if !emitted.insert(key) { + token.position = Some(candidate.position.clone()); + processed += 1; + continue; + } + let bytes = serde_json::to_vec(&change) + .map_err(|_| unavailable())? + .len(); + // Leave room for the signed continuation and response fields. + if size + bytes > RESPONSE_BYTES - 262_144 { + if processed == 0 { + return Err(limit()); + } + break; + } + size += bytes; + changes.push(change); + token.position = Some(candidate.position.clone()); + processed += 1; + } + let (_, final_policy) = state + .db + .artifact_sync_boundary(tenant.community()) + .await + .map_err(db_error)?; + if final_policy != token.policy { + return Err(reset("policy_changed")); + } + let more = more || processed < candidates.len(); + let (cursor, checkpoint) = if more { + (Some(encode(state, &token)?), None) + } else { + token.cursor = false; + token.position = None; + token.since = None; + (None, Some(encode(state, &token)?)) + }; + Ok(Json( + json!({"version":1,"startedAt":token.at,"overlapSeconds":OVERLAP, + "changes":changes,"nextCursor":cursor,"checkpoint":checkpoint}), + )) +} + +async fn current( + state: &AppState, + tenant: &TenantContext, + reader: &nostr::PublicKey, + query: &ArtifactQuery, + ids: &[Uuid], +) -> Result, (StatusCode, Json)> { + let events = state + .db + .artifact_sync_current(tenant.community(), reader.as_bytes(), query, ids) + .await + .map_err(db_error)?; + let mut by_id = HashMap::new(); + for stored in events { + let env = buzz_core::artifact::validate(&stored.event).map_err(|_| unavailable())?; + by_id.insert(env.id, stored.event); + } + Ok(ids + .iter() + .map(|id| match by_id.remove(id) { + Some(event) => json!({"type":"upsert","artifactId":id,"event":event}), + None => json!({"type":"remove","artifactId":id}), + }) + .collect()) +} + +fn change( + candidate: &Candidate, + resolved: &HashMap, +) -> Result)> { + if let Some(channel) = candidate.removed_channel { + return Ok(json!({"type":"removeChannel","channelId":channel})); + } + let id = candidate.artifact_id.ok_or_else(unavailable)?; + resolved + .get(&id.to_string()) + .cloned() + .ok_or_else(unavailable) +} + +#[cfg(test)] +pub(super) fn expired_checkpoint_for_test( + state: &AppState, + value: &str, +) -> Result)> { + let mut token = decode(state, value)?; + token.at -= Duration::days(31); + encode(state, &token) +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 27bba6fee04..98f188a149e 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -696,4 +696,4 @@ mod parse_query_tests { } mod artifact; -pub(crate) mod artifact_snapshot; +pub(crate) mod artifact_sync; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index c0e30144099..7ad4ee1a020 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -116,6 +116,8 @@ pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { + /// Enable incremental artifact reads after every serving writer is upgraded. + pub artifact_sync_enabled: bool, /// Address the relay HTTP/WebSocket server binds to. pub bind_addr: SocketAddr, /// Postgres database connection URL. @@ -772,6 +774,9 @@ impl Config { let require_relay_membership = std::env::var("BUZZ_REQUIRE_RELAY_MEMBERSHIP") .map(|v| v == "true" || v == "1") .unwrap_or(false); + let artifact_sync_enabled = std::env::var("BUZZ_ARTIFACT_SYNC_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); // Defaults true → single-pod (N=1) keeps today's huddle behavior. A // horizontally-scaled deployment sets this false; see the field doc. @@ -1427,6 +1432,7 @@ impl Config { partition_manager_advance_enabled, pubkey_allowlist_enabled, require_relay_membership, + artifact_sync_enabled, huddle_audio_available, mesh, mesh_demo_echo, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 5c5ba42776d..01469855802 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -283,7 +283,6 @@ impl RelayInfo { "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", "modes": ["current", "history"], - "snapshot": crate::api::artifact_snapshot::capability(), "websocket_query_extensions": false, "max_tags": buzz_core::artifact::MAX_TAGS, "max_tag_name_bytes": buzz_core::artifact::MAX_TAG_NAME_BYTES, @@ -388,6 +387,9 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st admin_api.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); + if state.config.artifact_sync_enabled { + info.artifacts["sync"] = crate::api::artifact_sync::capability(); + } if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if state.config.buzz_v1_enabled { use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS}; diff --git a/docs/artifact-sync-deployment.md b/docs/artifact-sync-deployment.md new file mode 100644 index 00000000000..719f1f46ca5 --- /dev/null +++ b/docs/artifact-sync-deployment.md @@ -0,0 +1,64 @@ +# Artifact sync deployment + +Incremental artifact sync uses a 120-second overlap and database modification +timestamps. `BUZZ_ARTIFACT_SYNC_ENABLED` is off by default. Enable it only +after the schema, indexes, and every serving writer are upgraded. + +## Schema and indexes + +Migration `0069` adds columns with constant defaults, then changes the defaults +for future inserts. It does not rewrite existing rows or run a backfill. +Old rows remain at the epoch timestamp. An initial read includes them. +New application writers stamp every head change, grant, rejoin, removal, +redaction, and move source. Old application updates do not maintain all these +stamps, so mixed-version serving cannot advertise incremental correctness. + +For a populated database, first build the revision lookup index outside a +transaction, before migration `0068`: + +```sql +CREATE INDEX CONCURRENTLY idx_artifact_revisions_artifact + ON public.artifact_revisions (community_id, artifact_id); +``` + +Then apply `0069_artifact_sync_timestamps.sql` in a +transaction with its lock and statement budgets. The migration is idempotent +for this prebuild step. Then build these indexes outside a transaction: + +```sql +CREATE INDEX CONCURRENTLY artifact_heads_modified + ON public.artifact_heads (community_id, modified_at, artifact_id); +CREATE INDEX CONCURRENTLY artifact_heads_channel + ON public.artifact_heads (community_id, channel_id, artifact_id); +CREATE INDEX CONCURRENTLY channel_members_sync + ON public.channel_members (community_id, pubkey, sync_modified_at, channel_id); +CREATE INDEX CONCURRENTLY artifact_revisions_moves + ON public.artifact_revisions (community_id, modified_at, event_id) + WHERE source_channel_id IS NOT NULL; +``` + +Migration `0070` verifies each index definition and its valid, ready, and live +flags. It refuses a populated table whose index is missing. A fresh empty +database can build the indexes under the migration's short budgets. +Migration `0068` also verifies the exact definition and all three flags. +Remove an invalid index with `DROP INDEX CONCURRENTLY` before a new build. + +## Enable and rollback + +Deploy the timestamp writers to every relay instance with the gate off. +Drain the old writers. All instances must use the same configured relay +signing key, because that key authenticates the progress tokens. Then enable +`BUZZ_ARTIFACT_SYNC_ENABLED=true` on the serving fleet. NIP-11 advertises +`artifacts.sync` only when the gate is enabled. The endpoint refuses sync +requests while the gate is off. + +Before a rollback to an old writer, disable the gate on every serving +instance. Before enabling it again, upgrade every writer and advance +`communities.artifact_policy_modified_at` for each affected active community +in a reviewed maintenance transaction under community deletion admission. +This forces clients to discover current records and revalidate saved IDs. +No local absence scan is permitted. + +The 120-second stamp-to-visibility bound remains an operating assumption. +Runtime measurements are tracked separately. This PR does not authorize +a deployment or a production maintenance write. diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index 1b0b7a43f46..6785d136783 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -107,147 +107,128 @@ Clients validate content before interpreting it as a known type. Unknown types o Artifact text is untrusted. A type name, relationship tag, or payload cannot authorize a privileged action. Moving, deleting, or revoking access cannot erase copies already downloaded. -## Stable manifest transport +## Incremental sync -The relay advertises `artifacts.snapshot.version = 1` in NIP-11. This -extension uses signed `POST /query` requests. Each request contains one -filter. It uses the same community and membership checks as other queries. -The existing `current` and `history` responses keep their array format. - -Start a capture with: +The relay advertises `artifacts.sync` when incremental reads are enabled. +Each signed `POST /query` request contains one filter. The query uses the +same community, relay admission, channel access, and exact tag rules as an +ordinary artifact read. The existing `current` and `history` array responses +keep their format. ```json -[{"artifact":"snapshot","action":"start","query":{"artifact":"current","#type":["buzz.task"]}}] +[{"artifact":"sync","query":{"artifact":"current","#type":["buzz.task"]}}] ``` -The query accepts current-state tag predicates. It does not accept `limit` -or `offset`. The relay captures all matching current identities in one -repeatable-read database transaction. Version 1 supports permanent channels only. A scope with a readable current -artifact in a TTL channel fails before the relay publishes a token. An -unrelated TTL chat does not block a task query. The relay never silently -omits matching artifacts in TTL channels. -Archive status remains client content. -The capture includes archived tasks that match the query. - -Start and page responses have this form: +The initial read pages through current identities. A later read supplies the +last completed `checkpoint`. A continuation supplies only `cursor`, with +the same `query`. The server signs these opaque strings. They bind the +community, reader, exact query, access-policy boundary, and database start +time. The cursor also binds the last page position. Relay instances use the +same configured signing identity, so another instance can serve a continuation. +No server-side manifest or Redis allocation is required. ```json -{"version":1,"token":"opaque","expiresAt":1792600000,"entries":[{"artifactId":"uuid","revisionId":"event-id","channelId":"uuid"}],"nextToken":"opaque-cursor"} +{ + "version": 1, + "startedAt": "2026-10-21T12:00:00.123456Z", + "overlapSeconds": 120, + "changes": [ + {"type":"upsert","artifactId":"22222222-2222-4222-8222-222222222222","event":{}}, + {"type":"remove","artifactId":"33333333-3333-4333-8333-333333333333"}, + {"type":"removeChannel","channelId":"44444444-4444-4444-8444-444444444444"} + ], + "nextCursor": null, + "checkpoint": "opaque" +} ``` -`expiresAt` is an integer in epoch seconds. `token` remains constant for the -capture. A token is bound to its reader, community, query, and capture -generation. It grants no access by itself. The cursor names an immutable -page position within that capture. A page request has this form: +`event` is the complete signed current revision. `remove` has one meaning: +the requested artifact is unavailable for this query. It does not distinguish +a missing identity, deletion, redaction, query exit, or denied access. +`removeChannel` names a removed membership retained for this exact reader. +It applies only to local rows whose current saved home is that channel. +An earlier move upsert to another home therefore survives a later source +channel removal. + +Each pass fixes `startedAt` from the database clock. A later pass reads from +the previous completed start time minus 120 seconds through its own start +time, inclusive. New head writes, move-source records, and membership changes +use database `clock_timestamp()`. The protocol assumes the delay from this +stamp to commit visibility stays within the accepted 120-second overlap. +That overlap is an operating assumption. It is not a measured upper bound. + +Pages order by `(change timestamp, source, stable identity)`. The source +separates membership removals, head changes, grants, and move-source records. +The identity breaks equal timestamp ties. It does not encode creation time. +The time window and page position are stable. The current contents can change +between requests. Replays must be idempotent. A later pass repairs changes +that move beyond the current window. + +Only the final page returns a checkpoint. The client saves it after every +page and local commit succeeds. A failed read keeps the prior checkpoint. +Neither an initial read nor delta completion establishes absence. The client +must not remove unseen rows. A fresh access grant replays older current +heads from that channel. A move retains its source channel in the existing +revision ledger, so a reader who cannot read the destination receives a +current revalidation result without destination metadata. + +### Current reads and write confirmation ```json -[{"artifact":"snapshot","action":"page","token":"opaque","cursor":"opaque-cursor"}] +[{"artifact":"sync","action":"current","query":{"artifact":"current","#type":["buzz.task"]},"artifactIds":["22222222-2222-4222-8222-222222222222"]}] ``` -Each response supplies the cursor for the next page. Repeating a cursor -returns the same entries. A `null` `nextToken` marks the final page. The -relay tracks the pages it serves. Completion fails if a page is skipped. -A lost page response can be retried with the same cursor. +The response has `version`, database `startedAt`, and `changes`. It contains +exactly one upsert or remove for each requested ID, in request order. +The request permits 1–100 unique nonempty strings, each at most 256 UTF-8 +bytes. A string that is not a canonical artifact UUID returns remove. +Duplicates and invalid bounds fail with `sync_invalid`. The server does not +truncate the request. -Fetch exact captured bodies with: +Clients serialize a full delta pass and its commits before a current-ID read. +Live revision pushes request a current-ID read; they do not replace confirmed +rows directly. A current-ID read that starts after publication acceptance +reads that commit or a later state on the writer. It can return a later head +or remove. An empty delta does not confirm an accepted edit. -```json -[{"artifact":"snapshot","action":"bodies","token":"opaque","revisionIds":["event-id"]}] -``` +### Access recovery and bounds + +Relevant channel access changes atomically advance one community policy +stamp. They do not update it on ordinary artifact writes. A different stamp +returns HTTP 409 `sync_reset` with `reason: "policy_changed"`, without naming +private channels. A checkpoint older than 30 days returns the same code with +`reason: "checkpoint_expired"`. + +Recovery runs a new initial read to discover current readable records and +bounded current-ID reads for the IDs already saved locally. These reads +revalidate known IDs without a channel roster or an absence scan. Save new +progress only after discovery, revalidation, and local commits succeed. +Preserve preferences when a record becomes unavailable. -The response is `{"version":1,"token":"opaque","events":[...]}`. It contains -exactly the requested revision IDs. It never substitutes a newer revision. -The client verifies each signature and its artifact, channel, and revision -against the manifest. The client can reuse a verified body that it already -has. Body durability is a client obligation. The relay does not keep body -receipts. +The server retains task heads, membership removal rows, and move-source +ledger records. Removing this recovery history requires an explicit policy +reset before a client can resume. Whole-community deletion closes admission +and purges these rows through the existing deletion path. -Validate the completion boundary with: +NIP-11 advertises: ```json -[{"artifact":"snapshot","action":"complete","token":"opaque"}] +{"version":1,"overlapSeconds":120,"pageRows":100,"currentRows":100,"responseBytes":8388608,"permanentChannelsOnly":true,"checkpointLifetimeSeconds":2592000} ``` -A valid response is `{"version":1,"token":"opaque","complete":true}`. Every request rechecks database authority. Manifest pages and completion -validate the full frozen scope. A body read validates only the requested -captured channels and revisions. A change in another captured channel can -remain undetected until completion; it still prevents absence reconciliation. Access -changes, a missing or redacted captured revision, and deletion or movement -of a captured artifact require a reset. A delete followed by restore, or a -move followed by a move back, still requires a reset. Ordinary post-capture -updates leave the captured revision available. Channel row changes can -conservatively require a reset, including metadata and TTL changes. - -The client starts its active live subscriptions before capture. Loss of the -live socket prevents client completion. Server completion does not prove -that every captured home has a live subscription. Periodic authoritative -captures repair homes outside the active routes. It saves needed bodies before -it reconciles absent identities. Completion alone does not permit deletion -of local writes or live events received after capture. The client uses its -local write boundary to preserve those records. If the live interval is -lost, the client retains local records and starts a new capture. Live -removal hints and periodic captures repair missed access and removal events. -This extension has no durable global change log. - -### Limits and failures - -NIP-11 advertises these fields under `artifacts.snapshot`: - -| Field | Value | -| --- | ---: | -| `version` | 1 | -| `maxRows` | 50000 | -| `pageRows` | 1000 | -| `pageBytes` | 262144 | -| `bodyRows` | 100 | -| `bodyBytes` | 8388608 | -| `lifetimeSeconds` | 300 | -| `permanentChannelsOnly` | true | - -A capture above the row or byte limit fails. It is never truncated. -Capture, manifest pages, and completion have these independent admission limits: - -- The reader has at most 50,000 active channel memberships in the community. This count applies before query filters. Memberships in archived channels count while active. -- The readable channel candidates contain at most 50,000 channels, before the artifact predicates apply. -- The current artifact candidates contain at most 50,000 records, after the immutable type predicate and before other tag predicates. - -A reader above the membership limit receives HTTP 413 `snapshot_limit`, even -if the query matches few or no artifacts. A narrower artifact query does not -remove this membership limit. Version 1 does not support a reader with 60,000 -active memberships. A client route test at that size does not establish -snapshot support. - -Each internal -manifest is at most 16 MiB. Shared state is limited to 32 active -captures and 64 MiB of charged snapshot storage. Each reader has at most two -captures per community. Each process runs at most four snapshot operations -at once. Each SQL statement has a two-second limit. Each operation has an -eight-second elapsed limit. These limits include access checks. - -A bounded error response contains `error` and `code` with the same value: - -| HTTP status | Code | Client action | -| --- | --- | --- | -| 409 | `snapshot_reset` | Retain local records. Start a new capture with bounded backoff. | -| 413 | `snapshot_limit` | Retain local records. Reduce a body batch or query scope where applicable. Never reconcile a partial capture as complete. | -| 422 | `snapshot_unsupported_scope` | Retain local records. Use a scope that contains only permanent channels. | -| 400 | `snapshot_invalid` | Correct the request. | -| 503 | `snapshot_unavailable` | Retry with bounded backoff. Retain local records. | - -A single body above the byte limit fails explicitly. Admission and -membership checks can also return their normal errors. All failures prevent -absence reconciliation. - -Snapshot state uses the existing shared Redis store. It supports requests -across relay processes without session affinity. The complete manifest is -published atomically before its token is returned. The fixed expiry does -not extend on reads. Redis expiry, eviction, or loss requires a reset. A -Redis outage returns a service failure. The relay uses no memory fallback. -One Redis hash holds manifests, direct body records, and quota charges. -Eviction removes them together. Body reads fetch only the requested records. -A serving write lease protects capture registration. Community deletion -removes its records and quota charges. -Each capture has a fixed logical expiry. A bounded allocation step removes -expired captures. The hash expires when its last capture expires. A completed capture remains -replayable until expiry and counts against the resource limits during that -interval. +Pages shrink to the response byte bound. A single oversized result returns +HTTP 413 `sync_limit`. A current-ID batch can return that error; the client +can split the batch. No page or batch silently truncates its result. The +server permits four concurrent operations per process, a two-second SQL +statement budget, and an eight-second operation budget. There is no +identity-wide membership count limit on this path. + +Only matching readable artifacts in permanent channels are supported. +A matching TTL artifact returns HTTP 422 `sync_unsupported_scope`. An +unrelated TTL channel does not block task sync. HTTP 400 `sync_invalid` +rejects invalid requests or tokens. HTTP 503 `sync_unavailable` reports a +service or work-budget failure. These failures do not advance progress. + +The rollout gate is `BUZZ_ARTIFACT_SYNC_ENABLED`, off by default. Follow +[artifact-sync-deployment.md](../artifact-sync-deployment.md) before enabling +it. Sync completion does not establish a live subscription to every channel. diff --git a/migrations/0068_artifact_revision_lookup.sql b/migrations/0068_artifact_revision_lookup.sql index 256c7b1f7d7..707f3f89b1d 100644 --- a/migrations/0068_artifact_revision_lookup.sql +++ b/migrations/0068_artifact_revision_lookup.sql @@ -1,5 +1,24 @@ -SET LOCAL lock_timeout = '5s'; -SET LOCAL statement_timeout = '30s'; +-- Populated databases prebuild this index concurrently. +-- See docs/artifact-sync-deployment.md. +SET LOCAL lock_timeout = '1s'; +SET LOCAL statement_timeout = '5s'; -CREATE INDEX idx_artifact_revisions_artifact - ON artifact_revisions (community_id, artifact_id); +DO $$ +BEGIN + IF to_regclass('public.idx_artifact_revisions_artifact') IS NULL THEN + IF EXISTS (SELECT 1 FROM public.artifact_revisions LIMIT 1) THEN + RAISE EXCEPTION 'prebuild idx_artifact_revisions_artifact concurrently; see docs/artifact-sync-deployment.md'; + END IF; + CREATE INDEX idx_artifact_revisions_artifact + ON public.artifact_revisions (community_id, artifact_id); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_index + WHERE indexrelid=to_regclass('public.idx_artifact_revisions_artifact') + AND indisvalid AND indisready AND indislive + AND pg_get_indexdef(indexrelid)= + 'CREATE INDEX idx_artifact_revisions_artifact ON public.artifact_revisions USING btree (community_id, artifact_id)' + ) THEN + RAISE EXCEPTION 'idx_artifact_revisions_artifact invalid or wrong definition; see docs/artifact-sync-deployment.md'; + END IF; +END $$; diff --git a/migrations/0069_artifact_sync_timestamps.sql b/migrations/0069_artifact_sync_timestamps.sql new file mode 100644 index 00000000000..b0df2f30102 --- /dev/null +++ b/migrations/0069_artifact_sync_timestamps.sql @@ -0,0 +1,11 @@ +SET LOCAL lock_timeout = '1s'; +SET LOCAL statement_timeout = '5s'; + +ALTER TABLE artifact_heads ADD COLUMN IF NOT EXISTS modified_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch'; +ALTER TABLE artifact_heads ALTER COLUMN modified_at SET DEFAULT clock_timestamp(); +ALTER TABLE artifact_revisions ADD COLUMN IF NOT EXISTS source_channel_id UUID; +ALTER TABLE artifact_revisions ADD COLUMN IF NOT EXISTS modified_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch'; +ALTER TABLE artifact_revisions ALTER COLUMN modified_at SET DEFAULT clock_timestamp(); +ALTER TABLE channel_members ADD COLUMN IF NOT EXISTS sync_modified_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch'; +ALTER TABLE channel_members ALTER COLUMN sync_modified_at SET DEFAULT clock_timestamp(); +ALTER TABLE communities ADD COLUMN IF NOT EXISTS artifact_policy_modified_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch'; diff --git a/migrations/0070_artifact_sync_indexes.sql b/migrations/0070_artifact_sync_indexes.sql new file mode 100644 index 00000000000..6d32b37a00f --- /dev/null +++ b/migrations/0070_artifact_sync_indexes.sql @@ -0,0 +1,35 @@ +-- Populated databases prebuild these indexes concurrently after migration 0069. +-- See docs/artifact-sync-deployment.md. +SET LOCAL lock_timeout = '1s'; +SET LOCAL statement_timeout = '5s'; + +DO $$ +DECLARE + item RECORD; + populated BOOLEAN; +BEGIN + FOR item IN SELECT * FROM (VALUES + ('artifact_heads_modified', 'artifact_heads', '(community_id, modified_at, artifact_id)', ''), + ('artifact_heads_channel', 'artifact_heads', '(community_id, channel_id, artifact_id)', ''), + ('channel_members_sync', 'channel_members', '(community_id, pubkey, sync_modified_at, channel_id)', ''), + ('artifact_revisions_moves', 'artifact_revisions', '(community_id, modified_at, event_id)', ' WHERE source_channel_id IS NOT NULL') + ) AS indexes(name, table_name, columns, predicate) + LOOP + IF to_regclass('public.' || item.name) IS NULL THEN + EXECUTE format('SELECT EXISTS(SELECT 1 FROM public.%I LIMIT 1)',item.table_name) INTO populated; + IF populated THEN + RAISE EXCEPTION 'prebuild % concurrently; see docs/artifact-sync-deployment.md', item.name; + END IF; + EXECUTE format('CREATE INDEX %I ON public.%I %s%s', item.name, item.table_name, item.columns, item.predicate); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_index WHERE indexrelid=to_regclass('public.' || item.name) + AND indisvalid AND indisready AND indislive + AND pg_get_indexdef(indexrelid)=format('CREATE INDEX %s ON public.%s USING btree %s%s', + item.name,item.table_name,item.columns, + CASE WHEN item.predicate='' THEN '' ELSE ' WHERE (source_channel_id IS NOT NULL)' END) + ) THEN + RAISE EXCEPTION '% is invalid or has a different definition', item.name; + END IF; + END LOOP; +END $$; diff --git a/schema/tables/public/artifact_heads.sql b/schema/tables/public/artifact_heads.sql index 168ec09d41f..7e73557b9b6 100644 --- a/schema/tables/public/artifact_heads.sql +++ b/schema/tables/public/artifact_heads.sql @@ -7,9 +7,12 @@ CREATE TABLE artifact_heads ( artifact_type TEXT NOT NULL, root BYTEA, deleted BOOLEAN NOT NULL DEFAULT false, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (community_id, artifact_id) ); CREATE INDEX artifact_heads_event ON artifact_heads (community_id, event_id); +CREATE INDEX artifact_heads_modified ON artifact_heads (community_id, modified_at, artifact_id); +CREATE INDEX artifact_heads_channel ON artifact_heads (community_id, channel_id, artifact_id); -- The relay does not expire events. Any future row retention or partition -- retirement must skip payloads referenced by `artifact_heads.event_id` diff --git a/schema/tables/public/artifact_revisions.sql b/schema/tables/public/artifact_revisions.sql index 5aa24c25724..d3320c0bfff 100644 --- a/schema/tables/public/artifact_revisions.sql +++ b/schema/tables/public/artifact_revisions.sql @@ -4,8 +4,12 @@ CREATE TABLE artifact_revisions ( community_id UUID NOT NULL REFERENCES communities(id), event_id BYTEA NOT NULL CHECK (length(event_id) = 32), artifact_id UUID NOT NULL, + source_channel_id UUID, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (community_id, event_id) ); CREATE INDEX idx_artifact_revisions_artifact ON artifact_revisions (community_id, artifact_id); +CREATE INDEX artifact_revisions_moves ON artifact_revisions (community_id, modified_at, event_id) + WHERE source_channel_id IS NOT NULL; diff --git a/schema/tables/public/channel_members.sql b/schema/tables/public/channel_members.sql index 7510e455de8..b0cd4e67926 100644 --- a/schema/tables/public/channel_members.sql +++ b/schema/tables/public/channel_members.sql @@ -11,6 +11,7 @@ CREATE TABLE channel_members ( removed_at TIMESTAMPTZ, removed_by BYTEA, hidden_at TIMESTAMPTZ, + sync_modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (community_id, channel_id, pubkey), FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ON DELETE CASCADE @@ -18,3 +19,4 @@ CREATE TABLE channel_members ( CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey) WHERE removed_at IS NULL; +CREATE INDEX channel_members_sync ON channel_members (community_id, pubkey, sync_modified_at, channel_id); diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql index 06f74c3f585..7f9d6f09653 100644 --- a/schema/tables/public/communities.sql +++ b/schema/tables/public/communities.sql @@ -24,6 +24,7 @@ CREATE TABLE communities ( deletion_state TEXT NOT NULL DEFAULT 'active' CHECK (deletion_state IN ('active', 'quiescing', 'fenced', 'tombstone')), deletion_fence_generation BIGINT NOT NULL DEFAULT 0 CHECK (deletion_fence_generation >= 0), deleted_at TIMESTAMPTZ, + artifact_policy_modified_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch', CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); From 4ce665b46f57ba93008b0f1fe141606a9c831387 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 16:26:02 -0700 Subject: [PATCH 06/12] fix(relay): harden artifact sync recovery Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/store/channel.rs | 6 +- .../sync_access_postgres_tests.rs | 122 ++++++++++++++++++ crates/buzz-relay/src/api/artifact_sync.rs | 17 ++- docs/nips/NIP-AR.md | 6 +- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 5fd1e4d7322..9b12d1533ad 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -902,7 +902,7 @@ pub async fn update_channel( .await?; } let policy_changed = if updates.visibility.is_some() || updates.ttl_seconds.is_some() { - sqlx::query_scalar::<_,bool>("SELECT (($3::text IS NOT NULL AND visibility::text IS DISTINCT FROM $3) OR ($4 AND ttl_seconds IS DISTINCT FROM $5::int)) FROM channels WHERE community_id=$1 AND id=$2 FOR UPDATE") + sqlx::query_scalar::<_,bool>("SELECT (($3::text IS NOT NULL AND visibility::text IS DISTINCT FROM $3) OR ($4 AND ttl_seconds IS DISTINCT FROM $5::int)) FROM channels WHERE community_id=$1 AND id=$2 FOR NO KEY UPDATE") .bind(community_id.as_uuid()).bind(channel_id).bind(updates.visibility.as_deref()) .bind(updates.ttl_seconds.is_some()).bind(updates.ttl_seconds.flatten()) .fetch_optional(tx.conn()).await?.unwrap_or(false) @@ -915,7 +915,7 @@ pub async fn update_channel( } if policy_changed { sqlx::query( - "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2)", + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND (EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2) OR EXISTS(SELECT 1 FROM artifact_revisions WHERE community_id=$1 AND source_channel_id=$2))", ) .bind(community_id.as_uuid()).bind(channel_id) .execute(tx.conn()) @@ -1119,7 +1119,7 @@ pub async fn soft_delete_channel( if result.rows_affected() > 0 { sqlx::query( - "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2)", + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND (EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2) OR EXISTS(SELECT 1 FROM artifact_revisions WHERE community_id=$1 AND source_channel_id=$2))", ) .bind(community_id.as_uuid()).bind(channel_id) .execute(tx.conn()) diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index aa11cbe5fc3..03dcdef2643 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -252,3 +252,125 @@ async fn sync_gate_controls_http_reads_and_capability() { ); } } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_resets_when_a_move_source_loses_access() { + let _guard = SERIAL.lock().await; + for delete_channel in [false, true] { + let f = fixture().await; + let id = Uuid::new_v4(); + let first = f.revision(id, "create", None); + accept(&f, &first).await; + let (_, read) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&first)); + accept(&f, &moved).await; + let heads: i64 = sqlx::query_scalar( + "SELECT count(*) FROM artifact_heads WHERE community_id=$1 AND channel_id=$2", + ) + .bind(f.community.as_uuid()) + .bind(f.home) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(heads, 0); + if delete_channel { + assert!( + buzz_db::channel::soft_delete_channel(&f.pool, f.community, f.home) + .await + .unwrap() + ); + } else { + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.home, + buzz_db::channel::ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + } + let (status, delta) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":read["checkpoint"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(delta["reason"], "policy_changed"); + assert!(!delta.to_string().contains(&f.home.to_string())); + let (status, current) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id]}]), + ) + .await; + assert!(status.is_success()); + assert_eq!( + current["changes"], + json!([{"type":"remove","artifactId":id}]) + ); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_policy_update_can_pass_a_channel_key_share() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let mut tx = f.pool.begin().await.unwrap(); + sqlx::query("SELECT id FROM channels WHERE community_id=$1 AND id=$2 FOR KEY SHARE") + .bind(f.community.as_uuid()) + .bind(f.home) + .fetch_one(&mut *tx) + .await + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.home, + buzz_db::channel::ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ), + ) + .await + .expect("policy update must permit a foreign-key reader") + .unwrap(); + tx.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_progress_replays_queries_with_escaped_values() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let scope = json!({"artifact":"current","#type":["buzz.task"],"#project":vec!["\u{1}".repeat(4096);15]}); + let (status, first) = f + .request("/query", json!([{"artifact":"sync","query":scope}])) + .await; + assert!(status.is_success(), "{status}: {first}"); + assert!(first["checkpoint"].as_str().unwrap().len() < 200_000); + let (status, next) = f + .request( + "/query", + json!([{"artifact":"sync","query":scope,"checkpoint":first["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {next}"); + assert_eq!(next["changes"], json!([])); +} diff --git a/crates/buzz-relay/src/api/artifact_sync.rs b/crates/buzz-relay/src/api/artifact_sync.rs index 0d20f164413..7bd50dfb3bc 100644 --- a/crates/buzz-relay/src/api/artifact_sync.rs +++ b/crates/buzz-relay/src/api/artifact_sync.rs @@ -41,7 +41,7 @@ struct Token { version: u8, community: Uuid, reader: String, - query: Value, + query: String, at: DateTime, policy: DateTime, since: Option>, @@ -97,11 +97,15 @@ fn encode(state: &AppState, token: &Token) -> Result 200_000 { + return Err(invalid()); + } + Ok(encoded) } fn decode(state: &AppState, value: &str) -> Result)> { if value.len() > 200_000 { @@ -150,6 +154,9 @@ async fn execute( return Err(invalid()); } let parsed = buzz_core::artifact::parse_query(&request.query).map_err(|_| invalid())?; + let query_binding = URL_SAFE_NO_PAD.encode(Sha256::digest( + serde_json::to_vec(&request.query).map_err(|_| invalid())?, + )); if parsed.view != ArtifactView::Current { return Err(invalid()); } @@ -215,7 +222,7 @@ async fn execute( if previous.version != 1 || previous.community != *tenant.community().as_uuid() || previous.reader != reader.to_hex() - || previous.query != request.query + || previous.query != query_binding || previous.cursor != request.cursor.is_some() || previous.at > now { @@ -243,7 +250,7 @@ async fn execute( version: 1, community: *tenant.community().as_uuid(), reader: reader.to_hex(), - query: request.query, + query: query_binding, at: now, policy, since: None, diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index 6785d136783..1aee61a19bf 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -123,7 +123,8 @@ The initial read pages through current identities. A later read supplies the last completed `checkpoint`. A continuation supplies only `cursor`, with the same `query`. The server signs these opaque strings. They bind the community, reader, exact query, access-policy boundary, and database start -time. The cursor also binds the last page position. Relay instances use the +time. Tokens contain a fixed-size digest of the query. The cursor also binds +the last page position. Relay instances use the same configured signing identity, so another instance can serve a continuation. No server-side manifest or Redis allocation is required. @@ -194,7 +195,8 @@ or remove. An empty delta does not confirm an accepted edit. ### Access recovery and bounds Relevant channel access changes atomically advance one community policy -stamp. They do not update it on ordinary artifact writes. A different stamp +stamp. The check includes channels with current heads or retained move-source +records. Ordinary artifact writes do not update this stamp. A different stamp returns HTTP 409 `sync_reset` with `reason: "policy_changed"`, without naming private channels. A checkpoint older than 30 days returns the same code with `reason: "checkpoint_expired"`. From c8d891ed0c62f7db6f9ba70ff544dc3369a5b828 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 17:46:26 -0700 Subject: [PATCH 07/12] fix(relay): reset sync for empty channel changes Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/store/channel.rs | 8 +- .../sync_access_postgres_tests.rs | 14 +++- .../sync_policy_postgres_tests.rs | 79 +++++++++++++++++++ .../sync_postgres_tests.rs | 2 + docs/artifact-sync-deployment.md | 6 ++ docs/nips/NIP-AR.md | 4 +- 6 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 9b12d1533ad..e984314d0a8 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -915,9 +915,9 @@ pub async fn update_channel( } if policy_changed { sqlx::query( - "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND (EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2) OR EXISTS(SELECT 1 FROM artifact_revisions WHERE community_id=$1 AND source_channel_id=$2))", + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1", ) - .bind(community_id.as_uuid()).bind(channel_id) + .bind(community_id.as_uuid()) .execute(tx.conn()) .await?; } @@ -1119,9 +1119,9 @@ pub async fn soft_delete_channel( if result.rows_affected() > 0 { sqlx::query( - "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1 AND (EXISTS(SELECT 1 FROM artifact_heads WHERE community_id=$1 AND channel_id=$2) OR EXISTS(SELECT 1 FROM artifact_revisions WHERE community_id=$1 AND source_channel_id=$2))", + "UPDATE communities SET artifact_policy_modified_at=clock_timestamp() WHERE id=$1", ) - .bind(community_id.as_uuid()).bind(channel_id) + .bind(community_id.as_uuid()) .execute(tx.conn()) .await?; } diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index 03dcdef2643..0855cd1c3a1 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -126,7 +126,7 @@ async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { #[tokio::test] #[ignore = "requires Postgres"] -async fn sync_ignores_unrelated_ttl_and_unchanged_visibility() { +async fn sync_ignores_unchanged_visibility_and_resets_on_ttl_changes() { let _guard = SERIAL.lock().await; let f = fixture().await; accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; @@ -143,6 +143,8 @@ async fn sync_ignores_unrelated_ttl_and_unchanged_visibility() { ) .await .unwrap(); + let renamed = sync(&f, Some(&first["checkpoint"])).await; + assert!(renamed["checkpoint"].is_string()); buzz_db::channel::update_channel( &f.pool, f.community, @@ -154,7 +156,15 @@ async fn sync_ignores_unrelated_ttl_and_unchanged_visibility() { ) .await .unwrap(); - let next = sync(&f, Some(&first["checkpoint"])).await; + let (status, reset) = f + .request( + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":renamed["checkpoint"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(reset["reason"], "policy_changed"); + let next = sync(&f, None).await; assert!(next["checkpoint"].is_string()); accept( &f, diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs new file mode 100644 index 00000000000..954a313cd65 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs @@ -0,0 +1,79 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_resets_for_first_artifact_during_channel_transition() { + let _guard = SERIAL.lock().await; + for delete_channel in [false, true] { + let f = fixture().await; + let mut blocker = f.pool.begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *blocker) + .await + .unwrap(); + sqlx::query("SELECT id FROM communities WHERE id=$1 FOR NO KEY UPDATE") + .bind(f.community.as_uuid()) + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pool = f.pool.clone(); + let community = f.community; + let home = f.home; + let transition = tokio::spawn(async move { + if delete_channel { + assert!( + buzz_db::channel::soft_delete_channel(&pool, community, home) + .await + .unwrap() + ); + } else { + buzz_db::channel::update_channel( + &pool, + community, + home, + buzz_db::channel::ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + } + }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + assert!(!transition.is_finished(), "an empty channel must still advance the recovery marker"); + let blocked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid)) AND query LIKE 'UPDATE communities SET artifact_policy_modified_at=%')") + .bind(blocker_pid).fetch_one(&f.pool).await.unwrap(); + if blocked { break; } + tokio::task::yield_now().await; + } + }).await.expect("channel transition reaches the recovery marker"); + let id = Uuid::new_v4(); + let event = f.revision(id, "create", None); + tokio::time::timeout(std::time::Duration::from_secs(2), accept(&f, &event)) + .await + .expect("first artifact commits while the channel transition is uncommitted"); + let (status, first) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + assert!(status.is_success(), "{status}: {first}"); + assert_eq!(first["changes"][0]["event"]["id"], event.id.to_hex()); + blocker.commit().await.unwrap(); + transition.await.unwrap(); + let (status, next) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(next["reason"], "policy_changed"); + assert!(!next.to_string().contains(&f.home.to_string())); + } +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs index 751ca3cd049..3a776dc1587 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -2,6 +2,8 @@ use super::*; use axum::http::StatusCode; #[path = "sync_access_postgres_tests.rs"] mod access_postgres_tests; +#[path = "sync_policy_postgres_tests.rs"] +mod policy_postgres_tests; #[path = "sync_scale_postgres_tests.rs"] mod scale_postgres_tests; diff --git a/docs/artifact-sync-deployment.md b/docs/artifact-sync-deployment.md index 719f1f46ca5..1b68c7e9b23 100644 --- a/docs/artifact-sync-deployment.md +++ b/docs/artifact-sync-deployment.md @@ -21,6 +21,7 @@ CREATE INDEX CONCURRENTLY idx_artifact_revisions_artifact ON public.artifact_revisions (community_id, artifact_id); ``` +Apply `0068_artifact_revision_lookup.sql` in a transaction after that prebuild. Then apply `0069_artifact_sync_timestamps.sql` in a transaction with its lock and statement budgets. The migration is idempotent for this prebuild step. Then build these indexes outside a transaction: @@ -43,6 +44,11 @@ database can build the indexes under the migration's short budgets. Migration `0068` also verifies the exact definition and all three flags. Remove an invalid index with `DROP INDEX CONCURRENTLY` before a new build. +After all index builds succeed, run `buzz-admin migrate` with the target +`DATABASE_URL`. It verifies and records `0068`, reapplies the idempotent `0069`, +then verifies and records `0070`. Direct SQL application does not record the +migration ledger. + ## Enable and rollback Deploy the timestamp writers to every relay instance with the gate off. diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index 1aee61a19bf..f91aa0dd678 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -195,8 +195,8 @@ or remove. An empty delta does not confirm an accepted edit. ### Access recovery and bounds Relevant channel access changes atomically advance one community policy -stamp. The check includes channels with current heads or retained move-source -records. Ordinary artifact writes do not update this stamp. A different stamp +stamp, including transitions on empty channels. Ordinary artifact writes do +not update this stamp. A different stamp returns HTTP 409 `sync_reset` with `reason: "policy_changed"`, without naming private channels. A checkpoint older than 30 days returns the same code with `reason: "checkpoint_expired"`. From 357da40b3941efd083ea053fab56dbef3cce619c Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 18:04:23 -0700 Subject: [PATCH 08/12] test(relay): retain early startup failure output Signed-off-by: Daniel Rouleau --- crates/buzz-relay/tests/boot_lifecycle.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 5d7dd2491f4..b0ec94c3089 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -170,10 +170,15 @@ fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String { fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String { let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE; loop { - assert!( - process.try_wait().is_none(), - "relay exited before exporting {needle}" - ); + if let Some(status) = process.try_wait() { + let stdout = join_capture(process.stdout.take(), "stdout"); + let stderr = join_capture(process.stderr.take(), "stderr"); + panic!( + "relay exited before exporting {needle}: {status}; stdout={}; stderr={}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + } if let Ok(response) = scrape_metrics(port) { if response.contains(needle) { return response; From 4019c1df8d266701de685f9c714f747c1781967a Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 21 Oct 2026 18:39:01 -0700 Subject: [PATCH 09/12] fix(relay): protect sync cursors and ID reads Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/store/artifact_sync.rs | 13 +-- .../sync_access_postgres_tests.rs | 27 +++++ .../sync_cursor_postgres_tests.rs | 102 ++++++++++++++++++ .../sync_postgres_tests.rs | 2 + 4 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs diff --git a/crates/buzz-db/src/store/artifact_sync.rs b/crates/buzz-db/src/store/artifact_sync.rs index 32067986e56..18b9323276c 100644 --- a/crates/buzz-db/src/store/artifact_sync.rs +++ b/crates/buzz-db/src/store/artifact_sync.rs @@ -120,14 +120,6 @@ impl Db { if ids.is_empty() { return Ok(vec![]); } - let mut tags = query.tags.clone(); - tags.push(("d".into(), ids.iter().map(Uuid::to_string).collect())); - let narrowed = ArtifactQuery { - view: query.view, - tags, - limit: PAGE_ROWS as i64, - offset: 0, - }; let connection = crate::observability::acquire_writer( &self.pool, crate::observability::WriterOperation::SubscriptionHistory, @@ -141,7 +133,8 @@ impl Db { .execute(&mut *tx) .await?; let mut qb = QueryBuilder::new("SELECT e.id,e.pubkey,e.created_at,e.kind,e.tags,e.content,e.sig,e.received_at,e.channel_id FROM events e "); - super::artifact_query::predicates(&mut qb, community, reader, &narrowed); + super::artifact_query::predicates(&mut qb, community, reader, query); + qb.push(" AND a.artifact_id=ANY(").push_bind(ids).push(")"); qb.push(" LIMIT ").push_bind(PAGE_ROWS as i64); let mut events = Vec::new(); let mut bytes = 0; @@ -204,7 +197,7 @@ WITH readable AS NOT MATERIALIZED ( AND m.sync_modified_at <= $4 AND a.channel_id IN (SELECT id FROM readable) AND (cardinality($8::text[])=0 OR a.artifact_type=ANY($8)) UNION ALL - SELECT r.modified_at,3,encode(r.event_id,'hex'),r.artifact_id,NULL::uuid + SELECT DISTINCT r.modified_at,3,r.artifact_id::text,r.artifact_id,NULL::uuid FROM artifact_revisions r WHERE r.community_id=$1 AND r.source_channel_id IS NOT NULL AND r.source_channel_id IN (SELECT id FROM readable) AND $3::timestamptz IS NOT NULL AND r.modified_at >= $3 AND r.modified_at <= $4 diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index 0855cd1c3a1..af52ff02e15 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -384,3 +384,30 @@ async fn sync_progress_replays_queries_with_escaped_values() { assert!(status.is_success(), "{status}: {next}"); assert_eq!(next["changes"], json!([])); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_current_intersects_known_ids_with_query_scope() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let included = Uuid::new_v4(); + let excluded = Uuid::new_v4(); + let event = f.revision(included, "create", None); + accept(&f, &event).await; + accept(&f, &f.revision(excluded, "create", None)).await; + let (status, body) = f + .request( + "/query", + json!([{"artifact":"sync","action":"current", + "query":{"artifact":"current","#type":["buzz.task"],"#d":[included]}, + "artifactIds":[included,excluded]}]), + ) + .await; + assert!(status.is_success(), "{status}: {body}"); + assert_eq!(body["changes"][0]["artifactId"], included.to_string()); + assert_eq!(body["changes"][0]["event"]["id"], event.id.to_hex()); + assert_eq!( + body["changes"][1], + json!({"type":"remove","artifactId":excluded}) + ); +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs new file mode 100644 index 00000000000..4f2b855e575 --- /dev/null +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs @@ -0,0 +1,102 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_move_cursor_hides_private_and_redacted_revision_ids() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + let (_, empty) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query()}]), + ) + .await; + let mut current = std::collections::HashMap::new(); + let mut private_ids = Vec::new(); + for _ in 0..101 { + let id = Uuid::new_v4(); + let created = f.revision(id, "create", None); + accept(&f, &created).await; + let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&created)); + accept(&f, &moved).await; + private_ids.push(moved.id.to_hex()); + current.insert(id, moved); + } + let (&id, previous) = current.iter().next().unwrap(); + let back = f.revision(id, "move", Some(previous)); + accept(&f, &back).await; + let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&back)); + accept(&f, &moved).await; + private_ids.push(moved.id.to_hex()); + current.insert(id, moved); + let stamp: chrono::DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&f.pool) + .await + .unwrap(); + sqlx::query("UPDATE artifact_revisions SET modified_at=$2 WHERE community_id=$1") + .bind(f.community.as_uuid()) + .bind(stamp) + .execute(&f.pool) + .await + .unwrap(); + for redacted in [false, true] { + let (status, first) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"checkpoint":empty["checkpoint"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {first}"); + let cursor = first["nextCursor"].as_str().unwrap(); + let payload = cursor.split_once('.').unwrap().0; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .unwrap(); + let decoded = String::from_utf8(decoded).unwrap(); + for private_id in &private_ids { + assert!( + !decoded.contains(private_id), + "cursor exposes a private revision ID (redacted={redacted})" + ); + } + assert_eq!(first["changes"].as_array().unwrap().len(), 100); + let (status, last) = f + .request_as( + &f.peer, + "/query", + json!([{"artifact":"sync","query":query(),"cursor":cursor}]), + ) + .await; + assert!(status.is_success(), "{status}: {last}"); + assert!(last["checkpoint"].is_string()); + let mut ids = std::collections::HashSet::new(); + for change in first["changes"] + .as_array() + .unwrap() + .iter() + .chain(last["changes"].as_array().unwrap()) + { + assert_eq!(change["type"], "remove"); + assert!(ids.insert(change["artifactId"].as_str().unwrap().to_owned())); + } + assert_eq!(ids.len(), 101); + if !redacted { + let boundary = first["changes"].as_array().unwrap().last().unwrap()["artifactId"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + assert!(buzz_db::event::soft_delete_event_and_update_thread( + &f.pool, + f.community, + current[&boundary].id.as_bytes(), + None, + None + ) + .await + .unwrap()); + } + } +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs index 3a776dc1587..aaac2be8641 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -2,6 +2,8 @@ use super::*; use axum::http::StatusCode; #[path = "sync_access_postgres_tests.rs"] mod access_postgres_tests; +#[path = "sync_cursor_postgres_tests.rs"] +mod cursor_postgres_tests; #[path = "sync_policy_postgres_tests.rs"] mod policy_postgres_tests; #[path = "sync_scale_postgres_tests.rs"] From 97cacecb5c15c3fff4c01ffd36eda5da984f3c14 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 22 Oct 2026 15:06:19 -0700 Subject: [PATCH 10/12] fix(relay): name incremental artifact reads Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/store/artifact_query.rs | 2 +- crates/buzz-db/src/store/artifact_sync.rs | 2 + crates/buzz-relay/src/api/artifact.rs | 7 ++- .../sync_access_postgres_tests.rs | 54 +++++++++++-------- .../sync_cursor_postgres_tests.rs | 6 +-- .../sync_policy_postgres_tests.rs | 6 +-- .../sync_postgres_tests.rs | 32 +++++------ .../sync_scale_postgres_tests.rs | 6 +-- crates/buzz-relay/src/api/artifact_sync.rs | 27 +++++----- crates/buzz-relay/src/config.rs | 6 +-- crates/buzz-relay/src/nip11.rs | 4 +- docs/artifact-sync-deployment.md | 10 ++-- docs/nips/NIP-AR.md | 28 +++++----- 13 files changed, 105 insertions(+), 85 deletions(-) diff --git a/crates/buzz-db/src/store/artifact_query.rs b/crates/buzz-db/src/store/artifact_query.rs index e1de756c525..80fe6b47a8c 100644 --- a/crates/buzz-db/src/store/artifact_query.rs +++ b/crates/buzz-db/src/store/artifact_query.rs @@ -55,7 +55,7 @@ impl Db { } } -/// Apply the same visibility and tag rules to pages and snapshot capture. +/// Own the visibility and tag rules for artifact queries and current-ID reads. pub(super) fn predicates( qb: &mut QueryBuilder, community: CommunityId, diff --git a/crates/buzz-db/src/store/artifact_sync.rs b/crates/buzz-db/src/store/artifact_sync.rs index 18b9323276c..127910c228c 100644 --- a/crates/buzz-db/src/store/artifact_sync.rs +++ b/crates/buzz-db/src/store/artifact_sync.rs @@ -167,6 +167,8 @@ impl Db { } } +// Keep candidate access rules aligned with artifact_query::predicates, which owns +// current-state visibility. This query also reads retained move and membership rows. const CANDIDATES: &str = r#" WITH readable AS NOT MATERIALIZED ( SELECT c.id FROM channels c diff --git a/crates/buzz-relay/src/api/artifact.rs b/crates/buzz-relay/src/api/artifact.rs index 9a60763a887..c41ce5d15d0 100644 --- a/crates/buzz-relay/src/api/artifact.rs +++ b/crates/buzz-relay/src/api/artifact.rs @@ -47,9 +47,12 @@ async fn execute( "artifact queries require exactly one filter", )); } - if filters[0].get("artifact").and_then(Value::as_str) == Some("sync") { + if filters[0].get("artifact").and_then(Value::as_str) == Some("incremental") { if count { - return Err(api_error(StatusCode::BAD_REQUEST, "sync requires /query")); + return Err(api_error( + StatusCode::BAD_REQUEST, + "incremental requires /query", + )); } return super::artifact_sync::query(state, tenant, reader, &filters[0]).await; } diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index af52ff02e15..58f63851bf9 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -20,7 +20,7 @@ async fn sync_redaction_and_query_exit_return_removals() { accept(&f, &first).await; let scope = json!({"artifact":"current","#type":["buzz.task"],"#project":["alpha"]}); let (_, page) = f - .request("/query", json!([{"artifact":"sync","query":scope}])) + .request("/query", json!([{"artifact":"incremental","query":scope}])) .await; assert_eq!(page["changes"][0]["type"], "upsert"); let changed = f.revision(id, "update", Some(&first)); @@ -28,7 +28,7 @@ async fn sync_redaction_and_query_exit_return_removals() { let (status, delta) = f .request( "/query", - json!([{"artifact":"sync","query":scope,"checkpoint":page["checkpoint"]}]), + json!([{"artifact":"incremental","query":scope,"checkpoint":page["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {delta}"); @@ -80,7 +80,7 @@ async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; buzz_db::channel_members::remove_member( @@ -106,7 +106,7 @@ async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":page["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":page["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {delta}"); @@ -117,7 +117,7 @@ async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":delta["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":delta["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {delta}"); @@ -159,7 +159,7 @@ async fn sync_ignores_unchanged_visibility_and_resets_on_ttl_changes() { let (status, reset) = f .request( "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":renamed["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":renamed["checkpoint"]}]), ) .await; assert_eq!(status, StatusCode::CONFLICT); @@ -172,10 +172,13 @@ async fn sync_ignores_unchanged_visibility_and_resets_on_ttl_changes() { ) .await; let (status, body) = f - .request("/query", json!([{"artifact":"sync","query":query()}])) + .request( + "/query", + json!([{"artifact":"incremental","query":query()}]), + ) .await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body["code"], "sync_unsupported_scope"); + assert_eq!(body["code"], "incremental_unsupported_scope"); } #[tokio::test] @@ -200,7 +203,7 @@ async fn sync_shrinks_large_pages_and_current_batches_fail_explicitly() { let (status, last) = f .request( "/query", - json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]), + json!([{"artifact":"incremental","query":query(),"cursor":first["nextCursor"]}]), ) .await; assert!(status.is_success(), "{status}: {last}"); @@ -209,11 +212,11 @@ async fn sync_shrinks_large_pages_and_current_batches_fail_explicitly() { let (status, body) = f .request( "/query", - json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":ids}]), + json!([{"artifact":"incremental","action":"current","query":query(),"artifactIds":ids}]), ) .await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); - assert_eq!(body["code"], "sync_limit"); + assert_eq!(body["code"], "incremental_limit"); } #[tokio::test] @@ -222,8 +225,10 @@ async fn sync_gate_controls_http_reads_and_capability() { use tower::ServiceExt; let _guard = SERIAL.lock().await; let mut f = fixture().await; + let mut websocket_sync = Value::Null; for enabled in [false, true] { - Arc::make_mut(&mut Arc::make_mut(&mut f.state).config).artifact_sync_enabled = enabled; + Arc::make_mut(&mut Arc::make_mut(&mut f.state).config).artifact_incremental_enabled = + enabled; let response = crate::router::build_router(f.state.clone()) .oneshot( axum::http::Request::builder() @@ -240,17 +245,22 @@ async fn sync_gate_controls_http_reads_and_capability() { .unwrap(); let info: Value = serde_json::from_slice(&bytes).unwrap(); if enabled { + assert_eq!(info["artifacts"]["sync"], websocket_sync); assert_eq!( - info["artifacts"]["sync"], + info["artifacts"]["incremental"], json!({"version":1,"overlapSeconds":120, "pageRows":100,"currentRows":100,"responseBytes":8388608, "permanentChannelsOnly":true,"checkpointLifetimeSeconds":2592000}) ); } else { - assert!(info["artifacts"].get("sync").is_none()); + websocket_sync = info["artifacts"]["sync"].clone(); + assert!(info["artifacts"].get("incremental").is_none()); } let (status, _) = f - .request("/query", json!([{"artifact":"sync","query":query()}])) + .request( + "/query", + json!([{"artifact":"incremental","query":query()}]), + ) .await; assert_eq!( status, @@ -265,7 +275,7 @@ async fn sync_gate_controls_http_reads_and_capability() { #[tokio::test] #[ignore = "requires Postgres"] -async fn sync_resets_when_a_move_source_loses_access() { +async fn incremental_resets_when_a_move_source_loses_access() { let _guard = SERIAL.lock().await; for delete_channel in [false, true] { let f = fixture().await; @@ -276,7 +286,7 @@ async fn sync_resets_when_a_move_source_loses_access() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&first)); @@ -313,7 +323,7 @@ async fn sync_resets_when_a_move_source_loses_access() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":read["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":read["checkpoint"]}]), ) .await; assert_eq!(status, StatusCode::CONFLICT); @@ -323,7 +333,7 @@ async fn sync_resets_when_a_move_source_loses_access() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id]}]), + json!([{"artifact":"incremental","action":"current","query":query(),"artifactIds":[id]}]), ) .await; assert!(status.is_success()); @@ -371,14 +381,14 @@ async fn sync_progress_replays_queries_with_escaped_values() { let f = fixture().await; let scope = json!({"artifact":"current","#type":["buzz.task"],"#project":vec!["\u{1}".repeat(4096);15]}); let (status, first) = f - .request("/query", json!([{"artifact":"sync","query":scope}])) + .request("/query", json!([{"artifact":"incremental","query":scope}])) .await; assert!(status.is_success(), "{status}: {first}"); assert!(first["checkpoint"].as_str().unwrap().len() < 200_000); let (status, next) = f .request( "/query", - json!([{"artifact":"sync","query":scope,"checkpoint":first["checkpoint"]}]), + json!([{"artifact":"incremental","query":scope,"checkpoint":first["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {next}"); @@ -398,7 +408,7 @@ async fn sync_current_intersects_known_ids_with_query_scope() { let (status, body) = f .request( "/query", - json!([{"artifact":"sync","action":"current", + json!([{"artifact":"incremental","action":"current", "query":{"artifact":"current","#type":["buzz.task"],"#d":[included]}, "artifactIds":[included,excluded]}]), ) diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs index 4f2b855e575..ddc71968f63 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs @@ -9,7 +9,7 @@ async fn sync_move_cursor_hides_private_and_redacted_revision_ids() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; let mut current = std::collections::HashMap::new(); @@ -45,7 +45,7 @@ async fn sync_move_cursor_hides_private_and_redacted_revision_ids() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":empty["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":empty["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {first}"); @@ -66,7 +66,7 @@ async fn sync_move_cursor_hides_private_and_redacted_revision_ids() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"cursor":cursor}]), + json!([{"artifact":"incremental","query":query(),"cursor":cursor}]), ) .await; assert!(status.is_success(), "{status}: {last}"); diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs index 954a313cd65..c3cc3d9f1d4 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_policy_postgres_tests.rs @@ -2,7 +2,7 @@ use super::*; #[tokio::test] #[ignore = "requires Postgres"] -async fn sync_resets_for_first_artifact_during_channel_transition() { +async fn incremental_resets_for_first_artifact_during_channel_transition() { let _guard = SERIAL.lock().await; for delete_channel in [false, true] { let f = fixture().await; @@ -58,7 +58,7 @@ async fn sync_resets_for_first_artifact_during_channel_transition() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; assert!(status.is_success(), "{status}: {first}"); @@ -69,7 +69,7 @@ async fn sync_resets_for_first_artifact_during_channel_transition() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":first["checkpoint"]}]), ) .await; assert_eq!(status, StatusCode::CONFLICT); diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs index aaac2be8641..a5025b8dca3 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -12,7 +12,7 @@ mod scale_postgres_tests; async fn fixture() -> Fixture { let mut f = Fixture::new().await; let mut state = (*f.state).clone(); - Arc::make_mut(&mut state.config).artifact_sync_enabled = true; + Arc::make_mut(&mut state.config).artifact_incremental_enabled = true; f.state = Arc::new(state); f } @@ -22,7 +22,7 @@ fn query() -> Value { json!({"artifact":"current","#type":["buzz.task"]}) } async fn sync(f: &Fixture, checkpoint: Option<&Value>) -> Value { - let mut request = json!({"artifact":"sync","query":query()}); + let mut request = json!({"artifact":"incremental","query":query()}); if let Some(checkpoint) = checkpoint { request["checkpoint"] = checkpoint.clone(); } @@ -103,7 +103,7 @@ async fn sync_pages_equal_timestamps_and_replays_the_same_cursor() { let first = sync(&f, None).await; assert_eq!(first["changes"].as_array().unwrap().len(), 100); assert!(first["checkpoint"].is_null()); - let request = json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]); + let request = json!([{"artifact":"incremental","query":query(),"cursor":first["nextCursor"]}]); let (status, last) = f.request("/query", request.clone()).await; assert!(status.is_success(), "{status}: {last}"); assert_eq!(last["changes"].as_array().unwrap().len(), 3); @@ -130,7 +130,7 @@ async fn sync_current_has_uniform_unavailable_and_rejects_duplicate_ids() { let event = f.revision_as(&f.owner, f.private, id, "create", None); accept(&f, &event).await; let missing = Uuid::new_v4(); - let body = json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id,missing,"local-only"]}]); + let body = json!([{"artifact":"incremental","action":"current","query":query(),"artifactIds":[id,missing,"local-only"]}]); let (status, body) = f.request_as(&f.peer, "/query", body).await; assert!(status.is_success(), "{status}: {body}"); assert_eq!( @@ -142,14 +142,14 @@ async fn sync_current_has_uniform_unavailable_and_rejects_duplicate_ids() { let (status, _) = f .request( "/query", - json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id,id]}]), + json!([{"artifact":"incremental","action":"current","query":query(),"artifactIds":[id,id]}]), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); let (status, body) = f .request( "/query", - json!([{"artifact":"sync","action":"current","query":query(),"artifactIds":[id]}]), + json!([{"artifact":"incremental","action":"current","query":query(),"artifactIds":[id]}]), ) .await; assert!(status.is_success(), "{status}: {body}"); @@ -173,7 +173,7 @@ async fn sync_grant_finds_old_heads_and_removal_names_only_own_membership() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; assert_eq!(first["changes"], json!([])); @@ -191,7 +191,7 @@ async fn sync_grant_finds_old_heads_and_removal_names_only_own_membership() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":first["checkpoint"]}]), ) .await; assert!( @@ -215,7 +215,7 @@ async fn sync_grant_finds_old_heads_and_removal_names_only_own_membership() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":granted["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":granted["checkpoint"]}]), ) .await; assert_eq!( @@ -236,7 +236,7 @@ async fn sync_move_to_hidden_destination_removes_the_source_view() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query()}]), + json!([{"artifact":"incremental","query":query()}]), ) .await; let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&first)); @@ -245,7 +245,7 @@ async fn sync_move_to_hidden_destination_removes_the_source_view() { .request_as( &f.peer, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":read["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":read["checkpoint"]}]), ) .await; assert_eq!( @@ -279,13 +279,13 @@ async fn sync_policy_changes_reset_without_hidden_channel_ids() { let (status, body) = f .request( "/query", - json!([{"artifact":"sync","query":query(),"cursor":first["nextCursor"]}]), + json!([{"artifact":"incremental","query":query(),"cursor":first["nextCursor"]}]), ) .await; assert_eq!(status, StatusCode::CONFLICT); assert_eq!( body, - json!({"code":"sync_reset","error":"sync_reset","reason":"policy_changed"}) + json!({"code":"incremental_reset","error":"incremental_reset","reason":"policy_changed"}) ); } @@ -306,7 +306,7 @@ async fn sync_progress_binds_reader_query_and_community() { .request_as( reader, "/query", - json!([{"artifact":"sync","query":scope,"checkpoint":first["checkpoint"]}]), + json!([{"artifact":"incremental","query":scope,"checkpoint":first["checkpoint"]}]), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); @@ -317,7 +317,7 @@ async fn sync_progress_binds_reader_query_and_community() { .request_as( &f.owner, "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":first["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":first["checkpoint"]}]), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); @@ -329,7 +329,7 @@ async fn sync_progress_binds_reader_query_and_community() { let (status, body) = f .request( "/query", - json!([{"artifact":"sync","query":query(),"checkpoint":expired}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":expired}]), ) .await; assert_eq!(status, StatusCode::CONFLICT); diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs index 130b1ab43f5..cf23203eaab 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_scale_postgres_tests.rs @@ -46,7 +46,7 @@ async fn sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships() { let mut other = (*bridge_handler_test_state().await.unwrap()).clone(); let config = Arc::make_mut(&mut other.config); config.require_auth_token = true; - config.artifact_sync_enabled = true; + config.artifact_incremental_enabled = true; other.relay_keypair = f.state.relay_keypair.clone(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/query", listener.local_addr().unwrap()); @@ -73,7 +73,7 @@ async fn sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships() { &f, &client, &url, - json!([{"artifact":"sync","query":query(),"cursor":page["nextCursor"]}]), + json!([{"artifact":"incremental","query":query(),"cursor":page["nextCursor"]}]), ) .await; assert!(status.is_success(), "{status}: {next}"); @@ -87,7 +87,7 @@ async fn sync_downloads_ten_thousand_tasks_with_sixty_thousand_memberships() { &f, &client, &url, - json!([{"artifact":"sync","query":query(),"checkpoint":page["checkpoint"]}]), + json!([{"artifact":"incremental","query":query(),"checkpoint":page["checkpoint"]}]), ) .await; assert!(status.is_success(), "{status}: {delta}"); diff --git a/crates/buzz-relay/src/api/artifact_sync.rs b/crates/buzz-relay/src/api/artifact_sync.rs index 7bd50dfb3bc..23373eede99 100644 --- a/crates/buzz-relay/src/api/artifact_sync.rs +++ b/crates/buzz-relay/src/api/artifact_sync.rs @@ -1,4 +1,4 @@ -//! Timestamp sync with signed progress and bounded current-identity reads. +//! Incremental reads with signed progress and bounded current-identity reads. use crate::state::AppState; use axum::{http::StatusCode, Json}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -53,28 +53,29 @@ fn error(status: StatusCode, code: &str) -> (StatusCode, Json) { (status, Json(json!({"error":code,"code":code}))) } fn invalid() -> (StatusCode, Json) { - error(StatusCode::BAD_REQUEST, "sync_invalid") + error(StatusCode::BAD_REQUEST, "incremental_invalid") } fn unavailable() -> (StatusCode, Json) { - error(StatusCode::SERVICE_UNAVAILABLE, "sync_unavailable") + error(StatusCode::SERVICE_UNAVAILABLE, "incremental_unavailable") } fn limit() -> (StatusCode, Json) { - error(StatusCode::PAYLOAD_TOO_LARGE, "sync_limit") + error(StatusCode::PAYLOAD_TOO_LARGE, "incremental_limit") } fn reset(reason: &str) -> (StatusCode, Json) { ( StatusCode::CONFLICT, - Json(json!({"error":"sync_reset","code":"sync_reset","reason":reason})), + Json(json!({"error":"incremental_reset","code":"incremental_reset","reason":reason})), ) } fn db_error(e: buzz_db::DbError) -> (StatusCode, Json) { match e { buzz_db::DbError::ArtifactSyncLimit => limit(), - buzz_db::DbError::ArtifactSyncUnsupportedScope => { - error(StatusCode::UNPROCESSABLE_ENTITY, "sync_unsupported_scope") - } + buzz_db::DbError::ArtifactSyncUnsupportedScope => error( + StatusCode::UNPROCESSABLE_ENTITY, + "incremental_unsupported_scope", + ), error => { - tracing::warn!(%error,"artifact sync database read failed"); + tracing::warn!(%error,"incremental artifact read failed"); unavailable() } } @@ -89,7 +90,7 @@ pub(crate) fn capability() -> Value { fn mac(state: &AppState) -> Result, (StatusCode, Json)> { let mut digest = Sha256::new(); - digest.update(b"buzz-artifact-sync-v1\0"); + digest.update(b"buzz-artifact-incremental-v1\0"); digest.update(state.relay_keypair.secret_key().as_secret_bytes()); Hmac::::new_from_slice(&digest.finalize()).map_err(|_| unavailable()) } @@ -120,7 +121,7 @@ fn decode(state: &AppState, value: &str) -> Result, @@ -128,7 +129,7 @@ pub(super) async fn query( reader: &nostr::PublicKey, filter: &Value, ) -> Response { - if !state.config.artifact_sync_enabled { + if !state.config.artifact_incremental_enabled { return Err(unavailable()); } let _permit = WORK.try_acquire().map_err(|_| unavailable())?; @@ -147,7 +148,7 @@ async fn execute( filter: &Value, ) -> Response { let request: Request = serde_json::from_value(filter.clone()).map_err(|_| invalid())?; - if request.artifact != "sync" + if request.artifact != "incremental" || request.query.get("limit").is_some() || request.query.get("offset").is_some() { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 7ad4ee1a020..b9ec10612c7 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -117,7 +117,7 @@ pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; #[derive(Debug, Clone)] pub struct Config { /// Enable incremental artifact reads after every serving writer is upgraded. - pub artifact_sync_enabled: bool, + pub artifact_incremental_enabled: bool, /// Address the relay HTTP/WebSocket server binds to. pub bind_addr: SocketAddr, /// Postgres database connection URL. @@ -774,7 +774,7 @@ impl Config { let require_relay_membership = std::env::var("BUZZ_REQUIRE_RELAY_MEMBERSHIP") .map(|v| v == "true" || v == "1") .unwrap_or(false); - let artifact_sync_enabled = std::env::var("BUZZ_ARTIFACT_SYNC_ENABLED") + let artifact_incremental_enabled = std::env::var("BUZZ_ARTIFACT_INCREMENTAL_ENABLED") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -1432,7 +1432,7 @@ impl Config { partition_manager_advance_enabled, pubkey_allowlist_enabled, require_relay_membership, - artifact_sync_enabled, + artifact_incremental_enabled, huddle_audio_available, mesh, mesh_demo_echo, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 01469855802..b7fabb08287 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -387,8 +387,8 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st admin_api.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); - if state.config.artifact_sync_enabled { - info.artifacts["sync"] = crate::api::artifact_sync::capability(); + if state.config.artifact_incremental_enabled { + info.artifacts["incremental"] = crate::api::artifact_sync::capability(); } if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { if state.config.buzz_v1_enabled { diff --git a/docs/artifact-sync-deployment.md b/docs/artifact-sync-deployment.md index 12b48873142..2ed5876d69f 100644 --- a/docs/artifact-sync-deployment.md +++ b/docs/artifact-sync-deployment.md @@ -1,7 +1,7 @@ -# Artifact sync deployment +# Incremental artifact reads deployment -Incremental artifact sync uses a 120-second overlap and database modification -timestamps. `BUZZ_ARTIFACT_SYNC_ENABLED` is off by default. Enable it only +Incremental artifact reads use a 120-second overlap and database modification +timestamps. `BUZZ_ARTIFACT_INCREMENTAL_ENABLED` is off by default. Enable it only after the schema, indexes, and every serving writer are upgraded. ## Schema and indexes @@ -54,8 +54,8 @@ migration ledger. Deploy the timestamp writers to every relay instance with the gate off. Drain the old writers. All instances must use the same configured relay signing key, because that key authenticates the progress tokens. Then enable -`BUZZ_ARTIFACT_SYNC_ENABLED=true` on the serving fleet. NIP-11 advertises -`artifacts.sync` only when the gate is enabled. The endpoint refuses sync +`BUZZ_ARTIFACT_INCREMENTAL_ENABLED=true` on the serving fleet. NIP-11 advertises +`artifacts.incremental` only when the gate is enabled. The endpoint refuses incremental requests while the gate is off. Before a rollback to an old writer, disable the gate on every serving diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index f91aa0dd678..51fabf12994 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -107,16 +107,20 @@ Clients validate content before interpreting it as a known type. Unknown types o Artifact text is untrusted. A type name, relationship tag, or payload cannot authorize a privileged action. Moving, deleting, or revoking access cannot erase copies already downloaded. -## Incremental sync +## Incremental reads -The relay advertises `artifacts.sync` when incremental reads are enabled. +Incremental reads maintain an eventually consistent local store. They do not prove a complete set at one database snapshot. Absence from a page never proves deletion or loss of access. + +This HTTP protocol uses `artifacts.incremental`. The WebSocket Sync subscription uses a separate `artifacts.sync` capability and defines its own complete-set guarantee. Clients select each protocol through its own capability. + +The relay advertises `artifacts.incremental` when incremental reads are enabled. Each signed `POST /query` request contains one filter. The query uses the same community, relay admission, channel access, and exact tag rules as an ordinary artifact read. The existing `current` and `history` array responses keep their format. ```json -[{"artifact":"sync","query":{"artifact":"current","#type":["buzz.task"]}}] +[{"artifact":"incremental","query":{"artifact":"current","#type":["buzz.task"]}}] ``` The initial read pages through current identities. A later read supplies the @@ -176,14 +180,14 @@ current revalidation result without destination metadata. ### Current reads and write confirmation ```json -[{"artifact":"sync","action":"current","query":{"artifact":"current","#type":["buzz.task"]},"artifactIds":["22222222-2222-4222-8222-222222222222"]}] +[{"artifact":"incremental","action":"current","query":{"artifact":"current","#type":["buzz.task"]},"artifactIds":["22222222-2222-4222-8222-222222222222"]}] ``` The response has `version`, database `startedAt`, and `changes`. It contains exactly one upsert or remove for each requested ID, in request order. The request permits 1–100 unique nonempty strings, each at most 256 UTF-8 bytes. A string that is not a canonical artifact UUID returns remove. -Duplicates and invalid bounds fail with `sync_invalid`. The server does not +Duplicates and invalid bounds fail with `incremental_invalid`. The server does not truncate the request. Clients serialize a full delta pass and its commits before a current-ID read. @@ -197,7 +201,7 @@ or remove. An empty delta does not confirm an accepted edit. Relevant channel access changes atomically advance one community policy stamp, including transitions on empty channels. Ordinary artifact writes do not update this stamp. A different stamp -returns HTTP 409 `sync_reset` with `reason: "policy_changed"`, without naming +returns HTTP 409 `incremental_reset` with `reason: "policy_changed"`, without naming private channels. A checkpoint older than 30 days returns the same code with `reason: "checkpoint_expired"`. @@ -219,18 +223,18 @@ NIP-11 advertises: ``` Pages shrink to the response byte bound. A single oversized result returns -HTTP 413 `sync_limit`. A current-ID batch can return that error; the client +HTTP 413 `incremental_limit`. A current-ID batch can return that error; the client can split the batch. No page or batch silently truncates its result. The server permits four concurrent operations per process, a two-second SQL statement budget, and an eight-second operation budget. There is no identity-wide membership count limit on this path. Only matching readable artifacts in permanent channels are supported. -A matching TTL artifact returns HTTP 422 `sync_unsupported_scope`. An -unrelated TTL channel does not block task sync. HTTP 400 `sync_invalid` -rejects invalid requests or tokens. HTTP 503 `sync_unavailable` reports a +A matching TTL artifact returns HTTP 422 `incremental_unsupported_scope`. An +unrelated TTL channel does not block incremental task reads. HTTP 400 `incremental_invalid` +rejects invalid requests or tokens. HTTP 503 `incremental_unavailable` reports a service or work-budget failure. These failures do not advance progress. -The rollout gate is `BUZZ_ARTIFACT_SYNC_ENABLED`, off by default. Follow +The rollout gate is `BUZZ_ARTIFACT_INCREMENTAL_ENABLED`, off by default. Follow [artifact-sync-deployment.md](../artifact-sync-deployment.md) before enabling -it. Sync completion does not establish a live subscription to every channel. +it. Incremental completion does not establish a live subscription to every channel. From 9ab9563a2e803ee89d53443920cc4e81f753a163 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Wed, 28 Oct 2026 16:23:26 -0700 Subject: [PATCH 11/12] test(db): allow later migrations in invite upgrade checks Signed-off-by: Daniel Rouleau --- .../store/relay_invite/upgrade_postgres_tests.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs index fd06aa0e62f..7a8b8fb3442 100644 --- a/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs +++ b/crates/buzz-db/src/store/relay_invite/upgrade_postgres_tests.rs @@ -506,13 +506,12 @@ async fn apply_pgschema_to_existing_database(pool: &PgPool) { async fn migration_schema_0075_adds_invite_columns_without_rewriting_history() { let (pool, cases) = legacy_fixture().await; crate::migration::run_migrations(&pool).await.unwrap(); - assert_eq!( - sqlx::query_scalar::<_, i64>("SELECT max(version) FROM _sqlx_migrations WHERE success") - .fetch_one(&pool) - .await - .unwrap(), - 75 - ); + assert!(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version=75 AND success)" + ) + .fetch_one(&pool) + .await + .unwrap()); assert_admission_column(&pool).await; assert_join_role_preserved(&pool, cases[0].community).await; assert_eq!( From f3d6ac852e7ce4e2cc53bd891cef87832a868d72 Mon Sep 17 00:00:00 2001 From: Daniel Rouleau Date: Thu, 29 Oct 2026 17:20:05 -0700 Subject: [PATCH 12/12] fix(relay): bound incremental read scope Signed-off-by: Daniel Rouleau --- crates/buzz-db/src/error.rs | 4 - crates/buzz-db/src/store/artifact_sync.rs | 16 +-- .../sync_access_postgres_tests.rs | 61 +++++++-- .../sync_cursor_postgres_tests.rs | 129 ++++++++++++++++++ crates/buzz-relay/src/api/artifact_sync.rs | 4 - docs/nips/NIP-AR.md | 10 +- 6 files changed, 193 insertions(+), 31 deletions(-) diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index e6ba77fbecc..5b43d443642 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -81,10 +81,6 @@ pub enum DbError { #[error("artifact sync limit exceeded")] ArtifactSyncLimit, - /// Sync scope contains a channel with expiring content. - #[error("artifact sync scope requires permanent channels")] - ArtifactSyncUnsupportedScope, - /// A complete read-state snapshot exceeds its bounded resource budget. #[error("read-state snapshot exceeds event or byte limit")] ReadStateSnapshotTooLarge, diff --git a/crates/buzz-db/src/store/artifact_sync.rs b/crates/buzz-db/src/store/artifact_sync.rs index ac1ae2b6896..4da01728fd0 100644 --- a/crates/buzz-db/src/store/artifact_sync.rs +++ b/crates/buzz-db/src/store/artifact_sync.rs @@ -85,6 +85,7 @@ impl Db { .bind(named) .bind((PAGE_ROWS + 1) as i64) .bind(buzz_core::channel::LEGACY_CHANNEL_TYPES.to_vec()) + .bind(query.tags.iter().any(|(name, _)| name == "h")) .fetch_all(&mut *tx) .await?; let candidates = rows @@ -135,6 +136,7 @@ impl Db { let mut qb = QueryBuilder::new("SELECT e.id,e.pubkey,e.created_at,e.kind,e.tags,e.content,e.sig,e.received_at,e.channel_id FROM events e "); super::artifact_query::predicates(&mut qb, community, reader, query); qb.push(" AND a.artifact_id=ANY(").push_bind(ids).push(")"); + qb.push(" AND EXISTS (SELECT 1 FROM channels c WHERE c.community_id=e.community_id AND c.id=e.channel_id AND c.ttl_seconds IS NULL)"); qb.push(" LIMIT ").push_bind(PAGE_ROWS as i64); let mut events = Vec::new(); let mut bytes = 0; @@ -153,15 +155,6 @@ impl Db { } } } - let homes = events - .iter() - .filter_map(|e| e.channel_id) - .collect::>(); - let ttl:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM channels WHERE community_id=$1 AND id=ANY($2) AND ttl_seconds IS NOT NULL)") - .bind(community.as_uuid()).bind(homes).fetch_one(&mut *tx).await?; - if ttl { - return Err(DbError::ArtifactSyncUnsupportedScope); - } tx.commit().await?; Ok(events) } @@ -172,7 +165,8 @@ impl Db { const CANDIDATES: &str = r#" WITH readable AS NOT MATERIALIZED ( SELECT c.id FROM channels c - WHERE c.community_id=$1 AND c.deleted_at IS NULL + WHERE c.community_id=$1 AND c.deleted_at IS NULL AND c.ttl_seconds IS NULL + AND (NOT $12::boolean OR c.id=ANY($9)) AND (c.channel_type::text=ANY($11) OR c.id=ANY($9)) AND ((c.visibility='open' AND (NOT c.unlisted OR c.id=ANY($9))) OR EXISTS ( SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id @@ -183,12 +177,14 @@ WITH readable AS NOT MATERIALIZED ( FROM artifact_heads a WHERE a.community_id=$1 AND a.modified_at >= COALESCE($3::timestamptz,'epoch'::timestamptz) AND a.modified_at <= $4 AND a.channel_id IN (SELECT id FROM readable) + AND ($3::timestamptz IS NOT NULL OR NOT a.deleted) AND (cardinality($8::text[])=0 OR a.artifact_type=ANY($8)) UNION ALL SELECT m.sync_modified_at,0,m.channel_id::text,NULL::uuid,m.channel_id FROM channel_members m WHERE m.community_id=$1 AND m.pubkey=$2 AND $3::timestamptz IS NOT NULL AND m.sync_modified_at >= $3 AND m.sync_modified_at <= $4 AND m.removed_at IS NOT NULL + AND (NOT $12::boolean OR m.channel_id=ANY($9)) AND NOT EXISTS (SELECT 1 FROM readable r WHERE r.id=m.channel_id) UNION ALL SELECT m.sync_modified_at,2,a.artifact_id::text,a.artifact_id,NULL::uuid diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs index a55f668fb0c..92461132597 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_access_postgres_tests.rs @@ -129,7 +129,9 @@ async fn sync_rejoin_replays_old_heads_without_resetting_on_task_edits() { async fn sync_ignores_unchanged_visibility_and_resets_on_ttl_changes() { let _guard = SERIAL.lock().await; let f = fixture().await; - accept(&f, &f.revision(Uuid::new_v4(), "create", None)).await; + let permanent_id = Uuid::new_v4(); + let permanent = f.revision(permanent_id, "create", None); + accept(&f, &permanent).await; let first = sync(&f, None).await; buzz_db::channel::update_channel( &f.pool, @@ -166,19 +168,58 @@ async fn sync_ignores_unchanged_visibility_and_resets_on_ttl_changes() { assert_eq!(reset["reason"], "policy_changed"); let next = sync(&f, None).await; assert!(next["checkpoint"].is_string()); - accept( - &f, - &f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None), - ) - .await; - let (status, body) = f + let expiring_id = Uuid::new_v4(); + let expiring = f.revision_as(&f.owner, f.private, expiring_id, "create", None); + accept(&f, &expiring).await; + for checkpoint in [None, Some(&next["checkpoint"])] { + let page = sync(&f, checkpoint).await; + assert_eq!(page["changes"].as_array().unwrap().len(), 1); + assert_eq!(page["changes"][0]["artifactId"], permanent_id.to_string()); + assert_eq!(page["changes"][0]["type"], "upsert"); + assert!(page["checkpoint"].is_string()); + } + let (status, current) = f .request( "/query", - json!([{"artifact":"incremental","query":query()}]), + json!([{ + "artifact":"incremental","action":"current","query":query(), + "artifactIds":[permanent_id,expiring_id] + }]), + ) + .await; + assert!(status.is_success(), "{status}: {current}"); + assert_eq!(current["changes"][0]["type"], "upsert"); + assert_eq!( + current["changes"][1], + json!({"type":"remove","artifactId":expiring_id}) + ); + let (status, named) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":{"artifact":"current","#h":[f.private]} + }]), + ) + .await; + assert!(status.is_success(), "{status}: {named}"); + assert_eq!(named["changes"], json!([])); + let moved = f.revision_as(&f.owner, f.private, permanent_id, "move", Some(&permanent)); + accept(&f, &moved).await; + let delta = sync(&f, Some(&next["checkpoint"])).await; + assert_eq!( + delta["changes"], + json!([{"type":"remove","artifactId":permanent_id}]) + ); + let (status, scoped) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":{"artifact":"current","#h":[f.home]}, + }]), ) .await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body["code"], "incremental_unsupported_scope"); + assert!(status.is_success(), "{status}: {scoped}"); + assert_eq!(scoped["changes"], json!([])); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs index ddc71968f63..eab9488eb2a 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_cursor_postgres_tests.rs @@ -100,3 +100,132 @@ async fn sync_move_cursor_hides_private_and_redacted_revision_ids() { } } } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_channel_scope_limits_candidates_and_keeps_source_removals() { + let _guard = SERIAL.lock().await; + let f = fixture().await; + buzz_db::channel::update_channel( + &f.pool, + f.community, + f.home, + buzz_db::channel::ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + for _ in 0..101 { + accept( + &f, + &f.revision_as(&f.owner, f.private, Uuid::new_v4(), "create", None), + ) + .await; + } + let deleted_id = Uuid::new_v4(); + let deleted = f.revision(deleted_id, "create", None); + accept(&f, &deleted).await; + accept(&f, &f.revision(deleted_id, "delete", Some(&deleted))).await; + let id = Uuid::new_v4(); + let created = f.revision(id, "create", None); + accept(&f, &created).await; + let scoped_query = json!({"artifact":"current","#type":["buzz.task"],"#h":[f.home]}); + let (status, initial) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":scoped_query + }]), + ) + .await; + assert!(status.is_success(), "{status}: {initial}"); + assert_eq!(initial["changes"].as_array().unwrap().len(), 1); + assert_eq!(initial["changes"][0]["event"]["id"], created.id.to_hex()); + assert!(initial["nextCursor"].is_null()); + assert!(initial["checkpoint"].is_string()); + let moved = f.revision_as(&f.owner, f.private, id, "move", Some(&created)); + accept(&f, &moved).await; + let (status, delta) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":scoped_query,"checkpoint":initial["checkpoint"] + }]), + ) + .await; + assert!(status.is_success(), "{status}: {delta}"); + assert!(delta["changes"] + .as_array() + .unwrap() + .contains(&json!({"type":"remove","artifactId":id}))); + assert!(!delta.to_string().contains(&f.private.to_string())); + let restored = f.revision(id, "move", Some(&moved)); + accept(&f, &restored).await; + for channel in [f.home, f.private] { + sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')") + .bind(f.community.as_uuid()).bind(channel).bind(f.peer.public_key().as_bytes().as_slice()) + .execute(&f.pool).await.unwrap(); + } + buzz_db::channel_members::remove_member( + &f.pool, + f.community, + f.private, + f.owner.public_key().as_bytes(), + f.owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let (status, returned) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":scoped_query,"checkpoint":delta["checkpoint"] + }]), + ) + .await; + assert!(status.is_success(), "{status}: {returned}"); + assert!(returned["changes"] + .as_array() + .unwrap() + .iter() + .any(|c| c["event"]["id"] == restored.id.to_hex())); + assert!(returned["changes"] + .as_array() + .unwrap() + .iter() + .all(|c| c["type"] != "removeChannel")); + buzz_db::channel_members::remove_member( + &f.pool, + f.community, + f.home, + f.owner.public_key().as_bytes(), + f.owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let (status, removed) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":scoped_query,"checkpoint":returned["checkpoint"] + }]), + ) + .await; + assert!(status.is_success(), "{status}: {removed}"); + assert_eq!( + removed["changes"], + json!([{"type":"removeChannel","channelId":f.home}]) + ); + let (status, invalid_scope) = f + .request( + "/query", + json!([{ + "artifact":"incremental","query":{"artifact":"current","#h":["not-a-uuid"]} + }]), + ) + .await; + assert!(status.is_success(), "{status}: {invalid_scope}"); + assert_eq!(invalid_scope["changes"], json!([])); +} diff --git a/crates/buzz-relay/src/api/artifact_sync.rs b/crates/buzz-relay/src/api/artifact_sync.rs index 23373eede99..6ec686d3ff2 100644 --- a/crates/buzz-relay/src/api/artifact_sync.rs +++ b/crates/buzz-relay/src/api/artifact_sync.rs @@ -70,10 +70,6 @@ fn reset(reason: &str) -> (StatusCode, Json) { fn db_error(e: buzz_db::DbError) -> (StatusCode, Json) { match e { buzz_db::DbError::ArtifactSyncLimit => limit(), - buzz_db::DbError::ArtifactSyncUnsupportedScope => error( - StatusCode::UNPROCESSABLE_ENTITY, - "incremental_unsupported_scope", - ), error => { tracing::warn!(%error,"incremental artifact read failed"); unavailable() diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index ddb3bdad06e..ff0d62c36b4 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -231,9 +231,13 @@ server permits four concurrent operations per process, a two-second SQL statement budget, and an eight-second operation budget. There is no identity-wide membership count limit on this path. -Only matching readable artifacts in permanent channels are supported. -A matching TTL artifact returns HTTP 422 `incremental_unsupported_scope`. An -unrelated TTL channel does not block incremental task reads. HTTP 400 `incremental_invalid` +Incremental reads include only matching readable artifacts in permanent channels. +TTL channels do not contribute discovery candidates. A known-ID read returns +`remove` for an artifact in a TTL channel. Moves into a TTL channel return +removals for the permanent source. An explicit `#h` scope limits candidates +to those channels, including retained move sources and membership removals. +Initial reads omit deleted heads. Later reads retain deletion removals. +HTTP 400 `incremental_invalid` rejects invalid requests or tokens. HTTP 503 `incremental_unavailable` reports a service or work-budget failure. These failures do not advance progress.