1use std::collections::{BTreeMap, BTreeSet};
5use std::error::Error;
6use std::fmt;
7use std::num::NonZeroU64;
8use std::panic::{AssertUnwindSafe, catch_unwind};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::SystemTime;
12
13use futures_util::{FutureExt, StreamExt};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::runtime::{invoke_after_step, invoke_before_step, invoke_tasklet};
18use crate::{
19 BatchStatus, BoxFuture, Clock, CompiledExecutionPlan, ExecutionAttempt, ExecutionCorrelation,
20 ExecutionCounts, ExitCode, ExitStatus, FailureCategory, FailureSummary, FlowDecision,
21 FlowDecisionRequest, FlowDecisionSequence, FlowNode, FlowSelectionError, FlowStepState,
22 FlowTarget, FlowTransitionKind, IdGenerator, JobExecution, JobExecutionId, JobInstance,
23 JobInstanceId, JobInstanceKey, JobName, JobParameters, JobRepository, LifecycleTransition,
24 ListenerContext, ListenerFailure, ListenerFailureKind, ListenerPhase, NodeId, PartitionKey,
25 PartitionPlanEntry, RepositoryCapability, RepositoryError, StartLimit, StepExecution,
26 StepExecutionId, StepName, StepPartition, StopPollInterval, StopTiming, StopToken,
27 TaskletContext, TaskletExecutionOutcome, TaskletFailure, TaskletOutcome, TaskletStep,
28 TerminalKind,
29};
30
31pub(crate) fn decision_matches_manifest(manifest: &Value, request: &FlowDecisionRequest) -> bool {
32 let Some(document) = manifest.as_object() else {
33 return false;
34 };
35 let format = document.get("format").and_then(Value::as_u64);
36 if !matches!(
37 format,
38 Some(value)
39 if value == u64::from(oxide_batch_core::MANIFEST_FORMAT_FLOW)
40 || value == u64::from(oxide_batch_core::MANIFEST_FORMAT_LOCAL_SCALE)
41 ) {
42 return false;
43 }
44 let source_is_declared = document
45 .get("nodes")
46 .and_then(Value::as_array)
47 .is_some_and(|nodes| {
48 nodes.iter().any(|node| {
49 let kind = node.get("kind").and_then(Value::as_str);
50 let kind_matches = match request.kind() {
51 FlowTransitionKind::Decider => kind == Some("decision"),
52 FlowTransitionKind::SplitAggregate => kind == Some("join"),
53 FlowTransitionKind::StepExit | FlowTransitionKind::CompletedStepReuse => {
54 matches!(kind, Some("step" | "partitioned_step"))
55 }
56 _ => false,
60 };
61 node.get("id").and_then(Value::as_str) == Some(request.source_node_id().as_str())
62 && kind_matches
63 })
64 });
65 if !source_is_declared {
66 return false;
67 }
68 document
69 .get("transitions")
70 .and_then(Value::as_array)
71 .and_then(|transitions| {
72 transitions.iter().find(|transition| {
73 transition.get("source").and_then(Value::as_str)
74 == Some(request.source_node_id().as_str())
75 && transition
76 .get("pattern")
77 .and_then(Value::as_str)
78 .and_then(|pattern| crate::ExitPattern::new(pattern).ok())
79 .is_some_and(|pattern| pattern.matches(request.observed_outcome()))
80 })
81 })
82 .and_then(|transition| transition.get("target"))
83 .is_some_and(|target| manifest_target_matches(target, request.target()))
84}
85
86fn manifest_target_matches(value: &Value, target: &FlowTarget) -> bool {
87 match target {
88 FlowTarget::Node(node) => value.get("node").and_then(Value::as_str) == Some(node.as_str()),
89 FlowTarget::Terminal(terminal) => {
90 value.get("terminal").and_then(Value::as_str) == Some(terminal.as_str())
91 }
92 }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct DecisionStepInput {
98 node_id: NodeId,
99 execution_id: StepExecutionId,
100 status: BatchStatus,
101 exit_status: ExitStatus,
102 counts: ExecutionCounts,
103 context: Option<crate::ExecutionContext>,
104}
105
106impl DecisionStepInput {
107 fn from_state(state: &FlowStepState) -> Self {
108 Self {
109 node_id: state.node_id().clone(),
110 execution_id: state.execution().id(),
111 status: state.execution().metadata().status(),
112 exit_status: state.execution().metadata().exit_status().clone(),
113 counts: state.execution().metadata().counts(),
114 context: state.context().cloned(),
115 }
116 }
117
118 #[must_use]
120 pub const fn node_id(&self) -> &NodeId {
121 &self.node_id
122 }
123
124 #[must_use]
126 pub const fn execution_id(&self) -> StepExecutionId {
127 self.execution_id
128 }
129
130 #[must_use]
132 pub const fn status(&self) -> BatchStatus {
133 self.status
134 }
135
136 #[must_use]
138 pub const fn exit_status(&self) -> &ExitStatus {
139 &self.exit_status
140 }
141
142 #[must_use]
144 pub const fn counts(&self) -> ExecutionCounts {
145 self.counts
146 }
147
148 #[must_use]
150 pub const fn context(&self) -> Option<&crate::ExecutionContext> {
151 self.context.as_ref()
152 }
153}
154
155pub struct DecisionInput<'a> {
157 job_instance_id: JobInstanceId,
158 job_execution_id: JobExecutionId,
159 attempt: ExecutionAttempt,
160 plan_fingerprint: [u8; 32],
161 node_id: &'a NodeId,
162 parameters: &'a JobParameters,
163 preceding_step: Option<DecisionStepInput>,
164}
165
166impl fmt::Debug for DecisionInput<'_> {
167 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168 formatter
169 .debug_struct("DecisionInput")
170 .field("job_instance_id", &self.job_instance_id)
171 .field("job_execution_id", &self.job_execution_id)
172 .field("attempt", &self.attempt)
173 .field("node_id", &self.node_id)
174 .field("parameters", &"<redacted>")
175 .field("preceding_step", &self.preceding_step)
176 .finish_non_exhaustive()
177 }
178}
179
180impl DecisionInput<'_> {
181 #[must_use]
183 pub const fn job_instance_id(&self) -> JobInstanceId {
184 self.job_instance_id
185 }
186
187 #[must_use]
189 pub const fn job_execution_id(&self) -> JobExecutionId {
190 self.job_execution_id
191 }
192
193 #[must_use]
195 pub const fn attempt(&self) -> ExecutionAttempt {
196 self.attempt
197 }
198
199 #[must_use]
201 pub const fn plan_fingerprint(&self) -> &[u8; 32] {
202 &self.plan_fingerprint
203 }
204
205 #[must_use]
207 pub const fn node_id(&self) -> &NodeId {
208 self.node_id
209 }
210
211 #[must_use]
213 pub const fn parameters(&self) -> &JobParameters {
214 self.parameters
215 }
216
217 #[must_use]
219 pub const fn preceding_step(&self) -> Option<&DecisionStepInput> {
220 self.preceding_step.as_ref()
221 }
222}
223
224#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
226pub struct DeciderError;
227
228impl DeciderError {
229 #[must_use]
231 pub const fn new() -> Self {
232 Self
233 }
234}
235
236impl fmt::Display for DeciderError {
237 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238 formatter.write_str("flow decider failed")
239 }
240}
241
242impl Error for DeciderError {}
243
244pub trait JobExecutionDecider: Send + Sync {
246 fn decide<'a>(
248 &'a self,
249 input: DecisionInput<'a>,
250 ) -> BoxFuture<'a, Result<ExitStatus, DeciderError>>;
251}
252
253async fn invoke_decider(
254 decider: &dyn JobExecutionDecider,
255 input: DecisionInput<'_>,
256) -> Result<ExitStatus, FlowFailure> {
257 let future = catch_unwind(AssertUnwindSafe(|| decider.decide(input)))
258 .map_err(|_| FlowFailure::DeciderPanic)?;
259 match AssertUnwindSafe(future).catch_unwind().await {
260 Ok(Ok(outcome)) => Ok(outcome),
261 Ok(Err(_)) => Err(FlowFailure::DeciderError),
262 Err(_) => Err(FlowFailure::DeciderPanic),
263 }
264}
265
266#[derive(Clone)]
272pub struct TaskletStepFactory {
273 step_name: StepName,
274 create: Arc<dyn Fn() -> TaskletStep + Send + Sync>,
275}
276
277#[derive(Clone, Copy, Debug)]
279pub struct PartitionPlanRequest<'a> {
280 plan_fingerprint: &'a [u8; 32],
281 job_instance_id: JobInstanceId,
282 node_id: &'a NodeId,
283 partition_count: crate::PartitionCount,
284}
285
286impl<'a> PartitionPlanRequest<'a> {
287 #[must_use]
289 pub const fn plan_fingerprint(self) -> &'a [u8; 32] {
290 self.plan_fingerprint
291 }
292
293 #[must_use]
295 pub const fn job_instance_id(self) -> JobInstanceId {
296 self.job_instance_id
297 }
298
299 #[must_use]
301 pub const fn node_id(self) -> &'a NodeId {
302 self.node_id
303 }
304
305 #[must_use]
307 pub const fn partition_count(self) -> crate::PartitionCount {
308 self.partition_count
309 }
310}
311
312#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314#[non_exhaustive]
315pub enum PartitionFactoryError {
316 Rejected,
318}
319
320impl fmt::Display for PartitionFactoryError {
321 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322 formatter.write_str("partition factory rejected the requested plan")
323 }
324}
325
326impl Error for PartitionFactoryError {}
327
328type PartitionPlanConstructor = dyn for<'a> Fn(PartitionPlanRequest<'a>) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError>
330 + Send
331 + Sync;
332
333#[derive(Clone)]
335pub struct PartitionPlanFactory {
336 create: Arc<PartitionPlanConstructor>,
337}
338
339impl PartitionPlanFactory {
340 #[must_use]
342 pub fn new(
343 create: impl for<'a> Fn(
344 PartitionPlanRequest<'a>,
345 ) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError>
346 + Send
347 + Sync
348 + 'static,
349 ) -> Self {
350 Self {
351 create: Arc::new(create),
352 }
353 }
354
355 fn create(
356 &self,
357 request: PartitionPlanRequest<'_>,
358 ) -> Result<Vec<PartitionPlanEntry>, PartitionFactoryError> {
359 (self.create)(request)
360 }
361}
362
363impl fmt::Debug for PartitionPlanFactory {
364 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365 formatter.write_str("PartitionPlanFactory(<redacted>)")
366 }
367}
368
369#[derive(Clone, Eq, PartialEq)]
371pub struct PartitionWorkerInput {
372 key: PartitionKey,
373 ordinal: u32,
374 context: crate::ExecutionContext,
375}
376
377impl PartitionWorkerInput {
378 fn from_partition(partition: &StepPartition) -> Self {
379 Self {
380 key: partition.key().clone(),
381 ordinal: partition.ordinal(),
382 context: partition.context().clone(),
383 }
384 }
385
386 #[must_use]
388 pub const fn key(&self) -> &PartitionKey {
389 &self.key
390 }
391
392 #[must_use]
394 pub const fn ordinal(&self) -> u32 {
395 self.ordinal
396 }
397
398 #[must_use]
400 pub const fn context(&self) -> &crate::ExecutionContext {
401 &self.context
402 }
403}
404
405impl fmt::Debug for PartitionWorkerInput {
406 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
407 formatter
408 .debug_struct("PartitionWorkerInput")
409 .field("key", &self.key)
410 .field("ordinal", &self.ordinal)
411 .field("context", &"<redacted>")
412 .finish()
413 }
414}
415
416#[derive(Clone)]
418pub struct PartitionTaskletFactory {
419 step_name: StepName,
420 create: Arc<dyn Fn(PartitionWorkerInput) -> TaskletStep + Send + Sync>,
421}
422
423impl PartitionTaskletFactory {
424 #[must_use]
426 pub fn new(
427 step_name: StepName,
428 create: impl Fn(PartitionWorkerInput) -> TaskletStep + Send + Sync + 'static,
429 ) -> Self {
430 Self {
431 step_name,
432 create: Arc::new(create),
433 }
434 }
435
436 #[must_use]
438 pub const fn step_name(&self) -> &StepName {
439 &self.step_name
440 }
441
442 fn create(&self, input: PartitionWorkerInput) -> TaskletStep {
443 (self.create)(input)
444 }
445}
446
447impl fmt::Debug for PartitionTaskletFactory {
448 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449 formatter
450 .debug_struct("PartitionTaskletFactory")
451 .field("step_name", &self.step_name)
452 .finish_non_exhaustive()
453 }
454}
455
456#[derive(Clone, Debug)]
457struct PartitionedTaskletBinding {
458 partitioner: PartitionPlanFactory,
459 worker: PartitionTaskletFactory,
460}
461
462impl TaskletStepFactory {
463 #[must_use]
465 pub fn new(
466 step_name: StepName,
467 create: impl Fn() -> TaskletStep + Send + Sync + 'static,
468 ) -> Self {
469 Self {
470 step_name,
471 create: Arc::new(create),
472 }
473 }
474
475 #[must_use]
477 pub const fn step_name(&self) -> &StepName {
478 &self.step_name
479 }
480
481 fn create(&self) -> TaskletStep {
482 (self.create)()
483 }
484}
485
486impl fmt::Debug for TaskletStepFactory {
487 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
488 formatter
489 .debug_struct("TaskletStepFactory")
490 .field("step_name", &self.step_name)
491 .finish_non_exhaustive()
492 }
493}
494
495pub struct FlowJob {
497 name: JobName,
498 plan: CompiledExecutionPlan,
499 steps: BTreeMap<NodeId, TaskletStep>,
500 deciders: BTreeMap<NodeId, Arc<dyn JobExecutionDecider>>,
501 split_tasklets: BTreeMap<NodeId, TaskletStepFactory>,
502 partitioned_tasklets: BTreeMap<NodeId, PartitionedTaskletBinding>,
503}
504
505impl fmt::Debug for FlowJob {
506 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
507 formatter
508 .debug_struct("FlowJob")
509 .field("name", &self.name)
510 .field("definition", self.plan.definition_identity())
511 .field("step_count", &self.steps.len())
512 .field("decider_count", &self.deciders.len())
513 .field("split_tasklet_count", &self.split_tasklets.len())
514 .field(
515 "partitioned_tasklet_count",
516 &self.partitioned_tasklets.len(),
517 )
518 .finish()
519 }
520}
521
522impl FlowJob {
523 pub fn new(name: JobName, plan: CompiledExecutionPlan) -> Result<Self, FlowJobError> {
529 if !matches!(
530 plan.manifest_format(),
531 oxide_batch_core::MANIFEST_FORMAT_FLOW | oxide_batch_core::MANIFEST_FORMAT_LOCAL_SCALE
532 ) {
533 return Err(FlowJobError::UnsupportedManifest {
534 format: plan.manifest_format(),
535 });
536 }
537 if plan.definition_identity().job_name() != Some(&name) {
538 return Err(FlowJobError::JobNameMismatch);
539 }
540 Ok(Self {
541 name,
542 plan,
543 steps: BTreeMap::new(),
544 deciders: BTreeMap::new(),
545 split_tasklets: BTreeMap::new(),
546 partitioned_tasklets: BTreeMap::new(),
547 })
548 }
549
550 pub fn with_tasklet_step(
557 mut self,
558 node_id: NodeId,
559 step: TaskletStep,
560 ) -> Result<Self, FlowJobError> {
561 self.bind_tasklet_step(node_id, step)?;
562 Ok(self)
563 }
564
565 pub(crate) fn bind_tasklet_step(
566 &mut self,
567 node_id: NodeId,
568 step: TaskletStep,
569 ) -> Result<(), FlowJobError> {
570 let Some(FlowNode::Step(compiled)) = self.plan.node(&node_id) else {
571 return Err(FlowJobError::WrongNodeKind { node: node_id });
572 };
573 if !matches!(compiled.components(), crate::StepComponents::Tasklet(_)) {
574 return Err(FlowJobError::ComponentMismatch { node: node_id });
575 }
576 if compiled.step_name() != step.name() {
577 return Err(FlowJobError::StepNameMismatch { node: node_id });
578 }
579 if self.steps.insert(node_id.clone(), step).is_some() {
580 return Err(FlowJobError::DuplicateBinding { node: node_id });
581 }
582 Ok(())
583 }
584
585 pub(crate) fn bind_chunk_tasklet(
586 &mut self,
587 node_id: NodeId,
588 step: TaskletStep,
589 ) -> Result<(), FlowJobError> {
590 let Some(FlowNode::Step(compiled)) = self.plan.node(&node_id) else {
591 return Err(FlowJobError::WrongNodeKind { node: node_id });
592 };
593 if !matches!(compiled.components(), crate::StepComponents::Chunk { .. }) {
594 return Err(FlowJobError::ComponentMismatch { node: node_id });
595 }
596 if compiled.step_name() != step.name() {
597 return Err(FlowJobError::StepNameMismatch { node: node_id });
598 }
599 if self.steps.insert(node_id.clone(), step).is_some() {
600 return Err(FlowJobError::DuplicateBinding { node: node_id });
601 }
602 Ok(())
603 }
604
605 pub fn with_decider(
611 mut self,
612 node_id: NodeId,
613 decider: Arc<dyn JobExecutionDecider>,
614 ) -> Result<Self, FlowJobError> {
615 if !matches!(self.plan.node(&node_id), Some(FlowNode::Decision(_))) {
616 return Err(FlowJobError::WrongNodeKind { node: node_id });
617 }
618 if self.deciders.insert(node_id.clone(), decider).is_some() {
619 return Err(FlowJobError::DuplicateBinding { node: node_id });
620 }
621 Ok(self)
622 }
623
624 pub fn with_split_tasklet_factory(
631 mut self,
632 node_id: NodeId,
633 factory: TaskletStepFactory,
634 ) -> Result<Self, FlowJobError> {
635 let Some(compiled) = split_step(&self.plan, &node_id) else {
636 return Err(FlowJobError::WrongNodeKind { node: node_id });
637 };
638 if !matches!(compiled.components(), crate::StepComponents::Tasklet(_)) {
639 return Err(FlowJobError::ComponentMismatch { node: node_id });
640 }
641 if compiled.step_name() != factory.step_name() {
642 return Err(FlowJobError::StepNameMismatch { node: node_id });
643 }
644 if self
645 .split_tasklets
646 .insert(node_id.clone(), factory)
647 .is_some()
648 {
649 return Err(FlowJobError::DuplicateBinding { node: node_id });
650 }
651 Ok(self)
652 }
653
654 pub fn with_partitioned_tasklet(
662 mut self,
663 node_id: NodeId,
664 partitioner: PartitionPlanFactory,
665 worker: PartitionTaskletFactory,
666 ) -> Result<Self, FlowJobError> {
667 let Some(FlowNode::PartitionedStep(compiled)) = self.plan.node(&node_id) else {
668 return Err(FlowJobError::WrongNodeKind { node: node_id });
669 };
670 if !matches!(
671 compiled.worker().components(),
672 crate::StepComponents::Tasklet(_)
673 ) {
674 return Err(FlowJobError::ComponentMismatch { node: node_id });
675 }
676 if compiled.worker().step_name() != worker.step_name() {
677 return Err(FlowJobError::StepNameMismatch { node: node_id });
678 }
679 if self
680 .partitioned_tasklets
681 .insert(
682 node_id.clone(),
683 PartitionedTaskletBinding {
684 partitioner,
685 worker,
686 },
687 )
688 .is_some()
689 {
690 return Err(FlowJobError::DuplicateBinding { node: node_id });
691 }
692 Ok(self)
693 }
694
695 pub fn validate(&self) -> Result<(), FlowJobError> {
701 for (id, node) in self.plan.nodes() {
702 if let FlowNode::Split(split) = node {
703 for step in split.branches().iter().flat_map(crate::SplitBranch::steps) {
704 if !self.split_tasklets.contains_key(step.id()) {
705 return Err(FlowJobError::MissingBinding {
706 node: step.id().clone(),
707 });
708 }
709 }
710 continue;
711 }
712 let present = match node {
713 FlowNode::Step(_) => self.steps.contains_key(id),
714 FlowNode::Decision(_) => self.deciders.contains_key(id),
715 FlowNode::Split(_) | FlowNode::Join(_) => true,
716 FlowNode::PartitionedStep(_) => self.partitioned_tasklets.contains_key(id),
717 _ => false,
721 };
722 if !present {
723 return Err(FlowJobError::MissingBinding { node: id.clone() });
724 }
725 }
726 Ok(())
727 }
728
729 fn materialize_split_tasklets(&self) -> Result<BTreeMap<NodeId, TaskletStep>, FlowJobError> {
730 let mut tasklets = BTreeMap::new();
731 for (node, factory) in &self.split_tasklets {
732 let step = catch_unwind(AssertUnwindSafe(|| factory.create()))
733 .map_err(|_| FlowJobError::FactoryPanic { node: node.clone() })?;
734 if step.name() != factory.step_name() {
735 return Err(FlowJobError::StepNameMismatch { node: node.clone() });
736 }
737 tasklets.insert(node.clone(), step);
738 }
739 Ok(tasklets)
740 }
741
742 #[must_use]
744 pub const fn compiled_plan(&self) -> &CompiledExecutionPlan {
745 &self.plan
746 }
747}
748
749#[derive(Clone, Debug, Eq, PartialEq)]
751#[non_exhaustive]
752pub enum FlowJobError {
753 UnsupportedManifest {
755 format: u16,
757 },
758 JobNameMismatch,
760 WrongNodeKind {
762 node: NodeId,
764 },
765 StepNameMismatch {
767 node: NodeId,
769 },
770 DuplicateBinding {
772 node: NodeId,
774 },
775 MissingBinding {
777 node: NodeId,
779 },
780 ComponentMismatch {
782 node: NodeId,
784 },
785 FactoryPanic {
787 node: NodeId,
789 },
790}
791
792impl fmt::Display for FlowJobError {
793 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
794 match self {
795 Self::UnsupportedManifest { format } => {
796 write!(
797 formatter,
798 "flow execution requires manifest format 2 or bounded format 3, found {format}"
799 )
800 }
801 Self::JobNameMismatch => formatter.write_str("flow job name does not match its plan"),
802 Self::WrongNodeKind { node } => {
803 write!(
804 formatter,
805 "node {} has no matching executable kind",
806 node.as_str()
807 )
808 }
809 Self::StepNameMismatch { node } => {
810 write!(
811 formatter,
812 "node {} was bound to a different step name",
813 node.as_str()
814 )
815 }
816 Self::DuplicateBinding { node } => {
817 write!(
818 formatter,
819 "node {} has more than one executable binding",
820 node.as_str()
821 )
822 }
823 Self::MissingBinding { node } => {
824 write!(
825 formatter,
826 "node {} has no executable binding",
827 node.as_str()
828 )
829 }
830 Self::ComponentMismatch { node } => write!(
831 formatter,
832 "node {} executable components do not match the compiled declaration",
833 node.as_str()
834 ),
835 Self::FactoryPanic { node } => write!(
836 formatter,
837 "node {} component factory panicked",
838 node.as_str()
839 ),
840 }
841 }
842}
843
844impl Error for FlowJobError {}
845
846fn split_step<'a>(
847 plan: &'a CompiledExecutionPlan,
848 node_id: &NodeId,
849) -> Option<&'a crate::StepNode> {
850 plan.nodes().find_map(|(_, node)| match node {
851 FlowNode::Split(split) => split
852 .branches()
853 .iter()
854 .flat_map(crate::SplitBranch::steps)
855 .find(|step| step.id() == node_id),
856 _ => None,
857 })
858}
859
860#[derive(Clone, Debug, Eq, PartialEq)]
862#[non_exhaustive]
863pub enum FlowExecutionOutcome {
864 Completed,
866 Stopped,
868 Unknown,
870 Failed(FlowFailure),
872}
873
874#[derive(Clone, Debug, Eq, PartialEq)]
876#[non_exhaustive]
877pub enum FlowFailure {
878 Tasklet(TaskletFailure),
880 Listener(TaskletFailure),
882 DeciderError,
884 DeciderPanic,
886 PartitionerError,
888 PartitionerPanic,
890 PartitionFactoryPanic,
892 UnmappedExitOutcome {
894 node: NodeId,
896 code: ExitCode,
898 },
899 StartLimitExceeded {
901 node: NodeId,
903 limit: StartLimit,
905 },
906 FailTerminal,
908}
909
910#[derive(Clone, Debug, Eq, PartialEq)]
912pub struct FlowLaunchReport {
913 instance: JobInstance,
914 job_execution: JobExecution,
915 step_executions: Vec<StepExecution>,
916 decisions: Vec<FlowDecision>,
917 outcome: FlowExecutionOutcome,
918 listener_failures: Vec<ListenerFailure>,
919}
920
921#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
923#[non_exhaustive]
924pub enum FlowEventKind {
925 StepResultCommitted,
927 DecisionCommitted,
929 CompletedStepReused,
931 StartLimitExceeded,
933}
934
935impl FlowEventKind {
936 #[must_use]
938 pub const fn telemetry_kind(self) -> crate::TelemetryEventKind {
939 match self {
940 Self::StepResultCommitted => crate::TelemetryEventKind::FlowStepResultCommitted,
941 Self::DecisionCommitted => crate::TelemetryEventKind::FlowDecisionCommitted,
942 Self::CompletedStepReused => crate::TelemetryEventKind::FlowCompletedStepReused,
943 Self::StartLimitExceeded => crate::TelemetryEventKind::StepStartLimitExceeded,
944 }
945 }
946
947 #[must_use]
949 pub const fn as_str(self) -> &'static str {
950 match self {
951 Self::StepResultCommitted => "flow.step_result_committed",
952 Self::DecisionCommitted => "flow.decision_committed",
953 Self::CompletedStepReused => "flow.completed_step_reused",
954 Self::StartLimitExceeded => "step.start_limit_exceeded",
955 }
956 }
957}
958
959impl fmt::Display for FlowEventKind {
960 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
961 formatter.write_str(self.as_str())
962 }
963}
964
965#[derive(Clone, Debug, Eq, PartialEq)]
967pub struct FlowEvent {
968 kind: FlowEventKind,
969 job_name: JobName,
970 job_instance_id: JobInstanceId,
971 job_execution_id: JobExecutionId,
972 job_attempt: ExecutionAttempt,
973 source_node_id: NodeId,
974 source_step_execution_id: Option<StepExecutionId>,
975 target: Option<FlowTarget>,
976 occurred_at: SystemTime,
977}
978
979impl FlowEvent {
980 #[must_use]
982 pub const fn schema_version(&self) -> u16 {
983 crate::TELEMETRY_SCHEMA_VERSION
984 }
985 #[allow(clippy::too_many_arguments)]
986 const fn new(
987 kind: FlowEventKind,
988 job_name: JobName,
989 job_instance_id: JobInstanceId,
990 job_execution_id: JobExecutionId,
991 job_attempt: ExecutionAttempt,
992 source_node_id: NodeId,
993 source_step_execution_id: Option<StepExecutionId>,
994 target: Option<FlowTarget>,
995 occurred_at: SystemTime,
996 ) -> Self {
997 Self {
998 kind,
999 job_name,
1000 job_instance_id,
1001 job_execution_id,
1002 job_attempt,
1003 source_node_id,
1004 source_step_execution_id,
1005 target,
1006 occurred_at,
1007 }
1008 }
1009
1010 #[must_use]
1012 pub const fn kind(&self) -> FlowEventKind {
1013 self.kind
1014 }
1015
1016 #[must_use]
1018 pub const fn job_name(&self) -> &JobName {
1019 &self.job_name
1020 }
1021
1022 #[must_use]
1024 pub const fn job_instance_id(&self) -> JobInstanceId {
1025 self.job_instance_id
1026 }
1027
1028 #[must_use]
1030 pub const fn job_execution_id(&self) -> JobExecutionId {
1031 self.job_execution_id
1032 }
1033
1034 #[must_use]
1036 pub const fn job_attempt(&self) -> ExecutionAttempt {
1037 self.job_attempt
1038 }
1039
1040 #[must_use]
1042 pub const fn source_node_id(&self) -> &NodeId {
1043 &self.source_node_id
1044 }
1045
1046 #[must_use]
1048 pub const fn source_step_execution_id(&self) -> Option<StepExecutionId> {
1049 self.source_step_execution_id
1050 }
1051
1052 #[must_use]
1054 pub const fn target(&self) -> Option<&FlowTarget> {
1055 self.target.as_ref()
1056 }
1057
1058 #[must_use]
1060 pub const fn occurred_at(&self) -> SystemTime {
1061 self.occurred_at
1062 }
1063}
1064
1065pub trait FlowEventSink: Send + Sync {
1067 fn emit(&self, event: &FlowEvent);
1069}
1070
1071impl FlowLaunchReport {
1072 #[must_use]
1074 pub const fn instance(&self) -> &JobInstance {
1075 &self.instance
1076 }
1077 #[must_use]
1079 pub const fn job_execution(&self) -> &JobExecution {
1080 &self.job_execution
1081 }
1082 #[must_use]
1084 pub fn step_executions(&self) -> &[StepExecution] {
1085 &self.step_executions
1086 }
1087 #[must_use]
1089 pub fn decisions(&self) -> &[FlowDecision] {
1090 &self.decisions
1091 }
1092 #[must_use]
1094 pub const fn outcome(&self) -> &FlowExecutionOutcome {
1095 &self.outcome
1096 }
1097 #[must_use]
1099 pub fn listener_failures(&self) -> &[ListenerFailure] {
1100 &self.listener_failures
1101 }
1102}
1103
1104#[derive(Clone, Debug, Eq, PartialEq)]
1106#[non_exhaustive]
1107pub enum FlowRuntimeError {
1108 Job(FlowJobError),
1110 Repository(RepositoryError),
1112 DecisionSequenceExhausted,
1114 CountExhausted,
1116 ShuttingDown,
1118 UndeclaredCapability {
1120 capability: RepositoryCapability,
1122 descriptor_version: u32,
1124 },
1125 InsufficientPoolCapacity {
1127 required: u32,
1129 configured: u32,
1131 },
1132 UnresolvedPartitionOutcome {
1134 step_execution_id: StepExecutionId,
1136 },
1137 PartitionerRejected {
1139 panicked: bool,
1141 },
1142}
1143
1144impl fmt::Display for FlowRuntimeError {
1145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1146 match self {
1147 Self::Job(error) => write!(formatter, "flow job is invalid: {error}"),
1148 Self::Repository(error) => {
1149 write!(formatter, "flow repository operation failed: {error}")
1150 }
1151 Self::DecisionSequenceExhausted => {
1152 formatter.write_str("flow decision sequence is exhausted")
1153 }
1154 Self::CountExhausted => formatter.write_str("flow execution count is exhausted"),
1155 Self::ShuttingDown => formatter.write_str("runtime intake is shutting down"),
1156 Self::UndeclaredCapability { capability, .. } => write!(
1157 formatter,
1158 "flow requires {capability}, which the connected repository does not declare"
1159 ),
1160 Self::InsufficientPoolCapacity {
1161 required,
1162 configured,
1163 } => write!(
1164 formatter,
1165 "flow requires {required} repository connections but only {configured} are configured"
1166 ),
1167 Self::UnresolvedPartitionOutcome { step_execution_id } => write!(
1168 formatter,
1169 "partition parent step execution {step_execution_id} is blocked by an unknown child outcome"
1170 ),
1171 Self::PartitionerRejected { panicked } => {
1172 if *panicked {
1173 formatter.write_str("partition factory panicked")
1174 } else {
1175 formatter.write_str("partition factory rejected the plan")
1176 }
1177 }
1178 }
1179 }
1180}
1181
1182impl Error for FlowRuntimeError {
1183 fn source(&self) -> Option<&(dyn Error + 'static)> {
1184 match self {
1185 Self::Job(error) => Some(error),
1186 Self::Repository(error) => Some(error),
1187 Self::DecisionSequenceExhausted
1188 | Self::CountExhausted
1189 | Self::ShuttingDown
1190 | Self::UndeclaredCapability { .. }
1191 | Self::InsufficientPoolCapacity { .. }
1192 | Self::UnresolvedPartitionOutcome { .. }
1193 | Self::PartitionerRejected { .. } => None,
1194 }
1195 }
1196}
1197
1198fn plan_capabilities(plan: &CompiledExecutionPlan) -> BTreeSet<RepositoryCapability> {
1206 plan.nodes()
1207 .filter_map(|(_, node)| match node {
1208 FlowNode::PartitionedStep(_) => Some(RepositoryCapability::StepPartitions),
1209 _ => None,
1210 })
1211 .collect()
1212}
1213
1214impl From<RepositoryError> for FlowRuntimeError {
1215 fn from(error: RepositoryError) -> Self {
1216 Self::Repository(error)
1217 }
1218}
1219
1220impl From<FlowJobError> for FlowRuntimeError {
1221 fn from(error: FlowJobError) -> Self {
1222 Self::Job(error)
1223 }
1224}
1225
1226pub struct FlowLauncher<'a> {
1228 repository: &'a dyn JobRepository,
1229 clock: &'a dyn Clock,
1230 ids: &'a dyn IdGenerator,
1231 event_sink: Option<&'a dyn FlowEventSink>,
1232 execution_control: Option<(crate::OwnerToken, StopPollInterval)>,
1233 shutdown_signal: Option<&'a crate::ShutdownSignal>,
1234}
1235
1236impl<'a> FlowLauncher<'a> {
1237 #[must_use]
1239 pub const fn new(
1240 repository: &'a dyn JobRepository,
1241 clock: &'a dyn Clock,
1242 ids: &'a dyn IdGenerator,
1243 ) -> Self {
1244 Self {
1245 repository,
1246 clock,
1247 ids,
1248 event_sink: None,
1249 execution_control: None,
1250 shutdown_signal: None,
1251 }
1252 }
1253
1254 #[must_use]
1256 pub const fn with_event_sink(mut self, event_sink: &'a dyn FlowEventSink) -> Self {
1257 self.event_sink = Some(event_sink);
1258 self
1259 }
1260
1261 #[must_use]
1263 pub const fn with_execution_control(
1264 mut self,
1265 owner: crate::OwnerToken,
1266 interval: StopPollInterval,
1267 ) -> Self {
1268 self.execution_control = Some((owner, interval));
1269 self
1270 }
1271
1272 #[must_use]
1274 pub const fn with_shutdown_signal(mut self, signal: &'a crate::ShutdownSignal) -> Self {
1275 self.shutdown_signal = Some(signal);
1276 self
1277 }
1278
1279 #[allow(clippy::too_many_lines)]
1291 pub async fn launch(
1292 &self,
1293 job: &FlowJob,
1294 parameters: &JobParameters,
1295 stop_token: &StopToken,
1296 ) -> Result<FlowLaunchReport, FlowRuntimeError> {
1297 self.ensure_accepting()?;
1298 job.validate()?;
1299 self.validate_repository_capabilities(job.compiled_plan())?;
1300 self.validate_repository_capacity(job.compiled_plan())?;
1301 let split_tasklets = job.materialize_split_tasklets()?;
1302 let key = JobInstanceKey::new(job.name.clone(), parameters);
1303 let (instance, mut execution, attempt) = self
1304 .create_job_execution(&key, job.plan.definition_identity())
1305 .await?;
1306 execution = self.start_job(&execution).await?;
1307 self.poll_execution_control(execution.id(), stop_token)
1308 .await?;
1309 self.observe_process_shutdown(stop_token);
1310
1311 let mut node_id = job.plan.entry().clone();
1312 let mut preceding: Option<FlowStepState> = None;
1313 let mut steps = Vec::new();
1314 let mut decisions = Vec::new();
1315 let mut listener_failures = Vec::new();
1316
1317 loop {
1318 self.observe_process_shutdown(stop_token);
1319 if stop_token.is_stop_requested() {
1320 let final_job = self
1321 .finish_job(&execution, BatchStatus::Stopped, None)
1322 .await?;
1323 return Ok(FlowLaunchReport {
1324 instance,
1325 job_execution: final_job,
1326 step_executions: steps,
1327 decisions,
1328 outcome: FlowExecutionOutcome::Stopped,
1329 listener_failures,
1330 });
1331 }
1332
1333 let node = job.plan.node(&node_id).ok_or_else(|| {
1334 FlowRuntimeError::Job(FlowJobError::MissingBinding {
1335 node: node_id.clone(),
1336 })
1337 })?;
1338 let (
1339 transition_node,
1340 observed,
1341 source_step,
1342 kind,
1343 input_digest,
1344 reused,
1345 source_failure,
1346 ) = match node {
1347 FlowNode::Step(compiled) => {
1348 let historical = self.latest_step(instance.id(), &node_id).await?;
1349 if let Some(history) = historical.as_ref()
1350 && history.execution().metadata().status() == BatchStatus::Completed
1351 && !compiled.start_controls().allow_start_if_complete()
1352 {
1353 let digest = step_input_digest(job.plan.fingerprint(), history);
1354 let reused = self
1355 .reusable_decision(
1356 instance.id(),
1357 &node_id,
1358 job.plan.fingerprint(),
1359 &digest,
1360 FlowTransitionKind::StepExit,
1361 )
1362 .await?;
1363 preceding = Some(history.clone());
1364 (
1365 node_id.clone(),
1366 history.execution().metadata().exit_status().clone(),
1367 Some(history.execution().id()),
1368 FlowTransitionKind::CompletedStepReuse,
1369 digest,
1370 reused.map(|decision| decision.id()),
1371 None,
1372 )
1373 } else {
1374 let tasklet = job.steps.get(&node_id).ok_or_else(|| {
1375 FlowRuntimeError::Job(FlowJobError::MissingBinding {
1376 node: node_id.clone(),
1377 })
1378 })?;
1379 let created = match self
1380 .create_step(
1381 execution.id(),
1382 compiled.step_name(),
1383 &node_id,
1384 compiled.start_controls().start_limit(),
1385 )
1386 .await
1387 {
1388 Ok(created) => created,
1389 Err(FlowRuntimeError::Repository(
1390 RepositoryError::StartLimitExceeded { limit, .. },
1391 )) => {
1392 self.emit_flow_event(&FlowEvent::new(
1393 FlowEventKind::StartLimitExceeded,
1394 job.name.clone(),
1395 instance.id(),
1396 execution.id(),
1397 attempt,
1398 node_id.clone(),
1399 None,
1400 None,
1401 self.clock.now(),
1402 ));
1403 let failure =
1404 self.next_failure_summary(FailureCategory::IllegalTransition)?;
1405 let final_job = self
1406 .finish_job(&execution, BatchStatus::Failed, Some(failure))
1407 .await?;
1408 return Ok(FlowLaunchReport {
1409 instance,
1410 job_execution: final_job,
1411 step_executions: steps,
1412 decisions,
1413 outcome: FlowExecutionOutcome::Failed(
1414 FlowFailure::StartLimitExceeded {
1415 node: node_id,
1416 limit,
1417 },
1418 ),
1419 listener_failures,
1420 });
1421 }
1422 Err(error) => return Err(error),
1423 };
1424 let correlation = correlation(
1425 &job.name,
1426 instance.id(),
1427 execution.id(),
1428 attempt,
1429 compiled.step_name(),
1430 created.id(),
1431 steps.len(),
1432 )?;
1433 let run = self
1434 .run_step(
1435 &node_id,
1436 tasklet,
1437 created,
1438 parameters,
1439 stop_token,
1440 &correlation,
1441 )
1442 .await?;
1443 listener_failures.extend(run.listener_failures);
1444 steps.push(run.execution.clone());
1445 if run.outcome == TaskletExecutionOutcome::Unknown {
1446 let final_job = self
1447 .finish_job(&execution, BatchStatus::Unknown, run.failure)
1448 .await?;
1449 return Ok(FlowLaunchReport {
1450 instance,
1451 job_execution: final_job,
1452 step_executions: steps,
1453 decisions,
1454 outcome: FlowExecutionOutcome::Unknown,
1455 listener_failures,
1456 });
1457 }
1458 if matches!(run.outcome, TaskletExecutionOutcome::Stopped(_)) {
1459 let final_job = self
1460 .finish_job(&execution, BatchStatus::Stopped, None)
1461 .await?;
1462 return Ok(FlowLaunchReport {
1463 instance,
1464 job_execution: final_job,
1465 step_executions: steps,
1466 decisions,
1467 outcome: FlowExecutionOutcome::Stopped,
1468 listener_failures,
1469 });
1470 }
1471 let state = self.latest_step(instance.id(), &node_id).await?.ok_or(
1472 FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1473 )?;
1474 let digest = step_input_digest(job.plan.fingerprint(), &state);
1475 preceding = Some(state);
1476 (
1477 node_id.clone(),
1478 run.exit_status,
1479 Some(run.execution.id()),
1480 FlowTransitionKind::StepExit,
1481 digest,
1482 None,
1483 run.flow_failure,
1484 )
1485 }
1486 }
1487 FlowNode::Decision(compiled) => {
1488 let digest = decision_input_digest(
1489 job.plan.fingerprint(),
1490 &node_id,
1491 compiled.revision().as_str(),
1492 compiled.input_version().get(),
1493 instance.id(),
1494 parameters,
1495 preceding.as_ref(),
1496 );
1497 if let Some(prior) = self
1498 .reusable_decision(
1499 instance.id(),
1500 &node_id,
1501 job.plan.fingerprint(),
1502 &digest,
1503 FlowTransitionKind::Decider,
1504 )
1505 .await?
1506 {
1507 (
1508 node_id.clone(),
1509 ExitStatus::new(prior.observed_outcome().clone()),
1510 None,
1511 FlowTransitionKind::Decider,
1512 digest,
1513 Some(prior.id()),
1514 None,
1515 )
1516 } else {
1517 let decider = job.deciders.get(&node_id).ok_or_else(|| {
1518 FlowRuntimeError::Job(FlowJobError::MissingBinding {
1519 node: node_id.clone(),
1520 })
1521 })?;
1522 let input = DecisionInput {
1523 job_instance_id: instance.id(),
1524 job_execution_id: execution.id(),
1525 attempt,
1526 plan_fingerprint: *job.plan.fingerprint(),
1527 node_id: &node_id,
1528 parameters,
1529 preceding_step: preceding.as_ref().map(DecisionStepInput::from_state),
1530 };
1531 match invoke_decider(decider.as_ref(), input).await {
1532 Ok(outcome) => (
1533 node_id.clone(),
1534 outcome,
1535 None,
1536 FlowTransitionKind::Decider,
1537 digest,
1538 None,
1539 None,
1540 ),
1541 Err(flow_failure) => {
1542 let failure =
1543 self.next_failure_summary(FailureCategory::UserComponent)?;
1544 let final_job = self
1545 .finish_job(&execution, BatchStatus::Failed, Some(failure))
1546 .await?;
1547 return Ok(FlowLaunchReport {
1548 instance,
1549 job_execution: final_job,
1550 step_executions: steps,
1551 decisions,
1552 outcome: FlowExecutionOutcome::Failed(flow_failure),
1553 listener_failures,
1554 });
1555 }
1556 }
1557 }
1558 }
1559 FlowNode::Split(split) => {
1560 let run = self
1561 .run_split(
1562 job,
1563 split,
1564 &split_tasklets,
1565 instance.id(),
1566 execution.id(),
1567 attempt,
1568 parameters,
1569 stop_token,
1570 )
1571 .await?;
1572 steps.extend(run.step_executions);
1573 listener_failures.extend(run.listener_failures);
1574 preceding = None;
1575 if run.status == BatchStatus::Unknown {
1576 let final_job = self
1577 .finish_job(&execution, BatchStatus::Unknown, run.failure)
1578 .await?;
1579 return Ok(FlowLaunchReport {
1580 instance,
1581 job_execution: final_job,
1582 step_executions: steps,
1583 decisions,
1584 outcome: FlowExecutionOutcome::Unknown,
1585 listener_failures,
1586 });
1587 }
1588 if run.status == BatchStatus::Stopped {
1589 let final_job = self
1590 .finish_job(&execution, BatchStatus::Stopped, None)
1591 .await?;
1592 return Ok(FlowLaunchReport {
1593 instance,
1594 job_execution: final_job,
1595 step_executions: steps,
1596 decisions,
1597 outcome: FlowExecutionOutcome::Stopped,
1598 listener_failures,
1599 });
1600 }
1601 let reused = self
1602 .reusable_decision(
1603 instance.id(),
1604 split.join(),
1605 job.plan.fingerprint(),
1606 &run.input_digest,
1607 FlowTransitionKind::SplitAggregate,
1608 )
1609 .await?;
1610 (
1611 split.join().clone(),
1612 run.exit_status,
1613 None,
1614 FlowTransitionKind::SplitAggregate,
1615 run.input_digest,
1616 reused.map(|decision| decision.id()),
1617 run.flow_failure,
1618 )
1619 }
1620 FlowNode::PartitionedStep(compiled) => {
1621 let historical = self.latest_step(instance.id(), &node_id).await?;
1622 if let Some(history) = historical.as_ref()
1623 && history.execution().metadata().status() == BatchStatus::Completed
1624 && !compiled.start_controls().allow_start_if_complete()
1625 {
1626 let digest = step_input_digest(job.plan.fingerprint(), history);
1627 let reused = self
1628 .reusable_decision(
1629 instance.id(),
1630 &node_id,
1631 job.plan.fingerprint(),
1632 &digest,
1633 FlowTransitionKind::StepExit,
1634 )
1635 .await?;
1636 preceding = Some(history.clone());
1637 (
1638 node_id.clone(),
1639 history.execution().metadata().exit_status().clone(),
1640 Some(history.execution().id()),
1641 FlowTransitionKind::CompletedStepReuse,
1642 digest,
1643 reused.map(|decision| decision.id()),
1644 None,
1645 )
1646 } else {
1647 let binding = job.partitioned_tasklets.get(&node_id).ok_or_else(|| {
1648 FlowRuntimeError::Job(FlowJobError::MissingBinding {
1649 node: node_id.clone(),
1650 })
1651 })?;
1652 let run = self
1653 .run_partitioned_step(
1654 job,
1655 compiled,
1656 binding,
1657 historical.as_ref(),
1658 instance.id(),
1659 execution.id(),
1660 attempt,
1661 parameters,
1662 stop_token,
1663 )
1664 .await?;
1665 listener_failures.extend(run.listener_failures);
1666 steps.extend(run.worker_executions);
1667 steps.push(run.parent.clone());
1668 if run.status == BatchStatus::Stopped {
1669 let final_job = self
1670 .finish_job(&execution, BatchStatus::Stopped, None)
1671 .await?;
1672 return Ok(FlowLaunchReport {
1673 instance,
1674 job_execution: final_job,
1675 step_executions: steps,
1676 decisions,
1677 outcome: FlowExecutionOutcome::Stopped,
1678 listener_failures,
1679 });
1680 }
1681 let state = self.latest_step(instance.id(), &node_id).await?.ok_or(
1682 FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1683 )?;
1684 let digest = step_input_digest(job.plan.fingerprint(), &state);
1685 preceding = Some(state);
1686 (
1687 node_id.clone(),
1688 run.exit_status,
1689 Some(run.parent.id()),
1690 FlowTransitionKind::StepExit,
1691 digest,
1692 None,
1693 run.flow_failure,
1694 )
1695 }
1696 }
1697 FlowNode::Join(_) | _ => {
1701 return Err(FlowRuntimeError::Job(FlowJobError::UnsupportedManifest {
1702 format: job.plan.manifest_format(),
1703 }));
1704 }
1705 };
1706
1707 let target = match job.plan.select_target(&transition_node, observed.code()) {
1708 Ok(target) => target.clone(),
1709 Err(FlowSelectionError::UnmappedExitOutcome { node, code }) => {
1710 let failure = self.next_failure_summary(FailureCategory::InvalidDefinition)?;
1711 let final_job = self
1712 .finish_job(&execution, BatchStatus::Failed, Some(failure))
1713 .await?;
1714 return Ok(FlowLaunchReport {
1715 instance,
1716 job_execution: final_job,
1717 step_executions: steps,
1718 decisions,
1719 outcome: FlowExecutionOutcome::Failed(FlowFailure::UnmappedExitOutcome {
1720 node,
1721 code,
1722 }),
1723 listener_failures,
1724 });
1725 }
1726 Err(FlowSelectionError::UnknownNode { .. }) => {
1727 return Err(FlowRuntimeError::Job(FlowJobError::MissingBinding {
1728 node: transition_node,
1729 }));
1730 }
1731 Err(_) => {
1736 return Err(FlowRuntimeError::Job(FlowJobError::UnsupportedManifest {
1737 format: job.plan.manifest_format(),
1738 }));
1739 }
1740 };
1741 let sequence = next_sequence(decisions.len())?;
1742 let request = FlowDecisionRequest::new(
1743 execution.id(),
1744 sequence,
1745 transition_node.clone(),
1746 source_step,
1747 kind,
1748 observed.code().clone(),
1749 target.clone(),
1750 *job.plan.fingerprint(),
1751 input_digest,
1752 reused,
1753 self.clock.now(),
1754 );
1755 let decision = self.append_decision(&request).await?;
1756 self.emit_flow_event(&FlowEvent::new(
1757 FlowEventKind::DecisionCommitted,
1758 job.name.clone(),
1759 instance.id(),
1760 execution.id(),
1761 attempt,
1762 transition_node.clone(),
1763 source_step,
1764 Some(target.clone()),
1765 decision.decided_at(),
1766 ));
1767 if kind == FlowTransitionKind::CompletedStepReuse {
1768 self.emit_flow_event(&FlowEvent::new(
1769 FlowEventKind::CompletedStepReused,
1770 job.name.clone(),
1771 instance.id(),
1772 execution.id(),
1773 attempt,
1774 transition_node.clone(),
1775 source_step,
1776 Some(target.clone()),
1777 decision.decided_at(),
1778 ));
1779 }
1780 decisions.push(decision);
1781
1782 match target {
1783 FlowTarget::Node(next) => node_id = next,
1784 FlowTarget::Terminal(terminal) => {
1785 let (status, outcome) = match terminal {
1786 TerminalKind::Complete => {
1787 (BatchStatus::Completed, FlowExecutionOutcome::Completed)
1788 }
1789 TerminalKind::Stop => (BatchStatus::Stopped, FlowExecutionOutcome::Stopped),
1790 _ => (
1795 BatchStatus::Failed,
1796 FlowExecutionOutcome::Failed(
1797 source_failure.unwrap_or(FlowFailure::FailTerminal),
1798 ),
1799 ),
1800 };
1801 let failure = if status == BatchStatus::Failed {
1802 Some(self.next_failure_summary(FailureCategory::UserComponent)?)
1803 } else {
1804 None
1805 };
1806 let final_job = self.finish_job(&execution, status, failure).await?;
1807 return Ok(FlowLaunchReport {
1808 instance,
1809 job_execution: final_job,
1810 step_executions: steps,
1811 decisions,
1812 outcome,
1813 listener_failures,
1814 });
1815 }
1816 }
1817 }
1818 }
1819
1820 #[allow(clippy::too_many_arguments)]
1821 async fn run_split(
1822 &self,
1823 job: &FlowJob,
1824 split: &crate::SplitNode,
1825 tasklets: &BTreeMap<NodeId, TaskletStep>,
1826 instance_id: JobInstanceId,
1827 execution_id: JobExecutionId,
1828 attempt: ExecutionAttempt,
1829 parameters: &JobParameters,
1830 parent_stop: &StopToken,
1831 ) -> Result<SplitRun, FlowRuntimeError> {
1832 let (split_stop_source, split_stop) = crate::StopSource::new();
1833 if parent_stop.is_stop_requested() {
1834 split_stop_source.request_stop();
1835 }
1836
1837 let mut ordinal_base = 0_usize;
1838 let branches = futures_util::stream::iter(split.branches().iter().enumerate().map(
1839 |(index, branch)| {
1840 let base = ordinal_base;
1841 ordinal_base = ordinal_base.saturating_add(branch.steps().len());
1842 let split_stop = split_stop.clone();
1843 async move {
1844 self.run_split_branch(
1845 job,
1846 index,
1847 base,
1848 branch,
1849 tasklets,
1850 instance_id,
1851 execution_id,
1852 attempt,
1853 parameters,
1854 &split_stop,
1855 )
1856 .await
1857 }
1858 },
1859 ))
1860 .buffer_unordered(usize::from(split.budget().max_parallel_branches()));
1861 tokio::pin!(branches);
1862
1863 let mut joined = Vec::with_capacity(split.branches().len());
1864 let mut first_error = None;
1865 let mut parent_stop_observed = parent_stop.is_stop_requested();
1866 loop {
1867 tokio::select! {
1868 result = branches.next() => {
1869 let Some(result) = result else { break; };
1870 match result {
1871 Ok(branch) => {
1872 if split.failure_policy() == crate::LocalFailurePolicy::CancelSiblings
1873 && matches!(branch.status, BatchStatus::Failed | BatchStatus::Unknown)
1874 {
1875 split_stop_source.request_stop();
1876 }
1877 joined.push(branch);
1878 }
1879 Err(error) => {
1880 split_stop_source.request_stop();
1881 if first_error.is_none() {
1882 first_error = Some(error);
1883 }
1884 }
1885 }
1886 }
1887 () = parent_stop.cancelled(), if !parent_stop_observed => {
1888 parent_stop_observed = true;
1889 split_stop_source.request_stop();
1890 }
1891 }
1892 }
1893 if let Some(error) = first_error {
1894 return Err(error);
1895 }
1896 joined.sort_by_key(|branch| branch.index);
1897 if joined.len() != split.branches().len() {
1898 return Err(FlowRuntimeError::Repository(
1899 RepositoryError::FlowStateCorrupt,
1900 ));
1901 }
1902
1903 let status = joined
1904 .iter()
1905 .map(|branch| branch.status)
1906 .max_by_key(|status| split_status_severity(*status))
1907 .ok_or(FlowRuntimeError::Repository(
1908 RepositoryError::FlowStateCorrupt,
1909 ))?;
1910 let selected = joined.iter().find(|branch| branch.status == status).ok_or(
1911 FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
1912 )?;
1913 let exit_status = selected.exit_status.clone();
1914 let failure = selected.failure;
1915 let flow_failure = selected.flow_failure.clone();
1916 let input_digest = split_input_digest(job.plan.fingerprint(), split.join(), &joined);
1917 let mut step_executions = Vec::new();
1918 let mut listener_failures = Vec::new();
1919 for branch in joined {
1920 step_executions.extend(branch.step_executions);
1921 listener_failures.extend(branch.listener_failures);
1922 }
1923 Ok(SplitRun {
1924 status,
1925 exit_status,
1926 failure,
1927 flow_failure,
1928 input_digest,
1929 step_executions,
1930 listener_failures,
1931 })
1932 }
1933
1934 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1935 async fn run_split_branch(
1936 &self,
1937 job: &FlowJob,
1938 index: usize,
1939 ordinal_base: usize,
1940 branch: &crate::SplitBranch,
1941 tasklets: &BTreeMap<NodeId, TaskletStep>,
1942 instance_id: JobInstanceId,
1943 execution_id: JobExecutionId,
1944 attempt: ExecutionAttempt,
1945 parameters: &JobParameters,
1946 stop: &StopToken,
1947 ) -> Result<SplitBranchRun, FlowRuntimeError> {
1948 let mut durable_states = Vec::with_capacity(branch.steps().len());
1949 let mut step_executions = Vec::new();
1950 let mut listener_failures = Vec::new();
1951 let mut status = BatchStatus::Completed;
1952 let mut exit_status = ExitStatus::completed();
1953 let mut failure = None;
1954 let mut flow_failure = None;
1955
1956 for (offset, compiled) in branch.steps().iter().enumerate() {
1957 if stop.is_stop_requested() {
1958 status = BatchStatus::Stopped;
1959 exit_status = ExitStatus::stopped();
1960 break;
1961 }
1962 let historical = self.latest_step(instance_id, compiled.id()).await?;
1963 if let Some(history) = historical
1964 && history.execution().metadata().status() == BatchStatus::Completed
1965 && !compiled.start_controls().allow_start_if_complete()
1966 {
1967 exit_status = history.execution().metadata().exit_status().clone();
1968 durable_states.push(history);
1969 continue;
1970 }
1971
1972 let tasklet = tasklets.get(compiled.id()).ok_or_else(|| {
1973 FlowRuntimeError::Job(FlowJobError::MissingBinding {
1974 node: compiled.id().clone(),
1975 })
1976 })?;
1977 let created = match self
1978 .create_step(
1979 execution_id,
1980 compiled.step_name(),
1981 compiled.id(),
1982 compiled.start_controls().start_limit(),
1983 )
1984 .await
1985 {
1986 Ok(created) => created,
1987 Err(FlowRuntimeError::Repository(RepositoryError::StartLimitExceeded {
1988 limit,
1989 ..
1990 })) => {
1991 self.emit_flow_event(&FlowEvent::new(
1992 FlowEventKind::StartLimitExceeded,
1993 job.name.clone(),
1994 instance_id,
1995 execution_id,
1996 attempt,
1997 compiled.id().clone(),
1998 None,
1999 None,
2000 self.clock.now(),
2001 ));
2002 status = BatchStatus::Failed;
2003 exit_status = ExitStatus::failed();
2004 failure = Some(self.next_failure_summary(FailureCategory::IllegalTransition)?);
2005 flow_failure = Some(FlowFailure::StartLimitExceeded {
2006 node: compiled.id().clone(),
2007 limit,
2008 });
2009 break;
2010 }
2011 Err(error) => return Err(error),
2012 };
2013 let correlation = correlation(
2014 &job.name,
2015 instance_id,
2016 execution_id,
2017 attempt,
2018 compiled.step_name(),
2019 created.id(),
2020 ordinal_base.saturating_add(offset),
2021 )?;
2022 let run = self
2023 .run_step(
2024 compiled.id(),
2025 tasklet,
2026 created,
2027 parameters,
2028 stop,
2029 &correlation,
2030 )
2031 .await?;
2032 status = status_for_tasklet(run.outcome);
2033 exit_status = run.exit_status;
2034 failure = run.failure;
2035 flow_failure = run.flow_failure;
2036 listener_failures.extend(run.listener_failures);
2037 step_executions.push(run.execution);
2038 let state = self.latest_step(instance_id, compiled.id()).await?.ok_or(
2039 FlowRuntimeError::Repository(RepositoryError::FlowStateCorrupt),
2040 )?;
2041 durable_states.push(state);
2042 if status != BatchStatus::Completed {
2043 break;
2044 }
2045 }
2046
2047 Ok(SplitBranchRun {
2048 index,
2049 status,
2050 exit_status,
2051 failure,
2052 flow_failure,
2053 states: durable_states,
2054 step_executions,
2055 listener_failures,
2056 })
2057 }
2058
2059 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2060 async fn run_partitioned_step(
2061 &self,
2062 job: &FlowJob,
2063 compiled: &crate::PartitionedStepNode,
2064 binding: &PartitionedTaskletBinding,
2065 historical: Option<&FlowStepState>,
2066 instance_id: JobInstanceId,
2067 execution_id: JobExecutionId,
2068 attempt: ExecutionAttempt,
2069 parameters: &JobParameters,
2070 parent_stop: &StopToken,
2071 ) -> Result<PartitionRun, FlowRuntimeError> {
2072 let created = self
2073 .create_step(
2074 execution_id,
2075 compiled.step_name(),
2076 compiled.id(),
2077 compiled.start_controls().start_limit(),
2078 )
2079 .await?;
2080 let parent = self.start_step(&created).await?;
2081 let plan_result: Result<Vec<StepPartition>, FlowRuntimeError> =
2082 if let Some(history) = historical {
2083 let source = self.partition_plan(history.execution().id()).await?;
2084 if source.is_empty() {
2085 self.create_partition_plan(
2086 binding,
2087 compiled,
2088 instance_id,
2089 parent.id(),
2090 job.plan.fingerprint(),
2091 )
2092 .await
2093 } else {
2094 self.restart_partition_plan(history.execution().id(), parent.id())
2095 .await
2096 }
2097 } else {
2098 self.create_partition_plan(
2099 binding,
2100 compiled,
2101 instance_id,
2102 parent.id(),
2103 job.plan.fingerprint(),
2104 )
2105 .await
2106 };
2107 let plan = match plan_result {
2108 Ok(plan) => plan,
2109 Err(FlowRuntimeError::PartitionerRejected { panicked }) => {
2110 let failure = self.next_failure_summary(FailureCategory::UserComponent)?;
2111 let parent = self
2112 .finish_step(
2113 &parent,
2114 TaskletExecutionOutcome::Failed(TaskletFailure::Error),
2115 &ExitStatus::failed(),
2116 Some(failure),
2117 false,
2118 )
2119 .await?;
2120 self.emit_flow_event(&FlowEvent::new(
2121 FlowEventKind::StepResultCommitted,
2122 job.name.clone(),
2123 instance_id,
2124 execution_id,
2125 attempt,
2126 compiled.id().clone(),
2127 Some(parent.id()),
2128 None,
2129 self.clock.now(),
2130 ));
2131 return Ok(PartitionRun {
2132 status: BatchStatus::Failed,
2133 exit_status: ExitStatus::failed(),
2134 parent,
2135 flow_failure: Some(if panicked {
2136 FlowFailure::PartitionerPanic
2137 } else {
2138 FlowFailure::PartitionerError
2139 }),
2140 worker_executions: Vec::new(),
2141 listener_failures: Vec::new(),
2142 });
2143 }
2144 Err(error) => return Err(error),
2145 };
2146
2147 if plan
2148 .iter()
2149 .any(|partition| partition.status() == BatchStatus::Unknown)
2150 {
2151 return Err(FlowRuntimeError::UnresolvedPartitionOutcome {
2152 step_execution_id: parent.id(),
2153 });
2154 }
2155
2156 let pending = plan
2157 .into_iter()
2158 .filter(|partition| partition.status() != BatchStatus::Completed)
2159 .collect::<Vec<_>>();
2160 let (partition_stop_source, partition_stop) = crate::StopSource::new();
2161 if parent_stop.is_stop_requested() {
2162 partition_stop_source.request_stop();
2163 }
2164 let workers = futures_util::stream::iter(pending.into_iter().map(|partition| {
2165 let partition_stop = partition_stop.clone();
2166 async move {
2167 self.run_partition_worker(
2168 job,
2169 compiled,
2170 binding,
2171 partition,
2172 instance_id,
2173 execution_id,
2174 attempt,
2175 parameters,
2176 &partition_stop,
2177 )
2178 .await
2179 }
2180 }))
2181 .buffer_unordered(usize::from(compiled.budget().max_partition_workers()));
2182 tokio::pin!(workers);
2183
2184 let mut joined = Vec::new();
2185 let mut first_error = None;
2186 let mut parent_stop_observed = parent_stop.is_stop_requested();
2187 loop {
2188 tokio::select! {
2189 result = workers.next() => {
2190 let Some(result) = result else { break; };
2191 match result {
2192 Ok(worker) => {
2193 if compiled.failure_policy() == crate::LocalFailurePolicy::CancelSiblings
2194 && matches!(worker.partition.status(), BatchStatus::Failed | BatchStatus::Unknown)
2195 {
2196 partition_stop_source.request_stop();
2197 }
2198 joined.push(worker);
2199 }
2200 Err(error) => {
2201 partition_stop_source.request_stop();
2202 if first_error.is_none() {
2203 first_error = Some(error);
2204 }
2205 }
2206 }
2207 }
2208 () = parent_stop.cancelled(), if !parent_stop_observed => {
2209 parent_stop_observed = true;
2210 partition_stop_source.request_stop();
2211 }
2212 }
2213 }
2214 if let Some(error) = first_error {
2215 return Err(error);
2216 }
2217
2218 let durable = self.partition_plan(parent.id()).await?;
2219 if durable
2220 .iter()
2221 .any(|partition| partition.status() == BatchStatus::Unknown)
2222 {
2223 return Err(FlowRuntimeError::UnresolvedPartitionOutcome {
2224 step_execution_id: parent.id(),
2225 });
2226 }
2227 let parent = self
2228 .aggregate_partition_parent(parent.id(), parent.version())
2229 .await?;
2230 self.emit_flow_event(&FlowEvent::new(
2231 FlowEventKind::StepResultCommitted,
2232 job.name.clone(),
2233 instance_id,
2234 execution_id,
2235 attempt,
2236 compiled.id().clone(),
2237 Some(parent.id()),
2238 None,
2239 self.clock.now(),
2240 ));
2241
2242 joined.sort_by(|left, right| left.partition.key().cmp(right.partition.key()));
2243 let selected = joined
2244 .iter()
2245 .find(|worker| worker.partition.status() == parent.metadata().status());
2246 let flow_failure = selected.and_then(|worker| worker.flow_failure.clone());
2247 let mut worker_executions = Vec::with_capacity(joined.len());
2248 let mut listener_failures = Vec::new();
2249 for worker in joined {
2250 worker_executions.push(worker.execution);
2251 listener_failures.extend(worker.listener_failures);
2252 }
2253 Ok(PartitionRun {
2254 status: parent.metadata().status(),
2255 exit_status: parent.metadata().exit_status().clone(),
2256 parent,
2257 flow_failure,
2258 worker_executions,
2259 listener_failures,
2260 })
2261 }
2262
2263 async fn create_partition_plan(
2264 &self,
2265 binding: &PartitionedTaskletBinding,
2266 compiled: &crate::PartitionedStepNode,
2267 instance_id: JobInstanceId,
2268 parent_id: StepExecutionId,
2269 fingerprint: &[u8; 32],
2270 ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2271 let request = PartitionPlanRequest {
2272 plan_fingerprint: fingerprint,
2273 job_instance_id: instance_id,
2274 node_id: compiled.id(),
2275 partition_count: compiled.partition_count(),
2276 };
2277 let entries = match catch_unwind(AssertUnwindSafe(|| binding.partitioner.create(request))) {
2278 Ok(Ok(entries)) => entries,
2279 Ok(Err(_)) => return Err(FlowRuntimeError::PartitionerRejected { panicked: false }),
2280 Err(_) => return Err(FlowRuntimeError::PartitionerRejected { panicked: true }),
2281 };
2282 if entries.len() != usize::from(compiled.partition_count().get()) {
2283 return Err(FlowRuntimeError::PartitionerRejected { panicked: false });
2284 }
2285 let mut unit = self.repository.begin().await?;
2286 let created = unit.create_step_partition_plan(parent_id, &entries).await?;
2287 match unit.commit().await {
2288 Ok(()) => Ok(created),
2289 Err(RepositoryError::CommitOutcomeUnknown) => {
2290 let durable = self.partition_plan(parent_id).await?;
2291 if durable == created {
2292 Ok(durable)
2293 } else {
2294 Err(RepositoryError::CommitOutcomeUnknown.into())
2295 }
2296 }
2297 Err(error) => Err(error.into()),
2298 }
2299 }
2300
2301 async fn restart_partition_plan(
2302 &self,
2303 source_parent_id: StepExecutionId,
2304 target_parent_id: StepExecutionId,
2305 ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2306 let mut unit = self.repository.begin().await?;
2307 let copied = unit
2308 .restart_step_partition_plan(source_parent_id, target_parent_id)
2309 .await?;
2310 match unit.commit().await {
2311 Ok(()) => Ok(copied),
2312 Err(RepositoryError::CommitOutcomeUnknown) => {
2313 let durable = self.partition_plan(target_parent_id).await?;
2314 if durable == copied {
2315 Ok(durable)
2316 } else {
2317 Err(RepositoryError::CommitOutcomeUnknown.into())
2318 }
2319 }
2320 Err(error) => Err(error.into()),
2321 }
2322 }
2323
2324 #[allow(clippy::too_many_arguments)]
2325 async fn run_partition_worker(
2326 &self,
2327 job: &FlowJob,
2328 compiled: &crate::PartitionedStepNode,
2329 binding: &PartitionedTaskletBinding,
2330 partition: StepPartition,
2331 instance_id: JobInstanceId,
2332 execution_id: JobExecutionId,
2333 attempt: ExecutionAttempt,
2334 parameters: &JobParameters,
2335 stop: &StopToken,
2336 ) -> Result<PartitionWorkerRun, FlowRuntimeError> {
2337 let (worker, assigned) = self
2338 .create_and_assign_partition_worker(execution_id, compiled, &partition)
2339 .await?;
2340 let correlation = correlation(
2341 &job.name,
2342 instance_id,
2343 execution_id,
2344 attempt,
2345 compiled.worker().step_name(),
2346 worker.id(),
2347 usize::try_from(partition.ordinal()).unwrap_or(usize::MAX),
2348 )?;
2349
2350 let run = if stop.is_stop_requested() {
2351 self.finish_uninvoked_partition_worker(
2352 worker,
2353 TaskletExecutionOutcome::Stopped(StopTiming::BeforeStart),
2354 ExitStatus::stopped(),
2355 None,
2356 )
2357 .await?
2358 } else {
2359 let input = PartitionWorkerInput::from_partition(&assigned);
2360 match catch_unwind(AssertUnwindSafe(|| binding.worker.create(input))) {
2361 Ok(tasklet_step) if tasklet_step.name() == binding.worker.step_name() => {
2362 self.run_step(
2363 compiled.worker().id(),
2364 &tasklet_step,
2365 worker,
2366 parameters,
2367 stop,
2368 &correlation,
2369 )
2370 .await?
2371 }
2372 Ok(_) | Err(_) => {
2373 let failure = self.next_failure_summary(FailureCategory::UserComponent)?;
2374 let mut run = self
2375 .finish_uninvoked_partition_worker(
2376 worker,
2377 TaskletExecutionOutcome::Failed(TaskletFailure::Panic),
2378 ExitStatus::failed(),
2379 Some(failure),
2380 )
2381 .await?;
2382 run.flow_failure = Some(FlowFailure::PartitionFactoryPanic);
2383 run
2384 }
2385 }
2386 };
2387 let completed = self
2388 .publish_partition_result(&assigned, run.execution.id())
2389 .await?;
2390 Ok(PartitionWorkerRun {
2391 partition: completed,
2392 execution: run.execution,
2393 flow_failure: run.flow_failure,
2394 listener_failures: run.listener_failures,
2395 })
2396 }
2397
2398 async fn create_and_assign_partition_worker(
2399 &self,
2400 execution_id: JobExecutionId,
2401 compiled: &crate::PartitionedStepNode,
2402 partition: &StepPartition,
2403 ) -> Result<(StepExecution, StepPartition), FlowRuntimeError> {
2404 let (worker_name, worker_node_id) = partition_worker_identity(compiled, partition)?;
2405 let mut unit = self.repository.begin().await?;
2406 let worker = unit
2407 .create_flow_step_execution(
2408 execution_id,
2409 &worker_name,
2410 &worker_node_id,
2411 compiled.worker().start_controls().start_limit(),
2412 )
2413 .await?;
2414 let assigned = unit
2415 .assign_step_partition(partition.id(), partition.version(), worker.id())
2416 .await?;
2417 unit.commit().await?;
2418 Ok((worker, assigned))
2419 }
2420
2421 async fn finish_uninvoked_partition_worker(
2422 &self,
2423 worker: StepExecution,
2424 outcome: TaskletExecutionOutcome,
2425 exit_status: ExitStatus,
2426 failure: Option<FailureSummary>,
2427 ) -> Result<StepRun, FlowRuntimeError> {
2428 let started = self.start_step(&worker).await?;
2429 let execution = self
2430 .finish_step(&started, outcome, &exit_status, failure, false)
2431 .await?;
2432 Ok(StepRun {
2433 execution,
2434 outcome,
2435 exit_status,
2436 failure,
2437 flow_failure: match outcome {
2438 TaskletExecutionOutcome::Failed(value) => Some(FlowFailure::Tasklet(value)),
2439 _ => None,
2440 },
2441 listener_failures: Vec::new(),
2442 })
2443 }
2444
2445 async fn publish_partition_result(
2446 &self,
2447 partition: &StepPartition,
2448 worker_id: StepExecutionId,
2449 ) -> Result<StepPartition, FlowRuntimeError> {
2450 let mut unit = self.repository.begin().await?;
2451 let completed = unit
2452 .complete_step_partition(partition.id(), partition.version(), worker_id)
2453 .await?;
2454 match unit.commit().await {
2455 Ok(()) => Ok(completed),
2456 Err(RepositoryError::CommitOutcomeUnknown) => {
2457 let durable = self.partition_plan(partition.step_execution_id()).await?;
2458 let current = durable
2459 .into_iter()
2460 .find(|candidate| candidate.id() == partition.id())
2461 .ok_or(RepositoryError::PartitionStateCorrupt)?;
2462 if current == completed {
2463 Ok(current)
2464 } else {
2465 Err(RepositoryError::CommitOutcomeUnknown.into())
2466 }
2467 }
2468 Err(error) => Err(error.into()),
2469 }
2470 }
2471
2472 async fn partition_plan(
2473 &self,
2474 parent_id: StepExecutionId,
2475 ) -> Result<Vec<StepPartition>, FlowRuntimeError> {
2476 let mut unit = self.repository.begin().await?;
2477 let plan = unit.step_partition_plan(parent_id).await?;
2478 unit.rollback().await?;
2479 Ok(plan)
2480 }
2481
2482 async fn aggregate_partition_parent(
2483 &self,
2484 parent_id: StepExecutionId,
2485 expected_version: crate::ExecutionVersion,
2486 ) -> Result<StepExecution, FlowRuntimeError> {
2487 let mut unit = self.repository.begin().await?;
2488 let aggregated = unit
2489 .aggregate_step_partitions(parent_id, expected_version, self.clock.now())
2490 .await?;
2491 match unit.commit().await {
2492 Ok(()) => Ok(aggregated),
2493 Err(RepositoryError::CommitOutcomeUnknown) => {
2494 let mut recovery = self.repository.begin().await?;
2495 let inspected = recovery
2496 .aggregate_step_partitions(parent_id, expected_version, self.clock.now())
2497 .await?;
2498 recovery.commit().await?;
2499 Ok(inspected)
2500 }
2501 Err(error) => Err(error.into()),
2502 }
2503 }
2504
2505 fn ensure_accepting(&self) -> Result<(), FlowRuntimeError> {
2506 self.shutdown_signal.map_or(Ok(()), |signal| {
2507 signal
2508 .ensure_accepting()
2509 .map_err(|_| FlowRuntimeError::ShuttingDown)
2510 })
2511 }
2512
2513 fn validate_repository_capabilities(
2521 &self,
2522 plan: &CompiledExecutionPlan,
2523 ) -> Result<(), FlowRuntimeError> {
2524 let descriptor = self.repository.descriptor();
2525 for capability in self.required_capabilities(plan) {
2526 descriptor
2527 .require(capability)
2528 .map_err(|_| FlowRuntimeError::UndeclaredCapability {
2529 capability,
2530 descriptor_version: descriptor.descriptor_version(),
2531 })?;
2532 }
2533 Ok(())
2534 }
2535
2536 fn required_capabilities(
2548 &self,
2549 plan: &CompiledExecutionPlan,
2550 ) -> BTreeSet<RepositoryCapability> {
2551 let mut required = plan_capabilities(plan);
2552 if self.execution_control.is_some() {
2553 required.insert(RepositoryCapability::ExecutionOwnership);
2557 }
2558 required
2559 }
2560
2561 fn validate_repository_capacity(
2562 &self,
2563 plan: &CompiledExecutionPlan,
2564 ) -> Result<(), FlowRuntimeError> {
2565 let configured = self.repository.connection_capacity();
2566 let required = plan
2567 .nodes()
2568 .filter_map(|(_, node)| match node {
2569 FlowNode::Split(split) => Some(split.budget().repository_pool_size()),
2570 FlowNode::PartitionedStep(partitioned) => {
2571 Some(partitioned.budget().repository_pool_size())
2572 }
2573 _ => None,
2574 })
2575 .max()
2576 .unwrap_or(1);
2577 if configured < required {
2578 return Err(FlowRuntimeError::InsufficientPoolCapacity {
2579 required,
2580 configured,
2581 });
2582 }
2583 Ok(())
2584 }
2585
2586 fn observe_process_shutdown(&self, stop: &StopToken) {
2587 if self
2588 .shutdown_signal
2589 .is_some_and(crate::ShutdownSignal::is_shutdown_requested)
2590 {
2591 stop.request_stop();
2592 }
2593 }
2594
2595 async fn poll_execution_control(
2596 &self,
2597 execution_id: JobExecutionId,
2598 stop: &StopToken,
2599 ) -> Result<(), FlowRuntimeError> {
2600 let Some((owner, _)) = self.execution_control else {
2601 return Ok(());
2602 };
2603 let mut unit = self.repository.begin().await?;
2604 let control = unit
2605 .observe_execution_control(execution_id, &owner, self.clock.now())
2606 .await?;
2607 unit.commit().await?;
2608 if !control.owner_matches() {
2609 return Err(RepositoryError::ExecutionOwned { id: execution_id }.into());
2610 }
2611 if control.stop_requested() {
2612 stop.request_stop();
2613 }
2614 Ok(())
2615 }
2616
2617 async fn invoke_with_execution_control(
2618 &self,
2619 execution_id: JobExecutionId,
2620 tasklet: &dyn crate::Tasklet,
2621 context: TaskletContext<'_>,
2622 stop: &StopToken,
2623 ) -> Result<Result<TaskletOutcome, TaskletFailure>, FlowRuntimeError> {
2624 if self.execution_control.is_none() && self.shutdown_signal.is_none() {
2625 return Ok(invoke_tasklet(tasklet, context).await);
2626 }
2627 let invocation = invoke_tasklet(tasklet, context);
2628 tokio::pin!(invocation);
2629 let mut shutdown_observed = false;
2630 loop {
2631 tokio::select! {
2632 result = &mut invocation => return Ok(result),
2633 () = async {
2634 match self.execution_control {
2635 Some((_, interval)) => tokio::time::sleep(interval.get()).await,
2636 None => std::future::pending().await,
2637 }
2638 } => {
2639 self.poll_execution_control(execution_id, stop).await?;
2640 }
2641 () = async {
2642 match self.shutdown_signal {
2643 Some(signal) => signal.cancelled().await,
2644 None => std::future::pending().await,
2645 }
2646 }, if !shutdown_observed => {
2647 shutdown_observed = true;
2648 stop.request_stop();
2649 }
2650 }
2651 }
2652 }
2653
2654 async fn create_job_execution(
2655 &self,
2656 key: &JobInstanceKey,
2657 definition: &crate::DefinitionIdentity,
2658 ) -> Result<(JobInstance, JobExecution, ExecutionAttempt), FlowRuntimeError> {
2659 let mut unit = self.repository.begin().await?;
2660 let instance = unit
2661 .select_or_create_job_instance(key)
2662 .await?
2663 .instance()
2664 .clone();
2665 let execution = unit
2666 .create_job_execution_with_definition(instance.id(), definition)
2667 .await?;
2668 let execution = if let Some((owner, _)) = self.execution_control {
2669 unit.claim_execution_owner(
2670 execution.id(),
2671 execution.version(),
2672 &owner,
2673 self.clock.now(),
2674 )
2675 .await?
2676 } else {
2677 execution
2678 };
2679 let attempt = NonZeroU64::new(
2680 u64::try_from(unit.job_executions(instance.id()).await?.len())
2681 .map_err(|_| FlowRuntimeError::CountExhausted)?,
2682 )
2683 .map(ExecutionAttempt::new)
2684 .ok_or(FlowRuntimeError::CountExhausted)?;
2685 unit.commit().await?;
2686 Ok((instance, execution, attempt))
2687 }
2688
2689 async fn start_job(&self, execution: &JobExecution) -> Result<JobExecution, FlowRuntimeError> {
2690 let mut unit = self.repository.begin().await?;
2691 let started = unit
2692 .transition_job_execution(
2693 execution.id(),
2694 execution.version(),
2695 LifecycleTransition::new(BatchStatus::Started, self.clock.now()),
2696 )
2697 .await?;
2698 unit.commit().await?;
2699 Ok(started)
2700 }
2701
2702 async fn finish_job(
2703 &self,
2704 execution: &JobExecution,
2705 status: BatchStatus,
2706 failure: Option<FailureSummary>,
2707 ) -> Result<JobExecution, FlowRuntimeError> {
2708 let exit = exit_for_status(status);
2709 let mut unit = self.repository.begin().await?;
2710 let execution = if self.execution_control.is_some() {
2711 unit.get_job_execution(execution.id())
2712 .await?
2713 .ok_or(RepositoryError::JobExecutionNotFound { id: execution.id() })?
2714 } else {
2715 execution.clone()
2716 };
2717 let enriched = unit
2718 .enrich_job_exit_status(execution.id(), execution.version(), &exit)
2719 .await?;
2720 let transition = terminal_transition(status, self.clock.now(), failure)?;
2721 let finished = unit
2722 .transition_job_execution(enriched.id(), enriched.version(), transition)
2723 .await?;
2724 unit.commit().await?;
2725 Ok(finished)
2726 }
2727
2728 async fn create_step(
2729 &self,
2730 job_execution_id: JobExecutionId,
2731 step_name: &StepName,
2732 node_id: &NodeId,
2733 limit: StartLimit,
2734 ) -> Result<StepExecution, FlowRuntimeError> {
2735 let mut unit = self.repository.begin().await?;
2736 let step = unit
2737 .create_flow_step_execution(job_execution_id, step_name, node_id, limit)
2738 .await?;
2739 unit.commit().await?;
2740 Ok(step)
2741 }
2742
2743 async fn latest_step(
2744 &self,
2745 instance_id: JobInstanceId,
2746 node_id: &NodeId,
2747 ) -> Result<Option<FlowStepState>, FlowRuntimeError> {
2748 let mut unit = self.repository.begin().await?;
2749 let state = unit.latest_flow_step(instance_id, node_id).await?;
2750 unit.rollback().await?;
2751 Ok(state)
2752 }
2753
2754 async fn reusable_decision(
2755 &self,
2756 instance_id: JobInstanceId,
2757 node_id: &NodeId,
2758 fingerprint: &[u8; 32],
2759 digest: &[u8; 32],
2760 kind: FlowTransitionKind,
2761 ) -> Result<Option<FlowDecision>, FlowRuntimeError> {
2762 let mut unit = self.repository.begin().await?;
2763 let decision = unit
2764 .find_reusable_flow_decision(instance_id, node_id, fingerprint, digest, kind)
2765 .await?;
2766 unit.rollback().await?;
2767 Ok(decision)
2768 }
2769
2770 async fn append_decision(
2771 &self,
2772 request: &FlowDecisionRequest,
2773 ) -> Result<FlowDecision, FlowRuntimeError> {
2774 let mut unit = self.repository.begin().await?;
2775 let decision = unit.append_flow_decision(request).await?;
2776 unit.commit().await?;
2777 Ok(decision)
2778 }
2779
2780 #[allow(clippy::too_many_lines)]
2781 async fn run_step(
2782 &self,
2783 node_id: &NodeId,
2784 step: &TaskletStep,
2785 created: StepExecution,
2786 parameters: &JobParameters,
2787 stop_token: &StopToken,
2788 correlation: &ExecutionCorrelation,
2789 ) -> Result<StepRun, FlowRuntimeError> {
2790 let context = ListenerContext::new(correlation, parameters, stop_token);
2791 for (index, listener) in step.listeners().iter().enumerate() {
2792 if let Err(kind) = invoke_before_step(listener.as_ref(), context).await {
2793 let summary = self.next_failure_summary(FailureCategory::UserComponent)?;
2794 let failure = ListenerFailure::new(ListenerPhase::BeforeStep, index, kind, summary);
2795 let outcome = if kind == ListenerFailureKind::Panic {
2796 TaskletExecutionOutcome::Failed(TaskletFailure::ListenerPanic)
2797 } else {
2798 TaskletExecutionOutcome::Failed(TaskletFailure::ListenerError)
2799 };
2800 let execution = self
2801 .finish_step(
2802 &created,
2803 outcome,
2804 &ExitStatus::failed(),
2805 Some(summary),
2806 false,
2807 )
2808 .await?;
2809 self.emit_flow_event(&FlowEvent::new(
2810 FlowEventKind::StepResultCommitted,
2811 correlation.job_name().clone(),
2812 correlation.job_instance_id(),
2813 correlation.job_execution_id(),
2814 correlation.job_attempt(),
2815 node_id.clone(),
2816 Some(execution.id()),
2817 None,
2818 self.clock.now(),
2819 ));
2820 return Ok(StepRun {
2821 execution,
2822 outcome,
2823 exit_status: ExitStatus::failed(),
2824 failure: Some(summary),
2825 flow_failure: Some(FlowFailure::Listener(match outcome {
2826 TaskletExecutionOutcome::Failed(value) => value,
2827 _ => TaskletFailure::ListenerError,
2828 })),
2829 listener_failures: vec![failure],
2830 });
2831 }
2832 }
2833
2834 let started = self.start_step(&created).await?;
2835 let terminal_rollback = AtomicBool::new(false);
2836 let tasklet_context = TaskletContext::new_for_flow(
2837 parameters,
2838 started.job_execution_id(),
2839 started.id(),
2840 stop_token,
2841 correlation,
2842 &terminal_rollback,
2843 );
2844 let invoked = self
2845 .invoke_with_execution_control(
2846 correlation.job_execution_id(),
2847 step.tasklet(),
2848 tasklet_context,
2849 stop_token,
2850 )
2851 .await?;
2852 let (mut outcome, mut exit, tasklet_failure) = match invoked {
2853 Ok(TaskletOutcome::Completed) if !stop_token.is_stop_requested() => (
2854 TaskletExecutionOutcome::Completed,
2855 ExitStatus::completed(),
2856 None,
2857 ),
2858 Ok(TaskletOutcome::CompletedWith(exit)) if !stop_token.is_stop_requested() => {
2859 (TaskletExecutionOutcome::Completed, exit, None)
2860 }
2861 Ok(
2862 TaskletOutcome::Completed
2863 | TaskletOutcome::CompletedWith(_)
2864 | TaskletOutcome::Stopped,
2865 ) => (
2866 TaskletExecutionOutcome::Stopped(StopTiming::DuringExecution),
2867 ExitStatus::stopped(),
2868 None,
2869 ),
2870 Ok(TaskletOutcome::StoppedAfterBlockingWork) => (
2871 TaskletExecutionOutcome::Stopped(StopTiming::AfterBlockingWork),
2872 ExitStatus::stopped(),
2873 None,
2874 ),
2875 Ok(TaskletOutcome::CommitOutcomeUnknown) => (
2876 TaskletExecutionOutcome::Unknown,
2877 ExitStatus::unknown(),
2878 None,
2879 ),
2880 Err(failure) => (
2881 TaskletExecutionOutcome::Failed(failure),
2882 ExitStatus::failed(),
2883 Some(failure),
2884 ),
2885 };
2886 let tasklet_summary = tasklet_failure
2887 .map(|_| self.next_failure_summary(FailureCategory::UserComponent))
2888 .transpose()?;
2889 let mut failures = Vec::new();
2890 for (index, listener) in step.listeners().iter().enumerate().rev() {
2891 if let Err(kind) = invoke_after_step(listener.as_ref(), context, outcome).await {
2892 let summary = self.next_failure_summary(FailureCategory::UserComponent)?;
2893 failures.push(ListenerFailure::new(
2894 ListenerPhase::AfterStep,
2895 index,
2896 kind,
2897 summary,
2898 ));
2899 }
2900 }
2901 if let Some(first) = failures.first() {
2902 outcome = if first.kind() == ListenerFailureKind::Panic {
2903 TaskletExecutionOutcome::Failed(TaskletFailure::ListenerPanic)
2904 } else {
2905 TaskletExecutionOutcome::Failed(TaskletFailure::ListenerError)
2906 };
2907 exit = ExitStatus::failed();
2908 }
2909 let failure = failures
2910 .first()
2911 .map(|failure| failure.summary())
2912 .or(tasklet_summary);
2913 let durable = self.reload_step(started.id()).await?;
2914 let execution = self
2915 .finish_step(
2916 &durable,
2917 outcome,
2918 &exit,
2919 failure,
2920 terminal_rollback.load(Ordering::Acquire),
2921 )
2922 .await?;
2923 self.emit_flow_event(&FlowEvent::new(
2924 FlowEventKind::StepResultCommitted,
2925 correlation.job_name().clone(),
2926 correlation.job_instance_id(),
2927 correlation.job_execution_id(),
2928 correlation.job_attempt(),
2929 node_id.clone(),
2930 Some(execution.id()),
2931 None,
2932 self.clock.now(),
2933 ));
2934 Ok(StepRun {
2935 execution,
2936 outcome,
2937 exit_status: exit,
2938 failure,
2939 flow_failure: match outcome {
2940 TaskletExecutionOutcome::Failed(value) => Some(
2941 if matches!(
2942 value,
2943 TaskletFailure::ListenerError | TaskletFailure::ListenerPanic
2944 ) {
2945 FlowFailure::Listener(value)
2946 } else {
2947 FlowFailure::Tasklet(value)
2948 },
2949 ),
2950 _ => None,
2951 },
2952 listener_failures: failures,
2953 })
2954 }
2955
2956 async fn start_step(&self, step: &StepExecution) -> Result<StepExecution, FlowRuntimeError> {
2957 let mut unit = self.repository.begin().await?;
2958 let started = unit
2959 .transition_step_execution(
2960 step.id(),
2961 step.version(),
2962 LifecycleTransition::new(BatchStatus::Started, self.clock.now()),
2963 )
2964 .await?;
2965 unit.commit().await?;
2966 Ok(started)
2967 }
2968
2969 async fn reload_step(&self, id: StepExecutionId) -> Result<StepExecution, FlowRuntimeError> {
2970 let mut unit = self.repository.begin().await?;
2971 let step = unit
2972 .get_step_execution(id)
2973 .await?
2974 .ok_or(RepositoryError::StepExecutionNotFound { id })?;
2975 unit.rollback().await?;
2976 Ok(step)
2977 }
2978
2979 async fn finish_step(
2980 &self,
2981 step: &StepExecution,
2982 outcome: TaskletExecutionOutcome,
2983 exit: &ExitStatus,
2984 failure: Option<FailureSummary>,
2985 terminal_rollback: bool,
2986 ) -> Result<StepExecution, FlowRuntimeError> {
2987 let status = status_for_tasklet(outcome);
2988 let mut unit = self.repository.begin().await?;
2989 let enriched = unit
2990 .enrich_step_exit_status(step.id(), step.version(), exit)
2991 .await?;
2992 let mut transition = terminal_transition(status, self.clock.now(), failure)?;
2993 if terminal_rollback {
2994 transition = transition.with_terminal_rollback();
2995 }
2996 let finished = unit
2997 .transition_step_execution(enriched.id(), enriched.version(), transition)
2998 .await?;
2999 match unit.commit().await {
3000 Ok(()) => Ok(finished),
3001 Err(RepositoryError::CommitOutcomeUnknown) => {
3002 let durable = self.reload_step(finished.id()).await?;
3003 if durable == finished {
3004 Ok(durable)
3005 } else {
3006 Err(RepositoryError::CommitOutcomeUnknown.into())
3007 }
3008 }
3009 Err(error) => Err(error.into()),
3010 }
3011 }
3012
3013 fn next_failure_summary(
3014 &self,
3015 category: FailureCategory,
3016 ) -> Result<FailureSummary, FlowRuntimeError> {
3017 Ok(FailureSummary::new(
3018 category,
3019 self.ids
3020 .next_failure_id()
3021 .map_err(RepositoryError::Identifier)?,
3022 ))
3023 }
3024
3025 fn emit_flow_event(&self, event: &FlowEvent) {
3026 let Some(sink) = self.event_sink else {
3027 return;
3028 };
3029 let _ = catch_unwind(AssertUnwindSafe(|| sink.emit(event)));
3030 }
3031}
3032
3033fn partition_worker_identity(
3034 compiled: &crate::PartitionedStepNode,
3035 partition: &StepPartition,
3036) -> Result<(StepName, NodeId), FlowRuntimeError> {
3037 let mut digest = Sha256::new();
3038 digest.update(b"oxide-batch.local-partition-worker.v1\0");
3039 digest.update(compiled.id().as_str().as_bytes());
3040 digest.update([0]);
3041 digest.update(partition.key().as_str().as_bytes());
3042 let token = format!(
3043 "__ob_partition_worker_{}",
3044 oxide_batch_repository::hex_digest(&digest.finalize())
3045 );
3046 let step_name =
3047 StepName::new(token.clone()).map_err(|_| RepositoryError::PartitionStateCorrupt)?;
3048 let node_id = NodeId::new(token).map_err(|_| RepositoryError::PartitionStateCorrupt)?;
3049 Ok((step_name, node_id))
3050}
3051
3052struct StepRun {
3053 execution: StepExecution,
3054 outcome: TaskletExecutionOutcome,
3055 exit_status: ExitStatus,
3056 failure: Option<FailureSummary>,
3057 flow_failure: Option<FlowFailure>,
3058 listener_failures: Vec<ListenerFailure>,
3059}
3060
3061struct SplitBranchRun {
3062 index: usize,
3063 status: BatchStatus,
3064 exit_status: ExitStatus,
3065 failure: Option<FailureSummary>,
3066 flow_failure: Option<FlowFailure>,
3067 states: Vec<FlowStepState>,
3068 step_executions: Vec<StepExecution>,
3069 listener_failures: Vec<ListenerFailure>,
3070}
3071
3072struct SplitRun {
3073 status: BatchStatus,
3074 exit_status: ExitStatus,
3075 failure: Option<FailureSummary>,
3076 flow_failure: Option<FlowFailure>,
3077 input_digest: [u8; 32],
3078 step_executions: Vec<StepExecution>,
3079 listener_failures: Vec<ListenerFailure>,
3080}
3081
3082struct PartitionWorkerRun {
3083 partition: StepPartition,
3084 execution: StepExecution,
3085 flow_failure: Option<FlowFailure>,
3086 listener_failures: Vec<ListenerFailure>,
3087}
3088
3089struct PartitionRun {
3090 status: BatchStatus,
3091 exit_status: ExitStatus,
3092 parent: StepExecution,
3093 flow_failure: Option<FlowFailure>,
3094 worker_executions: Vec<StepExecution>,
3095 listener_failures: Vec<ListenerFailure>,
3096}
3097
3098const fn split_status_severity(status: BatchStatus) -> u8 {
3099 match status {
3100 BatchStatus::Completed => 0,
3101 BatchStatus::Stopped => 1,
3102 BatchStatus::Failed => 2,
3103 BatchStatus::Unknown => 3,
3104 _ => 4,
3109 }
3110}
3111
3112fn split_input_digest(
3113 fingerprint: &[u8; 32],
3114 join: &NodeId,
3115 branches: &[SplitBranchRun],
3116) -> [u8; 32] {
3117 let mut hash = Sha256::new();
3118 hash.update(b"oxide-batch.split-aggregate-input.v1\0");
3119 hash.update(fingerprint);
3120 hash_field(&mut hash, join.as_str().as_bytes());
3121 for branch in branches {
3122 hash.update(
3123 u64::try_from(branch.index)
3124 .unwrap_or(u64::MAX)
3125 .to_be_bytes(),
3126 );
3127 hash_field(&mut hash, branch.status.to_string().as_bytes());
3128 hash_field(&mut hash, branch.exit_status.code().as_str().as_bytes());
3129 for state in &branch.states {
3130 hash.update(step_input_digest(fingerprint, state));
3131 }
3132 }
3133 hash.finalize().into()
3134}
3135
3136fn next_sequence(length: usize) -> Result<FlowDecisionSequence, FlowRuntimeError> {
3137 let next = u64::try_from(length)
3138 .ok()
3139 .and_then(|value| value.checked_add(1))
3140 .ok_or(FlowRuntimeError::DecisionSequenceExhausted)?;
3141 FlowDecisionSequence::new(next).map_err(|_| FlowRuntimeError::DecisionSequenceExhausted)
3145}
3146
3147fn correlation(
3148 job_name: &JobName,
3149 instance_id: JobInstanceId,
3150 job_execution_id: JobExecutionId,
3151 attempt: ExecutionAttempt,
3152 step_name: &StepName,
3153 step_execution_id: StepExecutionId,
3154 completed_steps: usize,
3155) -> Result<ExecutionCorrelation, FlowRuntimeError> {
3156 let step_attempt = u64::try_from(completed_steps)
3157 .ok()
3158 .and_then(|value| value.checked_add(1))
3159 .and_then(NonZeroU64::new)
3160 .map(ExecutionAttempt::new)
3161 .ok_or(FlowRuntimeError::CountExhausted)?;
3162 Ok(ExecutionCorrelation::new(
3163 job_name.clone(),
3164 instance_id,
3165 job_execution_id,
3166 attempt,
3167 step_name.clone(),
3168 step_execution_id,
3169 step_attempt,
3170 ))
3171}
3172
3173fn status_for_tasklet(outcome: TaskletExecutionOutcome) -> BatchStatus {
3174 match outcome {
3175 TaskletExecutionOutcome::Completed => BatchStatus::Completed,
3176 TaskletExecutionOutcome::Stopped(_) => BatchStatus::Stopped,
3177 TaskletExecutionOutcome::Failed(_) => BatchStatus::Failed,
3178 TaskletExecutionOutcome::Unknown => BatchStatus::Unknown,
3179 }
3180}
3181
3182fn exit_for_status(status: BatchStatus) -> ExitStatus {
3183 match status {
3184 BatchStatus::Completed => ExitStatus::completed(),
3185 BatchStatus::Stopped => ExitStatus::stopped(),
3186 BatchStatus::Unknown => ExitStatus::unknown(),
3187 _ => ExitStatus::failed(),
3188 }
3189}
3190
3191fn terminal_transition(
3192 status: BatchStatus,
3193 at: SystemTime,
3194 failure: Option<FailureSummary>,
3195) -> Result<LifecycleTransition, FlowRuntimeError> {
3196 if status == BatchStatus::Failed {
3197 let failure = failure.ok_or(FlowRuntimeError::CountExhausted)?;
3198 Ok(LifecycleTransition::failed(at, failure))
3199 } else {
3200 Ok(LifecycleTransition::new(status, at))
3201 }
3202}
3203
3204fn step_input_digest(fingerprint: &[u8; 32], state: &FlowStepState) -> [u8; 32] {
3205 let mut hash = Sha256::new();
3206 hash.update(b"oxide-batch.flow-step-input.v1\0");
3207 hash.update(fingerprint);
3208 hash_field(&mut hash, state.node_id().as_str().as_bytes());
3209 hash.update(state.execution().id().get().to_be_bytes());
3210 hash.update(state.execution().version().get().to_be_bytes());
3211 hash_field(
3212 &mut hash,
3213 state.execution().metadata().status().to_string().as_bytes(),
3214 );
3215 hash_field(
3216 &mut hash,
3217 state
3218 .execution()
3219 .metadata()
3220 .exit_status()
3221 .code()
3222 .as_str()
3223 .as_bytes(),
3224 );
3225 hash_counts(&mut hash, state.execution().metadata().counts());
3226 if let Some(context) = state.context()
3227 && let Ok(bytes) = context.to_json()
3228 {
3229 hash.update(Sha256::digest(bytes));
3230 }
3231 hash.finalize().into()
3232}
3233
3234fn decision_input_digest(
3235 fingerprint: &[u8; 32],
3236 node_id: &NodeId,
3237 revision: &str,
3238 input_version: u32,
3239 instance_id: JobInstanceId,
3240 parameters: &JobParameters,
3241 preceding: Option<&FlowStepState>,
3242) -> [u8; 32] {
3243 let mut hash = Sha256::new();
3244 hash.update(b"oxide-batch.decider-input.v1\0");
3245 hash.update(fingerprint);
3246 hash_field(&mut hash, node_id.as_str().as_bytes());
3247 hash_field(&mut hash, revision.as_bytes());
3248 hash.update(input_version.to_be_bytes());
3249 hash.update(instance_id.get().to_be_bytes());
3250 hash.update(parameters.flow_input_digest());
3253 if let Some(state) = preceding {
3254 hash.update(step_input_digest(fingerprint, state));
3255 }
3256 hash.finalize().into()
3257}
3258
3259fn hash_field(hash: &mut Sha256, value: &[u8]) {
3260 let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
3261 hash.update(length.to_be_bytes());
3262 hash.update(value);
3263}
3264
3265fn hash_counts(hash: &mut Sha256, counts: ExecutionCounts) {
3266 for count in [
3267 counts.read(),
3268 counts.processed(),
3269 counts.written(),
3270 counts.filtered(),
3271 counts.committed(),
3272 counts.rolled_back(),
3273 ] {
3274 hash.update(count.to_be_bytes());
3275 }
3276}