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