Skip to main content

oxide_batch/
runtime.rs

1//! Async tasklet execution and cooperative stopping.
2
3use std::error::Error;
4use std::fmt;
5use std::num::{NonZeroU64, NonZeroUsize};
6use std::panic::{AssertUnwindSafe, catch_unwind};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10
11use futures_util::FutureExt;
12use tokio::sync::{Notify, Semaphore};
13
14use crate::{
15    BatchStatus, BoxFuture, Clock, CompiledExecutionPlan, ComponentRevision, DefinitionError,
16    DefinitionIdentity, DefinitionRevision, ExecutionAttempt, ExecutionCorrelation, ExitStatus,
17    FailureCategory, FailureSummary, FlowSelectionError, FlowTarget, IdGenerator, JobExecution,
18    JobExecutionId, JobExecutionListener, JobInstance, JobInstanceKey, JobName, JobParameters,
19    JobRepository, LifecycleEvent, LifecycleEventKind, LifecycleEventSink, LifecycleTransition,
20    ListenerContext, ListenerFailure, ListenerFailureKind, ListenerPhase, NodeId, RepositoryError,
21    StepComponents, StepExecution, StepExecutionId, StepExecutionListener, StepName, StepNode,
22    TerminalKind,
23};
24
25/// A dynamically dispatched, single-invocation asynchronous step body.
26///
27/// Implementations may borrow both themselves and the call-scoped
28/// [`TaskletContext`] for the entire returned future. They must observe the
29/// supplied [`StopToken`] when performing cancellable or repeated work.
30///
31/// ```
32/// use oxide_batch::{
33///     BoxFuture, Tasklet, TaskletContext, TaskletError, TaskletOutcome,
34/// };
35///
36/// struct ImportTasklet;
37///
38/// impl Tasklet for ImportTasklet {
39///     fn execute<'a>(
40///         &'a self,
41///         context: TaskletContext<'a>,
42///     ) -> BoxFuture<'a, Result<TaskletOutcome, TaskletError>> {
43///         Box::pin(async move {
44///             if context.stop_token().is_stop_requested() {
45///                 return Ok(TaskletOutcome::Stopped);
46///             }
47///             let _parameters = context.parameters();
48///             Ok(TaskletOutcome::Completed)
49///         })
50///     }
51/// }
52/// ```
53pub trait Tasklet: Send + Sync {
54    /// Executes the step body once.
55    fn execute<'a>(
56        &'a self,
57        context: TaskletContext<'a>,
58    ) -> BoxFuture<'a, Result<TaskletOutcome, TaskletError>>;
59}
60
61/// A synchronous tasklet isolated by [`BlockingTaskletAdapter`].
62///
63/// Once this method starts it runs to completion even when stop is requested.
64/// The adapter reports that request as a late stop after the method returns.
65pub trait BlockingTasklet: Send + Sync + 'static {
66    /// Executes owned call context on a blocking worker.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`TaskletError`] when user work cannot complete.
71    fn execute(&self, context: BlockingTaskletContext) -> Result<TaskletOutcome, TaskletError>;
72}
73
74/// A validated one-step tasklet definition.
75pub struct TaskletStep {
76    name: StepName,
77    tasklet: Arc<dyn Tasklet>,
78    listeners: Vec<Arc<dyn StepExecutionListener>>,
79}
80
81impl TaskletStep {
82    /// Constructs a step from its validated name and async body.
83    #[must_use]
84    pub fn new(name: StepName, tasklet: Arc<dyn Tasklet>) -> Self {
85        Self {
86            name,
87            tasklet,
88            listeners: Vec::new(),
89        }
90    }
91
92    /// Registers a step listener in deterministic before-order.
93    #[must_use]
94    pub fn with_listener(mut self, listener: Arc<dyn StepExecutionListener>) -> Self {
95        self.listeners.push(listener);
96        self
97    }
98
99    /// Borrows the step name.
100    #[must_use]
101    pub const fn name(&self) -> &StepName {
102        &self.name
103    }
104
105    pub(crate) fn tasklet(&self) -> &dyn Tasklet {
106        self.tasklet.as_ref()
107    }
108
109    pub(crate) fn listeners(&self) -> &[Arc<dyn StepExecutionListener>] {
110        &self.listeners
111    }
112}
113
114impl fmt::Debug for TaskletStep {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter
117            .debug_struct("TaskletStep")
118            .field("name", &self.name)
119            .field("listener_count", &self.listeners.len())
120            .finish_non_exhaustive()
121    }
122}
123
124/// A validated single-step job definition.
125pub struct TaskletJob {
126    name: JobName,
127    step: TaskletStep,
128    plan: CompiledExecutionPlan,
129    listeners: Vec<Arc<dyn JobExecutionListener>>,
130}
131
132impl fmt::Debug for TaskletJob {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        formatter
135            .debug_struct("TaskletJob")
136            .field("name", &self.name)
137            .field("step", &self.step)
138            .field("definition", self.plan.definition_identity())
139            .field("listener_count", &self.listeners.len())
140            .finish()
141    }
142}
143
144impl TaskletJob {
145    /// Constructs a tasklet job with explicit restart-relevant revisions.
146    ///
147    /// Applications that may durably restart a job should use this constructor
148    /// and change the component revision whenever the tasklet's durable
149    /// behavior changes.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`DefinitionError`] if the bounded canonical manifest cannot be
154    /// encoded.
155    pub fn new(
156        name: JobName,
157        step: TaskletStep,
158        revision: DefinitionRevision,
159        component_revision: &ComponentRevision,
160    ) -> Result<Self, DefinitionError> {
161        let definition =
162            DefinitionIdentity::tasklet(&name, step.name(), revision, component_revision)?;
163        let plan = lower_one_step(
164            definition,
165            one_step_node(
166                step.name(),
167                StepComponents::Tasklet(component_revision.clone()),
168            )?,
169        )?;
170        Ok(Self {
171            name,
172            step,
173            plan,
174            listeners: Vec::new(),
175        })
176    }
177
178    pub(crate) fn from_lowered_plan(
179        name: JobName,
180        step: TaskletStep,
181        plan: CompiledExecutionPlan,
182    ) -> Self {
183        Self {
184            name,
185            step,
186            plan,
187            listeners: Vec::new(),
188        }
189    }
190
191    /// Registers a job listener in deterministic before-order.
192    #[must_use]
193    pub fn with_listener(mut self, listener: Arc<dyn JobExecutionListener>) -> Self {
194        self.listeners.push(listener);
195        self
196    }
197
198    /// Borrows the job name.
199    #[must_use]
200    pub const fn name(&self) -> &JobName {
201        &self.name
202    }
203
204    /// Borrows the sole step.
205    #[must_use]
206    pub const fn step(&self) -> &TaskletStep {
207        &self.step
208    }
209
210    /// Borrows the exact restart-relevant definition identity.
211    #[must_use]
212    pub const fn definition_identity(&self) -> &DefinitionIdentity {
213        self.plan.definition_identity()
214    }
215
216    /// Borrows the in-memory compatibility plan this wrapper lowers into.
217    ///
218    /// The plan retains the wrapper's original manifest bytes, format, and
219    /// fingerprint. Its synthetic graph routes the framework's own exit codes
220    /// to terminals and records no durable flow decision.
221    #[must_use]
222    pub const fn compiled_plan(&self) -> &CompiledExecutionPlan {
223        &self.plan
224    }
225}
226
227/// Derives the synthetic step node of a one-step compatibility plan.
228///
229/// The node identifier is the validated step name, which already satisfies the
230/// logical-identifier rules, so lowering never invents an identity.
231pub(crate) fn one_step_node(
232    step_name: &StepName,
233    components: StepComponents,
234) -> Result<StepNode, DefinitionError> {
235    Ok(StepNode::new(
236        NodeId::new(step_name.as_str())?,
237        step_name.clone(),
238        components,
239    ))
240}
241
242/// Lowers one validated wrapper step into its compatibility plan.
243///
244/// The framework derives every value from inputs it has already validated, so
245/// a rejected graph reports [`DefinitionError::CompatibilityLowering`] rather
246/// than an application mistake.
247pub(crate) fn lower_one_step(
248    definition: DefinitionIdentity,
249    node: StepNode,
250) -> Result<CompiledExecutionPlan, DefinitionError> {
251    CompiledExecutionPlan::compatibility_one_step(definition, node)
252        .map_err(|_| DefinitionError::CompatibilityLowering)
253}
254
255/// Borrowed execution data supplied to an asynchronous tasklet.
256///
257/// Its references are call-scoped and cannot be retained as static framework
258/// or application state:
259///
260/// ```compile_fail
261/// use oxide_batch::{JobParameters, TaskletContext};
262///
263/// fn escape(context: TaskletContext<'_>) -> &'static JobParameters {
264///     context.parameters()
265/// }
266/// ```
267#[derive(Clone, Copy)]
268pub struct TaskletContext<'a> {
269    parameters: &'a JobParameters,
270    job_execution_id: JobExecutionId,
271    step_execution_id: StepExecutionId,
272    stop: &'a StopToken,
273    correlation: &'a ExecutionCorrelation,
274    event_sink: Option<&'a dyn LifecycleEventSink>,
275    terminal_rollback: &'a AtomicBool,
276}
277
278impl<'a> TaskletContext<'a> {
279    pub(crate) const fn new_for_flow(
280        parameters: &'a JobParameters,
281        job_execution_id: JobExecutionId,
282        step_execution_id: StepExecutionId,
283        stop: &'a StopToken,
284        correlation: &'a ExecutionCorrelation,
285        terminal_rollback: &'a AtomicBool,
286    ) -> Self {
287        Self {
288            parameters,
289            job_execution_id,
290            step_execution_id,
291            stop,
292            correlation,
293            event_sink: None,
294            terminal_rollback,
295        }
296    }
297
298    /// Borrows the launch parameters.
299    #[must_use]
300    pub const fn parameters(&self) -> &'a JobParameters {
301        self.parameters
302    }
303
304    /// Returns the enclosing job-attempt identifier.
305    #[must_use]
306    pub const fn job_execution_id(self) -> JobExecutionId {
307        self.job_execution_id
308    }
309
310    /// Returns this step-attempt identifier.
311    #[must_use]
312    pub const fn step_execution_id(self) -> StepExecutionId {
313        self.step_execution_id
314    }
315
316    /// Borrows the cooperative stop token.
317    #[must_use]
318    pub const fn stop_token(&self) -> &'a StopToken {
319        self.stop
320    }
321
322    /// Borrows the validated execution correlation.
323    #[must_use]
324    pub const fn correlation(&self) -> &'a ExecutionCorrelation {
325        self.correlation
326    }
327
328    pub(crate) fn emit_chunk_event(&self, kind: LifecycleEventKind, sequence: crate::ChunkCount) {
329        let Some(sink) = self.event_sink else {
330            return;
331        };
332        let event = LifecycleEvent::chunk(kind, self.correlation.clone(), sequence);
333        let _ = catch_unwind(AssertUnwindSafe(|| sink.emit(&event)));
334    }
335
336    pub(crate) fn emit_fault_event(&self, fault: &crate::chunk_runtime::FaultRuntimeEvent) {
337        let Some(sink) = self.event_sink else {
338            return;
339        };
340        let mut event = LifecycleEvent::fault(
341            fault.kind,
342            self.correlation.clone(),
343            fault.sequence,
344            fault.phase,
345        );
346        if let Some(summary) = fault.summary {
347            event = event.with_failure(summary);
348        }
349        if let Some(ordinal) = fault.ordinal {
350            event = event.with_retry_ordinal(ordinal);
351        }
352        if let Some(backoff) = fault.backoff {
353            event = event.with_backoff(backoff);
354        }
355        let _ = catch_unwind(AssertUnwindSafe(|| sink.emit(&event)));
356    }
357
358    pub(crate) fn acknowledge_terminal_rollback(&self) {
359        self.terminal_rollback.store(true, Ordering::Release);
360    }
361
362    fn into_blocking(self) -> BlockingTaskletContext {
363        BlockingTaskletContext {
364            parameters: self.parameters.clone(),
365            job_execution_id: self.job_execution_id,
366            step_execution_id: self.step_execution_id,
367            stop: self.stop.clone(),
368        }
369    }
370}
371
372impl fmt::Debug for TaskletContext<'_> {
373    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
374        formatter
375            .debug_struct("TaskletContext")
376            .field("job_execution_id", &self.job_execution_id)
377            .field("step_execution_id", &self.step_execution_id)
378            .field("stop_requested", &self.stop.is_stop_requested())
379            .field("correlation", &self.correlation)
380            .field("event_sink", &self.event_sink.map(|_| "<attached>"))
381            .field(
382                "terminal_rollback",
383                &self.terminal_rollback.load(Ordering::Acquire),
384            )
385            .finish_non_exhaustive()
386    }
387}
388
389/// Owned execution data supplied to an isolated blocking tasklet.
390#[derive(Clone, Debug)]
391pub struct BlockingTaskletContext {
392    parameters: JobParameters,
393    job_execution_id: JobExecutionId,
394    step_execution_id: StepExecutionId,
395    stop: StopToken,
396}
397
398impl BlockingTaskletContext {
399    /// Borrows the launch parameters.
400    #[must_use]
401    pub const fn parameters(&self) -> &JobParameters {
402        &self.parameters
403    }
404
405    /// Returns the enclosing job-attempt identifier.
406    #[must_use]
407    pub const fn job_execution_id(&self) -> JobExecutionId {
408        self.job_execution_id
409    }
410
411    /// Returns this step-attempt identifier.
412    #[must_use]
413    pub const fn step_execution_id(&self) -> StepExecutionId {
414        self.step_execution_id
415    }
416
417    /// Borrows the cooperative stop token.
418    ///
419    /// Blocking code cannot be interrupted after it starts. This token is
420    /// useful only for application-specific polling inside the synchronous
421    /// body; the adapter still awaits the body before returning.
422    #[must_use]
423    pub const fn stop_token(&self) -> &StopToken {
424        &self.stop
425    }
426}
427
428/// The user-controlled result of one tasklet invocation.
429#[derive(Clone, Debug, Eq, PartialEq)]
430#[non_exhaustive]
431pub enum TaskletOutcome {
432    /// User work finished successfully.
433    Completed,
434    /// User work completed with a custom bounded flow-facing exit status.
435    ///
436    /// The lifecycle status remains `COMPLETED`; only transition selection and
437    /// the persisted exit status observe this value.
438    CompletedWith(ExitStatus),
439    /// User work observed a cooperative stop.
440    Stopped,
441    /// A blocking adapter completed already-running synchronous work and then
442    /// observed stop.
443    ///
444    /// Application tasklets should return [`Self::Stopped`]. This variant is
445    /// emitted by [`BlockingTaskletAdapter`] to preserve the late-stop
446    /// limitation in the launch report.
447    StoppedAfterBlockingWork,
448    /// An adapter-owned commit returned without a knowable durable outcome.
449    ///
450    /// Application tasklets should not return this variant. It exists so
451    /// framework adapters can persist `UNKNOWN` without guessing.
452    CommitOutcomeUnknown,
453}
454
455/// A value-redacted typed user-component failure.
456#[derive(Clone, Copy, Debug, Eq, PartialEq)]
457pub struct TaskletError {
458    kind: TaskletErrorKind,
459}
460
461#[derive(Clone, Copy, Debug, Eq, PartialEq)]
462enum TaskletErrorKind {
463    Component,
464    Panic,
465}
466
467impl TaskletError {
468    /// Constructs a classified tasklet failure.
469    #[must_use]
470    pub const fn new() -> Self {
471        Self {
472            kind: TaskletErrorKind::Component,
473        }
474    }
475
476    /// Classifies an arbitrary user error without retaining its payload.
477    #[must_use]
478    pub fn from_error(error: impl Error + Send + Sync + 'static) -> Self {
479        drop(error);
480        Self::new()
481    }
482
483    const fn panic() -> Self {
484        Self {
485            kind: TaskletErrorKind::Panic,
486        }
487    }
488}
489
490impl Default for TaskletError {
491    fn default() -> Self {
492        Self::new()
493    }
494}
495
496impl fmt::Display for TaskletError {
497    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
498        formatter.write_str("the tasklet failed")
499    }
500}
501
502impl Error for TaskletError {}
503
504#[derive(Debug)]
505struct StopState {
506    requested: AtomicBool,
507    notify: Notify,
508}
509
510/// The owner used by application code or an operator adapter to request stop.
511#[derive(Clone, Debug)]
512pub struct StopSource {
513    state: Arc<StopState>,
514}
515
516/// A cloneable cooperative stop token passed to user work.
517#[derive(Clone, Debug)]
518pub struct StopToken {
519    state: Arc<StopState>,
520}
521
522impl StopSource {
523    /// Creates a stop source and its corresponding tasklet token.
524    #[must_use]
525    pub fn new() -> (Self, StopToken) {
526        let state = Arc::new(StopState {
527            requested: AtomicBool::new(false),
528            notify: Notify::new(),
529        });
530        (
531            Self {
532                state: Arc::clone(&state),
533            },
534            StopToken { state },
535        )
536    }
537
538    /// Requests a cooperative stop and wakes current waiters.
539    pub fn request_stop(&self) {
540        self.state.requested.store(true, Ordering::Release);
541        self.state.notify.notify_waiters();
542    }
543}
544
545impl StopToken {
546    /// Returns whether stop has been requested.
547    #[must_use]
548    pub fn is_stop_requested(&self) -> bool {
549        self.state.requested.load(Ordering::Acquire)
550    }
551
552    /// Waits for a stop request without exposing an executor-specific type.
553    pub async fn cancelled(&self) {
554        loop {
555            let notified = self.state.notify.notified();
556            if self.is_stop_requested() {
557                return;
558            }
559            notified.await;
560        }
561    }
562
563    pub(crate) fn request_stop(&self) {
564        self.state.requested.store(true, Ordering::Release);
565        self.state.notify.notify_waiters();
566    }
567}
568
569/// The maximum interval between durable operator-stop observations.
570#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
571pub struct StopPollInterval(Duration);
572
573impl StopPollInterval {
574    /// The accepted default of one second.
575    pub const DEFAULT: Self = Self(Duration::from_secs(1));
576
577    /// Validates `100 ms..=60 s`.
578    ///
579    /// # Errors
580    ///
581    /// Returns [`LaunchError::InvalidStopPollInterval`] outside the bound.
582    pub fn new(value: Duration) -> Result<Self, LaunchError> {
583        if !(Duration::from_millis(100)..=Duration::from_mins(1)).contains(&value) {
584            return Err(LaunchError::InvalidStopPollInterval);
585        }
586        Ok(Self(value))
587    }
588
589    /// Returns the validated duration.
590    #[must_use]
591    pub const fn get(self) -> Duration {
592        self.0
593    }
594}
595
596impl Default for StopPollInterval {
597    fn default() -> Self {
598        Self::DEFAULT
599    }
600}
601
602/// Classifies when a cooperative stop was observed.
603#[derive(Clone, Copy, Debug, Eq, PartialEq)]
604#[non_exhaustive]
605pub enum StopTiming {
606    /// Stop was requested before user work started.
607    BeforeStart,
608    /// An asynchronous tasklet observed stop while it was running.
609    DuringExecution,
610    /// Stop arrived after synchronous work started and was reported after it
611    /// completed.
612    AfterBlockingWork,
613}
614
615/// Classifies a tasklet failure without exposing an error or panic payload.
616#[derive(Clone, Copy, Debug, Eq, PartialEq)]
617#[non_exhaustive]
618pub enum TaskletFailure {
619    /// The tasklet returned [`TaskletError`].
620    Error,
621    /// The tasklet panicked before or while its future was polled.
622    Panic,
623    /// A listener returned a classified error.
624    ListenerError,
625    /// A listener panicked at its framework boundary.
626    ListenerPanic,
627}
628
629/// The stable execution result captured by a launch.
630#[derive(Clone, Copy, Debug, Eq, PartialEq)]
631#[non_exhaustive]
632pub enum TaskletExecutionOutcome {
633    /// The job and step completed.
634    Completed,
635    /// The job and step failed at the tasklet boundary.
636    Failed(TaskletFailure),
637    /// The job and step stopped cooperatively.
638    Stopped(StopTiming),
639    /// A resource commit may or may not have reached durable storage.
640    Unknown,
641}
642
643/// Final persisted execution snapshots returned by [`JobLauncher`].
644#[derive(Clone, Debug, Eq, PartialEq)]
645pub struct LaunchReport {
646    instance: JobInstance,
647    job_execution: JobExecution,
648    step_execution: StepExecution,
649    outcome: TaskletExecutionOutcome,
650    original_outcome: Option<TaskletExecutionOutcome>,
651    original_failure: Option<FailureSummary>,
652    listener_failures: Vec<ListenerFailure>,
653}
654
655impl LaunchReport {
656    /// Borrows the selected logical job instance.
657    #[must_use]
658    pub const fn instance(&self) -> &JobInstance {
659        &self.instance
660    }
661
662    /// Borrows the final job-execution snapshot.
663    #[must_use]
664    pub const fn job_execution(&self) -> &JobExecution {
665        &self.job_execution
666    }
667
668    /// Borrows the final step-execution snapshot.
669    #[must_use]
670    pub const fn step_execution(&self) -> &StepExecution {
671        &self.step_execution
672    }
673
674    /// Returns the classified user-work outcome.
675    #[must_use]
676    pub const fn outcome(&self) -> TaskletExecutionOutcome {
677        self.outcome
678    }
679
680    /// Returns the provisional tasklet or nested-listener outcome retained
681    /// when an after-listener changed the enclosing result.
682    #[must_use]
683    pub const fn original_outcome(&self) -> Option<TaskletExecutionOutcome> {
684        self.original_outcome
685    }
686
687    /// Returns the original redacted tasklet failure retained when a listener
688    /// changed the final outcome.
689    #[must_use]
690    pub const fn original_failure(&self) -> Option<FailureSummary> {
691        self.original_failure
692    }
693
694    /// Borrows listener failures in callback execution order.
695    #[must_use]
696    pub fn listener_failures(&self) -> &[ListenerFailure] {
697        &self.listener_failures
698    }
699}
700
701/// A launch failure that prevented a final execution report.
702#[derive(Clone, Debug, Eq, PartialEq)]
703#[non_exhaustive]
704pub enum LaunchError {
705    /// Metadata creation, transition, or commit failed.
706    Repository(RepositoryError),
707    /// The compiled plan could not route a produced exit outcome.
708    Flow(FlowSelectionError),
709    /// The launcher was given a plan it cannot execute.
710    ///
711    /// This launcher executes the one-step compatibility plan that
712    /// [`TaskletJob`] and [`ChunkJob`](crate::ChunkJob) lower into. Multi-node
713    /// graphs are executed by the durable-flow runtime.
714    UnsupportedPlan,
715    /// The durable stop-poll interval was outside `100 ms..=60 s`.
716    InvalidStopPollInterval,
717    /// Process shutdown stopped intake before this launch was accepted.
718    ShuttingDown,
719}
720
721impl fmt::Display for LaunchError {
722    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
723        match self {
724            Self::Repository(error) => {
725                write!(formatter, "job repository operation failed: {error}")
726            }
727            Self::Flow(error) => write!(formatter, "compiled plan could not route: {error}"),
728            Self::UnsupportedPlan => {
729                formatter.write_str("this launcher executes one-step compatibility plans only")
730            }
731            Self::InvalidStopPollInterval => {
732                formatter.write_str("stop poll interval must be between 100 ms and 60 seconds")
733            }
734            Self::ShuttingDown => formatter.write_str("runtime intake is shutting down"),
735        }
736    }
737}
738
739impl Error for LaunchError {
740    fn source(&self) -> Option<&(dyn Error + 'static)> {
741        match self {
742            Self::Repository(error) => Some(error),
743            Self::Flow(error) => Some(error),
744            Self::UnsupportedPlan | Self::InvalidStopPollInterval | Self::ShuttingDown => None,
745        }
746    }
747}
748
749impl From<RepositoryError> for LaunchError {
750    fn from(error: RepositoryError) -> Self {
751        Self::Repository(error)
752    }
753}
754
755impl From<FlowSelectionError> for LaunchError {
756    fn from(error: FlowSelectionError) -> Self {
757        Self::Flow(error)
758    }
759}
760
761/// Async-first launcher for one-step tasklet jobs.
762///
763/// The launcher borrows its repository, clock, and identifier source and never
764/// creates or owns an async runtime.
765pub struct JobLauncher<'a> {
766    repository: &'a dyn JobRepository,
767    clock: &'a dyn Clock,
768    ids: &'a dyn IdGenerator,
769    event_sink: Option<&'a dyn LifecycleEventSink>,
770    execution_control: Option<(crate::OwnerToken, StopPollInterval)>,
771    shutdown_signal: Option<&'a crate::ShutdownSignal>,
772}
773
774impl<'a> JobLauncher<'a> {
775    /// Constructs a launcher from explicitly owned infrastructure ports.
776    #[must_use]
777    pub const fn new(
778        repository: &'a dyn JobRepository,
779        clock: &'a dyn Clock,
780        ids: &'a dyn IdGenerator,
781    ) -> Self {
782        Self {
783            repository,
784            clock,
785            ids,
786            event_sink: None,
787            execution_control: None,
788            shutdown_signal: None,
789        }
790    }
791
792    /// Attaches a non-authoritative lifecycle-event sink.
793    #[must_use]
794    pub const fn with_event_sink(mut self, event_sink: &'a dyn LifecycleEventSink) -> Self {
795        self.event_sink = Some(event_sink);
796        self
797    }
798
799    /// Enables durable ownership evidence and operator-stop polling.
800    #[must_use]
801    pub const fn with_execution_control(
802        mut self,
803        owner: crate::OwnerToken,
804        interval: StopPollInterval,
805    ) -> Self {
806        self.execution_control = Some((owner, interval));
807        self
808    }
809
810    /// Attaches the application-owned process-shutdown intake and cancellation signal.
811    #[must_use]
812    pub const fn with_shutdown_signal(mut self, signal: &'a crate::ShutdownSignal) -> Self {
813        self.shutdown_signal = Some(signal);
814        self
815    }
816
817    /// Creates and executes one launch or restart attempt.
818    ///
819    /// Creation is committed before the `STARTED` transition, and `STARTED` is
820    /// committed before user work. A final status is committed after the
821    /// tasklet boundary returns. Dropping this future can therefore leave an
822    /// accepted execution in `STARTING` or `STARTED`; recovery must inspect the
823    /// repository rather than infer an outcome.
824    ///
825    /// # Errors
826    ///
827    /// Returns [`LaunchError`] when a repository operation, lifecycle update,
828    /// or commit fails. A tasklet error or panic is instead persisted as a
829    /// failed execution and returned in [`LaunchReport`].
830    #[allow(
831        clippy::too_many_lines,
832        reason = "the launch method keeps the listener nesting and commit order visible"
833    )]
834    pub async fn launch(
835        &self,
836        job: &TaskletJob,
837        parameters: &JobParameters,
838        stop: &StopToken,
839    ) -> Result<LaunchReport, LaunchError> {
840        self.ensure_accepting()?;
841        let key = JobInstanceKey::new(job.name.clone(), parameters);
842        let plan = job.compiled_plan();
843        let graph = self
844            .create_execution_graph(&key, job.step.name(), job.definition_identity())
845            .await?;
846        self.emit_event(LifecycleEventKind::LaunchAccepted, &graph.correlation, None);
847        self.emit_event(LifecycleEventKind::JobStarting, &graph.correlation, None);
848        self.emit_event(LifecycleEventKind::StepStarting, &graph.correlation, None);
849
850        self.poll_execution_control(graph.job_execution.id(), stop)
851            .await?;
852        self.observe_process_shutdown(stop);
853
854        if stop.is_stop_requested() {
855            let (job_execution, step_execution) = self
856                .stop_graph(
857                    plan,
858                    &graph.job_execution,
859                    &graph.step_execution,
860                    &graph.correlation,
861                )
862                .await?;
863            return Ok(LaunchReport {
864                instance: graph.instance,
865                job_execution,
866                step_execution,
867                outcome: TaskletExecutionOutcome::Stopped(StopTiming::BeforeStart),
868                original_outcome: None,
869                original_failure: None,
870                listener_failures: Vec::new(),
871            });
872        }
873
874        let context = ListenerContext::new(&graph.correlation, parameters, stop);
875        if let Some(failure) = self.run_before_job(&job.listeners, context).await? {
876            let outcome = listener_failure_outcome(failure.kind());
877            let step_execution = self
878                .finish_step(
879                    &graph.step_execution,
880                    outcome,
881                    Some(failure.summary()),
882                    None,
883                    false,
884                    &graph.correlation,
885                )
886                .await?;
887            let job_execution = self
888                .finish_job(
889                    &graph.job_execution,
890                    Self::terminal_status(plan, outcome)?,
891                    Some(failure.summary()),
892                    &graph.correlation,
893                )
894                .await?;
895            return Ok(LaunchReport {
896                instance: graph.instance,
897                job_execution,
898                step_execution,
899                outcome,
900                original_outcome: None,
901                original_failure: None,
902                listener_failures: vec![failure],
903            });
904        }
905
906        let started_job = self
907            .start_job(&graph.job_execution, &graph.correlation)
908            .await?;
909        self.poll_execution_control(started_job.id(), stop).await?;
910        self.observe_process_shutdown(stop);
911        if stop.is_stop_requested() {
912            let (job_execution, step_execution) = self
913                .stop_graph(
914                    plan,
915                    &started_job,
916                    &graph.step_execution,
917                    &graph.correlation,
918                )
919                .await?;
920            return Ok(LaunchReport {
921                instance: graph.instance,
922                job_execution,
923                step_execution,
924                outcome: TaskletExecutionOutcome::Stopped(StopTiming::BeforeStart),
925                original_outcome: None,
926                original_failure: None,
927                listener_failures: Vec::new(),
928            });
929        }
930
931        if let Some(failure) = self.run_before_step(&job.step.listeners, context).await? {
932            let outcome = listener_failure_outcome(failure.kind());
933            let step_execution = self
934                .finish_step(
935                    &graph.step_execution,
936                    outcome,
937                    Some(failure.summary()),
938                    None,
939                    false,
940                    &graph.correlation,
941                )
942                .await?;
943            let mut listener_failures = vec![failure];
944            let mut original_outcome = None;
945            let after_job_failures = self.run_after_job(&job.listeners, context, outcome).await?;
946            if !after_job_failures.is_empty() {
947                original_outcome = Some(outcome);
948                listener_failures.extend(after_job_failures);
949            }
950            let final_outcome = listener_failure_outcome(listener_failures[0].kind());
951            let job_execution = self
952                .finish_job(
953                    &started_job,
954                    Self::terminal_status(plan, final_outcome)?,
955                    Some(listener_failures[0].summary()),
956                    &graph.correlation,
957                )
958                .await?;
959            return Ok(LaunchReport {
960                instance: graph.instance,
961                job_execution,
962                step_execution,
963                outcome: final_outcome,
964                original_outcome,
965                original_failure: None,
966                listener_failures,
967            });
968        }
969
970        let started_step = self
971            .start_step(&graph.step_execution, &graph.correlation)
972            .await?;
973        let terminal_rollback = AtomicBool::new(false);
974        let tasklet_context = TaskletContext {
975            parameters,
976            job_execution_id: started_job.id(),
977            step_execution_id: started_step.id(),
978            stop,
979            correlation: &graph.correlation,
980            event_sink: self.event_sink,
981            terminal_rollback: &terminal_rollback,
982        };
983        let invocation = self
984            .invoke_with_execution_control(
985                started_job.id(),
986                job.step.tasklet.as_ref(),
987                tasklet_context,
988                stop,
989            )
990            .await?;
991        let mut custom_exit = None;
992        let provisional_outcome = match invocation {
993            Ok(TaskletOutcome::Completed) if !stop.is_stop_requested() => {
994                TaskletExecutionOutcome::Completed
995            }
996            Ok(TaskletOutcome::CompletedWith(exit_status)) if !stop.is_stop_requested() => {
997                custom_exit = Some(exit_status);
998                TaskletExecutionOutcome::Completed
999            }
1000            Ok(
1001                TaskletOutcome::Completed
1002                | TaskletOutcome::CompletedWith(_)
1003                | TaskletOutcome::Stopped,
1004            ) => TaskletExecutionOutcome::Stopped(StopTiming::DuringExecution),
1005            Ok(TaskletOutcome::StoppedAfterBlockingWork) => {
1006                TaskletExecutionOutcome::Stopped(StopTiming::AfterBlockingWork)
1007            }
1008            Ok(TaskletOutcome::CommitOutcomeUnknown) => TaskletExecutionOutcome::Unknown,
1009            Err(failure) => TaskletExecutionOutcome::Failed(failure),
1010        };
1011        let tasklet_failure = if matches!(
1012            provisional_outcome,
1013            TaskletExecutionOutcome::Failed(TaskletFailure::Error | TaskletFailure::Panic)
1014        ) {
1015            Some(self.next_failure_summary()?)
1016        } else {
1017            None
1018        };
1019
1020        let mut outcome = provisional_outcome;
1021        let mut original_outcome = None;
1022        let mut listener_failures = self
1023            .run_after_step(&job.step.listeners, context, outcome)
1024            .await?;
1025        if let Some(failure) = listener_failures.first()
1026            && outcome != TaskletExecutionOutcome::Unknown
1027        {
1028            original_outcome = Some(outcome);
1029            outcome = listener_failure_outcome(failure.kind());
1030            custom_exit = None;
1031        }
1032        let step_failure = listener_failures
1033            .first()
1034            .map(|failure| failure.summary())
1035            .or(tasklet_failure);
1036        let durable_step = self.reload_step(started_step.id()).await?;
1037        let step_execution = self
1038            .finish_step(
1039                &durable_step,
1040                outcome,
1041                step_failure,
1042                custom_exit.as_ref(),
1043                terminal_rollback.load(Ordering::Acquire),
1044                &graph.correlation,
1045            )
1046            .await?;
1047
1048        let after_job_failures = self.run_after_job(&job.listeners, context, outcome).await?;
1049        if !after_job_failures.is_empty() {
1050            if original_outcome.is_none() && outcome != TaskletExecutionOutcome::Unknown {
1051                original_outcome = Some(outcome);
1052            }
1053            if listener_failures.is_empty() && outcome != TaskletExecutionOutcome::Unknown {
1054                outcome = listener_failure_outcome(after_job_failures[0].kind());
1055            }
1056            listener_failures.extend(after_job_failures);
1057        }
1058        let job_failure = listener_failures
1059            .first()
1060            .map(|failure| failure.summary())
1061            .or(tasklet_failure);
1062        let job_execution = self
1063            .finish_job(
1064                &started_job,
1065                Self::terminal_status_for_exit(
1066                    plan,
1067                    outcome,
1068                    step_execution.metadata().exit_status(),
1069                )?,
1070                job_failure,
1071                &graph.correlation,
1072            )
1073            .await?;
1074
1075        Ok(LaunchReport {
1076            instance: graph.instance,
1077            job_execution,
1078            step_execution,
1079            outcome,
1080            original_outcome,
1081            original_failure: original_outcome.and(tasklet_failure),
1082            listener_failures,
1083        })
1084    }
1085
1086    async fn reload_step(&self, id: StepExecutionId) -> Result<StepExecution, LaunchError> {
1087        let mut unit = self.repository.begin().await?;
1088        let step = unit
1089            .get_step_execution(id)
1090            .await?
1091            .ok_or(RepositoryError::StepExecutionNotFound { id })?;
1092        unit.rollback().await?;
1093        Ok(step)
1094    }
1095
1096    async fn poll_execution_control(
1097        &self,
1098        execution_id: JobExecutionId,
1099        stop: &StopToken,
1100    ) -> Result<(), LaunchError> {
1101        let Some((owner, _)) = self.execution_control else {
1102            return Ok(());
1103        };
1104        let mut unit = self.repository.begin().await?;
1105        let control = unit
1106            .observe_execution_control(execution_id, &owner, self.clock.now())
1107            .await?;
1108        unit.commit().await?;
1109        if !control.owner_matches() {
1110            return Err(RepositoryError::ExecutionOwned { id: execution_id }.into());
1111        }
1112        if control.stop_requested() {
1113            stop.request_stop();
1114        }
1115        Ok(())
1116    }
1117
1118    async fn invoke_with_execution_control(
1119        &self,
1120        execution_id: JobExecutionId,
1121        tasklet: &dyn Tasklet,
1122        context: TaskletContext<'_>,
1123        stop: &StopToken,
1124    ) -> Result<Result<TaskletOutcome, TaskletFailure>, LaunchError> {
1125        if self.execution_control.is_none() && self.shutdown_signal.is_none() {
1126            return Ok(invoke_tasklet(tasklet, context).await);
1127        }
1128        let invocation = invoke_tasklet(tasklet, context);
1129        tokio::pin!(invocation);
1130        let mut shutdown_observed = false;
1131        loop {
1132            tokio::select! {
1133                result = &mut invocation => return Ok(result),
1134                () = async {
1135                    match self.execution_control {
1136                        Some((_, interval)) => tokio::time::sleep(interval.get()).await,
1137                        None => std::future::pending().await,
1138                    }
1139                } => {
1140                    self.poll_execution_control(execution_id, stop).await?;
1141                }
1142                () = async {
1143                    match self.shutdown_signal {
1144                        Some(signal) => signal.cancelled().await,
1145                        None => std::future::pending().await,
1146                    }
1147                }, if !shutdown_observed => {
1148                    shutdown_observed = true;
1149                    stop.request_stop();
1150                }
1151            }
1152        }
1153    }
1154
1155    fn ensure_accepting(&self) -> Result<(), LaunchError> {
1156        self.shutdown_signal.map_or(Ok(()), |signal| {
1157            signal
1158                .ensure_accepting()
1159                .map_err(|_| LaunchError::ShuttingDown)
1160        })
1161    }
1162
1163    fn observe_process_shutdown(&self, stop: &StopToken) {
1164        if self
1165            .shutdown_signal
1166            .is_some_and(crate::ShutdownSignal::is_shutdown_requested)
1167        {
1168            stop.request_stop();
1169        }
1170    }
1171
1172    async fn create_execution_graph(
1173        &self,
1174        key: &JobInstanceKey,
1175        step_name: &StepName,
1176        definition: &DefinitionIdentity,
1177    ) -> Result<CreatedExecutionGraph, LaunchError> {
1178        let mut unit = self.repository.begin().await?;
1179        let instance = unit
1180            .select_or_create_job_instance(key)
1181            .await?
1182            .instance()
1183            .clone();
1184        let job_execution = unit
1185            .create_job_execution_with_definition(instance.id(), definition)
1186            .await?;
1187        let job_execution = if let Some((owner, _)) = self.execution_control {
1188            unit.claim_execution_owner(
1189                job_execution.id(),
1190                job_execution.version(),
1191                &owner,
1192                self.clock.now(),
1193            )
1194            .await?
1195        } else {
1196            job_execution
1197        };
1198        let step_execution = unit
1199            .create_step_execution(job_execution.id(), step_name)
1200            .await?;
1201        let attempt_count = unit.job_executions(instance.id()).await?.len();
1202        let attempt = u64::try_from(attempt_count)
1203            .ok()
1204            .and_then(NonZeroU64::new)
1205            .map(ExecutionAttempt::new)
1206            .ok_or(RepositoryError::Unavailable)?;
1207        unit.commit().await?;
1208        let correlation = ExecutionCorrelation::new(
1209            key.job_name().clone(),
1210            instance.id(),
1211            job_execution.id(),
1212            attempt,
1213            step_name.clone(),
1214            step_execution.id(),
1215            attempt,
1216        );
1217        Ok(CreatedExecutionGraph {
1218            instance,
1219            job_execution,
1220            step_execution,
1221            correlation,
1222        })
1223    }
1224
1225    async fn start_job(
1226        &self,
1227        job: &JobExecution,
1228        correlation: &ExecutionCorrelation,
1229    ) -> Result<JobExecution, LaunchError> {
1230        let now = self.clock.now();
1231        let mut unit = self.repository.begin().await?;
1232        let started_job = unit
1233            .transition_job_execution(
1234                job.id(),
1235                job.version(),
1236                LifecycleTransition::new(BatchStatus::Started, now),
1237            )
1238            .await?;
1239        unit.commit().await?;
1240        self.emit_event(LifecycleEventKind::JobStarted, correlation, None);
1241        Ok(started_job)
1242    }
1243
1244    async fn start_step(
1245        &self,
1246        step: &StepExecution,
1247        correlation: &ExecutionCorrelation,
1248    ) -> Result<StepExecution, LaunchError> {
1249        let now = self.clock.now();
1250        let mut unit = self.repository.begin().await?;
1251        let started_step = unit
1252            .transition_step_execution(
1253                step.id(),
1254                step.version(),
1255                LifecycleTransition::new(BatchStatus::Started, now),
1256            )
1257            .await?;
1258        unit.commit().await?;
1259        self.emit_event(LifecycleEventKind::StepStarted, correlation, None);
1260        Ok(started_step)
1261    }
1262
1263    async fn finish_job(
1264        &self,
1265        job: &JobExecution,
1266        status: BatchStatus,
1267        failure: Option<FailureSummary>,
1268        correlation: &ExecutionCorrelation,
1269    ) -> Result<JobExecution, LaunchError> {
1270        let exit_status = status_exit_status(status);
1271        let now = self.clock.now();
1272        let mut unit = self.repository.begin().await?;
1273        let current = if self.execution_control.is_some() {
1274            unit.get_job_execution(job.id())
1275                .await?
1276                .ok_or(RepositoryError::JobExecutionNotFound { id: job.id() })?
1277        } else {
1278            job.clone()
1279        };
1280        let job = unit
1281            .enrich_job_exit_status(current.id(), current.version(), &exit_status)
1282            .await?;
1283        let transition = transition_for_outcome(status, now, failure)?;
1284        let job = unit
1285            .transition_job_execution(job.id(), job.version(), transition)
1286            .await?;
1287        unit.commit().await?;
1288        self.emit_event(job_event_kind(status), correlation, failure);
1289        Ok(job)
1290    }
1291
1292    async fn finish_step(
1293        &self,
1294        step: &StepExecution,
1295        outcome: TaskletExecutionOutcome,
1296        failure: Option<FailureSummary>,
1297        custom_exit: Option<&ExitStatus>,
1298        terminal_rollback: bool,
1299        correlation: &ExecutionCorrelation,
1300    ) -> Result<StepExecution, LaunchError> {
1301        let (status, default_exit) = final_status(outcome);
1302        let exit_status = custom_exit.unwrap_or(&default_exit);
1303        let now = self.clock.now();
1304        let mut unit = self.repository.begin().await?;
1305        let step = unit
1306            .enrich_step_exit_status(step.id(), step.version(), exit_status)
1307            .await?;
1308        let mut transition = transition_for_outcome(status, now, failure)?;
1309        if terminal_rollback {
1310            transition = transition.with_terminal_rollback();
1311        }
1312        let step = unit
1313            .transition_step_execution(step.id(), step.version(), transition)
1314            .await?;
1315        unit.commit().await?;
1316        self.emit_final_event(outcome, correlation, failure);
1317        Ok(step)
1318    }
1319
1320    async fn stop_graph(
1321        &self,
1322        plan: &CompiledExecutionPlan,
1323        job: &JobExecution,
1324        step: &StepExecution,
1325        correlation: &ExecutionCorrelation,
1326    ) -> Result<(JobExecution, StepExecution), LaunchError> {
1327        let stopping_job = self.mark_job_stopping(job, correlation).await?;
1328        let stopping_step = self.mark_step_stopping(step, correlation).await?;
1329        let outcome = TaskletExecutionOutcome::Stopped(StopTiming::BeforeStart);
1330        let step = self
1331            .finish_step(&stopping_step, outcome, None, None, false, correlation)
1332            .await?;
1333        let status = Self::terminal_status(plan, outcome)?;
1334        let job = self
1335            .finish_job(&stopping_job, status, None, correlation)
1336            .await?;
1337        Ok((job, step))
1338    }
1339
1340    async fn mark_job_stopping(
1341        &self,
1342        job: &JobExecution,
1343        correlation: &ExecutionCorrelation,
1344    ) -> Result<JobExecution, LaunchError> {
1345        let mut unit = self.repository.begin().await?;
1346        let current = if self.execution_control.is_some() {
1347            unit.get_job_execution(job.id())
1348                .await?
1349                .ok_or(RepositoryError::JobExecutionNotFound { id: job.id() })?
1350        } else {
1351            job.clone()
1352        };
1353        if current.metadata().status() == BatchStatus::Stopping {
1354            unit.rollback().await?;
1355            self.emit_event(LifecycleEventKind::JobStopping, correlation, None);
1356            return Ok(current);
1357        }
1358        let job = unit
1359            .transition_job_execution(
1360                current.id(),
1361                current.version(),
1362                LifecycleTransition::new(BatchStatus::Stopping, self.clock.now()),
1363            )
1364            .await?;
1365        unit.commit().await?;
1366        self.emit_event(LifecycleEventKind::JobStopping, correlation, None);
1367        Ok(job)
1368    }
1369
1370    async fn mark_step_stopping(
1371        &self,
1372        step: &StepExecution,
1373        correlation: &ExecutionCorrelation,
1374    ) -> Result<StepExecution, LaunchError> {
1375        let mut unit = self.repository.begin().await?;
1376        let step = unit
1377            .transition_step_execution(
1378                step.id(),
1379                step.version(),
1380                LifecycleTransition::new(BatchStatus::Stopping, self.clock.now()),
1381            )
1382            .await?;
1383        unit.commit().await?;
1384        self.emit_event(LifecycleEventKind::StepStopping, correlation, None);
1385        Ok(step)
1386    }
1387
1388    async fn run_before_job(
1389        &self,
1390        listeners: &[Arc<dyn JobExecutionListener>],
1391        context: ListenerContext<'_>,
1392    ) -> Result<Option<ListenerFailure>, LaunchError> {
1393        for (index, listener) in listeners.iter().enumerate() {
1394            if let Err(kind) = invoke_before_job(listener.as_ref(), context).await {
1395                return self
1396                    .listener_failure(ListenerPhase::BeforeJob, index, kind, context)
1397                    .map(Some);
1398            }
1399        }
1400        Ok(None)
1401    }
1402
1403    async fn run_before_step(
1404        &self,
1405        listeners: &[Arc<dyn StepExecutionListener>],
1406        context: ListenerContext<'_>,
1407    ) -> Result<Option<ListenerFailure>, LaunchError> {
1408        for (index, listener) in listeners.iter().enumerate() {
1409            if let Err(kind) = invoke_before_step(listener.as_ref(), context).await {
1410                return self
1411                    .listener_failure(ListenerPhase::BeforeStep, index, kind, context)
1412                    .map(Some);
1413            }
1414        }
1415        Ok(None)
1416    }
1417
1418    async fn run_after_job(
1419        &self,
1420        listeners: &[Arc<dyn JobExecutionListener>],
1421        context: ListenerContext<'_>,
1422        outcome: TaskletExecutionOutcome,
1423    ) -> Result<Vec<ListenerFailure>, LaunchError> {
1424        let mut failures = Vec::new();
1425        for (index, listener) in listeners.iter().enumerate().rev() {
1426            if let Err(kind) = invoke_after_job(listener.as_ref(), context, outcome).await {
1427                failures.push(self.listener_failure(
1428                    ListenerPhase::AfterJob,
1429                    index,
1430                    kind,
1431                    context,
1432                )?);
1433            }
1434        }
1435        Ok(failures)
1436    }
1437
1438    async fn run_after_step(
1439        &self,
1440        listeners: &[Arc<dyn StepExecutionListener>],
1441        context: ListenerContext<'_>,
1442        outcome: TaskletExecutionOutcome,
1443    ) -> Result<Vec<ListenerFailure>, LaunchError> {
1444        let mut failures = Vec::new();
1445        for (index, listener) in listeners.iter().enumerate().rev() {
1446            if let Err(kind) = invoke_after_step(listener.as_ref(), context, outcome).await {
1447                failures.push(self.listener_failure(
1448                    ListenerPhase::AfterStep,
1449                    index,
1450                    kind,
1451                    context,
1452                )?);
1453            }
1454        }
1455        Ok(failures)
1456    }
1457
1458    fn listener_failure(
1459        &self,
1460        phase: ListenerPhase,
1461        registration_index: usize,
1462        kind: ListenerFailureKind,
1463        context: ListenerContext<'_>,
1464    ) -> Result<ListenerFailure, LaunchError> {
1465        let summary = self.next_failure_summary()?;
1466        let event_kind = match phase {
1467            ListenerPhase::BeforeJob => LifecycleEventKind::JobBeforeListenerFailed,
1468            ListenerPhase::BeforeStep => LifecycleEventKind::StepBeforeListenerFailed,
1469            ListenerPhase::AfterStep => LifecycleEventKind::StepAfterListenerFailed,
1470            ListenerPhase::AfterJob => LifecycleEventKind::JobAfterListenerFailed,
1471        };
1472        self.emit_event(event_kind, context.correlation(), Some(summary));
1473        Ok(ListenerFailure::new(
1474            phase,
1475            registration_index,
1476            kind,
1477            summary,
1478        ))
1479    }
1480
1481    fn next_failure_summary(&self) -> Result<FailureSummary, LaunchError> {
1482        Ok(FailureSummary::new(
1483            FailureCategory::UserComponent,
1484            self.ids
1485                .next_failure_id()
1486                .map_err(RepositoryError::Identifier)?,
1487        ))
1488    }
1489
1490    fn emit_final_event(
1491        &self,
1492        outcome: TaskletExecutionOutcome,
1493        correlation: &ExecutionCorrelation,
1494        failure: Option<FailureSummary>,
1495    ) {
1496        let kind = match outcome {
1497            TaskletExecutionOutcome::Completed => LifecycleEventKind::StepCompleted,
1498            TaskletExecutionOutcome::Stopped(_) => LifecycleEventKind::StepStopped,
1499            TaskletExecutionOutcome::Failed(_) => LifecycleEventKind::StepFailed,
1500            TaskletExecutionOutcome::Unknown => LifecycleEventKind::StepUnknown,
1501        };
1502        self.emit_event(kind, correlation, failure);
1503    }
1504
1505    /// Routes one step outcome through the compiled plan.
1506    ///
1507    /// An unknown commit never reaches the graph. The runtime cannot know what
1508    /// happened, and the accepted M3 terminal set deliberately has no unknown
1509    /// terminal, so routing it would downgrade an unknown outcome to a decided
1510    /// one. It fails closed as `UNKNOWN` instead.
1511    fn terminal_status(
1512        plan: &CompiledExecutionPlan,
1513        outcome: TaskletExecutionOutcome,
1514    ) -> Result<BatchStatus, LaunchError> {
1515        let (_, exit_status) = final_status(outcome);
1516        Self::terminal_status_for_exit(plan, outcome, &exit_status)
1517    }
1518
1519    fn terminal_status_for_exit(
1520        plan: &CompiledExecutionPlan,
1521        outcome: TaskletExecutionOutcome,
1522        exit_status: &ExitStatus,
1523    ) -> Result<BatchStatus, LaunchError> {
1524        if matches!(outcome, TaskletExecutionOutcome::Unknown) {
1525            return Ok(BatchStatus::Unknown);
1526        }
1527        if plan.manifest_format() == oxide_batch_core::MANIFEST_FORMAT_ONE_STEP
1528            && outcome == TaskletExecutionOutcome::Completed
1529            && exit_status.code().as_str() != "COMPLETED"
1530        {
1531            return Ok(BatchStatus::Completed);
1532        }
1533        match plan.select_target(plan.entry(), exit_status.code())? {
1534            FlowTarget::Terminal(TerminalKind::Complete) => Ok(BatchStatus::Completed),
1535            FlowTarget::Terminal(TerminalKind::Fail) => Ok(BatchStatus::Failed),
1536            FlowTarget::Terminal(TerminalKind::Stop) => Ok(BatchStatus::Stopped),
1537            // A node target, and any terminal this build does not know:
1538            // `TerminalKind` is `#[non_exhaustive]`, and an unrecognized
1539            // terminal is an unsupported plan, never a guessed status.
1540            _ => Err(LaunchError::UnsupportedPlan),
1541        }
1542    }
1543
1544    fn emit_event(
1545        &self,
1546        kind: LifecycleEventKind,
1547        correlation: &ExecutionCorrelation,
1548        failure: Option<FailureSummary>,
1549    ) {
1550        let Some(sink) = self.event_sink else {
1551            return;
1552        };
1553        let event = failure.map_or_else(
1554            || LifecycleEvent::new(kind, correlation.clone()),
1555            |summary| LifecycleEvent::failed(kind, correlation.clone(), summary),
1556        );
1557        let _ = catch_unwind(AssertUnwindSafe(|| sink.emit(&event)));
1558    }
1559}
1560
1561struct CreatedExecutionGraph {
1562    instance: JobInstance,
1563    job_execution: JobExecution,
1564    step_execution: StepExecution,
1565    correlation: ExecutionCorrelation,
1566}
1567
1568const fn job_event_kind(status: BatchStatus) -> LifecycleEventKind {
1569    match status {
1570        BatchStatus::Completed => LifecycleEventKind::JobCompleted,
1571        BatchStatus::Stopped => LifecycleEventKind::JobStopped,
1572        BatchStatus::Unknown => LifecycleEventKind::JobUnknown,
1573        _ => LifecycleEventKind::JobFailed,
1574    }
1575}
1576
1577fn status_exit_status(status: BatchStatus) -> ExitStatus {
1578    match status {
1579        BatchStatus::Completed => ExitStatus::completed(),
1580        BatchStatus::Stopped => ExitStatus::stopped(),
1581        BatchStatus::Unknown => ExitStatus::unknown(),
1582        _ => ExitStatus::failed(),
1583    }
1584}
1585
1586fn final_status(outcome: TaskletExecutionOutcome) -> (BatchStatus, ExitStatus) {
1587    match outcome {
1588        TaskletExecutionOutcome::Completed => (BatchStatus::Completed, ExitStatus::completed()),
1589        TaskletExecutionOutcome::Stopped(_) => (BatchStatus::Stopped, ExitStatus::stopped()),
1590        TaskletExecutionOutcome::Failed(_) => (BatchStatus::Failed, ExitStatus::failed()),
1591        TaskletExecutionOutcome::Unknown => (BatchStatus::Unknown, ExitStatus::unknown()),
1592    }
1593}
1594
1595fn transition_for_outcome(
1596    status: BatchStatus,
1597    transitioned_at: std::time::SystemTime,
1598    failure: Option<FailureSummary>,
1599) -> Result<LifecycleTransition, LaunchError> {
1600    if matches!(status, BatchStatus::Failed) {
1601        let summary = failure.ok_or(RepositoryError::Unavailable)?;
1602        Ok(LifecycleTransition::failed(transitioned_at, summary))
1603    } else {
1604        Ok(LifecycleTransition::new(status, transitioned_at))
1605    }
1606}
1607
1608const fn listener_failure_outcome(kind: ListenerFailureKind) -> TaskletExecutionOutcome {
1609    match kind {
1610        ListenerFailureKind::Error => {
1611            TaskletExecutionOutcome::Failed(TaskletFailure::ListenerError)
1612        }
1613        ListenerFailureKind::Panic => {
1614            TaskletExecutionOutcome::Failed(TaskletFailure::ListenerPanic)
1615        }
1616    }
1617}
1618
1619async fn invoke_before_job(
1620    listener: &dyn JobExecutionListener,
1621    context: ListenerContext<'_>,
1622) -> Result<(), ListenerFailureKind> {
1623    let future = catch_unwind(AssertUnwindSafe(|| listener.before_job(context)))
1624        .map_err(|_| ListenerFailureKind::Panic)?;
1625    match AssertUnwindSafe(future).catch_unwind().await {
1626        Ok(Ok(())) => Ok(()),
1627        Ok(Err(_)) => Err(ListenerFailureKind::Error),
1628        Err(_) => Err(ListenerFailureKind::Panic),
1629    }
1630}
1631
1632async fn invoke_after_job(
1633    listener: &dyn JobExecutionListener,
1634    context: ListenerContext<'_>,
1635    outcome: TaskletExecutionOutcome,
1636) -> Result<(), ListenerFailureKind> {
1637    let future = catch_unwind(AssertUnwindSafe(|| listener.after_job(context, outcome)))
1638        .map_err(|_| ListenerFailureKind::Panic)?;
1639    match AssertUnwindSafe(future).catch_unwind().await {
1640        Ok(Ok(())) => Ok(()),
1641        Ok(Err(_)) => Err(ListenerFailureKind::Error),
1642        Err(_) => Err(ListenerFailureKind::Panic),
1643    }
1644}
1645
1646pub(crate) async fn invoke_before_step(
1647    listener: &dyn StepExecutionListener,
1648    context: ListenerContext<'_>,
1649) -> Result<(), ListenerFailureKind> {
1650    let future = catch_unwind(AssertUnwindSafe(|| listener.before_step(context)))
1651        .map_err(|_| ListenerFailureKind::Panic)?;
1652    match AssertUnwindSafe(future).catch_unwind().await {
1653        Ok(Ok(())) => Ok(()),
1654        Ok(Err(_)) => Err(ListenerFailureKind::Error),
1655        Err(_) => Err(ListenerFailureKind::Panic),
1656    }
1657}
1658
1659pub(crate) async fn invoke_after_step(
1660    listener: &dyn StepExecutionListener,
1661    context: ListenerContext<'_>,
1662    outcome: TaskletExecutionOutcome,
1663) -> Result<(), ListenerFailureKind> {
1664    let future = catch_unwind(AssertUnwindSafe(|| listener.after_step(context, outcome)))
1665        .map_err(|_| ListenerFailureKind::Panic)?;
1666    match AssertUnwindSafe(future).catch_unwind().await {
1667        Ok(Ok(())) => Ok(()),
1668        Ok(Err(_)) => Err(ListenerFailureKind::Error),
1669        Err(_) => Err(ListenerFailureKind::Panic),
1670    }
1671}
1672
1673pub(crate) async fn invoke_tasklet(
1674    tasklet: &dyn Tasklet,
1675    context: TaskletContext<'_>,
1676) -> Result<TaskletOutcome, TaskletFailure> {
1677    let future = catch_unwind(AssertUnwindSafe(|| tasklet.execute(context)))
1678        .map_err(|_| TaskletFailure::Panic)?;
1679    match AssertUnwindSafe(future).catch_unwind().await {
1680        Ok(Ok(outcome)) => Ok(outcome),
1681        Ok(Err(error)) => match error.kind {
1682            TaskletErrorKind::Component => Err(TaskletFailure::Error),
1683            TaskletErrorKind::Panic => Err(TaskletFailure::Panic),
1684        },
1685        Err(_) => Err(TaskletFailure::Panic),
1686    }
1687}
1688
1689/// Isolates synchronous tasklet work behind a bounded Tokio blocking pool.
1690///
1691/// The semaphore limits submitted blocking calls independently of Tokio's
1692/// process-wide blocking-thread limit. This adapter requires the launch future
1693/// to be polled inside a Tokio runtime, but does not create or own one.
1694pub struct BlockingTaskletAdapter<T> {
1695    tasklet: Arc<T>,
1696    permits: Arc<Semaphore>,
1697}
1698
1699impl<T> BlockingTaskletAdapter<T>
1700where
1701    T: BlockingTasklet,
1702{
1703    /// Constructs an adapter with an explicit nonzero concurrency bound.
1704    #[must_use]
1705    pub fn new(tasklet: T, maximum_concurrency: NonZeroUsize) -> Self {
1706        Self {
1707            tasklet: Arc::new(tasklet),
1708            permits: Arc::new(Semaphore::new(maximum_concurrency.get())),
1709        }
1710    }
1711}
1712
1713impl<T> fmt::Debug for BlockingTaskletAdapter<T> {
1714    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1715        formatter
1716            .debug_struct("BlockingTaskletAdapter")
1717            .field("available_permits", &self.permits.available_permits())
1718            .finish_non_exhaustive()
1719    }
1720}
1721
1722impl<T> Tasklet for BlockingTaskletAdapter<T>
1723where
1724    T: BlockingTasklet,
1725{
1726    fn execute<'a>(
1727        &'a self,
1728        context: TaskletContext<'a>,
1729    ) -> BoxFuture<'a, Result<TaskletOutcome, TaskletError>> {
1730        Box::pin(async move {
1731            if context.stop.is_stop_requested() {
1732                return Ok(TaskletOutcome::Stopped);
1733            }
1734
1735            let permit = tokio::select! {
1736                result = Arc::clone(&self.permits).acquire_owned() => {
1737                    match result {
1738                        Ok(permit) => permit,
1739                        Err(_) => return Err(TaskletError::new()),
1740                    }
1741                }
1742                () = context.stop.cancelled() => return Ok(TaskletOutcome::Stopped),
1743            };
1744            if context.stop.is_stop_requested() {
1745                return Ok(TaskletOutcome::Stopped);
1746            }
1747
1748            let stop = context.stop.clone();
1749            let tasklet = Arc::clone(&self.tasklet);
1750            let owned_context = context.into_blocking();
1751            let joined = tokio::task::spawn_blocking(move || {
1752                let _permit = permit;
1753                if owned_context.stop_token().is_stop_requested() {
1754                    (false, Ok(TaskletOutcome::Stopped))
1755                } else {
1756                    (true, tasklet.execute(owned_context))
1757                }
1758            })
1759            .await;
1760
1761            let (started, result) = match joined {
1762                Ok(result) => result,
1763                Err(error) if error.is_panic() => return Err(TaskletError::panic()),
1764                Err(_) => return Err(TaskletError::new()),
1765            };
1766            let outcome = result?;
1767            if started && stop.is_stop_requested() {
1768                Ok(TaskletOutcome::StoppedAfterBlockingWork)
1769            } else {
1770                Ok(outcome)
1771            }
1772        })
1773    }
1774}