1use std::collections::{BTreeMap, BTreeSet, VecDeque};
8
9use crate::graph::{DescribeEdge, DescribeNode, DescribeSnapshot, DescribeValue, Graph, Profile};
10use crate::node::Status;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ReachableDirection {
15 Upstream,
17 Downstream,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ReachableOptions {
24 pub max_depth: Option<usize>,
26 pub both: bool,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct ReachableResult {
33 pub paths: Vec<String>,
35 pub depths: BTreeMap<String, usize>,
37 pub truncated: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ExplainPathReason {
44 Ok,
46 NoSuchFrom,
48 NoSuchTo,
50 NoPath,
52 MaxDepthExceeded,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct ExplainPathOptions {
59 pub max_depth: Option<usize>,
61 pub find_cycle: bool,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct CausalStep {
68 pub id: String,
70 pub factory: String,
72 pub status: Status,
74 pub value: Option<DescribeValue>,
76 pub hop: usize,
78 pub dep_index: Option<usize>,
80 pub dep_indices: Option<Vec<usize>>,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85pub struct CausalChain {
87 pub from: String,
89 pub to: String,
91 pub found: bool,
93 pub reason: ExplainPathReason,
95 pub steps: Vec<CausalStep>,
97 pub text: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct IslandReport {
104 pub id: String,
106 pub factory: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ValidateNoIslandsResult {
113 pub ok: bool,
115 pub orphans: Vec<IslandReport>,
117}
118
119#[derive(Debug, Clone, PartialEq)]
120pub enum DescribeEvent {
122 NodeAdded {
124 id: String,
126 node: DescribeNode,
128 },
129 NodeRemoved {
131 id: String,
133 },
134 NodeMetaChanged {
136 id: String,
138 prev_meta: BTreeMap<String, String>,
140 next_meta: BTreeMap<String, String>,
142 },
143 EdgeAdded {
145 from: String,
147 to: String,
149 },
150 EdgeRemoved {
152 from: String,
154 to: String,
156 },
157 SubgraphMounted {
159 path: String,
161 },
162 SubgraphUnmounted {
164 path: String,
166 },
167}
168
169#[derive(Debug, Clone, Default, PartialEq)]
170pub struct DescribeChangeset {
172 pub events: Vec<DescribeEvent>,
174}
175
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct ProfileSummaryOptions {
179 pub limit: Option<usize>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ProfileSummaryNode {
186 pub path: String,
188 pub invokes: u64,
190 pub total_duration_ns: u128,
192 pub last_duration_ns: u128,
194 pub status: Status,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct ProfileSummaryStatus {
201 pub status: Status,
203 pub count: usize,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ProfileSummary {
210 pub node_count: usize,
212 pub total_invokes: u64,
214 pub by_status: Vec<ProfileSummaryStatus>,
216 pub hot_nodes: Vec<ProfileSummaryNode>,
218}
219
220impl ValidateNoIslandsResult {
221 pub fn summary(&self) -> String {
223 if self.orphans.is_empty() {
224 return "validate_no_islands: ok (no islands)".to_owned();
225 }
226 let head = self
227 .orphans
228 .iter()
229 .take(3)
230 .map(|o| format!("{} ({})", o.id, o.factory))
231 .collect::<Vec<_>>()
232 .join(", ");
233 format!(
234 "validate_no_islands: {} island node(s) - {}{}",
235 self.orphans.len(),
236 head,
237 if self.orphans.len() > 3 { ", ..." } else { "" }
238 )
239 }
240}
241
242#[derive(Debug, Default)]
243struct FlatSnapshot {
244 nodes: BTreeMap<String, DescribeNode>,
245 edges: BTreeMap<(String, String), DescribeEdge>,
246 subgraphs: BTreeSet<String>,
247}
248
249#[derive(Debug, Default)]
250struct SnapshotIndex {
251 nodes: BTreeMap<String, DescribeNode>,
252 outgoing: BTreeMap<String, BTreeSet<String>>,
253 incoming: BTreeMap<String, BTreeSet<String>>,
254}
255
256fn flatten(snapshot: &DescribeSnapshot, nodes: &mut Vec<DescribeNode>) {
257 nodes.extend(snapshot.nodes.clone());
258 for child in snapshot.subgraphs.iter().flatten() {
259 flatten(child, nodes);
260 }
261}
262
263fn flatten_for_diff(snapshot: &DescribeSnapshot) -> FlatSnapshot {
264 fn visit(snapshot: &DescribeSnapshot, index_path: &str, flat: &mut FlatSnapshot) {
265 for node in &snapshot.nodes {
266 flat.nodes.insert(node.id.clone(), node.clone());
267 }
268 for edge in &snapshot.edges {
269 flat.edges
270 .insert((edge.from.clone(), edge.to.clone()), edge.clone());
271 }
272 if let Some(children) = &snapshot.subgraphs {
273 for (index, child) in children.iter().enumerate() {
274 let prefixed = child.nodes.iter().find_map(|node| {
275 node.id
276 .rsplit_once("::")
277 .map(|(prefix, _)| prefix.to_owned())
278 });
279 let key = prefixed.unwrap_or_else(|| {
280 child
281 .name
282 .clone()
283 .unwrap_or_else(|| format!("{index_path}{index}"))
284 });
285 flat.subgraphs.insert(key.clone());
286 visit(child, &format!("{key}/"), flat);
287 }
288 }
289 }
290
291 let mut flat = FlatSnapshot::default();
292 visit(snapshot, "", &mut flat);
293 flat
294}
295
296fn meta_or_empty(node: &DescribeNode) -> BTreeMap<String, String> {
297 node.meta.clone().unwrap_or_default()
298}
299
300pub fn topology_diff(prev: &DescribeSnapshot, next: &DescribeSnapshot) -> DescribeChangeset {
305 let prev = flatten_for_diff(prev);
306 let next = flatten_for_diff(next);
307 let mut events = Vec::new();
308
309 for path in next.subgraphs.difference(&prev.subgraphs) {
310 events.push(DescribeEvent::SubgraphMounted { path: path.clone() });
311 }
312 for (id, node) in next
313 .nodes
314 .iter()
315 .filter(|(id, _)| !prev.nodes.contains_key(*id))
316 {
317 events.push(DescribeEvent::NodeAdded {
318 id: id.clone(),
319 node: node.clone(),
320 });
321 }
322 for (id, node) in next
323 .nodes
324 .iter()
325 .filter(|(id, _)| prev.nodes.contains_key(*id))
326 {
327 let prev_meta = meta_or_empty(
328 prev.nodes
329 .get(id)
330 .expect("filtered to nodes present in the previous snapshot"),
331 );
332 let next_meta = meta_or_empty(node);
333 if prev_meta != next_meta {
334 events.push(DescribeEvent::NodeMetaChanged {
335 id: id.clone(),
336 prev_meta,
337 next_meta,
338 });
339 }
340 }
341 for ((from, to), _) in next
342 .edges
343 .iter()
344 .filter(|(key, _)| !prev.edges.contains_key(*key))
345 {
346 events.push(DescribeEvent::EdgeAdded {
347 from: from.clone(),
348 to: to.clone(),
349 });
350 }
351 for ((from, to), _) in prev
352 .edges
353 .iter()
354 .filter(|(key, _)| !next.edges.contains_key(*key))
355 {
356 events.push(DescribeEvent::EdgeRemoved {
357 from: from.clone(),
358 to: to.clone(),
359 });
360 }
361 for id in prev.nodes.keys().filter(|id| !next.nodes.contains_key(*id)) {
362 events.push(DescribeEvent::NodeRemoved { id: id.clone() });
363 }
364 for path in prev.subgraphs.difference(&next.subgraphs) {
365 events.push(DescribeEvent::SubgraphUnmounted { path: path.clone() });
366 }
367
368 DescribeChangeset { events }
369}
370
371#[must_use]
376pub fn profile_summary(graph: &Graph, options: ProfileSummaryOptions) -> ProfileSummary {
377 let snapshot = graph.describe();
378 let profile = graph.profile();
379 profile_summary_from_snapshots(&snapshot, &profile, options)
380}
381
382#[must_use]
384pub fn profile_summary_from_snapshots(
385 snapshot: &DescribeSnapshot,
386 profile: &Profile,
387 options: ProfileSummaryOptions,
388) -> ProfileSummary {
389 let mut node_ids = BTreeSet::new();
390 collect_describe_ids(snapshot, &mut node_ids);
391 node_ids.extend(profile.nodes.keys().cloned());
392
393 let mut by_status_counts = BTreeMap::<usize, ProfileSummaryStatus>::new();
394 let mut hot_nodes = Vec::new();
395 for path in &node_ids {
396 let Some(node_profile) = profile.nodes.get(path) else {
397 continue;
398 };
399 let rank = status_rank(node_profile.status);
400 by_status_counts
401 .entry(rank)
402 .and_modify(|summary| summary.count += 1)
403 .or_insert(ProfileSummaryStatus {
404 status: node_profile.status,
405 count: 1,
406 });
407 hot_nodes.push(ProfileSummaryNode {
408 path: path.clone(),
409 invokes: node_profile.invokes,
410 total_duration_ns: node_profile.total_duration_ns,
411 last_duration_ns: node_profile.last_duration_ns,
412 status: node_profile.status,
413 });
414 }
415
416 hot_nodes.sort_by(|a, b| b.invokes.cmp(&a.invokes).then_with(|| a.path.cmp(&b.path)));
417 if let Some(limit) = options.limit {
418 hot_nodes.truncate(limit);
419 }
420
421 ProfileSummary {
422 node_count: node_ids.len(),
423 total_invokes: profile.total_invokes,
424 by_status: by_status_counts.into_values().collect(),
425 hot_nodes,
426 }
427}
428
429fn collect_describe_ids(snapshot: &DescribeSnapshot, ids: &mut BTreeSet<String>) {
430 ids.extend(snapshot.nodes.iter().map(|node| node.id.clone()));
431 for child in snapshot.subgraphs.iter().flatten() {
432 collect_describe_ids(child, ids);
433 }
434}
435
436fn status_rank(status: Status) -> usize {
437 match status {
438 Status::Sentinel => 0,
439 Status::Pending => 1,
440 Status::Dirty => 2,
441 Status::Settled => 3,
442 Status::Resolved => 4,
443 Status::Completed => 5,
444 Status::Errored => 6,
445 }
446}
447
448fn add_edge(map: &mut BTreeMap<String, BTreeSet<String>>, from: &str, to: &str) {
449 map.entry(from.to_owned())
450 .or_default()
451 .insert(to.to_owned());
452}
453
454fn index_snapshot(snapshot: &DescribeSnapshot) -> SnapshotIndex {
455 let mut flat_nodes = Vec::new();
456 flatten(snapshot, &mut flat_nodes);
457 let mut idx = SnapshotIndex::default();
458 for node in flat_nodes {
459 idx.outgoing.entry(node.id.clone()).or_default();
460 idx.incoming.entry(node.id.clone()).or_default();
461 idx.nodes.insert(node.id.clone(), node);
462 }
463
464 let nodes = idx.nodes.clone();
465 for node in nodes.values() {
466 for dep in &node.deps {
467 if idx.nodes.contains_key(dep) {
468 add_edge(&mut idx.outgoing, dep, &node.id);
469 add_edge(&mut idx.incoming, &node.id, dep);
470 }
471 }
472 }
473
474 index_snapshot_edges(snapshot, &mut idx);
475 idx
476}
477
478fn index_snapshot_edges(snapshot: &DescribeSnapshot, idx: &mut SnapshotIndex) {
479 for edge in &snapshot.edges {
480 if idx.nodes.contains_key(&edge.from) && idx.nodes.contains_key(&edge.to) {
481 add_edge(&mut idx.outgoing, &edge.from, &edge.to);
482 add_edge(&mut idx.incoming, &edge.to, &edge.from);
483 }
484 }
485 for child in snapshot.subgraphs.iter().flatten() {
486 index_snapshot_edges(child, idx);
487 }
488}
489
490fn adjacent(
491 idx: &SnapshotIndex,
492 id: &str,
493 direction: ReachableDirection,
494 both: bool,
495) -> BTreeSet<String> {
496 if both {
497 let mut out = idx.incoming.get(id).cloned().unwrap_or_default();
498 out.extend(idx.outgoing.get(id).cloned().unwrap_or_default());
499 return out;
500 }
501 match direction {
502 ReachableDirection::Upstream => idx.incoming.get(id).cloned().unwrap_or_default(),
503 ReachableDirection::Downstream => idx.outgoing.get(id).cloned().unwrap_or_default(),
504 }
505}
506
507pub fn reachable(
509 snapshot: &DescribeSnapshot,
510 from: &str,
511 direction: ReachableDirection,
512 options: ReachableOptions,
513) -> ReachableResult {
514 if from.is_empty() {
515 return ReachableResult::default();
516 }
517 let idx = index_snapshot(snapshot);
518 if !idx.nodes.contains_key(from) {
519 return ReachableResult::default();
520 }
521 if options.max_depth == Some(0) {
522 return ReachableResult {
523 truncated: !adjacent(&idx, from, direction, options.both).is_empty(),
524 ..ReachableResult::default()
525 };
526 }
527
528 let mut depths = BTreeMap::new();
529 let mut seen = BTreeSet::from([from.to_owned()]);
530 let mut queue = VecDeque::from([(from.to_owned(), 0usize)]);
531 let mut truncated = false;
532 while let Some((id, depth)) = queue.pop_front() {
533 let next = adjacent(&idx, &id, direction, options.both);
534 if options.max_depth.is_some_and(|max| depth >= max) {
535 if !next.is_empty() {
536 truncated = true;
537 }
538 continue;
539 }
540 for next_id in next {
541 if seen.insert(next_id.clone()) {
542 let next_depth = depth + 1;
543 depths.insert(next_id.clone(), next_depth);
544 queue.push_back((next_id, next_depth));
545 }
546 }
547 }
548 ReachableResult {
549 paths: depths.keys().cloned().collect(),
550 depths,
551 truncated,
552 }
553}
554
555fn dep_indices(next: Option<&DescribeNode>, prev_id: &str) -> Option<Vec<usize>> {
556 let next = next?;
557 let indices = next
558 .deps
559 .iter()
560 .enumerate()
561 .filter_map(|(i, dep)| (dep == prev_id).then_some(i))
562 .collect::<Vec<_>>();
563 (!indices.is_empty()).then_some(indices)
564}
565
566fn step_for(
567 node: &DescribeNode,
568 hop: usize,
569 edge_to_next: Option<(&DescribeNode, &str)>,
570) -> CausalStep {
571 let indices = edge_to_next.and_then(|(next, prev_id)| dep_indices(Some(next), prev_id));
572 CausalStep {
573 id: node.id.clone(),
574 factory: node.factory.clone(),
575 status: node.status,
576 value: node.value.clone(),
577 hop,
578 dep_index: indices.as_ref().and_then(|xs| xs.first().copied()),
579 dep_indices: indices.as_ref().filter(|xs| xs.len() > 1).cloned(),
580 }
581}
582
583fn make_chain(
584 from: &str,
585 to: &str,
586 reason: ExplainPathReason,
587 steps: Vec<CausalStep>,
588) -> CausalChain {
589 let found = reason == ExplainPathReason::Ok;
590 let text = if found {
591 steps
592 .iter()
593 .map(|s| s.id.as_str())
594 .collect::<Vec<_>>()
595 .join(" -> ")
596 } else {
597 format!("explain_path: {reason:?} from '{from}' to '{to}'")
598 };
599 CausalChain {
600 from: from.to_owned(),
601 to: to.to_owned(),
602 found,
603 reason,
604 steps,
605 text,
606 }
607}
608
609fn shortest_path(
610 idx: &SnapshotIndex,
611 from: &str,
612 to: &str,
613 max_depth: Option<usize>,
614) -> Option<(Vec<String>, bool)> {
615 let mut pred = BTreeMap::<String, String>::new();
616 let mut seen = BTreeSet::from([from.to_owned()]);
617 let mut queue = VecDeque::from([(from.to_owned(), 0usize)]);
618 let mut truncated = false;
619 while let Some((id, depth)) = queue.pop_front() {
620 let next = idx.outgoing.get(&id).cloned().unwrap_or_default();
621 if max_depth.is_some_and(|max| depth >= max) {
622 if !next.is_empty() {
623 truncated = true;
624 }
625 continue;
626 }
627 for next_id in next {
628 if seen.contains(&next_id) {
629 continue;
630 }
631 seen.insert(next_id.clone());
632 pred.insert(next_id.clone(), id.clone());
633 if next_id == to {
634 let mut path = vec![to.to_owned()];
635 let mut p = to.to_owned();
636 while p != from {
637 p = pred.get(&p).expect("predecessor exists").clone();
638 path.push(p.clone());
639 }
640 path.reverse();
641 return Some((path, truncated));
642 }
643 queue.push_back((next_id, depth + 1));
644 }
645 }
646 truncated.then_some((Vec::new(), true))
647}
648
649fn shortest_cycle(
650 idx: &SnapshotIndex,
651 from: &str,
652 max_depth: Option<usize>,
653) -> Option<(Vec<String>, bool)> {
654 let first = idx.outgoing.get(from).cloned().unwrap_or_default();
655 if max_depth == Some(0) {
656 return (!first.is_empty()).then_some((Vec::new(), true));
657 }
658 let mut queue = VecDeque::<(String, usize, Vec<String>)>::new();
659 let mut seen = BTreeSet::new();
660 for id in first {
661 if id == from {
662 return Some((vec![from.to_owned(), from.to_owned()], false));
663 }
664 seen.insert(id.clone());
665 queue.push_back((id.clone(), 1, vec![from.to_owned(), id]));
666 }
667
668 let mut truncated = false;
669 while let Some((id, depth, path)) = queue.pop_front() {
670 let next = idx.outgoing.get(&id).cloned().unwrap_or_default();
671 if max_depth.is_some_and(|max| depth >= max) {
672 if !next.is_empty() {
673 truncated = true;
674 }
675 continue;
676 }
677 for next_id in next {
678 if next_id == from {
679 let mut found = path;
680 found.push(from.to_owned());
681 return Some((found, false));
682 }
683 if seen.insert(next_id.clone()) {
684 let mut next_path = path.clone();
685 next_path.push(next_id.clone());
686 queue.push_back((next_id, depth + 1, next_path));
687 }
688 }
689 }
690 truncated.then_some((Vec::new(), true))
691}
692
693fn materialize_path(idx: &SnapshotIndex, path: &[String]) -> Vec<CausalStep> {
694 path.iter()
695 .enumerate()
696 .map(|(i, id)| {
697 let node = idx.nodes.get(id).expect("path node exists");
698 let next = path.get(i + 1).and_then(|next_id| idx.nodes.get(next_id));
699 step_for(node, i, next.map(|next| (next, id.as_str())))
700 })
701 .collect()
702}
703
704pub fn explain_path(
706 snapshot: &DescribeSnapshot,
707 from: &str,
708 to: &str,
709 options: ExplainPathOptions,
710) -> CausalChain {
711 let idx = index_snapshot(snapshot);
712 if !idx.nodes.contains_key(from) {
713 return make_chain(from, to, ExplainPathReason::NoSuchFrom, Vec::new());
714 }
715 if !idx.nodes.contains_key(to) {
716 return make_chain(from, to, ExplainPathReason::NoSuchTo, Vec::new());
717 }
718 if options.max_depth == Some(0) && from != to {
719 return make_chain(from, to, ExplainPathReason::NoPath, Vec::new());
720 }
721
722 if from == to && !options.find_cycle {
723 let step = step_for(idx.nodes.get(from).expect("node exists"), 0, None);
724 return make_chain(from, to, ExplainPathReason::Ok, vec![step]);
725 }
726 if from == to && options.find_cycle {
727 match shortest_cycle(&idx, from, options.max_depth) {
728 Some((path, false)) if !path.is_empty() => {
729 return make_chain(
730 from,
731 to,
732 ExplainPathReason::Ok,
733 materialize_path(&idx, &path),
734 );
735 }
736 Some((_, true)) => {
737 return make_chain(from, to, ExplainPathReason::MaxDepthExceeded, Vec::new());
738 }
739 _ => {
740 let step = step_for(idx.nodes.get(from).expect("node exists"), 0, None);
741 return make_chain(from, to, ExplainPathReason::Ok, vec![step]);
742 }
743 }
744 }
745
746 match shortest_path(&idx, from, to, options.max_depth) {
747 Some((path, _)) if !path.is_empty() => make_chain(
748 from,
749 to,
750 ExplainPathReason::Ok,
751 materialize_path(&idx, &path),
752 ),
753 Some((_, true)) => make_chain(from, to, ExplainPathReason::MaxDepthExceeded, Vec::new()),
754 _ => make_chain(from, to, ExplainPathReason::NoPath, Vec::new()),
755 }
756}
757
758pub fn validate_no_islands(snapshot: &DescribeSnapshot) -> ValidateNoIslandsResult {
760 let idx = index_snapshot(snapshot);
761 let mut orphans = Vec::new();
762 for node in idx.nodes.values() {
763 let has_deps =
764 !node.deps.is_empty() || !idx.incoming.get(&node.id).is_none_or(BTreeSet::is_empty);
765 let has_dependents = !idx.outgoing.get(&node.id).is_none_or(BTreeSet::is_empty);
766 if !has_deps && !has_dependents && !node.id.starts_with("__internal__/") {
767 orphans.push(IslandReport {
768 id: node.id.clone(),
769 factory: node.factory.clone(),
770 });
771 }
772 }
773 orphans.sort_by(|a, b| a.id.cmp(&b.id));
774 ValidateNoIslandsResult {
775 ok: orphans.is_empty(),
776 orphans,
777 }
778}