Skip to main content

cu29_runtime/
distributed_replay.rs

1//! Discovery, validation, planning, and causal execution helpers for
2//! distributed deterministic replay.
3//!
4//! The distributed replay flow is:
5//! - discover Copper logs and recover runtime identity from lifecycle metadata
6//! - validate those logs against a strict multi-Copper topology
7//! - register the generated replayable app type for each subsystem
8//! - build one replay session per `(instance_id, subsystem_id)` assignment
9//! - stitch sessions together through recorded message provenance
10//! - replay the fleet in a stable causal order
11
12use crate::app::{
13    CuDistributedReplayApplication, CuRecordedReplayApplication, CuSimApplication, Subsystem,
14};
15use crate::config::{MultiCopperConfig, read_configuration_str, read_multi_configuration};
16use crate::copperlist::CopperList;
17use crate::curuntime::{
18    KeyFrame, RuntimeLifecycleConfigSource, RuntimeLifecycleEvent, RuntimeLifecycleRecord,
19    RuntimeLifecycleStackInfo,
20};
21use crate::debug::{
22    SectionIndexEntry, build_read_logger, decode_copperlists, index_log, read_section_at,
23};
24use crate::simulation::recorded_copperlist_timestamp;
25use bincode::config::standard;
26use bincode::decode_from_std_read;
27use bincode::error::DecodeError;
28use cu29_clock::{RobotClock, RobotClockMock};
29use cu29_traits::{CopperListTuple, CuError, CuResult, ErasedCuStampedDataSet, UnifiedLogType};
30use cu29_unifiedlog::memmap::MmapSectionStorage;
31use cu29_unifiedlog::{
32    NoopLogger, NoopSectionStorage, SectionStorage, UnifiedLogWrite, UnifiedLogger,
33    UnifiedLoggerBuilder, UnifiedLoggerIOReader, UnifiedLoggerRead, UnifiedLoggerWrite,
34};
35use std::any::type_name;
36use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
37use std::fmt::{Debug, Display, Formatter, Result as FmtResult};
38use std::fs;
39use std::io::Read;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42
43/// One discovered Copper log that can participate in distributed replay.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DistributedReplayLog {
46    pub base_path: PathBuf,
47    pub stack: RuntimeLifecycleStackInfo,
48    pub config_source: RuntimeLifecycleConfigSource,
49    pub effective_config_ron: String,
50    pub mission: Option<String>,
51}
52
53impl DistributedReplayLog {
54    /// Discover a single Copper log from either its base path (`foo.copper`) or
55    /// one of its slab paths (`foo_0.copper`, `foo_1.copper`, ...).
56    pub fn discover(path: impl AsRef<Path>) -> CuResult<Self> {
57        let requested_path = path.as_ref();
58        let normalized_path = normalize_candidate_log_base(requested_path);
59        match Self::discover_from_base_path(requested_path) {
60            Ok(log) => Ok(log),
61            Err(_) if normalized_path != requested_path => {
62                Self::discover_from_base_path(&normalized_path)
63            }
64            Err(err) => Err(err),
65        }
66    }
67
68    fn discover_from_base_path(base_path: &Path) -> CuResult<Self> {
69        let UnifiedLogger::Read(read_logger) = UnifiedLoggerBuilder::new()
70            .file_base_name(base_path)
71            .build()
72            .map_err(|err| {
73                CuError::new_with_cause(
74                    &format!(
75                        "Failed to open Copper log '{}' for distributed replay discovery",
76                        base_path.display()
77                    ),
78                    err,
79                )
80            })?
81        else {
82            return Err(CuError::from(
83                "Expected a readable unified logger during distributed replay discovery",
84            ));
85        };
86
87        let mut reader = UnifiedLoggerIOReader::new(read_logger, UnifiedLogType::RuntimeLifecycle);
88        let mut instantiated: Option<(
89            RuntimeLifecycleConfigSource,
90            String,
91            RuntimeLifecycleStackInfo,
92        )> = None;
93        let mut mission = None;
94
95        while let Some(record) =
96            read_next_entry::<RuntimeLifecycleRecord>(&mut reader).map_err(|err| {
97                CuError::from(format!(
98                    "Failed to decode runtime lifecycle for '{}': {err}",
99                    base_path.display()
100                ))
101            })?
102        {
103            match record.event {
104                RuntimeLifecycleEvent::Instantiated {
105                    config_source,
106                    effective_config_ron,
107                    stack,
108                } if instantiated.is_none() => {
109                    instantiated = Some((config_source, effective_config_ron, stack));
110                }
111                RuntimeLifecycleEvent::MissionStarted {
112                    mission: started_mission,
113                } if mission.is_none() => {
114                    mission = Some(started_mission);
115                }
116                _ => {}
117            }
118
119            if instantiated.is_some() && mission.is_some() {
120                break;
121            }
122        }
123
124        let Some((config_source, effective_config_ron, stack)) = instantiated else {
125            return Err(CuError::from(format!(
126                "Copper log '{}' has no RuntimeLifecycle::Instantiated record",
127                base_path.display()
128            )));
129        };
130
131        Ok(Self {
132            base_path: base_path.to_path_buf(),
133            stack,
134            config_source,
135            effective_config_ron,
136            mission,
137        })
138    }
139
140    #[inline]
141    pub fn instance_id(&self) -> u32 {
142        self.stack.instance_id
143    }
144
145    #[inline]
146    pub fn subsystem_code(&self) -> u16 {
147        self.stack.subsystem_code
148    }
149
150    #[inline]
151    pub fn subsystem_id(&self) -> Option<&str> {
152        self.stack.subsystem_id.as_deref()
153    }
154}
155
156/// Discovery error recorded for one log candidate.
157#[derive(Debug, Clone)]
158pub struct DistributedReplayDiscoveryFailure {
159    pub candidate_path: PathBuf,
160    pub error: String,
161}
162
163impl Display for DistributedReplayDiscoveryFailure {
164    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
165        write!(
166            f,
167            "{}: {}",
168            self.candidate_path.display(),
169            self.error.as_str()
170        )
171    }
172}
173
174/// Result of scanning one or more paths for distributed replay logs.
175#[derive(Debug, Clone, Default)]
176pub struct DistributedReplayCatalog {
177    pub logs: Vec<DistributedReplayLog>,
178    pub failures: Vec<DistributedReplayDiscoveryFailure>,
179}
180
181impl DistributedReplayCatalog {
182    /// Discover logs from a list of files and/or directories.
183    ///
184    /// Directories are traversed recursively. Any physical slab file
185    /// (`*_0.copper`, `*_1.copper`, ...) is normalized back to its base log path.
186    pub fn discover<I, P>(inputs: I) -> CuResult<Self>
187    where
188        I: IntoIterator<Item = P>,
189        P: AsRef<Path>,
190    {
191        let mut candidates = BTreeSet::new();
192        for input in inputs {
193            collect_candidate_base_paths(input.as_ref(), &mut candidates)?;
194        }
195
196        let mut logs = Vec::new();
197        let mut failures = Vec::new();
198
199        for candidate in candidates {
200            match DistributedReplayLog::discover(&candidate) {
201                Ok(log) => logs.push(log),
202                Err(err) => failures.push(DistributedReplayDiscoveryFailure {
203                    candidate_path: candidate,
204                    error: err.to_string(),
205                }),
206            }
207        }
208
209        logs.sort_by(|left, right| {
210            (
211                left.instance_id(),
212                left.subsystem_code(),
213                left.subsystem_id(),
214                left.base_path.as_os_str(),
215            )
216                .cmp(&(
217                    right.instance_id(),
218                    right.subsystem_code(),
219                    right.subsystem_id(),
220                    right.base_path.as_os_str(),
221                ))
222        });
223        failures.sort_by(|left, right| left.candidate_path.cmp(&right.candidate_path));
224
225        Ok(Self { logs, failures })
226    }
227
228    /// Convenience wrapper for recursive discovery rooted at one directory.
229    pub fn discover_under(root: impl AsRef<Path>) -> CuResult<Self> {
230        Self::discover([root])
231    }
232}
233
234type DistributedReplaySessionFactory = fn(
235    &DistributedReplayAssignment,
236    &DistributedReplaySessionConfig,
237) -> CuResult<DistributedReplaySessionBuild>;
238
239const DEFAULT_SECTION_CACHE_CAP: usize = 8;
240const DEFAULT_REPLAY_LOG_SIZE_BYTES: usize = 64 * 1024 * 1024;
241
242#[derive(Debug, Clone, Default)]
243struct DistributedReplaySessionConfig {
244    output_root: Option<PathBuf>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
248struct DistributedReplayOriginKey {
249    instance_id: u32,
250    subsystem_code: u16,
251    cl_id: u64,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Hash)]
255pub struct DistributedReplayCursor {
256    pub instance_id: u32,
257    pub subsystem_id: String,
258    pub cl_id: u64,
259    subsystem_code: u16,
260}
261
262impl DistributedReplayCursor {
263    #[inline]
264    fn new(instance_id: u32, subsystem_id: String, subsystem_code: u16, cl_id: u64) -> Self {
265        Self {
266            instance_id,
267            subsystem_id,
268            cl_id,
269            subsystem_code,
270        }
271    }
272
273    #[inline]
274    pub fn subsystem_code(&self) -> u16 {
275        self.subsystem_code
276    }
277}
278
279#[derive(Debug, Clone)]
280struct DistributedReplayNodeDescriptor {
281    cursor: DistributedReplayCursor,
282    origin_key: DistributedReplayOriginKey,
283    incoming_origins: BTreeSet<DistributedReplayOriginKey>,
284}
285
286#[derive(Debug, Clone)]
287struct DistributedReplayGraphNode {
288    cursor: DistributedReplayCursor,
289    session_index: usize,
290    outgoing: Vec<usize>,
291    initial_dependencies: usize,
292    remaining_dependencies: usize,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
296struct DistributedReplayReadyNode {
297    instance_id: u32,
298    subsystem_code: u16,
299    cl_id: u64,
300    node_index: usize,
301}
302
303struct DistributedReplaySessionBuild {
304    session: Box<dyn DistributedReplaySession>,
305    nodes: Vec<DistributedReplayNodeDescriptor>,
306    output_log_path: Option<PathBuf>,
307}
308
309trait DistributedReplaySession {
310    fn goto_cl(&mut self, cl_id: u64) -> CuResult<()>;
311    fn shutdown(&mut self) -> CuResult<()>;
312}
313
314#[derive(Debug, Clone)]
315struct RecordedReplayCachedSection<P: CopperListTuple> {
316    entries: Vec<Arc<CopperList<P>>>,
317}
318
319struct RecordedReplaySession<App, P, S, L>
320where
321    App: CuDistributedReplayApplication<S, L>,
322    P: CopperListTuple,
323    S: SectionStorage,
324    L: UnifiedLogWrite<S> + 'static,
325{
326    assignment: DistributedReplayAssignment,
327    app: App,
328    clock_mock: RobotClockMock,
329    log_reader: UnifiedLoggerRead,
330    sections: Vec<SectionIndexEntry>,
331    total_entries: usize,
332    keyframes: Vec<KeyFrame>,
333    started: bool,
334    current_idx: Option<usize>,
335    last_keyframe: Option<u64>,
336    cache: HashMap<usize, RecordedReplayCachedSection<P>>,
337    cache_order: VecDeque<usize>,
338    cache_cap: usize,
339    phantom: std::marker::PhantomData<(S, L)>,
340}
341
342impl<App, P, S, L> RecordedReplaySession<App, P, S, L>
343where
344    App: CuDistributedReplayApplication<S, L>
345        + CuRecordedReplayApplication<S, L, RecordedDataSet = P>,
346    P: CopperListTuple + 'static,
347    S: SectionStorage,
348    L: UnifiedLogWrite<S> + 'static,
349{
350    fn from_log(
351        assignment: DistributedReplayAssignment,
352        app: App,
353        clock_mock: RobotClockMock,
354        log_base: &Path,
355    ) -> CuResult<Self> {
356        crate::logcodec::set_effective_config_ron::<P>(&assignment.log.effective_config_ron);
357        let (sections, keyframes, total_entries) =
358            index_log::<P, _>(log_base, &recorded_copperlist_timestamp::<P>)?;
359        let log_reader = build_read_logger(log_base)?;
360        Ok(Self {
361            assignment,
362            app,
363            clock_mock,
364            log_reader,
365            sections,
366            total_entries,
367            keyframes,
368            started: false,
369            current_idx: None,
370            last_keyframe: None,
371            cache: HashMap::new(),
372            cache_order: VecDeque::new(),
373            cache_cap: DEFAULT_SECTION_CACHE_CAP,
374            phantom: std::marker::PhantomData,
375        })
376    }
377
378    fn describe_nodes(&mut self) -> CuResult<Vec<DistributedReplayNodeDescriptor>> {
379        let mut nodes = Vec::with_capacity(self.total_entries);
380        for idx in 0..self.total_entries {
381            let (copperlist, _) = self.copperlist_at(idx)?;
382            let cursor = DistributedReplayCursor::new(
383                self.assignment.instance_id,
384                self.assignment.subsystem_id.clone(),
385                self.assignment.log.subsystem_code(),
386                copperlist.id,
387            );
388            nodes.push(DistributedReplayNodeDescriptor {
389                origin_key: DistributedReplayOriginKey {
390                    instance_id: cursor.instance_id,
391                    subsystem_code: cursor.subsystem_code(),
392                    cl_id: cursor.cl_id,
393                },
394                incoming_origins: copperlist_origins(copperlist.as_ref()),
395                cursor,
396            });
397        }
398        Ok(nodes)
399    }
400
401    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
402    #[allow(deprecated)]
403    fn ensure_started(&mut self) -> CuResult<()> {
404        if self.started {
405            return Ok(());
406        }
407        let mut noop = |_step: App::Step<'_>| crate::simulation::SimOverride::ExecuteByRuntime;
408        <App as CuSimApplication<S, L>>::start_all_tasks(&mut self.app, &mut noop)?;
409        self.started = true;
410        Ok(())
411    }
412
413    fn nearest_keyframe(&self, target_cl_id: u64) -> Option<KeyFrame> {
414        self.keyframes
415            .iter()
416            .filter(|keyframe| keyframe.culistid <= target_cl_id)
417            .max_by_key(|keyframe| keyframe.culistid)
418            .cloned()
419    }
420
421    fn restore_keyframe(&mut self, keyframe: &KeyFrame) -> CuResult<()> {
422        <App as CuSimApplication<S, L>>::restore_keyframe(&mut self.app, keyframe)?;
423        self.clock_mock.set_value(keyframe.timestamp.as_nanos());
424        self.last_keyframe = Some(keyframe.culistid);
425        Ok(())
426    }
427
428    fn find_section_for_index(&self, idx: usize) -> Option<usize> {
429        self.sections
430            .binary_search_by(|section| {
431                if idx < section.start_idx {
432                    std::cmp::Ordering::Greater
433                } else if idx >= section.start_idx + section.len {
434                    std::cmp::Ordering::Less
435                } else {
436                    std::cmp::Ordering::Equal
437                }
438            })
439            .ok()
440    }
441
442    fn find_section_for_cl_id(&self, cl_id: u64) -> Option<usize> {
443        self.sections
444            .binary_search_by(|section| {
445                if cl_id < section.first_id {
446                    std::cmp::Ordering::Greater
447                } else if cl_id > section.last_id {
448                    std::cmp::Ordering::Less
449                } else {
450                    std::cmp::Ordering::Equal
451                }
452            })
453            .ok()
454    }
455
456    fn touch_cache(&mut self, key: usize) {
457        if let Some(position) = self.cache_order.iter().position(|entry| *entry == key) {
458            self.cache_order.remove(position);
459        }
460        self.cache_order.push_back(key);
461        while self.cache_order.len() > self.cache_cap {
462            if let Some(oldest) = self.cache_order.pop_front() {
463                self.cache.remove(&oldest);
464            }
465        }
466    }
467
468    fn load_section(&mut self, section_idx: usize) -> CuResult<&RecordedReplayCachedSection<P>> {
469        if self.cache.contains_key(&section_idx) {
470            self.touch_cache(section_idx);
471            return Ok(self.cache.get(&section_idx).expect("cache entry exists"));
472        }
473
474        let entry = &self.sections[section_idx];
475        let (header, data) = read_section_at(&mut self.log_reader, entry.pos)?;
476        if header.entry_type != UnifiedLogType::CopperList {
477            return Err(CuError::from(
478                "Section type mismatch while loading distributed replay copperlists",
479            ));
480        }
481        let (entries, _) = decode_copperlists::<P, _>(&data, &recorded_copperlist_timestamp::<P>)?;
482        self.cache
483            .insert(section_idx, RecordedReplayCachedSection { entries });
484        self.touch_cache(section_idx);
485        Ok(self.cache.get(&section_idx).expect("cache entry exists"))
486    }
487
488    fn copperlist_at(&mut self, idx: usize) -> CuResult<(Arc<CopperList<P>>, Option<KeyFrame>)> {
489        let section_idx = self
490            .find_section_for_index(idx)
491            .ok_or_else(|| CuError::from("Distributed replay index is outside the log"))?;
492        let start_idx = self.sections[section_idx].start_idx;
493        let section = self.load_section(section_idx)?;
494        let local_idx = idx - start_idx;
495        let copperlist = section
496            .entries
497            .get(local_idx)
498            .ok_or_else(|| CuError::from("Corrupt distributed replay section index"))?
499            .clone();
500        let keyframe = self
501            .keyframes
502            .iter()
503            .find(|keyframe| keyframe.culistid == copperlist.id)
504            .cloned();
505        Ok((copperlist, keyframe))
506    }
507
508    fn index_for_cl_id(&mut self, cl_id: u64) -> CuResult<usize> {
509        let section_idx = self
510            .find_section_for_cl_id(cl_id)
511            .ok_or_else(|| CuError::from("Requested CopperList id is not present in the log"))?;
512        let start_idx = self.sections[section_idx].start_idx;
513        let section = self.load_section(section_idx)?;
514        for (offset, copperlist) in section.entries.iter().enumerate() {
515            if copperlist.id == cl_id {
516                return Ok(start_idx + offset);
517            }
518        }
519        Err(CuError::from(
520            "Requested CopperList id is missing from its indexed log section",
521        ))
522    }
523
524    fn replay_range(
525        &mut self,
526        start_idx: usize,
527        end_idx: usize,
528        replay_keyframe: Option<&KeyFrame>,
529    ) -> CuResult<()> {
530        for idx in start_idx..=end_idx {
531            let (copperlist, keyframe) = self.copperlist_at(idx)?;
532            let keyframe = replay_keyframe
533                .filter(|candidate| candidate.culistid == copperlist.id)
534                .or(keyframe
535                    .as_ref()
536                    .filter(|candidate| candidate.culistid == copperlist.id));
537            <App as CuRecordedReplayApplication<S, L>>::replay_recorded_copperlist(
538                &mut self.app,
539                &self.clock_mock,
540                copperlist.as_ref(),
541                keyframe,
542            )?;
543            self.current_idx = Some(idx);
544        }
545        Ok(())
546    }
547
548    fn goto_index(&mut self, target_idx: usize) -> CuResult<()> {
549        self.ensure_started()?;
550        if target_idx >= self.total_entries {
551            return Err(CuError::from(
552                "Distributed replay target is outside the log",
553            ));
554        }
555
556        let (target_copperlist, _) = self.copperlist_at(target_idx)?;
557        let target_cl_id = target_copperlist.id;
558
559        let replay_start_idx;
560        let replay_keyframe;
561
562        if let Some(current_idx) = self.current_idx {
563            if current_idx == target_idx {
564                return Ok(());
565            }
566
567            if target_idx > current_idx {
568                replay_start_idx = current_idx + 1;
569                replay_keyframe = None;
570            } else {
571                let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
572                    CuError::from("No keyframe is available to rewind distributed replay")
573                })?;
574                self.restore_keyframe(&keyframe)?;
575                replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
576                replay_keyframe = Some(keyframe);
577            }
578        } else {
579            let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
580                CuError::from("No keyframe is available to initialize distributed replay")
581            })?;
582            self.restore_keyframe(&keyframe)?;
583            replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
584            replay_keyframe = Some(keyframe);
585        }
586
587        self.replay_range(replay_start_idx, target_idx, replay_keyframe.as_ref())
588    }
589}
590
591impl<App, P, S, L> DistributedReplaySession for RecordedReplaySession<App, P, S, L>
592where
593    App: CuDistributedReplayApplication<S, L>
594        + CuRecordedReplayApplication<S, L, RecordedDataSet = P>,
595    P: CopperListTuple + 'static,
596    S: SectionStorage,
597    L: UnifiedLogWrite<S> + 'static,
598{
599    fn goto_cl(&mut self, cl_id: u64) -> CuResult<()> {
600        let target_idx = self.index_for_cl_id(cl_id)?;
601        self.goto_index(target_idx)
602    }
603
604    // Framework replay engine: drives the raw (app-deprecated) lifecycle on purpose.
605    #[allow(deprecated)]
606    fn shutdown(&mut self) -> CuResult<()> {
607        if !self.started {
608            return Ok(());
609        }
610
611        let mut noop = |_step: App::Step<'_>| crate::simulation::SimOverride::ExecuteByRuntime;
612        <App as CuSimApplication<S, L>>::stop_all_tasks(&mut self.app, &mut noop)?;
613        self.started = false;
614        Ok(())
615    }
616}
617
618/// One typed subsystem registration provided to the distributed replay builder.
619#[derive(Clone)]
620pub struct DistributedReplayAppRegistration {
621    pub subsystem: Subsystem,
622    pub app_type_name: &'static str,
623    session_factory: DistributedReplaySessionFactory,
624}
625
626impl Debug for DistributedReplayAppRegistration {
627    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
628        f.debug_struct("DistributedReplayAppRegistration")
629            .field("subsystem", &self.subsystem)
630            .field("app_type_name", &self.app_type_name)
631            .finish()
632    }
633}
634
635impl PartialEq for DistributedReplayAppRegistration {
636    fn eq(&self, other: &Self) -> bool {
637        self.subsystem == other.subsystem && self.app_type_name == other.app_type_name
638    }
639}
640
641impl Eq for DistributedReplayAppRegistration {}
642
643/// One validated log assignment for a subsystem instance.
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct DistributedReplayAssignment {
646    pub instance_id: u32,
647    pub subsystem_id: String,
648    pub log: DistributedReplayLog,
649    pub registration: DistributedReplayAppRegistration,
650}
651
652/// Validated replay plan produced by [`DistributedReplayBuilder`].
653#[derive(Debug, Clone)]
654pub struct DistributedReplayPlan {
655    pub multi_config_path: PathBuf,
656    pub multi_config: MultiCopperConfig,
657    pub catalog: DistributedReplayCatalog,
658    pub selected_instances: Vec<u32>,
659    pub mission: Option<String>,
660    pub registrations: Vec<DistributedReplayAppRegistration>,
661    pub assignments: Vec<DistributedReplayAssignment>,
662}
663
664impl DistributedReplayPlan {
665    #[inline]
666    pub fn builder(multi_config_path: impl AsRef<Path>) -> CuResult<DistributedReplayBuilder> {
667        DistributedReplayBuilder::new(multi_config_path)
668    }
669
670    #[inline]
671    pub fn assignment(
672        &self,
673        instance_id: u32,
674        subsystem_id: &str,
675    ) -> Option<&DistributedReplayAssignment> {
676        self.assignments.iter().find(|assignment| {
677            assignment.instance_id == instance_id && assignment.subsystem_id == subsystem_id
678        })
679    }
680
681    /// Build a causal distributed replay engine from this validated plan.
682    pub fn start(self) -> CuResult<DistributedReplayEngine> {
683        DistributedReplayEngine::new(self, DistributedReplaySessionConfig::default())
684    }
685
686    /// Build a causal distributed replay engine and persist replayed logs under `output_root`.
687    pub fn start_recording_logs_under(
688        self,
689        output_root: impl AsRef<Path>,
690    ) -> CuResult<DistributedReplayEngine> {
691        DistributedReplayEngine::new(
692            self,
693            DistributedReplaySessionConfig {
694                output_root: Some(output_root.as_ref().to_path_buf()),
695            },
696        )
697    }
698}
699
700/// Aggregated validation diagnostics emitted while constructing a distributed replay plan.
701#[derive(Debug, Clone, Default)]
702pub struct DistributedReplayValidationError {
703    pub issues: Vec<String>,
704}
705
706impl DistributedReplayValidationError {
707    fn push(&mut self, issue: impl Into<String>) {
708        self.issues.push(issue.into());
709    }
710
711    fn is_empty(&self) -> bool {
712        self.issues.is_empty()
713    }
714}
715
716impl Display for DistributedReplayValidationError {
717    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
718        writeln!(f, "Distributed replay validation failed:")?;
719        for issue in &self.issues {
720            writeln!(f, " - {issue}")?;
721        }
722        Ok(())
723    }
724}
725
726/// Builder for a validated distributed replay plan.
727#[derive(Debug, Clone)]
728pub struct DistributedReplayBuilder {
729    multi_config_path: PathBuf,
730    multi_config: MultiCopperConfig,
731    discovery_inputs: Vec<PathBuf>,
732    catalog: Option<DistributedReplayCatalog>,
733    registrations: BTreeMap<String, DistributedReplayAppRegistration>,
734    selected_instances: Option<BTreeSet<u32>>,
735}
736
737impl DistributedReplayBuilder {
738    /// Load a strict multi-Copper config and start building a distributed replay plan.
739    pub fn new(multi_config_path: impl AsRef<Path>) -> CuResult<Self> {
740        let multi_config_path = multi_config_path.as_ref().to_path_buf();
741        let multi_config = read_multi_configuration(&multi_config_path.to_string_lossy())?;
742        Ok(Self {
743            multi_config_path,
744            multi_config,
745            discovery_inputs: Vec::new(),
746            catalog: None,
747            registrations: BTreeMap::new(),
748            selected_instances: None,
749        })
750    }
751
752    /// Replace the discovered catalog explicitly.
753    pub fn with_catalog(mut self, catalog: DistributedReplayCatalog) -> Self {
754        self.catalog = Some(catalog);
755        self
756    }
757
758    /// Discover logs from files and/or directories.
759    ///
760    /// Directories are walked recursively by [`DistributedReplayCatalog`].
761    pub fn discover_logs<I, P>(mut self, inputs: I) -> CuResult<Self>
762    where
763        I: IntoIterator<Item = P>,
764        P: AsRef<Path>,
765    {
766        self.discovery_inputs
767            .extend(inputs.into_iter().map(|path| path.as_ref().to_path_buf()));
768        self.catalog = Some(DistributedReplayCatalog::discover(
769            self.discovery_inputs.iter().collect::<Vec<_>>(),
770        )?);
771        Ok(self)
772    }
773
774    /// Convenience wrapper for recursive discovery under one root directory.
775    pub fn discover_logs_under(self, root: impl AsRef<Path>) -> CuResult<Self> {
776        self.discover_logs([root.as_ref().to_path_buf()])
777    }
778
779    /// Restrict plan construction to a subset of instance ids.
780    pub fn instances<I>(mut self, instances: I) -> Self
781    where
782        I: IntoIterator<Item = u32>,
783    {
784        self.selected_instances = Some(instances.into_iter().collect());
785        self
786    }
787
788    /// Register the generated app type expected for one subsystem.
789    pub fn register<App>(mut self, subsystem_id: &str) -> CuResult<Self>
790    where
791        App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
792            + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
793            + 'static,
794    {
795        if self.registrations.contains_key(subsystem_id) {
796            return Err(CuError::from(format!(
797                "Subsystem '{}' is already registered for distributed replay",
798                subsystem_id
799            )));
800        }
801
802        let expected_subsystem = self.multi_config.subsystem(subsystem_id).ok_or_else(|| {
803            CuError::from(format!(
804                "Multi-Copper config '{}' does not define subsystem '{}'",
805                self.multi_config_path.display(),
806                subsystem_id
807            ))
808        })?;
809
810        let registered_subsystem = App::subsystem();
811        let Some(registered_subsystem_id) = registered_subsystem.id() else {
812            return Err(CuError::from(format!(
813                "App type '{}' was not generated for a multi-Copper subsystem and cannot be registered for distributed replay",
814                type_name::<App>()
815            )));
816        };
817
818        if registered_subsystem_id != subsystem_id {
819            return Err(CuError::from(format!(
820                "App type '{}' declares subsystem '{}' but was registered as '{}'",
821                type_name::<App>(),
822                registered_subsystem_id,
823                subsystem_id
824            )));
825        }
826
827        let registered_subsystem_code = registered_subsystem.code();
828        if registered_subsystem_code != expected_subsystem.subsystem_code {
829            return Err(CuError::from(format!(
830                "App type '{}' declares subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
831                type_name::<App>(),
832                registered_subsystem_code,
833                subsystem_id,
834                self.multi_config_path.display(),
835                expected_subsystem.subsystem_code
836            )));
837        }
838
839        self.registrations.insert(
840            subsystem_id.to_string(),
841            DistributedReplayAppRegistration {
842                subsystem: registered_subsystem,
843                app_type_name: type_name::<App>(),
844                session_factory: build_distributed_replay_session::<App>,
845            },
846        );
847        Ok(self)
848    }
849
850    /// Validate discovery + registrations and prepare a typed replay plan.
851    pub fn build(self) -> CuResult<DistributedReplayPlan> {
852        let catalog = match self.catalog {
853            Some(catalog) => catalog,
854            None if self.discovery_inputs.is_empty() => DistributedReplayCatalog::default(),
855            None => DistributedReplayCatalog::discover(
856                self.discovery_inputs.iter().collect::<Vec<_>>(),
857            )?,
858        };
859
860        let mut validation = DistributedReplayValidationError::default();
861
862        for failure in &catalog.failures {
863            validation.push(format!(
864                "discovery failure for '{}': {}",
865                failure.candidate_path.display(),
866                failure.error
867            ));
868        }
869
870        let subsystem_map: BTreeMap<_, _> = self
871            .multi_config
872            .subsystems
873            .iter()
874            .map(|subsystem| (subsystem.id.clone(), subsystem))
875            .collect();
876
877        for subsystem in subsystem_map.keys() {
878            if !self.registrations.contains_key(subsystem) {
879                validation.push(format!(
880                    "missing app registration for subsystem '{}'",
881                    subsystem
882                ));
883            }
884        }
885
886        let mut discovered_instances = BTreeSet::new();
887        let mut logs_by_target: BTreeMap<(u32, String), Vec<DistributedReplayLog>> =
888            BTreeMap::new();
889
890        for log in &catalog.logs {
891            let Some(subsystem_id) = log.subsystem_id() else {
892                validation.push(format!(
893                    "discovered log '{}' is missing subsystem_id runtime metadata",
894                    log.base_path.display()
895                ));
896                continue;
897            };
898
899            let Some(expected_subsystem) = subsystem_map.get(subsystem_id) else {
900                validation.push(format!(
901                    "discovered log '{}' belongs to subsystem '{}' which is not present in multi-Copper config '{}'",
902                    log.base_path.display(),
903                    subsystem_id,
904                    self.multi_config_path.display()
905                ));
906                continue;
907            };
908
909            if log.subsystem_code() != expected_subsystem.subsystem_code {
910                validation.push(format!(
911                    "discovered log '{}' reports subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
912                    log.base_path.display(),
913                    log.subsystem_code(),
914                    subsystem_id,
915                    self.multi_config_path.display(),
916                    expected_subsystem.subsystem_code
917                ));
918            }
919
920            discovered_instances.insert(log.instance_id());
921            logs_by_target
922                .entry((log.instance_id(), subsystem_id.to_string()))
923                .or_default()
924                .push(log.clone());
925        }
926
927        for ((instance_id, subsystem_id), logs) in &logs_by_target {
928            if logs.len() > 1 {
929                validation.push(format!(
930                    "found {} logs for instance {} subsystem '{}': {}",
931                    logs.len(),
932                    instance_id,
933                    subsystem_id,
934                    join_log_paths(logs)
935                ));
936            }
937        }
938
939        let selected_instances: Vec<u32> =
940            if let Some(selected_instances) = &self.selected_instances {
941                let mut selected_instances: Vec<_> = selected_instances.iter().copied().collect();
942                selected_instances.sort_unstable();
943                for instance_id in &selected_instances {
944                    if !discovered_instances.contains(instance_id) {
945                        validation.push(format!(
946                            "selected instance {} has no discovered logs",
947                            instance_id
948                        ));
949                    }
950                }
951                selected_instances
952            } else {
953                discovered_instances.iter().copied().collect()
954            };
955
956        if selected_instances.is_empty() {
957            validation.push("no instances selected for distributed replay");
958        }
959
960        for instance_id in &selected_instances {
961            for subsystem in &self.multi_config.subsystems {
962                if !logs_by_target.contains_key(&(*instance_id, subsystem.id.clone())) {
963                    validation.push(format!(
964                        "missing log for instance {} subsystem '{}'",
965                        instance_id, subsystem.id
966                    ));
967                }
968            }
969        }
970
971        let mut known_missions = BTreeSet::new();
972        for instance_id in &selected_instances {
973            for subsystem in &self.multi_config.subsystems {
974                if let Some(logs) = logs_by_target.get(&(*instance_id, subsystem.id.clone()))
975                    && let Some(log) = logs.first()
976                    && let Some(mission) = &log.mission
977                {
978                    known_missions.insert(mission.clone());
979                }
980            }
981        }
982        if known_missions.len() > 1 {
983            validation.push(format!(
984                "selected logs disagree on mission: {}",
985                known_missions.into_iter().collect::<Vec<_>>().join(", ")
986            ));
987        }
988
989        if !validation.is_empty() {
990            return Err(CuError::from(validation.to_string()));
991        }
992
993        let mission = selected_instances
994            .iter()
995            .flat_map(|instance_id| {
996                self.multi_config.subsystems.iter().filter_map(|subsystem| {
997                    logs_by_target
998                        .get(&(*instance_id, subsystem.id.clone()))
999                        .and_then(|logs| logs.first())
1000                        .and_then(|log| log.mission.clone())
1001                })
1002            })
1003            .next();
1004
1005        let mut registrations: Vec<_> = self.registrations.into_values().collect();
1006        registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1007
1008        let mut assignments = Vec::new();
1009        for instance_id in &selected_instances {
1010            for subsystem in &self.multi_config.subsystems {
1011                let log = logs_by_target
1012                    .get(&(*instance_id, subsystem.id.clone()))
1013                    .and_then(|logs| logs.first())
1014                    .expect("validated distributed replay plan is missing a log")
1015                    .clone();
1016                let registration = registrations
1017                    .iter()
1018                    .find(|registration| registration.subsystem.id() == Some(subsystem.id.as_str()))
1019                    .expect("validated distributed replay plan is missing a registration")
1020                    .clone();
1021                assignments.push(DistributedReplayAssignment {
1022                    instance_id: *instance_id,
1023                    subsystem_id: subsystem.id.clone(),
1024                    log,
1025                    registration,
1026                });
1027            }
1028        }
1029        assignments.sort_by(|left, right| {
1030            (
1031                left.instance_id,
1032                left.registration.subsystem.code(),
1033                left.subsystem_id.as_str(),
1034            )
1035                .cmp(&(
1036                    right.instance_id,
1037                    right.registration.subsystem.code(),
1038                    right.subsystem_id.as_str(),
1039                ))
1040        });
1041
1042        Ok(DistributedReplayPlan {
1043            multi_config_path: self.multi_config_path,
1044            multi_config: self.multi_config,
1045            catalog,
1046            selected_instances,
1047            mission,
1048            registrations,
1049            assignments,
1050        })
1051    }
1052}
1053
1054fn build_distributed_replay_session<App>(
1055    assignment: &DistributedReplayAssignment,
1056    session_config: &DistributedReplaySessionConfig,
1057) -> CuResult<DistributedReplaySessionBuild>
1058where
1059    App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
1060        + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
1061        + 'static,
1062{
1063    let config = read_configuration_str(assignment.log.effective_config_ron.clone(), None)
1064        .map_err(|err| {
1065            CuError::from(format!(
1066                "Failed to parse recorded effective config from '{}': {err}",
1067                assignment.log.base_path.display()
1068            ))
1069        })?;
1070    let (clock, clock_mock) = RobotClock::mock();
1071
1072    if let Some(output_root) = &session_config.output_root {
1073        let output_log_path = replay_output_log_path(output_root, assignment)?;
1074        let logger = build_replay_output_logger(
1075            &output_log_path,
1076            replay_output_log_size_bytes(assignment, &config),
1077        )?;
1078        let app = <App as CuDistributedReplayApplication<
1079            MmapSectionStorage,
1080            UnifiedLoggerWrite,
1081        >>::build_distributed_replay(
1082            clock.clone(), logger, assignment.instance_id, Some(config)
1083        )?;
1084        let mut session = RecordedReplaySession::<
1085            App,
1086            <App as CuRecordedReplayApplication<
1087                MmapSectionStorage,
1088                UnifiedLoggerWrite,
1089            >>::RecordedDataSet,
1090            MmapSectionStorage,
1091            UnifiedLoggerWrite,
1092        >::from_log(assignment.clone(), app, clock_mock, &assignment.log.base_path)?;
1093        let nodes = session.describe_nodes()?;
1094        return Ok(DistributedReplaySessionBuild {
1095            session: Box::new(session),
1096            nodes,
1097            output_log_path: Some(output_log_path),
1098        });
1099    }
1100
1101    let logger = Arc::new(Mutex::new(NoopLogger::new()));
1102    let app = <App as CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>>::build_distributed_replay(
1103        clock,
1104        logger,
1105        assignment.instance_id,
1106        Some(config),
1107    )?;
1108    let mut session = RecordedReplaySession::<
1109        App,
1110        <App as CuRecordedReplayApplication<NoopSectionStorage, NoopLogger>>::RecordedDataSet,
1111        NoopSectionStorage,
1112        NoopLogger,
1113    >::from_log(
1114        assignment.clone(),
1115        app,
1116        clock_mock,
1117        &assignment.log.base_path,
1118    )?;
1119    let nodes = session.describe_nodes()?;
1120    Ok(DistributedReplaySessionBuild {
1121        session: Box::new(session),
1122        nodes,
1123        output_log_path: None,
1124    })
1125}
1126
1127fn replay_output_log_path(
1128    output_root: &Path,
1129    assignment: &DistributedReplayAssignment,
1130) -> CuResult<PathBuf> {
1131    let file_name = assignment
1132        .log
1133        .base_path
1134        .file_name()
1135        .ok_or_else(|| {
1136            CuError::from(format!(
1137                "Replay assignment log '{}' has no file name",
1138                assignment.log.base_path.display()
1139            ))
1140        })?
1141        .to_owned();
1142    Ok(output_root.join(file_name))
1143}
1144
1145fn build_replay_output_logger(
1146    path: &Path,
1147    preallocated_size: usize,
1148) -> CuResult<Arc<Mutex<UnifiedLoggerWrite>>> {
1149    if let Some(parent) = path.parent() {
1150        fs::create_dir_all(parent).map_err(|err| {
1151            CuError::new_with_cause(
1152                &format!(
1153                    "Failed to create replay log directory '{}'",
1154                    parent.display()
1155                ),
1156                err,
1157            )
1158        })?;
1159    }
1160    let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1161        .write(true)
1162        .create(true)
1163        .file_base_name(path)
1164        .preallocated_size(preallocated_size)
1165        .build()
1166        .map_err(|err| {
1167            CuError::new_with_cause(
1168                &format!("Failed to create replay log '{}'", path.display()),
1169                err,
1170            )
1171        })?
1172    else {
1173        return Err(CuError::from(format!(
1174            "Expected writable replay logger for '{}'",
1175            path.display()
1176        )));
1177    };
1178    Ok(Arc::new(Mutex::new(writer)))
1179}
1180
1181fn replay_output_log_size_bytes(
1182    assignment: &DistributedReplayAssignment,
1183    config: &crate::config::CuConfig,
1184) -> usize {
1185    if let Some(slab_zero) = slab_zero_path(&assignment.log.base_path)
1186        && let Ok(metadata) = fs::metadata(slab_zero)
1187        && let Ok(size) = usize::try_from(metadata.len())
1188    {
1189        return size.max(DEFAULT_REPLAY_LOG_SIZE_BYTES);
1190    }
1191
1192    config
1193        .logging
1194        .as_ref()
1195        .and_then(|logging| logging.slab_size_mib)
1196        .and_then(|size_mib| usize::try_from(size_mib).ok())
1197        .and_then(|size_mib| size_mib.checked_mul(1024 * 1024))
1198        .unwrap_or(DEFAULT_REPLAY_LOG_SIZE_BYTES)
1199}
1200
1201fn copperlist_origins<P: CopperListTuple>(
1202    copperlist: &CopperList<P>,
1203) -> BTreeSet<DistributedReplayOriginKey> {
1204    <CopperList<P> as ErasedCuStampedDataSet>::cumsgs(copperlist)
1205        .into_iter()
1206        .filter_map(|msg| msg.metadata().origin())
1207        .map(|origin| DistributedReplayOriginKey {
1208            instance_id: origin.instance_id,
1209            subsystem_code: origin.subsystem_code,
1210            cl_id: origin.cl_id,
1211        })
1212        .collect()
1213}
1214
1215#[derive(Default)]
1216struct DistributedReplayEngineState {
1217    sessions: Vec<Box<dyn DistributedReplaySession>>,
1218    nodes: Vec<DistributedReplayGraphNode>,
1219    node_lookup: BTreeMap<(u32, String, u64), usize>,
1220    output_log_paths: BTreeMap<(u32, String), PathBuf>,
1221    ready: BTreeSet<DistributedReplayReadyNode>,
1222    frontier: Vec<Option<DistributedReplayCursor>>,
1223}
1224
1225/// One causal distributed replay engine built from a validated plan.
1226pub struct DistributedReplayEngine {
1227    plan: DistributedReplayPlan,
1228    session_config: DistributedReplaySessionConfig,
1229    sessions: Vec<Box<dyn DistributedReplaySession>>,
1230    nodes: Vec<DistributedReplayGraphNode>,
1231    node_lookup: BTreeMap<(u32, String, u64), usize>,
1232    output_log_paths: BTreeMap<(u32, String), PathBuf>,
1233    ready: BTreeSet<DistributedReplayReadyNode>,
1234    frontier: Vec<Option<DistributedReplayCursor>>,
1235    executed: Vec<bool>,
1236    executed_count: usize,
1237}
1238
1239impl DistributedReplayEngine {
1240    fn new(
1241        plan: DistributedReplayPlan,
1242        session_config: DistributedReplaySessionConfig,
1243    ) -> CuResult<Self> {
1244        let state = Self::build_state(&plan, &session_config)?;
1245        let executed = vec![false; state.nodes.len()];
1246        Ok(Self {
1247            plan,
1248            session_config,
1249            sessions: state.sessions,
1250            nodes: state.nodes,
1251            node_lookup: state.node_lookup,
1252            output_log_paths: state.output_log_paths,
1253            ready: state.ready,
1254            frontier: state.frontier,
1255            executed,
1256            executed_count: 0,
1257        })
1258    }
1259
1260    fn build_state(
1261        plan: &DistributedReplayPlan,
1262        session_config: &DistributedReplaySessionConfig,
1263    ) -> CuResult<DistributedReplayEngineState> {
1264        let mut sessions = Vec::with_capacity(plan.assignments.len());
1265        let mut pending_nodes = Vec::new();
1266        let mut session_nodes = Vec::with_capacity(plan.assignments.len());
1267        let mut output_log_paths = BTreeMap::new();
1268
1269        for assignment in &plan.assignments {
1270            let build = (assignment.registration.session_factory)(assignment, session_config)?;
1271            let session_index = sessions.len();
1272            let mut node_indices = Vec::with_capacity(build.nodes.len());
1273            for node in build.nodes {
1274                let pending_index = pending_nodes.len();
1275                pending_nodes.push((session_index, node));
1276                node_indices.push(pending_index);
1277            }
1278            if let Some(output_log_path) = build.output_log_path {
1279                let replaced = output_log_paths.insert(
1280                    (assignment.instance_id, assignment.subsystem_id.clone()),
1281                    output_log_path,
1282                );
1283                if replaced.is_some() {
1284                    return Err(CuError::from(format!(
1285                        "Duplicate replay output log assignment for instance {} subsystem '{}'",
1286                        assignment.instance_id, assignment.subsystem_id
1287                    )));
1288                }
1289            }
1290            sessions.push(build.session);
1291            session_nodes.push(node_indices);
1292        }
1293
1294        let mut nodes = Vec::with_capacity(pending_nodes.len());
1295        let mut origin_lookup = BTreeMap::new();
1296        let mut node_lookup = BTreeMap::new();
1297
1298        for (node_index, (session_index, descriptor)) in pending_nodes.iter().enumerate() {
1299            if origin_lookup
1300                .insert(descriptor.origin_key.clone(), node_index)
1301                .is_some()
1302            {
1303                return Err(CuError::from(format!(
1304                    "Duplicate replay node detected for instance {} subsystem code {} CopperList {}",
1305                    descriptor.origin_key.instance_id,
1306                    descriptor.origin_key.subsystem_code,
1307                    descriptor.origin_key.cl_id
1308                )));
1309            }
1310
1311            if node_lookup
1312                .insert(
1313                    (
1314                        descriptor.cursor.instance_id,
1315                        descriptor.cursor.subsystem_id.clone(),
1316                        descriptor.cursor.cl_id,
1317                    ),
1318                    node_index,
1319                )
1320                .is_some()
1321            {
1322                return Err(CuError::from(format!(
1323                    "Duplicate replay cursor detected for instance {} subsystem '{}' CopperList {}",
1324                    descriptor.cursor.instance_id,
1325                    descriptor.cursor.subsystem_id,
1326                    descriptor.cursor.cl_id
1327                )));
1328            }
1329
1330            nodes.push(DistributedReplayGraphNode {
1331                cursor: descriptor.cursor.clone(),
1332                session_index: *session_index,
1333                outgoing: Vec::new(),
1334                initial_dependencies: 0,
1335                remaining_dependencies: 0,
1336            });
1337        }
1338
1339        let mut edges = BTreeSet::new();
1340
1341        for node_indices in &session_nodes {
1342            for pair in node_indices.windows(2) {
1343                let from = pair[0];
1344                let to = pair[1];
1345                if edges.insert((from, to)) {
1346                    nodes[from].outgoing.push(to);
1347                    nodes[to].initial_dependencies += 1;
1348                }
1349            }
1350        }
1351
1352        for (target_index, (_, descriptor)) in pending_nodes.iter().enumerate() {
1353            for origin in &descriptor.incoming_origins {
1354                let source_index = origin_lookup.get(origin).copied().ok_or_else(|| {
1355                    CuError::from(format!(
1356                        "Unresolved recorded provenance edge into instance {} subsystem '{}' CopperList {} from instance {} subsystem code {} CopperList {}",
1357                        descriptor.cursor.instance_id,
1358                        descriptor.cursor.subsystem_id,
1359                        descriptor.cursor.cl_id,
1360                        origin.instance_id,
1361                        origin.subsystem_code,
1362                        origin.cl_id
1363                    ))
1364                })?;
1365                if source_index == target_index {
1366                    return Err(CuError::from(format!(
1367                        "Recorded provenance on instance {} subsystem '{}' CopperList {} points to itself",
1368                        descriptor.cursor.instance_id,
1369                        descriptor.cursor.subsystem_id,
1370                        descriptor.cursor.cl_id
1371                    )));
1372                }
1373                if edges.insert((source_index, target_index)) {
1374                    nodes[source_index].outgoing.push(target_index);
1375                    nodes[target_index].initial_dependencies += 1;
1376                }
1377            }
1378        }
1379
1380        let mut ready = BTreeSet::new();
1381        for (node_index, node) in nodes.iter_mut().enumerate() {
1382            node.remaining_dependencies = node.initial_dependencies;
1383            if node.remaining_dependencies == 0 {
1384                ready.insert(DistributedReplayReadyNode {
1385                    instance_id: node.cursor.instance_id,
1386                    subsystem_code: node.cursor.subsystem_code(),
1387                    cl_id: node.cursor.cl_id,
1388                    node_index,
1389                });
1390            }
1391        }
1392
1393        if !nodes.is_empty() && ready.is_empty() {
1394            return Err(CuError::from(
1395                "Distributed replay graph has no causally ready starting point",
1396            ));
1397        }
1398
1399        Ok(DistributedReplayEngineState {
1400            frontier: vec![None; sessions.len()],
1401            sessions,
1402            nodes,
1403            node_lookup,
1404            output_log_paths,
1405            ready,
1406        })
1407    }
1408
1409    fn shutdown_sessions(sessions: &mut Vec<Box<dyn DistributedReplaySession>>) -> CuResult<()> {
1410        for session in sessions.iter_mut() {
1411            session.shutdown()?;
1412        }
1413        Ok(())
1414    }
1415
1416    fn ready_key(&self, node_index: usize) -> DistributedReplayReadyNode {
1417        let node = &self.nodes[node_index];
1418        DistributedReplayReadyNode {
1419            instance_id: node.cursor.instance_id,
1420            subsystem_code: node.cursor.subsystem_code(),
1421            cl_id: node.cursor.cl_id,
1422            node_index,
1423        }
1424    }
1425
1426    /// Reset all replay sessions and graph execution state back to the beginning.
1427    pub fn reset(&mut self) -> CuResult<()> {
1428        Self::shutdown_sessions(&mut self.sessions)?;
1429        let state = Self::build_state(&self.plan, &self.session_config)?;
1430        self.sessions = state.sessions;
1431        self.nodes = state.nodes;
1432        self.node_lookup = state.node_lookup;
1433        self.output_log_paths = state.output_log_paths;
1434        self.ready = state.ready;
1435        self.frontier = state.frontier;
1436        self.executed = vec![false; self.nodes.len()];
1437        self.executed_count = 0;
1438        Ok(())
1439    }
1440
1441    /// Replay the next causally ready CopperList, if any.
1442    pub fn step_causal(&mut self) -> CuResult<Option<DistributedReplayCursor>> {
1443        let Some(next_ready) = self.ready.iter().next().copied() else {
1444            if self.executed_count == self.nodes.len() {
1445                return Ok(None);
1446            }
1447            return Err(CuError::from(
1448                "Distributed replay is deadlocked: no causally ready CopperList remains",
1449            ));
1450        };
1451        self.ready.remove(&next_ready);
1452
1453        let cursor = self.nodes[next_ready.node_index].cursor.clone();
1454        let session_index = self.nodes[next_ready.node_index].session_index;
1455        self.sessions[session_index].goto_cl(cursor.cl_id)?;
1456        self.executed[next_ready.node_index] = true;
1457        self.executed_count += 1;
1458        self.frontier[session_index] = Some(cursor.clone());
1459
1460        let outgoing = self.nodes[next_ready.node_index].outgoing.clone();
1461        for dependent in outgoing {
1462            let node = &mut self.nodes[dependent];
1463            node.remaining_dependencies = node.remaining_dependencies.saturating_sub(1);
1464            if node.remaining_dependencies == 0 {
1465                self.ready.insert(self.ready_key(dependent));
1466            }
1467        }
1468
1469        Ok(Some(cursor))
1470    }
1471
1472    /// Replay the entire selected fleet to completion.
1473    pub fn run_all(&mut self) -> CuResult<()> {
1474        while self.step_causal()?.is_some() {}
1475        Ok(())
1476    }
1477
1478    /// Rebuild the replay from scratch and advance until the target CopperList is reached.
1479    pub fn goto(&mut self, instance_id: u32, subsystem_id: &str, cl_id: u64) -> CuResult<()> {
1480        let target = self
1481            .node_lookup
1482            .get(&(instance_id, subsystem_id.to_string(), cl_id))
1483            .copied()
1484            .ok_or_else(|| {
1485                CuError::from(format!(
1486                    "Distributed replay target instance {} subsystem '{}' CopperList {} does not exist",
1487                    instance_id, subsystem_id, cl_id
1488                ))
1489            })?;
1490        self.reset()?;
1491        while !self.executed[target] {
1492            let Some(_) = self.step_causal()? else {
1493                return Err(CuError::from(format!(
1494                    "Distributed replay exhausted before reaching instance {} subsystem '{}' CopperList {}",
1495                    instance_id, subsystem_id, cl_id
1496                )));
1497            };
1498        }
1499        Ok(())
1500    }
1501
1502    /// Return the latest executed CopperList cursor for each replay session.
1503    pub fn current_frontier(&self) -> Vec<DistributedReplayCursor> {
1504        self.frontier
1505            .iter()
1506            .filter_map(|cursor| cursor.clone())
1507            .collect()
1508    }
1509
1510    pub fn output_log_path(&self, instance_id: u32, subsystem_id: &str) -> Option<&Path> {
1511        self.output_log_paths
1512            .get(&(instance_id, subsystem_id.to_string()))
1513            .map(PathBuf::as_path)
1514    }
1515
1516    #[inline]
1517    pub fn total_nodes(&self) -> usize {
1518        self.nodes.len()
1519    }
1520
1521    #[inline]
1522    pub fn executed_nodes(&self) -> usize {
1523        self.executed_count
1524    }
1525}
1526
1527fn join_log_paths(logs: &[DistributedReplayLog]) -> String {
1528    logs.iter()
1529        .map(|log| log.base_path.display().to_string())
1530        .collect::<Vec<_>>()
1531        .join(", ")
1532}
1533
1534fn collect_candidate_base_paths(path: &Path, out: &mut BTreeSet<PathBuf>) -> CuResult<()> {
1535    if path.is_dir() {
1536        let mut entries = fs::read_dir(path)
1537            .map_err(|err| {
1538                CuError::new_with_cause(
1539                    &format!(
1540                        "Failed to read directory '{}' during distributed replay discovery",
1541                        path.display()
1542                    ),
1543                    err,
1544                )
1545            })?
1546            .collect::<Result<Vec<_>, _>>()
1547            .map_err(|err| {
1548                CuError::new_with_cause(
1549                    &format!(
1550                        "Failed to enumerate directory '{}' during distributed replay discovery",
1551                        path.display()
1552                    ),
1553                    err,
1554                )
1555            })?;
1556        entries.sort_by_key(|entry| entry.path());
1557        for entry in entries {
1558            collect_candidate_base_paths(&entry.path(), out)?;
1559        }
1560        return Ok(());
1561    }
1562
1563    if path
1564        .extension()
1565        .and_then(|ext| ext.to_str())
1566        .is_some_and(|ext| ext == "copper")
1567    {
1568        out.insert(normalize_candidate_log_base(path));
1569    }
1570
1571    Ok(())
1572}
1573
1574fn normalize_candidate_log_base(path: &Path) -> PathBuf {
1575    let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
1576        return path.to_path_buf();
1577    };
1578    let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1579        return path.to_path_buf();
1580    };
1581    let Some((base_stem, slab_suffix)) = stem.rsplit_once('_') else {
1582        return path.to_path_buf();
1583    };
1584
1585    if slab_suffix.is_empty() || !slab_suffix.chars().all(|c| c.is_ascii_digit()) {
1586        return path.to_path_buf();
1587    }
1588
1589    let mut normalized = path.to_path_buf();
1590    normalized.set_file_name(format!("{base_stem}.{extension}"));
1591    if slab_zero_path(&normalized).is_some_and(|slab_zero| slab_zero.exists()) {
1592        normalized
1593    } else {
1594        path.to_path_buf()
1595    }
1596}
1597
1598fn slab_zero_path(base_path: &Path) -> Option<PathBuf> {
1599    let extension = base_path.extension()?.to_str()?;
1600    let stem = base_path.file_stem()?.to_str()?;
1601    let mut slab_zero = base_path.to_path_buf();
1602    slab_zero.set_file_name(format!("{stem}_0.{extension}"));
1603    Some(slab_zero)
1604}
1605
1606fn read_next_entry<T: bincode::Decode<()>>(src: &mut impl Read) -> CuResult<Option<T>> {
1607    match decode_from_std_read::<T, _, _>(src, standard()) {
1608        Ok(entry) => Ok(Some(entry)),
1609        Err(DecodeError::UnexpectedEnd { .. }) => Ok(None),
1610        Err(DecodeError::Io { inner, .. }) if inner.kind() == std::io::ErrorKind::UnexpectedEof => {
1611            Ok(None)
1612        }
1613        Err(err) => Err(CuError::new_with_cause(
1614            "Failed to decode bincode entry during distributed replay discovery",
1615            err,
1616        )),
1617    }
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623    use crate::app::{
1624        CuDistributedReplayApplication, CuRecordedReplayApplication, CuSimApplication,
1625        CuSubsystemMetadata,
1626    };
1627    use crate::config::CuConfig;
1628    use crate::copperlist::CopperList;
1629    use crate::curuntime::KeyFrame;
1630    use crate::simulation::SimOverride;
1631    use bincode::{Decode, Encode};
1632    use cu29_clock::CuTime;
1633    use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks, WriteStream};
1634    use cu29_unifiedlog::memmap::MmapSectionStorage;
1635    use cu29_unifiedlog::stream_write;
1636    use serde::Serialize;
1637    use std::sync::{Arc, Mutex};
1638    use tempfile::TempDir;
1639
1640    fn write_runtime_lifecycle_log(
1641        base_path: &Path,
1642        stack: RuntimeLifecycleStackInfo,
1643        mission: Option<&str>,
1644    ) -> CuResult<()> {
1645        if let Some(parent) = base_path.parent() {
1646            fs::create_dir_all(parent).map_err(|err| {
1647                CuError::new_with_cause(
1648                    &format!("Failed to create test log directory '{}'", parent.display()),
1649                    err,
1650                )
1651            })?;
1652        }
1653
1654        let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1655            .write(true)
1656            .create(true)
1657            .preallocated_size(256 * 1024)
1658            .file_base_name(base_path)
1659            .build()
1660            .map_err(|err| {
1661                CuError::new_with_cause(
1662                    &format!("Failed to create test log '{}'", base_path.display()),
1663                    err,
1664                )
1665            })?
1666        else {
1667            return Err(CuError::from("Expected writable unified logger in test"));
1668        };
1669
1670        let logger = Arc::new(Mutex::new(writer));
1671        let mut stream = stream_write::<RuntimeLifecycleRecord, MmapSectionStorage>(
1672            logger.clone(),
1673            UnifiedLogType::RuntimeLifecycle,
1674            4096,
1675        )?;
1676        stream.log(&RuntimeLifecycleRecord {
1677            timestamp: CuTime::default(),
1678            event: RuntimeLifecycleEvent::Instantiated {
1679                config_source: RuntimeLifecycleConfigSource::ExternalFile,
1680                effective_config_ron: "(runtime: ())".to_string(),
1681                stack,
1682            },
1683        })?;
1684        if let Some(mission) = mission {
1685            stream.log(&RuntimeLifecycleRecord {
1686                timestamp: CuTime::from_nanos(1),
1687                event: RuntimeLifecycleEvent::MissionStarted {
1688                    mission: mission.to_string(),
1689                },
1690            })?;
1691        }
1692        drop(stream);
1693        drop(logger);
1694        Ok(())
1695    }
1696
1697    fn test_stack(
1698        subsystem_id: &str,
1699        subsystem_code: u16,
1700        instance_id: u32,
1701    ) -> RuntimeLifecycleStackInfo {
1702        RuntimeLifecycleStackInfo {
1703            app_name: "demo".to_string(),
1704            app_version: "0.1.0".to_string(),
1705            git_commit: Some("abc123".to_string()),
1706            git_dirty: Some(false),
1707            subsystem_id: Some(subsystem_id.to_string()),
1708            subsystem_code,
1709            instance_id,
1710        }
1711    }
1712
1713    fn write_multi_config_fixture(temp_dir: &TempDir, subsystem_ids: &[&str]) -> CuResult<PathBuf> {
1714        for subsystem_id in subsystem_ids {
1715            let subsystem_config = temp_dir.path().join(format!("{subsystem_id}_config.ron"));
1716            fs::write(&subsystem_config, "(tasks: [], cnx: [])").map_err(|err| {
1717                CuError::new_with_cause(
1718                    &format!(
1719                        "Failed to write subsystem config '{}'",
1720                        subsystem_config.display()
1721                    ),
1722                    err,
1723                )
1724            })?;
1725        }
1726
1727        let subsystem_entries = subsystem_ids
1728            .iter()
1729            .map(|subsystem_id| {
1730                format!(
1731                    r#"(
1732            id: "{subsystem_id}",
1733            config: "{subsystem_id}_config.ron",
1734        )"#
1735                )
1736            })
1737            .collect::<Vec<_>>()
1738            .join(",\n");
1739
1740        let multi_config = format!(
1741            "(\n    subsystems: [\n{entries}\n    ],\n    interconnects: [],\n)\n",
1742            entries = subsystem_entries
1743        );
1744        let multi_config_path = temp_dir.path().join("multi_copper.ron");
1745        fs::write(&multi_config_path, multi_config).map_err(|err| {
1746            CuError::new_with_cause(
1747                &format!(
1748                    "Failed to write multi-Copper config '{}'",
1749                    multi_config_path.display()
1750                ),
1751                err,
1752            )
1753        })?;
1754        Ok(multi_config_path)
1755    }
1756
1757    #[derive(Debug, Default, Encode, Decode, Serialize)]
1758    struct DummyRecordedDataSet;
1759
1760    impl ErasedCuStampedDataSet for DummyRecordedDataSet {
1761        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1762            Vec::new()
1763        }
1764    }
1765
1766    impl MatchingTasks for DummyRecordedDataSet {
1767        fn get_all_task_ids() -> &'static [&'static str] {
1768            &[]
1769        }
1770    }
1771
1772    macro_rules! impl_registered_test_app {
1773        ($name:ident, $subsystem_id:expr, $subsystem_code:expr) => {
1774            struct $name;
1775
1776            impl CuSubsystemMetadata for $name {
1777                fn subsystem() -> Subsystem {
1778                    Subsystem::new(Some($subsystem_id), $subsystem_code)
1779                }
1780            }
1781
1782            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1783                CuSimApplication<S, L> for $name
1784            {
1785                type Step<'z> = ();
1786
1787                fn get_original_config() -> String {
1788                    "(tasks: [], cnx: [])".to_string()
1789                }
1790
1791                fn start_all_tasks(
1792                    &mut self,
1793                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1794                ) -> CuResult<()> {
1795                    Ok(())
1796                }
1797
1798                fn run_one_iteration(
1799                    &mut self,
1800                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1801                ) -> CuResult<()> {
1802                    Ok(())
1803                }
1804
1805                fn run(
1806                    &mut self,
1807                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1808                ) -> CuResult<()> {
1809                    Ok(())
1810                }
1811
1812                fn stop_all_tasks(
1813                    &mut self,
1814                    _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1815                ) -> CuResult<()> {
1816                    Ok(())
1817                }
1818
1819                fn restore_keyframe(&mut self, _freezer: &KeyFrame) -> CuResult<()> {
1820                    Ok(())
1821                }
1822            }
1823
1824            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1825                CuRecordedReplayApplication<S, L> for $name
1826            {
1827                type RecordedDataSet = DummyRecordedDataSet;
1828
1829                fn replay_recorded_copperlist(
1830                    &mut self,
1831                    _clock_mock: &RobotClockMock,
1832                    _copperlist: &CopperList<Self::RecordedDataSet>,
1833                    _keyframe: Option<&KeyFrame>,
1834                ) -> CuResult<()> {
1835                    Ok(())
1836                }
1837            }
1838
1839            impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1840                CuDistributedReplayApplication<S, L> for $name
1841            {
1842                fn build_distributed_replay(
1843                    _clock: RobotClock,
1844                    _unified_logger: Arc<Mutex<L>>,
1845                    _instance_id: u32,
1846                    _config_override: Option<CuConfig>,
1847                ) -> CuResult<Self> {
1848                    Ok(Self)
1849                }
1850            }
1851        };
1852    }
1853
1854    impl_registered_test_app!(PingRegisteredApp, "ping", 0);
1855    impl_registered_test_app!(PongRegisteredApp, "pong", 1);
1856    impl_registered_test_app!(PingWrongCodeApp, "ping", 99);
1857
1858    struct FakeReplaySession;
1859
1860    impl DistributedReplaySession for FakeReplaySession {
1861        fn goto_cl(&mut self, _cl_id: u64) -> CuResult<()> {
1862            Ok(())
1863        }
1864
1865        fn shutdown(&mut self) -> CuResult<()> {
1866            Ok(())
1867        }
1868    }
1869
1870    fn fake_registration(
1871        subsystem_id: &'static str,
1872        subsystem_code: u16,
1873        session_factory: DistributedReplaySessionFactory,
1874    ) -> DistributedReplayAppRegistration {
1875        DistributedReplayAppRegistration {
1876            subsystem: Subsystem::new(Some(subsystem_id), subsystem_code),
1877            app_type_name: "fake",
1878            session_factory,
1879        }
1880    }
1881
1882    fn fake_assignment(
1883        instance_id: u32,
1884        subsystem_id: &'static str,
1885        subsystem_code: u16,
1886        session_factory: DistributedReplaySessionFactory,
1887    ) -> DistributedReplayAssignment {
1888        DistributedReplayAssignment {
1889            instance_id,
1890            subsystem_id: subsystem_id.to_string(),
1891            log: DistributedReplayLog {
1892                base_path: PathBuf::from(format!("{subsystem_id}_{instance_id}.copper")),
1893                stack: test_stack(subsystem_id, subsystem_code, instance_id),
1894                config_source: RuntimeLifecycleConfigSource::ExternalFile,
1895                effective_config_ron: "(tasks: [], cnx: [])".to_string(),
1896                mission: Some("default".to_string()),
1897            },
1898            registration: fake_registration(subsystem_id, subsystem_code, session_factory),
1899        }
1900    }
1901
1902    fn fake_plan(assignments: Vec<DistributedReplayAssignment>) -> DistributedReplayPlan {
1903        let mut registrations: Vec<_> = assignments
1904            .iter()
1905            .map(|assignment| assignment.registration.clone())
1906            .collect();
1907        registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1908        let mut selected_instances: Vec<_> = assignments
1909            .iter()
1910            .map(|assignment| assignment.instance_id)
1911            .collect::<BTreeSet<_>>()
1912            .into_iter()
1913            .collect();
1914        selected_instances.sort_unstable();
1915        DistributedReplayPlan {
1916            multi_config_path: PathBuf::from("fake_multi.ron"),
1917            multi_config: MultiCopperConfig {
1918                subsystems: Vec::new(),
1919                interconnects: Vec::new(),
1920                instance_overrides_root: None,
1921            },
1922            catalog: DistributedReplayCatalog::default(),
1923            selected_instances,
1924            mission: Some("default".to_string()),
1925            registrations,
1926            assignments,
1927        }
1928    }
1929
1930    fn fake_ping_session(
1931        assignment: &DistributedReplayAssignment,
1932        _session_config: &DistributedReplaySessionConfig,
1933    ) -> CuResult<DistributedReplaySessionBuild> {
1934        Ok(DistributedReplaySessionBuild {
1935            session: Box::new(FakeReplaySession),
1936            nodes: vec![
1937                DistributedReplayNodeDescriptor {
1938                    cursor: DistributedReplayCursor::new(
1939                        assignment.instance_id,
1940                        assignment.subsystem_id.clone(),
1941                        assignment.log.subsystem_code(),
1942                        0,
1943                    ),
1944                    origin_key: DistributedReplayOriginKey {
1945                        instance_id: assignment.instance_id,
1946                        subsystem_code: assignment.log.subsystem_code(),
1947                        cl_id: 0,
1948                    },
1949                    incoming_origins: BTreeSet::new(),
1950                },
1951                DistributedReplayNodeDescriptor {
1952                    cursor: DistributedReplayCursor::new(
1953                        assignment.instance_id,
1954                        assignment.subsystem_id.clone(),
1955                        assignment.log.subsystem_code(),
1956                        1,
1957                    ),
1958                    origin_key: DistributedReplayOriginKey {
1959                        instance_id: assignment.instance_id,
1960                        subsystem_code: assignment.log.subsystem_code(),
1961                        cl_id: 1,
1962                    },
1963                    incoming_origins: BTreeSet::new(),
1964                },
1965            ],
1966            output_log_path: None,
1967        })
1968    }
1969
1970    fn fake_pong_session(
1971        assignment: &DistributedReplayAssignment,
1972        _session_config: &DistributedReplaySessionConfig,
1973    ) -> CuResult<DistributedReplaySessionBuild> {
1974        Ok(DistributedReplaySessionBuild {
1975            session: Box::new(FakeReplaySession),
1976            nodes: vec![
1977                DistributedReplayNodeDescriptor {
1978                    cursor: DistributedReplayCursor::new(
1979                        assignment.instance_id,
1980                        assignment.subsystem_id.clone(),
1981                        assignment.log.subsystem_code(),
1982                        0,
1983                    ),
1984                    origin_key: DistributedReplayOriginKey {
1985                        instance_id: assignment.instance_id,
1986                        subsystem_code: assignment.log.subsystem_code(),
1987                        cl_id: 0,
1988                    },
1989                    incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
1990                        instance_id: assignment.instance_id,
1991                        subsystem_code: 0,
1992                        cl_id: 0,
1993                    }]),
1994                },
1995                DistributedReplayNodeDescriptor {
1996                    cursor: DistributedReplayCursor::new(
1997                        assignment.instance_id,
1998                        assignment.subsystem_id.clone(),
1999                        assignment.log.subsystem_code(),
2000                        1,
2001                    ),
2002                    origin_key: DistributedReplayOriginKey {
2003                        instance_id: assignment.instance_id,
2004                        subsystem_code: assignment.log.subsystem_code(),
2005                        cl_id: 1,
2006                    },
2007                    incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2008                        instance_id: assignment.instance_id,
2009                        subsystem_code: 0,
2010                        cl_id: 1,
2011                    }]),
2012                },
2013            ],
2014            output_log_path: None,
2015        })
2016    }
2017
2018    fn fake_bad_pong_session(
2019        assignment: &DistributedReplayAssignment,
2020        _session_config: &DistributedReplaySessionConfig,
2021    ) -> CuResult<DistributedReplaySessionBuild> {
2022        Ok(DistributedReplaySessionBuild {
2023            session: Box::new(FakeReplaySession),
2024            nodes: vec![DistributedReplayNodeDescriptor {
2025                cursor: DistributedReplayCursor::new(
2026                    assignment.instance_id,
2027                    assignment.subsystem_id.clone(),
2028                    assignment.log.subsystem_code(),
2029                    0,
2030                ),
2031                origin_key: DistributedReplayOriginKey {
2032                    instance_id: assignment.instance_id,
2033                    subsystem_code: assignment.log.subsystem_code(),
2034                    cl_id: 0,
2035                },
2036                incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2037                    instance_id: assignment.instance_id,
2038                    subsystem_code: 0,
2039                    cl_id: 99,
2040                }]),
2041            }],
2042            output_log_path: None,
2043        })
2044    }
2045
2046    const STRESS_SUBSYSTEMS: [(&str, u16); 4] =
2047        [("sense", 0), ("plan", 1), ("control", 2), ("telemetry", 3)];
2048
2049    fn stress_origins_for(
2050        subsystem_id: &str,
2051        instance_id: u32,
2052        cl_id: u64,
2053    ) -> BTreeSet<DistributedReplayOriginKey> {
2054        match subsystem_id {
2055            "sense" => BTreeSet::new(),
2056            "plan" => BTreeSet::from([DistributedReplayOriginKey {
2057                instance_id,
2058                subsystem_code: 0,
2059                cl_id,
2060            }]),
2061            "control" => BTreeSet::from([DistributedReplayOriginKey {
2062                instance_id,
2063                subsystem_code: 1,
2064                cl_id,
2065            }]),
2066            "telemetry" => BTreeSet::from([
2067                DistributedReplayOriginKey {
2068                    instance_id,
2069                    subsystem_code: 0,
2070                    cl_id,
2071                },
2072                DistributedReplayOriginKey {
2073                    instance_id,
2074                    subsystem_code: 2,
2075                    cl_id,
2076                },
2077            ]),
2078            _ => panic!("unexpected synthetic stress subsystem '{subsystem_id}'"),
2079        }
2080    }
2081
2082    fn build_stress_session(
2083        assignment: &DistributedReplayAssignment,
2084        _session_config: &DistributedReplaySessionConfig,
2085        cl_count: u64,
2086    ) -> CuResult<DistributedReplaySessionBuild> {
2087        let subsystem_code = assignment.log.subsystem_code();
2088        let nodes = (0..cl_count)
2089            .map(|cl_id| DistributedReplayNodeDescriptor {
2090                cursor: DistributedReplayCursor::new(
2091                    assignment.instance_id,
2092                    assignment.subsystem_id.clone(),
2093                    subsystem_code,
2094                    cl_id,
2095                ),
2096                origin_key: DistributedReplayOriginKey {
2097                    instance_id: assignment.instance_id,
2098                    subsystem_code,
2099                    cl_id,
2100                },
2101                incoming_origins: stress_origins_for(
2102                    &assignment.subsystem_id,
2103                    assignment.instance_id,
2104                    cl_id,
2105                ),
2106            })
2107            .collect();
2108        Ok(DistributedReplaySessionBuild {
2109            session: Box::new(FakeReplaySession),
2110            nodes,
2111            output_log_path: None,
2112        })
2113    }
2114
2115    fn stress_session_ci(
2116        assignment: &DistributedReplayAssignment,
2117        session_config: &DistributedReplaySessionConfig,
2118    ) -> CuResult<DistributedReplaySessionBuild> {
2119        build_stress_session(assignment, session_config, 24)
2120    }
2121
2122    fn stress_session_goto(
2123        assignment: &DistributedReplayAssignment,
2124        session_config: &DistributedReplaySessionConfig,
2125    ) -> CuResult<DistributedReplaySessionBuild> {
2126        build_stress_session(assignment, session_config, 32)
2127    }
2128
2129    fn stress_session_heavy(
2130        assignment: &DistributedReplayAssignment,
2131        session_config: &DistributedReplaySessionConfig,
2132    ) -> CuResult<DistributedReplaySessionBuild> {
2133        build_stress_session(assignment, session_config, 96)
2134    }
2135
2136    fn stress_plan(
2137        instance_count: u32,
2138        session_factory: DistributedReplaySessionFactory,
2139    ) -> DistributedReplayPlan {
2140        let assignments = (1..=instance_count)
2141            .flat_map(|instance_id| {
2142                STRESS_SUBSYSTEMS
2143                    .into_iter()
2144                    .map(move |(subsystem_id, subsystem_code)| {
2145                        fake_assignment(instance_id, subsystem_id, subsystem_code, session_factory)
2146                    })
2147            })
2148            .collect();
2149        fake_plan(assignments)
2150    }
2151
2152    fn collect_engine_order(
2153        engine: &mut DistributedReplayEngine,
2154    ) -> CuResult<Vec<DistributedReplayCursor>> {
2155        let mut order = Vec::new();
2156        while let Some(cursor) = engine.step_causal()? {
2157            order.push(cursor);
2158        }
2159        Ok(order)
2160    }
2161
2162    fn assert_stress_order_is_topological(
2163        order: &[DistributedReplayCursor],
2164        instance_count: u32,
2165        cl_count: u64,
2166    ) {
2167        let expected_len = instance_count as usize * STRESS_SUBSYSTEMS.len() * cl_count as usize;
2168        assert_eq!(order.len(), expected_len);
2169
2170        let positions: BTreeMap<_, _> = order
2171            .iter()
2172            .enumerate()
2173            .map(|(idx, cursor)| {
2174                (
2175                    (
2176                        cursor.instance_id,
2177                        cursor.subsystem_id.clone(),
2178                        cursor.cl_id,
2179                    ),
2180                    idx,
2181                )
2182            })
2183            .collect();
2184        assert_eq!(positions.len(), expected_len);
2185
2186        for instance_id in 1..=instance_count {
2187            for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2188                for cl_id in 1..cl_count {
2189                    let previous = positions
2190                        .get(&(instance_id, subsystem_id.to_string(), cl_id - 1))
2191                        .expect("previous local node missing");
2192                    let current = positions
2193                        .get(&(instance_id, subsystem_id.to_string(), cl_id))
2194                        .expect("current local node missing");
2195                    assert!(
2196                        previous < current,
2197                        "local order violated for instance {instance_id} subsystem '{subsystem_id}' cl {cl_id}"
2198                    );
2199                }
2200            }
2201
2202            for cl_id in 0..cl_count {
2203                let sense = positions
2204                    .get(&(instance_id, "sense".to_string(), cl_id))
2205                    .expect("sense node missing");
2206                let plan = positions
2207                    .get(&(instance_id, "plan".to_string(), cl_id))
2208                    .expect("plan node missing");
2209                let control = positions
2210                    .get(&(instance_id, "control".to_string(), cl_id))
2211                    .expect("control node missing");
2212                let telemetry = positions
2213                    .get(&(instance_id, "telemetry".to_string(), cl_id))
2214                    .expect("telemetry node missing");
2215                assert!(sense < plan);
2216                assert!(plan < control);
2217                assert!(sense < telemetry);
2218                assert!(control < telemetry);
2219            }
2220        }
2221    }
2222
2223    #[test]
2224    fn discovers_single_log_identity_from_runtime_lifecycle() -> CuResult<()> {
2225        let temp_dir = TempDir::new()
2226            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2227        let base_path = temp_dir.path().join("logs/ping.copper");
2228        write_runtime_lifecycle_log(&base_path, test_stack("ping", 7, 42), Some("default"))?;
2229
2230        let discovered = DistributedReplayLog::discover(&base_path)?;
2231        assert_eq!(discovered.base_path, base_path);
2232        assert_eq!(discovered.subsystem_id(), Some("ping"));
2233        assert_eq!(discovered.subsystem_code(), 7);
2234        assert_eq!(discovered.instance_id(), 42);
2235        assert_eq!(discovered.mission.as_deref(), Some("default"));
2236        Ok(())
2237    }
2238
2239    #[test]
2240    fn catalog_discovery_normalizes_slab_paths_and_deduplicates_candidates() -> CuResult<()> {
2241        let temp_dir = TempDir::new()
2242            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2243        let base_path = temp_dir.path().join("logs/pong.copper");
2244        let slab_zero_path = temp_dir.path().join("logs/pong_0.copper");
2245        write_runtime_lifecycle_log(&base_path, test_stack("pong", 3, 9), Some("default"))?;
2246
2247        let catalog = DistributedReplayCatalog::discover([base_path.clone(), slab_zero_path])?;
2248        assert!(
2249            catalog.failures.is_empty(),
2250            "unexpected failures: {:?}",
2251            catalog.failures
2252        );
2253        assert_eq!(catalog.logs.len(), 1);
2254        assert_eq!(catalog.logs[0].base_path, base_path);
2255        assert_eq!(catalog.logs[0].subsystem_id(), Some("pong"));
2256        Ok(())
2257    }
2258
2259    #[test]
2260    fn catalog_discovery_walks_directories_using_physical_slab_files() -> CuResult<()> {
2261        let temp_dir = TempDir::new()
2262            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2263        let ping_base = temp_dir.path().join("logs/ping.copper");
2264        let pong_base = temp_dir.path().join("logs/pong.copper");
2265        write_runtime_lifecycle_log(&ping_base, test_stack("ping", 0, 1), Some("alpha"))?;
2266        write_runtime_lifecycle_log(&pong_base, test_stack("pong", 1, 1), Some("alpha"))?;
2267
2268        let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2269        assert!(
2270            catalog.failures.is_empty(),
2271            "unexpected failures: {:?}",
2272            catalog.failures
2273        );
2274        assert_eq!(catalog.logs.len(), 2);
2275        assert_eq!(catalog.logs[0].subsystem_id(), Some("ping"));
2276        assert_eq!(catalog.logs[1].subsystem_id(), Some("pong"));
2277        assert_eq!(catalog.logs[0].base_path, ping_base);
2278        assert_eq!(catalog.logs[1].base_path, pong_base);
2279        Ok(())
2280    }
2281
2282    #[test]
2283    fn catalog_reports_invalid_logs_without_aborting_scan() -> CuResult<()> {
2284        let temp_dir = TempDir::new()
2285            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2286        let good_base = temp_dir.path().join("logs/good.copper");
2287        write_runtime_lifecycle_log(&good_base, test_stack("good", 2, 5), Some("beta"))?;
2288
2289        let bad_slab = temp_dir.path().join("logs/bad_0.copper");
2290        if let Some(parent) = bad_slab.parent() {
2291            fs::create_dir_all(parent).map_err(|err| {
2292                CuError::new_with_cause(
2293                    &format!("Failed to create bad log dir '{}'", parent.display()),
2294                    err,
2295                )
2296            })?;
2297        }
2298        fs::write(&bad_slab, b"not a copper log").map_err(|err| {
2299            CuError::new_with_cause(
2300                &format!("Failed to create bad log '{}'", bad_slab.display()),
2301                err,
2302            )
2303        })?;
2304
2305        let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2306        assert_eq!(catalog.logs.len(), 1);
2307        assert_eq!(catalog.failures.len(), 1);
2308        assert_eq!(catalog.logs[0].subsystem_id(), Some("good"));
2309        assert_eq!(
2310            catalog.failures[0].candidate_path,
2311            temp_dir.path().join("logs/bad.copper")
2312        );
2313        Ok(())
2314    }
2315
2316    #[test]
2317    fn builder_builds_validated_plan_for_selected_instances() -> CuResult<()> {
2318        let temp_dir = TempDir::new()
2319            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2320        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2321        let logs_root = temp_dir.path().join("logs");
2322
2323        write_runtime_lifecycle_log(
2324            &logs_root.join("instance1_ping.copper"),
2325            test_stack("ping", 0, 1),
2326            Some("default"),
2327        )?;
2328        write_runtime_lifecycle_log(
2329            &logs_root.join("instance1_pong.copper"),
2330            test_stack("pong", 1, 1),
2331            Some("default"),
2332        )?;
2333        write_runtime_lifecycle_log(
2334            &logs_root.join("instance2_ping.copper"),
2335            test_stack("ping", 0, 2),
2336            Some("default"),
2337        )?;
2338        write_runtime_lifecycle_log(
2339            &logs_root.join("instance2_pong.copper"),
2340            test_stack("pong", 1, 2),
2341            Some("default"),
2342        )?;
2343
2344        let plan = DistributedReplayPlan::builder(&multi_config_path)?
2345            .discover_logs_under(&logs_root)?
2346            .register::<PingRegisteredApp>("ping")?
2347            .register::<PongRegisteredApp>("pong")?
2348            .instances([2])
2349            .build()?;
2350
2351        assert_eq!(plan.selected_instances, vec![2]);
2352        assert_eq!(plan.mission.as_deref(), Some("default"));
2353        assert_eq!(plan.assignments.len(), 2);
2354        assert_eq!(
2355            plan.assignment(2, "ping").unwrap().log.base_path,
2356            logs_root.join("instance2_ping.copper")
2357        );
2358        assert_eq!(
2359            plan.assignment(2, "pong").unwrap().log.base_path,
2360            logs_root.join("instance2_pong.copper")
2361        );
2362        Ok(())
2363    }
2364
2365    #[test]
2366    fn register_rejects_subsystem_code_mismatch() -> CuResult<()> {
2367        let temp_dir = TempDir::new()
2368            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2369        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2370
2371        let err = DistributedReplayPlan::builder(&multi_config_path)?
2372            .register::<PingWrongCodeApp>("ping")
2373            .unwrap_err();
2374        assert!(err.to_string().contains("declares subsystem code 99"));
2375        Ok(())
2376    }
2377
2378    #[test]
2379    fn build_reports_missing_logs_and_missing_registrations() -> CuResult<()> {
2380        let temp_dir = TempDir::new()
2381            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2382        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2383        let logs_root = temp_dir.path().join("logs");
2384
2385        write_runtime_lifecycle_log(
2386            &logs_root.join("instance1_ping.copper"),
2387            test_stack("ping", 0, 1),
2388            Some("default"),
2389        )?;
2390
2391        let err = DistributedReplayPlan::builder(&multi_config_path)?
2392            .discover_logs_under(&logs_root)?
2393            .register::<PingRegisteredApp>("ping")?
2394            .build()
2395            .unwrap_err();
2396        let err_text = err.to_string();
2397        assert!(err_text.contains("missing app registration for subsystem 'pong'"));
2398        assert!(err_text.contains("missing log for instance 1 subsystem 'pong'"));
2399        Ok(())
2400    }
2401
2402    #[test]
2403    fn build_reports_duplicate_logs_for_one_target() -> CuResult<()> {
2404        let temp_dir = TempDir::new()
2405            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2406        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2407        let logs_root = temp_dir.path().join("logs");
2408
2409        write_runtime_lifecycle_log(
2410            &logs_root.join("instance1_ping_a.copper"),
2411            test_stack("ping", 0, 1),
2412            Some("default"),
2413        )?;
2414        write_runtime_lifecycle_log(
2415            &logs_root.join("instance1_ping_b.copper"),
2416            test_stack("ping", 0, 1),
2417            Some("default"),
2418        )?;
2419        write_runtime_lifecycle_log(
2420            &logs_root.join("instance1_pong.copper"),
2421            test_stack("pong", 1, 1),
2422            Some("default"),
2423        )?;
2424
2425        let err = DistributedReplayPlan::builder(&multi_config_path)?
2426            .discover_logs_under(&logs_root)?
2427            .register::<PingRegisteredApp>("ping")?
2428            .register::<PongRegisteredApp>("pong")?
2429            .build()
2430            .unwrap_err();
2431        assert!(
2432            err.to_string()
2433                .contains("found 2 logs for instance 1 subsystem 'ping'")
2434        );
2435        Ok(())
2436    }
2437
2438    #[test]
2439    fn build_reports_mission_mismatch_across_selected_logs() -> CuResult<()> {
2440        let temp_dir = TempDir::new()
2441            .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2442        let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2443        let logs_root = temp_dir.path().join("logs");
2444
2445        write_runtime_lifecycle_log(
2446            &logs_root.join("instance1_ping.copper"),
2447            test_stack("ping", 0, 1),
2448            Some("default"),
2449        )?;
2450        write_runtime_lifecycle_log(
2451            &logs_root.join("instance1_pong.copper"),
2452            test_stack("pong", 1, 1),
2453            Some("recovery"),
2454        )?;
2455
2456        let err = DistributedReplayPlan::builder(&multi_config_path)?
2457            .discover_logs_under(&logs_root)?
2458            .register::<PingRegisteredApp>("ping")?
2459            .register::<PongRegisteredApp>("pong")?
2460            .build()
2461            .unwrap_err();
2462        assert!(
2463            err.to_string()
2464                .contains("selected logs disagree on mission: default, recovery")
2465        );
2466        Ok(())
2467    }
2468
2469    #[test]
2470    fn engine_steps_in_stable_causal_order() -> CuResult<()> {
2471        let plan = fake_plan(vec![
2472            fake_assignment(1, "ping", 0, fake_ping_session),
2473            fake_assignment(1, "pong", 1, fake_pong_session),
2474        ]);
2475
2476        let mut engine = plan.start()?;
2477        let mut order = Vec::new();
2478        while let Some(cursor) = engine.step_causal()? {
2479            order.push((cursor.subsystem_id, cursor.cl_id));
2480        }
2481
2482        assert_eq!(
2483            order,
2484            vec![
2485                ("ping".to_string(), 0),
2486                ("ping".to_string(), 1),
2487                ("pong".to_string(), 0),
2488                ("pong".to_string(), 1),
2489            ]
2490        );
2491        assert_eq!(engine.executed_nodes(), 4);
2492        Ok(())
2493    }
2494
2495    #[test]
2496    fn engine_goto_rebuilds_and_replays_to_target() -> CuResult<()> {
2497        let plan = fake_plan(vec![
2498            fake_assignment(1, "ping", 0, fake_ping_session),
2499            fake_assignment(1, "pong", 1, fake_pong_session),
2500        ]);
2501
2502        let mut engine = plan.start()?;
2503        engine.run_all()?;
2504        engine.goto(1, "pong", 0)?;
2505
2506        assert_eq!(engine.executed_nodes(), 3);
2507        let frontier = engine.current_frontier();
2508        assert_eq!(frontier.len(), 2);
2509        assert!(frontier.iter().any(|cursor| {
2510            cursor.instance_id == 1 && cursor.subsystem_id == "ping" && cursor.cl_id == 1
2511        }));
2512        assert!(frontier.iter().any(|cursor| {
2513            cursor.instance_id == 1 && cursor.subsystem_id == "pong" && cursor.cl_id == 0
2514        }));
2515        Ok(())
2516    }
2517
2518    #[test]
2519    fn engine_reports_unresolved_recorded_provenance() -> CuResult<()> {
2520        let plan = fake_plan(vec![
2521            fake_assignment(1, "ping", 0, fake_ping_session),
2522            fake_assignment(1, "pong", 1, fake_bad_pong_session),
2523        ]);
2524
2525        let err = match plan.start() {
2526            Ok(_) => return Err(CuError::from("expected distributed replay startup failure")),
2527            Err(err) => err,
2528        };
2529        assert!(
2530            err.to_string()
2531                .contains("Unresolved recorded provenance edge")
2532        );
2533        Ok(())
2534    }
2535
2536    #[test]
2537    fn engine_run_all_scales_across_many_identical_instances() -> CuResult<()> {
2538        let mut engine = stress_plan(6, stress_session_ci).start()?;
2539        let order = collect_engine_order(&mut engine)?;
2540
2541        assert_stress_order_is_topological(&order, 6, 24);
2542        assert_eq!(engine.executed_nodes(), 6 * STRESS_SUBSYSTEMS.len() * 24);
2543
2544        let frontier = engine.current_frontier();
2545        assert_eq!(frontier.len(), 6 * STRESS_SUBSYSTEMS.len());
2546        for instance_id in 1..=6 {
2547            for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2548                assert!(frontier.iter().any(|cursor| {
2549                    cursor.instance_id == instance_id
2550                        && cursor.subsystem_id == subsystem_id
2551                        && cursor.cl_id == 23
2552                }));
2553            }
2554        }
2555        Ok(())
2556    }
2557
2558    #[test]
2559    fn engine_goto_matches_manual_replay_on_large_graph() -> CuResult<()> {
2560        let plan = stress_plan(5, stress_session_goto);
2561        let mut manual = plan.clone().start()?;
2562
2563        let (expected_steps, expected_frontier) = {
2564            let mut expected_steps = 0usize;
2565            loop {
2566                let Some(cursor) = manual.step_causal()? else {
2567                    return Err(CuError::from(
2568                        "manual distributed replay exhausted before reaching stress target",
2569                    ));
2570                };
2571                expected_steps += 1;
2572                if cursor.instance_id == 4 && cursor.subsystem_id == "control" && cursor.cl_id == 17
2573                {
2574                    break (expected_steps, manual.current_frontier());
2575                }
2576            }
2577        };
2578
2579        let mut via_goto = plan.start()?;
2580        via_goto.goto(4, "control", 17)?;
2581
2582        assert_eq!(via_goto.executed_nodes(), expected_steps);
2583        assert_eq!(via_goto.current_frontier(), expected_frontier);
2584        Ok(())
2585    }
2586
2587    #[test]
2588    #[ignore = "stress"]
2589    fn engine_heavy_stress_run_all_completes() -> CuResult<()> {
2590        let mut engine = stress_plan(12, stress_session_heavy).start()?;
2591        engine.run_all()?;
2592
2593        let expected = 12 * STRESS_SUBSYSTEMS.len() * 96;
2594        assert_eq!(engine.executed_nodes(), expected);
2595        assert_eq!(
2596            engine.current_frontier().len(),
2597            12 * STRESS_SUBSYSTEMS.len()
2598        );
2599        Ok(())
2600    }
2601}