Skip to main content

mqtt_topic_engine/
topic_router.rs

1//! Subscription router built on the topic matcher.
2//!
3//! [`TopicRouter`] maps MQTT subscription patterns to caller-supplied payloads
4//! `T`, assigns each a [`SubscriptionId`], and resolves all payloads whose
5//! pattern matches a delivered topic. It also tracks the set of distinct broker
6//! subscriptions (with their effective QoS) so callers know when to actually
7//! subscribe or unsubscribe on the wire.
8
9#![allow(clippy::missing_docs_in_private_items)]
10use std::collections::{HashMap, HashSet};
11use std::fmt::Display;
12
13use arcstr::ArcStr;
14use thiserror::Error;
15
16use crate::qos::QoS;
17use crate::topic_match::TopicPath;
18use crate::topic_matcher::{TopicMatcherError, TopicMatcherNode};
19use crate::topic_pattern_item::TopicPatternError;
20use crate::topic_pattern_path::TopicPatternPath;
21
22/// Errors that can occur during topic routing operations
23#[derive(Error, Debug, Clone, PartialEq, Eq)]
24pub enum TopicRouterError {
25	/// Topic pattern validation failed
26	#[error("Invalid topic pattern: {0}")]
27	InvalidPattern(#[from] TopicPatternError),
28
29	/// Topic matching operation failed
30	#[error("Topic matching failed: {0}")]
31	MatchingFailed(#[from] TopicMatcherError),
32
33	/// Subscription with given ID was not found
34	#[error("Subscription {id:?} not found")]
35	SubscriptionNotFound {
36		/// The subscription id that could not be found.
37		id: SubscriptionId,
38	},
39
40	/// Topic is invalid for routing operations
41	#[error("Topic '{topic}' is invalid for routing: {reason}")]
42	InvalidRoutingTopic {
43		/// The topic that was rejected.
44		topic: String,
45		/// Why the topic is invalid for routing.
46		reason: String,
47	},
48
49	/// Internal state corruption detected
50	#[error("Internal routing state corrupted: {details}")]
51	InternalStateCorrupted {
52		/// Description of the detected inconsistency.
53		details: String,
54	},
55}
56
57impl TopicRouterError {
58	/// Creates a new SubscriptionNotFound error
59	pub fn subscription_not_found(id: SubscriptionId) -> Self {
60		Self::SubscriptionNotFound { id }
61	}
62
63	/// Creates a new InvalidRoutingTopic error
64	pub fn invalid_routing_topic(
65		topic: impl Into<String>,
66		reason: impl Into<String>,
67	) -> Self {
68		Self::InvalidRoutingTopic {
69			topic: topic.into(),
70			reason: reason.into(),
71		}
72	}
73
74	/// Creates a new InternalStateCorrupted error
75	pub fn internal_state_corrupted(details: impl Into<String>) -> Self {
76		Self::InternalStateCorrupted {
77			details: details.into(),
78		}
79	}
80}
81
82/// A subscription identifier.
83///
84/// Used for tracking individual subscriptions and handling cancellation errors.
85/// Primarily useful for advanced error handling and debugging.
86#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone)]
87pub struct SubscriptionId(usize);
88
89impl Display for SubscriptionId {
90	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91		write!(f, "SubscriptionId({})", self.0)
92	}
93}
94
95type SubscriptionTable<T> = HashMap<SubscriptionId, T>;
96//type RouteCallback = Box<dyn for<'a, 'b> Fn(&'a str, &'b [u8]) + Send + Sync>;
97
98/// Routes MQTT topics to subscription payloads.
99///
100/// Each subscription pattern is associated with a payload `T` and a unique
101/// [`SubscriptionId`]. Delivered topics are matched against all stored patterns
102/// (including `+`/`#` wildcards); the router also bookkeeps which distinct
103/// broker subscriptions are active and at what QoS.
104pub struct TopicRouter<T> {
105	topic_matcher: TopicMatcherNode<SubscriptionTable<T>>,
106	subscriptions: SubscriptionTable<(TopicPatternPath, QoS)>,
107	next_id: usize,
108}
109
110impl<T> Default for TopicRouter<T> {
111	fn default() -> Self {
112		Self::new()
113	}
114}
115
116impl<T> TopicRouter<T> {
117	/// Creates an empty router with no subscriptions.
118	pub fn new() -> Self {
119		Self {
120			topic_matcher: TopicMatcherNode::new(),
121			subscriptions: SubscriptionTable::new(),
122			next_id: 0,
123		}
124	}
125
126	/// Registers a subscription for `topic` with the given `qos` and payload.
127	///
128	/// Returns `(needs_subscribe, id)`: `needs_subscribe` is `true` when this is
129	/// the first subscription for the pattern or it raises the effective QoS, so
130	/// the caller must (re)subscribe on the broker; `id` identifies the new
131	/// subscription for later [`unsubscribe`](Self::unsubscribe).
132	pub fn add_subscription(
133		&mut self,
134		topic: TopicPatternPath,
135		qos: impl Into<QoS>,
136		subscription: T,
137	) -> (bool, SubscriptionId) {
138		let qos = qos.into();
139		let subscription_table =
140			self.topic_matcher.get_or_create_subscription_table(&topic);
141		let needs_subscribe = subscription_table
142			.keys()
143			.map(|id| {
144				self.subscriptions
145					.get(id)
146					.unwrap_or_else(|| {
147						panic!(
148							"BUG: Subscription ID {id:?} exists in topic \
149							 matcher but missing from subscriptions. Topic: \
150							 {topic}"
151						)
152					})
153					.1
154			})
155			.max_by_key(|qos| *qos as u8)
156			.is_none_or(|max| qos > max);
157
158		let id = SubscriptionId(self.next_id);
159		self.next_id = self.next_id.wrapping_add(1);
160
161		subscription_table.insert(id, subscription);
162		self.subscriptions.insert(id, (topic, qos));
163
164		(needs_subscribe, id)
165	}
166
167	/// Removes the subscription identified by `id`.
168	///
169	/// Returns `(topic_now_empty, pattern)`: `topic_now_empty` is `true` when no
170	/// other subscription remains for the pattern, so the caller should
171	/// unsubscribe it on the broker. Fails with
172	/// [`TopicRouterError::SubscriptionNotFound`] if `id` is unknown.
173	pub fn unsubscribe(
174		&mut self,
175		id: &SubscriptionId,
176	) -> Result<(bool, TopicPatternPath), TopicRouterError> {
177		// TODO(enhancement): Implement QoS downgrade.
178		// We currently keep the higher QoS on the broker after a removal (safe
179		// but potentially wasteful). To implement: after removing this id,
180		// recompute the max QoS among the remaining subscribers for the pattern
181		// — `get_max_qos_for_topic` below is the ready-made helper for that — and
182		// mirror the QoS-raising logic in `add_subscription`.
183		let topic = self.subscriptions.remove(id);
184		match topic {
185			| Some((topic_pattern, _qos)) => {
186				let resolved_segments = topic_pattern.resolve_bound_segments();
187				let topic_now_empty = self.topic_matcher.update_node(
188					&resolved_segments,
189					|table| {
190						table.remove(id);
191					},
192				)?;
193				Ok((topic_now_empty, topic_pattern))
194			}
195			| None => Err(TopicRouterError::subscription_not_found(*id)),
196		}
197	}
198
199	/// Returns every subscription whose pattern matches `topic`.
200	///
201	/// Each entry is `(id, (pattern, qos), payload)` for a matching subscription
202	/// (one delivered topic may match several patterns via `+`/`#` wildcards).
203	pub fn get_subscribers<'a>(
204		&'a self,
205		topic: &TopicPath,
206	) -> Vec<(&'a SubscriptionId, &'a (TopicPatternPath, QoS), &'a T)> {
207		let subscribers = self.topic_matcher.find_by_path(topic);
208		subscribers
209			.into_iter()
210			.flat_map(|hash_map| hash_map.iter())
211			.map(|(id, subscription)| {
212				let topic_pattern = self
213					.subscriptions
214					.get(id)
215					.expect("Subscription ID should exist in subscriptions");
216				(id, topic_pattern, subscription)
217			})
218			.collect()
219	}
220
221	/// Iterates over every active subscription as `(pattern, qos)`.
222	///
223	/// Patterns are not deduplicated; multiple subscriptions may share one.
224	pub fn get_active_subscriptions(
225		&self,
226	) -> impl Iterator<Item = &(TopicPatternPath, QoS)> {
227		self.subscriptions.values()
228	}
229
230	/// Finds the maximum QoS among the subscribers to a single topic pattern.
231	///
232	/// Intentionally unused for now: it is the ready-made helper for the QoS
233	/// downgrade described in the TODO inside [`unsubscribe`](Self::unsubscribe).
234	/// The QoS-raising counterpart is currently inlined in
235	/// [`add_subscription`](Self::add_subscription); when downgrade is
236	/// implemented, the two should share this helper.
237	#[allow(dead_code)] // Kept as the helper for the planned QoS-downgrade in unsubscribe()
238	fn get_max_qos_for_topic(
239		&self,
240		topic: &TopicPatternPath,
241		topic_subscriptions: &HashMap<SubscriptionId, T>,
242	) -> QoS {
243		debug_assert!(
244			!topic_subscriptions.is_empty(),
245			"topic_subscriptions should never be empty - this is guaranteed \
246			 by collect_active_subscriptions()"
247		);
248
249		let max_qos = topic_subscriptions
250			.keys()
251			.map(|id| {
252				self.subscriptions
253					.get(id)
254					.unwrap_or_else(|| {
255						panic!(
256							"BUG: Subscription ID {id:?} exists in topic \
257							 matcher but missing from subscriptions. Topic: \
258							 {topic}"
259						)
260					})
261					.1
262			})
263			.max_by_key(|qos| *qos as u8)
264			.unwrap();
265		max_qos
266	}
267
268	/// Get all unique active topic patterns
269	pub fn get_topics_for_unsubscribe(&self) -> HashSet<ArcStr> {
270		self.subscriptions
271			.values()
272			.map(|(topic, _)| topic.mqtt_pattern())
273			.collect()
274	}
275
276	/// Get all active topic patterns with their maximum QoS
277	/// Returns unique topics (grouped by pattern) with the highest QoS among all subscribers
278	pub fn get_topics_for_resubscribe(&self) -> HashMap<ArcStr, QoS> {
279		let mut result: HashMap<ArcStr, QoS> = HashMap::new();
280
281		for (topic, qos) in self.subscriptions.values() {
282			let mqtt_pattern = topic.mqtt_pattern();
283			result
284				.entry(mqtt_pattern)
285				.and_modify(|existing_qos| {
286					if *qos > *existing_qos {
287						*existing_qos = *qos;
288					}
289				})
290				.or_insert(*qos);
291		}
292
293		result
294	}
295
296	/// Cleanup all internal data structures and close subscriber channels
297	/// This method is called during shutdown to ensure proper resource cleanup
298	pub fn cleanup(&mut self) {
299		// Replacing topic_matcher with new instance triggers Drop for all subscription channels
300		// This ensures all subscribers receive a channel close signal
301		self.topic_matcher = TopicMatcherNode::new();
302		self.subscriptions.clear();
303		self.next_id = 0;
304	}
305
306	/// Looks up the `(pattern, qos)` registered for a subscription `id`.
307	///
308	/// Fails with [`TopicRouterError::SubscriptionNotFound`] if `id` is unknown.
309	pub fn get_topic_by_id(
310		&self,
311		id: &SubscriptionId,
312	) -> Result<&(TopicPatternPath, QoS), TopicRouterError> {
313		self.subscriptions
314			.get(id)
315			.ok_or(TopicRouterError::subscription_not_found(*id))
316	}
317}