Skip to main content

surreal_sync_runtime/pipeline/interleaved/
runner.rs

1//! The generic DBLog-style watermark snapshot loop.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::Arc;
5
6use crate::pipeline::{
7    run_source_runtime_with, ApplyOpts, CheckpointPolicy, Pipeline, PositionedEvent, SourceDriver,
8    SourceRuntimeOpts,
9};
10use anyhow::Result;
11use async_trait::async_trait;
12use surreal_sync_core::SurrealSink;
13use surreal_sync_core::{Change, Row};
14use surreal_sync_core::{InterleavedSnapshotCheckpoint, SnapshotTableProgress};
15use tracing::info;
16use uuid::Uuid;
17
18use super::checkpointer::SnapshotCheckpointer;
19use super::source::WatermarkSource;
20use super::types::{PkTuple, ReconciliationPos, TableSpec, WatermarkKind};
21
22/// Default chunk size (matches Debezium's incremental snapshot default).
23pub const DEFAULT_CHUNK_SIZE: usize = 1024;
24
25/// Configuration for a watermark snapshot run.
26#[derive(Debug, Clone)]
27pub struct InterleavedSnapshotConfig {
28    /// Maximum number of rows read per chunk (the `LIMIT` of each keyset read).
29    /// This structurally bounds the loop's buffered memory.
30    pub chunk_size: usize,
31}
32
33impl Default for InterleavedSnapshotConfig {
34    fn default() -> Self {
35        Self {
36            chunk_size: DEFAULT_CHUNK_SIZE,
37        }
38    }
39}
40
41/// Transform pipeline + apply options for interleaved snapshot sink writes.
42///
43/// Default is an identity pipeline with `batch_size = 1` (no transform stage
44/// dispatch; matches pre-transform behavior).
45#[derive(Debug, Clone)]
46pub struct SnapshotTransforms {
47    pub pipeline: Pipeline,
48    pub apply_opts: ApplyOpts,
49}
50
51impl Default for SnapshotTransforms {
52    fn default() -> Self {
53        Self::identity()
54    }
55}
56
57impl SnapshotTransforms {
58    /// Empty pipeline, per-row apply cadence.
59    pub fn identity() -> Self {
60        Self {
61            pipeline: Pipeline::new(),
62            apply_opts: ApplyOpts::identity(),
63        }
64    }
65
66    /// Borrowed view used by the runner.
67    pub fn from_refs(pipeline: &Pipeline, apply_opts: &ApplyOpts) -> Self {
68        Self {
69            pipeline: pipeline.clone(),
70            apply_opts: apply_opts.clone(),
71        }
72    }
73}
74
75/// Outcome of a completed watermark snapshot.
76#[derive(Debug, Clone)]
77pub struct InterleavedSnapshotResult<P: ReconciliationPos> {
78    /// The final stream position reached; downstream live processing resumes
79    /// from here.
80    pub final_position: P,
81    /// The peak number of rows held in the chunk buffer at any instant during
82    /// the run, recorded inline at every buffer insertion (an exact maximum,
83    /// not a sample). For full chunks this equals `chunk_size`, independent of
84    /// table size.
85    pub peak_buffered_rows: usize,
86}
87
88/// Per-table copy progress accumulated across chunks.
89struct TableState {
90    name: String,
91    last_pk: Option<PkTuple>,
92    done: bool,
93}
94
95fn build_checkpoint<P: ReconciliationPos>(
96    position: &P,
97    progress: &[TableState],
98) -> Result<InterleavedSnapshotCheckpoint> {
99    let tables = progress
100        .iter()
101        .map(|t| {
102            Ok(SnapshotTableProgress {
103                name: t.name.clone(),
104                last_pk: t.last_pk.as_ref().map(serde_json::to_value).transpose()?,
105                done: t.done,
106            })
107        })
108        .collect::<Result<Vec<_>>>()?;
109    Ok(InterleavedSnapshotCheckpoint {
110        reconciliation_pos: serde_json::to_value(position)?,
111        tables,
112    })
113}
114
115fn table_state_from_progress(t: &SnapshotTableProgress) -> Result<TableState> {
116    let last_pk = match &t.last_pk {
117        None => None,
118        Some(v) => Some(serde_json::from_value(v.clone())?),
119    };
120    Ok(TableState {
121        name: t.name.clone(),
122        last_pk,
123        done: t.done,
124    })
125}
126
127fn init_snapshot_state(
128    all_tables: Vec<TableSpec>,
129    resume: Option<&InterleavedSnapshotCheckpoint>,
130) -> Result<(VecDeque<TableSpec>, HashSet<String>, Vec<TableState>)> {
131    let progress_by_name: HashMap<String, &SnapshotTableProgress> = resume
132        .map(|cp| cp.tables.iter().map(|t| (t.name.clone(), t)).collect())
133        .unwrap_or_default();
134
135    let progress: Vec<TableState> = all_tables
136        .iter()
137        .map(|spec| {
138            if let Some(saved) = progress_by_name.get(&spec.table) {
139                table_state_from_progress(saved)
140            } else {
141                Ok(TableState {
142                    name: spec.table.clone(),
143                    last_pk: None,
144                    done: false,
145                })
146            }
147        })
148        .collect::<Result<Vec<_>>>()?;
149
150    let queue: VecDeque<TableSpec> = all_tables
151        .into_iter()
152        .filter(|spec| {
153            progress_by_name
154                .get(&spec.table)
155                .map(|t| !t.done)
156                .unwrap_or(true)
157        })
158        .collect();
159
160    let seen: HashSet<String> = progress.iter().map(|t| t.name.clone()).collect();
161    Ok((queue, seen, progress))
162}
163
164/// Run a watermark snapshot, copying every table reported by `source` in
165/// primary-key-ordered chunks while concurrently consuming and applying the
166/// source's change stream to `sink`.
167///
168/// Uses an identity transform pipeline. Prefer
169/// [`run_interleaved_snapshot_with_transforms`] when a [`Pipeline`] is configured.
170pub async fn run_interleaved_snapshot<S, K, C>(
171    source: &mut S,
172    sink: &K,
173    config: &InterleavedSnapshotConfig,
174    checkpointer: &mut C,
175) -> Result<InterleavedSnapshotResult<S::Position>>
176where
177    S: WatermarkSource,
178    K: SurrealSink,
179    C: SnapshotCheckpointer,
180{
181    let transforms = SnapshotTransforms::identity();
182    run_interleaved_snapshot_with_resume_and_transforms(
183        source,
184        sink,
185        config,
186        checkpointer,
187        None,
188        &transforms,
189    )
190    .await
191}
192
193/// Like [`run_interleaved_snapshot`], but resumes from a saved per-chunk
194/// checkpoint when `resume` is set. Identity transforms.
195pub async fn run_interleaved_snapshot_with_resume<S, K, C>(
196    source: &mut S,
197    sink: &K,
198    config: &InterleavedSnapshotConfig,
199    checkpointer: &mut C,
200    resume: Option<&InterleavedSnapshotCheckpoint>,
201) -> Result<InterleavedSnapshotResult<S::Position>>
202where
203    S: WatermarkSource,
204    K: SurrealSink,
205    C: SnapshotCheckpointer,
206{
207    let transforms = SnapshotTransforms::identity();
208    run_interleaved_snapshot_with_resume_and_transforms(
209        source,
210        sink,
211        config,
212        checkpointer,
213        resume,
214        &transforms,
215    )
216    .await
217}
218
219/// [`run_interleaved_snapshot`] with an explicit transform pipeline.
220pub async fn run_interleaved_snapshot_with_transforms<S, K, C>(
221    source: &mut S,
222    sink: &K,
223    config: &InterleavedSnapshotConfig,
224    checkpointer: &mut C,
225    transforms: &SnapshotTransforms,
226) -> Result<InterleavedSnapshotResult<S::Position>>
227where
228    S: WatermarkSource,
229    K: SurrealSink,
230    C: SnapshotCheckpointer,
231{
232    run_interleaved_snapshot_with_resume_and_transforms(
233        source,
234        sink,
235        config,
236        checkpointer,
237        None,
238        transforms,
239    )
240    .await
241}
242
243/// Resume-capable watermark snapshot with transform → sink apply.
244///
245/// Checkpoint / `commit_reconciled` still run only after successful sink writes
246/// (transform ack is in-memory only).
247pub async fn run_interleaved_snapshot_with_resume_and_transforms<S, K, C>(
248    source: &mut S,
249    sink: &K,
250    config: &InterleavedSnapshotConfig,
251    checkpointer: &mut C,
252    resume: Option<&InterleavedSnapshotCheckpoint>,
253    transforms: &SnapshotTransforms,
254) -> Result<InterleavedSnapshotResult<S::Position>>
255where
256    S: WatermarkSource,
257    K: SurrealSink,
258    C: SnapshotCheckpointer,
259{
260    let all_tables = source.snapshot_tables().await?;
261    let (mut queue, mut seen, mut progress) = init_snapshot_state(all_tables, resume)?;
262
263    let mut peak_buffered_rows = 0usize;
264
265    loop {
266        // Poll for ad-hoc snapshot signals and enqueue any newly requested
267        // tables, so the stream keeps running while additional tables are
268        // snapshotted with the same watermark-window machinery.
269        for signal in source.read_signals().await? {
270            let specs = source.resolve_tables(&signal.tables).await?;
271            for spec in specs {
272                if seen.insert(spec.table.clone()) {
273                    info!(
274                        "ad-hoc snapshot requested for table '{}' (signal {})",
275                        spec.table, signal.id
276                    );
277                    progress.push(TableState {
278                        name: spec.table.clone(),
279                        last_pk: None,
280                        done: false,
281                    });
282                    queue.push_back(spec);
283                }
284            }
285        }
286
287        let Some(spec) = queue.pop_front() else {
288            break;
289        };
290        let table_index = progress
291            .iter()
292            .position(|t| t.name == spec.table)
293            .expect("progress entry for queued table");
294
295        snapshot_one_table(
296            source,
297            sink,
298            config,
299            checkpointer,
300            &spec,
301            table_index,
302            &mut progress,
303            &mut peak_buffered_rows,
304            transforms,
305        )
306        .await?;
307    }
308
309    let final_position = source.current_position().await?;
310    Ok(InterleavedSnapshotResult {
311        final_position,
312        peak_buffered_rows,
313    })
314}
315
316/// Snapshot a fixed set of tables (for example after an ad-hoc
317/// `execute-snapshot` signal during steady-state streaming) without re-running
318/// the initial [`WatermarkSource::snapshot_tables`] enumeration.
319pub async fn run_adhoc_snapshot_tables<S, K, C>(
320    source: &mut S,
321    sink: &K,
322    tables: Vec<TableSpec>,
323    config: &InterleavedSnapshotConfig,
324    checkpointer: &mut C,
325) -> Result<()>
326where
327    S: WatermarkSource,
328    K: SurrealSink,
329    C: SnapshotCheckpointer,
330{
331    let transforms = SnapshotTransforms::identity();
332    run_adhoc_snapshot_tables_with_transforms(
333        source,
334        sink,
335        tables,
336        config,
337        checkpointer,
338        &transforms,
339    )
340    .await
341}
342
343/// [`run_adhoc_snapshot_tables`] with an explicit transform pipeline.
344pub async fn run_adhoc_snapshot_tables_with_transforms<S, K, C>(
345    source: &mut S,
346    sink: &K,
347    tables: Vec<TableSpec>,
348    config: &InterleavedSnapshotConfig,
349    checkpointer: &mut C,
350    transforms: &SnapshotTransforms,
351) -> Result<()>
352where
353    S: WatermarkSource,
354    K: SurrealSink,
355    C: SnapshotCheckpointer,
356{
357    let mut progress: Vec<TableState> = tables
358        .iter()
359        .map(|t| TableState {
360            name: t.table.clone(),
361            last_pk: None,
362            done: false,
363        })
364        .collect();
365    let mut peak_buffered_rows = 0usize;
366    for (table_index, spec) in tables.iter().enumerate() {
367        snapshot_one_table(
368            source,
369            sink,
370            config,
371            checkpointer,
372            spec,
373            table_index,
374            &mut progress,
375            &mut peak_buffered_rows,
376            transforms,
377        )
378        .await?;
379    }
380    Ok(())
381}
382
383/// Snapshot a single table in primary-key-ordered chunks, applying the same
384/// low/high watermark dedup window per chunk and checkpointing after each one.
385///
386/// One long-lived [`run_source_runtime_with`] spans every chunk so reconciliation
387/// polls continue under spare `max_in_flight` while ordered sink applies run
388/// (R∩W), and surviving buffer rows share that same apply window (M1).
389/// `commit_reconciled` / checkpointer run only after each chunk's events sink.
390#[allow(clippy::too_many_arguments)]
391async fn snapshot_one_table<S, K, C>(
392    source: &mut S,
393    sink: &K,
394    config: &InterleavedSnapshotConfig,
395    checkpointer: &mut C,
396    spec: &TableSpec,
397    table_index: usize,
398    progress: &mut [TableState],
399    peak_buffered_rows: &mut usize,
400    transforms: &SnapshotTransforms,
401) -> Result<()>
402where
403    S: WatermarkSource,
404    K: SurrealSink,
405    C: SnapshotCheckpointer,
406{
407    let after = progress[table_index].last_pk.clone();
408    let mut driver = SnapshotTableDriver {
409        source,
410        checkpointer,
411        spec,
412        table_index,
413        progress,
414        peak_buffered_rows,
415        chunk_size: config.chunk_size,
416        after,
417        phase: SnapshotPhase::NeedChunk,
418        low_id: Uuid::nil(),
419        high_id: Uuid::nil(),
420        in_window: false,
421        buffer: HashMap::new(),
422        last_pk: None,
423        chunk_len: 0,
424        pending: VecDeque::new(),
425        next_pos: 0,
426        events_emitted: 0,
427        events_sunk: 0,
428        chunk_poll_complete: false,
429        finished: false,
430    };
431
432    let transformer = Arc::new(transforms.pipeline.clone());
433    let runtime_opts = SourceRuntimeOpts::new();
434    run_source_runtime_with(
435        &mut driver,
436        sink,
437        transformer,
438        &transforms.apply_opts,
439        &runtime_opts,
440    )
441    .await?;
442    Ok(())
443}
444
445/// Phases of the per-table interleaved snapshot state machine.
446enum SnapshotPhase {
447    /// Open watermarks and read the next keyset chunk.
448    NeedChunk,
449    /// Consume reconciliation events until the high watermark; survivors are
450    /// flushed into the apply queue as soon as high is observed (before any
451    /// later events in the same poll batch).
452    Reconciling,
453    /// Wait until sunk count catches emitted count, then checkpoint.
454    AwaitingChunkSink,
455}
456
457/// Long-lived driver: chunk reads + reconciliation + buffer flush share one
458/// `run_source_runtime` apply window across the whole table.
459struct SnapshotTableDriver<'a, S, C>
460where
461    S: WatermarkSource,
462    C: SnapshotCheckpointer,
463{
464    source: &'a mut S,
465    checkpointer: &'a mut C,
466    spec: &'a TableSpec,
467    table_index: usize,
468    progress: &'a mut [TableState],
469    peak_buffered_rows: &'a mut usize,
470    chunk_size: usize,
471    after: Option<PkTuple>,
472    phase: SnapshotPhase,
473    low_id: Uuid,
474    high_id: Uuid,
475    in_window: bool,
476    buffer: HashMap<String, Row>,
477    last_pk: Option<PkTuple>,
478    chunk_len: usize,
479    pending: VecDeque<PositionedEvent<u64>>,
480    next_pos: u64,
481    events_emitted: u64,
482    events_sunk: u64,
483    chunk_poll_complete: bool,
484    finished: bool,
485}
486
487impl<'a, S, C> SnapshotTableDriver<'a, S, C>
488where
489    S: WatermarkSource,
490    C: SnapshotCheckpointer,
491{
492    fn push_change(&mut self, change: Change) {
493        let pos = self.next_pos;
494        self.next_pos = self.next_pos.saturating_add(1);
495        self.events_emitted = self.events_emitted.saturating_add(1);
496        self.pending.push_back(PositionedEvent::change(change, pos));
497    }
498
499    async fn open_chunk(&mut self) -> Result<()> {
500        let low_id = Uuid::new_v4();
501        self.source
502            .write_watermark(WatermarkKind::Low, low_id)
503            .await?;
504
505        let rows = self
506            .source
507            .read_chunk(self.spec, self.after.as_ref(), self.chunk_size)
508            .await?;
509
510        if rows.is_empty() {
511            self.progress[self.table_index].done = true;
512            self.source
513                .on_table_snapshot_complete(&self.spec.table)
514                .await?;
515            let position = self.source.current_position().await?;
516            self.checkpointer
517                .save_progress(&build_checkpoint(&position, self.progress)?)
518                .await?;
519            self.source.commit_reconciled(position).await?;
520            self.finished = true;
521            return Ok(());
522        }
523
524        self.chunk_len = rows.len();
525        self.buffer = HashMap::with_capacity(self.chunk_len);
526        self.last_pk = None;
527        for row in rows {
528            let pk = PkTuple::from_row(&row, &self.spec.pk_columns)?;
529            self.last_pk = Some(pk.clone());
530            self.buffer.insert(pk.key(), row);
531            if self.buffer.len() > *self.peak_buffered_rows {
532                *self.peak_buffered_rows = self.buffer.len();
533            }
534        }
535
536        let high_id = Uuid::new_v4();
537        self.source
538            .write_watermark(WatermarkKind::High, high_id)
539            .await?;
540
541        self.low_id = low_id;
542        self.high_id = high_id;
543        self.in_window = false;
544        self.events_emitted = 0;
545        self.events_sunk = 0;
546        self.chunk_poll_complete = false;
547        self.phase = SnapshotPhase::Reconciling;
548        Ok(())
549    }
550
551    fn queue_buffer_rows(&mut self) {
552        let rows: Vec<Row> = self.buffer.drain().map(|(_, row)| row).collect();
553        for row in rows {
554            let change = Change::update(row.table, row.id, row.fields);
555            self.push_change(change);
556        }
557        self.chunk_poll_complete = true;
558        self.phase = SnapshotPhase::AwaitingChunkSink;
559    }
560
561    async fn try_finish_chunk_after_sink(&mut self) -> Result<()> {
562        if !self.chunk_poll_complete || self.events_sunk < self.events_emitted {
563            return Ok(());
564        }
565
566        self.after = self.last_pk.clone();
567        self.progress[self.table_index].last_pk = self.last_pk.clone();
568
569        let table_done = self.chunk_len < self.chunk_size;
570        if table_done {
571            self.progress[self.table_index].done = true;
572            self.source
573                .on_table_snapshot_complete(&self.spec.table)
574                .await?;
575        }
576
577        let position = self.source.current_position().await?;
578        self.checkpointer
579            .save_progress(&build_checkpoint(&position, self.progress)?)
580            .await?;
581        self.source.commit_reconciled(position).await?;
582
583        if table_done {
584            self.finished = true;
585        } else {
586            // Next chunk may open while prior-chunk transforms already drained;
587            // the same ApplyContext stays alive across chunks (M1).
588            self.phase = SnapshotPhase::NeedChunk;
589            self.chunk_poll_complete = false;
590        }
591        Ok(())
592    }
593}
594
595#[async_trait]
596impl<S, C> SourceDriver for SnapshotTableDriver<'_, S, C>
597where
598    S: WatermarkSource,
599    C: SnapshotCheckpointer,
600{
601    type Position = u64;
602
603    async fn poll_work(&mut self) -> Result<Vec<PositionedEvent<Self::Position>>> {
604        loop {
605            if self.finished {
606                return Ok(Vec::new());
607            }
608
609            // Drain already-queued events first so the runtime can fill the window.
610            if !self.pending.is_empty() {
611                let mut out = Vec::with_capacity(self.pending.len().min(64));
612                while out.len() < 64 {
613                    match self.pending.pop_front() {
614                        Some(pe) => out.push(pe),
615                        None => break,
616                    }
617                }
618                return Ok(out);
619            }
620
621            match self.phase {
622                SnapshotPhase::NeedChunk => {
623                    self.open_chunk().await?;
624                    if self.finished {
625                        return Ok(Vec::new());
626                    }
627                    // Same poll: start reconciling the chunk we just opened.
628                    continue;
629                }
630                SnapshotPhase::Reconciling => {
631                    let events = self.source.next_reconciliation_events().await?;
632                    let mut saw_high = false;
633                    for event in events {
634                        if let Some(watermark_id) = event.pk.single_uuid() {
635                            if watermark_id == self.low_id {
636                                self.in_window = true;
637                                continue;
638                            }
639                            if watermark_id == self.high_id {
640                                self.in_window = false;
641                                // Flush survivors *before* any post-high events
642                                // that share this poll batch. Otherwise a Delete
643                                // that commits after high would be queued first
644                                // and then overwritten by a stale buffer upsert.
645                                self.queue_buffer_rows();
646                                saw_high = true;
647                                continue;
648                            }
649                        }
650
651                        if self.in_window {
652                            self.buffer.remove(&event.pk.key());
653                        }
654                        self.push_change(event.change);
655                    }
656
657                    if saw_high {
658                        if self.events_emitted == 0 {
659                            // Empty window (no CDC, no survivors): checkpoint now.
660                            self.try_finish_chunk_after_sink().await?;
661                            continue;
662                        }
663                        let mut out = Vec::with_capacity(self.pending.len().min(64));
664                        while out.len() < 64 {
665                            match self.pending.pop_front() {
666                                Some(pe) => out.push(pe),
667                                None => break,
668                            }
669                        }
670                        return Ok(out);
671                    }
672
673                    let mut out = Vec::with_capacity(self.pending.len().min(64));
674                    while out.len() < 64 {
675                        match self.pending.pop_front() {
676                            Some(pe) => out.push(pe),
677                            None => break,
678                        }
679                    }
680                    return Ok(out);
681                }
682                SnapshotPhase::AwaitingChunkSink => {
683                    // Sink-gated: do not open the next chunk until watermark advance catches up.
684                    return Ok(Vec::new());
685                }
686            }
687        }
688    }
689
690    async fn advance_watermark(&mut self, _position: Self::Position) -> Result<()> {
691        // Watermark advance is sink-ordered; chunk retention advances in
692        // note_sunk_events once emitted count is fully sunk (see
693        // try_finish_chunk_after_sink).
694        self.try_finish_chunk_after_sink().await
695    }
696
697    fn is_finished(&self) -> bool {
698        self.finished
699    }
700
701    fn checkpoint_policy(&self) -> CheckpointPolicy {
702        // Durability is the interleaved checkpointer + commit_reconciled, not
703        // persist_checkpoint on this u64 apply index.
704        CheckpointPolicy::AdvanceOnly
705    }
706
707    fn note_sunk_events(&mut self, count: u64) {
708        self.events_sunk = self.events_sunk.saturating_add(count);
709    }
710}