Skip to main content

oxide_batch/
chunk_runtime.rs

1//! Deterministic single-threaded chunk-step orchestration.
2
3use std::collections::BTreeSet;
4use std::fmt;
5use std::panic::{AssertUnwindSafe, catch_unwind};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use futures_util::FutureExt;
10use sha2::{Digest, Sha256};
11use tokio::sync::Mutex as AsyncMutex;
12
13use crate::runtime::{lower_one_step, one_step_node};
14use crate::{
15    AdaptiveCompletionPolicy, BackoffOutcome, BoxFuture, BoxedStream, ChunkCommitReceipt,
16    ChunkCompletion, ChunkCompletionContext, ChunkCompletionOutcome, ChunkComponentRevisions,
17    ChunkCount, ChunkCounts, ChunkFaultProgress, ChunkSize, ChunkTransaction,
18    ChunkTransactionContext, ChunkTransactionError, ChunkTransactionManager, CompiledExecutionPlan,
19    CompletionPolicy, ComponentRevision, ComponentStateEnvelope, ComponentStreamIdentity,
20    DefinitionError, DefinitionIdentity, DefinitionRevision, ExecutionCorrelation, FailureCategory,
21    FailureId, FailureSummary, FaultDecision, FaultDescriptor, FaultEvidence, FaultPhase,
22    FaultProgress, FaultRuntime, InFlightPolicy, InheritedStepProgress, ItemListenerContext,
23    ItemListenerFailure, ItemListenerSet, ItemProcessor, ItemReader, ItemStream, ItemWriter,
24    JobExecutionListener, JobLauncher, JobName, JobParameters, LaunchError, LaunchReport,
25    LifecycleEventKind, ListenerFailureKind, ProcessContext, ProcessOutcome, ProcessorError,
26    ReadContext, ReadOutcome, ReaderError, RestartabilityDeclaration, RetryCounts, RetryKey,
27    RetryOrdinal, RetryOutcome, RetryReservation, RollbackDisposition, SkipCounts, StepComponents,
28    StepExecutionListener, StepName, StopToken, StreamCloseContext, StreamOpenContext,
29    StreamRuntimeOutcome, StreamStateContract, StreamUpdateContext, Tasklet, TaskletContext,
30    TaskletError, TaskletJob, TaskletOutcome, TaskletStep, WriteContext, WriteOutcome, WriterError,
31};
32
33/// Distinguishes who registered one runtime [`ItemStream`] entry, so a
34/// completion-policy replacement can remove exactly the registrations it
35/// itself installed and never one a caller registered directly.
36///
37/// Ownership is tracked explicitly per entry rather than inferred from
38/// [`ComponentStreamIdentity`] equality: two entries can legitimately share
39/// one identity for a moment while a `ChunkStep` is still being built (a
40/// manual registration and a policy's own registration, installed in either
41/// order), and removing "whatever this policy reports" must never remove
42/// the wrong one just because the values match.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44enum StreamOwner {
45    /// Registered directly via [`ChunkStep::with_item_stream`].
46    Manual,
47    /// Registered by [`ChunkStep::with_completion_policy`] on behalf of the
48    /// installed [`CompletionPolicy`]'s own [`CompletionPolicy::stream_registrations`].
49    Policy,
50}
51
52/// One runtime [`ItemStream`] registration and its explicit provenance.
53type StreamRegistration = (
54    ComponentStreamIdentity,
55    Arc<BoxedStream>,
56    StreamStateContract,
57    StreamOwner,
58);
59
60/// A validated one-step chunk definition.
61pub struct ChunkStep<I, O, R, P, W> {
62    name: StepName,
63    size: ChunkSize,
64    reader: R,
65    processor: P,
66    writer: W,
67    transactions: Arc<dyn ChunkTransactionManager>,
68    completion: Arc<dyn ChunkCompletion>,
69    completion_policy: Option<Arc<dyn CompletionPolicy>>,
70    listeners: Vec<Arc<dyn ChunkListener>>,
71    step_listeners: Vec<Arc<dyn StepExecutionListener>>,
72    item_listeners: ItemListenerSet<I, O>,
73    fault: Option<FaultRuntime>,
74    streams: Vec<StreamRegistration>,
75    in_flight_policy: InFlightPolicy,
76    definition_digest: [u8; 32],
77}
78
79impl<I, O, R, P, W> ChunkStep<I, O, R, P, W>
80where
81    R: ItemReader<I>,
82    P: ItemProcessor<I, O>,
83    W: ItemWriter<O>,
84{
85    /// Constructs a chunk step from facade-owned component and transaction
86    /// ports.
87    #[must_use]
88    #[allow(clippy::too_many_arguments)]
89    pub fn new(
90        name: StepName,
91        size: ChunkSize,
92        reader: R,
93        processor: P,
94        writer: W,
95        transactions: Arc<dyn ChunkTransactionManager>,
96        completion: Arc<dyn ChunkCompletion>,
97    ) -> Self {
98        Self {
99            name,
100            size,
101            reader,
102            processor,
103            writer,
104            transactions,
105            completion,
106            completion_policy: None,
107            listeners: Vec::new(),
108            step_listeners: Vec::new(),
109            item_listeners: ItemListenerSet::new(),
110            fault: None,
111            streams: Vec::new(),
112            in_flight_policy: InFlightPolicy::FinishChunk,
113            definition_digest: [0; 32],
114        }
115    }
116
117    /// Replaces the post-commit completion observer installed by [`Self::new`].
118    ///
119    /// Additive parity with the other post-construction `with_*` methods:
120    /// [`Self::new`] requires a [`ChunkCompletion`] up front because it is a
121    /// required construction port, not because every caller has an opinion
122    /// about it. [`crate::ChunkPipelineBuilder`] installs a no-op default and
123    /// uses this method to let a caller override it without hand-rolling a
124    /// [`ChunkCompletion`] implementation for the common case that does not
125    /// need one.
126    #[must_use]
127    pub fn with_completion(mut self, completion: Arc<dyn ChunkCompletion>) -> Self {
128        self.completion = completion;
129        self
130    }
131
132    /// Installs an additional early-completion policy.
133    ///
134    /// The configured [`ChunkSize`] passed to [`Self::new`] always remains
135    /// the hard resource-safety ceiling for buffered items; installing a
136    /// policy here only allows a chunk to stop *earlier* than that ceiling,
137    /// per `REPEAT-POLICY-001`. Without a policy, completion is exactly the
138    /// pre-#151 `ChunkSize`-only behavior.
139    ///
140    /// Also registers every [`ItemStream`] that `policy` (or, for a
141    /// [`crate::CompositeCompletionPolicy`], any of its members at any
142    /// nesting depth) reports through
143    /// [`CompletionPolicy::stream_registrations`], bound to that exact
144    /// instance. This is the only place that registration happens, so an
145    /// [`AdaptiveCompletionPolicy`] gets its persisted decision wired
146    /// correctly whether it is installed directly here or nested anywhere
147    /// inside a composite -- there is no second, composite-blind path that
148    /// could silently drop it.
149    ///
150    /// Calling this again to replace a previously installed policy first
151    /// removes exactly the previous policy's own registered streams --
152    /// every entry this method itself tagged as policy-owned, never a
153    /// manually registered [`Self::with_item_stream`] entry that happens
154    /// to coexist, even under the identical [`ComponentStreamIdentity`]: a
155    /// policy replacement is a full replacement of what *this method*
156    /// installed, not an accumulation of every policy this step ever
157    /// installed, and ownership is tracked per entry rather than inferred
158    /// from identity equality, so a coincidental identity collision with a
159    /// manual registration can never cause that manual entry to be removed.
160    #[must_use]
161    pub fn with_completion_policy(mut self, policy: Arc<dyn CompletionPolicy>) -> Self {
162        if self.completion_policy.is_some() {
163            self.streams
164                .retain(|(_, _, _, owner)| *owner != StreamOwner::Policy);
165        }
166        for (identity, stream, contract) in policy.stream_registrations() {
167            self.streams
168                .push((identity, stream, contract, StreamOwner::Policy));
169        }
170        self.completion_policy = Some(policy);
171        self
172    }
173
174    /// Registers one [`AdaptiveCompletionPolicy`] instance as this step's
175    /// completion policy.
176    ///
177    /// A thin, discoverable alias for [`Self::with_completion_policy`]:
178    /// [`CompletionPolicy::stream_registrations`] already binds this exact
179    /// instance's persisted decision to the step, so there is exactly one
180    /// registration mechanism regardless of which method installs it --
181    /// never a second path that could drift onto a different instance.
182    #[must_use]
183    pub fn with_adaptive_completion_policy(self, policy: Arc<AdaptiveCompletionPolicy>) -> Self {
184        self.with_completion_policy(policy)
185    }
186
187    /// Registers a chunk listener in deterministic before-order.
188    #[must_use]
189    pub fn with_chunk_listener(mut self, listener: Arc<dyn ChunkListener>) -> Self {
190        self.listeners.push(listener);
191        self
192    }
193
194    /// Installs the authoritative item, retry, and skip listener families.
195    ///
196    /// The set replaces any previously installed families.
197    #[must_use]
198    pub fn with_item_listeners(mut self, listeners: ItemListenerSet<I, O>) -> Self {
199        self.item_listeners = listeners;
200        self
201    }
202
203    /// Installs bounded retry, backoff, skip, and rollback behavior.
204    ///
205    /// Without a fault runtime every component failure fails the step after a
206    /// known rollback, which is the M2 behavior.
207    #[must_use]
208    pub fn with_fault_runtime(mut self, fault: FaultRuntime) -> Self {
209        self.fault = Some(fault);
210        self
211    }
212
213    /// Registers a step listener in deterministic before-order.
214    #[must_use]
215    pub fn with_listener(mut self, listener: Arc<dyn StepExecutionListener>) -> Self {
216        self.step_listeners.push(listener);
217        self
218    }
219
220    /// Registers one namespaced `ItemStream` in deterministic registration
221    /// order.
222    ///
223    /// `identity` must match the namespace registered for this stream in the
224    /// [`ChunkComponentRevisions`] this step's definition declares (see
225    /// [`ChunkComponentRevisions::with_stream_revision`]): it is both the
226    /// restart-relevant fingerprint token and the runtime key used to
227    /// associate this stream with its committed durable state.
228    /// [`ChunkJob::new`]/[`FlowJob::with_chunk_step`](crate::FlowJob::with_chunk_step)
229    /// reject a definition where the runtime registrations and declared
230    /// revisions are not the same set.
231    ///
232    /// `contract` binds this stream's expected schema/codec identity,
233    /// version, and restartability declaration so the runtime can validate
234    /// and migrate an inherited envelope -- and enforce restartability at
235    /// binding time -- before `open` ever runs, without introspecting this
236    /// stream's opaque internal state.
237    #[must_use]
238    pub fn with_item_stream(
239        mut self,
240        identity: ComponentStreamIdentity,
241        stream: impl ItemStream + 'static,
242        contract: StreamStateContract,
243    ) -> Self {
244        self.streams.push((
245            identity,
246            Arc::new(BoxedStream::new(stream)),
247            contract,
248            StreamOwner::Manual,
249        ));
250        self
251    }
252
253    /// Borrows the step name.
254    #[must_use]
255    pub const fn name(&self) -> &StepName {
256        &self.name
257    }
258
259    /// Returns the configured chunk size.
260    #[must_use]
261    pub const fn size(&self) -> ChunkSize {
262        self.size
263    }
264
265    /// Executes this step deterministically on the caller's async runtime.
266    ///
267    /// The reader is stateful, so the definition is mutably borrowed for the
268    /// duration of the run. Only counts returned by successful transaction
269    /// commits appear in the report. `correlation` identifies the execution for
270    /// item, retry, and skip listeners; it never reaches a component.
271    pub async fn execute(
272        &mut self,
273        correlation: &ExecutionCorrelation,
274        stop: &StopToken,
275    ) -> ChunkExecutionReport
276    where
277        I: Send + Sync,
278        O: Send + Sync,
279    {
280        execute_chunk_step(self, correlation, stop, None, |_| {}).await
281    }
282}
283
284/// Validates the bijection between a step's runtime `ItemStream`
285/// registrations and its definition's declared stream revisions, and
286/// enforces that no registered stream's contract blocks the plan from
287/// claiming restartability.
288///
289/// Restart-relevant stream revisions are declared through
290/// [`ChunkComponentRevisions::with_stream_revision`] independently of the
291/// runtime registrations a step assembles through
292/// [`ChunkStep::with_item_stream`]. Nothing else in the framework proves
293/// these two sets describe the same streams, so a mismatch here would
294/// otherwise surface only as an opaque runtime `open`/`update` failure (or,
295/// worse, silent misbinding) instead of failing before execution begins.
296///
297/// A chunk definition always carries a checkpoint/restart contract (Gate C),
298/// so it implicitly claims restartability; a registered stream whose
299/// contract declares [`RestartabilityDeclaration::NotRestartable`] must
300/// therefore prevent binding unconditionally -- independent of whether the
301/// step's own reader checkpoint exists or is itself restartable.
302fn validate_stream_registrations<I, O, R, P, W>(
303    step: &ChunkStep<I, O, R, P, W>,
304    components: &ChunkComponentRevisions,
305) -> Result<(), DefinitionError> {
306    let mut seen: BTreeSet<&ComponentStreamIdentity> = BTreeSet::new();
307    for (identity, _, contract, _owner) in &step.streams {
308        if !seen.insert(identity) {
309            return Err(DefinitionError::DuplicateRuntimeStream {
310                namespace: identity.clone(),
311            });
312        }
313        if components
314            .stream_revisions()
315            .all(|(declared, _)| declared != identity)
316        {
317            return Err(DefinitionError::RuntimeStreamNotDeclared {
318                namespace: identity.clone(),
319            });
320        }
321        if contract.restartability() == RestartabilityDeclaration::NotRestartable {
322            return Err(DefinitionError::NonRestartableStream {
323                namespace: identity.clone(),
324            });
325        }
326    }
327    for (declared, _) in components.stream_revisions() {
328        if !step
329            .streams
330            .iter()
331            .any(|(identity, _, _, _)| identity == declared)
332        {
333            return Err(DefinitionError::DeclaredStreamMissingRuntime {
334                namespace: declared.clone(),
335            });
336        }
337    }
338    Ok(())
339}
340
341/// Computes the [`ChunkComponentRevisions`] completion-policy revision token
342/// for `policy`: the restart-relevant hash of its live
343/// [`CompletionPolicy::fingerprint`], the same one [`ChunkJob::new`] folds
344/// in automatically.
345///
346/// [`ChunkJob::new`] builds its compiled plan and its runtime step from the
347/// same call, so it can call this for you. [`FlowJob::with_chunk_step`](crate::FlowJob::with_chunk_step)
348/// cannot: a flow node is compiled from bare [`ChunkComponentRevisions`]
349/// before any concrete [`ChunkStep`] (or the completion policy it installs)
350/// exists, so nothing at compile time can fold a not-yet-installed policy's
351/// fingerprint in. Call this explicitly instead, fold the result into the
352/// [`ChunkComponentRevisions`] used for *both*
353/// [`crate::FlowGraph`](crate::FlowGraph) compilation and the later
354/// [`FlowJob::with_chunk_step`](crate::FlowJob::with_chunk_step) call (via
355/// [`ChunkComponentRevisions::with_completion_policy_revision`]) -- the same
356/// pattern already used to declare a stream revision up front, rather than
357/// relying on the automatic folding only a single-call constructor can do.
358///
359/// The configuration is hashed (rather than embedded verbatim) so an
360/// arbitrarily large composite tree still yields a bounded-length revision
361/// token.
362///
363/// # Errors
364///
365/// Returns [`DefinitionError::CompletionPolicyFingerprintPanic`] if the
366/// application-supplied [`CompletionPolicy::fingerprint`] panics, and
367/// [`DefinitionError`] if the computed digest somehow failed
368/// [`ComponentRevision`]'s token validation, which cannot happen for a fixed
369/// hex-digest shape.
370pub fn completion_policy_revision(
371    policy: &dyn CompletionPolicy,
372) -> Result<ComponentRevision, DefinitionError> {
373    let fingerprint = catch_unwind(AssertUnwindSafe(|| policy.fingerprint()))
374        .map_err(|_| DefinitionError::CompletionPolicyFingerprintPanic)?;
375    let digest: [u8; 32] = Sha256::digest(fingerprint.as_bytes()).into();
376    let hex = digest.iter().fold(String::new(), |mut hex, byte| {
377        use std::fmt::Write as _;
378        let _ = write!(hex, "{byte:02x}");
379        hex
380    });
381    ComponentRevision::new(format!("completion:{hex}"))
382}
383
384/// Folds an installed completion policy's configuration into the
385/// restart-relevant component revisions used to compute a standalone
386/// [`ChunkJob`]'s definition fingerprint.
387///
388/// Computed automatically from the live [`CompletionPolicy::fingerprint`] of
389/// whatever policy [`ChunkStep::with_completion_policy`] (or
390/// [`ChunkStep::with_adaptive_completion_policy`]) installed, never from an
391/// application-supplied token: an application cannot forget to bump a
392/// revision it never has to declare, and a configuration change that alters
393/// completion semantics always changes the resulting definition fingerprint.
394/// A step with no completion policy installed folds in nothing, so its
395/// fingerprint stays byte-for-byte identical to one built before this
396/// function existed.
397///
398/// This automatic folding is only sound for [`ChunkJob::new`], which builds
399/// the compiled plan and the runtime step together in one call; see
400/// [`completion_policy_revision`] for why [`FlowJob::with_chunk_step`](crate::FlowJob::with_chunk_step)
401/// cannot do the same and must validate a declared revision instead.
402///
403/// # Errors
404///
405/// Returns [`DefinitionError::CompletionPolicyFingerprintPanic`] if the
406/// installed policy's application-supplied [`CompletionPolicy::fingerprint`]
407/// panics, and [`DefinitionError`] if the computed digest somehow failed
408/// [`ComponentRevision`]'s token validation, which cannot happen for a fixed
409/// hex-digest shape.
410pub(crate) fn completion_policy_component_revisions<I, O, R, P, W>(
411    step: &ChunkStep<I, O, R, P, W>,
412    components: &ChunkComponentRevisions,
413) -> Result<ChunkComponentRevisions, DefinitionError> {
414    let Some(policy) = step.completion_policy.as_ref() else {
415        return Ok(components.clone());
416    };
417    let revision = completion_policy_revision(policy.as_ref())?;
418    Ok(components.clone().with_completion_policy_revision(revision))
419}
420
421/// Validates that a step's live completion-policy fingerprint (if any)
422/// matches the completion-policy revision already declared in `revisions`.
423///
424/// Unlike [`ChunkJob::new`], [`FlowJob::with_chunk_step`](crate::FlowJob::with_chunk_step) binds a runtime
425/// step to a plan compiled earlier from bare [`ChunkComponentRevisions`], so
426/// it cannot compute this fingerprint for the caller -- it can only check
427/// that whatever the caller declared (via [`completion_policy_revision`],
428/// folded in before compiling the graph) still matches the step's actual,
429/// live policy. A step with no completion policy installed must declare
430/// none either.
431///
432/// # Errors
433///
434/// Returns [`DefinitionError::CompletionPolicyFingerprintPanic`] if the
435/// installed policy's [`CompletionPolicy::fingerprint`] panics, and
436/// [`DefinitionError::CompletionPolicyRevisionMismatch`] if the declared and
437/// live revisions disagree.
438fn validate_completion_policy_revision<I, O, R, P, W>(
439    step: &ChunkStep<I, O, R, P, W>,
440    revisions: &ChunkComponentRevisions,
441) -> Result<(), DefinitionError> {
442    let expected = step
443        .completion_policy
444        .as_ref()
445        .map(|policy| completion_policy_revision(policy.as_ref()))
446        .transpose()?;
447    if expected.as_ref() == revisions.completion_policy_revision() {
448        Ok(())
449    } else {
450        Err(DefinitionError::CompletionPolicyRevisionMismatch)
451    }
452}
453
454impl<I, O, R, P, W> fmt::Debug for ChunkStep<I, O, R, P, W> {
455    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
456        formatter
457            .debug_struct("ChunkStep")
458            .field("name", &self.name)
459            .field("size", &self.size)
460            .field("chunk_listener_count", &self.listeners.len())
461            .field("step_listener_count", &self.step_listeners.len())
462            .field("stream_count", &self.streams.len())
463            .finish_non_exhaustive()
464    }
465}
466
467/// A validated single-step chunk job definition.
468pub struct ChunkJob<I, O, R, P, W> {
469    name: JobName,
470    step_name: StepName,
471    plan: CompiledExecutionPlan,
472    tasklet: Arc<ChunkTasklet<I, O, R, P, W>>,
473    step_listeners: Vec<Arc<dyn StepExecutionListener>>,
474    listeners: Vec<Arc<dyn JobExecutionListener>>,
475}
476
477impl<I, O, R, P, W> ChunkJob<I, O, R, P, W> {
478    /// Constructs a chunk job with explicit restart-relevant revisions.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`DefinitionError::ManifestEncoding`] if the bounded canonical
483    /// manifest cannot be encoded, and
484    /// [`DefinitionError::DeliveryModeMismatch`] when an installed fault
485    /// runtime declares a different delivery mode than the restart contract.
486    pub fn new(
487        name: JobName,
488        mut step: ChunkStep<I, O, R, P, W>,
489        revision: DefinitionRevision,
490        components: &ChunkComponentRevisions,
491    ) -> Result<Self, DefinitionError>
492    where
493        R: ItemReader<I> + Send + 'static,
494        P: ItemProcessor<I, O> + Send + 'static,
495        W: ItemWriter<O> + Send + 'static,
496    {
497        if let Some(fault) = step.fault.as_ref()
498            && fault.delivery_mode() != components.delivery_mode()
499        {
500            return Err(DefinitionError::DeliveryModeMismatch);
501        }
502        validate_stream_registrations(&step, components)?;
503        let components = completion_policy_component_revisions(&step, components)?;
504        let components = &components;
505        let step_name = step.name.clone();
506        let definition =
507            DefinitionIdentity::chunk(&name, &step_name, step.size, revision, components)?;
508        step.in_flight_policy = components.in_flight_policy();
509        step.definition_digest = *definition.manifest_digest();
510        let mut node = one_step_node(
511            &step_name,
512            StepComponents::Chunk {
513                size: step.size,
514                revisions: Box::new(components.clone()),
515            },
516        )?;
517        if let Some(fault) = step.fault.as_ref() {
518            node = node.with_fault_policy(fault.policy().clone());
519        }
520        let plan = lower_one_step(definition, node)?;
521        let step_listeners = step.step_listeners.clone();
522        Ok(Self {
523            name,
524            step_name,
525            plan,
526            tasklet: Arc::new(ChunkTasklet::new(step)),
527            step_listeners,
528            listeners: Vec::new(),
529        })
530    }
531
532    /// Borrows the in-memory compatibility plan this wrapper lowers into.
533    ///
534    /// The plan retains the wrapper's original manifest bytes, format, and
535    /// fingerprint and records no durable flow decision.
536    #[must_use]
537    pub const fn compiled_plan(&self) -> &CompiledExecutionPlan {
538        &self.plan
539    }
540
541    /// Borrows the exact restart-relevant definition identity.
542    #[must_use]
543    pub const fn definition_identity(&self) -> &DefinitionIdentity {
544        self.plan.definition_identity()
545    }
546
547    /// Registers a job listener in deterministic before-order.
548    #[must_use]
549    pub fn with_listener(mut self, listener: Arc<dyn JobExecutionListener>) -> Self {
550        self.listeners.push(listener);
551        self
552    }
553
554    /// Borrows the job name.
555    #[must_use]
556    pub const fn name(&self) -> &JobName {
557        &self.name
558    }
559
560    /// Borrows the chunk-step name.
561    #[must_use]
562    pub const fn step_name(&self) -> &StepName {
563        &self.step_name
564    }
565}
566
567impl<I, O, R, P, W> fmt::Debug for ChunkJob<I, O, R, P, W> {
568    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
569        formatter
570            .debug_struct("ChunkJob")
571            .field("name", &self.name)
572            .field("step_name", &self.step_name)
573            .field("definition", self.plan.definition_identity())
574            .field("listener_count", &self.listeners.len())
575            .field("step_listener_count", &self.step_listeners.len())
576            .finish_non_exhaustive()
577    }
578}
579
580impl crate::FlowJob {
581    /// Binds a stateful chunk step to one compiled flow node.
582    ///
583    /// The chunk's size, component revisions, delivery mode, and fault policy
584    /// must exactly match the immutable plan. The component is erased only at
585    /// the existing tasklet composition boundary; the reader, processor, and
586    /// writer keep their concrete types (ADR-0008) all the way through, with
587    /// [`BoxedReader`](crate::BoxedReader), [`BoxedProcessor`](crate::BoxedProcessor),
588    /// and [`BoxedWriter`](crate::BoxedWriter) available if a caller wants
589    /// item-level erasure too.
590    ///
591    /// If `step` installs a [`CompletionPolicy`], `revisions` must already
592    /// declare the matching completion-policy revision -- computed via
593    /// [`completion_policy_revision`] and folded in with
594    /// [`ChunkComponentRevisions::with_completion_policy_revision`] *before*
595    /// the enclosing [`crate::FlowGraph`] was compiled, the same way a
596    /// stream revision is declared up front rather than derived here.
597    /// Unlike [`ChunkJob::new`], this method cannot compute it for you: the
598    /// plan was already compiled from bare `ChunkComponentRevisions` before
599    /// this step (or the policy it installs) existed.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`crate::FlowJobError::ComponentMismatch`] when executable and
604    /// manifest declarations differ (including a missing or stale declared
605    /// completion-policy revision), or the ordinary binding errors for an
606    /// unknown, wrong-kind, duplicate, or differently named node.
607    pub fn with_chunk_step<I, O, R, P, W>(
608        mut self,
609        node_id: crate::NodeId,
610        mut step: ChunkStep<I, O, R, P, W>,
611        revisions: &ChunkComponentRevisions,
612    ) -> Result<Self, crate::FlowJobError>
613    where
614        I: Send + Sync + 'static,
615        O: Send + Sync + 'static,
616        R: ItemReader<I> + Send + 'static,
617        P: ItemProcessor<I, O> + Send + 'static,
618        W: ItemWriter<O> + Send + 'static,
619    {
620        let Some(crate::FlowNode::Step(compiled)) = self.compiled_plan().node(&node_id) else {
621            return Err(crate::FlowJobError::WrongNodeKind { node: node_id });
622        };
623        if validate_completion_policy_revision(&step, revisions).is_err() {
624            return Err(crate::FlowJobError::ComponentMismatch { node: node_id });
625        }
626        let expected = StepComponents::Chunk {
627            size: step.size,
628            revisions: Box::new(revisions.clone()),
629        };
630        if compiled.step_name() != step.name()
631            || compiled.components() != &expected
632            || compiled.fault_policy() != step.fault.as_ref().map(FaultRuntime::policy)
633        {
634            return Err(crate::FlowJobError::ComponentMismatch { node: node_id });
635        }
636        if validate_stream_registrations(&step, revisions).is_err() {
637            return Err(crate::FlowJobError::ComponentMismatch { node: node_id });
638        }
639        step.definition_digest = *self.compiled_plan().fingerprint();
640        step.in_flight_policy = revisions.in_flight_policy();
641        let listeners = step.step_listeners.clone();
642        let tasklet: Arc<dyn Tasklet> = Arc::new(ChunkTasklet::new(step));
643        let mut tasklet_step = TaskletStep::new(compiled.step_name().clone(), tasklet);
644        for listener in listeners {
645            tasklet_step = tasklet_step.with_listener(listener);
646        }
647        self.bind_chunk_tasklet(node_id, tasklet_step)?;
648        Ok(self)
649    }
650}
651
652struct ChunkTasklet<I, O, R, P, W> {
653    step: AsyncMutex<ChunkStep<I, O, R, P, W>>,
654    last_report: Mutex<Option<ChunkExecutionReport>>,
655}
656
657impl<I, O, R, P, W> ChunkTasklet<I, O, R, P, W>
658where
659    R: ItemReader<I>,
660    P: ItemProcessor<I, O>,
661    W: ItemWriter<O>,
662{
663    fn new(step: ChunkStep<I, O, R, P, W>) -> Self {
664        Self {
665            step: AsyncMutex::new(step),
666            last_report: Mutex::new(None),
667        }
668    }
669
670    fn take_last_report(&self) -> Option<ChunkExecutionReport> {
671        self.last_report
672            .lock()
673            .unwrap_or_else(std::sync::PoisonError::into_inner)
674            .take()
675    }
676
677    fn clear_last_report(&self) {
678        *self
679            .last_report
680            .lock()
681            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
682    }
683}
684
685impl<I, O, R, P, W> Tasklet for ChunkTasklet<I, O, R, P, W>
686where
687    I: Send + Sync + 'static,
688    O: Send + Sync + 'static,
689    R: ItemReader<I> + Send + 'static,
690    P: ItemProcessor<I, O> + Send + 'static,
691    W: ItemWriter<O> + Send + 'static,
692{
693    fn execute<'a>(
694        &'a self,
695        context: TaskletContext<'a>,
696    ) -> BoxFuture<'a, Result<TaskletOutcome, TaskletError>> {
697        Box::pin(async move {
698            let mut step = self.step.lock().await;
699            let transaction_context = ChunkTransactionContext::new(
700                context.job_execution_id(),
701                context.step_execution_id(),
702            );
703            let report = execute_chunk_step(
704                &mut step,
705                context.correlation(),
706                context.stop_token(),
707                Some(transaction_context),
708                |event| match event {
709                    ChunkRuntimeEvent::Started(sequence) => {
710                        context.emit_chunk_event(LifecycleEventKind::ChunkStarted, sequence);
711                    }
712                    ChunkRuntimeEvent::Committed(sequence) => {
713                        context.emit_chunk_event(LifecycleEventKind::ChunkCommitted, sequence);
714                    }
715                    ChunkRuntimeEvent::RolledBack(sequence) => {
716                        context.emit_chunk_event(LifecycleEventKind::ChunkRolledBack, sequence);
717                    }
718                    ChunkRuntimeEvent::Unknown(sequence) => {
719                        context.emit_chunk_event(LifecycleEventKind::ChunkUnknown, sequence);
720                    }
721                    ChunkRuntimeEvent::Fault(fault) => context.emit_fault_event(&fault),
722                },
723            )
724            .await;
725            let outcome = match report.outcome() {
726                ChunkExecutionOutcome::Completed => Ok(TaskletOutcome::Completed),
727                ChunkExecutionOutcome::Stopped => Ok(TaskletOutcome::Stopped),
728                ChunkExecutionOutcome::Failed(_) => Err(TaskletError::new()),
729                ChunkExecutionOutcome::Unknown => Ok(TaskletOutcome::CommitOutcomeUnknown),
730            };
731            if report.terminal_rollback {
732                context.acknowledge_terminal_rollback();
733            }
734            *self
735                .last_report
736                .lock()
737                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(report);
738            outcome
739        })
740    }
741}
742
743/// Combined repository lifecycle and chunk-orchestration result.
744#[derive(Clone, Debug, Eq, PartialEq)]
745pub struct ChunkLaunchReport {
746    launch: LaunchReport,
747    chunk: Option<ChunkExecutionReport>,
748}
749
750impl ChunkLaunchReport {
751    /// Borrows the persisted job/step lifecycle result.
752    #[must_use]
753    pub const fn launch(&self) -> &LaunchReport {
754        &self.launch
755    }
756
757    /// Borrows chunk evidence when user work reached the chunk step.
758    ///
759    /// A stop or before-listener failure can finish the launch without
760    /// invoking the chunk body.
761    #[must_use]
762    pub const fn chunk(&self) -> Option<&ChunkExecutionReport> {
763        self.chunk.as_ref()
764    }
765}
766
767impl JobLauncher<'_> {
768    /// Launches a stateful one-step chunk job through the existing repository,
769    /// lifecycle-listener, and event contracts.
770    ///
771    /// The mutable job borrow prevents concurrent use of one stateful reader.
772    ///
773    /// # Errors
774    ///
775    /// Returns [`LaunchError`] when repository metadata cannot reach a final
776    /// state. Component failures are persisted and returned in the reports.
777    pub async fn launch_chunk<I, O, R, P, W>(
778        &self,
779        job: &mut ChunkJob<I, O, R, P, W>,
780        parameters: &JobParameters,
781        stop: &StopToken,
782    ) -> Result<ChunkLaunchReport, LaunchError>
783    where
784        I: Send + Sync + 'static,
785        O: Send + Sync + 'static,
786        R: ItemReader<I> + Send + 'static,
787        P: ItemProcessor<I, O> + Send + 'static,
788        W: ItemWriter<O> + Send + 'static,
789    {
790        job.tasklet.clear_last_report();
791        let tasklet: Arc<dyn Tasklet> = job.tasklet.clone();
792        let mut tasklet_step = TaskletStep::new(job.step_name.clone(), tasklet);
793        for listener in &job.step_listeners {
794            tasklet_step = tasklet_step.with_listener(Arc::clone(listener));
795        }
796        let mut tasklet_job =
797            TaskletJob::from_lowered_plan(job.name.clone(), tasklet_step, job.plan.clone());
798        for listener in &job.listeners {
799            tasklet_job = tasklet_job.with_listener(Arc::clone(listener));
800        }
801
802        let launch = self.launch(&tasklet_job, parameters, stop).await?;
803        let chunk = job.tasklet.take_last_report();
804        Ok(ChunkLaunchReport { launch, chunk })
805    }
806}
807
808/// Read-only state supplied at a chunk-listener boundary.
809#[derive(Clone, Copy, Debug)]
810pub struct ChunkListenerContext<'a> {
811    sequence: ChunkCount,
812    committed_counts: ChunkCounts,
813    stop: &'a StopToken,
814}
815
816impl<'a> ChunkListenerContext<'a> {
817    const fn new(sequence: ChunkCount, committed_counts: ChunkCounts, stop: &'a StopToken) -> Self {
818        Self {
819            sequence,
820            committed_counts,
821            stop,
822        }
823    }
824
825    /// Returns the nonzero chunk-attempt sequence.
826    #[must_use]
827    pub const fn sequence(self) -> ChunkCount {
828        self.sequence
829    }
830
831    /// Returns counts from chunks committed before this attempt.
832    #[must_use]
833    pub const fn committed_counts(self) -> ChunkCounts {
834        self.committed_counts
835    }
836
837    /// Borrows the cooperative stop token.
838    #[must_use]
839    pub const fn stop_token(self) -> &'a StopToken {
840        self.stop
841    }
842}
843
844/// The result visible to an after-chunk listener.
845#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846#[non_exhaustive]
847pub enum ChunkAttemptOutcome {
848    /// The chunk transaction committed.
849    Committed,
850    /// The transaction rolled back after a failure.
851    RolledBack,
852    /// Cooperative stop rolled back the open attempt.
853    Stopped,
854    /// The commit result is unknown and must not be guessed.
855    Unknown,
856}
857
858/// A value-redacted chunk-listener failure.
859#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
860pub struct ChunkListenerError;
861
862impl ChunkListenerError {
863    /// Constructs a listener error without retaining application data.
864    #[must_use]
865    pub const fn new() -> Self {
866        Self
867    }
868}
869
870impl fmt::Display for ChunkListenerError {
871    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
872        formatter.write_str("chunk listener failed")
873    }
874}
875
876impl std::error::Error for ChunkListenerError {}
877
878/// Observes a chunk attempt around its transaction body.
879pub trait ChunkListener: Send + Sync {
880    /// Runs before the transaction begins.
881    fn before_chunk<'a>(
882        &'a self,
883        context: ChunkListenerContext<'a>,
884    ) -> BoxFuture<'a, Result<(), ChunkListenerError>>;
885
886    /// Runs after commit, rollback, stop, or an unknown commit result.
887    fn after_chunk<'a>(
888        &'a self,
889        context: ChunkListenerContext<'a>,
890        outcome: ChunkAttemptOutcome,
891    ) -> BoxFuture<'a, Result<(), ChunkListenerError>>;
892}
893
894/// Whether a chunk listener returned an error or panicked.
895#[derive(Clone, Copy, Debug, Eq, PartialEq)]
896#[non_exhaustive]
897pub enum ChunkListenerFailureKind {
898    /// The listener returned a classified error.
899    Error,
900    /// The listener panicked before or while its future was polled.
901    Panic,
902}
903
904/// The listener callback phase.
905#[derive(Clone, Copy, Debug, Eq, PartialEq)]
906#[non_exhaustive]
907pub enum ChunkListenerPhase {
908    /// Before the transaction body.
909    BeforeChunk,
910    /// After the transaction outcome.
911    AfterChunk,
912}
913
914/// One redacted chunk-listener failure in callback execution order.
915#[derive(Clone, Copy, Debug, Eq, PartialEq)]
916pub struct ChunkListenerFailure {
917    phase: ChunkListenerPhase,
918    registration_index: usize,
919    kind: ChunkListenerFailureKind,
920}
921
922impl ChunkListenerFailure {
923    const fn new(
924        phase: ChunkListenerPhase,
925        registration_index: usize,
926        kind: ChunkListenerFailureKind,
927    ) -> Self {
928        Self {
929            phase,
930            registration_index,
931            kind,
932        }
933    }
934
935    /// Returns the callback phase.
936    #[must_use]
937    pub const fn phase(self) -> ChunkListenerPhase {
938        self.phase
939    }
940
941    /// Returns the zero-based registration index.
942    #[must_use]
943    pub const fn registration_index(self) -> usize {
944        self.registration_index
945    }
946
947    /// Returns whether the listener errored or panicked.
948    #[must_use]
949    pub const fn kind(self) -> ChunkListenerFailureKind {
950        self.kind
951    }
952}
953
954/// Stable phase classification for a failed chunk step.
955#[derive(Clone, Copy, Debug, Eq, PartialEq)]
956#[non_exhaustive]
957pub enum ChunkFailure {
958    /// Checked count arithmetic rejected the attempted update.
959    Count,
960    /// The reader returned an error.
961    Reader,
962    /// The reader panicked.
963    ReaderPanic,
964    /// The processor returned an error.
965    Processor,
966    /// The processor panicked.
967    ProcessorPanic,
968    /// The writer returned an error.
969    Writer,
970    /// The writer panicked.
971    WriterPanic,
972    /// A chunk transaction could not begin.
973    TransactionBegin,
974    /// A chunk transaction was known not to commit.
975    TransactionCommit,
976    /// Rollback itself failed.
977    TransactionRollback,
978    /// The post-commit completion callback returned an error.
979    Completion,
980    /// The post-commit completion callback panicked.
981    CompletionPanic,
982    /// A chunk listener returned an error.
983    Listener,
984    /// A chunk listener panicked.
985    ListenerPanic,
986    /// An item, retry, or skip listener returned an error.
987    ItemListener,
988    /// An item, retry, or skip listener panicked.
989    ItemListenerPanic,
990    /// A retry ordinal could not be reserved durably.
991    RetryReservation,
992    /// Durable fault state was unusable, so no component work began.
993    FaultState,
994    /// The step already retains its maximum unresolved retry keys.
995    RetryStateExhausted,
996    /// The selected resource cannot honour the declared policy.
997    UnsupportedCapability,
998    /// Restoring a required `ItemStream` failed before any component work
999    /// began.
1000    StreamOpen,
1001    /// An `ItemStream` could not prepare its candidate state for the
1002    /// committing chunk.
1003    StreamUpdate,
1004    /// An `ItemStream` failed to close.
1005    StreamClose,
1006    /// A completion policy panicked in `begin_chunk`, `is_complete`, or
1007    /// `end_chunk`.
1008    CompletionPolicyPanic,
1009}
1010
1011/// Final result of deterministic chunk orchestration.
1012#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1013#[non_exhaustive]
1014pub enum ChunkExecutionOutcome {
1015    /// End of input was reached and every open chunk committed.
1016    Completed,
1017    /// Cooperative stop was observed at a safe chunk boundary.
1018    Stopped,
1019    /// A typed component, listener, count, or transaction failure occurred.
1020    Failed(ChunkFailure),
1021    /// Commit outcome is unknown; replay requires durable recovery evidence.
1022    Unknown,
1023}
1024
1025/// In-memory execution evidence returned by a chunk step.
1026#[derive(Clone, Debug, Eq, PartialEq)]
1027pub struct ChunkExecutionReport {
1028    outcome: ChunkExecutionOutcome,
1029    original_outcome: Option<ChunkExecutionOutcome>,
1030    committed_counts: ChunkCounts,
1031    committed_chunks: ChunkCount,
1032    rolled_back_chunks: ChunkCount,
1033    listener_failures: Vec<ChunkListenerFailure>,
1034    item_listener_failures: Vec<ItemListenerFailure>,
1035    skip_counts: SkipCounts,
1036    retry_counts: RetryCounts,
1037    rollback_count: u64,
1038    no_rollback_count: u64,
1039    terminal_rollback: bool,
1040    stream_close_failed: bool,
1041}
1042
1043impl ChunkExecutionReport {
1044    /// Returns the final chunk-step result.
1045    #[must_use]
1046    pub const fn outcome(&self) -> ChunkExecutionOutcome {
1047        self.outcome
1048    }
1049
1050    /// Returns whether closing a registered `ItemStream` failed.
1051    ///
1052    /// A close failure never erases an earlier primary failure or
1053    /// already-committed chunks: when the step would otherwise have
1054    /// completed cleanly, [`Self::outcome`] becomes
1055    /// `Failed(ChunkFailure::StreamClose)` and [`Self::original_outcome`]
1056    /// records the superseded `Completed` result; when the step already had
1057    /// a failure, stop, or unknown outcome, that outcome is unchanged and
1058    /// this flag is the only signal of the additional close failure.
1059    #[must_use]
1060    pub const fn stream_close_failed(&self) -> bool {
1061        self.stream_close_failed
1062    }
1063
1064    /// Merges a stream-close failure into this report without erasing an
1065    /// earlier primary failure or already-committed chunks.
1066    fn with_stream_close_failure(mut self) -> Self {
1067        if matches!(self.outcome, ChunkExecutionOutcome::Completed) {
1068            self.original_outcome = Some(self.outcome);
1069            self.outcome = ChunkExecutionOutcome::Failed(ChunkFailure::StreamClose);
1070        }
1071        self.stream_close_failed = true;
1072        self
1073    }
1074
1075    /// Returns the result superseded by an after-listener failure.
1076    #[must_use]
1077    pub const fn original_outcome(&self) -> Option<ChunkExecutionOutcome> {
1078        self.original_outcome
1079    }
1080
1081    /// Returns aggregate counts from committed chunks only.
1082    #[must_use]
1083    pub const fn committed_counts(&self) -> ChunkCounts {
1084        self.committed_counts
1085    }
1086
1087    /// Returns the number of committed chunk transactions.
1088    #[must_use]
1089    pub const fn committed_chunks(&self) -> ChunkCount {
1090        self.committed_chunks
1091    }
1092
1093    /// Returns the number of rolled-back chunk transactions.
1094    #[must_use]
1095    pub const fn rolled_back_chunks(&self) -> ChunkCount {
1096        self.rolled_back_chunks
1097    }
1098
1099    /// Borrows listener failures in callback execution order.
1100    #[must_use]
1101    pub fn listener_failures(&self) -> &[ChunkListenerFailure] {
1102        &self.listener_failures
1103    }
1104
1105    /// Borrows item, retry, and skip listener failures in execution order.
1106    #[must_use]
1107    pub fn item_listener_failures(&self) -> &[ItemListenerFailure] {
1108        &self.item_listener_failures
1109    }
1110
1111    /// Returns committed per-phase skip counts.
1112    ///
1113    /// A skip appears here only after the chunk that accepted it committed. On
1114    /// a repository-backed run the totals include the counts this attempt
1115    /// inherited, because the aggregate skip limit spans every attempt of one
1116    /// job instance.
1117    #[must_use]
1118    pub const fn skip_counts(&self) -> SkipCounts {
1119        self.skip_counts
1120    }
1121
1122    /// Returns per-phase counts of durably reserved retry ordinals.
1123    ///
1124    /// The counts include the ordinals this attempt inherited.
1125    #[must_use]
1126    pub const fn retry_counts(&self) -> RetryCounts {
1127        self.retry_counts
1128    }
1129
1130    /// Returns framework rollback decisions with a durable acknowledgement.
1131    ///
1132    /// A retry reservation and a terminal known rollback each add one. A
1133    /// database abort caused by process death is not counted. Unlike the skip
1134    /// and retry counts this value is scoped to the current attempt.
1135    #[must_use]
1136    pub const fn rollback_count(&self) -> u64 {
1137        self.rollback_count
1138    }
1139
1140    /// Returns commits that accepted a
1141    /// [`RollbackDisposition::CommitSafeSkip`], including inherited ones.
1142    #[must_use]
1143    pub const fn no_rollback_count(&self) -> u64 {
1144        self.no_rollback_count
1145    }
1146}
1147
1148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1149pub(crate) enum ChunkRuntimeEvent {
1150    Started(ChunkCount),
1151    Committed(ChunkCount),
1152    RolledBack(ChunkCount),
1153    Unknown(ChunkCount),
1154    Fault(FaultRuntimeEvent),
1155}
1156
1157/// A post-decision fault observation with only reviewed bounded fields.
1158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1159pub(crate) struct FaultRuntimeEvent {
1160    pub(crate) kind: LifecycleEventKind,
1161    pub(crate) sequence: ChunkCount,
1162    pub(crate) phase: FaultPhase,
1163    pub(crate) summary: Option<FailureSummary>,
1164    pub(crate) ordinal: Option<RetryOrdinal>,
1165    pub(crate) backoff: Option<Duration>,
1166}
1167
1168impl FaultRuntimeEvent {
1169    const fn new(kind: LifecycleEventKind, sequence: ChunkCount, phase: FaultPhase) -> Self {
1170        Self {
1171            kind,
1172            sequence,
1173            phase,
1174            summary: None,
1175            ordinal: None,
1176            backoff: None,
1177        }
1178    }
1179
1180    const fn with_summary(mut self, summary: FailureSummary) -> Self {
1181        self.summary = Some(summary);
1182        self
1183    }
1184
1185    const fn with_ordinal(mut self, ordinal: RetryOrdinal) -> Self {
1186        self.ordinal = Some(ordinal);
1187        self
1188    }
1189
1190    const fn with_backoff(mut self, backoff: Duration) -> Self {
1191        self.backoff = Some(backoff);
1192        self
1193    }
1194}
1195
1196struct ExecutionState {
1197    committed_counts: ChunkCounts,
1198    committed_chunks: ChunkCount,
1199    rolled_back_chunks: ChunkCount,
1200    listener_failures: Vec<ChunkListenerFailure>,
1201    item_listener_failures: Vec<ItemListenerFailure>,
1202    skip_counts: SkipCounts,
1203    retry_counts: RetryCounts,
1204    rollback_count: u64,
1205    no_rollback_count: u64,
1206    terminal_rollback: bool,
1207    next_failure_id: u64,
1208}
1209
1210impl ExecutionState {
1211    fn new() -> Self {
1212        Self::inheriting(FaultProgress::NONE)
1213    }
1214
1215    /// Starts an attempt from the totals its durable predecessor committed.
1216    fn inheriting(inherited: FaultProgress) -> Self {
1217        Self {
1218            committed_counts: ChunkCounts::default(),
1219            committed_chunks: ChunkCount::ZERO,
1220            rolled_back_chunks: ChunkCount::ZERO,
1221            listener_failures: Vec::new(),
1222            item_listener_failures: Vec::new(),
1223            skip_counts: inherited.skips(),
1224            retry_counts: inherited.retries(),
1225            rollback_count: 0,
1226            no_rollback_count: inherited.no_rollbacks(),
1227            terminal_rollback: false,
1228            next_failure_id: 0,
1229        }
1230    }
1231
1232    /// Preserves step-scoped fault evidence while resetting per-attempt state.
1233    fn drain(&mut self) -> Self {
1234        let mut replacement = Self::new();
1235        replacement.skip_counts = self.skip_counts;
1236        replacement.retry_counts = self.retry_counts;
1237        replacement.rollback_count = self.rollback_count;
1238        replacement.no_rollback_count = self.no_rollback_count;
1239        replacement.terminal_rollback = self.terminal_rollback;
1240        replacement.next_failure_id = self.next_failure_id;
1241        std::mem::replace(self, replacement)
1242    }
1243
1244    fn report(
1245        self,
1246        outcome: ChunkExecutionOutcome,
1247        original_outcome: Option<ChunkExecutionOutcome>,
1248    ) -> ChunkExecutionReport {
1249        ChunkExecutionReport {
1250            outcome,
1251            original_outcome,
1252            committed_counts: self.committed_counts,
1253            committed_chunks: self.committed_chunks,
1254            rolled_back_chunks: self.rolled_back_chunks,
1255            listener_failures: self.listener_failures,
1256            item_listener_failures: self.item_listener_failures,
1257            skip_counts: self.skip_counts,
1258            retry_counts: self.retry_counts,
1259            rollback_count: self.rollback_count,
1260            no_rollback_count: self.no_rollback_count,
1261            terminal_rollback: self.terminal_rollback,
1262            stream_close_failed: false,
1263        }
1264    }
1265}
1266
1267/// One buffered input retained across the retry replays of a chunk.
1268struct ItemSlot<I> {
1269    item: I,
1270    ordinal: u64,
1271    skipped: bool,
1272}
1273
1274/// One accepted skip awaiting the commit that makes it authoritative.
1275struct PendingSkip<O> {
1276    phase: FaultPhase,
1277    fault: FaultDescriptor,
1278    disposition: RollbackDisposition,
1279    slot: Option<usize>,
1280    output: Option<O>,
1281}
1282
1283/// The in-flight retry scope for one key.
1284struct PendingRetry {
1285    key: RetryKey,
1286    fault: FaultDescriptor,
1287    entered: usize,
1288}
1289
1290/// Chunk-scoped work that survives a rollback and its replay.
1291///
1292/// A retry rolls the open transaction back and replays the chunk. The reader is
1293/// stateful and cannot rewind in process, so already-read inputs stay buffered
1294/// and the replay re-invokes only the components that have not yet succeeded.
1295struct ChunkBuffer<I, O> {
1296    slots: Vec<ItemSlot<I>>,
1297    skips: Vec<PendingSkip<O>>,
1298    retry: Option<PendingRetry>,
1299    end_of_input: bool,
1300    base_ordinal: u64,
1301    read_ordinal: u64,
1302    checkpoint_digest: [u8; 32],
1303}
1304
1305impl<I, O> ChunkBuffer<I, O> {
1306    const fn new(base_ordinal: u64, checkpoint_digest: [u8; 32]) -> Self {
1307        Self {
1308            slots: Vec::new(),
1309            skips: Vec::new(),
1310            retry: None,
1311            end_of_input: false,
1312            base_ordinal,
1313            read_ordinal: base_ordinal,
1314            checkpoint_digest,
1315        }
1316    }
1317}
1318
1319/// Returns the fault-tolerance deltas one chunk commit makes authoritative.
1320fn accepted_fault_progress<O>(skips: &[PendingSkip<O>]) -> Option<ChunkFaultProgress> {
1321    let mut counts = SkipCounts::ZERO;
1322    let mut no_rollbacks = 0_u64;
1323    for skip in skips {
1324        counts = counts.checked_increment(skip.phase).ok()?;
1325        if skip.disposition == RollbackDisposition::CommitSafeSkip {
1326            no_rollbacks = no_rollbacks.checked_add(1)?;
1327        }
1328    }
1329    Some(ChunkFaultProgress::new(counts, no_rollbacks))
1330}
1331
1332/// Returns durable skips plus the skips one chunk has not yet committed.
1333fn projected_skips<O>(committed: SkipCounts, skips: &[PendingSkip<O>]) -> Option<SkipCounts> {
1334    skips.iter().try_fold(committed, |counts, skip| {
1335        counts.checked_increment(skip.phase).ok()
1336    })
1337}
1338
1339/// The verdict of one chunk attempt body, before rollback or commit.
1340enum Verdict {
1341    /// Every buffered input is classified; the attempt may commit.
1342    Commit,
1343    /// A retryable fault ends the attempt, reserves an ordinal, and replays.
1344    Retry(RetryRequest),
1345    /// An accepted rollback skip ends the attempt and replays the chunk.
1346    Replay,
1347    /// The attempt is terminal.
1348    Terminal(ChunkExecutionOutcome),
1349}
1350
1351/// One retry, reserved after rollback and before backoff.
1352struct RetryRequest {
1353    key: RetryKey,
1354    phase: FaultPhase,
1355    fault: FaultDescriptor,
1356    ordinal: RetryOrdinal,
1357    delay: Duration,
1358}
1359
1360/// The result of one complete chunk attempt.
1361enum AttemptResult {
1362    /// The chunk transaction committed.
1363    Committed {
1364        counts: ChunkCounts,
1365        receipt: ChunkCommitReceipt,
1366    },
1367    /// The attempt rolled back and the chunk replays.
1368    Replay,
1369    /// The attempt rolled back and the step is finished.
1370    RolledBack(ChunkExecutionOutcome),
1371    /// Rollback itself failed after an earlier outcome.
1372    RollbackFailed(Option<ChunkExecutionOutcome>),
1373    /// The commit outcome is unknown and must never be guessed.
1374    Unknown,
1375}
1376
1377/// Borrowed components and policy for one chunk step.
1378struct Components<'a, I, O, P, W> {
1379    processor: &'a P,
1380    writer: &'a W,
1381    item_listeners: &'a ItemListenerSet<I, O>,
1382    fault: Option<&'a FaultRuntime>,
1383    streams: &'a [StreamRegistration],
1384    step_name: &'a StepName,
1385    definition_digest: [u8; 32],
1386    size: ChunkSize,
1387    completion_policy: Option<&'a dyn CompletionPolicy>,
1388}
1389
1390/// Borrowed call state for one chunk attempt.
1391#[derive(Clone, Copy)]
1392struct AttemptScope<'a> {
1393    correlation: &'a ExecutionCorrelation,
1394    stop: &'a StopToken,
1395    sequence: ChunkCount,
1396}
1397
1398impl<'a> AttemptScope<'a> {
1399    const fn listener_context(self) -> ItemListenerContext<'a> {
1400        ItemListenerContext::new(self.correlation, self.sequence, self.stop)
1401    }
1402}
1403
1404/// Provisional writer input for one attempt.
1405struct AttemptOutputs<O> {
1406    values: Vec<O>,
1407    slots: Vec<usize>,
1408    filtered: u64,
1409}
1410
1411impl<O> AttemptOutputs<O> {
1412    const fn new() -> Self {
1413        Self {
1414            values: Vec::new(),
1415            slots: Vec::new(),
1416            filtered: 0,
1417        }
1418    }
1419
1420    fn reset(&mut self) {
1421        self.values.clear();
1422        self.slots.clear();
1423        self.filtered = 0;
1424    }
1425}
1426
1427/// One component invocation classified without inspecting its payload.
1428enum Invoked<T, E> {
1429    Completed(T),
1430    Failed(E),
1431    Panicked,
1432}
1433
1434#[allow(
1435    clippy::similar_names,
1436    clippy::too_many_lines,
1437    reason = "the chunk loop keeps the canonical attempt, commit, and stop order visible"
1438)]
1439pub(crate) async fn execute_chunk_step<I, O, R, P, W>(
1440    step: &mut ChunkStep<I, O, R, P, W>,
1441    correlation: &ExecutionCorrelation,
1442    stop: &StopToken,
1443    transaction_context: Option<ChunkTransactionContext>,
1444    emit: impl FnMut(ChunkRuntimeEvent),
1445) -> ChunkExecutionReport
1446where
1447    I: Send + Sync,
1448    O: Send + Sync,
1449    R: ItemReader<I>,
1450    P: ItemProcessor<I, O>,
1451    W: ItemWriter<O>,
1452{
1453    let ChunkStep {
1454        name,
1455        size,
1456        reader,
1457        processor,
1458        writer,
1459        transactions,
1460        completion,
1461        completion_policy,
1462        listeners,
1463        item_listeners,
1464        fault,
1465        streams,
1466        in_flight_policy,
1467        definition_digest,
1468        ..
1469    } = step;
1470    let components = Components {
1471        processor,
1472        writer,
1473        item_listeners,
1474        fault: fault.as_ref(),
1475        streams,
1476        step_name: name,
1477        definition_digest: *definition_digest,
1478        size: *size,
1479        completion_policy: completion_policy.as_deref(),
1480    };
1481
1482    let (inherited, opened) = match inherited_progress(
1483        transactions.as_ref(),
1484        fault.as_ref(),
1485        transaction_context,
1486        streams,
1487        stop,
1488    )
1489    .await
1490    {
1491        StreamOpenPhaseResult::Opened { progress, streams } => (progress, streams),
1492        StreamOpenPhaseResult::Failed {
1493            outcome,
1494            close_failed,
1495        } => {
1496            let report = ExecutionState::new().report(outcome, None);
1497            return if close_failed {
1498                report.with_stream_close_failure()
1499            } else {
1500                report
1501            };
1502        }
1503    };
1504    let base_ordinal = inherited.read_ordinal();
1505    let state = ExecutionState::inheriting(inherited.fault());
1506    let buffer = ChunkBuffer::new(base_ordinal, inherited.checkpoint_digest());
1507
1508    let report = run_chunk_loop(
1509        &components,
1510        reader,
1511        correlation,
1512        stop,
1513        listeners,
1514        transactions.as_ref(),
1515        transaction_context,
1516        *in_flight_policy,
1517        completion.as_ref(),
1518        base_ordinal,
1519        buffer,
1520        state,
1521        emit,
1522    )
1523    .await;
1524
1525    close_opened_streams(&opened, stop, report).await
1526}
1527
1528/// Runs [`ItemStream::close`] for every stream that opened successfully, in
1529/// reverse registration order, and merges any close failure into `report`
1530/// without erasing an earlier primary failure or already-committed chunks.
1531async fn close_opened_streams(
1532    opened: &[Arc<BoxedStream>],
1533    stop: &StopToken,
1534    report: ChunkExecutionReport,
1535) -> ChunkExecutionReport {
1536    if opened.is_empty() {
1537        return report;
1538    }
1539    let runtime_outcome = match report.outcome() {
1540        ChunkExecutionOutcome::Completed => StreamRuntimeOutcome::Committed,
1541        ChunkExecutionOutcome::Stopped => StreamRuntimeOutcome::Stopped,
1542        ChunkExecutionOutcome::Unknown => StreamRuntimeOutcome::Unknown,
1543        ChunkExecutionOutcome::Failed(_) => StreamRuntimeOutcome::Failed,
1544    };
1545    let context = StreamCloseContext::new(stop, runtime_outcome);
1546    let close_failed = close_streams(opened, context).await;
1547    if close_failed {
1548        report.with_stream_close_failure()
1549    } else {
1550        report
1551    }
1552}
1553
1554/// Closes already-opened streams in reverse successful-open order, continuing
1555/// after an individual close failure so every cleanup call is attempted.
1556async fn close_streams(streams: &[Arc<BoxedStream>], context: StreamCloseContext<'_>) -> bool {
1557    let mut close_failed = false;
1558    for stream in streams.iter().rev() {
1559        if stream.close(context).await.is_err() {
1560            close_failed = true;
1561        }
1562    }
1563    close_failed
1564}
1565
1566/// Drives one step attempt's per-chunk loop to its terminal
1567/// [`ChunkExecutionReport`].
1568///
1569/// Extracted from [`execute_chunk_step`] as a pure, behavior-preserving
1570/// refactor so a future "always run before returning" funnel (stream close)
1571/// can wrap this call without touching the loop's many existing exit points.
1572#[allow(
1573    clippy::similar_names,
1574    clippy::too_many_arguments,
1575    clippy::too_many_lines,
1576    reason = "the chunk loop keeps the canonical attempt, commit, and stop order visible"
1577)]
1578async fn run_chunk_loop<I, O, E, P, W, R>(
1579    components: &Components<'_, I, O, P, W>,
1580    reader: &mut R,
1581    correlation: &ExecutionCorrelation,
1582    stop: &StopToken,
1583    listeners: &[Arc<dyn ChunkListener>],
1584    transactions: &dyn ChunkTransactionManager,
1585    transaction_context: Option<ChunkTransactionContext>,
1586    in_flight_policy: InFlightPolicy,
1587    completion: &dyn ChunkCompletion,
1588    base_ordinal: u64,
1589    mut buffer: ChunkBuffer<I, O>,
1590    mut state: ExecutionState,
1591    mut emit: E,
1592) -> ChunkExecutionReport
1593where
1594    I: Send + Sync,
1595    O: Send + Sync,
1596    E: FnMut(ChunkRuntimeEvent),
1597    P: ItemProcessor<I, O>,
1598    W: ItemWriter<O>,
1599    R: ItemReader<I>,
1600{
1601    let mut sequence = ChunkCount::ZERO;
1602    loop {
1603        if stop.is_stop_requested() {
1604            return state.report(ChunkExecutionOutcome::Stopped, None);
1605        }
1606        sequence = match sequence.checked_increment() {
1607            Ok(value) => value,
1608            Err(_) => {
1609                return state.report(ChunkExecutionOutcome::Failed(ChunkFailure::Count), None);
1610            }
1611        };
1612        let listener_context = ChunkListenerContext::new(sequence, state.committed_counts, stop);
1613
1614        if let Some(failure) = run_before_listeners(listeners, listener_context).await {
1615            let outcome = listener_failure_outcome(failure.kind());
1616            state.listener_failures.push(failure);
1617            return state.report(outcome, None);
1618        }
1619        if stop.is_stop_requested() {
1620            return state.report(ChunkExecutionOutcome::Stopped, None);
1621        }
1622
1623        let begun = match transaction_context {
1624            Some(context) => transactions.begin_for(context).await,
1625            None => transactions.begin().await,
1626        };
1627        let mut transaction = match begun {
1628            Ok(transaction) => transaction,
1629            Err(
1630                ChunkTransactionError::NotCommitted
1631                | ChunkTransactionError::ComponentStateUnsupported,
1632            ) => {
1633                // No transaction ever began this attempt, so its
1634                // `begin_chunk` never ran either -- `not_begun` reports this
1635                // truthfully rather than invoking `end_chunk` for a `begin`
1636                // that never happened.
1637                return finish_failed_attempt(
1638                    listeners,
1639                    CompletionPolicyAttempt::not_begun(components.completion_policy),
1640                    listener_context,
1641                    ChunkAttemptOutcome::RolledBack,
1642                    ChunkExecutionOutcome::Failed(ChunkFailure::TransactionBegin),
1643                    &mut state,
1644                )
1645                .await;
1646            }
1647            Err(ChunkTransactionError::CommitOutcomeUnknown) => {
1648                return finish_failed_attempt(
1649                    listeners,
1650                    CompletionPolicyAttempt::not_begun(components.completion_policy),
1651                    listener_context,
1652                    ChunkAttemptOutcome::Unknown,
1653                    ChunkExecutionOutcome::Unknown,
1654                    &mut state,
1655                )
1656                .await;
1657            }
1658        };
1659        emit(ChunkRuntimeEvent::Started(sequence));
1660
1661        // Once a chunk is open, the definition decides whether a shutdown
1662        // request remains visible to component calls. The masked token is
1663        // scoped to this attempt; the real token is consulted immediately
1664        // after commit so `FinishChunk` never starts another chunk.
1665        let masked_stop;
1666        let attempt_stop = match in_flight_policy {
1667            InFlightPolicy::FinishChunk => {
1668                let (_, token) = crate::StopSource::new();
1669                masked_stop = token;
1670                &masked_stop
1671            }
1672            // `InFlightPolicy::RollbackChunk`, and any policy this build does
1673            // not know: `InFlightPolicy` is `#[non_exhaustive]`, and an
1674            // unrecognized policy never masks a shutdown request.
1675            _ => stop,
1676        };
1677        let scope = AttemptScope {
1678            correlation,
1679            stop: attempt_stop,
1680            sequence,
1681        };
1682
1683        let (result, policy_attempt) = run_attempt(
1684            components,
1685            reader,
1686            scope,
1687            &mut buffer,
1688            &mut state,
1689            transaction.as_mut(),
1690            &mut emit,
1691        )
1692        .await;
1693        drop(transaction);
1694
1695        match result {
1696            AttemptResult::Committed { counts, receipt } => {
1697                let Ok(next_counts) = state.committed_counts.checked_add(counts) else {
1698                    return finish_failed_attempt(
1699                        listeners,
1700                        policy_attempt,
1701                        listener_context,
1702                        ChunkAttemptOutcome::Committed,
1703                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1704                        &mut state,
1705                    )
1706                    .await;
1707                };
1708                let Ok(next_chunks) = state.committed_chunks.checked_increment() else {
1709                    return finish_failed_attempt(
1710                        listeners,
1711                        policy_attempt,
1712                        listener_context,
1713                        ChunkAttemptOutcome::Committed,
1714                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1715                        &mut state,
1716                    )
1717                    .await;
1718                };
1719                state.committed_counts = next_counts;
1720                state.committed_chunks = next_chunks;
1721                emit(ChunkRuntimeEvent::Committed(sequence));
1722                emit_committed_skips(&buffer, sequence, &mut emit);
1723
1724                let end_of_input = buffer.end_of_input;
1725                let checkpoint_digest = checkpoint_digest(receipt.checkpoint());
1726                let Some(next_ordinal) =
1727                    base_ordinal.checked_add(state.committed_counts.read().get())
1728                else {
1729                    return finish_failed_attempt(
1730                        listeners,
1731                        policy_attempt,
1732                        listener_context,
1733                        ChunkAttemptOutcome::Committed,
1734                        ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1735                        &mut state,
1736                    )
1737                    .await;
1738                };
1739                buffer = ChunkBuffer::new(next_ordinal, checkpoint_digest);
1740
1741                let completion_context = ChunkCompletionContext::new(
1742                    receipt.checkpoint(),
1743                    receipt.execution_context(),
1744                    counts,
1745                    stop,
1746                );
1747                let terminal_outcome = match invoke_completion(completion, completion_context).await
1748                {
1749                    Ok(ChunkCompletionOutcome::Acknowledged) => {
1750                        if stop.is_stop_requested() {
1751                            Some(ChunkExecutionOutcome::Stopped)
1752                        } else if end_of_input {
1753                            Some(ChunkExecutionOutcome::Completed)
1754                        } else {
1755                            None
1756                        }
1757                    }
1758                    Ok(ChunkCompletionOutcome::StoppedAfterCommit) => {
1759                        Some(ChunkExecutionOutcome::Stopped)
1760                    }
1761                    Err(failure) => Some(ChunkExecutionOutcome::Failed(failure)),
1762                };
1763
1764                let after_context =
1765                    ChunkListenerContext::new(sequence, state.committed_counts, stop);
1766                let after_failures =
1767                    run_after_listeners(listeners, after_context, ChunkAttemptOutcome::Committed)
1768                        .await;
1769                // `end_chunk` observes this attempt's transaction outcome
1770                // (already committed by this point), never a listener's
1771                // outcome, so it always runs here -- even when an
1772                // `after_chunk` listener below fails -- or an
1773                // AdaptiveCompletionPolicy's pending candidate would never
1774                // promote to confirmed for a chunk that in fact committed,
1775                // leaving durable and in-memory state disagreeing about the
1776                // authoritative target for the rest of this process.
1777                let completion_policy_panicked = policy_attempt
1778                    .finish(ChunkAttemptOutcome::Committed)
1779                    .is_err();
1780                if let Some(first) = after_failures.first().copied() {
1781                    state.listener_failures.extend(after_failures);
1782                    let original = terminal_outcome.or(Some(ChunkExecutionOutcome::Completed));
1783                    return state.report(listener_failure_outcome(first.kind()), original);
1784                }
1785                if completion_policy_panicked {
1786                    let original = terminal_outcome.or(Some(ChunkExecutionOutcome::Completed));
1787                    return state.report(
1788                        ChunkExecutionOutcome::Failed(ChunkFailure::CompletionPolicyPanic),
1789                        original,
1790                    );
1791                }
1792                if let Some(outcome) = terminal_outcome {
1793                    return state.report(outcome, None);
1794                }
1795            }
1796            AttemptResult::Replay => {
1797                if let Err(report) = record_rolled_back_attempt(
1798                    listeners,
1799                    policy_attempt,
1800                    listener_context,
1801                    &mut state,
1802                    &mut emit,
1803                )
1804                .await
1805                {
1806                    return report;
1807                }
1808            }
1809            AttemptResult::RolledBack(ChunkExecutionOutcome::Completed) => {
1810                // The final chunk read nothing, so its unused transaction rolls
1811                // back without counting as a rolled-back attempt.
1812                emit(ChunkRuntimeEvent::RolledBack(sequence));
1813                let failures = run_after_listeners(
1814                    listeners,
1815                    listener_context,
1816                    ChunkAttemptOutcome::RolledBack,
1817                )
1818                .await;
1819                let completion_policy_panicked = policy_attempt
1820                    .finish(ChunkAttemptOutcome::RolledBack)
1821                    .is_err();
1822                if let Some(first) = failures.first().copied() {
1823                    state.listener_failures.extend(failures);
1824                    return state
1825                        .drain()
1826                        .report(listener_failure_outcome(first.kind()), None);
1827                }
1828                if completion_policy_panicked {
1829                    return state.drain().report(
1830                        ChunkExecutionOutcome::Failed(ChunkFailure::CompletionPolicyPanic),
1831                        None,
1832                    );
1833                }
1834                return state.report(ChunkExecutionOutcome::Completed, None);
1835            }
1836            AttemptResult::RolledBack(outcome) => {
1837                state.rollback_count = state.rollback_count.saturating_add(1);
1838                state.terminal_rollback = true;
1839                state.rolled_back_chunks = match state.rolled_back_chunks.checked_increment() {
1840                    Ok(count) => count,
1841                    Err(_) => {
1842                        return state.drain().report(
1843                            ChunkExecutionOutcome::Failed(ChunkFailure::Count),
1844                            Some(outcome),
1845                        );
1846                    }
1847                };
1848                emit(ChunkRuntimeEvent::RolledBack(sequence));
1849                let attempt_outcome = match outcome {
1850                    ChunkExecutionOutcome::Stopped => ChunkAttemptOutcome::Stopped,
1851                    _ => ChunkAttemptOutcome::RolledBack,
1852                };
1853                return finish_failed_attempt(
1854                    listeners,
1855                    policy_attempt,
1856                    listener_context,
1857                    attempt_outcome,
1858                    outcome,
1859                    &mut state,
1860                )
1861                .await;
1862            }
1863            AttemptResult::RollbackFailed(original) => {
1864                // Rollback failure must never suppress a policy lifecycle
1865                // this attempt already began: the transaction's fate is no
1866                // longer knowable, so `end_chunk` observes `Unknown` rather
1867                // than being skipped -- see `CompletionPolicy::end_chunk`.
1868                let completion_policy_panicked =
1869                    policy_attempt.finish(ChunkAttemptOutcome::Unknown).is_err();
1870                if completion_policy_panicked {
1871                    return state.drain().report(
1872                        ChunkExecutionOutcome::Failed(ChunkFailure::CompletionPolicyPanic),
1873                        Some(ChunkExecutionOutcome::Failed(
1874                            ChunkFailure::TransactionRollback,
1875                        )),
1876                    );
1877                }
1878                return state.drain().report(
1879                    ChunkExecutionOutcome::Failed(ChunkFailure::TransactionRollback),
1880                    original,
1881                );
1882            }
1883            AttemptResult::Unknown => {
1884                emit(ChunkRuntimeEvent::Unknown(sequence));
1885                return finish_failed_attempt(
1886                    listeners,
1887                    policy_attempt,
1888                    listener_context,
1889                    ChunkAttemptOutcome::Unknown,
1890                    ChunkExecutionOutcome::Unknown,
1891                    &mut state,
1892                )
1893                .await;
1894            }
1895        }
1896    }
1897}
1898
1899/// Loads the durable progress this attempt inherits and binds durable state.
1900///
1901/// A standalone chunk step has no repository execution and inherits nothing. A
1902/// repository-backed step fails closed rather than restarting a bounded policy
1903/// limit from zero or deriving retry keys from the wrong checkpoint.
1904enum StreamOpenPhaseResult {
1905    Opened {
1906        progress: InheritedStepProgress,
1907        streams: Vec<Arc<BoxedStream>>,
1908    },
1909    Failed {
1910        outcome: ChunkExecutionOutcome,
1911        close_failed: bool,
1912    },
1913}
1914
1915async fn inherited_progress(
1916    transactions: &dyn ChunkTransactionManager,
1917    fault: Option<&FaultRuntime>,
1918    context: Option<ChunkTransactionContext>,
1919    streams: &[StreamRegistration],
1920    stop: &StopToken,
1921) -> StreamOpenPhaseResult {
1922    let inherited_state = match context {
1923        None => Vec::new(),
1924        Some(_) if streams.is_empty() => Vec::new(),
1925        Some(context) => match transactions.inherited_component_state(context).await {
1926            Ok(state) => state,
1927            Err(_) => {
1928                return StreamOpenPhaseResult::Failed {
1929                    outcome: ChunkExecutionOutcome::Failed(ChunkFailure::StreamOpen),
1930                    close_failed: false,
1931                };
1932            }
1933        },
1934    };
1935
1936    let progress = match context {
1937        None => InheritedStepProgress::NONE,
1938        Some(context) => {
1939            if let Some(fault) = fault
1940                && fault.state().bind(context).await.is_err()
1941            {
1942                return StreamOpenPhaseResult::Failed {
1943                    outcome: ChunkExecutionOutcome::Failed(ChunkFailure::FaultState),
1944                    close_failed: false,
1945                };
1946            }
1947            match transactions.inherited_progress(context).await {
1948                Ok(inherited) => inherited,
1949                Err(ChunkTransactionError::CommitOutcomeUnknown) => {
1950                    return StreamOpenPhaseResult::Failed {
1951                        outcome: ChunkExecutionOutcome::Unknown,
1952                        close_failed: false,
1953                    };
1954                }
1955                Err(
1956                    ChunkTransactionError::NotCommitted
1957                    | ChunkTransactionError::ComponentStateUnsupported,
1958                ) => {
1959                    return StreamOpenPhaseResult::Failed {
1960                        outcome: ChunkExecutionOutcome::Failed(ChunkFailure::FaultState),
1961                        close_failed: false,
1962                    };
1963                }
1964            }
1965        }
1966    };
1967
1968    let mut opened: Vec<Arc<BoxedStream>> = Vec::with_capacity(streams.len());
1969    for (identity, stream, contract, _owner) in streams {
1970        let envelope = inherited_state
1971            .iter()
1972            .find(|envelope| envelope.namespace() == identity);
1973        // Gate C: an inherited envelope is validated against this stream's
1974        // declared schema/codec contract and migrated to its current
1975        // versions -- rejecting an unknown/newer schema or codec, or a
1976        // migration failure -- before `open` ever runs. `open` never
1977        // observes an envelope this contract has not already accepted.
1978        let validated = match envelope.map(|envelope| contract.validate_for_open(envelope)) {
1979            None => None,
1980            Some(Ok(migrated)) => Some(migrated),
1981            Some(Err(_)) => {
1982                let close_context = StreamCloseContext::new(stop, StreamRuntimeOutcome::Failed);
1983                let close_failed = close_streams(&opened, close_context).await;
1984                return StreamOpenPhaseResult::Failed {
1985                    outcome: ChunkExecutionOutcome::Failed(ChunkFailure::StreamOpen),
1986                    close_failed,
1987                };
1988            }
1989        };
1990        let open_context = StreamOpenContext::new(validated.as_ref(), stop);
1991        if stream.open(open_context).await.is_ok() {
1992            opened.push(Arc::clone(stream));
1993        } else {
1994            let close_context = StreamCloseContext::new(stop, StreamRuntimeOutcome::Failed);
1995            let close_failed = close_streams(&opened, close_context).await;
1996            return StreamOpenPhaseResult::Failed {
1997                outcome: ChunkExecutionOutcome::Failed(ChunkFailure::StreamOpen),
1998                close_failed,
1999            };
2000        }
2001    }
2002    StreamOpenPhaseResult::Opened {
2003        progress,
2004        streams: opened,
2005    }
2006}
2007
2008/// Counts one rolled-back attempt and runs its after-chunk listeners.
2009async fn record_rolled_back_attempt<E>(
2010    listeners: &[Arc<dyn ChunkListener>],
2011    policy_attempt: CompletionPolicyAttempt<'_>,
2012    context: ChunkListenerContext<'_>,
2013    state: &mut ExecutionState,
2014    emit: &mut E,
2015) -> Result<(), ChunkExecutionReport>
2016where
2017    E: FnMut(ChunkRuntimeEvent),
2018{
2019    state.rolled_back_chunks = match state.rolled_back_chunks.checked_increment() {
2020        Ok(count) => count,
2021        Err(_) => {
2022            return Err(state
2023                .drain()
2024                .report(ChunkExecutionOutcome::Failed(ChunkFailure::Count), None));
2025        }
2026    };
2027    emit(ChunkRuntimeEvent::RolledBack(context.sequence()));
2028    let failures = run_after_listeners(listeners, context, ChunkAttemptOutcome::RolledBack).await;
2029    let completion_policy_panicked = policy_attempt
2030        .finish(ChunkAttemptOutcome::RolledBack)
2031        .is_err();
2032    if let Some(first) = failures.first().copied() {
2033        state.listener_failures.extend(failures);
2034        return Err(state
2035            .drain()
2036            .report(listener_failure_outcome(first.kind()), None));
2037    }
2038    if completion_policy_panicked {
2039        return Err(state.drain().report(
2040            ChunkExecutionOutcome::Failed(ChunkFailure::CompletionPolicyPanic),
2041            None,
2042        ));
2043    }
2044    Ok(())
2045}
2046
2047async fn run_attempt<'c, I, O, E, P, W, R>(
2048    components: &Components<'c, I, O, P, W>,
2049    reader: &mut R,
2050    scope: AttemptScope<'_>,
2051    buffer: &mut ChunkBuffer<I, O>,
2052    state: &mut ExecutionState,
2053    transaction: &mut dyn ChunkTransaction,
2054    emit: &mut E,
2055) -> (AttemptResult, CompletionPolicyAttempt<'c>)
2056where
2057    I: Send + Sync,
2058    O: Send + Sync,
2059    E: FnMut(ChunkRuntimeEvent),
2060    P: ItemProcessor<I, O>,
2061    W: ItemWriter<O>,
2062    R: ItemReader<I>,
2063{
2064    let mut outputs = AttemptOutputs::new();
2065    let mut component_state = Vec::new();
2066    // Constructed first, before any other phase of this attempt: every
2067    // attempt whose transaction has already begun (this function only runs
2068    // once one has) owes exactly one `begin_chunk`/`end_chunk` pair,
2069    // regardless of what happens afterward in this attempt -- see the
2070    // lifecycle contract on `CompletionPolicy::begin_chunk`.
2071    let policy_attempt = CompletionPolicyAttempt::begin(components.completion_policy);
2072
2073    let verdict = 'body: {
2074        if policy_attempt.begin_panicked() {
2075            break 'body Verdict::Terminal(ChunkExecutionOutcome::Failed(
2076                ChunkFailure::CompletionPolicyPanic,
2077            ));
2078        }
2079        if let Some(fault) = components.fault
2080            && fault.policy().requires_commit_safe_skip()
2081            && transaction.business_transaction().is_none()
2082        {
2083            break 'body Verdict::Terminal(ChunkExecutionOutcome::Failed(
2084                ChunkFailure::UnsupportedCapability,
2085            ));
2086        }
2087
2088        match read_phase(components, reader, scope, buffer, state, emit).await {
2089            Verdict::Commit => {}
2090            other => break 'body other,
2091        }
2092
2093        if buffer.slots.is_empty() && buffer.end_of_input && buffer.skips.is_empty() {
2094            break 'body Verdict::Terminal(ChunkExecutionOutcome::Completed);
2095        }
2096
2097        match process_phase(components, scope, buffer, state, &mut outputs, emit).await {
2098            Verdict::Commit => {}
2099            other => break 'body other,
2100        }
2101
2102        write_phase(
2103            components,
2104            scope,
2105            buffer,
2106            state,
2107            &mut outputs,
2108            transaction,
2109            &mut component_state,
2110            emit,
2111        )
2112        .await
2113    };
2114
2115    let result = match verdict {
2116        Verdict::Commit => {
2117            commit_attempt(
2118                components,
2119                scope,
2120                buffer,
2121                state,
2122                transaction,
2123                &outputs,
2124                &component_state,
2125            )
2126            .await
2127        }
2128        Verdict::Retry(request) => {
2129            schedule_retry(components, scope, buffer, state, transaction, request, emit).await
2130        }
2131        Verdict::Replay => {
2132            if transaction.rollback().await.is_err() {
2133                AttemptResult::RollbackFailed(None)
2134            } else {
2135                AttemptResult::Replay
2136            }
2137        }
2138        Verdict::Terminal(ChunkExecutionOutcome::Unknown) => AttemptResult::Unknown,
2139        Verdict::Terminal(outcome) => {
2140            if transaction.rollback().await.is_err() {
2141                AttemptResult::RollbackFailed(Some(outcome))
2142            } else {
2143                AttemptResult::RolledBack(outcome)
2144            }
2145        }
2146    };
2147    (result, policy_attempt)
2148}
2149
2150/// Reads until the chunk is full or the input ends.
2151#[allow(
2152    clippy::too_many_lines,
2153    reason = "one phase keeps its listener, classification, and skip order visible"
2154)]
2155async fn read_phase<I, O, E, P, W, R>(
2156    components: &Components<'_, I, O, P, W>,
2157    reader: &mut R,
2158    scope: AttemptScope<'_>,
2159    buffer: &mut ChunkBuffer<I, O>,
2160    state: &mut ExecutionState,
2161    emit: &mut E,
2162) -> Verdict
2163where
2164    I: Send + Sync,
2165    P: ItemProcessor<I, O>,
2166    W: ItemWriter<O>,
2167    R: ItemReader<I>,
2168    O: Send + Sync,
2169    E: FnMut(ChunkRuntimeEvent),
2170{
2171    let listener_context = scope.listener_context();
2172    loop {
2173        if buffer.end_of_input || buffer.slots.len() >= components.size.get() as usize {
2174            break;
2175        }
2176        // Forward-progress invariant (`REPEAT-POLICY-001` livelock guard):
2177        // consult the completion policy only once this attempt has actually
2178        // accepted at least one item. A policy that reports
2179        // `is_complete(0) == true` can therefore never stop this attempt
2180        // before it has read (or exhausted) at least one input, so the step
2181        // can never repeatedly commit an all-empty chunk purely on the
2182        // policy's say-so -- see `CompletionPolicy::is_complete`.
2183        if !buffer.slots.is_empty()
2184            && let Some(policy) = components.completion_policy
2185        {
2186            match invoke_completion_policy_is_complete(
2187                policy,
2188                ChunkCount::new(buffer.slots.len() as u64),
2189            ) {
2190                Ok(true) => break,
2191                Ok(false) => {}
2192                Err(()) => {
2193                    return Verdict::Terminal(ChunkExecutionOutcome::Failed(
2194                        ChunkFailure::CompletionPolicyPanic,
2195                    ));
2196                }
2197            }
2198        }
2199        if scope.stop.is_stop_requested() {
2200            return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2201        }
2202        let ordinal = buffer.read_ordinal;
2203        let key = retry_key(
2204            components,
2205            buffer.checkpoint_digest,
2206            FaultPhase::Read,
2207            ordinal,
2208        );
2209
2210        let before = components
2211            .item_listeners
2212            .before_read(listener_context)
2213            .await;
2214        if let Some(failure) = before.failure() {
2215            state.item_listener_failures.push(failure);
2216            return Verdict::Terminal(item_listener_outcome(failure.kind()));
2217        }
2218
2219        match invoke_reader(reader, ReadContext::new(scope.stop)).await {
2220            Invoked::Completed(ReadOutcome::Item(item)) => {
2221                let failures = components
2222                    .item_listeners
2223                    .after_read(before.entered(), &item, listener_context)
2224                    .await;
2225                if let Some(first) = failures.first().copied() {
2226                    state.item_listener_failures.extend(failures);
2227                    return Verdict::Terminal(item_listener_outcome(first.kind()));
2228                }
2229                if let Some(outcome) = complete_retry(
2230                    components,
2231                    listener_context,
2232                    &mut buffer.retry,
2233                    state,
2234                    key,
2235                    RetryOutcome::Recovered,
2236                )
2237                .await
2238                {
2239                    return Verdict::Terminal(outcome);
2240                }
2241                resolve_key(components, key).await;
2242                buffer.slots.push(ItemSlot {
2243                    item,
2244                    ordinal,
2245                    skipped: false,
2246                });
2247                buffer.read_ordinal = buffer.read_ordinal.saturating_add(1);
2248            }
2249            Invoked::Completed(ReadOutcome::EndOfInput) => buffer.end_of_input = true,
2250            Invoked::Completed(ReadOutcome::Stopped) => {
2251                return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2252            }
2253            invoked => {
2254                let (error, panicked) = match invoked {
2255                    Invoked::Failed(error) => (error, false),
2256                    _ => (ReaderError::new(), true),
2257                };
2258                let terminal = if panicked {
2259                    ChunkFailure::ReaderPanic
2260                } else {
2261                    ChunkFailure::Reader
2262                };
2263                let advanced = !panicked && error.has_checkpoint_advanced();
2264                let Some(fault) = descriptor(
2265                    components,
2266                    state,
2267                    &buffer.skips,
2268                    FaultPhase::Read,
2269                    error.category(),
2270                ) else {
2271                    return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
2272                };
2273                let fault = with_reserved_ordinal(components, key, fault).await;
2274
2275                let failures = components
2276                    .item_listeners
2277                    .on_read_error(before.entered(), fault, listener_context)
2278                    .await;
2279                if let Some(first) = failures.first().copied() {
2280                    state.item_listener_failures.extend(failures);
2281                    return Verdict::Terminal(item_listener_outcome(first.kind()));
2282                }
2283
2284                let evidence = FaultEvidence::new(advanced, true, advanced);
2285                let decision = match classify(
2286                    components,
2287                    listener_context,
2288                    &mut buffer.retry,
2289                    state,
2290                    key,
2291                    fault,
2292                    evidence,
2293                    scope.sequence,
2294                    emit,
2295                )
2296                .await
2297                {
2298                    Ok(decision) => decision,
2299                    Err(outcome) => return Verdict::Terminal(outcome),
2300                };
2301                match decision {
2302                    FaultDecision::Retry { ordinal, delay } => {
2303                        return Verdict::Retry(RetryRequest {
2304                            key,
2305                            phase: FaultPhase::Read,
2306                            fault,
2307                            ordinal,
2308                            delay,
2309                        });
2310                    }
2311                    FaultDecision::Skip { disposition } => {
2312                        resolve_key(components, key).await;
2313                        buffer.read_ordinal = buffer.read_ordinal.saturating_add(1);
2314                        buffer.skips.push(PendingSkip {
2315                            phase: FaultPhase::Read,
2316                            fault,
2317                            disposition,
2318                            slot: None,
2319                            output: None,
2320                        });
2321                        if disposition == RollbackDisposition::Rollback {
2322                            return Verdict::Replay;
2323                        }
2324                    }
2325                    FaultDecision::Unknown => {
2326                        return Verdict::Terminal(ChunkExecutionOutcome::Unknown);
2327                    }
2328                    FaultDecision::Stop => {
2329                        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2330                    }
2331                    // `FaultDecision::FailAndRollback`, and any decision this
2332                    // build does not know: `FaultDecision` is
2333                    // `#[non_exhaustive]`, and an unrecognized decision rolls
2334                    // back and fails rather than committing work or claiming an
2335                    // unknown commit.
2336                    _ => {
2337                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
2338                    }
2339                }
2340            }
2341        }
2342    }
2343    Verdict::Commit
2344}
2345
2346/// Processes every buffered input that is not already skipped.
2347#[allow(
2348    clippy::too_many_lines,
2349    reason = "one phase keeps its listener, classification, and skip order visible"
2350)]
2351async fn process_phase<I, O, E, P, W>(
2352    components: &Components<'_, I, O, P, W>,
2353    scope: AttemptScope<'_>,
2354    buffer: &mut ChunkBuffer<I, O>,
2355    state: &mut ExecutionState,
2356    outputs: &mut AttemptOutputs<O>,
2357    emit: &mut E,
2358) -> Verdict
2359where
2360    I: Send + Sync,
2361    O: Send + Sync,
2362    E: FnMut(ChunkRuntimeEvent),
2363    P: ItemProcessor<I, O>,
2364    W: ItemWriter<O>,
2365{
2366    let listener_context = scope.listener_context();
2367    outputs.reset();
2368    for index in 0..buffer.slots.len() {
2369        if buffer.slots[index].skipped {
2370            continue;
2371        }
2372        if scope.stop.is_stop_requested() {
2373            return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2374        }
2375        let ordinal = buffer.slots[index].ordinal;
2376        let key = retry_key(
2377            components,
2378            buffer.checkpoint_digest,
2379            FaultPhase::Process,
2380            ordinal,
2381        );
2382
2383        let before = components
2384            .item_listeners
2385            .before_process(&buffer.slots[index].item, listener_context)
2386            .await;
2387        if let Some(failure) = before.failure() {
2388            state.item_listener_failures.push(failure);
2389            return Verdict::Terminal(item_listener_outcome(failure.kind()));
2390        }
2391
2392        let invoked = invoke_processor(
2393            components.processor,
2394            &buffer.slots[index].item,
2395            ProcessContext::new(scope.stop),
2396        )
2397        .await;
2398        match invoked {
2399            Invoked::Completed(ProcessOutcome::Item(output)) => {
2400                let failures = components
2401                    .item_listeners
2402                    .after_process(
2403                        before.entered(),
2404                        &buffer.slots[index].item,
2405                        Some(&output),
2406                        listener_context,
2407                    )
2408                    .await;
2409                if let Some(first) = failures.first().copied() {
2410                    state.item_listener_failures.extend(failures);
2411                    return Verdict::Terminal(item_listener_outcome(first.kind()));
2412                }
2413                if let Some(outcome) = complete_retry(
2414                    components,
2415                    listener_context,
2416                    &mut buffer.retry,
2417                    state,
2418                    key,
2419                    RetryOutcome::Recovered,
2420                )
2421                .await
2422                {
2423                    return Verdict::Terminal(outcome);
2424                }
2425                resolve_key(components, key).await;
2426                outputs.values.push(output);
2427                outputs.slots.push(index);
2428            }
2429            Invoked::Completed(ProcessOutcome::Filtered) => {
2430                let failures = components
2431                    .item_listeners
2432                    .after_process(
2433                        before.entered(),
2434                        &buffer.slots[index].item,
2435                        None,
2436                        listener_context,
2437                    )
2438                    .await;
2439                if let Some(first) = failures.first().copied() {
2440                    state.item_listener_failures.extend(failures);
2441                    return Verdict::Terminal(item_listener_outcome(first.kind()));
2442                }
2443                if let Some(outcome) = complete_retry(
2444                    components,
2445                    listener_context,
2446                    &mut buffer.retry,
2447                    state,
2448                    key,
2449                    RetryOutcome::Recovered,
2450                )
2451                .await
2452                {
2453                    return Verdict::Terminal(outcome);
2454                }
2455                resolve_key(components, key).await;
2456                outputs.filtered = outputs.filtered.saturating_add(1);
2457            }
2458            Invoked::Completed(ProcessOutcome::Stopped) => {
2459                return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2460            }
2461            invoked => {
2462                let (error, panicked) = match invoked {
2463                    Invoked::Failed(error) => (error, false),
2464                    _ => (ProcessorError::new(), true),
2465                };
2466                let terminal = if panicked {
2467                    ChunkFailure::ProcessorPanic
2468                } else {
2469                    ChunkFailure::Processor
2470                };
2471                let Some(fault) = descriptor(
2472                    components,
2473                    state,
2474                    &buffer.skips,
2475                    FaultPhase::Process,
2476                    error.category(),
2477                ) else {
2478                    return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
2479                };
2480                let fault = with_reserved_ordinal(components, key, fault).await;
2481
2482                let failures = components
2483                    .item_listeners
2484                    .on_process_error(
2485                        before.entered(),
2486                        &buffer.slots[index].item,
2487                        fault,
2488                        listener_context,
2489                    )
2490                    .await;
2491                if let Some(first) = failures.first().copied() {
2492                    state.item_listener_failures.extend(failures);
2493                    return Verdict::Terminal(item_listener_outcome(first.kind()));
2494                }
2495
2496                // The input is located and no writer effect has started, so the
2497                // framework owns every piece of process-skip evidence.
2498                let evidence = FaultEvidence::new(true, true, true);
2499                let decision = match classify(
2500                    components,
2501                    listener_context,
2502                    &mut buffer.retry,
2503                    state,
2504                    key,
2505                    fault,
2506                    evidence,
2507                    scope.sequence,
2508                    emit,
2509                )
2510                .await
2511                {
2512                    Ok(decision) => decision,
2513                    Err(outcome) => return Verdict::Terminal(outcome),
2514                };
2515                match decision {
2516                    FaultDecision::Retry { ordinal, delay } => {
2517                        return Verdict::Retry(RetryRequest {
2518                            key,
2519                            phase: FaultPhase::Process,
2520                            fault,
2521                            ordinal,
2522                            delay,
2523                        });
2524                    }
2525                    FaultDecision::Skip { disposition } => {
2526                        resolve_key(components, key).await;
2527                        buffer.slots[index].skipped = true;
2528                        buffer.skips.push(PendingSkip {
2529                            phase: FaultPhase::Process,
2530                            fault,
2531                            disposition,
2532                            slot: Some(index),
2533                            output: None,
2534                        });
2535                        if disposition == RollbackDisposition::Rollback {
2536                            return Verdict::Replay;
2537                        }
2538                    }
2539                    FaultDecision::Unknown => {
2540                        return Verdict::Terminal(ChunkExecutionOutcome::Unknown);
2541                    }
2542                    FaultDecision::Stop => {
2543                        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2544                    }
2545                    // `FaultDecision::FailAndRollback`, and any decision this
2546                    // build does not know: `FaultDecision` is
2547                    // `#[non_exhaustive]`, and an unrecognized decision rolls
2548                    // back and fails rather than committing work or claiming an
2549                    // unknown commit.
2550                    _ => {
2551                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
2552                    }
2553                }
2554            }
2555        }
2556    }
2557    Verdict::Commit
2558}
2559
2560/// Writes the provisional output batch inside the open transaction.
2561#[allow(
2562    clippy::too_many_lines,
2563    reason = "one phase keeps its listener, classification, and skip order visible"
2564)]
2565#[allow(
2566    clippy::too_many_arguments,
2567    reason = "the write boundary needs components, scope, buffer, state, outputs, and the transaction"
2568)]
2569async fn write_phase<I, O, E, P, W>(
2570    components: &Components<'_, I, O, P, W>,
2571    scope: AttemptScope<'_>,
2572    buffer: &mut ChunkBuffer<I, O>,
2573    state: &mut ExecutionState,
2574    outputs: &mut AttemptOutputs<O>,
2575    transaction: &mut dyn ChunkTransaction,
2576    component_state: &mut Vec<ComponentStateEnvelope>,
2577    emit: &mut E,
2578) -> Verdict
2579where
2580    I: Send + Sync,
2581    O: Send + Sync,
2582    E: FnMut(ChunkRuntimeEvent),
2583    P: ItemProcessor<I, O>,
2584    W: ItemWriter<O>,
2585{
2586    if outputs.values.is_empty() {
2587        return update_streams(components, scope, component_state).await;
2588    }
2589    if scope.stop.is_stop_requested() {
2590        return Verdict::Terminal(ChunkExecutionOutcome::Stopped);
2591    }
2592    let listener_context = scope.listener_context();
2593    let key = retry_key(
2594        components,
2595        buffer.checkpoint_digest,
2596        FaultPhase::Write,
2597        buffer.base_ordinal,
2598    );
2599
2600    let before = components
2601        .item_listeners
2602        .before_write(&outputs.values, listener_context)
2603        .await;
2604    if let Some(failure) = before.failure() {
2605        state.item_listener_failures.push(failure);
2606        return Verdict::Terminal(item_listener_outcome(failure.kind()));
2607    }
2608
2609    let write_context = match transaction.business_transaction() {
2610        Some(business) => WriteContext::enlisted(scope.stop, business),
2611        None => WriteContext::non_transactional(scope.stop),
2612    };
2613    match invoke_writer(components.writer, &outputs.values, write_context).await {
2614        Invoked::Completed(WriteOutcome::Written) => {
2615            let failures = components
2616                .item_listeners
2617                .after_write(before.entered(), &outputs.values, listener_context)
2618                .await;
2619            if let Some(first) = failures.first().copied() {
2620                state.item_listener_failures.extend(failures);
2621                return Verdict::Terminal(item_listener_outcome(first.kind()));
2622            }
2623            if let Some(outcome) = complete_retry(
2624                components,
2625                listener_context,
2626                &mut buffer.retry,
2627                state,
2628                key,
2629                RetryOutcome::Recovered,
2630            )
2631            .await
2632            {
2633                return Verdict::Terminal(outcome);
2634            }
2635            resolve_key(components, key).await;
2636            update_streams(components, scope, component_state).await
2637        }
2638        Invoked::Completed(WriteOutcome::Stopped) => {
2639            Verdict::Terminal(ChunkExecutionOutcome::Stopped)
2640        }
2641        invoked => {
2642            let (error, panicked) = match invoked {
2643                Invoked::Failed(error) => (error, false),
2644                _ => (WriterError::new(), true),
2645            };
2646            let terminal = if panicked {
2647                ChunkFailure::WriterPanic
2648            } else {
2649                ChunkFailure::Writer
2650            };
2651            let located = if panicked {
2652                None
2653            } else {
2654                error
2655                    .rolled_back_output()
2656                    .filter(|index| *index < outputs.values.len())
2657            };
2658            let Some(fault) = descriptor(
2659                components,
2660                state,
2661                &buffer.skips,
2662                FaultPhase::Write,
2663                error.category(),
2664            ) else {
2665                return Verdict::Terminal(ChunkExecutionOutcome::Failed(ChunkFailure::Count));
2666            };
2667            let fault = with_reserved_ordinal(components, key, fault).await;
2668
2669            let failures = components
2670                .item_listeners
2671                .on_write_error(before.entered(), &outputs.values, fault, listener_context)
2672                .await;
2673            if let Some(first) = failures.first().copied() {
2674                state.item_listener_failures.extend(failures);
2675                return Verdict::Terminal(item_listener_outcome(first.kind()));
2676            }
2677
2678            let evidence = FaultEvidence::new(located.is_some(), located.is_some(), false);
2679            let decision = match classify(
2680                components,
2681                listener_context,
2682                &mut buffer.retry,
2683                state,
2684                key,
2685                fault,
2686                evidence,
2687                scope.sequence,
2688                emit,
2689            )
2690            .await
2691            {
2692                Ok(decision) => decision,
2693                Err(outcome) => return Verdict::Terminal(outcome),
2694            };
2695            match decision {
2696                FaultDecision::Retry { ordinal, delay } => Verdict::Retry(RetryRequest {
2697                    key,
2698                    phase: FaultPhase::Write,
2699                    fault,
2700                    ordinal,
2701                    delay,
2702                }),
2703                FaultDecision::Skip { disposition } => {
2704                    let Some(index) = located else {
2705                        return Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal));
2706                    };
2707                    resolve_key(components, key).await;
2708                    let slot = outputs.slots[index];
2709                    buffer.slots[slot].skipped = true;
2710                    buffer.skips.push(PendingSkip {
2711                        phase: FaultPhase::Write,
2712                        fault,
2713                        disposition,
2714                        slot: Some(slot),
2715                        output: Some(outputs.values.remove(index)),
2716                    });
2717                    outputs.slots.remove(index);
2718                    Verdict::Replay
2719                }
2720                FaultDecision::Unknown => Verdict::Terminal(ChunkExecutionOutcome::Unknown),
2721                FaultDecision::Stop => Verdict::Terminal(ChunkExecutionOutcome::Stopped),
2722                // `FaultDecision::FailAndRollback`, and any decision this build
2723                // does not know: `FaultDecision` is `#[non_exhaustive]`, and an
2724                // unrecognized decision rolls back and fails rather than
2725                // committing work or claiming an unknown commit.
2726                _ => Verdict::Terminal(ChunkExecutionOutcome::Failed(terminal)),
2727            }
2728        }
2729    }
2730}
2731
2732/// Prepares each registered `ItemStream`'s candidate state for the
2733/// committing chunk, in registration order.
2734///
2735/// Runs once per committing attempt, after the writer has accepted the
2736/// chunk and before the durable commit -- never per item. A failure here
2737/// prevents the candidate commit, matching process/write-error handling: no
2738/// partial commit. A stream that returns an envelope carrying a namespace
2739/// other than its own registered identity is rejected here, before the
2740/// candidate ever reaches the durable UPSERT: an unregistered or mismatched
2741/// namespace on the persisted side would let one stream silently overwrite
2742/// another's committed state.
2743async fn update_streams<I, O, P, W>(
2744    components: &Components<'_, I, O, P, W>,
2745    scope: AttemptScope<'_>,
2746    component_state: &mut Vec<ComponentStateEnvelope>,
2747) -> Verdict {
2748    component_state.clear();
2749    let context = StreamUpdateContext::new(scope.stop);
2750    for (identity, stream, _, _owner) in components.streams {
2751        match stream.update(context).await {
2752            Ok(envelope) if envelope.namespace() == identity => component_state.push(envelope),
2753            Ok(_) | Err(_) => {
2754                component_state.clear();
2755                return Verdict::Terminal(ChunkExecutionOutcome::Failed(
2756                    ChunkFailure::StreamUpdate,
2757                ));
2758            }
2759        }
2760    }
2761    Verdict::Commit
2762}
2763
2764/// Runs skip callbacks, clears resolved keys, and commits the chunk.
2765async fn commit_attempt<I, O, P, W>(
2766    components: &Components<'_, I, O, P, W>,
2767    scope: AttemptScope<'_>,
2768    buffer: &mut ChunkBuffer<I, O>,
2769    state: &mut ExecutionState,
2770    transaction: &mut dyn ChunkTransaction,
2771    outputs: &AttemptOutputs<O>,
2772    component_state: &[ComponentStateEnvelope],
2773) -> AttemptResult
2774where
2775    I: Send + Sync,
2776    O: Send + Sync,
2777    P: ItemProcessor<I, O>,
2778    W: ItemWriter<O>,
2779{
2780    let listener_context = scope.listener_context();
2781    for skip in &buffer.skips {
2782        let failures = match (skip.phase, skip.slot, skip.output.as_ref()) {
2783            (FaultPhase::Process, Some(index), _) => {
2784                components
2785                    .item_listeners
2786                    .on_skip_in_process(&buffer.slots[index].item, skip.fault, listener_context)
2787                    .await
2788            }
2789            (FaultPhase::Write, _, Some(output)) => {
2790                components
2791                    .item_listeners
2792                    .on_skip_in_write(output, skip.fault, listener_context)
2793                    .await
2794            }
2795            _ => {
2796                components
2797                    .item_listeners
2798                    .on_skip_in_read(skip.fault, listener_context)
2799                    .await
2800            }
2801        };
2802        if let Some(first) = failures.first().copied() {
2803            state.item_listener_failures.extend(failures);
2804            let outcome = item_listener_outcome(first.kind());
2805            if transaction.rollback().await.is_err() {
2806                return AttemptResult::RollbackFailed(Some(outcome));
2807            }
2808            return AttemptResult::RolledBack(outcome);
2809        }
2810    }
2811
2812    let read = ChunkCount::new(buffer.slots.len() as u64);
2813    let processed = ChunkCount::new(outputs.values.len() as u64);
2814    let Ok(counts) = ChunkCounts::new(
2815        read,
2816        processed,
2817        processed,
2818        ChunkCount::new(outputs.filtered),
2819    ) else {
2820        let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::Count);
2821        if transaction.rollback().await.is_err() {
2822            return AttemptResult::RollbackFailed(Some(outcome));
2823        }
2824        return AttemptResult::RolledBack(outcome);
2825    };
2826
2827    let Some(accepted) = accepted_fault_progress(&buffer.skips) else {
2828        let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::Count);
2829        if transaction.rollback().await.is_err() {
2830            return AttemptResult::RollbackFailed(Some(outcome));
2831        }
2832        return AttemptResult::RolledBack(outcome);
2833    };
2834
2835    match transaction
2836        .commit_with_component_state(counts, accepted, component_state)
2837        .await
2838    {
2839        Ok(receipt) => {
2840            let Ok(next_skips) = state.skip_counts.checked_add(accepted.skips()) else {
2841                return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2842                    ChunkFailure::Count,
2843                ));
2844            };
2845            state.skip_counts = next_skips;
2846            state.no_rollback_count = state
2847                .no_rollback_count
2848                .saturating_add(accepted.no_rollbacks());
2849            // The commit that advanced the checkpoint superseded every retry
2850            // key of the previous generation, so the durable clear is already
2851            // authoritative and this only prunes process-local bookkeeping.
2852            if let Some(fault) = components.fault {
2853                let _ = fault.state().clear_resolved().await;
2854            }
2855            AttemptResult::Committed { counts, receipt }
2856        }
2857        Err(
2858            ChunkTransactionError::NotCommitted | ChunkTransactionError::ComponentStateUnsupported,
2859        ) => {
2860            let outcome = ChunkExecutionOutcome::Failed(ChunkFailure::TransactionCommit);
2861            if transaction.rollback().await.is_err() {
2862                return AttemptResult::RollbackFailed(Some(outcome));
2863            }
2864            AttemptResult::RolledBack(outcome)
2865        }
2866        Err(ChunkTransactionError::CommitOutcomeUnknown) => AttemptResult::Unknown,
2867    }
2868}
2869
2870/// Rolls back, reserves the retry ordinal durably, then waits for backoff.
2871#[allow(
2872    clippy::too_many_arguments,
2873    reason = "the retry scope needs components, scope, buffer, state, transaction, request, and events"
2874)]
2875async fn schedule_retry<I, O, E, P, W>(
2876    components: &Components<'_, I, O, P, W>,
2877    scope: AttemptScope<'_>,
2878    buffer: &mut ChunkBuffer<I, O>,
2879    state: &mut ExecutionState,
2880    transaction: &mut dyn ChunkTransaction,
2881    request: RetryRequest,
2882    emit: &mut E,
2883) -> AttemptResult
2884where
2885    I: Send + Sync,
2886    O: Send + Sync,
2887    E: FnMut(ChunkRuntimeEvent),
2888    P: ItemProcessor<I, O>,
2889    W: ItemWriter<O>,
2890{
2891    let Some(fault_runtime) = components.fault else {
2892        return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2893            ChunkFailure::RetryReservation,
2894        ));
2895    };
2896    if transaction.rollback().await.is_err() {
2897        return AttemptResult::RollbackFailed(None);
2898    }
2899    if scope.stop.is_stop_requested() {
2900        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2901    }
2902
2903    let reservation = RetryReservation::new(
2904        request.key,
2905        request.phase,
2906        request.fault.category(),
2907        request.ordinal,
2908    );
2909    match fault_runtime.state().reserve(reservation).await {
2910        Ok(()) => {}
2911        Err(crate::FaultStateError::CapacityExhausted { .. }) => {
2912            return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2913                ChunkFailure::RetryStateExhausted,
2914            ));
2915        }
2916        Err(_) => {
2917            return AttemptResult::RolledBack(ChunkExecutionOutcome::Failed(
2918                ChunkFailure::RetryReservation,
2919            ));
2920        }
2921    }
2922    state.rollback_count = state.rollback_count.saturating_add(1);
2923    state.retry_counts = state.retry_counts.increment(request.phase);
2924    emit(ChunkRuntimeEvent::Fault(
2925        FaultRuntimeEvent::new(
2926            LifecycleEventKind::RetryReserved,
2927            scope.sequence,
2928            request.phase,
2929        )
2930        .with_summary(request.fault.summary())
2931        .with_ordinal(request.ordinal),
2932    ));
2933    emit(ChunkRuntimeEvent::Fault(
2934        FaultRuntimeEvent::new(
2935            LifecycleEventKind::FaultRollbackCommitted,
2936            scope.sequence,
2937            request.phase,
2938        )
2939        .with_summary(request.fault.summary()),
2940    ));
2941
2942    let listener_context = scope.listener_context();
2943    let before = components
2944        .item_listeners
2945        .before_retry(request.fault, listener_context)
2946        .await;
2947    if let Some(failure) = before.failure() {
2948        state.item_listener_failures.push(failure);
2949        return AttemptResult::RolledBack(item_listener_outcome(failure.kind()));
2950    }
2951    buffer.retry = Some(PendingRetry {
2952        key: request.key,
2953        fault: request.fault,
2954        entered: before.entered(),
2955    });
2956
2957    if scope.stop.is_stop_requested() {
2958        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2959    }
2960    emit(ChunkRuntimeEvent::Fault(
2961        FaultRuntimeEvent::new(
2962            LifecycleEventKind::RetryBackoffStarted,
2963            scope.sequence,
2964            request.phase,
2965        )
2966        .with_ordinal(request.ordinal)
2967        .with_backoff(request.delay),
2968    ));
2969    if fault_runtime
2970        .sleeper()
2971        .sleep(request.delay, scope.stop)
2972        .await
2973        == BackoffOutcome::Stopped
2974    {
2975        emit(ChunkRuntimeEvent::Fault(
2976            FaultRuntimeEvent::new(
2977                LifecycleEventKind::RetryBackoffCancelled,
2978                scope.sequence,
2979                request.phase,
2980            )
2981            .with_ordinal(request.ordinal)
2982            .with_backoff(request.delay),
2983        ));
2984        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2985    }
2986    if scope.stop.is_stop_requested() {
2987        return AttemptResult::RolledBack(ChunkExecutionOutcome::Stopped);
2988    }
2989    AttemptResult::Replay
2990}
2991
2992/// Runs the retry-completion callback when `key` had a reserved retry.
2993async fn complete_retry<I, O, P, W>(
2994    components: &Components<'_, I, O, P, W>,
2995    listener_context: ItemListenerContext<'_>,
2996    retry: &mut Option<PendingRetry>,
2997    state: &mut ExecutionState,
2998    key: RetryKey,
2999    outcome: RetryOutcome,
3000) -> Option<ChunkExecutionOutcome>
3001where
3002    I: Send + Sync,
3003    O: Send + Sync,
3004    P: ItemProcessor<I, O>,
3005    W: ItemWriter<O>,
3006{
3007    let pending = retry.take_if(|pending| pending.key == key)?;
3008    let failures = components
3009        .item_listeners
3010        .after_retry(pending.entered, pending.fault, outcome, listener_context)
3011        .await;
3012    let first = failures.first().copied()?;
3013    state.item_listener_failures.extend(failures);
3014    Some(item_listener_outcome(first.kind()))
3015}
3016
3017/// Decides one fault and runs the exhaustion callback when the budget is spent.
3018#[allow(
3019    clippy::too_many_arguments,
3020    reason = "classification needs components, listeners, retry state, and the fault inputs"
3021)]
3022async fn classify<I, O, E, P, W>(
3023    components: &Components<'_, I, O, P, W>,
3024    listener_context: ItemListenerContext<'_>,
3025    retry: &mut Option<PendingRetry>,
3026    state: &mut ExecutionState,
3027    key: RetryKey,
3028    fault: FaultDescriptor,
3029    evidence: FaultEvidence,
3030    sequence: ChunkCount,
3031    emit: &mut E,
3032) -> Result<FaultDecision, ChunkExecutionOutcome>
3033where
3034    I: Send + Sync,
3035    O: Send + Sync,
3036    E: FnMut(ChunkRuntimeEvent),
3037    P: ItemProcessor<I, O>,
3038    W: ItemWriter<O>,
3039{
3040    let entered = retry
3041        .as_ref()
3042        .filter(|pending| pending.key == key)
3043        .map(|pending| pending.entered);
3044    if entered.is_some()
3045        && let Some(outcome) = complete_retry(
3046            components,
3047            listener_context,
3048            retry,
3049            state,
3050            key,
3051            RetryOutcome::Failed,
3052        )
3053        .await
3054    {
3055        return Err(outcome);
3056    }
3057
3058    let Some(fault_runtime) = components.fault else {
3059        return Ok(FaultDecision::FailAndRollback);
3060    };
3061    let decision = fault_runtime.policy().decide(&fault, evidence);
3062
3063    if !decision.is_retry()
3064        && let Some(entered) = entered
3065    {
3066        emit(ChunkRuntimeEvent::Fault(
3067            FaultRuntimeEvent::new(LifecycleEventKind::RetryExhausted, sequence, fault.phase())
3068                .with_summary(fault.summary())
3069                .with_ordinal(fault.retry_ordinal()),
3070        ));
3071        let failures = components
3072            .item_listeners
3073            .on_retry_exhausted(entered, fault, listener_context)
3074            .await;
3075        if let Some(first) = failures.first().copied() {
3076            state.item_listener_failures.extend(failures);
3077            return Err(item_listener_outcome(first.kind()));
3078        }
3079    }
3080    Ok(decision)
3081}
3082
3083/// Builds the framework-owned classification input for one fault.
3084fn descriptor<I, O, P, W>(
3085    components: &Components<'_, I, O, P, W>,
3086    state: &mut ExecutionState,
3087    skips: &[PendingSkip<O>],
3088    phase: FaultPhase,
3089    category: FailureCategory,
3090) -> Option<FaultDescriptor>
3091where
3092    P: ItemProcessor<I, O>,
3093    W: ItemWriter<O>,
3094{
3095    let delivery_mode = components.fault.map_or(
3096        crate::ChunkDeliveryMode::AtLeastOnce,
3097        FaultRuntime::delivery_mode,
3098    );
3099    let committed = projected_skips(state.skip_counts, skips)?;
3100    state.next_failure_id = state.next_failure_id.saturating_add(1);
3101    let failure_id = FailureId::new(state.next_failure_id).ok()?;
3102    Some(FaultDescriptor::new(
3103        phase,
3104        FailureSummary::new(category, failure_id),
3105        RetryOrdinal::INITIAL,
3106        committed,
3107        true,
3108        delivery_mode,
3109    ))
3110}
3111
3112/// Replaces the descriptor ordinal with the durably reserved one.
3113async fn with_reserved_ordinal<I, O, P, W>(
3114    components: &Components<'_, I, O, P, W>,
3115    key: RetryKey,
3116    fault: FaultDescriptor,
3117) -> FaultDescriptor
3118where
3119    P: ItemProcessor<I, O>,
3120    W: ItemWriter<O>,
3121{
3122    let Some(fault_runtime) = components.fault else {
3123        return fault;
3124    };
3125    let ordinal = fault_runtime
3126        .state()
3127        .reserved_ordinal(key)
3128        .await
3129        .ok()
3130        .flatten()
3131        .unwrap_or(RetryOrdinal::INITIAL);
3132    FaultDescriptor::new(
3133        fault.phase(),
3134        fault.summary(),
3135        ordinal,
3136        fault.committed_skips(),
3137        fault.is_transaction_open(),
3138        fault.delivery_mode(),
3139    )
3140}
3141
3142/// Marks one retry key resolved because its unit of work finished.
3143async fn resolve_key<I, O, P, W>(components: &Components<'_, I, O, P, W>, key: RetryKey)
3144where
3145    P: ItemProcessor<I, O>,
3146    W: ItemWriter<O>,
3147{
3148    if let Some(fault_runtime) = components.fault {
3149        let _ = fault_runtime.state().resolve(key).await;
3150    }
3151}
3152
3153fn retry_key<I, O, P, W>(
3154    components: &Components<'_, I, O, P, W>,
3155    checkpoint_digest: [u8; 32],
3156    phase: FaultPhase,
3157    ordinal: u64,
3158) -> RetryKey
3159where
3160    P: ItemProcessor<I, O>,
3161    W: ItemWriter<O>,
3162{
3163    RetryKey::derive(
3164        &components.definition_digest,
3165        components.step_name,
3166        phase,
3167        &checkpoint_digest,
3168        ordinal,
3169    )
3170}
3171
3172fn checkpoint_digest(checkpoint: &crate::Checkpoint) -> [u8; 32] {
3173    checkpoint.generation_digest()
3174}
3175
3176fn emit_committed_skips<I, O, E>(buffer: &ChunkBuffer<I, O>, sequence: ChunkCount, emit: &mut E)
3177where
3178    E: FnMut(ChunkRuntimeEvent),
3179{
3180    for skip in &buffer.skips {
3181        emit(ChunkRuntimeEvent::Fault(
3182            FaultRuntimeEvent::new(LifecycleEventKind::ItemSkipped, sequence, skip.phase)
3183                .with_summary(skip.fault.summary()),
3184        ));
3185        if skip.disposition == RollbackDisposition::CommitSafeSkip {
3186            emit(ChunkRuntimeEvent::Fault(
3187                FaultRuntimeEvent::new(
3188                    LifecycleEventKind::FaultNoRollbackCommitted,
3189                    sequence,
3190                    skip.phase,
3191                )
3192                .with_summary(skip.fault.summary()),
3193            ));
3194        }
3195    }
3196}
3197
3198const fn item_listener_outcome(kind: ListenerFailureKind) -> ChunkExecutionOutcome {
3199    match kind {
3200        ListenerFailureKind::Error => ChunkExecutionOutcome::Failed(ChunkFailure::ItemListener),
3201        ListenerFailureKind::Panic => {
3202            ChunkExecutionOutcome::Failed(ChunkFailure::ItemListenerPanic)
3203        }
3204    }
3205}
3206
3207async fn finish_failed_attempt(
3208    listeners: &[Arc<dyn ChunkListener>],
3209    policy_attempt: CompletionPolicyAttempt<'_>,
3210    context: ChunkListenerContext<'_>,
3211    attempt_outcome: ChunkAttemptOutcome,
3212    outcome: ChunkExecutionOutcome,
3213    state: &mut ExecutionState,
3214) -> ChunkExecutionReport {
3215    let failures = run_after_listeners(listeners, context, attempt_outcome).await;
3216    // Always observed, regardless of listener outcome: `attempt_outcome` may
3217    // be `Committed` (for example this attempt's transaction committed but
3218    // bookkeeping afterward overflowed a counter), and `end_chunk` must see
3219    // that commit exactly once to keep a policy's durable and in-memory
3220    // state from disagreeing. A no-op when this attempt's `begin_chunk`
3221    // never ran (for example its transaction never began).
3222    let completion_policy_panicked = policy_attempt.finish(attempt_outcome).is_err();
3223    if let Some(first) = failures.first().copied() {
3224        state.listener_failures.extend(failures);
3225        if outcome == ChunkExecutionOutcome::Unknown {
3226            return state.drain().report(outcome, None);
3227        }
3228        return state
3229            .drain()
3230            .report(listener_failure_outcome(first.kind()), Some(outcome));
3231    }
3232    if completion_policy_panicked {
3233        if outcome == ChunkExecutionOutcome::Unknown {
3234            return state.drain().report(outcome, None);
3235        }
3236        return state.drain().report(
3237            ChunkExecutionOutcome::Failed(ChunkFailure::CompletionPolicyPanic),
3238            Some(outcome),
3239        );
3240    }
3241    state.drain().report(outcome, None)
3242}
3243
3244async fn run_before_listeners(
3245    listeners: &[Arc<dyn ChunkListener>],
3246    context: ChunkListenerContext<'_>,
3247) -> Option<ChunkListenerFailure> {
3248    for (index, listener) in listeners.iter().enumerate() {
3249        if let Err(kind) = invoke_before_listener(listener.as_ref(), context).await {
3250            return Some(ChunkListenerFailure::new(
3251                ChunkListenerPhase::BeforeChunk,
3252                index,
3253                kind,
3254            ));
3255        }
3256    }
3257    None
3258}
3259
3260async fn run_after_listeners(
3261    listeners: &[Arc<dyn ChunkListener>],
3262    context: ChunkListenerContext<'_>,
3263    outcome: ChunkAttemptOutcome,
3264) -> Vec<ChunkListenerFailure> {
3265    let mut failures = Vec::new();
3266    for (index, listener) in listeners.iter().enumerate().rev() {
3267        if let Err(kind) = invoke_after_listener(listener.as_ref(), context, outcome).await {
3268            failures.push(ChunkListenerFailure::new(
3269                ChunkListenerPhase::AfterChunk,
3270                index,
3271                kind,
3272            ));
3273        }
3274    }
3275    failures
3276}
3277
3278const fn listener_failure_outcome(kind: ChunkListenerFailureKind) -> ChunkExecutionOutcome {
3279    match kind {
3280        ChunkListenerFailureKind::Error => ChunkExecutionOutcome::Failed(ChunkFailure::Listener),
3281        ChunkListenerFailureKind::Panic => {
3282            ChunkExecutionOutcome::Failed(ChunkFailure::ListenerPanic)
3283        }
3284    }
3285}
3286
3287async fn invoke_before_listener(
3288    listener: &dyn ChunkListener,
3289    context: ChunkListenerContext<'_>,
3290) -> Result<(), ChunkListenerFailureKind> {
3291    let future = catch_unwind(AssertUnwindSafe(|| listener.before_chunk(context)))
3292        .map_err(|_| ChunkListenerFailureKind::Panic)?;
3293    match AssertUnwindSafe(future).catch_unwind().await {
3294        Ok(Ok(())) => Ok(()),
3295        Ok(Err(_)) => Err(ChunkListenerFailureKind::Error),
3296        Err(_) => Err(ChunkListenerFailureKind::Panic),
3297    }
3298}
3299
3300async fn invoke_after_listener(
3301    listener: &dyn ChunkListener,
3302    context: ChunkListenerContext<'_>,
3303    outcome: ChunkAttemptOutcome,
3304) -> Result<(), ChunkListenerFailureKind> {
3305    let future = catch_unwind(AssertUnwindSafe(|| listener.after_chunk(context, outcome)))
3306        .map_err(|_| ChunkListenerFailureKind::Panic)?;
3307    match AssertUnwindSafe(future).catch_unwind().await {
3308        Ok(Ok(())) => Ok(()),
3309        Ok(Err(_)) => Err(ChunkListenerFailureKind::Error),
3310        Err(_) => Err(ChunkListenerFailureKind::Panic),
3311    }
3312}
3313
3314/// Guards construction of the reader's call future against a synchronous
3315/// panic, without allocating.
3316///
3317/// Each `invoke_*` helper below drives its component call inside one `async`
3318/// block wrapped by [`FutureExt::catch_unwind`], rather than constructing the
3319/// call future in a plain closure passed to [`catch_unwind`]. An `async`
3320/// block is not desugared through the `Fn` family of traits, so it does not
3321/// hit the borrow-checker limitation where a closure that captures a `&mut`
3322/// receiver by move and returns its borrowed, opaque call future is rejected
3323/// as escaping a `FnMut` body — even though the closure is only ever called
3324/// once (rust-lang/rust#89976 and similar). Because the component call sits
3325/// before the block's only `await` point, a synchronous panic during call
3326/// construction happens during the block's first poll, which
3327/// `FutureExt::catch_unwind` already guards; polling panics are guarded the
3328/// same way. Neither step allocates: the block compiles to a plain state
3329/// machine borrowing the receiver for the call's lifetime, not a boxed future.
3330async fn invoke_reader<'a, I, R>(
3331    reader: &'a mut R,
3332    context: ReadContext<'a>,
3333) -> Invoked<ReadOutcome<I>, ReaderError>
3334where
3335    R: ItemReader<I>,
3336{
3337    let future = async move { reader.read(context).await };
3338    match AssertUnwindSafe(future).catch_unwind().await {
3339        Ok(Ok(outcome)) => Invoked::Completed(outcome),
3340        Ok(Err(error)) => Invoked::Failed(error),
3341        Err(_) => Invoked::Panicked,
3342    }
3343}
3344
3345async fn invoke_processor<I, O, P>(
3346    processor: &P,
3347    item: &I,
3348    context: ProcessContext<'_>,
3349) -> Invoked<ProcessOutcome<O>, ProcessorError>
3350where
3351    P: ItemProcessor<I, O>,
3352{
3353    let future = async move { processor.process(item, context).await };
3354    match AssertUnwindSafe(future).catch_unwind().await {
3355        Ok(Ok(outcome)) => Invoked::Completed(outcome),
3356        Ok(Err(error)) => Invoked::Failed(error),
3357        Err(_) => Invoked::Panicked,
3358    }
3359}
3360
3361async fn invoke_writer<'a, O, W>(
3362    writer: &'a W,
3363    items: &'a [O],
3364    context: WriteContext<'a>,
3365) -> Invoked<WriteOutcome, WriterError>
3366where
3367    W: ItemWriter<O>,
3368{
3369    let future = async move { writer.write(items, context).await };
3370    match AssertUnwindSafe(future).catch_unwind().await {
3371        Ok(Ok(outcome)) => Invoked::Completed(outcome),
3372        Ok(Err(error)) => Invoked::Failed(error),
3373        Err(_) => Invoked::Panicked,
3374    }
3375}
3376
3377async fn invoke_completion(
3378    completion: &dyn ChunkCompletion,
3379    context: ChunkCompletionContext<'_>,
3380) -> Result<ChunkCompletionOutcome, ChunkFailure> {
3381    let future = catch_unwind(AssertUnwindSafe(|| completion.after_commit(context)))
3382        .map_err(|_| ChunkFailure::CompletionPanic)?;
3383    match AssertUnwindSafe(future).catch_unwind().await {
3384        Ok(Ok(outcome)) => Ok(outcome),
3385        Ok(Err(_)) => Err(ChunkFailure::Completion),
3386        Err(_) => Err(ChunkFailure::CompletionPanic),
3387    }
3388}
3389
3390/// Calls a completion policy's synchronous `begin_chunk` inside the same
3391/// panic-containment boundary every other user-supplied component call in
3392/// this module already uses. A public [`CompletionPolicy`] implementation
3393/// panicking must fail this attempt through the ordinary typed-failure path,
3394/// never unwind through the chunk runtime.
3395fn invoke_completion_policy_begin(policy: &dyn CompletionPolicy) -> Result<(), ()> {
3396    catch_unwind(AssertUnwindSafe(|| policy.begin_chunk())).map_err(|_| ())
3397}
3398
3399/// Calls a completion policy's synchronous `is_complete` under the same
3400/// panic containment as [`invoke_completion_policy_begin`].
3401fn invoke_completion_policy_is_complete(
3402    policy: &dyn CompletionPolicy,
3403    items_read: ChunkCount,
3404) -> Result<bool, ()> {
3405    catch_unwind(AssertUnwindSafe(|| policy.is_complete(items_read))).map_err(|_| ())
3406}
3407
3408/// Calls a completion policy's synchronous `end_chunk` under the same panic
3409/// containment as [`invoke_completion_policy_begin`]. Called for every
3410/// terminal chunk-attempt outcome (committed, rolled back, stopped, or
3411/// unknown) so an [`AdaptiveCompletionPolicy`] can promote or discard its
3412/// speculative candidate; a no-op default for every other policy.
3413fn invoke_completion_policy_end(
3414    policy: Option<&dyn CompletionPolicy>,
3415    outcome: ChunkAttemptOutcome,
3416) -> Result<(), ()> {
3417    let Some(policy) = policy else {
3418        return Ok(());
3419    };
3420    catch_unwind(AssertUnwindSafe(|| policy.end_chunk(outcome))).map_err(|_| ())
3421}
3422
3423/// Structurally enforces the [`CompletionPolicy`] begin/end lifecycle
3424/// contract documented on [`CompletionPolicy::begin_chunk`]:
3425/// [`Self::begin`] performs an attempt's one `begin_chunk` call, and
3426/// [`Self::finish`] performs its one matching `end_chunk` call -- so no call
3427/// site in this module can invoke `end_chunk` without a preceding
3428/// `begin_chunk` for the same attempt, and none can invoke `begin_chunk`
3429/// without the runtime later owing a matching `end_chunk`.
3430///
3431/// Dropping an attempt that began successfully but was never explicitly
3432/// [`finish`](Self::finish)ed -- reachable only by a future bug in this
3433/// module, never by any call path today -- still invokes `end_chunk`, with
3434/// [`ChunkAttemptOutcome::Unknown`] standing in for whatever outcome was
3435/// never reported. A missed call site therefore fails safe (a possibly
3436/// imprecise outcome) rather than silently leaking the lifecycle.
3437struct CompletionPolicyAttempt<'a> {
3438    policy: Option<&'a dyn CompletionPolicy>,
3439    began: bool,
3440    finished: bool,
3441}
3442
3443impl<'a> CompletionPolicyAttempt<'a> {
3444    /// Calls `begin_chunk` exactly once, if a policy is installed.
3445    ///
3446    /// [`Self::begin_panicked`] distinguishes a panic there from simply
3447    /// having no policy installed; either way, [`Self::finish`] becomes a
3448    /// deliberate no-op for this attempt.
3449    fn begin(policy: Option<&'a dyn CompletionPolicy>) -> Self {
3450        let began = match policy {
3451            Some(policy) => invoke_completion_policy_begin(policy).is_ok(),
3452            None => false,
3453        };
3454        Self {
3455            policy,
3456            began,
3457            finished: false,
3458        }
3459    }
3460
3461    /// Represents an attempt whose transaction never began, so this
3462    /// attempt's `begin_chunk` was never called at all -- never even
3463    /// attempted. [`Self::finish`] on this value is always a no-op: no
3464    /// `begin_chunk` ran, so no `end_chunk` is owed.
3465    const fn not_begun(policy: Option<&'a dyn CompletionPolicy>) -> Self {
3466        Self {
3467            policy,
3468            began: false,
3469            finished: false,
3470        }
3471    }
3472
3473    /// Reports whether a policy is installed and its `begin_chunk` panicked
3474    /// this attempt -- distinct from simply having no policy installed at
3475    /// all, which also leaves nothing owed but is not a failure.
3476    const fn begin_panicked(&self) -> bool {
3477        self.policy.is_some() && !self.began
3478    }
3479
3480    /// Calls `end_chunk` with `outcome`, exactly once, only when this
3481    /// attempt's `begin_chunk` actually ran without panicking. A `begin`
3482    /// that never ran (or panicked) owes no matching `end`, so this is a
3483    /// deliberate no-op in that case (never an error): a panicked `begin` is
3484    /// instead surfaced by the caller checking [`Self::begin_panicked`]
3485    /// immediately after construction.
3486    fn finish(mut self, outcome: ChunkAttemptOutcome) -> Result<(), ()> {
3487        self.finished = true;
3488        if !self.began {
3489            return Ok(());
3490        }
3491        invoke_completion_policy_end(self.policy, outcome)
3492    }
3493}
3494
3495impl Drop for CompletionPolicyAttempt<'_> {
3496    fn drop(&mut self) {
3497        if self.finished || !self.began {
3498            return;
3499        }
3500        let _ = invoke_completion_policy_end(self.policy, ChunkAttemptOutcome::Unknown);
3501    }
3502}