1use 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
19pub 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#[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 restart_floor: u64,
74}
75
76pub 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 pub fn new(catalog: Arc<EventCatalog>) -> Result<Self, EventError> {
101 Self::with_clock(catalog, BrokerConfig::default(), Arc::new(SystemClock))
102 }
103
104 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 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 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 return;
338 };
339
340 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 }
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 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 fn publish(&self, event: TraverseEvent) -> Result<(), EventError> {
420 self.publish_internal(&event, None)
421 }
422
423 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 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 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 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 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 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 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 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 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 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 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}