Skip to main content

monocoque_core/pubsub/
hub.rs

1//! PUB/SUB Hub (Phase 3)
2//!
3//! Responsibilities:
4//! - Maintain a stable mapping from `RoutingID` -> `PeerKey` (compact u64).
5//! - Track active peers with an Epoch to avoid ghost-peer races.
6//! - Apply SUB / UNSUB commands to the `SubscriptionIndex`.
7//! - Fan out published messages to matching peers (zero-copy via Bytes).
8//!
9//! Concurrency model:
10//! - Single-threaded async task.
11//! - Uses `flume::select`! for runtime-agnostic multiplexing.
12//! - No locks on the hot publish path.
13
14use crate::pubsub::index::{PeerKey, SubscriptionIndex};
15use crate::router::PeerCmd;
16
17use bytes::Bytes;
18use flume::{Receiver, Sender};
19use hashbrown::HashMap;
20use std::collections::HashMap as StdHashMap;
21use std::collections::hash_map::RandomState;
22use std::sync::Arc;
23
24/// Map keyed by peer-reported routing identity.
25///
26/// The routing id is attacker-controlled, so this uses the per-process
27/// randomly seeded [`RandomState`] (`SipHash`) to resist hash-flooding. The
28/// `PeerKey`-keyed maps below stay on the faster default hasher because their
29/// keys are server-assigned monotonic counters, not attacker input.
30type RidMap<V> = StdHashMap<Bytes, V, RandomState>;
31
32/// Commands from application to `PubSub` Hub
33#[derive(Debug)]
34pub enum PubSubCmd {
35    /// Publish a message (frame 0 is topic)
36    Publish(Vec<Bytes>),
37    /// Close all peers
38    Close,
39}
40
41/// Events coming from peers (SUB sockets).
42///
43/// These are emitted when:
44/// - handshake completes
45/// - connection closes
46/// - SUB / UNSUB commands are parsed
47#[derive(Debug)]
48pub enum PubSubEvent {
49    PeerUp {
50        routing_id: Bytes,
51        epoch: u64,
52        tx: Sender<PeerCmd>,
53    },
54    PeerDown {
55        routing_id: Bytes,
56        epoch: u64,
57    },
58    Subscribe {
59        routing_id: Bytes,
60        prefix: Bytes,
61    },
62    Unsubscribe {
63        routing_id: Bytes,
64        prefix: Bytes,
65    },
66}
67
68/// Supervisor for PUB/SUB sockets.
69///
70/// This hub does *no* I/O itself.
71/// It only routes already-decoded messages between peers.
72pub struct PubSubHub {
73    /// Subscription index (topic -> peers)
74    index: SubscriptionIndex,
75
76    /// Stable mapping: `RoutingID` -> `PeerKey` (attacker-keyed; seeded hasher)
77    rid_to_key: RidMap<PeerKey>,
78
79    /// Reverse mapping for cleanup/debug
80    key_to_rid: HashMap<PeerKey, Bytes>,
81
82    /// Active peers: `PeerKey` -> (epoch, sender)
83    peers: HashMap<PeerKey, (u64, Sender<PeerCmd>)>,
84
85    /// Monotonic key generator
86    next_key: PeerKey,
87
88    /// Events from peers
89    hub_rx: Receiver<PubSubEvent>,
90
91    /// Messages from user (publish path)
92    user_tx_rx: Receiver<PubSubCmd>,
93}
94
95impl PubSubHub {
96    #[must_use]
97    pub fn new(hub_rx: Receiver<PubSubEvent>, user_tx_rx: Receiver<PubSubCmd>) -> Self {
98        Self {
99            index: SubscriptionIndex::new(),
100            rid_to_key: RidMap::default(),
101            key_to_rid: HashMap::new(),
102            peers: HashMap::new(),
103            next_key: 1, // reserve 0
104            hub_rx,
105            user_tx_rx,
106        }
107    }
108
109    /// Main event loop.
110    pub async fn run(mut self) {
111        use futures::FutureExt;
112        use futures::select;
113
114        loop {
115            // Use futures::select! for runtime-agnostic multiplexing
116            select! {
117                msg = self.hub_rx.recv_async().fuse() => {
118                    match msg {
119                        Ok(ev) => self.on_hub_event(ev),
120                        Err(_) => break, // shutdown
121                    }
122                }
123                msg = self.user_tx_rx.recv_async().fuse() => {
124                    match msg {
125                        Ok(cmd) => self.on_user_cmd(cmd),
126                        Err(_) => break, // shutdown
127                    }
128                }
129            }
130        }
131    }
132
133    fn on_hub_event(&mut self, ev: PubSubEvent) {
134        match ev {
135            PubSubEvent::PeerUp {
136                routing_id,
137                epoch,
138                tx,
139            } => {
140                // Resolve or allocate PeerKey
141                let key = if let Some(&k) = self.rid_to_key.get(&routing_id) {
142                    k
143                } else {
144                    let k = self.next_key;
145                    self.next_key += 1;
146                    // Single clone for both bidirectional map inserts
147                    self.key_to_rid.insert(k, routing_id.clone());
148                    self.rid_to_key.insert(routing_id, k);
149                    k
150                };
151
152                // Overwrite any previous epoch (reconnect case)
153                self.peers.insert(key, (epoch, tx));
154            }
155
156            PubSubEvent::PeerDown { routing_id, epoch } => {
157                if let Some(&key) = self.rid_to_key.get(&routing_id)
158                    && let Some((current_epoch, _)) = self.peers.get(&key)
159                    // Epoch check prevents ghost-peer removal
160                    && *current_epoch == epoch
161                {
162                    self.peers.remove(&key);
163                    self.index.remove_peer_everywhere(key);
164                }
165            }
166
167            PubSubEvent::Subscribe { routing_id, prefix } => {
168                if let Some(&key) = self.rid_to_key.get(&routing_id)
169                    && self.peers.contains_key(&key)
170                {
171                    self.index.subscribe(key, prefix);
172                }
173            }
174
175            PubSubEvent::Unsubscribe { routing_id, prefix } => {
176                if let Some(&key) = self.rid_to_key.get(&routing_id) {
177                    self.index.unsubscribe(key, &prefix);
178                }
179            }
180        }
181    }
182
183    fn on_user_cmd(&mut self, cmd: PubSubCmd) {
184        match cmd {
185            PubSubCmd::Publish(parts) => self.publish(parts),
186            PubSubCmd::Close => {
187                // Broadcast close to all peers
188                for (_, (_, tx)) in &self.peers {
189                    let _ = tx.send(PeerCmd::Close);
190                }
191            }
192        }
193    }
194
195    /// Publish a multipart message.
196    ///
197    /// ZMQ convention:
198    /// - Frame 0 is the topic
199    fn publish(&mut self, parts: Vec<Bytes>) {
200        if parts.is_empty() || self.index.is_empty() {
201            return;
202        }
203
204        let topic = &parts[0];
205        let keys = self.index.match_topic(topic);
206
207        if keys.is_empty() {
208            return;
209        }
210
211        // Zero-copy fan-out: share one allocation across all matching peers via
212        // Arc instead of cloning a fresh Vec<Bytes> per peer. Each peer gets an
213        // Arc refcount bump; the frames themselves are never re-copied.
214        let msg = Arc::new(parts);
215        for key in keys {
216            if let Some((_, tx)) = self.peers.get(&key) {
217                let _ = tx.send(PeerCmd::SendBody(Arc::clone(&msg)));
218            }
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::time::Duration;
227
228    fn b(s: &str) -> Bytes {
229        Bytes::copy_from_slice(s.as_bytes())
230    }
231
232    /// Receive the next `PeerCmd` body Arc within a timeout; None on timeout/close.
233    async fn recv_arc(rx: &Receiver<PeerCmd>) -> Option<Arc<Vec<Bytes>>> {
234        match crate::rt::timeout(Duration::from_secs(1), rx.recv_async()).await {
235            Ok(Ok(PeerCmd::SendBody(parts))) => Some(parts),
236            _ => None,
237        }
238    }
239
240    /// True if no message body arrives within a short window.
241    async fn expect_no_body(rx: &Receiver<PeerCmd>) -> bool {
242        !matches!(
243            crate::rt::timeout(Duration::from_millis(150), rx.recv_async()).await,
244            Ok(Ok(PeerCmd::SendBody(_)))
245        )
246    }
247
248    #[test]
249    fn publishes_only_to_matching_subscribers() {
250        crate::rt::LocalRuntime::new().unwrap().block_on(async {
251            let (hub_tx, hub_rx) = flume::unbounded::<PubSubEvent>();
252            let (user_tx, user_rx) = flume::unbounded::<PubSubCmd>();
253            let hub = PubSubHub::new(hub_rx, user_rx);
254            let handle = crate::rt::spawn(hub.run());
255
256            let (peer_tx, peer_rx) = flume::unbounded::<PeerCmd>();
257            hub_tx
258                .send(PubSubEvent::PeerUp {
259                    routing_id: b("sub1"),
260                    epoch: 1,
261                    tx: peer_tx,
262                })
263                .unwrap();
264            hub_tx
265                .send(PubSubEvent::Subscribe {
266                    routing_id: b("sub1"),
267                    prefix: b("weather."),
268                })
269                .unwrap();
270            crate::rt::sleep(Duration::from_millis(30)).await;
271
272            // Matching topic is delivered.
273            user_tx
274                .send(PubSubCmd::Publish(vec![b("weather.london"), b("sunny")]))
275                .unwrap();
276            let got = recv_arc(&peer_rx).await.expect("matching topic delivered");
277            assert_eq!(*got, vec![b("weather.london"), b("sunny")]);
278
279            // Non-matching topic is filtered out.
280            user_tx
281                .send(PubSubCmd::Publish(vec![b("stocks.aapl"), b("100")]))
282                .unwrap();
283            assert!(
284                expect_no_body(&peer_rx).await,
285                "non-matching topic must not be delivered"
286            );
287
288            drop(hub_tx);
289            drop(user_tx);
290            crate::rt::join(handle).await;
291        });
292    }
293
294    #[test]
295    fn fanout_shares_one_allocation_across_peers() {
296        crate::rt::LocalRuntime::new().unwrap().block_on(async {
297            let (hub_tx, hub_rx) = flume::unbounded::<PubSubEvent>();
298            let (user_tx, user_rx) = flume::unbounded::<PubSubCmd>();
299            let hub = PubSubHub::new(hub_rx, user_rx);
300            let handle = crate::rt::spawn(hub.run());
301
302            let (p1_tx, p1_rx) = flume::unbounded::<PeerCmd>();
303            let (p2_tx, p2_rx) = flume::unbounded::<PeerCmd>();
304            for (rid, tx) in [(b("s1"), p1_tx), (b("s2"), p2_tx)] {
305                hub_tx
306                    .send(PubSubEvent::PeerUp {
307                        routing_id: rid.clone(),
308                        epoch: 1,
309                        tx,
310                    })
311                    .unwrap();
312                hub_tx
313                    .send(PubSubEvent::Subscribe {
314                        routing_id: rid,
315                        prefix: b(""), // subscribe to all
316                    })
317                    .unwrap();
318            }
319            crate::rt::sleep(Duration::from_millis(30)).await;
320
321            user_tx
322                .send(PubSubCmd::Publish(vec![b("news"), b("hello")]))
323                .unwrap();
324
325            let a = recv_arc(&p1_rx).await.expect("peer 1 delivered");
326            let c = recv_arc(&p2_rx).await.expect("peer 2 delivered");
327            assert_eq!(*a, vec![b("news"), b("hello")]);
328            assert_eq!(*c, vec![b("news"), b("hello")]);
329            // Both peers received the SAME allocation: fan-out shared one Arc
330            // instead of cloning a fresh Vec<Bytes> per peer.
331            assert!(
332                Arc::ptr_eq(&a, &c),
333                "fan-out must share one Arc allocation across peers"
334            );
335
336            drop(hub_tx);
337            drop(user_tx);
338            crate::rt::join(handle).await;
339        });
340    }
341
342    #[test]
343    fn peer_down_with_stale_epoch_is_ignored() {
344        crate::rt::LocalRuntime::new().unwrap().block_on(async {
345            let (hub_tx, hub_rx) = flume::unbounded::<PubSubEvent>();
346            let (user_tx, user_rx) = flume::unbounded::<PubSubCmd>();
347            let hub = PubSubHub::new(hub_rx, user_rx);
348            let handle = crate::rt::spawn(hub.run());
349
350            let (peer_tx, peer_rx) = flume::unbounded::<PeerCmd>();
351            hub_tx
352                .send(PubSubEvent::PeerUp {
353                    routing_id: b("sub1"),
354                    epoch: 2,
355                    tx: peer_tx,
356                })
357                .unwrap();
358            hub_tx
359                .send(PubSubEvent::Subscribe {
360                    routing_id: b("sub1"),
361                    prefix: b(""),
362                })
363                .unwrap();
364            // A PeerDown carrying a stale epoch must NOT evict the live peer.
365            hub_tx
366                .send(PubSubEvent::PeerDown {
367                    routing_id: b("sub1"),
368                    epoch: 1,
369                })
370                .unwrap();
371            crate::rt::sleep(Duration::from_millis(30)).await;
372
373            user_tx
374                .send(PubSubCmd::Publish(vec![b("x"), b("still-here")]))
375                .unwrap();
376            let got = recv_arc(&peer_rx)
377                .await
378                .expect("stale-epoch PeerDown must not evict the peer");
379            assert_eq!(*got, vec![b("x"), b("still-here")]);
380
381            drop(hub_tx);
382            drop(user_tx);
383            crate::rt::join(handle).await;
384        });
385    }
386}