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