diff --git a/crates/buzz-core/src/artifact.rs b/crates/buzz-core/src/artifact.rs index 74f33c08d8a..50e8f6070db 100644 --- a/crates/buzz-core/src/artifact.rs +++ b/crates/buzz-core/src/artifact.rs @@ -248,6 +248,8 @@ mod tests { ); let query = parse_query(&json!({"artifact":"current","#project":["p"]})).unwrap(); assert_eq!(query.view, ArtifactView::Current); + let head = parse_query(&json!({"artifact":"head","#d":["x"]})).unwrap(); + assert_eq!(head.view, ArtifactView::Head); for invalid in [ json!({"artifact":"lookup","#d":["x"]}), json!({"artifact":"history"}), @@ -256,6 +258,8 @@ mod tests { json!({"artifact":"current","limit":1001}), json!({"artifact":"current","#assignee":[]}), json!({"artifact":"history","#d":[]}), + json!({"artifact":"head"}), + json!({"artifact":"sync","#h":["c"]}), ] { assert!(parse_query(&invalid).is_err(), "{invalid}"); } @@ -276,6 +280,9 @@ pub enum ArtifactView { Current, /// Every stored, unredacted revision of the artifacts named by `#d`. History, + /// The current revision of each artifact named by `#d`, including a + /// deletion. + Head, } /// Explicit HTTP artifact query. All `#name` predicates compare the first /// value of the same tag. @@ -331,8 +338,38 @@ pub fn parse_query(value: &serde_json::Value) -> Result ArtifactView::Current, Some("history") => ArtifactView::History, - _ => return Err("artifact must be \"current\" or \"history\""), + Some("head") => ArtifactView::Head, + _ => return Err("artifact must be \"current\", \"history\" or \"head\""), }; + let tags = parse_predicates(object, &["artifact", "kinds", "limit", "offset"])?; + let number = |name: &str, default: u64| { + object + .get(name) + .map(|v| v.as_u64().ok_or("invalid limit or offset")) + .transpose() + .map(|v| v.unwrap_or(default)) + }; + let limit = number("limit", 100)?; + let offset = number("offset", 0)?; + if limit == 0 || limit > MAX_PAGE_SIZE as u64 || offset > MAX_OFFSET { + return Err("artifact page limit exceeded"); + } + if view != ArtifactView::Current && !tags.iter().any(|(n, _)| n == "d") { + return Err("history and head require #d"); + } + Ok(ArtifactQuery { + view, + tags, + limit: limit as i64, + offset: offset as i64, + }) +} +/// Parse the `kinds` and `#name` predicates shared by every artifact filter. +/// Keys outside `#name` and `allowed` are rejected. +pub(crate) fn parse_predicates( + object: &serde_json::Map, + allowed: &[&str], +) -> Result)>, &'static str> { if let Some(kinds) = object.get("kinds") { if kinds.as_array().map(Vec::as_slice) != Some(&[serde_json::json!(45010)]) { return Err("artifact queries accept only kinds [45010]"); @@ -361,32 +398,12 @@ pub fn parse_query(value: &serde_json::Value) -> Result MAX_PREDICATES || total > MAX_QUERY_VALUES || bytes > MAX_TAG_BYTES { return Err("artifact predicate limits exceeded"); } - let number = |name: &str, default: u64| { - object - .get(name) - .map(|v| v.as_u64().ok_or("invalid limit or offset")) - .transpose() - .map(|v| v.unwrap_or(default)) - }; - let limit = number("limit", 100)?; - let offset = number("offset", 0)?; - if limit == 0 || limit > MAX_PAGE_SIZE as u64 || offset > MAX_OFFSET { - return Err("artifact page limit exceeded"); - } - if view == ArtifactView::History && !tags.iter().any(|(n, _)| n == "d") { - return Err("history requires #d"); - } - Ok(ArtifactQuery { - view, - tags, - limit: limit as i64, - offset: offset as i64, - }) + Ok(tags) } diff --git a/crates/buzz-core/src/artifact_sync.rs b/crates/buzz-core/src/artifact_sync.rs new file mode 100644 index 00000000000..87f63d417eb --- /dev/null +++ b/crates/buzz-core/src/artifact_sync.rs @@ -0,0 +1,132 @@ +//! NIP-AR sync filter: `{"artifact":"current", "#h":[...], ...}` on a `REQ`. +//! The same current-state filter as HTTP `/query`; the `REQ` transport makes +//! it a sync (complete set, `EOSE`, then live heads). +use crate::artifact::{canonical_uuid, parse_predicates}; +use uuid::Uuid; + +/// Largest set one sync serves: it bounds each initial read and the held +/// list of changed artifacts. Advertised in NIP-11 as `sync.max_events`. +pub const MAX_SYNC_EVENTS: usize = 4096; + +/// A parsed sync filter. `channels` are the `#h` values; `tags` holds every +/// `#name` predicate, `#h` included, with NIP-AR match semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtifactSyncFilter { + /// Named channels (`#h`), deduplicated in request order. Never empty. + pub channels: Vec, + /// Exact tag-name/value predicates (AND across names, OR within one). + pub tags: Vec<(String, Vec)>, +} + +impl ArtifactSyncFilter { + /// Whether `head` matches every predicate with the NIP-AR match rule: a + /// tag with that name whose first value is one of the requested values. + /// The same rule the current-state query applies (NIP-AR sync rule 3). + pub fn matches(&self, head: &nostr::Event) -> bool { + self.tags.iter().all(|(name, values)| { + head.tags.iter().any(|tag| match tag.as_slice() { + [tag_name, value, ..] => tag_name == name && values.contains(value), + _ => false, + }) + }) + } +} + +/// Whether a raw `REQ` filter is a sync: the current-state view. Other +/// artifact views have no live form and stay HTTP-only. +pub fn is_sync_filter(value: &serde_json::Value) -> bool { + value.get("artifact").and_then(|v| v.as_str()) == Some("current") +} + +/// Parse a sync filter. It requires `#h` with canonical channel UUIDs and +/// accepts the current-state predicates and `kinds: [45010]`, but no `limit` +/// or `offset`: a sync always serves the whole set. +pub fn parse_sync(value: &serde_json::Value) -> Result { + let object = value + .as_object() + .ok_or("artifact filter must be an object")?; + if !is_sync_filter(value) { + return Err("artifact must be \"current\""); + } + let tags = parse_predicates(object, &["artifact", "kinds"])?; + let mut channels = Vec::new(); + for (_, values) in tags.iter().filter(|(name, _)| name == "h") { + for value in values { + let id = canonical_uuid(value).map_err(|_| "#h values must be channel UUIDs")?; + if !channels.contains(&id) { + channels.push(id); + } + } + } + if channels.is_empty() { + return Err("sync requires #h"); + } + Ok(ArtifactSyncFilter { channels, tags }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sync_filter_requires_channels_and_rejects_paging() { + let a = Uuid::new_v4().to_string(); + let parsed = parse_sync( + &json!({"artifact":"current","#h":[a, a],"#type":["buzz.task"],"kinds":[45010]}), + ) + .unwrap(); + assert_eq!(parsed.channels.len(), 1); + assert_eq!(parsed.tags.len(), 2); + for invalid in [ + json!({"artifact":"current"}), + json!({"artifact":"current","#type":["buzz.task"]}), + json!({"artifact":"current","#h":[a],"limit":10}), + json!({"artifact":"current","#h":[a],"offset":0}), + json!({"artifact":"current","#h":[a],"kinds":[45011]}), + json!({"artifact":"current","#h":["not-a-uuid"]}), + json!({"artifact":"current","#h":[a],"since":1}), + json!({"artifact":"history","#h":[a]}), + ] { + assert!(parse_sync(&invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn matches_applies_every_predicate() { + let h = Uuid::new_v4().to_string(); + let filter = parse_sync( + &json!({"artifact":"current","#h":[h],"#type":["work.set"],"#status":["open"]}), + ) + .unwrap(); + let keys = nostr::Keys::generate(); + let head = |tags: &[[&str; 2]]| { + nostr::EventBuilder::new(nostr::Kind::Custom(45010), "") + .tags(tags.iter().map(|t| nostr::Tag::parse(t.to_vec()).unwrap())) + .sign_with_keys(&keys) + .unwrap() + }; + assert!(filter.matches(&head(&[ + ["h", &h], + ["type", "work.set"], + ["status", "open"] + ]))); + // A mutable tag that does not match, a missing tag, another type or + // another channel: not in scope. + for tags in [ + &[["h", &h], ["type", "work.set"], ["status", "done"]][..], + &[["h", &h], ["type", "work.set"]][..], + &[["h", &h], ["type", "work.goals"], ["status", "open"]][..], + &[["h", "other"], ["type", "work.set"], ["status", "open"]][..], + ] { + assert!(!filter.matches(&head(tags)), "{tags:?}"); + } + // Any tag of the name can match, as in the query. + assert!(filter.matches(&head(&[ + ["h", &h], + ["type", "work.set"], + ["status", "done"], + ["status", "open"] + ]))); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index e7669c874b0..99b4aa68032 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -84,3 +84,5 @@ pub mod test_helpers { /// NIP-AR channel artifact envelope and limits. pub mod artifact; +/// NIP-AR sync filter parsing and limits. +pub mod artifact_sync; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 8d012dfae7d..a96d35b005c 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -86,6 +86,7 @@ pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; pub use nip_fi_binding::{NipFiBindingRecord, NipFiUnbind, RowCapExceeded}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; +pub use store::{ArtifactSnapshot, SnapshotDenied}; pub use usage::SessionLeader; use buzz_core::CommunityId; diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 97534792e76..2f8a2e5f7a7 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -698,3 +698,135 @@ async fn head_outside_authorized_channel_matches_missing_head() { ArtifactOutcome::Accepted(_) )); } + +/// Sync's complete-set read: keyset pages by `d` share one snapshot, so a +/// change committed after it opens is in no page. Mutation: drop the +/// `REPEATABLE READ` statement → the late create appears in a later page → +/// RED. Mutation: drop the `a.artifact_id>` keyset bound → pages repeat → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn sync_snapshot_pages_are_one_cut_and_head_includes_deletions() { + let f = Fixture::new().await; + let mut ids: Vec = (0..5).map(|_| Uuid::new_v4()).collect(); + ids.sort(); + let mut heads = vec![]; + for d in &ids { + let create = f.revision(*d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + heads.push(create); + } + // A deleted artifact leaves the set but stays visible to the head view. + let deleted = f.revision(ids[4], "delete", f.a, Some(&heads[4]), &f.owner, vec![]); + assert!(matches!( + f.accept(&deleted, None).await, + ArtifactOutcome::Accepted(_) + )); + let reader = f.owner.public_key(); + let mut snapshot = + f.db.open_artifact_snapshot(f.community, reader.as_bytes(), &[f.a]) + .await + .unwrap() + .unwrap(); + let first = snapshot.next_page(&[], 2).await.unwrap(); + // Committed after the snapshot opened, and its `d` sorts after the first + // page, so only a fresh snapshot per page would return it. + let late = loop { + let d = Uuid::new_v4(); + if d > ids[1] { + break d; + } + }; + let late_create = f.revision(late, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&late_create, None).await, + ArtifactOutcome::Accepted(_) + )); + let mut seen: Vec<_> = first.iter().map(|e| e.event.id).collect(); + loop { + let page = snapshot.next_page(&[], 2).await.unwrap(); + if page.is_empty() { + break; + } + seen.extend(page.iter().map(|e| e.event.id)); + } + snapshot.finish().await.unwrap(); + assert_eq!(seen, heads[..4].iter().map(|e| e.id).collect::>()); + + // Predicates apply inside the snapshot. + let mut filtered = + f.db.open_artifact_snapshot(f.community, reader.as_bytes(), &[f.a]) + .await + .unwrap() + .unwrap(); + let none = filtered + .next_page(&[("type".into(), vec!["other".into()])], 10) + .await + .unwrap(); + assert!(none.is_empty()); + + // A private channel the reader cannot read refuses the whole sync. + assert!(matches!( + f.db.open_artifact_snapshot(f.community, f.peer.public_key().as_bytes(), &[f.a, f.b]) + .await + .unwrap(), + Err(super::artifact_query::SnapshotDenied::Restricted) + )); + // So does a channel that does not exist. + assert!(matches!( + f.db.open_artifact_snapshot(f.community, reader.as_bytes(), &[Uuid::new_v4()]) + .await + .unwrap(), + Err(super::artifact_query::SnapshotDenied::Restricted) + )); + + let (head, is_deletion) = + f.db.artifact_head(f.community, reader.as_bytes(), ids[4], &[]) + .await + .unwrap() + .unwrap(); + assert_eq!(head.event.id, deleted.id); + assert!(is_deletion); + let (head, is_deletion) = + f.db.artifact_head(f.community, reader.as_bytes(), ids[0], &[]) + .await + .unwrap() + .unwrap(); + assert_eq!(head.event.id, heads[0].id); + assert!(!is_deletion); + let (view, _) = f + .query( + &f.owner, + serde_json::json!({"artifact":"head","#d":[ids[4], ids[0]]}), + false, + ) + .await; + let mut view: Vec<_> = view.iter().map(|e| e.event.id).collect(); + view.sort(); + let mut expected = vec![deleted.id, heads[0].id]; + expected.sort(); + assert_eq!(view, expected); + assert_eq!( + f.count( + &f.owner, + serde_json::json!({"artifact":"current","#d":[ids[4]]}) + ) + .await, + 0 + ); + // A reader who cannot read the head's channel learns nothing. + let private = Uuid::new_v4(); + let create = f.revision(private, "create", f.b, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + assert!(f + .db + .artifact_head(f.community, f.peer.public_key().as_bytes(), private, &[]) + .await + .unwrap() + .is_none()); +} diff --git a/crates/buzz-db/src/store/artifact_query.rs b/crates/buzz-db/src/store/artifact_query.rs index 6dc38ba11ff..9a5cee1bc4f 100644 --- a/crates/buzz-db/src/store/artifact_query.rs +++ b/crates/buzz-db/src/store/artifact_query.rs @@ -4,75 +4,62 @@ use buzz_core::{ artifact::{ArtifactQuery, ArtifactView}, CommunityId, StoredEvent, }; -use sqlx::QueryBuilder; +use sqlx::{pool::PoolConnection, Postgres, QueryBuilder, Transaction}; +use uuid::Uuid; -impl Db { - /// Execute a bounded query against current artifacts or revision history. - /// Permission is resolved in the query snapshot. - pub async fn query_artifacts( - &self, - community: CommunityId, - reader: &[u8], - query: &ArtifactQuery, - count: bool, - ) -> Result<(Vec, i64)> { - // Read-only: a plain writer-pool snapshot transaction, labeled as the - // history read it is, so it neither takes community admission nor - // counts as an event write. - 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 mut qb = QueryBuilder::::new(if count { - "SELECT count(*) AS count FROM events e " - } 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 " - }); - predicates(&mut qb, community, reader, query); - if count { - let n: i64 = qb.build_query_scalar().fetch_one(&mut *tx).await?; - tx.commit().await?; - return Ok((vec![], n)); +const EVENT_COLUMNS: &str = + "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 "; + +/// Own the visibility and tag rules for artifact queries and current-ID +/// reads: the `query.view` join, then `push_scope` with the channels `#h` +/// names. +pub(super) fn predicates( + qb: &mut QueryBuilder, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, +) { + match 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 "); } - qb.push(" ORDER BY e.received_at DESC,e.id ASC LIMIT ") - .push_bind(query.limit) - .push(" OFFSET ") - .push_bind(query.offset); - let rows = qb.build().fetch_all(&mut *tx).await?; - let mut events = vec![]; - for row in rows { - if let Some(event) = crate::event::row_to_stored_event(row)? { - events.push(event); - } + ArtifactView::Head => { + qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id "); + } + ArtifactView::History => {} + } + push_scope( + qb, + community, + reader, + &query.tags, + named_channels(&query.tags), + ); + if query.view != ArtifactView::History { + 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(")"); } - tx.commit().await?; - Ok((events, 0)) } } -/// Own the visibility and tag rules for artifact queries and current-ID reads. -pub(super) fn predicates( - qb: &mut QueryBuilder, +/// Append the reader's permission scope and the exact tag predicates. A +/// system channel, or an unlisted open channel the reader is not a member +/// of, is readable only when `named_channels` names it (NIP-CL). This is the +/// one copy of the current-state access rule. +fn push_scope( + qb: &mut QueryBuilder, community: CommunityId, reader: &[u8], - query: &ArtifactQuery, + tags: &[(String, Vec)], + named_channels: Vec, ) { - 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 "); - } - // System channels and unlisted open channels require an explicit `#h` - // unless the reader is a member of the unlisted channel (NIP-CL). - 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 (") @@ -83,24 +70,12 @@ pub(super) fn predicates( .push_bind(named_channels) .push("))) 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(")"); - } - } + tag_predicates(qb, tags); } /// 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 { +fn tag_predicates(qb: &mut QueryBuilder, tags: &[(String, Vec)]) { + for (name, values) in tags { // GIN-usable necessary prefilter; retain the positional recheck // because JSON array containment alone also matches swapped values. qb.push(" AND ("); @@ -119,3 +94,196 @@ pub(super) fn tag_predicates(qb: &mut QueryBuilder, query: &Arti .push("))"); } } + +/// Channel UUIDs named by `#h`; malformed values name no channel. +fn named_channels(tags: &[(String, Vec)]) -> Vec { + tags.iter() + .filter(|(name, _)| name == "h") + .flat_map(|(_, values)| values.iter().filter_map(|value| value.parse().ok())) + .collect() +} + +fn rows_to_events(rows: Vec) -> Result> { + let mut events = vec![]; + for row in rows { + if let Some(event) = crate::event::row_to_stored_event(row)? { + events.push(event); + } + } + Ok(events) +} + +/// Read-only: a plain writer-pool connection, labeled as the history read it +/// is, so it neither takes community admission nor counts as an event write. +/// The writer is always at least as fresh as every accepted change. +async fn read_connection(db: &Db) -> Result> { + crate::observability::acquire_writer( + &db.pool, + crate::observability::WriterOperation::SubscriptionHistory, + ) + .await + .map_err(Into::into) +} + +/// Why a sync snapshot did not open. +#[derive(Debug, PartialEq, Eq)] +pub enum SnapshotDenied { + /// The reader cannot read a named channel, or it does not exist. + Restricted, +} + +/// One read-only `REPEATABLE READ` snapshot of a sync's current heads, read +/// in keyset pages ordered by artifact identity. Every page sees the same +/// database snapshot, so the pages together are one consistent cut. +pub struct ArtifactSnapshot { + tx: Transaction<'static, Postgres>, + community: CommunityId, + reader: Vec, + channels: Vec, + after: Option, + done: bool, +} + +impl ArtifactSnapshot { + /// Read the next page of at most `page` heads that match `tags`, ordered + /// by `d`. Returns an empty page once the set is exhausted. + pub async fn next_page( + &mut self, + tags: &[(String, Vec)], + page: i64, + ) -> Result> { + if self.done { + return Ok(vec![]); + } + 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,a.artifact_id FROM events e ", + ); + qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND NOT a.deleted "); + push_scope( + &mut qb, + self.community, + &self.reader, + tags, + self.channels.clone(), + ); + if let Some(after) = self.after { + qb.push(" AND a.artifact_id>").push_bind(after); + } + qb.push(" ORDER BY a.artifact_id LIMIT ").push_bind(page); + let rows = qb.build().fetch_all(&mut *self.tx).await?; + self.done = (rows.len() as i64) < page; + if let Some(last) = rows.last() { + self.after = Some(sqlx::Row::try_get(last, "artifact_id")?); + } + rows_to_events(rows) + } + + /// End the snapshot. Dropping it rolls the read-only transaction back. + pub async fn finish(self) -> Result<()> { + self.tx.commit().await?; + Ok(()) + } +} + +impl Db { + /// Execute a bounded query against current artifacts, current heads + /// including deletions, or revision history. Permission is resolved in + /// the query snapshot. + pub async fn query_artifacts( + &self, + community: CommunityId, + reader: &[u8], + query: &ArtifactQuery, + count: bool, + ) -> Result<(Vec, i64)> { + let connection = read_connection(self).await?; + let mut tx = sqlx::Transaction::begin(connection, None).await?; + sqlx::query("SET LOCAL statement_timeout='2s'") + .execute(&mut *tx) + .await?; + let mut qb = QueryBuilder::::new(if count { + "SELECT count(*) AS count FROM events e " + } else { + EVENT_COLUMNS + }); + predicates(&mut qb, community, reader, query); + if count { + let n: i64 = qb.build_query_scalar().fetch_one(&mut *tx).await?; + tx.commit().await?; + return Ok((vec![], n)); + } + qb.push(" ORDER BY e.received_at DESC,e.id ASC LIMIT ") + .push_bind(query.limit) + .push(" OFFSET ") + .push_bind(query.offset); + let events = rows_to_events(qb.build().fetch_all(&mut *tx).await?)?; + tx.commit().await?; + Ok((events, 0)) + } + + /// The current head of one artifact, including a deletion, when the + /// reader can read the head's channel, with whether it is a deletion. + /// `named_channels` are the system channels the caller's request names. + pub async fn artifact_head( + &self, + community: CommunityId, + reader: &[u8], + artifact: Uuid, + named_channels: &[Uuid], + ) -> Result> { + let mut connection = read_connection(self).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,a.deleted FROM events e ", + ); + qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND a.artifact_id=") + .push_bind(artifact) + .push(" "); + push_scope(&mut qb, community, reader, &[], named_channels.to_vec()); + let Some(row) = qb.build().fetch_optional(&mut *connection).await? else { + return Ok(None); + }; + let deleted: bool = sqlx::Row::try_get(&row, "deleted")?; + Ok(rows_to_events(vec![row])? + .into_iter() + .next() + .map(|head| (head, deleted))) + } + + /// Open a sync snapshot over `channels`, after confirming in the same + /// snapshot that the reader can read every one of them. + /// `statement_timeout` bounds each page read. + pub async fn open_artifact_snapshot( + &self, + community: CommunityId, + reader: &[u8], + channels: &[Uuid], + ) -> Result> { + let connection = read_connection(self).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 readable: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channels c WHERE c.community_id=$1 AND c.id=ANY($2) AND c.deleted_at IS NULL 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=$3 AND m.removed_at IS NULL))", + ) + .bind(community.as_uuid()) + .bind(channels) + .bind(reader) + .fetch_one(&mut *tx) + .await?; + if readable != channels.len() as i64 { + return Ok(Err(SnapshotDenied::Restricted)); + } + Ok(Ok(ArtifactSnapshot { + tx, + community, + reader: reader.to_vec(), + channels: channels.to_vec(), + after: None, + done: false, + })) + } +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 961d50bb179..d5bbde43472 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -85,6 +85,7 @@ pub mod artifact; mod artifact_query; /// Timestamp-based incremental artifact reads. pub mod artifact_sync; +pub use artifact_query::{ArtifactSnapshot, SnapshotDenied}; #[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 fc1bc93770a..f1fa4af9beb 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -811,6 +811,22 @@ pub async fn execute_delete_with_marker( .fetch_optional(tx.conn()) .await?; + // NIP-AR: an artifact revision leaves current state only through its own + // `op=delete` or a kind-9005 redaction. Both are fanned out, so open syncs + // see them; this delete would retire a head with no live signal. + let kind: Option = + sqlx::query_scalar("SELECT kind FROM events WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(target_event_id) + .fetch_optional(tx.conn()) + .await?; + if kind == Some(45010) { + return Err(crate::error::DbError::InvalidData( + "artifact revisions cannot be deleted by admin action; use op=delete or kind 9005 redaction" + .into(), + )); + } + // Canonical delete + thread_metadata counters, fenced by this transaction. // Counters move only when the row transitions to deleted, so a second // action against an already-deleted target leaves them unchanged. diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 487ea9b6302..b078699abb4 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -51,7 +51,7 @@ use std::time::Duration; use buzz_core::TenantContext; use nostr::PublicKey; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, @@ -72,6 +72,20 @@ pub struct ChannelEvent { pub event: nostr::Event, } +/// Health of this node's Redis event subscription link. +/// +/// `epoch` advances on every event that can lose messages without a replay: +/// the link connects, drops, or skips a message it could not decode. A reader +/// that needs every message records the epoch when its interest is confirmed +/// and treats any later change as a possible loss. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LinkState { + /// Advances on every possible loss. + pub epoch: u64, + /// Whether the link is connected and its desired topics are subscribed. + pub up: bool, +} + /// Configuration for the pub/sub subsystem. #[derive(Debug, Clone)] pub struct PubSubConfig { @@ -115,6 +129,7 @@ pub struct PubSubManager { cache_invalidation_tx: broadcast::Sender, conn_control_tx: broadcast::Sender, nip_fi_disconnect_tx: broadcast::Sender, + link: watch::Sender, } impl PubSubManager { @@ -145,6 +160,7 @@ impl PubSubManager { cache_invalidation_tx, conn_control_tx, nip_fi_disconnect_tx, + link: watch::Sender::new(LinkState::default()), }) } @@ -163,6 +179,7 @@ impl PubSubManager { self.broadcast_tx.clone(), self.desired_topics.clone(), subscription_rx, + self.link.clone(), ) .await; } @@ -232,6 +249,28 @@ impl PubSubManager { } } + /// Watch this node's Redis event link (see [`LinkState`]). + pub fn link_state(&self) -> watch::Receiver { + self.link.subscribe() + } + + /// Wait until Redis has confirmed a subscription to an already retained + /// topic, and return the link epoch it was confirmed on. Fails when the + /// link drops before it answers; the caller bounds the wait. + pub async fn confirm_topic( + &self, + ctx: &TenantContext, + topic: EventTopic, + ) -> Result { + let (reply, answer) = oneshot::channel(); + let topic_key = EventTopicKey::from_context(ctx, topic); + self.subscription_tx + .send(subscriber::SubscriptionCommand::Confirm(topic_key, reply)) + .await + .map_err(|_| PubSubError::SubscriberStopped)?; + answer.await.map_err(|_| PubSubError::SubscriberStopped) + } + /// Release local interest in a scoped Redis event topic. /// /// When the last retain is released, unsubscribe is delayed by the configured diff --git a/crates/buzz-pubsub/src/subscriber.rs b/crates/buzz-pubsub/src/subscriber.rs index 88826ed99be..0c34dba8d13 100644 --- a/crates/buzz-pubsub/src/subscriber.rs +++ b/crates/buzz-pubsub/src/subscriber.rs @@ -6,10 +6,10 @@ use std::time::Duration; use futures_util::StreamExt; use nostr::JsonUtil; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex}; use crate::topic::EventTopicKey; -use crate::ChannelEvent; +use crate::{ChannelEvent, LinkState}; /// Initial reconnect backoff (1 second). const BACKOFF_INITIAL_SECS: u64 = 1; @@ -21,12 +21,24 @@ pub(crate) type DesiredTopics = Arc>>; /// Commands sent from relay subscription registration/removal to the Redis /// pub/sub task. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug)] pub(crate) enum SubscriptionCommand { /// Ensure the topic is subscribed on the current Redis pub/sub connection. Subscribe(EventTopicKey), /// Unsubscribe only if the desired refcount is still zero when processed. UnsubscribeIfIdle(EventTopicKey), + /// Subscribe if needed, then answer with the link epoch on which the + /// server has confirmed the subscription. + Confirm(EventTopicKey, oneshot::Sender), +} + +/// Record a possible loss of messages: the epoch advances, so every reader +/// that started on the old epoch can tell. +fn mark_loss(link: &watch::Sender, up: bool) { + link.send_modify(|state| { + state.epoch += 1; + state.up = up; + }); } /// Runs a dynamically scoped subscriber and forwards events to broadcast. @@ -39,18 +51,24 @@ pub(crate) async fn run_subscriber( broadcast_tx: broadcast::Sender, desired_topics: DesiredTopics, mut subscription_rx: mpsc::Receiver, + link: watch::Sender, ) { let mut backoff_secs = BACKOFF_INITIAL_SECS; loop { - match connect_and_subscribe( + let result = connect_and_subscribe( &redis_url, &broadcast_tx, desired_topics.clone(), &mut subscription_rx, + &link, ) - .await - { + .await; + if link.borrow().up { + // Messages published while the link is down are not replayed. + mark_loss(&link, false); + } + match result { Ok(()) => { // Stream ended cleanly (Redis returned None). The connection was // established and ran successfully, so reset backoff to the initial @@ -77,6 +95,7 @@ async fn connect_and_subscribe( broadcast_tx: &broadcast::Sender, desired_topics: DesiredTopics, subscription_rx: &mut mpsc::Receiver, + link: &watch::Sender, ) -> Result<(), redis::RedisError> { let client = redis::Client::open(redis_url)?; let conn = client.get_async_pubsub().await?; @@ -101,6 +120,7 @@ async fn connect_and_subscribe( topic_count = active_topics.len(), "Redis pub/sub subscriber connected with dynamic scoped subscriptions" ); + mark_loss(link, true); loop { tokio::select! { @@ -112,6 +132,14 @@ async fn connect_and_subscribe( sink.subscribe(&channel).await?; } } + SubscriptionCommand::Confirm(topic, reply) => { + let channel = topic.redis_channel(); + if !active_topics.contains(&channel) { + sink.subscribe(&channel).await?; + active_topics.insert(channel); + } + let _ = reply.send(link.borrow().epoch); + } SubscriptionCommand::UnsubscribeIfIdle(topic) => { if desired_refcount(&desired_topics, topic).await == 0 { let channel = topic.redis_channel(); @@ -132,6 +160,7 @@ async fn connect_and_subscribe( Ok(p) => p, Err(e) => { tracing::warn!("Failed to get pub/sub message payload: {e}"); + mark_loss(link, true); continue; } }; @@ -149,6 +178,7 @@ async fn connect_and_subscribe( Ok(e) => e, Err(e) => { tracing::warn!("Failed to deserialize event from pub/sub: {e}"); + mark_loss(link, true); continue; } }; diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index b5cfa1ef490..15ad690bc77 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -11686,6 +11686,43 @@ mod postgres_tests { assert_eq!(live, 0); } + /// An artifact revision is not admin-deletable: open NIP-AR syncs would + /// get no live signal (they see `op=delete` and kind 9005). The action + /// fails terminally and the head stays. Mutation: drop the kind check in + /// `execute_delete_with_marker` → 200 and the head is gone → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn direct_delete_refuses_artifact_revisions() { + let (pool, community, host, state) = direct_fixture().await; + let id = seed_signed_event(&pool, community, &test_operator_keys()).await; + sqlx::query("UPDATE events SET kind = 45010 WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&pool) + .await + .unwrap(); + let path = format!("/events/{id}/delete?communityHost={host}"); + let (status, body) = direct_post( + &state, + &path, + serde_json::json!({ "requestId": Uuid::new_v4() }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!( + body.to_string() + .contains("artifact revisions cannot be deleted"), + "{body}" + ); + let live: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM events WHERE community_id = $1 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(live, 1); + } + /// F1: attempt B passes the stored-action lookup, attempt A (same request) /// accepts and deletes, then B validates against a gone event. B must /// replay A (200), and a B carrying a different intent must get 409 — diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 8384345671f..f2f49eedc01 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1385,10 +1385,14 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let permit = match state.handler_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - conn.send(request_rejection_message( - RejectionTarget::Subscription(&sub_id), + // Retires whatever held the ID before `CLOSED`. + handlers::close::refuse( + &sub_id, "rate-limited: too many concurrent requests", - )); + &conn, + &state, + ) + .await; return; } }; @@ -1401,6 +1405,32 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar .instrument(span), ); } + ClientMessage::ArtifactSync { sub_id, filter } => { + let conn = Arc::clone(&conn); + let state = Arc::clone(&state); + let permit = match state.handler_semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + // Retires whatever held the ID before `CLOSED`. + handlers::close::refuse( + &sub_id, + "rate-limited: too many concurrent requests", + &conn, + &state, + ) + .await; + return; + } + }; + let span = + tracing::info_span!("ws.artifact_sync", conn_id = %conn.conn_id, sub_id = %sub_id); + tokio::spawn( + async move { + handlers::artifact_sync::handle_sync(sub_id, filter, conn, state, permit).await; + } + .instrument(span), + ); + } ClientMessage::Count { sub_id, filters } => { let conn = Arc::clone(&conn); let state = Arc::clone(&state); @@ -2119,6 +2149,258 @@ pub(crate) mod tests { assert_eq!(frame[2], "rate-limited: too many concurrent requests"); } + /// A sync or ordinary REQ refused before its handler runs still replaces + /// the old holder of its ID (NIP-01), whether that is a sync or a live + /// subscription: the `CLOSED` must not leave either one fanning out under + /// a closed ID. Covers handler saturation and WS admission, both through + /// production dispatch. Mutation: send any refusal without + /// `retire_locked` → RED. + #[tokio::test] + async fn pre_handler_req_refusal_retires_the_old_holder() { + use buzz_pubsub::EventTopic; + let cases = [ + ( + AuthState::Failed, + true, + "rate-limited: too many concurrent requests", + ), + ( + authenticated_state(), + false, + "rate-limited: shared admission unavailable", + ), + ]; + let shapes = [(true, true), (true, false), (false, true), (false, false)]; + for ((sync, sync_holder), (auth, saturate, reason)) in shapes + .into_iter() + .flat_map(|shape| cases.clone().map(|case| (shape, case))) + { + let case = format!("{reason}, sync REQ: {sync}, sync holder: {sync_holder}"); + // Redis is unreachable here, so authenticated admission is + // `Unavailable`; an unauthenticated connection skips admission. + let state = crate::state::tests::test_state().await; + let (conn, mut rx) = test_conn_with_auth(auth); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + handlers::req::claim_live_subscription( + "s", + &[nostr::Filter::new().kind(Kind::TextNote)], + Some(&[channel]), + &conn, + &state, + ) + .await + .expect("claim the old holder"); + if sync_holder { + state.artifact_syncs.insert_idle(conn.conn_id, "s"); + } + let _held = if saturate { + let permits = state.handler_semaphore.available_permits(); + Some( + Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"), + ) + } else { + None + }; + + let filter = if sync { + serde_json::json!({"artifact": "current", "#h": [channel.to_string()]}) + } else { + serde_json::json!({"kinds": [1], "#h": [channel.to_string()]}) + }; + let raw = serde_json::json!(["REQ", "s", filter]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + assert!(conn.subscriptions.lock().await.is_empty(), "{case}"); + assert!( + state.sub_registry.get_filters(conn.conn_id, "s").is_none(), + "{case}" + ); + assert!(!state.artifact_syncs.contains(conn.conn_id, "s"), "{case}"); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, topic).await, + 0, + "{case}" + ); + let frame = read_frame(&mut rx); + assert_eq!(frame, serde_json::json!(["CLOSED", "s", reason]), "{case}"); + assert!(rx.try_recv().is_err(), "{case}: only CLOSED is sent"); + } + } + + /// An ordinary REQ that `handle_req` itself refuses, before it claims its + /// ID, still replaces the old holder (NIP-01): a live subscription or an + /// artifact sync must not keep using an ID the client was told is closed. + /// Covers the auth, scope, channel-count and database-error refusals. + /// Mutation: send any of them as a bare `CLOSED` → RED. + #[tokio::test] + async fn in_handler_req_refusal_retires_the_old_holder() { + use buzz_pubsub::EventTopic; + let mut scoped = auth_context(); + scoped.scopes = vec![buzz_auth::Scope::ChannelsRead]; + let channel = uuid::Uuid::new_v4(); + let too_many: Vec = (0..=handlers::req::MAX_EXPLICIT_CHANNEL_VALUES) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect(); + let cases = [ + ( + AuthState::Failed, + serde_json::json!({"kinds": [1]}), + "auth-required: not authenticated", + ), + ( + AuthState::Authenticated(scoped), + serde_json::json!({"kinds": [1]}), + "restricted: insufficient scope", + ), + ( + authenticated_state(), + serde_json::json!({"kinds": [1], "#h": too_many}), + "restricted: too many explicit channels", + ), + ( + authenticated_state(), + serde_json::json!({"search": "x"}), + "error: database error", + ), + ]; + for ((auth, filter, reason), sync_holder) in cases + .into_iter() + .flat_map(|case| [(case.clone(), false), (case, true)]) + { + let case = format!("{reason}, sync holder: {sync_holder}"); + let state = crate::state::tests::test_state_with_unreachable_database_url( + "postgres://127.0.0.1:1/none", + ) + .await; + let (conn, mut rx) = test_conn_with_auth(auth); + let topic = EventTopic::Channel(channel); + handlers::req::claim_live_subscription( + "s", + &[nostr::Filter::new().kind(Kind::TextNote)], + Some(&[channel]), + &conn, + &state, + ) + .await + .expect("claim the old holder"); + if sync_holder { + state.artifact_syncs.insert_idle(conn.conn_id, "s"); + } + + let filter = serde_json::from_value(filter).expect("filter"); + handlers::req::handle_req( + "s".into(), + vec![filter], + vec![None], + Arc::clone(&conn), + Arc::clone(&state), + ) + .await; + + assert!(conn.subscriptions.lock().await.is_empty(), "{case}"); + assert!( + state.sub_registry.get_filters(conn.conn_id, "s").is_none(), + "{case}" + ); + assert!(!state.artifact_syncs.contains(conn.conn_id, "s"), "{case}"); + assert_eq!( + state.pubsub.topic_refcount(&conn.tenant, topic).await, + 0, + "{case}" + ); + let mut frame = read_frame(&mut rx); + if frame[0] == "NOTICE" { + frame = read_frame(&mut rx); + } + assert_eq!(frame, serde_json::json!(["CLOSED", "s", reason]), "{case}"); + assert!(rx.try_recv().is_err(), "{case}: nothing follows CLOSED"); + } + } + + /// A REQ for a new ID refused at the subscription limit cancels the + /// connection when its `CLOSED` cannot be queued: the client would + /// otherwise wait on a request the relay refused. + /// Mutation: ignore the `send` result on that refusal → RED. + #[tokio::test] + async fn subscription_limit_refusal_with_a_full_queue_cancels_the_connection() { + let state = crate::state::tests::test_state().await; + let (conn, _rx) = test_conn_with_auth(authenticated_state()); + { + let mut subs = conn.subscriptions.lock().await; + for index in 0..handlers::req::MAX_SUBSCRIPTIONS { + subs.insert(format!("held-{index}"), 0); + } + } + while conn + .send_tx + .try_send(WsMessage::Text("filler".into())) + .is_ok() + {} + + handlers::req::handle_req( + "s".into(), + vec![nostr::Filter::new().kind(Kind::TextNote)], + vec![None], + Arc::clone(&conn), + Arc::clone(&state), + ) + .await; + + assert!(conn.cancel.is_cancelled()); + assert!(!conn.subscriptions.lock().await.contains_key("s")); + } + + /// A pre-handler refusal whose `CLOSED` cannot be queued has already + /// retired the old holder, so it cancels the connection: the client + /// learns the ID is gone from the disconnect. The queue is full but + /// below the slow-client grace limit, which would not cancel by itself. + /// Covers a sync and an ordinary REQ through production dispatch. + /// Mutation: ignore the `send` result in `close::refuse` → RED. + #[tokio::test] + async fn pre_handler_refusal_with_a_full_queue_cancels_the_connection() { + for filter in [ + serde_json::json!({"artifact": "current", "#h": [uuid::Uuid::new_v4().to_string()]}), + serde_json::json!({"kinds": [1]}), + ] { + let state = crate::state::tests::test_state().await; + let (conn, _rx) = test_conn_with_auth(AuthState::Failed); + handlers::req::claim_live_subscription( + "s", + &[nostr::Filter::new().kind(Kind::TextNote)], + None, + &conn, + &state, + ) + .await + .expect("claim the old holder"); + state.artifact_syncs.insert_idle(conn.conn_id, "s"); + while conn + .send_tx + .try_send(WsMessage::Text("filler".into())) + .is_ok() + {} + let permits = state.handler_semaphore.available_permits(); + let _held = Arc::clone(&state.handler_semaphore) + .acquire_many_owned(permits as u32) + .await + .expect("hold every handler permit"); + + let raw = serde_json::json!(["REQ", "s", filter]).to_string(); + handle_text_message(raw, Arc::clone(&conn), Arc::clone(&state)).await; + + assert!(conn.subscriptions.lock().await.is_empty(), "{filter}"); + assert!( + !state.artifact_syncs.contains(conn.conn_id, "s"), + "{filter}" + ); + assert!(conn.cancel.is_cancelled(), "{filter}"); + } + } + #[derive(Debug, Default)] struct MockSinkState { messages: Vec, diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 772e032e75f..c52e71d6d4d 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -73,26 +73,18 @@ fn head_unavailable() -> IngestError { } /// Live delivery only; unlike kind 9 there are no audit, workflow, or thread -/// side effects. Missed deliveries are recovered by replaying stored events. +/// side effects. A failed publish is retried, because syncs depend on it. async fn publish(state: &Arc, tenant: &TenantContext, stored: &StoredEvent) { let Some(channel) = stored.channel_id else { return; }; - state.mark_local_event(tenant.community(), &stored.event.id); - if let Err(e) = state - .pubsub - .publish_event( - tenant, - buzz_pubsub::EventTopic::Channel(channel), - &stored.event, - ) - .await - { - state - .local_event_ids - .invalidate(&(tenant.community(), stored.event.id.to_bytes())); - tracing::warn!(error=%e, "artifact Redis publish failed"); - } + super::artifact_sync::publish_reliably( + state, + tenant, + buzz_pubsub::EventTopic::Channel(channel), + stored, + ) + .await; super::event::fan_out_event_to_local_subscribers(state, tenant.community(), stored).await; } diff --git a/crates/buzz-relay/src/handlers/artifact_sync.rs b/crates/buzz-relay/src/handlers/artifact_sync.rs new file mode 100644 index 00000000000..ec69fa586fa --- /dev/null +++ b/crates/buzz-relay/src/handlers/artifact_sync.rs @@ -0,0 +1,1038 @@ +//! NIP-AR Sync: a `REQ` that keeps an exact copy of a set of artifacts. +//! +//! The relay never forwards the published event to a sync. A live entry only +//! says that one `d` changed; the sync task then reads that artifact's current +//! head and sends it (NIP-AR Sync rules 3 and 4). Any possible loss of a live +//! entry ends the sync with `CLOSED "resync: ..."` (rule 5). +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex as StdMutex, PoisonError}; +use std::time::Duration; + +use axum::extract::ws::Message as WsMessage; +use buzz_auth::Scope; +use buzz_core::artifact_sync::{ArtifactSyncFilter, MAX_SYNC_EVENTS}; +use buzz_core::StoredEvent; +use buzz_pubsub::EventTopic; +use dashmap::DashMap; +use nostr::{Alphabet, Event, Filter, JsonUtil, Kind, SingleLetterTag}; +use tokio::sync::{Notify, OwnedSemaphorePermit}; +use tokio_util::sync::CancellationToken; +use tracing::warn; +use uuid::Uuid; + +use super::close::refuse; +use crate::connection::{AuthState, ConnectionState}; +use crate::protocol::RelayMessage; +use crate::state::AppState; +use crate::subscription::{ConnId, SubId}; + +/// Heads read per snapshot page. One page is the most the relay buffers. +const SNAPSHOT_PAGE: i64 = 256; +/// The longest the relay holds one sync's snapshot open, including the time +/// a slow reader takes to receive it (rule 2). Not advertised. +const SNAPSHOT_BUDGET: Duration = Duration::from_secs(30); +/// How long a new sync waits for Redis to confirm its live interest (rule 1). +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); +/// The most bytes of kind-45011 removals one sync holds (rule 5). Other +/// held changes are a `d` only, so `MAX_SYNC_EVENTS` bounds them. Not +/// advertised: a sync past either bound resyncs. +const MAX_HELD_BYTES: usize = 4 << 20; +/// Kind-9005 redaction; it can retire a head that is in scope (rule 6). +const KIND_REDACTION: u16 = 9005; +/// NIP-29 kinds that can change a reader's access to a channel: remove user, +/// edit metadata (visibility), delete group, leave (rule 9). +const ACCESS_KINDS: [u16; 4] = [9001, 9002, 9008, 9022]; +/// Every kind a sync consumes from live fan-out. A failed publish of one of +/// these is retried (rule 5). +pub(crate) const LIVE_KINDS: [u16; 7] = [45010, 45011, KIND_REDACTION, 9001, 9002, 9008, 9022]; + +/// Live changes held for one sync until its task reads their heads. +#[derive(Default)] +struct Pending { + /// Changed artifacts, with the latest kind-45011 removal seen for each. + changed: HashMap>, + /// Serialized size of the removals in `changed`. + held_bytes: usize, + /// The first reason this sync must resync, if any. Once set, nothing + /// more is held: the sync ends without reading its changes. + resync: Option<&'static str>, +} + +impl Pending { + fn end(&mut self, reason: &'static str) { + self.resync.get_or_insert(reason); + self.changed = HashMap::new(); + self.held_bytes = 0; + } + + /// Hold one change, refusing it before it is stored when it would pass + /// a bound. + fn hold(&mut self, d: Uuid, removal: Option) { + if self.resync.is_some() { + return; + } + if !self.changed.contains_key(&d) && self.changed.len() >= MAX_SYNC_EVENTS { + return self.end("resync: too many changes"); + } + let Some(removal) = removal else { + self.changed.entry(d).or_default(); + return; + }; + let size = removal.as_json().len(); + let replaced = self + .changed + .get(&d) + .and_then(Option::as_ref) + .map_or(0, |old| old.as_json().len()); + let held = self.held_bytes - replaced + size; + if held > MAX_HELD_BYTES { + return self.end("resync: too many changes"); + } + self.held_bytes = held; + self.changed.insert(d, Some(removal)); + } +} + +/// The artifact identity (`d`) of a revision or removal. +fn artifact_id(event: &Event) -> Option { + event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("d")) + .and_then(|tag| tag.as_slice().get(1)?.parse().ok()) +} + +/// One live sync's signal inbox. Fan-out and loss detection write to it; the +/// sync's own task is its only reader. +pub struct SyncHandle { + pending: StdMutex, + notify: Notify, + cancel: CancellationToken, +} + +impl SyncHandle { + fn new() -> Self { + Self { + pending: StdMutex::new(Pending::default()), + notify: Notify::new(), + cancel: CancellationToken::new(), + } + } + + fn update(&self, change: impl FnOnce(&mut Pending)) { + change(&mut self.pending.lock().unwrap_or_else(PoisonError::into_inner)); + self.notify.notify_one(); + } + + /// Record a fanned-out event that falls in this sync's channels. + fn signal(&self, stored: &StoredEvent) { + let kind = stored.event.kind.as_u16(); + if kind == KIND_REDACTION { + // The redacted head is withheld, so only a new sync can show the + // client that the artifact left the set. Any redaction in the + // channels counts: telling which ones retire a head needs a read. + return self.resync("resync: redaction"); + } + if ACCESS_KINDS.contains(&kind) { + return self.resync("resync: channel access changed"); + } + let Some(d) = artifact_id(&stored.event) else { + return; + }; + let removal = (kind == 45011).then(|| stored.event.clone()); + self.update(|pending| pending.hold(d, removal)); + } + + /// Ask the sync to end with `CLOSED reason`, where reason is `resync: ...`. + pub fn resync(&self, reason: &'static str) { + self.update(|pending| pending.end(reason)); + } + + fn take(&self) -> Pending { + std::mem::take(&mut *self.pending.lock().unwrap_or_else(PoisonError::into_inner)) + } +} + +/// Every live sync this node serves, by connection and subscription ID. +#[derive(Default)] +pub struct ArtifactSyncs { + syncs: DashMap<(ConnId, SubId), Arc>, +} + +impl ArtifactSyncs { + fn insert(&self, conn: ConnId, sub: &str, handle: Arc) { + if let Some(old) = self.syncs.insert((conn, sub.to_owned()), handle) { + old.cancel.cancel(); + } + } + + /// Stop the sync that holds `sub` on `conn`, if one does. Called wherever + /// the subscription ID is retired or replaced. + /// Returns whether `sub` was a sync. + pub fn remove(&self, conn: ConnId, sub: &str) -> bool { + match self.syncs.remove(&(conn, sub.to_owned())) { + Some((_, handle)) => { + handle.cancel.cancel(); + true + } + None => false, + } + } + + /// Whether `sub` on `conn` is a sync. + pub fn contains(&self, conn: ConnId, sub: &str) -> bool { + self.syncs.contains_key(&(conn, sub.to_owned())) + } + + /// Stop every sync of a closed connection. + pub fn remove_connection(&self, conn: ConnId) { + self.syncs.retain(|(owner, _), handle| { + if *owner == conn { + handle.cancel.cancel(); + } + *owner != conn + }); + } + + /// End every sync on this node: a loss here can affect any of them. + pub fn resync_all(&self, reason: &'static str) { + for entry in self.syncs.iter() { + entry.value().resync(reason); + } + } + + /// Signal syncs among the fan-out matches *before* the access filter. A + /// reader who just lost access to a channel no longer passes that filter, + /// yet the change that removed it must still resync its syncs (rule 9). + /// Only kinds that resync are signalled here; nothing is sent. + pub fn signal_access_change(&self, stored: &StoredEvent, matches: &[(ConnId, SubId)]) { + let kind = stored.event.kind.as_u16(); + if self.syncs.is_empty() || !(kind == KIND_REDACTION || ACCESS_KINDS.contains(&kind)) { + return; + } + for key in matches { + if let Some(handle) = self.syncs.get(key) { + handle.signal(stored); + } + } + } + + /// End the syncs among `matches` with `reason`: a change reached them, + /// but this node cannot deliver its signal (rule 5). + pub fn resync_matches(&self, matches: &[(ConnId, SubId)], reason: &'static str) { + if self.syncs.is_empty() { + return; + } + for key in matches { + if let Some(handle) = self.syncs.get(key) { + handle.resync(reason); + } + } + } + + /// Divert fan-out matches that are syncs into their signal inboxes, and + /// return the matches that receive the event itself. + pub fn divert( + &self, + stored: &StoredEvent, + matches: Vec<(ConnId, SubId)>, + ) -> Vec<(ConnId, SubId)> { + if self.syncs.is_empty() { + return matches; + } + matches + .into_iter() + .filter(|key| match self.syncs.get(key) { + Some(handle) => { + handle.signal(stored); + false + } + None => true, + }) + .collect() + } +} + +#[cfg(test)] +impl ArtifactSyncs { + /// Install an idle sync on `sub`, as a sync claim does. + pub(crate) fn insert_idle(&self, conn: ConnId, sub: &str) { + self.insert(conn, sub, Arc::new(SyncHandle::new())); + } + + /// The resync reason recorded for the sync on `sub`, if any. + pub(crate) fn pending_resync(&self, conn: ConnId, sub: &str) -> Option<&'static str> { + let handle = self.syncs.get(&(conn, sub.to_owned()))?; + let pending = handle + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner); + pending.resync + } +} + +/// Resync every sync on this node whenever its Redis link may have lost a +/// message (rule 5). A sync records the epoch its interest was confirmed on, +/// so any later change reaches it here. Runs until the link owner stops. +pub async fn watch_link(state: Arc) { + let mut link = state.pubsub.link_state(); + let mut seen = link.borrow_and_update().epoch; + while link.changed().await.is_ok() { + let epoch = link.borrow_and_update().epoch; + if epoch != seen { + seen = epoch; + state + .artifact_syncs + .resync_all("resync: live delivery interrupted"); + } + } +} + +/// Publish `stored` to Redis. A failed publish of a kind that syncs consume +/// is retried in the background until it succeeds or the node stops (rule +/// 5): order does not matter, because a sync reads the current head. +pub(crate) async fn publish_reliably( + state: &Arc, + tenant: &buzz_core::TenantContext, + topic: EventTopic, + stored: &StoredEvent, +) { + if publish_once(state, tenant, topic, &stored.event).await { + return; + } + if !LIVE_KINDS.contains(&stored.event.kind.as_u16()) { + return; + } + metrics::counter!("buzz_artifact_sync_publish_retry_total").increment(1); + let state = Arc::clone(state); + let tenant = tenant.clone(); + let event = stored.event.clone(); + tokio::spawn(async move { + let mut delay = Duration::from_millis(100); + loop { + tokio::time::sleep(delay).await; + if publish_once(&state, &tenant, topic, &event).await { + return; + } + delay = (delay * 2).min(Duration::from_secs(5)); + } + }); +} + +/// One publish attempt. The local-echo mark is set first, so this node does +/// not fan the event out a second time when Redis echoes it back. +async fn publish_once( + state: &AppState, + tenant: &buzz_core::TenantContext, + topic: EventTopic, + event: &Event, +) -> bool { + state.mark_local_event(tenant.community(), &event.id); + #[cfg(test)] + crate::nip_fi_test_hooks::before_event_publish(tenant.community()); + match state.pubsub.publish_event(tenant, topic, event).await { + Ok(_) => true, + Err(e) => { + state + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!(event_id = %event.id.to_hex(), "Redis publish failed: {e}"); + false + } + } +} + +/// The fan-out filter a sync registers: every change signal in its channels. +fn live_filter(channels: &[Uuid]) -> Filter { + Filter::new() + .kinds(LIVE_KINDS.map(Kind::Custom)) + .custom_tags( + SingleLetterTag::lowercase(Alphabet::H), + channels.iter().map(Uuid::to_string), + ) +} + +/// Serve one sync `REQ`. `permit` is the handler slot; it is held only for +/// the initial read. +pub async fn handle_sync( + sub_id: String, + filter: Result, + conn: Arc, + state: Arc, + permit: OwnedSemaphorePermit, +) { + let filter = match filter { + Ok(filter) => filter, + Err(reason) => { + refuse(&sub_id, &format!("invalid: {reason}"), &conn, &state).await; + return; + } + }; + let (reader, bound) = match conn.auth_state_snapshot() { + AuthState::Authenticated(ctx) => { + if !ctx.scopes.is_empty() && !ctx.scopes.contains(&Scope::MessagesRead) { + refuse(&sub_id, "restricted: insufficient scope", &conn, &state).await; + return; + } + // A channel-scoped token reaches only its own channels; outside + // them the answer matches an unreadable channel. + if let Some(allowed) = ctx.channel_ids.as_deref() { + if filter.channels.iter().any(|id| !allowed.contains(id)) { + refuse(&sub_id, RESTRICTED, &conn, &state).await; + return; + } + } + (ctx.pubkey.to_bytes().to_vec(), ctx.channel_ids) + } + _ => { + refuse(&sub_id, "auth-required: not authenticated", &conn, &state).await; + return; + } + }; + let _effect = match conn.nip_fi_gate.acquire_effect().await { + Ok(permit) => permit, + Err(crate::nip_fi_gate::SessionExpired) => { + refuse(&sub_id, "restricted: authorization denied", &conn, &state).await; + return; + } + }; + + // Rule 1: live interest first, confirmed by Redis on an unbroken link. + let start = *state.pubsub.link_state().borrow(); + let handle = Arc::new(SyncHandle::new()); + let Some(owner) = claim(&sub_id, &filter.channels, &handle, &conn, &state).await else { + return; + }; + if !start.up || !confirm(&filter.channels, start.epoch, &conn, &state).await { + close( + &sub_id, + owner, + "error: live delivery unavailable", + &conn, + &state, + ) + .await; + return; + } + + // Rule 2: the initial set from one snapshot and its EOSE, all inside the + // time budget and the cancellation race, so a slow reader cannot hold + // the handler slot past the budget or get EOSE after retirement. + let initial = async { + let sent = send_initial_set(&sub_id, owner, &filter, &reader, &conn, &state).await?; + // A resync requested before EOSE is queued ends the sync without it. + send_eose(&sub_id, owner, &handle, &conn).await?; + Ok::<_, Stop>(sent) + }; + let outcome = tokio::select! { + biased; + () = conn.nip_fi_gate.cancelled() => None, + () = handle.cancel.cancelled() => return, + out = tokio::time::timeout(SNAPSHOT_BUDGET, initial) => Some(out), + }; + let in_set = match outcome { + None => { + super::close::close_if_owner(&sub_id, owner, None, &conn, &state).await; + return; + } + Some(Err(_)) => { + let reason = "error: sync snapshot budget exceeded"; + close(&sub_id, owner, reason, &conn, &state).await; + return; + } + Some(Ok(Err(Stop::Retired))) => return, + Some(Ok(Err(Stop::Close(reason)))) => { + close(&sub_id, owner, reason, &conn, &state).await; + return; + } + Some(Ok(Ok(sent))) => sent, + }; + drop(_effect); + drop(permit); + + run_live( + &sub_id, + owner, + &filter, + &reader, + bound.as_deref(), + in_set, + &handle, + &conn, + &state, + ) + .await; +} + +const RESTRICTED: &str = "restricted: channel not readable"; +const OVER_MAX_EVENTS: &str = "error: sync set exceeds max_events"; + +/// Why a sync stops sending. +enum Stop { + /// End with `CLOSED reason`. + Close(&'static str), + /// Another request now owns the ID, or it was closed: say nothing. + Retired, +} + +/// Claim `sub_id` under the lifecycle lock. The sync's inbox is installed +/// before fan-out registration, so no event frame is ever sent to it. +async fn claim( + sub_id: &str, + channels: &[Uuid], + handle: &Arc, + conn: &ConnectionState, + state: &AppState, +) -> Option { + let mut subs = conn.subscriptions.lock().await; + if conn.cancel.is_cancelled() { + return None; + } + if !subs.contains_key(sub_id) && subs.len() >= super::req::MAX_SUBSCRIPTIONS { + // A new ID has no old holder to retire, but a lost CLOSED would leave + // the client waiting on a refused sync. + if !conn.send(RelayMessage::closed( + sub_id, + "error: too many subscriptions", + )) { + conn.cancel.cancel(); + } + return None; + } + super::close::retire_locked(&mut subs, sub_id, conn, state).await; + let owner = super::close::next_owner(); + subs.insert(sub_id.to_owned(), owner); + state + .artifact_syncs + .insert(conn.conn_id, sub_id, Arc::clone(handle)); + state.sub_registry.register_channels_scoped( + conn.tenant.community(), + conn.conn_id, + sub_id.to_owned(), + vec![live_filter(channels)], + channels.to_vec(), + ); + for &channel in channels { + state + .pubsub + .retain_topic(&conn.tenant, EventTopic::Channel(channel)) + .await; + } + Some(owner) +} + +/// Wait until Redis confirms every channel topic on link epoch `epoch`. A +/// later change of the epoch resyncs this sync through `resync_all`. +async fn confirm(channels: &[Uuid], epoch: u64, conn: &ConnectionState, state: &AppState) -> bool { + let confirm_all = async { + for &channel in channels { + match state + .pubsub + .confirm_topic(&conn.tenant, EventTopic::Channel(channel)) + .await + { + Ok(confirmed) if confirmed == epoch => {} + _ => return false, + } + } + true + }; + tokio::time::timeout(CONFIRM_TIMEOUT, confirm_all) + .await + .unwrap_or(false) +} + +/// Read and send every head in scope from one snapshot, and return their +/// `d`. After an `Err` nothing more is sent. +async fn send_initial_set( + sub_id: &str, + owner: u64, + filter: &ArtifactSyncFilter, + reader: &[u8], + conn: &ConnectionState, + state: &AppState, +) -> Result, Stop> { + let mut snapshot = match state + .db + .open_artifact_snapshot(conn.tenant.community(), reader, &filter.channels) + .await + { + Ok(Ok(snapshot)) => snapshot, + Ok(Err(buzz_db::SnapshotDenied::Restricted)) => return Err(Stop::Close(RESTRICTED)), + Err(e) => { + warn!(conn_id = %conn.conn_id, "artifact sync snapshot failed: {e}"); + return Err(Stop::Close("error: sync read failed")); + } + }; + let mut sent = HashSet::new(); + loop { + let page = snapshot + .next_page(&filter.tags, SNAPSHOT_PAGE) + .await + .map_err(|e| { + warn!(conn_id = %conn.conn_id, "artifact sync page failed: {e}"); + Stop::Close("error: sync read failed") + })?; + if page.is_empty() { + break; + } + if sent.len() + page.len() > MAX_SYNC_EVENTS { + return Err(Stop::Close(OVER_MAX_EVENTS)); + } + for stored in page { + sent.extend(artifact_id(&stored.event)); + send_owned( + sub_id, + owner, + RelayMessage::event(sub_id, &stored.event), + conn, + ) + .await?; + } + } + snapshot + .finish() + .await + .map_err(|_| Stop::Close("error: sync read failed"))?; + Ok(sent) +} + +/// Queue a frame while this request owns `sub_id`, waiting for room: the +/// snapshot budget, not the slow-client grace limit, bounds a slow reader +/// during the initial set. The slot is reserved first and the owner checked +/// under the lifecycle lock, so the lock is never held while waiting, and a +/// CLOSE or replacing REQ (which retire under that lock) fences the frame. +async fn send_owned( + sub_id: &str, + owner: u64, + frame: String, + conn: &ConnectionState, +) -> Result<(), Stop> { + send_reserved(sub_id, owner, frame, None, conn).await +} + +/// Queue `EOSE` like `send_owned`, unless the sync must resync. The pending +/// resync is checked under the inbox lock while the frame is queued, so a +/// resync recorded at any time before `EOSE` enters the queue ends the sync +/// with `CLOSED` and no `EOSE` (rule 1); a later one closes after it. +async fn send_eose( + sub_id: &str, + owner: u64, + handle: &SyncHandle, + conn: &ConnectionState, +) -> Result<(), Stop> { + send_reserved( + sub_id, + owner, + RelayMessage::eose(sub_id), + Some(handle), + conn, + ) + .await +} + +async fn send_reserved( + sub_id: &str, + owner: u64, + frame: String, + unless_resync: Option<&SyncHandle>, + conn: &ConnectionState, +) -> Result<(), Stop> { + let slot = conn + .send_tx + .reserve() + .await + .map_err(|_| Stop::Close("error: connection closed"))?; + let subs = conn.subscriptions.lock().await; + if subs.get(sub_id) != Some(&owner) { + return Err(Stop::Retired); + } + let pending = unless_resync.map(|handle| { + handle + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + }); + if let Some(reason) = pending.as_ref().and_then(|pending| pending.resync) { + return Err(Stop::Close(reason)); + } + slot.send(WsMessage::Text(frame.into())); + Ok(()) +} + +/// Queue a live frame without waiting, fenced like `send_owned`. A full +/// queue is a lost frame. +async fn try_send_owned( + sub_id: &str, + owner: u64, + frame: String, + conn: &ConnectionState, +) -> Result<(), Stop> { + let subs = conn.subscriptions.lock().await; + if subs.get(sub_id) != Some(&owner) { + return Err(Stop::Retired); + } + conn.send_tx + .try_send(WsMessage::Text(frame.into())) + .map_err(|_| Stop::Close("resync: send queue full")) +} + +/// Rules 3–5 after `EOSE`: read each changed head, one read at a time, and +/// send it. A dropped frame or a resync request ends the sync. `in_set` is +/// the client's copy as this sync sent it: the `d` of each artifact in scope. +/// `bound` is a channel-scoped token's channels: the reader reads no head +/// outside them, even where its pubkey could. +#[allow(clippy::too_many_arguments)] +async fn run_live( + sub_id: &str, + owner: u64, + filter: &ArtifactSyncFilter, + reader: &[u8], + bound: Option<&[Uuid]>, + mut in_set: HashSet, + handle: &SyncHandle, + conn: &ConnectionState, + state: &AppState, +) { + loop { + let pending = handle.take(); + if let Some(reason) = pending.resync { + close(sub_id, owner, reason, conn, state).await; + return; + } + for (d, removal) in pending.changed { + // A retired sync reads nothing more. + if handle.cancel.is_cancelled() { + return; + } + let head = match state + .db + .artifact_head(conn.tenant.community(), reader, d, &filter.channels) + .await + { + Ok(head) => head.filter(|(head, _)| { + bound.is_none_or(|allowed| { + head.channel_id.is_some_and(|id| allowed.contains(&id)) + }) + }), + Err(e) => { + warn!(conn_id = %conn.conn_id, "artifact sync head read failed: {e}"); + close(sub_id, owner, "error: sync read failed", conn, state).await; + return; + } + }; + // Rule 3: send a change only for an artifact in scope before it + // (in the client's copy) or after it (a live head that matches + // every predicate). The change signal is per channel, so it + // reaches syncs that never matched this artifact. + let in_scope = head + .as_ref() + .is_some_and(|(head, deleted)| !deleted && filter.matches(&head.event)); + if !in_scope && !in_set.contains(&d) { + continue; + } + // A head the reader cannot read leaves only the source removal. + // Without one, nothing the reader may read can remove the + // artifact from its copy, so the copy is no longer exact. + let Some(event) = head.map(|(head, _)| head.event).or(removal) else { + close(sub_id, owner, "resync: artifact left scope", conn, state).await; + return; + }; + #[cfg(test)] + super::close::test_seam::pause_at(&test_seam::LIVE_SEND_PAUSE).await; + match try_send_owned(sub_id, owner, RelayMessage::event(sub_id, &event), conn).await { + Ok(()) => {} + Err(Stop::Retired) => return, + // One lost frame breaks the copy; the slow-client grace limit + // does not apply. `close` drops the connection when even the + // CLOSED frame cannot be queued. + Err(Stop::Close(reason)) => { + close(sub_id, owner, reason, conn, state).await; + return; + } + } + if !in_scope { + in_set.remove(&d); + } else if in_set.insert(d) && in_set.len() > MAX_SYNC_EVENTS { + // A new sync of this set would be refused the same way. + close(sub_id, owner, OVER_MAX_EVENTS, conn, state).await; + return; + } + } + tokio::select! { + () = handle.cancel.cancelled() => return, + () = conn.cancel.cancelled() => return, + () = handle.notify.notified() => {} + } + } +} + +/// End the sync with `CLOSED reason` if this request still owns its ID. +async fn close(sub_id: &str, owner: u64, reason: &str, conn: &ConnectionState, state: &AppState) { + super::close::close_if_owner(sub_id, owner, Some(reason), conn, state).await; +} + +#[cfg(test)] +pub(crate) mod test_seam { + use std::sync::Arc; + + use super::super::close::test_seam::Pause; + + tokio::task_local! { + /// After a live head read, before its frame is queued. + pub(crate) static LIVE_SEND_PAUSE: Arc; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Tag}; + + fn stored(kind: u16, d: Uuid) -> StoredEvent { + let event = EventBuilder::new(Kind::Custom(kind), "") + .tags([Tag::parse(["d", &d.to_string()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + StoredEvent::new(event, Some(Uuid::new_v4())) + } + + #[test] + fn divert_signals_syncs_and_keeps_ordinary_matches() { + let syncs = ArtifactSyncs::default(); + let conn = Uuid::new_v4(); + let handle = Arc::new(SyncHandle::new()); + syncs.insert(conn, "sync", Arc::clone(&handle)); + let d = Uuid::new_v4(); + let rest = syncs.divert( + &stored(45010, d), + vec![(conn, "sync".into()), (conn, "plain".into())], + ); + assert_eq!(rest, vec![(conn, "plain".to_string())]); + let removal = stored(45011, d); + syncs.divert(&removal, vec![(conn, "sync".into())]); + // A later revision keeps the removal: only the head read decides. + syncs.divert(&stored(45010, d), vec![(conn, "sync".into())]); + let pending = handle.take(); + assert_eq!(pending.changed.len(), 1); + assert_eq!( + pending.changed[&d].as_ref().map(|e| e.id), + Some(removal.event.id) + ); + assert!(pending.resync.is_none()); + + syncs.divert(&stored(KIND_REDACTION, d), vec![(conn, "sync".into())]); + assert_eq!(handle.take().resync, Some("resync: redaction")); + + syncs.remove(conn, "sync"); + assert!(handle.cancel.is_cancelled()); + assert_eq!( + syncs + .divert(&stored(45010, d), vec![(conn, "sync".into())]) + .len(), + 1 + ); + } + + /// The access-change signal runs before the access filter, so a reader + /// who already lost access still gets its resync. Content kinds wait for + /// the filtered `divert`. Mutation: drop the `ACCESS_KINDS` check in + /// `signal_access_change` → the 45010 is recorded unfiltered → RED. + #[test] + fn access_changes_signal_before_the_access_filter() { + let syncs = ArtifactSyncs::default(); + let conn = Uuid::new_v4(); + let handle = Arc::new(SyncHandle::new()); + syncs.insert(conn, "sync", Arc::clone(&handle)); + let matches = vec![(conn, "sync".to_string())]; + syncs.signal_access_change(&stored(45010, Uuid::new_v4()), &matches); + let pending = handle.take(); + assert!(pending.changed.is_empty() && pending.resync.is_none()); + for kind in ACCESS_KINDS { + syncs.signal_access_change(&stored(kind, Uuid::new_v4()), &matches); + assert_eq!( + handle.take().resync, + Some("resync: channel access changed"), + "{kind}" + ); + } + } + + #[test] + fn held_changes_past_max_events_resync() { + let handle = SyncHandle::new(); + for _ in 0..=MAX_SYNC_EVENTS { + handle.signal(&stored(45010, Uuid::new_v4())); + } + assert_eq!(handle.take().resync, Some("resync: too many changes")); + } + + /// The held list is a hard bound: a change past the limit is refused + /// before it is stored, and nothing is held after the sync must resync. + /// Mutation: insert before the size check, or keep holding after + /// `resync` → `changed` grows past `MAX_SYNC_EVENTS` → RED. + #[test] + fn held_list_holds_nothing_past_its_limit() { + let handle = SyncHandle::new(); + for _ in 0..MAX_SYNC_EVENTS { + handle.signal(&stored(45010, Uuid::new_v4())); + } + { + let pending = handle.pending.lock().unwrap(); + assert_eq!(pending.changed.len(), MAX_SYNC_EVENTS); + assert!(pending.resync.is_none()); + } + for _ in 0..100 { + handle.signal(&stored(45010, Uuid::new_v4())); + handle.signal(&stored(45011, Uuid::new_v4())); + } + let pending = handle.pending.lock().unwrap(); + assert_eq!(pending.resync, Some("resync: too many changes")); + assert!(pending.changed.is_empty()); + assert_eq!(pending.held_bytes, 0); + } + + fn removal(d: Uuid, size: usize) -> StoredEvent { + let event = EventBuilder::new(Kind::Custom(45011), "x".repeat(size)) + .tags([Tag::parse(["d", &d.to_string()]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + StoredEvent::new(event, Some(Uuid::new_v4())) + } + + /// Held removals are whole events, so their bytes have their own bound. + /// A newer removal for the same `d` replaces the older one's bytes. + #[test] + fn held_removals_have_a_byte_bound() { + let handle = SyncHandle::new(); + let d = Uuid::new_v4(); + for _ in 0..2 * (MAX_HELD_BYTES >> 16) { + handle.signal(&removal(d, 1 << 16)); + } + { + let pending = handle.pending.lock().unwrap(); + assert!(pending.resync.is_none()); + assert!(pending.held_bytes < 1 << 17, "{}", pending.held_bytes); + } + for _ in 0..=MAX_HELD_BYTES >> 16 { + handle.signal(&removal(Uuid::new_v4(), 1 << 16)); + } + let pending = handle.pending.lock().unwrap(); + assert_eq!(pending.resync, Some("resync: too many changes")); + assert!(pending.changed.is_empty()); + } + + fn conn_with_sub(owner: u64) -> (Arc, tokio::sync::mpsc::Receiver) { + let tenant = buzz_core::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + "relay.example", + ); + let (conn, rx) = + super::super::group_state_postgres_tests::test_conn(&tenant, &Keys::generate()); + conn.subscriptions + .try_lock() + .unwrap() + .insert("s".into(), owner); + (conn, rx) + } + + /// A live frame goes out only while its request still owns the ID. + /// Mutation: drop the owner check in `try_send_owned` → RED. + #[tokio::test] + async fn live_frame_is_fenced_by_the_owner() { + let (conn, mut rx) = conn_with_sub(1); + assert!(matches!( + try_send_owned("s", 2, "x".into(), &conn).await, + Err(Stop::Retired) + )); + conn.subscriptions.lock().await.remove("s"); + assert!(matches!( + try_send_owned("s", 1, "x".into(), &conn).await, + Err(Stop::Retired) + )); + assert!(rx.try_recv().is_err()); + conn.subscriptions.lock().await.insert("s".into(), 1); + assert!(try_send_owned("s", 1, "x".into(), &conn).await.is_ok()); + assert!(rx.try_recv().is_ok()); + } + + /// A frame (such as `EOSE`) waiting for queue room is checked against + /// the owner after the room appears, not before it waited. Mutation: + /// check the owner before `reserve` → the stale frame is queued → RED. + #[tokio::test] + async fn waiting_frame_is_fenced_when_replaced_during_the_wait() { + let (conn, mut rx) = conn_with_sub(1); + let capacity = conn.send_tx.capacity(); + for _ in 0..capacity { + conn.send_tx + .try_send(WsMessage::Text("fill".into())) + .unwrap(); + } + let mut waiting = std::pin::pin!(send_owned("s", 1, "eose".into(), &conn)); + // Polled once, the frame is now waiting in `reserve`. + assert!(futures_util::poll!(&mut waiting).is_pending()); + // A replacing REQ takes the ID while the frame waits. + conn.subscriptions.lock().await.insert("s".into(), 2); + rx.recv().await.unwrap(); + assert!(matches!(waiting.await, Err(Stop::Retired))); + let mut rest = 0; + while let Ok(WsMessage::Text(text)) = rx.try_recv() { + assert_eq!(text.as_str(), "fill"); + rest += 1; + } + assert_eq!(rest, capacity - 1); + } + + /// A resync recorded while `EOSE` waits for queue room ends the sync + /// without `EOSE` (rule 1). Mutation: check the pending resync before + /// `reserve` instead of while queueing → `EOSE` is queued → RED. + #[tokio::test] + async fn resync_during_the_eose_wait_sends_no_eose() { + let (conn, mut rx) = conn_with_sub(1); + let handle = SyncHandle::new(); + let capacity = conn.send_tx.capacity(); + for _ in 0..capacity { + conn.send_tx + .try_send(WsMessage::Text("fill".into())) + .unwrap(); + } + let mut waiting = std::pin::pin!(send_eose("s", 1, &handle, &conn)); + assert!(futures_util::poll!(&mut waiting).is_pending()); + // The held list overflows while EOSE waits. + for _ in 0..=MAX_SYNC_EVENTS { + handle.signal(&stored(45010, Uuid::new_v4())); + } + rx.recv().await.unwrap(); + assert!(matches!( + waiting.await, + Err(Stop::Close("resync: too many changes")) + )); + while let Ok(WsMessage::Text(text)) = rx.try_recv() { + assert_eq!(text.as_str(), "fill"); + } + + // Without a pending resync, EOSE is queued. + let clean = SyncHandle::new(); + assert!(send_eose("s", 1, &clean, &conn).await.is_ok()); + let Ok(WsMessage::Text(text)) = rx.try_recv() else { + panic!("no EOSE"); + }; + assert!(text.as_str().starts_with(r#"["EOSE""#), "{text}"); + } + + #[test] + fn resync_all_reaches_every_sync() { + let syncs = ArtifactSyncs::default(); + let handles: Vec<_> = (0..3).map(|_| Arc::new(SyncHandle::new())).collect(); + for (i, handle) in handles.iter().enumerate() { + syncs.insert(Uuid::new_v4(), &i.to_string(), Arc::clone(handle)); + } + syncs.resync_all("resync: live delivery interrupted"); + for handle in handles { + assert_eq!( + handle.take().resync, + Some("resync: live delivery interrupted") + ); + } + } +} diff --git a/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs b/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs new file mode 100644 index 00000000000..6696877c4e3 --- /dev/null +++ b/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs @@ -0,0 +1,693 @@ +//! Postgres- and Redis-backed tests for NIP-AR Sync over the real REQ +//! handler, ingest pipeline, fan-out and pub/sub link. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::Message as WsMessage; +use buzz_core::TenantContext; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use super::group_state_postgres_tests::{create_channel, http_auth, test_conn}; +use crate::connection::ConnectionState; +use crate::handlers::ingest::ingest_event; +use crate::state::AppState; + +struct Fixture { + state: Arc, + tenant: TenantContext, +} + +async fn fixture() -> Fixture { + let state = crate::api::bridge::postgres_tests::bridge_handler_test_state() + .await + .expect("local Postgres and Redis"); + let host = format!("artifact-sync-{}.local", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + state + .db + .maintain_partitions( + 1, + buzz_db::partition::PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: false, + }, + ) + .await + .expect("partitions"); + // The real Redis link: rule 1 waits for it to confirm live interest. + tokio::spawn(Arc::clone(&state.pubsub).run_subscriber()); + state + .pubsub + .link_state() + .wait_for(|link| link.up) + .await + .expect("link up"); + Fixture { + state, + tenant: TenantContext::resolved(community, &host), + } +} + +async fn ingest(f: &Fixture, keys: &Keys, event: Event) { + let kind = event.kind.as_u16(); + let result = ingest_event(&f.state, &f.tenant, event, http_auth(keys)) + .await + .unwrap_or_else(|error| panic!("ingest kind:{kind}: {error:?}")); + assert!(result.accepted, "kind:{kind} refused: {}", result.message); +} + +/// Accept one `buzz.task` revision and return it. +async fn revision( + f: &Fixture, + keys: &Keys, + home: Uuid, + d: Uuid, + op: &str, + prev: Option<&Event>, +) -> Event { + typed_revision(f, keys, home, d, "buzz.task", op, prev).await +} + +/// Accept one artifact revision of type `ty` and return it. +async fn typed_revision( + f: &Fixture, + keys: &Keys, + home: Uuid, + d: Uuid, + ty: &str, + op: &str, + prev: Option<&Event>, +) -> Event { + let mut tags = vec![ + vec!["ar".to_owned(), "1".into()], + vec!["d".into(), d.to_string()], + vec!["h".into(), home.to_string()], + vec!["type".into(), ty.into()], + vec!["op".into(), op.into()], + ]; + if op != "delete" { + tags.push(vec!["title".into(), "Sync task".into()]); + } + if let Some(prev) = prev { + tags.push(vec!["prev".into(), prev.id.to_hex()]); + } + let event = EventBuilder::new( + Kind::Custom(45010), + if op == "delete" { "" } else { "body" }, + ) + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .sign_with_keys(keys) + .unwrap(); + ingest(f, keys, event.clone()).await; + event +} + +async fn membership(f: &Fixture, owner: &Keys, kind: u16, channel: Uuid, member: &Keys) { + let event = EventBuilder::new(Kind::Custom(kind), "") + .tags([ + Tag::parse(["h", &channel.to_string()]).unwrap(), + Tag::parse(["p", &member.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(owner) + .unwrap(); + ingest(f, owner, event).await; +} + +/// A connection for `reader`, registered so fan-out can reach it. +fn connect(f: &Fixture, reader: &Keys) -> (Arc, mpsc::Receiver) { + let (conn, rx) = test_conn(&f.tenant, reader); + f.state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + mpsc::channel(1).0, + None, + conn.cancel.clone(), + conn.tenant.community(), + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + 3, + crate::state::CommunityConnectionControl::new(conn.cancel.clone()), + ); + (conn, rx) +} + +fn start_sync( + f: &Fixture, + conn: &Arc, + filter: Value, +) -> tokio::task::JoinHandle<()> { + let filter = buzz_core::artifact_sync::parse_sync(&filter); + let permit = f + .state + .handler_semaphore + .clone() + .try_acquire_owned() + .expect("permit"); + tokio::spawn(super::artifact_sync::handle_sync( + "s".into(), + filter, + Arc::clone(conn), + Arc::clone(&f.state), + permit, + )) +} + +async fn frame(rx: &mut mpsc::Receiver) -> Value { + loop { + let message = tokio::time::timeout(Duration::from_secs(10), rx.recv()) + .await + .expect("relay frame in time") + .expect("connection open"); + if let WsMessage::Text(text) = message { + return serde_json::from_str(&text).expect("json frame"); + } + } +} + +/// Collect the initial set up to `EOSE`; panics on any other terminal frame. +async fn initial_set(rx: &mut mpsc::Receiver) -> Vec { + let mut ids = vec![]; + loop { + let message = frame(rx).await; + match message[0].as_str() { + Some("EVENT") => ids.push(message[2]["id"].as_str().unwrap().to_owned()), + Some("EOSE") => return ids, + _ => panic!("unexpected frame before EOSE: {message}"), + } + } +} + +async fn no_frame(rx: &mut mpsc::Receiver) { + if let Ok(Some(message)) = tokio::time::timeout(Duration::from_millis(300), rx.recv()).await { + panic!("unexpected frame: {message:?}"); + } +} + +const PRIVATE: &[&[&str]] = &[&["visibility", "private"]]; + +fn sync_filter(channels: &[Uuid]) -> Value { + json!({"artifact": "current", "#h": channels, "#type": ["buzz.task"]}) +} + +/// Accept one `buzz.task` revision with a `status` tag and return it. +async fn status_revision( + f: &Fixture, + keys: &Keys, + home: Uuid, + d: Uuid, + status: &str, + prev: Option<&Event>, +) -> Event { + let op = if prev.is_some() { "update" } else { "create" }; + let mut tags = vec![ + vec!["ar".to_owned(), "1".into()], + vec!["d".into(), d.to_string()], + vec!["h".into(), home.to_string()], + vec!["type".into(), "buzz.task".into()], + vec!["op".into(), op.into()], + vec!["title".into(), "Sync task".into()], + vec!["status".into(), status.into()], + ]; + if let Some(prev) = prev { + tags.push(vec!["prev".into(), prev.id.to_hex()]); + } + let event = EventBuilder::new(Kind::Custom(45010), "body") + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .sign_with_keys(keys) + .unwrap(); + ingest(f, keys, event.clone()).await; + event +} + +async fn live_event(rx: &mut mpsc::Receiver) -> String { + let message = frame(rx).await; + assert_eq!(message[0].as_str(), Some("EVENT"), "{message}"); + message[2]["id"].as_str().unwrap().to_owned() +} + +/// Rule 3 with a mutable predicate: a live head goes out only for an +/// artifact in scope before the change (in the client's copy) or after it. +/// A head that never matched never crosses the socket; one that stops +/// matching is sent once, so the client can drop it. Mutation: send every +/// same-type head (the old `never_in_scope` check) → the `done` task is +/// sent → RED. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn live_heads_follow_every_predicate() { + let f = fixture().await; + let owner = Keys::generate(); + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let open = status_revision(&f, &owner, home, Uuid::new_v4(), "open", None).await; + + let (conn, mut rx) = connect(&f, &owner); + let filter = + json!({"artifact": "current", "#h": [home], "#type": ["buzz.task"], "#status": ["open"]}); + let task = start_sync(&f, &conn, filter); + assert_eq!(initial_set(&mut rx).await, vec![open.id.to_hex()]); + + // Never in scope: not sent. + let other = Uuid::new_v4(); + let done = status_revision(&f, &owner, home, other, "done", None).await; + no_frame(&mut rx).await; + // Leaves the set: sent once. + let d = open + .tags + .iter() + .find_map(|t| match t.as_slice() { + [name, value, ..] if name == "d" => value.parse().ok(), + _ => None, + }) + .unwrap(); + let closed = status_revision(&f, &owner, home, d, "done", Some(&open)).await; + assert_eq!(live_event(&mut rx).await, closed.id.to_hex()); + let again = status_revision(&f, &owner, home, d, "done", Some(&closed)).await; + no_frame(&mut rx).await; + // Enters the set: sent. + let reopened = status_revision(&f, &owner, home, other, "open", Some(&done)).await; + assert_eq!(live_event(&mut rx).await, reopened.id.to_hex()); + let back = status_revision(&f, &owner, home, d, "open", Some(&again)).await; + assert_eq!(live_event(&mut rx).await, back.id.to_hex()); + + super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await; + assert_eq!(frame(&mut rx).await, json!(["CLOSED", "s", ""])); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends on CLOSE") + .unwrap(); +} + +/// Rules 2–4 over the real pipeline: the initial set in `d` order, `EOSE`, +/// then the current head of each later change (including a deletion), and +/// no live entry ever arrives as the published event before `EOSE`. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn sync_sends_one_cut_then_current_heads() { + let f = fixture().await; + let owner = Keys::generate(); + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let mut ds = [Uuid::new_v4(), Uuid::new_v4()]; + ds.sort(); + let first = revision(&f, &owner, home, ds[0], "create", None).await; + let second = revision(&f, &owner, home, ds[1], "create", None).await; + + let (conn, mut rx) = connect(&f, &owner); + let task = start_sync(&f, &conn, sync_filter(&[home])); + assert_eq!( + initial_set(&mut rx).await, + vec![first.id.to_hex(), second.id.to_hex()] + ); + + let updated = revision(&f, &owner, home, ds[0], "update", Some(&first)).await; + let message = frame(&mut rx).await; + assert_eq!( + (message[0].as_str(), message[2]["id"].as_str()), + (Some("EVENT"), Some(updated.id.to_hex().as_str())) + ); + let deleted = revision(&f, &owner, home, ds[1], "delete", Some(&second)).await; + let message = frame(&mut rx).await; + assert_eq!( + (message[0].as_str(), message[2]["id"].as_str()), + (Some("EVENT"), Some(deleted.id.to_hex().as_str())) + ); + // An artifact of another type in the same channel is never in scope. + typed_revision( + &f, + &owner, + home, + Uuid::new_v4(), + "buzz.note", + "create", + None, + ) + .await; + no_frame(&mut rx).await; + // An ordinary chat message in the channel is not a change. + let chat = EventBuilder::new(Kind::Custom(9), "hi") + .tags([Tag::parse(["h", &home.to_string()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + ingest(&f, &owner, chat).await; + no_frame(&mut rx).await; + + super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await; + assert_eq!(frame(&mut rx).await, json!(["CLOSED", "s", ""])); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends on CLOSE") + .unwrap(); + assert!(!f.state.artifact_syncs.contains(conn.conn_id, "s")); +} + +/// Rule 9: losing access to one named channel ends the whole sync with a +/// resync, and leaves no fan-out entry for its other channel. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn membership_removal_resyncs_the_whole_sync() { + let f = fixture().await; + let owner = Keys::generate(); + let reader = Keys::generate(); + let shared = create_channel(&f.state, &f.tenant, &owner, PRIVATE).await; + membership(&f, &owner, 9000, shared, &reader).await; + let own = create_channel(&f.state, &f.tenant, &reader, &[]).await; + + let (conn, mut rx) = connect(&f, &reader); + let task = start_sync(&f, &conn, sync_filter(&[shared, own])); + assert!(initial_set(&mut rx).await.is_empty()); + + membership(&f, &owner, 9001, shared, &reader).await; + let message = frame(&mut rx).await; + assert_eq!(message[0], "CLOSED", "{message}"); + assert!( + message[2].as_str().unwrap().starts_with("resync: "), + "{message}" + ); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends") + .unwrap(); + no_frame(&mut rx).await; + assert!(!f.state.artifact_syncs.contains(conn.conn_id, "s")); + assert!(f + .state + .sub_registry + .channel_subscriber_conns_scoped(f.tenant.community(), own) + .is_empty()); +} + +/// Rule 3 under a channel-scoped token: the token bounds what the reader +/// can read, so a move to a channel outside it sends the source removal, +/// never the destination head, even though the pubkey can read that +/// channel. Mutation: drop the token bound on the live head read → the +/// moved head is sent → RED. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn scoped_token_gets_only_the_removal_for_a_move_outside_it() { + let f = fixture().await; + let owner = Keys::generate(); + let source = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let destination = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let d = Uuid::new_v4(); + let created = revision(&f, &owner, source, d, "create", None).await; + + let (conn, mut rx) = connect(&f, &owner); + match &mut *conn.auth_state.lock().unwrap() { + crate::connection::AuthState::Authenticated(ctx) => { + ctx.channel_ids = Some(vec![source]); + } + _ => unreachable!("test connections are authenticated"), + } + let task = start_sync(&f, &conn, sync_filter(&[source])); + assert_eq!(initial_set(&mut rx).await, vec![created.id.to_hex()]); + + let moved = revision(&f, &owner, destination, d, "move", Some(&created)).await; + let message = frame(&mut rx).await; + assert_eq!( + (message[0].as_str(), message[2]["kind"].as_u64()), + (Some("EVENT"), Some(45011)), + "{message}" + ); + assert_ne!(message[2]["id"].as_str(), Some(moved.id.to_hex().as_str())); + no_frame(&mut rx).await; + + super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await; + assert_eq!(frame(&mut rx).await, json!(["CLOSED", "s", ""])); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends on CLOSE") + .unwrap(); +} + +/// Rule 2: an unreadable or unknown channel gets `CLOSED restricted` and +/// no `EOSE`, with the same reason for both. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn unreadable_channel_is_restricted_without_eose() { + let f = fixture().await; + let owner = Keys::generate(); + let private = create_channel(&f.state, &f.tenant, &owner, PRIVATE).await; + let stranger = Keys::generate(); + for channel in [private, Uuid::new_v4()] { + let (conn, mut rx) = connect(&f, &stranger); + start_sync(&f, &conn, sync_filter(&[channel])) + .await + .unwrap(); + assert_eq!( + frame(&mut rx).await, + json!(["CLOSED", "s", "restricted: channel not readable"]) + ); + no_frame(&mut rx).await; + assert!(!f.state.artifact_syncs.contains(conn.conn_id, "s")); + } +} + +/// Rule 5: a node-wide loss (fan-out lag or a link epoch change) ends a live +/// sync with a resync. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn node_loss_resyncs_a_live_sync() { + let f = fixture().await; + let owner = Keys::generate(); + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let (conn, mut rx) = connect(&f, &owner); + let task = start_sync(&f, &conn, sync_filter(&[home])); + assert!(initial_set(&mut rx).await.is_empty()); + f.state + .artifact_syncs + .resync_all("resync: live delivery lagged"); + assert_eq!( + frame(&mut rx).await, + json!(["CLOSED", "s", "resync: live delivery lagged"]) + ); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends") + .unwrap(); + assert!(!f.state.artifact_syncs.contains(conn.conn_id, "s")); +} + +/// A CLOSE while a live head read is in flight retires the sync: the head +/// it read is never sent under the closed ID. Mutation: drop the owner +/// check in `try_send_owned` → the EVENT follows `CLOSED` → RED. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn close_during_a_live_head_read_sends_nothing_more() { + let f = fixture().await; + let owner = Keys::generate(); + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let d = Uuid::new_v4(); + let first = revision(&f, &owner, home, d, "create", None).await; + + let (conn, mut rx) = connect(&f, &owner); + let filter = buzz_core::artifact_sync::parse_sync(&sync_filter(&[home])); + let permit = f + .state + .handler_semaphore + .clone() + .try_acquire_owned() + .expect("permit"); + let pause = Arc::new(super::close::test_seam::Pause::default()); + let task = tokio::spawn(super::artifact_sync::test_seam::LIVE_SEND_PAUSE.scope( + Arc::clone(&pause), + super::artifact_sync::handle_sync( + "s".into(), + filter, + Arc::clone(&conn), + Arc::clone(&f.state), + permit, + ), + )); + assert_eq!(initial_set(&mut rx).await, vec![first.id.to_hex()]); + + revision(&f, &owner, home, d, "update", Some(&first)).await; + tokio::time::timeout(Duration::from_secs(10), pause.reached.notified()) + .await + .expect("live head read reached"); + super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await; + assert_eq!(frame(&mut rx).await, json!(["CLOSED", "s", ""])); + pause.resume.notify_one(); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("sync task ends") + .unwrap(); + no_frame(&mut rx).await; +} + +/// Run an ordinary `REQ "s"` through the real handler. +async fn ordinary_req(f: &Fixture, conn: &Arc, filters: Vec) { + let filters: Vec = filters + .into_iter() + .map(|filter| serde_json::from_value(filter).expect("filter")) + .collect(); + let before_ids = vec![None; filters.len()]; + super::req::handle_req( + "s".into(), + filters, + before_ids, + Arc::clone(conn), + Arc::clone(&f.state), + ) + .await; +} + +/// An ordinary `REQ` that replaces a live sync or a live subscription with +/// the same ID retires it, also when `handle_req` refuses the new request +/// (mixed search) or answers it once (huddle liveness) without registering +/// it. A later change in the channel then sends nothing under that ID. +/// Mutation: refuse mixed search with a bare `CLOSED`, or answer liveness +/// without claiming the ID → RED. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn refused_or_one_shot_replacement_retires_the_old_holder() { + let f = fixture().await; + let owner = Keys::generate(); + let mixed: fn(Uuid) -> Vec = |home| { + vec![ + json!({"search": "x", "#h": [home]}), + json!({"kinds": [45010], "#h": [home]}), + ] + }; + let liveness: fn(Uuid) -> Vec = + |home| vec![json!({"kinds": [buzz_core::kind::KIND_HUDDLE_LIVENESS], "#h": [home]})]; + for (sync_holder, replacement, terminal) in [ + ( + true, + mixed, + json!([ + "CLOSED", + "s", + "error: mixed search and non-search filters not supported" + ]), + ), + ( + false, + mixed, + json!([ + "CLOSED", + "s", + "error: mixed search and non-search filters not supported" + ]), + ), + (true, liveness, json!(["EOSE", "s"])), + (false, liveness, json!(["EOSE", "s"])), + ] { + let case = format!("sync holder: {sync_holder}, replacement: {terminal}"); + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let (conn, mut rx) = connect(&f, &owner); + let sync = if sync_holder { + Some(start_sync(&f, &conn, sync_filter(&[home]))) + } else { + ordinary_req( + &f, + &conn, + vec![json!({"kinds": [45010], "#h": [home], "limit": 0})], + ) + .await; + None + }; + assert!(initial_set(&mut rx).await.is_empty(), "{case}"); + + ordinary_req(&f, &conn, replacement(home)).await; + assert_eq!(frame(&mut rx).await, terminal, "{case}"); + if let Some(sync) = sync { + tokio::time::timeout(Duration::from_secs(5), sync) + .await + .expect("the replaced sync ends") + .unwrap(); + } + assert!(conn.subscriptions.lock().await.is_empty(), "{case}"); + assert!( + !f.state.artifact_syncs.contains(conn.conn_id, "s"), + "{case}" + ); + assert!( + f.state + .sub_registry + .get_filters(conn.conn_id, "s") + .is_none(), + "{case}" + ); + + revision(&f, &owner, home, Uuid::new_v4(), "create", None).await; + no_frame(&mut rx).await; + } +} + +/// A same-ID sync that replaces a history, search or huddle-liveness `REQ` +/// while that request waits to send gets only its own frames: the old +/// request's late frames, `EOSE` included, must not reach the sync's +/// client. The old request pauses just before its first frame. +/// Mutation: drop the owner check in `close::send_if_owner` → stale frames +/// follow the sync's `EOSE` → RED. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn a_replaced_req_sends_nothing_under_a_sync_that_took_its_id() { + use super::close::test_seam::{Pause, SEND_PAUSE}; + let f = fixture().await; + let owner = Keys::generate(); + let history: fn(Uuid) -> Vec = |home| vec![json!({"kinds": [45010], "#h": [home]})]; + let search: fn(Uuid) -> Vec = |home| vec![json!({"search": "x", "#h": [home]})]; + let liveness: fn(Uuid) -> Vec = + |home| vec![json!({"kinds": [buzz_core::kind::KIND_HUDDLE_LIVENESS], "#h": [home]})]; + for (name, old_req) in [ + ("history", history), + ("search", search), + ("liveness", liveness), + ] { + let home = create_channel(&f.state, &f.tenant, &owner, &[]).await; + let created = revision(&f, &owner, home, Uuid::new_v4(), "create", None).await; + let (conn, mut rx) = connect(&f, &owner); + let pause = Arc::new(Pause::default()); + let stale = tokio::spawn({ + let (f_conn, state) = (Arc::clone(&conn), Arc::clone(&f.state)); + let filters: Vec = old_req(home) + .into_iter() + .map(|filter| serde_json::from_value(filter).expect("filter")) + .collect(); + let before_ids = vec![None; filters.len()]; + SEND_PAUSE.scope( + Arc::clone(&pause), + super::req::handle_req("s".into(), filters, before_ids, f_conn, state), + ) + }); + tokio::time::timeout(Duration::from_secs(10), pause.reached.notified()) + .await + .unwrap_or_else(|_| panic!("{name}: the old REQ reaches its first send")); + + let sync = start_sync(&f, &conn, sync_filter(&[home])); + assert_eq!( + initial_set(&mut rx).await, + vec![created.id.to_hex()], + "{name}" + ); + // The seam pauses before every send; release each one. + tokio::time::timeout(Duration::from_secs(10), async { + while !stale.is_finished() { + pause.resume.notify_one(); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("{name}: the old REQ ends")); + stale.await.unwrap(); + no_frame(&mut rx).await; + assert!( + f.state.artifact_syncs.contains(conn.conn_id, "s"), + "{name}: the sync still holds the ID" + ); + super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await; + tokio::time::timeout(Duration::from_secs(5), sync) + .await + .expect("sync task ends on CLOSE") + .unwrap(); + } +} diff --git a/crates/buzz-relay/src/handlers/close.rs b/crates/buzz-relay/src/handlers/close.rs index 33b8b6020f0..62e60b39536 100644 --- a/crates/buzz-relay/src/handlers/close.rs +++ b/crates/buzz-relay/src/handlers/close.rs @@ -25,6 +25,36 @@ pub async fn handle_close(sub_id: String, conn: Arc, state: Arc debug!(conn_id = %conn.conn_id, sub_id = %sub_id, "Subscription closed"); } +/// Refuse a `REQ` or sync `REQ` with `CLOSED reason`. A `REQ` replaces +/// whatever held its ID (NIP-01), and `CLOSED` tells the client the ID is +/// closed, so the old holder is retired before the frame, under the lifecycle +/// lock. Every refusal before a request claims its ID goes through here. If +/// the frame cannot be queued, the connection is cancelled: the client would +/// otherwise never learn that the old holder is gone. +pub(crate) async fn refuse(sub_id: &str, reason: &str, conn: &ConnectionState, state: &AppState) { + let mut subs = conn.subscriptions.lock().await; + retire_locked(&mut subs, sub_id, conn, state).await; + if !conn.send(RelayMessage::closed(sub_id, reason)) { + conn.cancel.cancel(); + } +} + +/// Queue `frame` for `sub_id` only while the request holding `owner` still +/// owns the ID. A request that a newer same-ID REQ replaced sends nothing: +/// its late `EOSE` would otherwise tell a replacing sync that a set it never +/// sent is complete. Returns whether the frame was queued. +pub(crate) async fn send_if_owner( + sub_id: &str, + owner: u64, + frame: String, + conn: &ConnectionState, +) -> bool { + #[cfg(test)] + test_seam::pause_at(&test_seam::SEND_PAUSE).await; + let subs = conn.subscriptions.lock().await; + subs.get(sub_id) == Some(&owner) && conn.send(frame) +} + /// Monotonic source of subscription owner tokens. Each REQ claims its sub ID /// with a fresh token, so a superseded request can tell it no longer owns it. static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); @@ -47,6 +77,7 @@ pub(crate) async fn retire_locked( state: &AppState, ) { subs.remove(sub_id); + state.artifact_syncs.remove(conn.conn_id, sub_id); if let Some(removed) = state.sub_registry.remove_subscription(conn.conn_id, sub_id) { #[cfg(test)] test_seam::pause_at(&test_seam::RELEASE_PAUSE).await; @@ -93,6 +124,7 @@ pub(crate) async fn release_connection_subscriptions(conn: &ConnectionState, sta test_seam::pause_at(&test_seam::CLEANUP_PAUSE).await; let mut subs = conn.subscriptions.lock().await; subs.clear(); + state.artifact_syncs.remove_connection(conn.conn_id); for removed in state.sub_registry.remove_connection(conn.conn_id) { release_scope_topics(state, &conn.tenant, &removed.scope).await; } @@ -135,9 +167,10 @@ pub(crate) mod test_seam { pub(crate) static PAUSE: Arc; pub(crate) static RELEASE_PAUSE: Arc; pub(crate) static CLEANUP_PAUSE: Arc; + pub(crate) static SEND_PAUSE: Arc; } - pub(super) async fn pause_at(point: &'static tokio::task::LocalKey>) { + pub(crate) async fn pause_at(point: &'static tokio::task::LocalKey>) { if let Ok(pause) = point.try_with(Arc::clone) { pause.reached.notify_one(); pause.resume.notified().await; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 6ee506f9e57..76ffc21f7f3 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -119,6 +119,33 @@ pub async fn filter_fanout_by_access( matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, threaded: Option<&crate::state::ThreadedChannelVisibility>, ) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { + check_fanout_access(state, community_id, stored_event, matches, threaded) + .await + .allowed +} + +/// The result of the fan-out access gate. +struct FanoutAccess { + /// Recipients that may receive the event. + allowed: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, + /// Recipients dropped because a visibility or membership lookup failed, + /// not because they lack access. + unverified: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, +} + +/// `filter_fanout_by_access`, also reporting the recipients it dropped +/// only because a lookup failed. +async fn check_fanout_access( + state: &AppState, + community_id: CommunityId, + stored_event: &StoredEvent, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, + threaded: Option<&crate::state::ThreadedChannelVisibility>, +) -> FanoutAccess { + let verified = |allowed| FanoutAccess { + allowed, + unverified: Vec::new(), + }; // First enforce the receiver-side tenant label. Subscription indexes are // community-scoped, but stale/injected matches and future fan-out helpers // must still fail closed at the send chokepoint: a connection bound to @@ -200,7 +227,7 @@ pub async fn filter_fanout_by_access( }; let Some(channel_id) = stored_event.channel_id else { - return matches; + return verified(matches); }; // Fence 3 (§4.8 phase-2): the threaded value is used only when it was // resolved under exactly this (community_id, channel_id); anything else @@ -217,17 +244,21 @@ pub async fn filter_fanout_by_access( } }; match visibility { - Ok(v) if v != "private" => return matches, + Ok(v) if v != "private" => return verified(matches), Ok(_) => {} Err(e) => { // Fail closed: if we cannot determine visibility, do not leak a // possibly-private channel's events. warn!(%channel_id, "fan-out access filter: visibility lookup failed: {e}"); - return Vec::new(); + return FanoutAccess { + allowed: Vec::new(), + unverified: matches, + }; } } let mut allowed = Vec::with_capacity(matches.len()); + let mut unverified = Vec::new(); for (conn_id, sub_id) in matches { let Some(pubkey) = state.conn_manager.pubkey_for_conn(conn_id) else { continue; @@ -240,10 +271,35 @@ pub async fn filter_fanout_by_access( Ok(false) => {} Err(e) => { warn!(%channel_id, "fan-out access filter: membership lookup failed: {e}"); + unverified.push((conn_id, sub_id)); } } } - allowed + FanoutAccess { + allowed, + unverified, + } +} + +/// Pass fan-out matches through the access gate, and return the ones that +/// receive the event itself. Syncs among the matches get its change signal +/// instead (NIP-AR rule 5), and each fan-out path calls this, so none of them +/// can skip a sync. +pub(crate) async fn route_fanout_matches( + state: &AppState, + community_id: CommunityId, + stored: &StoredEvent, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, + threaded: Option<&crate::state::ThreadedChannelVisibility>, +) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { + state.artifact_syncs.signal_access_change(stored, &matches); + let access = check_fanout_access(state, community_id, stored, matches, threaded).await; + // A sync that the gate could not check loses this change, so its copy is + // no longer exact. It must not stay open as if it were. + state + .artifact_syncs + .resync_matches(&access.unverified, "resync: access check failed"); + state.artifact_syncs.divert(stored, access.allowed) } /// Deliver one event to this relay's local subscribers through the access gate. @@ -267,7 +323,7 @@ pub(crate) async fn fan_out_event_to_local_subscribers( stored: &StoredEvent, ) { let matches = state.sub_registry.fan_out_scoped(community_id, stored); - let matches = filter_fanout_by_access(state, community_id, stored, matches, None).await; + let matches = route_fanout_matches(state, community_id, stored, matches, None).await; metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); if matches.is_empty() { return; @@ -327,7 +383,7 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } let matches = state.sub_registry.fan_out_scoped(community_id, &stored); - let matches = filter_fanout_by_access(state, community_id, &stored, matches, None).await; + let matches = route_fanout_matches(state, community_id, &stored, matches, None).await; metrics::counter!("buzz_multinode_fanout_total").increment(1); if matches.is_empty() { return; @@ -437,24 +493,12 @@ async fn dispatch_persistent_event_inner( Some(channel_id) => EventTopic::Channel(channel_id), None => EventTopic::Global, }; - state.mark_local_event(tenant.community(), &stored_event.event.id); - #[cfg(test)] - crate::nip_fi_test_hooks::before_event_publish(tenant.community()); - if let Err(e) = state - .pubsub - .publish_event(tenant, topic, &stored_event.event) - .await - { - state - .local_event_ids - .invalidate(&(tenant.community(), stored_event.event.id.to_bytes())); - warn!(event_id = %event_id_hex, "Redis publish failed: {e}"); - } + super::artifact_sync::publish_reliably(state, tenant, topic, stored_event).await; let matches = state .sub_registry .fan_out_scoped(tenant.community(), stored_event); - let matches = filter_fanout_by_access( + let matches = route_fanout_matches( state, tenant.community(), stored_event, @@ -3351,6 +3395,92 @@ mod tests { .await; assert_eq!(out, matches); } + /// A sync among the fan-out matches must resync when the access gate + /// cannot check it: the gate drops the change, so the sync's copy is + /// no longer exact. Runs through both production fan-out entries. + /// Mutation: drop the `resync_matches` call in `route_fanout_matches` + /// → the failed lookups leave every sync open with no resync → RED. + #[tokio::test] + async fn failed_access_lookup_resyncs_the_syncs_it_drops() { + let state = test_state().await; + // Every database lookup now fails at once. + state.db.pool().close().await; + let community_id = buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()); + let pk = vec![1u8; 32]; + let artifact = |channel_id| { + let event = EventBuilder::new(Kind::Custom(45010), "{}") + .tags([nostr::Tag::parse(["d", &Uuid::new_v4().to_string()]).expect("d")]) + .sign_with_keys(&Keys::generate()) + .expect("sign"); + StoredEvent::new(event, Some(channel_id)) + }; + let sync_on = |channel_id| { + let conn = register_conn(&state, Some(pk.clone())); + state.sub_registry.register_channels_scoped( + community_id, + conn, + "s".to_string(), + vec![nostr::Filter::new()], + vec![channel_id], + ); + state.artifact_syncs.insert_idle(conn, "s"); + conn + }; + let private = |channel_id| { + state + .channel_visibility_cache + .insert((community_id, channel_id), "private".to_string()); + }; + + // Control: a member whose access is known gets the change signal. + let known = Uuid::new_v4(); + private(known); + state + .membership_cache + .insert((community_id, known, pk.clone()), true); + let conn = sync_on(known); + super::super::fan_out_event_to_local_subscribers( + &state, + community_id, + &artifact(known), + ) + .await; + assert_eq!(state.artifact_syncs.pending_resync(conn, "s"), None); + + // The visibility lookup fails (local fan-out). + let channel_id = Uuid::new_v4(); + let conn = sync_on(channel_id); + super::super::fan_out_event_to_local_subscribers( + &state, + community_id, + &artifact(channel_id), + ) + .await; + assert_eq!( + state.artifact_syncs.pending_resync(conn, "s"), + Some("resync: access check failed"), + "visibility lookup" + ); + + // The membership lookup fails (Redis fan-out). + let channel_id = Uuid::new_v4(); + private(channel_id); + let conn = sync_on(channel_id); + super::super::fan_out_pubsub_event( + &state, + buzz_pubsub::ChannelEvent { + community_id, + topic: buzz_pubsub::EventTopic::Channel(channel_id), + event: artifact(channel_id).event, + }, + ) + .await; + assert_eq!( + state.artifact_syncs.pending_resync(conn, "s"), + Some("resync: access check failed"), + "membership lookup" + ); + } } // --------------------------------------------------------------------- diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index cd610668906..cb18b8525c8 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -1,6 +1,10 @@ /// NIP-42 authentication handler. pub mod admin_action_worker; pub mod admin_outbox_worker; +/// NIP-AR Sync subscriptions. +pub mod artifact_sync; +#[cfg(test)] +mod artifact_sync_postgres_tests; pub mod auth; /// Pure NIP-29 channel membership-authority decisions (kinds 9000/9001/9022). pub mod channel_authz; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 6676dd19ad4..bddaa55e154 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -22,7 +22,7 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_SUBSCRIPTIONS: usize = 1024; +pub(crate) const MAX_SUBSCRIPTIONS: usize = 1024; /// Maximum aggregate number of explicit `#h` values accepted in one REQ, /// COUNT, HTTP `/query`, or HTTP `/count` request. @@ -65,10 +65,8 @@ pub async fn handle_req( AuthState::Authenticated(ctx) => { if !ctx.scopes.is_empty() && !ctx.scopes.contains(&Scope::MessagesRead) { conn.send(RelayMessage::notice("restricted: insufficient scope")); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: insufficient scope", - )); + super::close::refuse(&sub_id, "restricted: insufficient scope", &conn, &state) + .await; return; } @@ -76,10 +74,14 @@ pub async fn handle_req( let subs = conn.subscriptions.lock().await; if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS { - conn.send(RelayMessage::closed( + // A new ID has no old holder to retire, but a lost CLOSED + // would leave the client waiting on a refused request. + if !conn.send(RelayMessage::closed( &sub_id, "error: too many subscriptions", - )); + )) { + conn.cancel.cancel(); + } return; } @@ -89,10 +91,8 @@ pub async fn handle_req( conn.send(RelayMessage::notice( "auth-required: authenticate before subscribing", )); - conn.send(RelayMessage::closed( - &sub_id, - "auth-required: not authenticated", - )); + super::close::refuse(&sub_id, "auth-required: not authenticated", &conn, &state) + .await; return; } } @@ -102,10 +102,13 @@ pub async fn handle_req( let requested_channel_ids = match extract_channel_ids_from_filters_limited(&filters) { Ok(ids) => ids, Err(()) => { - conn.send(RelayMessage::closed( + super::close::refuse( &sub_id, "restricted: too many explicit channels", - )); + &conn, + &state, + ) + .await; return; } }; @@ -122,7 +125,7 @@ pub async fn handle_req( Ok(ids) => ids, Err(e) => { warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; return; } } @@ -146,7 +149,7 @@ pub async fn handle_req( Ok(ids) => ids, Err(e) => { warn!(conn_id = %conn_id, "Failed to get readable named-only channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; return; } }; @@ -185,7 +188,7 @@ pub async fn handle_req( Ok(confirmed) => confirmed, Err(e) => { warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; return; } }; @@ -204,7 +207,7 @@ pub async fn handle_req( .await { warn!(conn_id = %conn_id, "Failed to classify repaired channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, DATABASE_ERROR_CLOSED)); + super::close::refuse(&sub_id, DATABASE_ERROR_CLOSED, &conn, &state).await; return; } } @@ -229,10 +232,7 @@ pub async fn handle_req( .as_ref() .is_some_and(|authorized| authorized.is_empty()) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: not a channel member", - )); + super::close::refuse(&sub_id, "restricted: not a channel member", &conn, &state).await; return; } @@ -251,17 +251,20 @@ pub async fn handle_req( Err(crate::nip_fi_gate::SessionExpired) => { // Fix 4: [FI-TRACE-DENIAL-ORACLE] gate is off_mode when no assertion // exists, so SessionExpired here always implies an active FI session. - conn.send(RelayMessage::closed( - &sub_id, - "restricted: authorization denied", - )); + super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state) + .await; return; } }; + // One-shot like search: accepting it replaces the ID's old holder. + let Some(owner) = claim_search_subscription(&sub_id, &conn, &state).await else { + return; + }; unless_cancelled( &conn, handle_huddle_liveness_req( &sub_id, + owner, &filters, authorized_requested_channels.as_deref().unwrap_or_default(), &conn, @@ -269,6 +272,7 @@ pub async fn handle_req( ), ) .await; + super::close::close_if_owner(&sub_id, owner, None, &conn, &state).await; return; } @@ -283,24 +287,33 @@ pub async fn handle_req( if channel_id.is_none() { let authed_pubkey_hex = hex::encode(&pubkey_bytes); if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( + super::close::refuse( &sub_id, "restricted: p-gated events require #p matching your pubkey", - )); + &conn, + &state, + ) + .await; return; } if !engram_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( + super::close::refuse( &sub_id, "restricted: agent-engram reads require authors=[self] or #p=[self]", - )); + &conn, + &state, + ) + .await; return; } if !author_only_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( + super::close::refuse( &sub_id, "restricted: author-only kinds require authors=[self]", - )); + &conn, + &state, + ) + .await; return; } } @@ -312,10 +325,13 @@ pub async fn handle_req( let has_search = filters.iter().any(|f| f.search.is_some()); if has_search { if filters.iter().any(|f| f.search.is_none()) { - conn.send(RelayMessage::closed( + super::close::refuse( &sub_id, "error: mixed search and non-search filters not supported", - )); + &conn, + &state, + ) + .await; return; } // IMPORTANT 6: acquire a REQ effect permit before the search query and @@ -327,10 +343,8 @@ pub async fn handle_req( Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { // Fix 4: [FI-TRACE-DENIAL-ORACLE] - conn.send(RelayMessage::closed( - &sub_id, - "restricted: authorization denied", - )); + super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state) + .await; return; } }; @@ -376,10 +390,7 @@ pub async fn handle_req( Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { // Fix 4: [FI-TRACE-DENIAL-ORACLE] - conn.send(RelayMessage::closed( - &sub_id, - "restricted: authorization denied", - )); + super::close::refuse(&sub_id, "restricted: authorization denied", &conn, &state).await; return; } }; @@ -568,7 +579,7 @@ pub async fn handle_req( } let msg = RelayMessage::event(&sub_id, &stored.event); - if !conn.send(msg) { + if !super::close::send_if_owner(&sub_id, owner, msg, &conn).await { return None; } total_sent += 1; @@ -578,7 +589,7 @@ pub async fn handle_req( } } - conn.send(RelayMessage::eose(&sub_id)); + super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn).await; debug!( conn_id = %conn_id, @@ -778,7 +789,7 @@ async fn handle_search_req( if build_search_channel_scope_filter(implicit_channels, include_global).is_none() && named_only.is_empty() { - conn.send(RelayMessage::eose(sub_id)); + super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; return; } @@ -993,7 +1004,8 @@ async fn handle_search_req( if !seen_ids.insert(stored.event.id) { continue; } - if !conn.send(RelayMessage::event(sub_id, &stored.event)) { + let frame = RelayMessage::event(sub_id, &stored.event); + if !super::close::send_if_owner(sub_id, owner, frame, conn).await { return; } emitted += 1; @@ -1006,7 +1018,7 @@ async fn handle_search_req( } } - conn.send(RelayMessage::eose(sub_id)); + super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1425,16 +1437,21 @@ fn huddle_liveness_session_ids(filters: &[Filter]) -> Vec { async fn handle_huddle_liveness_req( sub_id: &str, + owner: u64, filters: &[Filter], parent_channel_ids: &[uuid::Uuid], conn: &ConnectionState, state: &AppState, ) { if parent_channel_ids.is_empty() { - conn.send(RelayMessage::closed( + super::close::close_if_owner( sub_id, - "restricted: huddle liveness requires an authorized #h channel", - )); + owner, + Some("restricted: huddle liveness requires an authorized #h channel"), + conn, + state, + ) + .await; return; } @@ -1451,7 +1468,8 @@ async fn handle_huddle_liveness_req( Ok(links) => links, Err(error) => { warn!("Huddle liveness linkage batch failed: {error}"); - conn.send(RelayMessage::closed(sub_id, DATABASE_ERROR_CLOSED)); + super::close::close_if_owner(sub_id, owner, Some(DATABASE_ERROR_CLOSED), conn, state) + .await; return; } }; @@ -1469,7 +1487,14 @@ async fn handle_huddle_liveness_req( Ok(_) => continue, Err(error) => { warn!(session_id = %session_id, "Huddle liveness lease lookup failed: {error}"); - conn.send(RelayMessage::closed(sub_id, "error: liveness unavailable")); + super::close::close_if_owner( + sub_id, + owner, + Some("error: liveness unavailable"), + conn, + state, + ) + .await; return; } } @@ -1507,23 +1532,31 @@ async fn handle_huddle_liveness_req( Ok(event) => event, Err(error) => { warn!(session_id = %session_id, "Huddle liveness signing failed: {error}"); - conn.send(RelayMessage::closed(sub_id, "error: signing failed")); + super::close::close_if_owner( + sub_id, + owner, + Some("error: signing failed"), + conn, + state, + ) + .await; return; } }; - if !conn.send(RelayMessage::event(sub_id, &event)) { + let frame = RelayMessage::event(sub_id, &event); + if !super::close::send_if_owner(sub_id, owner, frame, conn).await { return; } } - conn.send(RelayMessage::eose(sub_id)); + super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await; } /// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh /// owner token, register for fan-out (replacing any same-ID entry), retain its /// topics, and release the replaced scope's. Returns the owner token, or /// `None` once the connection is closing — the caller then exits silently. -async fn claim_live_subscription( +pub(crate) async fn claim_live_subscription( sub_id: &str, filters: &[Filter], channel_ids: Option<&[uuid::Uuid]>, @@ -1536,6 +1569,9 @@ async fn claim_live_subscription( } let owner = super::close::next_owner(); subs.insert(sub_id.to_string(), owner); + // A sync that held the ID stops; the registry entry and topics below are + // replaced in place. + state.artifact_syncs.remove(conn.conn_id, sub_id); let community = conn.tenant.community(); let replaced = match channel_ids { Some(ids) => state.sub_registry.register_channels_scoped( @@ -1575,10 +1611,10 @@ async fn claim_live_subscription( Some(owner) } -/// Claim `sub_id` for a one-shot search REQ under the lifecycle lock. -/// Accepting it retires whatever holds the ID (NIP-01 replacement), even -/// though search never registers for fan-out. `None` once the connection is -/// closing. +/// Claim `sub_id` for a one-shot search or huddle-liveness REQ under the +/// lifecycle lock. Accepting it retires whatever holds the ID (NIP-01 +/// replacement), even though neither registers for fan-out. `None` once the +/// connection is closing. async fn claim_search_subscription( sub_id: &str, conn: &ConnectionState, @@ -1962,6 +1998,78 @@ mod tests { .collect() } + /// A live REQ that reuses a sync's ID retires the sync, so fan-out + /// delivers to the new subscription instead of the stale sync inbox. + /// Mutation: drop the `artifact_syncs.remove` in + /// `claim_live_subscription` → the event is diverted → RED. + #[tokio::test] + async fn live_req_replacing_a_sync_retires_it() { + let state = crate::state::tests::test_state().await; + let (conn, _send_rx) = lifecycle_conn(); + state.artifact_syncs.insert_idle(conn.conn_id, "s"); + claim_live_subscription( + "s", + &text_filters(), + Some(&[uuid::Uuid::new_v4()]), + &conn, + &state, + ) + .await + .expect("claim"); + assert!(!state.artifact_syncs.contains(conn.conn_id, "s")); + let event = nostr::EventBuilder::text_note("x") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign"); + let matches = vec![(conn.conn_id, "s".to_string())]; + let delivered = state + .artifact_syncs + .divert(&buzz_core::StoredEvent::new(event, None), matches.clone()); + assert_eq!(delivered, matches); + } + + /// A refused sync REQ answers `CLOSED`, so whatever held its ID must stop + /// first (NIP-01 replacement): no later frame may arrive under a closed + /// ID. Covers a filter refusal and an auth refusal. Mutation: send the + /// refusal without `retire_locked` → the old subscription stays → RED. + #[tokio::test] + async fn refused_sync_req_retires_the_old_holder() { + let state = crate::state::tests::test_state().await; + let (conn, mut send_rx) = lifecycle_conn(); + let conn = Arc::new(conn); + let channel = uuid::Uuid::new_v4(); + let topic = EventTopic::Channel(channel); + let valid = buzz_core::artifact_sync::parse_sync( + &serde_json::json!({"artifact":"current","#h":[channel.to_string()]}), + ); + for (filter, reason) in [ + (Err("sync requires #h"), "invalid: sync requires #h"), + (valid, "auth-required: not authenticated"), + ] { + claim_live_subscription("s", &text_filters(), Some(&[channel]), &conn, &state) + .await + .expect("claim"); + let permit = Arc::new(tokio::sync::Semaphore::new(1)) + .acquire_owned() + .await + .expect("permit"); + super::super::artifact_sync::handle_sync( + "s".into(), + filter, + Arc::clone(&conn), + Arc::clone(&state), + permit, + ) + .await; + assert!(conn.subscriptions.lock().await.is_empty(), "{reason}"); + assert!(!registered(&state, &conn, "s"), "{reason}"); + assert_eq!(state.pubsub.topic_refcount(&conn.tenant, topic).await, 0); + assert_eq!( + closed_frames(&mut send_rx), + vec![serde_json::json!(["CLOSED", "s", reason]).to_string()] + ); + } + } + #[tokio::test] async fn timed_out_historical_read_deregisters_before_closed() { let state = crate::state::tests::test_state().await; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index add1ddcd9b2..de4ccfc673f 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -257,12 +257,28 @@ pub(crate) async fn evict_conn_channel_subscriptions( .pubsub .release_topic(tenant, buzz_pubsub::EventTopic::Channel(channel_id)) .await; - if update.removed { + // A sync cannot keep its other channels: its set is no longer exact, + // so the whole sync ends with a resync (NIP-AR Sync rule 9). + let sync = state.artifact_syncs.remove(conn_id, &update.sub_id); + if sync && !update.removed { + if let Some(rest) = state + .sub_registry + .remove_subscription(conn_id, &update.sub_id) + { + super::close::release_scope_topics(state, tenant, &rest.scope).await; + } + } + if update.removed || sync { conn_subscriptions.remove(&update.sub_id); - if !state.conn_manager.send_to( - conn_id, - RelayMessage::closed(&update.sub_id, "restricted: channel access revoked"), - ) { + let reason = if sync { + "resync: channel access changed" + } else { + "restricted: channel access revoked" + }; + if !state + .conn_manager + .send_to(conn_id, RelayMessage::closed(&update.sub_id, reason)) + { // Terminal frame lost — cancel so the subscription is not // silently orphaned on a congested connection. state.conn_manager.cancel_conn(conn_id); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 44d385a8d32..f48926f72d2 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1462,6 +1462,11 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { }); } + // NIP-AR Sync: a possible loss on the Redis link resyncs every sync. + tokio::spawn(buzz_relay::handlers::artifact_sync::watch_link(Arc::clone( + &state, + ))); + // Multi-node fan-out consumer: receive events from Redis pub/sub // (published by other relay instances) and fan out to local WS subscribers. { @@ -1480,6 +1485,10 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { metrics::counter!("buzz_multinode_fanout_lag_total").increment(n); tracing::warn!("Multi-node fan-out lagged by {n} messages"); + // Skipped messages are not replayed (NIP-AR Sync rule 5). + state_for_sub + .artifact_syncs + .resync_all("resync: live delivery lagged"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { tracing::error!("Multi-node fan-out broadcast channel closed"); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 82e016c5dd9..6d8fefcc81e 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -286,8 +286,12 @@ impl RelayInfo { artifacts: serde_json::json!({ "version": 1, "revision_kind": 45010, "removal_kind": 45011, "query": "/query", "count": "/count", - "modes": ["current", "history"], - "websocket_query_extensions": false, + "modes": ["current", "history", "head"], + "sync": { + "version": 1, + "max_events": buzz_core::artifact_sync::MAX_SYNC_EVENTS, + }, + "websocket_query_extensions": true, "max_tags": buzz_core::artifact::MAX_TAGS, "max_tag_name_bytes": buzz_core::artifact::MAX_TAG_NAME_BYTES, "max_tag_value_bytes": buzz_core::artifact::MAX_TAG_VALUE_BYTES, diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index 8994a105c7c..58c1a188a91 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -25,6 +25,15 @@ pub enum ClientMessage { /// Optional per-filter composite cursor tiebreaks from raw extension fields. before_ids: Vec>>, }, + /// A REQ whose single filter is a NIP-AR sync (`"artifact":"current"`). + /// An invalid sync filter is answered with `CLOSED`, so the parse error + /// travels to the handler. + ArtifactSync { + /// The client-assigned subscription identifier. + sub_id: String, + /// The parsed sync filter, or the reason it is invalid. + filter: std::result::Result, + }, /// A CLOSE message cancelling an active subscription. Close(String), /// A COUNT message requesting aggregate counts (NIP-45). @@ -38,8 +47,8 @@ pub enum ClientMessage { Auth(Event), } -/// Artifact queries are HTTP-only; reject rather than silently drop their -/// predicates on the generic WebSocket path. +/// Artifact views other than `current` (a sync on `REQ`) are HTTP-only; +/// reject rather than silently drop their predicates on the generic path. fn reject_artifact_query_filters(filters: &[serde_json::Value]) -> Result<()> { use buzz_core::artifact::{route_filter, FilterRoute}; for filter in filters { @@ -47,7 +56,7 @@ fn reject_artifact_query_filters(filters: &[serde_json::Value]) -> Result<()> { FilterRoute::Generic => {} FilterRoute::Artifact => { return Err(RelayError::InvalidMessage( - "artifact queries require HTTP /query or /count".into(), + "only artifact \"current\" has a REQ form; other views require HTTP /query or /count".into(), )) } FilterRoute::Rejected(reason) => return Err(RelayError::InvalidMessage(reason.into())), @@ -109,6 +118,16 @@ impl ClientMessage { ))); } let filter_values = &arr[2..]; + if filter_values + .iter() + .any(buzz_core::artifact_sync::is_sync_filter) + { + let filter = match filter_values { + [filter] => buzz_core::artifact_sync::parse_sync(filter), + _ => Err("sync requires exactly one filter"), + }; + return Ok(ClientMessage::ArtifactSync { sub_id, filter }); + } reject_artifact_query_filters(filter_values)?; // Enforce NIP-11 advertised max_filters: 10 @@ -304,6 +323,32 @@ mod tests { } } + /// A sync filter becomes its own message, even when invalid, so the + /// handler can answer with `CLOSED`. COUNT has no sync mode. + #[test] + fn sync_filters_route_to_artifact_sync() { + let h = uuid::Uuid::new_v4(); + let sync = format!(r##"{{"artifact":"current","#h":["{h}"]}}"##); + let parsed = super::ClientMessage::parse(&format!(r#"["REQ","s",{sync}]"#)).unwrap(); + assert!(matches!( + parsed, + super::ClientMessage::ArtifactSync { ref sub_id, filter: Ok(ref f) } if sub_id == "s" && f.channels == [h] + )); + for request in [ + format!(r#"["REQ","s",{sync},{{"kinds":[1]}}]"#), + r#"["REQ","s",{"artifact":"current"}]"#.to_owned(), + ] { + assert!( + matches!( + super::ClientMessage::parse(&request).unwrap(), + super::ClientMessage::ArtifactSync { filter: Err(_), .. } + ), + "{request}" + ); + } + assert!(super::ClientMessage::parse(&format!(r#"["COUNT","c",{sync}]"#)).is_err()); + } + use super::*; use buzz_core::test_helpers::make_event; use nostr::{EventBuilder, Keys, Kind}; diff --git a/crates/buzz-relay/src/rejection.rs b/crates/buzz-relay/src/rejection.rs index 1fb760ec4dd..8af208d2d80 100644 --- a/crates/buzz-relay/src/rejection.rs +++ b/crates/buzz-relay/src/rejection.rs @@ -26,7 +26,9 @@ pub(crate) enum RejectionTarget<'a> { /// Picks the acknowledgement channel a rejection of `msg` must travel on. pub(crate) fn rejection_target_for(msg: &ClientMessage) -> RejectionTarget<'_> { match msg { - ClientMessage::Req { sub_id, .. } | ClientMessage::Count { sub_id, .. } => { + ClientMessage::Req { sub_id, .. } + | ClientMessage::Count { sub_id, .. } + | ClientMessage::ArtifactSync { sub_id, .. } => { RejectionTarget::Subscription(sub_id.as_str()) } ClientMessage::Event(event) => RejectionTarget::Event(event.id), @@ -52,7 +54,14 @@ pub(crate) async fn enforce_ws_admission( state: &AppState, ) -> bool { let is_event = matches!(msg, ClientMessage::Event(_)); - if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { + if !is_event + && !matches!( + msg, + ClientMessage::Req { .. } + | ClientMessage::Count { .. } + | ClientMessage::ArtifactSync { .. } + ) + { return true; } @@ -75,7 +84,7 @@ pub(crate) async fn enforce_ws_admission( ws_limit, ) .await; - if !send_admission_result(conn, ws_result, msg, "ws_operations") { + if !send_admission_result(conn, state, ws_result, msg, "ws_operations").await { return false; } @@ -97,7 +106,7 @@ pub(crate) async fn enforce_ws_admission( .await; // Only persistable EVENT attempts consume the minute quota. Ephemeral activity // still passes the shared WS flood limit above and normal event auth. - if !send_admission_result(conn, message_result, msg, "messages") { + if !send_admission_result(conn, state, message_result, msg, "messages").await { return false; } } @@ -112,32 +121,38 @@ pub(crate) async fn enforce_ws_admission( /// caller: every quota check in this module must address its rejection to the /// rejected frame's own acknowledgement channel, so there is deliberately no way /// for a call site to name a different one. -fn send_admission_result( +async fn send_admission_result( conn: &ConnectionState, + state: &AppState, result: Result<(), AdmissionError>, msg: &ClientMessage, bucket: &'static str, ) -> bool { - let target = rejection_target_for(msg); - match result { - Ok(()) => true, - Err(AdmissionError::Exceeded { reset_in_secs }) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota", "bucket" => bucket).increment(1); - conn.send(request_rejection_message( - target, - &format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), - )); - false + let (reason, label) = match result { + Ok(()) => return true, + Err(AdmissionError::Exceeded { reset_in_secs }) => ( + format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"), + "quota", + ), + Err(AdmissionError::Unavailable) => ( + "rate-limited: shared admission unavailable".to_owned(), + "unavailable", + ), + }; + metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => label, "bucket" => bucket).increment(1); + match msg { + // A refused REQ or sync retires whatever held its ID before `CLOSED`. + ClientMessage::Req { sub_id, .. } | ClientMessage::ArtifactSync { sub_id, .. } => { + crate::handlers::close::refuse(sub_id, &reason, conn, state).await; } - Err(AdmissionError::Unavailable) => { - metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable", "bucket" => bucket).increment(1); + _ => { conn.send(request_rejection_message( - target, - "rate-limited: shared admission unavailable", + rejection_target_for(msg), + &reason, )); - false } } + false } #[cfg(test)] @@ -191,17 +206,20 @@ mod tests { /// The regression: an over-quota EVENT must be rejected with /// `OK(event_id, false, reason)` so the client can settle the exact pending /// publish it belongs to. A NOTICE here reintroduces the 25s send stall. - #[test] - fn over_quota_event_is_rejected_with_a_correlated_ok() { + #[tokio::test] + async fn over_quota_event_is_rejected_with_a_correlated_ok() { + let state = crate::state::tests::test_state().await; let (conn, mut rx) = test_conn(); let (msg, event_id) = parsed_event_message(); let admitted = send_admission_result( &conn, + &state, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, "ws_operations", - ); + ) + .await; assert!(!admitted, "an over-quota frame is not admitted"); let frame = sent_frame(&mut rx); @@ -225,17 +243,20 @@ mod tests { /// The same correlation is required when admission is unavailable rather /// than exceeded — both branches strand a send if they emit a NOTICE. - #[test] - fn event_rejected_for_unavailable_admission_is_also_correlated() { + #[tokio::test] + async fn event_rejected_for_unavailable_admission_is_also_correlated() { + let state = crate::state::tests::test_state().await; let (conn, mut rx) = test_conn(); let (msg, event_id) = parsed_event_message(); send_admission_result( &conn, + &state, Err(AdmissionError::Unavailable), &msg, "ws_operations", - ); + ) + .await; let frame = sent_frame(&mut rx); assert_eq!(frame[0], "OK"); @@ -245,18 +266,21 @@ mod tests { /// A REQ still settles on CLOSED, which carries the subscription id. This /// pins the pre-existing behavior the fix must not disturb. - #[test] - fn over_quota_req_still_closes_the_subscription() { + #[tokio::test] + async fn over_quota_req_still_closes_the_subscription() { + let state = crate::state::tests::test_state().await; let (conn, mut rx) = test_conn(); let raw = serde_json::json!(["REQ", "history-abc", {"kinds": [1]}]).to_string(); let msg = ClientMessage::parse(&raw).expect("parse REQ"); send_admission_result( &conn, + &state, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, "ws_operations", - ); + ) + .await; let frame = sent_frame(&mut rx); assert_eq!(frame[0], "CLOSED"); @@ -268,18 +292,21 @@ mod tests { } /// NIP-45 uses `CLOSED(query_id, reason)` when a relay refuses a COUNT. - #[test] - fn over_quota_count_closes_the_query() { + #[tokio::test] + async fn over_quota_count_closes_the_query() { + let state = crate::state::tests::test_state().await; let (conn, mut rx) = test_conn(); let raw = serde_json::json!(["COUNT", "count-abc", {"kinds": [1]}]).to_string(); let msg = ClientMessage::parse(&raw).expect("parse COUNT"); send_admission_result( &conn, + &state, Err(AdmissionError::Exceeded { reset_in_secs: 7 }), &msg, "ws_operations", - ); + ) + .await; let frame = sent_frame(&mut rx); assert_eq!(frame[0], "CLOSED"); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 7b8b7174917..c37c9e3d281 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1242,6 +1242,8 @@ pub struct AppState { pub search: Arc, /// Registry of active client subscriptions. pub sub_registry: Arc, + /// Live NIP-AR syncs this node serves. + pub artifact_syncs: Arc, /// Registry of active WebSocket connections. pub conn_manager: Arc, /// Lifecycle cancellation for every long-lived socket, including huddle audio. @@ -1578,6 +1580,7 @@ impl AppState { auth: Arc::new(auth), search: search_arc, sub_registry: Arc::new(SubscriptionRegistry::new()), + artifact_syncs: Arc::default(), conn_manager: Arc::new(ConnectionManager::new()), community_connections: Arc::new(CommunityConnectionRegistry::new()), community_revalidator_cancel: CancellationToken::new(), diff --git a/docs/nips/NIP-AR.md b/docs/nips/NIP-AR.md index ff0d62c36b4..ce829897a3b 100644 --- a/docs/nips/NIP-AR.md +++ b/docs/nips/NIP-AR.md @@ -93,7 +93,7 @@ Relays MUST reject kind-5 deletion requests targeting artifact revisions with an ## Current state and queries -An artifact's current state is its latest accepted revision, as determined by the `prev` rules above. Current-state queries MUST return only that revision and omit deleted artifacts. For example, a task whose current revision belongs to Project B no longer appears in Project A's list, even if an earlier revision belonged there. Revision history is queried separately. This NIP does not define a public acceptance order or history pagination; relays record the order in which they accept revisions, and a later sync specification defines how clients observe it. +An artifact's current state is its latest accepted revision, as determined by the `prev` rules above. Current-state queries MUST return only that revision and omit deleted artifacts. For example, a task whose current revision belongs to Project B no longer appears in Project A's list, even if an earlier revision belonged there. Revision history is queried separately. This NIP does not define a public acceptance order or history pagination. Relays record the order in which they accept revisions; clients observe the resulting current state through sync (see Sync), not through timestamps or `prev` chains. Relays MUST support querying current artifacts across channels the reader can access using exact tag-name/value matches, including multi-character tag names. Conditions on different tag names are combined with AND; requested values for one name are combined with OR. A match compares the tag name and its first value in the same tag. Access checks and tag matching apply before pagination and any counts. Older revisions do not contribute matches. @@ -101,6 +101,136 @@ Relays MUST advertise and enforce limits on tag-name/value sizes, total tag byte Artifact revisions are state changes, not conversation messages. Receiving one MUST NOT increase chat or reply counts, add a chat unread, mark a conversation read, or repeat mention or push notifications for tags carried over from the previous revision. +## Sync + +Sync lets a client hold an exact local copy of a set of artifacts and keep it current. The relay owns the order of changes: the relay already decides which revision is current (the `prev` check above), and sync only reports those decisions by always sending the current head. Clients do not order changes by `created_at`, which the signer chooses, or by walking `prev` chains, which needs every revision. + +Sync is additive. A client that does not use it keeps working unchanged, and a relay advertises each part separately (see Relay information document). A reader receives through sync only artifacts that it can already read with a current-state query, under the same channel read permission. + +Incremental reads (below) are a separate HTTP protocol with their own capability. They let a client update a saved store after a disconnect without reading the whole set again, but they do not prove the complete set that `EOSE` proves here. + +```mermaid +sequenceDiagram + participant C as Client + participant R as Relay + C->>R: REQ sub {"artifact":"current", "#h":[...], "#type":[...]} + Note over R: Register live interest first,
then read all matching heads
in one snapshot + R-->>C: EVENT sub head (0..n) + R-->>C: EOSE sub (the set is complete at one cut) + Note over C: Replace local set only here + loop live + R-->>C: EVENT sub head or kind 45011 removal + end + R-->>C: CLOSED sub "resync: ..." (access changed, or a change could not be delivered) +``` + +### Sync subscription + +**Request.** Sync is a NIP-01 `REQ` whose single filter has `"artifact": "current"`. It is the current-state filter of HTTP `/query`; the `REQ` transport adds the complete set, `EOSE` and live heads. The client does not page: the relay streams the whole set until `EOSE`. Other views (`history`, `head`) have no `REQ` form and stay HTTP-only: + +```json +["REQ", "s1", {"artifact": "current", "#h": [""], "#type": ["buzz.task"]}] +``` + +The filter MUST include `#h` with 1 or more channels. It accepts the same `#name` predicates and limits as a NIP-AR current-state query, with the same AND/OR rules, and also `kinds: [45010]`. It accepts no `limit` or `offset`. A relay rejects a filter without `#h`, or with an unknown key, with `CLOSED`. + +**Scope.** The sync's scope is the set of artifacts that a NIP-AR current-state query with the same filter returns to this reader at that time. The rules below use this one definition for access, matching and system channels. + +**Response.** The relay answers with ordinary NIP-01 messages. Only the meaning of `EOSE` is stronger: for a sync, it means "this is the complete set at one point in time", not only "end of stored events". + +| Message | Meaning | +|---|---| +| `["EVENT", sub, ]` | This revision is the artifact's current head. If it is a deletion, or it no longer matches the filter, the client removes the artifact from its set. | +| `["EVENT", sub, ]` | Live only. A move removed the artifact from a channel the reader can read. The client removes it. | +| `["EOSE", sub]` | Sent once, after the initial set. The set has been delivered in full, as one consistent cut (rule 2). | +| `["CLOSED", sub, "resync: "]` | The relay can no longer keep this subscription exact. The client starts a new sync. | +| `["CLOSED", sub, "restricted: "]` | The reader cannot read a channel that `#h` names, or the channel does not exist. The message does not say which. | +| `["CLOSED", sub, "error: "]` | The sync failed. The client keeps its last complete set and treats it as possibly stale. | + +Rules: + +1. **Live before stored.** The relay MUST register live interest, and wait until it is active, before it opens the snapshot of rule 2. Live interest is active only when the pub/sub server has confirmed the subscription (for Redis, the reply to `SUBSCRIBE`) and the node's pub/sub connection is still up. While that connection is down or reconnecting, a new sync waits, or gets `CLOSED "error: ..."`. Then every change that commits after the snapshot causes a live entry, or a resync when the relay detects that the entry was lost (rule 5). This is the order that `REQ` already uses. The relay MUST NOT send a live entry before `EOSE`. During the read it holds the `d` of each changed artifact. After `EOSE` it reads and sends their heads as in rule 3. A held change can already be in the snapshot; then the relay sends the same head again, a newer one, or nothing (rule 4). The held list is bounded: if it grows past `max_events` distinct `d`, the relay sends `CLOSED "resync: ..."` and no `EOSE`. +2. **Initial set: one consistent cut, read in pages.** The relay reads the set inside one read-only transaction that has one database snapshot (Postgres `REPEATABLE READ`). It opens the transaction after it receives the `REQ` and after rule 1, on the writer database or on a replica proven at least as fresh. So every change that the relay accepted with `OK` before it received the `REQ` is in the set. Inside the transaction it reads pages ordered by `d`. Each page starts after the last `d` of the page before it (keyset pages, not offset pages). All pages see the same snapshot. So the set at `EOSE` is the exact set at one point in time: a combination of heads that existed together, never a mix of earlier and later states. This is the complete-set read. The relay sends one `EVENT` for each artifact in scope as it reads it, then `EOSE`. The client sees one stream; it never joins pages. A snapshot does not need the whole set in one result, so there is no byte limit on the whole set. The relay bounds how long it holds a snapshot open, with a budget that it defines. It MAY copy pages into a bounded buffer so that it can close the snapshot before a slow reader has received them. If the read fails, the snapshot is lost, or the budget runs out (for example, the reader is too slow), the relay sends `CLOSED "error: ..."` and no `EOSE` (rule 8). It MUST NOT continue the read on another snapshot. If the reader cannot read a channel that `#h` names, the relay sends `CLOSED "restricted: ..."` and no `EOSE`, whether or not the channel exists. +3. **Live changes.** After `EOSE`, when an artifact in scope changes, the relay reads its current head. If the reader can read the head's channel, it sends the head, even when the head is a deletion or no longer matches. If the artifact moved to a channel the reader cannot read, it sends the kind 45011 removal stored in the source. It never sends anything about an artifact the reader cannot read. A change is an accepted revision (any `op`), or a move's removal from the source. An artifact is in scope for a change when it was in scope before the change or is in scope after it. "Before" is the client's copy as this sync sent it: the initial set plus later heads that matched. "After" is a current head, not a deletion, that matches every predicate of the filter with the NIP-AR match rule (the tag name and its first value in the same tag). The relay sends nothing for an artifact that is in scope neither before nor after, so a head that never matched the filter never reaches the client. +4. **Heads only, never older.** The relay sends the current head, never an older revision. It MAY skip intermediate revisions and MAY repeat a head the reader already has. For any one `d`, it MUST NOT send a head that it read before the head it last sent for that `d`. To keep this, for each sync the relay reads the head of one `d` one read at a time, starts each read only after the result of the read before it (a head, a kind 45011 removal or nothing) is in the outbound queue, and reads from the writer database or a replica proven at least as fresh. Clients that need every revision use the NIP-AR history view. +5. **No silent loss.** Live fan-out can lose entries: a publish can fail, a node's pub/sub connection can drop for seconds, and a node's local fan-out can lag. Pub/sub does not replay any of these. A sync MUST NOT lose a change silently. So: + - A node MUST send `CLOSED "resync: ..."` to every sync it serves that a loss on that node can affect: its pub/sub connection dropped or reconnected; its local fan-out lagged or dropped entries, including a message that it could not decode; it could not check the reader's access to a change for that sync (for example, a failed membership lookup); or the connection's own send queue was full and dropped an entry for that sync. One drop is enough: the slow-client grace limit does not apply to a sync. It MAY resync every sync it serves. + - If the node cannot queue the `CLOSED "resync: ..."` itself (for example, because the same send queue is full), it MUST close the connection. A client starts a new sync on any disconnect (rule 7), so a resync is never lost. + - A node MUST retry a failed publish until it succeeds or the node stops. Delivery order does not matter. For a sync, a live entry is only a signal that one `d` changed. The reader's node never sends the published event itself to a sync; it reads the current head (rule 3). So a late retry of an older change sends the current head or nothing, never an older one (rule 4). + + So a sync that is open and has had no resync has received every change, except when a node crashes after it commits a change and before it publishes it. A client that must bound that window can sync again on its own schedule. The relay does not read the set again on a timer. +6. **Retirement.** A moderation redaction that retires the current revision of an artifact in scope makes the relay send `CLOSED "resync: ..."`. The relay withholds the redacted head, so a new sync is the only way the client learns that the artifact left the set. A relay MUST NOT retire a head in any other way that open syncs cannot observe, such as an operator delete outside NIP-29; it refuses such deletes or ends the affected syncs with `resync`. +7. **Commit point.** A client MUST replace its local set only when it receives `EOSE`. If the connection drops before `EOSE`, it discards what it received. After `EOSE`, it applies each live entry as it arrives. On any disconnect it starts a new sync; there is no resume. Before a new sync after a resync, an error or a dropped connection, the client waits a random delay that grows after each failure in a row (backoff with jitter). +8. **No partial success.** If the relay cannot deliver the full set within its limits or budget, it MUST send `CLOSED` and MUST NOT send `EOSE`. A closed connection without `EOSE` is never proof of an empty set. The relay bounds its buffers for a sync in bytes as well as in count, and it ends the snapshot and its reads as soon as the client disconnects or sends `CLOSE`. +9. **Resync.** The relay MUST send `CLOSED "resync: ..."` when the reader's access to a channel that `#h` names changes: membership, channel visibility or channel deletion. This closes the whole sync, also when `#h` names other channels. An ordinary `REQ` can keep its other channels; a sync cannot, because its set is no longer exact. Archive does not change reads (see Home channel lifecycle), so it does not cause a resync. + +**Short-lived clients.** A CLI that needs the set one time sends the same `REQ`, waits for `EOSE`, then sends `CLOSE`. It gets the cut of rule 2: a complete set that includes every change the relay accepted before it received the `REQ`. It does not see changes after the cut. A writer catches those with `prev` (NIP-AR compare-and-set). There is no separate one-shot request. + +**Set digest.** `set_digest` is the lowercase hex SHA-256 of the UTF-8 bytes `nip-ar-set-v1`, a zero byte, and then `:\n` for each artifact in the set, sorted by `d`. It is a content identifier. It is not a counter, not a `prev` precondition and not a live-delivery cursor. An unchanged set has the same digest. A client can compute it over the set that it holds, or over any part of that set, and get the same value as any other client with the same heads. + +The relay does not send the digest. A client computes it over the set that it holds at `EOSE` or later. + +**Offset pages are views.** The existing `/query` offset pages stay for display and browsing. This section states that they are not evidence of a complete set. + +### Head lookup, including deletions + +Retries and "add or restore" flows need the current revision of one artifact even when it is deleted. Today a current-state query omits deleted artifacts, and history returns every revision. + +Relays that support sync also support the query view `"artifact": "head"`. It requires `#d` and returns the current revision of each named artifact the reader can read, **including** a deletion. It returns nothing for an unknown `d`, or for one the reader cannot read; it does not say which. + +### Deterministic identity and the ensure flow + +Clients MAY derive `d`. This section defines one standard derivation, so that independent clients compute the same `d` for the same logical record: + +``` +d = UUIDv8( first 16 bytes of SHA-256("nip-ar-artifact\0" + h + "\0" + type + "\0" + key) ) +``` + +`h` is the home channel at creation, `type` is the artifact type, and `key` is a client-chosen name, unique within that type and channel (for example a config entry name). Version and variant bits are set as in RFC 9562. + +A derived `d` is a name, not a proof of authorship. Anyone who can write in `h` can create it first. Readers decide which authors to trust, as with every artifact. + +**Ensure (create or restore, safe to retry).** To make "this record exists with this content" true, a client: + +1. Sends `create`. If accepted or a duplicate of an accepted event, it is done. +2. On `conflict`, reads the head (see Head lookup). + - If the head is a deletion, sends `restore` with `prev` set to it. + - If the head is live with the same content and tags, it is done. + - Otherwise sends `update` with `prev` set to the head, or reports the conflict, as the client's type requires. +3. Repeats step 2 on a further conflict, up to a client-defined limit. + +The relay changes nothing for this flow except the head view. A deleted `d` stays reserved (see Identity and edits). + +### Home channel lifecycle + +| Home channel event | Writes | Current-state reads and sync | +|---|---|---| +| Archived | Rejected with the kind-9 archive reason. | Unchanged. Readers keep reading. To keep editing, unarchive or `move` the artifacts out first. | +| Unarchived | Allowed again. | Unchanged. | +| Deleted | Rejected. | Its artifacts leave every current-state result. Each live sync that covered the channel gets `resync`. A later channel with a different ID does not inherit them. | + +### Client guidance (non-normative) + +- **Type is the record family.** `type` cannot change. Put variants that can change (for example watch vs timer) in `content` under one type. If a family must change type, create new artifacts and delete the old ones. +- **Version the content.** Put `"version": n` in `content`. Ship readers that accept `n+1` before writers that produce it. Editors already keep unknown fields or decline the edit (see Client behavior). +- **Titles are visible to every reader of the channel.** Put no secrets in `title`. +- **Conflicts are normal.** On `conflict`, reload with the head view, reapply the user's change to the new head, and show the user when that is not possible. Do not resubmit with only `prev` replaced. + +### Relay information document + +The NIP-11 relay information document has an `artifacts` object. A relay that supports sync adds: + +```json +"sync": {"version": 1, "max_events": 4096}, +"modes": ["current", "history", "head"] +``` + +`max_events` is the largest set that a sync serves. It bounds each initial read and the held list (rule 1). There is no byte limit on the whole set: the relay reads and sends it in pages from one snapshot, and the relay's existing event size limit bounds each event. The relay does not advertise its snapshot time budget (rule 2); a reader that is too slow gets `CLOSED`. + +and sets `websocket_query_extensions` to `true`. + +Relays advertise `sync` and `head` only when every backend that can serve the connection runs them. + ## Client behavior 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.