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, QuotesResponse, RequestCommand, RequestQuotes,
1388 SubscribeCommand, SubscribeQuotes,
1389 },
1390 execution::{SubmitOrder, TradingCommand},
1391 },
1392 timer::{TimeEvent, TimeEventCallback, TimeEventHandler},
1393 };
1394 use nautilus_core::time::get_atomic_clock_static;
1395 use nautilus_model::{
1396 data::stubs::{quote_ethusdt_binance, stub_deltas},
1397 enums::TimeInForce,
1398 events::{
1399 OrderEventAny, OrderFilled,
1400 order::spec::{OrderFilledSpec, OrderInitializedSpec},
1401 },
1402 identifiers::{
1403 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
1404 VenueOrderId,
1405 },
1406 types::{Currency, Money, Price, Quantity},
1407 };
1408 use nautilus_system::event_store::{DataMarkerClass, DataMarkerConfig, RunIdentity};
1409 use rstest::rstest;
1410 use tempfile::TempDir;
1411
1412 use super::*;
1413 use crate::{
1414 AppendEntry, DataClass, EncodedPayload, EventStoreEntry, IndexKind, MarkerBackend,
1415 MemoryBackend, RedbMarkerBackend, SnapshotAnchor,
1416 capture::builtins::PAYLOAD_TYPE_TIME_EVENT, compute_entry_hash,
1417 };
1418
1419 const INSTANCE_ID: &str = "trader-001";
1420
1421 fn make_config(base_dir: PathBuf) -> EventStoreConfig {
1422 EventStoreConfig {
1423 base_dir,
1424 identity: RunIdentity {
1425 binary_hash: "deadbeef".to_string(),
1426 schema_version: 1,
1427 crate_versions: "feedface".to_string(),
1428 feature_flags: Vec::new(),
1429 adapter_versions: IndexMap::new(),
1430 config_hash: "cafebabe".to_string(),
1431 seed: None,
1432 },
1433 retention: RetentionMode::Full,
1434 replay_from_run_id: None,
1435 data_markers: None,
1436 channel_capacity: 64,
1437 max_batch_entries: 1,
1438 max_batch_latency: Duration::from_millis(2),
1439 halt_threshold: Duration::from_secs(2),
1440 run_started_timeout: Duration::from_secs(2),
1441 }
1442 }
1443
1444 #[derive(Clone, Copy, Debug)]
1445 enum CrashPoint {
1446 BeforeEnqueue,
1447 AfterEnqueueBeforeCommit,
1448 AfterCommitBeforeSnapshot,
1449 AfterSnapshot,
1450 }
1451
1452 fn append_entry(seq: u64, topic: &str, payload_type: &str, payload: Bytes) -> AppendEntry {
1453 let ts = UnixNanos::from(seq);
1454 let headers = Headers::empty();
1455 let hash = compute_entry_hash(seq, ts, ts, topic, payload_type, &payload, &headers);
1456 let entry = EventStoreEntry::new(
1457 hash,
1458 seq,
1459 headers,
1460 Topic::from(topic),
1461 Ustr::from(payload_type),
1462 payload,
1463 ts,
1464 ts,
1465 );
1466 AppendEntry::without_indices(entry)
1467 }
1468
1469 fn make_submit_order(client_order_id: ClientOrderId) -> SubmitOrder {
1470 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1471 let order_init = OrderInitializedSpec::builder()
1472 .instrument_id(instrument_id)
1473 .client_order_id(client_order_id)
1474 .quantity(Quantity::from("1"))
1475 .time_in_force(TimeInForce::Gtc)
1476 .ts_event(UnixNanos::from(1))
1477 .ts_init(UnixNanos::from(2))
1478 .build();
1479 SubmitOrder::new(
1480 TraderId::from("TRADER-001"),
1481 Some(ClientId::from("BINANCE")),
1482 StrategyId::from("S-001"),
1483 instrument_id,
1484 client_order_id,
1485 order_init,
1486 None,
1487 None,
1488 None,
1489 UUID4::new(),
1490 UnixNanos::from(3),
1491 None, )
1493 }
1494
1495 fn append_run_started(seq: u64) -> AppendEntry {
1496 append_entry(
1497 seq,
1498 RUN_STARTED_TOPIC,
1499 RUN_STARTED_PAYLOAD_TYPE,
1500 encode_run_started(&RegisteredComponents::default()),
1501 )
1502 }
1503
1504 #[derive(Debug)]
1505 struct TestAuditMessage {
1506 value: u8,
1507 }
1508
1509 fn test_registry() -> EncoderRegistry {
1510 let mut registry = EncoderRegistry::new();
1511 registry.register::<TestAuditMessage, _>(Ustr::from("TestAuditMessage"), |message| {
1512 Ok(EncodedPayload::without_indices(Bytes::copy_from_slice(&[
1513 message.value,
1514 ])))
1515 });
1516 registry
1517 }
1518
1519 #[derive(Debug, Clone)]
1520 struct SharedMemoryBackend(Arc<Mutex<MemoryBackend>>);
1521
1522 impl EventStore for SharedMemoryBackend {
1523 fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
1524 self.0.lock().open_run(manifest)
1525 }
1526
1527 fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
1528 self.0.lock().append_batch(entries)
1529 }
1530
1531 fn scan_range(
1532 &self,
1533 from: u64,
1534 to: u64,
1535 direction: ScanDirection,
1536 ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
1537 self.0.lock().scan_range(from, to, direction)
1538 }
1539
1540 fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
1541 self.0.lock().scan_seq(seq)
1542 }
1543
1544 fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
1545 self.0.lock().lookup(kind, key)
1546 }
1547
1548 fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
1549 self.0.lock().iter_index_keys(kind)
1550 }
1551
1552 fn record_snapshot_anchor(
1553 &mut self,
1554 anchor: SnapshotAnchor,
1555 ) -> Result<(), EventStoreError> {
1556 self.0.lock().record_snapshot_anchor(anchor)
1557 }
1558
1559 fn latest_snapshot_anchor(&self) -> Result<Option<SnapshotAnchor>, EventStoreError> {
1560 self.0.lock().latest_snapshot_anchor()
1561 }
1562
1563 fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
1564 self.0.lock().seal(status)
1565 }
1566
1567 fn manifest(&self) -> Result<RunManifest, EventStoreError> {
1568 self.0.lock().manifest()
1569 }
1570
1571 fn high_watermark(&self) -> Result<u64, EventStoreError> {
1572 self.0.lock().high_watermark()
1573 }
1574 }
1575
1576 fn seed_crashed_predecessor(config: &EventStoreConfig, run_id: &str, crash_point: CrashPoint) {
1577 let mut backend = RedbBackend::new(config.base_dir.clone());
1578 backend
1579 .open_run(build_manifest(
1580 config,
1581 INSTANCE_ID,
1582 run_id.to_string(),
1583 None,
1584 UnixNanos::from(1_000),
1585 RegisteredComponents::default(),
1586 ))
1587 .expect("open predecessor");
1588
1589 match crash_point {
1590 CrashPoint::BeforeEnqueue | CrashPoint::AfterEnqueueBeforeCommit => {}
1594 CrashPoint::AfterCommitBeforeSnapshot => {
1595 backend
1596 .append_batch(&[append_run_started(1)])
1597 .expect("append committed entry");
1598 }
1599 CrashPoint::AfterSnapshot => {
1600 backend
1601 .append_batch(&[append_run_started(1)])
1602 .expect("append committed entry");
1603 backend
1604 .record_snapshot_anchor(SnapshotAnchor::new(
1605 1,
1606 "cache://snapshot/run-crash/1",
1607 "blake3:abc",
1608 ))
1609 .expect("record snapshot anchor");
1610 }
1611 }
1612 }
1613
1614 #[rstest]
1615 fn halt_signal_callback_records_first_reason() {
1616 let signal = HaltSignal::new();
1617 let cb = signal.callback();
1618 cb(HaltReason::BackendDisk("ENOSPC".to_string()));
1619 cb(HaltReason::BackendError("second".to_string()));
1620
1621 assert!(signal.is_halted());
1622 match signal.reason() {
1623 Some(HaltReason::BackendDisk(msg)) => assert!(msg.contains("ENOSPC")),
1624 other => panic!("expected first reason BackendDisk, was {other:?}"),
1625 }
1626 }
1627
1628 #[rstest]
1629 fn recover_predecessors_returns_empty_for_missing_directory() {
1630 let tmp = TempDir::new().expect("tempdir");
1631 let outcome =
1632 recover_predecessors(tmp.path(), INSTANCE_ID).expect("recover empty directory");
1633 assert!(outcome.recovered.is_empty());
1634 assert!(outcome.parent_run_id.is_none());
1635 }
1636
1637 #[rstest]
1638 fn restore_cache_snapshot_blob_rejects_hash_mismatch() {
1639 let mut cache = Cache::default();
1640 let blob = Bytes::from_static(b"snapshot");
1641 let anchor =
1642 crate::SnapshotAnchor::new(0, "cache://position-snapshots/P-1/0", "blake3:bad");
1643
1644 cache
1645 .add(&anchor.blob_ref, blob)
1646 .expect("seed snapshot blob");
1647 let err =
1648 crate::restore_cache_snapshot_blob(&mut cache, Some(&anchor)).expect_err("hash error");
1649
1650 assert!(
1651 err.to_string().contains("content_hash mismatch"),
1652 "err was: {err}",
1653 );
1654 }
1655
1656 #[rstest]
1657 fn open_run_writes_run_started_and_advances_watermark() {
1658 let tmp = TempDir::new().expect("tempdir");
1659 let config = make_config(tmp.path().to_path_buf());
1660 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover empty");
1661 assert!(outcome.parent_run_id.is_none());
1662
1663 let halt = HaltSignal::new();
1664 let session = open_run(
1665 &config,
1666 INSTANCE_ID,
1667 build_run_id(UnixNanos::from(1_000)),
1668 outcome.parent_run_id,
1669 UnixNanos::from(1_000),
1670 &RegisteredComponents::default(),
1671 halt,
1672 get_atomic_clock_static(),
1673 )
1674 .expect("open run");
1675
1676 assert_eq!(session.high_watermark(), 1);
1678 assert_eq!(session.parent_run_id(), None);
1679
1680 let manifest = session.manifest();
1684 assert_eq!(manifest.instance_id, INSTANCE_ID);
1685 assert_eq!(manifest.status, RunStatus::Running);
1686 assert_eq!(manifest.binary_hash, "deadbeef");
1687 assert_eq!(manifest.schema_version, 1);
1688 assert_eq!(manifest.crate_versions, "feedface");
1689 assert_eq!(manifest.config_hash, "cafebabe");
1690 assert_eq!(manifest.start_ts_init, UnixNanos::from(1_000));
1691 assert_eq!(manifest.end_ts_init, None);
1692 assert!(
1693 manifest
1694 .feature_flags
1695 .contains(&"retention=full".to_string()),
1696 "feature_flags must record the retention mode, was {:?}",
1697 manifest.feature_flags,
1698 );
1699 }
1700
1701 #[rstest]
1702 fn lifecycle_options_default_registry_keeps_builtin_encoders() {
1703 let registry = EventStoreLifecycleOptions::default().build_registry();
1704
1705 assert!(registry.contains::<SubmitOrder>());
1706 assert!(registry.contains::<TradingCommand>());
1707 assert!(!registry.contains::<TestAuditMessage>());
1708 }
1709
1710 #[rstest]
1711 fn lifecycle_options_custom_registry_captures_registered_message() {
1712 let tmp = TempDir::new().expect("tempdir");
1713 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1714 let instance_id = UUID4::new();
1715 let options = EventStoreLifecycleOptions::new().with_encoder_registry(test_registry());
1716
1717 let mut store = EventStoreLifecycle::boot_with_options(
1718 Some(make_config(tmp.path().to_path_buf())),
1719 instance_id,
1720 clock_rc,
1721 options,
1722 )
1723 .expect("boot store");
1724 store
1725 .open(
1726 instance_id,
1727 &RegisteredComponents::default(),
1728 Environment::Backtest,
1729 )
1730 .expect("open run");
1731 let run_id = store.run_id().expect("run open").to_string();
1732
1733 let topic: MStr<msgbus::Topic> = MStr::from("events.test.audit");
1734 msgbus::publish_any(topic, &TestAuditMessage { value: 42 });
1735 store.seal(UnixNanos::from(0));
1736
1737 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
1738 .expect("open sealed");
1739 let captured = sealed
1740 .scan_seq(2)
1741 .expect("scan")
1742 .expect("captured entry present");
1743
1744 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1745 assert_eq!(captured.topic.as_ref(), topic.as_str());
1746 assert_eq!(captured.payload.as_ref(), &[42]);
1747 }
1748
1749 #[rstest]
1750 fn lifecycle_options_memory_backend_opener_captures_and_seals() {
1751 let tmp = TempDir::new().expect("tempdir");
1752 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1753 let opener_memory = Arc::clone(&memory);
1754 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1755 let instance_id = UUID4::new();
1756 let options = EventStoreLifecycleOptions::new()
1757 .with_encoder_registry(test_registry())
1758 .with_backend_opener(move |_, manifest| {
1759 opener_memory.lock().open_run(manifest.clone())?;
1760 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1761 });
1762
1763 let mut store = EventStoreLifecycle::boot_with_options(
1764 Some(make_config(tmp.path().to_path_buf())),
1765 instance_id,
1766 clock_rc,
1767 options,
1768 )
1769 .expect("boot store");
1770 store
1771 .open(
1772 instance_id,
1773 &RegisteredComponents::default(),
1774 Environment::Backtest,
1775 )
1776 .expect("open run");
1777
1778 let topic: MStr<msgbus::Topic> = MStr::from("events.test.memory");
1779 msgbus::publish_any(topic, &TestAuditMessage { value: 7 });
1780 store.seal(UnixNanos::from(1_000));
1781
1782 let backend = memory.lock();
1783 let manifest = backend.manifest().expect("manifest");
1784 let captured = backend
1785 .scan_seq(2)
1786 .expect("scan")
1787 .expect("captured entry present");
1788
1789 assert_eq!(manifest.instance_id, instance_id.to_string());
1790 assert_eq!(manifest.status, RunStatus::Ended);
1791 assert_eq!(manifest.high_watermark, 3);
1792 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1793 assert_eq!(captured.topic.as_ref(), topic.as_str());
1794 assert_eq!(captured.payload.as_ref(), &[7]);
1795 }
1796
1797 #[cfg(madsim)]
1798 #[rstest]
1799 fn lifecycle_options_memory_backend_opener_captures_deterministic_seq_order_under_madsim() {
1800 let first = capture_madsim_memory_lifecycle_summary(42);
1801 let second = capture_madsim_memory_lifecycle_summary(42);
1802 let expected = expected_madsim_memory_entries();
1803
1804 assert_eq!(first.entries, second.entries);
1805 assert_eq!(first.entries, expected);
1806 assert_eq!(
1807 first
1808 .entries
1809 .iter()
1810 .map(|entry| entry.seq)
1811 .collect::<Vec<_>>(),
1812 vec![1, 2, 3, 4],
1813 );
1814 assert!(
1815 first.redb_files.is_empty(),
1816 "memory opener must not create redb files, was {:?}",
1817 first.redb_files,
1818 );
1819 assert!(
1820 second.redb_files.is_empty(),
1821 "memory opener must not create redb files, was {:?}",
1822 second.redb_files,
1823 );
1824 }
1825
1826 #[cfg(madsim)]
1827 fn expected_madsim_memory_entries() -> Vec<CapturedEntrySummary> {
1828 vec![
1829 CapturedEntrySummary {
1830 seq: 1,
1831 topic: RUN_STARTED_TOPIC.to_string(),
1832 payload_type: RUN_STARTED_PAYLOAD_TYPE.to_string(),
1833 payload: encode_run_started(&RegisteredComponents::default()).to_vec(),
1834 ts_init: UnixNanos::from(0),
1835 ts_publish: UnixNanos::from(10_000),
1836 },
1837 CapturedEntrySummary {
1838 seq: 2,
1839 topic: "events.test.madsim".to_string(),
1840 payload_type: "TestAuditMessage".to_string(),
1841 payload: vec![1],
1842 ts_init: UnixNanos::from(20_000),
1843 ts_publish: UnixNanos::from(20_000),
1844 },
1845 CapturedEntrySummary {
1846 seq: 3,
1847 topic: "events.test.madsim".to_string(),
1848 payload_type: "TestAuditMessage".to_string(),
1849 payload: vec![2],
1850 ts_init: UnixNanos::from(30_000),
1851 ts_publish: UnixNanos::from(30_000),
1852 },
1853 CapturedEntrySummary {
1854 seq: 4,
1855 topic: RUN_ENDED_TOPIC.to_string(),
1856 payload_type: RUN_ENDED_PAYLOAD_TYPE.to_string(),
1857 payload: Vec::new(),
1858 ts_init: UnixNanos::from(40_000),
1859 ts_publish: UnixNanos::from(40_000),
1860 },
1861 ]
1862 }
1863
1864 #[rstest]
1865 fn open_run_with_options_surfaces_backend_opener_error() {
1866 let tmp = TempDir::new().expect("tempdir");
1867 let config = make_config(tmp.path().to_path_buf());
1868 let options = EventStoreLifecycleOptions::new().with_backend_opener(|_, _| {
1869 Err(EventStoreError::Backend(
1870 "test backend open failed".to_string(),
1871 ))
1872 });
1873
1874 let err = open_run_with_options(
1875 &config,
1876 INSTANCE_ID,
1877 "run-open-error".to_string(),
1878 None,
1879 UnixNanos::from(5_000),
1880 &RegisteredComponents::default(),
1881 HaltSignal::new(),
1882 get_atomic_clock_static(),
1883 &options,
1884 )
1885 .expect_err("backend opener error must stop run open");
1886
1887 match err {
1888 BootError::EventStore(EventStoreError::Backend(msg)) => {
1889 assert!(msg.contains("test backend open failed"));
1890 }
1891 other => panic!("expected backend open failure, was {other:?}"),
1892 }
1893 }
1894
1895 #[cfg(madsim)]
1896 #[derive(Debug, PartialEq, Eq)]
1897 struct MadsimMemoryLifecycleCapture {
1898 entries: Vec<CapturedEntrySummary>,
1899 redb_files: Vec<PathBuf>,
1900 }
1901
1902 #[cfg(madsim)]
1903 #[derive(Debug, PartialEq, Eq)]
1904 struct CapturedEntrySummary {
1905 seq: u64,
1906 topic: String,
1907 payload_type: String,
1908 payload: Vec<u8>,
1909 ts_init: UnixNanos,
1910 ts_publish: UnixNanos,
1911 }
1912
1913 #[cfg(madsim)]
1914 fn capture_madsim_memory_lifecycle_summary(seed: u64) -> MadsimMemoryLifecycleCapture {
1915 get_atomic_clock_static().set_time(UnixNanos::from(10_000));
1916
1917 let tmp = TempDir::new().expect("tempdir");
1918 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1919 let opener_memory = Arc::clone(&memory);
1920 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1921 let instance_id = UUID4::new();
1922 let mut config = make_config(tmp.path().to_path_buf());
1923 config.identity.seed = Some(seed);
1924 let options = EventStoreLifecycleOptions::new()
1925 .with_encoder_registry(test_registry())
1926 .with_backend_opener(move |_, manifest| {
1927 opener_memory.lock().open_run(manifest.clone())?;
1928 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1929 });
1930
1931 let mut store =
1932 EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
1933 .expect("boot store");
1934 store
1935 .open(
1936 instance_id,
1937 &RegisteredComponents::default(),
1938 Environment::Backtest,
1939 )
1940 .expect("open run");
1941
1942 let topic: MStr<msgbus::Topic> = MStr::from("events.test.madsim");
1943 get_atomic_clock_static().set_time(UnixNanos::from(20_000));
1944 msgbus::publish_any(topic, &TestAuditMessage { value: 1 });
1945 get_atomic_clock_static().set_time(UnixNanos::from(30_000));
1946 msgbus::publish_any(topic, &TestAuditMessage { value: 2 });
1947 assert_eq!(
1948 store
1949 .session
1950 .as_ref()
1951 .expect("open session")
1952 .high_watermark(),
1953 3
1954 );
1955
1956 get_atomic_clock_static().set_time(UnixNanos::from(40_000));
1957 store.seal(UnixNanos::from(40_000));
1958
1959 let backend = memory.lock();
1960 let manifest = backend.manifest().expect("manifest");
1961 assert_eq!(manifest.seed, Some(seed));
1962 assert_eq!(manifest.status, RunStatus::Ended);
1963 assert_eq!(manifest.high_watermark, 4);
1964 let entries = backend
1965 .scan_range(1, manifest.high_watermark, ScanDirection::Forward)
1966 .expect("scan entries")
1967 .into_iter()
1968 .map(|entry| CapturedEntrySummary {
1969 seq: entry.seq,
1970 topic: entry.topic.as_ref().to_string(),
1971 payload_type: entry.payload_type.as_str().to_string(),
1972 payload: entry.payload.to_vec(),
1973 ts_init: entry.ts_init,
1974 ts_publish: entry.ts_publish,
1975 })
1976 .collect();
1977 drop(backend);
1978
1979 MadsimMemoryLifecycleCapture {
1980 entries,
1981 redb_files: redb_files_under(tmp.path()),
1982 }
1983 }
1984
1985 #[cfg(madsim)]
1986 fn redb_files_under(dir: &Path) -> Vec<PathBuf> {
1987 let mut paths = Vec::new();
1988 collect_redb_files(dir, &mut paths);
1989 paths.sort();
1990 paths
1991 }
1992
1993 #[cfg(madsim)]
1994 fn collect_redb_files(dir: &Path, paths: &mut Vec<PathBuf>) {
1995 for entry in std::fs::read_dir(dir).expect("read dir") {
1996 let path = entry.expect("dir entry").path();
1997 if path.is_dir() {
1998 collect_redb_files(&path, paths);
1999 } else if path
2000 .extension()
2001 .is_some_and(|extension| extension == "redb")
2002 {
2003 paths.push(path);
2004 }
2005 }
2006 }
2007
2008 #[rstest]
2009 fn close_seals_manifest_and_records_run_ended() {
2010 let tmp = TempDir::new().expect("tempdir");
2011 let config = make_config(tmp.path().to_path_buf());
2012
2013 let halt = HaltSignal::new();
2014 let mut session = open_run(
2015 &config,
2016 INSTANCE_ID,
2017 build_run_id(UnixNanos::from(2_000)),
2018 None,
2019 UnixNanos::from(2_000),
2020 &RegisteredComponents::default(),
2021 halt,
2022 get_atomic_clock_static(),
2023 )
2024 .expect("open run");
2025
2026 let run_id = session.run_id().to_string();
2027 session.close(UnixNanos::from(3_000)).expect("close");
2028
2029 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2030 let manifest = manifests
2031 .into_iter()
2032 .find(|m| m.run_id == run_id)
2033 .expect("manifest present");
2034 assert_eq!(manifest.status, RunStatus::Ended);
2035 assert!(manifest.high_watermark >= 2);
2036 }
2037
2038 #[rstest]
2039 fn snapshot_anchorer_persists_anchor_for_open_session() {
2040 let tmp = TempDir::new().expect("tempdir");
2041 let config = make_config(tmp.path().to_path_buf());
2042
2043 let halt = HaltSignal::new();
2044 let mut session = open_run(
2045 &config,
2046 INSTANCE_ID,
2047 build_run_id(UnixNanos::from(4_000)),
2048 None,
2049 UnixNanos::from(4_000),
2050 &RegisteredComponents::default(),
2051 halt,
2052 get_atomic_clock_static(),
2053 )
2054 .expect("open run");
2055
2056 let run_id = session.run_id().to_string();
2057
2058 {
2059 let anchorer = session.snapshot_anchorer().expect("snapshot anchorer");
2060 anchorer(CacheSnapshotRef::new(
2061 "cache://position-snapshots/P-1/0",
2062 Bytes::from_static(b"snapshot"),
2063 ))
2064 .expect("record snapshot anchor");
2065 }
2066
2067 session.close(UnixNanos::from(4_500)).expect("close");
2068
2069 let reader =
2070 RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &run_id).expect("open sealed");
2071 let anchor = reader
2072 .latest_snapshot_anchor()
2073 .expect("latest snapshot anchor")
2074 .expect("anchor present");
2075
2076 assert_eq!(anchor.high_watermark, 1);
2077 assert_eq!(anchor.blob_ref, "cache://position-snapshots/P-1/0");
2078 assert_eq!(
2079 anchor.content_hash,
2080 compute_snapshot_content_hash(b"snapshot"),
2081 );
2082 }
2083
2084 #[rstest]
2085 fn recovery_seals_tail_ending_in_run_ended_as_ended_not_crashed() {
2086 let tmp = TempDir::new().expect("tempdir");
2095 let config = make_config(tmp.path().to_path_buf());
2096
2097 let halt = HaltSignal::new();
2098 let run_id = build_run_id(UnixNanos::from(7_000));
2099 let session = open_run(
2100 &config,
2101 INSTANCE_ID,
2102 run_id.clone(),
2103 None,
2104 UnixNanos::from(7_000),
2105 &RegisteredComponents::default(),
2106 halt,
2107 get_atomic_clock_static(),
2108 )
2109 .expect("open run");
2110
2111 let writer = session.writer.as_ref().expect("writer attached");
2112 writer
2113 .submit(run_ended_draft(UnixNanos::from(7_500)))
2114 .expect("submit RunEnded as tail entry");
2115 let deadline = Instant::now() + Duration::from_secs(2);
2119
2120 while session.high_watermark() < 2 {
2121 assert!(
2122 Instant::now() < deadline,
2123 "writer high_watermark stuck at {} before deadline",
2124 session.high_watermark(),
2125 );
2126 thread::sleep(Duration::from_millis(2));
2127 }
2128 drop(session);
2129
2130 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2131 assert_eq!(outcome.recovered.len(), 1);
2132 assert_eq!(outcome.recovered[0].run_id, run_id);
2133 assert_eq!(
2134 outcome.recovered[0].status,
2135 RunStatus::Ended,
2136 "tail ending in RunEnded must seal as Ended",
2137 );
2138 assert!(
2139 outcome.parent_run_id.is_none(),
2140 "Ended runs must not become parents",
2141 );
2142
2143 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2144 let manifest = manifests
2145 .into_iter()
2146 .find(|m| m.run_id == run_id)
2147 .expect("manifest present");
2148 assert_eq!(manifest.status, RunStatus::Ended);
2149 }
2150
2151 #[rstest]
2152 fn recovery_quarantines_run_with_rows_swapped_between_keys() {
2153 let tmp = TempDir::new().expect("tempdir");
2156 let config = make_config(tmp.path().to_path_buf());
2157 let run_id = "1700000000-swapped1";
2158
2159 let path = {
2160 let mut backend = RedbBackend::new(config.base_dir.clone());
2161 backend.open_run(manifest_for(run_id)).expect("open run");
2162 backend
2163 .append_batch(&[
2164 append_entry(
2165 1,
2166 "events.order.1",
2167 "OrderAccepted",
2168 Bytes::from_static(b"\x01"),
2169 ),
2170 append_entry(
2171 2,
2172 "events.order.2",
2173 "OrderFilled",
2174 Bytes::from_static(b"\x02"),
2175 ),
2176 ])
2177 .expect("append");
2178 config
2179 .base_dir
2180 .join(INSTANCE_ID)
2181 .join(format!("{run_id}.redb"))
2182 };
2183
2184 {
2185 let entries: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("entries");
2186 let db = redb::Database::create(&path).expect("open redb");
2187 let txn = db.begin_write().expect("begin write");
2188 {
2189 let mut table = txn.open_table(entries).expect("open table");
2190 let bytes_1 = table
2191 .remove(1_u64)
2192 .expect("remove seq 1")
2193 .expect("seq 1 present")
2194 .value()
2195 .to_vec();
2196 let bytes_2 = table
2197 .remove(2_u64)
2198 .expect("remove seq 2")
2199 .expect("seq 2 present")
2200 .value()
2201 .to_vec();
2202 table
2203 .insert(1_u64, bytes_2.as_slice())
2204 .expect("insert under key 1");
2205 table
2206 .insert(2_u64, bytes_1.as_slice())
2207 .expect("insert under key 2");
2208 }
2209 txn.commit().expect("commit swap");
2210 }
2211
2212 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2213
2214 assert_eq!(outcome.recovered.len(), 1);
2215 assert_eq!(outcome.recovered[0].run_id, run_id);
2216 assert_eq!(outcome.recovered[0].status, RunStatus::Quarantined);
2217 assert!(
2218 outcome.parent_run_id.is_none(),
2219 "quarantined runs must not become parents",
2220 );
2221
2222 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2223 assert_eq!(manifests[0].status, RunStatus::Quarantined);
2224 }
2225
2226 #[rstest]
2227 fn drop_without_close_leaves_run_for_next_boot_to_recover() {
2228 let tmp = TempDir::new().expect("tempdir");
2229 let config = make_config(tmp.path().to_path_buf());
2230
2231 let halt = HaltSignal::new();
2232 let session = open_run(
2233 &config,
2234 INSTANCE_ID,
2235 build_run_id(UnixNanos::from(4_000)),
2236 None,
2237 UnixNanos::from(4_000),
2238 &RegisteredComponents::default(),
2239 halt,
2240 get_atomic_clock_static(),
2241 )
2242 .expect("open run");
2243 let run_id = session.run_id().to_string();
2244 drop(session);
2245
2246 let outcome =
2247 recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover after crash");
2248 assert_eq!(outcome.recovered.len(), 1);
2249 assert_eq!(outcome.recovered[0].run_id, run_id);
2250 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2251 assert_eq!(outcome.parent_run_id.as_deref(), Some(run_id.as_str()));
2252
2253 let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2254 let sealed = manifests
2255 .into_iter()
2256 .find(|m| m.run_id == run_id)
2257 .expect("present");
2258 assert_eq!(sealed.status, RunStatus::CrashedRecovered);
2259 }
2260
2261 #[rstest]
2262 fn build_run_id_uses_expected_format() {
2263 let id = build_run_id(UnixNanos::from(123_456));
2267 let (prefix, suffix) = id.split_once('-').expect("run id must contain a hyphen");
2268 assert_eq!(prefix, "123456");
2269 assert_eq!(suffix.len(), 8, "suffix was {suffix:?}");
2270 assert!(
2271 suffix.chars().all(|c| c.is_ascii_hexdigit()),
2272 "suffix must be hex, was {suffix:?}",
2273 );
2274 }
2275
2276 #[rstest]
2277 fn crash_recovery_seals_predecessor_and_links_parent_run_id() {
2278 let tmp = TempDir::new().expect("tempdir");
2282 let config = make_config(tmp.path().to_path_buf());
2283
2284 let halt_first = HaltSignal::new();
2286 let first = open_run(
2287 &config,
2288 INSTANCE_ID,
2289 build_run_id(UnixNanos::from(10_000)),
2290 None,
2291 UnixNanos::from(10_000),
2292 &RegisteredComponents::default(),
2293 halt_first,
2294 get_atomic_clock_static(),
2295 )
2296 .expect("open first run");
2297 let crashed_run_id = first.run_id().to_string();
2298 drop(first);
2299
2300 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2302 assert_eq!(outcome.recovered.len(), 1);
2303 assert_eq!(outcome.recovered[0].run_id, crashed_run_id);
2304 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2305 assert_eq!(
2306 outcome.parent_run_id.as_deref(),
2307 Some(crashed_run_id.as_str())
2308 );
2309
2310 let manifests_after_seal =
2312 RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2313 let predecessor = manifests_after_seal
2314 .iter()
2315 .find(|m| m.run_id == crashed_run_id)
2316 .expect("predecessor present");
2317 assert_eq!(predecessor.status, RunStatus::CrashedRecovered);
2318
2319 let halt_second = HaltSignal::new();
2321 let new_run_id = build_run_id(UnixNanos::from(20_000));
2322 let next = open_run(
2323 &config,
2324 INSTANCE_ID,
2325 new_run_id.clone(),
2326 outcome.parent_run_id,
2327 UnixNanos::from(20_000),
2328 &RegisteredComponents::default(),
2329 halt_second,
2330 get_atomic_clock_static(),
2331 )
2332 .expect("open second run");
2333 assert_eq!(next.parent_run_id(), Some(crashed_run_id.as_str()));
2334 assert_eq!(
2335 next.manifest().parent_run_id.as_deref(),
2336 Some(crashed_run_id.as_str()),
2337 );
2338 assert_eq!(next.high_watermark(), 1, "RunStarted is the first entry");
2339
2340 drop(next);
2343 let outcome_after = recover_predecessors(&config.base_dir, INSTANCE_ID)
2344 .expect("recover after second crash");
2345 assert_eq!(outcome_after.recovered.len(), 1);
2347 assert_eq!(outcome_after.recovered[0].run_id, new_run_id);
2348 assert_eq!(
2349 outcome_after.recovered[0].status,
2350 RunStatus::CrashedRecovered,
2351 );
2352
2353 let sealed = RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &new_run_id)
2355 .expect("open sealed");
2356 let first_entry = sealed
2357 .scan_seq(1)
2358 .expect("scan")
2359 .expect("RunStarted present");
2360 assert_eq!(first_entry.payload_type.as_str(), "RunStarted");
2361 assert_eq!(first_entry.topic.as_ref(), "run.lifecycle.RunStarted");
2362 }
2363
2364 #[rstest]
2365 #[case::before_enqueue(CrashPoint::BeforeEnqueue, 0, false)]
2366 #[case::after_enqueue_before_commit(CrashPoint::AfterEnqueueBeforeCommit, 0, false)]
2367 #[case::after_commit_before_snapshot(CrashPoint::AfterCommitBeforeSnapshot, 1, false)]
2368 #[case::after_snapshot(CrashPoint::AfterSnapshot, 1, true)]
2369 fn crash_recovery_matrix_seals_predecessor_and_links_parent_run_id(
2370 #[case] crash_point: CrashPoint,
2371 #[case] expected_hwm: u64,
2372 #[case] expect_snapshot_anchor: bool,
2373 ) {
2374 let tmp = TempDir::new().expect("tempdir");
2375 let config = make_config(tmp.path().to_path_buf());
2376 let predecessor_run_id = format!("3000-{crash_point:?}");
2377 seed_crashed_predecessor(&config, &predecessor_run_id, crash_point);
2378
2379 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2380 assert_eq!(outcome.recovered.len(), 1);
2381 assert_eq!(outcome.recovered[0].run_id, predecessor_run_id);
2382 assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2383 assert_eq!(
2384 outcome.parent_run_id.as_deref(),
2385 Some(predecessor_run_id.as_str()),
2386 );
2387
2388 let predecessor =
2389 RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &predecessor_run_id)
2390 .expect("open sealed predecessor");
2391 let manifest = predecessor.manifest().expect("manifest");
2392 let snapshot_anchor = predecessor.latest_snapshot_anchor().expect("anchor read");
2393
2394 assert_eq!(manifest.status, RunStatus::CrashedRecovered);
2395 assert_eq!(manifest.high_watermark, expected_hwm);
2396 assert_eq!(
2397 snapshot_anchor.is_some(),
2398 expect_snapshot_anchor,
2399 "snapshot anchor presence must match crash point",
2400 );
2401
2402 let next = open_run(
2403 &config,
2404 INSTANCE_ID,
2405 "4000-next".to_string(),
2406 outcome.parent_run_id,
2407 UnixNanos::from(4_000),
2408 &RegisteredComponents::default(),
2409 HaltSignal::new(),
2410 get_atomic_clock_static(),
2411 )
2412 .expect("open next run");
2413 assert_eq!(next.parent_run_id(), Some(predecessor_run_id.as_str()));
2414 assert_eq!(
2415 next.manifest().parent_run_id.as_deref(),
2416 Some(predecessor_run_id.as_str()),
2417 );
2418 }
2419
2420 #[rstest]
2421 fn kernel_event_store_open_seals_leftover_session_before_reopen() {
2422 let tmp = TempDir::new().expect("tempdir");
2427 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
2428 let instance_id = UUID4::new();
2429
2430 let mut store = EventStoreLifecycle::boot(
2431 Some(make_config(tmp.path().to_path_buf())),
2432 instance_id,
2433 clock_rc,
2434 )
2435 .expect("boot store");
2436
2437 store
2438 .open(
2439 instance_id,
2440 &RegisteredComponents::default(),
2441 Environment::Backtest,
2442 )
2443 .expect("open first run");
2444 let run_one = store.run_id().expect("run one open").to_string();
2445
2446 store
2447 .open(
2448 instance_id,
2449 &RegisteredComponents::default(),
2450 Environment::Backtest,
2451 )
2452 .expect("open second run");
2453 let run_two = store.run_id().expect("run two open").to_string();
2454
2455 assert_ne!(run_one, run_two, "second open must produce a fresh run id");
2456
2457 drop(store);
2460 let manifests =
2461 RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2462 let m1 = manifests
2463 .iter()
2464 .find(|m| m.run_id == run_one)
2465 .expect("first run present");
2466 let m2 = manifests
2467 .iter()
2468 .find(|m| m.run_id == run_two)
2469 .expect("second run present");
2470 assert_eq!(m1.status, RunStatus::Ended);
2471 assert_eq!(m2.status, RunStatus::Ended);
2472 }
2473
2474 #[rstest]
2475 fn open_after_halt_re_arms_signal_and_next_run_seals_ended() {
2476 let tmp = TempDir::new().expect("tempdir");
2480 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
2481 let instance_id = UUID4::new();
2482
2483 let mut store = EventStoreLifecycle::boot(
2484 Some(make_config(tmp.path().to_path_buf())),
2485 instance_id,
2486 clock_rc,
2487 )
2488 .expect("boot store");
2489
2490 store
2491 .open(
2492 instance_id,
2493 &RegisteredComponents::default(),
2494 Environment::Backtest,
2495 )
2496 .expect("open first run");
2497 let run_one = store.run_id().expect("run one open").to_string();
2498
2499 store.halt.callback()(HaltReason::BackendDisk("ENOSPC".to_string()));
2500 assert!(store.halt.is_halted());
2501
2502 store
2503 .open(
2504 instance_id,
2505 &RegisteredComponents::default(),
2506 Environment::Backtest,
2507 )
2508 .expect("open second run after halt");
2509 let run_two = store.run_id().expect("run two open").to_string();
2510
2511 assert_ne!(run_one, run_two);
2512 assert!(!store.halt.is_halted(), "open must re-arm the halt signal");
2513 assert!(store.halt.reason().is_none());
2514
2515 drop(store);
2516 let manifests =
2517 RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2518 let m1 = manifests
2519 .iter()
2520 .find(|m| m.run_id == run_one)
2521 .expect("first run present");
2522 let m2 = manifests
2523 .iter()
2524 .find(|m| m.run_id == run_two)
2525 .expect("second run present");
2526 assert_eq!(m1.status, RunStatus::Running);
2529 assert_eq!(m2.status, RunStatus::Ended);
2530 }
2531
2532 #[rstest]
2533 fn recover_picks_most_recent_crashed_recovered_as_parent() {
2534 let tmp = TempDir::new().expect("tempdir");
2537 let config = make_config(tmp.path().to_path_buf());
2538
2539 for ts in [1_000_u64, 2_000_u64, 3_000_u64] {
2540 let session = open_run(
2541 &config,
2542 INSTANCE_ID,
2543 build_run_id(UnixNanos::from(ts)),
2544 None,
2545 UnixNanos::from(ts),
2546 &RegisteredComponents::default(),
2547 HaltSignal::new(),
2548 get_atomic_clock_static(),
2549 )
2550 .expect("open");
2551 drop(session);
2552 }
2553
2554 let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2555 assert_eq!(outcome.recovered.len(), 3);
2556 assert!(
2557 outcome
2558 .recovered
2559 .iter()
2560 .all(|r| r.status == RunStatus::CrashedRecovered),
2561 "every predecessor must seal CrashedRecovered, was {:?}",
2562 outcome.recovered,
2563 );
2564 let parent = outcome.parent_run_id.as_deref().expect("parent set");
2566 assert!(
2567 parent.starts_with("3000-"),
2568 "parent must be the run with the highest start_ts_init, was {parent}",
2569 );
2570 }
2571
2572 #[cfg(not(madsim))]
2573 #[rstest]
2574 fn submit_run_started_returns_timeout_when_writer_stalls() {
2575 let stub = StallBackend::default();
2579 let manifest = manifest_for("run-timeout");
2580 let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2581 backend.open_run(manifest).expect("open stub");
2582
2583 let halt = HaltSignal::new();
2584
2585 let writer = EventStoreWriter::spawn(
2586 backend,
2587 get_atomic_clock_static(),
2588 halt.callback(),
2589 WriterConfig::default(),
2590 )
2591 .expect("spawn writer");
2592
2593 let err = submit_run_started_blocking(
2594 &writer,
2595 &RegisteredComponents::default(),
2596 UnixNanos::from(100),
2597 &halt,
2598 Duration::from_millis(20),
2599 )
2600 .expect_err("must time out");
2601
2602 match err {
2603 BootError::RunStartedTimeout { timeout } => {
2604 assert_eq!(timeout, Duration::from_millis(20));
2605 }
2606 other => panic!("expected RunStartedTimeout, was {other:?}"),
2607 }
2608
2609 stub.release();
2611 }
2612
2613 #[cfg(not(madsim))]
2614 #[rstest]
2615 fn submit_run_started_returns_halted_when_writer_halts_during_wait() {
2616 let stub = StallBackend::default();
2619 let manifest = manifest_for("run-halt");
2620 let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2621 backend.open_run(manifest).expect("open stub");
2622
2623 let halt = HaltSignal::new();
2624
2625 let writer = EventStoreWriter::spawn(
2626 backend,
2627 get_atomic_clock_static(),
2628 halt.callback(),
2629 WriterConfig::default(),
2630 )
2631 .expect("spawn writer");
2632
2633 let halt_for_thread = halt.clone();
2635
2636 let firer = thread::spawn(move || {
2637 thread::sleep(Duration::from_millis(10));
2638 halt_for_thread.callback()(HaltReason::BackendDisk("test stall".to_string()));
2639 });
2640
2641 let err = submit_run_started_blocking(
2642 &writer,
2643 &RegisteredComponents::default(),
2644 UnixNanos::from(200),
2645 &halt,
2646 Duration::from_secs(2),
2647 )
2648 .expect_err("must observe halt");
2649
2650 match err {
2651 BootError::HaltedDuringBoot(HaltReason::BackendDisk(msg)) => {
2652 assert!(msg.contains("test stall"), "reason msg was {msg}");
2653 }
2654 other => panic!("expected HaltedDuringBoot(BackendDisk), was {other:?}"),
2655 }
2656 firer.join().expect("halt firer joined");
2657 stub.release();
2658 }
2659
2660 fn manifest_for(run_id: &str) -> RunManifest {
2661 RunManifest {
2662 run_id: run_id.to_string(),
2663 parent_run_id: None,
2664 instance_id: INSTANCE_ID.to_string(),
2665 binary_hash: String::new(),
2666 schema_version: 1,
2667 crate_versions: String::new(),
2668 feature_flags: Vec::new(),
2669 adapter_versions: IndexMap::new(),
2670 config_hash: String::new(),
2671 registered_components: RegisteredComponents::default(),
2672 seed: None,
2673 start_ts_init: UnixNanos::default(),
2674 end_ts_init: None,
2675 high_watermark: 0,
2676 status: RunStatus::Running,
2677 }
2678 }
2679
2680 #[cfg(not(madsim))]
2684 #[derive(Debug, Default, Clone)]
2685 struct StallBackend {
2686 inner: Arc<Mutex<StallInner>>,
2687 gate: Arc<(Mutex<bool>, parking_lot::Condvar)>,
2688 }
2689
2690 #[cfg(not(madsim))]
2691 #[derive(Debug, Default)]
2692 struct StallInner {
2693 manifest: Option<RunManifest>,
2694 }
2695
2696 #[cfg(not(madsim))]
2697 impl StallBackend {
2698 fn release(&self) {
2699 let (lock, cvar) = &*self.gate;
2700 *lock.lock() = true;
2701 cvar.notify_all();
2702 }
2703 }
2704
2705 #[cfg(not(madsim))]
2706 impl crate::EventStore for StallBackend {
2707 fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
2708 self.inner.lock().manifest = Some(manifest);
2709 Ok(())
2710 }
2711
2712 fn append_batch(&mut self, _: &[crate::AppendEntry]) -> Result<u64, EventStoreError> {
2713 let (lock, cvar) = &*self.gate;
2714 let mut released = lock.lock();
2715
2716 while !*released {
2717 cvar.wait(&mut released);
2718 }
2719 Ok(0)
2720 }
2721
2722 fn scan_range(
2723 &self,
2724 _: u64,
2725 _: u64,
2726 _: crate::ScanDirection,
2727 ) -> Result<Vec<crate::EventStoreEntry>, EventStoreError> {
2728 Ok(Vec::new())
2729 }
2730
2731 fn scan_seq(&self, _: u64) -> Result<Option<crate::EventStoreEntry>, EventStoreError> {
2732 Ok(None)
2733 }
2734
2735 fn lookup(&self, _: crate::IndexKind, _: &str) -> Result<Option<u64>, EventStoreError> {
2736 Ok(None)
2737 }
2738
2739 fn iter_index_keys(
2740 &self,
2741 _: crate::IndexKind,
2742 ) -> Result<Vec<(String, u64)>, EventStoreError> {
2743 Ok(Vec::new())
2744 }
2745
2746 fn seal(&mut self, _: RunStatus) -> Result<(), EventStoreError> {
2747 Ok(())
2748 }
2749
2750 fn manifest(&self) -> Result<RunManifest, EventStoreError> {
2751 self.inner
2752 .lock()
2753 .manifest
2754 .clone()
2755 .ok_or_else(|| EventStoreError::Backend("no manifest".to_string()))
2756 }
2757
2758 fn high_watermark(&self) -> Result<u64, EventStoreError> {
2759 Ok(0)
2760 }
2761 }
2762
2763 #[rstest]
2768 fn bus_tap_captures_submit_order_sent_through_msgbus() {
2769 let tmp = TempDir::new().expect("tempdir");
2770 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
2771 let instance_id = UUID4::new();
2772
2773 let mut store = EventStoreLifecycle::boot(
2774 Some(make_config(tmp.path().to_path_buf())),
2775 instance_id,
2776 clock_rc,
2777 )
2778 .expect("boot store");
2779 store
2780 .open(
2781 instance_id,
2782 &RegisteredComponents::default(),
2783 Environment::Backtest,
2784 )
2785 .expect("open run");
2786 let run_id = store.run_id().expect("run open").to_string();
2787
2788 let trader_id = TraderId::from("TRADER-001");
2789 let strategy_id = StrategyId::from("S-001");
2790 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
2791 let client_order_id = ClientOrderId::from("O-20260510-000001");
2792 let order_init = OrderInitializedSpec::builder()
2793 .instrument_id(instrument_id)
2794 .client_order_id(client_order_id)
2795 .quantity(Quantity::from("1"))
2796 .time_in_force(TimeInForce::Gtc)
2797 .build();
2798 let submit_order = SubmitOrder::new(
2799 trader_id,
2800 Some(ClientId::from("BINANCE")),
2801 strategy_id,
2802 instrument_id,
2803 client_order_id,
2804 order_init,
2805 None,
2806 None,
2807 None,
2808 UUID4::new(),
2809 UnixNanos::from(3),
2810 None, );
2812
2813 let endpoint = MStr::<Endpoint>::from("test.exec.engine.process");
2814 msgbus::send_any_value(endpoint, &submit_order);
2815
2816 store.seal(UnixNanos::from(0));
2817
2818 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
2819 .expect("open sealed");
2820 let captured = sealed
2821 .scan_seq(2)
2822 .expect("scan")
2823 .expect("captured entry present");
2824 assert_eq!(captured.payload_type.as_str(), "SubmitOrder");
2825 assert_eq!(captured.topic.as_ref(), endpoint.as_str());
2826
2827 let by_client = sealed
2830 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
2831 .expect("lookup")
2832 .expect("indexed");
2833 assert_eq!(by_client, 2);
2834 }
2835
2836 #[rstest]
2837 fn kernel_with_markers_captures_snapshots_over_synthetic_bus() {
2838 let tmp = TempDir::new().expect("tempdir");
2839 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
2840 let instance_id = UUID4::new();
2841 let mut config = make_config(tmp.path().to_path_buf());
2842 config.data_markers = Some(DataMarkerConfig {
2843 classes: vec![DataMarkerClass::BookDeltas, DataMarkerClass::Quote],
2844 safety_flush_interval: Duration::from_secs(1),
2845 channel_capacity: 128,
2846 high_fidelity: Vec::new(),
2847 });
2848
2849 let mut store =
2850 EventStoreLifecycle::boot(Some(config), instance_id, clock_rc).expect("boot store");
2851 store
2852 .open(
2853 instance_id,
2854 &RegisteredComponents::default(),
2855 Environment::Backtest,
2856 )
2857 .expect("open run");
2858 let run_id = store.run_id().expect("run open").to_string();
2859
2860 let first = make_submit_order(ClientOrderId::from("O-marker-1"));
2861 msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &first);
2862
2863 let quote = quote_ethusdt_binance();
2864 msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), "e);
2865 let deltas = stub_deltas();
2866 msgbus::publish_deltas(MStr::from("data.deltas.XNAS.AAPL"), &deltas);
2867
2868 let second = make_submit_order(ClientOrderId::from("O-marker-2"));
2869 msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &second);
2870 store.seal(UnixNanos::from(500));
2871
2872 let marker_path = tmp
2873 .path()
2874 .join(instance_id.to_string())
2875 .join(format!("{run_id}.markers.redb"));
2876 let marker = RedbMarkerBackend::open_read_only_file(marker_path).expect("open markers");
2877 let snapshots = marker.scan_snapshots().expect("scan snapshots");
2878 let dict = marker.scan_dict().expect("scan dict");
2879
2880 assert_eq!(snapshots.len(), 1);
2881 assert_eq!(snapshots[0].event_seq_before, 3);
2882 assert_eq!(snapshots[0].advanced.len(), 2);
2883 assert_eq!(
2884 snapshots[0]
2885 .advanced
2886 .iter()
2887 .map(|cursor| cursor.count)
2888 .collect::<Vec<_>>(),
2889 vec![1, 1]
2890 );
2891 assert_eq!(
2892 dict.iter()
2893 .map(|entry| (entry.data_cls, entry.identifier.as_str()))
2894 .collect::<Vec<_>>(),
2895 vec![
2896 (DataClass::Quote, "ETHUSDT-PERP.BINANCE"),
2897 (DataClass::BookDeltas, "AAPL.XNAS"),
2898 ],
2899 );
2900 }
2901
2902 #[rstest]
2903 fn boot_recovery_ignores_marker_sidecar_files() {
2904 let tmp = TempDir::new().expect("tempdir");
2905 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
2906 let instance_id = UUID4::new();
2907 let mut config = make_config(tmp.path().to_path_buf());
2908 config.data_markers = Some(DataMarkerConfig {
2909 classes: vec![DataMarkerClass::Quote],
2910 safety_flush_interval: Duration::from_secs(1),
2911 channel_capacity: 128,
2912 high_fidelity: Vec::new(),
2913 });
2914
2915 let mut store =
2916 EventStoreLifecycle::boot(Some(config.clone()), instance_id, Rc::clone(&clock_rc))
2917 .expect("boot store");
2918 store
2919 .open(
2920 instance_id,
2921 &RegisteredComponents::default(),
2922 Environment::Backtest,
2923 )
2924 .expect("open run");
2925 let run_id = store.run_id().expect("run open").to_string();
2926 store.seal(UnixNanos::from(500));
2927
2928 let marker_path = tmp
2929 .path()
2930 .join(instance_id.to_string())
2931 .join(format!("{run_id}.markers.redb"));
2932 assert!(marker_path.exists());
2933
2934 let rebooted = EventStoreLifecycle::boot(Some(config), instance_id, clock_rc)
2935 .expect("boot after marker sidecar");
2936
2937 assert!(rebooted.recovered().is_empty());
2938 }
2939
2940 #[rstest]
2941 fn marker_setup_failure_disables_markers_without_blocking_open() {
2942 let tmp = TempDir::new().expect("tempdir");
2943 let bad_base = tmp.path().join("not-a-directory");
2944 std::fs::write(&bad_base, b"not a directory").expect("write bad base");
2945 let memory = Arc::new(Mutex::new(MemoryBackend::new()));
2946 let opener_memory = Arc::clone(&memory);
2947 let mut config = make_config(bad_base);
2948 config.data_markers = Some(DataMarkerConfig {
2949 classes: vec![DataMarkerClass::Quote],
2950 safety_flush_interval: Duration::from_secs(1),
2951 channel_capacity: 128,
2952 high_fidelity: Vec::new(),
2953 });
2954 let options = EventStoreLifecycleOptions::new()
2955 .with_encoder_registry(test_registry())
2956 .with_backend_opener(move |_, manifest| {
2957 opener_memory.lock().open_run(manifest.clone())?;
2958 Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
2959 });
2960
2961 let mut session = open_run_with_options(
2962 &config,
2963 INSTANCE_ID,
2964 "run-marker-setup-fails".to_string(),
2965 None,
2966 UnixNanos::from(5_000),
2967 &RegisteredComponents::default(),
2968 HaltSignal::new(),
2969 get_atomic_clock_static(),
2970 &options,
2971 )
2972 .expect("open run despite marker failure");
2973
2974 assert!(session.marker_capture.is_none());
2975
2976 let topic: MStr<msgbus::Topic> = MStr::from("events.test.marker-fallback");
2977 session
2978 .adapter()
2979 .expect("adapter")
2980 .capture::<TestAuditMessage>(
2981 topic,
2982 &TestAuditMessage { value: 11 },
2983 Headers::empty(),
2984 UnixNanos::from(5_001),
2985 )
2986 .expect("capture");
2987 session
2988 .close(UnixNanos::from(6_000))
2989 .expect("close session");
2990
2991 let backend = memory.lock();
2992 let captured = backend
2993 .scan_seq(2)
2994 .expect("scan")
2995 .expect("captured entry present");
2996
2997 assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
2998 assert_eq!(captured.topic.as_ref(), topic.as_str());
2999 assert_eq!(captured.payload.as_ref(), &[11]);
3000 }
3001
3002 #[rstest]
3003 fn marker_registry_factory_receives_enabled_classes() {
3004 let tmp = TempDir::new().expect("tempdir");
3005 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3006 let instance_id = UUID4::new();
3007 let seen_classes: Arc<Mutex<Vec<Vec<DataClass>>>> = Arc::new(Mutex::new(Vec::new()));
3008 let seen_for_factory = Arc::clone(&seen_classes);
3009 let mut config = make_config(tmp.path().to_path_buf());
3010 config.data_markers = Some(DataMarkerConfig {
3011 classes: vec![DataMarkerClass::Trade, DataMarkerClass::Quote],
3012 safety_flush_interval: Duration::from_secs(1),
3013 channel_capacity: 128,
3014 high_fidelity: Vec::new(),
3015 });
3016 let options =
3017 EventStoreLifecycleOptions::new().with_marker_registry_factory(move |classes| {
3018 seen_for_factory.lock().push(classes.to_vec());
3019 DataMarkerExtractorRegistry::default_registry(classes)
3020 });
3021
3022 let mut store =
3023 EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
3024 .expect("boot store");
3025 store
3026 .open(
3027 instance_id,
3028 &RegisteredComponents::default(),
3029 Environment::Backtest,
3030 )
3031 .expect("open run");
3032 store.seal(UnixNanos::from(1_000));
3033
3034 let seen = seen_classes.lock();
3035 assert_eq!(seen.as_slice(), &[vec![DataClass::Trade, DataClass::Quote]]);
3036 }
3037
3038 #[rstest]
3039 fn markers_disabled_installs_no_file_and_no_cost() {
3040 let tmp = TempDir::new().expect("tempdir");
3041 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3042 let instance_id = UUID4::new();
3043
3044 let mut store = EventStoreLifecycle::boot(
3045 Some(make_config(tmp.path().to_path_buf())),
3046 instance_id,
3047 clock_rc,
3048 )
3049 .expect("boot store");
3050 store
3051 .open(
3052 instance_id,
3053 &RegisteredComponents::default(),
3054 Environment::Backtest,
3055 )
3056 .expect("open run");
3057 let run_id = store.run_id().expect("run open").to_string();
3058
3059 assert!(
3060 store
3061 .session
3062 .as_ref()
3063 .expect("session")
3064 .marker_capture
3065 .is_none()
3066 );
3067
3068 let quote = quote_ethusdt_binance();
3069 msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), "e);
3070 store.seal(UnixNanos::from(500));
3071
3072 let marker_path = tmp
3073 .path()
3074 .join(instance_id.to_string())
3075 .join(format!("{run_id}.markers.redb"));
3076 assert!(!marker_path.exists());
3077 }
3078
3079 #[rstest]
3083 fn bus_tap_captures_time_event_handler_run() {
3084 let tmp = TempDir::new().expect("tempdir");
3085 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3086 let instance_id = UUID4::new();
3087
3088 let mut store = EventStoreLifecycle::boot(
3089 Some(make_config(tmp.path().to_path_buf())),
3090 instance_id,
3091 clock_rc,
3092 )
3093 .expect("boot store");
3094 store
3095 .open(
3096 instance_id,
3097 &RegisteredComponents::default(),
3098 Environment::Backtest,
3099 )
3100 .expect("open run");
3101 let run_id = store.run_id().expect("run open").to_string();
3102
3103 let event = TimeEvent::new(
3104 Ustr::from("strategy.heartbeat"),
3105 UUID4::new(),
3106 UnixNanos::from(100),
3107 UnixNanos::from(99),
3108 );
3109 let callback = TimeEventCallback::from(|_: TimeEvent| {});
3110 TimeEventHandler::new(event, callback).run();
3111
3112 store.seal(UnixNanos::from(0));
3113
3114 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3115 .expect("open sealed");
3116 let captured = sealed
3117 .scan_seq(2)
3118 .expect("scan")
3119 .expect("captured entry present");
3120
3121 assert_eq!(captured.payload_type.as_str(), PAYLOAD_TYPE_TIME_EVENT);
3122 assert_eq!(captured.topic, MessagingSwitchboard::time_event_topic());
3123 }
3124
3125 #[rstest]
3130 fn seal_clears_bus_tap_so_post_seal_dispatches_do_not_capture() {
3131 let tmp = TempDir::new().expect("tempdir");
3132 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3133 let instance_id = UUID4::new();
3134
3135 let mut store = EventStoreLifecycle::boot(
3136 Some(make_config(tmp.path().to_path_buf())),
3137 instance_id,
3138 clock_rc,
3139 )
3140 .expect("boot store");
3141 store
3142 .open(
3143 instance_id,
3144 &RegisteredComponents::default(),
3145 Environment::Backtest,
3146 )
3147 .expect("open run");
3148 let run_id = store.run_id().expect("run open").to_string();
3149
3150 store.seal(UnixNanos::from(0));
3151
3152 let endpoint = MStr::<Endpoint>::from("test.post.seal.endpoint");
3157 let payload: u32 = 99;
3158 msgbus::send_any_value(endpoint, &payload);
3159
3160 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3161 .expect("open sealed");
3162 assert!(
3164 sealed.scan_seq(3).expect("scan").is_none(),
3165 "no entry must land after seal",
3166 );
3167 }
3168
3169 #[rstest]
3175 fn bus_tap_captures_trading_command_envelope_with_inner_payload_type() {
3176 let tmp = TempDir::new().expect("tempdir");
3177 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3178 let instance_id = UUID4::new();
3179
3180 let mut store = EventStoreLifecycle::boot(
3181 Some(make_config(tmp.path().to_path_buf())),
3182 instance_id,
3183 clock_rc,
3184 )
3185 .expect("boot store");
3186 store
3187 .open(
3188 instance_id,
3189 &RegisteredComponents::default(),
3190 Environment::Backtest,
3191 )
3192 .expect("open run");
3193 let run_id = store.run_id().expect("run open").to_string();
3194
3195 let trader_id = TraderId::from("TRADER-001");
3196 let strategy_id = StrategyId::from("S-001");
3197 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3198 let client_order_id = ClientOrderId::from("O-20260510-000002");
3199 let order_init = OrderInitializedSpec::builder()
3200 .instrument_id(instrument_id)
3201 .client_order_id(client_order_id)
3202 .quantity(Quantity::from("1"))
3203 .time_in_force(TimeInForce::Gtc)
3204 .build();
3205 let submit_order = SubmitOrder::new(
3206 trader_id,
3207 Some(ClientId::from("BINANCE")),
3208 strategy_id,
3209 instrument_id,
3210 client_order_id,
3211 order_init,
3212 None,
3213 None,
3214 None,
3215 UUID4::new(),
3216 UnixNanos::from(3),
3217 None, );
3219 let command = TradingCommand::SubmitOrder(submit_order.clone());
3220
3221 let endpoint = MStr::<Endpoint>::from("test.exec.engine.envelope");
3222 msgbus::send_trading_command(endpoint, command);
3223
3224 store.seal(UnixNanos::from(0));
3225
3226 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3227 .expect("open sealed");
3228 let captured = sealed
3229 .scan_seq(2)
3230 .expect("scan")
3231 .expect("captured entry present");
3232 assert_eq!(
3233 captured.payload_type.as_str(),
3234 "SubmitOrder",
3235 "wrapper envelope must stamp the inner payload_type",
3236 );
3237 assert_eq!(captured.topic.as_ref(), endpoint.as_str());
3238
3239 let by_client = sealed
3240 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3241 .expect("lookup")
3242 .expect("indexed");
3243 assert_eq!(by_client, 2);
3244
3245 let decoded: SubmitOrder =
3249 rmp_serde::from_slice(&captured.payload).expect("decode captured SubmitOrder");
3250 assert_eq!(decoded, submit_order);
3251 }
3252
3253 #[rstest]
3257 fn bus_tap_captures_order_event_any_envelope_with_inner_payload_type() {
3258 let tmp = TempDir::new().expect("tempdir");
3259 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3260 let instance_id = UUID4::new();
3261
3262 let mut store = EventStoreLifecycle::boot(
3263 Some(make_config(tmp.path().to_path_buf())),
3264 instance_id,
3265 clock_rc,
3266 )
3267 .expect("boot store");
3268 store
3269 .open(
3270 instance_id,
3271 &RegisteredComponents::default(),
3272 Environment::Backtest,
3273 )
3274 .expect("open run");
3275 let run_id = store.run_id().expect("run open").to_string();
3276
3277 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3278 let client_order_id = ClientOrderId::from("O-20260510-000003");
3279 let venue_order_id = VenueOrderId::from("V-99");
3280 let filled = OrderFilledSpec::builder()
3281 .instrument_id(instrument_id)
3282 .client_order_id(client_order_id)
3283 .venue_order_id(venue_order_id)
3284 .account_id(AccountId::from("BINANCE-001"))
3285 .trade_id(TradeId::from("T-1"))
3286 .last_qty(Quantity::from("1"))
3287 .last_px(Price::from("100.00"))
3288 .currency(Currency::USDT())
3289 .ts_event(UnixNanos::from(10))
3290 .ts_init(UnixNanos::from(11))
3291 .commission(Money::new(0.10, Currency::USDT()))
3292 .build();
3293 let event = OrderEventAny::Filled(filled.clone());
3294
3295 let topic: MStr<msgbus::Topic> = MStr::from("events.order.ETHUSDT-PERP.BINANCE");
3296 msgbus::publish_order_event(topic, &event);
3297
3298 store.seal(UnixNanos::from(0));
3299
3300 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3301 .expect("open sealed");
3302 let captured = sealed
3303 .scan_seq(2)
3304 .expect("scan")
3305 .expect("captured entry present");
3306 assert_eq!(
3307 captured.payload_type.as_str(),
3308 "OrderFilled",
3309 "wrapper envelope must stamp the inner payload_type",
3310 );
3311 assert_eq!(captured.topic.as_ref(), topic.as_str());
3312
3313 let by_client = sealed
3314 .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3315 .expect("lookup")
3316 .expect("indexed");
3317 let by_venue = sealed
3318 .lookup(IndexKind::VenueOrderId, venue_order_id.as_str())
3319 .expect("lookup")
3320 .expect("indexed");
3321 assert_eq!(by_client, 2);
3322 assert_eq!(by_venue, 2);
3323
3324 let decoded: OrderFilled =
3328 rmp_serde::from_slice(&captured.payload).expect("decode captured OrderFilled");
3329 assert_eq!(decoded, filled);
3330 }
3331
3332 #[rstest]
3337 fn bus_tap_captures_data_command_envelopes_with_category_payload_types() {
3338 let tmp = TempDir::new().expect("tempdir");
3339 let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
3340 let instance_id = UUID4::new();
3341
3342 let mut store = EventStoreLifecycle::boot(
3343 Some(make_config(tmp.path().to_path_buf())),
3344 instance_id,
3345 clock_rc,
3346 )
3347 .expect("boot store");
3348 store
3349 .open(
3350 instance_id,
3351 &RegisteredComponents::default(),
3352 Environment::Backtest,
3353 )
3354 .expect("open run");
3355 let run_id = store.run_id().expect("run open").to_string();
3356
3357 let request = RequestCommand::Quotes(RequestQuotes::new(
3358 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3359 None,
3360 None,
3361 None,
3362 Some(ClientId::from("BINANCE")),
3363 UUID4::new(),
3364 UnixNanos::from(20),
3365 None,
3366 ));
3367 let subscribe = SubscribeCommand::Quotes(SubscribeQuotes::new(
3368 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3369 Some(ClientId::from("BINANCE")),
3370 Some(Venue::from("BINANCE")),
3371 UUID4::new(),
3372 UnixNanos::from(21),
3373 Some(UUID4::new()),
3374 None,
3375 ));
3376
3377 let request_endpoint = MStr::<Endpoint>::from("test.data.engine.request");
3378 msgbus::send_data_command(request_endpoint, DataCommand::Request(request.clone()));
3379
3380 let subscribe_endpoint = MStr::<Endpoint>::from("test.data.engine.subscribe");
3381 msgbus::send_data_command(
3382 subscribe_endpoint,
3383 DataCommand::Subscribe(subscribe.clone()),
3384 );
3385
3386 store.seal(UnixNanos::from(0));
3387
3388 let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3389 .expect("open sealed");
3390 let captured_request = sealed
3391 .scan_seq(2)
3392 .expect("scan request")
3393 .expect("captured request present");
3394 assert_eq!(captured_request.payload_type.as_str(), "RequestCommand");
3395 assert_eq!(captured_request.topic.as_ref(), request_endpoint.as_str());
3396
3397 let decoded_request: RequestCommand =
3398 rmp_serde::from_slice(&captured_request.payload).expect("decode RequestCommand");
3399 match (decoded_request, request) {
3400 (RequestCommand::Quotes(decoded), RequestCommand::Quotes(expected)) => {
3401 assert_eq!(decoded.request_id, expected.request_id);
3402 assert_eq!(decoded.instrument_id, expected.instrument_id);
3403 assert_eq!(decoded.client_id, expected.client_id);
3404 assert_eq!(decoded.ts_init, expected.ts_init);
3405 }
3406 other => panic!("expected RequestCommand::Quotes round trip, was {other:?}"),
3407 }
3408
3409 let captured_subscribe = sealed
3410 .scan_seq(3)
3411 .expect("scan subscribe")
3412 .expect("captured subscribe present");
3413 assert_eq!(captured_subscribe.payload_type.as_str(), "SubscribeCommand");
3414 assert_eq!(
3415 captured_subscribe.topic.as_ref(),
3416 subscribe_endpoint.as_str()
3417 );
3418
3419 let decoded_subscribe: SubscribeCommand =
3420 rmp_serde::from_slice(&captured_subscribe.payload).expect("decode SubscribeCommand");
3421 match (decoded_subscribe, subscribe) {
3422 (SubscribeCommand::Quotes(decoded), SubscribeCommand::Quotes(expected)) => {
3423 assert_eq!(decoded.command_id, expected.command_id);
3424 assert_eq!(decoded.instrument_id, expected.instrument_id);
3425 assert_eq!(decoded.client_id, expected.client_id);
3426 assert_eq!(decoded.venue, expected.venue);
3427 assert_eq!(decoded.ts_init, expected.ts_init);
3428 assert_eq!(decoded.correlation_id, expected.correlation_id);
3429 }
3430 other => panic!("expected SubscribeCommand::Quotes round trip, was {other:?}"),
3431 }
3432 }
3433
3434 #[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 msgbus::ShareableMessageHandler::from_typed(move |_resp: &QuotesResponse| {
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(), "QuotesResponse");
3490 assert_eq!(captured.topic, MessagingSwitchboard::data_response_topic());
3491
3492 let decoded: QuotesResponse =
3493 rmp_serde::from_slice(&captured.payload).expect("decode QuotesResponse");
3494 assert_eq!(decoded.correlation_id, response.correlation_id);
3495 assert_eq!(decoded.client_id, response.client_id);
3496 assert_eq!(decoded.instrument_id, response.instrument_id);
3497 assert_eq!(decoded.ts_init, response.ts_init);
3498 assert!(decoded.data.is_empty());
3499 }
3500}