Skip to main content

nautilus_event_store/
kernel.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Run lifecycle and kernel boot integration for the event store.
17//!
18//! This module owns the kernel side of the SPEC's run lifecycle: it scans the on-disk
19//! instance directory for crashed predecessors before a fresh run opens, seals each
20//! survivor, opens the new run, blocks `start()` until the writer acknowledges the
21//! `RunStarted` entry, and seals the manifest with a final `RunEnded` entry on graceful
22//! stop. The writer's halt callback is wrapped in a typed [`HaltSignal`] that records
23//! the first fail-stop reason for supervision; no runtime component polls it to stop
24//! the trader.
25
26use 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/// The outcome of sealing a single crashed predecessor.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct RecoveredRun {
81    /// The id of the sealed predecessor.
82    pub run_id: RunId,
83    /// The terminal status applied: [`RunStatus::CrashedRecovered`] or
84    /// [`RunStatus::Quarantined`].
85    pub status: RunStatus,
86}
87
88/// Result of the predecessor recovery sweep performed in the kernel constructor.
89#[derive(Debug, Default)]
90pub struct RecoveryOutcome {
91    /// One entry per predecessor that was sealed by the sweep.
92    pub recovered: Vec<RecoveredRun>,
93    /// The id of the most-recently-crashed predecessor sealed as
94    /// [`RunStatus::CrashedRecovered`], or `None` when no recoverable predecessor
95    /// existed (or every predecessor was quarantined).
96    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/// Non-serialized lifecycle policy for advanced event-store callers.
108///
109/// [`EventStoreConfig`] remains the serializable run policy. This type carries process-local
110/// construction choices, such as the encoder registry and backend opener used when a kernel
111/// opens a run.
112#[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    /// Creates options that use [`default_registry`] and [`RedbBackend`].
138    #[must_use]
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Uses a caller-supplied encoder registry factory for each opened run.
144    #[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    /// Uses a caller-supplied encoder registry for each opened run.
154    #[must_use]
155    pub fn with_encoder_registry(self, registry: EncoderRegistry) -> Self {
156        self.with_registry_factory(move || registry.clone())
157    }
158
159    /// Uses a caller-supplied backend opener for each opened run.
160    #[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    /// Uses a caller-supplied data-marker extractor registry factory for each opened run.
170    #[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/// Errors surfaced by the boot path.
199#[derive(Debug, thiserror::Error)]
200pub enum BootError {
201    /// The event store backend rejected an open, scan, or seal during recovery or
202    /// new-run creation.
203    #[error(transparent)]
204    EventStore(#[from] EventStoreError),
205    /// The writer rejected the `RunStarted` submit.
206    #[error("RunStarted submit failed: {0}")]
207    RunStartedSubmit(String),
208    /// The writer accepted `RunStarted` but did not durably commit it inside the
209    /// configured timeout.
210    #[error("RunStarted did not durably commit within {timeout:?}")]
211    RunStartedTimeout {
212        /// The configured ceiling that elapsed before the writer's high-watermark
213        /// advanced.
214        timeout: Duration,
215    },
216    /// The writer signaled fail-stop while the boot path was waiting for the
217    /// `RunStarted` entry to commit.
218    #[error("event store halted during boot: {0:?}")]
219    HaltedDuringBoot(HaltReason),
220}
221
222/// A thread-safe halt signal the kernel registers with the writer.
223///
224/// Each fail-stop source fires the shared callback at most once: the submitting
225/// thread on a backpressure stall, the writer thread on a backend failure, and the
226/// capture adapter on a rejected submit. The signal records the first reason for
227/// supervision; no runtime component polls it to stop the trader.
228#[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    /// Constructs a fresh, un-fired halt signal.
242    #[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    /// Returns the [`HaltCallback`] the writer fires when an unrecoverable condition
251    /// occurs.
252    ///
253    /// The callback records the [`HaltReason`] (preserving only the first one when
254    /// the writer and the capture adapter both signal) and then flips the halted
255    /// flag, so a poller that observes `is_halted()` never reads back an empty
256    /// reason.
257    #[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    /// Returns whether the writer has signaled fail-stop.
272    #[must_use]
273    pub fn is_halted(&self) -> bool {
274        self.halted.load(Ordering::Acquire)
275    }
276
277    /// Returns the [`HaltReason`] recorded on the first fail-stop, if any.
278    ///
279    /// Calling this does not clear the signal; the kernel's halted flag remains set so
280    /// subsequent submits surface as fail-stopped.
281    #[must_use]
282    pub fn reason(&self) -> Option<HaltReason> {
283        self.reason.lock().clone()
284    }
285}
286
287/// Live event-store session owned by the kernel between `start()` and `finalize_stop()`.
288pub 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    /// Returns the captured manifest as it was written to disk at run start.
311    ///
312    /// The high-watermark and `end_ts_init` advance after seal; the snapshot here is
313    /// frozen at boot time.
314    #[must_use]
315    pub const fn manifest(&self) -> &RunManifest {
316        &self.manifest
317    }
318
319    /// Returns the run id of the currently open run.
320    #[must_use]
321    pub fn run_id(&self) -> &str {
322        self.manifest.run_id.as_str()
323    }
324
325    /// Returns the parent run id for the current run.
326    #[must_use]
327    pub fn parent_run_id(&self) -> Option<&str> {
328        self.manifest.parent_run_id.as_deref()
329    }
330
331    /// Returns whether the writer has fail-stopped.
332    #[must_use]
333    pub fn is_halted(&self) -> bool {
334        self.halt_signal.is_halted()
335    }
336
337    /// Returns the writer's current durable high-watermark.
338    ///
339    /// Returns `0` when the writer has been consumed by a prior `close`.
340    #[must_use]
341    pub fn high_watermark(&self) -> u64 {
342        self.writer.as_ref().map_or(0, |w| w.high_watermark())
343    }
344
345    /// Returns a snapshot anchorer bound to the open writer.
346    ///
347    /// The execution engine installs this callback while the run is open. The callback
348    /// records the cache-owned snapshot reference against the writer's durable
349    /// high-watermark after flushing earlier captured entries.
350    #[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    /// Returns the live bus capture adapter, when one was wired into this run.
364    ///
365    /// `None` after [`Self::close`] consumes the writer.
366    #[must_use]
367    pub fn adapter(&self) -> Option<&Arc<BusCaptureAdapter>> {
368        self.adapter.as_ref()
369    }
370
371    /// Submits the terminal `RunEnded` entry, drains pending entries, and seals the
372    /// manifest as [`RunStatus::Ended`].
373    ///
374    /// Consumes the inner writer; subsequent calls return without effect.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`EventStoreError`] if the writer fails to commit the final batch, the
379    /// seal step fails, or the writer Arc has outstanding clones (the bus tap must be
380    /// cleared before close to release the adapter's writer reference).
381    pub fn close(&mut self, ts_init: UnixNanos) -> Result<(), EventStoreError> {
382        // Drop the adapter first so the writer Arc has no other strong owners on
383        // try_unwrap. The kernel clears the bus tap before this site, so dropping the
384        // session-side adapter clone here is the last release before close.
385        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        // Drop without close: release adapter then writer so the writer thread exits
411        // unsealed; the next boot recovers.
412        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/// Typed error surfaced when the event store fails the run lifecycle.
427///
428/// Wraps the boot-time and shutdown-time failure modes so a kernel caller can react to a
429/// fail-stop without inspecting individual writer/backend errors.
430#[derive(Debug, thiserror::Error)]
431pub enum KernelError {
432    /// The event-store boot path failed.
433    #[error("event store boot failed: {0}")]
434    EventStoreBoot(#[from] BootError),
435    /// Cache state reconstruction from a recovered event-store run failed.
436    #[error("event store cache replay failed: {0}")]
437    CacheReplay(#[from] CacheReplayError),
438    /// The writer signaled fail-stop after the kernel was already started.
439    #[error("event store halted: {0:?}")]
440    EventStoreHalted(HaltReason),
441}
442
443/// Kernel-facing wrapper that bundles every event-store concern: predecessor recovery,
444/// the open run, the halt signal, and the seal-on-drop fail-safe.
445///
446/// One instance is typically owned by [`nautilus_system::NautilusKernel`] via the
447/// [`KernelEventStoreTrait`] seam: the kernel calls [`EventStoreLifecycle::open`] from
448/// `start()`, [`EventStoreLifecycle::seal`] from `finalize_stop()` / `dispose()`, and
449/// the wrapper's [`Drop`] runs as the last-chance seal site for callers that skip both
450/// teardown paths (e.g. imperative `engine.run(...)` followed by drop in
451/// `BacktestEngine`).
452#[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    // Held so `Drop` can stamp the seal even when the kernel never called seal()
461    // explicitly. Cloning the kernel's clock Rc keeps the wrapper independent of
462    // its owner.
463    clock: Rc<RefCell<dyn Clock>>,
464}
465
466impl EventStoreLifecycle {
467    /// Boots the wrapper at kernel construction time.
468    ///
469    /// Runs the predecessor recovery sweep against `<base_dir>/<instance_id>/`. When
470    /// `config` is `None` the wrapper is inert: every method becomes a no-op.
471    ///
472    /// # Errors
473    ///
474    /// Returns the underlying [`EventStoreError`] when the recovery sweep fails for a
475    /// reason other than the expected `CrashedPredecessor` handshake.
476    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    /// Boots the wrapper at kernel construction time with process-local lifecycle options.
490    ///
491    /// `EventStoreConfig` remains serializable. `options` carries runtime-only construction
492    /// policy for the encoder registry and backend opener.
493    ///
494    /// # Errors
495    ///
496    /// Returns the underlying [`EventStoreError`] when the recovery sweep fails for a
497    /// reason other than the expected `CrashedPredecessor` handshake.
498    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    /// Opens a fresh run on kernel `start()`. Idempotent against reset/rerun: a
529    /// leftover session from a prior `start()` is sealed before a new one opens, so
530    /// `RunStarted` remains the first entry of every run.
531    ///
532    /// `components` is the manifest captured into the `RunStarted` payload. `environment`
533    /// selects the static (backtest) or realtime (live) clock used to stamp `ts_publish`
534    /// inside the writer.
535    ///
536    /// Returns without effect when no event-store config was supplied.
537    ///
538    /// # Errors
539    ///
540    /// Returns [`KernelError::EventStoreBoot`] when opening the new run, spawning the
541    /// writer, or blocking on the `RunStarted` ack fails.
542    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            // Reset/rerun (BacktestEngine::run -> reset -> run) reuses the kernel
554            // across runs. Seal the leftover session before opening a fresh one.
555            let ts = self.clock.borrow().timestamp_ns();
556            self.seal(ts);
557        }
558
559        // Re-arm the fail-stop signal: a halt is terminal for the run that fired it,
560        // not for the kernel. A stale signal fails the rerun's boot or opens it
561        // permanently halted, downgrading its graceful stop to CrashedRecovered.
562        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    /// Restores cache state from the configured replay run or recovered parent run.
602    ///
603    /// This is a bootstrap-only reconstruction path. It opens the sealed replay source
604    /// for read-only replay, restores the cache-owned snapshot blob, then replays only
605    /// the entries after the snapshot anchor directly into [`Cache`].
606    ///
607    /// # Errors
608    ///
609    /// Returns [`KernelError::CacheReplay`] when the source reader, snapshot restore, decode,
610    /// or cache apply step fails.
611    pub fn restore_parent_cache(
612        &self,
613        instance_id: UUID4,
614        cache: &mut Cache,
615    ) -> Result<Option<CacheReplayReport>, KernelError> {
616        let Some(config) = self.config.as_ref() else {
617            return Ok(None);
618        };
619        let replay_run_id = config
620            .replay_from_run_id
621            .as_deref()
622            .or(self.parent_run_id.as_deref());
623        let Some(replay_run_id) = replay_run_id else {
624            return Ok(None);
625        };
626        let source = if config.replay_from_run_id.is_some() {
627            "configured replay run"
628        } else {
629            "parent run"
630        };
631
632        let report = restore_cache_from_sealed_run(
633            cache,
634            config.base_dir.clone(),
635            &instance_id.to_string(),
636            replay_run_id,
637        )?;
638
639        log::info!(
640            "Restored cache from event-store {source} {replay_run_id}: from_seq={}, to_seq={}, applied={}, ignored={}",
641            report.cache.plan.from_seq,
642            report.cache.plan.to_seq,
643            report.cache.applied_entries,
644            report.cache.ignored_entries,
645        );
646
647        Ok(Some(report.cache))
648    }
649
650    /// Seals the open session by writing `RunEnded` and updating the manifest to
651    /// `Ended`. Idempotent: a closed or absent session makes this a no-op. Halted
652    /// sessions skip the close (the recovery sweep on next boot owns the seal).
653    pub fn seal(&mut self, ts_init: UnixNanos) {
654        let Some(mut session) = self.session.take() else {
655            return;
656        };
657
658        // Drop the bus tap before close so the adapter's writer Arc is released; the
659        // close path then takes sole ownership of the writer and commits RunEnded.
660        msgbus::clear_bus_tap();
661
662        if session.is_halted() {
663            log::warn!(
664                "Event-store writer fail-stopped before close; run {} sealed by recovery sweep on next boot",
665                session.run_id(),
666            );
667            return;
668        }
669        let run_id = session.run_id().to_string();
670        if let Err(e) = session.close(ts_init) {
671            log::error!(
672                "Failed to seal event-store run {run_id} on graceful stop: {e}; run will be sealed as CrashedRecovered on next boot",
673            );
674        } else {
675            log::info!("Sealed event-store run {run_id}");
676        }
677    }
678
679    /// Returns the recovery report from the boot sweep.
680    #[must_use]
681    pub fn recovered(&self) -> &[RecoveredRun] {
682        &self.recovered
683    }
684
685    /// Returns the configured replay source or recovered parent run id, when present.
686    #[must_use]
687    pub fn parent_run_id(&self) -> Option<&str> {
688        self.config
689            .as_ref()
690            .and_then(|config| config.replay_from_run_id.as_deref())
691            .or(self.parent_run_id.as_deref())
692    }
693
694    /// Returns whether this lifecycle is configured for event-store-only replay.
695    #[must_use]
696    pub fn is_event_store_replay_configured(&self) -> bool {
697        self.config
698            .as_ref()
699            .is_some_and(|config| config.replay_from_run_id.is_some())
700    }
701
702    /// Returns the run id of the open session, when capture is active.
703    #[must_use]
704    pub fn run_id(&self) -> Option<&str> {
705        self.session.as_ref().map(EventStoreSession::run_id)
706    }
707
708    /// Returns a snapshot anchorer for the open run, when capture is active.
709    #[must_use]
710    pub fn snapshot_anchorer(&self) -> Option<SnapshotAnchorer> {
711        self.session
712            .as_ref()
713            .and_then(EventStoreSession::snapshot_anchorer)
714    }
715
716    /// Returns whether the writer has signaled fail-stop.
717    #[must_use]
718    pub fn is_halted(&self) -> bool {
719        self.halt.is_halted()
720    }
721
722    /// Returns the [`HaltReason`] recorded on the first fail-stop, if any.
723    #[must_use]
724    pub fn halt_reason(&self) -> Option<HaltReason> {
725        self.halt.reason()
726    }
727
728    /// Surfaces the current halt as a typed [`KernelError`], or `None` when the
729    /// writer has not halted.
730    #[must_use]
731    pub fn check_halt(&self) -> Option<KernelError> {
732        self.halt_reason().map(KernelError::EventStoreHalted)
733    }
734
735    #[cfg(feature = "live")]
736    fn clock_for(environment: Environment) -> &'static AtomicTime {
737        match environment {
738            Environment::Backtest => get_atomic_clock_static(),
739            Environment::Live | Environment::Sandbox => get_atomic_clock_realtime(),
740        }
741    }
742
743    #[cfg(not(feature = "live"))]
744    fn clock_for(_environment: Environment) -> &'static AtomicTime {
745        get_atomic_clock_static()
746    }
747}
748
749impl Drop for EventStoreLifecycle {
750    fn drop(&mut self) {
751        // Last-chance seal: callers may skip both finalize_stop() and dispose().
752        if self.session.is_none() {
753            return;
754        }
755        let ts = self
756            .clock
757            .try_borrow()
758            .map(|c| c.timestamp_ns())
759            .unwrap_or_default();
760        self.seal(ts);
761    }
762}
763
764/// Sweeps `<base_dir>/<instance_id>/` for crashed predecessor runs and seals each one.
765///
766/// A predecessor is a run file whose manifest still reads [`RunStatus::Running`]: the
767/// previous trader exited (cleanly via drop, or crashed) without sealing. The sweep
768/// scans every entry in the run, validating hashes; on success the manifest seals as
769/// [`RunStatus::CrashedRecovered`], otherwise as [`RunStatus::Quarantined`]. The
770/// most-recently-crashed survivor's `run_id` is returned so the new run records it as
771/// `parent_run_id`.
772///
773/// Quarantined runs do not become parents: a future replay must skip the corrupted
774/// tail rather than chain through it.
775///
776/// A predecessor that cannot be reopened, scanned, or sealed is skipped with a logged
777/// error rather than failing the sweep: recovery must never leave the trader unbootable
778/// because one run file is damaged. Skipped runs keep their on-disk status, so the next
779/// boot retries them.
780///
781/// # Errors
782///
783/// Returns [`EventStoreError`] when the directory enumeration fails, or when a
784/// predecessor unexpectedly reopens without the
785/// [`EventStoreError::CrashedPredecessor`] handshake the backend uses to surface
786/// unsealed runs.
787pub fn recover_predecessors(
788    base_dir: &Path,
789    instance_id: &str,
790) -> Result<RecoveryOutcome, EventStoreError> {
791    let manifests = RedbBackend::list_runs(base_dir, instance_id)?;
792    let crashed: Vec<RunManifest> = manifests
793        .into_iter()
794        .filter(|m| matches!(m.status, RunStatus::Running))
795        .collect();
796
797    let mut outcome = RecoveryOutcome::default();
798
799    for predecessor in crashed {
800        let run_id = predecessor.run_id.clone();
801        let mut backend = RedbBackend::new(base_dir.to_path_buf());
802
803        match backend.open_run(predecessor) {
804            Err(EventStoreError::CrashedPredecessor) => {}
805            Ok(()) => {
806                return Err(EventStoreError::Backend(format!(
807                    "expected CrashedPredecessor reopening {run_id}, was Ok",
808                )));
809            }
810            Err(other) => {
811                log::error!("Skipping recovery of run {run_id}, reopen failed: {other}");
812                continue;
813            }
814        }
815
816        let high_watermark = backend.high_watermark()?;
817        let final_status = if high_watermark == 0 {
818            RunStatus::CrashedRecovered
819        } else {
820            match backend.scan_range(1, high_watermark, ScanDirection::Forward) {
821                Ok(entries) => {
822                    // The writer commits RunEnded before seal; a crash between those
823                    // two steps leaves a graceful tail without a sealed manifest.
824                    // Honor the tail: if the last entry is the kernel's RunEnded
825                    // marker, the predecessor closed cleanly and is not a crash to
826                    // chain through. Match both topic and payload_type so a future
827                    // capture-registry entry that happens to share the payload tag
828                    // cannot be misclassified as a graceful close.
829                    let tail_is_run_ended = entries.last().is_some_and(|e| {
830                        e.topic.as_ref() == RUN_ENDED_TOPIC
831                            && e.payload_type.as_str() == RUN_ENDED_PAYLOAD_TYPE
832                    });
833
834                    if tail_is_run_ended {
835                        RunStatus::Ended
836                    } else {
837                        RunStatus::CrashedRecovered
838                    }
839                }
840                Err(
841                    EventStoreError::HashMismatch { .. }
842                    | EventStoreError::SeqMismatch { .. }
843                    | EventStoreError::Corrupted(_)
844                    | EventStoreError::Gap { .. },
845                ) => RunStatus::Quarantined,
846                Err(other) => {
847                    log::error!("Skipping recovery of run {run_id}, scan failed: {other}");
848                    continue;
849                }
850            }
851        };
852
853        if let Err(e) = backend.seal(final_status) {
854            log::error!("Skipping recovery of run {run_id}, seal as {final_status:?} failed: {e}");
855            continue;
856        }
857        outcome.recovered.push(RecoveredRun {
858            run_id: run_id.clone(),
859            status: final_status,
860        });
861
862        if matches!(final_status, RunStatus::CrashedRecovered) {
863            outcome.parent_run_id = Some(run_id);
864        }
865    }
866
867    Ok(outcome)
868}
869
870/// Builds the `<start_ts_init>-<short_uuid>` run id used as the manifest key and on-disk
871/// file name.
872///
873/// The id is sortable by start time so directory listings produce chronological order;
874/// the short uuid suffix keeps it unique even when two kernels start at the same
875/// nanosecond on different machines.
876#[must_use]
877pub fn build_run_id(start_ts_init: UnixNanos) -> RunId {
878    let suffix: String = UUID4::new().to_string().chars().take(8).collect();
879    format!("{}-{suffix}", u64::from(start_ts_init))
880}
881
882/// Opens a fresh run, spawns the writer, and submits a blocking `RunStarted` entry.
883///
884/// The kernel calls this from `start()` after components have registered with the
885/// trader so the captured `RunStarted` payload reflects the actual boot configuration.
886/// The function blocks until the writer's high-watermark advances past zero (i.e. the
887/// `RunStarted` entry has durably committed) or until [`EventStoreConfig::run_started_timeout`]
888/// elapses.
889///
890/// `feature_flags` is appended after the configured `feature_flags` so the retention
891/// mode survives in the manifest as `retention=<mode>`.
892///
893/// # Errors
894///
895/// Returns [`BootError::EventStore`] when the backend rejects open, [`BootError::RunStartedSubmit`]
896/// when the writer rejects the submit, [`BootError::RunStartedTimeout`] when the
897/// commit does not happen inside the configured ceiling, and [`BootError::HaltedDuringBoot`]
898/// when the writer fail-stops while waiting for the commit.
899#[expect(
900    clippy::too_many_arguments,
901    reason = "run opening requires the boot context and event-store handles"
902)]
903pub fn open_run(
904    config: &EventStoreConfig,
905    instance_id: &str,
906    run_id: RunId,
907    parent_run_id: Option<RunId>,
908    start_ts_init: UnixNanos,
909    components: &RegisteredComponents,
910    halt_signal: HaltSignal,
911    clock: &'static AtomicTime,
912) -> Result<EventStoreSession, BootError> {
913    open_run_with_options(
914        config,
915        instance_id,
916        run_id,
917        parent_run_id,
918        start_ts_init,
919        components,
920        halt_signal,
921        clock,
922        &EventStoreLifecycleOptions::default(),
923    )
924}
925
926/// Opens a fresh run with process-local lifecycle options.
927///
928/// This follows [`open_run`] but obtains the backend and encoder registry from
929/// `options`.
930///
931/// # Errors
932///
933/// Returns [`BootError::EventStore`] when the backend rejects open, [`BootError::RunStartedSubmit`]
934/// when the writer rejects the submit, [`BootError::RunStartedTimeout`] when the
935/// commit does not happen inside the configured ceiling, and [`BootError::HaltedDuringBoot`]
936/// when the writer fail-stops while waiting for the commit.
937#[expect(
938    clippy::too_many_arguments,
939    reason = "run opening with options requires the boot context and lifecycle handles"
940)]
941pub fn open_run_with_options(
942    config: &EventStoreConfig,
943    instance_id: &str,
944    run_id: RunId,
945    parent_run_id: Option<RunId>,
946    start_ts_init: UnixNanos,
947    components: &RegisteredComponents,
948    halt_signal: HaltSignal,
949    clock: &'static AtomicTime,
950    options: &EventStoreLifecycleOptions,
951) -> Result<EventStoreSession, BootError> {
952    let manifest = build_manifest(
953        config,
954        instance_id,
955        run_id,
956        parent_run_id,
957        start_ts_init,
958        components.clone(),
959    );
960
961    let backend = options.open_backend(config, &manifest)?;
962
963    let writer = Arc::new(EventStoreWriter::spawn(
964        backend,
965        clock,
966        halt_signal.callback(),
967        writer_config_from(config),
968    )?);
969
970    submit_run_started_blocking(
971        &writer,
972        components,
973        start_ts_init,
974        &halt_signal,
975        config.run_started_timeout,
976    )?;
977
978    let (marker_capture, submit_counter) =
979        build_marker_capture(config, &manifest, writer.high_watermark(), clock, options);
980    let mut adapter = BusCaptureAdapter::new(
981        Arc::clone(&writer),
982        Arc::new(options.build_registry()),
983        halt_signal.callback(),
984    );
985
986    if let Some(submit_counter) = submit_counter {
987        adapter = adapter.with_submit_counter(submit_counter);
988    }
989    let adapter = Arc::new(adapter);
990
991    Ok(EventStoreSession {
992        writer: Some(writer),
993        adapter: Some(adapter),
994        marker_capture,
995        manifest,
996        halt_signal,
997    })
998}
999
1000fn build_marker_capture(
1001    config: &EventStoreConfig,
1002    manifest: &RunManifest,
1003    initial_submit_counter: u64,
1004    clock: &'static AtomicTime,
1005    options: &EventStoreLifecycleOptions,
1006) -> (Option<SharedMarkerCapture>, Option<Arc<AtomicU64>>) {
1007    let Some(marker_config) = config.data_markers.as_ref() else {
1008        return (None, None);
1009    };
1010
1011    match open_marker_capture(
1012        config,
1013        manifest,
1014        marker_config,
1015        initial_submit_counter,
1016        clock,
1017        options,
1018    ) {
1019        Ok((capture, submit_counter)) => (
1020            Some(Rc::new(RefCell::new(Some(capture)))),
1021            Some(submit_counter),
1022        ),
1023        Err(e) => {
1024            log::warn!(
1025                "Data marker sidecar disabled for run {} after marker setup failed: {e}",
1026                manifest.run_id,
1027            );
1028            (None, None)
1029        }
1030    }
1031}
1032
1033fn open_marker_capture(
1034    config: &EventStoreConfig,
1035    manifest: &RunManifest,
1036    marker_config: &DataMarkerConfig,
1037    initial_submit_counter: u64,
1038    clock: &'static AtomicTime,
1039    options: &EventStoreLifecycleOptions,
1040) -> Result<(DataMarkerCapture, Arc<AtomicU64>), EventStoreError> {
1041    let classes = marker_config
1042        .classes
1043        .iter()
1044        .copied()
1045        .map(data_marker_class_to_data_class)
1046        .collect::<Vec<_>>();
1047    let marker_manifest = marker_manifest_for(manifest, classes.clone(), marker_config);
1048    let marker_path = marker_file_path(config, &manifest.instance_id, &manifest.run_id);
1049    let mut marker_backend = RedbMarkerBackend::new(marker_path);
1050    marker_backend.open_run(marker_manifest)?;
1051    let writer = MarkerWriter::spawn(
1052        Box::new(marker_backend),
1053        clock,
1054        MarkerWriterConfig {
1055            channel_capacity: marker_config.channel_capacity,
1056            ..MarkerWriterConfig::default()
1057        },
1058    )?;
1059    let submit_counter = Arc::new(AtomicU64::new(initial_submit_counter));
1060    let registry = options.build_marker_registry(&classes);
1061    let capture =
1062        DataMarkerCapture::new(registry, writer, Arc::clone(&submit_counter), marker_config);
1063
1064    Ok((capture, submit_counter))
1065}
1066
1067fn marker_file_path(config: &EventStoreConfig, instance_id: &str, run_id: &str) -> PathBuf {
1068    config
1069        .base_dir
1070        .join(instance_id)
1071        .join(format!("{run_id}.markers.redb"))
1072}
1073
1074fn marker_manifest_for(
1075    manifest: &RunManifest,
1076    enabled_classes: Vec<DataClass>,
1077    config: &DataMarkerConfig,
1078) -> MarkerManifest {
1079    MarkerManifest {
1080        run_id: manifest.run_id.clone(),
1081        enabled_classes,
1082        high_fidelity: !config.high_fidelity.is_empty(),
1083        snapshot_count: 0,
1084        hifi_count: 0,
1085        gap_count: 0,
1086        dict_count: 0,
1087        status: RunStatus::Running,
1088    }
1089}
1090
1091const fn data_marker_class_to_data_class(class: DataMarkerClass) -> DataClass {
1092    match class {
1093        DataMarkerClass::BookDeltas => DataClass::BookDeltas,
1094        DataMarkerClass::BookDepth10 => DataClass::BookDepth10,
1095        DataMarkerClass::Quote => DataClass::Quote,
1096        DataMarkerClass::Trade => DataClass::Trade,
1097        DataMarkerClass::Bar => DataClass::Bar,
1098    }
1099}
1100
1101fn build_manifest(
1102    config: &EventStoreConfig,
1103    instance_id: &str,
1104    run_id: RunId,
1105    parent_run_id: Option<RunId>,
1106    start_ts_init: UnixNanos,
1107    components: RegisteredComponents,
1108) -> RunManifest {
1109    let mut feature_flags = config.identity.feature_flags.clone();
1110    feature_flags.push(format!("retention={}", retention_tag(config.retention)));
1111
1112    RunManifest {
1113        run_id,
1114        parent_run_id,
1115        instance_id: instance_id.to_string(),
1116        binary_hash: config.identity.binary_hash.clone(),
1117        schema_version: config.identity.schema_version,
1118        crate_versions: config.identity.crate_versions.clone(),
1119        feature_flags,
1120        adapter_versions: config.identity.adapter_versions.clone(),
1121        config_hash: config.identity.config_hash.clone(),
1122        registered_components: components,
1123        seed: config.identity.seed,
1124        start_ts_init,
1125        end_ts_init: None,
1126        high_watermark: 0,
1127        status: RunStatus::Running,
1128    }
1129}
1130
1131const fn retention_tag(mode: RetentionMode) -> &'static str {
1132    match mode {
1133        RetentionMode::Full => "full",
1134        RetentionMode::Bounded { .. } => "bounded",
1135        RetentionMode::SnapshotAnchored => "snapshot",
1136    }
1137}
1138
1139fn writer_config_from(config: &EventStoreConfig) -> WriterConfig {
1140    WriterConfig {
1141        channel_capacity: config.channel_capacity,
1142        max_batch_entries: config.max_batch_entries,
1143        max_batch_latency: config.max_batch_latency,
1144        halt_threshold: config.halt_threshold,
1145    }
1146}
1147
1148/// Submits the `RunStarted` draft and blocks until the writer durably acknowledges it,
1149/// the writer fail-stops, or `timeout` elapses.
1150///
1151/// Exposed at `pub(crate)` so tests can drive it against a stub backend without going
1152/// through [`open_run`].
1153///
1154/// # Errors
1155///
1156/// Returns [`BootError::RunStartedSubmit`] when the writer rejects the submit,
1157/// [`BootError::HaltedDuringBoot`] when the writer fail-stops during the wait, and
1158/// [`BootError::RunStartedTimeout`] when the writer does not commit within `timeout`.
1159pub(crate) fn submit_run_started_blocking(
1160    writer: &EventStoreWriter,
1161    components: &RegisteredComponents,
1162    ts_init: UnixNanos,
1163    halt_signal: &HaltSignal,
1164    timeout: Duration,
1165) -> Result<(), BootError> {
1166    let payload = encode_run_started(components);
1167    let draft = EntryDraft::without_indices(
1168        Headers::empty(),
1169        Topic::from(RUN_STARTED_TOPIC),
1170        Ustr::from(RUN_STARTED_PAYLOAD_TYPE),
1171        payload,
1172        ts_init,
1173    );
1174
1175    writer
1176        .submit(draft)
1177        .map_err(|e| BootError::RunStartedSubmit(e.to_string()))?;
1178
1179    // Wall-clock timeout against the writer thread: the writer drives the seam,
1180    // not the kernel state machine, so monotonic Instant timing is correct here.
1181    let start = Instant::now(); // dst-ok
1182
1183    while writer.high_watermark() == 0 {
1184        if halt_signal.is_halted() {
1185            return Err(BootError::HaltedDuringBoot(
1186                halt_signal.reason().unwrap_or_else(|| {
1187                    HaltReason::BackendError("event store halted during boot".to_string())
1188                }),
1189            ));
1190        }
1191
1192        let elapsed = start.elapsed();
1193
1194        if elapsed >= timeout {
1195            return Err(BootError::RunStartedTimeout { timeout });
1196        }
1197        thread::sleep(Duration::from_millis(1));
1198    }
1199
1200    Ok(())
1201}
1202
1203fn encode_run_started(components: &RegisteredComponents) -> Bytes {
1204    // The payload uses the same positional codec as the event-store envelope.
1205    let bytes = crate::codec::encode_to_vec(components).expect(
1206        "RegisteredComponents serializes via serde, must not fail under the positional codec",
1207    );
1208    Bytes::from(bytes)
1209}
1210
1211fn run_ended_draft(ts_init: UnixNanos) -> EntryDraft {
1212    EntryDraft::without_indices(
1213        Headers::empty(),
1214        Topic::from(RUN_ENDED_TOPIC),
1215        Ustr::from(RUN_ENDED_PAYLOAD_TYPE),
1216        Bytes::new(),
1217        ts_init,
1218    )
1219}
1220
1221/// Bus tap that forwards captured publish and send dispatches to the event store.
1222///
1223/// Built and registered by [`EventStoreLifecycle::open`]; cleared by
1224/// [`EventStoreLifecycle::seal`] and the wrapper's [`Drop`]. The tap reads `ts_init` from
1225/// the kernel's `AtomicTime` at capture time so non-Phase-A headers carry a
1226/// writer-receive timestamp.
1227struct EventStoreBusTap {
1228    adapter: Arc<BusCaptureAdapter>,
1229    marker_capture: Option<SharedMarkerCapture>,
1230    clock: &'static AtomicTime,
1231    // Latch for the one-time halted log: the per-message Halted arm stays silent to
1232    // avoid log spam, but the transition into dropping captures must leave a trace.
1233    halted_logged: AtomicBool,
1234}
1235
1236impl Debug for EventStoreBusTap {
1237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1238        f.debug_struct(stringify!(EventStoreBusTap))
1239            .field("halted", &self.adapter.is_halted())
1240            .field("marker_capture_attached", &self.marker_capture.is_some())
1241            .finish_non_exhaustive()
1242    }
1243}
1244
1245impl BusTap for EventStoreBusTap {
1246    fn on_publish(&self, topic: Topic, message: &dyn Any) {
1247        let ts_init = self.clock.get_time_ns();
1248        self.capture(topic, message, ts_init);
1249    }
1250
1251    fn on_send(&self, endpoint: MStr<Endpoint>, message: &dyn Any) {
1252        let ts_init = self.clock.get_time_ns();
1253        // Reuse the endpoint string as the captured topic. The MStr markers differ but
1254        // the underlying interned string is the same; offline scans match either way.
1255        let topic = Topic::from(*endpoint);
1256        self.capture(topic, message, ts_init);
1257    }
1258
1259    fn on_response(&self, _correlation_id: &UUID4, message: &dyn Any) {
1260        let ts_init = self.clock.get_time_ns();
1261        let topic = MessagingSwitchboard::data_response_topic();
1262        self.capture(topic, message, ts_init);
1263    }
1264}
1265
1266impl EventStoreBusTap {
1267    fn capture(&self, topic: Topic, message: &dyn Any, ts_init: UnixNanos) {
1268        // The registry both gates capture (no encoder -> no entry) and supplies headers
1269        // for entries that do flow through. Looking the headers up here keeps the
1270        // adapter encoder-only and lets header propagation light up per-type as the
1271        // SPEC's workstream A lands fields on commands and events.
1272        let headers = self
1273            .adapter
1274            .registry()
1275            .headers_for_any(message)
1276            .unwrap_or_else(Headers::empty);
1277        // Submit failures fire the adapter halt callback before returning; HaltSignal
1278        // is the observation path. Halted means the signal already fired.
1279        match self.adapter.capture_any(topic, message, headers, ts_init) {
1280            Ok(captured) => {
1281                self.capture_marker(topic, message, ts_init, captured);
1282            }
1283            Err(CaptureError::Halted) => {
1284                if !self.halted_logged.swap(true, Ordering::AcqRel) {
1285                    log::error!(
1286                        "Event store capture is halted; state-affecting messages are no longer recorded for this run"
1287                    );
1288                }
1289            }
1290            Err(CaptureError::Submit(e)) => {
1291                log::error!("Event store capture submit failed on {topic}: {e}");
1292            }
1293            Err(CaptureError::Encode(e)) => {
1294                log::warn!("Event store encoder rejected message on {topic}: {e}");
1295            }
1296        }
1297    }
1298
1299    fn capture_marker(&self, topic: Topic, message: &dyn Any, ts_init: UnixNanos, captured: bool) {
1300        let Some(marker_capture) = self.marker_capture.as_ref() else {
1301            return;
1302        };
1303        let mut marker_capture = marker_capture.borrow_mut();
1304        let Some(capture) = marker_capture.as_mut() else {
1305            return;
1306        };
1307
1308        if captured {
1309            capture.on_entry_submitted(ts_init);
1310        } else {
1311            capture.observe_publish(topic, message, ts_init);
1312        }
1313        capture.maybe_safety_flush(ts_init);
1314    }
1315}
1316
1317fn install_bus_tap(
1318    adapter: Arc<BusCaptureAdapter>,
1319    marker_capture: Option<SharedMarkerCapture>,
1320    clock: &'static AtomicTime,
1321) {
1322    let tap: Rc<dyn BusTap> = Rc::new(EventStoreBusTap {
1323        adapter,
1324        marker_capture,
1325        clock,
1326        halted_logged: AtomicBool::new(false),
1327    });
1328    msgbus::set_bus_tap(tap);
1329}
1330
1331// Use fully qualified `EventStoreLifecycle::` to dispatch to the inherent methods;
1332// `Self::` would resolve back into this trait impl and recurse.
1333#[expect(
1334    clippy::use_self,
1335    reason = "Self would dispatch back into this trait impl"
1336)]
1337impl KernelEventStoreTrait for EventStoreLifecycle {
1338    fn restore_parent_cache(
1339        &mut self,
1340        instance_id: UUID4,
1341        cache: &mut Cache,
1342    ) -> anyhow::Result<()> {
1343        EventStoreLifecycle::restore_parent_cache(self, instance_id, cache)
1344            .map(|_| ())
1345            .map_err(Into::into)
1346    }
1347
1348    fn open(
1349        &mut self,
1350        instance_id: UUID4,
1351        components: &RegisteredComponents,
1352        environment: Environment,
1353    ) -> anyhow::Result<()> {
1354        EventStoreLifecycle::open(self, instance_id, components, environment).map_err(Into::into)
1355    }
1356
1357    fn snapshot_anchorer(&self) -> Option<SnapshotAnchorer> {
1358        EventStoreLifecycle::snapshot_anchorer(self)
1359    }
1360
1361    fn seal(&mut self, ts_init: UnixNanos) {
1362        EventStoreLifecycle::seal(self, ts_init);
1363    }
1364
1365    fn run_id(&self) -> Option<&str> {
1366        EventStoreLifecycle::run_id(self)
1367    }
1368
1369    fn parent_run_id(&self) -> Option<&str> {
1370        EventStoreLifecycle::parent_run_id(self)
1371    }
1372
1373    fn is_event_store_replay_configured(&self) -> bool {
1374        EventStoreLifecycle::is_event_store_replay_configured(self)
1375    }
1376
1377    fn is_halted(&self) -> bool {
1378        EventStoreLifecycle::is_halted(self)
1379    }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    #[cfg(madsim)]
1385    use std::path::Path;
1386    use std::path::PathBuf;
1387
1388    use indexmap::IndexMap;
1389    use nautilus_common::{
1390        clock::TestClock,
1391        messages::{
1392            data::{
1393                DataCommand, DataResponse, QuotesResponse, RequestCommand, RequestQuotes,
1394                SubscribeCommand, SubscribeQuotes,
1395            },
1396            execution::{SubmitOrder, TradingCommand},
1397        },
1398        timer::{TimeEvent, TimeEventCallback, TimeEventHandler},
1399    };
1400    use nautilus_core::time::get_atomic_clock_static;
1401    use nautilus_model::{
1402        data::stubs::{quote_ethusdt_binance, stub_deltas},
1403        enums::TimeInForce,
1404        events::{
1405            OrderEventAny, OrderFilled,
1406            order::spec::{OrderFilledSpec, OrderInitializedSpec},
1407        },
1408        identifiers::{
1409            AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, Venue,
1410            VenueOrderId,
1411        },
1412        types::{Currency, Money, Price, Quantity},
1413    };
1414    use nautilus_system::event_store::{DataMarkerClass, DataMarkerConfig, RunIdentity};
1415    use rstest::rstest;
1416    use tempfile::TempDir;
1417
1418    use super::*;
1419    use crate::{
1420        AppendEntry, DataClass, EncodedPayload, EventStoreEntry, IndexKind, MarkerBackend,
1421        MemoryBackend, RedbMarkerBackend, SnapshotAnchor,
1422        capture::builtins::PAYLOAD_TYPE_TIME_EVENT, compute_entry_hash,
1423    };
1424
1425    const INSTANCE_ID: &str = "trader-001";
1426
1427    fn make_config(base_dir: PathBuf) -> EventStoreConfig {
1428        EventStoreConfig {
1429            base_dir,
1430            identity: RunIdentity {
1431                binary_hash: "deadbeef".to_string(),
1432                schema_version: 1,
1433                crate_versions: "feedface".to_string(),
1434                feature_flags: Vec::new(),
1435                adapter_versions: IndexMap::new(),
1436                config_hash: "cafebabe".to_string(),
1437                seed: None,
1438            },
1439            retention: RetentionMode::Full,
1440            replay_from_run_id: None,
1441            data_markers: None,
1442            channel_capacity: 64,
1443            max_batch_entries: 1,
1444            max_batch_latency: Duration::from_millis(2),
1445            halt_threshold: Duration::from_secs(2),
1446            run_started_timeout: Duration::from_secs(2),
1447        }
1448    }
1449
1450    #[derive(Clone, Copy, Debug)]
1451    enum CrashPoint {
1452        BeforeEnqueue,
1453        AfterEnqueueBeforeCommit,
1454        AfterCommitBeforeSnapshot,
1455        AfterSnapshot,
1456    }
1457
1458    fn append_entry(seq: u64, topic: &str, payload_type: &str, payload: Bytes) -> AppendEntry {
1459        let ts = UnixNanos::from(seq);
1460        let headers = Headers::empty();
1461        let hash = compute_entry_hash(seq, ts, ts, topic, payload_type, &payload, &headers);
1462        let entry = EventStoreEntry::new(
1463            hash,
1464            seq,
1465            headers,
1466            Topic::from(topic),
1467            Ustr::from(payload_type),
1468            payload,
1469            ts,
1470            ts,
1471        );
1472        AppendEntry::without_indices(entry)
1473    }
1474
1475    fn make_submit_order(client_order_id: ClientOrderId) -> SubmitOrder {
1476        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1477        let order_init = OrderInitializedSpec::builder()
1478            .instrument_id(instrument_id)
1479            .client_order_id(client_order_id)
1480            .quantity(Quantity::from("1"))
1481            .time_in_force(TimeInForce::Gtc)
1482            .ts_event(UnixNanos::from(1))
1483            .ts_init(UnixNanos::from(2))
1484            .build();
1485        SubmitOrder::new(
1486            TraderId::from("TRADER-001"),
1487            Some(ClientId::from("BINANCE")),
1488            StrategyId::from("S-001"),
1489            instrument_id,
1490            client_order_id,
1491            order_init,
1492            None,
1493            None,
1494            None,
1495            UUID4::new(),
1496            UnixNanos::from(3),
1497            None, // correlation_id
1498        )
1499    }
1500
1501    fn append_run_started(seq: u64) -> AppendEntry {
1502        append_entry(
1503            seq,
1504            RUN_STARTED_TOPIC,
1505            RUN_STARTED_PAYLOAD_TYPE,
1506            encode_run_started(&RegisteredComponents::default()),
1507        )
1508    }
1509
1510    #[derive(Debug)]
1511    struct TestAuditMessage {
1512        value: u8,
1513    }
1514
1515    fn test_registry() -> EncoderRegistry {
1516        let mut registry = EncoderRegistry::new();
1517        registry.register::<TestAuditMessage, _>(Ustr::from("TestAuditMessage"), |message| {
1518            Ok(EncodedPayload::without_indices(Bytes::copy_from_slice(&[
1519                message.value,
1520            ])))
1521        });
1522        registry
1523    }
1524
1525    #[derive(Debug, Clone)]
1526    struct SharedMemoryBackend(Arc<Mutex<MemoryBackend>>);
1527
1528    impl EventStore for SharedMemoryBackend {
1529        fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
1530            self.0.lock().open_run(manifest)
1531        }
1532
1533        fn append_batch(&mut self, entries: &[AppendEntry]) -> Result<u64, EventStoreError> {
1534            self.0.lock().append_batch(entries)
1535        }
1536
1537        fn scan_range(
1538            &self,
1539            from: u64,
1540            to: u64,
1541            direction: ScanDirection,
1542        ) -> Result<Vec<EventStoreEntry>, EventStoreError> {
1543            self.0.lock().scan_range(from, to, direction)
1544        }
1545
1546        fn scan_seq(&self, seq: u64) -> Result<Option<EventStoreEntry>, EventStoreError> {
1547            self.0.lock().scan_seq(seq)
1548        }
1549
1550        fn lookup(&self, kind: IndexKind, key: &str) -> Result<Option<u64>, EventStoreError> {
1551            self.0.lock().lookup(kind, key)
1552        }
1553
1554        fn iter_index_keys(&self, kind: IndexKind) -> Result<Vec<(String, u64)>, EventStoreError> {
1555            self.0.lock().iter_index_keys(kind)
1556        }
1557
1558        fn record_snapshot_anchor(
1559            &mut self,
1560            anchor: SnapshotAnchor,
1561        ) -> Result<(), EventStoreError> {
1562            self.0.lock().record_snapshot_anchor(anchor)
1563        }
1564
1565        fn latest_snapshot_anchor(&self) -> Result<Option<SnapshotAnchor>, EventStoreError> {
1566            self.0.lock().latest_snapshot_anchor()
1567        }
1568
1569        fn seal(&mut self, status: RunStatus) -> Result<(), EventStoreError> {
1570            self.0.lock().seal(status)
1571        }
1572
1573        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
1574            self.0.lock().manifest()
1575        }
1576
1577        fn high_watermark(&self) -> Result<u64, EventStoreError> {
1578            self.0.lock().high_watermark()
1579        }
1580    }
1581
1582    fn seed_crashed_predecessor(config: &EventStoreConfig, run_id: &str, crash_point: CrashPoint) {
1583        let mut backend = RedbBackend::new(config.base_dir.clone());
1584        backend
1585            .open_run(build_manifest(
1586                config,
1587                INSTANCE_ID,
1588                run_id.to_string(),
1589                None,
1590                UnixNanos::from(1_000),
1591                RegisteredComponents::default(),
1592            ))
1593            .expect("open predecessor");
1594
1595        match crash_point {
1596            // An entry sitting only in the writer channel leaves no durable redb
1597            // footprint after process death, so these two fault points intentionally
1598            // recover from the same on-disk state.
1599            CrashPoint::BeforeEnqueue | CrashPoint::AfterEnqueueBeforeCommit => {}
1600            CrashPoint::AfterCommitBeforeSnapshot => {
1601                backend
1602                    .append_batch(&[append_run_started(1)])
1603                    .expect("append committed entry");
1604            }
1605            CrashPoint::AfterSnapshot => {
1606                backend
1607                    .append_batch(&[append_run_started(1)])
1608                    .expect("append committed entry");
1609                backend
1610                    .record_snapshot_anchor(SnapshotAnchor::new(
1611                        1,
1612                        "cache://snapshot/run-crash/1",
1613                        "blake3:abc",
1614                    ))
1615                    .expect("record snapshot anchor");
1616            }
1617        }
1618    }
1619
1620    #[rstest]
1621    fn halt_signal_callback_records_first_reason() {
1622        let signal = HaltSignal::new();
1623        let cb = signal.callback();
1624        cb(HaltReason::BackendDisk("ENOSPC".to_string()));
1625        cb(HaltReason::BackendError("second".to_string()));
1626
1627        assert!(signal.is_halted());
1628        match signal.reason() {
1629            Some(HaltReason::BackendDisk(msg)) => assert!(msg.contains("ENOSPC")),
1630            other => panic!("expected first reason BackendDisk, was {other:?}"),
1631        }
1632    }
1633
1634    #[rstest]
1635    fn recover_predecessors_returns_empty_for_missing_directory() {
1636        let tmp = TempDir::new().expect("tempdir");
1637        let outcome =
1638            recover_predecessors(tmp.path(), INSTANCE_ID).expect("recover empty directory");
1639        assert!(outcome.recovered.is_empty());
1640        assert!(outcome.parent_run_id.is_none());
1641    }
1642
1643    #[rstest]
1644    fn restore_cache_snapshot_blob_rejects_hash_mismatch() {
1645        let mut cache = Cache::default();
1646        let blob = Bytes::from_static(b"snapshot");
1647        let anchor =
1648            crate::SnapshotAnchor::new(0, "cache://position-snapshots/P-1/0", "blake3:bad");
1649
1650        cache
1651            .add(&anchor.blob_ref, blob)
1652            .expect("seed snapshot blob");
1653        let err =
1654            crate::restore_cache_snapshot_blob(&mut cache, Some(&anchor)).expect_err("hash error");
1655
1656        assert!(
1657            err.to_string().contains("content_hash mismatch"),
1658            "err was: {err}",
1659        );
1660    }
1661
1662    #[rstest]
1663    fn open_run_writes_run_started_and_advances_watermark() {
1664        let tmp = TempDir::new().expect("tempdir");
1665        let config = make_config(tmp.path().to_path_buf());
1666        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover empty");
1667        assert!(outcome.parent_run_id.is_none());
1668
1669        let halt = HaltSignal::new();
1670        let session = open_run(
1671            &config,
1672            INSTANCE_ID,
1673            build_run_id(UnixNanos::from(1_000)),
1674            outcome.parent_run_id,
1675            UnixNanos::from(1_000),
1676            &RegisteredComponents::default(),
1677            halt,
1678            get_atomic_clock_static(),
1679        )
1680        .expect("open run");
1681
1682        // Watermark + run-status snapshot.
1683        assert_eq!(session.high_watermark(), 1);
1684        assert_eq!(session.parent_run_id(), None);
1685
1686        // Every identity field must thread from EventStoreConfig into the manifest.
1687        // A field-swap mutation in build_manifest (e.g. assigning binary_hash from
1688        // config.identity.config_hash) would fail one of these assertions.
1689        let manifest = session.manifest();
1690        assert_eq!(manifest.instance_id, INSTANCE_ID);
1691        assert_eq!(manifest.status, RunStatus::Running);
1692        assert_eq!(manifest.binary_hash, "deadbeef");
1693        assert_eq!(manifest.schema_version, 1);
1694        assert_eq!(manifest.crate_versions, "feedface");
1695        assert_eq!(manifest.config_hash, "cafebabe");
1696        assert_eq!(manifest.start_ts_init, UnixNanos::from(1_000));
1697        assert_eq!(manifest.end_ts_init, None);
1698        assert!(
1699            manifest
1700                .feature_flags
1701                .contains(&"retention=full".to_string()),
1702            "feature_flags must record the retention mode, was {:?}",
1703            manifest.feature_flags,
1704        );
1705    }
1706
1707    #[rstest]
1708    fn lifecycle_options_default_registry_keeps_builtin_encoders() {
1709        let registry = EventStoreLifecycleOptions::default().build_registry();
1710
1711        assert!(registry.contains::<SubmitOrder>());
1712        assert!(registry.contains::<TradingCommand>());
1713        assert!(!registry.contains::<TestAuditMessage>());
1714    }
1715
1716    #[rstest]
1717    fn lifecycle_options_custom_registry_captures_registered_message() {
1718        let tmp = TempDir::new().expect("tempdir");
1719        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1720        let instance_id = UUID4::new();
1721        let options = EventStoreLifecycleOptions::new().with_encoder_registry(test_registry());
1722
1723        let mut store = EventStoreLifecycle::boot_with_options(
1724            Some(make_config(tmp.path().to_path_buf())),
1725            instance_id,
1726            clock_rc,
1727            options,
1728        )
1729        .expect("boot store");
1730        store
1731            .open(
1732                instance_id,
1733                &RegisteredComponents::default(),
1734                Environment::Backtest,
1735            )
1736            .expect("open run");
1737        let run_id = store.run_id().expect("run open").to_string();
1738
1739        let topic: MStr<msgbus::Topic> = MStr::from("events.test.audit");
1740        msgbus::publish_any(topic, &TestAuditMessage { value: 42 });
1741        store.seal(UnixNanos::from(0));
1742
1743        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
1744            .expect("open sealed");
1745        let captured = sealed
1746            .scan_seq(2)
1747            .expect("scan")
1748            .expect("captured entry present");
1749
1750        assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1751        assert_eq!(captured.topic.as_ref(), topic.as_str());
1752        assert_eq!(captured.payload.as_ref(), &[42]);
1753    }
1754
1755    #[rstest]
1756    fn lifecycle_options_memory_backend_opener_captures_and_seals() {
1757        let tmp = TempDir::new().expect("tempdir");
1758        let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1759        let opener_memory = Arc::clone(&memory);
1760        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1761        let instance_id = UUID4::new();
1762        let options = EventStoreLifecycleOptions::new()
1763            .with_encoder_registry(test_registry())
1764            .with_backend_opener(move |_, manifest| {
1765                opener_memory.lock().open_run(manifest.clone())?;
1766                Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1767            });
1768
1769        let mut store = EventStoreLifecycle::boot_with_options(
1770            Some(make_config(tmp.path().to_path_buf())),
1771            instance_id,
1772            clock_rc,
1773            options,
1774        )
1775        .expect("boot store");
1776        store
1777            .open(
1778                instance_id,
1779                &RegisteredComponents::default(),
1780                Environment::Backtest,
1781            )
1782            .expect("open run");
1783
1784        let topic: MStr<msgbus::Topic> = MStr::from("events.test.memory");
1785        msgbus::publish_any(topic, &TestAuditMessage { value: 7 });
1786        store.seal(UnixNanos::from(1_000));
1787
1788        let backend = memory.lock();
1789        let manifest = backend.manifest().expect("manifest");
1790        let captured = backend
1791            .scan_seq(2)
1792            .expect("scan")
1793            .expect("captured entry present");
1794
1795        assert_eq!(manifest.instance_id, instance_id.to_string());
1796        assert_eq!(manifest.status, RunStatus::Ended);
1797        assert_eq!(manifest.high_watermark, 3);
1798        assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
1799        assert_eq!(captured.topic.as_ref(), topic.as_str());
1800        assert_eq!(captured.payload.as_ref(), &[7]);
1801    }
1802
1803    #[cfg(madsim)]
1804    #[rstest]
1805    fn lifecycle_options_memory_backend_opener_captures_deterministic_seq_order_under_madsim() {
1806        let first = capture_madsim_memory_lifecycle_summary(42);
1807        let second = capture_madsim_memory_lifecycle_summary(42);
1808        let expected = expected_madsim_memory_entries();
1809
1810        assert_eq!(first.entries, second.entries);
1811        assert_eq!(first.entries, expected);
1812        assert_eq!(
1813            first
1814                .entries
1815                .iter()
1816                .map(|entry| entry.seq)
1817                .collect::<Vec<_>>(),
1818            vec![1, 2, 3, 4],
1819        );
1820        assert!(
1821            first.redb_files.is_empty(),
1822            "memory opener must not create redb files, was {:?}",
1823            first.redb_files,
1824        );
1825        assert!(
1826            second.redb_files.is_empty(),
1827            "memory opener must not create redb files, was {:?}",
1828            second.redb_files,
1829        );
1830    }
1831
1832    #[cfg(madsim)]
1833    fn expected_madsim_memory_entries() -> Vec<CapturedEntrySummary> {
1834        vec![
1835            CapturedEntrySummary {
1836                seq: 1,
1837                topic: RUN_STARTED_TOPIC.to_string(),
1838                payload_type: RUN_STARTED_PAYLOAD_TYPE.to_string(),
1839                payload: encode_run_started(&RegisteredComponents::default()).to_vec(),
1840                ts_init: UnixNanos::from(0),
1841                ts_publish: UnixNanos::from(10_000),
1842            },
1843            CapturedEntrySummary {
1844                seq: 2,
1845                topic: "events.test.madsim".to_string(),
1846                payload_type: "TestAuditMessage".to_string(),
1847                payload: vec![1],
1848                ts_init: UnixNanos::from(20_000),
1849                ts_publish: UnixNanos::from(20_000),
1850            },
1851            CapturedEntrySummary {
1852                seq: 3,
1853                topic: "events.test.madsim".to_string(),
1854                payload_type: "TestAuditMessage".to_string(),
1855                payload: vec![2],
1856                ts_init: UnixNanos::from(30_000),
1857                ts_publish: UnixNanos::from(30_000),
1858            },
1859            CapturedEntrySummary {
1860                seq: 4,
1861                topic: RUN_ENDED_TOPIC.to_string(),
1862                payload_type: RUN_ENDED_PAYLOAD_TYPE.to_string(),
1863                payload: Vec::new(),
1864                ts_init: UnixNanos::from(40_000),
1865                ts_publish: UnixNanos::from(40_000),
1866            },
1867        ]
1868    }
1869
1870    #[rstest]
1871    fn open_run_with_options_surfaces_backend_opener_error() {
1872        let tmp = TempDir::new().expect("tempdir");
1873        let config = make_config(tmp.path().to_path_buf());
1874        let options = EventStoreLifecycleOptions::new().with_backend_opener(|_, _| {
1875            Err(EventStoreError::Backend(
1876                "test backend open failed".to_string(),
1877            ))
1878        });
1879
1880        let err = open_run_with_options(
1881            &config,
1882            INSTANCE_ID,
1883            "run-open-error".to_string(),
1884            None,
1885            UnixNanos::from(5_000),
1886            &RegisteredComponents::default(),
1887            HaltSignal::new(),
1888            get_atomic_clock_static(),
1889            &options,
1890        )
1891        .expect_err("backend opener error must stop run open");
1892
1893        match err {
1894            BootError::EventStore(EventStoreError::Backend(msg)) => {
1895                assert!(msg.contains("test backend open failed"));
1896            }
1897            other => panic!("expected backend open failure, was {other:?}"),
1898        }
1899    }
1900
1901    #[cfg(madsim)]
1902    #[derive(Debug, PartialEq, Eq)]
1903    struct MadsimMemoryLifecycleCapture {
1904        entries: Vec<CapturedEntrySummary>,
1905        redb_files: Vec<PathBuf>,
1906    }
1907
1908    #[cfg(madsim)]
1909    #[derive(Debug, PartialEq, Eq)]
1910    struct CapturedEntrySummary {
1911        seq: u64,
1912        topic: String,
1913        payload_type: String,
1914        payload: Vec<u8>,
1915        ts_init: UnixNanos,
1916        ts_publish: UnixNanos,
1917    }
1918
1919    #[cfg(madsim)]
1920    fn capture_madsim_memory_lifecycle_summary(seed: u64) -> MadsimMemoryLifecycleCapture {
1921        get_atomic_clock_static().set_time(UnixNanos::from(10_000));
1922
1923        let tmp = TempDir::new().expect("tempdir");
1924        let memory = Arc::new(Mutex::new(MemoryBackend::new()));
1925        let opener_memory = Arc::clone(&memory);
1926        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1927        let instance_id = UUID4::new();
1928        let mut config = make_config(tmp.path().to_path_buf());
1929        config.identity.seed = Some(seed);
1930        let options = EventStoreLifecycleOptions::new()
1931            .with_encoder_registry(test_registry())
1932            .with_backend_opener(move |_, manifest| {
1933                opener_memory.lock().open_run(manifest.clone())?;
1934                Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
1935            });
1936
1937        let mut store =
1938            EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
1939                .expect("boot store");
1940        store
1941            .open(
1942                instance_id,
1943                &RegisteredComponents::default(),
1944                Environment::Backtest,
1945            )
1946            .expect("open run");
1947
1948        let topic: MStr<msgbus::Topic> = MStr::from("events.test.madsim");
1949        get_atomic_clock_static().set_time(UnixNanos::from(20_000));
1950        msgbus::publish_any(topic, &TestAuditMessage { value: 1 });
1951        get_atomic_clock_static().set_time(UnixNanos::from(30_000));
1952        msgbus::publish_any(topic, &TestAuditMessage { value: 2 });
1953        assert_eq!(
1954            store
1955                .session
1956                .as_ref()
1957                .expect("open session")
1958                .high_watermark(),
1959            3
1960        );
1961
1962        get_atomic_clock_static().set_time(UnixNanos::from(40_000));
1963        store.seal(UnixNanos::from(40_000));
1964
1965        let backend = memory.lock();
1966        let manifest = backend.manifest().expect("manifest");
1967        assert_eq!(manifest.seed, Some(seed));
1968        assert_eq!(manifest.status, RunStatus::Ended);
1969        assert_eq!(manifest.high_watermark, 4);
1970        let entries = backend
1971            .scan_range(1, manifest.high_watermark, ScanDirection::Forward)
1972            .expect("scan entries")
1973            .into_iter()
1974            .map(|entry| CapturedEntrySummary {
1975                seq: entry.seq,
1976                topic: entry.topic.as_ref().to_string(),
1977                payload_type: entry.payload_type.as_str().to_string(),
1978                payload: entry.payload.to_vec(),
1979                ts_init: entry.ts_init,
1980                ts_publish: entry.ts_publish,
1981            })
1982            .collect();
1983        drop(backend);
1984
1985        MadsimMemoryLifecycleCapture {
1986            entries,
1987            redb_files: redb_files_under(tmp.path()),
1988        }
1989    }
1990
1991    #[cfg(madsim)]
1992    fn redb_files_under(dir: &Path) -> Vec<PathBuf> {
1993        let mut paths = Vec::new();
1994        collect_redb_files(dir, &mut paths);
1995        paths.sort();
1996        paths
1997    }
1998
1999    #[cfg(madsim)]
2000    fn collect_redb_files(dir: &Path, paths: &mut Vec<PathBuf>) {
2001        for entry in std::fs::read_dir(dir).expect("read dir") {
2002            let path = entry.expect("dir entry").path();
2003            if path.is_dir() {
2004                collect_redb_files(&path, paths);
2005            } else if path
2006                .extension()
2007                .is_some_and(|extension| extension == "redb")
2008            {
2009                paths.push(path);
2010            }
2011        }
2012    }
2013
2014    #[rstest]
2015    fn close_seals_manifest_and_records_run_ended() {
2016        let tmp = TempDir::new().expect("tempdir");
2017        let config = make_config(tmp.path().to_path_buf());
2018
2019        let halt = HaltSignal::new();
2020        let mut session = open_run(
2021            &config,
2022            INSTANCE_ID,
2023            build_run_id(UnixNanos::from(2_000)),
2024            None,
2025            UnixNanos::from(2_000),
2026            &RegisteredComponents::default(),
2027            halt,
2028            get_atomic_clock_static(),
2029        )
2030        .expect("open run");
2031
2032        let run_id = session.run_id().to_string();
2033        session.close(UnixNanos::from(3_000)).expect("close");
2034
2035        let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2036        let manifest = manifests
2037            .into_iter()
2038            .find(|m| m.run_id == run_id)
2039            .expect("manifest present");
2040        assert_eq!(manifest.status, RunStatus::Ended);
2041        assert!(manifest.high_watermark >= 2);
2042    }
2043
2044    #[rstest]
2045    fn snapshot_anchorer_persists_anchor_for_open_session() {
2046        let tmp = TempDir::new().expect("tempdir");
2047        let config = make_config(tmp.path().to_path_buf());
2048
2049        let halt = HaltSignal::new();
2050        let mut session = open_run(
2051            &config,
2052            INSTANCE_ID,
2053            build_run_id(UnixNanos::from(4_000)),
2054            None,
2055            UnixNanos::from(4_000),
2056            &RegisteredComponents::default(),
2057            halt,
2058            get_atomic_clock_static(),
2059        )
2060        .expect("open run");
2061
2062        let run_id = session.run_id().to_string();
2063
2064        {
2065            let anchorer = session.snapshot_anchorer().expect("snapshot anchorer");
2066            anchorer(CacheSnapshotRef::new(
2067                "cache://position-snapshots/P-1/0",
2068                Bytes::from_static(b"snapshot"),
2069            ))
2070            .expect("record snapshot anchor");
2071        }
2072
2073        session.close(UnixNanos::from(4_500)).expect("close");
2074
2075        let reader =
2076            RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &run_id).expect("open sealed");
2077        let anchor = reader
2078            .latest_snapshot_anchor()
2079            .expect("latest snapshot anchor")
2080            .expect("anchor present");
2081
2082        assert_eq!(anchor.high_watermark, 1);
2083        assert_eq!(anchor.blob_ref, "cache://position-snapshots/P-1/0");
2084        assert_eq!(
2085            anchor.content_hash,
2086            compute_snapshot_content_hash(b"snapshot"),
2087        );
2088    }
2089
2090    #[rstest]
2091    fn recovery_seals_tail_ending_in_run_ended_as_ended_not_crashed() {
2092        // The writer commits RunEnded before sealing the manifest. A crash between
2093        // those two steps leaves the manifest Running while the tail already proves
2094        // graceful close: recovery must seal as Ended (not CrashedRecovered) and
2095        // must not chain the next run to it as a crashed parent.
2096        //
2097        // Reproduce the in-between state by submitting a RunEnded draft through the
2098        // writer's normal append path and then dropping the session without going
2099        // through close() (which is what would have sealed the manifest).
2100        let tmp = TempDir::new().expect("tempdir");
2101        let config = make_config(tmp.path().to_path_buf());
2102
2103        let halt = HaltSignal::new();
2104        let run_id = build_run_id(UnixNanos::from(7_000));
2105        let session = open_run(
2106            &config,
2107            INSTANCE_ID,
2108            run_id.clone(),
2109            None,
2110            UnixNanos::from(7_000),
2111            &RegisteredComponents::default(),
2112            halt,
2113            get_atomic_clock_static(),
2114        )
2115        .expect("open run");
2116
2117        let writer = session.writer.as_ref().expect("writer attached");
2118        writer
2119            .submit(run_ended_draft(UnixNanos::from(7_500)))
2120            .expect("submit RunEnded as tail entry");
2121        // Wait until the writer durably commits the RunEnded entry before dropping;
2122        // otherwise the on-disk tail might not include it and the recovery test
2123        // would fall back to CrashedRecovered for an unrelated reason.
2124        let deadline = Instant::now() + Duration::from_secs(2);
2125
2126        while session.high_watermark() < 2 {
2127            assert!(
2128                Instant::now() < deadline,
2129                "writer high_watermark stuck at {} before deadline",
2130                session.high_watermark(),
2131            );
2132            thread::sleep(Duration::from_millis(2));
2133        }
2134        drop(session);
2135
2136        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2137        assert_eq!(outcome.recovered.len(), 1);
2138        assert_eq!(outcome.recovered[0].run_id, run_id);
2139        assert_eq!(
2140            outcome.recovered[0].status,
2141            RunStatus::Ended,
2142            "tail ending in RunEnded must seal as Ended",
2143        );
2144        assert!(
2145            outcome.parent_run_id.is_none(),
2146            "Ended runs must not become parents",
2147        );
2148
2149        let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2150        let manifest = manifests
2151            .into_iter()
2152            .find(|m| m.run_id == run_id)
2153            .expect("manifest present");
2154        assert_eq!(manifest.status, RunStatus::Ended);
2155    }
2156
2157    #[rstest]
2158    fn recovery_quarantines_run_with_rows_swapped_between_keys() {
2159        // A row moved under a different table key still hashes correctly; the sweep
2160        // must quarantine rather than chain the next run through a tampered parent.
2161        let tmp = TempDir::new().expect("tempdir");
2162        let config = make_config(tmp.path().to_path_buf());
2163        let run_id = "1700000000-swapped1";
2164
2165        let path = {
2166            let mut backend = RedbBackend::new(config.base_dir.clone());
2167            backend.open_run(manifest_for(run_id)).expect("open run");
2168            backend
2169                .append_batch(&[
2170                    append_entry(
2171                        1,
2172                        "events.order.1",
2173                        "OrderAccepted",
2174                        Bytes::from_static(b"\x01"),
2175                    ),
2176                    append_entry(
2177                        2,
2178                        "events.order.2",
2179                        "OrderFilled",
2180                        Bytes::from_static(b"\x02"),
2181                    ),
2182                ])
2183                .expect("append");
2184            config
2185                .base_dir
2186                .join(INSTANCE_ID)
2187                .join(format!("{run_id}.redb"))
2188        };
2189
2190        {
2191            let entries: redb::TableDefinition<u64, &[u8]> = redb::TableDefinition::new("entries");
2192            let db = redb::Database::create(&path).expect("open redb");
2193            let txn = db.begin_write().expect("begin write");
2194            {
2195                let mut table = txn.open_table(entries).expect("open table");
2196                let bytes_1 = table
2197                    .remove(1_u64)
2198                    .expect("remove seq 1")
2199                    .expect("seq 1 present")
2200                    .value()
2201                    .to_vec();
2202                let bytes_2 = table
2203                    .remove(2_u64)
2204                    .expect("remove seq 2")
2205                    .expect("seq 2 present")
2206                    .value()
2207                    .to_vec();
2208                table
2209                    .insert(1_u64, bytes_2.as_slice())
2210                    .expect("insert under key 1");
2211                table
2212                    .insert(2_u64, bytes_1.as_slice())
2213                    .expect("insert under key 2");
2214            }
2215            txn.commit().expect("commit swap");
2216        }
2217
2218        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2219
2220        assert_eq!(outcome.recovered.len(), 1);
2221        assert_eq!(outcome.recovered[0].run_id, run_id);
2222        assert_eq!(outcome.recovered[0].status, RunStatus::Quarantined);
2223        assert!(
2224            outcome.parent_run_id.is_none(),
2225            "quarantined runs must not become parents",
2226        );
2227
2228        let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2229        assert_eq!(manifests[0].status, RunStatus::Quarantined);
2230    }
2231
2232    #[rstest]
2233    fn drop_without_close_leaves_run_for_next_boot_to_recover() {
2234        let tmp = TempDir::new().expect("tempdir");
2235        let config = make_config(tmp.path().to_path_buf());
2236
2237        let halt = HaltSignal::new();
2238        let session = open_run(
2239            &config,
2240            INSTANCE_ID,
2241            build_run_id(UnixNanos::from(4_000)),
2242            None,
2243            UnixNanos::from(4_000),
2244            &RegisteredComponents::default(),
2245            halt,
2246            get_atomic_clock_static(),
2247        )
2248        .expect("open run");
2249        let run_id = session.run_id().to_string();
2250        drop(session);
2251
2252        let outcome =
2253            recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover after crash");
2254        assert_eq!(outcome.recovered.len(), 1);
2255        assert_eq!(outcome.recovered[0].run_id, run_id);
2256        assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2257        assert_eq!(outcome.parent_run_id.as_deref(), Some(run_id.as_str()));
2258
2259        let manifests = RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2260        let sealed = manifests
2261            .into_iter()
2262            .find(|m| m.run_id == run_id)
2263            .expect("present");
2264        assert_eq!(sealed.status, RunStatus::CrashedRecovered);
2265    }
2266
2267    #[rstest]
2268    fn build_run_id_uses_expected_format() {
2269        // Format: "<start_ts_init>-<8 hex chars>". The prefix is sortable by start
2270        // time so directory listings produce chronological order; the suffix
2271        // disambiguates concurrent starts at the same nanosecond.
2272        let id = build_run_id(UnixNanos::from(123_456));
2273        let (prefix, suffix) = id.split_once('-').expect("run id must contain a hyphen");
2274        assert_eq!(prefix, "123456");
2275        assert_eq!(suffix.len(), 8, "suffix was {suffix:?}");
2276        assert!(
2277            suffix.chars().all(|c| c.is_ascii_hexdigit()),
2278            "suffix must be hex, was {suffix:?}",
2279        );
2280    }
2281
2282    #[rstest]
2283    fn crash_recovery_seals_predecessor_and_links_parent_run_id() {
2284        // SPEC Phase 7 acceptance: kill mid-run, restart, assert the predecessor seals
2285        // as CrashedRecovered, the new run's parent_run_id points to it, and the new
2286        // run's first entry is a RunStarted at seq=1.
2287        let tmp = TempDir::new().expect("tempdir");
2288        let config = make_config(tmp.path().to_path_buf());
2289
2290        // Kernel boot 1: open a run and crash (drop the session without close).
2291        let halt_first = HaltSignal::new();
2292        let first = open_run(
2293            &config,
2294            INSTANCE_ID,
2295            build_run_id(UnixNanos::from(10_000)),
2296            None,
2297            UnixNanos::from(10_000),
2298            &RegisteredComponents::default(),
2299            halt_first,
2300            get_atomic_clock_static(),
2301        )
2302        .expect("open first run");
2303        let crashed_run_id = first.run_id().to_string();
2304        drop(first);
2305
2306        // Kernel boot 2: recover predecessors then open the next run.
2307        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2308        assert_eq!(outcome.recovered.len(), 1);
2309        assert_eq!(outcome.recovered[0].run_id, crashed_run_id);
2310        assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2311        assert_eq!(
2312            outcome.parent_run_id.as_deref(),
2313            Some(crashed_run_id.as_str())
2314        );
2315
2316        // Predecessor's on-disk manifest is sealed CrashedRecovered.
2317        let manifests_after_seal =
2318            RedbBackend::list_runs(&config.base_dir, INSTANCE_ID).expect("list");
2319        let predecessor = manifests_after_seal
2320            .iter()
2321            .find(|m| m.run_id == crashed_run_id)
2322            .expect("predecessor present");
2323        assert_eq!(predecessor.status, RunStatus::CrashedRecovered);
2324
2325        // New run is opened with parent_run_id pointing to the predecessor.
2326        let halt_second = HaltSignal::new();
2327        let new_run_id = build_run_id(UnixNanos::from(20_000));
2328        let next = open_run(
2329            &config,
2330            INSTANCE_ID,
2331            new_run_id.clone(),
2332            outcome.parent_run_id,
2333            UnixNanos::from(20_000),
2334            &RegisteredComponents::default(),
2335            halt_second,
2336            get_atomic_clock_static(),
2337        )
2338        .expect("open second run");
2339        assert_eq!(next.parent_run_id(), Some(crashed_run_id.as_str()));
2340        assert_eq!(
2341            next.manifest().parent_run_id.as_deref(),
2342            Some(crashed_run_id.as_str()),
2343        );
2344        assert_eq!(next.high_watermark(), 1, "RunStarted is the first entry");
2345
2346        // The first entry in the new run is RunStarted at seq=1; close cleanly so we
2347        // can read the on-disk file without contending with the writer's lock.
2348        drop(next);
2349        let outcome_after = recover_predecessors(&config.base_dir, INSTANCE_ID)
2350            .expect("recover after second crash");
2351        // Only the second run shows up because the first is already sealed.
2352        assert_eq!(outcome_after.recovered.len(), 1);
2353        assert_eq!(outcome_after.recovered[0].run_id, new_run_id);
2354        assert_eq!(
2355            outcome_after.recovered[0].status,
2356            RunStatus::CrashedRecovered,
2357        );
2358
2359        // Open the recovered run read-only and verify seq=1 is RunStarted.
2360        let sealed = RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &new_run_id)
2361            .expect("open sealed");
2362        let first_entry = sealed
2363            .scan_seq(1)
2364            .expect("scan")
2365            .expect("RunStarted present");
2366        assert_eq!(first_entry.payload_type.as_str(), "RunStarted");
2367        assert_eq!(first_entry.topic.as_ref(), "run.lifecycle.RunStarted");
2368    }
2369
2370    #[rstest]
2371    #[case::before_enqueue(CrashPoint::BeforeEnqueue, 0, false)]
2372    #[case::after_enqueue_before_commit(CrashPoint::AfterEnqueueBeforeCommit, 0, false)]
2373    #[case::after_commit_before_snapshot(CrashPoint::AfterCommitBeforeSnapshot, 1, false)]
2374    #[case::after_snapshot(CrashPoint::AfterSnapshot, 1, true)]
2375    fn crash_recovery_matrix_seals_predecessor_and_links_parent_run_id(
2376        #[case] crash_point: CrashPoint,
2377        #[case] expected_hwm: u64,
2378        #[case] expect_snapshot_anchor: bool,
2379    ) {
2380        let tmp = TempDir::new().expect("tempdir");
2381        let config = make_config(tmp.path().to_path_buf());
2382        let predecessor_run_id = format!("3000-{crash_point:?}");
2383        seed_crashed_predecessor(&config, &predecessor_run_id, crash_point);
2384
2385        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2386        assert_eq!(outcome.recovered.len(), 1);
2387        assert_eq!(outcome.recovered[0].run_id, predecessor_run_id);
2388        assert_eq!(outcome.recovered[0].status, RunStatus::CrashedRecovered);
2389        assert_eq!(
2390            outcome.parent_run_id.as_deref(),
2391            Some(predecessor_run_id.as_str()),
2392        );
2393
2394        let predecessor =
2395            RedbBackend::open_sealed(&config.base_dir, INSTANCE_ID, &predecessor_run_id)
2396                .expect("open sealed predecessor");
2397        let manifest = predecessor.manifest().expect("manifest");
2398        let snapshot_anchor = predecessor.latest_snapshot_anchor().expect("anchor read");
2399
2400        assert_eq!(manifest.status, RunStatus::CrashedRecovered);
2401        assert_eq!(manifest.high_watermark, expected_hwm);
2402        assert_eq!(
2403            snapshot_anchor.is_some(),
2404            expect_snapshot_anchor,
2405            "snapshot anchor presence must match crash point",
2406        );
2407
2408        let next = open_run(
2409            &config,
2410            INSTANCE_ID,
2411            "4000-next".to_string(),
2412            outcome.parent_run_id,
2413            UnixNanos::from(4_000),
2414            &RegisteredComponents::default(),
2415            HaltSignal::new(),
2416            get_atomic_clock_static(),
2417        )
2418        .expect("open next run");
2419        assert_eq!(next.parent_run_id(), Some(predecessor_run_id.as_str()));
2420        assert_eq!(
2421            next.manifest().parent_run_id.as_deref(),
2422            Some(predecessor_run_id.as_str()),
2423        );
2424    }
2425
2426    #[rstest]
2427    fn kernel_event_store_open_seals_leftover_session_before_reopen() {
2428        // BacktestEngine::run -> reset -> run reuses the kernel. EventStoreLifecycle::open
2429        // must seal any leftover session before opening a fresh one so RunStarted is
2430        // the first entry of every run. The UUID suffix in build_run_id keeps the
2431        // two ids distinct even though TestClock holds start_ts_init at zero.
2432        let tmp = TempDir::new().expect("tempdir");
2433        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2434        let instance_id = UUID4::new();
2435
2436        let mut store = EventStoreLifecycle::boot(
2437            Some(make_config(tmp.path().to_path_buf())),
2438            instance_id,
2439            clock_rc,
2440        )
2441        .expect("boot store");
2442
2443        store
2444            .open(
2445                instance_id,
2446                &RegisteredComponents::default(),
2447                Environment::Backtest,
2448            )
2449            .expect("open first run");
2450        let run_one = store.run_id().expect("run one open").to_string();
2451
2452        store
2453            .open(
2454                instance_id,
2455                &RegisteredComponents::default(),
2456                Environment::Backtest,
2457            )
2458            .expect("open second run");
2459        let run_two = store.run_id().expect("run two open").to_string();
2460
2461        assert_ne!(run_one, run_two, "second open must produce a fresh run id");
2462
2463        // Drop the wrapper so any open run seals via Drop, then read both manifests
2464        // off disk and assert each closed cleanly as Ended.
2465        drop(store);
2466        let manifests =
2467            RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2468        let m1 = manifests
2469            .iter()
2470            .find(|m| m.run_id == run_one)
2471            .expect("first run present");
2472        let m2 = manifests
2473            .iter()
2474            .find(|m| m.run_id == run_two)
2475            .expect("second run present");
2476        assert_eq!(m1.status, RunStatus::Ended);
2477        assert_eq!(m2.status, RunStatus::Ended);
2478    }
2479
2480    #[rstest]
2481    fn open_after_halt_re_arms_signal_and_next_run_seals_ended() {
2482        // One halt must be terminal for the run that fired it, not for the kernel: a
2483        // rerun (reset -> run) opens with a fresh signal, reports no stale halt, and
2484        // its graceful stop still seals Ended.
2485        let tmp = TempDir::new().expect("tempdir");
2486        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2487        let instance_id = UUID4::new();
2488
2489        let mut store = EventStoreLifecycle::boot(
2490            Some(make_config(tmp.path().to_path_buf())),
2491            instance_id,
2492            clock_rc,
2493        )
2494        .expect("boot store");
2495
2496        store
2497            .open(
2498                instance_id,
2499                &RegisteredComponents::default(),
2500                Environment::Backtest,
2501            )
2502            .expect("open first run");
2503        let run_one = store.run_id().expect("run one open").to_string();
2504
2505        store.halt.callback()(HaltReason::BackendDisk("ENOSPC".to_string()));
2506        assert!(store.halt.is_halted());
2507
2508        store
2509            .open(
2510                instance_id,
2511                &RegisteredComponents::default(),
2512                Environment::Backtest,
2513            )
2514            .expect("open second run after halt");
2515        let run_two = store.run_id().expect("run two open").to_string();
2516
2517        assert_ne!(run_one, run_two);
2518        assert!(!store.halt.is_halted(), "open must re-arm the halt signal");
2519        assert!(store.halt.reason().is_none());
2520
2521        drop(store);
2522        let manifests =
2523            RedbBackend::list_runs(tmp.path(), &instance_id.to_string()).expect("list runs");
2524        let m1 = manifests
2525            .iter()
2526            .find(|m| m.run_id == run_one)
2527            .expect("first run present");
2528        let m2 = manifests
2529            .iter()
2530            .find(|m| m.run_id == run_two)
2531            .expect("second run present");
2532        // The halted run skips the in-process seal; the recovery sweep on next boot
2533        // owns it. The post-halt rerun must close cleanly.
2534        assert_eq!(m1.status, RunStatus::Running);
2535        assert_eq!(m2.status, RunStatus::Ended);
2536    }
2537
2538    #[rstest]
2539    fn recover_picks_most_recent_crashed_recovered_as_parent() {
2540        // With multiple unsealed predecessors, the sweep must seal every one and the
2541        // new run's parent_run_id must point to the most recently started survivor.
2542        let tmp = TempDir::new().expect("tempdir");
2543        let config = make_config(tmp.path().to_path_buf());
2544
2545        for ts in [1_000_u64, 2_000_u64, 3_000_u64] {
2546            let session = open_run(
2547                &config,
2548                INSTANCE_ID,
2549                build_run_id(UnixNanos::from(ts)),
2550                None,
2551                UnixNanos::from(ts),
2552                &RegisteredComponents::default(),
2553                HaltSignal::new(),
2554                get_atomic_clock_static(),
2555            )
2556            .expect("open");
2557            drop(session);
2558        }
2559
2560        let outcome = recover_predecessors(&config.base_dir, INSTANCE_ID).expect("recover sweep");
2561        assert_eq!(outcome.recovered.len(), 3);
2562        assert!(
2563            outcome
2564                .recovered
2565                .iter()
2566                .all(|r| r.status == RunStatus::CrashedRecovered),
2567            "every predecessor must seal CrashedRecovered, was {:?}",
2568            outcome.recovered,
2569        );
2570        // Most-recent (start_ts_init=3_000) becomes the parent.
2571        let parent = outcome.parent_run_id.as_deref().expect("parent set");
2572        assert!(
2573            parent.starts_with("3000-"),
2574            "parent must be the run with the highest start_ts_init, was {parent}",
2575        );
2576    }
2577
2578    #[cfg(not(madsim))]
2579    #[rstest]
2580    fn submit_run_started_returns_timeout_when_writer_stalls() {
2581        // A backend whose append_batch never returns simulates a stuck writer. The
2582        // wait loop must surface BootError::RunStartedTimeout after the configured
2583        // ceiling elapses, never block indefinitely.
2584        let stub = StallBackend::default();
2585        let manifest = manifest_for("run-timeout");
2586        let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2587        backend.open_run(manifest).expect("open stub");
2588
2589        let halt = HaltSignal::new();
2590
2591        let writer = EventStoreWriter::spawn(
2592            backend,
2593            get_atomic_clock_static(),
2594            halt.callback(),
2595            WriterConfig::default(),
2596        )
2597        .expect("spawn writer");
2598
2599        let err = submit_run_started_blocking(
2600            &writer,
2601            &RegisteredComponents::default(),
2602            UnixNanos::from(100),
2603            &halt,
2604            Duration::from_millis(20),
2605        )
2606        .expect_err("must time out");
2607
2608        match err {
2609            BootError::RunStartedTimeout { timeout } => {
2610                assert_eq!(timeout, Duration::from_millis(20));
2611            }
2612            other => panic!("expected RunStartedTimeout, was {other:?}"),
2613        }
2614
2615        // Release the gate so the writer thread can exit cleanly before drop.
2616        stub.release();
2617    }
2618
2619    #[cfg(not(madsim))]
2620    #[rstest]
2621    fn submit_run_started_returns_halted_when_writer_halts_during_wait() {
2622        // A halt signal fired before the writer can commit must surface
2623        // BootError::HaltedDuringBoot with the recorded reason.
2624        let stub = StallBackend::default();
2625        let manifest = manifest_for("run-halt");
2626        let mut backend: Box<dyn EventStore + Send> = Box::new(stub.clone());
2627        backend.open_run(manifest).expect("open stub");
2628
2629        let halt = HaltSignal::new();
2630
2631        let writer = EventStoreWriter::spawn(
2632            backend,
2633            get_atomic_clock_static(),
2634            halt.callback(),
2635            WriterConfig::default(),
2636        )
2637        .expect("spawn writer");
2638
2639        // Fire the halt from a peer thread shortly after we begin waiting.
2640        let halt_for_thread = halt.clone();
2641
2642        let firer = thread::spawn(move || {
2643            thread::sleep(Duration::from_millis(10));
2644            halt_for_thread.callback()(HaltReason::BackendDisk("test stall".to_string()));
2645        });
2646
2647        let err = submit_run_started_blocking(
2648            &writer,
2649            &RegisteredComponents::default(),
2650            UnixNanos::from(200),
2651            &halt,
2652            Duration::from_secs(2),
2653        )
2654        .expect_err("must observe halt");
2655
2656        match err {
2657            BootError::HaltedDuringBoot(HaltReason::BackendDisk(msg)) => {
2658                assert!(msg.contains("test stall"), "reason msg was {msg}");
2659            }
2660            other => panic!("expected HaltedDuringBoot(BackendDisk), was {other:?}"),
2661        }
2662        firer.join().expect("halt firer joined");
2663        stub.release();
2664    }
2665
2666    fn manifest_for(run_id: &str) -> RunManifest {
2667        RunManifest {
2668            run_id: run_id.to_string(),
2669            parent_run_id: None,
2670            instance_id: INSTANCE_ID.to_string(),
2671            binary_hash: String::new(),
2672            schema_version: 1,
2673            crate_versions: String::new(),
2674            feature_flags: Vec::new(),
2675            adapter_versions: IndexMap::new(),
2676            config_hash: String::new(),
2677            registered_components: RegisteredComponents::default(),
2678            seed: None,
2679            start_ts_init: UnixNanos::default(),
2680            end_ts_init: None,
2681            high_watermark: 0,
2682            status: RunStatus::Running,
2683        }
2684    }
2685
2686    /// Stub backend whose `append_batch` blocks until `release()` is called. Used to
2687    /// hold the writer's high-watermark at zero so the boot path's wait loop can
2688    /// exercise its timeout and halt branches deterministically.
2689    #[cfg(not(madsim))]
2690    #[derive(Debug, Default, Clone)]
2691    struct StallBackend {
2692        inner: Arc<Mutex<StallInner>>,
2693        gate: Arc<(Mutex<bool>, parking_lot::Condvar)>,
2694    }
2695
2696    #[cfg(not(madsim))]
2697    #[derive(Debug, Default)]
2698    struct StallInner {
2699        manifest: Option<RunManifest>,
2700    }
2701
2702    #[cfg(not(madsim))]
2703    impl StallBackend {
2704        fn release(&self) {
2705            let (lock, cvar) = &*self.gate;
2706            *lock.lock() = true;
2707            cvar.notify_all();
2708        }
2709    }
2710
2711    #[cfg(not(madsim))]
2712    impl crate::EventStore for StallBackend {
2713        fn open_run(&mut self, manifest: RunManifest) -> Result<(), EventStoreError> {
2714            self.inner.lock().manifest = Some(manifest);
2715            Ok(())
2716        }
2717
2718        fn append_batch(&mut self, _: &[crate::AppendEntry]) -> Result<u64, EventStoreError> {
2719            let (lock, cvar) = &*self.gate;
2720            let mut released = lock.lock();
2721
2722            while !*released {
2723                cvar.wait(&mut released);
2724            }
2725            Ok(0)
2726        }
2727
2728        fn scan_range(
2729            &self,
2730            _: u64,
2731            _: u64,
2732            _: crate::ScanDirection,
2733        ) -> Result<Vec<crate::EventStoreEntry>, EventStoreError> {
2734            Ok(Vec::new())
2735        }
2736
2737        fn scan_seq(&self, _: u64) -> Result<Option<crate::EventStoreEntry>, EventStoreError> {
2738            Ok(None)
2739        }
2740
2741        fn lookup(&self, _: crate::IndexKind, _: &str) -> Result<Option<u64>, EventStoreError> {
2742            Ok(None)
2743        }
2744
2745        fn iter_index_keys(
2746            &self,
2747            _: crate::IndexKind,
2748        ) -> Result<Vec<(String, u64)>, EventStoreError> {
2749            Ok(Vec::new())
2750        }
2751
2752        fn seal(&mut self, _: RunStatus) -> Result<(), EventStoreError> {
2753            Ok(())
2754        }
2755
2756        fn manifest(&self) -> Result<RunManifest, EventStoreError> {
2757            self.inner
2758                .lock()
2759                .manifest
2760                .clone()
2761                .ok_or_else(|| EventStoreError::Backend("no manifest".to_string()))
2762        }
2763
2764        fn high_watermark(&self) -> Result<u64, EventStoreError> {
2765            Ok(0)
2766        }
2767    }
2768
2769    /// Integration: the kernel-installed bus tap forwards a `SubmitOrder` dispatched
2770    /// through the typed-send path into the event store before any subscriber observes
2771    /// it. The captured entry carries the dispatching endpoint as the topic and the
2772    /// canonical `SubmitOrder` payload type tag.
2773    #[rstest]
2774    fn bus_tap_captures_submit_order_sent_through_msgbus() {
2775        let tmp = TempDir::new().expect("tempdir");
2776        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2777        let instance_id = UUID4::new();
2778
2779        let mut store = EventStoreLifecycle::boot(
2780            Some(make_config(tmp.path().to_path_buf())),
2781            instance_id,
2782            clock_rc,
2783        )
2784        .expect("boot store");
2785        store
2786            .open(
2787                instance_id,
2788                &RegisteredComponents::default(),
2789                Environment::Backtest,
2790            )
2791            .expect("open run");
2792        let run_id = store.run_id().expect("run open").to_string();
2793
2794        let trader_id = TraderId::from("TRADER-001");
2795        let strategy_id = StrategyId::from("S-001");
2796        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
2797        let client_order_id = ClientOrderId::from("O-20260510-000001");
2798        let order_init = OrderInitializedSpec::builder()
2799            .instrument_id(instrument_id)
2800            .client_order_id(client_order_id)
2801            .quantity(Quantity::from("1"))
2802            .time_in_force(TimeInForce::Gtc)
2803            .build();
2804        let submit_order = SubmitOrder::new(
2805            trader_id,
2806            Some(ClientId::from("BINANCE")),
2807            strategy_id,
2808            instrument_id,
2809            client_order_id,
2810            order_init,
2811            None,
2812            None,
2813            None,
2814            UUID4::new(),
2815            UnixNanos::from(3),
2816            None, // correlation_id
2817        );
2818
2819        let endpoint = MStr::<Endpoint>::from("test.exec.engine.process");
2820        msgbus::send_any_value(endpoint, &submit_order);
2821
2822        store.seal(UnixNanos::from(0));
2823
2824        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
2825            .expect("open sealed");
2826        let captured = sealed
2827            .scan_seq(2)
2828            .expect("scan")
2829            .expect("captured entry present");
2830        assert_eq!(captured.payload_type.as_str(), "SubmitOrder");
2831        assert_eq!(captured.topic.as_ref(), endpoint.as_str());
2832
2833        // The SubmitOrder encoder commits a ClientOrderId sidecar index; the lookup
2834        // must resolve to the captured seq.
2835        let by_client = sealed
2836            .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
2837            .expect("lookup")
2838            .expect("indexed");
2839        assert_eq!(by_client, 2);
2840    }
2841
2842    #[rstest]
2843    fn kernel_with_markers_captures_snapshots_over_synthetic_bus() {
2844        let tmp = TempDir::new().expect("tempdir");
2845        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2846        let instance_id = UUID4::new();
2847        let mut config = make_config(tmp.path().to_path_buf());
2848        config.data_markers = Some(DataMarkerConfig {
2849            classes: vec![DataMarkerClass::BookDeltas, DataMarkerClass::Quote],
2850            safety_flush_interval: Duration::from_secs(1),
2851            channel_capacity: 128,
2852            high_fidelity: Vec::new(),
2853        });
2854
2855        let mut store =
2856            EventStoreLifecycle::boot(Some(config), instance_id, clock_rc).expect("boot store");
2857        store
2858            .open(
2859                instance_id,
2860                &RegisteredComponents::default(),
2861                Environment::Backtest,
2862            )
2863            .expect("open run");
2864        let run_id = store.run_id().expect("run open").to_string();
2865
2866        let first = make_submit_order(ClientOrderId::from("O-marker-1"));
2867        msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &first);
2868
2869        let quote = quote_ethusdt_binance();
2870        msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), &quote);
2871        let deltas = stub_deltas();
2872        msgbus::publish_deltas(MStr::from("data.deltas.XNAS.AAPL"), &deltas);
2873
2874        let second = make_submit_order(ClientOrderId::from("O-marker-2"));
2875        msgbus::send_any_value(MStr::<Endpoint>::from("test.exec.process"), &second);
2876        store.seal(UnixNanos::from(500));
2877
2878        let marker_path = tmp
2879            .path()
2880            .join(instance_id.to_string())
2881            .join(format!("{run_id}.markers.redb"));
2882        let marker = RedbMarkerBackend::open_read_only_file(marker_path).expect("open markers");
2883        let snapshots = marker.scan_snapshots().expect("scan snapshots");
2884        let dict = marker.scan_dict().expect("scan dict");
2885
2886        assert_eq!(snapshots.len(), 1);
2887        assert_eq!(snapshots[0].event_seq_before, 3);
2888        assert_eq!(snapshots[0].advanced.len(), 2);
2889        assert_eq!(
2890            snapshots[0]
2891                .advanced
2892                .iter()
2893                .map(|cursor| cursor.count)
2894                .collect::<Vec<_>>(),
2895            vec![1, 1]
2896        );
2897        assert_eq!(
2898            dict.iter()
2899                .map(|entry| (entry.data_cls, entry.identifier.as_str()))
2900                .collect::<Vec<_>>(),
2901            vec![
2902                (DataClass::Quote, "ETHUSDT-PERP.BINANCE"),
2903                (DataClass::BookDeltas, "AAPL.XNAS"),
2904            ],
2905        );
2906    }
2907
2908    #[rstest]
2909    fn boot_recovery_ignores_marker_sidecar_files() {
2910        let tmp = TempDir::new().expect("tempdir");
2911        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
2912        let instance_id = UUID4::new();
2913        let mut config = make_config(tmp.path().to_path_buf());
2914        config.data_markers = Some(DataMarkerConfig {
2915            classes: vec![DataMarkerClass::Quote],
2916            safety_flush_interval: Duration::from_secs(1),
2917            channel_capacity: 128,
2918            high_fidelity: Vec::new(),
2919        });
2920
2921        let mut store =
2922            EventStoreLifecycle::boot(Some(config.clone()), instance_id, Rc::clone(&clock_rc))
2923                .expect("boot store");
2924        store
2925            .open(
2926                instance_id,
2927                &RegisteredComponents::default(),
2928                Environment::Backtest,
2929            )
2930            .expect("open run");
2931        let run_id = store.run_id().expect("run open").to_string();
2932        store.seal(UnixNanos::from(500));
2933
2934        let marker_path = tmp
2935            .path()
2936            .join(instance_id.to_string())
2937            .join(format!("{run_id}.markers.redb"));
2938        assert!(marker_path.exists());
2939
2940        let rebooted = EventStoreLifecycle::boot(Some(config), instance_id, clock_rc)
2941            .expect("boot after marker sidecar");
2942
2943        assert!(rebooted.recovered().is_empty());
2944    }
2945
2946    #[rstest]
2947    fn marker_setup_failure_disables_markers_without_blocking_open() {
2948        let tmp = TempDir::new().expect("tempdir");
2949        let bad_base = tmp.path().join("not-a-directory");
2950        std::fs::write(&bad_base, b"not a directory").expect("write bad base");
2951        let memory = Arc::new(Mutex::new(MemoryBackend::new()));
2952        let opener_memory = Arc::clone(&memory);
2953        let mut config = make_config(bad_base);
2954        config.data_markers = Some(DataMarkerConfig {
2955            classes: vec![DataMarkerClass::Quote],
2956            safety_flush_interval: Duration::from_secs(1),
2957            channel_capacity: 128,
2958            high_fidelity: Vec::new(),
2959        });
2960        let options = EventStoreLifecycleOptions::new()
2961            .with_encoder_registry(test_registry())
2962            .with_backend_opener(move |_, manifest| {
2963                opener_memory.lock().open_run(manifest.clone())?;
2964                Ok(Box::new(SharedMemoryBackend(Arc::clone(&opener_memory))))
2965            });
2966
2967        let mut session = open_run_with_options(
2968            &config,
2969            INSTANCE_ID,
2970            "run-marker-setup-fails".to_string(),
2971            None,
2972            UnixNanos::from(5_000),
2973            &RegisteredComponents::default(),
2974            HaltSignal::new(),
2975            get_atomic_clock_static(),
2976            &options,
2977        )
2978        .expect("open run despite marker failure");
2979
2980        assert!(session.marker_capture.is_none());
2981
2982        let topic: MStr<msgbus::Topic> = MStr::from("events.test.marker-fallback");
2983        session
2984            .adapter()
2985            .expect("adapter")
2986            .capture::<TestAuditMessage>(
2987                topic,
2988                &TestAuditMessage { value: 11 },
2989                Headers::empty(),
2990                UnixNanos::from(5_001),
2991            )
2992            .expect("capture");
2993        session
2994            .close(UnixNanos::from(6_000))
2995            .expect("close session");
2996
2997        let backend = memory.lock();
2998        let captured = backend
2999            .scan_seq(2)
3000            .expect("scan")
3001            .expect("captured entry present");
3002
3003        assert_eq!(captured.payload_type.as_str(), "TestAuditMessage");
3004        assert_eq!(captured.topic.as_ref(), topic.as_str());
3005        assert_eq!(captured.payload.as_ref(), &[11]);
3006    }
3007
3008    #[rstest]
3009    fn marker_registry_factory_receives_enabled_classes() {
3010        let tmp = TempDir::new().expect("tempdir");
3011        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3012        let instance_id = UUID4::new();
3013        let seen_classes: Arc<Mutex<Vec<Vec<DataClass>>>> = Arc::new(Mutex::new(Vec::new()));
3014        let seen_for_factory = Arc::clone(&seen_classes);
3015        let mut config = make_config(tmp.path().to_path_buf());
3016        config.data_markers = Some(DataMarkerConfig {
3017            classes: vec![DataMarkerClass::Trade, DataMarkerClass::Quote],
3018            safety_flush_interval: Duration::from_secs(1),
3019            channel_capacity: 128,
3020            high_fidelity: Vec::new(),
3021        });
3022        let options =
3023            EventStoreLifecycleOptions::new().with_marker_registry_factory(move |classes| {
3024                seen_for_factory.lock().push(classes.to_vec());
3025                DataMarkerExtractorRegistry::default_registry(classes)
3026            });
3027
3028        let mut store =
3029            EventStoreLifecycle::boot_with_options(Some(config), instance_id, clock_rc, options)
3030                .expect("boot store");
3031        store
3032            .open(
3033                instance_id,
3034                &RegisteredComponents::default(),
3035                Environment::Backtest,
3036            )
3037            .expect("open run");
3038        store.seal(UnixNanos::from(1_000));
3039
3040        let seen = seen_classes.lock();
3041        assert_eq!(seen.as_slice(), &[vec![DataClass::Trade, DataClass::Quote]]);
3042    }
3043
3044    #[rstest]
3045    fn markers_disabled_installs_no_file_and_no_cost() {
3046        let tmp = TempDir::new().expect("tempdir");
3047        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3048        let instance_id = UUID4::new();
3049
3050        let mut store = EventStoreLifecycle::boot(
3051            Some(make_config(tmp.path().to_path_buf())),
3052            instance_id,
3053            clock_rc,
3054        )
3055        .expect("boot store");
3056        store
3057            .open(
3058                instance_id,
3059                &RegisteredComponents::default(),
3060                Environment::Backtest,
3061            )
3062            .expect("open run");
3063        let run_id = store.run_id().expect("run open").to_string();
3064
3065        assert!(
3066            store
3067                .session
3068                .as_ref()
3069                .expect("session")
3070                .marker_capture
3071                .is_none()
3072        );
3073
3074        let quote = quote_ethusdt_binance();
3075        msgbus::publish_quote(MStr::from("data.quotes.BINANCE.ETHUSDT-PERP"), &quote);
3076        store.seal(UnixNanos::from(500));
3077
3078        let marker_path = tmp
3079            .path()
3080            .join(instance_id.to_string())
3081            .join(format!("{run_id}.markers.redb"));
3082        assert!(!marker_path.exists());
3083    }
3084
3085    /// Fired clock events do not pass through normal message bus publish/send calls.
3086    /// `TimeEventHandler::run` must still hit the installed tap so timer-driven
3087    /// strategy logic has a durable trigger record.
3088    #[rstest]
3089    fn bus_tap_captures_time_event_handler_run() {
3090        let tmp = TempDir::new().expect("tempdir");
3091        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3092        let instance_id = UUID4::new();
3093
3094        let mut store = EventStoreLifecycle::boot(
3095            Some(make_config(tmp.path().to_path_buf())),
3096            instance_id,
3097            clock_rc,
3098        )
3099        .expect("boot store");
3100        store
3101            .open(
3102                instance_id,
3103                &RegisteredComponents::default(),
3104                Environment::Backtest,
3105            )
3106            .expect("open run");
3107        let run_id = store.run_id().expect("run open").to_string();
3108
3109        let event = TimeEvent::new(
3110            Ustr::from("strategy.heartbeat"),
3111            UUID4::new(),
3112            UnixNanos::from(100),
3113            UnixNanos::from(99),
3114        );
3115        let callback = TimeEventCallback::from(|_: TimeEvent| {});
3116        TimeEventHandler::new(event, callback).run();
3117
3118        store.seal(UnixNanos::from(0));
3119
3120        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3121            .expect("open sealed");
3122        let captured = sealed
3123            .scan_seq(2)
3124            .expect("scan")
3125            .expect("captured entry present");
3126
3127        assert_eq!(captured.payload_type.as_str(), PAYLOAD_TYPE_TIME_EVENT);
3128        assert_eq!(captured.topic, MessagingSwitchboard::time_event_topic());
3129    }
3130
3131    /// `EventStoreLifecycle::seal` must clear the bus tap so a publish issued after the
3132    /// run closes cannot reach the sealed writer. Without the clear, the dropped
3133    /// adapter would still receive captures and `Arc::try_unwrap` inside close would
3134    /// fail with multiple owners.
3135    #[rstest]
3136    fn seal_clears_bus_tap_so_post_seal_dispatches_do_not_capture() {
3137        let tmp = TempDir::new().expect("tempdir");
3138        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3139        let instance_id = UUID4::new();
3140
3141        let mut store = EventStoreLifecycle::boot(
3142            Some(make_config(tmp.path().to_path_buf())),
3143            instance_id,
3144            clock_rc,
3145        )
3146        .expect("boot store");
3147        store
3148            .open(
3149                instance_id,
3150                &RegisteredComponents::default(),
3151                Environment::Backtest,
3152            )
3153            .expect("open run");
3154        let run_id = store.run_id().expect("run open").to_string();
3155
3156        store.seal(UnixNanos::from(0));
3157
3158        // Post-seal dispatch: any tap that survived would either capture into the
3159        // dropped writer (panic via the channel close path) or hold the adapter Arc
3160        // and fail the close try_unwrap. The session is already gone, so this just
3161        // exercises the cleared-tap path through msgbus dispatch.
3162        let endpoint = MStr::<Endpoint>::from("test.post.seal.endpoint");
3163        let payload: u32 = 99;
3164        msgbus::send_any_value(endpoint, &payload);
3165
3166        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3167            .expect("open sealed");
3168        // RunStarted at seq=1, RunEnded at seq=2; no captured u32 entry must exist
3169        assert!(
3170            sealed.scan_seq(3).expect("scan").is_none(),
3171            "no entry must land after seal",
3172        );
3173    }
3174
3175    /// Production code reaches the bus tap with [`TradingCommand`] wrapped around the
3176    /// inner command (the wrapper's `TypeId`, not `SubmitOrder`'s). The envelope
3177    /// dispatcher in [`default_registry`] must unwrap the variant, stamp the inner
3178    /// `payload_type` (`SubmitOrder`), and commit the same indices the bare-type encoder
3179    /// would have produced.
3180    #[rstest]
3181    fn bus_tap_captures_trading_command_envelope_with_inner_payload_type() {
3182        let tmp = TempDir::new().expect("tempdir");
3183        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3184        let instance_id = UUID4::new();
3185
3186        let mut store = EventStoreLifecycle::boot(
3187            Some(make_config(tmp.path().to_path_buf())),
3188            instance_id,
3189            clock_rc,
3190        )
3191        .expect("boot store");
3192        store
3193            .open(
3194                instance_id,
3195                &RegisteredComponents::default(),
3196                Environment::Backtest,
3197            )
3198            .expect("open run");
3199        let run_id = store.run_id().expect("run open").to_string();
3200
3201        let trader_id = TraderId::from("TRADER-001");
3202        let strategy_id = StrategyId::from("S-001");
3203        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3204        let client_order_id = ClientOrderId::from("O-20260510-000002");
3205        let order_init = OrderInitializedSpec::builder()
3206            .instrument_id(instrument_id)
3207            .client_order_id(client_order_id)
3208            .quantity(Quantity::from("1"))
3209            .time_in_force(TimeInForce::Gtc)
3210            .build();
3211        let submit_order = SubmitOrder::new(
3212            trader_id,
3213            Some(ClientId::from("BINANCE")),
3214            strategy_id,
3215            instrument_id,
3216            client_order_id,
3217            order_init,
3218            None,
3219            None,
3220            None,
3221            UUID4::new(),
3222            UnixNanos::from(3),
3223            None, // correlation_id
3224        );
3225        let command = TradingCommand::SubmitOrder(submit_order.clone());
3226
3227        let endpoint = MStr::<Endpoint>::from("test.exec.engine.envelope");
3228        msgbus::send_trading_command(endpoint, command);
3229
3230        store.seal(UnixNanos::from(0));
3231
3232        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3233            .expect("open sealed");
3234        let captured = sealed
3235            .scan_seq(2)
3236            .expect("scan")
3237            .expect("captured entry present");
3238        assert_eq!(
3239            captured.payload_type.as_str(),
3240            "SubmitOrder",
3241            "wrapper envelope must stamp the inner payload_type",
3242        );
3243        assert_eq!(captured.topic.as_ref(), endpoint.as_str());
3244
3245        let by_client = sealed
3246            .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3247            .expect("lookup")
3248            .expect("indexed");
3249        assert_eq!(by_client, 2);
3250
3251        // Round-trip the captured payload back through the inner-type decoder so the
3252        // bytes-equal-bare invariant is checked at the integration layer too: a mutation
3253        // that wrote the wrapper-typed bytes instead of the inner would fail here.
3254        let decoded: SubmitOrder =
3255            rmp_serde::from_slice(&captured.payload).expect("decode captured SubmitOrder");
3256        assert_eq!(decoded, submit_order);
3257    }
3258
3259    /// `publish_order_event` reaches the bus tap with `OrderEventAny::Filled(...)`; the
3260    /// envelope dispatcher must unwrap to `OrderFilled`, stamp `OrderFilled` as the
3261    /// `payload_type`, and commit both the `client_order_id` and `venue_order_id` indices.
3262    #[rstest]
3263    fn bus_tap_captures_order_event_any_envelope_with_inner_payload_type() {
3264        let tmp = TempDir::new().expect("tempdir");
3265        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3266        let instance_id = UUID4::new();
3267
3268        let mut store = EventStoreLifecycle::boot(
3269            Some(make_config(tmp.path().to_path_buf())),
3270            instance_id,
3271            clock_rc,
3272        )
3273        .expect("boot store");
3274        store
3275            .open(
3276                instance_id,
3277                &RegisteredComponents::default(),
3278                Environment::Backtest,
3279            )
3280            .expect("open run");
3281        let run_id = store.run_id().expect("run open").to_string();
3282
3283        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
3284        let client_order_id = ClientOrderId::from("O-20260510-000003");
3285        let venue_order_id = VenueOrderId::from("V-99");
3286        let filled = OrderFilledSpec::builder()
3287            .instrument_id(instrument_id)
3288            .client_order_id(client_order_id)
3289            .venue_order_id(venue_order_id)
3290            .account_id(AccountId::from("BINANCE-001"))
3291            .trade_id(TradeId::from("T-1"))
3292            .last_qty(Quantity::from("1"))
3293            .last_px(Price::from("100.00"))
3294            .currency(Currency::USDT())
3295            .ts_event(UnixNanos::from(10))
3296            .ts_init(UnixNanos::from(11))
3297            .commission(Money::new(0.10, Currency::USDT()))
3298            .build();
3299        let event = OrderEventAny::Filled(filled.clone());
3300
3301        let topic: MStr<msgbus::Topic> = MStr::from("events.order.ETHUSDT-PERP.BINANCE");
3302        msgbus::publish_order_event(topic, &event);
3303
3304        store.seal(UnixNanos::from(0));
3305
3306        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3307            .expect("open sealed");
3308        let captured = sealed
3309            .scan_seq(2)
3310            .expect("scan")
3311            .expect("captured entry present");
3312        assert_eq!(
3313            captured.payload_type.as_str(),
3314            "OrderFilled",
3315            "wrapper envelope must stamp the inner payload_type",
3316        );
3317        assert_eq!(captured.topic.as_ref(), topic.as_str());
3318
3319        let by_client = sealed
3320            .lookup(IndexKind::ClientOrderId, client_order_id.as_str())
3321            .expect("lookup")
3322            .expect("indexed");
3323        let by_venue = sealed
3324            .lookup(IndexKind::VenueOrderId, venue_order_id.as_str())
3325            .expect("lookup")
3326            .expect("indexed");
3327        assert_eq!(by_client, 2);
3328        assert_eq!(by_venue, 2);
3329
3330        // Round-trip the captured payload back through the inner-type decoder so a
3331        // mutation that wrote the wrapper-typed bytes instead of the inner would fail
3332        // here rather than only at the unit-level bytes-equal-bare check.
3333        let decoded: OrderFilled =
3334            rmp_serde::from_slice(&captured.payload).expect("decode captured OrderFilled");
3335        assert_eq!(decoded, filled);
3336    }
3337
3338    /// `send_data_command` reaches the bus tap with the [`DataCommand`] wrapper. The
3339    /// envelope dispatcher must unwrap to the request/subscription category, stamp that
3340    /// category as the `payload_type`, and write bytes that decode as the inner command
3341    /// enum.
3342    #[rstest]
3343    fn bus_tap_captures_data_command_envelopes_with_category_payload_types() {
3344        let tmp = TempDir::new().expect("tempdir");
3345        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3346        let instance_id = UUID4::new();
3347
3348        let mut store = EventStoreLifecycle::boot(
3349            Some(make_config(tmp.path().to_path_buf())),
3350            instance_id,
3351            clock_rc,
3352        )
3353        .expect("boot store");
3354        store
3355            .open(
3356                instance_id,
3357                &RegisteredComponents::default(),
3358                Environment::Backtest,
3359            )
3360            .expect("open run");
3361        let run_id = store.run_id().expect("run open").to_string();
3362
3363        let request = RequestCommand::Quotes(RequestQuotes::new(
3364            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3365            None,
3366            None,
3367            None,
3368            Some(ClientId::from("BINANCE")),
3369            UUID4::new(),
3370            UnixNanos::from(20),
3371            None,
3372        ));
3373        let subscribe = SubscribeCommand::Quotes(SubscribeQuotes::new(
3374            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3375            Some(ClientId::from("BINANCE")),
3376            Some(Venue::from("BINANCE")),
3377            UUID4::new(),
3378            UnixNanos::from(21),
3379            Some(UUID4::new()),
3380            None,
3381        ));
3382
3383        let request_endpoint = MStr::<Endpoint>::from("test.data.engine.request");
3384        msgbus::send_data_command(request_endpoint, DataCommand::Request(request.clone()));
3385
3386        let subscribe_endpoint = MStr::<Endpoint>::from("test.data.engine.subscribe");
3387        msgbus::send_data_command(
3388            subscribe_endpoint,
3389            DataCommand::Subscribe(subscribe.clone()),
3390        );
3391
3392        store.seal(UnixNanos::from(0));
3393
3394        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3395            .expect("open sealed");
3396        let captured_request = sealed
3397            .scan_seq(2)
3398            .expect("scan request")
3399            .expect("captured request present");
3400        assert_eq!(captured_request.payload_type.as_str(), "RequestCommand");
3401        assert_eq!(captured_request.topic.as_ref(), request_endpoint.as_str());
3402
3403        let decoded_request: RequestCommand =
3404            rmp_serde::from_slice(&captured_request.payload).expect("decode RequestCommand");
3405        match (decoded_request, request) {
3406            (RequestCommand::Quotes(decoded), RequestCommand::Quotes(expected)) => {
3407                assert_eq!(decoded.request_id, expected.request_id);
3408                assert_eq!(decoded.instrument_id, expected.instrument_id);
3409                assert_eq!(decoded.client_id, expected.client_id);
3410                assert_eq!(decoded.ts_init, expected.ts_init);
3411            }
3412            other => panic!("expected RequestCommand::Quotes round trip, was {other:?}"),
3413        }
3414
3415        let captured_subscribe = sealed
3416            .scan_seq(3)
3417            .expect("scan subscribe")
3418            .expect("captured subscribe present");
3419        assert_eq!(captured_subscribe.payload_type.as_str(), "SubscribeCommand");
3420        assert_eq!(
3421            captured_subscribe.topic.as_ref(),
3422            subscribe_endpoint.as_str()
3423        );
3424
3425        let decoded_subscribe: SubscribeCommand =
3426            rmp_serde::from_slice(&captured_subscribe.payload).expect("decode SubscribeCommand");
3427        match (decoded_subscribe, subscribe) {
3428            (SubscribeCommand::Quotes(decoded), SubscribeCommand::Quotes(expected)) => {
3429                assert_eq!(decoded.command_id, expected.command_id);
3430                assert_eq!(decoded.instrument_id, expected.instrument_id);
3431                assert_eq!(decoded.client_id, expected.client_id);
3432                assert_eq!(decoded.venue, expected.venue);
3433                assert_eq!(decoded.ts_init, expected.ts_init);
3434                assert_eq!(decoded.correlation_id, expected.correlation_id);
3435            }
3436            other => panic!("expected SubscribeCommand::Quotes round trip, was {other:?}"),
3437        }
3438    }
3439
3440    // `send_response` dispatches through a correlation handler rather than an endpoint
3441    // or pub/sub topic. The bus tap must still capture the `DataResponse` envelope and
3442    // stamp the inner response category as the payload type.
3443    #[rstest]
3444    fn bus_tap_captures_data_response_sent_through_correlation_handler() {
3445        let tmp = TempDir::new().expect("tempdir");
3446        let clock_rc: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
3447        let instance_id = UUID4::new();
3448
3449        let mut store = EventStoreLifecycle::boot(
3450            Some(make_config(tmp.path().to_path_buf())),
3451            instance_id,
3452            clock_rc,
3453        )
3454        .expect("boot store");
3455        store
3456            .open(
3457                instance_id,
3458                &RegisteredComponents::default(),
3459                Environment::Backtest,
3460            )
3461            .expect("open run");
3462        let run_id = store.run_id().expect("run open").to_string();
3463
3464        let correlation_id = UUID4::new();
3465        let handler_called = Rc::new(RefCell::new(false));
3466        let handler_called_clone = handler_called.clone();
3467        msgbus::register_response_handler(
3468            &correlation_id,
3469            msgbus::ShareableMessageHandler::from_typed(move |_resp: &QuotesResponse| {
3470                *handler_called_clone.borrow_mut() = true;
3471            }),
3472        );
3473
3474        let response = QuotesResponse::new(
3475            correlation_id,
3476            ClientId::from("BINANCE"),
3477            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
3478            vec![],
3479            None,
3480            None,
3481            UnixNanos::from(30),
3482            None,
3483        );
3484        msgbus::send_response(&correlation_id, &DataResponse::Quotes(response.clone()));
3485
3486        assert!(*handler_called.borrow());
3487        store.seal(UnixNanos::from(0));
3488
3489        let sealed = RedbBackend::open_sealed(tmp.path(), &instance_id.to_string(), &run_id)
3490            .expect("open sealed");
3491        let captured = sealed
3492            .scan_seq(2)
3493            .expect("scan")
3494            .expect("captured response present");
3495        assert_eq!(captured.payload_type.as_str(), "QuotesResponse");
3496        assert_eq!(captured.topic, MessagingSwitchboard::data_response_topic());
3497
3498        let decoded: QuotesResponse =
3499            rmp_serde::from_slice(&captured.payload).expect("decode QuotesResponse");
3500        assert_eq!(decoded.correlation_id, response.correlation_id);
3501        assert_eq!(decoded.client_id, response.client_id);
3502        assert_eq!(decoded.instrument_id, response.instrument_id);
3503        assert_eq!(decoded.ts_init, response.ts_init);
3504        assert!(decoded.data.is_empty());
3505    }
3506}