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