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