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