1use 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#[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 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#[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#[derive(Debug, Clone, Default)]
176pub struct DistributedReplayCatalog {
177 pub logs: Vec<DistributedReplayLog>,
178 pub failures: Vec<DistributedReplayDiscoveryFailure>,
179}
180
181impl DistributedReplayCatalog {
182 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 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 #[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(§ion_idx) {
470 self.touch_cache(section_idx);
471 return Ok(self.cache.get(§ion_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(§ion_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 #[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#[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#[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#[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 pub fn start(self) -> CuResult<DistributedReplayEngine> {
683 DistributedReplayEngine::new(self, DistributedReplaySessionConfig::default())
684 }
685
686 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#[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#[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 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 pub fn with_catalog(mut self, catalog: DistributedReplayCatalog) -> Self {
754 self.catalog = Some(catalog);
755 self
756 }
757
758 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 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 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 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 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
1225pub 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 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 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 pub fn run_all(&mut self) -> CuResult<()> {
1474 while self.step_causal()?.is_some() {}
1475 Ok(())
1476 }
1477
1478 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 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}