1use std::collections::HashMap;
10use std::path::Path;
11use std::sync::mpsc;
12use std::sync::{Arc, Condvar, Mutex, PoisonError, RwLock};
13use std::time::Duration;
14
15use serde::Serialize;
16
17use super::broker::BrokerClock;
18use super::journal::{DurableEventJournal, JournalConfig, JournalError};
19use super::types::{
20 BrokerEvent, EventBroker, EventError, Subscription, SubscriptionPoll, TraverseEvent,
21};
22
23const DURABLE_SUBSCRIPTION_PREFIX: &str = "durable-sub-";
27
28pub trait JournalSource: Send + Sync {
34 fn replay_from(
41 &self,
42 cursor: &str,
43 max_events: usize,
44 ) -> Result<Vec<(String, TraverseEvent)>, JournalError>;
45}
46
47impl JournalSource for RwLock<DurableEventJournal> {
48 fn replay_from(
49 &self,
50 cursor: &str,
51 max_events: usize,
52 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
53 self.read()
54 .unwrap_or_else(PoisonError::into_inner)
55 .replay_from(cursor, max_events)
56 }
57}
58
59struct SharedJournalSink(Arc<RwLock<DurableEventJournal>>);
63
64impl JournalSink for SharedJournalSink {
65 fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
66 self.0
67 .write()
68 .unwrap_or_else(PoisonError::into_inner)
69 .append(event)
70 }
71
72 fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
73 self.0
74 .write()
75 .unwrap_or_else(PoisonError::into_inner)
76 .append_revocation(revoked_cursor)
77 }
78}
79
80struct DurableSubscriptionState {
81 event_type: String,
82 subject_id: Option<String>,
83 cursor: String,
84}
85
86#[derive(Default)]
87struct DurableSubscriptions {
88 next_id: u64,
89 entries: HashMap<String, DurableSubscriptionState>,
90}
91
92fn map_journal_read_error(event_type: &str, error: JournalError) -> EventError {
93 match error {
94 JournalError::CursorExpired {
95 oldest_available_cursor,
96 } => EventError::CursorExpired {
97 event_type: event_type.to_string(),
98 oldest_available_cursor,
99 },
100 JournalError::InvalidCursor(msg) => EventError::InvalidCursor(msg),
101 JournalError::Io(msg) | JournalError::InvalidConfig(msg) => EventError::JournalRead(msg),
102 JournalError::Corrupt {
103 path,
104 line,
105 message,
106 } => EventError::JournalRead(format!(
107 "corrupt journal record at {path}:{line}: {message}"
108 )),
109 }
110}
111
112pub trait JournalSink: Send {
116 fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError>;
122
123 fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError>;
129}
130
131impl JournalSink for DurableEventJournal {
132 fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
133 self.append(event)
134 }
135
136 fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
137 DurableEventJournal::append_revocation(self, revoked_cursor)
138 }
139}
140
141#[derive(Debug, Clone, Copy)]
143pub struct DurableBrokerConfig {
144 pub write_timeout: Duration,
146}
147
148impl Default for DurableBrokerConfig {
149 fn default() -> Self {
150 Self {
151 write_timeout: Duration::from_secs(2),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
159pub struct JournalWriteAuditRecord {
160 pub kind: String,
162 pub event_id: String,
164 pub event_type: String,
166 pub detail: String,
168}
169
170pub trait JournalWriteAuditSink: Send + Sync {
172 fn record(&self, record: &JournalWriteAuditRecord);
174}
175
176enum AckState {
177 Pending,
178 Done(String),
179 Failed(JournalError),
180 Abandoned,
181}
182
183type Ack = Arc<(Mutex<AckState>, Condvar)>;
184
185enum WriterJob {
186 Write {
187 event: Box<TraverseEvent>,
188 ack: Ack,
189 },
190 Revoke {
191 cursor: String,
192 event_id: String,
193 event_type: String,
194 ack: Option<Ack>,
195 },
196}
197
198pub struct DurableBroker<B: EventBroker> {
218 inner: B,
219 jobs: mpsc::Sender<WriterJob>,
220 write_timeout: Duration,
221 audit: Arc<dyn JournalWriteAuditSink>,
222 source: Arc<dyn JournalSource>,
223 subscriptions: Mutex<DurableSubscriptions>,
224}
225
226impl<B: EventBroker> DurableBroker<B> {
227 pub fn new(
234 inner: B,
235 sink: impl JournalSink + 'static,
236 source: Arc<dyn JournalSource>,
237 config: DurableBrokerConfig,
238 audit: Arc<dyn JournalWriteAuditSink>,
239 ) -> Self {
240 let (jobs, queue) = mpsc::channel();
241 let writer_audit = Arc::clone(&audit);
242 drop(std::thread::spawn(move || {
243 run_writer(sink, &queue, writer_audit.as_ref());
244 }));
245 Self {
246 inner,
247 jobs,
248 write_timeout: config.write_timeout,
249 audit,
250 source,
251 subscriptions: Mutex::new(DurableSubscriptions::default()),
252 }
253 }
254
255 pub fn open(
265 root: &Path,
266 inner: B,
267 journal_config: JournalConfig,
268 broker_config: DurableBrokerConfig,
269 audit: Arc<dyn JournalWriteAuditSink>,
270 clock: Arc<dyn BrokerClock>,
271 ) -> Result<Self, JournalError> {
272 let journal = Arc::new(RwLock::new(DurableEventJournal::open(
273 root,
274 journal_config,
275 clock,
276 )?));
277 inner.seed_restart_floor(
284 journal
285 .read()
286 .unwrap_or_else(PoisonError::into_inner)
287 .latest_cursor(),
288 );
289 let source: Arc<dyn JournalSource> = Arc::clone(&journal) as Arc<dyn JournalSource>;
290 Ok(Self::new(
291 inner,
292 SharedJournalSink(journal),
293 source,
294 broker_config,
295 audit,
296 ))
297 }
298}
299
300impl<B: EventBroker> EventBroker for DurableBroker<B> {
301 fn publish(&self, event: TraverseEvent) -> Result<(), EventError> {
302 let ack: Ack = Arc::new((Mutex::new(AckState::Pending), Condvar::new()));
303 self.jobs
304 .send(WriterJob::Write {
305 event: Box::new(event.clone()),
306 ack: Arc::clone(&ack),
307 })
308 .map_err(|_| EventError::JournalWrite("journal writer is unavailable".to_string()))?;
309
310 let (lock, cvar) = &*ack;
311 let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
312 let (mut state, _) = cvar
313 .wait_timeout_while(guard, self.write_timeout, |state| {
314 matches!(state, AckState::Pending)
315 })
316 .unwrap_or_else(PoisonError::into_inner);
317 let outcome = std::mem::replace(&mut *state, AckState::Abandoned);
320 drop(state);
321
322 match outcome {
323 AckState::Done(cursor) => {
324 let event_id = event.id.clone();
325 let event_type = event.event_type.clone();
326 match self.inner.publish_with_cursor(event, &cursor) {
327 Ok(()) => Ok(()),
328 Err(error) => {
329 let revoke_ack: Ack =
334 Arc::new((Mutex::new(AckState::Pending), Condvar::new()));
335 let revoke = WriterJob::Revoke {
336 cursor,
337 event_id: event_id.clone(),
338 event_type: event_type.clone(),
339 ack: Some(Arc::clone(&revoke_ack)),
340 };
341 enqueue_revocation(
342 &self.jobs,
343 revoke,
344 self.audit.as_ref(),
345 &event_id,
346 &event_type,
347 )
348 .and_then(|()| {
349 let (lock, cvar) = &*revoke_ack;
350 let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
351 let (mut state, _) = cvar
352 .wait_timeout_while(guard, self.write_timeout, |state| {
353 matches!(state, AckState::Pending)
354 })
355 .unwrap_or_else(PoisonError::into_inner);
356 let revoke_outcome =
357 std::mem::replace(&mut *state, AckState::Abandoned);
358 map_revocation_outcome(
359 revoke_outcome,
360 self.audit.as_ref(),
361 &event_id,
362 &event_type,
363 self.write_timeout,
364 )
365 })
366 .and(Err(error))
367 }
368 }
369 }
370 AckState::Failed(error) => Err(EventError::JournalWrite(error.to_string())),
371 AckState::Pending | AckState::Abandoned => {
372 let detail = format!(
373 "durable write exceeded {}ms; event rejected",
374 self.write_timeout.as_millis()
375 );
376 self.audit.record(&JournalWriteAuditRecord {
377 kind: "journal_write_timeout".to_string(),
378 event_id: event.id.clone(),
379 event_type: event.event_type.clone(),
380 detail: detail.clone(),
381 });
382 Err(EventError::JournalWriteTimeout(detail))
383 }
384 }
385 }
386
387 fn subscribe(&self, event_type: &str, from_cursor: &str) -> Result<Subscription, EventError> {
388 self.subscribe_for_subject(event_type, from_cursor, None)
389 }
390
391 fn subscribe_for_subject(
392 &self,
393 event_type: &str,
394 from_cursor: &str,
395 subject_id: Option<&str>,
396 ) -> Result<Subscription, EventError> {
397 match self
398 .inner
399 .subscribe_for_subject(event_type, from_cursor, subject_id)
400 {
401 Ok(subscription) => Ok(subscription),
402 Err(EventError::CursorExpired {
403 event_type: expired_event_type,
404 ..
405 }) => {
406 match self.source.replay_from(from_cursor, 0) {
410 Ok(_) => {
411 let cursor = normalize_cursor(from_cursor)?;
412 let mut subscriptions = self
413 .subscriptions
414 .lock()
415 .unwrap_or_else(PoisonError::into_inner);
416 subscriptions.next_id = subscriptions.next_id.saturating_add(1);
417 let subscription_id =
418 format!("{DURABLE_SUBSCRIPTION_PREFIX}{}", subscriptions.next_id);
419 subscriptions.entries.insert(
420 subscription_id.clone(),
421 DurableSubscriptionState {
422 event_type: event_type.to_string(),
423 subject_id: subject_id.map(str::to_owned),
424 cursor: cursor.clone(),
425 },
426 );
427 Ok(Subscription {
428 subscription_id,
429 event_type: event_type.to_string(),
430 cursor,
431 })
432 }
433 Err(JournalError::CursorExpired {
434 oldest_available_cursor,
435 }) => Err(EventError::CursorExpired {
436 event_type: expired_event_type,
437 oldest_available_cursor,
438 }),
439 Err(other) => Err(map_journal_read_error(event_type, other)),
440 }
441 }
442 Err(other) => Err(other),
443 }
444 }
445
446 fn poll(
447 &self,
448 subscription_id: &str,
449 max_events: usize,
450 ) -> Result<SubscriptionPoll, EventError> {
451 if !subscription_id.starts_with(DURABLE_SUBSCRIPTION_PREFIX) {
452 return self.inner.poll(subscription_id, max_events);
453 }
454
455 let mut subscriptions = self
456 .subscriptions
457 .lock()
458 .unwrap_or_else(PoisonError::into_inner);
459 let subscription = subscriptions
460 .entries
461 .get_mut(subscription_id)
462 .ok_or_else(|| EventError::SubscriptionNotFound(subscription_id.to_string()))?;
463
464 let mut delivered = Vec::new();
465 let mut cursor = subscription.cursor.clone();
466 if max_events > 0 {
467 loop {
468 let batch = self
469 .source
470 .replay_from(&cursor, max_events)
471 .map_err(|error| map_journal_read_error(&subscription.event_type, error))?;
472 if batch.is_empty() {
473 break;
474 }
475 let batch_len = batch.len();
476 for (record_cursor, event) in batch {
477 cursor = record_cursor;
478 if event.event_type != subscription.event_type {
479 continue;
480 }
481 if subscription
482 .subject_id
483 .as_deref()
484 .is_some_and(|subject| event.subject_id.as_deref() != Some(subject))
485 {
486 continue;
487 }
488 delivered.push(BrokerEvent {
489 cursor: cursor.clone(),
490 event,
491 });
492 if delivered.len() >= max_events {
493 break;
494 }
495 }
496 if delivered.len() >= max_events || batch_len < max_events {
497 break;
498 }
499 }
500 }
501 subscription.cursor.clone_from(&cursor);
502
503 Ok(SubscriptionPoll {
504 subscription_id: subscription_id.to_string(),
505 event_type: subscription.event_type.clone(),
506 cursor,
507 events: delivered,
508 })
509 }
510
511 fn cancel(&self, subscription_id: &str) -> Result<(), EventError> {
512 if subscription_id.starts_with(DURABLE_SUBSCRIPTION_PREFIX) {
513 let mut subscriptions = self
514 .subscriptions
515 .lock()
516 .unwrap_or_else(PoisonError::into_inner);
517 return if subscriptions.entries.remove(subscription_id).is_some() {
518 Ok(())
519 } else {
520 Err(EventError::SubscriptionNotFound(
521 subscription_id.to_string(),
522 ))
523 };
524 }
525 self.inner.cancel(subscription_id)
526 }
527}
528
529fn revocation_writer_unavailable(
530 audit: &dyn JournalWriteAuditSink,
531 event_id: &str,
532 event_type: &str,
533) -> EventError {
534 audit.record(&JournalWriteAuditRecord {
535 kind: "journal_revocation_failed".to_string(),
536 event_id: event_id.to_string(),
537 event_type: event_type.to_string(),
538 detail: "journal writer is unavailable".to_string(),
539 });
540 EventError::JournalWrite("journal revocation failed: journal writer is unavailable".to_string())
541}
542
543fn enqueue_revocation(
544 jobs: &mpsc::Sender<WriterJob>,
545 revoke: WriterJob,
546 audit: &dyn JournalWriteAuditSink,
547 event_id: &str,
548 event_type: &str,
549) -> Result<(), EventError> {
550 jobs.send(revoke)
551 .map_err(|_| revocation_writer_unavailable(audit, event_id, event_type))
552}
553
554fn map_revocation_outcome(
555 outcome: AckState,
556 audit: &dyn JournalWriteAuditSink,
557 event_id: &str,
558 event_type: &str,
559 timeout: Duration,
560) -> Result<(), EventError> {
561 match outcome {
562 AckState::Done(_) => Ok(()),
563 AckState::Failed(error) => Err(EventError::JournalWrite(format!(
564 "journal revocation failed: {error}"
565 ))),
566 AckState::Pending | AckState::Abandoned => {
567 let detail = format!(
568 "journal revocation exceeded {}ms; event remains rejected",
569 timeout.as_millis()
570 );
571 audit.record(&JournalWriteAuditRecord {
572 kind: "journal_revocation_failed".to_string(),
573 event_id: event_id.to_string(),
574 event_type: event_type.to_string(),
575 detail: detail.clone(),
576 });
577 Err(EventError::JournalWriteTimeout(detail))
578 }
579 }
580}
581
582fn normalize_cursor(raw: &str) -> Result<String, EventError> {
583 raw.parse::<u64>()
584 .map(|parsed| parsed.to_string())
585 .map_err(|_| EventError::InvalidCursor(raw.to_string()))
586}
587
588fn run_writer(
589 mut sink: impl JournalSink,
590 jobs: &mpsc::Receiver<WriterJob>,
591 audit: &dyn JournalWriteAuditSink,
592) {
593 while let Ok(job) = jobs.recv() {
594 match job {
595 WriterJob::Write { event, ack } => {
596 let result = sink.append_event(&event);
597 let (lock, cvar) = &*ack;
598 let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner);
599 if matches!(*state, AckState::Abandoned) {
600 drop(state);
601 if let Ok(cursor) = result {
604 let _ = revoke(&mut sink, audit, &cursor, &event.id, &event.event_type);
605 }
606 } else {
607 *state = match result {
608 Ok(cursor) => AckState::Done(cursor),
609 Err(error) => AckState::Failed(error),
610 };
611 cvar.notify_one();
612 }
613 }
614 WriterJob::Revoke {
615 cursor,
616 event_id,
617 event_type,
618 ack,
619 } => {
620 let result = revoke(&mut sink, audit, &cursor, &event_id, &event_type);
621 let _ = ack.map(|ack| acknowledge_revocation(&ack, result));
622 }
623 }
624 }
625}
626
627fn acknowledge_revocation(ack: &Ack, result: Result<(), JournalError>) {
628 let (lock, cvar) = &**ack;
629 let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner);
630 *state = match result {
631 Ok(()) => AckState::Done(String::new()),
632 Err(error) => AckState::Failed(error),
633 };
634 cvar.notify_one();
635}
636
637fn revoke(
638 sink: &mut impl JournalSink,
639 audit: &dyn JournalWriteAuditSink,
640 cursor: &str,
641 event_id: &str,
642 event_type: &str,
643) -> Result<(), JournalError> {
644 sink.append_revocation(cursor)
645 .map(|_| ())
646 .inspect_err(|error| {
647 audit.record(&JournalWriteAuditRecord {
648 kind: "journal_revocation_failed".to_string(),
649 event_id: event_id.to_string(),
650 event_type: event_type.to_string(),
651 detail: error.to_string(),
652 });
653 })
654}
655
656#[cfg(test)]
657#[allow(clippy::expect_used)]
658mod tests {
659 use super::*;
660 use crate::events::broker::InProcessBroker;
661 use crate::events::catalog::{EventCatalog, EventCatalogEntry};
662 use crate::events::journal::JournalConfig;
663 use crate::events::types::LifecycleStatus;
664 use std::sync::Mutex as StdMutex;
665 use std::sync::mpsc::{Receiver, Sender, channel};
666 use uuid::Uuid;
667
668 const EVENT_TYPE: &str = "dev.traverse.test.durable";
669
670 #[derive(Default)]
671 struct RecordingAudit {
672 records: StdMutex<Vec<JournalWriteAuditRecord>>,
673 notify: StdMutex<Option<Sender<JournalWriteAuditRecord>>>,
674 }
675
676 impl RecordingAudit {
677 fn with_notify(sender: Sender<JournalWriteAuditRecord>) -> Arc<Self> {
678 Arc::new(Self {
679 records: StdMutex::new(Vec::new()),
680 notify: StdMutex::new(Some(sender)),
681 })
682 }
683
684 fn kinds(&self) -> Vec<String> {
685 self.records
686 .lock()
687 .expect("audit lock must not poison")
688 .iter()
689 .map(|record| record.kind.clone())
690 .collect()
691 }
692 }
693
694 impl JournalWriteAuditSink for RecordingAudit {
695 fn record(&self, record: &JournalWriteAuditRecord) {
696 self.records
697 .lock()
698 .expect("audit lock must not poison")
699 .push(record.clone());
700 if let Some(sender) = &*self.notify.lock().expect("notify lock must not poison") {
701 let _ = sender.send(record.clone());
702 }
703 }
704 }
705
706 struct ScriptedSink {
710 gate: Receiver<Result<String, JournalError>>,
711 revocations: Sender<String>,
712 fail_revocation: bool,
713 }
714
715 impl JournalSink for ScriptedSink {
716 fn append_event(&mut self, _event: &TraverseEvent) -> Result<String, JournalError> {
717 self.gate
718 .recv()
719 .unwrap_or_else(|_| Err(JournalError::Io("gate closed".to_string())))
720 }
721
722 fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
723 let _ = self.revocations.send(revoked_cursor.to_string());
724 if self.fail_revocation {
725 return Err(JournalError::Io("revocation rejected".to_string()));
726 }
727 Ok("0".to_string())
728 }
729 }
730
731 fn test_event() -> TraverseEvent {
732 TraverseEvent {
733 id: Uuid::new_v4().to_string(),
734 source: "traverse-runtime/test.capability".to_string(),
735 event_type: EVENT_TYPE.to_string(),
736 datacontenttype: "application/json".to_string(),
737 time: "2026-07-13T00:00:00Z".to_string(),
738 data: serde_json::json!({ "ok": true }),
739 owner: "test.capability".to_string(),
740 version: "1.0.0".to_string(),
741 lifecycle_status: LifecycleStatus::Active,
742 deduplication_id: Some("durable-test".to_string()),
743 ordering_scope: Some("test".to_string()),
744 correlation_id: Some("correlation-test".to_string()),
745 causation_id: Some("command-test".to_string()),
746 subject_id: None,
747 actor_id: None,
748 }
749 }
750
751 fn active_catalog() -> Arc<EventCatalog> {
752 let catalog = EventCatalog::new();
753 catalog
754 .register(EventCatalogEntry {
755 event_type: EVENT_TYPE.to_string(),
756 version: "1.0.0".to_string(),
757 owner: "test.capability".to_string(),
758 lifecycle_status: LifecycleStatus::Active,
759 consumer_count: 0,
760 })
761 .expect("catalog entry must register");
762 Arc::new(catalog)
763 }
764
765 fn inner_broker() -> InProcessBroker {
766 InProcessBroker::new(active_catalog()).expect("broker must build")
767 }
768
769 fn test_root(name: &str) -> std::path::PathBuf {
770 std::env::temp_dir().join(format!("traverse-durable-{name}-{}", Uuid::new_v4()))
771 }
772
773 struct NullJournalSource;
778
779 impl JournalSource for NullJournalSource {
780 fn replay_from(
781 &self,
782 _cursor: &str,
783 _max_events: usize,
784 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
785 Err(JournalError::CursorExpired {
786 oldest_available_cursor: "0".to_string(),
787 })
788 }
789 }
790
791 fn null_source() -> Arc<dyn JournalSource> {
792 Arc::new(NullJournalSource)
793 }
794
795 fn open_shared_journal(root: &std::path::Path) -> Arc<RwLock<DurableEventJournal>> {
796 Arc::new(RwLock::new(
797 DurableEventJournal::open(
798 root,
799 JournalConfig::default(),
800 Arc::new(crate::events::broker::SystemClock),
801 )
802 .expect("journal must open"),
803 ))
804 }
805
806 #[test]
807 fn journal_sink_impl_appends_and_revokes_through_the_real_journal() {
808 let root = test_root("sink-impl");
809 let mut journal = DurableEventJournal::open(
810 &root,
811 JournalConfig::default(),
812 Arc::new(crate::events::broker::SystemClock),
813 )
814 .expect("journal must open");
815 let cursor =
816 JournalSink::append_event(&mut journal, &test_event()).expect("append must succeed");
817 JournalSink::append_revocation(&mut journal, &cursor).expect("revocation must succeed");
818 assert!(
819 journal
820 .replay_from("0", 10)
821 .expect("replay must succeed")
822 .is_empty(),
823 "the revoked event must not replay"
824 );
825 }
826
827 #[test]
828 fn durable_publish_journals_then_delivers() {
829 let root = test_root("happy");
830 let audit = Arc::new(RecordingAudit::default());
831 let broker = DurableBroker::open(
832 &root,
833 inner_broker(),
834 JournalConfig::default(),
835 DurableBrokerConfig::default(),
836 audit.clone(),
837 Arc::new(crate::events::broker::SystemClock),
838 )
839 .expect("durable broker must open");
840
841 let subscription = broker
842 .subscribe(EVENT_TYPE, "0")
843 .expect("subscribe must succeed");
844 broker.publish(test_event()).expect("publish must succeed");
845
846 let poll = broker
847 .poll(&subscription.subscription_id, 10)
848 .expect("poll must succeed");
849 assert_eq!(poll.events.len(), 1, "live delivery must still work");
850 broker
851 .cancel(&subscription.subscription_id)
852 .expect("cancel must succeed");
853
854 let reader = DurableEventJournal::open(
855 &root,
856 JournalConfig::default(),
857 Arc::new(crate::events::broker::SystemClock),
858 )
859 .expect("journal must reopen");
860 let replayed = reader.replay_from("0", 10).expect("replay must succeed");
861 assert_eq!(replayed.len(), 1, "the event must be durable");
862 assert!(audit.kinds().is_empty(), "no audit records on success");
863 }
864
865 #[test]
866 fn durable_subject_subscription_delegates_to_inner_broker() {
867 let root = test_root("subject-subscription");
868 let broker = DurableBroker::open(
869 &root,
870 inner_broker(),
871 JournalConfig::default(),
872 DurableBrokerConfig::default(),
873 Arc::new(RecordingAudit::default()),
874 Arc::new(crate::events::broker::SystemClock),
875 )
876 .expect("durable broker must open");
877 let mut event = test_event();
878 event.subject_id = Some("subject-match".to_string());
879 broker.publish(event).expect("publish must succeed");
880
881 let subscription = broker
882 .subscribe_for_subject(EVENT_TYPE, "0", Some("subject-match"))
883 .expect("subject subscription must succeed");
884 let poll = broker
885 .poll(&subscription.subscription_id, 10)
886 .expect("poll must succeed");
887 assert_eq!(poll.events.len(), 1);
888 assert_eq!(
889 poll.events[0].event.subject_id.as_deref(),
890 Some("subject-match")
891 );
892 }
893
894 #[test]
895 fn undeliverable_event_is_revoked_after_durable_write() {
896 let (gate_tx, gate_rx) = channel();
897 let (revoked_tx, revoked_rx) = channel();
898 let sink = ScriptedSink {
899 gate: gate_rx,
900 revocations: revoked_tx,
901 fail_revocation: false,
902 };
903 let audit = Arc::new(RecordingAudit::default());
904 let broker = DurableBroker::new(
905 inner_broker(),
906 sink,
907 null_source(),
908 DurableBrokerConfig::default(),
909 audit,
910 );
911
912 let mut unregistered = test_event();
913 unregistered.event_type = "dev.traverse.test.unknown".to_string();
914 gate_tx
915 .send(Ok("41".to_string()))
916 .expect("gate must accept");
917 let err = broker
918 .publish(unregistered)
919 .expect_err("unregistered event type must be rejected by the inner broker");
920 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
921
922 let revoked = revoked_rx
923 .recv_timeout(Duration::from_secs(5))
924 .expect("the durably written but undelivered event must be revoked");
925 assert_eq!(revoked, "41");
926 }
927
928 #[test]
929 fn slow_durable_write_times_out_rejects_and_revokes() {
930 let (gate_tx, gate_rx) = channel();
931 let (revoked_tx, revoked_rx) = channel();
932 let (audit_tx, audit_rx) = channel();
933 let sink = ScriptedSink {
934 gate: gate_rx,
935 revocations: revoked_tx,
936 fail_revocation: false,
937 };
938 let audit = RecordingAudit::with_notify(audit_tx);
939 let broker = DurableBroker::new(
940 inner_broker(),
941 sink,
942 null_source(),
943 DurableBrokerConfig {
944 write_timeout: Duration::from_millis(50),
945 },
946 audit.clone(),
947 );
948
949 let subscription = broker
950 .subscribe(EVENT_TYPE, "0")
951 .expect("subscribe must succeed");
952 let err = broker
953 .publish(test_event())
954 .expect_err("a stalled durable write must time out");
955 assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
956 assert!(
957 err.to_string().contains("journal_write_timeout"),
958 "the error code must be distinct: {err}"
959 );
960
961 let timeout_audit = audit_rx
962 .recv_timeout(Duration::from_secs(5))
963 .expect("timeout must produce a structured audit record");
964 assert_eq!(timeout_audit.kind, "journal_write_timeout");
965 assert_eq!(timeout_audit.event_type, EVENT_TYPE);
966
967 let poll = broker
968 .poll(&subscription.subscription_id, 10)
969 .expect("poll must succeed");
970 assert!(
971 poll.events.is_empty(),
972 "a rejected event must not be delivered live"
973 );
974
975 gate_tx.send(Ok("7".to_string())).expect("gate must accept");
978 let revoked = revoked_rx
979 .recv_timeout(Duration::from_secs(5))
980 .expect("the late write must be revoked");
981 assert_eq!(revoked, "7");
982 }
983
984 #[test]
985 fn failed_durable_write_rejects_the_event() {
986 let (gate_tx, gate_rx) = channel();
987 let (revoked_tx, _revoked_rx) = channel();
988 let sink = ScriptedSink {
989 gate: gate_rx,
990 revocations: revoked_tx,
991 fail_revocation: false,
992 };
993 let audit = Arc::new(RecordingAudit::default());
994 let broker = DurableBroker::new(
995 inner_broker(),
996 sink,
997 null_source(),
998 DurableBrokerConfig::default(),
999 audit,
1000 );
1001
1002 gate_tx
1003 .send(Err(JournalError::Io("disk gone".to_string())))
1004 .expect("gate must accept");
1005 let err = broker
1006 .publish(test_event())
1007 .expect_err("a failed durable write must reject the event");
1008 assert!(matches!(err, EventError::JournalWrite(_)), "{err}");
1009 assert!(err.to_string().contains("disk gone"), "{err}");
1010 }
1011
1012 #[test]
1013 fn revocation_failures_are_audited() {
1014 let (gate_tx, gate_rx) = channel();
1015 let (revoked_tx, revoked_rx) = channel();
1016 let (audit_tx, audit_rx) = channel();
1017 let sink = ScriptedSink {
1018 gate: gate_rx,
1019 revocations: revoked_tx,
1020 fail_revocation: true,
1021 };
1022 let audit = RecordingAudit::with_notify(audit_tx);
1023 let broker = DurableBroker::new(
1024 inner_broker(),
1025 sink,
1026 null_source(),
1027 DurableBrokerConfig {
1028 write_timeout: Duration::from_millis(50),
1029 },
1030 audit.clone(),
1031 );
1032
1033 let err = broker
1034 .publish(test_event())
1035 .expect_err("a stalled durable write must time out");
1036 assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
1037 let timeout_audit = audit_rx
1038 .recv_timeout(Duration::from_secs(5))
1039 .expect("timeout audit must arrive");
1040 assert_eq!(timeout_audit.kind, "journal_write_timeout");
1041
1042 gate_tx.send(Ok("9".to_string())).expect("gate must accept");
1043 let _ = revoked_rx
1044 .recv_timeout(Duration::from_secs(5))
1045 .expect("revocation must be attempted");
1046 let failure_audit = audit_rx
1047 .recv_timeout(Duration::from_secs(5))
1048 .expect("revocation failure must be audited");
1049 assert_eq!(failure_audit.kind, "journal_revocation_failed");
1050 assert!(failure_audit.detail.contains("revocation rejected"));
1051 }
1052
1053 #[test]
1054 fn immediate_revocation_failure_returns_a_stable_error() {
1055 let (gate_tx, gate_rx) = channel();
1056 let (revoked_tx, _revoked_rx) = channel();
1057 let sink = ScriptedSink {
1058 gate: gate_rx,
1059 revocations: revoked_tx,
1060 fail_revocation: true,
1061 };
1062 let broker = DurableBroker::new(
1063 inner_broker(),
1064 sink,
1065 null_source(),
1066 DurableBrokerConfig::default(),
1067 Arc::new(RecordingAudit::default()),
1068 );
1069
1070 let mut unregistered = test_event();
1071 unregistered.event_type = "dev.traverse.test.unknown".to_string();
1072 gate_tx
1073 .send(Ok("11".to_string()))
1074 .expect("gate must accept");
1075 let error = broker
1076 .publish(unregistered)
1077 .expect_err("delivery and revocation failure must be returned");
1078 assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1079 assert!(error.to_string().contains("revocation rejected"));
1080 }
1081
1082 #[test]
1083 fn disconnected_revocation_writer_is_audited() {
1084 let (jobs, receiver) = channel();
1085 drop(receiver);
1086 let revoke = WriterJob::Revoke {
1087 cursor: "1".to_string(),
1088 event_id: "event-1".to_string(),
1089 event_type: EVENT_TYPE.to_string(),
1090 ack: Some(Arc::new((Mutex::new(AckState::Pending), Condvar::new()))),
1091 };
1092 let audit = RecordingAudit::default();
1093 let error = enqueue_revocation(&jobs, revoke, &audit, "event-1", EVENT_TYPE)
1094 .expect_err("disconnected writer must fail closed");
1095 assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1096 }
1097
1098 #[test]
1099 fn pending_revocation_ack_reports_timeout_and_audits_failure() {
1100 let audit = RecordingAudit::default();
1101 let timeout = map_revocation_outcome(
1102 AckState::Pending,
1103 &audit,
1104 "event-1",
1105 EVENT_TYPE,
1106 Duration::from_millis(25),
1107 )
1108 .expect_err("pending acknowledgement must time out");
1109 assert!(matches!(timeout, EventError::JournalWriteTimeout(_)));
1110 assert_eq!(audit.kinds(), vec!["journal_revocation_failed"]);
1111 }
1112
1113 #[derive(Debug)]
1116 struct ManualClock(StdMutex<std::time::SystemTime>);
1117
1118 impl ManualClock {
1119 fn new() -> Self {
1120 Self(StdMutex::new(std::time::SystemTime::now()))
1121 }
1122
1123 fn advance(&self, by: Duration) {
1124 if let Ok(mut guard) = self.0.lock()
1125 && let Some(next) = guard.checked_add(by)
1126 {
1127 *guard = next;
1128 }
1129 }
1130 }
1131
1132 impl crate::events::broker::BrokerClock for ManualClock {
1133 fn now(&self) -> std::time::SystemTime {
1134 self.0
1135 .lock()
1136 .ok()
1137 .map_or(std::time::SystemTime::UNIX_EPOCH, |guard| *guard)
1138 }
1139 }
1140
1141 struct AlwaysExpiredSource;
1145
1146 impl JournalSource for AlwaysExpiredSource {
1147 fn replay_from(
1148 &self,
1149 _cursor: &str,
1150 _max_events: usize,
1151 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1152 Err(JournalError::CursorExpired {
1153 oldest_available_cursor: "5".to_string(),
1154 })
1155 }
1156 }
1157
1158 fn short_retention_inner(
1159 clock: Arc<dyn crate::events::broker::BrokerClock>,
1160 ) -> crate::events::broker::InProcessBroker {
1161 crate::events::broker::InProcessBroker::with_clock(
1162 active_catalog(),
1163 crate::events::broker::BrokerConfig {
1164 retention_window: Duration::from_millis(10),
1165 max_queue_len: 16,
1166 },
1167 clock,
1168 )
1169 .expect("short-retention broker must build")
1170 }
1171
1172 #[test]
1173 fn poll_falls_back_to_the_durable_journal_once_the_in_memory_window_expires() {
1174 let root = test_root("fallback-live");
1175 let clock = Arc::new(ManualClock::new());
1176 let journal = open_shared_journal(&root);
1177 let audit = Arc::new(RecordingAudit::default());
1178 let broker = DurableBroker::new(
1179 short_retention_inner(clock.clone()),
1180 SharedJournalSink(Arc::clone(&journal)),
1181 Arc::clone(&journal) as Arc<dyn JournalSource>,
1182 DurableBrokerConfig::default(),
1183 audit,
1184 );
1185
1186 broker
1191 .publish(test_event())
1192 .expect("first publish must succeed");
1193 broker
1194 .publish(test_event())
1195 .expect("second publish must succeed");
1196
1197 clock.advance(Duration::from_secs(1));
1201 let subscription = broker
1202 .subscribe(EVENT_TYPE, "1")
1203 .expect("subscribing on an expired cursor must fall back to the durable journal");
1204 assert!(
1205 subscription
1206 .subscription_id
1207 .starts_with(DURABLE_SUBSCRIPTION_PREFIX),
1208 "a durable fallback subscription must use the durable-mode id prefix"
1209 );
1210
1211 let poll = broker
1212 .poll(&subscription.subscription_id, 10)
1213 .expect("durable poll must succeed");
1214 assert_eq!(
1215 poll.events.len(),
1216 1,
1217 "only the event strictly after cursor \"1\" must replay"
1218 );
1219
1220 broker
1224 .publish(test_event())
1225 .expect("second publish must succeed");
1226 let poll_again = broker
1227 .poll(&subscription.subscription_id, 10)
1228 .expect("second durable poll must succeed");
1229 assert_eq!(poll_again.events.len(), 1, "the new event must also replay");
1230
1231 broker
1232 .cancel(&subscription.subscription_id)
1233 .expect("cancelling a durable-mode subscription must succeed");
1234 let after_cancel = broker.poll(&subscription.subscription_id, 10);
1235 assert!(matches!(
1236 after_cancel,
1237 Err(EventError::SubscriptionNotFound(_))
1238 ));
1239 }
1240
1241 #[test]
1242 fn durable_fallback_applies_the_identical_subject_filter_as_live_delivery() {
1243 let root = test_root("fallback-subject");
1244 let clock = Arc::new(ManualClock::new());
1245 let journal = open_shared_journal(&root);
1246 let broker = DurableBroker::new(
1247 short_retention_inner(clock.clone()),
1248 SharedJournalSink(Arc::clone(&journal)),
1249 Arc::clone(&journal) as Arc<dyn JournalSource>,
1250 DurableBrokerConfig::default(),
1251 Arc::new(RecordingAudit::default()),
1252 );
1253
1254 broker
1258 .publish(test_event())
1259 .expect("leading publish must succeed");
1260 let mut matching = test_event();
1261 matching.subject_id = Some("subject-match".to_string());
1262 broker.publish(matching).expect("publish must succeed");
1263 let mut other = test_event();
1264 other.subject_id = Some("subject-other".to_string());
1265 broker.publish(other).expect("publish must succeed");
1266
1267 clock.advance(Duration::from_secs(1));
1268 let subscription = broker
1269 .subscribe_for_subject(EVENT_TYPE, "1", Some("subject-match"))
1270 .expect("subject subscription must fall back to the durable journal");
1271 assert!(
1272 subscription
1273 .subscription_id
1274 .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1275 );
1276
1277 let poll = broker
1278 .poll(&subscription.subscription_id, 10)
1279 .expect("durable poll must succeed");
1280 assert_eq!(
1281 poll.events.len(),
1282 1,
1283 "only the matching subject must replay"
1284 );
1285 assert_eq!(
1286 poll.events[0].event.subject_id.as_deref(),
1287 Some("subject-match")
1288 );
1289 }
1290
1291 #[test]
1292 fn durable_fallback_surfaces_cursor_expired_when_the_journal_has_also_reclaimed_history() {
1293 let clock = Arc::new(ManualClock::new());
1294 let (gate_tx, gate_rx) = channel();
1295 let (revoked_tx, _revoked_rx) = channel();
1296 let sink = ScriptedSink {
1297 gate: gate_rx,
1298 revocations: revoked_tx,
1299 fail_revocation: false,
1300 };
1301 let broker = DurableBroker::new(
1302 short_retention_inner(clock.clone()),
1303 sink,
1304 Arc::new(AlwaysExpiredSource) as Arc<dyn JournalSource>,
1305 DurableBrokerConfig::default(),
1306 Arc::new(RecordingAudit::default()),
1307 );
1308
1309 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1310 broker
1311 .publish(test_event())
1312 .expect("first publish must succeed");
1313 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1314 broker
1315 .publish(test_event())
1316 .expect("second publish must succeed");
1317
1318 clock.advance(Duration::from_secs(1));
1319 let err = broker
1320 .subscribe(EVENT_TYPE, "1")
1321 .expect_err("both the in-memory and durable layers have expired this cursor");
1322 assert_eq!(
1323 err,
1324 EventError::CursorExpired {
1325 event_type: EVENT_TYPE.to_string(),
1326 oldest_available_cursor: "5".to_string(),
1327 },
1328 "the durable journal's own oldest-available cursor must be surfaced"
1329 );
1330 }
1331
1332 #[test]
1333 fn durable_fallback_propagates_non_cursor_journal_read_errors() {
1334 struct FailingSource;
1335 impl JournalSource for FailingSource {
1336 fn replay_from(
1337 &self,
1338 _cursor: &str,
1339 _max_events: usize,
1340 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1341 Err(JournalError::Io("disk unavailable".to_string()))
1342 }
1343 }
1344
1345 let clock = Arc::new(ManualClock::new());
1346 let (gate_tx, gate_rx) = channel();
1347 let (revoked_tx, _revoked_rx) = channel();
1348 let sink = ScriptedSink {
1349 gate: gate_rx,
1350 revocations: revoked_tx,
1351 fail_revocation: false,
1352 };
1353 let broker = DurableBroker::new(
1354 short_retention_inner(clock.clone()),
1355 sink,
1356 Arc::new(FailingSource) as Arc<dyn JournalSource>,
1357 DurableBrokerConfig::default(),
1358 Arc::new(RecordingAudit::default()),
1359 );
1360
1361 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1362 broker
1363 .publish(test_event())
1364 .expect("first publish must succeed");
1365 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1366 broker
1367 .publish(test_event())
1368 .expect("second publish must succeed");
1369
1370 clock.advance(Duration::from_secs(1));
1371 let err = broker
1372 .subscribe(EVENT_TYPE, "1")
1373 .expect_err("a durable read failure must not be silently swallowed");
1374 assert!(matches!(err, EventError::JournalRead(_)), "{err}");
1375 }
1376
1377 #[test]
1378 fn poll_on_an_unknown_durable_subscription_id_returns_subscription_not_found() {
1379 let root = test_root("fallback-unknown-poll");
1380 let journal = open_shared_journal(&root);
1381 let broker = DurableBroker::new(
1382 inner_broker(),
1383 SharedJournalSink(Arc::clone(&journal)),
1384 Arc::clone(&journal) as Arc<dyn JournalSource>,
1385 DurableBrokerConfig::default(),
1386 Arc::new(RecordingAudit::default()),
1387 );
1388
1389 let err = broker
1390 .poll(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"), 10)
1391 .expect_err("unknown durable subscription id must be rejected");
1392 assert!(matches!(err, EventError::SubscriptionNotFound(_)));
1393
1394 let cancel_err = broker
1395 .cancel(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"))
1396 .expect_err("cancelling an unknown durable subscription id must be rejected");
1397 assert!(matches!(cancel_err, EventError::SubscriptionNotFound(_)));
1398 }
1399
1400 #[test]
1401 fn poll_with_zero_max_events_on_a_durable_subscription_returns_no_events() {
1402 let root = test_root("fallback-zero-poll");
1403 let clock = Arc::new(ManualClock::new());
1404 let journal = open_shared_journal(&root);
1405 let broker = DurableBroker::new(
1406 short_retention_inner(clock.clone()),
1407 SharedJournalSink(Arc::clone(&journal)),
1408 Arc::clone(&journal) as Arc<dyn JournalSource>,
1409 DurableBrokerConfig::default(),
1410 Arc::new(RecordingAudit::default()),
1411 );
1412
1413 broker
1414 .publish(test_event())
1415 .expect("first publish must succeed");
1416 broker
1417 .publish(test_event())
1418 .expect("second publish must succeed");
1419 clock.advance(Duration::from_secs(1));
1420 let subscription = broker
1421 .subscribe(EVENT_TYPE, "1")
1422 .expect("subscribe must fall back to the durable journal");
1423
1424 let poll = broker
1425 .poll(&subscription.subscription_id, 0)
1426 .expect("poll with max_events=0 must succeed");
1427 assert!(poll.events.is_empty());
1428 assert_eq!(poll.cursor, "1", "an unread cursor must not advance");
1429 }
1430
1431 #[test]
1432 fn cursor_survives_a_full_broker_restart_at_the_same_journal_root() {
1433 let root = test_root("restart-continuity");
1434 let cursor_before_restart = {
1435 let broker = DurableBroker::open(
1436 &root,
1437 inner_broker(),
1438 JournalConfig::default(),
1439 DurableBrokerConfig::default(),
1440 Arc::new(RecordingAudit::default()),
1441 Arc::new(crate::events::broker::SystemClock),
1442 )
1443 .expect("durable broker must open");
1444
1445 let subscription = broker
1446 .subscribe(EVENT_TYPE, "0")
1447 .expect("subscribe must succeed");
1448 broker
1449 .publish(test_event())
1450 .expect("first publish must succeed");
1451 let poll = broker
1452 .poll(&subscription.subscription_id, 10)
1453 .expect("poll must succeed");
1454 assert_eq!(poll.events.len(), 1);
1455 broker
1459 .publish(test_event())
1460 .expect("second publish must succeed");
1461 poll.cursor
1462 };
1466
1467 let restarted = DurableBroker::open(
1470 &root,
1471 inner_broker(),
1472 JournalConfig::default(),
1473 DurableBrokerConfig::default(),
1474 Arc::new(RecordingAudit::default()),
1475 Arc::new(crate::events::broker::SystemClock),
1476 )
1477 .expect("durable broker must reopen at the same root");
1478
1479 let subscription = restarted
1484 .subscribe(EVENT_TYPE, &cursor_before_restart)
1485 .expect("resuming the pre-restart cursor must succeed after restart");
1486 assert!(
1487 subscription
1488 .subscription_id
1489 .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1490 );
1491
1492 let poll = restarted
1493 .poll(&subscription.subscription_id, 10)
1494 .expect("poll after restart must succeed");
1495 assert_eq!(
1496 poll.events.len(),
1497 1,
1498 "the event published before restart but never consumed must replay"
1499 );
1500
1501 restarted
1502 .publish(test_event())
1503 .expect("publish after restart must succeed");
1504 let poll = restarted
1505 .poll(&subscription.subscription_id, 10)
1506 .expect("second poll after restart must succeed");
1507 assert_eq!(
1508 poll.events.len(),
1509 1,
1510 "the event published after restart must replay from the resumed cursor"
1511 );
1512 }
1513
1514 #[test]
1515 fn open_surfaces_an_invalid_journal_config() {
1516 let root = test_root("open-invalid-config");
1517 let err = DurableBroker::open(
1518 &root,
1519 inner_broker(),
1520 JournalConfig {
1521 max_segment_bytes: 0,
1522 ..JournalConfig::default()
1523 },
1524 DurableBrokerConfig::default(),
1525 Arc::new(RecordingAudit::default()),
1526 Arc::new(crate::events::broker::SystemClock),
1527 )
1528 .err()
1529 .expect("an invalid journal config must not open");
1530 assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
1531 }
1532
1533 #[test]
1534 fn subscribe_propagates_non_cursor_errors_from_the_inner_broker_unchanged() {
1535 let root = test_root("subscribe-non-cursor-error");
1536 let broker = DurableBroker::open(
1537 &root,
1538 inner_broker(),
1539 JournalConfig::default(),
1540 DurableBrokerConfig::default(),
1541 Arc::new(RecordingAudit::default()),
1542 Arc::new(crate::events::broker::SystemClock),
1543 )
1544 .expect("durable broker must open");
1545
1546 let err = broker
1547 .subscribe("dev.traverse.test.unregistered", "0")
1548 .expect_err(
1549 "an unregistered event type must be rejected without consulting the journal",
1550 );
1551 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1552 }
1553
1554 #[test]
1555 fn durable_poll_returns_no_events_when_already_caught_up_to_the_journal_tip() {
1556 let root = test_root("fallback-caught-up");
1557 let clock = Arc::new(ManualClock::new());
1558 let journal = open_shared_journal(&root);
1559 let broker = DurableBroker::new(
1560 short_retention_inner(clock.clone()),
1561 SharedJournalSink(Arc::clone(&journal)),
1562 Arc::clone(&journal) as Arc<dyn JournalSource>,
1563 DurableBrokerConfig::default(),
1564 Arc::new(RecordingAudit::default()),
1565 );
1566
1567 broker
1568 .publish(test_event())
1569 .expect("first publish must succeed");
1570 broker
1571 .publish(test_event())
1572 .expect("second publish must succeed");
1573 clock.advance(Duration::from_secs(1));
1574 let subscription = broker
1575 .subscribe(EVENT_TYPE, "1")
1576 .expect("subscribe must fall back to the durable journal");
1577
1578 let poll = broker
1582 .poll(&subscription.subscription_id, 10)
1583 .expect("first poll must succeed");
1584 assert_eq!(poll.events.len(), 1, "only the missed event must replay");
1585 let poll_again = broker
1586 .poll(&subscription.subscription_id, 10)
1587 .expect("second poll must succeed");
1588 assert!(
1589 poll_again.events.is_empty(),
1590 "nothing new since the last poll must yield an empty batch"
1591 );
1592 }
1593
1594 #[test]
1595 fn durable_poll_respects_max_events_and_can_be_paged() {
1596 let root = test_root("fallback-paging");
1597 let clock = Arc::new(ManualClock::new());
1598 let journal = open_shared_journal(&root);
1599 let broker = DurableBroker::new(
1600 short_retention_inner(clock.clone()),
1601 SharedJournalSink(Arc::clone(&journal)),
1602 Arc::clone(&journal) as Arc<dyn JournalSource>,
1603 DurableBrokerConfig::default(),
1604 Arc::new(RecordingAudit::default()),
1605 );
1606
1607 for _ in 0..4 {
1608 broker.publish(test_event()).expect("publish must succeed");
1609 }
1610 clock.advance(Duration::from_secs(1));
1611 let subscription = broker
1612 .subscribe(EVENT_TYPE, "1")
1613 .expect("subscribe must fall back to the durable journal");
1614
1615 let first_page = broker
1616 .poll(&subscription.subscription_id, 2)
1617 .expect("first page must succeed");
1618 assert_eq!(
1619 first_page.events.len(),
1620 2,
1621 "the page must stop at max_events"
1622 );
1623 assert_eq!(first_page.cursor, "3");
1624
1625 let second_page = broker
1626 .poll(&subscription.subscription_id, 2)
1627 .expect("second page must succeed");
1628 assert_eq!(second_page.events.len(), 1, "only one event remains");
1629 assert_eq!(second_page.cursor, "4");
1630 }
1631
1632 #[test]
1633 fn durable_poll_skips_records_of_other_event_types() {
1634 const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other";
1635 let root = test_root("fallback-other-type");
1636 let clock = Arc::new(ManualClock::new());
1637 let journal = open_shared_journal(&root);
1638 let catalog = active_catalog();
1639 catalog
1640 .register(EventCatalogEntry {
1641 event_type: OTHER_EVENT_TYPE.to_string(),
1642 version: "1.0.0".to_string(),
1643 owner: "test.capability".to_string(),
1644 lifecycle_status: LifecycleStatus::Active,
1645 consumer_count: 0,
1646 })
1647 .expect("second catalog entry must register");
1648 let inner = crate::events::broker::InProcessBroker::with_clock(
1649 catalog,
1650 crate::events::broker::BrokerConfig {
1651 retention_window: Duration::from_millis(10),
1652 max_queue_len: 16,
1653 },
1654 clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1655 )
1656 .expect("broker must build");
1657 let broker = DurableBroker::new(
1658 inner,
1659 SharedJournalSink(Arc::clone(&journal)),
1660 Arc::clone(&journal) as Arc<dyn JournalSource>,
1661 DurableBrokerConfig::default(),
1662 Arc::new(RecordingAudit::default()),
1663 );
1664
1665 broker
1666 .publish(test_event())
1667 .expect("first publish must succeed");
1668 let mut other = test_event();
1669 other.event_type = OTHER_EVENT_TYPE.to_string();
1670 broker
1671 .publish(other)
1672 .expect("other-type publish must succeed");
1673 broker
1674 .publish(test_event())
1675 .expect("third publish must succeed");
1676
1677 clock.advance(Duration::from_secs(1));
1678 let subscription = broker
1679 .subscribe(EVENT_TYPE, "1")
1680 .expect("subscribe must fall back to the durable journal");
1681 let poll = broker
1682 .poll(&subscription.subscription_id, 10)
1683 .expect("poll must succeed");
1684 assert_eq!(
1685 poll.events.len(),
1686 1,
1687 "the interleaved other-type record must be skipped"
1688 );
1689 assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1690 }
1691
1692 #[test]
1693 fn durable_poll_surfaces_cursor_expired_when_the_journal_prunes_mid_subscription() {
1694 struct ExpiresAfterFirstCallSource(std::sync::atomic::AtomicUsize);
1695
1696 impl JournalSource for ExpiresAfterFirstCallSource {
1697 fn replay_from(
1698 &self,
1699 _cursor: &str,
1700 _max_events: usize,
1701 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1702 let call = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1703 if call == 0 {
1704 Ok(Vec::new())
1705 } else {
1706 Err(JournalError::CursorExpired {
1707 oldest_available_cursor: "9".to_string(),
1708 })
1709 }
1710 }
1711 }
1712
1713 let clock = Arc::new(ManualClock::new());
1714 let (gate_tx, gate_rx) = channel();
1715 let (revoked_tx, _revoked_rx) = channel();
1716 let sink = ScriptedSink {
1717 gate: gate_rx,
1718 revocations: revoked_tx,
1719 fail_revocation: false,
1720 };
1721 let broker = DurableBroker::new(
1722 short_retention_inner(clock.clone()),
1723 sink,
1724 Arc::new(ExpiresAfterFirstCallSource(
1725 std::sync::atomic::AtomicUsize::new(0),
1726 )) as Arc<dyn JournalSource>,
1727 DurableBrokerConfig::default(),
1728 Arc::new(RecordingAudit::default()),
1729 );
1730
1731 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1732 broker
1733 .publish(test_event())
1734 .expect("first publish must succeed");
1735 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1736 broker
1737 .publish(test_event())
1738 .expect("second publish must succeed");
1739
1740 clock.advance(Duration::from_secs(1));
1741 let subscription = broker
1742 .subscribe(EVENT_TYPE, "1")
1743 .expect("subscribe must fall back to the durable journal (first source call)");
1744
1745 let err = broker.poll(&subscription.subscription_id, 10).expect_err(
1746 "the journal pruning this cursor mid-subscription must surface cursor_expired",
1747 );
1748 assert_eq!(
1749 err,
1750 EventError::CursorExpired {
1751 event_type: EVENT_TYPE.to_string(),
1752 oldest_available_cursor: "9".to_string(),
1753 }
1754 );
1755 }
1756
1757 #[test]
1758 fn durable_poll_propagates_a_corrupt_journal_read_as_journal_read() {
1759 struct CorruptSource;
1760 impl JournalSource for CorruptSource {
1761 fn replay_from(
1762 &self,
1763 _cursor: &str,
1764 _max_events: usize,
1765 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1766 Err(JournalError::Corrupt {
1767 path: "segment-1.jsonl".to_string(),
1768 line: 3,
1769 message: "truncated record".to_string(),
1770 })
1771 }
1772 }
1773
1774 let clock = Arc::new(ManualClock::new());
1775 let (gate_tx, gate_rx) = channel();
1776 let (revoked_tx, _revoked_rx) = channel();
1777 let sink = ScriptedSink {
1778 gate: gate_rx,
1779 revocations: revoked_tx,
1780 fail_revocation: false,
1781 };
1782 let broker = DurableBroker::new(
1783 short_retention_inner(clock.clone()),
1784 sink,
1785 Arc::new(CorruptSource) as Arc<dyn JournalSource>,
1786 DurableBrokerConfig::default(),
1787 Arc::new(RecordingAudit::default()),
1788 );
1789
1790 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1791 broker
1792 .publish(test_event())
1793 .expect("first publish must succeed");
1794 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1795 broker
1796 .publish(test_event())
1797 .expect("second publish must succeed");
1798
1799 clock.advance(Duration::from_secs(1));
1800 let err = broker
1801 .subscribe(EVENT_TYPE, "1")
1802 .expect_err("a corrupt journal read must not be silently swallowed");
1803 assert!(
1804 matches!(&err, EventError::JournalRead(msg) if msg.contains("truncated record")),
1805 "{err}"
1806 );
1807 }
1808
1809 #[test]
1810 fn null_source_fails_loudly_instead_of_silently_serving_a_fallback() {
1811 let clock = Arc::new(ManualClock::new());
1812 let broker = DurableBroker::new(
1813 short_retention_inner(clock.clone()),
1814 SharedJournalSink(open_shared_journal(&test_root("null-source-fallback"))),
1815 null_source(),
1816 DurableBrokerConfig::default(),
1817 Arc::new(RecordingAudit::default()),
1818 );
1819
1820 broker
1821 .publish(test_event())
1822 .expect("first publish must succeed");
1823 broker
1824 .publish(test_event())
1825 .expect("second publish must succeed");
1826 clock.advance(Duration::from_secs(1));
1827
1828 let err = broker.subscribe(EVENT_TYPE, "1").expect_err(
1829 "a broker with no real durable source must not silently accept a stale cursor",
1830 );
1831 assert!(matches!(err, EventError::CursorExpired { .. }), "{err}");
1832 }
1833
1834 #[test]
1835 fn map_journal_read_error_translates_invalid_cursor() {
1836 struct InvalidCursorSource;
1837 impl JournalSource for InvalidCursorSource {
1838 fn replay_from(
1839 &self,
1840 _cursor: &str,
1841 _max_events: usize,
1842 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1843 Err(JournalError::InvalidCursor("not a real cursor".to_string()))
1844 }
1845 }
1846
1847 let clock = Arc::new(ManualClock::new());
1848 let (gate_tx, gate_rx) = channel();
1849 let (revoked_tx, _revoked_rx) = channel();
1850 let sink = ScriptedSink {
1851 gate: gate_rx,
1852 revocations: revoked_tx,
1853 fail_revocation: false,
1854 };
1855 let broker = DurableBroker::new(
1856 short_retention_inner(clock.clone()),
1857 sink,
1858 Arc::new(InvalidCursorSource) as Arc<dyn JournalSource>,
1859 DurableBrokerConfig::default(),
1860 Arc::new(RecordingAudit::default()),
1861 );
1862
1863 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1864 broker
1865 .publish(test_event())
1866 .expect("first publish must succeed");
1867 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1868 broker
1869 .publish(test_event())
1870 .expect("second publish must succeed");
1871
1872 clock.advance(Duration::from_secs(1));
1873 let err = broker
1874 .subscribe(EVENT_TYPE, "1")
1875 .expect_err("an invalid durable cursor must not be silently accepted");
1876 assert!(matches!(err, EventError::InvalidCursor(_)), "{err}");
1877 }
1878
1879 #[test]
1880 fn shared_journal_sink_revokes_a_durably_written_but_undeliverable_event() {
1881 let root = test_root("shared-sink-revocation");
1885 let broker = DurableBroker::open(
1886 &root,
1887 inner_broker(),
1888 JournalConfig::default(),
1889 DurableBrokerConfig::default(),
1890 Arc::new(RecordingAudit::default()),
1891 Arc::new(crate::events::broker::SystemClock),
1892 )
1893 .expect("durable broker must open");
1894
1895 let mut unregistered = test_event();
1896 unregistered.event_type = "dev.traverse.test.unknown".to_string();
1897 let err = broker
1898 .publish(unregistered)
1899 .expect_err("an unregistered event type must be rejected by the inner broker");
1900 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1901
1902 let reader = DurableEventJournal::open(
1905 &root,
1906 JournalConfig::default(),
1907 Arc::new(crate::events::broker::SystemClock),
1908 )
1909 .expect("journal must reopen");
1910 let replayed = reader.replay_from("0", 10).expect("replay must succeed");
1911 assert!(
1912 replayed.is_empty(),
1913 "the revoked, undeliverable event must never replay"
1914 );
1915 }
1916
1917 #[test]
1918 fn durable_poll_fetches_additional_batches_when_a_full_batch_matches_nothing() {
1919 const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other-batching";
1920 let root = test_root("fallback-multi-batch");
1921 let clock = Arc::new(ManualClock::new());
1922 let journal = open_shared_journal(&root);
1923 let catalog = active_catalog();
1924 catalog
1925 .register(EventCatalogEntry {
1926 event_type: OTHER_EVENT_TYPE.to_string(),
1927 version: "1.0.0".to_string(),
1928 owner: "test.capability".to_string(),
1929 lifecycle_status: LifecycleStatus::Active,
1930 consumer_count: 0,
1931 })
1932 .expect("second catalog entry must register");
1933 let inner = crate::events::broker::InProcessBroker::with_clock(
1934 catalog,
1935 crate::events::broker::BrokerConfig {
1936 retention_window: Duration::from_millis(10),
1937 max_queue_len: 16,
1938 },
1939 clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1940 )
1941 .expect("broker must build");
1942 let broker = DurableBroker::new(
1943 inner,
1944 SharedJournalSink(Arc::clone(&journal)),
1945 Arc::clone(&journal) as Arc<dyn JournalSource>,
1946 DurableBrokerConfig::default(),
1947 Arc::new(RecordingAudit::default()),
1948 );
1949
1950 broker
1951 .publish(test_event())
1952 .expect("leading publish must succeed");
1953 let mut other_one = test_event();
1957 other_one.event_type = OTHER_EVENT_TYPE.to_string();
1958 broker
1959 .publish(other_one)
1960 .expect("first other-type publish must succeed");
1961 let mut other_two = test_event();
1962 other_two.event_type = OTHER_EVENT_TYPE.to_string();
1963 broker
1964 .publish(other_two)
1965 .expect("second other-type publish must succeed");
1966 broker
1967 .publish(test_event())
1968 .expect("trailing publish must succeed");
1969
1970 clock.advance(Duration::from_secs(1));
1971 let subscription = broker
1972 .subscribe(EVENT_TYPE, "1")
1973 .expect("subscribe must fall back to the durable journal");
1974
1975 let poll = broker
1976 .poll(&subscription.subscription_id, 1)
1977 .expect("poll must succeed across multiple internal batches");
1978 assert_eq!(poll.events.len(), 1);
1979 assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1980 assert_eq!(
1981 poll.cursor, "4",
1982 "cursor must advance past the skipped batch"
1983 );
1984 }
1985}