mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
perf(relay): route author- and #p-scoped global subscriptions by selector (#8666)
This commit is contained in:
4
Justfile
4
Justfile
@@ -545,8 +545,10 @@ test-unit:
|
||||
# All four modules are infra-free; config parses env into a struct and
|
||||
# never connects.
|
||||
# `^config::` is anchored so it does not also select nip_fi_config.
|
||||
# subscription::tests covers the in-memory registry and its fan-out
|
||||
# indexes; it is infra-free.
|
||||
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
|
||||
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
-E 'test(/^subscription::tests::/) + test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::coalesced_refresh::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^nip_fi_resolver::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::profiles::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^mesh_boot::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::accessible_channel_invalidation_is_scoped_to_community) + test(=state::tests::accessible_channels_join_member_and_open_halves) + test(=state::tests::channel_deleted_invalidation_is_scoped_to_community) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::failed_search_closes_without_eose_or_trying_later_filters) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + test(=api::invites::postgres_tests::restriction_tests::postgres_tests::destructive_fixture_rejects_shared_database_urls) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
|
||||
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
|
||||
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Subscription registry with active WebSocket indexes for targeted fan-out.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::Hash;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nostr::{Alphabet, Filter, Kind, SingleLetterTag};
|
||||
use nostr::{Alphabet, Filter, Kind, PublicKey, SingleLetterTag};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_core::{filter::filters_match, CommunityId, StoredEvent};
|
||||
@@ -56,11 +57,22 @@ pub struct IndexKey {
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
/// A value that every filter of a global subscription is constrained to, so
|
||||
/// fan-out only visits subscriptions naming the event's own value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct GlobalPKindIndexKey {
|
||||
enum GlobalSelector {
|
||||
/// A `#p` recipient.
|
||||
P(String),
|
||||
/// An event author.
|
||||
Author(PublicKey),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct GlobalSelectorIndexKey {
|
||||
community_id: CommunityId,
|
||||
kind: Kind,
|
||||
p: String,
|
||||
/// `None` for a kindless filter, which matches the selector at any kind.
|
||||
kind: Option<Kind>,
|
||||
selector: GlobalSelector,
|
||||
}
|
||||
|
||||
/// A removed subscription's server-resolved routing scope.
|
||||
@@ -93,8 +105,8 @@ pub struct SubscriptionRegistry {
|
||||
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 indexed by kind and a `#p` recipient or author.
|
||||
global_selector_index: DashMap<GlobalSelectorIndexKey, Vec<(ConnId, SubId)>>,
|
||||
/// Global subscriptions with no kind filter — wildcard, receives all global events.
|
||||
global_wildcard_index: DashMap<CommunityId, Vec<(ConnId, SubId)>>,
|
||||
}
|
||||
@@ -188,12 +200,12 @@ impl SubscriptionRegistry {
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
// Global subscription. Fully p- or author-constrained filters, even
|
||||
// kindless ones, use the narrower selector index; broader filters
|
||||
// stay on the generic kind/wildcard indexes.
|
||||
if let Some(keys) = extract_global_selector_index_keys(community_id, &filters) {
|
||||
for key in keys {
|
||||
self.global_p_kind_index
|
||||
self.global_selector_index
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.push((conn_id, sub_id.clone()));
|
||||
@@ -426,14 +438,18 @@ impl SubscriptionRegistry {
|
||||
// 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,
|
||||
};
|
||||
let selector_keys = event_global_selectors(event)
|
||||
.into_iter()
|
||||
.flat_map(|selector| {
|
||||
[Some(event.event.kind), None].map(|kind| GlobalSelectorIndexKey {
|
||||
community_id,
|
||||
kind,
|
||||
selector: selector.clone(),
|
||||
})
|
||||
});
|
||||
for key in selector_keys {
|
||||
if let Some(candidates) = self
|
||||
.global_p_kind_index
|
||||
.global_selector_index
|
||||
.get(&key)
|
||||
.map(|entry| entry.value().clone())
|
||||
{
|
||||
@@ -574,79 +590,55 @@ impl SubscriptionRegistry {
|
||||
let ch_id = *ch_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => remove_index_entry(
|
||||
&self.channel_wildcard_index,
|
||||
&(community_id, ch_id),
|
||||
conn_id,
|
||||
sub_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));
|
||||
}
|
||||
}
|
||||
remove_index_entry(
|
||||
&self.channel_kind_index,
|
||||
&(community_id, key),
|
||||
conn_id,
|
||||
sub_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
if let Some(keys) = extract_global_selector_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);
|
||||
}
|
||||
}
|
||||
remove_index_entry(&self.global_selector_index, &key, conn_id, sub_id);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => remove_index_entry(
|
||||
&self.global_wildcard_index,
|
||||
&community_id,
|
||||
conn_id,
|
||||
sub_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));
|
||||
}
|
||||
}
|
||||
remove_index_entry(
|
||||
&self.global_kind_index,
|
||||
&(community_id, kind),
|
||||
conn_id,
|
||||
sub_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,35 +647,104 @@ impl SubscriptionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes one subscription from an index bucket, then drops the bucket if it
|
||||
/// is still empty. The bucket guard is released between the two steps, and a
|
||||
/// registration from another connection can refill the bucket in that gap, so
|
||||
/// the drop re-checks emptiness under the shard lock.
|
||||
fn remove_index_entry<K: Eq + Hash>(
|
||||
index: &DashMap<K, Vec<(ConnId, SubId)>>,
|
||||
key: &K,
|
||||
conn_id: ConnId,
|
||||
sub_id: &str,
|
||||
) {
|
||||
let emptied = match index.get_mut(key) {
|
||||
Some(mut entries) => {
|
||||
entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id));
|
||||
entries.is_empty()
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
if emptied {
|
||||
#[cfg(test)]
|
||||
test_hooks::before_empty_bucket_drop();
|
||||
index.remove_if(key, |_, entries| entries.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only hook into the gap between emptying an index bucket and dropping it.
|
||||
#[cfg(test)]
|
||||
mod test_hooks {
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static BEFORE_EMPTY_BUCKET_DROP: RefCell<Option<Box<dyn FnOnce()>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `hook` once, on this thread, the next time a bucket is emptied.
|
||||
pub(super) fn arm_before_empty_bucket_drop(hook: impl FnOnce() + 'static) {
|
||||
BEFORE_EMPTY_BUCKET_DROP.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
|
||||
}
|
||||
|
||||
pub(super) fn before_empty_bucket_drop() {
|
||||
if let Some(hook) = BEFORE_EMPTY_BUCKET_DROP.with(|slot| slot.borrow_mut().take()) {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn p_tag() -> SingleLetterTag {
|
||||
SingleLetterTag::lowercase(Alphabet::P)
|
||||
}
|
||||
|
||||
fn extract_global_p_kind_index_keys(
|
||||
/// Most selector-index keys one global subscription may occupy. Keys are the
|
||||
/// product of a filter's kinds and its `#p` values or authors, so a large
|
||||
/// filter would otherwise allocate millions of them; past this budget the
|
||||
/// subscription stays on the kind/wildcard indexes instead.
|
||||
const MAX_GLOBAL_SELECTOR_KEYS: usize = 4096;
|
||||
|
||||
/// Index keys for a global subscription whose every filter is constrained to
|
||||
/// `#p` recipients or to authors, with or without kinds, within
|
||||
/// [`MAX_GLOBAL_SELECTOR_KEYS`]. Each filter is keyed by its `#p` values when
|
||||
/// it names them, else by its authors. `None` means it needs the broader
|
||||
/// kind/wildcard indexes.
|
||||
fn extract_global_selector_index_keys(
|
||||
community_id: CommunityId,
|
||||
filters: &[Filter],
|
||||
) -> Option<Vec<GlobalPKindIndexKey>> {
|
||||
) -> Option<Vec<GlobalSelectorIndexKey>> {
|
||||
let p_tag = p_tag();
|
||||
let mut seen = HashSet::new();
|
||||
let mut keys = Vec::new();
|
||||
let p_tag = p_tag();
|
||||
let mut budget = MAX_GLOBAL_SELECTOR_KEYS;
|
||||
|
||||
for filter in filters {
|
||||
let kinds = filter.kinds.as_ref()?;
|
||||
if kinds.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let kinds: Vec<Option<Kind>> = match &filter.kinds {
|
||||
Some(kinds) if kinds.is_empty() => continue,
|
||||
Some(kinds) => kinds.iter().copied().map(Some).collect(),
|
||||
None => vec![None],
|
||||
};
|
||||
|
||||
let p_values = filter.generic_tags.get(&p_tag)?;
|
||||
if p_values.is_empty() {
|
||||
let selectors: Vec<GlobalSelector> =
|
||||
match (filter.generic_tags.get(&p_tag), &filter.authors) {
|
||||
(Some(values), _) => values.iter().cloned().map(GlobalSelector::P).collect(),
|
||||
(None, Some(authors)) => authors
|
||||
.iter()
|
||||
.copied()
|
||||
.map(GlobalSelector::Author)
|
||||
.collect(),
|
||||
(None, None) => return None,
|
||||
};
|
||||
if selectors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
budget = budget.checked_sub(kinds.len().saturating_mul(selectors.len()))?;
|
||||
|
||||
for kind in kinds {
|
||||
for p in p_values {
|
||||
let key = GlobalPKindIndexKey {
|
||||
for selector in &selectors {
|
||||
let key = GlobalSelectorIndexKey {
|
||||
community_id,
|
||||
kind: *kind,
|
||||
p: p.clone(),
|
||||
kind,
|
||||
selector: selector.clone(),
|
||||
};
|
||||
if seen.insert(key.clone()) {
|
||||
keys.push(key);
|
||||
@@ -695,9 +756,13 @@ fn extract_global_p_kind_index_keys(
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
fn event_p_tag_values(event: &StoredEvent) -> Vec<String> {
|
||||
/// The selectors a global event can be routed by: each distinct `#p` value
|
||||
/// and its author.
|
||||
fn event_global_selectors(event: &StoredEvent) -> Vec<GlobalSelector> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut values = Vec::new();
|
||||
let mut selectors = Vec::new();
|
||||
// Must read `p` tags exactly as `buzz_core::filter::filters_match` does, or
|
||||
// fan-out silently skips subscriptions that match.
|
||||
for tag in event.event.tags.iter() {
|
||||
if tag.kind().to_string() != "p" {
|
||||
continue;
|
||||
@@ -705,11 +770,12 @@ fn event_p_tag_values(event: &StoredEvent) -> Vec<String> {
|
||||
if let Some(value) = tag.content() {
|
||||
let value = value.to_string();
|
||||
if seen.insert(value.clone()) {
|
||||
values.push(value);
|
||||
selectors.push(GlobalSelector::P(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
values
|
||||
selectors.push(GlobalSelector::Author(event.event.pubkey));
|
||||
selectors
|
||||
}
|
||||
|
||||
/// Returns the union of all `kinds` across filters, or `None` if any filter
|
||||
@@ -1571,7 +1637,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_p_kind_index_fan_out_targets_matching_p() {
|
||||
fn test_global_selector_index_fan_out_targets_matching_p() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let conn_a = Uuid::new_v4();
|
||||
let conn_b = Uuid::new_v4();
|
||||
@@ -1597,7 +1663,7 @@ mod tests {
|
||||
.global_kind_index
|
||||
.get(&(test_community(), kind))
|
||||
.is_none(),
|
||||
"fully p-constrained global subscriptions should use the p-kind index"
|
||||
"fully p-constrained global subscriptions should use the selector index"
|
||||
);
|
||||
|
||||
let event = make_stored_event_with_p(kind, p_a, None);
|
||||
@@ -1606,28 +1672,519 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_p_kind_index_removal_cleanup() {
|
||||
fn test_global_selector_index_p_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 {
|
||||
let key = GlobalSelectorIndexKey {
|
||||
community_id: test_community(),
|
||||
kind,
|
||||
p: p.to_string(),
|
||||
kind: Some(kind),
|
||||
selector: GlobalSelector::P(p.to_string()),
|
||||
};
|
||||
|
||||
registry.register(conn_id, "observer".to_string(), vec![filter], None);
|
||||
assert!(registry.global_p_kind_index.get(&key).is_some());
|
||||
assert!(registry.global_selector_index.get(&key).is_some());
|
||||
|
||||
registry.remove_subscription(conn_id, "observer");
|
||||
assert!(registry.global_p_kind_index.get(&key).is_none());
|
||||
assert!(registry.global_selector_index.get(&key).is_none());
|
||||
|
||||
let event = make_stored_event_with_p(kind, p, None);
|
||||
assert!(registry.fan_out(&event).is_empty());
|
||||
}
|
||||
|
||||
fn make_stored_event_by(keys: &Keys, kind: Kind) -> StoredEvent {
|
||||
let event = EventBuilder::new(kind, "online")
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign");
|
||||
StoredEvent::with_received_at(event, Utc::now(), None, true)
|
||||
}
|
||||
|
||||
fn author_key(kind: Kind, author: &Keys) -> GlobalSelectorIndexKey {
|
||||
GlobalSelectorIndexKey {
|
||||
community_id: test_community(),
|
||||
kind: Some(kind),
|
||||
selector: GlobalSelector::Author(author.public_key()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_selector_index_fan_out_targets_matching_author() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let carol = Keys::generate();
|
||||
let watches_alice = Uuid::new_v4();
|
||||
let watches_bob = Uuid::new_v4();
|
||||
|
||||
registry.register(
|
||||
watches_alice,
|
||||
"presence".to_string(),
|
||||
vec![Filter::new()
|
||||
.kind(presence)
|
||||
.authors([alice.public_key(), carol.public_key()])],
|
||||
None,
|
||||
);
|
||||
registry.register(
|
||||
watches_bob,
|
||||
"presence".to_string(),
|
||||
vec![Filter::new().kind(presence).author(bob.public_key())],
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
registry
|
||||
.global_kind_index
|
||||
.get(&(test_community(), presence))
|
||||
.is_none(),
|
||||
"fully author-constrained global subscriptions should use the selector index"
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&alice, presence)),
|
||||
vec![(watches_alice, "presence".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&bob, presence)),
|
||||
vec![(watches_bob, "presence".to_string())]
|
||||
);
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&Keys::generate(), presence))
|
||||
.is_empty());
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&alice, Kind::TextNote))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_selector_index_author_removal_paths() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let conn_id = Uuid::new_v4();
|
||||
let alice_filter = vec![Filter::new().kind(presence).author(alice.public_key())];
|
||||
|
||||
// CLOSE.
|
||||
registry.register(conn_id, "presence".to_string(), alice_filter.clone(), None);
|
||||
assert!(registry
|
||||
.global_selector_index
|
||||
.get(&author_key(presence, &alice))
|
||||
.is_some());
|
||||
registry.remove_subscription(conn_id, "presence");
|
||||
assert!(registry
|
||||
.global_selector_index
|
||||
.get(&author_key(presence, &alice))
|
||||
.is_none());
|
||||
|
||||
// Same-ID replacement moves the subscription to the new author.
|
||||
registry.register(conn_id, "presence".to_string(), alice_filter, None);
|
||||
registry.register(
|
||||
conn_id,
|
||||
"presence".to_string(),
|
||||
vec![Filter::new().kind(presence).author(bob.public_key())],
|
||||
None,
|
||||
);
|
||||
assert!(registry
|
||||
.global_selector_index
|
||||
.get(&author_key(presence, &alice))
|
||||
.is_none());
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&alice, presence))
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&bob, presence)),
|
||||
vec![(conn_id, "presence".to_string())]
|
||||
);
|
||||
|
||||
// Disconnect.
|
||||
registry.remove_connection(conn_id);
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&bob, presence))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_cleanup_keeps_a_registration_from_another_connection() {
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let channel_id = Uuid::new_v4();
|
||||
// (case, filter, channel scope, an event the filter routes)
|
||||
let cases = [
|
||||
(
|
||||
"author selector",
|
||||
Filter::new().kind(presence).author(alice.public_key()),
|
||||
None,
|
||||
make_stored_event_by(&alice, presence),
|
||||
),
|
||||
(
|
||||
"global kind",
|
||||
Filter::new().kind(Kind::TextNote),
|
||||
None,
|
||||
make_stored_event(Kind::TextNote, None),
|
||||
),
|
||||
(
|
||||
"global wildcard",
|
||||
Filter::new(),
|
||||
None,
|
||||
make_stored_event(Kind::TextNote, None),
|
||||
),
|
||||
(
|
||||
"channel kind",
|
||||
Filter::new().kind(Kind::TextNote),
|
||||
Some(channel_id),
|
||||
make_stored_event(Kind::TextNote, Some(channel_id)),
|
||||
),
|
||||
(
|
||||
"channel wildcard",
|
||||
Filter::new(),
|
||||
Some(channel_id),
|
||||
make_stored_event(Kind::TextNote, Some(channel_id)),
|
||||
),
|
||||
];
|
||||
|
||||
for (case, filter, channel, event) in cases {
|
||||
let registry = Arc::new(SubscriptionRegistry::new());
|
||||
let leaving = Uuid::new_v4();
|
||||
let joining = Uuid::new_v4();
|
||||
registry.register(leaving, "s".to_string(), vec![filter.clone()], channel);
|
||||
|
||||
// The last subscriber leaves; another connection subscribes to the
|
||||
// same bucket after it is emptied and before it is dropped.
|
||||
let joined = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
{
|
||||
let registry = Arc::clone(®istry);
|
||||
let joined = Arc::clone(&joined);
|
||||
test_hooks::arm_before_empty_bucket_drop(move || {
|
||||
registry.register(joining, "s".to_string(), vec![filter], channel);
|
||||
joined.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
registry.remove_connection(leaving);
|
||||
|
||||
assert!(
|
||||
joined.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"{case}: hook did not run"
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&event),
|
||||
vec![(joining, "s".to_string())],
|
||||
"{case}: cleanup dropped the new subscriber's bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_selector_index_falls_back_past_the_key_budget() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let kinds = [Kind::Custom(20001), Kind::Custom(20002)];
|
||||
let authors: Vec<Keys> = (0..MAX_GLOBAL_SELECTOR_KEYS)
|
||||
.map(|_| Keys::generate())
|
||||
.collect();
|
||||
let pubkeys: Vec<PublicKey> = authors.iter().map(Keys::public_key).collect();
|
||||
// One past budget across both kinds.
|
||||
let over = MAX_GLOBAL_SELECTOR_KEYS / kinds.len() + 1;
|
||||
let p_values: Vec<String> = pubkeys[..over].iter().map(PublicKey::to_hex).collect();
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
// Exactly at budget: one kind per author key.
|
||||
registry.register(
|
||||
conn_id,
|
||||
"at".to_string(),
|
||||
vec![Filter::new()
|
||||
.kind(kinds[0])
|
||||
.authors(pubkeys.iter().copied())],
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
registry.global_selector_index.len(),
|
||||
MAX_GLOBAL_SELECTOR_KEYS
|
||||
);
|
||||
registry.remove_subscription(conn_id, "at");
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
|
||||
// One past budget, by authors and by `#p`: the generic kind index
|
||||
// carries them and still routes every author or recipient.
|
||||
for filter in [
|
||||
Filter::new()
|
||||
.kinds(kinds)
|
||||
.authors(pubkeys[..over].iter().copied()),
|
||||
Filter::new()
|
||||
.kinds(kinds)
|
||||
.custom_tags(p_tag(), p_values.iter().cloned()),
|
||||
] {
|
||||
registry.register(conn_id, "over".to_string(), vec![filter], None);
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
assert_eq!(registry.global_kind_index.len(), kinds.len());
|
||||
registry.remove_connection(conn_id);
|
||||
assert!(registry.global_kind_index.is_empty());
|
||||
}
|
||||
|
||||
registry.register(
|
||||
conn_id,
|
||||
"over".to_string(),
|
||||
vec![Filter::new()
|
||||
.kinds(kinds)
|
||||
.authors(pubkeys[..over].iter().copied())],
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&authors[over - 1], kinds[1])),
|
||||
vec![(conn_id, "over".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_selector_index_falls_back_unless_every_filter_is_constrained() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
// One author-constrained filter plus one kind-only filter: the
|
||||
// kind-only filter must still see every author's events.
|
||||
registry.register(
|
||||
conn_id,
|
||||
"mixed".to_string(),
|
||||
vec![
|
||||
Filter::new().kind(presence).author(alice.public_key()),
|
||||
Filter::new().kind(presence),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&Keys::generate(), presence)),
|
||||
vec![(conn_id, "mixed".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_selector_index_prefers_p_when_filter_names_both() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let kind = Kind::Custom(buzz_core::kind::KIND_AGENT_OBSERVER_FRAME as u16);
|
||||
let author = Keys::generate();
|
||||
let p = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
registry.register(
|
||||
conn_id,
|
||||
"both".to_string(),
|
||||
vec![Filter::new()
|
||||
.kind(kind)
|
||||
.author(author.public_key())
|
||||
.custom_tags(p_tag(), [p])],
|
||||
None,
|
||||
);
|
||||
assert!(registry
|
||||
.global_selector_index
|
||||
.get(&author_key(kind, &author))
|
||||
.is_none());
|
||||
|
||||
let event = EventBuilder::new(kind, "frame")
|
||||
.tags([Tag::parse(["p", p]).expect("valid p tag")])
|
||||
.sign_with_keys(&author)
|
||||
.expect("sign");
|
||||
let event = StoredEvent::with_received_at(event, Utc::now(), None, true);
|
||||
assert_eq!(
|
||||
registry.fan_out(&event),
|
||||
vec![(conn_id, "both".to_string())]
|
||||
);
|
||||
// The author alone is not enough: the filter also requires the `#p`.
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&author, kind))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// A set whose filters each name `#p` or authors routes by selector, each
|
||||
/// filter by its own; a `kinds: []` filter beside them matches nothing
|
||||
/// and needs no key.
|
||||
#[test]
|
||||
fn test_global_selector_index_routes_mixed_p_and_author_filters() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let p = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
registry.register(
|
||||
conn_id,
|
||||
"mixed".to_string(),
|
||||
vec![
|
||||
Filter::new().custom_tags(p_tag(), [p]),
|
||||
Filter::new().kind(presence).author(alice.public_key()),
|
||||
Filter::new().kinds(Vec::<Kind>::new()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(registry.global_kind_index.is_empty());
|
||||
assert!(registry
|
||||
.global_wildcard_index
|
||||
.get(&test_community())
|
||||
.is_none());
|
||||
assert_eq!(registry.global_selector_index.len(), 2);
|
||||
|
||||
let delivered = vec![(conn_id, "mixed".to_string())];
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_with_p(Kind::TextNote, p, None)),
|
||||
delivered
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&alice, presence)),
|
||||
delivered
|
||||
);
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&alice, Kind::TextNote))
|
||||
.is_empty());
|
||||
|
||||
registry.remove_subscription(conn_id, "mixed");
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
}
|
||||
|
||||
/// `None`, an empty list, or a random subset of `pool`.
|
||||
fn random_constraint<T: Clone>(
|
||||
next: &mut impl FnMut(usize) -> usize,
|
||||
pool: &[T],
|
||||
) -> Option<Vec<T>> {
|
||||
match next(4) {
|
||||
0 => None,
|
||||
1 => Some(Vec::new()),
|
||||
_ => Some(pool.iter().filter(|_| next(2) == 0).cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed global fan-out delivers to exactly the subscriptions a full
|
||||
/// `filters_match` scan selects, once each, across random filter sets,
|
||||
/// `p`-tag shapes and connections holding several subscriptions.
|
||||
#[test]
|
||||
fn global_fan_out_matches_a_full_filter_scan() {
|
||||
// xorshift64*, seeded, so a failing case reproduces.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
let mut next = move |n: usize| -> usize {
|
||||
state ^= state >> 12;
|
||||
state ^= state << 25;
|
||||
state ^= state >> 27;
|
||||
(state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 33) as usize % n
|
||||
};
|
||||
let kinds = [
|
||||
Kind::TextNote,
|
||||
Kind::Reaction,
|
||||
Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16),
|
||||
];
|
||||
let authors: Vec<Keys> = (0..3).map(|_| Keys::generate()).collect();
|
||||
let author_keys: Vec<PublicKey> = authors.iter().map(Keys::public_key).collect();
|
||||
let p_values = ["a".repeat(64), "b".repeat(64), "not-hex".to_string()];
|
||||
let conn_ids: Vec<ConnId> = (0..8).map(|_| Uuid::new_v4()).collect();
|
||||
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let mut subscriptions = Vec::new();
|
||||
for i in 0..60 {
|
||||
let filters: Vec<Filter> = (0..=next(3))
|
||||
.map(|_| {
|
||||
let mut filter = Filter::new();
|
||||
if let Some(kinds) = random_constraint(&mut next, &kinds) {
|
||||
filter = filter.kinds(kinds);
|
||||
}
|
||||
if let Some(authors) = random_constraint(&mut next, &author_keys) {
|
||||
filter = filter.authors(authors);
|
||||
}
|
||||
if let Some(values) = random_constraint(&mut next, &p_values) {
|
||||
filter = filter.custom_tags(p_tag(), values);
|
||||
}
|
||||
filter
|
||||
})
|
||||
.collect();
|
||||
let (conn_id, sub_id) = (conn_ids[next(conn_ids.len())], format!("sub-{i}"));
|
||||
registry.register(conn_id, sub_id.clone(), filters.clone(), None);
|
||||
subscriptions.push((conn_id, sub_id, filters));
|
||||
}
|
||||
assert!(!registry.global_selector_index.is_empty());
|
||||
|
||||
for _ in 0..2_000 {
|
||||
let tags: Vec<Tag> = (0..next(4))
|
||||
.map(|_| {
|
||||
let value = p_values[next(p_values.len())].as_str();
|
||||
let parts = match next(5) {
|
||||
0 => vec!["p", value],
|
||||
1 => vec!["p", value, "wss://relay.example"],
|
||||
2 => vec!["P", value],
|
||||
3 => vec!["p"],
|
||||
_ => vec!["e", value],
|
||||
};
|
||||
Tag::parse(parts).expect("valid tag")
|
||||
})
|
||||
.collect();
|
||||
let event = EventBuilder::new(kinds[next(kinds.len())], "x")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&authors[next(authors.len())])
|
||||
.expect("sign");
|
||||
let event = StoredEvent::with_received_at(event, Utc::now(), None, true);
|
||||
|
||||
let expected: HashSet<(ConnId, SubId)> = subscriptions
|
||||
.iter()
|
||||
.filter(|(_, _, filters)| filters_match(filters, &event))
|
||||
.map(|(conn_id, sub_id, _)| (*conn_id, sub_id.clone()))
|
||||
.collect();
|
||||
let delivered = registry.fan_out(&event);
|
||||
let delivered_set: HashSet<_> = delivered.iter().cloned().collect();
|
||||
assert_eq!(delivered.len(), delivered_set.len(), "duplicate delivery");
|
||||
assert_eq!(
|
||||
delivered_set, expected,
|
||||
"kind {:?}, tags {:?}",
|
||||
event.event.kind, event.event.tags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_selector_index_kindless_filters_skip_wildcard_scan() {
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let presence = Kind::Custom(buzz_core::kind::KIND_PRESENCE_UPDATE as u16);
|
||||
let alice = Keys::generate();
|
||||
let p = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let mentions = Uuid::new_v4();
|
||||
let follows = Uuid::new_v4();
|
||||
|
||||
registry.register(
|
||||
mentions,
|
||||
"mentions".to_string(),
|
||||
vec![Filter::new().custom_tags(p_tag(), [p])],
|
||||
None,
|
||||
);
|
||||
registry.register(
|
||||
follows,
|
||||
"follows".to_string(),
|
||||
vec![Filter::new().author(alice.public_key())],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.global_wildcard_index
|
||||
.get(&test_community())
|
||||
.is_none(),
|
||||
"kindless p- or author-constrained subscriptions should use the selector index"
|
||||
);
|
||||
|
||||
// Any kind reaches the matching selector, and only it.
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_with_p(Kind::TextNote, p, None)),
|
||||
vec![(mentions, "mentions".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
registry.fan_out(&make_stored_event_by(&alice, presence)),
|
||||
vec![(follows, "follows".to_string())]
|
||||
);
|
||||
assert!(registry
|
||||
.fan_out(&make_stored_event_by(&Keys::generate(), presence))
|
||||
.is_empty());
|
||||
|
||||
registry.remove_connection(mentions);
|
||||
registry.remove_subscription(follows, "follows");
|
||||
assert!(registry.global_selector_index.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_wildcard_index_fan_out() {
|
||||
// A global subscription with no kind filter should receive all global events.
|
||||
|
||||
@@ -250,6 +250,9 @@ run_unit_tests() {
|
||||
run_test_step "buzz-relay mesh boot tests" \
|
||||
cargo test -p buzz-relay --lib mesh_boot::tests:: -- --nocapture
|
||||
|
||||
run_test_step "buzz-relay subscription registry tests" \
|
||||
cargo test -p buzz-relay --lib subscription::tests:: -- --nocapture
|
||||
|
||||
# telemetry and config tests mutate process env. libtest runs one process
|
||||
# with many threads (nextest, used by `just test-unit`, isolates each test),
|
||||
# so run them single-threaded here. `--skip` keeps the substring filter
|
||||
|
||||
Reference in New Issue
Block a user