tangle


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

commit 67cb8a1a310e12f06b83b28654a8b34462b04c64
parent a84211124edb5e6c2b6d2206b43b0406e1958b23
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 18:05:22 -0700

runtime: classify broad count queries

Diffstat:
Mcrates/tangle_runtime/src/runtime.rs | 233++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 221 insertions(+), 12 deletions(-)

diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -80,6 +80,159 @@ struct TangleQueryRateLimitRequest<'a> { now: UnixTimestamp, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TangleQueryClassification { + Bounded, + Broad(TangleBroadQueryReason), +} + +impl TangleQueryClassification { + fn is_broad(self) -> bool { + matches!(self, Self::Broad(_)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TangleBroadQueryReason { + EmptyFilters, + MissingPrimaryConstraint, + MissingBoundedSelector, + HighLimit, + BroadTimeWindow, +} + +#[derive(Debug, Clone, Copy)] +struct TangleQueryClassifier { + limits: BaseRelayLimits, +} + +const BROAD_QUERY_TIME_WINDOW_SECONDS: u64 = 31 * 24 * 60 * 60; + +impl TangleQueryClassifier { + fn new(limits: BaseRelayLimits) -> Self { + Self { limits } + } + + fn classify( + self, + scope: TangleRateLimitScope, + filters: &[Filter], + ) -> TangleQueryClassification { + match scope { + TangleRateLimitScope::Req => self.classify_query(filters), + TangleRateLimitScope::Count => self.classify_count(filters), + TangleRateLimitScope::Auth + | TangleRateLimitScope::Event + | TangleRateLimitScope::GroupWrite => self.classify_query(filters), + } + } + + fn classify_query(self, filters: &[Filter]) -> TangleQueryClassification { + self.classify_filters(filters, Self::classify_query_filter) + } + + fn classify_count(self, filters: &[Filter]) -> TangleQueryClassification { + self.classify_filters(filters, Self::classify_count_filter) + } + + fn classify_filters( + self, + filters: &[Filter], + classify_filter: fn(Self, &Filter) -> TangleQueryClassification, + ) -> TangleQueryClassification { + if filters.is_empty() { + return TangleQueryClassification::Broad(TangleBroadQueryReason::EmptyFilters); + } + filters + .iter() + .map(|filter| classify_filter(self, filter)) + .find(|classification| classification.is_broad()) + .unwrap_or(TangleQueryClassification::Bounded) + } + + fn classify_query_filter(self, filter: &Filter) -> TangleQueryClassification { + if !self.has_primary_constraint(filter) { + return TangleQueryClassification::Broad( + TangleBroadQueryReason::MissingPrimaryConstraint, + ); + } + if self.has_high_limit(filter) { + return TangleQueryClassification::Broad(TangleBroadQueryReason::HighLimit); + } + if self.has_broad_time_window(filter) && !self.has_strong_constraint(filter) { + return TangleQueryClassification::Broad(TangleBroadQueryReason::BroadTimeWindow); + } + TangleQueryClassification::Bounded + } + + fn classify_count_filter(self, filter: &Filter) -> TangleQueryClassification { + if !self.has_primary_constraint(filter) { + return TangleQueryClassification::Broad( + TangleBroadQueryReason::MissingPrimaryConstraint, + ); + } + if self.has_high_limit(filter) { + return TangleQueryClassification::Broad(TangleBroadQueryReason::HighLimit); + } + if self.has_broad_time_window(filter) { + return TangleQueryClassification::Broad(TangleBroadQueryReason::BroadTimeWindow); + } + if !self.has_count_bounded_selector(filter) { + return TangleQueryClassification::Broad( + TangleBroadQueryReason::MissingBoundedSelector, + ); + } + TangleQueryClassification::Bounded + } + + fn has_primary_constraint(self, filter: &Filter) -> bool { + !filter.ids().is_empty() + || !filter.authors().is_empty() + || !filter.kinds().is_empty() + || self.has_group_constraint(filter) + } + + fn has_strong_constraint(self, filter: &Filter) -> bool { + !filter.ids().is_empty() + || !filter.authors().is_empty() + || self.has_group_constraint(filter) + } + + fn has_count_bounded_selector(self, filter: &Filter) -> bool { + self.has_strong_constraint(filter) + || (!filter.kinds().is_empty() && self.has_bounded_time_window(filter)) + } + + fn has_group_constraint(self, filter: &Filter) -> bool { + filter + .tag_filters() + .iter() + .any(|(name, values)| matches!(name.as_str(), "h" | "d") && !values.is_empty()) + } + + fn has_high_limit(self, filter: &Filter) -> bool { + filter.limit().unwrap_or(self.limits.default_limit()) >= self.limits.max_limit() + } + + fn has_bounded_time_window(self, filter: &Filter) -> bool { + match (filter.since(), filter.until()) { + (Some(since), Some(until)) => { + until.as_u64().saturating_sub(since.as_u64()) <= BROAD_QUERY_TIME_WINDOW_SECONDS + } + _ => false, + } + } + + fn has_broad_time_window(self, filter: &Filter) -> bool { + match (filter.since(), filter.until()) { + (Some(since), Some(until)) => { + until.as_u64().saturating_sub(since.as_u64()) > BROAD_QUERY_TIME_WINDOW_SECONDS + } + _ => false, + } + } +} + impl TangleRuntime { pub fn open(config: BaseRelayRuntimeConfig) -> Result<Self, BaseRelayError> { let limits = TangleRuntimeLimits::from_config(&config)?; @@ -529,7 +682,9 @@ impl TangleRuntimeShared { return Some(message); } } - if query_is_broad(request.filters) + if TangleQueryClassifier::new(self.limits.base_relay_limits()) + .classify(request.scope, request.filters) + .is_broad() && let Some(message) = self.rate_limit_closed( request.subscription_id, TangleRateLimitKey::query_class(request.scope, TangleRateLimitQueryClass::Broad), @@ -992,16 +1147,6 @@ fn filter_kinds(filters: &[Filter]) -> Vec<Kind> { .collect() } -fn query_is_broad(filters: &[Filter]) -> bool { - filters.iter().any(|filter| { - filter.ids().is_empty() - && filter.authors().is_empty() - && filter.kinds().is_empty() - && filter.tag_filters().is_empty() - && filter.search().is_none() - }) -} - impl fmt::Debug for TangleRuntimeHandle { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("TangleRuntimeHandle") @@ -1639,7 +1784,9 @@ impl Default for TangleShutdownSignal { #[cfg(test)] mod tests { use super::{ - TangleClientRateLimitContext, TangleRuntime, TangleRuntimeHandle, TangleRuntimeLimits, + BROAD_QUERY_TIME_WINDOW_SECONDS, TangleBroadQueryReason, TangleClientRateLimitContext, + TangleQueryClassification, TangleQueryClassifier, TangleRuntime, TangleRuntimeHandle, + TangleRuntimeLimits, }; use crate::config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}; use crate::event_bus::{TangleEventBus, TangleEventReceiveError}; @@ -2721,6 +2868,68 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn query_classifier_identifies_broad_count_shapes() { + let classifier = TangleQueryClassifier::new(runtime_relay_limits(8)); + let empty_filter = filter_from_value(&json!({})).expect("filter"); + let tag_only_filter = + filter_from_value(&json!({"#t": ["market"], "limit": 1})).expect("filter"); + let kind_only_filter = + filter_from_value(&json!({"kinds": [1], "limit": 1})).expect("filter"); + let high_limit_filter = + filter_from_value(&json!({"kinds": [1], "#h": ["Farm"], "limit": 500})) + .expect("filter"); + let broad_time_filter = filter_from_value(&json!({ + "kinds": [1], + "since": 1, + "until": BROAD_QUERY_TIME_WINDOW_SECONDS + 2, + "limit": 1 + })) + .expect("filter"); + let bounded_group_filter = + filter_from_value(&json!({"kinds": [1], "#h": ["Farm"], "limit": 1})).expect("filter"); + let bounded_time_filter = filter_from_value(&json!({ + "kinds": [1], + "since": 1, + "until": BROAD_QUERY_TIME_WINDOW_SECONDS, + "limit": 1 + })) + .expect("filter"); + + assert_eq!( + classifier.classify_count(&[]), + TangleQueryClassification::Broad(TangleBroadQueryReason::EmptyFilters) + ); + assert_eq!( + classifier.classify_count(&[empty_filter]), + TangleQueryClassification::Broad(TangleBroadQueryReason::MissingPrimaryConstraint) + ); + assert_eq!( + classifier.classify_count(&[tag_only_filter]), + TangleQueryClassification::Broad(TangleBroadQueryReason::MissingPrimaryConstraint) + ); + assert_eq!( + classifier.classify_count(&[kind_only_filter]), + TangleQueryClassification::Broad(TangleBroadQueryReason::MissingBoundedSelector) + ); + assert_eq!( + classifier.classify_count(&[high_limit_filter]), + TangleQueryClassification::Broad(TangleBroadQueryReason::HighLimit) + ); + assert_eq!( + classifier.classify_count(&[broad_time_filter]), + TangleQueryClassification::Broad(TangleBroadQueryReason::BroadTimeWindow) + ); + assert_eq!( + classifier.classify_count(&[bounded_group_filter]), + TangleQueryClassification::Bounded + ); + assert_eq!( + classifier.classify_count(&[bounded_time_filter]), + TangleQueryClassification::Bounded + ); + } + #[tokio::test] async fn runtime_rate_limits_count_peer_ips() { let root = temp_root("runtime-count-ip-rate-limit");