Skip to main content

oxide_batch/
chunk_runtime.rs

1//! Deterministic single-threaded chunk-step orchestration.
2
3use std::fmt;
4use std::panic::{AssertUnwindSafe, catch_unwind};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use futures_util::FutureExt;
9use tokio::sync::Mutex as AsyncMutex;
10
11use crate::runtime::{lower_one_step, one_step_node};
12use crate::{
13    BackoffOutcome, BoxFuture, ChunkCommitReceipt, ChunkCompletion, ChunkCompletionContext,
14    ChunkCompletionOutcome, ChunkComponentRevisions, ChunkCount, ChunkCounts, ChunkFaultProgress,
15    ChunkSize, ChunkTransaction, ChunkTransactionContext, ChunkTransactionError,
16    ChunkTransactionManager, CompiledExecutionPlan, DefinitionError, DefinitionIdentity,
17    DefinitionRevision, ExecutionCorrelation, FailureCategory, FailureId, FailureSummary,
18    FaultDecision, FaultDescriptor, FaultEvidence, FaultPhase, FaultProgress, FaultRuntime,
19    InFlightPolicy, InheritedStepProgress, ItemListenerContext, ItemListenerFailure,
20    ItemListenerSet, ItemProcessor, ItemReader, ItemWriter, JobExecutionListener, JobLauncher,
21    JobName, JobParameters, LaunchError, LaunchReport, LifecycleEventKind, ListenerFailureKind,
22    ProcessContext, ProcessOutcome, ProcessorError, ReadContext, ReadOutcome, ReaderError,
23    RetryCounts, RetryKey, RetryOrdinal, RetryOutcome, RetryReservation, RollbackDisposition,
24    SkipCounts, StepComponents, StepExecutionListener, StepName, StopToken, Tasklet,
25    TaskletContext, TaskletError, TaskletJob, TaskletOutcome, TaskletStep, WriteContext,
26    WriteOutcome, WriterError,
27};
28
29/// A validated one-step chunk definition.
30pub struct ChunkStep<I, O> {
31    name: StepName,
32    size: ChunkSize,
33    reader: Box<dyn ItemReader<I>>,
34    processor: Arc<dyn ItemProcessor<I, O>>,
35    writer: Arc<dyn ItemWriter<O>>,
36    transactions: Arc<dyn ChunkTransactionManager>,
37    completion: Arc<dyn ChunkCompletion>,
38    listeners: Vec<Arc<dyn ChunkListener>>,
39    step_listeners: Vec<Arc<dyn StepExecutionListener>>,
40    item_listeners: ItemListenerSet<I, O>,
41    fault: Option<FaultRuntime>,
42    in_flight_policy: InFlightPolicy,
43    definition_digest: [u8; 32],
44}
45
46impl<I, O> ChunkStep<I, O> {
47    /// Constructs a chunk step from facade-owned component and transaction
48    /// ports.
49    #[must_use]
50    #[allow(clippy::too_many_arguments)]
51    pub fn new(
52        name: StepName,
53        size: ChunkSize,
54        reader: Box<dyn ItemReader<I>>,
55        processor: Arc<dyn ItemProcessor<I, O>>,
56        writer: Arc<dyn ItemWriter<O>>,
57        transactions: Arc<dyn ChunkTransactionManager>,
58        completion: Arc<dyn ChunkCompletion>,
59    ) -> Self {
60        Self {
61            name,
62            size,
63            reader,
64            processor,
65            writer,
66            transactions,
67            completion,
68            listeners: Vec::new(),
69            step_listeners: Vec::new(),
70            item_listeners: ItemListenerSet::new(),
71            fault: None,
72            in_flight_policy: InFlightPolicy::FinishChunk,
73            definition_digest: [0; 32],
74        }
75    }
76
77    /// Registers a chunk listener in deterministic before-order.
78    #[must_use]
79    pub fn with_chunk_listener(mut self, listener: Arc<dyn ChunkListener>) -> Self {
80        self.listeners.push(listener);
81        self
82    }
83
84    /// Installs the authoritative item, retry, and skip listener families.
85    ///
86    /// The set replaces any previously installed families.
87    #[must_use]
88    pub fn with_item_listeners(mut self, listeners: ItemListenerSet<I, O>) -> Self {
89        self.item_listeners = listeners;
90        self
91    }
92
93    /// Installs bounded retry, backoff, skip, and rollback behavior.
94    ///
95    /// Without a fault runtime every component failure fails the step after a
96    /// known rollback, which is the M2 behavior.
97    #[must_use]
98    pub fn with_fault_runtime(mut self, fault: FaultRuntime) -> Self {
99        self.fault = Some(fault);
100        self
101    }
102
103    /// Registers a step listener in deterministic before-order.
104    #[must_use]
105    pub fn with_listener(mut self, listener: Arc<dyn StepExecutionListener>) -> Self {
106        self.step_listeners.push(listener);
107        self
108    }
109
110    /// Borrows the step name.
111    #[must_use]
112    pub const fn name(&self) -> &StepName {
113        &self.name
114    }
115
116    /// Executes this step deterministically on the caller's async runtime.
117    ///
118    /// The reader is stateful, so the definition is mutably borrowed for the
119    /// duration of the run. Only counts returned by successful transaction
120    /// commits appear in the report. `correlation` identifies the execution for
121    /// item, retry, and skip listeners; it never reaches a component.
122    pub async fn execute(
123        &mut self,
124        correlation: &ExecutionCorrelation,
125        stop: &StopToken,
126    ) -> ChunkExecutionReport
127    where
128        I: Send + Sync,
129        O: Send + Sync,
130    {
131        execute_chunk_step(self, correlation, stop, None, |_| {}).await
132    }
133}
134
135impl<I, O> fmt::Debug for ChunkStep<I, O> {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        formatter
138            .debug_struct("ChunkStep")
139            .field("name", &self.name)
140            .field("size", &self.size)
141            .field("chunk_listener_count", &self.listeners.len())
142            .field("step_listener_count", &self.step_listeners.len())
143            .finish_non_exhaustive()
144    }
145}
146
147/// A validated single-step chunk job definition.
148pub struct ChunkJob<I, O> {
149    name: JobName,
150    step_name: StepName,
151    plan: CompiledExecutionPlan,
152    tasklet: Arc<ChunkTasklet<I, O>>,
153    step_listeners: Vec<Arc<dyn StepExecutionListener>>,
154    listeners: Vec<Arc<dyn JobExecutionListener>>,
155}
156
157impl<I, O> ChunkJob<I, O> {
158    /// Constructs a chunk job with explicit restart-relevant revisions.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`DefinitionError::ManifestEncoding`] if the bounded canonical
163    /// manifest cannot be encoded, and
164    /// [`DefinitionError::DeliveryModeMismatch`] when an installed fault
165    /// runtime declares a different delivery mode than the restart contract.
166    pub fn new(
167        name: JobName,
168        mut step: ChunkStep<I, O>,
169        revision: DefinitionRevision,
170        components: &ChunkComponentRevisions,
171    ) -> Result<Self, DefinitionError> {
172        if let Some(fault) = step.fault.as_ref()
173            && fault.delivery_mode() != components.delivery_mode()
174        {
175            return Err(DefinitionError::DeliveryModeMismatch);
176        }
177        let step_name = step.name.clone();
178        let definition =
179            DefinitionIdentity::chunk(&name, &step_name, step.size, revision, components)?;
180        step.in_flight_policy = components.in_flight_policy();
181        step.definition_digest = *definition.manifest_digest();
182        let mut node = one_step_node(
183            &step_name,
184            StepComponents::Chunk {
185                size: step.size,
186                revisions: Box::new(components.clone()),
187            },
188        )?;
189        if let Some(fault) = step.fault.as_ref() {
190            node = node.with_fault_policy(fault.policy().clone());
191        }
192        let plan = lower_one_step(definition, node)?;
193        let step_listeners = step.step_listeners.clone();
194        Ok(Self {
195            name,
196            step_name,
197            plan,
198            tasklet: Arc::new(ChunkTasklet::new(step)),
199            step_listeners,
200            listeners: Vec::new(),
201        })
202    }
203
204    /// Borrows the in-memory compatibility plan this wrapper lowers into.
205    ///
206    /// The plan retains the wrapper's original manifest bytes, format, and
207    /// fingerprint and records no durable flow decision.
208    #[must_use]
209    pub const fn compiled_plan(&self) -> &CompiledExecutionPlan {
210        &self.plan
211    }
212
213    /// Borrows the exact restart-relevant definition identity.
214    #[must_use]
215    pub const fn definition_identity(&self) -> &DefinitionIdentity {
216        self.plan.definition_identity()
217    }
218
219    /// Registers a job listener in deterministic before-order.
220    #[must_use]
221    pub fn with_listener(mut self, listener: Arc<dyn JobExecutionListener>) -> Self {
222        self.listeners.push(listener);
223        self
224    }
225
226    /// Borrows the job name.
227    #[must_use]
228    pub const fn name(&self) -> &JobName {
229        &self.name
230    }
231
232    /// Borrows the chunk-step name.
233    #[must_use]
234    pub const fn step_name(&self) -> &StepName {
235        &self.step_name
236    }
237}
238
239impl<I, O> fmt::Debug for ChunkJob<I, O> {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter
242            .debug_struct("ChunkJob")
243            .field("name", &self.name)
244            .field("step_name", &self.step_name)
245            .field("definition", self.plan.definition_identity())
246            .field("listener_count", &self.listeners.len())
247            .field("step_listener_count", &self.step_listeners.len())
248            .finish_non_exhaustive()
249    }
250}
251
252impl crate::FlowJob {
253    /// Binds a stateful chunk step to one compiled flow node.
254    ///
255    /// The chunk's size, component revisions, delivery mode, and fault policy
256    /// must exactly match the immutable plan. The component is erased only at
257    /// the existing tasklet composition boundary; item calls keep the accepted
258    /// M3 boxed component contract.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`crate::FlowJobError::ComponentMismatch`] when executable and
263    /// manifest declarations differ, or the ordinary binding errors for an
264    /// unknown, wrong-kind, duplicate, or differently named node.
265    pub fn with_chunk_step<I, O>(
266        mut self,
267        node_id: crate::NodeId,
268        mut step: ChunkStep<I, O>,
269        revisions: &ChunkComponentRevisions,
270    ) -> Result<Self, crate::FlowJobError>
271    where
272        I: Send + Sync + 'static,
273        O: Send + Sync + 'static,
274    {
275        let Some(crate::FlowNode::Step(compiled)) = self.compiled_plan().node(&node_id) else {
276            return Err(crate::FlowJobError::WrongNodeKind { node: node_id });
277        };
278        let expected = StepComponents::Chunk {
279            size: step.size,
280            revisions: Box::new(revisions.clone()),
281        };
282        if compiled.step_name() != step.name()
283            || compiled.components() != &expected
284            || compiled.fault_policy() != step.fault.as_ref().map(FaultRuntime::policy)
285        {
286            return Err(crate::FlowJobError::ComponentMismatch { node: node_id });
287        }
288        step.definition_digest = *self.compiled_plan().fingerprint();
289        step.in_flight_policy = revisions.in_flight_policy();
290        let listeners = step.step_listeners.clone();
291        let tasklet: Arc<dyn Tasklet> = Arc::new(ChunkTasklet::new(step));
292        let mut tasklet_step = TaskletStep::new(compiled.step_name().clone(), tasklet);
293        for listener in listeners {
294            tasklet_step = tasklet_step.with_listener(listener);
295        }
296        self.bind_chunk_tasklet(node_id, tasklet_step)?;
297        Ok(self)
298    }
299}
300
301struct ChunkTasklet<I, O> {
302    step: AsyncMutex<ChunkStep<I, O>>,
303    last_report: Mutex<Option<ChunkExecutionReport>>,
304}
305
306impl<I, O> ChunkTasklet<I, O> {
307    fn new(step: ChunkStep<I, O>) -> Self {
308        Self {
309            step: AsyncMutex::new(step),
310            last_report: Mutex::new(None),
311        }
312    }
313
314    fn take_last_report(&self) -> Option<ChunkExecutionReport> {
315        self.last_report
316            .lock()
317            .unwrap_or_else(std::sync::PoisonError::into_inner)
318            .take()
319    }
320
321    fn clear_last_report(&self) {
322        *self
323            .last_report
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
326    }
327}
328
329impl<I, O> Tasklet for ChunkTasklet<I, O>
330where
331    I: Send + Sync + 'static,
332    O: Send + Sync + 'static,
333{
334    fn execute<'a>(
335        &'a self,
336        context: TaskletContext<'a>,
337    ) -> BoxFuture<'a, Result<TaskletOutcome, TaskletError>> {
338        Box::pin(async move {
339            let mut step = self.step.lock().await;
340            let transaction_context = ChunkTransactionContext::new(
341                context.job_execution_id(),
342                context.step_execution_id(),
343            );
344            let report = execute_chunk_step(
345                &mut step,
346                context.correlation(),
347                context.stop_token(),
348                Some(transaction_context),
349                |event| match event {
350                    ChunkRuntimeEvent::Started(sequence) => {
351                        context.emit_chunk_event(LifecycleEventKind::ChunkStarted, sequence);
352                    }
353                    ChunkRuntimeEvent::Committed(sequence) => {
354                        context.emit_chunk_event(LifecycleEventKind::ChunkCommitted, sequence);
355                    }
356                    ChunkRuntimeEvent::RolledBack(sequence) => {
357                        context.emit_chunk_event(LifecycleEventKind::ChunkRolledBack, sequence);
358                    }
359                    ChunkRuntimeEvent::Unknown(sequence) => {
360                        context.emit_chunk_event(LifecycleEventKind::ChunkUnknown, sequence);
361                    }
362                    ChunkRuntimeEvent::Fault(fault) => context.emit_fault_event(&fault),
363                },
364            )
365            .await;
366            let outcome = match report.outcome() {
367                ChunkExecutionOutcome::Completed => Ok(TaskletOutcome::Completed),
368                ChunkExecutionOutcome::Stopped => Ok(TaskletOutcome::Stopped),
369                ChunkExecutionOutcome::Failed(_) => Err(TaskletError::new()),
370                ChunkExecutionOutcome::Unknown => Ok(TaskletOutcome::CommitOutcomeUnknown),
371            };
372            if report.terminal_rollback {
373                context.acknowledge_terminal_rollback();
374            }
375            *self
376                .last_report
377                .lock()
378                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(report);
379            outcome
380        })
381    }
382}
383
384/// Combined repository lifecycle and chunk-orchestration result.
385#[derive(Clone, Debug, Eq, PartialEq)]
386pub struct ChunkLaunchReport {
387    launch: LaunchReport,
388    chunk: Option<ChunkExecutionReport>,
389}
390
391impl ChunkLaunchReport {
392    /// Borrows the persisted job/step lifecycle result.
393    #[must_use]
394    pub const fn launch(&self) -> &LaunchReport {
395        &self.launch
396    }
397
398    /// Borrows chunk evidence when user work reached the chunk step.
399    ///
400    /// A stop or before-listener failure can finish the launch without
401    /// invoking the chunk body.
402    #[must_use]
403    pub const fn chunk(&self) -> Option<&ChunkExecutionReport> {
404        self.chunk.as_ref()
405    }
406}
407
408impl JobLauncher<'_> {
409    /// Launches a stateful one-step chunk job through the existing repository,
410    /// lifecycle-listener, and event contracts.
411    ///
412    /// The mutable job borrow prevents concurrent use of one stateful reader.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`LaunchError`] when repository metadata cannot reach a final
417    /// state. Component failures are persisted and returned in the reports.
418    pub async fn launch_chunk<I, O>(
419        &self,
420        job: &mut ChunkJob<I, O>,
421        parameters: &JobParameters,
422        stop: &StopToken,
423    ) -> Result<ChunkLaunchReport, LaunchError>
424    where
425        I: Send + Sync + 'static,
426        O: Send + Sync + 'static,
427    {
428        job.tasklet.clear_last_report();
429        let tasklet: Arc<dyn Tasklet> = job.tasklet.clone();
430        let mut tasklet_step = TaskletStep::new(job.step_name.clone(), tasklet);
431        for listener in &job.step_listeners {
432            tasklet_step = tasklet_step.with_listener(Arc::clone(listener));
433        }
434        let mut tasklet_job =
435            TaskletJob::from_lowered_plan(job.name.clone(), tasklet_step, job.plan.clone());
436        for listener in &job.listeners {
437            tasklet_job = tasklet_job.with_listener(Arc::clone(listener));
438        }
439
440        let launch = self.launch(&tasklet_job, parameters, stop).await?;
441        let chunk = job.tasklet.take_last_report();
442        Ok(ChunkLaunchReport { launch, chunk })
443    }
444}
445
446/// Read-only state supplied at a chunk-listener boundary.
447#[derive(Clone, Copy, Debug)]
448pub struct ChunkListenerContext<'a> {
449    sequence: ChunkCount,
450    committed_counts: ChunkCounts,
451    stop: &'a StopToken,
452}
453
454impl<'a> ChunkListenerContext<'a> {
455    const fn new(sequence: ChunkCount, committed_counts: ChunkCounts, stop: &'a StopToken) -> Self {
456        Self {
457            sequence,
458            committed_counts,
459            stop,
460        }
461    }
462
463    /// Returns the nonzero chunk-attempt sequence.
464    #[must_use]
465    pub const fn sequence(self) -> ChunkCount {
466        self.sequence
467    }
468
469    /// Returns counts from chunks committed before this attempt.
470    #[must_use]
471    pub const fn committed_counts(self) -> ChunkCounts {
472        self.committed_counts
473    }
474
475    /// Borrows the cooperative stop token.
476    #[must_use]
477    pub const fn stop_token(self) -> &'a StopToken {
478        self.stop
479    }
480}
481
482/// The result visible to an after-chunk listener.
483#[derive(Clone, Copy, Debug, Eq, PartialEq)]
484#[non_exhaustive]
485pub enum ChunkAttemptOutcome {
486    /// The chunk transaction committed.
487    Committed,
488    /// The transaction rolled back after a failure.
489    RolledBack,
490    /// Cooperative stop rolled back the open attempt.
491    Stopped,
492    /// The commit result is unknown and must not be guessed.
493    Unknown,
494}
495
496/// A value-redacted chunk-listener failure.
497#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
498pub struct ChunkListenerError;
499
500impl ChunkListenerError {
501    /// Constructs a listener error without retaining application data.
502    #[must_use]
503    pub const fn new() -> Self {
504        Self
505    }
506}
507
508impl fmt::Display for ChunkListenerError {
509    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
510        formatter.write_str("chunk listener failed")
511    }
512}
513
514impl std::error::Error for ChunkListenerError {}
515
516/// Observes a chunk attempt around its transaction body.
517pub trait ChunkListener: Send + Sync {
518    /// Runs before the transaction begins.
519    fn before_chunk<'a>(
520        &'a self,
521        context: ChunkListenerContext<'a>,
522    ) -> BoxFuture<'a, Result<(), ChunkListenerError>>;
523
524    /// Runs after commit, rollback, stop, or an unknown commit result.
525    fn after_chunk<'a>(
526        &'a self,
527        context: ChunkListenerContext<'a>,
528        outcome: ChunkAttemptOutcome,
529    ) -> BoxFuture<'a, Result<(), ChunkListenerError>>;
530}
531
532/// Whether a chunk listener returned an error or panicked.
533#[derive(Clone, Copy, Debug, Eq, PartialEq)]
534#[non_exhaustive]
535pub enum ChunkListenerFailureKind {
536    /// The listener returned a classified error.
537    Error,
538    /// The listener panicked before or while its future was polled.
539    Panic,
540}
541
542/// The listener callback phase.
543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
544#[non_exhaustive]
545pub enum ChunkListenerPhase {
546    /// Before the transaction body.
547    BeforeChunk,
548    /// After the transaction outcome.
549    AfterChunk,
550}
551
552/// One redacted chunk-listener failure in callback execution order.
553#[derive(Clone, Copy, Debug, Eq, PartialEq)]
554pub struct ChunkListenerFailure {
555    phase: ChunkListenerPhase,
556    registration_index: usize,
557    kind: ChunkListenerFailureKind,
558}
559
560impl ChunkListenerFailure {
561    const fn new(
562        phase: ChunkListenerPhase,
563        registration_index: usize,
564        kind: ChunkListenerFailureKind,
565    ) -> Self {
566        Self {
567            phase,
568            registration_index,
569            kind,
570        }
571    }
572
573    /// Returns the callback phase.
574    #[must_use]
575    pub const fn phase(self) -> ChunkListenerPhase {
576        self.phase
577    }
578
579    /// Returns the zero-based registration index.
580    #[must_use]
581    pub const fn registration_index(self) -> usize {
582        self.registration_index
583    }
584
585    /// Returns whether the listener errored or panicked.
586    #[must_use]
587    pub const fn kind(self) -> ChunkListenerFailureKind {
588        self.kind
589    }
590}
591
592/// Stable phase classification for a failed chunk step.
593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
594#[non_exhaustive]
595pub enum ChunkFailure {
596    /// Checked count arithmetic rejected the attempted update.
597    Count,
598    /// The reader returned an error.
599    Reader,
600    /// The reader panicked.
601    ReaderPanic,
602    /// The processor returned an error.
603    Processor,
604    /// The processor panicked.
605    ProcessorPanic,
606    /// The writer returned an error.
607    Writer,
608    /// The writer panicked.
609    WriterPanic,
610    /// A chunk transaction could not begin.
611    TransactionBegin,
612    /// A chunk transaction was known not to commit.
613    TransactionCommit,
614    /// Rollback itself failed.
615    TransactionRollback,
616    /// The post-commit completion callback returned an error.
617    Completion,
618    /// The post-commit completion callback panicked.
619    CompletionPanic,
620    /// A chunk listener returned an error.
621    Listener,
622    /// A chunk listener panicked.
623    ListenerPanic,
624    /// An item, retry, or skip listener returned an error.
625    ItemListener,
626    /// An item, retry, or skip listener panicked.
627    ItemListenerPanic,
628    /// A retry ordinal could not be reserved durably.
629    RetryReservation,
630    /// Durable fault state was unusable, so no component work began.
631    FaultState,
632    /// The step already retains its maximum unresolved retry keys.
633    RetryStateExhausted,
634    /// The selected resource cannot honour the declared policy.
635    UnsupportedCapability,
636}
637
638/// Final result of deterministic chunk orchestration.
639#[derive(Clone, Copy, Debug, Eq, PartialEq)]
640#[non_exhaustive]
641pub enum ChunkExecutionOutcome {
642    /// End of input was reached and every open chunk committed.
643    Completed,
644    /// Cooperative stop was observed at a safe chunk boundary.
645    Stopped,
646    /// A typed component, listener, count, or transaction failure occurred.
647    Failed(ChunkFailure),
648    /// Commit outcome is unknown; replay requires durable recovery evidence.
649    Unknown,
650}
651
652/// In-memory execution evidence returned by a chunk step.
653#[derive(Clone, Debug, Eq, PartialEq)]
654pub struct ChunkExecutionReport {
655    outcome: ChunkExecutionOutcome,
656    original_outcome: Option<ChunkExecutionOutcome>,
657    committed_counts: ChunkCounts,
658    committed_chunks: ChunkCount,
659    rolled_back_chunks: ChunkCount,
660    listener_failures: Vec<ChunkListenerFailure>,
661    item_listener_failures: Vec<ItemListenerFailure>,
662    skip_counts: SkipCounts,
663    retry_counts: RetryCounts,
664    rollback_count: u64,
665    no_rollback_count: u64,
666    terminal_rollback: bool,
667}
668
669impl ChunkExecutionReport {
670    /// Returns the final chunk-step result.
671    #[must_use]
672    pub const fn outcome(&self) -> ChunkExecutionOutcome {
673        self.outcome
674    }
675
676    /// Returns the result superseded by an after-listener failure.
677    #[must_use]
678    pub const fn original_outcome(&self) -> Option<ChunkExecutionOutcome> {
679        self.original_outcome
680    }
681
682    /// Returns aggregate counts from committed chunks only.
683    #[must_use]
684    pub const fn committed_counts(&self) -> ChunkCounts {
685        self.committed_counts
686    }
687
688    /// Returns the number of committed chunk transactions.
689    #[must_use]
690    pub const fn committed_chunks(&self) -> ChunkCount {
691        self.committed_chunks
692    }
693
694    /// Returns the number of rolled-back chunk transactions.
695    #[must_use]
696    pub const fn rolled_back_chunks(&self) -> ChunkCount {
697        self.rolled_back_chunks
698    }
699
700    /// Borrows listener failures in callback execution order.
701    #[must_use]
702    pub fn listener_failures(&self) -> &[ChunkListenerFailure] {
703        &self.listener_failures
704    }
705
706    /// Borrows item, retry, and skip listener failures in execution order.
707    #[must_use]
708    pub fn item_listener_failures(&self) -> &[ItemListenerFailure] {
709        &self.item_listener_failures
710    }
711
712    /// Returns committed per-phase skip counts.
713    ///
714    /// A skip appears here only after the chunk that accepted it committed. On
715    /// a repository-backed run the totals include the counts this attempt
716    /// inherited, because the aggregate skip limit spans every attempt of one
717    /// job instance.
718    #[must_use]
719    pub const fn skip_counts(&self) -> SkipCounts {
720        self.skip_counts
721    }
722
723    /// Returns per-phase counts of durably reserved retry ordinals.
724    ///
725    /// The counts include the ordinals this attempt inherited.
726    #[must_use]
727    pub const fn retry_counts(&self) -> RetryCounts {
728        self.retry_counts
729    }
730
731    /// Returns framework rollback decisions with a durable acknowledgement.
732    ///
733    /// A retry reservation and a terminal known rollback each add one. A
734    /// database abort caused by process death is not counted. Unlike the skip
735    /// and retry counts this value is scoped to the current attempt.
736    #[must_use]
737    pub const fn rollback_count(&self) -> u64 {
738        self.rollback_count
739    }
740
741    /// Returns commits that accepted a
742    /// [`RollbackDisposition::CommitSafeSkip`], including inherited ones.
743    #[must_use]
744    pub const fn no_rollback_count(&self) -> u64 {
745        self.no_rollback_count
746    }
747}
748
749#[derive(Clone, Copy, Debug, Eq, PartialEq)]
750pub(crate) enum ChunkRuntimeEvent {
751    Started(ChunkCount),
752    Committed(ChunkCount),
753    RolledBack(ChunkCount),
754    Unknown(ChunkCount),
755    Fault(FaultRuntimeEvent),
756}
757
758/// A post-decision fault observation with only reviewed bounded fields.
759#[derive(Clone, Copy, Debug, Eq, PartialEq)]
760pub(crate) struct FaultRuntimeEvent {
761    pub(crate) kind: LifecycleEventKind,
762    pub(crate) sequence: ChunkCount,
763    pub(crate) phase: FaultPhase,
764    pub(crate) summary: Option<FailureSummary>,
765    pub(crate) ordinal: Option<RetryOrdinal>,
766    pub(crate) backoff: Option<Duration>,
767}
768
769impl FaultRuntimeEvent {
770    const fn new(kind: LifecycleEventKind, sequence: ChunkCount, phase: FaultPhase) -> Self {
771        Self {
772            kind,
773            sequence,
774            phase,
775            summary: None,
776            ordinal: None,
777            backoff: None,
778        }
779    }
780
781    const fn with_summary(mut self, summary: FailureSummary) -> Self {
782        self.summary = Some(summary);
783        self
784    }
785
786    const fn with_ordinal(mut self, ordinal: RetryOrdinal) -> Self {
787        self.ordinal = Some(ordinal);
788        self
789    }
790
791    const fn with_backoff(mut self, backoff: Duration) -> Self {
792        self.backoff = Some(backoff);
793        self
794    }
795}
796
797struct ExecutionState {
798    committed_counts: ChunkCounts,
799    committed_chunks: ChunkCount,
800    rolled_back_chunks: ChunkCount,
801    listener_failures: Vec<ChunkListenerFailure>,
802    item_listener_failures: Vec<ItemListenerFailure>,
803    skip_counts: SkipCounts,
804    retry_counts: RetryCounts,
805    rollback_count: u64,
806    no_rollback_count: u64,
807    terminal_rollback: bool,
808    next_failure_id: u64,
809}
810
811impl ExecutionState {
812    fn new() -> Self {
813        Self::inheriting(FaultProgress::NONE)
814    }
815
816    /// Starts an attempt from the totals its durable predecessor committed.
817    fn inheriting(inherited: FaultProgress) -> Self {
818        Self {
819            committed_counts: ChunkCounts::default(),
820            committed_chunks: ChunkCount::ZERO,
821            rolled_back_chunks: ChunkCount::ZERO,
822            listener_failures: Vec::new(),
823            item_listener_failures: Vec::new(),
824            skip_counts: inherited.skips(),
825            retry_counts: inherited.retries(),
826            rollback_count: 0,
827            no_rollback_count: inherited.no_rollbacks(),
828            terminal_rollback: false,
829            next_failure_id: 0,
830        }
831    }
832
833    /// Preserves step-scoped fault evidence while resetting per-attempt state.
834    fn drain(&mut self) -> Self {
835        let mut replacement = Self::new();
836        replacement.skip_counts = self.skip_counts;
837        replacement.retry_counts = self.retry_counts;
838        replacement.rollback_count = self.rollback_count;
839        replacement.no_rollback_count = self.no_rollback_count;
840        replacement.terminal_rollback = self.terminal_rollback;
841        replacement.next_failure_id = self.next_failure_id;
842        std::mem::replace(self, replacement)
843    }
844
845    fn report(
846        self,
847        outcome: ChunkExecutionOutcome,
848        original_outcome: Option<ChunkExecutionOutcome>,
849    ) -> ChunkExecutionReport {
850        ChunkExecutionReport {
851            outcome,
852            original_outcome,
853            committed_counts: self.committed_counts,
854            committed_chunks: self.committed_chunks,
855            rolled_back_chunks: self.rolled_back_chunks,
856            listener_failures: self.listener_failures,
857            item_listener_failures: self.item_listener_failures,
858            skip_counts: self.skip_counts,
859            retry_counts: self.retry_counts,
860            rollback_count: self.rollback_count,
861            no_rollback_count: self.no_rollback_count,
862            terminal_rollback: self.terminal_rollback,
863        }
864    }
865}
866
867/// One buffered input retained across the retry replays of a chunk.
868struct ItemSlot<I> {
869    item: I,
870    ordinal: u64,
871    skipped: bool,
872}
873
874/// One accepted skip awaiting the commit that makes it authoritative.
875struct PendingSkip<O> {
876    phase: FaultPhase,
877    fault: FaultDescriptor,
878    disposition: RollbackDisposition,
879    slot: Option<usize>,
880    output: Option<O>,
881}
882
883/// The in-flight retry scope for one key.
884struct PendingRetry {
885    key: RetryKey,
886    fault: FaultDescriptor,
887    entered: usize,
888}
889
890/// Chunk-scoped work that survives a rollback and its replay.
891///
892/// A retry rolls the open transaction back and replays the chunk. The reader is
893/// stateful and cannot rewind in process, so already-read inputs stay buffered
894/// and the replay re-invokes only the components that have not yet succeeded.
895struct ChunkBuffer<I, O> {
896    slots: Vec<ItemSlot<I>>,
897    skips: Vec<PendingSkip<O>>,
898    retry: Option<PendingRetry>,
899    end_of_input: bool,
900    base_ordinal: u64,
901    read_ordinal: u64,
902    checkpoint_digest: [u8; 32],
903}
904
905impl<I, O> ChunkBuffer<I, O> {
906    const fn new(base_ordinal: u64, checkpoint_digest: [u8; 32]) -> Self {
907        Self {
908            slots: Vec::new(),
909            skips: Vec::new(),
910            retry: None,
911            end_of_input: false,
912            base_ordinal,
913            read_ordinal: base_ordinal,
914            checkpoint_digest,
915        }
916    }
917}
918
919/// Returns the fault-tolerance deltas one chunk commit makes authoritative.
920fn accepted_fault_progress<O>(skips: &[PendingSkip<O>]) -> Option<ChunkFaultProgress> {
921    let mut counts = SkipCounts::ZERO;
922    let mut no_rollbacks = 0_u64;
923    for skip in skips {
924        counts = counts.checked_increment(skip.phase).ok()?;
925        if skip.disposition == RollbackDisposition::CommitSafeSkip {
926            no_rollbacks = no_rollbacks.checked_add(1)?;
927        }
928    }
929    Some(ChunkFaultProgress::new(counts, no_rollbacks))
930}
931
932/// Returns durable skips plus the skips one chunk has not yet committed.
933fn projected_skips<O>(committed: SkipCounts, skips: &[PendingSkip<O>]) -> Option<SkipCounts> {
934    skips.iter().try_fold(committed, |counts, skip| {
935        counts.checked_increment(skip.phase).ok()
936    })
937}
938
939/// The verdict of one chunk attempt body, before rollback or commit.
940enum Verdict {
941    /// Every buffered input is classified; the attempt may commit.
942    Commit,
943    /// A retryable fault ends the attempt, reserves an ordinal, and replays.
944    Retry(RetryRequest),
945    /// An accepted rollback skip ends the attempt and replays the chunk.
946    Replay,
947    /// The attempt is terminal.
948    Terminal(ChunkExecutionOutcome),
949}
950
951/// One retry, reserved after rollback and before backoff.
952struct RetryRequest {
953    key: RetryKey,
954    phase: FaultPhase,
955    fault: FaultDescriptor,
956    ordinal: RetryOrdinal,
957    delay: Duration,
958}
959
960/// The result of one complete chunk attempt.
961enum AttemptResult {
962    /// The chunk transaction committed.
963    Committed {
964        counts: ChunkCounts,
965        receipt: ChunkCommitReceipt,
966    },
967    /// The attempt rolled back and the chunk replays.
968    Replay,
969    /// The attempt rolled back and the step is finished.
970    RolledBack(ChunkExecutionOutcome),
971    /// Rollback itself failed after an earlier outcome.
972    RollbackFailed(Option<ChunkExecutionOutcome>),
973    /// The commit outcome is unknown and must never be guessed.
974    Unknown,
975}
976
977/// Borrowed components and policy for one chunk step.
978struct Components<'a, I, O> {
979    processor: &'a dyn ItemProcessor<I, O>,
980    writer: &'a dyn ItemWriter<O>,
981    item_listeners: &'a ItemListenerSet<I, O>,
982    fault: Option<&'a FaultRuntime>,
983    step_name: &'a StepName,
984    definition_digest: [u8; 32],
985    size: ChunkSize,
986}
987
988/// Borrowed call state for one chunk attempt.
989#[derive(Clone, Copy)]
990struct AttemptScope<'a> {
991    correlation: &'a ExecutionCorrelation,
992    stop: &'a StopToken,
993    sequence: ChunkCount,
994}
995
996impl<'a> AttemptScope<'a> {
997    const fn listener_context(self) -> ItemListenerContext<'a> {
998        ItemListenerContext::new(self.correlation, self.sequence, self.stop)
999    }
1000}
1001
1002/// Provisional writer input for one attempt.
1003struct AttemptOutputs<O> {
1004    values: Vec<O>,
1005    slots: Vec<usize>,
1006    filtered: u64,
1007}
1008
1009impl<O> AttemptOutputs<O> {
1010    const fn new() -> Self {
1011        Self {
1012            values: Vec::new(),
1013            slots: Vec::new(),
1014            filtered: 0,
1015        }
1016    }
1017
1018    fn reset(&mut self) {
1019        self.values.clear();
1020        self.slots.clear();
1021        self.filtered = 0;
1022    }
1023}
1024
1025/// One component invocation classified without inspecting its payload.
1026enum Invoked<T, E> {
1027    Completed(T),
1028    Failed(E),
1029    Panicked,
1030}
1031
1032#[allow(
1033    clippy::similar_names,
1034    clippy::too_many_lines,
1035    reason = "the chunk loop keeps the canonical attempt, commit, and stop order visible"
1036)]
1037pub(crate) async fn execute_chunk_step<I, O>(
1038    step: &mut ChunkStep<I, O>,
1039    correlation: &ExecutionCorrelation,
1040    stop: &StopToken,
1041    transaction_context: Option<ChunkTransactionContext>,
1042    mut emit: impl FnMut(ChunkRuntimeEvent),
1043) -> ChunkExecutionReport
1044where
1045    I: Send + Sync,
1046    O: Send + Sync,
1047{
1048    let ChunkStep {
1049        name,
1050        size,
1051        reader,
1052        processor,
1053        writer,
1054        transactions,
1055        completion,
1056        listeners,
1057        item_listeners,
1058        fault,
1059        in_flight_policy,
1060        definition_digest,
1061        ..
1062    } = step;
1063    let components = Components {
1064        processor: processor.as_ref(),
1065        writer: writer.as_ref(),
1066        item_listeners,
1067        fault: fault.as_ref(),
1068        step_name: name,
1069        definition_digest: *definition_digest,
1070        size: *size,
1071    };
1072
1073    let inherited = match inherited_progress(
1074        transactions.as_ref(),
1075        fault.as_ref(),
1076        transaction_context,
1077    )
1078    .await
1079    {
1080        Ok(inherited) => inherited,
1081        Err(outcome) => return ExecutionState::new().report(outcome, None),
1082    };
1083    let base_ordinal = inherited.read_ordinal();
1084    let mut state = ExecutionState::inheriting(inherited.fault());
1085    let mut sequence = ChunkCount::ZERO;
1086    let mut buffer = ChunkBuffer::new(base_ordinal, inherited.checkpoint_digest());
1087
1088    loop {
1089        if stop.is_stop_requested() {
1090            return state.report(ChunkExecutionOutcome::Stopped, None);
1091        }
1092        sequence = match sequence.checked_increment() {
1093            Ok(value) => value,
1094            Err(_) => {
1095                return state.report(ChunkExecutionOutcome::Failed(ChunkFailure::Count), None);
1096            }
1097        };
1098        let listener_context = ChunkListenerContext::new(sequence, state.committed_counts, stop);
1099
1100        if let Some(failure) = run_before_listeners(listeners, listener_context).await {
1101            let outcome = listener_failure_outcome(failure.kind());
1102            state.listener_failures.push(failure);
1103            return state.report(outcome, None);
1104        }
1105        if stop.is_stop_requested() {
1106            return state.report(ChunkExecutionOutcome::Stopped, None);
1107        }
1108
1109        let begun = match transaction_context {
1110            Some(context) => transactions.begin_for(context).await,
1111            None => transactions.begin().await,
1112        };
1113        let mut transaction = match begun {
1114            Ok(transaction) => transaction,
1115            Err(ChunkTransactionError::NotCommitted) => {
1116                return finish_failed_attempt(
1117                    listeners,
1118                    listener_context,
1119                    ChunkAttemptOutcome::RolledBack,
1120                    ChunkExecutionOutcome::Failed(ChunkFailure::TransactionBegin),
1121                    &mut state,
1122                )
1123                .await;
1124            }
1125            Err(ChunkTransactionError::CommitOutcomeUnknown) => {
1126                return finish_failed_attempt(
1127                    listeners,
1128                    listener_context,
1129                    ChunkAttemptOutcome::Unknown,
1130                    ChunkExecutionOutcome::Unknown,
1131                    &mut state,
1132                )
1133                .await;
1134            }
1135        };
1136        emit(ChunkRuntimeEvent::Started(sequence));
1137
1138        // Once a chunk is open, the definition decides whether a shutdown
1139        // request remains visible to component calls. The masked token is
1140        // scoped to this attempt; the real token is consulted immediately
1141        // after commit so `FinishChunk` never starts another chunk.
1142        let masked_stop;
1143        let attempt_stop = match in_flight_policy {
1144            InFlightPolicy::FinishChunk => {
1145                let (_, token) = crate::StopSource::new();
1146                masked_stop = token;
1147                &masked_stop
1148            }
1149            // `InFlightPolicy::RollbackChunk`, and any policy this build does
1150            // not know: `InFlightPolicy` is `#[non_exhaustive]`, and an
1151            // unrecognized policy never masks a shutdown request.
1152            _ => stop,
1153        };
1154        let scope = AttemptScope {
1155            correlation,
1156            stop: attempt_stop,
1157            sequence,
1158        };
1159
1160        let result = run_attempt(
1161            &components,
1162            reader.as_mut(),
1163            scope,
1164            &mut buffer,
1165            &mut state,
1166            transaction.as_mut(),
1167            &mut emit,
1168        )
1169        .await;
1170        drop(transaction);
1171
1172        match result {
1173            AttemptResult::Committed { counts, receipt } => {
1174                let Ok(next_counts) = state.committed_counts.checked_add(counts) else {
1175                    return finish_failed_attempt(
1176                        listeners,
1177                        listener_context,
1178                        ChunkAttemptOutcome::Committed,
1179                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1180                        &mut state,
1181                    )
1182                    .await;
1183                };
1184                let Ok(next_chunks) = state.committed_chunks.checked_increment() else {
1185                    return finish_failed_attempt(
1186                        listeners,
1187                        listener_context,
1188                        ChunkAttemptOutcome::Committed,
1189                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1190                        &mut state,
1191                    )
1192                    .await;
1193                };
1194                state.committed_counts = next_counts;
1195                state.committed_chunks = next_chunks;
1196                emit(ChunkRuntimeEvent::Committed(sequence));
1197                emit_committed_skips(&buffer, sequence, &mut emit);
1198
1199                let end_of_input = buffer.end_of_input;
1200                let checkpoint_digest = checkpoint_digest(receipt.checkpoint());
1201                let Some(next_ordinal) =
1202                    base_ordinal.checked_add(state.committed_counts.read().get())
1203                else {
1204                    return finish_failed_attempt(
1205                        listeners,
1206                        listener_context,
1207                        ChunkAttemptOutcome::Committed,
1208                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1209                        &mut state,
1210                    )
1211                    .await;
1212                };
1213                buffer = ChunkBuffer::new(next_ordinal, checkpoint_digest);
1214
1215                let completion_context = ChunkCompletionContext::new(
1216                    receipt.checkpoint(),
1217                    receipt.execution_context(),
1218                    counts,
1219                    stop,
1220                );
1221                let terminal_outcome =
1222                    match invoke_completion(completion.as_ref(), completion_context).await {
1223                        Ok(ChunkCompletionOutcome::Acknowledged) => {
1224                            if stop.is_stop_requested() {
1225                                Some(ChunkExecutionOutcome::Stopped)
1226                            } else if end_of_input {
1227                                Some(ChunkExecutionOutcome::Completed)
1228                            } else {
1229                                None
1230                            }
1231                        }
1232                        Ok(ChunkCompletionOutcome::StoppedAfterCommit) => {
1233                            Some(ChunkExecutionOutcome::Stopped)
1234                        }
1235                        Err(failure) => Some(ChunkExecutionOutcome::Failed(failure)),
1236                    };
1237
1238                let after_context =
1239                    ChunkListenerContext::new(sequence, state.committed_counts, stop);
1240                let after_failures =
1241                    run_after_listeners(listeners, after_context, ChunkAttemptOutcome::Committed)
1242                        .await;
1243                if let Some(first) = after_failures.first().copied() {
1244                    state.listener_failures.extend(after_failures);
1245                    let original = terminal_outcome.or(Some(ChunkExecutionOutcome::Completed));
1246                    return state.report(listener_failure_outcome(first.kind()), original);
1247                }
1248                if let Some(outcome) = terminal_outcome {
1249                    return state.report(outcome, None);
1250                }
1251            }
1252            AttemptResult::Replay => {
1253                if let Err(report) =
1254                    record_rolled_back_attempt(listeners, listener_context, &mut state, &mut emit)
1255                        .await
1256                {
1257                    return report;
1258                }
1259            }
1260            AttemptResult::RolledBack(ChunkExecutionOutcome::Completed) => {
1261                // The final chunk read nothing, so its unused transaction rolls
1262                // back without counting as a rolled-back attempt.
1263                emit(ChunkRuntimeEvent::RolledBack(sequence));
1264                let failures = run_after_listeners(
1265                    listeners,
1266                    listener_context,
1267                    ChunkAttemptOutcome::RolledBack,
1268                )
1269                .await;
1270                if let Some(first) = failures.first().copied() {
1271                    state.listener_failures.extend(failures);
1272                    return state
1273                        .drain()
1274                        .report(listener_failure_outcome(first.kind()), None);
1275                }
1276                return state.report(ChunkExecutionOutcome::Completed, None);
1277            }
1278            AttemptResult::RolledBack(outcome) => {
1279                state.rollback_count = state.rollback_count.saturating_add(1);
1280                state.terminal_rollback = true;
1281                state.rolled_back_chunks = match state.rolled_back_chunks.checked_increment() {
1282                    Ok(count) => count,
1283                    Err(_) => {
1284                        return state.drain().report(
1285                            ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1286                            Some(outcome),
1287                        );
1288                    }
1289                };
1290                emit(ChunkRuntimeEvent::RolledBack(sequence));
1291                let attempt_outcome = match outcome {
1292                    ChunkExecutionOutcome::Stopped => ChunkAttemptOutcome::Stopped,
1293                    _ => ChunkAttemptOutcome::RolledBack,
1294                };
1295                return finish_failed_attempt(
1296                    listeners,
1297                    listener_context,
1298                    attempt_outcome,
1299                    outcome,
1300                    &mut state,
1301                )
1302                .await;
1303            }
1304            AttemptResult::RollbackFailed(original) => {
1305                return state.drain().report(
1306                    ChunkExecutionOutcome::Failed(ChunkFailure::TransactionRollback),
1307                    original,
1308                );
1309            }
1310            AttemptResult::Unknown => {
1311                emit(ChunkRuntimeEvent::Unknown(sequence));
1312                return finish_failed_attempt(
1313                    listeners,
1314                    listener_context,
1315                    ChunkAttemptOutcome::Unknown,
1316                    ChunkExecutionOutcome::Unknown,
1317                    &mut state,
1318                )
1319                .await;
1320            }
1321        }
1322    }
1323}
1324
1325/// Loads the durable progress this attempt inherits and binds durable state.
1326///
1327/// A standalone chunk step has no repository execution and inherits nothing. A
1328/// repository-backed step fails closed rather than restarting a bounded policy
1329/// limit from zero or deriving retry keys from the wrong checkpoint.
1330async fn inherited_progress(
1331    transactions: &dyn ChunkTransactionManager,
1332    fault: Option<&FaultRuntime>,
1333    context: Option<ChunkTransactionContext>,
1334) -> Result<InheritedStepProgress, ChunkExecutionOutcome> {
1335    let Some(context) = context else {
1336        return Ok(InheritedStepProgress::NONE);
1337    };
1338    if let Some(fault) = fault
1339        && fault.state().bind(context).await.is_err()
1340    {
1341        return Err(ChunkExecutionOutcome::Failed(ChunkFailure::FaultState));
1342    }
1343    match transactions.inherited_progress(context).await {
1344        Ok(inherited) => Ok(inherited),
1345        Err(ChunkTransactionError::CommitOutcomeUnknown) => Err(ChunkExecutionOutcome::Unknown),
1346        Err(ChunkTransactionError::NotCommitted) => {
1347            Err(ChunkExecutionOutcome::Failed(ChunkFailure::FaultState))
1348        }
1349    }
1350}
1351
1352/// Counts one rolled-back attempt and runs its after-chunk listeners.
1353async fn record_rolled_back_attempt<E>(
1354    listeners: &[Arc<dyn ChunkListener>],
1355    context: ChunkListenerContext<'_>,
1356    state: &mut ExecutionState,
1357    emit: &mut E,
1358) -> Result<(), ChunkExecutionReport>
1359where
1360    E: FnMut(ChunkRuntimeEvent),
1361{
1362    state.rolled_back_chunks = match state.rolled_back_chunks.checked_increment() {
1363        Ok(count) => count,
1364        Err(_) => {
1365            return Err(state
1366                .drain()
1367                .report(ChunkExecutionOutcome::Failed(ChunkFailure::Count), None));
1368        }
1369    };
1370    emit(ChunkRuntimeEvent::RolledBack(context.sequence()));
1371    let failures = run_after_listeners(listeners, context, ChunkAttemptOutcome::RolledBack).await;
1372    if let Some(first) = failures.first().copied() {
1373        state.listener_failures.extend(failures);
1374        return Err(state
1375            .drain()
1376            .report(listener_failure_outcome(first.kind()), None));
1377    }
1378    Ok(())
1379}
1380
1381async fn run_attempt<I, O, E>(
1382    components: &Components<'_, I, O>,
1383    reader: &mut dyn ItemReader<I>,
1384    scope: AttemptScope<'_>,
1385    buffer: &mut ChunkBuffer<I, O>,
1386    state: &mut ExecutionState,
1387    transaction: &mut dyn ChunkTransaction,
1388    emit: &mut E,
1389) -> AttemptResult
1390where
1391    I: Send + Sync,
1392    O: Send + Sync,
1393    E: FnMut(ChunkRuntimeEvent),
1394{
1395    let mut outputs = AttemptOutputs::new();
1396
1397    let verdict = 'body: {
1398        if let Some(fault) = components.fault
1399            && fault.policy().requires_commit_safe_skip()
1400            && transaction.business_transaction().is_none()
1401        {
1402            break 'body Verdict::Terminal(ChunkExecutionOutcome::Failed(
1403                ChunkFailure::UnsupportedCapability,
1404            ));
1405        }
1406
1407        match read_phase(components, reader, scope, buffer, state, emit).await {
1408            Verdict::Commit => {}
1409            other => break 'body other,
1410        }
1411
1412        if buffer.slots.is_empty() && buffer.end_of_input && buffer.skips.is_empty() {
1413            break 'body Verdict::Terminal(ChunkExecutionOutcome::Completed);
1414        }
1415
1416        match process_phase(components, scope, buffer, state, &mut outputs, emit).await {
1417            Verdict::Commit => {}
1418            other => break 'body other,
1419        }
1420
1421        write_phase(
1422            components,
1423            scope,
1424            buffer,
1425            state,
1426            &mut outputs,
1427            transaction,
1428            emit,
1429        )
1430        .await
1431    };
1432
1433    match verdict {
1434        Verdict::Commit => {
1435            commit_attempt(components, scope, buffer, state, transaction, &outputs).await
1436        }
1437        Verdict::Retry(request) => {
1438            schedule_retry(components, scope, buffer, state, transaction, request, emit).await
1439        }
1440        Verdict::Replay => {
1441            if transaction.rollback().await.is_err() {
1442                return AttemptResult::RollbackFailed(None);
1443            }
1444            AttemptResult::Replay
1445        }
1446        Verdict::Terminal(ChunkExecutionOutcome::Unknown) => AttemptResult::Unknown,
1447        Verdict::Terminal(outcome) => {
1448            if transaction.rollback().await.is_err() {
1449                return AttemptResult::RollbackFailed(Some(outcome));
1450            }
1451            AttemptResult::RolledBack(outcome)
1452        }
1453    }
1454}
1455
1456/// Reads until the chunk is full or the input ends.
1457#[allow(
1458    clippy::too_many_lines,
1459    reason = "one phase keeps its listener, classification, and skip order visible"
1460)]
1461async fn read_phase<I, O, E>(
1462    components: &Components<'_, I, O>,
1463    reader: &mut dyn ItemReader<I>,
1464    scope: AttemptScope<'_>,
1465    buffer: &mut ChunkBuffer<I, O>,
1466    state: &mut ExecutionState,
1467    emit: &mut E,
1468) -> Verdict
1469where
1470    I: Send + Sync,
1471    O: Send + Sync,
1472    E: FnMut(ChunkRuntimeEvent),
1473{
1474    let listener_context = scope.listener_context();
1475    while !buffer.end_of_input && buffer.slots.len() < components.size.get() as usize {
1476        if scope.stop.is_stop_requested() {
1477            return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1478        }
1479        let ordinal = buffer.read_ordinal;
1480        let key = retry_key(
1481            components,
1482            buffer.checkpoint_digest,
1483            FaultPhase::Read,
1484            ordinal,
1485        );
1486
1487        let before = components
1488            .item_listeners
1489            .before_read(listener_context)
1490            .await;
1491        if let Some(failure) = before.failure() {
1492            state.item_listener_failures.push(failure);
1493            return Verdict::Terminal(item_listener_outcome(failure.kind()));
1494        }
1495
1496        match invoke_reader(reader, ReadContext::new(scope.stop)).await {
1497            Invoked::Completed(ReadOutcome::Item(item)) => {
1498                let failures = components
1499                    .item_listeners
1500                    .after_read(before.entered(), &item, listener_context)
1501                    .await;
1502                if let Some(first) = failures.first().copied() {
1503                    state.item_listener_failures.extend(failures);
1504                    return Verdict::Terminal(item_listener_outcome(first.kind()));
1505                }
1506                if let Some(outcome) = complete_retry(
1507                    components,
1508                    listener_context,
1509                    &mut buffer.retry,
1510                    state,
1511                    key,
1512                    RetryOutcome::Recovered,
1513                )
1514                .await
1515                {
1516                    return Verdict::Terminal(outcome);
1517                }
1518                resolve_key(components, key).await;
1519                buffer.slots.push(ItemSlot {
1520                    item,
1521                    ordinal,
1522                    skipped: false,
1523                });
1524                buffer.read_ordinal = buffer.read_ordinal.saturating_add(1);
1525            }
1526            Invoked::Completed(ReadOutcome::EndOfInput) => buffer.end_of_input = true,
1527            Invoked::Completed(ReadOutcome::Stopped) => {
1528                return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1529            }
1530            invoked => {
1531                let (error, panicked) = match invoked {
1532                    Invoked::Failed(error) => (error, false),
1533                    _ => (ReaderError::new(), true),
1534                };
1535                let terminal = if panicked {
1536                    ChunkFailure::ReaderPanic
1537                } else {
1538                    ChunkFailure::Reader
1539                };
1540                let advanced = !panicked && error.has_checkpoint_advanced();
1541                let Some(fault) = descriptor(
1542                    components,
1543                    state,
1544                    &buffer.skips,
1545                    FaultPhase::Read,
1546                    error.category(),
1547                ) else {
1548                    return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
1549                };
1550                let fault = with_reserved_ordinal(components, key, fault).await;
1551
1552                let failures = components
1553                    .item_listeners
1554                    .on_read_error(before.entered(), fault, listener_context)
1555                    .await;
1556                if let Some(first) = failures.first().copied() {
1557                    state.item_listener_failures.extend(failures);
1558                    return Verdict::Terminal(item_listener_outcome(first.kind()));
1559                }
1560
1561                let evidence = FaultEvidence::new(advanced, true, advanced);
1562                let decision = match classify(
1563                    components,
1564                    listener_context,
1565                    &mut buffer.retry,
1566                    state,
1567                    key,
1568                    fault,
1569                    evidence,
1570                    scope.sequence,
1571                    emit,
1572                )
1573                .await
1574                {
1575                    Ok(decision) => decision,
1576                    Err(outcome) => return Verdict::Terminal(outcome),
1577                };
1578                match decision {
1579                    FaultDecision::Retry { ordinal, delay } => {
1580                        return Verdict::Retry(RetryRequest {
1581                            key,
1582                            phase: FaultPhase::Read,
1583                            fault,
1584                            ordinal,
1585                            delay,
1586                        });
1587                    }
1588                    FaultDecision::Skip { disposition } => {
1589                        resolve_key(components, key).await;
1590                        buffer.read_ordinal = buffer.read_ordinal.saturating_add(1);
1591                        buffer.skips.push(PendingSkip {
1592                            phase: FaultPhase::Read,
1593                            fault,
1594                            disposition,
1595                            slot: None,
1596                            output: None,
1597                        });
1598                        if disposition == RollbackDisposition::Rollback {
1599                            return Verdict::Replay;
1600                        }
1601                    }
1602                    FaultDecision::Unknown => {
1603                        return Verdict::Terminal(ChunkExecutionOutcome::Unknown);
1604                    }
1605                    FaultDecision::Stop => {
1606                        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1607                    }
1608                    // `FaultDecision::FailAndRollback`, and any decision this
1609                    // build does not know: `FaultDecision` is
1610                    // `#[non_exhaustive]`, and an unrecognized decision rolls
1611                    // back and fails rather than committing work or claiming an
1612                    // unknown commit.
1613                    _ => {
1614                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
1615                    }
1616                }
1617            }
1618        }
1619    }
1620    Verdict::Commit
1621}
1622
1623/// Processes every buffered input that is not already skipped.
1624#[allow(
1625    clippy::too_many_lines,
1626    reason = "one phase keeps its listener, classification, and skip order visible"
1627)]
1628async fn process_phase<I, O, E>(
1629    components: &Components<'_, I, O>,
1630    scope: AttemptScope<'_>,
1631    buffer: &mut ChunkBuffer<I, O>,
1632    state: &mut ExecutionState,
1633    outputs: &mut AttemptOutputs<O>,
1634    emit: &mut E,
1635) -> Verdict
1636where
1637    I: Send + Sync,
1638    O: Send + Sync,
1639    E: FnMut(ChunkRuntimeEvent),
1640{
1641    let listener_context = scope.listener_context();
1642    outputs.reset();
1643    for index in 0..buffer.slots.len() {
1644        if buffer.slots[index].skipped {
1645            continue;
1646        }
1647        if scope.stop.is_stop_requested() {
1648            return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1649        }
1650        let ordinal = buffer.slots[index].ordinal;
1651        let key = retry_key(
1652            components,
1653            buffer.checkpoint_digest,
1654            FaultPhase::Process,
1655            ordinal,
1656        );
1657
1658        let before = components
1659            .item_listeners
1660            .before_process(&buffer.slots[index].item, listener_context)
1661            .await;
1662        if let Some(failure) = before.failure() {
1663            state.item_listener_failures.push(failure);
1664            return Verdict::Terminal(item_listener_outcome(failure.kind()));
1665        }
1666
1667        let invoked = invoke_processor(
1668            components.processor,
1669            &buffer.slots[index].item,
1670            ProcessContext::new(scope.stop),
1671        )
1672        .await;
1673        match invoked {
1674            Invoked::Completed(ProcessOutcome::Item(output)) => {
1675                let failures = components
1676                    .item_listeners
1677                    .after_process(
1678                        before.entered(),
1679                        &buffer.slots[index].item,
1680                        Some(&output),
1681                        listener_context,
1682                    )
1683                    .await;
1684                if let Some(first) = failures.first().copied() {
1685                    state.item_listener_failures.extend(failures);
1686                    return Verdict::Terminal(item_listener_outcome(first.kind()));
1687                }
1688                if let Some(outcome) = complete_retry(
1689                    components,
1690                    listener_context,
1691                    &mut buffer.retry,
1692                    state,
1693                    key,
1694                    RetryOutcome::Recovered,
1695                )
1696                .await
1697                {
1698                    return Verdict::Terminal(outcome);
1699                }
1700                resolve_key(components, key).await;
1701                outputs.values.push(output);
1702                outputs.slots.push(index);
1703            }
1704            Invoked::Completed(ProcessOutcome::Filtered) => {
1705                let failures = components
1706                    .item_listeners
1707                    .after_process(
1708                        before.entered(),
1709                        &buffer.slots[index].item,
1710                        None,
1711                        listener_context,
1712                    )
1713                    .await;
1714                if let Some(first) = failures.first().copied() {
1715                    state.item_listener_failures.extend(failures);
1716                    return Verdict::Terminal(item_listener_outcome(first.kind()));
1717                }
1718                if let Some(outcome) = complete_retry(
1719                    components,
1720                    listener_context,
1721                    &mut buffer.retry,
1722                    state,
1723                    key,
1724                    RetryOutcome::Recovered,
1725                )
1726                .await
1727                {
1728                    return Verdict::Terminal(outcome);
1729                }
1730                resolve_key(components, key).await;
1731                outputs.filtered = outputs.filtered.saturating_add(1);
1732            }
1733            Invoked::Completed(ProcessOutcome::Stopped) => {
1734                return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1735            }
1736            invoked => {
1737                let (error, panicked) = match invoked {
1738                    Invoked::Failed(error) => (error, false),
1739                    _ => (ProcessorError::new(), true),
1740                };
1741                let terminal = if panicked {
1742                    ChunkFailure::ProcessorPanic
1743                } else {
1744                    ChunkFailure::Processor
1745                };
1746                let Some(fault) = descriptor(
1747                    components,
1748                    state,
1749                    &buffer.skips,
1750                    FaultPhase::Process,
1751                    error.category(),
1752                ) else {
1753                    return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
1754                };
1755                let fault = with_reserved_ordinal(components, key, fault).await;
1756
1757                let failures = components
1758                    .item_listeners
1759                    .on_process_error(
1760                        before.entered(),
1761                        &buffer.slots[index].item,
1762                        fault,
1763                        listener_context,
1764                    )
1765                    .await;
1766                if let Some(first) = failures.first().copied() {
1767                    state.item_listener_failures.extend(failures);
1768                    return Verdict::Terminal(item_listener_outcome(first.kind()));
1769                }
1770
1771                // The input is located and no writer effect has started, so the
1772                // framework owns every piece of process-skip evidence.
1773                let evidence = FaultEvidence::new(true, true, true);
1774                let decision = match classify(
1775                    components,
1776                    listener_context,
1777                    &mut buffer.retry,
1778                    state,
1779                    key,
1780                    fault,
1781                    evidence,
1782                    scope.sequence,
1783                    emit,
1784                )
1785                .await
1786                {
1787                    Ok(decision) => decision,
1788                    Err(outcome) => return Verdict::Terminal(outcome),
1789                };
1790                match decision {
1791                    FaultDecision::Retry { ordinal, delay } => {
1792                        return Verdict::Retry(RetryRequest {
1793                            key,
1794                            phase: FaultPhase::Process,
1795                            fault,
1796                            ordinal,
1797                            delay,
1798                        });
1799                    }
1800                    FaultDecision::Skip { disposition } => {
1801                        resolve_key(components, key).await;
1802                        buffer.slots[index].skipped = true;
1803                        buffer.skips.push(PendingSkip {
1804                            phase: FaultPhase::Process,
1805                            fault,
1806                            disposition,
1807                            slot: Some(index),
1808                            output: None,
1809                        });
1810                        if disposition == RollbackDisposition::Rollback {
1811                            return Verdict::Replay;
1812                        }
1813                    }
1814                    FaultDecision::Unknown => {
1815                        return Verdict::Terminal(ChunkExecutionOutcome::Unknown);
1816                    }
1817                    FaultDecision::Stop => {
1818                        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1819                    }
1820                    // `FaultDecision::FailAndRollback`, and any decision this
1821                    // build does not know: `FaultDecision` is
1822                    // `#[non_exhaustive]`, and an unrecognized decision rolls
1823                    // back and fails rather than committing work or claiming an
1824                    // unknown commit.
1825                    _ => {
1826                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
1827                    }
1828                }
1829            }
1830        }
1831    }
1832    Verdict::Commit
1833}
1834
1835/// Writes the provisional output batch inside the open transaction.
1836#[allow(
1837    clippy::too_many_lines,
1838    reason = "one phase keeps its listener, classification, and skip order visible"
1839)]
1840#[allow(
1841    clippy::too_many_arguments,
1842    reason = "the write boundary needs components, scope, buffer, state, outputs, and the transaction"
1843)]
1844async fn write_phase<I, O, E>(
1845    components: &Components<'_, I, O>,
1846    scope: AttemptScope<'_>,
1847    buffer: &mut ChunkBuffer<I, O>,
1848    state: &mut ExecutionState,
1849    outputs: &mut AttemptOutputs<O>,
1850    transaction: &mut dyn ChunkTransaction,
1851    emit: &mut E,
1852) -> Verdict
1853where
1854    I: Send + Sync,
1855    O: Send + Sync,
1856    E: FnMut(ChunkRuntimeEvent),
1857{
1858    if outputs.values.is_empty() {
1859        return Verdict::Commit;
1860    }
1861    if scope.stop.is_stop_requested() {
1862        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
1863    }
1864    let listener_context = scope.listener_context();
1865    let key = retry_key(
1866        components,
1867        buffer.checkpoint_digest,
1868        FaultPhase::Write,
1869        buffer.base_ordinal,
1870    );
1871
1872    let before = components
1873        .item_listeners
1874        .before_write(&outputs.values, listener_context)
1875        .await;
1876    if let Some(failure) = before.failure() {
1877        state.item_listener_failures.push(failure);
1878        return Verdict::Terminal(item_listener_outcome(failure.kind()));
1879    }
1880
1881    let write_context = match transaction.business_transaction() {
1882        Some(business) => WriteContext::enlisted(scope.stop, business),
1883        None => WriteContext::non_transactional(scope.stop),
1884    };
1885    match invoke_writer(components.writer, &outputs.values, write_context).await {
1886        Invoked::Completed(WriteOutcome::Written) => {
1887            let failures = components
1888                .item_listeners
1889                .after_write(before.entered(), &outputs.values, listener_context)
1890                .await;
1891            if let Some(first) = failures.first().copied() {
1892                state.item_listener_failures.extend(failures);
1893                return Verdict::Terminal(item_listener_outcome(first.kind()));
1894            }
1895            if let Some(outcome) = complete_retry(
1896                components,
1897                listener_context,
1898                &mut buffer.retry,
1899                state,
1900                key,
1901                RetryOutcome::Recovered,
1902            )
1903            .await
1904            {
1905                return Verdict::Terminal(outcome);
1906            }
1907            resolve_key(components, key).await;
1908            Verdict::Commit
1909        }
1910        Invoked::Completed(WriteOutcome::Stopped) => {
1911            Verdict::Terminal(ChunkExecutionOutcome::Stopped)
1912        }
1913        invoked => {
1914            let (error, panicked) = match invoked {
1915                Invoked::Failed(error) => (error, false),
1916                _ => (WriterError::new(), true),
1917            };
1918            let terminal = if panicked {
1919                ChunkFailure::WriterPanic
1920            } else {
1921                ChunkFailure::Writer
1922            };
1923            let located = if panicked {
1924                None
1925            } else {
1926                error
1927                    .rolled_back_output()
1928                    .filter(|index| *index < outputs.values.len())
1929            };
1930            let Some(fault) = descriptor(
1931                components,
1932                state,
1933                &buffer.skips,
1934                FaultPhase::Write,
1935                error.category(),
1936            ) else {
1937                return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
1938            };
1939            let fault = with_reserved_ordinal(components, key, fault).await;
1940
1941            let failures = components
1942                .item_listeners
1943                .on_write_error(before.entered(), &outputs.values, fault, listener_context)
1944                .await;
1945            if let Some(first) = failures.first().copied() {
1946                state.item_listener_failures.extend(failures);
1947                return Verdict::Terminal(item_listener_outcome(first.kind()));
1948            }
1949
1950            let evidence = FaultEvidence::new(located.is_some(), located.is_some(), false);
1951            let decision = match classify(
1952                components,
1953                listener_context,
1954                &mut buffer.retry,
1955                state,
1956                key,
1957                fault,
1958                evidence,
1959                scope.sequence,
1960                emit,
1961            )
1962            .await
1963            {
1964                Ok(decision) => decision,
1965                Err(outcome) => return Verdict::Terminal(outcome),
1966            };
1967            match decision {
1968                FaultDecision::Retry { ordinal, delay } => Verdict::Retry(RetryRequest {
1969                    key,
1970                    phase: FaultPhase::Write,
1971                    fault,
1972                    ordinal,
1973                    delay,
1974                }),
1975                FaultDecision::Skip { disposition } => {
1976                    let Some(index) = located else {
1977                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
1978                    };
1979                    resolve_key(components, key).await;
1980                    let slot = outputs.slots[index];
1981                    buffer.slots[slot].skipped = true;
1982                    buffer.skips.push(PendingSkip {
1983                        phase: FaultPhase::Write,
1984                        fault,
1985                        disposition,
1986                        slot: Some(slot),
1987                        output: Some(outputs.values.remove(index)),
1988                    });
1989                    outputs.slots.remove(index);
1990                    Verdict::Replay
1991                }
1992                FaultDecision::Unknown => Verdict::Terminal(ChunkExecutionOutcome::Unknown),
1993                FaultDecision::Stop => Verdict::Terminal(ChunkExecutionOutcome::Stopped),
1994                // `FaultDecision::FailAndRollback`, and any decision this build
1995                // does not know: `FaultDecision` is `#[non_exhaustive]`, and an
1996                // unrecognized decision rolls back and fails rather than
1997                // committing work or claiming an unknown commit.
1998                _ => Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal)),
1999            }
2000        }
2001    }
2002}
2003
2004/// Runs skip callbacks, clears resolved keys, and commits the chunk.
2005async fn commit_attempt<I, O>(
2006    components: &Components<'_, I, O>,
2007    scope: AttemptScope<'_>,
2008    buffer: &mut ChunkBuffer<I, O>,
2009    state: &mut ExecutionState,
2010    transaction: &mut dyn ChunkTransaction,
2011    outputs: &AttemptOutputs<O>,
2012) -> AttemptResult
2013where
2014    I: Send + Sync,
2015    O: Send + Sync,
2016{
2017    let listener_context = scope.listener_context();
2018    for skip in &buffer.skips {
2019        let failures = match (skip.phase, skip.slot, skip.output.as_ref()) {
2020            (FaultPhase::Process, Some(index), _) => {
2021                components
2022                    .item_listeners
2023                    .on_skip_in_process(&buffer.slots[index].item, skip.fault, listener_context)
2024                    .await
2025            }
2026            (FaultPhase::Write, _, Some(output)) => {
2027                components
2028                    .item_listeners
2029                    .on_skip_in_write(output, skip.fault, listener_context)
2030                    .await
2031            }
2032            _ => {
2033                components
2034                    .item_listeners
2035                    .on_skip_in_read(skip.fault, listener_context)
2036                    .await
2037            }
2038        };
2039        if let Some(first) = failures.first().copied() {
2040            state.item_listener_failures.extend(failures);
2041            let outcome = item_listener_outcome(first.kind());
2042            if transaction.rollback().await.is_err() {
2043                return AttemptResult::RollbackFailed(Some(outcome));
2044            }
2045            return AttemptResult::RolledBack(outcome);
2046        }
2047    }
2048
2049    let read = ChunkCount::new(buffer.slots.len() as u64);
2050    let processed = ChunkCount::new(outputs.values.len() as u64);
2051    let Ok(counts) = ChunkCounts::new(
2052        read,
2053        processed,
2054        processed,
2055        ChunkCount::new(outputs.filtered),
2056    ) else {
2057        let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::Count);
2058        if transaction.rollback().await.is_err() {
2059            return AttemptResult::RollbackFailed(Some(outcome));
2060        }
2061        return AttemptResult::RolledBack(outcome);
2062    };
2063
2064    let Some(accepted) = accepted_fault_progress(&buffer.skips) else {
2065        let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::Count);
2066        if transaction.rollback().await.is_err() {
2067            return AttemptResult::RollbackFailed(Some(outcome));
2068        }
2069        return AttemptResult::RolledBack(outcome);
2070    };
2071
2072    match transaction.commit(counts, accepted).await {
2073        Ok(receipt) => {
2074            let Ok(next_skips) = state.skip_counts.checked_add(accepted.skips()) else {
2075                return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2076                    ChunkFailure::Count,
2077                ));
2078            };
2079            state.skip_counts = next_skips;
2080            state.no_rollback_count = state
2081                .no_rollback_count
2082                .saturating_add(accepted.no_rollbacks());
2083            // The commit that advanced the checkpoint superseded every retry
2084            // key of the previous generation, so the durable clear is already
2085            // authoritative and this only prunes process-local bookkeeping.
2086            if let Some(fault) = components.fault {
2087                let _ = fault.state().clear_resolved().await;
2088            }
2089            AttemptResult::Committed { counts, receipt }
2090        }
2091        Err(ChunkTransactionError::NotCommitted) => {
2092            let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::TransactionCommit);
2093            if transaction.rollback().await.is_err() {
2094                return AttemptResult::RollbackFailed(Some(outcome));
2095            }
2096            AttemptResult::RolledBack(outcome)
2097        }
2098        Err(ChunkTransactionError::CommitOutcomeUnknown) => AttemptResult::Unknown,
2099    }
2100}
2101
2102/// Rolls back, reserves the retry ordinal durably, then waits for backoff.
2103#[allow(
2104    clippy::too_many_arguments,
2105    reason = "the retry scope needs components, scope, buffer, state, transaction, request, and events"
2106)]
2107async fn schedule_retry<I, O, E>(
2108    components: &Components<'_, I, O>,
2109    scope: AttemptScope<'_>,
2110    buffer: &mut ChunkBuffer<I, O>,
2111    state: &mut ExecutionState,
2112    transaction: &mut dyn ChunkTransaction,
2113    request: RetryRequest,
2114    emit: &mut E,
2115) -> AttemptResult
2116where
2117    I: Send + Sync,
2118    O: Send + Sync,
2119    E: FnMut(ChunkRuntimeEvent),
2120{
2121    let Some(fault_runtime) = components.fault else {
2122        return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2123            ChunkFailure::RetryReservation,
2124        ));
2125    };
2126    if transaction.rollback().await.is_err() {
2127        return AttemptResult::RollbackFailed(None);
2128    }
2129    if scope.stop.is_stop_requested() {
2130        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2131    }
2132
2133    let reservation = RetryReservation::new(
2134        request.key,
2135        request.phase,
2136        request.fault.category(),
2137        request.ordinal,
2138    );
2139    match fault_runtime.state().reserve(reservation).await {
2140        Ok(()) => {}
2141        Err(crate::FaultStateError::CapacityExhausted { .. }) => {
2142            return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2143                ChunkFailure::RetryStateExhausted,
2144            ));
2145        }
2146        Err(_) => {
2147            return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2148                ChunkFailure::RetryReservation,
2149            ));
2150        }
2151    }
2152    state.rollback_count = state.rollback_count.saturating_add(1);
2153    state.retry_counts = state.retry_counts.increment(request.phase);
2154    emit(ChunkRuntimeEvent::Fault(
2155        FaultRuntimeEvent::new(
2156            LifecycleEventKind::RetryReserved,
2157            scope.sequence,
2158            request.phase,
2159        )
2160        .with_summary(request.fault.summary())
2161        .with_ordinal(request.ordinal),
2162    ));
2163    emit(ChunkRuntimeEvent::Fault(
2164        FaultRuntimeEvent::new(
2165            LifecycleEventKind::FaultRollbackCommitted,
2166            scope.sequence,
2167            request.phase,
2168        )
2169        .with_summary(request.fault.summary()),
2170    ));
2171
2172    let listener_context = scope.listener_context();
2173    let before = components
2174        .item_listeners
2175        .before_retry(request.fault, listener_context)
2176        .await;
2177    if let Some(failure) = before.failure() {
2178        state.item_listener_failures.push(failure);
2179        return AttemptResult::RolledBack(item_listener_outcome(failure.kind()));
2180    }
2181    buffer.retry = Some(PendingRetry {
2182        key: request.key,
2183        fault: request.fault,
2184        entered: before.entered(),
2185    });
2186
2187    if scope.stop.is_stop_requested() {
2188        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2189    }
2190    emit(ChunkRuntimeEvent::Fault(
2191        FaultRuntimeEvent::new(
2192            LifecycleEventKind::RetryBackoffStarted,
2193            scope.sequence,
2194            request.phase,
2195        )
2196        .with_ordinal(request.ordinal)
2197        .with_backoff(request.delay),
2198    ));
2199    if fault_runtime
2200        .sleeper()
2201        .sleep(request.delay, scope.stop)
2202        .await
2203        == BackoffOutcome::Stopped
2204    {
2205        emit(ChunkRuntimeEvent::Fault(
2206            FaultRuntimeEvent::new(
2207                LifecycleEventKind::RetryBackoffCancelled,
2208                scope.sequence,
2209                request.phase,
2210            )
2211            .with_ordinal(request.ordinal)
2212            .with_backoff(request.delay),
2213        ));
2214        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2215    }
2216    if scope.stop.is_stop_requested() {
2217        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2218    }
2219    AttemptResult::Replay
2220}
2221
2222/// Runs the retry-completion callback when `key` had a reserved retry.
2223async fn complete_retry<I, O>(
2224    components: &Components<'_, I, O>,
2225    listener_context: ItemListenerContext<'_>,
2226    retry: &mut Option<PendingRetry>,
2227    state: &mut ExecutionState,
2228    key: RetryKey,
2229    outcome: RetryOutcome,
2230) -> Option<ChunkExecutionOutcome>
2231where
2232    I: Send + Sync,
2233    O: Send + Sync,
2234{
2235    let pending = retry.take_if(|pending| pending.key == key)?;
2236    let failures = components
2237        .item_listeners
2238        .after_retry(pending.entered, pending.fault, outcome, listener_context)
2239        .await;
2240    let first = failures.first().copied()?;
2241    state.item_listener_failures.extend(failures);
2242    Some(item_listener_outcome(first.kind()))
2243}
2244
2245/// Decides one fault and runs the exhaustion callback when the budget is spent.
2246#[allow(
2247    clippy::too_many_arguments,
2248    reason = "classification needs components, listeners, retry state, and the fault inputs"
2249)]
2250async fn classify<I, O, E>(
2251    components: &Components<'_, I, O>,
2252    listener_context: ItemListenerContext<'_>,
2253    retry: &mut Option<PendingRetry>,
2254    state: &mut ExecutionState,
2255    key: RetryKey,
2256    fault: FaultDescriptor,
2257    evidence: FaultEvidence,
2258    sequence: ChunkCount,
2259    emit: &mut E,
2260) -> Result<FaultDecision, ChunkExecutionOutcome>
2261where
2262    I: Send + Sync,
2263    O: Send + Sync,
2264    E: FnMut(ChunkRuntimeEvent),
2265{
2266    let entered = retry
2267        .as_ref()
2268        .filter(|pending| pending.key == key)
2269        .map(|pending| pending.entered);
2270    if entered.is_some()
2271        && let Some(outcome) = complete_retry(
2272            components,
2273            listener_context,
2274            retry,
2275            state,
2276            key,
2277            RetryOutcome::Failed,
2278        )
2279        .await
2280    {
2281        return Err(outcome);
2282    }
2283
2284    let Some(fault_runtime) = components.fault else {
2285        return Ok(FaultDecision::FailAndRollback);
2286    };
2287    let decision = fault_runtime.policy().decide(&fault, evidence);
2288
2289    if !decision.is_retry()
2290        && let Some(entered) = entered
2291    {
2292        emit(ChunkRuntimeEvent::Fault(
2293            FaultRuntimeEvent::new(LifecycleEventKind::RetryExhausted, sequence, fault.phase())
2294                .with_summary(fault.summary())
2295                .with_ordinal(fault.retry_ordinal()),
2296        ));
2297        let failures = components
2298            .item_listeners
2299            .on_retry_exhausted(entered, fault, listener_context)
2300            .await;
2301        if let Some(first) = failures.first().copied() {
2302            state.item_listener_failures.extend(failures);
2303            return Err(item_listener_outcome(first.kind()));
2304        }
2305    }
2306    Ok(decision)
2307}
2308
2309/// Builds the framework-owned classification input for one fault.
2310fn descriptor<I, O>(
2311    components: &Components<'_, I, O>,
2312    state: &mut ExecutionState,
2313    skips: &[PendingSkip<O>],
2314    phase: FaultPhase,
2315    category: FailureCategory,
2316) -> Option<FaultDescriptor> {
2317    let delivery_mode = components.fault.map_or(
2318        crate::ChunkDeliveryMode::AtLeastOnce,
2319        FaultRuntime::delivery_mode,
2320    );
2321    let committed = projected_skips(state.skip_counts, skips)?;
2322    state.next_failure_id = state.next_failure_id.saturating_add(1);
2323    let failure_id = FailureId::new(state.next_failure_id).ok()?;
2324    Some(FaultDescriptor::new(
2325        phase,
2326        FailureSummary::new(category, failure_id),
2327        RetryOrdinal::INITIAL,
2328        committed,
2329        true,
2330        delivery_mode,
2331    ))
2332}
2333
2334/// Replaces the descriptor ordinal with the durably reserved one.
2335async fn with_reserved_ordinal<I, O>(
2336    components: &Components<'_, I, O>,
2337    key: RetryKey,
2338    fault: FaultDescriptor,
2339) -> FaultDescriptor {
2340    let Some(fault_runtime) = components.fault else {
2341        return fault;
2342    };
2343    let ordinal = fault_runtime
2344        .state()
2345        .reserved_ordinal(key)
2346        .await
2347        .ok()
2348        .flatten()
2349        .unwrap_or(RetryOrdinal::INITIAL);
2350    FaultDescriptor::new(
2351        fault.phase(),
2352        fault.summary(),
2353        ordinal,
2354        fault.committed_skips(),
2355        fault.is_transaction_open(),
2356        fault.delivery_mode(),
2357    )
2358}
2359
2360/// Marks one retry key resolved because its unit of work finished.
2361async fn resolve_key<I, O>(components: &Components<'_, I, O>, key: RetryKey) {
2362    if let Some(fault_runtime) = components.fault {
2363        let _ = fault_runtime.state().resolve(key).await;
2364    }
2365}
2366
2367fn retry_key<I, O>(
2368    components: &Components<'_, I, O>,
2369    checkpoint_digest: [u8; 32],
2370    phase: FaultPhase,
2371    ordinal: u64,
2372) -> RetryKey {
2373    RetryKey::derive(
2374        &components.definition_digest,
2375        components.step_name,
2376        phase,
2377        &checkpoint_digest,
2378        ordinal,
2379    )
2380}
2381
2382fn checkpoint_digest(checkpoint: &crate::Checkpoint) -> [u8; 32] {
2383    checkpoint.generation_digest()
2384}
2385
2386fn emit_committed_skips<I, O, E>(buffer: &ChunkBuffer<I, O>, sequence: ChunkCount, emit: &mut E)
2387where
2388    E: FnMut(ChunkRuntimeEvent),
2389{
2390    for skip in &buffer.skips {
2391        emit(ChunkRuntimeEvent::Fault(
2392            FaultRuntimeEvent::new(LifecycleEventKind::ItemSkipped, sequence, skip.phase)
2393                .with_summary(skip.fault.summary()),
2394        ));
2395        if skip.disposition == RollbackDisposition::CommitSafeSkip {
2396            emit(ChunkRuntimeEvent::Fault(
2397                FaultRuntimeEvent::new(
2398                    LifecycleEventKind::FaultNoRollbackCommitted,
2399                    sequence,
2400                    skip.phase,
2401                )
2402                .with_summary(skip.fault.summary()),
2403            ));
2404        }
2405    }
2406}
2407
2408const fn item_listener_outcome(kind: ListenerFailureKind) -> ChunkExecutionOutcome {
2409    match kind {
2410        ListenerFailureKind::Error => ChunkExecutionOutcome::Failed(ChunkFailure::ItemListener),
2411        ListenerFailureKind::Panic => {
2412            ChunkExecutionOutcome::Failed(ChunkFailure::ItemListenerPanic)
2413        }
2414    }
2415}
2416
2417async fn finish_failed_attempt(
2418    listeners: &[Arc<dyn ChunkListener>],
2419    context: ChunkListenerContext<'_>,
2420    attempt_outcome: ChunkAttemptOutcome,
2421    outcome: ChunkExecutionOutcome,
2422    state: &mut ExecutionState,
2423) -> ChunkExecutionReport {
2424    let failures = run_after_listeners(listeners, context, attempt_outcome).await;
2425    if let Some(first) = failures.first().copied() {
2426        state.listener_failures.extend(failures);
2427        if outcome == ChunkExecutionOutcome::Unknown {
2428            return state.drain().report(outcome, None);
2429        }
2430        return state
2431            .drain()
2432            .report(listener_failure_outcome(first.kind()), Some(outcome));
2433    }
2434    state.drain().report(outcome, None)
2435}
2436
2437async fn run_before_listeners(
2438    listeners: &[Arc<dyn ChunkListener>],
2439    context: ChunkListenerContext<'_>,
2440) -> Option<ChunkListenerFailure> {
2441    for (index, listener) in listeners.iter().enumerate() {
2442        if let Err(kind) = invoke_before_listener(listener.as_ref(), context).await {
2443            return Some(ChunkListenerFailure::new(
2444                ChunkListenerPhase::BeforeChunk,
2445                index,
2446                kind,
2447            ));
2448        }
2449    }
2450    None
2451}
2452
2453async fn run_after_listeners(
2454    listeners: &[Arc<dyn ChunkListener>],
2455    context: ChunkListenerContext<'_>,
2456    outcome: ChunkAttemptOutcome,
2457) -> Vec<ChunkListenerFailure> {
2458    let mut failures = Vec::new();
2459    for (index, listener) in listeners.iter().enumerate().rev() {
2460        if let Err(kind) = invoke_after_listener(listener.as_ref(), context, outcome).await {
2461            failures.push(ChunkListenerFailure::new(
2462                ChunkListenerPhase::AfterChunk,
2463                index,
2464                kind,
2465            ));
2466        }
2467    }
2468    failures
2469}
2470
2471const fn listener_failure_outcome(kind: ChunkListenerFailureKind) -> ChunkExecutionOutcome {
2472    match kind {
2473        ChunkListenerFailureKind::Error => ChunkExecutionOutcome::Failed(ChunkFailure::Listener),
2474        ChunkListenerFailureKind::Panic => {
2475            ChunkExecutionOutcome::Failed(ChunkFailure::ListenerPanic)
2476        }
2477    }
2478}
2479
2480async fn invoke_before_listener(
2481    listener: &dyn ChunkListener,
2482    context: ChunkListenerContext<'_>,
2483) -> Result<(), ChunkListenerFailureKind> {
2484    let future = catch_unwind(AssertUnwindSafe(|| listener.before_chunk(context)))
2485        .map_err(|_| ChunkListenerFailureKind::Panic)?;
2486    match AssertUnwindSafe(future).catch_unwind().await {
2487        Ok(Ok(())) => Ok(()),
2488        Ok(Err(_)) => Err(ChunkListenerFailureKind::Error),
2489        Err(_) => Err(ChunkListenerFailureKind::Panic),
2490    }
2491}
2492
2493async fn invoke_after_listener(
2494    listener: &dyn ChunkListener,
2495    context: ChunkListenerContext<'_>,
2496    outcome: ChunkAttemptOutcome,
2497) -> Result<(), ChunkListenerFailureKind> {
2498    let future = catch_unwind(AssertUnwindSafe(|| listener.after_chunk(context, outcome)))
2499        .map_err(|_| ChunkListenerFailureKind::Panic)?;
2500    match AssertUnwindSafe(future).catch_unwind().await {
2501        Ok(Ok(())) => Ok(()),
2502        Ok(Err(_)) => Err(ChunkListenerFailureKind::Error),
2503        Err(_) => Err(ChunkListenerFailureKind::Panic),
2504    }
2505}
2506
2507struct ReaderInvocation<'a, I>(&'a mut dyn ItemReader<I>);
2508
2509impl<'a, I> ReaderInvocation<'a, I> {
2510    fn invoke(
2511        self,
2512        context: ReadContext<'a>,
2513    ) -> BoxFuture<'a, Result<ReadOutcome<I>, ReaderError>> {
2514        self.0.read(context)
2515    }
2516}
2517
2518async fn invoke_reader<'a, I>(
2519    reader: &'a mut dyn ItemReader<I>,
2520    context: ReadContext<'a>,
2521) -> Invoked<ReadOutcome<I>, ReaderError> {
2522    let invocation = ReaderInvocation(reader);
2523    let Ok(future) = catch_unwind(AssertUnwindSafe(move || invocation.invoke(context))) else {
2524        return Invoked::Panicked;
2525    };
2526    match AssertUnwindSafe(future).catch_unwind().await {
2527        Ok(Ok(outcome)) => Invoked::Completed(outcome),
2528        Ok(Err(error)) => Invoked::Failed(error),
2529        Err(_) => Invoked::Panicked,
2530    }
2531}
2532
2533async fn invoke_processor<I, O>(
2534    processor: &dyn ItemProcessor<I, O>,
2535    item: &I,
2536    context: ProcessContext<'_>,
2537) -> Invoked<ProcessOutcome<O>, ProcessorError> {
2538    let Ok(future) = catch_unwind(AssertUnwindSafe(|| processor.process(item, context))) else {
2539        return Invoked::Panicked;
2540    };
2541    match AssertUnwindSafe(future).catch_unwind().await {
2542        Ok(Ok(outcome)) => Invoked::Completed(outcome),
2543        Ok(Err(error)) => Invoked::Failed(error),
2544        Err(_) => Invoked::Panicked,
2545    }
2546}
2547
2548async fn invoke_writer<'a, O>(
2549    writer: &'a dyn ItemWriter<O>,
2550    items: &'a [O],
2551    context: WriteContext<'a>,
2552) -> Invoked<WriteOutcome, WriterError> {
2553    let Ok(future) = catch_unwind(AssertUnwindSafe(|| writer.write(items, context))) else {
2554        return Invoked::Panicked;
2555    };
2556    match AssertUnwindSafe(future).catch_unwind().await {
2557        Ok(Ok(outcome)) => Invoked::Completed(outcome),
2558        Ok(Err(error)) => Invoked::Failed(error),
2559        Err(_) => Invoked::Panicked,
2560    }
2561}
2562
2563async fn invoke_completion(
2564    completion: &dyn ChunkCompletion,
2565    context: ChunkCompletionContext<'_>,
2566) -> Result<ChunkCompletionOutcome, ChunkFailure> {
2567    let future = catch_unwind(AssertUnwindSafe(|| completion.after_commit(context)))
2568        .map_err(|_| ChunkFailure::CompletionPanic)?;
2569    match AssertUnwindSafe(future).catch_unwind().await {
2570        Ok(Ok(outcome)) => Ok(outcome),
2571        Ok(Err(_)) => Err(ChunkFailure::Completion),
2572        Err(_) => Err(ChunkFailure::CompletionPanic),
2573    }
2574}