Skip to main content

surreal_sync_runtime/pipeline/apply/
runtime.rs

1//! Unified in-flight window runtime: transform → ordered sink → contiguous watermark advance.
2//!
3//! Handles row changes **and** relation changes through the same window,
4//! ordered sink, sink-safe positions, and flush/discard/poison invariants.
5//!
6//! The general incremental driver API is [`crate::pipeline::SourceDriver`] /
7//! [`crate::pipeline::run_source_runtime`]. [`run_change_feed`] remains a convenience for
8//! row-only feeds.
9
10use crate::pipeline::apply::opts::ApplyOpts;
11use crate::pipeline::apply::transform::BatchTransformer;
12use crate::pipeline::apply::{
13    ApplyEvent, ChangeFeed, ChangeFeedRef, CheckpointPolicy, FailurePolicy, PositionedEvent,
14    RuntimeExit, SourceDriver, SourceRuntimeOpts,
15};
16use crate::pipeline::pipeline::Pipeline;
17use anyhow::{anyhow, bail, Context, Result};
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20use std::time::Instant;
21use surreal_sync_core::SurrealSink;
22use surreal_sync_core::{Change, ChangeOp, Relation, RelationChange, Row};
23use tokio::task::JoinSet;
24use tracing::warn;
25
26/// Transform then sink rows via the same overlapping [`ApplyContext`] window as
27/// CDC. Shared by full sync and snapshot flushes.
28///
29/// Rows become upsert [`Change`]s and honor `max_in_flight` /
30/// `batch_size`. Homogeneous upsert batches coalesce to
31/// [`SurrealSink::write_rows`] inside the ordered sink step.
32pub async fn write_rows<S: SurrealSink>(
33    sink: &S,
34    pipeline: &Pipeline,
35    rows: Vec<Row>,
36    opts: &ApplyOpts,
37) -> Result<()> {
38    write_rows_with(sink, Arc::new(pipeline.clone()), rows, opts).await
39}
40
41/// Transform then sink relations via the same overlapping [`ApplyContext`]
42/// window as CDC (see [`write_rows`]).
43pub async fn write_relations<S: SurrealSink>(
44    sink: &S,
45    pipeline: &Pipeline,
46    relations: Vec<Relation>,
47    opts: &ApplyOpts,
48) -> Result<()> {
49    write_relations_with(sink, Arc::new(pipeline.clone()), relations, opts).await
50}
51
52/// Transform then apply each change via [`SurrealSink::apply_change`].
53pub async fn apply_changes<S: SurrealSink>(
54    sink: &S,
55    pipeline: &Pipeline,
56    changes: Vec<Change>,
57    opts: &ApplyOpts,
58) -> Result<()> {
59    apply_changes_with(sink, Arc::new(pipeline.clone()), changes, opts).await
60}
61
62/// Transform then apply each relation change via
63/// [`SurrealSink::apply_relation_change`].
64pub async fn apply_relation_changes<S: SurrealSink>(
65    sink: &S,
66    pipeline: &Pipeline,
67    changes: Vec<RelationChange>,
68    opts: &ApplyOpts,
69) -> Result<()> {
70    apply_relation_changes_with(sink, Arc::new(pipeline.clone()), changes, opts).await
71}
72
73/// Like [`apply_changes`] but accepts any [`BatchTransformer`] behind [`Arc`].
74///
75/// Always honors `max_in_flight` / `batch_size` via [`ApplyContext`].
76pub async fn apply_changes_with<S, T>(
77    sink: &S,
78    transformer: Arc<T>,
79    changes: Vec<Change>,
80    opts: &ApplyOpts,
81) -> Result<()>
82where
83    S: SurrealSink,
84    T: BatchTransformer + 'static,
85{
86    if changes.is_empty() {
87        return Ok(());
88    }
89    let mut ctx = ApplyContext::new(sink, transformer, opts);
90    for (i, change) in changes.into_iter().enumerate() {
91        ctx.push_change(change, i as u64).await?;
92    }
93    ctx.flush().await?;
94    Ok(())
95}
96
97/// Like [`apply_relation_changes`] but accepts any [`BatchTransformer`] behind [`Arc`].
98pub async fn apply_relation_changes_with<S, T>(
99    sink: &S,
100    transformer: Arc<T>,
101    changes: Vec<RelationChange>,
102    opts: &ApplyOpts,
103) -> Result<()>
104where
105    S: SurrealSink,
106    T: BatchTransformer + 'static,
107{
108    if changes.is_empty() {
109        return Ok(());
110    }
111    let mut ctx = ApplyContext::new(sink, transformer, opts);
112    for (i, change) in changes.into_iter().enumerate() {
113        ctx.push_relation_change(change, i as u64).await?;
114    }
115    ctx.flush().await?;
116    Ok(())
117}
118
119/// Like [`write_rows`] but accepts any [`BatchTransformer`] behind [`Arc`].
120pub async fn write_rows_with<S, T>(
121    sink: &S,
122    transformer: Arc<T>,
123    rows: Vec<Row>,
124    opts: &ApplyOpts,
125) -> Result<()>
126where
127    S: SurrealSink,
128    T: BatchTransformer + 'static,
129{
130    if rows.is_empty() {
131        return Ok(());
132    }
133    // Overlapping window: convert rows to upserts and push through ApplyContext
134    // so max_in_flight can absorb slow transforms within a chunk.
135    let mut ctx = ApplyContext::new(sink, transformer, opts);
136    for (i, row) in rows.into_iter().enumerate() {
137        let change = Change::update(row.table, row.id, row.fields);
138        ctx.push_change(change, i as u64).await?;
139    }
140    ctx.flush().await?;
141    Ok(())
142}
143
144/// Like [`write_relations`] but accepts any [`BatchTransformer`] behind [`Arc`].
145pub async fn write_relations_with<S, T>(
146    sink: &S,
147    transformer: Arc<T>,
148    relations: Vec<Relation>,
149    opts: &ApplyOpts,
150) -> Result<()>
151where
152    S: SurrealSink,
153    T: BatchTransformer + 'static,
154{
155    if relations.is_empty() {
156        return Ok(());
157    }
158    let mut ctx = ApplyContext::new(sink, transformer, opts);
159    for (i, relation) in relations.into_iter().enumerate() {
160        let change = RelationChange::update(relation);
161        ctx.push_relation_change(change, i as u64).await?;
162    }
163    ctx.flush().await?;
164    Ok(())
165}
166
167/// Ordered sink apply for a transformed batch.
168///
169/// Homogeneous upsert (`Update`) batches coalesce to
170/// [`SurrealSink::write_rows`] /
171/// [`SurrealSink::write_relations`] so large `write_rows` /
172/// `write_relations` vecs keep a bulk trait call **inside** the window. CDC
173/// `Create` / `Delete` (and mixed batches) stay on per-event apply.
174pub(crate) async fn apply_transformed_sink_events<S: SurrealSink>(
175    sink: &S,
176    events: &[ApplyEvent],
177) -> Result<()> {
178    if events.is_empty() {
179        return Ok(());
180    }
181    if let Some(rows) = try_coalesce_row_upserts(events) {
182        return sink.write_rows(&rows).await.context("sink write_rows");
183    }
184    if let Some(relations) = try_coalesce_relation_upserts(events) {
185        return sink
186            .write_relations(&relations)
187            .await
188            .context("sink write_relations");
189    }
190    for event in events {
191        match event {
192            ApplyEvent::Change(change) => {
193                sink.apply_change(change)
194                    .await
195                    .context("sink apply_change")?;
196            }
197            ApplyEvent::RelationChange(change) => {
198                sink.apply_relation_change(change)
199                    .await
200                    .context("sink apply_relation_change")?;
201            }
202        }
203    }
204    Ok(())
205}
206
207fn try_coalesce_row_upserts(events: &[ApplyEvent]) -> Option<Vec<Row>> {
208    let mut rows = Vec::with_capacity(events.len());
209    for event in events {
210        match event {
211            ApplyEvent::Change(change) if change.operation == ChangeOp::Update => {
212                let data = change.fields.as_ref()?.clone();
213                rows.push(Row::new(change.table.clone(), 0, change.id.clone(), data));
214            }
215            _ => return None,
216        }
217    }
218    Some(rows)
219}
220
221fn try_coalesce_relation_upserts(events: &[ApplyEvent]) -> Option<Vec<Relation>> {
222    let mut relations = Vec::with_capacity(events.len());
223    for event in events {
224        match event {
225            ApplyEvent::RelationChange(change) if change.operation == ChangeOp::Update => {
226                relations.push(change.relation.clone());
227            }
228            _ => return None,
229        }
230    }
231    Some(relations)
232}
233
234/// Convenience loop for row-only [`ChangeFeed`] sources.
235///
236/// Equivalent to [`crate::pipeline::run_source_runtime`] over a [`ChangeFeedDriver`].
237/// Prefer [`crate::pipeline::SourceDriver`] when you need relation events or schema /
238/// ad-hoc / cancel / checkpoint hooks (schema refresh, ad-hoc snapshot,
239/// cancel/deadline, persist_checkpoint).
240pub async fn run_change_feed<F, S>(
241    feed: &mut F,
242    sink: &S,
243    pipeline: &Pipeline,
244    opts: &ApplyOpts,
245) -> Result<()>
246where
247    F: ChangeFeed,
248    S: SurrealSink,
249{
250    run_change_feed_with(feed, sink, Arc::new(pipeline.clone()), opts).await
251}
252
253/// Like [`run_change_feed`] but accepts any [`BatchTransformer`] behind [`Arc`].
254pub async fn run_change_feed_with<F, S, T>(
255    feed: &mut F,
256    sink: &S,
257    transformer: Arc<T>,
258    opts: &ApplyOpts,
259) -> Result<()>
260where
261    F: ChangeFeed,
262    S: SurrealSink,
263    T: BatchTransformer + 'static,
264{
265    let mut driver = ChangeFeedRef::new(feed);
266    let exit = crate::pipeline::apply::source_driver::run_source_runtime_with(
267        &mut driver,
268        sink,
269        transformer,
270        opts,
271        &SourceRuntimeOpts::default(),
272    )
273    .await?;
274    match exit {
275        RuntimeExit::Stopped(_) => Ok(()),
276    }
277}
278
279struct CompletedBatch<P> {
280    batch_id: u64,
281    last_position: P,
282    /// Pre-transform input event count (for `note_sunk_events` / skip).
283    event_count: u64,
284    result: Result<Vec<ApplyEvent>>,
285}
286
287struct TransformOutcome<P> {
288    epoch: u64,
289    seq: u64,
290    batch_id: u64,
291    last_position: P,
292    event_count: u64,
293    result: Result<Vec<ApplyEvent>>,
294}
295
296/// Batch ready for ordered sink apply (transform done; sink slot reserved).
297pub(crate) struct PreparedSinkBatch<P> {
298    pub(crate) batch_id: u64,
299    pub(crate) last_position: P,
300    pub(crate) event_count: u64,
301    pub(crate) result: Result<Vec<ApplyEvent>>,
302}
303
304/// Library / custom-loop driver sharing the same ordered apply path as
305/// [`crate::pipeline::run_source_runtime`].
306///
307/// Accepts row [`Change`] and [`RelationChange`] into one
308/// buffer / window. Identity and non-identity pipelines share one path: every
309/// batch is spawned onto the transform [`JoinSet`] (identity is an async
310/// no-op). Sink apply is ordered and may overlap with polling / transforming
311/// later batches; `push_*` / `flush` still return `Some(position)` only after
312/// that batch’s sink succeeds. They do **not** call
313/// [`SourceDriver::advance_watermark`] / [`ChangeFeed::advance_watermark`].
314///
315/// # Poisoning after [`FailurePolicy::Fail`]
316///
317/// After a batch fails under [`FailurePolicy::Fail`], this context is
318/// **poisoned**: successors are discarded, and further push/flush return `Err`
319/// immediately. Do not reuse — create a new context and replay from the last
320/// successful sink-safe watermark.
321pub struct ApplyContext<'a, S, T, P = ()> {
322    sink: &'a S,
323    transformer: Arc<T>,
324    opts: &'a ApplyOpts,
325    buffer: Vec<PositionedEvent<P>>,
326    next_batch_id: u64,
327    next_seq: u64,
328    next_to_apply: u64,
329    in_flight: HashSet<u64>,
330    completed: HashMap<u64, CompletedBatch<P>>,
331    join_set: JoinSet<TransformOutcome<P>>,
332    /// True while a prepared sink batch is being applied (slot reserved).
333    sink_in_flight: bool,
334    buffer_started: Option<tokio::time::Instant>,
335    /// Bumped on fail-discard so stale JoinSet tasks are ignored.
336    epoch: u64,
337    /// Set after [`FailurePolicy::Fail`]; context must not be reused.
338    poisoned: bool,
339    /// Events successfully sunk since the last [`Self::take_sunk_change_count`].
340    sunk_since_take: u64,
341    /// Deferred sink-safe position for [`CheckpointPolicy::IntervalWhenDrained`].
342    pending_checkpoint: Option<P>,
343    /// Last wall-clock time a deferred checkpoint was persisted.
344    last_checkpoint_persist: Instant,
345}
346
347impl<'a, S, T, P> ApplyContext<'a, S, T, P>
348where
349    S: SurrealSink,
350    T: BatchTransformer + 'static,
351    P: Clone + Send + Sync + 'static,
352{
353    /// Create an apply context bound to a sink, transformer, and options.
354    pub fn new(sink: &'a S, transformer: Arc<T>, opts: &'a ApplyOpts) -> Self {
355        Self {
356            sink,
357            transformer,
358            opts,
359            buffer: Vec::new(),
360            next_batch_id: 1,
361            next_seq: 0,
362            next_to_apply: 0,
363            in_flight: HashSet::new(),
364            completed: HashMap::new(),
365            join_set: JoinSet::new(),
366            sink_in_flight: false,
367            buffer_started: None,
368            epoch: 0,
369            poisoned: false,
370            sunk_since_take: 0,
371            pending_checkpoint: None,
372            last_checkpoint_persist: Instant::now(),
373        }
374    }
375
376    /// Whether this context was poisoned by a [`FailurePolicy::Fail`] error.
377    pub fn is_poisoned(&self) -> bool {
378        self.poisoned
379    }
380
381    /// Number of events waiting to form a batch.
382    pub fn buffer_len(&self) -> usize {
383        self.buffer.len()
384    }
385
386    /// Number of batches currently transforming (JoinSet path).
387    pub fn in_flight_count(&self) -> usize {
388        self.in_flight.len()
389    }
390
391    /// Whether an ordered sink apply is in progress (slot reserved).
392    pub fn sink_in_flight(&self) -> bool {
393        self.sink_in_flight
394    }
395
396    /// Batches occupying the apply window: transforming + awaiting/in sink.
397    ///
398    /// [`ApplyOpts::max_in_flight`] bounds this total so a slow sink back-pressures
399    /// new transforms while still allowing overlap (reads / transforms / writes).
400    pub fn window_occupancy(&self) -> usize {
401        self.in_flight.len() + self.completed.len() + usize::from(self.sink_in_flight)
402    }
403
404    /// Take and reset the count of events sunk since the previous take.
405    pub fn take_sunk_change_count(&mut self) -> u64 {
406        std::mem::take(&mut self.sunk_since_take)
407    }
408
409    /// Number of transform results waiting for ordered sink apply.
410    pub fn completed_waiting_count(&self) -> usize {
411        self.completed.len()
412    }
413
414    /// Whether any buffered, in-flight, completed-waiting, or sinking work remains.
415    ///
416    /// Used by checkpoint policies that must not persist a read-ahead position
417    /// while transform/apply still has unsunk work.
418    pub fn has_unsunk_work(&self) -> bool {
419        self.buffer_len() > 0
420            || self.in_flight_count() > 0
421            || self.completed_waiting_count() > 0
422            || self.sink_in_flight
423    }
424
425    /// Whether the apply window is fully drained (no unsunk work).
426    pub fn is_fully_drained(&self) -> bool {
427        !self.has_unsunk_work()
428    }
429
430    fn ensure_not_poisoned(&self) -> Result<()> {
431        if self.poisoned {
432            bail!(
433                "ApplyContext is poisoned after FailurePolicy::Fail; \
434                 create a new context and replay from the last successful advance_watermark"
435            );
436        }
437        Ok(())
438    }
439
440    pub(crate) fn push_buffered_event(&mut self, pe: PositionedEvent<P>) {
441        if self.buffer.is_empty() {
442            self.buffer_started = Some(tokio::time::Instant::now());
443        }
444        self.buffer.push(pe);
445    }
446
447    pub(crate) fn should_flush_partial_public(&self) -> bool {
448        self.should_flush_partial()
449    }
450
451    fn should_flush_partial(&self) -> bool {
452        match self.buffer_started {
453            Some(started) => started.elapsed() >= self.opts.batch_max_wait,
454            None => false,
455        }
456    }
457
458    /// Push one row change; may start transforms and drain ordered sink.
459    ///
460    /// Returns the last position successfully sunk (caller should `advance_watermark`).
461    pub async fn push_change(&mut self, change: Change, position: P) -> Result<Option<P>> {
462        self.ensure_not_poisoned()?;
463        self.push_buffered_event(PositionedEvent::change(change, position));
464        while self.try_start_full_batch() {}
465        self.collect_ready_transforms().await?;
466        self.drain_ordered_no_advance().await
467    }
468
469    /// Push one relation change into the same window as row changes.
470    pub async fn push_relation_change(
471        &mut self,
472        change: RelationChange,
473        position: P,
474    ) -> Result<Option<P>> {
475        self.ensure_not_poisoned()?;
476        self.push_buffered_event(PositionedEvent::relation_change(change, position));
477        while self.try_start_full_batch() {}
478        self.collect_ready_transforms().await?;
479        self.drain_ordered_no_advance().await
480    }
481
482    /// Push a unified positioned event.
483    pub async fn push_event(&mut self, event: ApplyEvent, position: P) -> Result<Option<P>> {
484        self.ensure_not_poisoned()?;
485        self.push_buffered_event(PositionedEvent::new(event, position));
486        while self.try_start_full_batch() {}
487        self.collect_ready_transforms().await?;
488        self.drain_ordered_no_advance().await
489    }
490
491    /// Collect JoinSet results that are already ready (non-blocking after a yield).
492    ///
493    /// Identity and non-identity share the same JoinSet path: ready tasks are
494    /// drained via [`poll_join_ready`]; slow transforms stay in-flight so the
495    /// caller can keep pushing / overlapping work.
496    async fn collect_ready_transforms(&mut self) -> Result<()> {
497        self.poll_join_ready().await
498    }
499
500    /// Flush remaining buffered events and wait for in-flight work.
501    pub async fn flush(&mut self) -> Result<Option<P>> {
502        self.ensure_not_poisoned()?;
503        let mut last = None;
504        loop {
505            while self.try_start_partial_batch() {}
506
507            if let Some(p) = self.drain_ordered_no_advance().await? {
508                last = Some(p);
509            }
510
511            if self.buffer.is_empty()
512                && self.in_flight.is_empty()
513                && self.completed.is_empty()
514                && !self.sink_in_flight
515            {
516                break;
517            }
518
519            if self.in_flight_count() > 0 {
520                self.wait_one_completion().await?;
521                continue;
522            }
523
524            if !self.buffer.is_empty() {
525                if !self.try_start_partial_batch() {
526                    bail!(
527                        "flush: {} buffered event(s) but window would not accept a batch",
528                        self.buffer.len()
529                    );
530                }
531                continue;
532            }
533
534            bail!(
535                "flush: {} completed batch(es) waiting but none is next_to_apply={}",
536                self.completed.len(),
537                self.next_to_apply
538            );
539        }
540        Ok(last)
541    }
542
543    /// Transform then sink rows (same as [`write_rows_with`]).
544    pub async fn write_rows(&self, rows: Vec<Row>) -> Result<()> {
545        self.ensure_not_poisoned()?;
546        write_rows_with(self.sink, Arc::clone(&self.transformer), rows, self.opts).await
547    }
548
549    /// Transform then sink relations (same as [`write_relations_with`]).
550    pub async fn write_relations(&self, relations: Vec<Relation>) -> Result<()> {
551        self.ensure_not_poisoned()?;
552        write_relations_with(
553            self.sink,
554            Arc::clone(&self.transformer),
555            relations,
556            self.opts,
557        )
558        .await
559    }
560
561    pub(crate) fn try_start_full_batch(&mut self) -> bool {
562        if self.buffer.len() < self.opts.batch_size {
563            return false;
564        }
565        self.start_batch_from_buffer(self.opts.batch_size)
566    }
567
568    pub(crate) fn try_start_partial_batch(&mut self) -> bool {
569        if self.buffer.is_empty() {
570            return false;
571        }
572        let n = self.buffer.len();
573        self.start_batch_from_buffer(n)
574    }
575
576    fn start_batch_from_buffer(&mut self, n: usize) -> bool {
577        if n == 0 {
578            return false;
579        }
580        // One window for identity and transforms: occupancy includes transforming,
581        // completed-waiting, and the in-flight ordered sink slot.
582        if self.window_occupancy() >= self.opts.max_in_flight {
583            return false;
584        }
585
586        let batch: Vec<PositionedEvent<P>> = self.buffer.drain(..n).collect();
587        if self.buffer.is_empty() {
588            self.buffer_started = None;
589        } else {
590            self.buffer_started = Some(tokio::time::Instant::now());
591        }
592
593        let last_position = batch.last().expect("n > 0").position.clone();
594        let events: Vec<ApplyEvent> = batch.into_iter().map(|pe| pe.event).collect();
595        let event_count = events.len() as u64;
596
597        let batch_id = self.next_batch_id;
598        self.next_batch_id = self.next_batch_id.saturating_add(1);
599        let seq = self.next_seq;
600        self.next_seq = self.next_seq.saturating_add(1);
601
602        self.in_flight.insert(seq);
603
604        let transformer = Arc::clone(&self.transformer);
605        let timeout = self.opts.timeout;
606        let epoch = self.epoch;
607        // Identity uses the same JoinSet path (async no-op) so poll/transform/sink
608        // can overlap; do not sync-insert into `completed` (that serialized the window).
609        let identity = transformer.is_identity();
610        self.join_set.spawn(async move {
611            let result = if identity {
612                Ok(events)
613            } else {
614                match tokio::time::timeout(timeout, transformer.transform_events(batch_id, events))
615                    .await
616                {
617                    Ok(inner) => inner,
618                    Err(_) => Err(anyhow!(
619                        "transform timeout after {:?} for batch_id={batch_id}",
620                        timeout
621                    )),
622                }
623            };
624            TransformOutcome {
625                epoch,
626                seq,
627                batch_id,
628                last_position,
629                event_count,
630                result,
631            }
632        });
633        true
634    }
635
636    pub(crate) async fn poll_join_ready_public(&mut self) -> Result<()> {
637        self.poll_join_ready().await
638    }
639
640    pub(crate) async fn wait_one_completion_public(&mut self) -> Result<()> {
641        self.wait_one_completion().await
642    }
643
644    pub(crate) async fn try_interval_persist_public(
645        &mut self,
646        driver: &mut impl SourceDriver<Position = P>,
647    ) -> Result<()> {
648        self.try_interval_persist(driver).await
649    }
650
651    fn try_poll_join(&mut self) -> Option<Result<TransformOutcome<P>>> {
652        let handle = self.join_set.try_join_next()?;
653        Some(match handle {
654            Ok(outcome) => Ok(outcome),
655            Err(e) => Err(anyhow!("transform task join error: {e}")),
656        })
657    }
658
659    async fn poll_join_ready(&mut self) -> Result<()> {
660        while let Some(outcome) = self.try_poll_join() {
661            self.handle_outcome(outcome?)?;
662        }
663        if self.in_flight_count() > 0 {
664            tokio::task::yield_now().await;
665            while let Some(outcome) = self.try_poll_join() {
666                self.handle_outcome(outcome?)?;
667            }
668        }
669        Ok(())
670    }
671
672    pub(crate) async fn wait_one_completion(&mut self) -> Result<()> {
673        let outcome = self
674            .join_set
675            .join_next()
676            .await
677            .ok_or_else(|| anyhow!("no in-flight transform tasks"))?
678            .map_err(|e| anyhow!("transform task join error: {e}"))?;
679        self.handle_outcome(outcome)
680    }
681
682    fn handle_outcome(&mut self, outcome: TransformOutcome<P>) -> Result<()> {
683        if outcome.epoch != self.epoch {
684            return Ok(());
685        }
686        if !self.in_flight.remove(&outcome.seq) {
687            return Ok(());
688        }
689        self.completed.insert(
690            outcome.seq,
691            CompletedBatch {
692                batch_id: outcome.batch_id,
693                last_position: outcome.last_position,
694                event_count: outcome.event_count,
695                result: outcome.result,
696            },
697        );
698        Ok(())
699    }
700
701    /// Reserve the next ordered batch for sink apply (non-blocking).
702    ///
703    /// Returns `None` when the sink slot is busy or the next seq is not ready.
704    /// Caller must apply (or skip) then call [`Self::finish_sink_ok_driver`] /
705    /// [`Self::finish_sink_ok_no_advance`] (or [`Self::release_sink_slot`] on abandon).
706    pub(crate) fn prepare_ordered_sink(&mut self) -> Option<PreparedSinkBatch<P>> {
707        if self.sink_in_flight {
708            return None;
709        }
710        let batch = self.completed.remove(&self.next_to_apply)?;
711        self.sink_in_flight = true;
712        Some(PreparedSinkBatch {
713            batch_id: batch.batch_id,
714            last_position: batch.last_position,
715            event_count: batch.event_count,
716            result: batch.result,
717        })
718    }
719
720    /// After a successful sink apply: note_sunk_events → advance_watermark → policy → persist.
721    ///
722    /// `sunk` is the **pre-transform input** event count (`batch.event_count`),
723    /// not the post-transform sink length.
724    pub(crate) async fn finish_sink_ok_driver(
725        &mut self,
726        driver: &mut impl SourceDriver<Position = P>,
727        last_position: P,
728        sunk: u64,
729    ) -> Result<()> {
730        self.sunk_since_take = self.sunk_since_take.saturating_add(sunk);
731        driver.note_sunk_events(sunk);
732        driver
733            .advance_watermark(last_position.clone())
734            .await
735            .context("advance_watermark")?;
736        // Free the sink slot before persist checks so IntervalWhenDrained sees a
737        // drained window when nothing else is outstanding.
738        self.next_to_apply += 1;
739        self.sink_in_flight = false;
740        self.after_advance_persist(driver, last_position).await?;
741        Ok(())
742    }
743
744    /// After sink/transform failure under driver path.
745    pub(crate) async fn finish_sink_err_driver(
746        &mut self,
747        driver: &mut impl SourceDriver<Position = P>,
748        batch_id: u64,
749        last_position: P,
750        event_count: u64,
751        e: anyhow::Error,
752    ) -> Result<()> {
753        self.sink_in_flight = false;
754        self.fail_or_skip_driver(driver, batch_id, last_position, event_count, e)
755            .await
756    }
757
758    /// After successful sink for push/flush (no driver advance_watermark).
759    pub(crate) fn finish_sink_ok_no_advance(&mut self, last_position: P, sunk: u64) -> P {
760        self.sunk_since_take = self.sunk_since_take.saturating_add(sunk);
761        self.next_to_apply += 1;
762        self.sink_in_flight = false;
763        last_position
764    }
765
766    /// After sink/transform failure for push/flush.
767    pub(crate) fn finish_sink_err_no_advance(
768        &mut self,
769        batch_id: u64,
770        last_position: P,
771        e: anyhow::Error,
772    ) -> Result<Option<P>> {
773        self.sink_in_flight = false;
774        self.fail_or_skip_no_feed(batch_id, last_position, e)
775    }
776
777    /// Drain ordered sink + driver advance_watermark (+ optional persist_checkpoint).
778    ///
779    /// Awaits each sink apply to completion (used by flush / schema/ad-hoc hooks).
780    pub(crate) async fn drain_ordered_driver(
781        &mut self,
782        driver: &mut impl SourceDriver<Position = P>,
783    ) -> Result<()> {
784        loop {
785            // Collect ready transform results without blocking.
786            self.poll_join_ready().await?;
787            let Some(batch) = self.prepare_ordered_sink() else {
788                break;
789            };
790            match batch.result {
791                Ok(events) => match self.apply_sink_events(&events).await {
792                    Ok(()) => {
793                        // Pre-transform input count — not post-transform
794                        // `events.len()` — so Kafka/wal2json watermarks stay
795                        // aligned under filter and fan-out.
796                        self.finish_sink_ok_driver(driver, batch.last_position, batch.event_count)
797                            .await?;
798                    }
799                    Err(e) => {
800                        self.finish_sink_err_driver(
801                            driver,
802                            batch.batch_id,
803                            batch.last_position,
804                            batch.event_count,
805                            e,
806                        )
807                        .await?;
808                    }
809                },
810                Err(e) => {
811                    self.finish_sink_err_driver(
812                        driver,
813                        batch.batch_id,
814                        batch.last_position,
815                        batch.event_count,
816                        e,
817                    )
818                    .await?;
819                }
820            }
821        }
822        self.try_interval_persist(driver).await
823    }
824
825    async fn after_advance_persist(
826        &mut self,
827        driver: &mut impl SourceDriver<Position = P>,
828        position: P,
829    ) -> Result<()> {
830        match driver.checkpoint_policy() {
831            CheckpointPolicy::PersistAfterAdvance => {
832                driver
833                    .persist_checkpoint(position)
834                    .await
835                    .context("persist_checkpoint")?;
836            }
837            CheckpointPolicy::AdvanceOnly => {}
838            CheckpointPolicy::IntervalWhenDrained { .. } => {
839                self.pending_checkpoint = Some(position);
840                // Persist sunk watermarks promptly once the window is empty
841                // (matches pre-SourceDriver last-sunk-on-sink cadence). Interval
842                // still gates filtered-only read_progress persists.
843                if self.is_fully_drained() {
844                    self.flush_pending_checkpoint(driver).await?;
845                }
846            }
847        }
848        Ok(())
849    }
850
851    async fn try_interval_persist(
852        &mut self,
853        driver: &mut impl SourceDriver<Position = P>,
854    ) -> Result<()> {
855        let CheckpointPolicy::IntervalWhenDrained { interval } = driver.checkpoint_policy() else {
856            return Ok(());
857        };
858        if !self.is_fully_drained() {
859            return Ok(());
860        }
861        if self.last_checkpoint_persist.elapsed() < interval {
862            return Ok(());
863        }
864        if self.pending_checkpoint.is_some() {
865            return self.flush_pending_checkpoint(driver).await;
866        }
867        // No advance armed a pending watermark (filtered-only / idle catch-up).
868        // Ask the driver for a drained-safe read position to persist.
869        if let Some(position) = driver
870            .read_progress_for_persist()
871            .await
872            .context("read_progress_for_persist")?
873        {
874            driver
875                .persist_checkpoint(position)
876                .await
877                .context("persist_checkpoint")?;
878            self.last_checkpoint_persist = Instant::now();
879        }
880        Ok(())
881    }
882
883    async fn flush_pending_checkpoint(
884        &mut self,
885        driver: &mut impl SourceDriver<Position = P>,
886    ) -> Result<()> {
887        if let Some(position) = self.pending_checkpoint.take() {
888            driver
889                .persist_checkpoint(position)
890                .await
891                .context("persist_checkpoint")?;
892            self.last_checkpoint_persist = Instant::now();
893        }
894        Ok(())
895    }
896
897    /// Flush remaining work and advance_watermark via driver (used on cancel/deadline stop).
898    pub(crate) async fn flush_for_driver(
899        &mut self,
900        driver: &mut impl SourceDriver<Position = P>,
901    ) -> Result<()> {
902        if self.poisoned {
903            return Ok(());
904        }
905        loop {
906            while self.try_start_partial_batch() {}
907            self.drain_ordered_driver(driver).await?;
908            if self.buffer.is_empty()
909                && self.in_flight.is_empty()
910                && self.completed.is_empty()
911                && !self.sink_in_flight
912            {
913                // Force-persist any deferred IntervalWhenDrained position.
914                self.flush_pending_checkpoint(driver).await?;
915                return Ok(());
916            }
917            if self.in_flight_count() > 0 {
918                self.wait_one_completion().await?;
919                continue;
920            }
921            if !self.buffer.is_empty() {
922                if !self.try_start_partial_batch() {
923                    bail!(
924                        "flush_for_driver: {} buffered but window would not accept a batch",
925                        self.buffer.len()
926                    );
927                }
928                continue;
929            }
930            bail!(
931                "flush_for_driver: {} completed waiting but none is next_to_apply={}",
932                self.completed.len(),
933                self.next_to_apply
934            );
935        }
936    }
937
938    async fn drain_ordered_no_advance(&mut self) -> Result<Option<P>> {
939        let mut last = None;
940        loop {
941            self.poll_join_ready().await?;
942            let Some(batch) = self.prepare_ordered_sink() else {
943                break;
944            };
945            match batch.result {
946                Ok(events) => match self.apply_sink_events(&events).await {
947                    Ok(()) => {
948                        // Pre-transform input count (same as drain_ordered_driver)
949                        // so filter/fan-out cannot under/over-count sunk_since_take.
950                        last = Some(
951                            self.finish_sink_ok_no_advance(batch.last_position, batch.event_count),
952                        );
953                    }
954                    Err(e) => {
955                        last = self.finish_sink_err_no_advance(
956                            batch.batch_id,
957                            batch.last_position,
958                            e,
959                        )?;
960                    }
961                },
962                Err(e) => {
963                    last =
964                        self.finish_sink_err_no_advance(batch.batch_id, batch.last_position, e)?;
965                }
966            }
967        }
968        Ok(last)
969    }
970
971    async fn fail_or_skip_driver(
972        &mut self,
973        driver: &mut impl SourceDriver<Position = P>,
974        batch_id: u64,
975        last_position: P,
976        event_count: u64,
977        e: anyhow::Error,
978    ) -> Result<()> {
979        match self.opts.failure_policy {
980            FailurePolicy::Fail => {
981                self.discard_successors();
982                Err(e).with_context(|| format!("batch {batch_id} failed"))
983            }
984            FailurePolicy::Skip => {
985                warn!(
986                    batch_id,
987                    error = %e,
988                    "skipping failed batch (failure_policy=skip); advancing watermark past it"
989                );
990                // Account for skipped events so drivers that gate advance on
991                // note_sunk_events (e.g. wal2json slot) do not stall.
992                driver.note_sunk_events(event_count);
993                driver
994                    .advance_watermark(last_position.clone())
995                    .await
996                    .context("advance_watermark after skip")?;
997                self.after_advance_persist(driver, last_position).await?;
998                self.next_to_apply += 1;
999                Ok(())
1000            }
1001        }
1002    }
1003
1004    fn fail_or_skip_no_feed(
1005        &mut self,
1006        batch_id: u64,
1007        last_position: P,
1008        e: anyhow::Error,
1009    ) -> Result<Option<P>> {
1010        match self.opts.failure_policy {
1011            FailurePolicy::Fail => {
1012                self.discard_successors();
1013                Err(e).with_context(|| format!("batch {batch_id} failed"))
1014            }
1015            FailurePolicy::Skip => {
1016                warn!(
1017                    batch_id,
1018                    error = %e,
1019                    "skipping failed batch (failure_policy=skip)"
1020                );
1021                self.next_to_apply += 1;
1022                Ok(Some(last_position))
1023            }
1024        }
1025    }
1026
1027    fn discard_successors(&mut self) {
1028        self.epoch = self.epoch.saturating_add(1);
1029        self.in_flight.clear();
1030        self.completed.clear();
1031        self.buffer.clear();
1032        self.buffer_started = None;
1033        self.sink_in_flight = false;
1034        self.join_set.abort_all();
1035        self.poisoned = true;
1036    }
1037
1038    async fn apply_sink_events(&self, events: &[ApplyEvent]) -> Result<()> {
1039        apply_transformed_sink_events(self.sink, events).await
1040    }
1041}