tangle


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

commit 3a55578e5d1996fe552776f48994ffda640dc547
parent 70f9b47b8e91b11ab4fc5a85a000d4337a0d2e42
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 22:52:34 +0000

runtime: order projection hooks before live publish

- run stored-event hooks before event-bus offset publication
- expose all matched filter summaries to projection hooks
- re-match replacements against every matching subscription filter
- add runtime tests for hook ordering and matched-filter context

Diffstat:
Mcrates/tangle_runtime/src/relay/live.rs | 42+++++++++++++++++++++++++-----------------
Mcrates/tangle_runtime/src/runtime.rs | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 230 insertions(+), 51 deletions(-)

diff --git a/crates/tangle_runtime/src/relay/live.rs b/crates/tangle_runtime/src/relay/live.rs @@ -20,20 +20,17 @@ struct LiveSubscription { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LiveSubscriptionMatch { subscription_id: SubscriptionId, - filter_index: usize, - filter: PocketOwnedFilter, + matched_filters: Vec<(usize, PocketOwnedFilter)>, } impl LiveSubscriptionMatch { fn new( subscription_id: SubscriptionId, - filter_index: usize, - filter: PocketOwnedFilter, + matched_filters: Vec<(usize, PocketOwnedFilter)>, ) -> Self { Self { subscription_id, - filter_index, - filter, + matched_filters, } } @@ -45,12 +42,25 @@ impl LiveSubscriptionMatch { self.subscription_id } - pub(crate) fn filter(&self) -> &PocketFilter { - &self.filter + #[cfg(test)] + pub(crate) fn matched_filter_context(&self) -> BaseRelayMatchedFilterContext { + let (filter_index, filter) = &self.matched_filters[0]; + BaseRelayMatchedFilterContext::from_filter(*filter_index, filter) } - pub(crate) fn matched_filter_context(&self) -> BaseRelayMatchedFilterContext { - BaseRelayMatchedFilterContext::from_filter(self.filter_index, &self.filter) + pub(crate) fn matched_filter_contexts(&self) -> Vec<BaseRelayMatchedFilterContext> { + self.matched_filters + .iter() + .map(|(filter_index, filter)| { + BaseRelayMatchedFilterContext::from_filter(*filter_index, filter) + }) + .collect() + } + + pub(crate) fn filters(&self) -> impl Iterator<Item = &PocketFilter> { + self.matched_filters + .iter() + .map(|(_, filter)| -> &PocketFilter { filter }) } } @@ -133,24 +143,22 @@ impl LiveSubscriptionSet { self.subscriptions.iter().try_fold( Vec::new(), |mut matched, (subscription_id, subscription)| { - let mut matched_filter = None; + let mut matched_filters = Vec::new(); 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; + matched_filters.push((filter_index, filter.clone())); } } - let Some((filter_index, filter)) = matched_filter else { + if matched_filters.is_empty() { return Ok(matched); - }; + } if visible_to_auth(event, auth) { matched.push(LiveSubscriptionMatch::new( subscription_id.clone(), - filter_index, - filter, + matched_filters, )); } Ok(matched) diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -476,8 +476,7 @@ struct RelayEventProjectionRequest<'a> { source: RelayEventProjectionSource, event: &'a PocketEvent, auth: &'a BaseAuthState, - matched_filter: &'a BaseRelayMatchedFilterContext, - filter: &'a PocketFilter, + matched_filters: Vec<(BaseRelayMatchedFilterContext, &'a PocketFilter)>, } struct RelayLiveProjectionDelivery<'a> { @@ -494,7 +493,7 @@ pub struct RelayEventProjectionContext { subscription_id: SubscriptionId, projection: RelayProjectionContext, source: RelayEventProjectionSource, - matched_filter: RelayMatchedFilterContext, + matched_filters: Vec<RelayMatchedFilterContext>, event: RelayEventContext, } @@ -506,11 +505,27 @@ impl RelayEventProjectionContext { matched_filter: RelayMatchedFilterContext, event: RelayEventContext, ) -> Self { + Self::new_with_matched_filters( + subscription_id, + projection, + source, + vec![matched_filter], + event, + ) + } + + pub fn new_with_matched_filters( + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + source: RelayEventProjectionSource, + matched_filters: Vec<RelayMatchedFilterContext>, + event: RelayEventContext, + ) -> Self { Self { subscription_id, projection, source, - matched_filter, + matched_filters, event, } } @@ -528,7 +543,13 @@ impl RelayEventProjectionContext { } pub fn matched_filter(&self) -> &RelayMatchedFilterContext { - &self.matched_filter + self.matched_filters + .first() + .expect("projection context must include at least one matched filter") + } + + pub fn matched_filters(&self) -> &[RelayMatchedFilterContext] { + &self.matched_filters } pub fn event(&self) -> &RelayEventContext { @@ -1102,8 +1123,7 @@ impl RelayRuntimeShared { subscription_id, event, } => { - let (matched_filter, filter) = - self.matched_filter_for_event(filters, &event)?; + let matched_filters = self.matched_filters_for_event(filters, &event)?; if let Some(projected) = self.project_event_output(RelayEventProjectionRequest { subscription_id: &subscription_id, @@ -1111,8 +1131,7 @@ impl RelayRuntimeShared { source: RelayEventProjectionSource::HistoricalQuery, event: &event, auth, - matched_filter: &matched_filter, - filter, + matched_filters, })? && event_ids.insert(projected.id()) { @@ -1135,14 +1154,16 @@ impl RelayRuntimeShared { source, event, auth, - matched_filter, - filter, + matched_filters, } = request; - let context = RelayEventProjectionContext::new( + let context = RelayEventProjectionContext::new_with_matched_filters( subscription_id.clone(), projection.clone(), source, - RelayMatchedFilterContext::from_base(matched_filter), + matched_filters + .iter() + .map(|(matched_filter, _)| RelayMatchedFilterContext::from_base(matched_filter)) + .collect(), RelayEventContext::from_pocket_event(event)?, ); match self.hooks.project_event(&context) { @@ -1158,9 +1179,16 @@ impl RelayRuntimeShared { self.groups.as_ref(), &replacement, &group_auth, - )? && filter - .event_matches(&replacement) - .map_err(|error| BaseRelayError::error(error.to_string()))? + )? && matched_filters + .iter() + .try_fold(false, |matched, (_, filter)| { + if matched { + return Ok(true); + } + filter + .event_matches(&replacement) + .map_err(|error| BaseRelayError::error(error.to_string())) + })? { Ok(Some(replacement)) } else { @@ -1170,22 +1198,27 @@ impl RelayRuntimeShared { } } - fn matched_filter_for_event<'a>( + fn matched_filters_for_event<'a>( &self, filters: &'a [PocketOwnedFilter], event: &PocketEvent, - ) -> Result<(BaseRelayMatchedFilterContext, &'a PocketFilter), BaseRelayError> { + ) -> Result<Vec<(BaseRelayMatchedFilterContext, &'a PocketFilter)>, BaseRelayError> { + let mut matched_filters = Vec::new(); 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)); + let filter: &PocketFilter = filter; + matched_filters.push((matched_filter_context(filter_index, filter), filter)); } } - Err(BaseRelayError::error( - "query output did not match any request filter", - )) + if matched_filters.is_empty() { + return Err(BaseRelayError::error( + "query output did not match any request filter", + )); + } + Ok(matched_filters) } fn query_projected_req_with_auth_report( @@ -1229,7 +1262,6 @@ impl RelayRuntimeShared { 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 { @@ -1238,8 +1270,7 @@ impl RelayRuntimeShared { source: RelayEventProjectionSource::HistoricalQuery, event: &event, auth, - matched_filter: &matched_filter, - filter, + matched_filters: vec![(matched_filter_context(filter_index, filter), filter)], })? { projected.push(event); } @@ -1655,11 +1686,6 @@ impl RelayRuntimeHandle { .metrics .record_outbox_pending_events(group_outbox_pending_events.unwrap_or(0)); } - for offset in result.stored_offsets() { - self.inner.metrics.record_stored_event_offset(); - let receivers = self.inner.event_bus.publish(*offset); - self.inner.metrics.record_event_bus_publish(receivers); - } if !result.stored_offsets().is_empty() { logging::log_event_stored( &event_id, @@ -1675,6 +1701,11 @@ impl RelayRuntimeHandle { .collect(), )); } + for offset in result.stored_offsets() { + self.inner.metrics.record_stored_event_offset(); + let receivers = self.inner.event_bus.publish(*offset); + self.inner.metrics.record_event_bus_publish(receivers); + } let message = result.into_message(); record_event_metrics(&self.inner.metrics, &message, is_group_event, started_at); Ok(vec![message.into()]) @@ -2021,7 +2052,6 @@ impl RelayRuntimeHandle { })?; for matched in subscriptions { let subscription_id = matched.subscription_id().clone(); - let matched_filter = matched.matched_filter_context(); if let Some(projected) = self.inner .project_event_output(RelayEventProjectionRequest { @@ -2030,8 +2060,11 @@ impl RelayRuntimeHandle { source: RelayEventProjectionSource::LiveFanout { store_offset }, event, auth: delivery.auth, - matched_filter: &matched_filter, - filter: matched.filter(), + matched_filters: matched + .matched_filter_contexts() + .into_iter() + .zip(matched.filters()) + .collect(), })? { let event_id = projected.id().as_hex_string(); @@ -2938,7 +2971,7 @@ mod tests { net::{IpAddr, Ipv4Addr}, num::NonZeroU32, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, mpsc}, time::Duration, }; use tangle_crypto::RelaySigner; @@ -3224,6 +3257,53 @@ mod tests { } #[tokio::test] + async fn runtime_runs_stored_event_hook_before_offset_publish() { + let root = temp_root("runtime-stored-hook-before-offset-publish"); + let _ = std::fs::remove_dir_all(&root); + let (started_sender, started_receiver) = mpsc::sync_channel(1); + let (release_sender, release_receiver) = mpsc::sync_channel(1); + let hooks = Arc::new(BlockingStoredHooks { + started_sender: Mutex::new(Some(started_sender)), + release_receiver: Mutex::new(release_receiver), + }); + let runtime = + RelayRuntime::open_with_hooks(runtime_config(&root, 8), hooks).expect("runtime"); + let handle = RelayRuntimeHandle::new(runtime); + let mut offsets = handle.subscribe_events().await; + let event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + Vec::new(), + "stored hook before publish", + ) + .expect("event"); + let task_handle = handle.clone(); + let event_for_task = event.clone(); + let task = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + runtime.block_on(async move { + let mut auth = task_handle.auth_state().await.expect("auth"); + runtime_event_reply(&task_handle, event_for_task, &mut auth, 1_714_124_433).await + }) + }); + + started_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("stored hook started"); + assert!(offsets.try_recv().is_err()); + release_sender.send(()).expect("release hook"); + assert_accepted_reply(task.join().expect("event task"), &event); + offsets.try_recv().expect("offset after hook release"); + handle.shutdown().await.expect("shutdown"); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] async fn runtime_hooks_reject_events_and_observe_stored_offsets() { let root = temp_root("runtime-hooks"); let _ = std::fs::remove_dir_all(&root); @@ -3455,6 +3535,79 @@ mod tests { } #[tokio::test] + async fn runtime_projection_context_reports_all_matching_query_filters() { + let root = temp_root("runtime-projection-query-all-matched-filters"); + let _ = std::fs::remove_dir_all(&root); + let hooks = Arc::new(ProjectingHooks::new( + "multi-filter", + ProjectionHookScope::Historical, + None, + RelayEventProjectionDecision::Emit, + )); + 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 event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + Vec::new(), + "multi filter query", + ) + .expect("event"); + assert_accepted_reply( + runtime_event_reply(&handle, event.clone(), &mut auth, 1_714_124_433).await, + &event, + ); + let subscription_id = SubscriptionId::new("query-multi-filter").expect("subscription"); + + let report = handle + .query_req_with_auth_report_with_projection_context( + subscription_id.clone(), + vec![ + pocket_filter(json!({"ids": [event.id().as_str()]})), + pocket_filter(json!({"kinds": [1]})), + ], + false, + &auth, + &RelayProjectionContext::named("multi-filter").expect("projection"), + ) + .await + .expect("query"); + assert!(matches!( + report.into_messages().as_slice(), + [ + RuntimeRelayMessage::Event { + subscription_id: delivered, + event: delivered_event + }, + RuntimeRelayMessage::Protocol(RelayMessage::Eose(eose)) + ] if delivered == &subscription_id + && delivered_event.id().as_hex_string() == event.id().as_str() + && eose == &subscription_id + )); + let contexts = hooks.contexts(); + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].matched_filter().filter_index(), 0); + let matched_filters = contexts[0].matched_filters(); + assert_eq!(matched_filters.len(), 2); + assert_eq!(matched_filters[0].filter_index(), 0); + assert_eq!( + matched_filters[0].requested_kinds(), + &RelayRequestedKinds::Absent + ); + assert_eq!(matched_filters[1].filter_index(), 1); + assert_eq!( + matched_filters[1].requested_kinds(), + &RelayRequestedKinds::Explicit(BTreeSet::from([1])) + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] async fn runtime_projection_can_suppress_live_fanout_events() { let root = temp_root("runtime-projection-live-suppress"); let _ = std::fs::remove_dir_all(&root); @@ -6087,6 +6240,24 @@ mod tests { } } + struct BlockingStoredHooks { + started_sender: Mutex<Option<mpsc::SyncSender<()>>>, + release_receiver: Mutex<mpsc::Receiver<()>>, + } + + impl RelayRuntimeHooks for BlockingStoredHooks { + fn event_stored(&self, _context: &RelayEventStoredContext) { + if let Some(sender) = self.started_sender.lock().expect("started sender").take() { + sender.send(()).expect("send hook start"); + } + self.release_receiver + .lock() + .expect("release receiver") + .recv() + .expect("receive hook release"); + } + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProjectionHookScope { Historical,