Skip to main content

surreal_sync_runtime/pipeline/apply/
source_driver.rs

1//! SourceDriver: poll / advance_watermark + schema/ad-hoc/cancel/checkpoint hooks.
2//!
3//! [`SourceDriver`] + [`run_source_runtime`] are the production incremental API.
4//! [`crate::pipeline::ChangeFeed`] / [`crate::pipeline::run_change_feed`] are a thin adapter for
5//! tests and simple row-CDC sources (defaults cover the rest as no-ops).
6
7use crate::pipeline::apply::event::PositionedEvent;
8use crate::pipeline::apply::feed::ChangeFeed;
9use crate::pipeline::apply::opts::ApplyOpts;
10use crate::pipeline::apply::runtime::{
11    apply_changes_with, apply_relation_changes_with, apply_transformed_sink_events,
12    write_relations_with, write_rows_with, ApplyContext,
13};
14use crate::pipeline::apply::transform::BatchTransformer;
15use crate::pipeline::pipeline::Pipeline;
16use anyhow::{Context, Result};
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::time::{Duration, Instant};
21use surreal_sync_core::SurrealSink;
22use surreal_sync_core::{Change, Relation, RelationChange, Row};
23use tracing::debug;
24
25/// Signal from [`SourceDriver::between_events`] for side work between polls.
26///
27/// Handled by [`run_source_runtime`] via the corresponding `on_*` hooks before
28/// the next poll. Defaults are no-ops so simple CDC drivers stay thin.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ControlSignal {
31    /// Refresh source schema / catalog (DDL observed, etc.).
32    SchemaRefresh,
33    /// Run an ad-hoc snapshot for the named tables (empty = driver-defined).
34    AdHocSnapshot {
35        /// Table names to snapshot; empty means “driver default set”.
36        tables: Vec<String>,
37    },
38}
39
40/// Why [`run_source_runtime`] should stop (checked each loop iteration).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum StopReason {
43    /// External cancellation (token, signal, operator stop).
44    Cancelled,
45    /// Wall-clock deadline reached.
46    Deadline,
47    /// Source-defined “until” bound reached (position / LSN / etc.).
48    Until,
49    /// Feed exhausted (`is_finished` after drain).
50    Finished,
51}
52
53/// When to call [`SourceDriver::persist_checkpoint`].
54///
55/// Persistence is always **sink-safe only**: the runtime never asks a driver to
56/// persist a position past unsunk transform/apply work. Drivers that map the
57/// persist argument to a read-ahead cursor must only do so when the apply window
58/// is drained (see [`IntervalWhenDrained`](Self::IntervalWhenDrained)).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum CheckpointPolicy {
61    /// After each successful sink + [`advance_watermark`](SourceDriver::advance_watermark),
62    /// also call [`SourceDriver::persist_checkpoint`] with that position.
63    #[default]
64    PersistAfterAdvance,
65    /// Do not call `persist_checkpoint` (driver folds durability into
66    /// [`advance_watermark`](SourceDriver::advance_watermark)).
67    AdvanceOnly,
68    /// Persist sink-safe positions when the apply window is **fully drained**
69    /// (no buffer / in-flight / completed-waiting):
70    ///
71    /// - After an `advance_watermark`, if the window is already drained, persist
72    ///   that sink-safe watermark promptly (same durability cadence as persisting
73    ///   last-sunk on sink success).
74    /// - Otherwise arm a pending watermark and flush once the window drains
75    ///   and at least `interval` has elapsed since the last persist.
76    /// - When drained with **no** pending advance (filtered-only / idle read
77    ///   progress), call [`SourceDriver::read_progress_for_persist`] on the
78    ///   same interval so drivers can advance a store cursor through noise
79    ///   without reintroducing unsunk read-ahead.
80    ///
81    /// `interval = Duration::ZERO` persists whenever the window is drained
82    /// (still never advances past unsunk work). On stop flush, any pending
83    /// sink-safe position is force-persisted.
84    IntervalWhenDrained {
85        /// Minimum time between deferred / filtered-only `persist_checkpoint` calls.
86        interval: Duration,
87    },
88}
89
90/// Why the runtime exited.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum RuntimeExit {
93    /// Clean stop with the given reason.
94    Stopped(StopReason),
95}
96
97/// Apply helpers passed into [`SourceDriver::on_adhoc_snapshot`].
98///
99/// Drivers must use these (or the same sink + transformer + [`ApplyOpts`]) so
100/// ad-hoc / snapshot writes honor transform and sink invariants — no private
101/// durability bypass of the pipeline.
102#[async_trait::async_trait]
103pub trait AdhocApply: Send + Sync {
104    /// Transform + `write_rows` via the runtime’s pipeline and opts.
105    async fn write_rows(&self, rows: Vec<Row>) -> Result<()>;
106
107    /// Transform + `write_relations`.
108    async fn write_relations(&self, relations: Vec<Relation>) -> Result<()>;
109
110    /// Transform + apply each row change.
111    async fn apply_changes(&self, changes: Vec<Change>) -> Result<()>;
112
113    /// Transform + apply each relation change.
114    async fn apply_relation_changes(&self, changes: Vec<RelationChange>) -> Result<()>;
115
116    /// Borrow the apply options used by the incremental loop.
117    fn apply_opts(&self) -> &ApplyOpts;
118}
119
120struct AdhocApplyImpl<'a, S, T> {
121    sink: &'a S,
122    transformer: Arc<T>,
123    apply_opts: &'a ApplyOpts,
124}
125
126#[async_trait::async_trait]
127impl<'a, S, T> AdhocApply for AdhocApplyImpl<'a, S, T>
128where
129    S: SurrealSink,
130    T: BatchTransformer + 'static,
131{
132    async fn write_rows(&self, rows: Vec<Row>) -> Result<()> {
133        write_rows_with(
134            self.sink,
135            Arc::clone(&self.transformer),
136            rows,
137            self.apply_opts,
138        )
139        .await
140    }
141
142    async fn write_relations(&self, relations: Vec<Relation>) -> Result<()> {
143        write_relations_with(
144            self.sink,
145            Arc::clone(&self.transformer),
146            relations,
147            self.apply_opts,
148        )
149        .await
150    }
151
152    async fn apply_changes(&self, changes: Vec<Change>) -> Result<()> {
153        apply_changes_with(
154            self.sink,
155            Arc::clone(&self.transformer),
156            changes,
157            self.apply_opts,
158        )
159        .await
160    }
161
162    async fn apply_relation_changes(&self, changes: Vec<RelationChange>) -> Result<()> {
163        apply_relation_changes_with(
164            self.sink,
165            Arc::clone(&self.transformer),
166            changes,
167            self.apply_opts,
168        )
169        .await
170    }
171
172    fn apply_opts(&self) -> &ApplyOpts {
173        self.apply_opts
174    }
175}
176
177/// Source-facing incremental driver: work items + optional schema / ad-hoc /
178/// cancel / checkpoint hooks.
179///
180/// Mirrors the spirit of [`interleaved_snapshot::WatermarkSource`]:
181/// [`run_source_runtime`] owns the loop; the driver supplies poll /
182/// [`advance_watermark`](Self::advance_watermark) and optional extension points.
183///
184/// # Defaults
185///
186/// All hooks default to no-op / empty so a minimal CDC source only implements
187/// [`poll_work`](Self::poll_work), [`advance_watermark`](Self::advance_watermark),
188/// and optionally [`is_finished`](Self::is_finished).
189#[async_trait::async_trait]
190pub trait SourceDriver: Send {
191    /// Checkpoint / resume position type.
192    type Position: Clone + Send + Sync + 'static;
193
194    /// Next work items (row and/or relation changes). May be empty on idle.
195    async fn poll_work(&mut self) -> Result<Vec<PositionedEvent<Self::Position>>>;
196
197    /// Mark `position` sink-safe. May be in-memory only, broker offset commit, or
198    /// both — durable store writes belong in [`persist_checkpoint`](Self::persist_checkpoint)
199    /// unless policy is [`CheckpointPolicy::AdvanceOnly`].
200    async fn advance_watermark(&mut self, position: Self::Position) -> Result<()>;
201
202    /// Whether the driver will produce no more work (EOF).
203    fn is_finished(&self) -> bool {
204        false
205    }
206
207    /// Polled between apply cycles; return control signals to handle before the
208    /// next [`poll_work`](Self::poll_work).
209    async fn between_events(&mut self) -> Result<Vec<ControlSignal>> {
210        Ok(Vec::new())
211    }
212
213    /// Handle [`ControlSignal::SchemaRefresh`]. Default: no-op.
214    async fn on_schema_refresh(&mut self) -> Result<()> {
215        Ok(())
216    }
217
218    /// Handle [`ControlSignal::AdHocSnapshot`].
219    ///
220    /// `apply` exposes the runtime’s sink + pipeline/transformer + [`ApplyOpts`]
221    /// so drivers can run snapshot writes through the same transform/sink path
222    /// as the incremental loop (no private durability bypass).
223    ///
224    /// Default: no-op.
225    async fn on_adhoc_snapshot(
226        &mut self,
227        _tables: &[String],
228        _apply: &dyn AdhocApply,
229    ) -> Result<()> {
230        Ok(())
231    }
232
233    /// If `Some`, the runtime stops after draining in-flight apply work.
234    fn stop_reason(&self) -> Option<StopReason> {
235        None
236    }
237
238    /// Checkpoint persistence policy. Default: [`CheckpointPolicy::PersistAfterAdvance`].
239    fn checkpoint_policy(&self) -> CheckpointPolicy {
240        CheckpointPolicy::PersistAfterAdvance
241    }
242
243    /// Persist a **sink-safe** checkpoint (called after successful sink +
244    /// [`advance_watermark`](Self::advance_watermark) according to
245    /// [`CheckpointPolicy`]). Default: no-op.
246    async fn persist_checkpoint(&mut self, _position: Self::Position) -> Result<()> {
247        Ok(())
248    }
249
250    /// Optional sink-safe position to persist when
251    /// [`CheckpointPolicy::IntervalWhenDrained`] finds the apply window empty
252    /// but nothing was advanced since the last persist (e.g. filtered-only
253    /// binlog traffic that advanced the read cursor with no work items).
254    ///
255    /// The runtime only consults this while fully drained, so returning the
256    /// current read position is safe. Default: `None` (no filtered-only persist).
257    async fn read_progress_for_persist(&mut self) -> Result<Option<Self::Position>> {
258        Ok(None)
259    }
260
261    /// Notify the driver that `count` **input** (pre-transform) events are
262    /// accounted for before [`advance_watermark`](Self::advance_watermark).
263    ///
264    /// `count` is the batch's poll/input size, not the post-transform sink
265    /// length — so filter under-count and fan-out over-count cannot stall or
266    /// premature-advance Kafka pending-acks, wal2json `emitted`/`sunk` gates,
267    /// or similar watermarks.
268    ///
269    /// Called after a successful sink apply, and also under
270    /// [`FailurePolicy::Skip`](crate::pipeline::FailurePolicy::Skip) for batches that are
271    /// advanced past without writing (so drivers that gate slot/cursor advance
272    /// on sunk counts do not stall). Default: no-op.
273    fn note_sunk_events(&mut self, _count: u64) {}
274}
275
276/// Adapter: any [`ChangeFeed`] is a [`SourceDriver`] with no-op control hooks.
277pub struct ChangeFeedDriver<F> {
278    /// Inner row-only feed.
279    pub inner: F,
280}
281
282impl<F> ChangeFeedDriver<F> {
283    /// Wrap a [`ChangeFeed`].
284    pub fn new(inner: F) -> Self {
285        Self { inner }
286    }
287
288    /// Borrow the inner feed.
289    pub fn inner(&self) -> &F {
290        &self.inner
291    }
292
293    /// Mutably borrow the inner feed.
294    pub fn inner_mut(&mut self) -> &mut F {
295        &mut self.inner
296    }
297
298    /// Unwrap the inner feed.
299    pub fn into_inner(self) -> F {
300        self.inner
301    }
302}
303
304#[async_trait::async_trait]
305impl<F> SourceDriver for ChangeFeedDriver<F>
306where
307    F: ChangeFeed,
308{
309    type Position = F::Position;
310
311    async fn poll_work(&mut self) -> Result<Vec<PositionedEvent<Self::Position>>> {
312        let changes = self.inner.poll_changes().await?;
313        Ok(changes.into_iter().map(PositionedEvent::from).collect())
314    }
315
316    async fn advance_watermark(&mut self, position: Self::Position) -> Result<()> {
317        self.inner.advance_watermark(position).await
318    }
319
320    fn is_finished(&self) -> bool {
321        self.inner.is_finished()
322    }
323}
324
325/// Borrowing adapter for [`run_change_feed`] (does not take ownership of the feed).
326pub struct ChangeFeedRef<'a, F: ChangeFeed> {
327    inner: &'a mut F,
328}
329
330impl<'a, F: ChangeFeed> ChangeFeedRef<'a, F> {
331    /// Borrow a feed as a [`SourceDriver`].
332    pub fn new(inner: &'a mut F) -> Self {
333        Self { inner }
334    }
335}
336
337#[async_trait::async_trait]
338impl<'a, F> SourceDriver for ChangeFeedRef<'a, F>
339where
340    F: ChangeFeed,
341{
342    type Position = F::Position;
343
344    async fn poll_work(&mut self) -> Result<Vec<PositionedEvent<Self::Position>>> {
345        let changes = self.inner.poll_changes().await?;
346        Ok(changes.into_iter().map(PositionedEvent::from).collect())
347    }
348
349    async fn advance_watermark(&mut self, position: Self::Position) -> Result<()> {
350        self.inner.advance_watermark(position).await
351    }
352
353    fn is_finished(&self) -> bool {
354        self.inner.is_finished()
355    }
356}
357
358/// Optional wall-clock / cancel bounds for [`run_source_runtime`].
359///
360/// Driver [`SourceDriver::stop_reason`] is also honored each iteration.
361#[derive(Debug, Clone, Default)]
362pub struct SourceRuntimeOpts {
363    /// Stop with [`StopReason::Deadline`] once `Instant::now() >= deadline`.
364    pub deadline: Option<Instant>,
365    /// When true, stop with [`StopReason::Cancelled`] at the next check.
366    pub cancelled: bool,
367}
368
369impl SourceRuntimeOpts {
370    /// No extra bounds (driver `stop_reason` / `is_finished` only).
371    pub fn new() -> Self {
372        Self::default()
373    }
374
375    /// Builder: wall-clock deadline.
376    pub fn with_deadline(mut self, deadline: Instant) -> Self {
377        self.deadline = Some(deadline);
378        self
379    }
380
381    /// Builder: mark cancelled.
382    pub fn with_cancelled(mut self, cancelled: bool) -> Self {
383        self.cancelled = cancelled;
384        self
385    }
386}
387
388/// Incremental loop over a [`SourceDriver`].
389///
390/// Shared apply order per batch: transform → ordered write → watermark
391/// (`note_sunk_events` → `advance_watermark` → policy → optional
392/// `persist_checkpoint`, sink-safe only). Between cycles: `between_events` →
393/// hooks (ad-hoc receives [`AdhocApply`]). Stops on `is_finished` (after drain),
394/// driver `stop_reason`, or [`SourceRuntimeOpts`] cancel/deadline.
395pub async fn run_source_runtime<D, S>(
396    driver: &mut D,
397    sink: &S,
398    pipeline: &Pipeline,
399    apply_opts: &ApplyOpts,
400    runtime_opts: &SourceRuntimeOpts,
401) -> Result<RuntimeExit>
402where
403    D: SourceDriver,
404    S: SurrealSink,
405{
406    run_source_runtime_with(
407        driver,
408        sink,
409        Arc::new(pipeline.clone()),
410        apply_opts,
411        runtime_opts,
412    )
413    .await
414}
415
416/// Like [`run_source_runtime`] but accepts any [`BatchTransformer`] behind [`Arc`].
417///
418/// Pipeline overlap model (identity and non-identity share this path):
419/// - **Reads** continue while prior batches transform or sink.
420/// - **Transforms** overlap up to `max_in_flight` window occupancy (transforming +
421///   awaiting/in-flight ordered sink).
422/// - **Writes** stay ordered; the fill loop does not await sink before the next
423///   poll/transform start. Source `advance_watermark` runs only after that
424///   batch’s sink succeeds.
425pub async fn run_source_runtime_with<D, S, T>(
426    driver: &mut D,
427    sink: &S,
428    transformer: Arc<T>,
429    apply_opts: &ApplyOpts,
430    runtime_opts: &SourceRuntimeOpts,
431) -> Result<RuntimeExit>
432where
433    D: SourceDriver,
434    S: SurrealSink,
435    T: BatchTransformer + 'static,
436{
437    let mut ctx = ApplyContext::new(sink, Arc::clone(&transformer), apply_opts);
438    // In-flight ordered sink work (overlaps with poll/transform). The drive
439    // future is created once per batch so select! cancelling an await never
440    // restarts apply from scratch (duplicate writes).
441    let mut sinking: Option<PendingSink<'_, D::Position>> = None;
442
443    loop {
444        if let Some(reason) = effective_stop_reason(driver, runtime_opts) {
445            finish_pending_sink(&mut ctx, driver, &mut sinking).await?;
446            ctx.flush_for_driver(driver).await?;
447            return Ok(RuntimeExit::Stopped(reason));
448        }
449
450        // Only drain an in-flight sink before schema/ad-hoc side work when there
451        // are signals. Spare window capacity must keep polling while sink runs.
452        let signals = driver.between_events().await.context("between_events")?;
453        if !signals.is_empty() {
454            finish_pending_sink(&mut ctx, driver, &mut sinking).await?;
455            handle_control_signals(driver, &mut ctx, sink, &transformer, apply_opts, signals)
456                .await?;
457        }
458
459        // Fill transform window; opportunistically start ordered sink without
460        // awaiting it so poll can continue while a slow sink is in flight.
461        loop {
462            if let Some(reason) = effective_stop_reason(driver, runtime_opts) {
463                finish_pending_sink(&mut ctx, driver, &mut sinking).await?;
464                ctx.flush_for_driver(driver).await?;
465                return Ok(RuntimeExit::Stopped(reason));
466            }
467
468            ctx.poll_join_ready_public().await?;
469            try_launch_sink(sink, &mut ctx, &mut sinking)?;
470
471            if ctx.window_occupancy() >= apply_opts.max_in_flight {
472                break;
473            }
474
475            // Pull work into the buffer. A single poll_work may return more than
476            // batch_size events — that overshoot is intentional: we keep every
477            // event in the buffer (never drop) and form transform batches from
478            // it. Memory may exceed batch_size until the excess is drained.
479            while ctx.buffer_len() < apply_opts.batch_size && !driver.is_finished() {
480                let polled = driver.poll_work().await.context("poll_work")?;
481                if polled.is_empty() {
482                    break;
483                }
484                for pe in polled {
485                    ctx.push_buffered_event(pe);
486                }
487            }
488
489            let started = if ctx.buffer_len() >= apply_opts.batch_size {
490                ctx.try_start_full_batch()
491            } else if ctx.buffer_len() > 0
492                && (driver.is_finished() || ctx.should_flush_partial_public())
493            {
494                ctx.try_start_partial_batch()
495            } else {
496                false
497            };
498
499            if !started {
500                break;
501            }
502            // Collect instant (identity) completions and start sink if possible
503            // without awaiting sink completion.
504            ctx.poll_join_ready_public().await?;
505            try_launch_sink(sink, &mut ctx, &mut sinking)?;
506        }
507
508        // Idle / progress wait: poll transforms and sink concurrently.
509        let has_transform = ctx.in_flight_count() > 0;
510        let has_sink = sinking.is_some();
511        let has_buffer = ctx.buffer_len() > 0;
512        let finished = driver.is_finished();
513
514        if !has_transform && !has_sink && !has_buffer {
515            if finished {
516                ctx.flush_for_driver(driver).await?;
517                return Ok(RuntimeExit::Stopped(StopReason::Finished));
518            }
519            if let Some(reason) = effective_stop_reason(driver, runtime_opts) {
520                ctx.flush_for_driver(driver).await?;
521                return Ok(RuntimeExit::Stopped(reason));
522            }
523            ctx.try_interval_persist_public(driver).await?;
524            tokio::time::sleep(apply_opts.batch_max_wait.min(Duration::from_millis(10))).await;
525            continue;
526        }
527
528        if !has_transform && !has_sink {
529            // Buffered but waiting on batch_max_wait / occupancy.
530            tokio::time::sleep(apply_opts.batch_max_wait.min(Duration::from_millis(10))).await;
531            continue;
532        }
533
534        // Spare window capacity while sink is in flight: return to fill/poll
535        // instead of parking solely on the sink future (reads must keep rising).
536        if has_sink && ctx.window_occupancy() < apply_opts.max_in_flight && !finished {
537            let idle = apply_opts.batch_max_wait.min(Duration::from_millis(10));
538            if has_transform {
539                // Concurrently progress transforms and sink, then refill.
540                tokio::select! {
541                    biased;
542                    outcome = ctx.wait_one_completion_public() => {
543                        outcome?;
544                        try_launch_sink(sink, &mut ctx, &mut sinking)?;
545                    }
546                    result = poll_pending_sink(&mut sinking) => {
547                        complete_pending_sink(&mut ctx, driver, &mut sinking, result).await?;
548                        ctx.try_interval_persist_public(driver).await?;
549                        try_launch_sink(sink, &mut ctx, &mut sinking)?;
550                    }
551                    _ = tokio::time::sleep(idle) => {}
552                }
553            } else {
554                // No transform to wait on: race sink against a short idle so we
555                // reopen the fill loop under spare capacity without busy-spinning
556                // when poll_work is temporarily empty.
557                tokio::select! {
558                    result = poll_pending_sink(&mut sinking) => {
559                        complete_pending_sink(&mut ctx, driver, &mut sinking, result).await?;
560                        ctx.try_interval_persist_public(driver).await?;
561                        try_launch_sink(sink, &mut ctx, &mut sinking)?;
562                    }
563                    _ = tokio::time::sleep(idle) => {}
564                }
565            }
566            continue;
567        }
568
569        tokio::select! {
570            biased;
571            outcome = ctx.wait_one_completion_public(), if has_transform => {
572                outcome?;
573                try_launch_sink(sink, &mut ctx, &mut sinking)?;
574            }
575            // Poll the same once-created drive future in place. Dropping this
576            // await (transform arm wins) must not recreate apply from scratch.
577            result = poll_pending_sink(&mut sinking), if has_sink => {
578                complete_pending_sink(&mut ctx, driver, &mut sinking, result).await?;
579                ctx.try_interval_persist_public(driver).await?;
580                try_launch_sink(sink, &mut ctx, &mut sinking)?;
581            }
582        }
583    }
584}
585
586struct PendingSinkMeta<P> {
587    batch_id: u64,
588    last_position: P,
589    /// Pre-transform input count (skip / sink-err note_sunk_events).
590    event_count: u64,
591    /// Pre-transform input count passed to `finish_sink_ok_driver` on success.
592    sunk: u64,
593}
594
595/// In-flight ordered sink batch. `drive` is started exactly once at launch.
596struct PendingSink<'s, P> {
597    meta: PendingSinkMeta<P>,
598    drive: Pin<Box<dyn Future<Output = Result<SinkDrive>> + Send + 's>>,
599}
600
601fn try_launch_sink<'s, S, T, P>(
602    sink: &'s S,
603    ctx: &mut ApplyContext<'_, S, T, P>,
604    sinking: &mut Option<PendingSink<'s, P>>,
605) -> Result<()>
606where
607    S: SurrealSink,
608    T: BatchTransformer + 'static,
609    P: Clone + Send + Sync + 'static,
610{
611    if sinking.is_some() {
612        return Ok(());
613    }
614    let Some(batch) = ctx.prepare_ordered_sink() else {
615        return Ok(());
616    };
617    match batch.result {
618        Ok(events) => {
619            // note_sunk_events / finish_sink_ok must use pre-transform input
620            // count so filter/fan-out cannot stall or over-advance drivers.
621            let event_count = batch.event_count;
622            let drive = Box::pin(async move {
623                apply_transformed_sink_events(sink, &events).await?;
624                Ok(SinkDrive::Applied)
625            });
626            *sinking = Some(PendingSink {
627                meta: PendingSinkMeta {
628                    batch_id: batch.batch_id,
629                    last_position: batch.last_position,
630                    event_count,
631                    sunk: event_count,
632                },
633                drive,
634            });
635        }
636        Err(e) => {
637            let drive = Box::pin(async move { Ok(SinkDrive::TransformFailed(e)) });
638            *sinking = Some(PendingSink {
639                meta: PendingSinkMeta {
640                    batch_id: batch.batch_id,
641                    last_position: batch.last_position,
642                    event_count: batch.event_count,
643                    sunk: 0,
644                },
645                drive,
646            });
647        }
648    }
649    Ok(())
650}
651
652enum SinkDrive {
653    Applied,
654    TransformFailed(anyhow::Error),
655}
656
657/// Await the in-flight drive future without taking ownership (select-safe).
658async fn poll_pending_sink<'s, P>(sinking: &mut Option<PendingSink<'s, P>>) -> Result<SinkDrive> {
659    match sinking.as_mut() {
660        Some(pending) => pending.drive.as_mut().await,
661        None => std::future::pending().await,
662    }
663}
664
665async fn complete_pending_sink<D, S, T>(
666    ctx: &mut ApplyContext<'_, S, T, D::Position>,
667    driver: &mut D,
668    sinking: &mut Option<PendingSink<'_, D::Position>>,
669    result: Result<SinkDrive>,
670) -> Result<()>
671where
672    D: SourceDriver,
673    S: SurrealSink,
674    T: BatchTransformer + 'static,
675{
676    let pending = sinking.take().expect("sink slot");
677    match result {
678        Ok(SinkDrive::Applied) => {
679            ctx.finish_sink_ok_driver(driver, pending.meta.last_position, pending.meta.sunk)
680                .await
681        }
682        Ok(SinkDrive::TransformFailed(e)) => {
683            ctx.finish_sink_err_driver(
684                driver,
685                pending.meta.batch_id,
686                pending.meta.last_position,
687                pending.meta.event_count,
688                e,
689            )
690            .await
691        }
692        Err(e) => {
693            ctx.finish_sink_err_driver(
694                driver,
695                pending.meta.batch_id,
696                pending.meta.last_position,
697                pending.meta.event_count,
698                e,
699            )
700            .await
701        }
702    }
703}
704
705async fn finish_pending_sink<D, S, T>(
706    ctx: &mut ApplyContext<'_, S, T, D::Position>,
707    driver: &mut D,
708    sinking: &mut Option<PendingSink<'_, D::Position>>,
709) -> Result<()>
710where
711    D: SourceDriver,
712    S: SurrealSink,
713    T: BatchTransformer + 'static,
714{
715    if sinking.is_none() {
716        return Ok(());
717    }
718    let result = poll_pending_sink(sinking).await;
719    complete_pending_sink(ctx, driver, sinking, result).await
720}
721
722fn effective_stop_reason<D: SourceDriver>(
723    driver: &D,
724    runtime_opts: &SourceRuntimeOpts,
725) -> Option<StopReason> {
726    if runtime_opts.cancelled {
727        return Some(StopReason::Cancelled);
728    }
729    if let Some(deadline) = runtime_opts.deadline {
730        if Instant::now() >= deadline {
731            return Some(StopReason::Deadline);
732        }
733    }
734    driver.stop_reason()
735}
736
737async fn handle_control_signals<D, S, T>(
738    driver: &mut D,
739    ctx: &mut ApplyContext<'_, S, T, D::Position>,
740    sink: &S,
741    transformer: &Arc<T>,
742    apply_opts: &ApplyOpts,
743    signals: Vec<ControlSignal>,
744) -> Result<()>
745where
746    D: SourceDriver,
747    S: SurrealSink,
748    T: BatchTransformer + 'static,
749{
750    // Drain the apply window before schema/ad-hoc side work so CatchUpProgress /
751    // ad-hoc snapshots never observe read-ahead positions past unsunk batches.
752    ctx.flush_for_driver(driver).await?;
753    for signal in signals {
754        match signal {
755            ControlSignal::SchemaRefresh => {
756                debug!("SourceDriver schema refresh");
757                driver
758                    .on_schema_refresh()
759                    .await
760                    .context("on_schema_refresh")?;
761            }
762            ControlSignal::AdHocSnapshot { tables } => {
763                debug!(?tables, "SourceDriver ad-hoc snapshot");
764                let apply = AdhocApplyImpl {
765                    sink,
766                    transformer: Arc::clone(transformer),
767                    apply_opts,
768                };
769                driver
770                    .on_adhoc_snapshot(&tables, &apply)
771                    .await
772                    .context("on_adhoc_snapshot")?;
773            }
774        }
775    }
776    Ok(())
777}