tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 1915c2e9946d860ccf9ccd405ec6762c21c76c69
parent 8568b59960664a87ae1e15d257f68aa96187b5ca
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 19:15:30 +0000

runtime: add projection filter context and query planning

- add matched-filter context and requested-kind state to projection hooks
- add bounded post-projection query limit planning
- re-match replacement output against original request filters
- cover default, suppression, limit, and live replacement paths

Diffstat:
Mcrates/tangle_runtime/src/relay/core.rs | 44++++++++++++++++++++++++++++++++++++--------
Acrates/tangle_runtime/src/relay/filter.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/relay/live.rs | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/tangle_runtime/src/relay/mod.rs | 1+
Mcrates/tangle_runtime/src/runtime.rs | 535+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
5 files changed, 683 insertions(+), 77 deletions(-)

diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -14,6 +14,7 @@ use crate::pocket_event_validation::{ use crate::relay::outbound::protocol_messages_for_test; use crate::relay::{ auth::BaseAuthState, + filter::BaseRelayMatchedFilterContext, live::{CloseResult, LiveSubscriptionSet}, outbound::RuntimeRelayMessage, }; @@ -202,7 +203,7 @@ impl BaseRelayCountReport { } #[derive(Debug, Clone, PartialEq)] -struct BaseRelayEventQueryReport { +pub(crate) struct BaseRelayEventQueryReport { events: Vec<PocketOwnedEvent>, group_read_denied: bool, query_metrics: BaseRelayQueryMetrics, @@ -220,6 +221,18 @@ impl BaseRelayEventQueryReport { query_metrics, } } + + pub(crate) fn group_read_denied(&self) -> bool { + self.group_read_denied + } + + pub(crate) fn query_metrics(&self) -> BaseRelayQueryMetrics { + self.query_metrics + } + + pub(crate) fn into_events(self) -> Vec<PocketOwnedEvent> { + self.events + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -238,7 +251,7 @@ impl BaseRelayQueryMetrics { } } - fn add(self, other: Self) -> Self { + pub(crate) fn add(self, other: Self) -> Self { Self { candidates_scanned: self .candidates_scanned @@ -379,9 +392,10 @@ impl BaseRelayCountHll { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BaseRelayFilterLimitMode { +pub(crate) enum BaseRelayFilterLimitMode { ApplyDefaultLimit, PreserveCountLimitless, + Override(u32), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -637,6 +651,10 @@ impl BaseRelayLimits { } } + pub(crate) fn effective_pocket_filter_limit_for_query(self, filter: &PocketFilter) -> usize { + self.effective_pocket_filter_limit(filter) + } + fn validate_pocket_query_complexity( &self, filters: &[PocketOwnedFilter], @@ -689,7 +707,7 @@ impl BaseRelay { }) } - fn redacted_req_closed( + pub(crate) fn redacted_req_closed( subscription_id: SubscriptionId, auth: &GroupAuthContext, ) -> RelayMessage { @@ -1557,8 +1575,8 @@ impl BaseRelay { }) .expect("Pocket live fanout must match") .into_iter() - .map(|subscription_id| RuntimeRelayMessage::Event { - subscription_id, + .map(|matched| RuntimeRelayMessage::Event { + subscription_id: matched.into_subscription_id(), event: event.to_owned(), }) .collect() @@ -1808,7 +1826,7 @@ impl BaseRelay { } } - fn query_filter_events_report_with_services( + pub(crate) fn query_filter_events_report_with_services( store: &PocketStoreHandle, groups: Option<&GroupServiceHandle>, limits: BaseRelayLimits, @@ -1870,6 +1888,7 @@ impl BaseRelay { .map_err(|_| BaseRelayError::invalid("default filter limit exceeds u32"))? } (BaseRelayFilterLimitMode::PreserveCountLimitless, _) => u32::MAX, + (BaseRelayFilterLimitMode::Override(limit), _) => limit, (_, limit) => limit, }; let ids = filter.ids().collect::<Vec<_>>(); @@ -1894,7 +1913,9 @@ impl BaseRelay { .map_err(|error| BaseRelayError::error(error.to_string())) } - fn sort_and_dedupe_query_events(mut events: Vec<PocketOwnedEvent>) -> Vec<PocketOwnedEvent> { + pub(crate) fn sort_and_dedupe_query_events( + mut events: Vec<PocketOwnedEvent>, + ) -> Vec<PocketOwnedEvent> { events.sort_by(|left, right| { let left: &PocketEvent = left; let right: &PocketEvent = right; @@ -1925,6 +1946,13 @@ impl BaseRelay { } } +pub(crate) fn matched_filter_context( + filter_index: usize, + filter: &PocketFilter, +) -> BaseRelayMatchedFilterContext { + BaseRelayMatchedFilterContext::from_filter(filter_index, filter) +} + fn pocket_filters_are_complete(filters: &[PocketOwnedFilter]) -> bool { !filters.is_empty() && filters.iter().all(|filter| filter.completes()) } diff --git a/crates/tangle_runtime/src/relay/filter.rs b/crates/tangle_runtime/src/relay/filter.rs @@ -0,0 +1,48 @@ +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; +use tangle_store_pocket::PocketFilter; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BaseRelayMatchedFilterContext { + filter_index: usize, + requested_kinds: BaseRelayRequestedKinds, +} + +impl BaseRelayMatchedFilterContext { + pub(crate) fn from_filter(filter_index: usize, filter: &PocketFilter) -> Self { + Self { + filter_index, + requested_kinds: BaseRelayRequestedKinds::from_filter(filter), + } + } + + pub(crate) fn filter_index(&self) -> usize { + self.filter_index + } + + pub(crate) fn requested_kinds(&self) -> &BaseRelayRequestedKinds { + &self.requested_kinds + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BaseRelayRequestedKinds { + Absent, + Explicit(BTreeSet<u32>), +} + +impl BaseRelayRequestedKinds { + fn from_filter(filter: &PocketFilter) -> Self { + if filter.num_kinds() == 0 { + Self::Absent + } else { + Self::Explicit( + filter + .kinds() + .map(|kind| u32::from(kind.as_u16())) + .collect(), + ) + } + } +} diff --git a/crates/tangle_runtime/src/relay/live.rs b/crates/tangle_runtime/src/relay/live.rs @@ -1,10 +1,10 @@ #![forbid(unsafe_code)] -use crate::errors::BaseRelayError; +use crate::{errors::BaseRelayError, relay::filter::BaseRelayMatchedFilterContext}; use std::collections::BTreeMap; use tangle_groups::GroupAuthContext; use tangle_protocol::SubscriptionId; -use tangle_store_pocket::{PocketEvent, PocketOwnedFilter}; +use tangle_store_pocket::{PocketEvent, PocketFilter, PocketOwnedFilter}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LiveSubscriptionSet { @@ -17,6 +17,43 @@ struct LiveSubscription { filters: Vec<PocketOwnedFilter>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveSubscriptionMatch { + subscription_id: SubscriptionId, + filter_index: usize, + filter: PocketOwnedFilter, +} + +impl LiveSubscriptionMatch { + fn new( + subscription_id: SubscriptionId, + filter_index: usize, + filter: PocketOwnedFilter, + ) -> Self { + Self { + subscription_id, + filter_index, + filter, + } + } + + pub(crate) fn subscription_id(&self) -> &SubscriptionId { + &self.subscription_id + } + + pub(crate) fn into_subscription_id(self) -> SubscriptionId { + self.subscription_id + } + + pub(crate) fn filter(&self) -> &PocketFilter { + &self.filter + } + + pub(crate) fn matched_filter_context(&self) -> BaseRelayMatchedFilterContext { + BaseRelayMatchedFilterContext::from_filter(self.filter_index, &self.filter) + } +} + impl LiveSubscriptionSet { pub(crate) fn new( max_pending_events: usize, @@ -92,23 +129,29 @@ impl LiveSubscriptionSet { event: &PocketEvent, auth: &GroupAuthContext, visible_to_auth: impl Fn(&PocketEvent, &GroupAuthContext) -> bool, - ) -> Result<Vec<SubscriptionId>, BaseRelayError> { + ) -> Result<Vec<LiveSubscriptionMatch>, BaseRelayError> { self.subscriptions.iter().try_fold( Vec::new(), |mut matched, (subscription_id, subscription)| { - if !subscription - .filters - .iter() - .map(|filter| filter.event_matches(event)) - .collect::<Result<Vec<_>, _>>() - .map_err(|error| BaseRelayError::error(error.to_string()))? - .into_iter() - .any(|matches| matches) - { - return Ok(matched); + let mut matched_filter = None; + for (filter_index, filter) in subscription.filters.iter().enumerate() { + if filter + .event_matches(event) + .map_err(|error| BaseRelayError::error(error.to_string()))? + { + matched_filter = Some((filter_index, filter.clone())); + break; + } } + let Some((filter_index, filter)) = matched_filter else { + return Ok(matched); + }; if visible_to_auth(event, auth) { - matched.push(subscription_id.clone()); + matched.push(LiveSubscriptionMatch::new( + subscription_id.clone(), + filter_index, + filter, + )); } Ok(matched) }, @@ -150,27 +193,42 @@ mod tests { let third = tangle_v2_event(FixtureKey::Member, 1_714_124_435, 1, Vec::new(), "third") .expect("third"); - assert!(matches!( + assert_live_match( subscriptions - .fanout(&pocket_event(&first), &GroupAuthContext::unauthenticated(), |_, _| true) + .fanout( + &pocket_event(&first), + &GroupAuthContext::unauthenticated(), + |_, _| true, + ) .expect("fanout") .as_slice(), - [delivered] if delivered == &subscription_id - )); - assert!(matches!( + &subscription_id, + 0, + ); + assert_live_match( subscriptions - .fanout(&pocket_event(&second), &GroupAuthContext::unauthenticated(), |_, _| true) + .fanout( + &pocket_event(&second), + &GroupAuthContext::unauthenticated(), + |_, _| true, + ) .expect("fanout") .as_slice(), - [delivered] if delivered == &subscription_id - )); - assert!(matches!( + &subscription_id, + 0, + ); + assert_live_match( subscriptions - .fanout(&pocket_event(&third), &GroupAuthContext::unauthenticated(), |_, _| true) + .fanout( + &pocket_event(&third), + &GroupAuthContext::unauthenticated(), + |_, _| true, + ) .expect("fanout") .as_slice(), - [delivered] if delivered == &subscription_id - )); + &subscription_id, + 0, + ); assert_eq!(subscriptions.close(&subscription_id), CloseResult::Closed); } @@ -208,12 +266,10 @@ mod tests { .expect("mismatched subscribe"); let event = pocket_event(&event); - assert_eq!( - subscriptions - .fanout(&event, &GroupAuthContext::unauthenticated(), |_, _| true) - .expect("fanout"), - vec![matched.clone()] - ); + let output = subscriptions + .fanout(&event, &GroupAuthContext::unauthenticated(), |_, _| true) + .expect("fanout"); + assert_live_match(output.as_slice(), &matched, 0); assert!( subscriptions .fanout(&event, &GroupAuthContext::unauthenticated(), |_, _| false) @@ -227,6 +283,18 @@ mod tests { crate::pocket_conversion::tangle_filter_to_pocket(&filter).expect("pocket filter") } + fn assert_live_match( + matches: &[super::LiveSubscriptionMatch], + subscription_id: &SubscriptionId, + filter_index: usize, + ) { + assert!(matches!( + matches, + [delivered] if delivered.subscription_id() == subscription_id + && delivered.matched_filter_context().filter_index() == filter_index + )); + } + fn pocket_event(event: &tangle_protocol::Event) -> tangle_store_pocket::PocketOwnedEvent { crate::pocket_conversion::tangle_event_to_pocket(event).expect("pocket event") } diff --git a/crates/tangle_runtime/src/relay/mod.rs b/crates/tangle_runtime/src/relay/mod.rs @@ -2,5 +2,6 @@ pub mod auth; pub mod core; +pub(crate) mod filter; pub mod live; pub mod outbound; diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -19,9 +19,10 @@ use crate::{ auth::BaseAuthState, core::{ BaseRelay, BaseRelayCountQuery, BaseRelayCountReport, BaseRelayEventWrite, - BaseRelayLimits, BaseRelayQueryMetrics, BaseRelayQueryReport, BaseRelayReqQuery, - BaseRelayShutdownReport, + BaseRelayFilterLimitMode, BaseRelayLimits, BaseRelayQueryMetrics, BaseRelayQueryReport, + BaseRelayReqQuery, BaseRelayShutdownReport, matched_filter_context, }, + filter::{BaseRelayMatchedFilterContext, BaseRelayRequestedKinds}, live::LiveSubscriptionSet, outbound::{RuntimeRelayMessage, protocol_control_messages}, }, @@ -31,6 +32,7 @@ use std::{ collections::BTreeSet, fmt, fs, net::IpAddr, + num::NonZeroU32, path::Path, str, sync::{ @@ -91,6 +93,10 @@ pub trait RelayRuntimeHooks: Send + Sync { fn event_stored(&self, _context: &RelayEventStoredContext) {} + fn plan_query(&self, _context: &RelayQueryProjectionContext) -> RelayProjectionQueryPlan { + RelayProjectionQueryPlan::default() + } + fn project_event( &self, _context: &RelayEventProjectionContext, @@ -174,6 +180,100 @@ impl RelayEventProjectionDecision { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RelayProjectionQueryPlan { + limit: RelayProjectionQueryLimit, +} + +impl RelayProjectionQueryPlan { + pub fn limit_after_projection(candidate_limit: NonZeroU32) -> Self { + Self { + limit: RelayProjectionQueryLimit::AfterProjection { candidate_limit }, + } + } + + pub fn limit(&self) -> RelayProjectionQueryLimit { + self.limit + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RelayProjectionQueryLimit { + #[default] + BeforeProjection, + AfterProjection { + candidate_limit: NonZeroU32, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayQueryProjectionContext { + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + filters: Vec<RelayMatchedFilterContext>, +} + +impl RelayQueryProjectionContext { + pub fn new( + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + filters: Vec<RelayMatchedFilterContext>, + ) -> Self { + Self { + subscription_id, + projection, + filters, + } + } + + pub fn subscription_id(&self) -> &SubscriptionId { + &self.subscription_id + } + + pub fn projection(&self) -> &RelayProjectionContext { + &self.projection + } + + pub fn filters(&self) -> &[RelayMatchedFilterContext] { + &self.filters + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayMatchedFilterContext { + filter_index: usize, + requested_kinds: RelayRequestedKinds, +} + +impl RelayMatchedFilterContext { + fn from_base(context: &BaseRelayMatchedFilterContext) -> Self { + let requested_kinds = match context.requested_kinds() { + BaseRelayRequestedKinds::Absent => RelayRequestedKinds::Absent, + BaseRelayRequestedKinds::Explicit(kinds) => { + RelayRequestedKinds::Explicit(kinds.clone()) + } + }; + Self { + filter_index: context.filter_index(), + requested_kinds, + } + } + + pub fn filter_index(&self) -> usize { + self.filter_index + } + + pub fn requested_kinds(&self) -> &RelayRequestedKinds { + &self.requested_kinds + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayRequestedKinds { + Absent, + Explicit(BTreeSet<u32>), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelayEventContext { event_id: String, @@ -315,11 +415,22 @@ pub struct RelayEventStoredContext { store_offsets: Vec<u64>, } +struct RelayEventProjectionRequest<'a> { + subscription_id: &'a SubscriptionId, + projection: &'a RelayProjectionContext, + source: RelayEventProjectionSource, + event: &'a PocketEvent, + auth: &'a BaseAuthState, + matched_filter: &'a BaseRelayMatchedFilterContext, + filter: &'a PocketFilter, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelayEventProjectionContext { subscription_id: SubscriptionId, projection: RelayProjectionContext, source: RelayEventProjectionSource, + matched_filter: RelayMatchedFilterContext, event: RelayEventContext, } @@ -328,12 +439,14 @@ impl RelayEventProjectionContext { subscription_id: SubscriptionId, projection: RelayProjectionContext, source: RelayEventProjectionSource, + matched_filter: RelayMatchedFilterContext, event: RelayEventContext, ) -> Self { Self { subscription_id, projection, source, + matched_filter, event, } } @@ -350,6 +463,10 @@ impl RelayEventProjectionContext { self.source } + pub fn matched_filter(&self) -> &RelayMatchedFilterContext { + &self.matched_filter + } + pub fn event(&self) -> &RelayEventContext { &self.event } @@ -886,12 +1003,14 @@ impl RelayRuntimeShared { fn project_query_report( &self, report: BaseRelayQueryReport, + filters: &[PocketOwnedFilter], projection: &RelayProjectionContext, auth: &BaseAuthState, ) -> Result<BaseRelayQueryReport, BaseRelayError> { let group_read_denied = report.group_read_denied(); let query_metrics = report.query_metrics(); - let messages = self.project_runtime_messages(report.into_messages(), projection, auth)?; + let messages = + self.project_runtime_messages(report.into_messages(), filters, projection, auth)?; let returned_events = messages .iter() .filter(|message| matches!(message, RuntimeRelayMessage::Event { .. })) @@ -907,23 +1026,32 @@ impl RelayRuntimeShared { fn project_runtime_messages( &self, messages: Vec<RuntimeRelayMessage>, + filters: &[PocketOwnedFilter], projection: &RelayProjectionContext, auth: &BaseAuthState, ) -> Result<Vec<RuntimeRelayMessage>, BaseRelayError> { let mut output = Vec::with_capacity(messages.len()); + let mut event_ids = BTreeSet::new(); for message in messages { match message { RuntimeRelayMessage::Event { subscription_id, event, } => { - if let Some(projected) = self.project_event_output( - &subscription_id, - projection, - RelayEventProjectionSource::HistoricalQuery, - &event, - auth, - )? { + let (matched_filter, filter) = + self.matched_filter_for_event(filters, &event)?; + if let Some(projected) = + self.project_event_output(RelayEventProjectionRequest { + subscription_id: &subscription_id, + projection, + source: RelayEventProjectionSource::HistoricalQuery, + event: &event, + auth, + matched_filter: &matched_filter, + filter, + })? + && event_ids.insert(projected.id()) + { output.push(RuntimeRelayMessage::event(subscription_id, projected)); } } @@ -935,16 +1063,22 @@ impl RelayRuntimeShared { fn project_event_output( &self, - subscription_id: &SubscriptionId, - projection: &RelayProjectionContext, - source: RelayEventProjectionSource, - event: &PocketEvent, - auth: &BaseAuthState, + request: RelayEventProjectionRequest<'_>, ) -> Result<Option<PocketOwnedEvent>, BaseRelayError> { + let RelayEventProjectionRequest { + subscription_id, + projection, + source, + event, + auth, + matched_filter, + filter, + } = request; let context = RelayEventProjectionContext::new( subscription_id.clone(), projection.clone(), source, + RelayMatchedFilterContext::from_base(matched_filter), RelayEventContext::from_pocket_event(event)?, ); match self.hooks.project_event(&context) { @@ -960,7 +1094,10 @@ impl RelayRuntimeShared { self.groups.as_ref(), &replacement, &group_auth, - )? { + )? && filter + .event_matches(&replacement) + .map_err(|error| BaseRelayError::error(error.to_string()))? + { Ok(Some(replacement)) } else { Ok(None) @@ -969,6 +1106,107 @@ impl RelayRuntimeShared { } } + fn matched_filter_for_event<'a>( + &self, + filters: &'a [PocketOwnedFilter], + event: &PocketEvent, + ) -> Result<(BaseRelayMatchedFilterContext, &'a PocketFilter), BaseRelayError> { + for (filter_index, filter) in filters.iter().enumerate() { + if filter + .event_matches(event) + .map_err(|error| BaseRelayError::error(error.to_string()))? + { + return Ok((matched_filter_context(filter_index, filter), filter)); + } + } + Err(BaseRelayError::error( + "query output did not match any request filter", + )) + } + + fn query_projected_req_with_auth_report( + &self, + subscription_id: SubscriptionId, + filters: Vec<PocketOwnedFilter>, + search_present: bool, + auth: &BaseAuthState, + projection: &RelayProjectionContext, + candidate_limit: NonZeroU32, + ) -> Result<BaseRelayQueryReport, BaseRelayError> { + self.limits + .base_relay_limits() + .validate_subscription_id(&subscription_id)?; + self.limits + .base_relay_limits() + .validate_pocket_filters(&filters)?; + if let Some(message) = + BaseRelay::unsupported_search_present_closed(&subscription_id, search_present) + { + return Ok(BaseRelayQueryReport::new( + vec![message.into()], + false, + BaseRelayQueryMetrics::default(), + )); + } + let group_auth = GroupAuthContext::new(auth.authenticated_pubkeys().iter().cloned()); + let mut output = Vec::new(); + let mut group_read_denied = false; + let mut query_metrics = BaseRelayQueryMetrics::default(); + for (filter_index, filter) in filters.iter().enumerate() { + let report = BaseRelay::query_filter_events_report_with_services( + &self.store, + self.groups.as_ref(), + self.limits.base_relay_limits(), + self.config.pocket_query_config(), + filter, + &group_auth, + BaseRelayFilterLimitMode::Override(candidate_limit.get()), + )?; + group_read_denied |= report.group_read_denied(); + query_metrics = query_metrics.add(report.query_metrics()); + let events = BaseRelay::sort_and_dedupe_query_events(report.into_events()); + let matched_filter = matched_filter_context(filter_index, filter); + let mut projected = Vec::new(); + for event in events { + if let Some(event) = self.project_event_output(RelayEventProjectionRequest { + subscription_id: &subscription_id, + projection, + source: RelayEventProjectionSource::HistoricalQuery, + event: &event, + auth, + matched_filter: &matched_filter, + filter, + })? { + projected.push(event); + } + } + let mut projected = BaseRelay::sort_and_dedupe_query_events(projected); + projected.truncate( + self.limits + .base_relay_limits() + .effective_pocket_filter_limit_for_query(filter), + ); + output.extend(projected); + } + let events = BaseRelay::sort_and_dedupe_query_events(output); + let query_metrics = query_metrics.with_returned_events(events.len()); + let mut messages = events + .into_iter() + .map(|event| RuntimeRelayMessage::event(subscription_id.clone(), event)) + .collect::<Vec<_>>(); + if group_read_denied { + let group_auth = GroupAuthContext::new(auth.authenticated_pubkeys().iter().cloned()); + messages.push(BaseRelay::redacted_req_closed(subscription_id, &group_auth).into()); + } else { + messages.push(RelayMessage::Eose(subscription_id).into()); + } + Ok(BaseRelayQueryReport::new( + messages, + group_read_denied, + query_metrics, + )) + } + fn handle_count_with_auth_report( &self, subscription_id: SubscriptionId, @@ -1413,12 +1651,13 @@ impl RelayRuntimeHandle { } let report = self.inner.query_req_with_auth_report( subscription_id, - filters, + filters.clone(), search_present, auth, )?; let report = self.inner.project_query_report( report, + &filters, &RelayProjectionContext::default(), auth, )?; @@ -1598,13 +1837,40 @@ impl RelayRuntimeHandle { projection: &RelayProjectionContext, ) -> Result<BaseRelayQueryReport, BaseRelayError> { let started_at = Instant::now(); - let report = self.inner.query_req_with_auth_report( - subscription_id, - filters, - search_present, - auth, - )?; - let report = self.inner.project_query_report(report, projection, auth)?; + let context = RelayQueryProjectionContext::new( + subscription_id.clone(), + projection.clone(), + filters + .iter() + .enumerate() + .map(|(index, filter)| { + RelayMatchedFilterContext::from_base(&matched_filter_context(index, filter)) + }) + .collect(), + ); + let plan = self.inner.hooks.plan_query(&context); + let report = match plan.limit() { + RelayProjectionQueryLimit::BeforeProjection => { + let report = self.inner.query_req_with_auth_report( + subscription_id, + filters.clone(), + search_present, + auth, + )?; + self.inner + .project_query_report(report, &filters, projection, auth)? + } + RelayProjectionQueryLimit::AfterProjection { candidate_limit } => { + self.inner.query_projected_req_with_auth_report( + subscription_id, + filters, + search_present, + auth, + projection, + candidate_limit, + )? + } + }; if report.group_read_denied() { self.inner.metrics.record_group_read_denial(); } @@ -1647,17 +1913,26 @@ impl RelayRuntimeHandle { .unwrap_or(false) })?; let mut messages = Vec::with_capacity(subscriptions.len()); - for subscription_id in subscriptions { - if let Some(projected) = self.inner.project_event_output( - &subscription_id, - projection, - RelayEventProjectionSource::LiveFanout { - store_offset: offset.as_u64(), - }, - &pocket_event, - auth, - )? { - messages.push(RuntimeRelayMessage::event(subscription_id, projected)); + for matched in subscriptions { + let matched_filter = matched.matched_filter_context(); + if let Some(projected) = + self.inner + .project_event_output(RelayEventProjectionRequest { + subscription_id: matched.subscription_id(), + projection, + source: RelayEventProjectionSource::LiveFanout { + store_offset: offset.as_u64(), + }, + event: &pocket_event, + auth, + matched_filter: &matched_filter, + filter: matched.filter(), + })? + { + messages.push(RuntimeRelayMessage::event( + matched.into_subscription_id(), + projected, + )); } } Ok(messages) @@ -2534,7 +2809,8 @@ mod tests { use super::{ BROAD_QUERY_TIME_WINDOW_SECONDS, EventAdmissionDecision, RelayEventAdmissionContext, RelayEventProjectionContext, RelayEventProjectionDecision, RelayEventProjectionSource, - RelayEventStoredContext, RelayProjectionContext, RelayRuntime, RelayRuntimeHandle, + RelayEventStoredContext, RelayProjectionContext, RelayProjectionQueryPlan, + RelayQueryProjectionContext, RelayRequestedKinds, RelayRuntime, RelayRuntimeHandle, RelayRuntimeHooks, RuntimeClientMessage, TangleBroadQueryReason, TangleClientRateLimitContext, TangleQueryClassification, TangleQueryClassifier, TangleRuntimeLimits, @@ -2550,6 +2826,7 @@ mod tests { use std::{ collections::{BTreeMap, BTreeSet}, net::{IpAddr, Ipv4Addr}, + num::NonZeroU32, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, @@ -3051,7 +3328,18 @@ mod tests { contexts[0].source(), RelayEventProjectionSource::HistoricalQuery ); + assert_eq!(contexts[0].matched_filter().filter_index(), 0); + assert_eq!( + contexts[0].matched_filter().requested_kinds(), + &RelayRequestedKinds::Absent + ); assert_eq!(contexts[0].event().event_id(), event.id().as_str()); + let query_contexts = hooks.query_contexts(); + assert_eq!(query_contexts.len(), 1); + assert_eq!( + query_contexts[0].filters()[0].requested_kinds(), + &RelayRequestedKinds::Absent + ); let _ = std::fs::remove_dir_all(root); } @@ -3114,6 +3402,11 @@ mod tests { store_offset: offset.as_u64() } ); + assert_eq!(contexts[0].matched_filter().filter_index(), 0); + assert_eq!( + contexts[0].matched_filter().requested_kinds(), + &RelayRequestedKinds::Explicit(BTreeSet::from([1])) + ); assert_eq!(contexts[0].event().event_id(), event.id().as_str()); let _ = std::fs::remove_dir_all(root); @@ -3163,7 +3456,7 @@ mod tests { let report = handle .query_req_with_auth_report_with_projection_context( subscription_id.clone(), - vec![pocket_filter(json!({"ids": [source.id().as_str()]}))], + vec![pocket_filter(json!({"kinds": [1]}))], false, &auth, &RelayProjectionContext::named("replace").expect("projection"), @@ -3208,6 +3501,154 @@ mod tests { } #[tokio::test] + async fn runtime_projection_can_apply_query_limit_after_projection() { + let root = temp_root("runtime-projection-post-limit"); + let _ = std::fs::remove_dir_all(&root); + let hooks = Arc::new(ProjectingHooks::new( + "post-limit", + ProjectionHookScope::Historical, + Some("drop"), + RelayEventProjectionDecision::Suppress, + )); + hooks.set_query_plan(RelayProjectionQueryPlan::limit_after_projection( + NonZeroU32::new(2).expect("candidate limit"), + )); + let handle = RelayRuntimeHandle::new( + RelayRuntime::open_with_hooks(runtime_config(&root, 8), hooks.clone()) + .expect("runtime"), + ); + let mut auth = handle.auth_state().await.expect("auth"); + let dropped = tangle_v2_event(FixtureKey::Member, 1_714_124_435, 1, Vec::new(), "drop") + .expect("drop"); + let kept = + tangle_v2_event(FixtureKey::Admin, 1_714_124_434, 1, Vec::new(), "keep").expect("keep"); + assert_accepted_reply( + runtime_event_reply(&handle, kept.clone(), &mut auth, 1_714_124_434).await, + &kept, + ); + assert_accepted_reply( + runtime_event_reply(&handle, dropped.clone(), &mut auth, 1_714_124_435).await, + &dropped, + ); + let subscription_id = SubscriptionId::new("post-limit").expect("subscription"); + + let report = handle + .query_req_with_auth_report_with_projection_context( + subscription_id.clone(), + vec![pocket_filter(json!({"kinds": [1], "limit": 1}))], + false, + &auth, + &RelayProjectionContext::named("post-limit").expect("projection"), + ) + .await + .expect("query"); + assert!(matches!( + report.into_messages().as_slice(), + [ + RuntimeRelayMessage::Event { + subscription_id: delivered, + event + }, + RuntimeRelayMessage::Protocol(RelayMessage::Eose(eose)) + ] if delivered == &subscription_id + && event.id().as_hex_string() == kept.id().as_str() + && eose == &subscription_id + )); + let query_contexts = hooks.query_contexts(); + assert_eq!(query_contexts.len(), 1); + assert_eq!( + query_contexts[0].filters()[0].requested_kinds(), + &RelayRequestedKinds::Explicit(BTreeSet::from([1])) + ); + assert_eq!(hooks.contexts().len(), 2); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn runtime_projection_live_replacement_must_match_original_filter() { + let root = temp_root("runtime-projection-live-replace-rematch"); + let _ = std::fs::remove_dir_all(&root); + let hooks = Arc::new(ProjectingHooks::new( + "live-replace", + ProjectionHookScope::Live, + Some("source"), + RelayEventProjectionDecision::Emit, + )); + let handle = RelayRuntimeHandle::new( + RelayRuntime::open_with_hooks(runtime_config(&root, 8), hooks.clone()) + .expect("runtime"), + ); + let mut offsets = handle.subscribe_events().await; + let mut auth = handle.auth_state().await.expect("auth"); + let source = tangle_v2_event(FixtureKey::Member, 1_714_124_433, 7, Vec::new(), "source") + .expect("source"); + let replacement = tangle_v2_event( + FixtureKey::Admin, + 1_714_124_434, + 1, + Vec::new(), + "replacement", + ) + .expect("replacement"); + assert_accepted_reply( + runtime_event_reply(&handle, source.clone(), &mut auth, 1_714_124_433).await, + &source, + ); + let source_offset = offsets.try_recv().expect("source offset"); + assert_accepted_reply( + runtime_event_reply(&handle, replacement.clone(), &mut auth, 1_714_124_434).await, + &replacement, + ); + let replacement_offset = offsets.try_recv().expect("replacement offset"); + hooks.set_decision(RelayEventProjectionDecision::replace_with_stored_offset( + replacement_offset.as_u64(), + )); + let mut subscriptions = LiveSubscriptionSet::new(8, 64).expect("subscriptions"); + let source_only = SubscriptionId::new("source-only").expect("source subscription"); + let source_or_note = SubscriptionId::new("source-or-note").expect("mixed subscription"); + subscriptions + .subscribe(source_only, vec![pocket_filter(json!({"kinds": [7]}))]) + .expect("source subscribe"); + subscriptions + .subscribe( + source_or_note.clone(), + vec![pocket_filter(json!({"kinds": [1, 7]}))], + ) + .expect("mixed subscribe"); + + let messages = handle + .fanout_event_offset_with_projection_context( + source_offset, + &mut subscriptions, + &auth, + &RelayProjectionContext::named("live-replace").expect("projection"), + ) + .await + .expect("fanout"); + assert!(matches!( + messages.as_slice(), + [RuntimeRelayMessage::Event { + subscription_id, + event + }] if subscription_id == &source_or_note + && event.id().as_hex_string() == replacement.id().as_str() + )); + let contexts = hooks.contexts(); + assert_eq!(contexts.len(), 2); + assert_eq!( + contexts[0].matched_filter().requested_kinds(), + &RelayRequestedKinds::Explicit(BTreeSet::from([7])) + ); + assert_eq!( + contexts[1].matched_filter().requested_kinds(), + &RelayRequestedKinds::Explicit(BTreeSet::from([1, 7])) + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] async fn runtime_projection_runs_after_group_read_gates() { let root = temp_root("runtime-projection-group-gate"); let _ = std::fs::remove_dir_all(&root); @@ -5462,7 +5903,9 @@ mod tests { scope: ProjectionHookScope, source_content: Option<&'static str>, decision: Mutex<RelayEventProjectionDecision>, + query_plan: Mutex<RelayProjectionQueryPlan>, contexts: Mutex<Vec<RelayEventProjectionContext>>, + query_contexts: Mutex<Vec<RelayQueryProjectionContext>>, } impl ProjectingHooks { @@ -5477,7 +5920,9 @@ mod tests { scope, source_content, decision: Mutex::new(decision), + query_plan: Mutex::new(RelayProjectionQueryPlan::default()), contexts: Mutex::new(Vec::new()), + query_contexts: Mutex::new(Vec::new()), } } @@ -5485,10 +5930,18 @@ mod tests { *self.decision.lock().expect("decision") = decision; } + fn set_query_plan(&self, plan: RelayProjectionQueryPlan) { + *self.query_plan.lock().expect("query plan") = plan; + } + fn contexts(&self) -> Vec<RelayEventProjectionContext> { self.contexts.lock().expect("contexts").clone() } + fn query_contexts(&self) -> Vec<RelayQueryProjectionContext> { + self.query_contexts.lock().expect("query contexts").clone() + } + fn scope_matches(&self, source: RelayEventProjectionSource) -> bool { matches!( (self.scope, source), @@ -5511,6 +5964,14 @@ mod tests { } impl RelayRuntimeHooks for ProjectingHooks { + fn plan_query(&self, context: &RelayQueryProjectionContext) -> RelayProjectionQueryPlan { + self.query_contexts + .lock() + .expect("query contexts") + .push(context.clone()); + *self.query_plan.lock().expect("query plan") + } + fn project_event( &self, context: &RelayEventProjectionContext,