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