Skip to main content

monocoque_core/
subscription.rs

1//! Subscription trie for efficient topic matching in XPUB/XSUB/SUB sockets.
2//!
3//! This provides a more efficient subscription matching mechanism than linear
4//! scanning, especially for large numbers of subscriptions.
5
6use bytes::{Bytes, BytesMut};
7use std::collections::BTreeSet;
8
9/// A subscription entry with topic prefix
10#[derive(Debug, Clone)]
11pub struct Subscription {
12    /// Topic prefix (empty = subscribe to all)
13    pub prefix: Bytes,
14}
15
16impl Subscription {
17    /// Create a new subscription for a topic prefix
18    #[must_use]
19    pub const fn new(prefix: Bytes) -> Self {
20        Self { prefix }
21    }
22
23    /// Check if this subscription matches a given topic
24    #[must_use]
25    pub fn matches(&self, topic: &[u8]) -> bool {
26        // Empty prefix matches everything
27        if self.prefix.is_empty() {
28            return true;
29        }
30
31        // Check if topic starts with prefix
32        topic.len() >= self.prefix.len() && topic[..self.prefix.len()] == self.prefix[..]
33    }
34}
35
36/// Efficient subscription storage using a sorted set for O(log N) operations.
37///
38/// Backed by a `BTreeSet<Vec<u8>>` which allows O(log N) prefix searching:
39/// find the largest stored prefix ≤ the topic, then check if it is a prefix
40/// of the topic.  Subscribe/unsubscribe are also O(log N).
41#[derive(Debug, Default)]
42pub struct SubscriptionTrie {
43    prefixes: BTreeSet<Vec<u8>>,
44}
45
46impl SubscriptionTrie {
47    /// Create a new empty subscription trie
48    #[must_use]
49    pub fn new() -> Self {
50        Self {
51            prefixes: BTreeSet::new(),
52        }
53    }
54
55    /// Add a subscription
56    pub fn subscribe(&mut self, prefix: Bytes) {
57        self.prefixes.insert(prefix.to_vec());
58    }
59
60    /// Remove a subscription
61    pub fn unsubscribe(&mut self, prefix: &Bytes) {
62        self.prefixes.remove(prefix.as_ref());
63    }
64
65    /// Check if a topic matches any subscription
66    ///
67    /// Returns true if the topic should be delivered.
68    /// Checks only prefixes of the topic, with one `BTreeSet` lookup per prefix.
69    #[must_use]
70    pub fn matches(&self, topic: &[u8]) -> bool {
71        if self.prefixes.is_empty() {
72            return false;
73        }
74
75        (0..=topic.len()).any(|len| self.prefixes.contains(&topic[..len]))
76    }
77
78    /// Get all subscriptions as a `Vec<Subscription>`.
79    #[must_use]
80    pub fn subscriptions(&self) -> Vec<Subscription> {
81        self.prefixes
82            .iter()
83            .map(|p| Subscription::new(Bytes::copy_from_slice(p)))
84            .collect()
85    }
86
87    /// Check if there are no subscriptions
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.prefixes.is_empty()
91    }
92
93    /// Get the number of subscriptions
94    #[must_use]
95    pub fn len(&self) -> usize {
96        self.prefixes.len()
97    }
98
99    /// Clear all subscriptions
100    pub fn clear(&mut self) {
101        self.prefixes.clear();
102    }
103}
104
105/// Check whether a topic matches any subscription prefix.
106///
107/// An empty prefix matches all topics. An empty prefix list matches none.
108#[must_use]
109pub fn topic_matches_prefixes(topic: &[u8], prefixes: &[Bytes]) -> bool {
110    prefixes
111        .iter()
112        .any(|prefix| prefix.is_empty() || topic.starts_with(prefix))
113}
114
115/// Subscription event for XPUB socket
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SubscriptionEvent {
118    /// A peer subscribed to a topic
119    Subscribe(Bytes),
120    /// A peer unsubscribed from a topic
121    Unsubscribe(Bytes),
122}
123
124impl SubscriptionEvent {
125    /// Create a subscription event from a ZMTP subscription message
126    ///
127    /// Format: [0x01|0x00] [topic prefix...]
128    #[must_use]
129    pub fn from_message(msg: &[u8]) -> Option<Self> {
130        if msg.is_empty() {
131            return None;
132        }
133
134        let prefix = Bytes::copy_from_slice(&msg[1..]);
135        match msg[0] {
136            0x01 => Some(Self::Subscribe(prefix)),
137            0x00 => Some(Self::Unsubscribe(prefix)),
138            _ => None,
139        }
140    }
141
142    /// Create a subscription event from an owned payload without copying the prefix.
143    #[must_use]
144    pub fn from_bytes(msg: Bytes) -> Option<Self> {
145        if msg.is_empty() {
146            return None;
147        }
148
149        let prefix = msg.slice(1..);
150        match msg[0] {
151            0x01 => Some(Self::Subscribe(prefix)),
152            0x00 => Some(Self::Unsubscribe(prefix)),
153            _ => None,
154        }
155    }
156
157    /// Encode this event as a ZMTP subscription message
158    #[must_use]
159    pub fn to_message(&self) -> Bytes {
160        let (cmd, prefix) = match self {
161            Self::Subscribe(p) => (0x01u8, p),
162            Self::Unsubscribe(p) => (0x00u8, p),
163        };
164
165        let mut msg = BytesMut::with_capacity(1 + prefix.len());
166        msg.extend_from_slice(&[cmd]);
167        msg.extend_from_slice(prefix);
168        msg.freeze()
169    }
170
171    /// Get the topic prefix
172    #[must_use]
173    pub const fn prefix(&self) -> &Bytes {
174        match self {
175            Self::Subscribe(p) | Self::Unsubscribe(p) => p,
176        }
177    }
178
179    /// Check if this is a subscribe event
180    #[must_use]
181    pub const fn is_subscribe(&self) -> bool {
182        matches!(self, Self::Subscribe(_))
183    }
184
185    /// Check if this is an unsubscribe event
186    #[must_use]
187    pub const fn is_unsubscribe(&self) -> bool {
188        matches!(self, Self::Unsubscribe(_))
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_subscription_matches() {
198        let sub = Subscription::new(Bytes::from_static(b"topic."));
199
200        assert!(sub.matches(b"topic.foo"));
201        assert!(sub.matches(b"topic.bar"));
202        assert!(!sub.matches(b"other.foo"));
203        assert!(!sub.matches(b"topi"));
204    }
205
206    #[test]
207    fn test_empty_subscription_matches_all() {
208        let sub = Subscription::new(Bytes::new());
209
210        assert!(sub.matches(b"anything"));
211        assert!(sub.matches(b""));
212    }
213
214    #[test]
215    fn test_trie_basic() {
216        let mut trie = SubscriptionTrie::new();
217
218        assert!(!trie.matches(b"topic.foo"));
219
220        trie.subscribe(Bytes::from_static(b"topic."));
221        assert!(trie.matches(b"topic.foo"));
222        assert!(!trie.matches(b"other.foo"));
223
224        trie.unsubscribe(&Bytes::from_static(b"topic."));
225        assert!(!trie.matches(b"topic.foo"));
226    }
227
228    #[test]
229    fn test_trie_multiple_subscriptions() {
230        let mut trie = SubscriptionTrie::new();
231
232        trie.subscribe(Bytes::from_static(b"topic."));
233        trie.subscribe(Bytes::from_static(b"events."));
234
235        assert!(trie.matches(b"topic.foo"));
236        assert!(trie.matches(b"events.bar"));
237        assert!(!trie.matches(b"other.baz"));
238    }
239
240    #[test]
241    fn test_trie_empty_prefix_matches_all() {
242        let mut trie = SubscriptionTrie::new();
243        trie.subscribe(Bytes::new());
244
245        assert!(trie.matches(b"anything"));
246        assert!(trie.matches(b""));
247    }
248
249    #[test]
250    fn test_trie_no_false_prefix_match() {
251        let mut trie = SubscriptionTrie::new();
252        trie.subscribe(Bytes::from_static(b"topic."));
253
254        // "topic" is a prefix of "topic." but "topic." is NOT a prefix of "topic"
255        assert!(!trie.matches(b"topic"));
256        assert!(trie.matches(b"topic."));
257        assert!(trie.matches(b"topic.sub"));
258    }
259
260    #[test]
261    fn test_trie_checks_broader_prefix_after_non_matching_candidate() {
262        let mut trie = SubscriptionTrie::new();
263        trie.subscribe(Bytes::from_static(b"a"));
264        trie.subscribe(Bytes::from_static(b"aa~"));
265
266        assert!(trie.matches(b"ab"));
267    }
268
269    #[test]
270    fn test_subscription_event() {
271        let sub = SubscriptionEvent::Subscribe(Bytes::from_static(b"topic"));
272        let msg = sub.to_message();
273
274        assert_eq!(msg[0], 0x01);
275        assert_eq!(&msg[1..], b"topic");
276
277        let parsed = SubscriptionEvent::from_message(&msg).unwrap();
278        assert_eq!(parsed, sub);
279    }
280
281    #[test]
282    fn test_unsubscription_event() {
283        let unsub = SubscriptionEvent::Unsubscribe(Bytes::from_static(b"topic"));
284        let msg = unsub.to_message();
285
286        assert_eq!(msg[0], 0x00);
287        assert_eq!(&msg[1..], b"topic");
288
289        let parsed = SubscriptionEvent::from_message(&msg).unwrap();
290        assert_eq!(parsed, unsub);
291    }
292}