1use std::{
26 any::Any,
27 cell::RefCell,
28 fmt::Debug,
29 path::{Path, PathBuf},
30 rc::Rc,
31 sync::{
32 Arc, Mutex,
33 atomic::{AtomicBool, AtomicU64, Ordering},
34 },
35 thread,
36 time::{Duration, Instant},
37};
38
39use bytes::Bytes;
40use nautilus_common::{
41 cache::{Cache, CacheSnapshotRef},
42 clock::Clock,
43 enums::Environment,
44 msgbus::{self, BusTap, Endpoint, MStr, MessagingSwitchboard},
45};
46#[cfg(feature = "live")]
47use nautilus_core::time::get_atomic_clock_realtime;
48use nautilus_core::{
49 UUID4, UnixNanos,
50 time::{AtomicTime, get_atomic_clock_static},
51};
52use nautilus_execution::engine::SnapshotAnchorer;
53use nautilus_system::{
54 KernelEventStore as KernelEventStoreTrait, RegisteredComponents,
55 event_store::{DataMarkerClass, DataMarkerConfig, EventStoreConfig, RetentionMode},
56};
57use ustr::Ustr;
58
59use crate::{
60 BusCaptureAdapter, CacheReplayError, CacheReplayReport, CaptureError, EncoderRegistry,
61 EntryDraft, EventStore, EventStoreError, EventStoreWriter, HaltCallback, HaltReason, Headers,
62 RedbBackend, RunId, RunManifest, RunStatus, ScanDirection, Topic, WriterConfig,
63 compute_snapshot_content_hash, default_registry,
64 markers::{
65 DataClass, DataMarkerCapture, DataMarkerExtractorRegistry, MarkerBackend, MarkerManifest,
66 MarkerWriter, MarkerWriterConfig, RedbMarkerBackend,
67 },
68 restore_cache_from_sealed_run, validate_event_store_replay_source,
69};
70
71const RUN_STARTED_TOPIC: &str = "run.lifecycle.RunStarted";
72const RUN_STARTED_PAYLOAD_TYPE: &str = "RunStarted";
73const RUN_ENDED_TOPIC: &str = "run.lifecycle.RunEnded";
74const RUN_ENDED_PAYLOAD_TYPE: &str = "RunEnded";
75
76#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct RecoveredRun {
79 pub run_id: RunId,
81 pub status: RunStatus,
84}
85
86#[derive(Debug, Default)]
88pub struct RecoveryOutcome {
89 pub recovered: Vec<RecoveredRun>,
91 pub parent_run_id: Option<RunId>,
95}
96
97type RegistryFactory = dyn Fn() -> EncoderRegistry + Send + Sync + 'static;
98type BackendOpenResult = Result<Box<dyn EventStore + Send>, EventStoreError>;
99type BackendOpener =
100 dyn Fn(&EventStoreConfig, &RunManifest) -> BackendOpenResult + Send + Sync + 'static;
101type MarkerRegistryFactory =
102 dyn Fn(&[DataClass]) -> DataMarkerExtractorRegistry + Send + Sync + 'static;
103type SharedMarkerCapture = Rc<RefCell<Option<DataMarkerCapture>>>;
104
105#[derive(Clone)]
111pub struct EventStoreLifecycleOptions {
112 registry_factory: Arc<RegistryFactory>,
113 backend_opener: Arc<BackendOpener>,
114 marker_registry_factory: Arc<MarkerRegistryFactory>,
115}
116
117impl Debug for EventStoreLifecycleOptions {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.debug_struct(stringify!(EventStoreLifecycleOptions))
120 .finish_non_exhaustive()
121 }
122}
123
124impl Default for EventStoreLifecycleOptions {
125 fn default() -> Self {
126 Self {
127 registry_factory: Arc::new(default_registry),
128 backend_opener: Arc::new(default_backend_opener),
129 marker_registry_factory: Arc::new(DataMarkerExtractorRegistry::default_registry),
130 }
131 }
132}
133
134impl EventStoreLifecycleOptions {
135 #[must_use]
137 pub fn new() -> Self {
138 Self::default()
139 }
140
141 #[must_use]
143 pub fn with_registry_factory<F>(mut self, factory: F) -> Self
144 where
145 F: Fn() -> EncoderRegistry + Send + Sync + 'static,
146 {
147 self.registry_factory = Arc::new(factory);
148 self
149 }
150
151 #[must_use]
153 pub fn with_encoder_registry(self, registry: EncoderRegistry) -> Self {
154 self.with_registry_factory(move || registry.clone())
155 }
156
157 #[must_use]
159 pub fn with_backend_opener<F>(mut self, opener: F) -> Self
160 where
161 F: Fn(&EventStoreConfig, &RunManifest) -> BackendOpenResult + Send + Sync + 'static,
162 {
163 self.backend_opener = Arc::new(opener);
164 self
165 }
166
167 #[must_use]
169 pub fn with_marker_registry_factory<F>(mut self, factory: F) -> Self
170 where
171 F: Fn(&[DataClass]) -> DataMarkerExtractorRegistry + Send + Sync + 'static,
172 {
173 self.marker_registry_factory = Arc::new(factory);
174 self
175 }
176
177 fn build_registry(&self) -> EncoderRegistry {
178 (self.registry_factory)()
179 }
180
181 fn open_backend(&self, config: &EventStoreConfig, manifest: &RunManifest) -> BackendOpenResult {
182 (self.backend_opener)(config, manifest)
183 }
184
185 fn build_marker_registry(&self, classes: &[DataClass]) -> DataMarkerExtractorRegistry {
186 (self.marker_registry_factory)(classes)
187 }
188}
189
190fn default_backend_opener(config: &EventStoreConfig, manifest: &RunManifest) -> BackendOpenResult {
191 let mut backend = RedbBackend::new(config.base_dir.clone());
192 backend.open_run(manifest.clone())?;
193 Ok(Box::new(backend))
194}
195
196#[derive(Debug, thiserror::Error)]
198pub enum BootError {
199 #[error(transparent)]
202 EventStore(#[from] EventStoreError),
203 #[error("RunStarted submit failed: {0}")]
205 RunStartedSubmit(String),
206 #[error("RunStarted did not durably commit within {timeout:?}")]
209 RunStartedTimeout {
210 timeout: Duration,
213 },
214 #[error("event store halted during boot: {0:?}")]
217 HaltedDuringBoot(HaltReason),
218}
219
220#[derive(Clone, Debug)]
226pub struct HaltSignal {
227 halted: Arc<AtomicBool>,
228 reason: Arc<Mutex<Option<HaltReason>>>,
229}
230
231impl Default for HaltSignal {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237impl HaltSignal {
238 #[must_use]
240 pub fn new() -> Self {
241 Self {
242 halted: Arc::new(AtomicBool::new(false)),
243 reason: Arc::new(Mutex::new(None)),
244 }
245 }
246
247 #[must_use]
254 pub fn callback(&self) -> HaltCallback {
255 let halted = Arc::clone(&self.halted);
256 let reason = Arc::clone(&self.reason);
257 Arc::new(move |r| {
258 if let Ok(mut slot) = reason.lock()
262 && slot.is_none()
263 {
264 *slot = Some(r);
265 }
266 halted.store(true, Ordering::Release);
267 })
268 }
269
270 #[must_use]
272 pub fn is_halted(&self) -> bool {
273 self.halted.load(Ordering::Acquire)
274 }
275
276 #[must_use]
281 pub fn reason(&self) -> Option<HaltReason> {
282 self.reason.lock().ok().and_then(|guard| guard.clone())
283 }
284}
285
286pub struct EventStoreSession {
288 writer: Option<Arc<EventStoreWriter>>,
289 adapter: Option<Arc<BusCaptureAdapter>>,
290 marker_capture: Option<SharedMarkerCapture>,
291 manifest: RunManifest,
292 halt_signal: HaltSignal,
293}
294
295impl Debug for EventStoreSession {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 f.debug_struct(stringify!(EventStoreSession))
298 .field("run_id", &self.manifest.run_id)
299 .field("parent_run_id", &self.manifest.parent_run_id)
300 .field("instance_id", &self.manifest.instance_id)
301 .field("halted", &self.halt_signal.is_halted())
302 .field("writer_attached", &self.writer.is_some())
303 .field("marker_capture_attached", &self.marker_capture.is_some())
304 .finish_non_exhaustive()
305 }
306}
307
308impl EventStoreSession {
309 #[must_use]
314 pub const fn manifest(&self) -> &RunManifest {
315 &self.manifest
316 }
317
318 #[must_use]
320 pub fn run_id(&self) -> &str {
321 self.manifest.run_id.as_str()
322 }
323
324 #[must_use]
326 pub fn parent_run_id(&self) -> Option<&str> {
327 self.manifest.parent_run_id.as_deref()
328 }
329
330 #[must_use]
332 pub fn is_halted(&self) -> bool {
333 self.halt_signal.is_halted()
334 }
335
336 #[must_use]
340 pub fn high_watermark(&self) -> u64 {
341 self.writer.as_ref().map_or(0, |w| w.high_watermark())
342 }
343
344 #[must_use]
350 pub fn snapshot_anchorer(&self) -> Option<SnapshotAnchorer> {
351 let writer = Arc::clone(self.writer.as_ref()?);
352
353 Some(Rc::new(move |snapshot_ref: CacheSnapshotRef| {
354 let content_hash = compute_snapshot_content_hash(snapshot_ref.blob.as_ref());
355 writer
356 .record_snapshot_anchor(snapshot_ref.blob_ref, content_hash)
357 .map(|_| ())
358 .map_err(|e| anyhow::anyhow!("record snapshot anchor: {e}"))
359 }))
360 }
361
362 #[must_use]
366 pub fn adapter(&self) -> Option<&Arc<BusCaptureAdapter>> {
367 self.adapter.as_ref()
368 }
369
370 pub fn close(&mut self, ts_init: UnixNanos) -> Result<(), EventStoreError> {
381 self.adapter = None;
385 let marker_capture = self.marker_capture.take();
386
387 let Some(writer_arc) = self.writer.take() else {
388 close_marker_capture(marker_capture);
389 return Ok(());
390 };
391 let Ok(writer) = Arc::try_unwrap(writer_arc) else {
392 close_marker_capture(marker_capture);
393 return Err(EventStoreError::Backend(
394 "event store writer has multiple owners; clear the bus tap before close"
395 .to_string(),
396 ));
397 };
398
399 let run_ended = run_ended_draft(ts_init);
400 let result = writer.close(run_ended);
401 close_marker_capture(marker_capture);
402 result?;
403 Ok(())
404 }
405}
406
407impl Drop for EventStoreSession {
408 fn drop(&mut self) {
409 self.adapter.take();
412 self.marker_capture.take();
413 self.writer.take();
414 }
415}
416
417fn close_marker_capture(marker_capture: Option<SharedMarkerCapture>) {
418 if let Some(marker_capture) = marker_capture
419 && let Some(capture) = marker_capture.borrow_mut().take()
420 {
421 capture.close();
422 }
423}
424
425#[derive(Debug, thiserror::Error)]
430pub enum KernelError {
431 #[error("event store boot failed: {0}")]
433 EventStoreBoot(#[from] BootError),
434 #[error("event store cache replay failed: {0}")]
436 CacheReplay(#[from] CacheReplayError),
437 #[error("event store halted: {0:?}")]
439 EventStoreHalted(HaltReason),
440}
441
442#[derive(Debug)]
452pub struct EventStoreLifecycle {
453 config: Option<EventStoreConfig>,
454 options: EventStoreLifecycleOptions,
455 recovered: Vec<RecoveredRun>,
456 parent_run_id: Option<String>,
457 session: Option<EventStoreSession>,
458 halt: HaltSignal,
459 clock: Rc<RefCell<dyn Clock>>,
463}
464
465impl EventStoreLifecycle {
466 pub fn boot(
476 config: Option<EventStoreConfig>,
477 instance_id: UUID4,
478 clock: Rc<RefCell<dyn Clock>>,
479 ) -> anyhow::Result<Self> {
480 Self::boot_with_options(
481 config,
482 instance_id,
483 clock,
484 EventStoreLifecycleOptions::default(),
485 )
486 }
487
488 pub fn boot_with_options(
498 config: Option<EventStoreConfig>,
499 instance_id: UUID4,
500 clock: Rc<RefCell<dyn Clock>>,
501 options: EventStoreLifecycleOptions,
502 ) -> anyhow::Result<Self> {
503 let (recovered, parent_run_id) = if let Some(cfg) = config.as_ref() {
504 let outcome = recover_predecessors(&cfg.base_dir, &instance_id.to_string())?;
505 if !outcome.recovered.is_empty() {
506 log::info!(
507 "Sealed {} crashed event-store predecessor(s); parent_run_id={:?}",
508 outcome.recovered.len(),
509 outcome.parent_run_id,
510 );
511 }
512 (outcome.recovered, outcome.parent_run_id)
513 } else {
514 (Vec::new(), None)
515 };
516 Ok(Self {
517 config,
518 options,
519 recovered,
520 parent_run_id,
521 session: None,
522 halt: HaltSignal::new(),
523 clock,
524 })
525 }
526
527 pub fn open(
542 &mut self,
543 instance_id: UUID4,
544 components: &RegisteredComponents,
545 environment: Environment,
546 ) -> Result<(), KernelError> {
547 let Some(config) = self.config.clone() else {
548 return Ok(());
549 };
550
551 if self.session.is_some() {
552 let ts = self.clock.borrow().timestamp_ns();
555 self.seal(ts);
556 }
557
558 self.halt = HaltSignal::new();
562
563 let clock = Self::clock_for(environment);
564 let start_ts_init = self.clock.borrow().timestamp_ns();
565 let run_id = build_run_id(start_ts_init);
566 let parent_run_id = if let Some(replay_run_id) = config.replay_from_run_id.as_deref() {
567 validate_event_store_replay_source(
568 config.base_dir.clone(),
569 &instance_id.to_string(),
570 replay_run_id,
571 )?;
572 Some(replay_run_id.to_string())
573 } else {
574 self.parent_run_id.clone()
575 };
576 let session = open_run_with_options(
577 &config,
578 &instance_id.to_string(),
579 run_id,
580 parent_run_id,
581 start_ts_init,
582 components,
583 self.halt.clone(),
584 clock,
585 &self.options,
586 )?;
587 log::info!(
588 "Opened event-store run {} (parent_run_id={:?})",
589 session.run_id(),
590 session.parent_run_id(),
591 );
592
593 if let Some(adapter) = session.adapter() {
594 install_bus_tap(Arc::clone(adapter), session.marker_capture.clone(), clock);
595 }
596 self.session = Some(session);
597 Ok(())
598 }
599
600 pub fn restore_parent_cache(
611 &self,
612 instance_id: UUID4,
613 cache: &mut Cache,
614 ) -> Result<Option<CacheReplayReport>, KernelError> {
615 let Some(config) = self.config.as_ref() else {
616 return Ok(None);
617 };
618 let replay_run_id = config
619 .replay_from_run_id
620 .as_deref()
621 .or(self.parent_run_id.as_deref());
622 let Some(replay_run_id) = replay_run_id else {
623 return Ok(None);
624 };
625 let source = if config.replay_from_run_id.is_some() {
626 "configured replay run"
627 } else {
628 "parent run"
629 };
630
631 let report = restore_cache_from_sealed_run(
632 cache,
633 config.base_dir.clone(),
634 &instance_id.to_string(),
635 replay_run_id,
636 )?;
637
638 log::info!(
639 "Restored cache from event-store {source} {replay_run_id}: from_seq={}, to_seq={}, applied={}, ignored={}",
640 report.cache.plan.from_seq,
641 report.cache.plan.to_seq,
642 report.cache.applied_entries,
643 report.cache.ignored_entries,
644 );
645
646 Ok(Some(report.cache))
647 }
648
649 pub fn seal(&mut self, ts_init: UnixNanos) {
653 let Some(mut session) = self.session.take() else {
654 return;
655 };
656
657 msgbus::clear_bus_tap();
660
661 if session.is_halted() {
662 log::warn!(
663 "Event-store writer fail-stopped before close; run {} sealed by recovery sweep on next boot",
664 session.run_id(),
665 );
666 return;
667 }
668 let run_id = session.run_id().to_string();
669 if let Err(e) = session.close(ts_init) {
670 log::error!(
671 "Failed to seal event-store run {run_id} on graceful stop: {e}; run will be sealed as CrashedRecovered on next boot",
672 );
673 } else {
674 log::info!("Sealed event-store run {run_id}");
675 }
676 }
677
678 #[must_use]
680 pub fn recovered(&self) -> &[RecoveredRun] {
681 &self.recovered
682 }
683
684 #[must_use]
686 pub fn parent_run_id(&self) -> Option<&str> {
687 self.config
688 .as_ref()
689 .and_then(|config| config.replay_from_run_id.as_deref())
690 .or(self.parent_run_id.as_deref())
691 }
692
693 #[must_use]
695 pub fn is_event_store_replay_configured(&self) -> bool {
696 self.config
697 .as_ref()
698 .is_some_and(|config| config.replay_from_run_id.is_some())
699 }
700
701 #[must_use]
703 pub fn run_id(&self) -> Option<&str> {
704 self.session.as_ref().map(EventStoreSession::run_id)
705 }
706
707 #[must_use]
709 pub fn snapshot_anchorer(&self) -> Option<SnapshotAnchorer> {
710 self.session
711 .as_ref()
712 .and_then(EventStoreSession::snapshot_anchorer)
713 }
714
715 #[must_use]
717 pub fn is_halted(&self) -> bool {
718 self.halt.is_halted()
719 }
720
721 #[must_use]
723 pub fn halt_reason(&self) -> Option<HaltReason> {
724 self.halt.reason()
725 }
726
727 #[must_use]
730 pub fn check_halt(&self) -> Option<KernelError> {
731 self.halt_reason().map(KernelError::EventStoreHalted)
732 }
733
734 #[cfg(feature = "live")]
735 fn clock_for(environment: Environment) -> &'static AtomicTime {
736 match environment {
737 Environment::Backtest => get_atomic_clock_static(),
738 Environment::Live | Environment::Sandbox => get_atomic_clock_realtime(),
739 }
740 }
741
742 #[cfg(not(feature = "live"))]
743 fn clock_for(_environment: Environment) -> &'static AtomicTime {
744 get_atomic_clock_static()
745 }
746}
747
748impl Drop for EventStoreLifecycle {
749 fn drop(&mut self) {
750 if self.session.is_none() {
752 return;
753 }
754 let ts = self
755 .clock
756 .try_borrow()
757 .map(|c| c.timestamp_ns())
758 .unwrap_or_default();
759 self.seal(ts);
760 }
761}
762
763pub fn recover_predecessors(
787 base_dir: &Path,
788 instance_id: &str,
789) -> Result<RecoveryOutcome, EventStoreError> {
790 let manifests = RedbBackend::list_runs(base_dir, instance_id)?;
791 let crashed: Vec<RunManifest> = manifests
792 .into_iter()
793 .filter(|m| matches!(m.status, RunStatus::Running))
794 .collect();
795
796 let mut outcome = RecoveryOutcome::default();
797
798 for predecessor in crashed {
799 let run_id = predecessor.run_id.clone();
800 let mut backend = RedbBackend::new(base_dir.to_path_buf());
801
802 match backend.open_run(predecessor) {
803 Err(EventStoreError::CrashedPredecessor) => {}
804 Ok(()) => {
805 return Err(EventStoreError::Backend(format!(
806 "expected CrashedPredecessor reopening {run_id}, was Ok",
807 )));
808 }
809 Err(other) => {
810 log::error!("Skipping recovery of run {run_id}, reopen failed: {other}");
811 continue;
812 }
813 }
814
815 let high_watermark = backend.high_watermark()?;
816 let final_status = if high_watermark == 0 {
817 RunStatus::CrashedRecovered
818 } else {
819 match backend.scan_range(1, high_watermark, ScanDirection::Forward) {
820 Ok(entries) => {
821 let tail_is_run_ended = entries.last().is_some_and(|e| {
829 e.topic.as_ref() == RUN_ENDED_TOPIC
830 && e.payload_type.as_str() == RUN_ENDED_PAYLOAD_TYPE
831 });
832
833 if tail_is_run_ended {
834 RunStatus::Ended
835 } else {
836 RunStatus::CrashedRecovered
837 }
838 }
839 Err(
840 EventStoreError::HashMismatch { .. }
841 | EventStoreError::Corrupted(_)
842 | EventStoreError::Gap { .. },
843 ) => RunStatus::Quarantined,
844 Err(other) => {
845 log::error!("Skipping recovery of run {run_id}, scan failed: {other}");
846 continue;
847 }
848 }
849 };
850
851 if let Err(e) = backend.seal(final_status) {
852 log::error!("Skipping recovery of run {run_id}, seal as {final_status:?} failed: {e}");
853 continue;
854 }
855 outcome.recovered.push(RecoveredRun {
856 run_id: run_id.clone(),
857 status: final_status,
858 });
859
860 if matches!(final_status, RunStatus::CrashedRecovered) {
861 outcome.parent_run_id = Some(run_id);
862 }
863 }
864
865 Ok(outcome)
866}
867
868#[must_use]
875pub fn build_run_id(start_ts_init: UnixNanos) -> RunId {
876 let suffix: String = UUID4::new().to_string().chars().take(8).collect();
877 format!("{}-{suffix}", u64::from(start_ts_init))
878}
879
880#[expect(
898 clippy::too_many_arguments,
899 reason = "run opening requires the boot context and event-store handles"
900)]
901pub fn open_run(
902 config: &EventStoreConfig,
903 instance_id: &str,
904 run_id: RunId,
905 parent_run_id: Option<RunId>,
906 start_ts_init: UnixNanos,
907 components: &RegisteredComponents,
908 halt_signal: HaltSignal,
909 clock: &'static AtomicTime,
910) -> Result<EventStoreSession, BootError> {
911 open_run_with_options(
912 config,
913 instance_id,
914 run_id,
915 parent_run_id,
916 start_ts_init,
917 components,
918 halt_signal,
919 clock,
920 &EventStoreLifecycleOptions::default(),
921 )
922}
923
924#[expect(
936 clippy::too_many_arguments,
937 reason = "run opening with options requires the boot context and lifecycle handles"
938)]
939pub fn open_run_with_options(
940 config: &EventStoreConfig,
941 instance_id: &str,
942 run_id: RunId,
943 parent_run_id: Option<RunId>,
944 start_ts_init: UnixNanos,
945 components: &RegisteredComponents,
946 halt_signal: HaltSignal,
947 clock: &'static AtomicTime,
948 options: &EventStoreLifecycleOptions,
949) -> Result<EventStoreSession, BootError> {
950 let manifest = build_manifest(
951 config,
952 instance_id,
953 run_id,
954 parent_run_id,
955 start_ts_init,
956 components.clone(),
957 );
958
959 let backend = options.open_backend(config, &manifest)?;
960
961 let writer = Arc::new(EventStoreWriter::spawn(
962 backend,
963 clock,
964 halt_signal.callback(),
965 writer_config_from(config),
966 )?);
967
968 submit_run_started_blocking(
969 &writer,
970 components,
971 start_ts_init,
972 &halt_signal,
973 config.run_started_timeout,
974 )?;
975
976 let (marker_capture, submit_counter) =
977 build_marker_capture(config, &manifest, writer.high_watermark(), clock, options);
978 let mut adapter = BusCaptureAdapter::new(
979 Arc::clone(&writer),
980 Arc::new(options.build_registry()),
981 halt_signal.callback(),
982 );
983
984 if let Some(submit_counter) = submit_counter {
985 adapter = adapter.with_submit_counter(submit_counter);
986 }
987 let adapter = Arc::new(adapter);
988
989 Ok(EventStoreSession {
990 writer: Some(writer),
991 adapter: Some(adapter),
992 marker_capture,
993 manifest,
994 halt_signal,
995 })
996}
997
998fn build_marker_capture(
999 config: &EventStoreConfig,
1000 manifest: &RunManifest,
1001 initial_submit_counter: u64,
1002 clock: &'static AtomicTime,
1003 options: &EventStoreLifecycleOptions,
1004) -> (Option<SharedMarkerCapture>, Option<Arc<AtomicU64>>) {
1005 let Some(marker_config) = config.data_markers.as_ref() else {
1006 return (None, None);
1007 };
1008
1009 match open_marker_capture(
1010 config,
1011 manifest,
1012 marker_config,
1013 initial_submit_counter,
1014 clock,
1015 options,
1016 ) {
1017 Ok((capture, submit_counter)) => (
1018 Some(Rc::new(RefCell::new(Some(capture)))),
1019 Some(submit_counter),
1020 ),
1021 Err(e) => {
1022 log::warn!(
1023 "Data marker sidecar disabled for run {} after marker setup failed: {e}",
1024 manifest.run_id,
1025 );
1026 (None, None)
1027 }
1028 }
1029}
1030
1031fn open_marker_capture(
1032 config: &EventStoreConfig,
1033 manifest: &RunManifest,
1034 marker_config: &DataMarkerConfig,
1035 initial_submit_counter: u64,
1036 clock: &'static AtomicTime,
1037 options: &EventStoreLifecycleOptions,
1038) -> Result<(DataMarkerCapture, Arc<AtomicU64>), EventStoreError> {
1039 let classes = marker_config
1040 .classes
1041 .iter()
1042 .copied()
1043 .map(data_marker_class_to_data_class)
1044 .collect::<Vec<_>>();
1045 let marker_manifest = marker_manifest_for(manifest, classes.clone(), marker_config);
1046 let marker_path = marker_file_path(config, &manifest.instance_id, &manifest.run_id);
1047 let mut marker_backend = RedbMarkerBackend::new(marker_path);
1048 marker_backend.open_run(marker_manifest)?;
1049 let writer = MarkerWriter::spawn(
1050 Box::new(marker_backend),
1051 clock,
1052 MarkerWriterConfig {
1053 channel_capacity: marker_config.channel_capacity,
1054 ..MarkerWriterConfig::default()
1055 },
1056 )?;
1057 let submit_counter = Arc::new(AtomicU64::new(initial_submit_counter));
1058 let registry = options.build_marker_registry(&classes);
1059 let capture =
1060 DataMarkerCapture::new(registry, writer, Arc::clone(&submit_counter), marker_config);
1061
1062 Ok((capture, submit_counter))
1063}
1064
1065fn marker_file_path(config: &EventStoreConfig, instance_id: &str, run_id: &str) -> PathBuf {
1066 config
1067 .base_dir
1068 .join(instance_id)
1069 .join(format!("{run_id}.markers.redb"))
1070}
1071
1072fn marker_manifest_for(
1073 manifest: &RunManifest,
1074 enabled_classes: Vec<DataClass>,
1075 config: &DataMarkerConfig,
1076) -> MarkerManifest {
1077 MarkerManifest {
1078 run_id: manifest.run_id.clone(),
1079 enabled_classes,
1080 high_fidelity: !config.high_fidelity.is_empty(),
1081 snapshot_count: 0,
1082 hifi_count: 0,
1083 gap_count: 0,
1084 dict_count: 0,
1085 status: RunStatus::Running,
1086 }
1087}
1088
1089const fn data_marker_class_to_data_class(class: DataMarkerClass) -> DataClass {
1090 match class {
1091 DataMarkerClass::BookDeltas => DataClass::BookDeltas,
1092 DataMarkerClass::BookDepth10 => DataClass::BookDepth10,
1093 DataMarkerClass::Quote => DataClass::Quote,
1094 DataMarkerClass::Trade => DataClass::Trade,
1095 DataMarkerClass::Bar => DataClass::Bar,
1096 }
1097}
1098
1099fn build_manifest(
1100 config: &EventStoreConfig,
1101 instance_id: &str,
1102 run_id: RunId,
1103 parent_run_id: Option<RunId>,
1104 start_ts_init: UnixNanos,
1105 components: RegisteredComponents,
1106) -> RunManifest {
1107 let mut feature_flags = config.identity.feature_flags.clone();
1108 feature_flags.push(format!("retention={}", retention_tag(config.retention)));
1109
1110 RunManifest {
1111 run_id,
1112 parent_run_id,
1113 instance_id: instance_id.to_string(),
1114 binary_hash: config.identity.binary_hash.clone(),
1115 schema_version: config.identity.schema_version,
1116 crate_versions: config.identity.crate_versions.clone(),
1117 feature_flags,
1118 adapter_versions: config.identity.adapter_versions.clone(),
1119 config_hash: config.identity.config_hash.clone(),
1120 registered_components: components,
1121 seed: config.identity.seed,
1122 start_ts_init,
1123 end_ts_init: None,
1124 high_watermark: 0,
1125 status: RunStatus::Running,
1126 }
1127}
1128
1129const fn retention_tag(mode: RetentionMode) -> &'static str {
1130 match mode {
1131 RetentionMode::Full => "full",
1132 RetentionMode::Bounded { .. } => "bounded",
1133 RetentionMode::SnapshotAnchored => "snapshot",
1134 }
1135}
1136
1137fn writer_config_from(config: &EventStoreConfig) -> WriterConfig {
1138 WriterConfig {
1139 channel_capacity: config.channel_capacity,
1140 max_batch_entries: config.max_batch_entries,
1141 max_batch_latency: config.max_batch_latency,
1142 halt_threshold: config.halt_threshold,
1143 }
1144}
1145
1146pub(crate) fn submit_run_started_blocking(
1158 writer: &EventStoreWriter,
1159 components: &RegisteredComponents,
1160 ts_init: UnixNanos,
1161 halt_signal: &HaltSignal,
1162 timeout: Duration,
1163) -> Result<(), BootError> {
1164 let payload = encode_run_started(components);
1165 let draft = EntryDraft::without_indices(
1166 Headers::empty(),
1167 Topic::from(RUN_STARTED_TOPIC),
1168 Ustr::from(RUN_STARTED_PAYLOAD_TYPE),
1169 payload,
1170 ts_init,
1171 );
1172
1173 writer
1174 .submit(draft)
1175 .map_err(|e| BootError::RunStartedSubmit(e.to_string()))?;
1176
1177 let start = Instant::now(); while writer.high_watermark() == 0 {
1182 if halt_signal.is_halted() {
1183 return Err(BootError::HaltedDuringBoot(
1184 halt_signal.reason().unwrap_or_else(|| {
1185 HaltReason::BackendError("event store halted during boot".to_string())
1186 }),
1187 ));
1188 }
1189
1190 let elapsed = start.elapsed();
1191
1192 if elapsed >= timeout {
1193 return Err(BootError::RunStartedTimeout { timeout });
1194 }
1195 thread::sleep(Duration::from_millis(1));
1196 }
1197
1198 Ok(())
1199}
1200
1201fn encode_run_started(components: &RegisteredComponents) -> Bytes {
1202 let bytes = crate::codec::encode_to_vec(components).expect(
1204 "RegisteredComponents serializes via serde, must not fail under the positional codec",
1205 );
1206 Bytes::from(bytes)
1207}
1208
1209fn run_ended_draft(ts_init: UnixNanos) -> EntryDraft {
1210 EntryDraft::without_indices(
1211 Headers::empty(),
1212 Topic::from(RUN_ENDED_TOPIC),
1213 Ustr::from(RUN_ENDED_PAYLOAD_TYPE),
1214 Bytes::new(),
1215 ts_init,
1216 )
1217}
1218
1219struct EventStoreBusTap {
1226 adapter: Arc<BusCaptureAdapter>,
1227 marker_capture: Option<SharedMarkerCapture>,
1228 clock: &'static AtomicTime,
1229 halted_logged: AtomicBool,
1232}
1233
1234impl Debug for EventStoreBusTap {
1235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1236 f.debug_struct(stringify!(EventStoreBusTap))
1237 .field("halted", &self.adapter.is_halted())
1238 .field("marker_capture_attached", &self.marker_capture.is_some())
1239 .finish_non_exhaustive()
1240 }
1241}
1242
1243impl BusTap for EventStoreBusTap {
1244 fn on_publish(&self, topic: Topic, message: &dyn Any) {
1245 let ts_init = self.clock.get_time_ns();
1246 self.capture(topic, message, ts_init);
1247 }
1248
1249 fn on_send(&self, endpoint: MStr<Endpoint>, message: &dyn Any) {
1250 let ts_init = self.clock.get_time_ns();
1251 let topic = Topic::from(*endpoint);
1254 self.capture(topic, message, ts_init);
1255 }
1256
1257 fn on_response(&self, _correlation_id: &UUID4, message: &dyn Any) {
1258 let ts_init = self.clock.get_time_ns();
1259 let topic = MessagingSwitchboard::data_response_topic();
1260 self.capture(topic, message, ts_init);
1261 }
1262}
1263
1264impl EventStoreBusTap {
1265 fn capture(&self, topic: Topic, message: &dyn Any, ts_init: UnixNanos) {
1266 let headers = self
1271 .adapter
1272 .registry()
1273 .headers_for_any(message)
1274 .unwrap_or_else(Headers::empty);
1275 match self.adapter.capture_any(topic, message, headers, ts_init) {
1278 Ok(captured) => {
1279 self.capture_marker(topic, message, ts_init, captured);
1280 }
1281 Err(CaptureError::Halted) => {
1282 if !self.halted_logged.swap(true, Ordering::AcqRel) {
1283 log::error!(
1284 "Event store capture is halted; state-affecting messages are no longer recorded for this run"
1285 );
1286 }
1287 }
1288 Err(CaptureError::Submit(e)) => {
1289 log::error!("Event store capture submit failed on {topic}: {e}");
1290 }
1291 Err(CaptureError::Encode(e)) => {
1292 log::warn!("Event store encoder rejected message on {topic}: {e}");
1293 }
1294 }
1295 }
1296
1297 fn capture_marker(&self, topic: Topic, message: &dyn Any, ts_init: UnixNanos, captured: bool) {
1298 let Some(marker_capture) = self.marker_capture.as_ref() else {
1299 return;
1300 };
1301 let mut marker_capture = marker_capture.borrow_mut();
1302 let Some(capture) = marker_capture.as_mut() else {
1303 return;
1304 };
1305
1306 if captured {
1307 capture.on_entry_submitted(ts_init);
1308 } else {
1309 capture.observe_publish(topic, message, ts_init);
1310 }
1311 capture.maybe_safety_flush(ts_init);
1312 }
1313}
1314
1315fn install_bus_tap(
1316 adapter: Arc<BusCaptureAdapter>,
1317 marker_capture: Option<SharedMarkerCapture>,
1318 clock: &'static AtomicTime,
1319) {
1320 let tap: Rc<dyn BusTap> = Rc::new(EventStoreBusTap {
1321 adapter,
1322 marker_capture,
1323 clock,
1324 halted_logged: AtomicBool::new(false),
1325 });
1326 msgbus::set_bus_tap(tap);
1327}
1328
1329#[expect(
1332 clippy::use_self,
1333 reason = "Self would dispatch back into this trait impl"
1334)]
1335impl KernelEventStoreTrait for EventStoreLifecycle {
1336 fn restore_parent_cache(
1337 &mut self,
1338 instance_id: UUID4,
1339 cache: &mut Cache,
1340 ) -> anyhow::Result<()> {
1341 EventStoreLifecycle::restore_parent_cache(self, instance_id, cache)
1342 .map(|_| ())
1343 .map_err(Into::into)
1344 }
1345
1346 fn open(
1347 &mut self,
1348 instance_id: UUID4,
1349 components: &RegisteredComponents,
1350 environment: Environment,
1351 ) -> anyhow::Result<()> {
1352 EventStoreLifecycle::open(self, instance_id, components, environment).map_err(Into::into)
1353 }
1354
1355 fn snapshot_anchorer(&self) -> Option<SnapshotAnchorer> {
1356 EventStoreLifecycle::snapshot_anchorer(self)
1357 }
1358
1359 fn seal(&mut self, ts_init: UnixNanos) {
1360 EventStoreLifecycle::seal(self, ts_init);
1361 }
1362
1363 fn run_id(&self) -> Option<&str> {
1364 EventStoreLifecycle::run_id(self)
1365 }
1366
1367 fn parent_run_id(&self) -> Option<&str> {
1368 EventStoreLifecycle::parent_run_id(self)
1369 }
1370
1371 fn is_event_store_replay_configured(&self) -> bool {
1372 EventStoreLifecycle::is_event_store_replay_configured(self)
1373 }
1374
1375 fn is_halted(&self) -> bool {
1376 EventStoreLifecycle::is_halted(self)
1377 }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 #[cfg(madsim)]
1383 use std::path::Path;
1384 use std::path::PathBuf;
1385
1386 use indexmap::IndexMap;
1387 use nautilus_common::{
1388 clock::TestClock,
1389 messages::{
1390 data::{
1391 DataCommand, DataResponse, QuotesResponse, RequestCommand, RequestQuotes,
1392 SubscribeCommand, SubscribeQuotes,
1393 },
1394 execution::{SubmitOrder, TradingCommand},
1395 },
1396 timer::{TimeEvent, TimeEventCallback, TimeEventHandler},
1397 };
1398 use nautilus_core::time::get_atomic_clock_static;
1399 use nautilus_model::{
1400 data::stubs::{quote_ethusdt_binance, stub_deltas},
1401 enums::TimeInForce,
1402 events::{
1403 OrderEventAny, OrderFilled,
1404 order::spec::{OrderFilledSpec, OrderInitializedSpec},
1405 },
1406 identifiers::{
1407 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
1408 VenueOrderId,
1409 },
1410 types::{Currency, Money, Price, Quantity},
1411 };
1412 use nautilus_system::event_store::{DataMarkerClass, DataMarkerConfig, RunIdentity};
1413 use rstest::rstest;
1414 use tempfile::TempDir;
1415
1416 use super::*;
1417 use crate::{
1418 AppendEntry, DataClass, EncodedPayload, EventStoreEntry, IndexKind, MarkerBackend,
1419 MemoryBackend, RedbMarkerBackend, SnapshotAnchor,
1420 capture::builtins::PAYLOAD_TYPE_TIME_EVENT, compute_entry_hash,
1421 };
1422
1423 const INSTANCE_ID: &str = "trader-001";
1424
1425 fn make_config(base_dir: PathBuf) -> EventStoreConfig {
1426 EventStoreConfig {
1427 base_dir,
1428 identity: RunIdentity {
1429 binary_hash: "deadbeef".to_string(),
1430 schema_version: 1,
1431 crate_versions: "feedface".to_string(),
1432 feature_flags: Vec::new(),
1433 adapter_versions: IndexMap::new(),
1434 config_hash: "cafebabe".to_string(),
1435 seed: None,
1436 },
1437 retention: RetentionMode::Full,
1438 replay_from_run_id: None,
1439 data_markers: None,
1440 channel_capacity: 64,
1441 max_batch_entries: 1,
1442 max_batch_latency: Duration::from_millis(2),
1443 halt_threshold: Duration::from_secs(2),
1444 run_started_timeout: Duration::from_secs(2),
1445 }
1446 }
1447
1448 #[derive(Clone, Copy, Debug)]
1449 enum CrashPoint {
1450 BeforeEnqueue,
1451 AfterEnqueueBeforeCommit,
1452 AfterCommitBeforeSnapshot,
1453 AfterSnapshot,
1454 }
1455
1456 fn append_entry(seq: u64, topic: &str, payload_type: &str, payload: Bytes) -> AppendEntry {
1457 let ts = UnixNanos::from(seq);
1458 let headers = Headers::empty();
1459 let hash = compute_entry_hash(seq, ts, ts, topic, payload_type, &payload, &headers);
1460 let entry = EventStoreEntry::new(
1461 hash,
1462 seq,
1463 headers,
1464 Topic::from(topic),
1465 Ustr::from(payload_type),
1466 payload,
1467 ts,
1468 ts,
1469 );
1470 AppendEntry::without_indices(entry)
1471 }
1472
1473 fn make_submit_order(client_order_id: ClientOrderId) -> SubmitOrder {
1474 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1475 let order_init = OrderInitializedSpec::builder()
1476 .instrument_id(instrument_id)
1477 .client_order_id(client_order_id)
1478 .quantity(Quantity::from("1"))
1479 .time_in_force(TimeInForce::Gtc)
1480 .ts_event(UnixNanos::from(1))
1481 .ts_init(UnixNanos::from(2))
1482 .build();
1483 SubmitOrder::new(
1484 TraderId::from("TRADER-001"),
1485 Some(ClientId::from("BINANCE")),
1486 StrategyId::from("S-001"),
1487 instrument_id,
1488 client_order_id,
1489 order_init,
1490 None,
1491 None,
1492 None,
1493 UUID4::new(),
1494 UnixNanos::from(3),
1495 None, )
1497 }
1498
1499 fn append_run_started(seq: u64) -> AppendEntry {
1500 append_entry(
1501 seq,
1502 RUN_STARTED_TOPIC,
1503 RUN_STARTED_PAYLOAD_TYPE,
1504 encode_run_started(&RegisteredComponents::default()),
1505 )
1506 }
1507
1508 #[derive(Debug)]
1509 struct TestAuditMessage {
1510 value: u8,
1511 }
1512
1513 fn test_registry() -> EncoderRegistry {
1514 let mut registry = EncoderRegistry::new();
1515 registry.register::<TestAuditMessage, _>(Ustr::from("TestAuditMessage"), |message| {
1516 Ok(EncodedPayload::without_indices(Bytes::copy_from_slice(&[
1517 message.value,
1518 ])))
1519 });
1520 registry
1521 }
1522
1523 fn wait_for_high_watermark(store: &EventStoreLifecycle, expected: u64) {
1524 let deadline = Instant::now() + Duration::from_secs(2);
1525
1526 loop {
1527 let hwm = store
1528 .session
1529 .as_ref()
1530 .map_or(0, EventStoreSession::high_watermark);
1531
1532 if hwm >= expected {
1533 break;
1534 }
1535 assert!(
1536 Instant::now() < deadline,
1537 "event store high_watermark did not reach {expected} within deadline (hwm={hwm})",
1538 );
1539 thread::sleep(Duration::from_millis(2));
1540 }
1541 }
1542
1543 #[derive(Debug, Clone)]
1544 struct SharedMemoryBackend(Arc<Mutex<MemoryBackend>>);
1545
1546 impl EventStore for SharedMemoryBackend {
1547 fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
1548 self.0.lock().expect("memory backend").open_run(manifest)
1549 }
1550
1551 fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
1552 self.0.lock().expect("memory backend").append_batch(entries)
1553 }
1554
1555 fn scan_range(
1556 &self,
1557 from: u64,
1558 to: u64,
1559 direction: ScanDirection,
1560 ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
1561 self.0
1562 .lock()
1563 .expect("memory backend")
1564 .scan_range(from, to, direction)
1565 }
1566
1567 fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
1568 self.0.lock().expect("memory backend").scan_seq(seq)
1569 }
1570
1571 fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
1572 self.0.lock().expect("memory backend").lookup(kind, key)
1573 }
1574
1575 fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
1576 self.0.lock().expect("memory backend").iter_index_keys(kind)
1577 }
1578
1579 fn record_snapshot_anchor(
1580 &mut self,
1581 anchor: SnapshotAnchor,
1582 ) -> Result<(), EventStoreError> {
1583 self.0
1584 .lock()
1585 .expect("memory backend")
1586 .record_snapshot_anchor(anchor)
1587 }
1588
1589 fn latest_snapshot_anchor(&self) -> Result<Option<SnapshotAnchor>, EventStoreError> {
1590 self.0
1591 .lock()
1592 .expect("memory backend")
1593 .latest_snapshot_anchor()
1594 }
1595
1596 fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
1597 self.0.lock().expect("memory backend").seal(status)
1598 }
1599
1600 fn manifest(&self) -> Result<RunManifest, EventStoreError> {
1601 self.0.lock().expect("memory backend").manifest()
1602 }
1603
1604 fn high_watermark(&self) -> Result<u64, EventStoreError> {
1605 self.0.lock().expect("memory backend").high_watermark()
1606 }
1607 }
1608
1609 fn seed_crashed_predecessor(config: &EventStoreConfig, run_id: &str, crash_point: CrashPoint) {
1610 let mut backend = RedbBackend::new(config.base_dir.clone());
1611 backend
1612 .open_run(build_manifest(
1613 config,
1614 INSTANCE_ID,
1615 run_id.to_string(),
1616 None,
1617 UnixNanos::from(1_000),
1618 RegisteredComponents::default(),
1619 ))
1620 .expect("open predecessor");
1621
1622 match crash_point {
1623 CrashPoint::BeforeEnqueue | CrashPoint::AfterEnqueueBeforeCommit => {}
1627 CrashPoint::AfterCommitBeforeSnapshot => {
1628 backend
1629 .append_batch(&[append_run_started(1)])
1630 .expect("append committed entry");
1631 }
1632 CrashPoint::AfterSnapshot => {
1633 backend
1634 .append_batch(&[append_run_started(1)])
1635 .expect("append committed entry");
1636 backend
1637 .record_snapshot_anchor(SnapshotAnchor::new(
1638 1,
1639 "cache://snapshot/run-crash/1",
1640 "blake3:abc",
1641 ))
1642 .expect("record snapshot anchor");
1643 }
1644 }
1645 }
1646
1647 #[rstest]
1648 fn halt_signal_callback_records_first_reason() {
1649 let signal = HaltSignal::new();
1650 let cb = signal.callback();
1651 cb(HaltReason::BackendDisk("ENOSPC".to_string()));
1652 cb(HaltReason::BackendError("second".to_string()));
1653
1654 assert!(signal.is_halted());
1655 match signal.reason() {
1656 Some(HaltReason::BackendDisk(msg)) => assert!(msg.contains("ENOSPC")),
1657 other => panic!("expected first reason BackendDisk, was {other:?}"),
1658 }
1659 }
1660
1661 #[rstest]
1662 fn recover_predecessors_returns_empty_for_missing_directory() {
1663 let tmp = TempDir::new().expect("tempdir");
1664 let outcome =
1665 recover_predecessors(tmp.path(), INSTANCE_ID).expect("recover empty directory");
1666 assert!(outcome.recovered.is_empty());
1667 assert!(outcome.parent_run_id.is_none());
1668 }
1669
1670 #[rstest]
1671 fn restore_cache_snapshot_blob_rejects_hash_mismatch() {
1672 let mut cache = Cache::default();
1673 let blob = Bytes::from_static(b"snapshot");
1674 let anchor =
1675 crate::SnapshotAnchor::new(0, "cache://position-snapshots/P-1/0", "blake3:bad");
1676
1677 cache
1678 .add(&anchor.blob_ref, blob)
1679 .expect("seed snapshot blob");
1680 let err =
1681 crate::restore_cache_snapshot_blob(&mut cache, Some(&anchor)).expect_err("hash error");
1682
1683 assert!(
1684 err.to_string().contains("content_hash mismatch"),
1685 "err was: {err}",
1686 );
1687 }
1688
1689 #[rstest]
1690 fn open_run_writes_run_started_and_advances_watermark() {
1691 let tmp = TempDir::new().expect("tempdir");
1692 let config = make_config(tmp.path().to_path_buf());
1693 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover empty");
1694 assert!(outcome.parent_run_id.is_none());
1695
1696 let halt = HaltSignal::new();
1697 let session = open_run(
1698 &config,
1699 INSTANCE_ID,
1700 build_run_id(UnixNanos::from(1_000)),
1701 outcome.parent_run_id,
1702 UnixNanos::from(1_000),
1703 &RegisteredComponents::default(),
1704 halt,
1705 get_atomic_clock_static(),
1706 )
1707 .expect("open run");
1708
1709 assert_eq!(session.high_watermark(), 1);
1711 assert_eq!(session.parent_run_id(), None);
1712
1713 let manifest = session.manifest();
1717 assert_eq!(manifest.instance_id, INSTANCE_ID);
1718 assert_eq!(manifest.status, RunStatus::Running);
1719 assert_eq!(manifest.binary_hash, "deadbeef");
1720 assert_eq!(manifest.schema_version, 1);
1721 assert_eq!(manifest.crate_versions, "feedface");
1722 assert_eq!(manifest.config_hash, "cafebabe");
1723 assert_eq!(manifest.start_ts_init, UnixNanos::from(1_000));
1724 assert_eq!(manifest.end_ts_init, None);
1725 assert!(
1726 manifest
1727 .feature_flags
1728 .contains(&"retention=full".to_string()),
1729 "feature_flags must record the retention mode, was {:?}",
1730 manifest.feature_flags,
1731 );
1732 }
1733
1734 #[rstest]
1735 fn lifecycle_options_default_registry_keeps_builtin_encoders() {
1736 let registry = EventStoreLifecycleOptions::default().build_registry();
1737
1738 assert!(registry.contains::<SubmitOrder>());
1739 assert!(registry.contains::<TradingCommand>());
1740 assert!(!registry.contains::<TestAuditMessage>());
1741 }
1742
1743 #[rstest]
1744 fn lifecycle_options_custom_registry_captures_registered_message() {
1745 let tmp = TempDir::new().expect("tempdir");
1746 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1747 let instance_id = UUID4::new();
1748 let options = EventStoreLifecycleOptions::new().with_encoder_registry(test_registry());
1749
1750 let mut store = EventStoreLifecycle::boot_with_options(
1751 Some(make_config(tmp.path().to_path_buf())),
1752 instance_id,
1753 clock_rc,
1754 options,
1755 )
1756 .expect("boot store");
1757 store
1758 .open(
1759 instance_id,
1760 &RegisteredComponents::default(),
1761 Environment::Backtest,
1762 )
1763 .expect("open run");
1764 let run_id = store.run_id().expect("run open").to_string();
1765
1766 let topic: MStr<msgbus::Topic> = MStr::from("events.test.audit");
1767 msgbus::publish_any(topic, &TestAuditMessage { value: 42 });
1768 wait_for_high_watermark(&store, 2);
1769
1770 drop(store);
1771
1772 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
1773 .expect("open sealed");
1774 let captured = sealed
1775 .scan_seq(2)
1776 .expect("scan")
1777 .expect("captured entry present");
1778
1779 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1780 assert_eq!(captured.topic.as_ref(), topic.as_str());
1781 assert_eq!(captured.payload.as_ref(), &[42]);
1782 }
1783
1784 #[rstest]
1785 fn lifecycle_options_memory_backend_opener_captures_and_seals() {
1786 let tmp = TempDir::new().expect("tempdir");
1787 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1788 let opener_memory = Arc::clone(&memory);
1789 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1790 let instance_id = UUID4::new();
1791 let options = EventStoreLifecycleOptions::new()
1792 .with_encoder_registry(test_registry())
1793 .with_backend_opener(move |_, manifest| {
1794 opener_memory
1795 .lock()
1796 .expect("memory backend")
1797 .open_run(manifest.clone())?;
1798 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1799 });
1800
1801 let mut store = EventStoreLifecycle::boot_with_options(
1802 Some(make_config(tmp.path().to_path_buf())),
1803 instance_id,
1804 clock_rc,
1805 options,
1806 )
1807 .expect("boot store");
1808 store
1809 .open(
1810 instance_id,
1811 &RegisteredComponents::default(),
1812 Environment::Backtest,
1813 )
1814 .expect("open run");
1815
1816 let topic: MStr<msgbus::Topic> = MStr::from("events.test.memory");
1817 msgbus::publish_any(topic, &TestAuditMessage { value: 7 });
1818 wait_for_high_watermark(&store, 2);
1819
1820 store.seal(UnixNanos::from(1_000));
1821
1822 let backend = memory.lock().expect("memory backend");
1823 let manifest = backend.manifest().expect("manifest");
1824 let captured = backend
1825 .scan_seq(2)
1826 .expect("scan")
1827 .expect("captured entry present");
1828
1829 assert_eq!(manifest.instance_id, instance_id.to_string());
1830 assert_eq!(manifest.status, RunStatus::Ended);
1831 assert_eq!(manifest.high_watermark, 3);
1832 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1833 assert_eq!(captured.topic.as_ref(), topic.as_str());
1834 assert_eq!(captured.payload.as_ref(), &[7]);
1835 }
1836
1837 #[cfg(madsim)]
1838 #[rstest]
1839 fn lifecycle_options_memory_backend_opener_captures_deterministic_seq_order_under_madsim() {
1840 let first = capture_madsim_memory_lifecycle_summary(42);
1841 let second = capture_madsim_memory_lifecycle_summary(42);
1842 let expected = expected_madsim_memory_entries();
1843
1844 assert_eq!(first.entries, second.entries);
1845 assert_eq!(first.entries, expected);
1846 assert_eq!(
1847 first
1848 .entries
1849 .iter()
1850 .map(|entry| entry.seq)
1851 .collect::<Vec<_>>(),
1852 vec![1, 2, 3, 4],
1853 );
1854 assert!(
1855 first.redb_files.is_empty(),
1856 "memory opener must not create redb files, was {:?}",
1857 first.redb_files,
1858 );
1859 assert!(
1860 second.redb_files.is_empty(),
1861 "memory opener must not create redb files, was {:?}",
1862 second.redb_files,
1863 );
1864 }
1865
1866 #[cfg(madsim)]
1867 fn expected_madsim_memory_entries() -> Vec<CapturedEntrySummary> {
1868 vec![
1869 CapturedEntrySummary {
1870 seq: 1,
1871 topic: RUN_STARTED_TOPIC.to_string(),
1872 payload_type: RUN_STARTED_PAYLOAD_TYPE.to_string(),
1873 payload: encode_run_started(&RegisteredComponents::default()).to_vec(),
1874 ts_init: UnixNanos::from(0),
1875 ts_publish: UnixNanos::from(10_000),
1876 },
1877 CapturedEntrySummary {
1878 seq: 2,
1879 topic: "events.test.madsim".to_string(),
1880 payload_type: "TestAuditMessage".to_string(),
1881 payload: vec![1],
1882 ts_init: UnixNanos::from(20_000),
1883 ts_publish: UnixNanos::from(20_000),
1884 },
1885 CapturedEntrySummary {
1886 seq: 3,
1887 topic: "events.test.madsim".to_string(),
1888 payload_type: "TestAuditMessage".to_string(),
1889 payload: vec![2],
1890 ts_init: UnixNanos::from(30_000),
1891 ts_publish: UnixNanos::from(30_000),
1892 },
1893 CapturedEntrySummary {
1894 seq: 4,
1895 topic: RUN_ENDED_TOPIC.to_string(),
1896 payload_type: RUN_ENDED_PAYLOAD_TYPE.to_string(),
1897 payload: Vec::new(),
1898 ts_init: UnixNanos::from(40_000),
1899 ts_publish: UnixNanos::from(40_000),
1900 },
1901 ]
1902 }
1903
1904 #[rstest]
1905 fn open_run_with_options_surfaces_backend_opener_error() {
1906 let tmp = TempDir::new().expect("tempdir");
1907 let config = make_config(tmp.path().to_path_buf());
1908 let options = EventStoreLifecycleOptions::new().with_backend_opener(|_, _| {
1909 Err(EventStoreError::Backend(
1910 "test backend open failed".to_string(),
1911 ))
1912 });
1913
1914 let err = open_run_with_options(
1915 &config,
1916 INSTANCE_ID,
1917 "run-open-error".to_string(),
1918 None,
1919 UnixNanos::from(5_000),
1920 &RegisteredComponents::default(),
1921 HaltSignal::new(),
1922 get_atomic_clock_static(),
1923 &options,
1924 )
1925 .expect_err("backend opener error must stop run open");
1926
1927 match err {
1928 BootError::EventStore(EventStoreError::Backend(msg)) => {
1929 assert!(msg.contains("test backend open failed"));
1930 }
1931 other => panic!("expected backend open failure, was {other:?}"),
1932 }
1933 }
1934
1935 #[cfg(madsim)]
1936 #[derive(Debug, PartialEq, Eq)]
1937 struct MadsimMemoryLifecycleCapture {
1938 entries: Vec<CapturedEntrySummary>,
1939 redb_files: Vec<PathBuf>,
1940 }
1941
1942 #[cfg(madsim)]
1943 #[derive(Debug, PartialEq, Eq)]
1944 struct CapturedEntrySummary {
1945 seq: u64,
1946 topic: String,
1947 payload_type: String,
1948 payload: Vec<u8>,
1949 ts_init: UnixNanos,
1950 ts_publish: UnixNanos,
1951 }
1952
1953 #[cfg(madsim)]
1954 fn capture_madsim_memory_lifecycle_summary(seed: u64) -> MadsimMemoryLifecycleCapture {
1955 get_atomic_clock_static().set_time(UnixNanos::from(10_000));
1956
1957 let tmp = TempDir::new().expect("tempdir");
1958 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1959 let opener_memory = Arc::clone(&memory);
1960 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1961 let instance_id = UUID4::new();
1962 let mut config = make_config(tmp.path().to_path_buf());
1963 config.identity.seed = Some(seed);
1964 let options = EventStoreLifecycleOptions::new()
1965 .with_encoder_registry(test_registry())
1966 .with_backend_opener(move |_, manifest| {
1967 opener_memory
1968 .lock()
1969 .expect("memory backend")
1970 .open_run(manifest.clone())?;
1971 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1972 });
1973
1974 let mut store =
1975 EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
1976 .expect("boot store");
1977 store
1978 .open(
1979 instance_id,
1980 &RegisteredComponents::default(),
1981 Environment::Backtest,
1982 )
1983 .expect("open run");
1984
1985 let topic: MStr<msgbus::Topic> = MStr::from("events.test.madsim");
1986 get_atomic_clock_static().set_time(UnixNanos::from(20_000));
1987 msgbus::publish_any(topic, &TestAuditMessage { value: 1 });
1988 get_atomic_clock_static().set_time(UnixNanos::from(30_000));
1989 msgbus::publish_any(topic, &TestAuditMessage { value: 2 });
1990 assert_eq!(
1991 store
1992 .session
1993 .as_ref()
1994 .expect("open session")
1995 .high_watermark(),
1996 3
1997 );
1998
1999 get_atomic_clock_static().set_time(UnixNanos::from(40_000));
2000 store.seal(UnixNanos::from(40_000));
2001
2002 let backend = memory.lock().expect("memory backend");
2003 let manifest = backend.manifest().expect("manifest");
2004 assert_eq!(manifest.seed, Some(seed));
2005 assert_eq!(manifest.status, RunStatus::Ended);
2006 assert_eq!(manifest.high_watermark, 4);
2007 let entries = backend
2008 .scan_range(1, manifest.high_watermark, ScanDirection::Forward)
2009 .expect("scan entries")
2010 .into_iter()
2011 .map(|entry| CapturedEntrySummary {
2012 seq: entry.seq,
2013 topic: entry.topic.as_ref().to_string(),
2014 payload_type: entry.payload_type.as_str().to_string(),
2015 payload: entry.payload.to_vec(),
2016 ts_init: entry.ts_init,
2017 ts_publish: entry.ts_publish,
2018 })
2019 .collect();
2020 drop(backend);
2021
2022 MadsimMemoryLifecycleCapture {
2023 entries,
2024 redb_files: redb_files_under(tmp.path()),
2025 }
2026 }
2027
2028 #[cfg(madsim)]
2029 fn redb_files_under(dir: &Path) -> Vec<PathBuf> {
2030 let mut paths = Vec::new();
2031 collect_redb_files(dir, &mut paths);
2032 paths.sort();
2033 paths
2034 }
2035
2036 #[cfg(madsim)]
2037 fn collect_redb_files(dir: &Path, paths: &mut Vec<PathBuf>) {
2038 for entry in std::fs::read_dir(dir).expect("read dir") {
2039 let path = entry.expect("dir entry").path();
2040 if path.is_dir() {
2041 collect_redb_files(&path, paths);
2042 } else if path
2043 .extension()
2044 .is_some_and(|extension| extension == "redb")
2045 {
2046 paths.push(path);
2047 }
2048 }
2049 }
2050
2051 #[rstest]
2052 fn close_seals_manifest_and_records_run_ended() {
2053 let tmp = TempDir::new().expect("tempdir");
2054 let config = make_config(tmp.path().to_path_buf());
2055
2056 let halt = HaltSignal::new();
2057 let mut session = open_run(
2058 &config,
2059 INSTANCE_ID,
2060 build_run_id(UnixNanos::from(2_000)),
2061 None,
2062 UnixNanos::from(2_000),
2063 &RegisteredComponents::default(),
2064 halt,
2065 get_atomic_clock_static(),
2066 )
2067 .expect("open run");
2068
2069 let run_id = session.run_id().to_string();
2070 session.close(UnixNanos::from(3_000)).expect("close");
2071
2072 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2073 let manifest = manifests
2074 .into_iter()
2075 .find(|m| m.run_id == run_id)
2076 .expect("manifest present");
2077 assert_eq!(manifest.status, RunStatus::Ended);
2078 assert!(manifest.high_watermark >= 2);
2079 }
2080
2081 #[rstest]
2082 fn snapshot_anchorer_persists_anchor_for_open_session() {
2083 let tmp = TempDir::new().expect("tempdir");
2084 let config = make_config(tmp.path().to_path_buf());
2085
2086 let halt = HaltSignal::new();
2087 let mut session = open_run(
2088 &config,
2089 INSTANCE_ID,
2090 build_run_id(UnixNanos::from(4_000)),
2091 None,
2092 UnixNanos::from(4_000),
2093 &RegisteredComponents::default(),
2094 halt,
2095 get_atomic_clock_static(),
2096 )
2097 .expect("open run");
2098
2099 let run_id = session.run_id().to_string();
2100
2101 {
2102 let anchorer = session.snapshot_anchorer().expect("snapshot anchorer");
2103 anchorer(CacheSnapshotRef::new(
2104 "cache://position-snapshots/P-1/0",
2105 Bytes::from_static(b"snapshot"),
2106 ))
2107 .expect("record snapshot anchor");
2108 }
2109
2110 session.close(UnixNanos::from(4_500)).expect("close");
2111
2112 let reader =
2113 RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &run_id).expect("open sealed");
2114 let anchor = reader
2115 .latest_snapshot_anchor()
2116 .expect("latest snapshot anchor")
2117 .expect("anchor present");
2118
2119 assert_eq!(anchor.high_watermark, 1);
2120 assert_eq!(anchor.blob_ref, "cache://position-snapshots/P-1/0");
2121 assert_eq!(
2122 anchor.content_hash,
2123 compute_snapshot_content_hash(b"snapshot"),
2124 );
2125 }
2126
2127 #[rstest]
2128 fn recovery_seals_tail_ending_in_run_ended_as_ended_not_crashed() {
2129 let tmp = TempDir::new().expect("tempdir");
2138 let config = make_config(tmp.path().to_path_buf());
2139
2140 let halt = HaltSignal::new();
2141 let run_id = build_run_id(UnixNanos::from(7_000));
2142 let session = open_run(
2143 &config,
2144 INSTANCE_ID,
2145 run_id.clone(),
2146 None,
2147 UnixNanos::from(7_000),
2148 &RegisteredComponents::default(),
2149 halt,
2150 get_atomic_clock_static(),
2151 )
2152 .expect("open run");
2153
2154 let writer = session.writer.as_ref().expect("writer attached");
2155 writer
2156 .submit(run_ended_draft(UnixNanos::from(7_500)))
2157 .expect("submit RunEnded as tail entry");
2158 let deadline = Instant::now() + Duration::from_secs(2);
2162
2163 while session.high_watermark() < 2 {
2164 assert!(
2165 Instant::now() < deadline,
2166 "writer high_watermark stuck at {} before deadline",
2167 session.high_watermark(),
2168 );
2169 thread::sleep(Duration::from_millis(2));
2170 }
2171 drop(session);
2172
2173 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2174 assert_eq!(outcome.recovered.len(), 1);
2175 assert_eq!(outcome.recovered[0].run_id, run_id);
2176 assert_eq!(
2177 outcome.recovered[0].status,
2178 RunStatus::Ended,
2179 "tail ending in RunEnded must seal as Ended",
2180 );
2181 assert!(
2182 outcome.parent_run_id.is_none(),
2183 "Ended runs must not become parents",
2184 );
2185
2186 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2187 let manifest = manifests
2188 .into_iter()
2189 .find(|m| m.run_id == run_id)
2190 .expect("manifest present");
2191 assert_eq!(manifest.status, RunStatus::Ended);
2192 }
2193
2194 #[rstest]
2195 fn drop_without_close_leaves_run_for_next_boot_to_recover() {
2196 let tmp = TempDir::new().expect("tempdir");
2197 let config = make_config(tmp.path().to_path_buf());
2198
2199 let halt = HaltSignal::new();
2200 let session = open_run(
2201 &config,
2202 INSTANCE_ID,
2203 build_run_id(UnixNanos::from(4_000)),
2204 None,
2205 UnixNanos::from(4_000),
2206 &RegisteredComponents::default(),
2207 halt,
2208 get_atomic_clock_static(),
2209 )
2210 .expect("open run");
2211 let run_id = session.run_id().to_string();
2212 drop(session);
2213
2214 let outcome =
2215 recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover after crash");
2216 assert_eq!(outcome.recovered.len(), 1);
2217 assert_eq!(outcome.recovered[0].run_id, run_id);
2218 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2219 assert_eq!(outcome.parent_run_id.as_deref(), Some(run_id.as_str()));
2220
2221 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2222 let sealed = manifests
2223 .into_iter()
2224 .find(|m| m.run_id == run_id)
2225 .expect("present");
2226 assert_eq!(sealed.status, RunStatus::CrashedRecovered);
2227 }
2228
2229 #[rstest]
2230 fn build_run_id_uses_expected_format() {
2231 let id = build_run_id(UnixNanos::from(123_456));
2235 let (prefix, suffix) = id.split_once('-').expect("run id must contain a hyphen");
2236 assert_eq!(prefix, "123456");
2237 assert_eq!(suffix.len(), 8, "suffix was {suffix:?}");
2238 assert!(
2239 suffix.chars().all(|c| c.is_ascii_hexdigit()),
2240 "suffix must be hex, was {suffix:?}",
2241 );
2242 }
2243
2244 #[rstest]
2245 fn crash_recovery_seals_predecessor_and_links_parent_run_id() {
2246 let tmp = TempDir::new().expect("tempdir");
2250 let config = make_config(tmp.path().to_path_buf());
2251
2252 let halt_first = HaltSignal::new();
2254 let first = open_run(
2255 &config,
2256 INSTANCE_ID,
2257 build_run_id(UnixNanos::from(10_000)),
2258 None,
2259 UnixNanos::from(10_000),
2260 &RegisteredComponents::default(),
2261 halt_first,
2262 get_atomic_clock_static(),
2263 )
2264 .expect("open first run");
2265 let crashed_run_id = first.run_id().to_string();
2266 drop(first);
2267
2268 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2270 assert_eq!(outcome.recovered.len(), 1);
2271 assert_eq!(outcome.recovered[0].run_id, crashed_run_id);
2272 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2273 assert_eq!(
2274 outcome.parent_run_id.as_deref(),
2275 Some(crashed_run_id.as_str())
2276 );
2277
2278 let manifests_after_seal =
2280 RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2281 let predecessor = manifests_after_seal
2282 .iter()
2283 .find(|m| m.run_id == crashed_run_id)
2284 .expect("predecessor present");
2285 assert_eq!(predecessor.status, RunStatus::CrashedRecovered);
2286
2287 let halt_second = HaltSignal::new();
2289 let new_run_id = build_run_id(UnixNanos::from(20_000));
2290 let next = open_run(
2291 &config,
2292 INSTANCE_ID,
2293 new_run_id.clone(),
2294 outcome.parent_run_id,
2295 UnixNanos::from(20_000),
2296 &RegisteredComponents::default(),
2297 halt_second,
2298 get_atomic_clock_static(),
2299 )
2300 .expect("open second run");
2301 assert_eq!(next.parent_run_id(), Some(crashed_run_id.as_str()));
2302 assert_eq!(
2303 next.manifest().parent_run_id.as_deref(),
2304 Some(crashed_run_id.as_str()),
2305 );
2306 assert_eq!(next.high_watermark(), 1, "RunStarted is the first entry");
2307
2308 drop(next);
2311 let outcome_after = recover_predecessors(&config.base_dir, INSTANCE_ID)
2312 .expect("recover after second crash");
2313 assert_eq!(outcome_after.recovered.len(), 1);
2315 assert_eq!(outcome_after.recovered[0].run_id, new_run_id);
2316 assert_eq!(
2317 outcome_after.recovered[0].status,
2318 RunStatus::CrashedRecovered,
2319 );
2320
2321 let sealed = RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &new_run_id)
2323 .expect("open sealed");
2324 let first_entry = sealed
2325 .scan_seq(1)
2326 .expect("scan")
2327 .expect("RunStarted present");
2328 assert_eq!(first_entry.payload_type.as_str(), "RunStarted");
2329 assert_eq!(first_entry.topic.as_ref(), "run.lifecycle.RunStarted");
2330 }
2331
2332 #[rstest]
2333 #[case::before_enqueue(CrashPoint::BeforeEnqueue, 0, false)]
2334 #[case::after_enqueue_before_commit(CrashPoint::AfterEnqueueBeforeCommit, 0, false)]
2335 #[case::after_commit_before_snapshot(CrashPoint::AfterCommitBeforeSnapshot, 1, false)]
2336 #[case::after_snapshot(CrashPoint::AfterSnapshot, 1, true)]
2337 fn crash_recovery_matrix_seals_predecessor_and_links_parent_run_id(
2338 #[case] crash_point: CrashPoint,
2339 #[case] expected_hwm: u64,
2340 #[case] expect_snapshot_anchor: bool,
2341 ) {
2342 let tmp = TempDir::new().expect("tempdir");
2343 let config = make_config(tmp.path().to_path_buf());
2344 let predecessor_run_id = format!("3000-{crash_point:?}");
2345 seed_crashed_predecessor(&config, &predecessor_run_id, crash_point);
2346
2347 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2348 assert_eq!(outcome.recovered.len(), 1);
2349 assert_eq!(outcome.recovered[0].run_id, predecessor_run_id);
2350 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2351 assert_eq!(
2352 outcome.parent_run_id.as_deref(),
2353 Some(predecessor_run_id.as_str()),
2354 );
2355
2356 let predecessor =
2357 RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &predecessor_run_id)
2358 .expect("open sealed predecessor");
2359 let manifest = predecessor.manifest().expect("manifest");
2360 let snapshot_anchor = predecessor.latest_snapshot_anchor().expect("anchor read");
2361
2362 assert_eq!(manifest.status, RunStatus::CrashedRecovered);
2363 assert_eq!(manifest.high_watermark, expected_hwm);
2364 assert_eq!(
2365 snapshot_anchor.is_some(),
2366 expect_snapshot_anchor,
2367 "snapshot anchor presence must match crash point",
2368 );
2369
2370 let next = open_run(
2371 &config,
2372 INSTANCE_ID,
2373 "4000-next".to_string(),
2374 outcome.parent_run_id,
2375 UnixNanos::from(4_000),
2376 &RegisteredComponents::default(),
2377 HaltSignal::new(),
2378 get_atomic_clock_static(),
2379 )
2380 .expect("open next run");
2381 assert_eq!(next.parent_run_id(), Some(predecessor_run_id.as_str()));
2382 assert_eq!(
2383 next.manifest().parent_run_id.as_deref(),
2384 Some(predecessor_run_id.as_str()),
2385 );
2386 }
2387
2388 #[rstest]
2389 fn kernel_event_store_open_seals_leftover_session_before_reopen() {
2390 let tmp = TempDir::new().expect("tempdir");
2395 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2396 let instance_id = UUID4::new();
2397
2398 let mut store = EventStoreLifecycle::boot(
2399 Some(make_config(tmp.path().to_path_buf())),
2400 instance_id,
2401 clock_rc,
2402 )
2403 .expect("boot store");
2404
2405 store
2406 .open(
2407 instance_id,
2408 &RegisteredComponents::default(),
2409 Environment::Backtest,
2410 )
2411 .expect("open first run");
2412 let run_one = store.run_id().expect("run one open").to_string();
2413
2414 store
2415 .open(
2416 instance_id,
2417 &RegisteredComponents::default(),
2418 Environment::Backtest,
2419 )
2420 .expect("open second run");
2421 let run_two = store.run_id().expect("run two open").to_string();
2422
2423 assert_ne!(run_one, run_two, "second open must produce a fresh run id");
2424
2425 drop(store);
2428 let manifests =
2429 RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2430 let m1 = manifests
2431 .iter()
2432 .find(|m| m.run_id == run_one)
2433 .expect("first run present");
2434 let m2 = manifests
2435 .iter()
2436 .find(|m| m.run_id == run_two)
2437 .expect("second run present");
2438 assert_eq!(m1.status, RunStatus::Ended);
2439 assert_eq!(m2.status, RunStatus::Ended);
2440 }
2441
2442 #[rstest]
2443 fn open_after_halt_re_arms_signal_and_next_run_seals_ended() {
2444 let tmp = TempDir::new().expect("tempdir");
2448 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2449 let instance_id = UUID4::new();
2450
2451 let mut store = EventStoreLifecycle::boot(
2452 Some(make_config(tmp.path().to_path_buf())),
2453 instance_id,
2454 clock_rc,
2455 )
2456 .expect("boot store");
2457
2458 store
2459 .open(
2460 instance_id,
2461 &RegisteredComponents::default(),
2462 Environment::Backtest,
2463 )
2464 .expect("open first run");
2465 let run_one = store.run_id().expect("run one open").to_string();
2466
2467 store.halt.callback()(HaltReason::BackendDisk("ENOSPC".to_string()));
2468 assert!(store.halt.is_halted());
2469
2470 store
2471 .open(
2472 instance_id,
2473 &RegisteredComponents::default(),
2474 Environment::Backtest,
2475 )
2476 .expect("open second run after halt");
2477 let run_two = store.run_id().expect("run two open").to_string();
2478
2479 assert_ne!(run_one, run_two);
2480 assert!(!store.halt.is_halted(), "open must re-arm the halt signal");
2481 assert!(store.halt.reason().is_none());
2482
2483 drop(store);
2484 let manifests =
2485 RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2486 let m1 = manifests
2487 .iter()
2488 .find(|m| m.run_id == run_one)
2489 .expect("first run present");
2490 let m2 = manifests
2491 .iter()
2492 .find(|m| m.run_id == run_two)
2493 .expect("second run present");
2494 assert_eq!(m1.status, RunStatus::Running);
2497 assert_eq!(m2.status, RunStatus::Ended);
2498 }
2499
2500 #[rstest]
2501 fn recover_picks_most_recent_crashed_recovered_as_parent() {
2502 let tmp = TempDir::new().expect("tempdir");
2505 let config = make_config(tmp.path().to_path_buf());
2506
2507 for ts in [1_000_u64, 2_000_u64, 3_000_u64] {
2508 let session = open_run(
2509 &config,
2510 INSTANCE_ID,
2511 build_run_id(UnixNanos::from(ts)),
2512 None,
2513 UnixNanos::from(ts),
2514 &RegisteredComponents::default(),
2515 HaltSignal::new(),
2516 get_atomic_clock_static(),
2517 )
2518 .expect("open");
2519 drop(session);
2520 }
2521
2522 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2523 assert_eq!(outcome.recovered.len(), 3);
2524 assert!(
2525 outcome
2526 .recovered
2527 .iter()
2528 .all(|r| r.status == RunStatus::CrashedRecovered),
2529 "every predecessor must seal CrashedRecovered, was {:?}",
2530 outcome.recovered,
2531 );
2532 let parent = outcome.parent_run_id.as_deref().expect("parent set");
2534 assert!(
2535 parent.starts_with("3000-"),
2536 "parent must be the run with the highest start_ts_init, was {parent}",
2537 );
2538 }
2539
2540 #[rstest]
2541 fn submit_run_started_returns_timeout_when_writer_stalls() {
2542 let stub = StallBackend::default();
2546 let manifest = manifest_for("run-timeout");
2547 let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2548 backend.open_run(manifest).expect("open stub");
2549
2550 let halt = HaltSignal::new();
2551
2552 let writer = EventStoreWriter::spawn(
2553 backend,
2554 get_atomic_clock_static(),
2555 halt.callback(),
2556 WriterConfig::default(),
2557 )
2558 .expect("spawn writer");
2559
2560 let err = submit_run_started_blocking(
2561 &writer,
2562 &RegisteredComponents::default(),
2563 UnixNanos::from(100),
2564 &halt,
2565 Duration::from_millis(20),
2566 )
2567 .expect_err("must time out");
2568
2569 match err {
2570 BootError::RunStartedTimeout { timeout } => {
2571 assert_eq!(timeout, Duration::from_millis(20));
2572 }
2573 other => panic!("expected RunStartedTimeout, was {other:?}"),
2574 }
2575
2576 stub.release();
2578 }
2579
2580 #[rstest]
2581 fn submit_run_started_returns_halted_when_writer_halts_during_wait() {
2582 let stub = StallBackend::default();
2585 let manifest = manifest_for("run-halt");
2586 let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2587 backend.open_run(manifest).expect("open stub");
2588
2589 let halt = HaltSignal::new();
2590
2591 let writer = EventStoreWriter::spawn(
2592 backend,
2593 get_atomic_clock_static(),
2594 halt.callback(),
2595 WriterConfig::default(),
2596 )
2597 .expect("spawn writer");
2598
2599 let halt_for_thread = halt.clone();
2601
2602 let firer = thread::spawn(move || {
2603 thread::sleep(Duration::from_millis(10));
2604 halt_for_thread.callback()(HaltReason::BackendDisk("test stall".to_string()));
2605 });
2606
2607 let err = submit_run_started_blocking(
2608 &writer,
2609 &RegisteredComponents::default(),
2610 UnixNanos::from(200),
2611 &halt,
2612 Duration::from_secs(2),
2613 )
2614 .expect_err("must observe halt");
2615
2616 match err {
2617 BootError::HaltedDuringBoot(HaltReason::BackendDisk(msg)) => {
2618 assert!(msg.contains("test stall"), "reason msg was {msg}");
2619 }
2620 other => panic!("expected HaltedDuringBoot(BackendDisk), was {other:?}"),
2621 }
2622 firer.join().expect("halt firer joined");
2623 stub.release();
2624 }
2625
2626 fn manifest_for(run_id: &str) -> RunManifest {
2627 RunManifest {
2628 run_id: run_id.to_string(),
2629 parent_run_id: None,
2630 instance_id: INSTANCE_ID.to_string(),
2631 binary_hash: String::new(),
2632 schema_version: 1,
2633 crate_versions: String::new(),
2634 feature_flags: Vec::new(),
2635 adapter_versions: IndexMap::new(),
2636 config_hash: String::new(),
2637 registered_components: RegisteredComponents::default(),
2638 seed: None,
2639 start_ts_init: UnixNanos::default(),
2640 end_ts_init: None,
2641 high_watermark: 0,
2642 status: RunStatus::Running,
2643 }
2644 }
2645
2646 #[derive(Debug, Default, Clone)]
2650 struct StallBackend {
2651 inner: Arc<Mutex<StallInner>>,
2652 gate: Arc<(Mutex<bool>, std::sync::Condvar)>,
2653 }
2654
2655 #[derive(Debug, Default)]
2656 struct StallInner {
2657 manifest: Option<RunManifest>,
2658 }
2659
2660 impl StallBackend {
2661 fn release(&self) {
2662 let (lock, cvar) = &*self.gate;
2663 *lock.lock().expect("gate") = true;
2664 cvar.notify_all();
2665 }
2666 }
2667
2668 impl crate::EventStore for StallBackend {
2669 fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
2670 self.inner.lock().expect("inner").manifest = Some(manifest);
2671 Ok(())
2672 }
2673
2674 fn append_batch(&mut self, _: &[crate::AppendEntry]) -> Result<u64, EventStoreError> {
2675 let (lock, cvar) = &*self.gate;
2676 let mut released = lock.lock().expect("gate");
2677
2678 while !*released {
2679 released = cvar.wait(released).expect("gate wait");
2680 }
2681 Ok(0)
2682 }
2683
2684 fn scan_range(
2685 &self,
2686 _: u64,
2687 _: u64,
2688 _: crate::ScanDirection,
2689 ) -> Result<Vec<crate::EventStoreEntry>, EventStoreError> {
2690 Ok(Vec::new())
2691 }
2692
2693 fn scan_seq(&self, _: u64) -> Result<Option<crate::EventStoreEntry>, EventStoreError> {
2694 Ok(None)
2695 }
2696
2697 fn lookup(&self, _: crate::IndexKind, _: &str) -> Result<Option<u64>, EventStoreError> {
2698 Ok(None)
2699 }
2700
2701 fn iter_index_keys(
2702 &self,
2703 _: crate::IndexKind,
2704 ) -> Result<Vec<(String, u64)>, EventStoreError> {
2705 Ok(Vec::new())
2706 }
2707
2708 fn seal(&mut self, _: RunStatus) -> Result<(), EventStoreError> {
2709 Ok(())
2710 }
2711
2712 fn manifest(&self) -> Result<RunManifest, EventStoreError> {
2713 self.inner
2714 .lock()
2715 .expect("inner")
2716 .manifest
2717 .clone()
2718 .ok_or_else(|| EventStoreError::Backend("no manifest".to_string()))
2719 }
2720
2721 fn high_watermark(&self) -> Result<u64, EventStoreError> {
2722 Ok(0)
2723 }
2724 }
2725
2726 #[rstest]
2731 fn bus_tap_captures_submit_order_sent_through_msgbus() {
2732 let tmp = TempDir::new().expect("tempdir");
2733 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2734 let instance_id = UUID4::new();
2735
2736 let mut store = EventStoreLifecycle::boot(
2737 Some(make_config(tmp.path().to_path_buf())),
2738 instance_id,
2739 clock_rc,
2740 )
2741 .expect("boot store");
2742 store
2743 .open(
2744 instance_id,
2745 &RegisteredComponents::default(),
2746 Environment::Backtest,
2747 )
2748 .expect("open run");
2749 let run_id = store.run_id().expect("run open").to_string();
2750
2751 let trader_id = TraderId::from("TRADER-001");
2752 let strategy_id = StrategyId::from("S-001");
2753 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
2754 let client_order_id = ClientOrderId::from("O-20260510-000001");
2755 let order_init = OrderInitializedSpec::builder()
2756 .instrument_id(instrument_id)
2757 .client_order_id(client_order_id)
2758 .quantity(Quantity::from("1"))
2759 .time_in_force(TimeInForce::Gtc)
2760 .build();
2761 let submit_order = SubmitOrder::new(
2762 trader_id,
2763 Some(ClientId::from("BINANCE")),
2764 strategy_id,
2765 instrument_id,
2766 client_order_id,
2767 order_init,
2768 None,
2769 None,
2770 None,
2771 UUID4::new(),
2772 UnixNanos::from(3),
2773 None, );
2775
2776 let endpoint = MStr::<Endpoint>::from("test.exec.engine.process");
2777 msgbus::send_any_value(endpoint, &submit_order);
2778
2779 let deadline = Instant::now() + Duration::from_secs(2);
2782
2783 loop {
2784 let hwm = store
2785 .session
2786 .as_ref()
2787 .map_or(0, EventStoreSession::high_watermark);
2788
2789 if hwm >= 2 {
2790 break;
2791 }
2792 assert!(
2793 Instant::now() < deadline,
2794 "captured SubmitOrder did not commit within deadline (hwm={hwm})",
2795 );
2796 thread::sleep(Duration::from_millis(2));
2797 }
2798
2799 drop(store);
2801
2802 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
2803 .expect("open sealed");
2804 let captured = sealed
2805 .scan_seq(2)
2806 .expect("scan")
2807 .expect("captured entry present");
2808 assert_eq!(captured.payload_type.as_str(), "SubmitOrder");
2809 assert_eq!(captured.topic.as_ref(), endpoint.as_str());
2810
2811 let by_client = sealed
2814 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
2815 .expect("lookup")
2816 .expect("indexed");
2817 assert_eq!(by_client, 2);
2818 }
2819
2820 #[rstest]
2821 fn kernel_with_markers_captures_snapshots_over_synthetic_bus() {
2822 let tmp = TempDir::new().expect("tempdir");
2823 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2824 let instance_id = UUID4::new();
2825 let mut config = make_config(tmp.path().to_path_buf());
2826 config.data_markers = Some(DataMarkerConfig {
2827 classes: vec![DataMarkerClass::BookDeltas, DataMarkerClass::Quote],
2828 safety_flush_interval: Duration::from_secs(1),
2829 channel_capacity: 128,
2830 high_fidelity: Vec::new(),
2831 });
2832
2833 let mut store =
2834 EventStoreLifecycle::boot(Some(config), instance_id, clock_rc).expect("boot store");
2835 store
2836 .open(
2837 instance_id,
2838 &RegisteredComponents::default(),
2839 Environment::Backtest,
2840 )
2841 .expect("open run");
2842 let run_id = store.run_id().expect("run open").to_string();
2843
2844 let first = make_submit_order(ClientOrderId::from("O-marker-1"));
2845 msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &first);
2846
2847 let quote = quote_ethusdt_binance();
2848 msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), "e);
2849 let deltas = stub_deltas();
2850 msgbus::publish_deltas(MStr::from("data.deltas.XNAS.AAPL"), &deltas);
2851
2852 let second = make_submit_order(ClientOrderId::from("O-marker-2"));
2853 msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &second);
2854 wait_for_high_watermark(&store, 3);
2855 store.seal(UnixNanos::from(500));
2856
2857 let marker_path = tmp
2858 .path()
2859 .join(instance_id.to_string())
2860 .join(format!("{run_id}.markers.redb"));
2861 let marker = RedbMarkerBackend::open_read_only_file(marker_path).expect("open markers");
2862 let snapshots = marker.scan_snapshots().expect("scan snapshots");
2863 let dict = marker.scan_dict().expect("scan dict");
2864
2865 assert_eq!(snapshots.len(), 1);
2866 assert_eq!(snapshots[0].event_seq_before, 3);
2867 assert_eq!(snapshots[0].advanced.len(), 2);
2868 assert_eq!(
2869 snapshots[0]
2870 .advanced
2871 .iter()
2872 .map(|cursor| cursor.count)
2873 .collect::<Vec<_>>(),
2874 vec![1, 1]
2875 );
2876 assert_eq!(
2877 dict.iter()
2878 .map(|entry| (entry.data_cls, entry.identifier.as_str()))
2879 .collect::<Vec<_>>(),
2880 vec![
2881 (DataClass::Quote, "ETHUSDT-PERP.BINANCE"),
2882 (DataClass::BookDeltas, "AAPL.XNAS"),
2883 ],
2884 );
2885 }
2886
2887 #[rstest]
2888 fn boot_recovery_ignores_marker_sidecar_files() {
2889 let tmp = TempDir::new().expect("tempdir");
2890 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2891 let instance_id = UUID4::new();
2892 let mut config = make_config(tmp.path().to_path_buf());
2893 config.data_markers = Some(DataMarkerConfig {
2894 classes: vec![DataMarkerClass::Quote],
2895 safety_flush_interval: Duration::from_secs(1),
2896 channel_capacity: 128,
2897 high_fidelity: Vec::new(),
2898 });
2899
2900 let mut store =
2901 EventStoreLifecycle::boot(Some(config.clone()), instance_id, Rc::clone(&clock_rc))
2902 .expect("boot store");
2903 store
2904 .open(
2905 instance_id,
2906 &RegisteredComponents::default(),
2907 Environment::Backtest,
2908 )
2909 .expect("open run");
2910 let run_id = store.run_id().expect("run open").to_string();
2911 store.seal(UnixNanos::from(500));
2912
2913 let marker_path = tmp
2914 .path()
2915 .join(instance_id.to_string())
2916 .join(format!("{run_id}.markers.redb"));
2917 assert!(marker_path.exists());
2918
2919 let rebooted = EventStoreLifecycle::boot(Some(config), instance_id, clock_rc)
2920 .expect("boot after marker sidecar");
2921
2922 assert!(rebooted.recovered().is_empty());
2923 }
2924
2925 #[rstest]
2926 fn marker_setup_failure_disables_markers_without_blocking_open() {
2927 let tmp = TempDir::new().expect("tempdir");
2928 let bad_base = tmp.path().join("not-a-directory");
2929 std::fs::write(&bad_base, b"not a directory").expect("write bad base");
2930 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
2931 let opener_memory = Arc::clone(&memory);
2932 let mut config = make_config(bad_base);
2933 config.data_markers = Some(DataMarkerConfig {
2934 classes: vec![DataMarkerClass::Quote],
2935 safety_flush_interval: Duration::from_secs(1),
2936 channel_capacity: 128,
2937 high_fidelity: Vec::new(),
2938 });
2939 let options = EventStoreLifecycleOptions::new()
2940 .with_encoder_registry(test_registry())
2941 .with_backend_opener(move |_, manifest| {
2942 opener_memory
2943 .lock()
2944 .expect("memory backend")
2945 .open_run(manifest.clone())?;
2946 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
2947 });
2948
2949 let mut session = open_run_with_options(
2950 &config,
2951 INSTANCE_ID,
2952 "run-marker-setup-fails".to_string(),
2953 None,
2954 UnixNanos::from(5_000),
2955 &RegisteredComponents::default(),
2956 HaltSignal::new(),
2957 get_atomic_clock_static(),
2958 &options,
2959 )
2960 .expect("open run despite marker failure");
2961
2962 assert!(session.marker_capture.is_none());
2963
2964 let topic: MStr<msgbus::Topic> = MStr::from("events.test.marker-fallback");
2965 session
2966 .adapter()
2967 .expect("adapter")
2968 .capture::<TestAuditMessage>(
2969 topic,
2970 &TestAuditMessage { value: 11 },
2971 Headers::empty(),
2972 UnixNanos::from(5_001),
2973 )
2974 .expect("capture");
2975 let deadline = Instant::now() + Duration::from_secs(2);
2976
2977 while session.high_watermark() < 2 {
2978 assert!(
2979 Instant::now() < deadline,
2980 "event-store high_watermark {} did not reach 2 within deadline",
2981 session.high_watermark(),
2982 );
2983 thread::sleep(Duration::from_millis(2));
2984 }
2985 session
2986 .close(UnixNanos::from(6_000))
2987 .expect("close session");
2988
2989 let backend = memory.lock().expect("memory backend");
2990 let captured = backend
2991 .scan_seq(2)
2992 .expect("scan")
2993 .expect("captured entry present");
2994
2995 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
2996 assert_eq!(captured.topic.as_ref(), topic.as_str());
2997 assert_eq!(captured.payload.as_ref(), &[11]);
2998 }
2999
3000 #[rstest]
3001 fn marker_registry_factory_receives_enabled_classes() {
3002 let tmp = TempDir::new().expect("tempdir");
3003 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3004 let instance_id = UUID4::new();
3005 let seen_classes: Arc<Mutex<Vec<Vec<DataClass>>>> = Arc::new(Mutex::new(Vec::new()));
3006 let seen_for_factory = Arc::clone(&seen_classes);
3007 let mut config = make_config(tmp.path().to_path_buf());
3008 config.data_markers = Some(DataMarkerConfig {
3009 classes: vec![DataMarkerClass::Trade, DataMarkerClass::Quote],
3010 safety_flush_interval: Duration::from_secs(1),
3011 channel_capacity: 128,
3012 high_fidelity: Vec::new(),
3013 });
3014 let options =
3015 EventStoreLifecycleOptions::new().with_marker_registry_factory(move |classes| {
3016 seen_for_factory
3017 .lock()
3018 .expect("seen classes")
3019 .push(classes.to_vec());
3020 DataMarkerExtractorRegistry::default_registry(classes)
3021 });
3022
3023 let mut store =
3024 EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
3025 .expect("boot store");
3026 store
3027 .open(
3028 instance_id,
3029 &RegisteredComponents::default(),
3030 Environment::Backtest,
3031 )
3032 .expect("open run");
3033 store.seal(UnixNanos::from(1_000));
3034
3035 let seen = seen_classes.lock().expect("seen classes");
3036 assert_eq!(seen.as_slice(), &[vec![DataClass::Trade, DataClass::Quote]]);
3037 }
3038
3039 #[rstest]
3040 fn markers_disabled_installs_no_file_and_no_cost() {
3041 let tmp = TempDir::new().expect("tempdir");
3042 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3043 let instance_id = UUID4::new();
3044
3045 let mut store = EventStoreLifecycle::boot(
3046 Some(make_config(tmp.path().to_path_buf())),
3047 instance_id,
3048 clock_rc,
3049 )
3050 .expect("boot store");
3051 store
3052 .open(
3053 instance_id,
3054 &RegisteredComponents::default(),
3055 Environment::Backtest,
3056 )
3057 .expect("open run");
3058 let run_id = store.run_id().expect("run open").to_string();
3059
3060 assert!(
3061 store
3062 .session
3063 .as_ref()
3064 .expect("session")
3065 .marker_capture
3066 .is_none()
3067 );
3068
3069 let quote = quote_ethusdt_binance();
3070 msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), "e);
3071 store.seal(UnixNanos::from(500));
3072
3073 let marker_path = tmp
3074 .path()
3075 .join(instance_id.to_string())
3076 .join(format!("{run_id}.markers.redb"));
3077 assert!(!marker_path.exists());
3078 }
3079
3080 #[rstest]
3084 fn bus_tap_captures_time_event_handler_run() {
3085 let tmp = TempDir::new().expect("tempdir");
3086 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3087 let instance_id = UUID4::new();
3088
3089 let mut store = EventStoreLifecycle::boot(
3090 Some(make_config(tmp.path().to_path_buf())),
3091 instance_id,
3092 clock_rc,
3093 )
3094 .expect("boot store");
3095 store
3096 .open(
3097 instance_id,
3098 &RegisteredComponents::default(),
3099 Environment::Backtest,
3100 )
3101 .expect("open run");
3102 let run_id = store.run_id().expect("run open").to_string();
3103
3104 let event = TimeEvent::new(
3105 Ustr::from("strategy.heartbeat"),
3106 UUID4::new(),
3107 UnixNanos::from(100),
3108 UnixNanos::from(99),
3109 );
3110 let callback = TimeEventCallback::from(|_: TimeEvent| {});
3111 TimeEventHandler::new(event, callback).run();
3112
3113 let deadline = Instant::now() + Duration::from_secs(2);
3114
3115 loop {
3116 let hwm = store
3117 .session
3118 .as_ref()
3119 .map_or(0, EventStoreSession::high_watermark);
3120
3121 if hwm >= 2 {
3122 break;
3123 }
3124 assert!(
3125 Instant::now() < deadline,
3126 "captured TimeEvent did not commit within deadline (hwm={hwm})",
3127 );
3128 thread::sleep(Duration::from_millis(2));
3129 }
3130
3131 drop(store);
3132
3133 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3134 .expect("open sealed");
3135 let captured = sealed
3136 .scan_seq(2)
3137 .expect("scan")
3138 .expect("captured entry present");
3139
3140 assert_eq!(captured.payload_type.as_str(), PAYLOAD_TYPE_TIME_EVENT);
3141 assert_eq!(captured.topic, MessagingSwitchboard::time_event_topic());
3142 }
3143
3144 #[rstest]
3149 fn seal_clears_bus_tap_so_post_seal_dispatches_do_not_capture() {
3150 let tmp = TempDir::new().expect("tempdir");
3151 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3152 let instance_id = UUID4::new();
3153
3154 let mut store = EventStoreLifecycle::boot(
3155 Some(make_config(tmp.path().to_path_buf())),
3156 instance_id,
3157 clock_rc,
3158 )
3159 .expect("boot store");
3160 store
3161 .open(
3162 instance_id,
3163 &RegisteredComponents::default(),
3164 Environment::Backtest,
3165 )
3166 .expect("open run");
3167 let run_id = store.run_id().expect("run open").to_string();
3168
3169 store.seal(UnixNanos::from(0));
3170
3171 let endpoint = MStr::<Endpoint>::from("test.post.seal.endpoint");
3176 let payload: u32 = 99;
3177 msgbus::send_any_value(endpoint, &payload);
3178
3179 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3180 .expect("open sealed");
3181 assert!(
3183 sealed.scan_seq(3).expect("scan").is_none(),
3184 "no entry must land after seal",
3185 );
3186 }
3187
3188 #[rstest]
3194 fn bus_tap_captures_trading_command_envelope_with_inner_payload_type() {
3195 let tmp = TempDir::new().expect("tempdir");
3196 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3197 let instance_id = UUID4::new();
3198
3199 let mut store = EventStoreLifecycle::boot(
3200 Some(make_config(tmp.path().to_path_buf())),
3201 instance_id,
3202 clock_rc,
3203 )
3204 .expect("boot store");
3205 store
3206 .open(
3207 instance_id,
3208 &RegisteredComponents::default(),
3209 Environment::Backtest,
3210 )
3211 .expect("open run");
3212 let run_id = store.run_id().expect("run open").to_string();
3213
3214 let trader_id = TraderId::from("TRADER-001");
3215 let strategy_id = StrategyId::from("S-001");
3216 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3217 let client_order_id = ClientOrderId::from("O-20260510-000002");
3218 let order_init = OrderInitializedSpec::builder()
3219 .instrument_id(instrument_id)
3220 .client_order_id(client_order_id)
3221 .quantity(Quantity::from("1"))
3222 .time_in_force(TimeInForce::Gtc)
3223 .build();
3224 let submit_order = SubmitOrder::new(
3225 trader_id,
3226 Some(ClientId::from("BINANCE")),
3227 strategy_id,
3228 instrument_id,
3229 client_order_id,
3230 order_init,
3231 None,
3232 None,
3233 None,
3234 UUID4::new(),
3235 UnixNanos::from(3),
3236 None, );
3238 let command = TradingCommand::SubmitOrder(submit_order.clone());
3239
3240 let endpoint = MStr::<Endpoint>::from("test.exec.engine.envelope");
3241 msgbus::send_trading_command(endpoint, command);
3242
3243 let deadline = Instant::now() + Duration::from_secs(2);
3244
3245 loop {
3246 let hwm = store
3247 .session
3248 .as_ref()
3249 .map_or(0, EventStoreSession::high_watermark);
3250
3251 if hwm >= 2 {
3252 break;
3253 }
3254 assert!(
3255 Instant::now() < deadline,
3256 "captured TradingCommand did not commit within deadline (hwm={hwm})",
3257 );
3258 thread::sleep(Duration::from_millis(2));
3259 }
3260
3261 drop(store);
3262
3263 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3264 .expect("open sealed");
3265 let captured = sealed
3266 .scan_seq(2)
3267 .expect("scan")
3268 .expect("captured entry present");
3269 assert_eq!(
3270 captured.payload_type.as_str(),
3271 "SubmitOrder",
3272 "wrapper envelope must stamp the inner payload_type",
3273 );
3274 assert_eq!(captured.topic.as_ref(), endpoint.as_str());
3275
3276 let by_client = sealed
3277 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3278 .expect("lookup")
3279 .expect("indexed");
3280 assert_eq!(by_client, 2);
3281
3282 let decoded: SubmitOrder =
3286 rmp_serde::from_slice(&captured.payload).expect("decode captured SubmitOrder");
3287 assert_eq!(decoded, submit_order);
3288 }
3289
3290 #[rstest]
3294 fn bus_tap_captures_order_event_any_envelope_with_inner_payload_type() {
3295 let tmp = TempDir::new().expect("tempdir");
3296 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3297 let instance_id = UUID4::new();
3298
3299 let mut store = EventStoreLifecycle::boot(
3300 Some(make_config(tmp.path().to_path_buf())),
3301 instance_id,
3302 clock_rc,
3303 )
3304 .expect("boot store");
3305 store
3306 .open(
3307 instance_id,
3308 &RegisteredComponents::default(),
3309 Environment::Backtest,
3310 )
3311 .expect("open run");
3312 let run_id = store.run_id().expect("run open").to_string();
3313
3314 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3315 let client_order_id = ClientOrderId::from("O-20260510-000003");
3316 let venue_order_id = VenueOrderId::from("V-99");
3317 let filled = OrderFilledSpec::builder()
3318 .instrument_id(instrument_id)
3319 .client_order_id(client_order_id)
3320 .venue_order_id(venue_order_id)
3321 .account_id(AccountId::from("BINANCE-001"))
3322 .trade_id(TradeId::from("T-1"))
3323 .last_qty(Quantity::from("1"))
3324 .last_px(Price::from("100.00"))
3325 .currency(Currency::USDT())
3326 .ts_event(UnixNanos::from(10))
3327 .ts_init(UnixNanos::from(11))
3328 .commission(Money::new(0.10, Currency::USDT()))
3329 .build();
3330 let event = OrderEventAny::Filled(filled);
3331
3332 let topic: MStr<msgbus::Topic> = MStr::from("events.order.ETHUSDT-PERP.BINANCE");
3333 msgbus::publish_order_event(topic, &event);
3334
3335 let deadline = Instant::now() + Duration::from_secs(2);
3336
3337 loop {
3338 let hwm = store
3339 .session
3340 .as_ref()
3341 .map_or(0, EventStoreSession::high_watermark);
3342
3343 if hwm >= 2 {
3344 break;
3345 }
3346 assert!(
3347 Instant::now() < deadline,
3348 "captured OrderEventAny did not commit within deadline (hwm={hwm})",
3349 );
3350 thread::sleep(Duration::from_millis(2));
3351 }
3352
3353 drop(store);
3354
3355 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3356 .expect("open sealed");
3357 let captured = sealed
3358 .scan_seq(2)
3359 .expect("scan")
3360 .expect("captured entry present");
3361 assert_eq!(
3362 captured.payload_type.as_str(),
3363 "OrderFilled",
3364 "wrapper envelope must stamp the inner payload_type",
3365 );
3366 assert_eq!(captured.topic.as_ref(), topic.as_str());
3367
3368 let by_client = sealed
3369 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3370 .expect("lookup")
3371 .expect("indexed");
3372 let by_venue = sealed
3373 .lookup(IndexKind::VenueOrderId, venue_order_id.as_str())
3374 .expect("lookup")
3375 .expect("indexed");
3376 assert_eq!(by_client, 2);
3377 assert_eq!(by_venue, 2);
3378
3379 let decoded: OrderFilled =
3383 rmp_serde::from_slice(&captured.payload).expect("decode captured OrderFilled");
3384 assert_eq!(decoded, filled);
3385 }
3386
3387 #[rstest]
3392 fn bus_tap_captures_data_command_envelopes_with_category_payload_types() {
3393 let tmp = TempDir::new().expect("tempdir");
3394 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3395 let instance_id = UUID4::new();
3396
3397 let mut store = EventStoreLifecycle::boot(
3398 Some(make_config(tmp.path().to_path_buf())),
3399 instance_id,
3400 clock_rc,
3401 )
3402 .expect("boot store");
3403 store
3404 .open(
3405 instance_id,
3406 &RegisteredComponents::default(),
3407 Environment::Backtest,
3408 )
3409 .expect("open run");
3410 let run_id = store.run_id().expect("run open").to_string();
3411
3412 let request = RequestCommand::Quotes(RequestQuotes::new(
3413 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3414 None,
3415 None,
3416 None,
3417 Some(ClientId::from("BINANCE")),
3418 UUID4::new(),
3419 UnixNanos::from(20),
3420 None,
3421 ));
3422 let subscribe = SubscribeCommand::Quotes(SubscribeQuotes::new(
3423 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3424 Some(ClientId::from("BINANCE")),
3425 Some(Venue::from("BINANCE")),
3426 UUID4::new(),
3427 UnixNanos::from(21),
3428 Some(UUID4::new()),
3429 None,
3430 ));
3431
3432 let request_endpoint = MStr::<Endpoint>::from("test.data.engine.request");
3433 msgbus::send_data_command(request_endpoint, DataCommand::Request(request.clone()));
3434
3435 let subscribe_endpoint = MStr::<Endpoint>::from("test.data.engine.subscribe");
3436 msgbus::send_data_command(
3437 subscribe_endpoint,
3438 DataCommand::Subscribe(subscribe.clone()),
3439 );
3440
3441 let deadline = Instant::now() + Duration::from_secs(2);
3442
3443 loop {
3444 let hwm = store
3445 .session
3446 .as_ref()
3447 .map_or(0, EventStoreSession::high_watermark);
3448
3449 if hwm >= 3 {
3450 break;
3451 }
3452 assert!(
3453 Instant::now() < deadline,
3454 "captured DataCommand entries did not commit within deadline (hwm={hwm})",
3455 );
3456 thread::sleep(Duration::from_millis(2));
3457 }
3458
3459 drop(store);
3460
3461 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3462 .expect("open sealed");
3463 let captured_request = sealed
3464 .scan_seq(2)
3465 .expect("scan request")
3466 .expect("captured request present");
3467 assert_eq!(captured_request.payload_type.as_str(), "RequestCommand");
3468 assert_eq!(captured_request.topic.as_ref(), request_endpoint.as_str());
3469
3470 let decoded_request: RequestCommand =
3471 rmp_serde::from_slice(&captured_request.payload).expect("decode RequestCommand");
3472 match (decoded_request, request) {
3473 (RequestCommand::Quotes(decoded), RequestCommand::Quotes(expected)) => {
3474 assert_eq!(decoded.request_id, expected.request_id);
3475 assert_eq!(decoded.instrument_id, expected.instrument_id);
3476 assert_eq!(decoded.client_id, expected.client_id);
3477 assert_eq!(decoded.ts_init, expected.ts_init);
3478 }
3479 other => panic!("expected RequestCommand::Quotes round trip, was {other:?}"),
3480 }
3481
3482 let captured_subscribe = sealed
3483 .scan_seq(3)
3484 .expect("scan subscribe")
3485 .expect("captured subscribe present");
3486 assert_eq!(captured_subscribe.payload_type.as_str(), "SubscribeCommand");
3487 assert_eq!(
3488 captured_subscribe.topic.as_ref(),
3489 subscribe_endpoint.as_str()
3490 );
3491
3492 let decoded_subscribe: SubscribeCommand =
3493 rmp_serde::from_slice(&captured_subscribe.payload).expect("decode SubscribeCommand");
3494 match (decoded_subscribe, subscribe) {
3495 (SubscribeCommand::Quotes(decoded), SubscribeCommand::Quotes(expected)) => {
3496 assert_eq!(decoded.command_id, expected.command_id);
3497 assert_eq!(decoded.instrument_id, expected.instrument_id);
3498 assert_eq!(decoded.client_id, expected.client_id);
3499 assert_eq!(decoded.venue, expected.venue);
3500 assert_eq!(decoded.ts_init, expected.ts_init);
3501 assert_eq!(decoded.correlation_id, expected.correlation_id);
3502 }
3503 other => panic!("expected SubscribeCommand::Quotes round trip, was {other:?}"),
3504 }
3505 }
3506
3507 #[rstest]
3511 fn bus_tap_captures_data_response_sent_through_correlation_handler() {
3512 let tmp = TempDir::new().expect("tempdir");
3513 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3514 let instance_id = UUID4::new();
3515
3516 let mut store = EventStoreLifecycle::boot(
3517 Some(make_config(tmp.path().to_path_buf())),
3518 instance_id,
3519 clock_rc,
3520 )
3521 .expect("boot store");
3522 store
3523 .open(
3524 instance_id,
3525 &RegisteredComponents::default(),
3526 Environment::Backtest,
3527 )
3528 .expect("open run");
3529 let run_id = store.run_id().expect("run open").to_string();
3530
3531 let correlation_id = UUID4::new();
3532 let handler_called = Rc::new(RefCell::new(false));
3533 let handler_called_clone = handler_called.clone();
3534 msgbus::register_response_handler(
3535 &correlation_id,
3536 msgbus::ShareableMessageHandler::from_typed(move |_resp: &QuotesResponse| {
3537 *handler_called_clone.borrow_mut() = true;
3538 }),
3539 );
3540
3541 let response = QuotesResponse::new(
3542 correlation_id,
3543 ClientId::from("BINANCE"),
3544 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3545 vec![],
3546 None,
3547 None,
3548 UnixNanos::from(30),
3549 None,
3550 );
3551 msgbus::send_response(&correlation_id, &DataResponse::Quotes(response.clone()));
3552
3553 let deadline = Instant::now() + Duration::from_secs(2);
3554
3555 loop {
3556 let hwm = store
3557 .session
3558 .as_ref()
3559 .map_or(0, EventStoreSession::high_watermark);
3560
3561 if hwm >= 2 {
3562 break;
3563 }
3564 assert!(
3565 Instant::now() < deadline,
3566 "captured DataResponse did not commit within deadline (hwm={hwm})",
3567 );
3568 thread::sleep(Duration::from_millis(2));
3569 }
3570
3571 assert!(*handler_called.borrow());
3572 drop(store);
3573
3574 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3575 .expect("open sealed");
3576 let captured = sealed
3577 .scan_seq(2)
3578 .expect("scan")
3579 .expect("captured response present");
3580 assert_eq!(captured.payload_type.as_str(), "QuotesResponse");
3581 assert_eq!(captured.topic, MessagingSwitchboard::data_response_topic());
3582
3583 let decoded: QuotesResponse =
3584 rmp_serde::from_slice(&captured.payload).expect("decode QuotesResponse");
3585 assert_eq!(decoded.correlation_id, response.correlation_id);
3586 assert_eq!(decoded.client_id, response.client_id);
3587 assert_eq!(decoded.instrument_id, response.instrument_id);
3588 assert_eq!(decoded.ts_init, response.ts_init);
3589 assert!(decoded.data.is_empty());
3590 }
3591}