Skip to main content

spate_core/ops/
chain.rs

1//! Chain combinators and the typed chain behind the erasure boundary.
2//!
3//! Stages compose statically — `Map<G, Filter<P, Term>>` monomorphizes into
4//! one loop body. Beyond [`Collector`](super::Collector), every stage
5//! implements [`StageLifecycle`]: the cascade the boundary uses to flush
6//! per-batch metric accumulators, surface fatal errors, and reach the
7//! terminal stage's buffers without any stage knowing its downstream's
8//! concrete type.
9//!
10//! Pressure discipline: the terminal stage never rejects a record
11//! mid-payload — it parks sealed chunks internally and reports pressure
12//! through [`StageLifecycle::relieve`], which the chain checks *between*
13//! payloads. Operators therefore never re-run for an already-pushed record,
14//! and no lifetime-bearing state is ever stored across the boundary.
15
16use super::{BlockReason, Collector, CollectorFor, PushOutcome, RunnableChain};
17use crate::checkpoint::AckRef;
18use crate::deser::{Deserializer, EmitRecord, RecFamily};
19use crate::error::{DeserError, ErrorClass, ErrorPolicy, FatalError};
20use crate::metrics::{DeserMetrics, OperatorMetrics};
21use crate::record::{Flow, RawPayload, Record, RecordMeta};
22use crate::source::PayloadBatch;
23use crate::telemetry::RateLimit;
24use std::marker::PhantomData;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28/// Per-batch lifecycle cascade implemented by every stage. Combinators
29/// handle their own concern and delegate downstream; the terminal stage
30/// anchors the recursion.
31pub trait StageLifecycle {
32    /// Flush per-batch metric accumulators. `elapsed` is the chain-level
33    /// batch duration: per-stage attribution inside one inlined loop is
34    /// not measurable without per-record clocks, so every stage reports
35    /// the chain figure, keeping the histograms comparable.
36    fn on_batch_end(&mut self, elapsed: Duration);
37
38    /// Take the first fatal error recorded by any stage.
39    fn take_fatal(&mut self) -> Option<FatalError>;
40
41    /// Let the terminal stage drain parked output. [`Flow::Blocked`] means
42    /// it is still backed up and the chain must not accept new payloads.
43    fn relieve(&mut self) -> Flow;
44
45    /// Seal and hand off all terminal buffers, partial chunks included.
46    /// [`Flow::Blocked`] means not everything could be sent; retry later.
47    fn flush_terminal(&mut self) -> Flow;
48}
49
50/// Local accumulators around a shared [`OperatorMetrics`] handle: stages
51/// count into plain fields during a batch and flush once at batch end.
52#[derive(Debug, Default)]
53pub(crate) struct OpMeter {
54    handle: Option<Arc<OperatorMetrics>>,
55    records_in: u64,
56    records_out: u64,
57    filtered: u64,
58    skipped: u64,
59    unrouted: u64,
60    record_errors: u64,
61}
62
63impl OpMeter {
64    pub(crate) fn new(handle: Option<Arc<OperatorMetrics>>) -> Self {
65        OpMeter {
66            handle,
67            ..Default::default()
68        }
69    }
70
71    #[inline(always)]
72    pub(crate) fn seen(&mut self) {
73        self.records_in += 1;
74    }
75
76    #[inline(always)]
77    pub(crate) fn out(&mut self) {
78        self.records_out += 1;
79    }
80
81    #[inline(always)]
82    pub(crate) fn out_n(&mut self, n: u64) {
83        self.records_out += n;
84    }
85
86    #[inline(always)]
87    pub(crate) fn filtered(&mut self) {
88        self.filtered += 1;
89    }
90
91    #[inline(always)]
92    pub(crate) fn skipped(&mut self) {
93        self.skipped += 1;
94    }
95
96    #[inline(always)]
97    pub(crate) fn unrouted(&mut self) {
98        self.unrouted += 1;
99    }
100
101    #[inline(always)]
102    pub(crate) fn record_error(&mut self) {
103        self.record_errors += 1;
104    }
105
106    pub(crate) fn flush(&mut self, elapsed: Duration) {
107        if let Some(h) = &self.handle {
108            h.batch(self.records_in, self.records_out, elapsed);
109            if self.filtered > 0 {
110                h.filtered(self.filtered);
111            }
112            if self.skipped > 0 {
113                h.skipped(self.skipped);
114            }
115            if self.unrouted > 0 {
116                h.unrouted(self.unrouted);
117            }
118            if self.record_errors > 0 {
119                h.errors(ErrorClass::RecordLevel, self.record_errors);
120            }
121        }
122        self.records_in = 0;
123        self.records_out = 0;
124        self.filtered = 0;
125        self.skipped = 0;
126        self.unrouted = 0;
127        self.record_errors = 0;
128    }
129}
130
131/// Cloneable meter slot: chain factories clone stage structs per pipeline
132/// thread; the shared handle is kept, the local accumulators reset.
133#[derive(Debug, Default)]
134pub(crate) struct OpMeterSlot(pub(crate) OpMeter);
135
136impl Clone for OpMeterSlot {
137    fn clone(&self) -> Self {
138        OpMeterSlot(OpMeter::new(self.0.handle.clone()))
139    }
140}
141
142/// Cloneable fatal-error slot (clears on clone, like the meter slot).
143#[derive(Debug, Default)]
144pub(crate) struct FatalSlot(pub(crate) Option<FatalError>);
145
146impl Clone for FatalSlot {
147    fn clone(&self) -> Self {
148        FatalSlot(None)
149    }
150}
151
152/// `map`: transform the payload.
153#[derive(Clone, Debug)]
154pub struct Map<G, N> {
155    pub(crate) f: G,
156    pub(crate) next: N,
157    pub(crate) meter: OpMeterSlot,
158}
159
160impl<In, Out, G, N> Collector<In> for Map<G, N>
161where
162    G: FnMut(In) -> Out,
163    N: Collector<Out>,
164{
165    #[inline(always)]
166    fn push(&mut self, rec: Record<In>) -> Flow {
167        self.meter.0.seen();
168        self.meter.0.out();
169        self.next.push(rec.map(&mut self.f))
170    }
171}
172
173/// `filter`: drop records failing the predicate. A drop releases the
174/// record's ack share — it counts as success for the batch.
175#[derive(Clone, Debug)]
176pub struct Filter<P, N> {
177    pub(crate) p: P,
178    pub(crate) next: N,
179    pub(crate) meter: OpMeterSlot,
180}
181
182impl<T, P, N> Collector<T> for Filter<P, N>
183where
184    P: FnMut(&T) -> bool,
185    N: Collector<T>,
186{
187    #[inline(always)]
188    fn push(&mut self, rec: Record<T>) -> Flow {
189        self.meter.0.seen();
190        if (self.p)(&rec.payload) {
191            self.meter.0.out();
192            self.next.push(rec)
193        } else {
194            self.meter.0.filtered();
195            Flow::Continue
196        }
197    }
198}
199
200/// `inspect`: observe without transforming (no metrics of its own).
201#[derive(Clone, Debug)]
202pub struct Inspect<G, N> {
203    pub(crate) f: G,
204    pub(crate) next: N,
205}
206
207impl<T, G, N> Collector<T> for Inspect<G, N>
208where
209    G: FnMut(&T),
210    N: Collector<T>,
211{
212    #[inline(always)]
213    fn push(&mut self, rec: Record<T>) -> Flow {
214        (self.f)(&rec.payload);
215        self.next.push(rec)
216    }
217}
218
219static TRY_MAP_SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
220
221/// `try_map`: fallible transform with a per-stage [`ErrorPolicy`].
222/// `Skip` drops the record (releasing its ack share) and counts it;
223/// `Fail` records a fatal error — the batch aborts and the pipeline stops.
224#[derive(Clone, Debug)]
225pub struct TryMap<G, N> {
226    pub(crate) f: G,
227    pub(crate) next: N,
228    pub(crate) policy: ErrorPolicy,
229    pub(crate) component: Arc<str>,
230    pub(crate) meter: OpMeterSlot,
231    pub(crate) fatal: FatalSlot,
232}
233
234impl<In, Out, E, G, N> Collector<In> for TryMap<G, N>
235where
236    G: FnMut(In) -> Result<Out, E>,
237    E: std::fmt::Display,
238    N: Collector<Out>,
239{
240    #[inline(always)]
241    fn push(&mut self, rec: Record<In>) -> Flow {
242        self.meter.0.seen();
243        if self.fatal.0.is_some() {
244            // Fatal pending: swallow the rest of the batch. The boundary
245            // fails the batch's ack, so nothing is lost.
246            return Flow::Continue;
247        }
248        let Record { payload, meta, ack } = rec;
249        match (self.f)(payload) {
250            Ok(out) => {
251                self.meter.0.out();
252                self.next.push(Record {
253                    payload: out,
254                    meta,
255                    ack,
256                })
257            }
258            Err(e) => {
259                match self.policy {
260                    ErrorPolicy::Skip => {
261                        self.meter.0.skipped();
262                        self.meter.0.record_error();
263                        crate::rate_limited_warn!(
264                            TRY_MAP_SKIP_WARN,
265                            component = &*self.component,
266                            error = %e,
267                            "record skipped by error policy"
268                        );
269                    }
270                    _ => {
271                        self.fatal.0 = Some(FatalError {
272                            component: self.component.to_string(),
273                            reason: e.to_string(),
274                        });
275                    }
276                }
277                Flow::Continue
278            }
279        }
280    }
281}
282
283/// Stack-borrowed emitter handed to `flat_map` closures. Parameterized by
284/// the output *family* (a `'static` tag), so user closures never name the
285/// concrete downstream stack type or the buffer lifetime. Each `emit` is
286/// one virtual call — confined to flat_map stages.
287pub struct Emitter<'a, OutF: RecFamily> {
288    next: &'a mut dyn CollectorFor<OutF>,
289    meta: RecordMeta,
290    ack: &'a AckRef,
291    emitted: u64,
292    flow: Flow,
293}
294
295impl<OutF: RecFamily> std::fmt::Debug for Emitter<'_, OutF> {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.debug_struct("Emitter")
298            .field("emitted", &self.emitted)
299            .field("flow", &self.flow)
300            .finish_non_exhaustive()
301    }
302}
303
304impl<OutF: RecFamily> Emitter<'_, OutF> {
305    /// Emit one derived record, inheriting the parent's metadata and ack
306    /// handle.
307    #[inline(always)]
308    pub fn emit<'buf>(&mut self, payload: OutF::Rec<'buf>) -> Flow {
309        let flow = self.next.push_rec(Record {
310            payload,
311            meta: self.meta,
312            ack: self.ack.clone(),
313        });
314        self.emitted += 1;
315        if self.flow != Flow::Blocked {
316            self.flow = flow;
317        }
318        flow
319    }
320
321    /// The parent record's metadata.
322    #[must_use]
323    pub fn meta(&self) -> RecordMeta {
324        self.meta
325    }
326}
327
328/// `flat_map`: one record in, 0..N out via a stack-borrowed [`Emitter`].
329/// Carries the output family as a type parameter so the impl is fully
330/// constrained (closure argument types are not associated bindings).
331#[derive(Clone, Debug)]
332pub struct FlatMap<OutF: RecFamily, G, N> {
333    pub(crate) g: G,
334    pub(crate) next: N,
335    pub(crate) meter: OpMeterSlot,
336    pub(crate) _out: PhantomData<fn() -> OutF>,
337}
338
339impl<In, OutF, G, N> Collector<In> for FlatMap<OutF, G, N>
340where
341    OutF: RecFamily,
342    G: FnMut(In, &mut Emitter<'_, OutF>),
343    N: for<'buf> Collector<<OutF as RecFamily>::Rec<'buf>>,
344{
345    #[inline(always)]
346    fn push(&mut self, rec: Record<In>) -> Flow {
347        self.meter.0.seen();
348        let Record { payload, meta, ack } = rec;
349        let mut em = Emitter {
350            next: &mut self.next,
351            meta,
352            ack: &ack,
353            emitted: 0,
354            flow: Flow::Continue,
355        };
356        (self.g)(payload, &mut em);
357        let (emitted, flow) = (em.emitted, em.flow);
358        self.meter.0.out_n(emitted);
359        flow
360    }
361}
362
363// ---- lifecycle cascade ---------------------------------------------------
364
365impl<G, N: StageLifecycle> StageLifecycle for Map<G, N> {
366    fn on_batch_end(&mut self, elapsed: Duration) {
367        self.meter.0.flush(elapsed);
368        self.next.on_batch_end(elapsed);
369    }
370    fn take_fatal(&mut self) -> Option<FatalError> {
371        self.next.take_fatal()
372    }
373    fn relieve(&mut self) -> Flow {
374        self.next.relieve()
375    }
376    fn flush_terminal(&mut self) -> Flow {
377        self.next.flush_terminal()
378    }
379}
380
381impl<P, N: StageLifecycle> StageLifecycle for Filter<P, N> {
382    fn on_batch_end(&mut self, elapsed: Duration) {
383        self.meter.0.flush(elapsed);
384        self.next.on_batch_end(elapsed);
385    }
386    fn take_fatal(&mut self) -> Option<FatalError> {
387        self.next.take_fatal()
388    }
389    fn relieve(&mut self) -> Flow {
390        self.next.relieve()
391    }
392    fn flush_terminal(&mut self) -> Flow {
393        self.next.flush_terminal()
394    }
395}
396
397impl<G, N: StageLifecycle> StageLifecycle for Inspect<G, N> {
398    fn on_batch_end(&mut self, elapsed: Duration) {
399        self.next.on_batch_end(elapsed);
400    }
401    fn take_fatal(&mut self) -> Option<FatalError> {
402        self.next.take_fatal()
403    }
404    fn relieve(&mut self) -> Flow {
405        self.next.relieve()
406    }
407    fn flush_terminal(&mut self) -> Flow {
408        self.next.flush_terminal()
409    }
410}
411
412impl<G, N: StageLifecycle> StageLifecycle for TryMap<G, N> {
413    fn on_batch_end(&mut self, elapsed: Duration) {
414        self.meter.0.flush(elapsed);
415        self.next.on_batch_end(elapsed);
416    }
417    fn take_fatal(&mut self) -> Option<FatalError> {
418        self.fatal.0.take().or_else(|| self.next.take_fatal())
419    }
420    fn relieve(&mut self) -> Flow {
421        self.next.relieve()
422    }
423    fn flush_terminal(&mut self) -> Flow {
424        self.next.flush_terminal()
425    }
426}
427
428impl<OutF: RecFamily, G, N: StageLifecycle> StageLifecycle for FlatMap<OutF, G, N> {
429    fn on_batch_end(&mut self, elapsed: Duration) {
430        self.meter.0.flush(elapsed);
431        self.next.on_batch_end(elapsed);
432    }
433    fn take_fatal(&mut self) -> Option<FatalError> {
434        self.next.take_fatal()
435    }
436    fn relieve(&mut self) -> Flow {
437        self.next.relieve()
438    }
439    fn flush_terminal(&mut self) -> Flow {
440        self.next.flush_terminal()
441    }
442}
443
444// ---- the typed chain behind the boundary ----------------------------------
445
446/// Adapter exposing the ops stack as the deserializer's emission target,
447/// counting emissions and latching downstream pressure.
448struct OpsEmit<'a, Ops> {
449    ops: &'a mut Ops,
450    emitted: &'a mut u64,
451    flow: &'a mut Flow,
452}
453
454impl<'buf, T, Ops> EmitRecord<'buf, T> for OpsEmit<'_, Ops>
455where
456    Ops: Collector<T>,
457{
458    #[inline(always)]
459    fn emit(&mut self, rec: Record<T>) -> Flow {
460        *self.emitted += 1;
461        let flow = self.ops.push(rec);
462        if *self.flow != Flow::Blocked {
463            *self.flow = flow;
464        }
465        flow
466    }
467}
468
469static DESER_SKIP_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
470
471/// An owned copy of a payload whose deserialization returned
472/// [`DeserError::NotReady`], replayed on the next push. Owned because the
473/// chain is `'static`-erased and cannot hold `'buf` data across calls; the
474/// copy happens only on the rare not-ready path, which already implies an
475/// asynchronous round-trip (e.g. a schema-registry fetch).
476#[derive(Debug)]
477struct PendingPayload {
478    bytes: Vec<u8>,
479    key: Option<Vec<u8>>,
480    partition: crate::record::PartitionId,
481    offset: i64,
482    timestamp_ms: i64,
483}
484
485impl PendingPayload {
486    fn from_raw(raw: &RawPayload<'_>) -> Self {
487        PendingPayload {
488            bytes: raw.bytes.to_vec(),
489            key: raw.key.map(<[u8]>::to_vec),
490            partition: raw.partition,
491            offset: raw.offset,
492            timestamp_ms: raw.timestamp_ms,
493        }
494    }
495
496    fn as_raw(&self) -> RawPayload<'_> {
497        RawPayload {
498            bytes: &self.bytes,
499            key: self.key.as_deref(),
500            partition: self.partition,
501            offset: self.offset,
502            timestamp_ms: self.timestamp_ms,
503        }
504    }
505}
506
507/// Outcome of pushing one payload through deserializer + operator stack.
508enum Step {
509    /// Payload fully processed; keep going.
510    Continue,
511    /// Payload fully processed (output parked as needed); downstream is
512    /// backed up — stop accepting further payloads.
513    Backpressure,
514    /// Payload untouched: deserialization reported
515    /// [`DeserError::NotReady`]; replay it later.
516    NotReady,
517    /// A stage failed fatally.
518    Fatal(FatalError),
519}
520
521/// A concrete chain: deserializer + statically composed operator stack,
522/// erased behind [`RunnableChain`]. `Ops` must accept the family's record
523/// type at every buffer lifetime — the HRTB is what makes borrowed records
524/// legal behind the erased boundary.
525pub struct TypedChain<F: RecFamily, D, Ops> {
526    deser: D,
527    ops: Ops,
528    deser_policy: ErrorPolicy,
529    deser_metrics: Option<Arc<DeserMetrics>>,
530    /// Payloads fully processed from the batch currently in progress.
531    cursor: usize,
532    mid_batch: bool,
533    /// Not-ready payload awaiting replay (always belongs to the batch in
534    /// progress; carries no ack — the batch's handle covers it).
535    pending: Option<PendingPayload>,
536    _family: PhantomData<fn() -> F>,
537}
538
539impl<F: RecFamily, D, Ops> TypedChain<F, D, Ops> {
540    pub(crate) fn new(
541        deser: D,
542        ops: Ops,
543        deser_policy: ErrorPolicy,
544        deser_metrics: Option<Arc<DeserMetrics>>,
545    ) -> Self {
546        TypedChain {
547            deser,
548            ops,
549            deser_policy,
550            deser_metrics,
551            cursor: 0,
552            mid_batch: false,
553            pending: None,
554            _family: PhantomData,
555        }
556    }
557}
558
559impl<F, D, Ops> TypedChain<F, D, Ops>
560where
561    F: RecFamily,
562    D: Deserializer<F>,
563    Ops: for<'buf> Collector<<F as RecFamily>::Rec<'buf>> + StageLifecycle + Send,
564{
565    /// Push one payload through the deserializer and operator stack.
566    fn deser_step(
567        &mut self,
568        raw: &RawPayload<'_>,
569        ack: &AckRef,
570        ok: &mut u64,
571        errors: &mut u64,
572    ) -> Step {
573        let mut flow = Flow::Continue;
574        let mut emitted = 0u64;
575        let result = self.deser.deserialize(
576            raw,
577            ack,
578            &mut OpsEmit {
579                ops: &mut self.ops,
580                emitted: &mut emitted,
581                flow: &mut flow,
582            },
583        );
584        *ok += emitted;
585        match result {
586            Err(DeserError::NotReady { .. }) => {
587                debug_assert_eq!(
588                    emitted, 0,
589                    "NotReady after emitting records would duplicate them on replay"
590                );
591                return Step::NotReady;
592            }
593            Err(e) => {
594                *errors += 1;
595                match self.deser_policy {
596                    ErrorPolicy::Skip => {
597                        crate::rate_limited_warn!(
598                            DESER_SKIP_WARN,
599                            partition = raw.partition.0,
600                            offset = raw.offset,
601                            error = %e,
602                            "payload skipped by deserializer error policy"
603                        );
604                    }
605                    _ => {
606                        return Step::Fatal(FatalError {
607                            component: "deserializer".to_string(),
608                            reason: e.to_string(),
609                        });
610                    }
611                }
612            }
613            Ok(()) => {}
614        }
615        if let Some(fatal) = self.ops.take_fatal() {
616            return Step::Fatal(fatal);
617        }
618        // The terminal never rejects mid-payload; pressure surfaces
619        // between payloads, so operators never re-run for a record.
620        if flow == Flow::Blocked || self.ops.relieve() == Flow::Blocked {
621            return Step::Backpressure;
622        }
623        Step::Continue
624    }
625}
626
627impl<F: RecFamily, D, Ops> std::fmt::Debug for TypedChain<F, D, Ops> {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        f.debug_struct("TypedChain")
630            .field("cursor", &self.cursor)
631            .field("mid_batch", &self.mid_batch)
632            .finish_non_exhaustive()
633    }
634}
635
636impl<F, D, Ops> RunnableChain for TypedChain<F, D, Ops>
637where
638    F: RecFamily,
639    D: Deserializer<F>,
640    Ops: for<'buf> Collector<<F as RecFamily>::Rec<'buf>> + StageLifecycle + Send,
641{
642    fn push_batch<'buf>(&mut self, batch: &mut dyn PayloadBatch<'buf>, from: usize) -> PushOutcome {
643        if self.mid_batch {
644            debug_assert_eq!(
645                from, self.cursor,
646                "resume must continue where Blocked stopped"
647            );
648        } else {
649            debug_assert_eq!(from, 0, "fresh batches start at payload 0");
650            debug_assert!(
651                self.pending.is_none(),
652                "a not-ready payload must not survive its batch"
653            );
654            self.cursor = 0;
655            self.pending = None;
656            self.mid_batch = true;
657        }
658
659        // A backed-up terminal would grow without bound if we kept
660        // encoding into it: drain first, stay blocked if that fails.
661        if self.ops.relieve() == Flow::Blocked {
662            return PushOutcome::Blocked {
663                resume_at: self.cursor,
664                reason: BlockReason::Capacity,
665            };
666        }
667
668        let started = Instant::now();
669        let mut ok: u64 = 0;
670        let mut errors: u64 = 0;
671        let mut not_ready: u64 = 0;
672        let mut outcome: Option<PushOutcome> = None;
673
674        // Replay a stashed not-ready payload before pulling new ones. Its
675        // index is `cursor`; the batch iterator is already past it.
676        if let Some(p) = self.pending.take() {
677            let raw = p.as_raw();
678            match self.deser_step(&raw, batch.ack(), &mut ok, &mut errors) {
679                Step::Continue => self.cursor += 1,
680                Step::Backpressure => {
681                    self.cursor += 1;
682                    outcome = Some(PushOutcome::Blocked {
683                        resume_at: self.cursor,
684                        reason: BlockReason::Capacity,
685                    });
686                }
687                Step::NotReady => {
688                    not_ready += 1;
689                    self.pending = Some(p);
690                    outcome = Some(PushOutcome::Blocked {
691                        resume_at: self.cursor,
692                        reason: BlockReason::NotReady,
693                    });
694                }
695                Step::Fatal(f) => outcome = Some(PushOutcome::Fatal(f)),
696            }
697        }
698
699        while outcome.is_none() {
700            let Some(raw) = batch.next_payload() else {
701                break;
702            };
703            match self.deser_step(&raw, batch.ack(), &mut ok, &mut errors) {
704                Step::Continue => self.cursor += 1,
705                Step::Backpressure => {
706                    self.cursor += 1;
707                    outcome = Some(PushOutcome::Blocked {
708                        resume_at: self.cursor,
709                        reason: BlockReason::Capacity,
710                    });
711                }
712                Step::NotReady => {
713                    not_ready += 1;
714                    self.pending = Some(PendingPayload::from_raw(&raw));
715                    outcome = Some(PushOutcome::Blocked {
716                        resume_at: self.cursor,
717                        reason: BlockReason::NotReady,
718                    });
719                }
720                Step::Fatal(f) => outcome = Some(PushOutcome::Fatal(f)),
721            }
722        }
723
724        let elapsed = started.elapsed();
725        if let Some(m) = &self.deser_metrics {
726            m.batch(ok, errors, elapsed);
727            if errors > 0 && matches!(self.deser_policy, ErrorPolicy::Skip) {
728                m.dropped(errors);
729            }
730            if not_ready > 0 {
731                m.not_ready(not_ready);
732            }
733        }
734        self.ops.on_batch_end(elapsed);
735
736        match outcome {
737            Some(PushOutcome::Fatal(f)) => {
738                batch.ack().fail();
739                self.mid_batch = false;
740                self.pending = None;
741                PushOutcome::Fatal(f)
742            }
743            Some(blocked) => blocked,
744            None => {
745                self.mid_batch = false;
746                PushOutcome::Done
747            }
748        }
749    }
750
751    fn flush(&mut self) -> PushOutcome {
752        if let Some(fatal) = self.ops.take_fatal() {
753            return PushOutcome::Fatal(fatal);
754        }
755        match self.ops.flush_terminal() {
756            Flow::Continue => PushOutcome::Done,
757            Flow::Blocked => PushOutcome::Blocked {
758                resume_at: self.cursor,
759                reason: BlockReason::Capacity,
760            },
761        }
762    }
763
764    fn abandon_batch(&mut self) {
765        // Drop the mid-batch cursor and any stashed not-ready payload. The
766        // terminal stage's parked chunks (with their own acks) are left
767        // alone; only this chain's per-batch bookkeeping is reset so the
768        // next fresh push_batch neither asserts nor replays the stale
769        // payload under a new batch's ack.
770        self.mid_batch = false;
771        self.cursor = 0;
772        self.pending = None;
773    }
774}