Skip to main content

traverse_runtime/events/
broker.rs

1//! Synchronous in-process event broker.
2//!
3//! Governed by spec 026-event-broker and spec 036-event-subscription-replay.
4
5use std::{
6    collections::{HashMap, HashSet, VecDeque},
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11use super::{
12    catalog::EventCatalog,
13    types::{
14        BrokerEvent, EventBroker, EventCursor, EventError, LifecycleStatus, Subscription,
15        SubscriptionId, SubscriptionPoll, TraverseEvent,
16    },
17};
18
19/// Clock abstraction used by the broker for retention pruning.
20pub trait BrokerClock: Send + Sync {
21    fn now(&self) -> std::time::SystemTime;
22}
23
24#[derive(Debug)]
25pub struct SystemClock;
26
27impl BrokerClock for SystemClock {
28    fn now(&self) -> std::time::SystemTime {
29        std::time::SystemTime::now()
30    }
31}
32
33/// Broker runtime configuration.
34#[derive(Debug, Clone)]
35pub struct BrokerConfig {
36    pub retention_window: Duration,
37    pub max_queue_len: usize,
38}
39
40impl Default for BrokerConfig {
41    fn default() -> Self {
42        Self {
43            retention_window: Duration::from_mins(5),
44            max_queue_len: 1024,
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50struct BufferedEvent {
51    cursor: u64,
52    published_at: std::time::SystemTime,
53    event: TraverseEvent,
54}
55
56#[derive(Debug)]
57struct SubscriptionState {
58    subscription_id: SubscriptionId,
59    event_type: String,
60    subject_id: Option<String>,
61    cursor: u64,
62    queue: VecDeque<BufferedEvent>,
63}
64
65#[derive(Debug, Default)]
66struct BrokerState {
67    next_subscription: u64,
68    next_cursor: HashMap<String, u64>,
69    buffers: HashMap<String, VecDeque<BufferedEvent>>,
70    seen_event_ids: HashMap<String, HashSet<String>>,
71    subscriptions: HashMap<SubscriptionId, SubscriptionState>,
72    /// See [`EventBroker::seed_restart_floor`].
73    restart_floor: u64,
74}
75
76/// Synchronous, in-memory implementation of [`EventBroker`].
77///
78/// The broker stores a bounded retention buffer per event type and maintains a
79/// bounded delivery queue per subscription. Subscribers poll for events using a
80/// broker-issued subscription id and a cursor.
81pub struct InProcessBroker {
82    catalog: Arc<EventCatalog>,
83    config: BrokerConfig,
84    clock: Arc<dyn BrokerClock>,
85    state: Mutex<BrokerState>,
86}
87
88impl std::fmt::Debug for InProcessBroker {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("InProcessBroker").finish_non_exhaustive()
91    }
92}
93
94impl InProcessBroker {
95    /// Create a new broker backed by the given catalog.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`EventError::InvalidRetentionWindow`] when the provided configuration is invalid.
100    pub fn new(catalog: Arc<EventCatalog>) -> Result<Self, EventError> {
101        Self::with_clock(catalog, BrokerConfig::default(), Arc::new(SystemClock))
102    }
103
104    /// Create a broker with explicit configuration and clock.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`EventError::InvalidRetentionWindow`] when the provided configuration is invalid.
109    pub fn with_clock(
110        catalog: Arc<EventCatalog>,
111        config: BrokerConfig,
112        clock: Arc<dyn BrokerClock>,
113    ) -> Result<Self, EventError> {
114        if config.retention_window == Duration::from_secs(0) {
115            return Err(EventError::InvalidRetentionWindow(
116                "retention_window must be > 0".to_string(),
117            ));
118        }
119        if config.max_queue_len == 0 {
120            return Err(EventError::InvalidRetentionWindow(
121                "max_queue_len must be > 0".to_string(),
122            ));
123        }
124
125        Ok(Self {
126            catalog,
127            config,
128            clock,
129            state: Mutex::new(BrokerState::default()),
130        })
131    }
132
133    fn subscribe_with_subject(
134        &self,
135        event_type: &str,
136        from_cursor: &str,
137        subject_id: Option<&str>,
138    ) -> Result<Subscription, EventError> {
139        if self.catalog.get(event_type).is_none() {
140            return Err(EventError::UnregisteredEventType(event_type.to_owned()));
141        }
142
143        let from_cursor = parse_cursor(from_cursor)?;
144        let now = self.clock.now();
145        let mut state = self
146            .state
147            .lock()
148            .map_err(|_| EventError::LifecycleViolation("broker lock poisoned".to_owned()))?;
149        prune_expired(&mut state, event_type, self.config.retention_window, now);
150        validate_from_cursor(&state, event_type, from_cursor)?;
151        self.catalog.increment_consumer_count(event_type);
152
153        state.next_subscription = state.next_subscription.saturating_add(1);
154        let subscription_id = format!("sub-{}", state.next_subscription);
155        let mut queue = VecDeque::new();
156        for item in state
157            .buffers
158            .get(event_type)
159            .into_iter()
160            .flat_map(|buffer| buffer.iter())
161        {
162            if (from_cursor == 0 || item.cursor > from_cursor)
163                && subject_id
164                    .is_none_or(|subject| item.event.subject_id.as_deref() == Some(subject))
165            {
166                enqueue_with_drop_oldest(&mut queue, self.config.max_queue_len, item.clone());
167            }
168        }
169
170        state.subscriptions.insert(
171            subscription_id.clone(),
172            SubscriptionState {
173                subscription_id: subscription_id.clone(),
174                event_type: event_type.to_string(),
175                subject_id: subject_id.map(str::to_owned),
176                cursor: from_cursor,
177                queue,
178            },
179        );
180
181        Ok(Subscription {
182            subscription_id,
183            event_type: event_type.to_string(),
184            cursor: cursor_to_string(from_cursor),
185        })
186    }
187
188    /// Shared implementation for `publish` and `publish_with_cursor`.
189    /// `assigned_cursor`, when given, is adopted as this event's cursor
190    /// instead of self-incrementing the per-type counter (spec 066 FR-007:
191    /// keeps live-delivery cursors consistent with the durable journal's
192    /// cursor space). The per-type counter still tracks the highest cursor
193    /// ever used so `validate_from_cursor`'s empty-buffer fallback remains
194    /// correct regardless of cursor source.
195    fn publish_internal(
196        &self,
197        event: &TraverseEvent,
198        assigned_cursor: Option<u64>,
199    ) -> Result<(), EventError> {
200        let entry = self
201            .catalog
202            .get(&event.event_type)
203            .ok_or_else(|| EventError::UnregisteredEventType(event.event_type.clone()))?;
204
205        match entry.lifecycle_status {
206            LifecycleStatus::Active => {}
207            LifecycleStatus::Deprecated => {
208                return Err(EventError::LifecycleViolation(format!(
209                    "event type '{}' is Deprecated and cannot be published",
210                    event.event_type
211                )));
212            }
213            LifecycleStatus::Draft => {
214                return Err(EventError::LifecycleViolation(format!(
215                    "event type '{}' is Draft and cannot be published",
216                    event.event_type
217                )));
218            }
219        }
220
221        let now = self.clock.now();
222
223        let mut state = self
224            .state
225            .lock()
226            .map_err(|_| EventError::LifecycleViolation("broker lock poisoned".to_owned()))?;
227
228        prune_expired(
229            &mut state,
230            &event.event_type,
231            self.config.retention_window,
232            now,
233        );
234
235        let seen = state
236            .seen_event_ids
237            .entry(event.event_type.clone())
238            .or_default();
239        if seen.contains(&event.id) {
240            // Duplicate emissions are silently discarded.
241            return Ok(());
242        }
243        seen.insert(event.id.clone());
244
245        let next = state
246            .next_cursor
247            .entry(event.event_type.clone())
248            .or_insert(0);
249        let cursor = if let Some(assigned) = assigned_cursor {
250            *next = (*next).max(assigned);
251            assigned
252        } else {
253            *next = next.saturating_add(1);
254            *next
255        };
256
257        let buffered = BufferedEvent {
258            cursor,
259            published_at: now,
260            event: event.clone(),
261        };
262
263        state
264            .buffers
265            .entry(event.event_type.clone())
266            .or_default()
267            .push_back(buffered.clone());
268
269        for sub in state.subscriptions.values_mut() {
270            if sub.event_type != event.event_type {
271                continue;
272            }
273            if sub
274                .subject_id
275                .as_deref()
276                .is_some_and(|subject_id| event.subject_id.as_deref() != Some(subject_id))
277            {
278                continue;
279            }
280            enqueue_with_drop_oldest(&mut sub.queue, self.config.max_queue_len, buffered.clone());
281        }
282
283        Ok(())
284    }
285}
286
287fn parse_cursor(raw: &str) -> Result<u64, EventError> {
288    let trimmed = raw.trim();
289    if trimmed == "0" {
290        return Ok(0);
291    }
292    trimmed.parse::<u64>().map_err(|_| {
293        EventError::InvalidCursor("cursor must be \"0\" or a base-10 unsigned integer".to_string())
294    })
295}
296
297fn cursor_to_string(cursor: u64) -> EventCursor {
298    cursor.to_string()
299}
300
301fn enqueue_with_drop_oldest(
302    queue: &mut VecDeque<BufferedEvent>,
303    max_len: usize,
304    item: BufferedEvent,
305) {
306    while queue.len() >= max_len {
307        let _ = queue.pop_front();
308    }
309    queue.push_back(item);
310}
311
312fn prune_expired(
313    state: &mut BrokerState,
314    event_type: &str,
315    retention_window: Duration,
316    now: std::time::SystemTime,
317) {
318    let buffer = state.buffers.entry(event_type.to_string()).or_default();
319    let mut oldest_retained_cursor = None;
320    while let Some(front) = buffer.pop_front() {
321        let age = now
322            .duration_since(front.published_at)
323            .unwrap_or(Duration::from_secs(0));
324        if age <= retention_window {
325            oldest_retained_cursor = Some(front.cursor);
326            buffer.push_front(front);
327            break;
328        }
329
330        if let Some(ids) = state.seen_event_ids.get_mut(event_type) {
331            let _ = ids.remove(&front.event.id);
332        }
333    }
334
335    let Some(oldest_cursor) = oldest_retained_cursor else {
336        // Buffer is empty after pruning; nothing to sync.
337        return;
338    };
339
340    // Sync per-subscription queues so they don't deliver events that are no longer retained.
341    for sub in state.subscriptions.values_mut() {
342        if sub.event_type != event_type {
343            continue;
344        }
345        while let Some(front) = sub.queue.front() {
346            if front.cursor >= oldest_cursor {
347                break;
348            }
349            let _ = sub.queue.pop_front();
350        }
351        if sub.cursor != 0 && sub.cursor < oldest_cursor.saturating_sub(1) {
352            // Cursor is now outside the retention window; keep it as-is so poll can surface cursor_expired.
353        }
354    }
355}
356
357fn validate_from_cursor(
358    state: &BrokerState,
359    event_type: &str,
360    from_cursor: u64,
361) -> Result<(), EventError> {
362    if from_cursor == 0 {
363        return Ok(());
364    }
365
366    let last_cursor = state
367        .next_cursor
368        .get(event_type)
369        .copied()
370        .unwrap_or(0)
371        .max(state.restart_floor);
372    if let Some(buffer) = state.buffers.get(event_type)
373        && let Some(front) = buffer.front()
374    {
375        let oldest_ok = front.cursor.saturating_sub(1);
376        if from_cursor < oldest_ok {
377            return Err(EventError::CursorExpired {
378                event_type: event_type.to_string(),
379                oldest_available_cursor: cursor_to_string(oldest_ok),
380            });
381        }
382        return Ok(());
383    }
384
385    // If the buffer is empty but we have published events before, treat cursors behind the last
386    // observed cursor as expired to avoid silent gaps.
387    if last_cursor > 0 && from_cursor < last_cursor {
388        return Err(EventError::CursorExpired {
389            event_type: event_type.to_string(),
390            oldest_available_cursor: cursor_to_string(last_cursor),
391        });
392    }
393
394    Ok(())
395}
396
397impl EventBroker for InProcessBroker {
398    fn subscribe_for_subject(
399        &self,
400        event_type: &str,
401        from_cursor: &str,
402        subject_id: Option<&str>,
403    ) -> Result<Subscription, EventError> {
404        self.subscribe_with_subject(event_type, from_cursor, subject_id)
405    }
406
407    fn seed_restart_floor(&self, floor: u64) {
408        if let Ok(mut state) = self.state.lock() {
409            state.restart_floor = state.restart_floor.max(floor);
410        }
411    }
412
413    /// Publish `event` to all registered subscribers.
414    ///
415    /// # Errors
416    ///
417    /// - [`EventError::UnregisteredEventType`] if the event type is not in the catalog.
418    /// - [`EventError::LifecycleViolation`] if the catalog entry is `Draft` or `Deprecated`.
419    fn publish(&self, event: TraverseEvent) -> Result<(), EventError> {
420        self.publish_internal(&event, None)
421    }
422
423    /// Publish `event`, adopting `cursor` as this broker's own cursor for it
424    /// instead of self-assigning the next per-type sequence value. Used by
425    /// [`DurableBroker`](super::durable::DurableBroker) so live-delivery
426    /// cursors stay numerically consistent with the durable journal's cursor
427    /// space (spec 066 FR-007): a cursor obtained during live polling
428    /// remains valid when a later `poll` falls back to durable replay.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`EventError::InvalidCursor`] when `cursor` is not a base-10
433    /// unsigned integer, plus the same errors as [`Self::publish`].
434    fn publish_with_cursor(&self, event: TraverseEvent, cursor: &str) -> Result<(), EventError> {
435        let assigned = parse_cursor(cursor)?;
436        self.publish_internal(&event, Some(assigned))
437    }
438
439    /// Create a subscription for `event_type` starting from `from_cursor`.
440    ///
441    /// The event type must already be registered in the catalog.
442    ///
443    /// # Errors
444    ///
445    /// Returns [`EventError::UnregisteredEventType`] if the event type is not catalogued.
446    fn subscribe(&self, event_type: &str, from_cursor: &str) -> Result<Subscription, EventError> {
447        self.subscribe_with_subject(event_type, from_cursor, None)
448    }
449
450    /// Poll a subscription for up to `max_events`.
451    ///
452    /// # Errors
453    ///
454    fn poll(
455        &self,
456        subscription_id: &str,
457        max_events: usize,
458    ) -> Result<SubscriptionPoll, EventError> {
459        let now = self.clock.now();
460        let mut state = self
461            .state
462            .lock()
463            .map_err(|_| EventError::LifecycleViolation("broker lock poisoned".to_owned()))?;
464
465        let mut subscription = state
466            .subscriptions
467            .remove(subscription_id)
468            .ok_or_else(|| EventError::SubscriptionNotFound(subscription_id.to_string()))?;
469        let event_type = subscription.event_type.clone();
470        let cursor = subscription.cursor;
471
472        prune_expired(&mut state, &event_type, self.config.retention_window, now);
473
474        validate_from_cursor(&state, &event_type, cursor)?;
475
476        if let Some(buffer) = state.buffers.get(&event_type)
477            && let Some(oldest_cursor) = buffer.front().map(|e| e.cursor)
478        {
479            while let Some(front) = subscription.queue.front() {
480                if front.cursor >= oldest_cursor {
481                    break;
482                }
483                let _ = subscription.queue.pop_front();
484            }
485        }
486
487        if max_events == 0 {
488            let cursor_str = cursor_to_string(subscription.cursor);
489            state
490                .subscriptions
491                .insert(subscription.subscription_id.clone(), subscription);
492            return Ok(SubscriptionPoll {
493                subscription_id: subscription_id.to_string(),
494                event_type,
495                cursor: cursor_str,
496                events: Vec::new(),
497            });
498        }
499
500        let mut out = Vec::new();
501        let mut delivered_cursor = subscription.cursor;
502        for _ in 0..max_events {
503            let Some(item) = subscription.queue.pop_front() else {
504                break;
505            };
506            delivered_cursor = item.cursor;
507            out.push(BrokerEvent {
508                cursor: cursor_to_string(item.cursor),
509                event: item.event,
510            });
511        }
512        subscription.cursor = delivered_cursor;
513
514        let subscription_id_value = subscription.subscription_id.clone();
515        let event_type_value = subscription.event_type.clone();
516        let cursor_value = cursor_to_string(subscription.cursor);
517        state
518            .subscriptions
519            .insert(subscription.subscription_id.clone(), subscription);
520
521        Ok(SubscriptionPoll {
522            subscription_id: subscription_id_value,
523            event_type: event_type_value,
524            cursor: cursor_value,
525            events: out,
526        })
527    }
528
529    /// Cancel a subscription.
530    ///
531    /// # Errors
532    ///
533    /// Returns [`EventError::SubscriptionNotFound`] if the subscription id is unknown.
534    fn cancel(&self, subscription_id: &str) -> Result<(), EventError> {
535        let mut state = self
536            .state
537            .lock()
538            .map_err(|_| EventError::LifecycleViolation("broker lock poisoned".to_owned()))?;
539
540        if state.subscriptions.remove(subscription_id).is_none() {
541            return Err(EventError::SubscriptionNotFound(
542                subscription_id.to_string(),
543            ));
544        }
545        Ok(())
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    #![allow(clippy::expect_used)]
552    #![allow(clippy::panic)]
553    #![allow(clippy::unwrap_used)]
554
555    use super::*;
556    use crate::events::catalog::EventCatalogEntry;
557
558    fn cursor_expired_oldest(err: &EventError) -> Option<String> {
559        if let EventError::CursorExpired {
560            oldest_available_cursor,
561            ..
562        } = err
563        {
564            Some(oldest_available_cursor.clone())
565        } else {
566            None
567        }
568    }
569
570    fn make_catalog(event_type: &str, status: LifecycleStatus) -> Arc<EventCatalog> {
571        let catalog = Arc::new(EventCatalog::new());
572        catalog
573            .register(EventCatalogEntry {
574                event_type: event_type.to_string(),
575                owner: "cap.test".to_string(),
576                version: "1.0.0".to_string(),
577                lifecycle_status: status,
578                consumer_count: 0,
579            })
580            .expect("catalog register must succeed");
581        catalog
582    }
583
584    fn sample_event(event_type: &str, id: &str) -> TraverseEvent {
585        TraverseEvent {
586            id: id.to_string(),
587            source: "traverse-runtime/cap.test".to_string(),
588            event_type: event_type.to_string(),
589            datacontenttype: "application/json".to_string(),
590            time: "2026-04-08T00:00:00Z".to_string(),
591            data: serde_json::json!({}),
592            owner: "cap.test".to_string(),
593            version: "1.0.0".to_string(),
594            lifecycle_status: LifecycleStatus::Active,
595            subject_id: None,
596            actor_id: None,
597        }
598    }
599
600    #[test]
601    fn broker_debug_impl_is_accessible() {
602        let catalog = make_catalog("dev.traverse.debug", LifecycleStatus::Active);
603        let broker = InProcessBroker::new(catalog).expect("broker must be created");
604        let rendered = format!("{broker:?}");
605        assert!(rendered.contains("InProcessBroker"));
606    }
607
608    #[test]
609    fn invalid_max_queue_len_is_rejected() {
610        let catalog = make_catalog("dev.traverse.invalid", LifecycleStatus::Active);
611        let err = InProcessBroker::with_clock(
612            catalog,
613            BrokerConfig {
614                retention_window: Duration::from_secs(1),
615                max_queue_len: 0,
616            },
617            Arc::new(SystemClock),
618        )
619        .expect_err("max_queue_len=0 must be rejected");
620        assert!(matches!(err, EventError::InvalidRetentionWindow(_)));
621    }
622
623    #[test]
624    fn publish_with_cursor_adopts_the_given_cursor_instead_of_self_assigning() {
625        let event_type = "dev.traverse.injected-cursor";
626        let catalog = make_catalog(event_type, LifecycleStatus::Active);
627        let broker = InProcessBroker::new(catalog).expect("broker must be created");
628
629        broker
630            .publish_with_cursor(sample_event(event_type, "evt-1"), "42")
631            .expect("publish_with_cursor must succeed");
632
633        let subscription = broker
634            .subscribe(event_type, "0")
635            .expect("subscribe must succeed");
636        let poll = broker
637            .poll(&subscription.subscription_id, 10)
638            .expect("poll must succeed");
639        assert_eq!(poll.events.len(), 1);
640        assert_eq!(poll.events[0].cursor, "42");
641        assert_eq!(poll.cursor, "42");
642    }
643
644    #[test]
645    fn publish_with_cursor_rejects_a_malformed_cursor() {
646        let event_type = "dev.traverse.injected-cursor-invalid";
647        let catalog = make_catalog(event_type, LifecycleStatus::Active);
648        let broker = InProcessBroker::new(catalog).expect("broker must be created");
649
650        let err = broker
651            .publish_with_cursor(sample_event(event_type, "evt-1"), "not-a-cursor")
652            .expect_err("malformed cursor must be rejected");
653        assert!(matches!(err, EventError::InvalidCursor(_)));
654    }
655
656    #[test]
657    fn default_publish_with_cursor_ignores_the_cursor_and_self_assigns() {
658        struct SelfAssigningOnlyBroker(InProcessBroker);
659
660        impl EventBroker for SelfAssigningOnlyBroker {
661            fn publish(&self, event: TraverseEvent) -> Result<(), EventError> {
662                self.0.publish(event)
663            }
664            fn subscribe(
665                &self,
666                event_type: &str,
667                from_cursor: &str,
668            ) -> Result<Subscription, EventError> {
669                self.0.subscribe(event_type, from_cursor)
670            }
671            fn subscribe_for_subject(
672                &self,
673                event_type: &str,
674                from_cursor: &str,
675                subject_id: Option<&str>,
676            ) -> Result<Subscription, EventError> {
677                self.0
678                    .subscribe_for_subject(event_type, from_cursor, subject_id)
679            }
680            fn poll(
681                &self,
682                subscription_id: &str,
683                max_events: usize,
684            ) -> Result<SubscriptionPoll, EventError> {
685                self.0.poll(subscription_id, max_events)
686            }
687            fn cancel(&self, subscription_id: &str) -> Result<(), EventError> {
688                self.0.cancel(subscription_id)
689            }
690        }
691
692        let event_type = "dev.traverse.default-publish-with-cursor";
693        let catalog = make_catalog(event_type, LifecycleStatus::Active);
694        let broker =
695            SelfAssigningOnlyBroker(InProcessBroker::new(catalog).expect("broker must be created"));
696
697        // The default `publish_with_cursor` ignores the supplied cursor and
698        // behaves exactly like `publish` (self-assigning cursor "1").
699        broker
700            .publish_with_cursor(sample_event(event_type, "evt-1"), "999")
701            .expect("default publish_with_cursor must succeed");
702
703        let subscription = broker
704            .subscribe(event_type, "0")
705            .expect("subscribe must succeed");
706        let poll = broker
707            .poll(&subscription.subscription_id, 10)
708            .expect("poll must succeed");
709        assert_eq!(poll.events[0].cursor, "1");
710
711        // The default `seed_restart_floor` is a no-op; exercise it alongside
712        // this wrapper's other pass-through trait methods.
713        broker.seed_restart_floor(999);
714        let subject_subscription = broker
715            .subscribe_for_subject(event_type, "0", None)
716            .expect("subscribe_for_subject must succeed");
717        broker
718            .cancel(&subject_subscription.subscription_id)
719            .expect("cancel must succeed");
720    }
721
722    #[test]
723    fn invalid_cursor_is_rejected() {
724        let catalog = make_catalog("dev.traverse.cursor", LifecycleStatus::Active);
725        let broker = InProcessBroker::new(catalog).expect("broker must be created");
726        let err = broker
727            .subscribe("dev.traverse.cursor", "not-a-cursor")
728            .expect_err("invalid cursor must fail");
729        assert!(matches!(err, EventError::InvalidCursor(_)));
730    }
731
732    #[test]
733    fn subject_subscription_filters_backlog_and_live_delivery() {
734        let event_type = "dev.traverse.subject-filter";
735        let broker = InProcessBroker::new(make_catalog(event_type, LifecycleStatus::Active))
736            .expect("broker must be created");
737        let mut other = sample_event(event_type, "evt-other");
738        other.subject_id = Some("subject-other".to_string());
739        let mut expected = sample_event(event_type, "evt-match");
740        expected.subject_id = Some("subject-match".to_string());
741        broker.publish(other).expect("backlog publish must succeed");
742        broker
743            .publish(expected.clone())
744            .expect("backlog publish must succeed");
745
746        let subscription = broker
747            .subscribe_for_subject(event_type, "0", Some("subject-match"))
748            .expect("subject subscription must succeed");
749        let backlog = broker
750            .poll(&subscription.subscription_id, 10)
751            .expect("backlog poll must succeed");
752        assert_eq!(backlog.events.len(), 1);
753        assert_eq!(backlog.events[0].event.id, expected.id);
754
755        let mut live_other = sample_event(event_type, "evt-live-other");
756        live_other.subject_id = Some("subject-other".to_string());
757        let mut live_expected = sample_event(event_type, "evt-live-match");
758        live_expected.subject_id = Some("subject-match".to_string());
759        broker
760            .publish(live_other)
761            .expect("non-matching live publish must succeed");
762        broker
763            .publish(live_expected.clone())
764            .expect("matching live publish must succeed");
765
766        let live = broker
767            .poll(&subscription.subscription_id, 10)
768            .expect("live poll must succeed");
769        assert_eq!(live.events.len(), 1);
770        assert_eq!(live.events[0].event.id, live_expected.id);
771    }
772
773    #[test]
774    fn publish_rejects_deprecated_and_draft_event_types() {
775        let deprecated = InProcessBroker::new(make_catalog(
776            "dev.traverse.deprecated",
777            LifecycleStatus::Deprecated,
778        ))
779        .expect("broker must be created");
780        let err = deprecated
781            .publish(sample_event("dev.traverse.deprecated", "evt-001"))
782            .expect_err("deprecated publish must fail");
783        assert!(matches!(err, EventError::LifecycleViolation(_)));
784
785        let draft =
786            InProcessBroker::new(make_catalog("dev.traverse.draft", LifecycleStatus::Draft))
787                .expect("broker must be created");
788        let err = draft
789            .publish(sample_event("dev.traverse.draft", "evt-001"))
790            .expect_err("draft publish must fail");
791        assert!(matches!(err, EventError::LifecycleViolation(_)));
792    }
793
794    #[test]
795    fn broker_lock_poisoning_surfaces_lifecycle_violation() {
796        let broker =
797            InProcessBroker::new(make_catalog("dev.traverse.poison", LifecycleStatus::Active))
798                .expect("broker must be created");
799
800        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
801            let _guard = broker.state.lock().unwrap();
802            panic!("poison lock");
803        }));
804
805        let err = broker
806            .publish(sample_event("dev.traverse.poison", "evt-001"))
807            .expect_err("poisoned publish must fail");
808        assert!(matches!(err, EventError::LifecycleViolation(_)));
809
810        let err = broker
811            .subscribe("dev.traverse.poison", "0")
812            .expect_err("poisoned subscribe must fail");
813        assert!(matches!(err, EventError::LifecycleViolation(_)));
814
815        let err = broker
816            .poll("sub-1", 1)
817            .expect_err("poisoned poll must fail");
818        assert!(matches!(err, EventError::LifecycleViolation(_)));
819
820        let err = broker
821            .cancel("sub-1")
822            .expect_err("poisoned cancel must fail");
823        assert!(matches!(err, EventError::LifecycleViolation(_)));
824    }
825
826    #[derive(Debug)]
827    struct ManualClock(std::sync::Mutex<std::time::SystemTime>);
828
829    impl ManualClock {
830        fn new(now: std::time::SystemTime) -> Self {
831            Self(std::sync::Mutex::new(now))
832        }
833
834        fn advance(&self, by: Duration) {
835            if let Ok(mut guard) = self.0.lock()
836                && let Some(next) = guard.checked_add(by)
837            {
838                *guard = next;
839            }
840        }
841
842        fn set(&self, now: std::time::SystemTime) {
843            if let Ok(mut guard) = self.0.lock() {
844                *guard = now;
845            }
846        }
847    }
848
849    impl BrokerClock for ManualClock {
850        fn now(&self) -> std::time::SystemTime {
851            self.0
852                .lock()
853                .ok()
854                .map_or(std::time::SystemTime::UNIX_EPOCH, |guard| *guard)
855        }
856    }
857
858    #[test]
859    fn clock_regression_does_not_break_retention_pruning() {
860        let clock = Arc::new(ManualClock::new(std::time::SystemTime::UNIX_EPOCH));
861        let broker = InProcessBroker::with_clock(
862            make_catalog("dev.traverse.clock", LifecycleStatus::Active),
863            BrokerConfig {
864                retention_window: Duration::from_mins(1),
865                max_queue_len: 16,
866            },
867            clock.clone(),
868        )
869        .expect("broker must be created");
870
871        clock.set(std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(10));
872        broker
873            .publish(sample_event("dev.traverse.clock", "evt-001"))
874            .expect("publish must succeed");
875
876        // Move time backwards to force duration_since() to hit the error path.
877        clock.set(std::time::SystemTime::UNIX_EPOCH);
878        broker
879            .publish(sample_event("dev.traverse.clock", "evt-002"))
880            .expect("publish must succeed");
881    }
882
883    #[test]
884    fn publish_pruning_syncs_subscription_queues_and_skips_other_event_types() {
885        let catalog = Arc::new(EventCatalog::new());
886        catalog
887            .register(EventCatalogEntry {
888                event_type: "dev.traverse.a".to_string(),
889                owner: "cap.test".to_string(),
890                version: "1.0.0".to_string(),
891                lifecycle_status: LifecycleStatus::Active,
892                consumer_count: 0,
893            })
894            .expect("register must succeed");
895        catalog
896            .register(EventCatalogEntry {
897                event_type: "dev.traverse.b".to_string(),
898                owner: "cap.test".to_string(),
899                version: "1.0.0".to_string(),
900                lifecycle_status: LifecycleStatus::Active,
901                consumer_count: 0,
902            })
903            .expect("register must succeed");
904
905        let clock = Arc::new(ManualClock::new(std::time::SystemTime::UNIX_EPOCH));
906        let broker = InProcessBroker::with_clock(
907            catalog,
908            BrokerConfig {
909                retention_window: Duration::from_secs(5),
910                max_queue_len: 64,
911            },
912            clock.clone(),
913        )
914        .expect("broker must be created");
915
916        let sub_a = broker
917            .subscribe("dev.traverse.a", "1")
918            .expect("subscribe must succeed");
919        let sub_b = broker
920            .subscribe("dev.traverse.b", "0")
921            .expect("subscribe must succeed");
922
923        broker
924            .publish(sample_event("dev.traverse.a", "evt-001"))
925            .expect("publish must succeed");
926        clock.advance(Duration::from_secs(1));
927        broker
928            .publish(sample_event("dev.traverse.a", "evt-002"))
929            .expect("publish must succeed");
930        clock.advance(Duration::from_secs(1));
931        broker
932            .publish(sample_event("dev.traverse.a", "evt-003"))
933            .expect("publish must succeed");
934
935        // Jump forward so evt-001 and evt-002 are outside retention; evt-003 is retained.
936        clock.advance(Duration::from_secs(5));
937        broker
938            .publish(sample_event("dev.traverse.a", "evt-004"))
939            .expect("publish must succeed");
940
941        let err = broker
942            .poll(&sub_a.subscription_id, 10)
943            .expect_err("poll must surface cursor_expired after retention pruning");
944        let oldest_available_cursor = cursor_expired_oldest(&err).expect("must be cursor_expired");
945
946        let sub_a_resumed = broker
947            .subscribe("dev.traverse.a", &oldest_available_cursor)
948            .expect("subscribe must succeed");
949        let poll_a = broker
950            .poll(&sub_a_resumed.subscription_id, 10)
951            .expect("poll must succeed");
952        assert!(
953            poll_a
954                .events
955                .first()
956                .is_some_and(|e| e.event.id == "evt-003"),
957            "queue must resume from oldest retained event"
958        );
959
960        let poll_b = broker
961            .poll(&sub_b.subscription_id, 10)
962            .expect("poll must succeed");
963        assert!(
964            poll_b.events.is_empty(),
965            "event_type mismatch must not enqueue"
966        );
967
968        // Also cover the non-cursor_expired branch in the extraction logic above.
969        let other_err = broker
970            .poll("sub-missing", 10)
971            .expect_err("poll must fail when subscription is missing");
972        assert!(cursor_expired_oldest(&other_err).is_none());
973    }
974
975    #[test]
976    fn subscribe_replays_events_from_existing_buffer() {
977        let clock = Arc::new(ManualClock::new(std::time::SystemTime::UNIX_EPOCH));
978        let broker = InProcessBroker::with_clock(
979            make_catalog("dev.traverse.replay", LifecycleStatus::Active),
980            BrokerConfig {
981                retention_window: Duration::from_secs(5),
982                max_queue_len: 64,
983            },
984            clock,
985        )
986        .expect("broker must be created");
987
988        broker
989            .publish(sample_event("dev.traverse.replay", "evt-001"))
990            .expect("publish must succeed");
991
992        let sub = broker
993            .subscribe("dev.traverse.replay", "0")
994            .expect("subscribe must succeed");
995        let poll = broker
996            .poll(&sub.subscription_id, 10)
997            .expect("poll must succeed");
998        assert_eq!(poll.events.len(), 1);
999        assert_eq!(poll.events[0].event.id, "evt-001");
1000    }
1001
1002    #[test]
1003    fn subscribe_rejects_cursor_expired_when_buffer_non_empty() {
1004        let clock = Arc::new(ManualClock::new(std::time::SystemTime::UNIX_EPOCH));
1005        let broker = InProcessBroker::with_clock(
1006            make_catalog("dev.traverse.expire", LifecycleStatus::Active),
1007            BrokerConfig {
1008                retention_window: Duration::from_secs(5),
1009                max_queue_len: 64,
1010            },
1011            clock.clone(),
1012        )
1013        .expect("broker must be created");
1014
1015        for i in 1..=5 {
1016            broker
1017                .publish(sample_event("dev.traverse.expire", &format!("evt-{i:03}")))
1018                .expect("publish must succeed");
1019            clock.advance(Duration::from_secs(1));
1020        }
1021
1022        // Advance so only the last event remains within retention.
1023        clock.advance(Duration::from_secs(5));
1024
1025        let err = broker
1026            .subscribe("dev.traverse.expire", "1")
1027            .expect_err("subscribe must fail with cursor_expired");
1028        assert!(matches!(err, EventError::CursorExpired { .. }));
1029    }
1030
1031    #[test]
1032    fn poll_with_zero_max_events_returns_empty() {
1033        let broker =
1034            InProcessBroker::new(make_catalog("dev.traverse.poll0", LifecycleStatus::Active))
1035                .expect("broker must be created");
1036        let sub = broker
1037            .subscribe("dev.traverse.poll0", "0")
1038            .expect("subscribe must succeed");
1039        let poll = broker
1040            .poll(&sub.subscription_id, 0)
1041            .expect("poll must succeed");
1042        assert!(poll.events.is_empty());
1043    }
1044
1045    #[test]
1046    fn poll_prunes_subscription_queue_based_on_retention() {
1047        let clock = Arc::new(ManualClock::new(std::time::SystemTime::UNIX_EPOCH));
1048        let broker = InProcessBroker::with_clock(
1049            make_catalog("dev.traverse.pollprune", LifecycleStatus::Active),
1050            BrokerConfig {
1051                retention_window: Duration::from_secs(5),
1052                max_queue_len: 64,
1053            },
1054            clock.clone(),
1055        )
1056        .expect("broker must be created");
1057
1058        let sub = broker
1059            .subscribe("dev.traverse.pollprune", "0")
1060            .expect("subscribe must succeed");
1061
1062        broker
1063            .publish(sample_event("dev.traverse.pollprune", "evt-001"))
1064            .expect("publish must succeed");
1065        clock.advance(Duration::from_secs(4));
1066        broker
1067            .publish(sample_event("dev.traverse.pollprune", "evt-002"))
1068            .expect("publish must succeed");
1069
1070        // Advance so evt-001 is outside retention but evt-002 is retained.
1071        clock.advance(Duration::from_secs(3));
1072
1073        let poll = broker
1074            .poll(&sub.subscription_id, 10)
1075            .expect("poll must succeed");
1076        assert_eq!(poll.events.len(), 1);
1077        assert_eq!(poll.events[0].event.id, "evt-002");
1078    }
1079
1080    #[test]
1081    fn cancel_unknown_subscription_returns_not_found() {
1082        let broker = InProcessBroker::new(make_catalog(
1083            "dev.traverse.cancel-miss",
1084            LifecycleStatus::Active,
1085        ))
1086        .expect("broker must be created");
1087        let err = broker.cancel("sub-missing").expect_err("cancel must fail");
1088        assert!(matches!(err, EventError::SubscriptionNotFound(_)));
1089    }
1090}