Skip to main content

klieo_bus_memory/
pubsub.rs

1//! In-process `Pubsub` implementation.
2//!
3//! Backed by `tokio::sync::broadcast` per subscription pattern. Each
4//! `subscribe` call returns a fresh receiver; messages published *after*
5//! subscribe are delivered (broadcast semantics — no replay of
6//! pre-subscribe messages). Messages are reconstructed per subscriber so
7//! each gets a fresh [`klieo_core::Msg`] with its own no-op
8//! [`klieo_core::AckHandle`].
9//!
10//! # NATS wildcard parity
11//!
12//! Subscribe patterns mirror NATS subject semantics (wildcard matching
13//! is faithful; delivery guarantees are NOT — see the divergence section
14//! below):
15//!
16//! - Tokens are dot-separated.
17//! - `*` matches exactly one token at its position.
18//! - `>` matches one or more remaining tokens; valid only as the LAST
19//!   token. A pattern that uses `>` anywhere else matches nothing.
20//! - Concrete (wildcard-free) patterns match only their literal subject.
21//! - Empty tokens (`a..b`) are rejected (no match).
22//!
23//! On publish, every live subscription whose pattern matches the
24//! published subject receives the message.
25//!
26//! The default per-subscription channel capacity is 1024. Slow
27//! subscribers may see `BusError::Retryable("subscriber lagged")` if
28//! they fall behind.
29//!
30//! # Delivery-guarantee divergence from NATS — READ BEFORE RELYING ON IT
31//!
32//! This in-process bus does NOT honour two parts of the
33//! [`klieo_core::Pubsub`] contract that the NATS impl does. It is a
34//! fan-out simulator, not a faithful JetStream substitute for these:
35//!
36//! - **`durable` is ignored.** Every `subscribe` gets its own broadcast
37//!   receiver, so multiple subscribers with the SAME durable name each
38//!   receive EVERY message (fan-out). They do NOT form a competing-consumer
39//!   group that load-balances messages the way NATS durables do. Code that
40//!   relies on competing-consumer semantics behaves differently here vs NATS.
41//! - **`nak`/`term` are no-ops; there is no redelivery.** A `nak` does not
42//!   re-queue the message. Retry logic built on redelivery passes its tests
43//!   on this bus but will only actually retry on NATS.
44//!
45//! Use [`klieo-bus-nats`](../../klieo_bus_nats/index.html) when those
46//! guarantees matter.
47
48use async_trait::async_trait;
49use bytes::Bytes;
50use klieo_core::bus::{AckHandle, AckHandleImpl, Headers, Msg, MsgStream, Pubsub};
51use klieo_core::error::BusError;
52use klieo_core::ids::DurableName;
53use std::collections::HashMap;
54use std::sync::Arc;
55use std::time::Duration;
56use tokio::sync::{broadcast, Mutex};
57use tokio_stream::wrappers::BroadcastStream;
58use tokio_stream::StreamExt;
59
60const DEFAULT_CAPACITY: usize = 1024;
61
62type PatternSender = broadcast::Sender<(String, Bytes, Headers)>;
63
64struct State {
65    /// Subscribers whose pattern is fully concrete (no `*` / `>`).
66    /// O(1) lookup on publish — the common case for per-id subjects
67    /// like `klieo.a2a.task.t-1`, `klieo.mcp.progress.tok-1`, and
68    /// `_reply.{id}`. The broadcast payload carries the concrete
69    /// published subject alongside the body/headers so the delivery
70    /// shape matches the wildcard path.
71    concretes: HashMap<String, PatternSender>,
72    /// Subscribers whose pattern contains `*` or `>`. Linear-scanned
73    /// on publish; expected to stay tiny (one per cancel-fanout
74    /// subscriber per replica).
75    wildcards: Vec<(String, PatternSender)>,
76    /// Per-channel capacity, applied lazily on first subscribe.
77    capacity: usize,
78}
79
80impl Default for State {
81    fn default() -> Self {
82        Self {
83            concretes: HashMap::new(),
84            wildcards: Vec::new(),
85            capacity: DEFAULT_CAPACITY,
86        }
87    }
88}
89
90/// `true` when the pattern contains a NATS wildcard token (`*` or
91/// `>`). Subscribers with such patterns go into `State.wildcards`;
92/// everything else goes into `State.concretes` for O(1) publish
93/// lookup.
94fn is_wildcard_pattern(pattern: &str) -> bool {
95    pattern.contains('*') || pattern.contains('>')
96}
97
98/// In-process `Pubsub` impl.
99#[derive(Clone)]
100pub struct MemoryPubsub {
101    state: Arc<Mutex<State>>,
102}
103
104impl MemoryPubsub {
105    /// Build an empty pubsub with the default 1024-message per-channel buffer.
106    pub fn new() -> Self {
107        Self::with_buffer_size(DEFAULT_CAPACITY)
108    }
109
110    /// Build an empty pubsub with a custom per-channel buffer size.
111    /// Larger buffers tolerate slow subscribers; smaller buffers reject
112    /// fast publishers earlier (slow subscribers see
113    /// `BusError::Retryable("subscriber lagged")`).
114    pub fn with_buffer_size(buffer: usize) -> Self {
115        Self {
116            state: Arc::new(Mutex::new(State {
117                concretes: HashMap::new(),
118                wildcards: Vec::new(),
119                capacity: buffer.max(1),
120            })),
121        }
122    }
123
124    /// Get-or-create the broadcast sender for a subscription pattern.
125    /// Used by `subscribe` only — `publish` MUST NOT create new entries
126    /// so the per-pattern stores do not grow with publish-only
127    /// subjects. Routes concrete patterns into `State.concretes`
128    /// (O(1) publish lookup) and wildcard patterns into
129    /// `State.wildcards` (linear scan only when needed).
130    async fn subscribe_sender(&self, pattern: &str) -> PatternSender {
131        let mut g = self.state.lock().await;
132        let cap = g.capacity;
133        if is_wildcard_pattern(pattern) {
134            if let Some((_, tx)) = g.wildcards.iter().find(|(p, _)| p == pattern) {
135                return tx.clone();
136            }
137            let (tx, _rx) = broadcast::channel(cap);
138            g.wildcards.push((pattern.to_string(), tx.clone()));
139            tx
140        } else {
141            g.concretes
142                .entry(pattern.to_string())
143                .or_insert_with(|| {
144                    let (tx, _rx) = broadcast::channel(cap);
145                    tx
146                })
147                .clone()
148        }
149    }
150
151    /// Drop the broadcast channel for `pattern`. Used by
152    /// [`crate::request_reply::MemoryRequestReply`] to clean up the
153    /// short-lived `_reply.{id}` subjects it mints per RPC — without
154    /// this the per-pattern stores grow unbounded with one entry
155    /// per request.
156    ///
157    /// Safe to call when no entry exists; behaves as a no-op. Removes
158    /// from whichever store (concrete or wildcard) holds the entry.
159    pub async fn remove_subject(&self, pattern: &str) {
160        let mut g = self.state.lock().await;
161        if g.concretes.remove(pattern).is_some() {
162            return;
163        }
164        g.wildcards.retain(|(p, _)| p != pattern);
165    }
166
167    /// Number of active subscription patterns currently retained
168    /// across both concrete and wildcard stores. Test/observability
169    /// helper; production callers should not rely on the exact value.
170    pub async fn subject_count(&self) -> usize {
171        let g = self.state.lock().await;
172        g.concretes.len() + g.wildcards.len()
173    }
174}
175
176impl Default for MemoryPubsub {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[async_trait]
183impl Pubsub for MemoryPubsub {
184    async fn publish(
185        &self,
186        subject: &str,
187        payload: Bytes,
188        headers: Headers,
189    ) -> Result<(), BusError> {
190        // Snapshot the matching senders under the lock, then send
191        // outside the lock so a slow subscriber cannot block publish.
192        // Concrete subscribers resolve via O(1) HashMap lookup;
193        // wildcards (`*` / `>`) require a linear scan but the vec
194        // stays tiny (one per cancel-fanout subscriber per replica).
195        let matched: Vec<PatternSender> = {
196            let g = self.state.lock().await;
197            let mut out: Vec<PatternSender> = Vec::new();
198            if let Some(tx) = g.concretes.get(subject) {
199                out.push(tx.clone());
200            }
201            for (pattern, tx) in &g.wildcards {
202                if subject_matches(pattern, subject) {
203                    out.push(tx.clone());
204                }
205            }
206            out
207        };
208        for tx in matched {
209            // `send` returns Err only when there are no active receivers
210            // — not an error in pub/sub semantics.
211            let _ = tx.send((subject.to_string(), payload.clone(), headers.clone()));
212        }
213        Ok(())
214    }
215
216    async fn subscribe(&self, subject: &str, _durable: DurableName) -> Result<MsgStream, BusError> {
217        let tx = self.subscribe_sender(subject).await;
218        let rx = tx.subscribe();
219        let pattern = subject.to_string();
220        let stream = BroadcastStream::new(rx).map(move |res| match res {
221            Ok((concrete_subject, payload, headers)) => Ok(Msg {
222                // Mirror NATS: deliver the concrete published subject,
223                // not the subscription filter. Wildcard consumers need
224                // this to recover which subject actually fired (e.g.
225                // strip prefix to extract the task id).
226                subject: concrete_subject,
227                payload,
228                headers,
229                ack: AckHandle::new(Box::new(NoopAck)),
230            }),
231            Err(_) => {
232                tracing::warn!(
233                    target: "klieo.bus.memory",
234                    pattern = %pattern,
235                    "subscriber lagged - increase MemoryPubsub buffer size or speed up consumer"
236                );
237                Err(BusError::Retryable("subscriber lagged".into()))
238            }
239        });
240        Ok(Box::pin(stream))
241    }
242}
243
244/// NATS-compatible subject matcher. Returns `true` when `concrete` (a
245/// wildcard-free published subject) matches the subscription `pattern`
246/// per NATS rules:
247///
248/// - `*` matches exactly one token at its position.
249/// - `>` matches one or more remaining tokens; only valid as the LAST
250///   token. Malformed (`>` in non-tail position) → `false`.
251/// - Empty tokens (`a..b`, leading/trailing `.`) → `false`.
252/// - Concrete patterns match only their literal subject.
253fn subject_matches(pattern: &str, concrete: &str) -> bool {
254    if pattern.is_empty() || concrete.is_empty() {
255        return false;
256    }
257    let mut p = pattern.split('.');
258    let mut c = concrete.split('.');
259    loop {
260        match (p.next(), c.next()) {
261            // Empty token (leading/trailing/double `.`) on either
262            // side is invalid and never matches.
263            (Some(""), _) | (_, Some("")) => return false,
264            // `>` is greedy: matches the current concrete token plus
265            // any remaining ones, but ONLY when it is the LAST token
266            // in the pattern. `>` mid-pattern is malformed.
267            (Some(">"), Some(_)) => return p.next().is_none(),
268            // `>` requires at least one trailing concrete token —
269            // `a.>` does not match `a`.
270            (Some(">"), None) => return false,
271            // `*` matches exactly one concrete token.
272            (Some("*"), Some(_)) => continue,
273            (Some("*"), None) => return false,
274            // Literal token must match exactly.
275            (Some(pt), Some(ct)) if pt == ct => continue,
276            (Some(_), Some(_)) => return false,
277            // Both ran out at the same time — full match.
278            (None, None) => return true,
279            // Length mismatch — pattern still has tokens or concrete
280            // still has tokens with no `>` to consume them.
281            (Some(_), None) | (None, Some(_)) => return false,
282        }
283    }
284}
285
286/// No-op ack handle. In-process pubsub has no redelivery semantics; the
287/// trait surface is preserved for consistency.
288pub(crate) struct NoopAck;
289
290#[async_trait]
291impl AckHandleImpl for NoopAck {
292    async fn ack(self: Box<Self>) -> Result<(), BusError> {
293        Ok(())
294    }
295    async fn nak(self: Box<Self>, _delay: Duration) -> Result<(), BusError> {
296        Ok(())
297    }
298    async fn term(self: Box<Self>) -> Result<(), BusError> {
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use klieo_core::Pubsub;
307    use tokio_stream::StreamExt;
308
309    #[tokio::test]
310    async fn publish_delivers_to_subscriber() {
311        let bus = MemoryPubsub::new();
312        let mut sub = bus
313            .subscribe("subject.a", DurableName::new("d1"))
314            .await
315            .unwrap();
316        bus.publish("subject.a", Bytes::from_static(b"hi"), Headers::new())
317            .await
318            .unwrap();
319        let msg = sub.next().await.unwrap().unwrap();
320        assert_eq!(msg.subject, "subject.a");
321        assert_eq!(msg.payload, Bytes::from_static(b"hi"));
322        // Memory impl: ack is a no-op but must succeed.
323        msg.ack.ack().await.unwrap();
324    }
325
326    #[tokio::test]
327    async fn two_subscribers_both_receive() {
328        let bus = MemoryPubsub::new();
329        let mut s1 = bus
330            .subscribe("subject.b", DurableName::new("d1"))
331            .await
332            .unwrap();
333        let mut s2 = bus
334            .subscribe("subject.b", DurableName::new("d2"))
335            .await
336            .unwrap();
337        bus.publish("subject.b", Bytes::from_static(b"x"), Headers::new())
338            .await
339            .unwrap();
340        assert_eq!(
341            s1.next().await.unwrap().unwrap().payload,
342            Bytes::from_static(b"x")
343        );
344        assert_eq!(
345            s2.next().await.unwrap().unwrap().payload,
346            Bytes::from_static(b"x")
347        );
348    }
349
350    #[tokio::test]
351    async fn publish_with_no_subscribers_succeeds() {
352        let bus = MemoryPubsub::new();
353        bus.publish("nobody.home", Bytes::from_static(b"x"), Headers::new())
354            .await
355            .unwrap();
356    }
357
358    #[tokio::test]
359    async fn headers_preserved() {
360        let bus = MemoryPubsub::new();
361        let mut sub = bus
362            .subscribe("subject.h", DurableName::new("d"))
363            .await
364            .unwrap();
365        let mut h = Headers::new();
366        h.insert("k".into(), "v".into());
367        bus.publish("subject.h", Bytes::from_static(b"hi"), h)
368            .await
369            .unwrap();
370        let msg = sub.next().await.unwrap().unwrap();
371        assert_eq!(msg.headers.get("k").map(|s| s.as_str()), Some("v"));
372    }
373
374    #[tokio::test]
375    async fn small_buffer_lags_on_burst() {
376        let bus = MemoryPubsub::with_buffer_size(2);
377        let mut sub = bus
378            .subscribe("subject.lag", DurableName::new("d"))
379            .await
380            .unwrap();
381        // Publish 4 with no consumption — the broadcast channel of size 2 will overflow.
382        for i in 0..4u8 {
383            bus.publish("subject.lag", Bytes::from(vec![i]), Headers::new())
384                .await
385                .unwrap();
386        }
387        // Drain until we observe a lag (broadcast may yield some messages
388        // before flagging the lag). The lag must surface within the burst.
389        let mut saw_lag = false;
390        for _ in 0..6 {
391            let next = sub.next().await.unwrap();
392            if let Err(BusError::Retryable(ref m)) = next {
393                if m.contains("lagged") {
394                    saw_lag = true;
395                    break;
396                }
397            }
398        }
399        assert!(saw_lag, "expected lagged err within the burst");
400    }
401
402    #[tokio::test]
403    async fn large_buffer_does_not_lag() {
404        let bus = MemoryPubsub::with_buffer_size(64);
405        let mut sub = bus
406            .subscribe("subject.nolag", DurableName::new("d"))
407            .await
408            .unwrap();
409        for i in 0..10u8 {
410            bus.publish("subject.nolag", Bytes::from(vec![i]), Headers::new())
411                .await
412                .unwrap();
413        }
414        for _ in 0..10 {
415            let msg = sub.next().await.unwrap().expect("no lag");
416            let _ = msg.payload;
417        }
418    }
419
420    #[test]
421    fn concrete_matches_self() {
422        assert!(subject_matches("a.b.c", "a.b.c"));
423        assert!(!subject_matches("a.b.c", "a.b"));
424        assert!(!subject_matches("a.b.c", "a.b.c.d"));
425        assert!(!subject_matches("a.b.c", "a.x.c"));
426    }
427
428    #[test]
429    fn single_token_wildcard() {
430        assert!(subject_matches("a.*.c", "a.x.c"));
431        assert!(subject_matches("a.*.c", "a.42.c"));
432        assert!(!subject_matches("a.*.c", "a.b.c.d"));
433        assert!(!subject_matches("a.*.c", "a.c"));
434        assert!(!subject_matches("*", "a.b"));
435        assert!(subject_matches("*", "a"));
436    }
437
438    #[test]
439    fn greedy_wildcard_matches_remaining_tokens() {
440        assert!(subject_matches("a.>", "a.b"));
441        assert!(subject_matches("a.>", "a.b.c.d"));
442        assert!(!subject_matches("a.>", "a"));
443        assert!(!subject_matches("a.>", "b.x"));
444        // `a.b.>` requires at least one token after `a.b`.
445        assert!(subject_matches("a.b.>", "a.b.x"));
446        assert!(!subject_matches("a.b.>", "a.b"));
447    }
448
449    #[test]
450    fn greedy_only_valid_as_last_token() {
451        // `>` mid-pattern is malformed → never matches.
452        assert!(!subject_matches("a.>.c", "a.x.c"));
453        assert!(!subject_matches("a.>.c", "a.b.c"));
454        assert!(!subject_matches("a.>.c", "anything"));
455        assert!(!subject_matches(">.a", "x.a"));
456    }
457
458    #[test]
459    fn bare_greedy_matches_anything_with_one_plus_token() {
460        assert!(subject_matches(">", "a"));
461        assert!(subject_matches(">", "a.b.c"));
462        assert!(!subject_matches(">", ""));
463    }
464
465    #[test]
466    fn prefix_does_not_partially_match() {
467        assert!(!subject_matches("a.b", "a"));
468        assert!(!subject_matches("a.b", "a.b.c"));
469    }
470
471    #[test]
472    fn empty_token_rejected() {
473        assert!(!subject_matches("a..b", "a..b"));
474        assert!(!subject_matches("a..b", "a.x.b"));
475        assert!(!subject_matches("a.b", ""));
476        assert!(!subject_matches("", "a.b"));
477        assert!(!subject_matches(".a", "x.a"));
478        assert!(!subject_matches("a.", "a.x"));
479    }
480
481    #[tokio::test]
482    async fn greedy_wildcard_subscriber_receives_matching_publishes() {
483        let bus = MemoryPubsub::new();
484        let mut sub = bus
485            .subscribe("klieo.a2a.cancel.>", DurableName::new("d"))
486            .await
487            .unwrap();
488        bus.publish(
489            "klieo.a2a.cancel.t-1",
490            Bytes::from_static(b"signal"),
491            Headers::new(),
492        )
493        .await
494        .unwrap();
495        let msg = sub.next().await.unwrap().unwrap();
496        assert_eq!(msg.subject, "klieo.a2a.cancel.t-1");
497        assert_eq!(msg.payload, Bytes::from_static(b"signal"));
498    }
499}