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 let expected = if idx > 0 {
538 self.copperlist_at(idx - 1)?
539 .0
540 .id
541 .checked_add(1)
542 .ok_or_else(|| CuError::from("Replay CopperList id overflow"))?
543 } else {
544 0
545 };
546 crate::continuity::validate_replay_continuity(
547 expected,
548 copperlist.id,
549 keyframe.map(|frame| frame.culistid),
550 )?;
551 <App as CuRecordedReplayApplication<S, L>>::replay_recorded_copperlist(
552 &mut self.app,
553 &self.clock_mock,
554 copperlist.as_ref(),
555 keyframe,
556 )?;
557 self.current_idx = Some(idx);
558 }
559 Ok(())
560 }
561
562 fn goto_index(&mut self, target_idx: usize) -> CuResult<()> {
563 self.ensure_started()?;
564 if target_idx >= self.total_entries {
565 return Err(CuError::from(
566 "Distributed replay target is outside the log",
567 ));
568 }
569
570 let (target_copperlist, _) = self.copperlist_at(target_idx)?;
571 let target_cl_id = target_copperlist.id;
572
573 let replay_start_idx;
574 let replay_keyframe;
575
576 if let Some(current_idx) = self.current_idx {
577 if current_idx == target_idx {
578 return Ok(());
579 }
580
581 if target_idx > current_idx {
582 replay_start_idx = current_idx + 1;
583 replay_keyframe = None;
584 } else {
585 let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
586 CuError::from("No keyframe is available to rewind distributed replay")
587 })?;
588 self.restore_keyframe(&keyframe)?;
589 replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
590 replay_keyframe = Some(keyframe);
591 }
592 } else {
593 let keyframe = self.nearest_keyframe(target_cl_id).ok_or_else(|| {
594 CuError::from("No keyframe is available to initialize distributed replay")
595 })?;
596 self.restore_keyframe(&keyframe)?;
597 replay_start_idx = self.index_for_cl_id(keyframe.culistid)?;
598 replay_keyframe = Some(keyframe);
599 }
600
601 self.replay_range(replay_start_idx, target_idx, replay_keyframe.as_ref())
602 }
603}
604
605impl<App, P, S, L> DistributedReplaySession for RecordedReplaySession<App, P, S, L>
606where
607 App: CuDistributedReplayApplication<S, L>
608 + CuRecordedReplayApplication<S, L, RecordedDataSet = P>,
609 P: CopperListTuple + 'static,
610 S: SectionStorage,
611 L: UnifiedLogWrite<S> + 'static,
612{
613 fn goto_cl(&mut self, cl_id: u64) -> CuResult<()> {
614 let target_idx = self.index_for_cl_id(cl_id)?;
615 self.goto_index(target_idx)
616 }
617
618 #[allow(deprecated)]
620 fn shutdown(&mut self) -> CuResult<()> {
621 if !self.started {
622 return Ok(());
623 }
624
625 let mut noop = |_step: App::Step<'_>| crate::simulation::SimOverride::ExecuteByRuntime;
626 <App as CuSimApplication<S, L>>::stop_all_tasks(&mut self.app, &mut noop)?;
627 self.started = false;
628 Ok(())
629 }
630}
631
632#[derive(Clone)]
634pub struct DistributedReplayAppRegistration {
635 pub subsystem: Subsystem,
636 pub app_type_name: &'static str,
637 session_factory: DistributedReplaySessionFactory,
638}
639
640impl Debug for DistributedReplayAppRegistration {
641 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
642 f.debug_struct("DistributedReplayAppRegistration")
643 .field("subsystem", &self.subsystem)
644 .field("app_type_name", &self.app_type_name)
645 .finish()
646 }
647}
648
649impl PartialEq for DistributedReplayAppRegistration {
650 fn eq(&self, other: &Self) -> bool {
651 self.subsystem == other.subsystem && self.app_type_name == other.app_type_name
652 }
653}
654
655impl Eq for DistributedReplayAppRegistration {}
656
657#[derive(Debug, Clone, PartialEq, Eq)]
659pub struct DistributedReplayAssignment {
660 pub instance_id: u32,
661 pub subsystem_id: String,
662 pub log: DistributedReplayLog,
663 pub registration: DistributedReplayAppRegistration,
664}
665
666#[derive(Debug, Clone)]
668pub struct DistributedReplayPlan {
669 pub multi_config_path: PathBuf,
670 pub multi_config: MultiCopperConfig,
671 pub catalog: DistributedReplayCatalog,
672 pub selected_instances: Vec<u32>,
673 pub mission: Option<String>,
674 pub registrations: Vec<DistributedReplayAppRegistration>,
675 pub assignments: Vec<DistributedReplayAssignment>,
676}
677
678impl DistributedReplayPlan {
679 #[inline]
680 pub fn builder(multi_config_path: impl AsRef<Path>) -> CuResult<DistributedReplayBuilder> {
681 DistributedReplayBuilder::new(multi_config_path)
682 }
683
684 #[inline]
685 pub fn assignment(
686 &self,
687 instance_id: u32,
688 subsystem_id: &str,
689 ) -> Option<&DistributedReplayAssignment> {
690 self.assignments.iter().find(|assignment| {
691 assignment.instance_id == instance_id && assignment.subsystem_id == subsystem_id
692 })
693 }
694
695 pub fn start(self) -> CuResult<DistributedReplayEngine> {
697 DistributedReplayEngine::new(self, DistributedReplaySessionConfig::default())
698 }
699
700 pub fn start_recording_logs_under(
702 self,
703 output_root: impl AsRef<Path>,
704 ) -> CuResult<DistributedReplayEngine> {
705 DistributedReplayEngine::new(
706 self,
707 DistributedReplaySessionConfig {
708 output_root: Some(output_root.as_ref().to_path_buf()),
709 },
710 )
711 }
712}
713
714#[derive(Debug, Clone, Default)]
716pub struct DistributedReplayValidationError {
717 pub issues: Vec<String>,
718}
719
720impl DistributedReplayValidationError {
721 fn push(&mut self, issue: impl Into<String>) {
722 self.issues.push(issue.into());
723 }
724
725 fn is_empty(&self) -> bool {
726 self.issues.is_empty()
727 }
728}
729
730impl Display for DistributedReplayValidationError {
731 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
732 writeln!(f, "Distributed replay validation failed:")?;
733 for issue in &self.issues {
734 writeln!(f, " - {issue}")?;
735 }
736 Ok(())
737 }
738}
739
740#[derive(Debug, Clone)]
742pub struct DistributedReplayBuilder {
743 multi_config_path: PathBuf,
744 multi_config: MultiCopperConfig,
745 discovery_inputs: Vec<PathBuf>,
746 catalog: Option<DistributedReplayCatalog>,
747 registrations: BTreeMap<String, DistributedReplayAppRegistration>,
748 selected_instances: Option<BTreeSet<u32>>,
749}
750
751impl DistributedReplayBuilder {
752 pub fn new(multi_config_path: impl AsRef<Path>) -> CuResult<Self> {
754 let multi_config_path = multi_config_path.as_ref().to_path_buf();
755 let multi_config = read_multi_configuration(&multi_config_path.to_string_lossy())?;
756 Ok(Self {
757 multi_config_path,
758 multi_config,
759 discovery_inputs: Vec::new(),
760 catalog: None,
761 registrations: BTreeMap::new(),
762 selected_instances: None,
763 })
764 }
765
766 pub fn with_catalog(mut self, catalog: DistributedReplayCatalog) -> Self {
768 self.catalog = Some(catalog);
769 self
770 }
771
772 pub fn discover_logs<I, P>(mut self, inputs: I) -> CuResult<Self>
776 where
777 I: IntoIterator<Item = P>,
778 P: AsRef<Path>,
779 {
780 self.discovery_inputs
781 .extend(inputs.into_iter().map(|path| path.as_ref().to_path_buf()));
782 self.catalog = Some(DistributedReplayCatalog::discover(
783 self.discovery_inputs.iter().collect::<Vec<_>>(),
784 )?);
785 Ok(self)
786 }
787
788 pub fn discover_logs_under(self, root: impl AsRef<Path>) -> CuResult<Self> {
790 self.discover_logs([root.as_ref().to_path_buf()])
791 }
792
793 pub fn instances<I>(mut self, instances: I) -> Self
795 where
796 I: IntoIterator<Item = u32>,
797 {
798 self.selected_instances = Some(instances.into_iter().collect());
799 self
800 }
801
802 pub fn register<App>(mut self, subsystem_id: &str) -> CuResult<Self>
804 where
805 App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
806 + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
807 + 'static,
808 {
809 if self.registrations.contains_key(subsystem_id) {
810 return Err(CuError::from(format!(
811 "Subsystem '{}' is already registered for distributed replay",
812 subsystem_id
813 )));
814 }
815
816 let expected_subsystem = self.multi_config.subsystem(subsystem_id).ok_or_else(|| {
817 CuError::from(format!(
818 "Multi-Copper config '{}' does not define subsystem '{}'",
819 self.multi_config_path.display(),
820 subsystem_id
821 ))
822 })?;
823
824 let registered_subsystem = App::subsystem();
825 let Some(registered_subsystem_id) = registered_subsystem.id() else {
826 return Err(CuError::from(format!(
827 "App type '{}' was not generated for a multi-Copper subsystem and cannot be registered for distributed replay",
828 type_name::<App>()
829 )));
830 };
831
832 if registered_subsystem_id != subsystem_id {
833 return Err(CuError::from(format!(
834 "App type '{}' declares subsystem '{}' but was registered as '{}'",
835 type_name::<App>(),
836 registered_subsystem_id,
837 subsystem_id
838 )));
839 }
840
841 let registered_subsystem_code = registered_subsystem.code();
842 if registered_subsystem_code != expected_subsystem.subsystem_code {
843 return Err(CuError::from(format!(
844 "App type '{}' declares subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
845 type_name::<App>(),
846 registered_subsystem_code,
847 subsystem_id,
848 self.multi_config_path.display(),
849 expected_subsystem.subsystem_code
850 )));
851 }
852
853 self.registrations.insert(
854 subsystem_id.to_string(),
855 DistributedReplayAppRegistration {
856 subsystem: registered_subsystem,
857 app_type_name: type_name::<App>(),
858 session_factory: build_distributed_replay_session::<App>,
859 },
860 );
861 Ok(self)
862 }
863
864 pub fn build(self) -> CuResult<DistributedReplayPlan> {
866 let catalog = match self.catalog {
867 Some(catalog) => catalog,
868 None if self.discovery_inputs.is_empty() => DistributedReplayCatalog::default(),
869 None => DistributedReplayCatalog::discover(
870 self.discovery_inputs.iter().collect::<Vec<_>>(),
871 )?,
872 };
873
874 let mut validation = DistributedReplayValidationError::default();
875
876 for failure in &catalog.failures {
877 validation.push(format!(
878 "discovery failure for '{}': {}",
879 failure.candidate_path.display(),
880 failure.error
881 ));
882 }
883
884 let subsystem_map: BTreeMap<_, _> = self
885 .multi_config
886 .subsystems
887 .iter()
888 .map(|subsystem| (subsystem.id.clone(), subsystem))
889 .collect();
890
891 for subsystem in subsystem_map.keys() {
892 if !self.registrations.contains_key(subsystem) {
893 validation.push(format!(
894 "missing app registration for subsystem '{}'",
895 subsystem
896 ));
897 }
898 }
899
900 let mut discovered_instances = BTreeSet::new();
901 let mut logs_by_target: BTreeMap<(u32, String), Vec<DistributedReplayLog>> =
902 BTreeMap::new();
903
904 for log in &catalog.logs {
905 let Some(subsystem_id) = log.subsystem_id() else {
906 validation.push(format!(
907 "discovered log '{}' is missing subsystem_id runtime metadata",
908 log.base_path.display()
909 ));
910 continue;
911 };
912
913 let Some(expected_subsystem) = subsystem_map.get(subsystem_id) else {
914 validation.push(format!(
915 "discovered log '{}' belongs to subsystem '{}' which is not present in multi-Copper config '{}'",
916 log.base_path.display(),
917 subsystem_id,
918 self.multi_config_path.display()
919 ));
920 continue;
921 };
922
923 if log.subsystem_code() != expected_subsystem.subsystem_code {
924 validation.push(format!(
925 "discovered log '{}' reports subsystem code {} for '{}' but multi-Copper config '{}' expects {}",
926 log.base_path.display(),
927 log.subsystem_code(),
928 subsystem_id,
929 self.multi_config_path.display(),
930 expected_subsystem.subsystem_code
931 ));
932 }
933
934 discovered_instances.insert(log.instance_id());
935 logs_by_target
936 .entry((log.instance_id(), subsystem_id.to_string()))
937 .or_default()
938 .push(log.clone());
939 }
940
941 for ((instance_id, subsystem_id), logs) in &logs_by_target {
942 if logs.len() > 1 {
943 validation.push(format!(
944 "found {} logs for instance {} subsystem '{}': {}",
945 logs.len(),
946 instance_id,
947 subsystem_id,
948 join_log_paths(logs)
949 ));
950 }
951 }
952
953 let selected_instances: Vec<u32> =
954 if let Some(selected_instances) = &self.selected_instances {
955 let mut selected_instances: Vec<_> = selected_instances.iter().copied().collect();
956 selected_instances.sort_unstable();
957 for instance_id in &selected_instances {
958 if !discovered_instances.contains(instance_id) {
959 validation.push(format!(
960 "selected instance {} has no discovered logs",
961 instance_id
962 ));
963 }
964 }
965 selected_instances
966 } else {
967 discovered_instances.iter().copied().collect()
968 };
969
970 if selected_instances.is_empty() {
971 validation.push("no instances selected for distributed replay");
972 }
973
974 for instance_id in &selected_instances {
975 for subsystem in &self.multi_config.subsystems {
976 if !logs_by_target.contains_key(&(*instance_id, subsystem.id.clone())) {
977 validation.push(format!(
978 "missing log for instance {} subsystem '{}'",
979 instance_id, subsystem.id
980 ));
981 }
982 }
983 }
984
985 let mut known_missions = BTreeSet::new();
986 for instance_id in &selected_instances {
987 for subsystem in &self.multi_config.subsystems {
988 if let Some(logs) = logs_by_target.get(&(*instance_id, subsystem.id.clone()))
989 && let Some(log) = logs.first()
990 && let Some(mission) = &log.mission
991 {
992 known_missions.insert(mission.clone());
993 }
994 }
995 }
996 if known_missions.len() > 1 {
997 validation.push(format!(
998 "selected logs disagree on mission: {}",
999 known_missions.into_iter().collect::<Vec<_>>().join(", ")
1000 ));
1001 }
1002
1003 if !validation.is_empty() {
1004 return Err(CuError::from(validation.to_string()));
1005 }
1006
1007 let mission = selected_instances
1008 .iter()
1009 .flat_map(|instance_id| {
1010 self.multi_config.subsystems.iter().filter_map(|subsystem| {
1011 logs_by_target
1012 .get(&(*instance_id, subsystem.id.clone()))
1013 .and_then(|logs| logs.first())
1014 .and_then(|log| log.mission.clone())
1015 })
1016 })
1017 .next();
1018
1019 let mut registrations: Vec<_> = self.registrations.into_values().collect();
1020 registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1021
1022 let mut assignments = Vec::new();
1023 for instance_id in &selected_instances {
1024 for subsystem in &self.multi_config.subsystems {
1025 let log = logs_by_target
1026 .get(&(*instance_id, subsystem.id.clone()))
1027 .and_then(|logs| logs.first())
1028 .expect("validated distributed replay plan is missing a log")
1029 .clone();
1030 let registration = registrations
1031 .iter()
1032 .find(|registration| registration.subsystem.id() == Some(subsystem.id.as_str()))
1033 .expect("validated distributed replay plan is missing a registration")
1034 .clone();
1035 assignments.push(DistributedReplayAssignment {
1036 instance_id: *instance_id,
1037 subsystem_id: subsystem.id.clone(),
1038 log,
1039 registration,
1040 });
1041 }
1042 }
1043 assignments.sort_by(|left, right| {
1044 (
1045 left.instance_id,
1046 left.registration.subsystem.code(),
1047 left.subsystem_id.as_str(),
1048 )
1049 .cmp(&(
1050 right.instance_id,
1051 right.registration.subsystem.code(),
1052 right.subsystem_id.as_str(),
1053 ))
1054 });
1055
1056 Ok(DistributedReplayPlan {
1057 multi_config_path: self.multi_config_path,
1058 multi_config: self.multi_config,
1059 catalog,
1060 selected_instances,
1061 mission,
1062 registrations,
1063 assignments,
1064 })
1065 }
1066}
1067
1068fn build_distributed_replay_session<App>(
1069 assignment: &DistributedReplayAssignment,
1070 session_config: &DistributedReplaySessionConfig,
1071) -> CuResult<DistributedReplaySessionBuild>
1072where
1073 App: CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>
1074 + CuDistributedReplayApplication<MmapSectionStorage, UnifiedLoggerWrite>
1075 + 'static,
1076{
1077 let config = read_configuration_str(assignment.log.effective_config_ron.clone(), None)
1078 .map_err(|err| {
1079 CuError::from(format!(
1080 "Failed to parse recorded effective config from '{}': {err}",
1081 assignment.log.base_path.display()
1082 ))
1083 })?;
1084 let (clock, clock_mock) = RobotClock::mock();
1085
1086 if let Some(output_root) = &session_config.output_root {
1087 let output_log_path = replay_output_log_path(output_root, assignment)?;
1088 let logger = build_replay_output_logger(
1089 &output_log_path,
1090 replay_output_log_size_bytes(assignment, &config),
1091 )?;
1092 let app = <App as CuDistributedReplayApplication<
1093 MmapSectionStorage,
1094 UnifiedLoggerWrite,
1095 >>::build_distributed_replay(
1096 clock.clone(), logger, assignment.instance_id, Some(config)
1097 )?;
1098 let mut session = RecordedReplaySession::<
1099 App,
1100 <App as CuRecordedReplayApplication<
1101 MmapSectionStorage,
1102 UnifiedLoggerWrite,
1103 >>::RecordedDataSet,
1104 MmapSectionStorage,
1105 UnifiedLoggerWrite,
1106 >::from_log(assignment.clone(), app, clock_mock, &assignment.log.base_path)?;
1107 let nodes = session.describe_nodes()?;
1108 return Ok(DistributedReplaySessionBuild {
1109 session: Box::new(session),
1110 nodes,
1111 output_log_path: Some(output_log_path),
1112 });
1113 }
1114
1115 let logger = Arc::new(Mutex::new(NoopLogger::new()));
1116 let app = <App as CuDistributedReplayApplication<NoopSectionStorage, NoopLogger>>::build_distributed_replay(
1117 clock,
1118 logger,
1119 assignment.instance_id,
1120 Some(config),
1121 )?;
1122 let mut session = RecordedReplaySession::<
1123 App,
1124 <App as CuRecordedReplayApplication<NoopSectionStorage, NoopLogger>>::RecordedDataSet,
1125 NoopSectionStorage,
1126 NoopLogger,
1127 >::from_log(
1128 assignment.clone(),
1129 app,
1130 clock_mock,
1131 &assignment.log.base_path,
1132 )?;
1133 let nodes = session.describe_nodes()?;
1134 Ok(DistributedReplaySessionBuild {
1135 session: Box::new(session),
1136 nodes,
1137 output_log_path: None,
1138 })
1139}
1140
1141fn replay_output_log_path(
1142 output_root: &Path,
1143 assignment: &DistributedReplayAssignment,
1144) -> CuResult<PathBuf> {
1145 let file_name = assignment
1146 .log
1147 .base_path
1148 .file_name()
1149 .ok_or_else(|| {
1150 CuError::from(format!(
1151 "Replay assignment log '{}' has no file name",
1152 assignment.log.base_path.display()
1153 ))
1154 })?
1155 .to_owned();
1156 Ok(output_root.join(file_name))
1157}
1158
1159fn build_replay_output_logger(
1160 path: &Path,
1161 preallocated_size: usize,
1162) -> CuResult<Arc<Mutex<UnifiedLoggerWrite>>> {
1163 if let Some(parent) = path.parent() {
1164 fs::create_dir_all(parent).map_err(|err| {
1165 CuError::new_with_cause(
1166 &format!(
1167 "Failed to create replay log directory '{}'",
1168 parent.display()
1169 ),
1170 err,
1171 )
1172 })?;
1173 }
1174 let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1175 .write(true)
1176 .create(true)
1177 .file_base_name(path)
1178 .preallocated_size(preallocated_size)
1179 .build()
1180 .map_err(|err| {
1181 CuError::new_with_cause(
1182 &format!("Failed to create replay log '{}'", path.display()),
1183 err,
1184 )
1185 })?
1186 else {
1187 return Err(CuError::from(format!(
1188 "Expected writable replay logger for '{}'",
1189 path.display()
1190 )));
1191 };
1192 Ok(Arc::new(Mutex::new(writer)))
1193}
1194
1195fn replay_output_log_size_bytes(
1196 assignment: &DistributedReplayAssignment,
1197 config: &crate::config::CuConfig,
1198) -> usize {
1199 if let Some(slab_zero) = slab_zero_path(&assignment.log.base_path)
1200 && let Ok(metadata) = fs::metadata(slab_zero)
1201 && let Ok(size) = usize::try_from(metadata.len())
1202 {
1203 return size.max(DEFAULT_REPLAY_LOG_SIZE_BYTES);
1204 }
1205
1206 config
1207 .logging
1208 .as_ref()
1209 .and_then(|logging| logging.slab_size_mib)
1210 .and_then(|size_mib| usize::try_from(size_mib).ok())
1211 .and_then(|size_mib| size_mib.checked_mul(1024 * 1024))
1212 .unwrap_or(DEFAULT_REPLAY_LOG_SIZE_BYTES)
1213}
1214
1215fn copperlist_origins<P: CopperListTuple>(
1216 copperlist: &CopperList<P>,
1217) -> BTreeSet<DistributedReplayOriginKey> {
1218 <CopperList<P> as ErasedCuStampedDataSet>::cumsgs(copperlist)
1219 .into_iter()
1220 .filter_map(|msg| msg.metadata().origin())
1221 .map(|origin| DistributedReplayOriginKey {
1222 instance_id: origin.instance_id,
1223 subsystem_code: origin.subsystem_code,
1224 cl_id: origin.cl_id,
1225 })
1226 .collect()
1227}
1228
1229#[derive(Default)]
1230struct DistributedReplayEngineState {
1231 sessions: Vec<Box<dyn DistributedReplaySession>>,
1232 nodes: Vec<DistributedReplayGraphNode>,
1233 node_lookup: BTreeMap<(u32, String, u64), usize>,
1234 output_log_paths: BTreeMap<(u32, String), PathBuf>,
1235 ready: BTreeSet<DistributedReplayReadyNode>,
1236 frontier: Vec<Option<DistributedReplayCursor>>,
1237}
1238
1239pub struct DistributedReplayEngine {
1241 plan: DistributedReplayPlan,
1242 session_config: DistributedReplaySessionConfig,
1243 sessions: Vec<Box<dyn DistributedReplaySession>>,
1244 nodes: Vec<DistributedReplayGraphNode>,
1245 node_lookup: BTreeMap<(u32, String, u64), usize>,
1246 output_log_paths: BTreeMap<(u32, String), PathBuf>,
1247 ready: BTreeSet<DistributedReplayReadyNode>,
1248 frontier: Vec<Option<DistributedReplayCursor>>,
1249 executed: Vec<bool>,
1250 executed_count: usize,
1251}
1252
1253impl DistributedReplayEngine {
1254 fn new(
1255 plan: DistributedReplayPlan,
1256 session_config: DistributedReplaySessionConfig,
1257 ) -> CuResult<Self> {
1258 let state = Self::build_state(&plan, &session_config)?;
1259 let executed = vec![false; state.nodes.len()];
1260 Ok(Self {
1261 plan,
1262 session_config,
1263 sessions: state.sessions,
1264 nodes: state.nodes,
1265 node_lookup: state.node_lookup,
1266 output_log_paths: state.output_log_paths,
1267 ready: state.ready,
1268 frontier: state.frontier,
1269 executed,
1270 executed_count: 0,
1271 })
1272 }
1273
1274 fn build_state(
1275 plan: &DistributedReplayPlan,
1276 session_config: &DistributedReplaySessionConfig,
1277 ) -> CuResult<DistributedReplayEngineState> {
1278 let mut sessions = Vec::with_capacity(plan.assignments.len());
1279 let mut pending_nodes = Vec::new();
1280 let mut session_nodes = Vec::with_capacity(plan.assignments.len());
1281 let mut output_log_paths = BTreeMap::new();
1282
1283 for assignment in &plan.assignments {
1284 let build = (assignment.registration.session_factory)(assignment, session_config)?;
1285 let session_index = sessions.len();
1286 let mut node_indices = Vec::with_capacity(build.nodes.len());
1287 for node in build.nodes {
1288 let pending_index = pending_nodes.len();
1289 pending_nodes.push((session_index, node));
1290 node_indices.push(pending_index);
1291 }
1292 if let Some(output_log_path) = build.output_log_path {
1293 let replaced = output_log_paths.insert(
1294 (assignment.instance_id, assignment.subsystem_id.clone()),
1295 output_log_path,
1296 );
1297 if replaced.is_some() {
1298 return Err(CuError::from(format!(
1299 "Duplicate replay output log assignment for instance {} subsystem '{}'",
1300 assignment.instance_id, assignment.subsystem_id
1301 )));
1302 }
1303 }
1304 sessions.push(build.session);
1305 session_nodes.push(node_indices);
1306 }
1307
1308 let mut nodes = Vec::with_capacity(pending_nodes.len());
1309 let mut origin_lookup = BTreeMap::new();
1310 let mut node_lookup = BTreeMap::new();
1311
1312 for (node_index, (session_index, descriptor)) in pending_nodes.iter().enumerate() {
1313 if origin_lookup
1314 .insert(descriptor.origin_key.clone(), node_index)
1315 .is_some()
1316 {
1317 return Err(CuError::from(format!(
1318 "Duplicate replay node detected for instance {} subsystem code {} CopperList {}",
1319 descriptor.origin_key.instance_id,
1320 descriptor.origin_key.subsystem_code,
1321 descriptor.origin_key.cl_id
1322 )));
1323 }
1324
1325 if node_lookup
1326 .insert(
1327 (
1328 descriptor.cursor.instance_id,
1329 descriptor.cursor.subsystem_id.clone(),
1330 descriptor.cursor.cl_id,
1331 ),
1332 node_index,
1333 )
1334 .is_some()
1335 {
1336 return Err(CuError::from(format!(
1337 "Duplicate replay cursor detected for instance {} subsystem '{}' CopperList {}",
1338 descriptor.cursor.instance_id,
1339 descriptor.cursor.subsystem_id,
1340 descriptor.cursor.cl_id
1341 )));
1342 }
1343
1344 nodes.push(DistributedReplayGraphNode {
1345 cursor: descriptor.cursor.clone(),
1346 session_index: *session_index,
1347 outgoing: Vec::new(),
1348 initial_dependencies: 0,
1349 remaining_dependencies: 0,
1350 });
1351 }
1352
1353 let mut edges = BTreeSet::new();
1354
1355 for node_indices in &session_nodes {
1356 for pair in node_indices.windows(2) {
1357 let from = pair[0];
1358 let to = pair[1];
1359 if edges.insert((from, to)) {
1360 nodes[from].outgoing.push(to);
1361 nodes[to].initial_dependencies += 1;
1362 }
1363 }
1364 }
1365
1366 for (target_index, (_, descriptor)) in pending_nodes.iter().enumerate() {
1367 for origin in &descriptor.incoming_origins {
1368 let source_index = origin_lookup.get(origin).copied().ok_or_else(|| {
1369 CuError::from(format!(
1370 "Unresolved recorded provenance edge into instance {} subsystem '{}' CopperList {} from instance {} subsystem code {} CopperList {}",
1371 descriptor.cursor.instance_id,
1372 descriptor.cursor.subsystem_id,
1373 descriptor.cursor.cl_id,
1374 origin.instance_id,
1375 origin.subsystem_code,
1376 origin.cl_id
1377 ))
1378 })?;
1379 if source_index == target_index {
1380 return Err(CuError::from(format!(
1381 "Recorded provenance on instance {} subsystem '{}' CopperList {} points to itself",
1382 descriptor.cursor.instance_id,
1383 descriptor.cursor.subsystem_id,
1384 descriptor.cursor.cl_id
1385 )));
1386 }
1387 if edges.insert((source_index, target_index)) {
1388 nodes[source_index].outgoing.push(target_index);
1389 nodes[target_index].initial_dependencies += 1;
1390 }
1391 }
1392 }
1393
1394 let mut ready = BTreeSet::new();
1395 for (node_index, node) in nodes.iter_mut().enumerate() {
1396 node.remaining_dependencies = node.initial_dependencies;
1397 if node.remaining_dependencies == 0 {
1398 ready.insert(DistributedReplayReadyNode {
1399 instance_id: node.cursor.instance_id,
1400 subsystem_code: node.cursor.subsystem_code(),
1401 cl_id: node.cursor.cl_id,
1402 node_index,
1403 });
1404 }
1405 }
1406
1407 if !nodes.is_empty() && ready.is_empty() {
1408 return Err(CuError::from(
1409 "Distributed replay graph has no causally ready starting point",
1410 ));
1411 }
1412
1413 Ok(DistributedReplayEngineState {
1414 frontier: vec![None; sessions.len()],
1415 sessions,
1416 nodes,
1417 node_lookup,
1418 output_log_paths,
1419 ready,
1420 })
1421 }
1422
1423 fn shutdown_sessions(sessions: &mut Vec<Box<dyn DistributedReplaySession>>) -> CuResult<()> {
1424 for session in sessions.iter_mut() {
1425 session.shutdown()?;
1426 }
1427 Ok(())
1428 }
1429
1430 fn ready_key(&self, node_index: usize) -> DistributedReplayReadyNode {
1431 let node = &self.nodes[node_index];
1432 DistributedReplayReadyNode {
1433 instance_id: node.cursor.instance_id,
1434 subsystem_code: node.cursor.subsystem_code(),
1435 cl_id: node.cursor.cl_id,
1436 node_index,
1437 }
1438 }
1439
1440 pub fn reset(&mut self) -> CuResult<()> {
1442 Self::shutdown_sessions(&mut self.sessions)?;
1443 let state = Self::build_state(&self.plan, &self.session_config)?;
1444 self.sessions = state.sessions;
1445 self.nodes = state.nodes;
1446 self.node_lookup = state.node_lookup;
1447 self.output_log_paths = state.output_log_paths;
1448 self.ready = state.ready;
1449 self.frontier = state.frontier;
1450 self.executed = vec![false; self.nodes.len()];
1451 self.executed_count = 0;
1452 Ok(())
1453 }
1454
1455 pub fn step_causal(&mut self) -> CuResult<Option<DistributedReplayCursor>> {
1457 let Some(next_ready) = self.ready.iter().next().copied() else {
1458 if self.executed_count == self.nodes.len() {
1459 return Ok(None);
1460 }
1461 return Err(CuError::from(
1462 "Distributed replay is deadlocked: no causally ready CopperList remains",
1463 ));
1464 };
1465 self.ready.remove(&next_ready);
1466
1467 let cursor = self.nodes[next_ready.node_index].cursor.clone();
1468 let session_index = self.nodes[next_ready.node_index].session_index;
1469 self.sessions[session_index].goto_cl(cursor.cl_id)?;
1470 self.executed[next_ready.node_index] = true;
1471 self.executed_count += 1;
1472 self.frontier[session_index] = Some(cursor.clone());
1473
1474 let outgoing = self.nodes[next_ready.node_index].outgoing.clone();
1475 for dependent in outgoing {
1476 let node = &mut self.nodes[dependent];
1477 node.remaining_dependencies = node.remaining_dependencies.saturating_sub(1);
1478 if node.remaining_dependencies == 0 {
1479 self.ready.insert(self.ready_key(dependent));
1480 }
1481 }
1482
1483 Ok(Some(cursor))
1484 }
1485
1486 pub fn run_all(&mut self) -> CuResult<()> {
1488 while self.step_causal()?.is_some() {}
1489 Ok(())
1490 }
1491
1492 pub fn goto(&mut self, instance_id: u32, subsystem_id: &str, cl_id: u64) -> CuResult<()> {
1494 let target = self
1495 .node_lookup
1496 .get(&(instance_id, subsystem_id.to_string(), cl_id))
1497 .copied()
1498 .ok_or_else(|| {
1499 CuError::from(format!(
1500 "Distributed replay target instance {} subsystem '{}' CopperList {} does not exist",
1501 instance_id, subsystem_id, cl_id
1502 ))
1503 })?;
1504 self.reset()?;
1505 while !self.executed[target] {
1506 let Some(_) = self.step_causal()? else {
1507 return Err(CuError::from(format!(
1508 "Distributed replay exhausted before reaching instance {} subsystem '{}' CopperList {}",
1509 instance_id, subsystem_id, cl_id
1510 )));
1511 };
1512 }
1513 Ok(())
1514 }
1515
1516 pub fn current_frontier(&self) -> Vec<DistributedReplayCursor> {
1518 self.frontier
1519 .iter()
1520 .filter_map(|cursor| cursor.clone())
1521 .collect()
1522 }
1523
1524 pub fn output_log_path(&self, instance_id: u32, subsystem_id: &str) -> Option<&Path> {
1525 self.output_log_paths
1526 .get(&(instance_id, subsystem_id.to_string()))
1527 .map(PathBuf::as_path)
1528 }
1529
1530 #[inline]
1531 pub fn total_nodes(&self) -> usize {
1532 self.nodes.len()
1533 }
1534
1535 #[inline]
1536 pub fn executed_nodes(&self) -> usize {
1537 self.executed_count
1538 }
1539}
1540
1541fn join_log_paths(logs: &[DistributedReplayLog]) -> String {
1542 logs.iter()
1543 .map(|log| log.base_path.display().to_string())
1544 .collect::<Vec<_>>()
1545 .join(", ")
1546}
1547
1548fn collect_candidate_base_paths(path: &Path, out: &mut BTreeSet<PathBuf>) -> CuResult<()> {
1549 if path.is_dir() {
1550 let mut entries = fs::read_dir(path)
1551 .map_err(|err| {
1552 CuError::new_with_cause(
1553 &format!(
1554 "Failed to read directory '{}' during distributed replay discovery",
1555 path.display()
1556 ),
1557 err,
1558 )
1559 })?
1560 .collect::<Result<Vec<_>, _>>()
1561 .map_err(|err| {
1562 CuError::new_with_cause(
1563 &format!(
1564 "Failed to enumerate directory '{}' during distributed replay discovery",
1565 path.display()
1566 ),
1567 err,
1568 )
1569 })?;
1570 entries.sort_by_key(|entry| entry.path());
1571 for entry in entries {
1572 collect_candidate_base_paths(&entry.path(), out)?;
1573 }
1574 return Ok(());
1575 }
1576
1577 if path
1578 .extension()
1579 .and_then(|ext| ext.to_str())
1580 .is_some_and(|ext| ext == "copper")
1581 {
1582 out.insert(normalize_candidate_log_base(path));
1583 }
1584
1585 Ok(())
1586}
1587
1588fn normalize_candidate_log_base(path: &Path) -> PathBuf {
1589 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
1590 return path.to_path_buf();
1591 };
1592 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1593 return path.to_path_buf();
1594 };
1595 let Some((base_stem, slab_suffix)) = stem.rsplit_once('_') else {
1596 return path.to_path_buf();
1597 };
1598
1599 if slab_suffix.is_empty() || !slab_suffix.chars().all(|c| c.is_ascii_digit()) {
1600 return path.to_path_buf();
1601 }
1602
1603 let mut normalized = path.to_path_buf();
1604 normalized.set_file_name(format!("{base_stem}.{extension}"));
1605 if slab_zero_path(&normalized).is_some_and(|slab_zero| slab_zero.exists()) {
1606 normalized
1607 } else {
1608 path.to_path_buf()
1609 }
1610}
1611
1612fn slab_zero_path(base_path: &Path) -> Option<PathBuf> {
1613 let extension = base_path.extension()?.to_str()?;
1614 let stem = base_path.file_stem()?.to_str()?;
1615 let mut slab_zero = base_path.to_path_buf();
1616 slab_zero.set_file_name(format!("{stem}_0.{extension}"));
1617 Some(slab_zero)
1618}
1619
1620fn read_next_entry<T: bincode::Decode<()>>(src: &mut impl Read) -> CuResult<Option<T>> {
1621 match decode_from_std_read::<T, _, _>(src, standard()) {
1622 Ok(entry) => Ok(Some(entry)),
1623 Err(DecodeError::UnexpectedEnd { .. }) => Ok(None),
1624 Err(DecodeError::Io { inner, .. }) if inner.kind() == std::io::ErrorKind::UnexpectedEof => {
1625 Ok(None)
1626 }
1627 Err(err) => Err(CuError::new_with_cause(
1628 "Failed to decode bincode entry during distributed replay discovery",
1629 err,
1630 )),
1631 }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::*;
1637 use crate::app::{
1638 CuDistributedReplayApplication, CuRecordedReplayApplication, CuSimApplication,
1639 CuSubsystemMetadata,
1640 };
1641 use crate::config::CuConfig;
1642 use crate::copperlist::CopperList;
1643 use crate::curuntime::KeyFrame;
1644 use crate::simulation::SimOverride;
1645 use bincode::{Decode, Encode};
1646 use cu29_clock::CuTime;
1647 use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks, WriteStream};
1648 use cu29_unifiedlog::memmap::MmapSectionStorage;
1649 use cu29_unifiedlog::stream_write;
1650 use serde::Serialize;
1651 use std::sync::{Arc, Mutex};
1652 use tempfile::TempDir;
1653
1654 fn write_runtime_lifecycle_log(
1655 base_path: &Path,
1656 stack: RuntimeLifecycleStackInfo,
1657 mission: Option<&str>,
1658 ) -> CuResult<()> {
1659 if let Some(parent) = base_path.parent() {
1660 fs::create_dir_all(parent).map_err(|err| {
1661 CuError::new_with_cause(
1662 &format!("Failed to create test log directory '{}'", parent.display()),
1663 err,
1664 )
1665 })?;
1666 }
1667
1668 let UnifiedLogger::Write(writer) = UnifiedLoggerBuilder::new()
1669 .write(true)
1670 .create(true)
1671 .preallocated_size(256 * 1024)
1672 .file_base_name(base_path)
1673 .build()
1674 .map_err(|err| {
1675 CuError::new_with_cause(
1676 &format!("Failed to create test log '{}'", base_path.display()),
1677 err,
1678 )
1679 })?
1680 else {
1681 return Err(CuError::from("Expected writable unified logger in test"));
1682 };
1683
1684 let logger = Arc::new(Mutex::new(writer));
1685 let mut stream = stream_write::<RuntimeLifecycleRecord, MmapSectionStorage>(
1686 logger.clone(),
1687 UnifiedLogType::RuntimeLifecycle,
1688 4096,
1689 )?;
1690 stream.log(&RuntimeLifecycleRecord {
1691 timestamp: CuTime::default(),
1692 event: RuntimeLifecycleEvent::Instantiated {
1693 config_source: RuntimeLifecycleConfigSource::ExternalFile,
1694 effective_config_ron: "(runtime: ())".to_string(),
1695 stack,
1696 },
1697 })?;
1698 if let Some(mission) = mission {
1699 stream.log(&RuntimeLifecycleRecord {
1700 timestamp: CuTime::from_nanos(1),
1701 event: RuntimeLifecycleEvent::MissionStarted {
1702 mission: mission.to_string(),
1703 },
1704 })?;
1705 }
1706 drop(stream);
1707 drop(logger);
1708 Ok(())
1709 }
1710
1711 fn test_stack(
1712 subsystem_id: &str,
1713 subsystem_code: u16,
1714 instance_id: u32,
1715 ) -> RuntimeLifecycleStackInfo {
1716 RuntimeLifecycleStackInfo {
1717 app_name: "demo".to_string(),
1718 app_version: "0.1.0".to_string(),
1719 git_commit: Some("abc123".to_string()),
1720 git_dirty: Some(false),
1721 subsystem_id: Some(subsystem_id.to_string()),
1722 subsystem_code,
1723 instance_id,
1724 }
1725 }
1726
1727 fn write_multi_config_fixture(temp_dir: &TempDir, subsystem_ids: &[&str]) -> CuResult<PathBuf> {
1728 for subsystem_id in subsystem_ids {
1729 let subsystem_config = temp_dir.path().join(format!("{subsystem_id}_config.ron"));
1730 fs::write(&subsystem_config, "(tasks: [], cnx: [])").map_err(|err| {
1731 CuError::new_with_cause(
1732 &format!(
1733 "Failed to write subsystem config '{}'",
1734 subsystem_config.display()
1735 ),
1736 err,
1737 )
1738 })?;
1739 }
1740
1741 let subsystem_entries = subsystem_ids
1742 .iter()
1743 .map(|subsystem_id| {
1744 format!(
1745 r#"(
1746 id: "{subsystem_id}",
1747 config: "{subsystem_id}_config.ron",
1748 )"#
1749 )
1750 })
1751 .collect::<Vec<_>>()
1752 .join(",\n");
1753
1754 let multi_config = format!(
1755 "(\n subsystems: [\n{entries}\n ],\n interconnects: [],\n)\n",
1756 entries = subsystem_entries
1757 );
1758 let multi_config_path = temp_dir.path().join("multi_copper.ron");
1759 fs::write(&multi_config_path, multi_config).map_err(|err| {
1760 CuError::new_with_cause(
1761 &format!(
1762 "Failed to write multi-Copper config '{}'",
1763 multi_config_path.display()
1764 ),
1765 err,
1766 )
1767 })?;
1768 Ok(multi_config_path)
1769 }
1770
1771 #[derive(Debug, Default, Encode, Decode, Serialize)]
1772 struct DummyRecordedDataSet;
1773
1774 impl ErasedCuStampedDataSet for DummyRecordedDataSet {
1775 fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
1776 Vec::new()
1777 }
1778 }
1779
1780 impl MatchingTasks for DummyRecordedDataSet {
1781 fn get_all_task_ids() -> &'static [&'static str] {
1782 &[]
1783 }
1784 }
1785
1786 macro_rules! impl_registered_test_app {
1787 ($name:ident, $subsystem_id:expr, $subsystem_code:expr) => {
1788 struct $name;
1789
1790 impl CuSubsystemMetadata for $name {
1791 fn subsystem() -> Subsystem {
1792 Subsystem::new(Some($subsystem_id), $subsystem_code)
1793 }
1794 }
1795
1796 impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1797 CuSimApplication<S, L> for $name
1798 {
1799 type Step<'z> = ();
1800
1801 fn get_original_config() -> String {
1802 "(tasks: [], cnx: [])".to_string()
1803 }
1804
1805 fn start_all_tasks(
1806 &mut self,
1807 _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1808 ) -> CuResult<()> {
1809 Ok(())
1810 }
1811
1812 fn run_one_iteration(
1813 &mut self,
1814 _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1815 ) -> CuResult<()> {
1816 Ok(())
1817 }
1818
1819 fn run(
1820 &mut self,
1821 _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1822 ) -> CuResult<()> {
1823 Ok(())
1824 }
1825
1826 fn stop_all_tasks(
1827 &mut self,
1828 _sim_callback: &mut impl for<'z> FnMut(Self::Step<'z>) -> SimOverride,
1829 ) -> CuResult<()> {
1830 Ok(())
1831 }
1832
1833 fn restore_keyframe(&mut self, _freezer: &KeyFrame) -> CuResult<()> {
1834 Ok(())
1835 }
1836 }
1837
1838 impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1839 CuRecordedReplayApplication<S, L> for $name
1840 {
1841 type RecordedDataSet = DummyRecordedDataSet;
1842
1843 fn replay_recorded_copperlist(
1844 &mut self,
1845 _clock_mock: &RobotClockMock,
1846 _copperlist: &CopperList<Self::RecordedDataSet>,
1847 _keyframe: Option<&KeyFrame>,
1848 ) -> CuResult<()> {
1849 Ok(())
1850 }
1851 }
1852
1853 impl<S: SectionStorage + 'static, L: UnifiedLogWrite<S> + 'static>
1854 CuDistributedReplayApplication<S, L> for $name
1855 {
1856 fn build_distributed_replay(
1857 _clock: RobotClock,
1858 _unified_logger: Arc<Mutex<L>>,
1859 _instance_id: u32,
1860 _config_override: Option<CuConfig>,
1861 ) -> CuResult<Self> {
1862 Ok(Self)
1863 }
1864 }
1865 };
1866 }
1867
1868 impl_registered_test_app!(PingRegisteredApp, "ping", 0);
1869 impl_registered_test_app!(PongRegisteredApp, "pong", 1);
1870 impl_registered_test_app!(PingWrongCodeApp, "ping", 99);
1871
1872 struct FakeReplaySession;
1873
1874 impl DistributedReplaySession for FakeReplaySession {
1875 fn goto_cl(&mut self, _cl_id: u64) -> CuResult<()> {
1876 Ok(())
1877 }
1878
1879 fn shutdown(&mut self) -> CuResult<()> {
1880 Ok(())
1881 }
1882 }
1883
1884 fn fake_registration(
1885 subsystem_id: &'static str,
1886 subsystem_code: u16,
1887 session_factory: DistributedReplaySessionFactory,
1888 ) -> DistributedReplayAppRegistration {
1889 DistributedReplayAppRegistration {
1890 subsystem: Subsystem::new(Some(subsystem_id), subsystem_code),
1891 app_type_name: "fake",
1892 session_factory,
1893 }
1894 }
1895
1896 fn fake_assignment(
1897 instance_id: u32,
1898 subsystem_id: &'static str,
1899 subsystem_code: u16,
1900 session_factory: DistributedReplaySessionFactory,
1901 ) -> DistributedReplayAssignment {
1902 DistributedReplayAssignment {
1903 instance_id,
1904 subsystem_id: subsystem_id.to_string(),
1905 log: DistributedReplayLog {
1906 base_path: PathBuf::from(format!("{subsystem_id}_{instance_id}.copper")),
1907 stack: test_stack(subsystem_id, subsystem_code, instance_id),
1908 config_source: RuntimeLifecycleConfigSource::ExternalFile,
1909 effective_config_ron: "(tasks: [], cnx: [])".to_string(),
1910 mission: Some("default".to_string()),
1911 },
1912 registration: fake_registration(subsystem_id, subsystem_code, session_factory),
1913 }
1914 }
1915
1916 fn fake_plan(assignments: Vec<DistributedReplayAssignment>) -> DistributedReplayPlan {
1917 let mut registrations: Vec<_> = assignments
1918 .iter()
1919 .map(|assignment| assignment.registration.clone())
1920 .collect();
1921 registrations.sort_by(|left, right| left.subsystem.id().cmp(&right.subsystem.id()));
1922 let mut selected_instances: Vec<_> = assignments
1923 .iter()
1924 .map(|assignment| assignment.instance_id)
1925 .collect::<BTreeSet<_>>()
1926 .into_iter()
1927 .collect();
1928 selected_instances.sort_unstable();
1929 DistributedReplayPlan {
1930 multi_config_path: PathBuf::from("fake_multi.ron"),
1931 multi_config: MultiCopperConfig {
1932 subsystems: Vec::new(),
1933 interconnects: Vec::new(),
1934 instance_overrides_root: None,
1935 },
1936 catalog: DistributedReplayCatalog::default(),
1937 selected_instances,
1938 mission: Some("default".to_string()),
1939 registrations,
1940 assignments,
1941 }
1942 }
1943
1944 fn fake_ping_session(
1945 assignment: &DistributedReplayAssignment,
1946 _session_config: &DistributedReplaySessionConfig,
1947 ) -> CuResult<DistributedReplaySessionBuild> {
1948 Ok(DistributedReplaySessionBuild {
1949 session: Box::new(FakeReplaySession),
1950 nodes: vec![
1951 DistributedReplayNodeDescriptor {
1952 cursor: DistributedReplayCursor::new(
1953 assignment.instance_id,
1954 assignment.subsystem_id.clone(),
1955 assignment.log.subsystem_code(),
1956 0,
1957 ),
1958 origin_key: DistributedReplayOriginKey {
1959 instance_id: assignment.instance_id,
1960 subsystem_code: assignment.log.subsystem_code(),
1961 cl_id: 0,
1962 },
1963 incoming_origins: BTreeSet::new(),
1964 },
1965 DistributedReplayNodeDescriptor {
1966 cursor: DistributedReplayCursor::new(
1967 assignment.instance_id,
1968 assignment.subsystem_id.clone(),
1969 assignment.log.subsystem_code(),
1970 1,
1971 ),
1972 origin_key: DistributedReplayOriginKey {
1973 instance_id: assignment.instance_id,
1974 subsystem_code: assignment.log.subsystem_code(),
1975 cl_id: 1,
1976 },
1977 incoming_origins: BTreeSet::new(),
1978 },
1979 ],
1980 output_log_path: None,
1981 })
1982 }
1983
1984 fn fake_pong_session(
1985 assignment: &DistributedReplayAssignment,
1986 _session_config: &DistributedReplaySessionConfig,
1987 ) -> CuResult<DistributedReplaySessionBuild> {
1988 Ok(DistributedReplaySessionBuild {
1989 session: Box::new(FakeReplaySession),
1990 nodes: vec![
1991 DistributedReplayNodeDescriptor {
1992 cursor: DistributedReplayCursor::new(
1993 assignment.instance_id,
1994 assignment.subsystem_id.clone(),
1995 assignment.log.subsystem_code(),
1996 0,
1997 ),
1998 origin_key: DistributedReplayOriginKey {
1999 instance_id: assignment.instance_id,
2000 subsystem_code: assignment.log.subsystem_code(),
2001 cl_id: 0,
2002 },
2003 incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2004 instance_id: assignment.instance_id,
2005 subsystem_code: 0,
2006 cl_id: 0,
2007 }]),
2008 },
2009 DistributedReplayNodeDescriptor {
2010 cursor: DistributedReplayCursor::new(
2011 assignment.instance_id,
2012 assignment.subsystem_id.clone(),
2013 assignment.log.subsystem_code(),
2014 1,
2015 ),
2016 origin_key: DistributedReplayOriginKey {
2017 instance_id: assignment.instance_id,
2018 subsystem_code: assignment.log.subsystem_code(),
2019 cl_id: 1,
2020 },
2021 incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2022 instance_id: assignment.instance_id,
2023 subsystem_code: 0,
2024 cl_id: 1,
2025 }]),
2026 },
2027 ],
2028 output_log_path: None,
2029 })
2030 }
2031
2032 fn fake_bad_pong_session(
2033 assignment: &DistributedReplayAssignment,
2034 _session_config: &DistributedReplaySessionConfig,
2035 ) -> CuResult<DistributedReplaySessionBuild> {
2036 Ok(DistributedReplaySessionBuild {
2037 session: Box::new(FakeReplaySession),
2038 nodes: vec![DistributedReplayNodeDescriptor {
2039 cursor: DistributedReplayCursor::new(
2040 assignment.instance_id,
2041 assignment.subsystem_id.clone(),
2042 assignment.log.subsystem_code(),
2043 0,
2044 ),
2045 origin_key: DistributedReplayOriginKey {
2046 instance_id: assignment.instance_id,
2047 subsystem_code: assignment.log.subsystem_code(),
2048 cl_id: 0,
2049 },
2050 incoming_origins: BTreeSet::from([DistributedReplayOriginKey {
2051 instance_id: assignment.instance_id,
2052 subsystem_code: 0,
2053 cl_id: 99,
2054 }]),
2055 }],
2056 output_log_path: None,
2057 })
2058 }
2059
2060 const STRESS_SUBSYSTEMS: [(&str, u16); 4] =
2061 [("sense", 0), ("plan", 1), ("control", 2), ("telemetry", 3)];
2062
2063 fn stress_origins_for(
2064 subsystem_id: &str,
2065 instance_id: u32,
2066 cl_id: u64,
2067 ) -> BTreeSet<DistributedReplayOriginKey> {
2068 match subsystem_id {
2069 "sense" => BTreeSet::new(),
2070 "plan" => BTreeSet::from([DistributedReplayOriginKey {
2071 instance_id,
2072 subsystem_code: 0,
2073 cl_id,
2074 }]),
2075 "control" => BTreeSet::from([DistributedReplayOriginKey {
2076 instance_id,
2077 subsystem_code: 1,
2078 cl_id,
2079 }]),
2080 "telemetry" => BTreeSet::from([
2081 DistributedReplayOriginKey {
2082 instance_id,
2083 subsystem_code: 0,
2084 cl_id,
2085 },
2086 DistributedReplayOriginKey {
2087 instance_id,
2088 subsystem_code: 2,
2089 cl_id,
2090 },
2091 ]),
2092 _ => panic!("unexpected synthetic stress subsystem '{subsystem_id}'"),
2093 }
2094 }
2095
2096 fn build_stress_session(
2097 assignment: &DistributedReplayAssignment,
2098 _session_config: &DistributedReplaySessionConfig,
2099 cl_count: u64,
2100 ) -> CuResult<DistributedReplaySessionBuild> {
2101 let subsystem_code = assignment.log.subsystem_code();
2102 let nodes = (0..cl_count)
2103 .map(|cl_id| DistributedReplayNodeDescriptor {
2104 cursor: DistributedReplayCursor::new(
2105 assignment.instance_id,
2106 assignment.subsystem_id.clone(),
2107 subsystem_code,
2108 cl_id,
2109 ),
2110 origin_key: DistributedReplayOriginKey {
2111 instance_id: assignment.instance_id,
2112 subsystem_code,
2113 cl_id,
2114 },
2115 incoming_origins: stress_origins_for(
2116 &assignment.subsystem_id,
2117 assignment.instance_id,
2118 cl_id,
2119 ),
2120 })
2121 .collect();
2122 Ok(DistributedReplaySessionBuild {
2123 session: Box::new(FakeReplaySession),
2124 nodes,
2125 output_log_path: None,
2126 })
2127 }
2128
2129 fn stress_session_ci(
2130 assignment: &DistributedReplayAssignment,
2131 session_config: &DistributedReplaySessionConfig,
2132 ) -> CuResult<DistributedReplaySessionBuild> {
2133 build_stress_session(assignment, session_config, 24)
2134 }
2135
2136 fn stress_session_goto(
2137 assignment: &DistributedReplayAssignment,
2138 session_config: &DistributedReplaySessionConfig,
2139 ) -> CuResult<DistributedReplaySessionBuild> {
2140 build_stress_session(assignment, session_config, 32)
2141 }
2142
2143 fn stress_session_heavy(
2144 assignment: &DistributedReplayAssignment,
2145 session_config: &DistributedReplaySessionConfig,
2146 ) -> CuResult<DistributedReplaySessionBuild> {
2147 build_stress_session(assignment, session_config, 96)
2148 }
2149
2150 fn stress_plan(
2151 instance_count: u32,
2152 session_factory: DistributedReplaySessionFactory,
2153 ) -> DistributedReplayPlan {
2154 let assignments = (1..=instance_count)
2155 .flat_map(|instance_id| {
2156 STRESS_SUBSYSTEMS
2157 .into_iter()
2158 .map(move |(subsystem_id, subsystem_code)| {
2159 fake_assignment(instance_id, subsystem_id, subsystem_code, session_factory)
2160 })
2161 })
2162 .collect();
2163 fake_plan(assignments)
2164 }
2165
2166 fn collect_engine_order(
2167 engine: &mut DistributedReplayEngine,
2168 ) -> CuResult<Vec<DistributedReplayCursor>> {
2169 let mut order = Vec::new();
2170 while let Some(cursor) = engine.step_causal()? {
2171 order.push(cursor);
2172 }
2173 Ok(order)
2174 }
2175
2176 fn assert_stress_order_is_topological(
2177 order: &[DistributedReplayCursor],
2178 instance_count: u32,
2179 cl_count: u64,
2180 ) {
2181 let expected_len = instance_count as usize * STRESS_SUBSYSTEMS.len() * cl_count as usize;
2182 assert_eq!(order.len(), expected_len);
2183
2184 let positions: BTreeMap<_, _> = order
2185 .iter()
2186 .enumerate()
2187 .map(|(idx, cursor)| {
2188 (
2189 (
2190 cursor.instance_id,
2191 cursor.subsystem_id.clone(),
2192 cursor.cl_id,
2193 ),
2194 idx,
2195 )
2196 })
2197 .collect();
2198 assert_eq!(positions.len(), expected_len);
2199
2200 for instance_id in 1..=instance_count {
2201 for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2202 for cl_id in 1..cl_count {
2203 let previous = positions
2204 .get(&(instance_id, subsystem_id.to_string(), cl_id - 1))
2205 .expect("previous local node missing");
2206 let current = positions
2207 .get(&(instance_id, subsystem_id.to_string(), cl_id))
2208 .expect("current local node missing");
2209 assert!(
2210 previous < current,
2211 "local order violated for instance {instance_id} subsystem '{subsystem_id}' cl {cl_id}"
2212 );
2213 }
2214 }
2215
2216 for cl_id in 0..cl_count {
2217 let sense = positions
2218 .get(&(instance_id, "sense".to_string(), cl_id))
2219 .expect("sense node missing");
2220 let plan = positions
2221 .get(&(instance_id, "plan".to_string(), cl_id))
2222 .expect("plan node missing");
2223 let control = positions
2224 .get(&(instance_id, "control".to_string(), cl_id))
2225 .expect("control node missing");
2226 let telemetry = positions
2227 .get(&(instance_id, "telemetry".to_string(), cl_id))
2228 .expect("telemetry node missing");
2229 assert!(sense < plan);
2230 assert!(plan < control);
2231 assert!(sense < telemetry);
2232 assert!(control < telemetry);
2233 }
2234 }
2235 }
2236
2237 #[test]
2238 fn discovers_single_log_identity_from_runtime_lifecycle() -> CuResult<()> {
2239 let temp_dir = TempDir::new()
2240 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2241 let base_path = temp_dir.path().join("logs/ping.copper");
2242 write_runtime_lifecycle_log(&base_path, test_stack("ping", 7, 42), Some("default"))?;
2243
2244 let discovered = DistributedReplayLog::discover(&base_path)?;
2245 assert_eq!(discovered.base_path, base_path);
2246 assert_eq!(discovered.subsystem_id(), Some("ping"));
2247 assert_eq!(discovered.subsystem_code(), 7);
2248 assert_eq!(discovered.instance_id(), 42);
2249 assert_eq!(discovered.mission.as_deref(), Some("default"));
2250 Ok(())
2251 }
2252
2253 #[test]
2254 fn catalog_discovery_normalizes_slab_paths_and_deduplicates_candidates() -> CuResult<()> {
2255 let temp_dir = TempDir::new()
2256 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2257 let base_path = temp_dir.path().join("logs/pong.copper");
2258 let slab_zero_path = temp_dir.path().join("logs/pong_0.copper");
2259 write_runtime_lifecycle_log(&base_path, test_stack("pong", 3, 9), Some("default"))?;
2260
2261 let catalog = DistributedReplayCatalog::discover([base_path.clone(), slab_zero_path])?;
2262 assert!(
2263 catalog.failures.is_empty(),
2264 "unexpected failures: {:?}",
2265 catalog.failures
2266 );
2267 assert_eq!(catalog.logs.len(), 1);
2268 assert_eq!(catalog.logs[0].base_path, base_path);
2269 assert_eq!(catalog.logs[0].subsystem_id(), Some("pong"));
2270 Ok(())
2271 }
2272
2273 #[test]
2274 fn catalog_discovery_walks_directories_using_physical_slab_files() -> CuResult<()> {
2275 let temp_dir = TempDir::new()
2276 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2277 let ping_base = temp_dir.path().join("logs/ping.copper");
2278 let pong_base = temp_dir.path().join("logs/pong.copper");
2279 write_runtime_lifecycle_log(&ping_base, test_stack("ping", 0, 1), Some("alpha"))?;
2280 write_runtime_lifecycle_log(&pong_base, test_stack("pong", 1, 1), Some("alpha"))?;
2281
2282 let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2283 assert!(
2284 catalog.failures.is_empty(),
2285 "unexpected failures: {:?}",
2286 catalog.failures
2287 );
2288 assert_eq!(catalog.logs.len(), 2);
2289 assert_eq!(catalog.logs[0].subsystem_id(), Some("ping"));
2290 assert_eq!(catalog.logs[1].subsystem_id(), Some("pong"));
2291 assert_eq!(catalog.logs[0].base_path, ping_base);
2292 assert_eq!(catalog.logs[1].base_path, pong_base);
2293 Ok(())
2294 }
2295
2296 #[test]
2297 fn catalog_reports_invalid_logs_without_aborting_scan() -> CuResult<()> {
2298 let temp_dir = TempDir::new()
2299 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2300 let good_base = temp_dir.path().join("logs/good.copper");
2301 write_runtime_lifecycle_log(&good_base, test_stack("good", 2, 5), Some("beta"))?;
2302
2303 let bad_slab = temp_dir.path().join("logs/bad_0.copper");
2304 if let Some(parent) = bad_slab.parent() {
2305 fs::create_dir_all(parent).map_err(|err| {
2306 CuError::new_with_cause(
2307 &format!("Failed to create bad log dir '{}'", parent.display()),
2308 err,
2309 )
2310 })?;
2311 }
2312 fs::write(&bad_slab, b"not a copper log").map_err(|err| {
2313 CuError::new_with_cause(
2314 &format!("Failed to create bad log '{}'", bad_slab.display()),
2315 err,
2316 )
2317 })?;
2318
2319 let catalog = DistributedReplayCatalog::discover_under(temp_dir.path())?;
2320 assert_eq!(catalog.logs.len(), 1);
2321 assert_eq!(catalog.failures.len(), 1);
2322 assert_eq!(catalog.logs[0].subsystem_id(), Some("good"));
2323 assert_eq!(
2324 catalog.failures[0].candidate_path,
2325 temp_dir.path().join("logs/bad.copper")
2326 );
2327 Ok(())
2328 }
2329
2330 #[test]
2331 fn builder_builds_validated_plan_for_selected_instances() -> CuResult<()> {
2332 let temp_dir = TempDir::new()
2333 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2334 let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2335 let logs_root = temp_dir.path().join("logs");
2336
2337 write_runtime_lifecycle_log(
2338 &logs_root.join("instance1_ping.copper"),
2339 test_stack("ping", 0, 1),
2340 Some("default"),
2341 )?;
2342 write_runtime_lifecycle_log(
2343 &logs_root.join("instance1_pong.copper"),
2344 test_stack("pong", 1, 1),
2345 Some("default"),
2346 )?;
2347 write_runtime_lifecycle_log(
2348 &logs_root.join("instance2_ping.copper"),
2349 test_stack("ping", 0, 2),
2350 Some("default"),
2351 )?;
2352 write_runtime_lifecycle_log(
2353 &logs_root.join("instance2_pong.copper"),
2354 test_stack("pong", 1, 2),
2355 Some("default"),
2356 )?;
2357
2358 let plan = DistributedReplayPlan::builder(&multi_config_path)?
2359 .discover_logs_under(&logs_root)?
2360 .register::<PingRegisteredApp>("ping")?
2361 .register::<PongRegisteredApp>("pong")?
2362 .instances([2])
2363 .build()?;
2364
2365 assert_eq!(plan.selected_instances, vec![2]);
2366 assert_eq!(plan.mission.as_deref(), Some("default"));
2367 assert_eq!(plan.assignments.len(), 2);
2368 assert_eq!(
2369 plan.assignment(2, "ping").unwrap().log.base_path,
2370 logs_root.join("instance2_ping.copper")
2371 );
2372 assert_eq!(
2373 plan.assignment(2, "pong").unwrap().log.base_path,
2374 logs_root.join("instance2_pong.copper")
2375 );
2376 Ok(())
2377 }
2378
2379 #[test]
2380 fn register_rejects_subsystem_code_mismatch() -> CuResult<()> {
2381 let temp_dir = TempDir::new()
2382 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2383 let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2384
2385 let err = DistributedReplayPlan::builder(&multi_config_path)?
2386 .register::<PingWrongCodeApp>("ping")
2387 .unwrap_err();
2388 assert!(err.to_string().contains("declares subsystem code 99"));
2389 Ok(())
2390 }
2391
2392 #[test]
2393 fn build_reports_missing_logs_and_missing_registrations() -> CuResult<()> {
2394 let temp_dir = TempDir::new()
2395 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2396 let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2397 let logs_root = temp_dir.path().join("logs");
2398
2399 write_runtime_lifecycle_log(
2400 &logs_root.join("instance1_ping.copper"),
2401 test_stack("ping", 0, 1),
2402 Some("default"),
2403 )?;
2404
2405 let err = DistributedReplayPlan::builder(&multi_config_path)?
2406 .discover_logs_under(&logs_root)?
2407 .register::<PingRegisteredApp>("ping")?
2408 .build()
2409 .unwrap_err();
2410 let err_text = err.to_string();
2411 assert!(err_text.contains("missing app registration for subsystem 'pong'"));
2412 assert!(err_text.contains("missing log for instance 1 subsystem 'pong'"));
2413 Ok(())
2414 }
2415
2416 #[test]
2417 fn build_reports_duplicate_logs_for_one_target() -> CuResult<()> {
2418 let temp_dir = TempDir::new()
2419 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2420 let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2421 let logs_root = temp_dir.path().join("logs");
2422
2423 write_runtime_lifecycle_log(
2424 &logs_root.join("instance1_ping_a.copper"),
2425 test_stack("ping", 0, 1),
2426 Some("default"),
2427 )?;
2428 write_runtime_lifecycle_log(
2429 &logs_root.join("instance1_ping_b.copper"),
2430 test_stack("ping", 0, 1),
2431 Some("default"),
2432 )?;
2433 write_runtime_lifecycle_log(
2434 &logs_root.join("instance1_pong.copper"),
2435 test_stack("pong", 1, 1),
2436 Some("default"),
2437 )?;
2438
2439 let err = DistributedReplayPlan::builder(&multi_config_path)?
2440 .discover_logs_under(&logs_root)?
2441 .register::<PingRegisteredApp>("ping")?
2442 .register::<PongRegisteredApp>("pong")?
2443 .build()
2444 .unwrap_err();
2445 assert!(
2446 err.to_string()
2447 .contains("found 2 logs for instance 1 subsystem 'ping'")
2448 );
2449 Ok(())
2450 }
2451
2452 #[test]
2453 fn build_reports_mission_mismatch_across_selected_logs() -> CuResult<()> {
2454 let temp_dir = TempDir::new()
2455 .map_err(|err| CuError::new_with_cause("Failed to create temp dir", err))?;
2456 let multi_config_path = write_multi_config_fixture(&temp_dir, &["ping", "pong"])?;
2457 let logs_root = temp_dir.path().join("logs");
2458
2459 write_runtime_lifecycle_log(
2460 &logs_root.join("instance1_ping.copper"),
2461 test_stack("ping", 0, 1),
2462 Some("default"),
2463 )?;
2464 write_runtime_lifecycle_log(
2465 &logs_root.join("instance1_pong.copper"),
2466 test_stack("pong", 1, 1),
2467 Some("recovery"),
2468 )?;
2469
2470 let err = DistributedReplayPlan::builder(&multi_config_path)?
2471 .discover_logs_under(&logs_root)?
2472 .register::<PingRegisteredApp>("ping")?
2473 .register::<PongRegisteredApp>("pong")?
2474 .build()
2475 .unwrap_err();
2476 assert!(
2477 err.to_string()
2478 .contains("selected logs disagree on mission: default, recovery")
2479 );
2480 Ok(())
2481 }
2482
2483 #[test]
2484 fn engine_steps_in_stable_causal_order() -> CuResult<()> {
2485 let plan = fake_plan(vec![
2486 fake_assignment(1, "ping", 0, fake_ping_session),
2487 fake_assignment(1, "pong", 1, fake_pong_session),
2488 ]);
2489
2490 let mut engine = plan.start()?;
2491 let mut order = Vec::new();
2492 while let Some(cursor) = engine.step_causal()? {
2493 order.push((cursor.subsystem_id, cursor.cl_id));
2494 }
2495
2496 assert_eq!(
2497 order,
2498 vec![
2499 ("ping".to_string(), 0),
2500 ("ping".to_string(), 1),
2501 ("pong".to_string(), 0),
2502 ("pong".to_string(), 1),
2503 ]
2504 );
2505 assert_eq!(engine.executed_nodes(), 4);
2506 Ok(())
2507 }
2508
2509 #[test]
2510 fn engine_goto_rebuilds_and_replays_to_target() -> CuResult<()> {
2511 let plan = fake_plan(vec![
2512 fake_assignment(1, "ping", 0, fake_ping_session),
2513 fake_assignment(1, "pong", 1, fake_pong_session),
2514 ]);
2515
2516 let mut engine = plan.start()?;
2517 engine.run_all()?;
2518 engine.goto(1, "pong", 0)?;
2519
2520 assert_eq!(engine.executed_nodes(), 3);
2521 let frontier = engine.current_frontier();
2522 assert_eq!(frontier.len(), 2);
2523 assert!(frontier.iter().any(|cursor| {
2524 cursor.instance_id == 1 && cursor.subsystem_id == "ping" && cursor.cl_id == 1
2525 }));
2526 assert!(frontier.iter().any(|cursor| {
2527 cursor.instance_id == 1 && cursor.subsystem_id == "pong" && cursor.cl_id == 0
2528 }));
2529 Ok(())
2530 }
2531
2532 #[test]
2533 fn engine_reports_unresolved_recorded_provenance() -> CuResult<()> {
2534 let plan = fake_plan(vec![
2535 fake_assignment(1, "ping", 0, fake_ping_session),
2536 fake_assignment(1, "pong", 1, fake_bad_pong_session),
2537 ]);
2538
2539 let err = match plan.start() {
2540 Ok(_) => return Err(CuError::from("expected distributed replay startup failure")),
2541 Err(err) => err,
2542 };
2543 assert!(
2544 err.to_string()
2545 .contains("Unresolved recorded provenance edge")
2546 );
2547 Ok(())
2548 }
2549
2550 #[test]
2551 fn engine_run_all_scales_across_many_identical_instances() -> CuResult<()> {
2552 let mut engine = stress_plan(6, stress_session_ci).start()?;
2553 let order = collect_engine_order(&mut engine)?;
2554
2555 assert_stress_order_is_topological(&order, 6, 24);
2556 assert_eq!(engine.executed_nodes(), 6 * STRESS_SUBSYSTEMS.len() * 24);
2557
2558 let frontier = engine.current_frontier();
2559 assert_eq!(frontier.len(), 6 * STRESS_SUBSYSTEMS.len());
2560 for instance_id in 1..=6 {
2561 for (subsystem_id, _) in STRESS_SUBSYSTEMS {
2562 assert!(frontier.iter().any(|cursor| {
2563 cursor.instance_id == instance_id
2564 && cursor.subsystem_id == subsystem_id
2565 && cursor.cl_id == 23
2566 }));
2567 }
2568 }
2569 Ok(())
2570 }
2571
2572 #[test]
2573 fn engine_goto_matches_manual_replay_on_large_graph() -> CuResult<()> {
2574 let plan = stress_plan(5, stress_session_goto);
2575 let mut manual = plan.clone().start()?;
2576
2577 let (expected_steps, expected_frontier) = {
2578 let mut expected_steps = 0usize;
2579 loop {
2580 let Some(cursor) = manual.step_causal()? else {
2581 return Err(CuError::from(
2582 "manual distributed replay exhausted before reaching stress target",
2583 ));
2584 };
2585 expected_steps += 1;
2586 if cursor.instance_id == 4 && cursor.subsystem_id == "control" && cursor.cl_id == 17
2587 {
2588 break (expected_steps, manual.current_frontier());
2589 }
2590 }
2591 };
2592
2593 let mut via_goto = plan.start()?;
2594 via_goto.goto(4, "control", 17)?;
2595
2596 assert_eq!(via_goto.executed_nodes(), expected_steps);
2597 assert_eq!(via_goto.current_frontier(), expected_frontier);
2598 Ok(())
2599 }
2600
2601 #[test]
2602 #[ignore = "stress"]
2603 fn engine_heavy_stress_run_all_completes() -> CuResult<()> {
2604 let mut engine = stress_plan(12, stress_session_heavy).start()?;
2605 engine.run_all()?;
2606
2607 let expected = 12 * STRESS_SUBSYSTEMS.len() * 96;
2608 assert_eq!(engine.executed_nodes(), expected);
2609 assert_eq!(
2610 engine.current_frontier().len(),
2611 12 * STRESS_SUBSYSTEMS.len()
2612 );
2613 Ok(())
2614 }
2615}