1#![forbid(unsafe_code)]
45
46use std::collections::{BTreeMap, BTreeSet};
47use std::error::Error;
48use std::fmt;
49use std::num::NonZeroU32;
50
51use serde_json::{Value, json};
52
53use oxide_batch_core::{
54 ChunkComponentRevisions, ChunkSize, ComponentRevision, DefinitionError, DefinitionIdentity,
55 DefinitionRevision, DefinitionTokenKind, ExitCode, FaultPolicy, FlowTarget, InFlightPolicy,
56 JobName, MAX_NODES, MAX_PARTITIONS, MAX_TRANSITIONS, NodeId, StartControls, StepName,
57 TerminalKind, definition_token, validate_token,
58};
59
60pub const MAX_OUTGOING_TRANSITIONS: usize = 64;
62pub const MAX_PATTERN_BYTES: usize = 64;
64pub const MAX_SPLIT_BRANCHES: usize = 8;
66pub const MAX_BRANCH_STEPS: usize = 8;
68pub const MAX_PARTITION_WORKERS: u8 = 64;
70
71#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
73#[non_exhaustive]
74pub enum LocalFailurePolicy {
75 #[default]
77 CancelSiblings,
78 DrainSiblings,
80}
81
82impl LocalFailurePolicy {
83 const fn as_str(self) -> &'static str {
84 match self {
85 Self::CancelSiblings => "cancel_siblings",
86 Self::DrainSiblings => "drain_siblings",
87 }
88 }
89}
90
91#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
93pub struct SplitBudget {
94 max_parallel_branches: u8,
95 repository_pool_size: u32,
96}
97
98impl SplitBudget {
99 pub fn new(max_parallel_branches: u8, repository_pool_size: u32) -> Result<Self, PlanError> {
106 if max_parallel_branches == 0 || usize::from(max_parallel_branches) > MAX_SPLIT_BRANCHES {
107 return Err(PlanError::InvalidParallelBranchBudget {
108 max: MAX_SPLIT_BRANCHES,
109 });
110 }
111 let required = u32::from(max_parallel_branches).saturating_add(1);
112 if repository_pool_size < required {
113 return Err(PlanError::InsufficientPoolCapacity {
114 required,
115 configured: repository_pool_size,
116 });
117 }
118 Ok(Self {
119 max_parallel_branches,
120 repository_pool_size,
121 })
122 }
123
124 #[must_use]
126 pub const fn max_parallel_branches(self) -> u8 {
127 self.max_parallel_branches
128 }
129
130 #[must_use]
132 pub const fn repository_pool_size(self) -> u32 {
133 self.repository_pool_size
134 }
135}
136
137impl Default for SplitBudget {
138 fn default() -> Self {
139 Self {
140 max_parallel_branches: 1,
141 repository_pool_size: 2,
142 }
143 }
144}
145
146#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
148pub struct PartitionBudget {
149 max_partition_workers: u8,
150 repository_pool_size: u32,
151}
152
153impl PartitionBudget {
154 pub fn new(max_partition_workers: u8, repository_pool_size: u32) -> Result<Self, PlanError> {
161 if !(1..=MAX_PARTITION_WORKERS).contains(&max_partition_workers) {
162 return Err(PlanError::InvalidPartitionWorkerBudget {
163 max: MAX_PARTITION_WORKERS,
164 });
165 }
166 let required = u32::from(max_partition_workers).saturating_add(1);
167 if repository_pool_size < required {
168 return Err(PlanError::InsufficientPoolCapacity {
169 required,
170 configured: repository_pool_size,
171 });
172 }
173 Ok(Self {
174 max_partition_workers,
175 repository_pool_size,
176 })
177 }
178
179 #[must_use]
181 pub const fn max_partition_workers(self) -> u8 {
182 self.max_partition_workers
183 }
184
185 #[must_use]
187 pub const fn repository_pool_size(self) -> u32 {
188 self.repository_pool_size
189 }
190}
191
192impl Default for PartitionBudget {
193 fn default() -> Self {
194 Self {
195 max_partition_workers: 4,
196 repository_pool_size: 5,
197 }
198 }
199}
200
201definition_token!(
202 DeciderRevision,
203 DefinitionTokenKind::Decider,
204 "An application-owned revision token for one deterministic decider."
205);
206
207#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub struct DecisionInputVersion(NonZeroU32);
210
211impl DecisionInputVersion {
212 pub fn new(value: u32) -> Result<Self, PlanError> {
218 NonZeroU32::new(value)
219 .map(Self)
220 .ok_or(PlanError::ZeroDecisionInputVersion)
221 }
222
223 #[must_use]
225 pub const fn get(self) -> u32 {
226 self.0.get()
227 }
228}
229
230#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
239pub struct ExitPattern(String);
240
241impl ExitPattern {
242 pub fn new(value: impl Into<String>) -> Result<Self, PlanError> {
250 let value = value.into();
251 if value.is_empty()
252 || value.len() > MAX_PATTERN_BYTES
253 || value.trim() != value
254 || value.chars().any(char::is_control)
255 {
256 return Err(PlanError::InvalidPattern {
257 max_bytes: MAX_PATTERN_BYTES,
258 });
259 }
260 Ok(Self(value))
261 }
262
263 #[must_use]
265 pub fn as_str(&self) -> &str {
266 &self.0
267 }
268
269 #[must_use]
271 pub fn matches(&self, code: &ExitCode) -> bool {
272 let pattern: Vec<char> = self.0.chars().collect();
273 let value: Vec<char> = code.as_str().chars().collect();
274 matches_from(&pattern, &value)
275 }
276
277 #[must_use]
281 pub fn specificity(&self) -> PatternSpecificity {
282 let wildcards = self
283 .0
284 .chars()
285 .filter(|character| matches!(character, '*' | '?'))
286 .count();
287 let literals = self.0.chars().count() - wildcards;
288 PatternSpecificity {
289 literals,
290 wildcards,
291 bytes: self.0.len(),
292 }
293 }
294
295 #[must_use]
297 pub fn intersects(&self, other: &Self) -> bool {
298 let left: Vec<char> = self.0.chars().collect();
299 let right: Vec<char> = other.0.chars().collect();
300 let mut memo = vec![None; (left.len() + 1) * (right.len() + 1)];
301 intersects_from(&left, &right, 0, 0, &mut memo)
302 }
303}
304
305impl fmt::Display for ExitPattern {
306 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
307 formatter.write_str(&self.0)
308 }
309}
310
311fn matches_from(pattern: &[char], value: &[char]) -> bool {
312 let mut pattern_index = 0_usize;
313 let mut value_index = 0_usize;
314 let mut star: Option<(usize, usize)> = None;
315 while value_index < value.len() {
316 match pattern.get(pattern_index) {
317 Some('*') => {
318 star = Some((pattern_index, value_index));
319 pattern_index += 1;
320 }
321 Some('?') => {
322 pattern_index += 1;
323 value_index += 1;
324 }
325 Some(literal) if *literal == value[value_index] => {
326 pattern_index += 1;
327 value_index += 1;
328 }
329 _ => match star {
330 Some((star_index, resume)) => {
331 pattern_index = star_index + 1;
332 value_index = resume + 1;
333 star = Some((star_index, resume + 1));
334 }
335 None => return false,
336 },
337 }
338 }
339 pattern[pattern_index..]
340 .iter()
341 .all(|character| *character == '*')
342}
343
344fn intersects_from(
345 left: &[char],
346 right: &[char],
347 left_index: usize,
348 right_index: usize,
349 memo: &mut [Option<bool>],
350) -> bool {
351 let key = left_index * (right.len() + 1) + right_index;
352 if let Some(cached) = memo[key] {
353 return cached;
354 }
355 let answer = match (left.get(left_index), right.get(right_index)) {
356 (None, None) => true,
357 (None, Some(_)) => right[right_index..].iter().all(|value| *value == '*'),
358 (Some(_), None) => left[left_index..].iter().all(|value| *value == '*'),
359 (Some('*'), _) => {
360 intersects_from(left, right, left_index + 1, right_index, memo)
361 || intersects_from(left, right, left_index, right_index + 1, memo)
362 }
363 (_, Some('*')) => {
364 intersects_from(left, right, left_index, right_index + 1, memo)
365 || intersects_from(left, right, left_index + 1, right_index, memo)
366 }
367 (Some(left_character), Some(right_character)) => {
368 (*left_character == '?' || *right_character == '?' || left_character == right_character)
369 && intersects_from(left, right, left_index + 1, right_index + 1, memo)
370 }
371 };
372 memo[key] = Some(answer);
373 answer
374}
375
376#[derive(Clone, Copy, Debug, Eq, PartialEq)]
381pub struct PatternSpecificity {
382 literals: usize,
383 wildcards: usize,
384 bytes: usize,
385}
386
387impl PatternSpecificity {
388 #[must_use]
390 pub const fn literals(self) -> usize {
391 self.literals
392 }
393
394 #[must_use]
396 pub const fn wildcards(self) -> usize {
397 self.wildcards
398 }
399
400 #[must_use]
402 pub const fn bytes(self) -> usize {
403 self.bytes
404 }
405}
406
407impl Ord for PatternSpecificity {
408 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
409 self.literals
410 .cmp(&other.literals)
411 .then_with(|| other.wildcards.cmp(&self.wildcards))
412 .then_with(|| self.bytes.cmp(&other.bytes))
413 }
414}
415
416impl PartialOrd for PatternSpecificity {
417 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
418 Some(self.cmp(other))
419 }
420}
421
422#[derive(Clone, Debug, Eq, PartialEq)]
424#[non_exhaustive]
425pub enum StepComponents {
426 Tasklet(ComponentRevision),
428 Chunk {
430 size: ChunkSize,
432 revisions: Box<ChunkComponentRevisions>,
434 },
435}
436
437impl StepComponents {
438 fn kind_name(&self) -> &'static str {
439 match self {
440 Self::Tasklet(_) => "tasklet",
441 Self::Chunk { .. } => "chunk",
442 }
443 }
444
445 fn manifest_value(&self) -> Value {
446 match self {
447 Self::Tasklet(revision) => json!({
448 "component": revision.as_str(),
449 "delivery_mode": "best_effort",
450 "transaction_boundary": "tasklet_completion"
451 }),
452 Self::Chunk { size, revisions } => {
453 let mut chunk = chunk_declaration_manifest(revisions);
454 if let Some(members) = chunk.as_object_mut() {
455 members.insert("size".to_owned(), json!(size.get()));
456 members.insert(
457 "transaction_boundary".to_owned(),
458 Value::String("chunk".to_owned()),
459 );
460 }
461 chunk
462 }
463 }
464 }
465}
466
467#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct StepNode {
470 id: NodeId,
471 step_name: StepName,
472 components: StepComponents,
473 start: StartControls,
474 fault: Option<FaultPolicy>,
475 listeners: Vec<ComponentRevision>,
476}
477
478impl StepNode {
479 #[must_use]
481 pub fn new(id: NodeId, step_name: StepName, components: StepComponents) -> Self {
482 Self {
483 id,
484 step_name,
485 components,
486 start: StartControls::default(),
487 fault: None,
488 listeners: Vec::new(),
489 }
490 }
491
492 #[must_use]
494 pub const fn with_start_controls(mut self, start: StartControls) -> Self {
495 self.start = start;
496 self
497 }
498
499 #[must_use]
501 pub fn with_fault_policy(mut self, policy: FaultPolicy) -> Self {
502 self.fault = Some(policy);
503 self
504 }
505
506 #[must_use]
508 pub fn with_listener_revision(mut self, revision: ComponentRevision) -> Self {
509 self.listeners.push(revision);
510 self
511 }
512
513 #[must_use]
515 pub const fn id(&self) -> &NodeId {
516 &self.id
517 }
518
519 #[must_use]
521 pub const fn step_name(&self) -> &StepName {
522 &self.step_name
523 }
524
525 #[must_use]
527 pub const fn components(&self) -> &StepComponents {
528 &self.components
529 }
530
531 #[must_use]
533 pub const fn start_controls(&self) -> StartControls {
534 self.start
535 }
536
537 #[must_use]
539 pub const fn fault_policy(&self) -> Option<&FaultPolicy> {
540 self.fault.as_ref()
541 }
542
543 #[must_use]
545 pub fn listener_revisions(&self) -> &[ComponentRevision] {
546 &self.listeners
547 }
548
549 fn manifest_value(&self) -> Value {
550 json!({
551 "id": self.id.as_str(),
552 "kind": "step",
553 "listeners": self
554 .listeners
555 .iter()
556 .map(|revision| Value::String(revision.as_str().to_owned()))
557 .collect::<Vec<_>>(),
558 "policy": self.fault.as_ref().map_or(Value::Null, fault_manifest_value),
559 "start": start_controls_manifest(self.start),
560 "step": {
561 "declaration": self.components.manifest_value(),
562 "kind": self.components.kind_name(),
563 "name": self.step_name.as_str()
564 }
565 })
566 }
567}
568
569#[derive(Clone, Debug, Eq, PartialEq)]
574pub struct DecisionNode {
575 id: NodeId,
576 revision: DeciderRevision,
577 input_version: DecisionInputVersion,
578}
579
580impl DecisionNode {
581 #[must_use]
583 pub const fn new(
584 id: NodeId,
585 revision: DeciderRevision,
586 input_version: DecisionInputVersion,
587 ) -> Self {
588 Self {
589 id,
590 revision,
591 input_version,
592 }
593 }
594
595 #[must_use]
597 pub const fn id(&self) -> &NodeId {
598 &self.id
599 }
600
601 #[must_use]
603 pub const fn revision(&self) -> &DeciderRevision {
604 &self.revision
605 }
606
607 #[must_use]
609 pub const fn input_version(&self) -> DecisionInputVersion {
610 self.input_version
611 }
612
613 fn manifest_value(&self) -> Value {
614 json!({
615 "decision": {
616 "input_version": self.input_version.get(),
617 "revision": self.revision.as_str()
618 },
619 "id": self.id.as_str(),
620 "kind": "decision"
621 })
622 }
623}
624
625#[derive(Clone, Debug, Eq, PartialEq)]
627pub struct SplitBranch {
628 steps: Vec<StepNode>,
629}
630
631impl SplitBranch {
632 #[must_use]
638 pub fn new(steps: Vec<StepNode>) -> Self {
639 Self { steps }
640 }
641
642 #[must_use]
644 pub fn steps(&self) -> &[StepNode] {
645 &self.steps
646 }
647
648 #[must_use]
650 pub fn id(&self) -> Option<&NodeId> {
651 self.steps.first().map(StepNode::id)
652 }
653
654 fn manifest_value(&self) -> Value {
655 Value::Array(self.steps.iter().map(StepNode::manifest_value).collect())
656 }
657}
658
659#[derive(Clone, Debug, Eq, PartialEq)]
661pub struct SplitNode {
662 id: NodeId,
663 branches: Vec<SplitBranch>,
664 join: NodeId,
665 budget: SplitBudget,
666 failure_policy: LocalFailurePolicy,
667}
668
669impl SplitNode {
670 #[must_use]
672 pub fn new(id: NodeId, branches: Vec<SplitBranch>, join: NodeId, budget: SplitBudget) -> Self {
673 Self {
674 id,
675 branches,
676 join,
677 budget,
678 failure_policy: LocalFailurePolicy::default(),
679 }
680 }
681
682 #[must_use]
684 pub const fn with_failure_policy(mut self, failure_policy: LocalFailurePolicy) -> Self {
685 self.failure_policy = failure_policy;
686 self
687 }
688
689 #[must_use]
691 pub const fn id(&self) -> &NodeId {
692 &self.id
693 }
694
695 #[must_use]
697 pub fn branches(&self) -> &[SplitBranch] {
698 &self.branches
699 }
700
701 #[must_use]
703 pub const fn join(&self) -> &NodeId {
704 &self.join
705 }
706
707 #[must_use]
709 pub const fn budget(&self) -> SplitBudget {
710 self.budget
711 }
712
713 #[must_use]
715 pub const fn failure_policy(&self) -> LocalFailurePolicy {
716 self.failure_policy
717 }
718
719 fn manifest_value(&self) -> Value {
725 json!({
726 "branches": self.branches.iter().map(SplitBranch::manifest_value).collect::<Vec<_>>(),
727 "failure_policy": self.failure_policy.as_str(),
728 "id": self.id.as_str(),
729 "join": self.join.as_str(),
730 "kind": "split"
731 })
732 }
733}
734
735#[derive(Clone, Debug, Eq, PartialEq)]
737pub struct JoinNode {
738 id: NodeId,
739}
740
741impl JoinNode {
742 #[must_use]
744 pub const fn new(id: NodeId) -> Self {
745 Self { id }
746 }
747
748 #[must_use]
750 pub const fn id(&self) -> &NodeId {
751 &self.id
752 }
753
754 fn manifest_value(&self) -> Value {
755 json!({
756 "id": self.id.as_str(),
757 "kind": "join"
758 })
759 }
760}
761
762#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
764pub struct PartitionCount(u16);
765
766impl PartitionCount {
767 pub fn new(value: u16) -> Result<Self, PlanError> {
773 if value == 0 || value > MAX_PARTITIONS {
774 return Err(PlanError::InvalidPartitionCount {
775 max: MAX_PARTITIONS,
776 });
777 }
778 Ok(Self(value))
779 }
780
781 #[must_use]
783 pub const fn get(self) -> u16 {
784 self.0
785 }
786}
787
788#[derive(Clone, Debug, Eq, PartialEq)]
790pub struct PartitionedStepNode {
791 id: NodeId,
792 step_name: StepName,
793 worker: StepNode,
794 partitioner: ComponentRevision,
795 aggregation: ComponentRevision,
796 partitions: PartitionCount,
797 budget: PartitionBudget,
798 failure_policy: LocalFailurePolicy,
799 start: StartControls,
800}
801
802impl PartitionedStepNode {
803 #[allow(clippy::too_many_arguments)]
805 #[must_use]
806 pub fn new(
807 id: NodeId,
808 step_name: StepName,
809 worker: StepNode,
810 partitioner: ComponentRevision,
811 aggregation: ComponentRevision,
812 partitions: PartitionCount,
813 budget: PartitionBudget,
814 ) -> Self {
815 Self {
816 id,
817 step_name,
818 worker,
819 partitioner,
820 aggregation,
821 partitions,
822 budget,
823 failure_policy: LocalFailurePolicy::default(),
824 start: StartControls::default(),
825 }
826 }
827
828 #[must_use]
830 pub const fn with_failure_policy(mut self, failure_policy: LocalFailurePolicy) -> Self {
831 self.failure_policy = failure_policy;
832 self
833 }
834
835 #[must_use]
837 pub const fn with_start_controls(mut self, start: StartControls) -> Self {
838 self.start = start;
839 self
840 }
841
842 #[must_use]
844 pub const fn id(&self) -> &NodeId {
845 &self.id
846 }
847
848 #[must_use]
850 pub const fn step_name(&self) -> &StepName {
851 &self.step_name
852 }
853
854 #[must_use]
856 pub const fn worker(&self) -> &StepNode {
857 &self.worker
858 }
859
860 #[must_use]
862 pub const fn partitioner(&self) -> &ComponentRevision {
863 &self.partitioner
864 }
865
866 #[must_use]
868 pub const fn aggregation(&self) -> &ComponentRevision {
869 &self.aggregation
870 }
871
872 #[must_use]
874 pub const fn partition_count(&self) -> PartitionCount {
875 self.partitions
876 }
877
878 #[must_use]
880 pub const fn budget(&self) -> PartitionBudget {
881 self.budget
882 }
883
884 #[must_use]
886 pub const fn failure_policy(&self) -> LocalFailurePolicy {
887 self.failure_policy
888 }
889
890 #[must_use]
892 pub const fn start_controls(&self) -> StartControls {
893 self.start
894 }
895
896 fn manifest_value(&self) -> Value {
904 json!({
905 "aggregation": self.aggregation.as_str(),
906 "failure_policy": self.failure_policy.as_str(),
907 "id": self.id.as_str(),
908 "kind": "partitioned_step",
909 "partition_count": self.partitions.get(),
910 "partitioner": self.partitioner.as_str(),
911 "start": start_controls_manifest(self.start),
912 "step_name": self.step_name.as_str(),
913 "worker": self.worker.manifest_value()
914 })
915 }
916}
917
918#[derive(Clone, Debug, Eq, PartialEq)]
920#[non_exhaustive]
921pub enum FlowNode {
922 Step(Box<StepNode>),
924 Decision(DecisionNode),
926 Split(Box<SplitNode>),
928 Join(JoinNode),
930 PartitionedStep(Box<PartitionedStepNode>),
932}
933
934impl FlowNode {
935 #[must_use]
937 pub fn step(node: StepNode) -> Self {
938 Self::Step(Box::new(node))
939 }
940
941 #[must_use]
943 pub const fn decision(node: DecisionNode) -> Self {
944 Self::Decision(node)
945 }
946
947 #[must_use]
949 pub fn split(node: SplitNode) -> Self {
950 Self::Split(Box::new(node))
951 }
952
953 #[must_use]
955 pub const fn join(node: JoinNode) -> Self {
956 Self::Join(node)
957 }
958
959 #[must_use]
961 pub fn partitioned_step(node: PartitionedStepNode) -> Self {
962 Self::PartitionedStep(Box::new(node))
963 }
964
965 #[must_use]
967 pub const fn id(&self) -> &NodeId {
968 match self {
969 Self::Step(node) => node.id(),
970 Self::Decision(node) => node.id(),
971 Self::Split(node) => node.id(),
972 Self::Join(node) => node.id(),
973 Self::PartitionedStep(node) => node.id(),
974 }
975 }
976
977 fn manifest_value(&self) -> Value {
978 match self {
979 Self::Step(node) => node.manifest_value(),
980 Self::Decision(node) => node.manifest_value(),
981 Self::Split(node) => node.manifest_value(),
982 Self::Join(node) => node.manifest_value(),
983 Self::PartitionedStep(node) => node.manifest_value(),
984 }
985 }
986}
987
988#[derive(Clone, Debug, Eq, PartialEq)]
990pub struct FlowTransition {
991 source: NodeId,
992 pattern: ExitPattern,
993 target: FlowTarget,
994}
995
996impl FlowTransition {
997 #[must_use]
999 pub const fn new(source: NodeId, pattern: ExitPattern, target: FlowTarget) -> Self {
1000 Self {
1001 source,
1002 pattern,
1003 target,
1004 }
1005 }
1006
1007 #[must_use]
1009 pub const fn source(&self) -> &NodeId {
1010 &self.source
1011 }
1012
1013 #[must_use]
1015 pub const fn pattern(&self) -> &ExitPattern {
1016 &self.pattern
1017 }
1018
1019 #[must_use]
1021 pub const fn target(&self) -> &FlowTarget {
1022 &self.target
1023 }
1024
1025 fn manifest_value(&self) -> Value {
1026 json!({
1027 "pattern": self.pattern.as_str(),
1028 "source": self.source.as_str(),
1029 "target": flow_target_manifest(&self.target)
1030 })
1031 }
1032}
1033
1034#[derive(Clone, Debug, Default, Eq, PartialEq)]
1039pub struct FlowGraph {
1040 entry: Option<NodeId>,
1041 nodes: Vec<FlowNode>,
1042 transitions: Vec<FlowTransition>,
1043}
1044
1045impl FlowGraph {
1046 #[must_use]
1048 pub fn new(entry: NodeId) -> Self {
1049 Self {
1050 entry: Some(entry),
1051 nodes: Vec::new(),
1052 transitions: Vec::new(),
1053 }
1054 }
1055
1056 #[must_use]
1058 pub fn with_node(mut self, node: FlowNode) -> Self {
1059 self.nodes.push(node);
1060 self
1061 }
1062
1063 #[must_use]
1065 pub fn with_transition(mut self, transition: FlowTransition) -> Self {
1066 self.transitions.push(transition);
1067 self
1068 }
1069
1070 pub fn with_sequence(self, source: NodeId, next: FlowTarget) -> Result<Self, PlanError> {
1082 Ok(self
1083 .with_transition(FlowTransition::new(
1084 source.clone(),
1085 ExitPattern::new("FAILED")?,
1086 FlowTarget::Terminal(TerminalKind::Fail),
1087 ))
1088 .with_transition(FlowTransition::new(source, ExitPattern::new("*")?, next)))
1089 }
1090
1091 pub fn compile(
1101 self,
1102 job_name: &JobName,
1103 revision: DefinitionRevision,
1104 ) -> Result<CompiledExecutionPlan, PlanError> {
1105 let entry = self.entry.ok_or(PlanError::MissingEntryNode)?;
1106 if self.nodes.len() > MAX_NODES {
1107 return Err(PlanError::TooManyNodes { max: MAX_NODES });
1108 }
1109 if self.transitions.len() > MAX_TRANSITIONS {
1110 return Err(PlanError::TooManyTransitions {
1111 max: MAX_TRANSITIONS,
1112 });
1113 }
1114
1115 let mut nodes = BTreeMap::new();
1116 for node in self.nodes {
1117 if nodes.insert(node.id().clone(), node.clone()).is_some() {
1118 return Err(PlanError::DuplicateNodeId {
1119 node: node.id().clone(),
1120 });
1121 }
1122 }
1123 if !nodes.contains_key(&entry) {
1124 return Err(PlanError::UndefinedNode {
1125 node: entry.clone(),
1126 });
1127 }
1128 let local_scale = check_local_scale_subset(&entry, &nodes)?;
1129
1130 let mut outgoing: BTreeMap<NodeId, Vec<FlowTransition>> = BTreeMap::new();
1131 for transition in self.transitions {
1132 if !nodes.contains_key(transition.source()) {
1133 return Err(PlanError::UndefinedNode {
1134 node: transition.source().clone(),
1135 });
1136 }
1137 if let FlowTarget::Node(target) = transition.target()
1138 && !nodes.contains_key(target)
1139 {
1140 return Err(PlanError::UndefinedNode {
1141 node: target.clone(),
1142 });
1143 }
1144 if let FlowTarget::Node(target) = transition.target()
1145 && matches!(nodes.get(target), Some(FlowNode::Join(_)))
1146 {
1147 return Err(PlanError::JoinHasExternalEntry {
1148 join: target.clone(),
1149 });
1150 }
1151 if matches!(nodes.get(transition.source()), Some(FlowNode::Split(_))) {
1152 return Err(PlanError::SplitHasExplicitTransition {
1153 split: transition.source().clone(),
1154 });
1155 }
1156 let edges = outgoing.entry(transition.source().clone()).or_default();
1157 if edges.len() == MAX_OUTGOING_TRANSITIONS {
1158 return Err(PlanError::TooManyOutgoingTransitions {
1159 node: transition.source().clone(),
1160 max: MAX_OUTGOING_TRANSITIONS,
1161 });
1162 }
1163 edges.push(transition);
1164 }
1165
1166 for (id, node) in &nodes {
1167 if matches!(node, FlowNode::Split(_)) {
1168 continue;
1169 }
1170 let edges = outgoing
1171 .get(id)
1172 .filter(|edges| !edges.is_empty())
1173 .ok_or_else(|| PlanError::MissingTransition { node: id.clone() })?;
1174 check_unambiguous(id, edges)?;
1175 }
1176
1177 let mut compiled: BTreeMap<NodeId, Vec<FlowTransition>> = outgoing;
1178 for edges in compiled.values_mut() {
1179 edges.sort_by(|left, right| {
1180 right
1181 .pattern()
1182 .specificity()
1183 .cmp(&left.pattern().specificity())
1184 .then_with(|| left.pattern().cmp(right.pattern()))
1185 .then_with(|| left.target().sort_key().cmp(&right.target().sort_key()))
1186 });
1187 }
1188
1189 check_reachable_and_acyclic(&entry, &nodes, &compiled)?;
1190
1191 let manifest = flow_manifest(job_name, &entry, &nodes, &compiled, local_scale);
1192 let canonical = serde_json::to_vec(&manifest)
1193 .map_err(|_| PlanError::Manifest(DefinitionError::ManifestEncoding))?;
1194 let definition = DefinitionIdentity::from_flow_manifest(job_name, revision, &canonical)
1195 .map_err(PlanError::Manifest)?;
1196 Ok(CompiledExecutionPlan {
1197 definition,
1198 entry,
1199 nodes,
1200 transitions: compiled,
1201 })
1202 }
1203}
1204
1205fn check_local_scale_subset(
1206 entry: &NodeId,
1207 nodes: &BTreeMap<NodeId, FlowNode>,
1208) -> Result<bool, PlanError> {
1209 let mut embedded_ids = BTreeSet::new();
1210 let mut join_owners: BTreeMap<NodeId, NodeId> = BTreeMap::new();
1211 let mut local_scale = false;
1212 for (id, node) in nodes {
1213 match node {
1214 FlowNode::Split(split) => {
1215 local_scale = true;
1216 if id == entry {
1217 return Err(PlanError::SplitIsEntry { split: id.clone() });
1218 }
1219 if !(2..=MAX_SPLIT_BRANCHES).contains(&split.branches().len()) {
1220 return Err(PlanError::InvalidSplitBranchCount {
1221 split: id.clone(),
1222 min: 2,
1223 max: MAX_SPLIT_BRANCHES,
1224 });
1225 }
1226 if usize::from(split.budget().max_parallel_branches()) > split.branches().len() {
1227 return Err(PlanError::ParallelBudgetExceedsBranches {
1228 split: id.clone(),
1229 branches: split.branches().len(),
1230 });
1231 }
1232 if !matches!(nodes.get(split.join()), Some(FlowNode::Join(_))) {
1233 return Err(PlanError::InvalidSplitJoin {
1234 split: id.clone(),
1235 join: split.join().clone(),
1236 });
1237 }
1238 if let Some(first) = join_owners.insert(split.join().clone(), id.clone()) {
1239 return Err(PlanError::JoinHasMultipleOwners {
1240 join: split.join().clone(),
1241 first,
1242 second: id.clone(),
1243 });
1244 }
1245 for branch in split.branches() {
1246 if !(1..=MAX_BRANCH_STEPS).contains(&branch.steps().len()) {
1247 return Err(PlanError::InvalidBranchLength {
1248 split: id.clone(),
1249 max: MAX_BRANCH_STEPS,
1250 });
1251 }
1252 for step in branch.steps() {
1253 if nodes.contains_key(step.id()) || !embedded_ids.insert(step.id().clone())
1254 {
1255 return Err(PlanError::DuplicateNodeId {
1256 node: step.id().clone(),
1257 });
1258 }
1259 }
1260 }
1261 }
1262 FlowNode::Join(_) => {
1263 local_scale = true;
1264 }
1265 FlowNode::PartitionedStep(partitioned) => {
1266 local_scale = true;
1267 let worker = partitioned.worker().id();
1268 if nodes.contains_key(worker) || !embedded_ids.insert(worker.clone()) {
1269 return Err(PlanError::DuplicateNodeId {
1270 node: worker.clone(),
1271 });
1272 }
1273 }
1274 FlowNode::Step(_) | FlowNode::Decision(_) => {}
1275 }
1276 }
1277 if nodes.len().saturating_add(embedded_ids.len()) > MAX_NODES {
1278 return Err(PlanError::TooManyNodes { max: MAX_NODES });
1279 }
1280 for (id, node) in nodes {
1281 if matches!(node, FlowNode::Join(_)) && !join_owners.contains_key(id) {
1282 return Err(PlanError::OrphanJoin { join: id.clone() });
1283 }
1284 }
1285 Ok(local_scale)
1286}
1287
1288fn check_unambiguous(node: &NodeId, edges: &[FlowTransition]) -> Result<(), PlanError> {
1289 for (index, left) in edges.iter().enumerate() {
1290 for right in &edges[index + 1..] {
1291 if left.pattern().specificity() == right.pattern().specificity()
1292 && left.pattern().intersects(right.pattern())
1293 {
1294 return Err(PlanError::AmbiguousTransition {
1295 node: node.clone(),
1296 first: left.pattern().clone(),
1297 second: right.pattern().clone(),
1298 });
1299 }
1300 }
1301 }
1302 Ok(())
1303}
1304
1305fn check_reachable_and_acyclic(
1306 entry: &NodeId,
1307 nodes: &BTreeMap<NodeId, FlowNode>,
1308 transitions: &BTreeMap<NodeId, Vec<FlowTransition>>,
1309) -> Result<(), PlanError> {
1310 let mut visited = BTreeSet::new();
1311 let mut on_path = BTreeSet::new();
1312 visit(entry, nodes, transitions, &mut visited, &mut on_path)?;
1313 for id in nodes.keys() {
1314 if !visited.contains(id) {
1315 return Err(PlanError::UnreachableNode { node: id.clone() });
1316 }
1317 }
1318 Ok(())
1319}
1320
1321fn visit(
1322 node: &NodeId,
1323 nodes: &BTreeMap<NodeId, FlowNode>,
1324 transitions: &BTreeMap<NodeId, Vec<FlowTransition>>,
1325 visited: &mut BTreeSet<NodeId>,
1326 on_path: &mut BTreeSet<NodeId>,
1327) -> Result<(), PlanError> {
1328 if on_path.contains(node) {
1329 return Err(PlanError::CyclicGraph { node: node.clone() });
1330 }
1331 if !visited.insert(node.clone()) {
1332 return Ok(());
1333 }
1334 on_path.insert(node.clone());
1335 if let Some(FlowNode::Split(split)) = nodes.get(node) {
1336 visit(split.join(), nodes, transitions, visited, on_path)?;
1337 }
1338 if let Some(edges) = transitions.get(node) {
1339 for edge in edges {
1340 if let FlowTarget::Node(target) = edge.target() {
1341 visit(target, nodes, transitions, visited, on_path)?;
1342 }
1343 }
1344 }
1345 on_path.remove(node);
1346 Ok(())
1347}
1348
1349fn start_controls_manifest(controls: StartControls) -> Value {
1356 json!({
1357 "allow_start_if_complete": controls.allow_start_if_complete(),
1358 "start_limit": controls.start_limit().get()
1359 })
1360}
1361
1362fn flow_target_manifest(target: &FlowTarget) -> Value {
1364 match target {
1365 FlowTarget::Node(id) => json!({ "node": id.as_str() }),
1366 FlowTarget::Terminal(kind) => json!({ "terminal": kind.as_str() }),
1367 }
1368}
1369
1370fn chunk_declaration_manifest(revisions: &ChunkComponentRevisions) -> Value {
1376 let mut value = json!({
1377 "checkpoint": {
1378 "schema": revisions.checkpoint_schema().as_str(),
1379 "version": revisions.checkpoint_schema_version().get()
1380 },
1381 "components": {
1382 "checkpoint": revisions.checkpoint().as_str(),
1383 "processor": revisions.processor().as_str(),
1384 "reader": revisions.reader().as_str(),
1385 "writer": revisions.writer().as_str()
1386 },
1387 "context": {
1388 "schema": revisions.context_schema().as_str(),
1389 "version": revisions.context_schema_version().get()
1390 },
1391 "delivery_mode": revisions.delivery_mode().manifest_name()
1392 });
1393 if revisions.in_flight_policy() == InFlightPolicy::RollbackChunk
1394 && let Some(object) = value.as_object_mut()
1395 {
1396 object.insert(
1397 "in_flight_policy".to_owned(),
1398 Value::String("rollback_chunk".to_owned()),
1399 );
1400 }
1401 value
1402}
1403
1404fn fault_manifest_value(policy: &FaultPolicy) -> Value {
1405 let backoff = policy.backoff();
1406 let rules: Vec<Value> = policy
1407 .classifier()
1408 .rules()
1409 .iter()
1410 .map(|rule| {
1411 json!({
1412 "category": rule.category().as_str(),
1413 "phase": rule.phase().as_str(),
1414 "retryable": rule.action().is_retryable(),
1415 "skip": rule
1416 .action()
1417 .skip_disposition()
1418 .map_or(Value::Null, |skip| Value::String(skip.as_str().to_owned()))
1419 })
1420 })
1421 .collect();
1422 json!({
1423 "backoff": {
1424 "initial_ms": u64::try_from(backoff.initial().as_millis()).unwrap_or(u64::MAX),
1425 "kind": backoff.kind().as_str(),
1426 "maximum_ms": u64::try_from(backoff.maximum().as_millis()).unwrap_or(u64::MAX),
1427 "multiplier": backoff.multiplier()
1428 },
1429 "classifier": {
1430 "revision": policy.classifier().revision().as_str(),
1431 "rules": rules
1432 },
1433 "retry_limit": policy.retry_limit().get(),
1434 "retry_state_limit": policy.retry_state_limit().get(),
1435 "skip_limit": policy.skip_limit().get()
1436 })
1437}
1438
1439fn flow_manifest(
1448 job_name: &JobName,
1449 entry: &NodeId,
1450 nodes: &BTreeMap<NodeId, FlowNode>,
1451 transitions: &BTreeMap<NodeId, Vec<FlowTransition>>,
1452 local_scale: bool,
1453) -> Value {
1454 let node_values: Vec<Value> = nodes.values().map(FlowNode::manifest_value).collect();
1455 let transition_values: Vec<Value> = transitions
1456 .values()
1457 .flat_map(|edges| edges.iter().map(FlowTransition::manifest_value))
1458 .collect();
1459 json!({
1460 "entry": entry.as_str(),
1461 "format": if local_scale {
1462 oxide_batch_core::MANIFEST_FORMAT_LOCAL_SCALE
1463 } else {
1464 oxide_batch_core::MANIFEST_FORMAT_FLOW
1465 },
1466 "job": job_name.as_str(),
1467 "nodes": node_values,
1468 "transitions": transition_values
1469 })
1470}
1471
1472#[derive(Clone, Debug, Eq, PartialEq)]
1479pub struct CompiledExecutionPlan {
1480 definition: DefinitionIdentity,
1481 entry: NodeId,
1482 nodes: BTreeMap<NodeId, FlowNode>,
1483 transitions: BTreeMap<NodeId, Vec<FlowTransition>>,
1484}
1485
1486impl CompiledExecutionPlan {
1487 #[doc(hidden)]
1494 pub fn compatibility_one_step(
1495 definition: DefinitionIdentity,
1496 step: StepNode,
1497 ) -> Result<Self, PlanError> {
1498 let entry = step.id().clone();
1499 let mut nodes = BTreeMap::new();
1500 nodes.insert(entry.clone(), FlowNode::step(step));
1501 let mut edges = Vec::with_capacity(3);
1502 for (code, terminal) in [
1503 ("COMPLETED", TerminalKind::Complete),
1504 ("FAILED", TerminalKind::Fail),
1505 ("STOPPED", TerminalKind::Stop),
1506 ] {
1507 edges.push(FlowTransition::new(
1508 entry.clone(),
1509 ExitPattern::new(code)?,
1510 FlowTarget::Terminal(terminal),
1511 ));
1512 }
1513 check_unambiguous(&entry, &edges)?;
1514 let mut transitions = BTreeMap::new();
1515 transitions.insert(entry.clone(), edges);
1516 Ok(Self {
1517 definition,
1518 entry,
1519 nodes,
1520 transitions,
1521 })
1522 }
1523
1524 #[must_use]
1526 pub const fn definition_identity(&self) -> &DefinitionIdentity {
1527 &self.definition
1528 }
1529
1530 #[must_use]
1532 pub const fn manifest_format(&self) -> u16 {
1533 self.definition.manifest_format()
1534 }
1535
1536 #[must_use]
1538 pub const fn fingerprint(&self) -> &[u8; 32] {
1539 self.definition.manifest_digest()
1540 }
1541
1542 #[must_use]
1544 pub const fn entry(&self) -> &NodeId {
1545 &self.entry
1546 }
1547
1548 #[must_use]
1550 pub fn node_count(&self) -> usize {
1551 self.nodes.len()
1552 }
1553
1554 #[must_use]
1556 pub fn transition_count(&self) -> usize {
1557 self.transitions.values().map(Vec::len).sum()
1558 }
1559
1560 #[must_use]
1562 pub fn node(&self, id: &NodeId) -> Option<&FlowNode> {
1563 self.nodes.get(id)
1564 }
1565
1566 #[must_use]
1572 pub fn nodes(&self) -> impl ExactSizeIterator<Item = (&NodeId, &FlowNode)> {
1573 self.nodes.iter()
1574 }
1575
1576 #[must_use]
1581 pub fn transitions(&self, id: &NodeId) -> &[FlowTransition] {
1582 self.transitions.get(id).map_or(&[], Vec::as_slice)
1583 }
1584
1585 pub fn select_target(
1594 &self,
1595 id: &NodeId,
1596 code: &ExitCode,
1597 ) -> Result<&FlowTarget, FlowSelectionError> {
1598 let edges = self
1599 .transitions
1600 .get(id)
1601 .ok_or_else(|| FlowSelectionError::UnknownNode { node: id.clone() })?;
1602 edges
1603 .iter()
1604 .find(|edge| edge.pattern().matches(code))
1605 .map(FlowTransition::target)
1606 .ok_or_else(|| FlowSelectionError::UnmappedExitOutcome {
1607 node: id.clone(),
1608 code: code.clone(),
1609 })
1610 }
1611}
1612
1613#[derive(Clone, Debug, Eq, PartialEq)]
1615#[non_exhaustive]
1616pub enum PlanError {
1617 MissingEntryNode,
1619 DuplicateNodeId {
1621 node: NodeId,
1623 },
1624 UndefinedNode {
1626 node: NodeId,
1628 },
1629 MissingTransition {
1631 node: NodeId,
1633 },
1634 AmbiguousTransition {
1636 node: NodeId,
1638 first: ExitPattern,
1640 second: ExitPattern,
1642 },
1643 UnreachableNode {
1645 node: NodeId,
1647 },
1648 CyclicGraph {
1650 node: NodeId,
1652 },
1653 TooManyNodes {
1655 max: usize,
1657 },
1658 TooManyTransitions {
1660 max: usize,
1662 },
1663 TooManyOutgoingTransitions {
1665 node: NodeId,
1667 max: usize,
1669 },
1670 InvalidPattern {
1672 max_bytes: usize,
1674 },
1675 ZeroDecisionInputVersion,
1677 InvalidSplitBranchCount {
1679 split: NodeId,
1681 min: usize,
1683 max: usize,
1685 },
1686 InvalidBranchLength {
1688 split: NodeId,
1690 max: usize,
1692 },
1693 SplitIsEntry {
1695 split: NodeId,
1697 },
1698 InvalidSplitJoin {
1700 split: NodeId,
1702 join: NodeId,
1704 },
1705 JoinHasMultipleOwners {
1707 join: NodeId,
1709 first: NodeId,
1711 second: NodeId,
1713 },
1714 OrphanJoin {
1716 join: NodeId,
1718 },
1719 JoinHasExternalEntry {
1721 join: NodeId,
1723 },
1724 SplitHasExplicitTransition {
1726 split: NodeId,
1728 },
1729 ParallelBudgetExceedsBranches {
1731 split: NodeId,
1733 branches: usize,
1735 },
1736 InvalidParallelBranchBudget {
1738 max: usize,
1740 },
1741 InvalidPartitionWorkerBudget {
1743 max: u8,
1745 },
1746 InsufficientPoolCapacity {
1748 required: u32,
1750 configured: u32,
1752 },
1753 InvalidPartitionCount {
1755 max: u16,
1757 },
1758 Token(DefinitionError),
1760 Manifest(DefinitionError),
1762}
1763
1764impl fmt::Display for PlanError {
1765 #[allow(
1766 clippy::too_many_lines,
1767 reason = "each typed plan rejection retains one stable redacted diagnostic"
1768 )]
1769 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1770 match self {
1771 Self::MissingEntryNode => formatter.write_str("flow graph has no entry node"),
1772 Self::DuplicateNodeId { node } => {
1773 write!(
1774 formatter,
1775 "node {} is declared more than once",
1776 node.as_str()
1777 )
1778 }
1779 Self::UndefinedNode { node } => {
1780 write!(formatter, "node {} is not declared", node.as_str())
1781 }
1782 Self::MissingTransition { node } => {
1783 write!(
1784 formatter,
1785 "node {} has no outgoing transition",
1786 node.as_str()
1787 )
1788 }
1789 Self::AmbiguousTransition {
1790 node,
1791 first,
1792 second,
1793 } => write!(
1794 formatter,
1795 "node {} patterns {first} and {second} are equally specific and overlap",
1796 node.as_str()
1797 ),
1798 Self::UnreachableNode { node } => {
1799 write!(
1800 formatter,
1801 "node {} is unreachable from the entry node",
1802 node.as_str()
1803 )
1804 }
1805 Self::CyclicGraph { node } => {
1806 write!(formatter, "node {} closes a cycle", node.as_str())
1807 }
1808 Self::TooManyNodes { max } => write!(formatter, "flow graph exceeds {max} nodes"),
1809 Self::TooManyTransitions { max } => {
1810 write!(formatter, "flow graph exceeds {max} transitions")
1811 }
1812 Self::TooManyOutgoingTransitions { node, max } => write!(
1813 formatter,
1814 "node {} exceeds {max} outgoing transitions",
1815 node.as_str()
1816 ),
1817 Self::InvalidPattern { max_bytes } => write!(
1818 formatter,
1819 "exit pattern must be 1 to {max_bytes} bytes without control characters"
1820 ),
1821 Self::ZeroDecisionInputVersion => {
1822 formatter.write_str("decision input version must be nonzero")
1823 }
1824 Self::InvalidSplitBranchCount { split, min, max } => write!(
1825 formatter,
1826 "split {} must declare {min} to {max} branches",
1827 split.as_str()
1828 ),
1829 Self::InvalidBranchLength { split, max } => write!(
1830 formatter,
1831 "split {} branches must declare 1 to {max} steps",
1832 split.as_str()
1833 ),
1834 Self::SplitIsEntry { split } => {
1835 write!(
1836 formatter,
1837 "split {} cannot be the entry node",
1838 split.as_str()
1839 )
1840 }
1841 Self::InvalidSplitJoin { split, join } => write!(
1842 formatter,
1843 "split {} does not own declared join {}",
1844 split.as_str(),
1845 join.as_str()
1846 ),
1847 Self::JoinHasMultipleOwners {
1848 join,
1849 first,
1850 second,
1851 } => write!(
1852 formatter,
1853 "join {} is owned by both splits {} and {}",
1854 join.as_str(),
1855 first.as_str(),
1856 second.as_str()
1857 ),
1858 Self::OrphanJoin { join } => {
1859 write!(formatter, "join {} has no owning split", join.as_str())
1860 }
1861 Self::JoinHasExternalEntry { join } => write!(
1862 formatter,
1863 "join {} can be entered only by its owning split",
1864 join.as_str()
1865 ),
1866 Self::SplitHasExplicitTransition { split } => write!(
1867 formatter,
1868 "split {} reaches only its declared join",
1869 split.as_str()
1870 ),
1871 Self::ParallelBudgetExceedsBranches { split, branches } => write!(
1872 formatter,
1873 "split {} parallel budget exceeds its {branches} branches",
1874 split.as_str()
1875 ),
1876 Self::InvalidParallelBranchBudget { max } => {
1877 write!(formatter, "parallel branch budget must be 1 to {max}")
1878 }
1879 Self::InvalidPartitionWorkerBudget { max } => {
1880 write!(formatter, "partition worker budget must be 1 to {max}")
1881 }
1882 Self::InsufficientPoolCapacity {
1883 required,
1884 configured,
1885 } => write!(
1886 formatter,
1887 "repository pool size {configured} cannot supply required capacity {required}"
1888 ),
1889 Self::InvalidPartitionCount { max } => {
1890 write!(formatter, "partition count must be 1 to {max}")
1891 }
1892 Self::Token(error) => write!(formatter, "flow graph token is invalid: {error}"),
1893 Self::Manifest(error) => {
1894 write!(formatter, "flow manifest could not be encoded: {error}")
1895 }
1896 }
1897 }
1898}
1899
1900impl Error for PlanError {
1901 fn source(&self) -> Option<&(dyn Error + 'static)> {
1902 match self {
1903 Self::Token(error) | Self::Manifest(error) => Some(error),
1904 _ => None,
1905 }
1906 }
1907}
1908
1909impl From<DefinitionError> for PlanError {
1910 fn from(error: DefinitionError) -> Self {
1911 Self::Token(error)
1912 }
1913}
1914
1915#[derive(Clone, Debug, Eq, PartialEq)]
1917#[non_exhaustive]
1918pub enum FlowSelectionError {
1919 UnknownNode {
1921 node: NodeId,
1923 },
1924 UnmappedExitOutcome {
1926 node: NodeId,
1928 code: ExitCode,
1930 },
1931}
1932
1933impl fmt::Display for FlowSelectionError {
1934 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1935 match self {
1936 Self::UnknownNode { node } => {
1937 write!(
1938 formatter,
1939 "node {} is not part of the compiled plan",
1940 node.as_str()
1941 )
1942 }
1943 Self::UnmappedExitOutcome { node, code } => write!(
1944 formatter,
1945 "node {} declares no transition for exit outcome {code}",
1946 node.as_str()
1947 ),
1948 }
1949 }
1950}
1951
1952impl Error for FlowSelectionError {}