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: 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: 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 subject_id: None,
743 actor_id: None,
744 }
745 }
746
747 fn active_catalog() -> Arc<EventCatalog> {
748 let catalog = EventCatalog::new();
749 catalog
750 .register(EventCatalogEntry {
751 event_type: EVENT_TYPE.to_string(),
752 version: "1.0.0".to_string(),
753 owner: "test.capability".to_string(),
754 lifecycle_status: LifecycleStatus::Active,
755 consumer_count: 0,
756 })
757 .expect("catalog entry must register");
758 Arc::new(catalog)
759 }
760
761 fn inner_broker() -> InProcessBroker {
762 InProcessBroker::new(active_catalog()).expect("broker must build")
763 }
764
765 fn test_root(name: &str) -> std::path::PathBuf {
766 std::env::temp_dir().join(format!("traverse-durable-{name}-{}", Uuid::new_v4()))
767 }
768
769 struct NullJournalSource;
774
775 impl JournalSource for NullJournalSource {
776 fn replay_from(
777 &self,
778 _cursor: &str,
779 _max_events: usize,
780 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
781 Err(JournalError::CursorExpired {
782 oldest_available_cursor: "0".to_string(),
783 })
784 }
785 }
786
787 fn null_source() -> Arc<dyn JournalSource> {
788 Arc::new(NullJournalSource)
789 }
790
791 fn open_shared_journal(root: &std::path::Path) -> Arc<RwLock<DurableEventJournal>> {
792 Arc::new(RwLock::new(
793 DurableEventJournal::open(
794 root,
795 JournalConfig::default(),
796 Arc::new(crate::events::broker::SystemClock),
797 )
798 .expect("journal must open"),
799 ))
800 }
801
802 #[test]
803 fn journal_sink_impl_appends_and_revokes_through_the_real_journal() {
804 let root = test_root("sink-impl");
805 let mut journal = DurableEventJournal::open(
806 &root,
807 JournalConfig::default(),
808 Arc::new(crate::events::broker::SystemClock),
809 )
810 .expect("journal must open");
811 let cursor =
812 JournalSink::append_event(&mut journal, &test_event()).expect("append must succeed");
813 JournalSink::append_revocation(&mut journal, &cursor).expect("revocation must succeed");
814 assert!(
815 journal
816 .replay_from("0", 10)
817 .expect("replay must succeed")
818 .is_empty(),
819 "the revoked event must not replay"
820 );
821 }
822
823 #[test]
824 fn durable_publish_journals_then_delivers() {
825 let root = test_root("happy");
826 let audit = Arc::new(RecordingAudit::default());
827 let broker = DurableBroker::open(
828 &root,
829 inner_broker(),
830 JournalConfig::default(),
831 DurableBrokerConfig::default(),
832 audit.clone(),
833 Arc::new(crate::events::broker::SystemClock),
834 )
835 .expect("durable broker must open");
836
837 let subscription = broker
838 .subscribe(EVENT_TYPE, "0")
839 .expect("subscribe must succeed");
840 broker.publish(test_event()).expect("publish must succeed");
841
842 let poll = broker
843 .poll(&subscription.subscription_id, 10)
844 .expect("poll must succeed");
845 assert_eq!(poll.events.len(), 1, "live delivery must still work");
846 broker
847 .cancel(&subscription.subscription_id)
848 .expect("cancel must succeed");
849
850 let reader = DurableEventJournal::open(
851 &root,
852 JournalConfig::default(),
853 Arc::new(crate::events::broker::SystemClock),
854 )
855 .expect("journal must reopen");
856 let replayed = reader.replay_from("0", 10).expect("replay must succeed");
857 assert_eq!(replayed.len(), 1, "the event must be durable");
858 assert!(audit.kinds().is_empty(), "no audit records on success");
859 }
860
861 #[test]
862 fn durable_subject_subscription_delegates_to_inner_broker() {
863 let root = test_root("subject-subscription");
864 let broker = DurableBroker::open(
865 &root,
866 inner_broker(),
867 JournalConfig::default(),
868 DurableBrokerConfig::default(),
869 Arc::new(RecordingAudit::default()),
870 Arc::new(crate::events::broker::SystemClock),
871 )
872 .expect("durable broker must open");
873 let mut event = test_event();
874 event.subject_id = Some("subject-match".to_string());
875 broker.publish(event).expect("publish must succeed");
876
877 let subscription = broker
878 .subscribe_for_subject(EVENT_TYPE, "0", Some("subject-match"))
879 .expect("subject subscription must succeed");
880 let poll = broker
881 .poll(&subscription.subscription_id, 10)
882 .expect("poll must succeed");
883 assert_eq!(poll.events.len(), 1);
884 assert_eq!(
885 poll.events[0].event.subject_id.as_deref(),
886 Some("subject-match")
887 );
888 }
889
890 #[test]
891 fn undeliverable_event_is_revoked_after_durable_write() {
892 let (gate_tx, gate_rx) = channel();
893 let (revoked_tx, revoked_rx) = channel();
894 let sink = ScriptedSink {
895 gate: gate_rx,
896 revocations: revoked_tx,
897 fail_revocation: false,
898 };
899 let audit = Arc::new(RecordingAudit::default());
900 let broker = DurableBroker::new(
901 inner_broker(),
902 sink,
903 null_source(),
904 DurableBrokerConfig::default(),
905 audit,
906 );
907
908 let mut unregistered = test_event();
909 unregistered.event_type = "dev.traverse.test.unknown".to_string();
910 gate_tx
911 .send(Ok("41".to_string()))
912 .expect("gate must accept");
913 let err = broker
914 .publish(unregistered)
915 .expect_err("unregistered event type must be rejected by the inner broker");
916 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
917
918 let revoked = revoked_rx
919 .recv_timeout(Duration::from_secs(5))
920 .expect("the durably written but undelivered event must be revoked");
921 assert_eq!(revoked, "41");
922 }
923
924 #[test]
925 fn slow_durable_write_times_out_rejects_and_revokes() {
926 let (gate_tx, gate_rx) = channel();
927 let (revoked_tx, revoked_rx) = channel();
928 let (audit_tx, audit_rx) = channel();
929 let sink = ScriptedSink {
930 gate: gate_rx,
931 revocations: revoked_tx,
932 fail_revocation: false,
933 };
934 let audit = RecordingAudit::with_notify(audit_tx);
935 let broker = DurableBroker::new(
936 inner_broker(),
937 sink,
938 null_source(),
939 DurableBrokerConfig {
940 write_timeout: Duration::from_millis(50),
941 },
942 audit.clone(),
943 );
944
945 let subscription = broker
946 .subscribe(EVENT_TYPE, "0")
947 .expect("subscribe must succeed");
948 let err = broker
949 .publish(test_event())
950 .expect_err("a stalled durable write must time out");
951 assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
952 assert!(
953 err.to_string().contains("journal_write_timeout"),
954 "the error code must be distinct: {err}"
955 );
956
957 let timeout_audit = audit_rx
958 .recv_timeout(Duration::from_secs(5))
959 .expect("timeout must produce a structured audit record");
960 assert_eq!(timeout_audit.kind, "journal_write_timeout");
961 assert_eq!(timeout_audit.event_type, EVENT_TYPE);
962
963 let poll = broker
964 .poll(&subscription.subscription_id, 10)
965 .expect("poll must succeed");
966 assert!(
967 poll.events.is_empty(),
968 "a rejected event must not be delivered live"
969 );
970
971 gate_tx.send(Ok("7".to_string())).expect("gate must accept");
974 let revoked = revoked_rx
975 .recv_timeout(Duration::from_secs(5))
976 .expect("the late write must be revoked");
977 assert_eq!(revoked, "7");
978 }
979
980 #[test]
981 fn failed_durable_write_rejects_the_event() {
982 let (gate_tx, gate_rx) = channel();
983 let (revoked_tx, _revoked_rx) = channel();
984 let sink = ScriptedSink {
985 gate: gate_rx,
986 revocations: revoked_tx,
987 fail_revocation: false,
988 };
989 let audit = Arc::new(RecordingAudit::default());
990 let broker = DurableBroker::new(
991 inner_broker(),
992 sink,
993 null_source(),
994 DurableBrokerConfig::default(),
995 audit,
996 );
997
998 gate_tx
999 .send(Err(JournalError::Io("disk gone".to_string())))
1000 .expect("gate must accept");
1001 let err = broker
1002 .publish(test_event())
1003 .expect_err("a failed durable write must reject the event");
1004 assert!(matches!(err, EventError::JournalWrite(_)), "{err}");
1005 assert!(err.to_string().contains("disk gone"), "{err}");
1006 }
1007
1008 #[test]
1009 fn revocation_failures_are_audited() {
1010 let (gate_tx, gate_rx) = channel();
1011 let (revoked_tx, revoked_rx) = channel();
1012 let (audit_tx, audit_rx) = channel();
1013 let sink = ScriptedSink {
1014 gate: gate_rx,
1015 revocations: revoked_tx,
1016 fail_revocation: true,
1017 };
1018 let audit = RecordingAudit::with_notify(audit_tx);
1019 let broker = DurableBroker::new(
1020 inner_broker(),
1021 sink,
1022 null_source(),
1023 DurableBrokerConfig {
1024 write_timeout: Duration::from_millis(50),
1025 },
1026 audit.clone(),
1027 );
1028
1029 let err = broker
1030 .publish(test_event())
1031 .expect_err("a stalled durable write must time out");
1032 assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
1033 let timeout_audit = audit_rx
1034 .recv_timeout(Duration::from_secs(5))
1035 .expect("timeout audit must arrive");
1036 assert_eq!(timeout_audit.kind, "journal_write_timeout");
1037
1038 gate_tx.send(Ok("9".to_string())).expect("gate must accept");
1039 let _ = revoked_rx
1040 .recv_timeout(Duration::from_secs(5))
1041 .expect("revocation must be attempted");
1042 let failure_audit = audit_rx
1043 .recv_timeout(Duration::from_secs(5))
1044 .expect("revocation failure must be audited");
1045 assert_eq!(failure_audit.kind, "journal_revocation_failed");
1046 assert!(failure_audit.detail.contains("revocation rejected"));
1047 }
1048
1049 #[test]
1050 fn immediate_revocation_failure_returns_a_stable_error() {
1051 let (gate_tx, gate_rx) = channel();
1052 let (revoked_tx, _revoked_rx) = channel();
1053 let sink = ScriptedSink {
1054 gate: gate_rx,
1055 revocations: revoked_tx,
1056 fail_revocation: true,
1057 };
1058 let broker = DurableBroker::new(
1059 inner_broker(),
1060 sink,
1061 null_source(),
1062 DurableBrokerConfig::default(),
1063 Arc::new(RecordingAudit::default()),
1064 );
1065
1066 let mut unregistered = test_event();
1067 unregistered.event_type = "dev.traverse.test.unknown".to_string();
1068 gate_tx
1069 .send(Ok("11".to_string()))
1070 .expect("gate must accept");
1071 let error = broker
1072 .publish(unregistered)
1073 .expect_err("delivery and revocation failure must be returned");
1074 assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1075 assert!(error.to_string().contains("revocation rejected"));
1076 }
1077
1078 #[test]
1079 fn disconnected_revocation_writer_is_audited() {
1080 let (jobs, receiver) = channel();
1081 drop(receiver);
1082 let revoke = WriterJob::Revoke {
1083 cursor: "1".to_string(),
1084 event_id: "event-1".to_string(),
1085 event_type: EVENT_TYPE.to_string(),
1086 ack: Some(Arc::new((Mutex::new(AckState::Pending), Condvar::new()))),
1087 };
1088 let audit = RecordingAudit::default();
1089 let error = enqueue_revocation(&jobs, revoke, &audit, "event-1", EVENT_TYPE)
1090 .expect_err("disconnected writer must fail closed");
1091 assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1092 }
1093
1094 #[test]
1095 fn pending_revocation_ack_reports_timeout_and_audits_failure() {
1096 let audit = RecordingAudit::default();
1097 let timeout = map_revocation_outcome(
1098 AckState::Pending,
1099 &audit,
1100 "event-1",
1101 EVENT_TYPE,
1102 Duration::from_millis(25),
1103 )
1104 .expect_err("pending acknowledgement must time out");
1105 assert!(matches!(timeout, EventError::JournalWriteTimeout(_)));
1106 assert_eq!(audit.kinds(), vec!["journal_revocation_failed"]);
1107 }
1108
1109 #[derive(Debug)]
1112 struct ManualClock(StdMutex<std::time::SystemTime>);
1113
1114 impl ManualClock {
1115 fn new() -> Self {
1116 Self(StdMutex::new(std::time::SystemTime::now()))
1117 }
1118
1119 fn advance(&self, by: Duration) {
1120 if let Ok(mut guard) = self.0.lock()
1121 && let Some(next) = guard.checked_add(by)
1122 {
1123 *guard = next;
1124 }
1125 }
1126 }
1127
1128 impl crate::events::broker::BrokerClock for ManualClock {
1129 fn now(&self) -> std::time::SystemTime {
1130 self.0
1131 .lock()
1132 .ok()
1133 .map_or(std::time::SystemTime::UNIX_EPOCH, |guard| *guard)
1134 }
1135 }
1136
1137 struct AlwaysExpiredSource;
1141
1142 impl JournalSource for AlwaysExpiredSource {
1143 fn replay_from(
1144 &self,
1145 _cursor: &str,
1146 _max_events: usize,
1147 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1148 Err(JournalError::CursorExpired {
1149 oldest_available_cursor: "5".to_string(),
1150 })
1151 }
1152 }
1153
1154 fn short_retention_inner(
1155 clock: Arc<dyn crate::events::broker::BrokerClock>,
1156 ) -> crate::events::broker::InProcessBroker {
1157 crate::events::broker::InProcessBroker::with_clock(
1158 active_catalog(),
1159 crate::events::broker::BrokerConfig {
1160 retention_window: Duration::from_millis(10),
1161 max_queue_len: 16,
1162 },
1163 clock,
1164 )
1165 .expect("short-retention broker must build")
1166 }
1167
1168 #[test]
1169 fn poll_falls_back_to_the_durable_journal_once_the_in_memory_window_expires() {
1170 let root = test_root("fallback-live");
1171 let clock = Arc::new(ManualClock::new());
1172 let journal = open_shared_journal(&root);
1173 let audit = Arc::new(RecordingAudit::default());
1174 let broker = DurableBroker::new(
1175 short_retention_inner(clock.clone()),
1176 SharedJournalSink(Arc::clone(&journal)),
1177 Arc::clone(&journal) as Arc<dyn JournalSource>,
1178 DurableBrokerConfig::default(),
1179 audit,
1180 );
1181
1182 broker
1187 .publish(test_event())
1188 .expect("first publish must succeed");
1189 broker
1190 .publish(test_event())
1191 .expect("second publish must succeed");
1192
1193 clock.advance(Duration::from_secs(1));
1197 let subscription = broker
1198 .subscribe(EVENT_TYPE, "1")
1199 .expect("subscribing on an expired cursor must fall back to the durable journal");
1200 assert!(
1201 subscription
1202 .subscription_id
1203 .starts_with(DURABLE_SUBSCRIPTION_PREFIX),
1204 "a durable fallback subscription must use the durable-mode id prefix"
1205 );
1206
1207 let poll = broker
1208 .poll(&subscription.subscription_id, 10)
1209 .expect("durable poll must succeed");
1210 assert_eq!(
1211 poll.events.len(),
1212 1,
1213 "only the event strictly after cursor \"1\" must replay"
1214 );
1215
1216 broker
1220 .publish(test_event())
1221 .expect("second publish must succeed");
1222 let poll_again = broker
1223 .poll(&subscription.subscription_id, 10)
1224 .expect("second durable poll must succeed");
1225 assert_eq!(poll_again.events.len(), 1, "the new event must also replay");
1226
1227 broker
1228 .cancel(&subscription.subscription_id)
1229 .expect("cancelling a durable-mode subscription must succeed");
1230 let after_cancel = broker.poll(&subscription.subscription_id, 10);
1231 assert!(matches!(
1232 after_cancel,
1233 Err(EventError::SubscriptionNotFound(_))
1234 ));
1235 }
1236
1237 #[test]
1238 fn durable_fallback_applies_the_identical_subject_filter_as_live_delivery() {
1239 let root = test_root("fallback-subject");
1240 let clock = Arc::new(ManualClock::new());
1241 let journal = open_shared_journal(&root);
1242 let broker = DurableBroker::new(
1243 short_retention_inner(clock.clone()),
1244 SharedJournalSink(Arc::clone(&journal)),
1245 Arc::clone(&journal) as Arc<dyn JournalSource>,
1246 DurableBrokerConfig::default(),
1247 Arc::new(RecordingAudit::default()),
1248 );
1249
1250 broker
1254 .publish(test_event())
1255 .expect("leading publish must succeed");
1256 let mut matching = test_event();
1257 matching.subject_id = Some("subject-match".to_string());
1258 broker.publish(matching).expect("publish must succeed");
1259 let mut other = test_event();
1260 other.subject_id = Some("subject-other".to_string());
1261 broker.publish(other).expect("publish must succeed");
1262
1263 clock.advance(Duration::from_secs(1));
1264 let subscription = broker
1265 .subscribe_for_subject(EVENT_TYPE, "1", Some("subject-match"))
1266 .expect("subject subscription must fall back to the durable journal");
1267 assert!(
1268 subscription
1269 .subscription_id
1270 .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1271 );
1272
1273 let poll = broker
1274 .poll(&subscription.subscription_id, 10)
1275 .expect("durable poll must succeed");
1276 assert_eq!(
1277 poll.events.len(),
1278 1,
1279 "only the matching subject must replay"
1280 );
1281 assert_eq!(
1282 poll.events[0].event.subject_id.as_deref(),
1283 Some("subject-match")
1284 );
1285 }
1286
1287 #[test]
1288 fn durable_fallback_surfaces_cursor_expired_when_the_journal_has_also_reclaimed_history() {
1289 let clock = Arc::new(ManualClock::new());
1290 let (gate_tx, gate_rx) = channel();
1291 let (revoked_tx, _revoked_rx) = channel();
1292 let sink = ScriptedSink {
1293 gate: gate_rx,
1294 revocations: revoked_tx,
1295 fail_revocation: false,
1296 };
1297 let broker = DurableBroker::new(
1298 short_retention_inner(clock.clone()),
1299 sink,
1300 Arc::new(AlwaysExpiredSource) as Arc<dyn JournalSource>,
1301 DurableBrokerConfig::default(),
1302 Arc::new(RecordingAudit::default()),
1303 );
1304
1305 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1306 broker
1307 .publish(test_event())
1308 .expect("first publish must succeed");
1309 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1310 broker
1311 .publish(test_event())
1312 .expect("second publish must succeed");
1313
1314 clock.advance(Duration::from_secs(1));
1315 let err = broker
1316 .subscribe(EVENT_TYPE, "1")
1317 .expect_err("both the in-memory and durable layers have expired this cursor");
1318 assert_eq!(
1319 err,
1320 EventError::CursorExpired {
1321 event_type: EVENT_TYPE.to_string(),
1322 oldest_available_cursor: "5".to_string(),
1323 },
1324 "the durable journal's own oldest-available cursor must be surfaced"
1325 );
1326 }
1327
1328 #[test]
1329 fn durable_fallback_propagates_non_cursor_journal_read_errors() {
1330 struct FailingSource;
1331 impl JournalSource for FailingSource {
1332 fn replay_from(
1333 &self,
1334 _cursor: &str,
1335 _max_events: usize,
1336 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1337 Err(JournalError::Io("disk unavailable".to_string()))
1338 }
1339 }
1340
1341 let clock = Arc::new(ManualClock::new());
1342 let (gate_tx, gate_rx) = channel();
1343 let (revoked_tx, _revoked_rx) = channel();
1344 let sink = ScriptedSink {
1345 gate: gate_rx,
1346 revocations: revoked_tx,
1347 fail_revocation: false,
1348 };
1349 let broker = DurableBroker::new(
1350 short_retention_inner(clock.clone()),
1351 sink,
1352 Arc::new(FailingSource) as Arc<dyn JournalSource>,
1353 DurableBrokerConfig::default(),
1354 Arc::new(RecordingAudit::default()),
1355 );
1356
1357 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1358 broker
1359 .publish(test_event())
1360 .expect("first publish must succeed");
1361 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1362 broker
1363 .publish(test_event())
1364 .expect("second publish must succeed");
1365
1366 clock.advance(Duration::from_secs(1));
1367 let err = broker
1368 .subscribe(EVENT_TYPE, "1")
1369 .expect_err("a durable read failure must not be silently swallowed");
1370 assert!(matches!(err, EventError::JournalRead(_)), "{err}");
1371 }
1372
1373 #[test]
1374 fn poll_on_an_unknown_durable_subscription_id_returns_subscription_not_found() {
1375 let root = test_root("fallback-unknown-poll");
1376 let journal = open_shared_journal(&root);
1377 let broker = DurableBroker::new(
1378 inner_broker(),
1379 SharedJournalSink(Arc::clone(&journal)),
1380 Arc::clone(&journal) as Arc<dyn JournalSource>,
1381 DurableBrokerConfig::default(),
1382 Arc::new(RecordingAudit::default()),
1383 );
1384
1385 let err = broker
1386 .poll(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"), 10)
1387 .expect_err("unknown durable subscription id must be rejected");
1388 assert!(matches!(err, EventError::SubscriptionNotFound(_)));
1389
1390 let cancel_err = broker
1391 .cancel(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"))
1392 .expect_err("cancelling an unknown durable subscription id must be rejected");
1393 assert!(matches!(cancel_err, EventError::SubscriptionNotFound(_)));
1394 }
1395
1396 #[test]
1397 fn poll_with_zero_max_events_on_a_durable_subscription_returns_no_events() {
1398 let root = test_root("fallback-zero-poll");
1399 let clock = Arc::new(ManualClock::new());
1400 let journal = open_shared_journal(&root);
1401 let broker = DurableBroker::new(
1402 short_retention_inner(clock.clone()),
1403 SharedJournalSink(Arc::clone(&journal)),
1404 Arc::clone(&journal) as Arc<dyn JournalSource>,
1405 DurableBrokerConfig::default(),
1406 Arc::new(RecordingAudit::default()),
1407 );
1408
1409 broker
1410 .publish(test_event())
1411 .expect("first publish must succeed");
1412 broker
1413 .publish(test_event())
1414 .expect("second publish must succeed");
1415 clock.advance(Duration::from_secs(1));
1416 let subscription = broker
1417 .subscribe(EVENT_TYPE, "1")
1418 .expect("subscribe must fall back to the durable journal");
1419
1420 let poll = broker
1421 .poll(&subscription.subscription_id, 0)
1422 .expect("poll with max_events=0 must succeed");
1423 assert!(poll.events.is_empty());
1424 assert_eq!(poll.cursor, "1", "an unread cursor must not advance");
1425 }
1426
1427 #[test]
1428 fn cursor_survives_a_full_broker_restart_at_the_same_journal_root() {
1429 let root = test_root("restart-continuity");
1430 let cursor_before_restart = {
1431 let broker = DurableBroker::open(
1432 &root,
1433 inner_broker(),
1434 JournalConfig::default(),
1435 DurableBrokerConfig::default(),
1436 Arc::new(RecordingAudit::default()),
1437 Arc::new(crate::events::broker::SystemClock),
1438 )
1439 .expect("durable broker must open");
1440
1441 let subscription = broker
1442 .subscribe(EVENT_TYPE, "0")
1443 .expect("subscribe must succeed");
1444 broker
1445 .publish(test_event())
1446 .expect("first publish must succeed");
1447 let poll = broker
1448 .poll(&subscription.subscription_id, 10)
1449 .expect("poll must succeed");
1450 assert_eq!(poll.events.len(), 1);
1451 broker
1455 .publish(test_event())
1456 .expect("second publish must succeed");
1457 poll.cursor
1458 };
1462
1463 let restarted = DurableBroker::open(
1466 &root,
1467 inner_broker(),
1468 JournalConfig::default(),
1469 DurableBrokerConfig::default(),
1470 Arc::new(RecordingAudit::default()),
1471 Arc::new(crate::events::broker::SystemClock),
1472 )
1473 .expect("durable broker must reopen at the same root");
1474
1475 let subscription = restarted
1480 .subscribe(EVENT_TYPE, &cursor_before_restart)
1481 .expect("resuming the pre-restart cursor must succeed after restart");
1482 assert!(
1483 subscription
1484 .subscription_id
1485 .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1486 );
1487
1488 let poll = restarted
1489 .poll(&subscription.subscription_id, 10)
1490 .expect("poll after restart must succeed");
1491 assert_eq!(
1492 poll.events.len(),
1493 1,
1494 "the event published before restart but never consumed must replay"
1495 );
1496
1497 restarted
1498 .publish(test_event())
1499 .expect("publish after restart must succeed");
1500 let poll = restarted
1501 .poll(&subscription.subscription_id, 10)
1502 .expect("second poll after restart must succeed");
1503 assert_eq!(
1504 poll.events.len(),
1505 1,
1506 "the event published after restart must replay from the resumed cursor"
1507 );
1508 }
1509
1510 #[test]
1511 fn open_surfaces_an_invalid_journal_config() {
1512 let root = test_root("open-invalid-config");
1513 let err = DurableBroker::open(
1514 &root,
1515 inner_broker(),
1516 JournalConfig {
1517 max_segment_bytes: 0,
1518 ..JournalConfig::default()
1519 },
1520 DurableBrokerConfig::default(),
1521 Arc::new(RecordingAudit::default()),
1522 Arc::new(crate::events::broker::SystemClock),
1523 )
1524 .err()
1525 .expect("an invalid journal config must not open");
1526 assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
1527 }
1528
1529 #[test]
1530 fn subscribe_propagates_non_cursor_errors_from_the_inner_broker_unchanged() {
1531 let root = test_root("subscribe-non-cursor-error");
1532 let broker = DurableBroker::open(
1533 &root,
1534 inner_broker(),
1535 JournalConfig::default(),
1536 DurableBrokerConfig::default(),
1537 Arc::new(RecordingAudit::default()),
1538 Arc::new(crate::events::broker::SystemClock),
1539 )
1540 .expect("durable broker must open");
1541
1542 let err = broker
1543 .subscribe("dev.traverse.test.unregistered", "0")
1544 .expect_err(
1545 "an unregistered event type must be rejected without consulting the journal",
1546 );
1547 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1548 }
1549
1550 #[test]
1551 fn durable_poll_returns_no_events_when_already_caught_up_to_the_journal_tip() {
1552 let root = test_root("fallback-caught-up");
1553 let clock = Arc::new(ManualClock::new());
1554 let journal = open_shared_journal(&root);
1555 let broker = DurableBroker::new(
1556 short_retention_inner(clock.clone()),
1557 SharedJournalSink(Arc::clone(&journal)),
1558 Arc::clone(&journal) as Arc<dyn JournalSource>,
1559 DurableBrokerConfig::default(),
1560 Arc::new(RecordingAudit::default()),
1561 );
1562
1563 broker
1564 .publish(test_event())
1565 .expect("first publish must succeed");
1566 broker
1567 .publish(test_event())
1568 .expect("second publish must succeed");
1569 clock.advance(Duration::from_secs(1));
1570 let subscription = broker
1571 .subscribe(EVENT_TYPE, "1")
1572 .expect("subscribe must fall back to the durable journal");
1573
1574 let poll = broker
1578 .poll(&subscription.subscription_id, 10)
1579 .expect("first poll must succeed");
1580 assert_eq!(poll.events.len(), 1, "only the missed event must replay");
1581 let poll_again = broker
1582 .poll(&subscription.subscription_id, 10)
1583 .expect("second poll must succeed");
1584 assert!(
1585 poll_again.events.is_empty(),
1586 "nothing new since the last poll must yield an empty batch"
1587 );
1588 }
1589
1590 #[test]
1591 fn durable_poll_respects_max_events_and_can_be_paged() {
1592 let root = test_root("fallback-paging");
1593 let clock = Arc::new(ManualClock::new());
1594 let journal = open_shared_journal(&root);
1595 let broker = DurableBroker::new(
1596 short_retention_inner(clock.clone()),
1597 SharedJournalSink(Arc::clone(&journal)),
1598 Arc::clone(&journal) as Arc<dyn JournalSource>,
1599 DurableBrokerConfig::default(),
1600 Arc::new(RecordingAudit::default()),
1601 );
1602
1603 for _ in 0..4 {
1604 broker.publish(test_event()).expect("publish must succeed");
1605 }
1606 clock.advance(Duration::from_secs(1));
1607 let subscription = broker
1608 .subscribe(EVENT_TYPE, "1")
1609 .expect("subscribe must fall back to the durable journal");
1610
1611 let first_page = broker
1612 .poll(&subscription.subscription_id, 2)
1613 .expect("first page must succeed");
1614 assert_eq!(
1615 first_page.events.len(),
1616 2,
1617 "the page must stop at max_events"
1618 );
1619 assert_eq!(first_page.cursor, "3");
1620
1621 let second_page = broker
1622 .poll(&subscription.subscription_id, 2)
1623 .expect("second page must succeed");
1624 assert_eq!(second_page.events.len(), 1, "only one event remains");
1625 assert_eq!(second_page.cursor, "4");
1626 }
1627
1628 #[test]
1629 fn durable_poll_skips_records_of_other_event_types() {
1630 const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other";
1631 let root = test_root("fallback-other-type");
1632 let clock = Arc::new(ManualClock::new());
1633 let journal = open_shared_journal(&root);
1634 let catalog = active_catalog();
1635 catalog
1636 .register(EventCatalogEntry {
1637 event_type: OTHER_EVENT_TYPE.to_string(),
1638 version: "1.0.0".to_string(),
1639 owner: "test.capability".to_string(),
1640 lifecycle_status: LifecycleStatus::Active,
1641 consumer_count: 0,
1642 })
1643 .expect("second catalog entry must register");
1644 let inner = crate::events::broker::InProcessBroker::with_clock(
1645 catalog,
1646 crate::events::broker::BrokerConfig {
1647 retention_window: Duration::from_millis(10),
1648 max_queue_len: 16,
1649 },
1650 clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1651 )
1652 .expect("broker must build");
1653 let broker = DurableBroker::new(
1654 inner,
1655 SharedJournalSink(Arc::clone(&journal)),
1656 Arc::clone(&journal) as Arc<dyn JournalSource>,
1657 DurableBrokerConfig::default(),
1658 Arc::new(RecordingAudit::default()),
1659 );
1660
1661 broker
1662 .publish(test_event())
1663 .expect("first publish must succeed");
1664 let mut other = test_event();
1665 other.event_type = OTHER_EVENT_TYPE.to_string();
1666 broker
1667 .publish(other)
1668 .expect("other-type publish must succeed");
1669 broker
1670 .publish(test_event())
1671 .expect("third publish must succeed");
1672
1673 clock.advance(Duration::from_secs(1));
1674 let subscription = broker
1675 .subscribe(EVENT_TYPE, "1")
1676 .expect("subscribe must fall back to the durable journal");
1677 let poll = broker
1678 .poll(&subscription.subscription_id, 10)
1679 .expect("poll must succeed");
1680 assert_eq!(
1681 poll.events.len(),
1682 1,
1683 "the interleaved other-type record must be skipped"
1684 );
1685 assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1686 }
1687
1688 #[test]
1689 fn durable_poll_surfaces_cursor_expired_when_the_journal_prunes_mid_subscription() {
1690 struct ExpiresAfterFirstCallSource(std::sync::atomic::AtomicUsize);
1691
1692 impl JournalSource for ExpiresAfterFirstCallSource {
1693 fn replay_from(
1694 &self,
1695 _cursor: &str,
1696 _max_events: usize,
1697 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1698 let call = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1699 if call == 0 {
1700 Ok(Vec::new())
1701 } else {
1702 Err(JournalError::CursorExpired {
1703 oldest_available_cursor: "9".to_string(),
1704 })
1705 }
1706 }
1707 }
1708
1709 let clock = Arc::new(ManualClock::new());
1710 let (gate_tx, gate_rx) = channel();
1711 let (revoked_tx, _revoked_rx) = channel();
1712 let sink = ScriptedSink {
1713 gate: gate_rx,
1714 revocations: revoked_tx,
1715 fail_revocation: false,
1716 };
1717 let broker = DurableBroker::new(
1718 short_retention_inner(clock.clone()),
1719 sink,
1720 Arc::new(ExpiresAfterFirstCallSource(
1721 std::sync::atomic::AtomicUsize::new(0),
1722 )) as Arc<dyn JournalSource>,
1723 DurableBrokerConfig::default(),
1724 Arc::new(RecordingAudit::default()),
1725 );
1726
1727 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1728 broker
1729 .publish(test_event())
1730 .expect("first publish must succeed");
1731 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1732 broker
1733 .publish(test_event())
1734 .expect("second publish must succeed");
1735
1736 clock.advance(Duration::from_secs(1));
1737 let subscription = broker
1738 .subscribe(EVENT_TYPE, "1")
1739 .expect("subscribe must fall back to the durable journal (first source call)");
1740
1741 let err = broker.poll(&subscription.subscription_id, 10).expect_err(
1742 "the journal pruning this cursor mid-subscription must surface cursor_expired",
1743 );
1744 assert_eq!(
1745 err,
1746 EventError::CursorExpired {
1747 event_type: EVENT_TYPE.to_string(),
1748 oldest_available_cursor: "9".to_string(),
1749 }
1750 );
1751 }
1752
1753 #[test]
1754 fn durable_poll_propagates_a_corrupt_journal_read_as_journal_read() {
1755 struct CorruptSource;
1756 impl JournalSource for CorruptSource {
1757 fn replay_from(
1758 &self,
1759 _cursor: &str,
1760 _max_events: usize,
1761 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1762 Err(JournalError::Corrupt {
1763 path: "segment-1.jsonl".to_string(),
1764 line: 3,
1765 message: "truncated record".to_string(),
1766 })
1767 }
1768 }
1769
1770 let clock = Arc::new(ManualClock::new());
1771 let (gate_tx, gate_rx) = channel();
1772 let (revoked_tx, _revoked_rx) = channel();
1773 let sink = ScriptedSink {
1774 gate: gate_rx,
1775 revocations: revoked_tx,
1776 fail_revocation: false,
1777 };
1778 let broker = DurableBroker::new(
1779 short_retention_inner(clock.clone()),
1780 sink,
1781 Arc::new(CorruptSource) as Arc<dyn JournalSource>,
1782 DurableBrokerConfig::default(),
1783 Arc::new(RecordingAudit::default()),
1784 );
1785
1786 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1787 broker
1788 .publish(test_event())
1789 .expect("first publish must succeed");
1790 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1791 broker
1792 .publish(test_event())
1793 .expect("second publish must succeed");
1794
1795 clock.advance(Duration::from_secs(1));
1796 let err = broker
1797 .subscribe(EVENT_TYPE, "1")
1798 .expect_err("a corrupt journal read must not be silently swallowed");
1799 assert!(
1800 matches!(&err, EventError::JournalRead(msg) if msg.contains("truncated record")),
1801 "{err}"
1802 );
1803 }
1804
1805 #[test]
1806 fn null_source_fails_loudly_instead_of_silently_serving_a_fallback() {
1807 let clock = Arc::new(ManualClock::new());
1808 let broker = DurableBroker::new(
1809 short_retention_inner(clock.clone()),
1810 SharedJournalSink(open_shared_journal(&test_root("null-source-fallback"))),
1811 null_source(),
1812 DurableBrokerConfig::default(),
1813 Arc::new(RecordingAudit::default()),
1814 );
1815
1816 broker
1817 .publish(test_event())
1818 .expect("first publish must succeed");
1819 broker
1820 .publish(test_event())
1821 .expect("second publish must succeed");
1822 clock.advance(Duration::from_secs(1));
1823
1824 let err = broker.subscribe(EVENT_TYPE, "1").expect_err(
1825 "a broker with no real durable source must not silently accept a stale cursor",
1826 );
1827 assert!(matches!(err, EventError::CursorExpired { .. }), "{err}");
1828 }
1829
1830 #[test]
1831 fn map_journal_read_error_translates_invalid_cursor() {
1832 struct InvalidCursorSource;
1833 impl JournalSource for InvalidCursorSource {
1834 fn replay_from(
1835 &self,
1836 _cursor: &str,
1837 _max_events: usize,
1838 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1839 Err(JournalError::InvalidCursor("not a real cursor".to_string()))
1840 }
1841 }
1842
1843 let clock = Arc::new(ManualClock::new());
1844 let (gate_tx, gate_rx) = channel();
1845 let (revoked_tx, _revoked_rx) = channel();
1846 let sink = ScriptedSink {
1847 gate: gate_rx,
1848 revocations: revoked_tx,
1849 fail_revocation: false,
1850 };
1851 let broker = DurableBroker::new(
1852 short_retention_inner(clock.clone()),
1853 sink,
1854 Arc::new(InvalidCursorSource) as Arc<dyn JournalSource>,
1855 DurableBrokerConfig::default(),
1856 Arc::new(RecordingAudit::default()),
1857 );
1858
1859 gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1860 broker
1861 .publish(test_event())
1862 .expect("first publish must succeed");
1863 gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1864 broker
1865 .publish(test_event())
1866 .expect("second publish must succeed");
1867
1868 clock.advance(Duration::from_secs(1));
1869 let err = broker
1870 .subscribe(EVENT_TYPE, "1")
1871 .expect_err("an invalid durable cursor must not be silently accepted");
1872 assert!(matches!(err, EventError::InvalidCursor(_)), "{err}");
1873 }
1874
1875 #[test]
1876 fn shared_journal_sink_revokes_a_durably_written_but_undeliverable_event() {
1877 let root = test_root("shared-sink-revocation");
1881 let broker = DurableBroker::open(
1882 &root,
1883 inner_broker(),
1884 JournalConfig::default(),
1885 DurableBrokerConfig::default(),
1886 Arc::new(RecordingAudit::default()),
1887 Arc::new(crate::events::broker::SystemClock),
1888 )
1889 .expect("durable broker must open");
1890
1891 let mut unregistered = test_event();
1892 unregistered.event_type = "dev.traverse.test.unknown".to_string();
1893 let err = broker
1894 .publish(unregistered)
1895 .expect_err("an unregistered event type must be rejected by the inner broker");
1896 assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1897
1898 let reader = DurableEventJournal::open(
1901 &root,
1902 JournalConfig::default(),
1903 Arc::new(crate::events::broker::SystemClock),
1904 )
1905 .expect("journal must reopen");
1906 let replayed = reader.replay_from("0", 10).expect("replay must succeed");
1907 assert!(
1908 replayed.is_empty(),
1909 "the revoked, undeliverable event must never replay"
1910 );
1911 }
1912
1913 #[test]
1914 fn durable_poll_fetches_additional_batches_when_a_full_batch_matches_nothing() {
1915 const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other-batching";
1916 let root = test_root("fallback-multi-batch");
1917 let clock = Arc::new(ManualClock::new());
1918 let journal = open_shared_journal(&root);
1919 let catalog = active_catalog();
1920 catalog
1921 .register(EventCatalogEntry {
1922 event_type: OTHER_EVENT_TYPE.to_string(),
1923 version: "1.0.0".to_string(),
1924 owner: "test.capability".to_string(),
1925 lifecycle_status: LifecycleStatus::Active,
1926 consumer_count: 0,
1927 })
1928 .expect("second catalog entry must register");
1929 let inner = crate::events::broker::InProcessBroker::with_clock(
1930 catalog,
1931 crate::events::broker::BrokerConfig {
1932 retention_window: Duration::from_millis(10),
1933 max_queue_len: 16,
1934 },
1935 clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1936 )
1937 .expect("broker must build");
1938 let broker = DurableBroker::new(
1939 inner,
1940 SharedJournalSink(Arc::clone(&journal)),
1941 Arc::clone(&journal) as Arc<dyn JournalSource>,
1942 DurableBrokerConfig::default(),
1943 Arc::new(RecordingAudit::default()),
1944 );
1945
1946 broker
1947 .publish(test_event())
1948 .expect("leading publish must succeed");
1949 let mut other_one = test_event();
1953 other_one.event_type = OTHER_EVENT_TYPE.to_string();
1954 broker
1955 .publish(other_one)
1956 .expect("first other-type publish must succeed");
1957 let mut other_two = test_event();
1958 other_two.event_type = OTHER_EVENT_TYPE.to_string();
1959 broker
1960 .publish(other_two)
1961 .expect("second other-type publish must succeed");
1962 broker
1963 .publish(test_event())
1964 .expect("trailing publish must succeed");
1965
1966 clock.advance(Duration::from_secs(1));
1967 let subscription = broker
1968 .subscribe(EVENT_TYPE, "1")
1969 .expect("subscribe must fall back to the durable journal");
1970
1971 let poll = broker
1972 .poll(&subscription.subscription_id, 1)
1973 .expect("poll must succeed across multiple internal batches");
1974 assert_eq!(poll.events.len(), 1);
1975 assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1976 assert_eq!(
1977 poll.cursor, "4",
1978 "cursor must advance past the skipped batch"
1979 );
1980 }
1981}