mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
+1








14fba21e57
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
1466 lines
52 KiB
Rust
1466 lines
52 KiB
Rust
//! Subscription registry with active WebSocket indexes for targeted fan-out.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use dashmap::DashMap;
|
|
use nostr::{Alphabet, Filter, Kind, SingleLetterTag};
|
|
use uuid::Uuid;
|
|
|
|
use buzz_core::{filter::filters_match, CommunityId, StoredEvent};
|
|
|
|
/// Connection identifier — a UUID assigned to each WebSocket connection.
|
|
pub type ConnId = Uuid;
|
|
/// Subscription identifier — the client-supplied string from a REQ message.
|
|
pub type SubId = String;
|
|
/// Stored subscription entry: filters paired with server-resolved community and optional channel scope.
|
|
pub type SubEntry = (Vec<Filter>, CommunityId, Option<Uuid>);
|
|
|
|
/// Index key combining a channel and event kind for O(1) fan-out lookups.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct IndexKey {
|
|
/// The channel this key is scoped to.
|
|
pub channel_id: Uuid,
|
|
/// The Nostr event kind this key is scoped to.
|
|
pub kind: Kind,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
struct GlobalPKindIndexKey {
|
|
community_id: CommunityId,
|
|
kind: Kind,
|
|
p: String,
|
|
}
|
|
|
|
/// A removed subscription's server-resolved routing scope.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct RemovedSubscription {
|
|
/// Server-resolved community this subscription belonged to.
|
|
pub community_id: CommunityId,
|
|
/// Tenant-local channel scope; `None` means the community-global topic.
|
|
pub channel_id: Option<Uuid>,
|
|
}
|
|
|
|
/// Thread-safe registry of active subscriptions with targeted in-memory fan-out indexes.
|
|
#[derive(Debug, Default)]
|
|
pub struct SubscriptionRegistry {
|
|
/// Maps conn_id → sub_id → (filters, community_id, channel_id).
|
|
/// Storing scope alongside filters enables O(1) targeted index removal and
|
|
/// gives lifecycle code the exact Redis topic to release.
|
|
subs: DashMap<ConnId, HashMap<SubId, SubEntry>>,
|
|
channel_kind_index: DashMap<(CommunityId, IndexKey), Vec<(ConnId, SubId)>>,
|
|
/// Subscriptions with a channel_id but no kind filter — need to receive ALL kinds.
|
|
channel_wildcard_index: DashMap<(CommunityId, Uuid), Vec<(ConnId, SubId)>>,
|
|
/// Global subscriptions indexed by kind — avoids O(all_subs) scan for global events.
|
|
global_kind_index: DashMap<(CommunityId, Kind), Vec<(ConnId, SubId)>>,
|
|
/// Global subscriptions indexed by both kind and `#p` recipient.
|
|
global_p_kind_index: DashMap<GlobalPKindIndexKey, Vec<(ConnId, SubId)>>,
|
|
/// Global subscriptions with no kind filter — wildcard, receives all global events.
|
|
global_wildcard_index: DashMap<CommunityId, Vec<(ConnId, SubId)>>,
|
|
}
|
|
|
|
impl SubscriptionRegistry {
|
|
/// Creates a new empty registry.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Replaces any existing subscription with the same sub_id (NIP-01), scoped
|
|
/// to the server-resolved community that owns the connection.
|
|
pub fn register_scoped(
|
|
&self,
|
|
community_id: CommunityId,
|
|
conn_id: ConnId,
|
|
sub_id: SubId,
|
|
filters: Vec<Filter>,
|
|
channel_id: Option<Uuid>,
|
|
) -> Option<RemovedSubscription> {
|
|
let removed = self.remove_subscription(conn_id, &sub_id);
|
|
|
|
self.subs
|
|
.entry(conn_id)
|
|
.or_default()
|
|
.insert(sub_id.clone(), (filters.clone(), community_id, channel_id));
|
|
metrics::gauge!("buzz_subscriptions_active").increment(1.0);
|
|
|
|
if let Some(ch_id) = channel_id {
|
|
match extract_kinds_from_filters(&filters) {
|
|
None => {
|
|
// At least one filter has no `kinds` constraint — wildcard,
|
|
// this sub wants all kinds in this channel.
|
|
self.channel_wildcard_index
|
|
.entry((community_id, ch_id))
|
|
.or_default()
|
|
.push((conn_id, sub_id.clone()));
|
|
}
|
|
Some(kinds) if kinds.is_empty() => {
|
|
// All filters had explicit empty kinds lists (`kinds: []`).
|
|
// Per NIP-01, `kinds: []` means "match no kinds" — this
|
|
// subscription will never receive any events. Do not index it
|
|
// anywhere; `filters_match` will reject all events at fan-out.
|
|
}
|
|
Some(kinds) => {
|
|
for kind in kinds {
|
|
let key = IndexKey {
|
|
channel_id: ch_id,
|
|
kind,
|
|
};
|
|
self.channel_kind_index
|
|
.entry((community_id, key))
|
|
.or_default()
|
|
.push((conn_id, sub_id.clone()));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Global subscription. Fully p-constrained filters can use the
|
|
// narrower (kind, #p) index; broader filters stay on the generic
|
|
// kind/wildcard indexes.
|
|
if let Some(keys) = extract_global_p_kind_index_keys(community_id, &filters) {
|
|
for key in keys {
|
|
self.global_p_kind_index
|
|
.entry(key)
|
|
.or_default()
|
|
.push((conn_id, sub_id.clone()));
|
|
}
|
|
} else {
|
|
match extract_kinds_from_filters(&filters) {
|
|
None => {
|
|
self.global_wildcard_index
|
|
.entry(community_id)
|
|
.or_default()
|
|
.push((conn_id, sub_id.clone()));
|
|
}
|
|
Some(kinds) if kinds.is_empty() => {}
|
|
Some(kinds) => {
|
|
for kind in kinds {
|
|
self.global_kind_index
|
|
.entry((community_id, kind))
|
|
.or_default()
|
|
.push((conn_id, sub_id.clone()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
removed
|
|
}
|
|
|
|
/// Test-only convenience wrapper preserving the original single-tenant test API.
|
|
#[cfg(test)]
|
|
pub fn register(
|
|
&self,
|
|
conn_id: ConnId,
|
|
sub_id: SubId,
|
|
filters: Vec<Filter>,
|
|
channel_id: Option<Uuid>,
|
|
) {
|
|
self.register_scoped(test_community(), conn_id, sub_id, filters, channel_id);
|
|
}
|
|
|
|
/// Remove a single subscription and clean up its index entries.
|
|
pub fn remove_subscription(
|
|
&self,
|
|
conn_id: ConnId,
|
|
sub_id: &str,
|
|
) -> Option<RemovedSubscription> {
|
|
if let Some(mut conn_subs) = self.subs.get_mut(&conn_id) {
|
|
if let Some((filters, community_id, channel_id)) = conn_subs.remove(sub_id) {
|
|
self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id);
|
|
metrics::gauge!("buzz_subscriptions_active").decrement(1.0);
|
|
return Some(RemovedSubscription {
|
|
community_id,
|
|
channel_id,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Remove all subscriptions for a connection and clean up index entries.
|
|
pub fn remove_connection(&self, conn_id: ConnId) -> Vec<RemovedSubscription> {
|
|
let mut removed = Vec::new();
|
|
if let Some((_, conn_subs)) = self.subs.remove(&conn_id) {
|
|
let count = conn_subs.len();
|
|
for (sub_id, (filters, community_id, channel_id)) in &conn_subs {
|
|
self.remove_from_index(conn_id, sub_id, filters, *community_id, *channel_id);
|
|
removed.push(RemovedSubscription {
|
|
community_id: *community_id,
|
|
channel_id: *channel_id,
|
|
});
|
|
}
|
|
metrics::gauge!("buzz_subscriptions_active").decrement(count as f64);
|
|
}
|
|
removed
|
|
}
|
|
|
|
/// Remove all subscriptions on `conn_id` scoped to `channel_id` in one community.
|
|
pub fn remove_channel_subscriptions_scoped(
|
|
&self,
|
|
community_id: CommunityId,
|
|
conn_id: ConnId,
|
|
channel_id: Uuid,
|
|
) -> Vec<(SubId, RemovedSubscription)> {
|
|
let sub_ids: Vec<SubId> = self
|
|
.subs
|
|
.get(&conn_id)
|
|
.map(|conn_subs| {
|
|
conn_subs
|
|
.iter()
|
|
.filter_map(|(sub_id, (_, sub_community_id, sub_channel_id))| {
|
|
(*sub_community_id == community_id && *sub_channel_id == Some(channel_id))
|
|
.then_some(sub_id.clone())
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
sub_ids
|
|
.into_iter()
|
|
.filter_map(|sub_id| {
|
|
let removed = self.remove_subscription(conn_id, &sub_id)?;
|
|
Some((sub_id, removed))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Test-only convenience wrapper preserving the original single-tenant test API.
|
|
#[cfg(test)]
|
|
pub fn remove_channel_subscriptions(&self, conn_id: ConnId, channel_id: Uuid) -> Vec<SubId> {
|
|
self.remove_channel_subscriptions_scoped(test_community(), conn_id, channel_id)
|
|
.into_iter()
|
|
.map(|(sub_id, _)| sub_id)
|
|
.collect()
|
|
}
|
|
|
|
/// Return the distinct connection IDs holding any subscription scoped to
|
|
/// `channel_id` (both kind-filtered and wildcard channel subscriptions) in
|
|
/// one server-resolved community.
|
|
pub fn channel_subscriber_conns_scoped(
|
|
&self,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Vec<ConnId> {
|
|
let mut conns: HashSet<ConnId> = HashSet::new();
|
|
for entry in self.channel_kind_index.iter() {
|
|
if entry.key().0 == community_id && entry.key().1.channel_id == channel_id {
|
|
conns.extend(entry.value().iter().map(|(conn_id, _)| *conn_id));
|
|
}
|
|
}
|
|
if let Some(entry) = self.channel_wildcard_index.get(&(community_id, channel_id)) {
|
|
conns.extend(entry.value().iter().map(|(conn_id, _)| *conn_id));
|
|
}
|
|
conns.into_iter().collect()
|
|
}
|
|
|
|
/// Test-only convenience wrapper preserving the original single-tenant test API.
|
|
#[cfg(test)]
|
|
pub fn channel_subscriber_conns(&self, channel_id: Uuid) -> Vec<ConnId> {
|
|
self.channel_subscriber_conns_scoped(test_community(), channel_id)
|
|
}
|
|
|
|
/// Return all (conn_id, sub_id) pairs whose filters match the given event in
|
|
/// one server-resolved community.
|
|
pub fn fan_out_scoped(
|
|
&self,
|
|
community_id: CommunityId,
|
|
event: &StoredEvent,
|
|
) -> Vec<(ConnId, SubId)> {
|
|
let mut results = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
|
|
if let Some(channel_id) = event.channel_id {
|
|
let key = IndexKey {
|
|
channel_id,
|
|
kind: event.event.kind,
|
|
};
|
|
if let Some(candidates) = self.channel_kind_index.get(&(community_id, key)) {
|
|
for (conn_id, sub_id) in candidates.iter() {
|
|
self.push_match(*conn_id, sub_id, event, &mut results, &mut seen);
|
|
}
|
|
}
|
|
// Also check wildcard (channel-only, kindless) index.
|
|
if let Some(wildcards) = self.channel_wildcard_index.get(&(community_id, channel_id)) {
|
|
for (conn_id, sub_id) in wildcards.iter() {
|
|
self.push_match(*conn_id, sub_id, event, &mut results, &mut seen);
|
|
}
|
|
}
|
|
} else {
|
|
// Global event (channel_id = None) — use global indexes for sub-linear fan-out.
|
|
// Channel-scoped subscriptions are never in these indexes, preserving the
|
|
// scoping invariant without an explicit skip check.
|
|
for p in event_p_tag_values(event) {
|
|
let key = GlobalPKindIndexKey {
|
|
community_id,
|
|
kind: event.event.kind,
|
|
p,
|
|
};
|
|
if let Some(candidates) = self.global_p_kind_index.get(&key) {
|
|
for (conn_id, sub_id) in candidates.iter() {
|
|
self.push_match(*conn_id, sub_id, event, &mut results, &mut seen);
|
|
}
|
|
}
|
|
}
|
|
if let Some(candidates) = self
|
|
.global_kind_index
|
|
.get(&(community_id, event.event.kind))
|
|
{
|
|
for (conn_id, sub_id) in candidates.iter() {
|
|
self.push_match(*conn_id, sub_id, event, &mut results, &mut seen);
|
|
}
|
|
}
|
|
// Also check global wildcard (kindless global subs).
|
|
if let Some(wildcards) = self.global_wildcard_index.get(&community_id) {
|
|
for (conn_id, sub_id) in wildcards.iter() {
|
|
self.push_match(*conn_id, sub_id, event, &mut results, &mut seen);
|
|
}
|
|
}
|
|
}
|
|
|
|
// NOTE: The scoping invariant is symmetric:
|
|
// - Global subscriptions (channel_id = None) do NOT receive channel-scoped events.
|
|
// - Channel-scoped subscriptions do NOT receive global events.
|
|
// This prevents both directions of information leakage: channel content
|
|
// leaking to global subscribers, and global infrastructure events (like
|
|
// membership notifications) leaking to channel subscribers.
|
|
|
|
results
|
|
}
|
|
|
|
/// Test-only convenience wrapper preserving the original single-tenant test API.
|
|
#[cfg(test)]
|
|
pub fn fan_out(&self, event: &StoredEvent) -> Vec<(ConnId, SubId)> {
|
|
self.fan_out_scoped(test_community(), event)
|
|
}
|
|
|
|
/// Return the filters for a specific subscription, or `None` if not found.
|
|
pub fn get_filters(&self, conn_id: ConnId, sub_id: &str) -> Option<Vec<Filter>> {
|
|
self.subs
|
|
.get(&conn_id)
|
|
.and_then(|conn_subs| conn_subs.get(sub_id).map(|(filters, _, _)| filters.clone()))
|
|
}
|
|
|
|
/// Return the total number of active subscriptions across all connections.
|
|
pub fn total_subscriptions(&self) -> usize {
|
|
self.subs.iter().map(|e| e.value().len()).sum()
|
|
}
|
|
|
|
/// Return the total number of connections with at least one active subscription.
|
|
pub fn total_connections(&self) -> usize {
|
|
self.subs.len()
|
|
}
|
|
|
|
fn push_match(
|
|
&self,
|
|
conn_id: ConnId,
|
|
sub_id: &str,
|
|
event: &StoredEvent,
|
|
results: &mut Vec<(ConnId, SubId)>,
|
|
seen: &mut HashSet<(ConnId, SubId)>,
|
|
) {
|
|
if let Some(conn_subs) = self.subs.get(&conn_id) {
|
|
if let Some((filters, _, _)) = conn_subs.get(sub_id) {
|
|
if filters_match(filters, event) {
|
|
let entry = (conn_id, sub_id.to_string());
|
|
if seen.insert(entry.clone()) {
|
|
results.push(entry);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Removes a subscription from the channel_kind_index (or channel_wildcard_index) using
|
|
/// targeted O(k) lookup where k = number of kinds in the filters, instead of O(n) full-scan.
|
|
fn remove_from_index(
|
|
&self,
|
|
conn_id: ConnId,
|
|
sub_id: &str,
|
|
filters: &[Filter],
|
|
community_id: CommunityId,
|
|
channel_id: Option<Uuid>,
|
|
) {
|
|
if let Some(ch_id) = channel_id {
|
|
match extract_kinds_from_filters(filters) {
|
|
// None = wildcard (at least one filter had no kinds constraint).
|
|
None => {
|
|
// Was in wildcard index.
|
|
if let Some(mut entries) =
|
|
self.channel_wildcard_index.get_mut(&(community_id, ch_id))
|
|
{
|
|
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
|
if entries.is_empty() {
|
|
drop(entries);
|
|
self.channel_wildcard_index.remove(&(community_id, ch_id));
|
|
}
|
|
}
|
|
}
|
|
Some(kinds) if kinds.is_empty() => {
|
|
// `kinds: []` subscriptions are never indexed (they match nothing),
|
|
// so there is nothing to remove here.
|
|
}
|
|
Some(kinds) => {
|
|
// Was in kind-specific index.
|
|
for kind in kinds {
|
|
let key = IndexKey {
|
|
channel_id: ch_id,
|
|
kind,
|
|
};
|
|
if let Some(mut entries) = self
|
|
.channel_kind_index
|
|
.get_mut(&(community_id, key.clone()))
|
|
{
|
|
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
|
if entries.is_empty() {
|
|
drop(entries);
|
|
self.channel_kind_index.remove(&(community_id, key));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Global subscription — remove from the same global index chosen at registration.
|
|
if let Some(keys) = extract_global_p_kind_index_keys(community_id, filters) {
|
|
for key in keys {
|
|
if let Some(mut entries) = self.global_p_kind_index.get_mut(&key) {
|
|
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
|
if entries.is_empty() {
|
|
drop(entries);
|
|
self.global_p_kind_index.remove(&key);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
match extract_kinds_from_filters(filters) {
|
|
None => {
|
|
if let Some(mut entries) = self.global_wildcard_index.get_mut(&community_id)
|
|
{
|
|
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
|
if entries.is_empty() {
|
|
drop(entries);
|
|
self.global_wildcard_index.remove(&community_id);
|
|
}
|
|
}
|
|
}
|
|
Some(kinds) if kinds.is_empty() => {}
|
|
Some(kinds) => {
|
|
for kind in kinds {
|
|
if let Some(mut entries) =
|
|
self.global_kind_index.get_mut(&(community_id, kind))
|
|
{
|
|
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
|
if entries.is_empty() {
|
|
drop(entries);
|
|
self.global_kind_index.remove(&(community_id, kind));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn p_tag() -> SingleLetterTag {
|
|
SingleLetterTag::lowercase(Alphabet::P)
|
|
}
|
|
|
|
fn extract_global_p_kind_index_keys(
|
|
community_id: CommunityId,
|
|
filters: &[Filter],
|
|
) -> Option<Vec<GlobalPKindIndexKey>> {
|
|
let mut seen = HashSet::new();
|
|
let mut keys = Vec::new();
|
|
let p_tag = p_tag();
|
|
|
|
for filter in filters {
|
|
let kinds = filter.kinds.as_ref()?;
|
|
if kinds.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let p_values = filter.generic_tags.get(&p_tag)?;
|
|
if p_values.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
for kind in kinds {
|
|
for p in p_values {
|
|
let key = GlobalPKindIndexKey {
|
|
community_id,
|
|
kind: *kind,
|
|
p: p.clone(),
|
|
};
|
|
if seen.insert(key.clone()) {
|
|
keys.push(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(keys)
|
|
}
|
|
|
|
fn event_p_tag_values(event: &StoredEvent) -> Vec<String> {
|
|
let mut seen = HashSet::new();
|
|
let mut values = Vec::new();
|
|
for tag in event.event.tags.iter() {
|
|
if tag.kind().to_string() != "p" {
|
|
continue;
|
|
}
|
|
if let Some(value) = tag.content() {
|
|
let value = value.to_string();
|
|
if seen.insert(value.clone()) {
|
|
values.push(value);
|
|
}
|
|
}
|
|
}
|
|
values
|
|
}
|
|
|
|
/// Returns the union of all `kinds` across filters, or `None` if any filter
|
|
/// lacks a `kinds` array (meaning that filter matches all kinds — wildcard).
|
|
///
|
|
/// NIP-01 OR semantics: a subscription with multiple filters is satisfied when
|
|
/// *any* filter matches. If one filter has no `kinds` constraint it matches
|
|
/// every kind, making the whole subscription a wildcard regardless of the other
|
|
/// filters.
|
|
fn extract_kinds_from_filters(filters: &[Filter]) -> Option<Vec<Kind>> {
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut kinds = Vec::new();
|
|
for f in filters {
|
|
match &f.kinds {
|
|
Some(filter_kinds) => {
|
|
for k in filter_kinds {
|
|
if seen.insert(*k) {
|
|
kinds.push(*k);
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
// At least one filter has no kind constraint — the whole
|
|
// subscription is a wildcard.
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(kinds)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_community() -> CommunityId {
|
|
CommunityId::from_uuid(Uuid::nil())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use buzz_core::StoredEvent;
|
|
use chrono::Utc;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
|
|
fn make_stored_event(kind: Kind, channel_id: Option<Uuid>) -> StoredEvent {
|
|
let keys = Keys::generate();
|
|
let event = EventBuilder::new(kind, "test")
|
|
.tags([])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
StoredEvent::with_received_at(event, Utc::now(), channel_id, true)
|
|
}
|
|
|
|
fn make_stored_event_with_p(kind: Kind, p: &str, channel_id: Option<Uuid>) -> StoredEvent {
|
|
let keys = Keys::generate();
|
|
let event = EventBuilder::new(kind, "test")
|
|
.tags([Tag::parse(["p", p]).expect("valid p tag")])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
StoredEvent::with_received_at(event, Utc::now(), channel_id, true)
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_register_and_fan_out() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
let sub_id = "sub1".to_string();
|
|
|
|
let filters = vec![Filter::new().kind(Kind::TextNote)];
|
|
registry.register(conn_id, sub_id.clone(), filters, Some(channel_id));
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event);
|
|
assert_eq!(matches.len(), 1);
|
|
assert_eq!(matches[0].0, conn_id);
|
|
assert_eq!(matches[0].1, sub_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_remove() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
let sub_id = "sub1".to_string();
|
|
|
|
let filters = vec![Filter::new().kind(Kind::TextNote)];
|
|
registry.register(conn_id, sub_id.clone(), filters, Some(channel_id));
|
|
|
|
registry.remove_subscription(conn_id, &sub_id);
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event);
|
|
assert!(matches.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_remove_connection() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
registry.register(
|
|
conn_id,
|
|
"sub2".to_string(),
|
|
vec![Filter::new().kind(Kind::Metadata)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
assert_eq!(registry.total_subscriptions(), 2);
|
|
|
|
registry.remove_connection(conn_id);
|
|
|
|
assert_eq!(registry.total_subscriptions(), 0);
|
|
assert_eq!(registry.total_connections(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_channel_kind_index() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
let mut conn_ids = Vec::new();
|
|
for i in 0..3 {
|
|
let conn_id = Uuid::new_v4();
|
|
conn_ids.push(conn_id);
|
|
registry.register(
|
|
conn_id,
|
|
format!("sub{i}"),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
}
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event);
|
|
assert_eq!(matches.len(), 3);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches_meta = registry.fan_out(&event_meta);
|
|
assert!(matches_meta.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_replace_existing() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new().kind(Kind::Metadata)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
let event1 = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches1 = registry.fan_out(&event1);
|
|
assert!(matches1.is_empty());
|
|
|
|
let event0 = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches0 = registry.fan_out(&event0);
|
|
assert_eq!(matches0.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_no_channel_slow_path() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None, // no channel
|
|
);
|
|
|
|
let event = make_stored_event(Kind::TextNote, None);
|
|
let matches = registry.fan_out(&event);
|
|
assert_eq!(matches.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_subscription_registry_get_filters() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let filters = vec![Filter::new().kind(Kind::TextNote)];
|
|
|
|
registry.register(conn_id, "sub1".to_string(), filters.clone(), None);
|
|
|
|
let retrieved = registry.get_filters(conn_id, "sub1");
|
|
assert!(retrieved.is_some());
|
|
assert_eq!(retrieved.unwrap().len(), 1);
|
|
|
|
let missing = registry.get_filters(conn_id, "nonexistent");
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_from_index_targeted_no_full_scan() {
|
|
// Verify that removing a subscription only touches the relevant index keys.
|
|
// We register subs for two different channels and two different kinds,
|
|
// then remove one and confirm the other channel's index is untouched.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let channel_x = Uuid::new_v4();
|
|
let channel_y = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_a,
|
|
"sub_a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_x),
|
|
);
|
|
registry.register(
|
|
conn_b,
|
|
"sub_b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_y),
|
|
);
|
|
|
|
registry.remove_subscription(conn_a, "sub_a");
|
|
|
|
let key_x = IndexKey {
|
|
channel_id: channel_x,
|
|
kind: Kind::TextNote,
|
|
};
|
|
assert!(registry
|
|
.channel_kind_index
|
|
.get(&(test_community(), key_x))
|
|
.is_none());
|
|
|
|
let key_y = IndexKey {
|
|
channel_id: channel_y,
|
|
kind: Kind::TextNote,
|
|
};
|
|
let entries = registry
|
|
.channel_kind_index
|
|
.get(&(test_community(), key_y))
|
|
.expect("channel_y index intact");
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].0, conn_b);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kindless_channel_subscription_receives_all_kinds() {
|
|
// A subscription with channel_id but NO kind filter should receive events
|
|
// of any kind posted to that channel.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
let sub_id = "wildcard_sub".to_string();
|
|
|
|
let filters = vec![Filter::new()]; // kindless — no .kind() constraint
|
|
registry.register(conn_id, sub_id.clone(), filters, Some(channel_id));
|
|
|
|
let event_text = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event_text);
|
|
assert_eq!(matches.len(), 1, "kindless sub should receive TextNote");
|
|
assert_eq!(matches[0].0, conn_id);
|
|
assert_eq!(matches[0].1, sub_id);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert_eq!(matches.len(), 1, "kindless sub should receive Metadata");
|
|
|
|
let event_custom = make_stored_event(Kind::Custom(9999), Some(channel_id));
|
|
let matches = registry.fan_out(&event_custom);
|
|
assert_eq!(matches.len(), 1, "kindless sub should receive custom kind");
|
|
|
|
let other_channel = Uuid::new_v4();
|
|
let event_other = make_stored_event(Kind::TextNote, Some(other_channel));
|
|
let matches = registry.fan_out(&event_other);
|
|
assert!(
|
|
matches.is_empty(),
|
|
"kindless sub should not receive events from other channels"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kindless_subscription_remove_cleans_wildcard_index() {
|
|
// Verify that removing a kindless subscription cleans up the wildcard index.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
let filters = vec![Filter::new()]; // kindless
|
|
registry.register(conn_id, "sub1".to_string(), filters, Some(channel_id));
|
|
|
|
assert!(registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_some());
|
|
|
|
registry.remove_subscription(conn_id, "sub1");
|
|
|
|
assert!(registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_none());
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event);
|
|
assert!(matches.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_kindless_and_kinded_subs_coexist() {
|
|
// Both a kindless sub and a kind-specific sub in the same channel should
|
|
// both receive events of the matching kind.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_wildcard = Uuid::new_v4();
|
|
let conn_kinded = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_wildcard,
|
|
"sub_wildcard".to_string(),
|
|
vec![Filter::new()],
|
|
Some(channel_id),
|
|
);
|
|
|
|
registry.register(
|
|
conn_kinded,
|
|
"sub_kinded".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
let event_text = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event_text);
|
|
assert_eq!(
|
|
matches.len(),
|
|
2,
|
|
"both wildcard and kinded sub should match TextNote"
|
|
);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert_eq!(matches.len(), 1, "only wildcard sub should match Metadata");
|
|
assert_eq!(matches[0].0, conn_wildcard);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kindless_subscription_replace() {
|
|
// Replacing a kindless sub with a kinded sub should move it from wildcard
|
|
// index to kind-specific index, and vice versa.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new()],
|
|
Some(channel_id),
|
|
);
|
|
assert!(registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_some());
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub1".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
assert!(registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_none());
|
|
|
|
let key = IndexKey {
|
|
channel_id,
|
|
kind: Kind::TextNote,
|
|
};
|
|
assert!(registry
|
|
.channel_kind_index
|
|
.get(&(test_community(), key))
|
|
.is_some());
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert!(matches.is_empty());
|
|
|
|
let event_text = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event_text);
|
|
assert_eq!(matches.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_kinds_array_matches_nothing() {
|
|
// NIP-01: `kinds: []` means "match no kinds". A subscription with an
|
|
// explicit empty kinds list should never receive any events — it should
|
|
// NOT be treated as a wildcard (match-all).
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
let filter_empty_kinds = Filter::new().kinds(vec![] as Vec<Kind>);
|
|
registry.register(
|
|
conn_id,
|
|
"sub_empty_kinds".to_string(),
|
|
vec![filter_empty_kinds],
|
|
Some(channel_id),
|
|
);
|
|
|
|
assert!(
|
|
registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_none(),
|
|
"kinds:[] sub must NOT be in the wildcard index"
|
|
);
|
|
|
|
let key = IndexKey {
|
|
channel_id,
|
|
kind: Kind::TextNote,
|
|
};
|
|
assert!(
|
|
registry
|
|
.channel_kind_index
|
|
.get(&(test_community(), key))
|
|
.is_none(),
|
|
"kinds:[] sub must NOT be in the kind-specific index"
|
|
);
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&event);
|
|
assert!(
|
|
matches.is_empty(),
|
|
"kinds:[] sub must not receive any events (got {:?})",
|
|
matches
|
|
);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, Some(channel_id));
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert!(
|
|
matches.is_empty(),
|
|
"kinds:[] sub must not receive Metadata events"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_sub_does_not_receive_channel_events() {
|
|
// Security regression test: a global subscription (channel_id = None) must
|
|
// NOT receive events that are scoped to a channel. Doing so would bypass the
|
|
// channel membership check and leak private channel content.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"global_sub".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None, // global — no channel scope
|
|
);
|
|
|
|
let channel_event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&channel_event);
|
|
assert!(
|
|
matches.is_empty(),
|
|
"global sub must not receive channel-scoped events (got {:?})",
|
|
matches
|
|
);
|
|
|
|
let global_event = make_stored_event(Kind::TextNote, None);
|
|
let matches = registry.fan_out(&global_event);
|
|
assert_eq!(
|
|
matches.len(),
|
|
1,
|
|
"global sub should still receive non-channel events"
|
|
);
|
|
assert_eq!(matches[0].0, conn_id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_kinds_array_remove_is_noop() {
|
|
// Removing a kinds:[] subscription should not panic or corrupt the index.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
let filter_empty_kinds = Filter::new().kinds(vec![] as Vec<Kind>);
|
|
registry.register(
|
|
conn_id,
|
|
"sub_empty".to_string(),
|
|
vec![filter_empty_kinds],
|
|
Some(channel_id),
|
|
);
|
|
|
|
registry.remove_subscription(conn_id, "sub_empty");
|
|
|
|
assert!(registry
|
|
.channel_wildcard_index
|
|
.get(&(test_community(), channel_id))
|
|
.is_none());
|
|
let key = IndexKey {
|
|
channel_id,
|
|
kind: Kind::TextNote,
|
|
};
|
|
assert!(registry
|
|
.channel_kind_index
|
|
.get(&(test_community(), key))
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_channel_subscriptions_only_evicts_target_channel() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let channel_a = Uuid::new_v4();
|
|
let channel_b = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"sub-a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_a),
|
|
);
|
|
registry.register(
|
|
conn_id,
|
|
"sub-b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_b),
|
|
);
|
|
|
|
let removed = registry.remove_channel_subscriptions(conn_id, channel_a);
|
|
assert_eq!(removed, vec!["sub-a".to_string()]);
|
|
|
|
let event_a = make_stored_event(Kind::TextNote, Some(channel_a));
|
|
assert!(registry.fan_out(&event_a).is_empty());
|
|
|
|
let event_b = make_stored_event(Kind::TextNote, Some(channel_b));
|
|
let matches_b = registry.fan_out(&event_b);
|
|
assert_eq!(matches_b.len(), 1);
|
|
assert_eq!(matches_b[0].1, "sub-b");
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_subscriber_conns_dedupes_and_scopes_to_channel() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let conn_other = Uuid::new_v4();
|
|
let channel = Uuid::new_v4();
|
|
let channel_other = Uuid::new_v4();
|
|
|
|
// conn_a: a kinded + a wildcard sub on the channel — must dedupe to one entry.
|
|
registry.register(
|
|
conn_a,
|
|
"kinded".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel),
|
|
);
|
|
registry.register(
|
|
conn_a,
|
|
"wildcard".to_string(),
|
|
vec![Filter::new()],
|
|
Some(channel),
|
|
);
|
|
// conn_b: kinded sub on the channel.
|
|
registry.register(
|
|
conn_b,
|
|
"b".to_string(),
|
|
vec![Filter::new().kind(Kind::Metadata)],
|
|
Some(channel),
|
|
);
|
|
// conn_other: subscribed to a different channel — must be excluded.
|
|
registry.register(
|
|
conn_other,
|
|
"other".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_other),
|
|
);
|
|
|
|
let mut conns = registry.channel_subscriber_conns(channel);
|
|
conns.sort();
|
|
let mut expected = vec![conn_a, conn_b];
|
|
expected.sort();
|
|
assert_eq!(conns, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_evict_all_subscribers_leaves_no_channel_subscriptions() {
|
|
// Models the reaper's evict_all_channel_subscriptions: remove every
|
|
// subscriber conn of a channel, leaving the channel with zero live subs
|
|
// while an unrelated channel is untouched. This is the registry behavior
|
|
// that lets an auto-archived channel stop fanning out without a storm.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let reaped = Uuid::new_v4();
|
|
let survivor = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_a,
|
|
"a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(reaped),
|
|
);
|
|
registry.register(
|
|
conn_b,
|
|
"b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(reaped),
|
|
);
|
|
registry.register(
|
|
conn_a,
|
|
"keep".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(survivor),
|
|
);
|
|
|
|
for conn_id in registry.channel_subscriber_conns(reaped) {
|
|
registry.remove_channel_subscriptions(conn_id, reaped);
|
|
}
|
|
|
|
assert!(registry.channel_subscriber_conns(reaped).is_empty());
|
|
assert!(registry
|
|
.fan_out(&make_stored_event(Kind::TextNote, Some(reaped)))
|
|
.is_empty());
|
|
// Unrelated channel still fans out.
|
|
let survivor_matches = registry.fan_out(&make_stored_event(Kind::TextNote, Some(survivor)));
|
|
assert_eq!(survivor_matches.len(), 1);
|
|
assert_eq!(survivor_matches[0].1, "keep");
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_kind_index_fan_out() {
|
|
// Global subscriptions with explicit kinds should use the global_kind_index
|
|
// for sub-linear fan-out instead of scanning all subs.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_a,
|
|
"global_text".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None,
|
|
);
|
|
registry.register(
|
|
conn_b,
|
|
"global_meta".to_string(),
|
|
vec![Filter::new().kind(Kind::Metadata)],
|
|
None,
|
|
);
|
|
|
|
let event_text = make_stored_event(Kind::TextNote, None);
|
|
let matches = registry.fan_out(&event_text);
|
|
assert_eq!(matches.len(), 1);
|
|
assert_eq!(matches[0].0, conn_a);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, None);
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert_eq!(matches.len(), 1);
|
|
assert_eq!(matches[0].0, conn_b);
|
|
|
|
// Unrelated kind matches nobody.
|
|
let event_custom = make_stored_event(Kind::Custom(9999), None);
|
|
assert!(registry.fan_out(&event_custom).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_p_kind_index_fan_out_targets_matching_p() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let kind = Kind::Custom(buzz_core::kind::KIND_AGENT_OBSERVER_FRAME as u16);
|
|
let p_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let p_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
registry.register(
|
|
conn_a,
|
|
"observer_a".to_string(),
|
|
vec![Filter::new().kind(kind).custom_tags(p_tag(), [p_a])],
|
|
None,
|
|
);
|
|
registry.register(
|
|
conn_b,
|
|
"observer_b".to_string(),
|
|
vec![Filter::new().kind(kind).custom_tags(p_tag(), [p_b])],
|
|
None,
|
|
);
|
|
|
|
assert!(
|
|
registry
|
|
.global_kind_index
|
|
.get(&(test_community(), kind))
|
|
.is_none(),
|
|
"fully p-constrained global subscriptions should use the p-kind index"
|
|
);
|
|
|
|
let event = make_stored_event_with_p(kind, p_a, None);
|
|
let matches = registry.fan_out(&event);
|
|
assert_eq!(matches, vec![(conn_a, "observer_a".to_string())]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_p_kind_index_removal_cleanup() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
let kind = Kind::Custom(buzz_core::kind::KIND_AGENT_OBSERVER_FRAME as u16);
|
|
let p = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let filter = Filter::new().kind(kind).custom_tags(p_tag(), [p]);
|
|
let key = GlobalPKindIndexKey {
|
|
community_id: test_community(),
|
|
kind,
|
|
p: p.to_string(),
|
|
};
|
|
|
|
registry.register(conn_id, "observer".to_string(), vec![filter], None);
|
|
assert!(registry.global_p_kind_index.get(&key).is_some());
|
|
|
|
registry.remove_subscription(conn_id, "observer");
|
|
assert!(registry.global_p_kind_index.get(&key).is_none());
|
|
|
|
let event = make_stored_event_with_p(kind, p, None);
|
|
assert!(registry.fan_out(&event).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_wildcard_index_fan_out() {
|
|
// A global subscription with no kind filter should receive all global events.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_id,
|
|
"global_wildcard".to_string(),
|
|
vec![Filter::new()], // kindless
|
|
None,
|
|
);
|
|
|
|
let event_text = make_stored_event(Kind::TextNote, None);
|
|
let matches = registry.fan_out(&event_text);
|
|
assert_eq!(matches.len(), 1);
|
|
|
|
let event_meta = make_stored_event(Kind::Metadata, None);
|
|
let matches = registry.fan_out(&event_meta);
|
|
assert_eq!(matches.len(), 1);
|
|
|
|
// Must NOT receive channel-scoped events.
|
|
let channel_event = make_stored_event(Kind::TextNote, Some(Uuid::new_v4()));
|
|
assert!(registry.fan_out(&channel_event).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_index_removal_cleanup() {
|
|
// Removing a global subscription should clean up the global indexes.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_id = Uuid::new_v4();
|
|
|
|
// Kind-specific global sub.
|
|
registry.register(
|
|
conn_id,
|
|
"g1".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None,
|
|
);
|
|
assert!(registry
|
|
.global_kind_index
|
|
.get(&(test_community(), Kind::TextNote))
|
|
.is_some());
|
|
|
|
registry.remove_subscription(conn_id, "g1");
|
|
assert!(registry
|
|
.global_kind_index
|
|
.get(&(test_community(), Kind::TextNote))
|
|
.is_none());
|
|
|
|
// Wildcard global sub.
|
|
registry.register(conn_id, "g2".to_string(), vec![Filter::new()], None);
|
|
assert!(registry
|
|
.global_wildcard_index
|
|
.get(&test_community())
|
|
.is_some());
|
|
|
|
registry.remove_subscription(conn_id, "g2");
|
|
assert!(registry
|
|
.global_wildcard_index
|
|
.get(&test_community())
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_and_channel_subs_are_isolated() {
|
|
// Global subs must not see channel events; channel subs must not see global events.
|
|
// This tests the invariant with the new global index in place.
|
|
let registry = SubscriptionRegistry::new();
|
|
let conn_global = Uuid::new_v4();
|
|
let conn_channel = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
|
|
registry.register(
|
|
conn_global,
|
|
"global".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None,
|
|
);
|
|
registry.register(
|
|
conn_channel,
|
|
"channel".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
let global_event = make_stored_event(Kind::TextNote, None);
|
|
let matches = registry.fan_out(&global_event);
|
|
assert_eq!(matches.len(), 1);
|
|
assert_eq!(
|
|
matches[0].0, conn_global,
|
|
"only global sub sees global event"
|
|
);
|
|
|
|
let channel_event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
let matches = registry.fan_out(&channel_event);
|
|
assert_eq!(matches.len(), 1);
|
|
assert_eq!(
|
|
matches[0].0, conn_channel,
|
|
"only channel sub sees channel event"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_registry_does_not_cross_fanout_same_channel_and_kind() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let community_a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
|
let community_b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb));
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let channel_id = Uuid::from_u128(0xcccc);
|
|
|
|
registry.register_scoped(
|
|
community_a,
|
|
conn_a,
|
|
"a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
registry.register_scoped(
|
|
community_b,
|
|
conn_b,
|
|
"b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
assert_eq!(
|
|
registry.fan_out_scoped(community_a, &event),
|
|
vec![(conn_a, "a".to_string())]
|
|
);
|
|
assert_eq!(
|
|
registry.fan_out_scoped(community_b, &event),
|
|
vec![(conn_b, "b".to_string())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_global_registry_does_not_cross_fanout_same_kind() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let community_a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
|
let community_b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb));
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
|
|
registry.register_scoped(
|
|
community_a,
|
|
conn_a,
|
|
"a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None,
|
|
);
|
|
registry.register_scoped(
|
|
community_b,
|
|
conn_b,
|
|
"b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
None,
|
|
);
|
|
|
|
let event = make_stored_event(Kind::TextNote, None);
|
|
assert_eq!(
|
|
registry.fan_out_scoped(community_a, &event),
|
|
vec![(conn_a, "a".to_string())]
|
|
);
|
|
assert_eq!(
|
|
registry.fan_out_scoped(community_b, &event),
|
|
vec![(conn_b, "b".to_string())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_remove_channel_subscriptions_keeps_same_channel_in_other_community() {
|
|
let registry = SubscriptionRegistry::new();
|
|
let community_a = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
|
let community_b = CommunityId::from_uuid(Uuid::from_u128(0xbbbb));
|
|
let conn_a = Uuid::new_v4();
|
|
let conn_b = Uuid::new_v4();
|
|
let channel_id = Uuid::from_u128(0xcccc);
|
|
|
|
registry.register_scoped(
|
|
community_a,
|
|
conn_a,
|
|
"a".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
registry.register_scoped(
|
|
community_b,
|
|
conn_b,
|
|
"b".to_string(),
|
|
vec![Filter::new().kind(Kind::TextNote)],
|
|
Some(channel_id),
|
|
);
|
|
|
|
let removed = registry.remove_channel_subscriptions_scoped(community_a, conn_a, channel_id);
|
|
assert_eq!(removed.len(), 1);
|
|
|
|
let event = make_stored_event(Kind::TextNote, Some(channel_id));
|
|
assert!(registry.fan_out_scoped(community_a, &event).is_empty());
|
|
assert_eq!(
|
|
registry.fan_out_scoped(community_b, &event),
|
|
vec![(conn_b, "b".to_string())]
|
|
);
|
|
}
|
|
}
|