Skip to main content

knut_bifrost/
source.rs

1//! Table sources. `SparkConnectSource` (feature `spark`) polls Iceberg
2//! tables through a Spark Connect 4.x endpoint.
3
4use arrow_array::RecordBatch;
5use async_trait::async_trait;
6
7use crate::{
8    error::Result,
9    mapping::{RowFilter, SyncMode},
10    state::TableState,
11};
12
13/// What the listener asks a source each round.
14///
15/// `columns` + `filter` are the **pushdown** half of the ask: they say what the
16/// round actually needs, so a source that can prune (the Iceberg one) reads only
17/// that. A source that cannot prune ignores them and returns everything — the
18/// batches are then a superset, which every downstream transform already
19/// tolerates (they project what they name).
20#[derive(Debug, Clone, Default)]
21pub struct TableSpec {
22    pub table: String,
23    pub mode: SyncMode,
24    /// The projection: columns the round needs. **Empty = every column**, which
25    /// is the pre-pushdown behaviour. Derived from the mapping's pipeline by
26    /// [`TableMapping::projected_columns`](crate::mapping::TableMapping::projected_columns).
27    pub columns: Vec<String>,
28    /// An optional row predicate to push into the scan planner (partition / data
29    /// file / row-group pruning). `None` = no filter.
30    pub filter: Option<RowFilter>,
31}
32
33impl TableSpec {
34    /// The pre-pushdown ask: this table, this mode, every column, no filter.
35    pub fn new(table: impl Into<String>, mode: SyncMode) -> Self {
36        Self {
37            table: table.into(),
38            mode,
39            columns: Vec::new(),
40            filter: None,
41        }
42    }
43
44    /// Narrow the read to `columns` (empty leaves it unprojected).
45    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
46        self.columns = columns;
47        self
48    }
49
50    /// Push `filter` into the scan planner.
51    pub fn with_filter(mut self, filter: Option<RowFilter>) -> Self {
52        self.filter = filter;
53        self
54    }
55
56    /// `columns` as the `&[&str]` the skade read API takes.
57    pub fn column_refs(&self) -> Vec<&str> {
58        self.columns.iter().map(String::as_str).collect()
59    }
60}
61
62/// A place rows come from.
63#[async_trait]
64pub trait TableSource: Send + Sync {
65    /// Return rows new since `state` and the state to persist once they are
66    /// sunk, or `None` when nothing changed.
67    async fn fetch_new(
68        &self,
69        spec: &TableSpec,
70        state: &TableState,
71    ) -> Result<Option<(Vec<RecordBatch>, TableState)>>;
72
73    /// **Which CDC backend this source resolved to for `table`, and why.**
74    ///
75    /// The listener stamps this onto the table's [`TableState`] after every
76    /// changelog round, so the selection — and any *fallback* — is readable out
77    /// of `state_json` rather than only out of the log. `None` means the source
78    /// has no CDC story at all (the file source); it never means "unknown".
79    ///
80    /// Default: `None`, so an out-of-tree source keeps compiling.
81    fn cdc_capability(&self, table: &str) -> Option<crate::cdc::CdcCapability> {
82        let _ = table;
83        None
84    }
85}
86
87pub use arrow_batches::{ArrowBatches, ArrowSource, EmptyRead};
88
89mod arrow_batches {
90    use super::*;
91    use crate::error::BifrostError;
92
93    /// **A caller-supplied producer of Arrow rows for one table.**
94    ///
95    /// This is the seam that lets *anything that already speaks Arrow* feed bifröst:
96    /// a warehouse client, a Flight stream, an ADBC read, a query result held in
97    /// memory. Implement it, wrap it in an [`ArrowSource`], and the whole mapping →
98    /// transform → graph-sink pipeline runs unchanged.
99    ///
100    /// `spec.columns` is the round's projection ask. Honour it if the producer can
101    /// prune; ignoring it is fine — the batches are then a superset, which every
102    /// downstream transform already tolerates (they project what they name).
103    ///
104    /// [`crate::rel2graph::ArrowTables`] — the in-memory holder the OFFLINE engine
105    /// already reads from — implements this, so one bag of Arrow batches drives both
106    /// the offline `build_mutation` and the live [`crate::Listener`].
107    #[async_trait]
108    pub trait ArrowBatches: Send + Sync {
109        /// Every row of `spec.table`, as Arrow.
110        async fn batches(&self, spec: &TableSpec) -> Result<Vec<RecordBatch>>;
111
112        /// The tables this producer knows about, when it can say. Used ONLY to make
113        /// an empty read's error useful ("you asked for `orders`; I hold `Orders`").
114        /// `None` means "cannot enumerate", which is honest for a live catalog.
115        fn known_tables(&self) -> Option<Vec<String>> {
116            None
117        }
118    }
119
120    /// The in-memory Arrow holder is a live-listener producer too — ONE type, both
121    /// paths. `rel2graph::ArrowTables` was already the crate's answer to "some Arrow
122    /// I already have"; this is what lets it drive a round loop as well as a one-shot
123    /// `build_mutation`.
124    #[async_trait]
125    impl ArrowBatches for crate::rel2graph::ArrowTables {
126        async fn batches(&self, spec: &TableSpec) -> Result<Vec<RecordBatch>> {
127            Ok(crate::rel2graph::TableSource::batches(self, &spec.table).to_vec())
128        }
129
130        fn known_tables(&self) -> Option<Vec<String>> {
131            Some(self.table_names().map(str::to_string).collect())
132        }
133    }
134
135    /// **What an Arrow read of ZERO rows means.**
136    ///
137    /// It is a policy, not a detail, because the two readings are opposite and both
138    /// look identical on the wire:
139    ///
140    /// * "the table really is empty" — a legitimate `Ok`, nothing to build;
141    /// * "I asked for a full build of `orders` and the catalog handed me nothing" —
142    ///   a mis-pointed warehouse, a wrong namespace, an unrefreshed snapshot. Shipping
143    ///   that as a successful round writes an EMPTY GRAPH that looks exactly like a
144    ///   built one, and nobody finds out until someone opens the viewer.
145    ///
146    /// The default is [`EmptyRead::Refuse`], because the second reading is the common
147    /// one and the first is easy to declare.
148    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149    pub enum EmptyRead {
150        /// A zero-row read is a typed [`BifrostError::Source`] naming the table and
151        /// the producer. **The default.**
152        #[default]
153        Refuse,
154        /// A zero-row read is `Ok(None)` — the caller has said this table may
155        /// legitimately be empty.
156        Allow,
157    }
158
159    /// **A [`TableSource`] over Arrow batches a caller already has.**
160    ///
161    /// `SparkConnectSource` needs a Spark Connect endpoint and `IcebergSource` needs
162    /// a local skade warehouse *directory*. Neither can be pointed at a warehouse
163    /// reached some other way — a remote skade over its Iceberg-REST front, an
164    /// Iceberg REST catalog, a Spark whose client lives in the calling process. All
165    /// of those already produce Arrow, and this is the source that takes it.
166    ///
167    /// ```no_run
168    /// # async fn demo() -> knut_bifrost::Result<()> {
169    /// use knut_bifrost::source::ArrowSource;
170    /// # let batches = Vec::new();
171    /// // A one-shot build from batches already in hand.
172    /// let source = ArrowSource::from_batches("my-warehouse", "lake.db.people", batches);
173    /// # let _ = source;
174    /// # Ok(()) }
175    /// ```
176    ///
177    /// ## What it is NOT
178    ///
179    /// **It is a batch read, not change-data-capture.** CDC and delta-watching in
180    /// bifröst (`SparkIcebergCdc`, `SparkNativeCdc`, `watch_iceberg_delta`,
181    /// `IcebergSource`'s incremental path) all rest on a *snapshot lineage* — a
182    /// before-id and an after-id the engine can diff. A bag of Arrow batches has no
183    /// such lineage and no engine behind it to ask, so:
184    ///
185    /// | [`SyncMode`] | what this source does |
186    /// |---|---|
187    /// | `Full` | re-reads the producer every round (idempotent MERGE in the sink makes that safe) |
188    /// | `Incremental` | ships once, then reports nothing — there is no delta to compute |
189    /// | `Cdc` | **refuses**, typed, naming the reason and the sources that can |
190    ///
191    /// [`cdc_capability`](TableSource::cdc_capability) is `None`, exactly as the file
192    /// source's is. A warehouse reached this way feeds bifröst for a **full build**,
193    /// not for streaming.
194    pub struct ArrowSource {
195        /// A short name for the producer behind this source — it appears in every
196        /// error, so an operator reads *which* warehouse came back empty.
197        producer: String,
198        batches: Box<dyn ArrowBatches>,
199        empty: EmptyRead,
200    }
201
202    impl ArrowSource {
203        /// Wrap any [`ArrowBatches`] producer. `producer` names it for diagnostics
204        /// (`"skade-remote"`, `"iceberg-rest"`, `"spark41"`, …).
205        pub fn new(producer: impl Into<String>, batches: impl ArrowBatches + 'static) -> Self {
206            Self {
207                producer: producer.into(),
208                batches: Box::new(batches),
209                empty: EmptyRead::default(),
210            }
211        }
212
213        /// A one-shot source over batches already in hand, for exactly one table.
214        /// Sugar over [`crate::rel2graph::ArrowTables`] — the same holder the offline
215        /// engine reads from, not a second one.
216        pub fn from_batches(
217            producer: impl Into<String>,
218            table: impl Into<String>,
219            batches: Vec<RecordBatch>,
220        ) -> Self {
221            Self::new(
222                producer,
223                crate::rel2graph::ArrowTables::new().with_table(table, batches),
224            )
225        }
226
227        /// Declare that a zero-row read is legitimate for this source. Say it on
228        /// purpose; the default refuses. See [`EmptyRead`].
229        #[must_use]
230        pub fn allowing_empty(mut self) -> Self {
231            self.empty = EmptyRead::Allow;
232            self
233        }
234
235        /// The producer name this source reports in its errors.
236        pub fn producer(&self) -> &str {
237            &self.producer
238        }
239
240        /// What a zero-row read means here.
241        pub fn empty_policy(&self) -> EmptyRead {
242            self.empty
243        }
244    }
245
246    #[async_trait]
247    impl TableSource for ArrowSource {
248        async fn fetch_new(
249            &self,
250            spec: &TableSpec,
251            state: &TableState,
252        ) -> Result<Option<(Vec<RecordBatch>, TableState)>> {
253            // CDC is refused BEFORE the read: a source with no snapshot lineage must
254            // not answer a changelog ask with an insert-only batch that the listener
255            // would then treat as the whole truth.
256            if matches!(spec.mode, SyncMode::Cdc) {
257                return Err(BifrostError::Source(format!(
258                    "table '{}': an Arrow batch source ({}) has no change feed — it is a \
259                     BATCH read with no snapshot lineage to diff. Use SyncMode::Full for a \
260                     complete rebuild, or a source with an engine behind it \
261                     (SparkIcebergCdc, SparkNativeCdc, or IcebergSource over a local skade \
262                     warehouse) for CDC.",
263                    spec.table, self.producer
264                )));
265            }
266
267            // Incremental over a batch source ships exactly once: there is no delta.
268            if matches!(spec.mode, SyncMode::Incremental) && state.synced_at.is_some() {
269                return Ok(None);
270            }
271
272            let batches = self.batches.batches(spec).await?;
273            let rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
274
275            if rows == 0 {
276                // **The gate.** An empty read is not a built graph.
277                return match self.empty {
278                    EmptyRead::Refuse => {
279                        let holds = match self.batches.known_tables() {
280                            Some(t) if !t.is_empty() => format!(" It holds {t:?}."),
281                            Some(_) => " It holds no tables at all.".to_string(),
282                            None => String::new(),
283                        };
284                        Err(BifrostError::Source(format!(
285                            "table '{}': the Arrow producer ({}) returned {} batch(es) and 0 \
286                             rows.{holds} An empty read is refused here rather than sunk as an \
287                             empty graph that looks like a successful build — check the \
288                             catalog, the namespace and the snapshot. If this table really is \
289                             empty, say so with ArrowSource::allowing_empty().",
290                            spec.table,
291                            self.producer,
292                            batches.len()
293                        )))
294                    }
295                    EmptyRead::Allow => Ok(None),
296                };
297            }
298
299            // The state is handed back unchanged: a batch producer has no snapshot
300            // id, no watermark and no file ledger to record. `SyncState::advance`
301            // stamps `synced_at` after the sink succeeds, and THAT is what the
302            // Incremental "ship once" check above reads — so the marker is written by
303            // the round that actually landed, not by the read that merely started.
304            Ok(Some((batches, state.clone())))
305        }
306
307        /// No CDC story at all — the same honest `None` the file source gives.
308        fn cdc_capability(&self, _table: &str) -> Option<crate::cdc::CdcCapability> {
309            None
310        }
311    }
312}
313
314pub use file::FileSource;
315
316mod file {
317    use std::path::{Path, PathBuf};
318
319    use graphar::FileType;
320
321    use super::*;
322    use crate::error::BifrostError;
323
324    /// A [`TableSource`] over a watched directory tree of GraphAr/columnar
325    /// file drops — the local half of the Airflow AIP-82 story.
326    ///
327    /// Each mapped table maps to a subdirectory `root/<table>/`; every file
328    /// in it with a recognised extension (`parquet`, `orc`, `csv`, `json`,
329    /// `arrow`) is a chunk. The set of already-ingested file names lives in
330    /// the table's [`TableState::done_files`] ledger, so a new file drop
331    /// triggers exactly one sync round and nothing is re-read.
332    ///
333    /// ```no_run
334    /// use knut_bifrost::source::FileSource;
335    /// let source = FileSource::new("/data/drops");
336    /// ```
337    ///
338    /// [`TableState::done_files`]: crate::state::TableState::done_files
339    pub struct FileSource {
340        root: PathBuf,
341    }
342
343    impl FileSource {
344        /// Watch `root`; table `lake.foo.bar` reads from `root/lake.foo.bar/`.
345        pub fn new(root: impl Into<PathBuf>) -> Self {
346            Self { root: root.into() }
347        }
348
349        fn table_dir(&self, table: &str) -> PathBuf {
350            self.root.join(table)
351        }
352
353        /// All readable chunk files in `dir`, sorted by name for determinism.
354        fn list_files(dir: &Path) -> Result<Vec<String>> {
355            let read = match std::fs::read_dir(dir) {
356                Ok(r) => r,
357                // A table whose drop directory does not exist yet is simply
358                // empty — not an error (it may appear on a later round).
359                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
360                Err(e) => return Err(e.into()),
361            };
362            let mut names = Vec::new();
363            for entry in read {
364                let entry = entry?;
365                if !entry.file_type()?.is_file() {
366                    continue;
367                }
368                let name = entry.file_name().to_string_lossy().into_owned();
369                if file_type_of(&name).is_some() {
370                    names.push(name);
371                }
372            }
373            names.sort();
374            Ok(names)
375        }
376    }
377
378    /// Recognise a chunk file by extension; `None` means "ignore this file".
379    fn file_type_of(name: &str) -> Option<FileType> {
380        match name.rsplit_once('.').map(|(_, ext)| ext) {
381            Some("parquet") => Some(FileType::Parquet),
382            Some("orc") => Some(FileType::Orc),
383            Some("csv") => Some(FileType::Csv),
384            Some("json") => Some(FileType::Json),
385            Some("arrow") => Some(FileType::ArrowIpc),
386            _ => None,
387        }
388    }
389
390    #[async_trait]
391    impl TableSource for FileSource {
392        async fn fetch_new(
393            &self,
394            spec: &TableSpec,
395            state: &TableState,
396        ) -> Result<Option<(Vec<RecordBatch>, TableState)>> {
397            let dir = self.table_dir(&spec.table);
398            let present = Self::list_files(&dir)?;
399
400            // Full mode re-reads every file each round (idempotent MERGE);
401            // incremental ships only files absent from the ledger.
402            let to_read: Vec<&String> = match spec.mode {
403                SyncMode::Full => present.iter().collect(),
404                // File drops have no changelog view, so CDC degrades to the
405                // append-only file ledger (new files only) — same as
406                // Incremental. (Per-row CDC would need a `_change_type` column
407                // inside the dropped files, which the Iceberg source supplies.)
408                SyncMode::Incremental | SyncMode::Cdc => present
409                    .iter()
410                    .filter(|f| !state.done_files.contains(f))
411                    .collect(),
412            };
413            if to_read.is_empty() {
414                tracing::debug!(
415                    target: "bifrost::file",
416                    table = %spec.table,
417                    present = present.len(),
418                    "no new files",
419                );
420                return Ok(None);
421            }
422
423            let mut batches = Vec::new();
424            let mut rows = 0usize;
425            for name in &to_read {
426                let ft = file_type_of(name).expect("filtered to known types");
427                let chunk = graphar::io::read_chunk(dir.join(name), &ft)
428                    .map_err(|e| BifrostError::Source(format!("{name}: {e}")))?;
429                let n: usize = chunk.iter().map(|b| b.num_rows()).sum();
430                rows += n;
431                // One event per detected-and-read file — the "file detected"
432                // signal a test/bencher asserts on without a human.
433                tracing::info!(
434                    target: "bifrost::file",
435                    event = "file_detected",
436                    table = %spec.table,
437                    file = %name,
438                    rows = n,
439                    "detected new file",
440                );
441                batches.extend(chunk);
442            }
443            tracing::info!(
444                target: "bifrost::file",
445                event = "files_read",
446                table = %spec.table,
447                files = to_read.len(),
448                rows,
449                "read new files",
450            );
451
452            let new_state = TableState {
453                done_files: present, // everything now present is done
454                ..state.clone()
455            };
456            Ok(Some((batches, new_state)))
457        }
458    }
459}
460
461#[cfg(feature = "spark")]
462pub use spark::SparkConnectSource;
463
464#[cfg(feature = "spark")]
465mod spark {
466    use super::*;
467    use arrow_array::cast::AsArray;
468    use arrow_array::types::Int64Type;
469    use spark_connect::{SparkSession, SparkSessionBuilder};
470
471    use crate::error::BifrostError;
472
473    /// Make a table name safe to embed in a temp-view identifier: keep
474    /// alphanumerics, map everything else (`.`, `-`, …) to `_`.
475    fn sanitise_view(table: &str) -> String {
476        table
477            .chars()
478            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
479            .collect()
480    }
481
482    /// The projected + filtered `SELECT` over a materialised Iceberg changelog
483    /// view — the CDC path's half of the pushdown that landed for `Full`.
484    ///
485    /// An empty projection **and** no filter reproduces the old `SELECT * FROM
486    /// view` byte for byte, so the narrowing can only ever apply where it was
487    /// asked for. When the spec does project, the changelog metadata columns
488    /// ride along regardless (`_change_type` is load-bearing for delete
489    /// routing; `_change_ordinal` / `_commit_snapshot_id` carry the order and
490    /// the snapshot a renderable feed needs), as does the filter's own column.
491    ///
492    /// Built on the shared [`crate::cdc`] vocabulary rather than a second copy
493    /// of the SQL: this is the same statement `cdc::SparkIcebergCdc` issues.
494    fn changelog_select(spec: &TableSpec, view: &str) -> Result<String> {
495        crate::cdc::SparkIcebergCdc::select_statement(&change_request(spec), view)
496    }
497
498    /// The [`crate::cdc::ChangeRequest`] a [`TableSpec`] describes — one place
499    /// the two pushdown vocabularies meet, so the plain-SQL path and the CDC
500    /// seam can never drift apart on what "projected" means.
501    fn change_request(spec: &TableSpec) -> crate::cdc::ChangeRequest {
502        crate::cdc::ChangeRequest::new(spec.table.clone(), None)
503            .with_columns(spec.columns.clone())
504            .with_filter(spec.filter.clone())
505    }
506
507    /// `spec.columns` as a Spark select-list, or `*` when unprojected.
508    fn select_list(spec: &TableSpec) -> String {
509        if spec.columns.is_empty() {
510            return "*".into();
511        }
512        let mut cols = spec.columns.clone();
513        // The predicate is evaluated by Spark against this same relation, so its
514        // column must survive the projection.
515        if let Some(f) = &spec.filter {
516            if !cols.iter().any(|c| c == &f.column) {
517                cols.push(f.column.clone());
518            }
519        }
520        cols.iter()
521            .map(|c| format!("`{}`", c.replace('`', "``")))
522            .collect::<Vec<_>>()
523            .join(", ")
524    }
525
526    /// `spec.filter` as a ` WHERE …` fragment (empty when there is none).
527    fn where_clause(spec: &TableSpec) -> Result<String> {
528        crate::cdc::spark::where_clause_for(&change_request(spec))
529    }
530
531    /// Polls Iceberg tables via Spark Connect (`sc://host:15002`).
532    ///
533    /// Snapshot detection queries the table's `.snapshots` metadata table;
534    /// incremental mode reads only the delta between two snapshots.
535    pub struct SparkConnectSource {
536        session: std::sync::Arc<SparkSession>,
537        /// Per-table CDC verdict, resolved ONCE on the first changelog round
538        /// (detection costs a version request plus a zero-row probe, so it must
539        /// not run every poll) and reused after that. Recorded into
540        /// [`TableState::cdc`] so `state_json` shows which backend ran.
541        cdc: std::sync::Mutex<std::collections::HashMap<String, crate::cdc::CdcCapability>>,
542    }
543
544    impl SparkConnectSource {
545        pub async fn connect(url: &str) -> Result<Self> {
546            let session = SparkSessionBuilder::new(url)
547                .build()
548                .await
549                .map_err(|e| BifrostError::Source(e.to_string()))?;
550            Ok(Self {
551                session: std::sync::Arc::new(session),
552                cdc: std::sync::Mutex::new(std::collections::HashMap::new()),
553            })
554        }
555
556        /// **The CDC verdict for `table`** — detected on first use, cached after.
557        ///
558        /// [`crate::cdc::detect`] asks the server its version and then *probes*
559        /// the table with a zero-row `CHANGES` read, because a 4.2 engine does
560        /// not imply a 4.2-capable connector. The verdict is logged once (with
561        /// `fell_back` set when the native path was expected but unusable) and
562        /// then reused, so a poll loop pays for detection exactly once.
563        pub async fn cdc_backend(
564            &self,
565            table: &str,
566        ) -> (
567            std::sync::Arc<dyn crate::cdc::CdcBackend>,
568            crate::cdc::CdcCapability,
569        ) {
570            if let Some(cap) = self.cdc.lock().ok().and_then(|m| m.get(table).cloned()) {
571                let backend: std::sync::Arc<dyn crate::cdc::CdcBackend> = match cap.chosen {
572                    crate::cdc::CdcKind::SparkNative => std::sync::Arc::new(
573                        crate::cdc::SparkNativeCdc::new(self.session.clone(), cap.clone()),
574                    ),
575                    _ => std::sync::Arc::new(crate::cdc::SparkIcebergCdc::new(
576                        self.session.clone(),
577                        cap.clone(),
578                    )),
579                };
580                return (backend, cap);
581            }
582            let (backend, cap) = crate::cdc::backend_for(self.session.clone(), table).await;
583            if let Ok(mut m) = self.cdc.lock() {
584                m.insert(table.to_string(), cap.clone());
585            }
586            (backend, cap)
587        }
588
589        /// **The renderable change feed** a UI consumes: `(op, before, after,
590        /// snapshot, ts)` batches over a [`crate::cdc::ChangeStream`], served by
591        /// whichever backend detection chose. This is the UI-facing twin of the
592        /// listener's wide-batch [`Self::read_changelog`] path.
593        pub async fn changes(
594            &self,
595            req: &crate::cdc::ChangeRequest,
596        ) -> Result<crate::cdc::ChangeStream> {
597            let (backend, _) = self.cdc_backend(&req.table).await;
598            backend.changes(req).await
599        }
600
601        /// Run arbitrary SQL on the Spark Connect session (DDL, admin,
602        /// test fixtures). Returns the result batches directly — the
603        /// `spark-connect-arrow59` fork shares the workspace arrow major, so
604        /// `collect()` already yields workspace `RecordBatch`es (no bridge).
605        pub async fn sql(&self, query: &str) -> Result<Vec<RecordBatch>> {
606            let plan = self
607                .session
608                .sql(query, vec![])
609                .await
610                .map_err(|e| BifrostError::Source(format!("{query}: {e}")))?;
611            self.session
612                .collect(plan)
613                .await
614                .map_err(|e| BifrostError::Source(format!("{query}: {e}")))
615        }
616
617        /// Read Iceberg's changelog between two snapshots via the
618        /// `system.create_changelog_view` procedure.
619        ///
620        /// The procedure materialises a temp view whose rows carry a
621        /// `_change_type` column (`INSERT` / `UPDATE_AFTER` / `UPDATE_BEFORE` /
622        /// `DELETE`); we then `SELECT *` from it. `net-changes` collapses an
623        /// update into a single `INSERT`/`DELETE` pair and drops rows that an
624        /// in-window insert+delete cancels out — exactly the upsert/delete
625        /// split the graph wants.
626        ///
627        /// `prev` is the *start* snapshot (already applied, so the changelog is
628        /// open-ended after it) and `latest` the end snapshot.
629        ///
630        /// **Projected and filtered.** `spec.columns` / `spec.filter` are pushed
631        /// into the `SELECT` over the materialised view instead of the old
632        /// `SELECT *` — closing the gap where `SyncMode::Cdc` stayed full-width
633        /// while `SyncMode::Full` had gained pushdown. `_change_type` is always
634        /// retained: without it the listener cannot route a delete. An
635        /// unprojected, unfiltered spec still produces exactly `SELECT *`.
636        async fn read_changelog(
637            &self,
638            spec: &TableSpec,
639            prev: i64,
640            latest: i64,
641        ) -> Result<Vec<RecordBatch>> {
642            let table = &spec.table;
643            // WHICH CDC. Detected from the live server (version + a real probe),
644            // not from config — and cached, so this costs one round trip ever.
645            let (_, cap) = self.cdc_backend(table).await;
646            let req = crate::cdc::ChangeRequest::new(table.clone(), Some(prev))
647                .to(Some(latest))
648                .with_columns(spec.columns.clone())
649                .with_filter(spec.filter.clone());
650
651            let batches = if cap.chosen == crate::cdc::CdcKind::SparkNative {
652                // Spark 4.2's own CDC: one statement, no temp view. The rows come
653                // back with Spark's lower-case `_change_type` spelling, which the
654                // listener's router understands because both dialects go through
655                // `cdc::ChangeOp::parse`.
656                self.sql(&crate::cdc::SparkNativeCdc::statement(&req)?)
657                    .await?
658            } else {
659                // Iceberg's CDC: materialise the changelog view, then read it
660                // PROJECTED + FILTERED (it used to be `SELECT *`).
661                let view = format!("bifrost_changes_{}", sanitise_view(table));
662                self.sql(&format!(
663                    "CALL system.create_changelog_view(\
664                       table => '{table}', \
665                       options => map('start-snapshot-id','{prev}','end-snapshot-id','{latest}'), \
666                       net_changes => true, \
667                       changelog_view => '{view}')"
668                ))
669                .await?;
670                let out = self.sql(&changelog_select(spec, &view)?).await?;
671                // Best-effort cleanup; a leftover temp view is harmless and gets
672                // replaced next round, so a drop failure must not fail the round.
673                let _ = self.sql(&format!("DROP VIEW IF EXISTS {view}")).await;
674                out
675            };
676
677            let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
678            tracing::info!(
679                target: "bifrost::spark",
680                event = "changelog_read",
681                table = %table,
682                backend = cap.chosen.as_str(),
683                fell_back = cap.fell_back,
684                from = prev,
685                to = latest,
686                projected = spec.columns.len(),
687                filtered = spec.filter.is_some(),
688                change_rows = rows,
689                "read changelog (pushdown)",
690            );
691            Ok(batches)
692        }
693
694        async fn latest_snapshot(&self, table: &str) -> Result<Option<i64>> {
695            // Spark caches Iceberg table metadata per session; without a
696            // refresh, commits from other writers stay invisible to a
697            // long-lived polling session.
698            self.sql(&format!("REFRESH TABLE {table}")).await?;
699            let batches = self
700                .sql(&format!(
701                    "SELECT snapshot_id FROM {table}.snapshots \
702                     ORDER BY committed_at DESC LIMIT 1"
703                ))
704                .await?;
705            for batch in &batches {
706                if batch.num_rows() > 0 {
707                    let col = batch.column(0).as_primitive::<Int64Type>();
708                    return Ok(Some(col.value(0)));
709                }
710            }
711            Ok(None)
712        }
713    }
714
715    #[async_trait]
716    impl TableSource for SparkConnectSource {
717        /// The cached verdict for `table` (`None` until the first changelog
718        /// round has run detection — a `Full`-only table never probes).
719        fn cdc_capability(&self, table: &str) -> Option<crate::cdc::CdcCapability> {
720            self.cdc.lock().ok().and_then(|m| m.get(table).cloned())
721        }
722
723        async fn fetch_new(
724            &self,
725            spec: &TableSpec,
726            state: &TableState,
727        ) -> Result<Option<(Vec<RecordBatch>, TableState)>> {
728            let latest = self.latest_snapshot(&spec.table).await?;
729            let Some(latest) = latest else {
730                return Ok(None); // table has no snapshots yet
731            };
732            if state.snapshot_id == Some(latest) {
733                return Ok(None); // nothing new
734            }
735
736            // CDC reads the changelog *view* (a separate SQL call that creates
737            // a temp view), so it can't share the single-`SELECT` path below.
738            // (First CDC round has no prior snapshot to diff against — it falls
739            // through to the whole-table seed load tagged INSERT, below.)
740            if let (SyncMode::Cdc, Some(prev)) = (spec.mode, state.snapshot_id) {
741                let batches = self.read_changelog(spec, prev, latest).await?;
742                let new_state = TableState {
743                    snapshot_id: Some(latest),
744                    ..state.clone()
745                };
746                return Ok(Some((batches, new_state)));
747            }
748
749            // The projection, as a SQL select-list. Empty projection → `*`, so an
750            // unprojected mapping issues exactly the query it always did. The
751            // `EXCEPT ALL` incremental diff **must** use the same list on both
752            // sides or the set difference compares different shapes — building it
753            // once here is what guarantees that.
754            let cols = select_list(spec);
755            let query = match (spec.mode, state.snapshot_id) {
756                // First sync, full mode, or first CDC round: whole table at the
757                // new snapshot. For CDC, tag every row INSERT so the listener's
758                // partitioning treats the seed load as upserts.
759                (SyncMode::Cdc, None) => format!(
760                    "SELECT {cols}, 'INSERT' AS {ct} FROM {t} VERSION AS OF {latest}{w}",
761                    ct = SyncMode::CHANGE_TYPE_COLUMN,
762                    t = spec.table,
763                    w = where_clause(spec)?,
764                ),
765                (SyncMode::Full, _) | (SyncMode::Incremental, None) => format!(
766                    "SELECT {cols} FROM {t} VERSION AS OF {latest}{w}",
767                    t = spec.table,
768                    w = where_clause(spec)?,
769                ),
770                // Appended rows only. EXCEPT ALL is portable SQL; CDC mode above
771                // sees deletes/updates via Iceberg's changelog view instead.
772                (SyncMode::Incremental, Some(prev)) => format!(
773                    "SELECT {cols} FROM {t} VERSION AS OF {latest}{w} \
774                     EXCEPT ALL \
775                     SELECT {cols} FROM {t} VERSION AS OF {prev}{w}",
776                    t = spec.table,
777                    w = where_clause(spec)?,
778                ),
779                // Unreachable: CDC with a prior snapshot returned above.
780                (SyncMode::Cdc, Some(_)) => unreachable!("CDC delta handled above"),
781            };
782            let batches = self.sql(&query).await?;
783
784            let new_state = TableState {
785                snapshot_id: Some(latest),
786                ..state.clone()
787            };
788            Ok(Some((batches, new_state)))
789        }
790    }
791
792    #[cfg(test)]
793    mod tests {
794        use super::*;
795
796        /// RED-WHEN-BROKEN version guard (container-free): the vendored
797        /// `spark-connect` (the `spark-connect-arrow59` fork) must yield knut's
798        /// **workspace** `arrow_array::RecordBatch` from `SparkSession::collect`,
799        /// so `SparkConnectSource` hands Spark batches straight to the sink with
800        /// no IPC / arrow-version bridge. If the fork ever regresses to an older
801        /// arrow major, `collect`'s payload becomes a *foreign* `RecordBatch`
802        /// type and this guard fails to compile — the whole no-bridge spark path
803        /// breaks with it. Never called; it only has to type-check.
804        #[allow(dead_code)]
805        fn _spark_collect_is_workspace_arrow59() -> impl std::future::Future<Output = ()> {
806            async {
807                // A generic never-returning helper rather than `unreachable!()` in
808                // each initializer: `unreachable!()` diverges, so everything after
809                // the first binding was unreachable code and `session` counted as
810                // unused. The guard type-checked anyway (unreachable code still
811                // is), but it warned on every build, and a type guard nobody can
812                // build cleanly is one people start ignoring. The bindings below
813                // are what actually pins the types; this only produces them.
814                fn nowhere<T>() -> T {
815                    unreachable!("compile-only type guard — never called")
816                }
817                let session: SparkSession = nowhere();
818                let plan: spark_connect::spark::Plan = nowhere();
819                // The binding pins the element type to the workspace's arrow-59
820                // `RecordBatch`; only compiles while spark-connect shares it.
821                let _batches: Vec<arrow_array::RecordBatch> = session.collect(plan).await.unwrap();
822            }
823        }
824
825        /// Runtime mirror of the compile guard: the `RecordBatch` type
826        /// `spark-connect` names (`arrow::record_batch::RecordBatch`) is the same
827        /// type as knut's `arrow_array::RecordBatch` at the shared arrow-59 major.
828        #[test]
829        fn spark_connect_shares_workspace_arrow59() {
830            use std::any::TypeId;
831            let ok = TypeId::of::<arrow::record_batch::RecordBatch>()
832                == TypeId::of::<arrow_array::RecordBatch>();
833            assert!(
834                ok,
835                "spark-connect's arrow RecordBatch must equal knut's workspace \
836                 arrow_array::RecordBatch (both arrow 59) — no bridge on the spark path",
837            );
838            crate::testmatrix::emit(
839                crate::testmatrix::COMPONENT,
840                "source::spark_connect_shares_workspace_arrow59",
841                ok,
842                "spark-connect (arrow59 fork) yields the workspace arrow-59 RecordBatch, no bridge",
843            );
844        }
845    }
846}
847
848#[cfg(feature = "skade")]
849pub use iceberg::IcebergSource;
850/// The delta plan an [`IcebergSource`] returns IS skade's: bifrost re-exports the
851/// canonical [`skade::DeltaPlan`] rather than forking its own copy, so the shared
852/// CDC changelog vocabulary never diverges.
853#[cfg(feature = "skade")]
854pub use skade::DeltaPlan;
855
856/// Iceberg source via `skade` (feature `skade`) — read an Iceberg table
857/// directly from an embedded warehouse, no Spark.
858#[cfg(feature = "skade")]
859mod iceberg {
860    use std::path::PathBuf;
861
862    use super::*;
863    use crate::delivery::IcebergLake;
864    use crate::error::BifrostError;
865    use skade::iceberg::table::Table as IceTable;
866    // skade's canonical CDC changelog surface — the vocabulary bifrost used to
867    // keep a private copy of. `Table::read_changelog` builds the `ChangelogBatch`
868    // (INSERT/DELETE tagging, delete-identity widening, full-reload fallback);
869    // `change_type::COLUMN` is the `_change_type` sentinel; `DeltaPlan` is the
870    // engine-side snapshot diff bifrost re-exports.
871    use skade::{ChangelogBatch, DeltaPlan, change_type};
872
873    /// A [`TableSource`] over an Iceberg table read through `skade`'s embedded
874    /// catalog. Each poll compares the table's current snapshot id to the
875    /// recorded one; on change it ships rows to the listener. The behaviour
876    /// depends on the [`SyncMode`] the listener asks for:
877    ///
878    /// * [`SyncMode::Full`] — re-read the **whole** table at the new snapshot
879    ///   (idempotent MERGE in the sink makes re-reads safe). Robust to any
880    ///   commit kind (overwrite, delete, compaction) at the cost of re-scanning
881    ///   every row each change.
882    /// * [`SyncMode::Incremental`] / [`SyncMode::Cdc`] — read **only the files
883    ///   added since the recorded snapshot** (a manifest-level delta over the
884    ///   snapshot lineage; see [`Self::ingest_delta`]). This is the
885    ///   streaming/delta path: a continuous poll injects just the changes. It is
886    ///   **CDC-aware**: appended data files become inserts, and a `Delete`
887    ///   snapshot's **equality-delete** files become `_change_type = DELETE`
888    ///   rows that remove the matching graph nodes (pure Rust, no Spark — skade
889    ///   writes the deletes and surfaces them, knut tags + routes them through
890    ///   the listener's changelog split). A position-delete or overwrite/replace
891    ///   commit falls back to a full re-read so the graph never goes stale.
892    ///
893    /// For append-only tables this replaces `SparkConnectSource` — no Spark
894    /// cluster needed, no JVM, pure Rust.
895    ///
896    /// skade and knut both build on Apache Arrow 58 (one workspace major), so
897    /// `skade::arrow_array::RecordBatch` *is* the workspace `RecordBatch`: the
898    /// read batches hand straight through, no version bridge (see
899    /// [`from_skade_batches`], now an identity clone).
900    ///
901    /// **Where the tables are** is an [`IcebergLake`]: an embedded warehouse
902    /// ([`Self::new`]), or — [`Self::from_lake`] — Iceberg tables on S3/MinIO read
903    /// with no catalog, or an Iceberg REST catalog such as MinIO AIStor's. Every
904    /// read below works on the loaded `iceberg` table, so all three stream alike.
905    pub struct IcebergSource {
906        lake: IcebergLake,
907    }
908
909    impl IcebergSource {
910        /// Read tables from the embedded Iceberg warehouse rooted at `dir`
911        /// (`<dir>/catalog.redb` + `warehouse/`). A `TableSpec.table` of
912        /// `name` resolves to the Iceberg table `name` in the default namespace.
913        pub fn new(dir: impl Into<PathBuf>) -> Self {
914            Self::from_lake(IcebergLake::Warehouse(dir.into()))
915        }
916
917        /// Read tables from any [`IcebergLake`]; a `TableSpec.table` is resolved
918        /// by [`IcebergLake::locate`].
919        pub fn from_lake(lake: IcebergLake) -> Self {
920            Self { lake }
921        }
922
923        /// Load the table at its current metadata. Loaded again on every call,
924        /// so each poll sees the lake's latest commit; an embedded warehouse is
925        /// opened and released inside the call (redb is single-open).
926        async fn open_table(&self, table: &str) -> Result<IceTable> {
927            self.lake.locate(table).load().await
928        }
929
930        /// **The delta primitive.** Read the changes to `table` between the
931        /// snapshots strictly after `from_snapshot` up to (and including)
932        /// `to_snapshot` into Arrow-58 batches — only the files those snapshots
933        /// added, never the whole table.
934        ///
935        /// One-shot, pure Rust, no Spark. The manifest-level snapshot diff is
936        /// built by [`skade::Table::read_delta`] (iceberg-rust 0.9.1 has no
937        /// first-class incremental scan): it walks the snapshot lineage and
938        /// classifies each added file — appended **data** files are inserts,
939        /// **equality-delete** files are CDC deletes.
940        ///
941        /// `from_snapshot = None` means "from the beginning" → every data file
942        /// up to `to` (equivalent to a full read, but file-by-file).
943        ///
944        /// Returns the read batches **and** the [`DeltaPlan`] so a caller can
945        /// assert on the plan (snapshots, added/delete files) without a human.
946        /// When the window has identity deletes the batches carry a
947        /// `_change_type` column (`INSERT`/`DELETE`) for the listener's changelog
948        /// routing; a pure-append window returns untagged batches. A position-
949        /// delete or overwrite/replace commit sets `needs_full_reload` and the
950        /// batches are a **full** re-read of `to`.
951        pub async fn ingest_delta(
952            &self,
953            table_name: &str,
954            from_snapshot: Option<i64>,
955            to_snapshot: i64,
956        ) -> Result<(Vec<RecordBatch>, DeltaPlan)> {
957            // Opens the warehouse once (redb is single-open); the delta runs
958            // against this same handle so we never reopen it mid-call.
959            let table = self.open_table(table_name).await?;
960            let spec = TableSpec::new(table_name, SyncMode::Cdc);
961            Self::delta_from_table(&table, &spec, from_snapshot, to_snapshot).await
962        }
963
964        /// The delta over an already-loaded iceberg table — the shared core of
965        /// [`Self::ingest_delta`] (which opens its own handle) and the
966        /// [`TableSource::fetch_new`] Incremental path (which reuses the handle
967        /// it already opened; redb is single-open, so reopening would deadlock).
968        ///
969        /// **Projected.** `spec.columns` narrows the changelog through skade's own
970        /// [`skade::ChangelogBatch::project_columns`] — the pushdown
971        /// `SyncMode::Incremental` / `SyncMode::Cdc` never had, because
972        /// `read_changelog` takes no projection and knut called it unprojected.
973        /// An empty projection reproduces the previous behaviour exactly.
974        ///
975        /// **`_change_type` survives the projection**, which is what makes this
976        /// safe for `Cdc`: `ChangelogBatch::project_columns` re-adds the sentinel
977        /// itself, so the listener's `partition_change_rows` still sees which
978        /// rows are deletes. (`TableMapping::projected_columns` does not name it —
979        /// skade does.) `tests/cdc_roundtrip.rs::source_cdc_mode_is_projected_and_agrees_with_the_full_width_read`
980        /// asserts the `_change_type` column is byte-identical projected vs full.
981        ///
982        /// Accounting, stated rather than implied: `project_columns` is a
983        /// **post-decode** slice (skade's `read_delta` decodes whole parquet
984        /// files). The win is in the encode, the transforms, the sink and the
985        /// memory — *not* in parquet I/O. Pushing the projection into the
986        /// manifest-delta scan is skade-side work. The predicate is applied by
987        /// the caller (`fetch_new`), which owns the null policy.
988        async fn delta_from_table(
989            table: &IceTable,
990            spec: &TableSpec,
991            from_snapshot: Option<i64>,
992            to_snapshot: i64,
993        ) -> Result<(Vec<RecordBatch>, DeltaPlan)> {
994            let table_name = &spec.table;
995            // skade's changelog encoder is the single source of truth for the
996            // snapshot diff AND the CDC re-tagging: `read_changelog` walks the
997            // manifest lineage (`read_delta`), tags appended rows INSERT and
998            // equality-deleted identities DELETE (widening them to the table
999            // schema), and on an overwrite/position-delete window re-reads the
1000            // whole `to` snapshot as an INSERT stream. knut no longer keeps a
1001            // private copy of any of that.
1002            let cl: ChangelogBatch = skade::read_changelog(table, from_snapshot, to_snapshot)
1003                .await
1004                .map_err(|e| BifrostError::Source(format!("{table_name}: {e}")))?;
1005            let plan = cl.plan.clone();
1006
1007            // THE PROJECTION. skade keeps `_change_type` for us (its
1008            // `project_columns` re-adds the sentinel), so the listener's delete
1009            // routing survives the narrowing. The filter's column is carried in
1010            // by `engine_columns` so the row prune below can still see it.
1011            let req = crate::cdc::ChangeRequest::new(table_name.clone(), from_snapshot)
1012                .with_columns(spec.columns.clone())
1013                .with_filter(spec.filter.clone());
1014            let engine_cols = req.engine_columns();
1015            let cl = if engine_cols.is_empty() {
1016                cl
1017            } else {
1018                let refs: Vec<&str> = engine_cols.iter().map(String::as_str).collect();
1019                cl.project_columns(&refs)
1020                    .map_err(|e| BifrostError::Source(format!("{table_name}: {e}")))?
1021            };
1022            // The PREDICATE is deliberately NOT applied here: `fetch_new` owns
1023            // the residual filter (one implementation, `mapping::apply_row_filter_with`)
1024            // and it is the caller that knows which null policy this batch needs.
1025            let cl_rows: Vec<RecordBatch> = cl.rows;
1026
1027            // bifrost's contract: a pure-append (or full-reload) window flows
1028            // through UNTAGGED — the rows go straight to the upsert pipeline, so
1029            // strip the `_change_type` column skade tags every row with.
1030            //
1031            // WHICH window carries deletes is read off the ROWS, not off
1032            // `plan.delete_files`: those are only the equality-delete files, and
1033            // a copy-on-write rewrite, a position delete or a deletion vector
1034            // deletes rows with none — its DELETE rows then lost their tag, became
1035            // upserts, and put the deleted row back into the graph.
1036            //
1037            // Both delta modes apply deletes: `incremental` has deleted on an
1038            // equality delete since the skade source existed
1039            // (tests/delta_iceberg_falkordb.rs), and now does on every kind of
1040            // delete — on an Iceberg source it reads the same changelog as `cdc`.
1041            let has_deletes = cl_rows.iter().any(has_non_insert);
1042            if !has_deletes {
1043                let out: Vec<RecordBatch> = cl_rows
1044                    .iter()
1045                    .map(strip_change_type)
1046                    .collect::<Result<_>>()?;
1047                let rows: usize = out.iter().map(|b| b.num_rows()).sum();
1048                tracing::info!(
1049                    target: "bifrost::iceberg",
1050                    event = "delta_read",
1051                    table = %table_name,
1052                    from = ?from_snapshot,
1053                    to = to_snapshot,
1054                    snapshots = plan.snapshots.len(),
1055                    files = plan.added_files.len(),
1056                    full_reload = plan.needs_full_reload,
1057                    projected = spec.columns.len(),
1058                    post_filtered = spec.filter.is_some(),
1059                    rows,
1060                    "read iceberg delta (appended/full rows, no CDC deletes)",
1061                );
1062                return Ok((out, plan));
1063            }
1064
1065            // CDC delta: keep skade's INSERT/DELETE tagging verbatim — the
1066            // listener's `partition_change_rows` splits upserts from deletes on
1067            // the `_change_type` column (skade already widened DELETE rows to the
1068            // table schema so the vertex transform projects them).
1069            let change_rows: usize = cl_rows.iter().map(|b| b.num_rows()).sum();
1070            tracing::info!(
1071                target: "bifrost::iceberg",
1072                event = "delta_read_cdc",
1073                table = %table_name,
1074                from = ?from_snapshot,
1075                to = to_snapshot,
1076                snapshots = plan.snapshots.len(),
1077                added_files = plan.added_files.len(),
1078                delete_files = plan.delete_files.len(),
1079                projected = spec.columns.len(),
1080                post_filtered = spec.filter.is_some(),
1081                change_rows,
1082                "read iceberg CDC delta (inserts + identity deletes, tagged _change_type)",
1083            );
1084            Ok((cl_rows, plan))
1085        }
1086
1087        /// **The projected / filtered snapshot read** — the `SyncMode::Full`
1088        /// path, and the point where knut stopped paying for a full-width scan.
1089        ///
1090        /// Before this, `Full` called `Table::read()`: every column of every data
1091        /// file, always. skade has carried a pruning read surface for a while
1092        /// (`read_columns` pushes the projection into the row-group readers so the
1093        /// dropped columns are never decompressed or allocated; `read_filtered`
1094        /// pushes a predicate into the scan planner so non-matching data files are
1095        /// never opened) and knut called NONE of it. This routes the ask:
1096        ///
1097        /// | `spec.columns` | `spec.filter` | skade call |
1098        /// |---|---|---|
1099        /// | empty | `None` | `read()` — unchanged full scan |
1100        /// | set | `None` | `read_columns(cols)` |
1101        /// | any | `Some(f)` | `read_filtered(f, cols)` (empty cols = all) |
1102        ///
1103        /// So a mapping that names no columns and no filter behaves EXACTLY as
1104        /// before; the pruning only ever narrows a read that was provably safe to
1105        /// narrow.
1106        ///
1107        /// When a filter is present the plan is also costed with
1108        /// [`skade::Table::plan_stats`] — the filtered plan against the
1109        /// whole-table baseline — so the data-skipping win is *observable*
1110        /// (files/rows skipped) rather than merely asserted. That is plan-time
1111        /// metadata work; it opens no data file.
1112        async fn read_snapshot(
1113            table: &IceTable,
1114            spec: &TableSpec,
1115            snapshot: Option<i64>,
1116        ) -> Result<Vec<RecordBatch>> {
1117            let cols = spec.column_refs();
1118            let src = |e: skade::SkadeError| BifrostError::Source(format!("{}: {e}", spec.table));
1119
1120            let (skade_batches, planned) = match &spec.filter {
1121                Some(f) => {
1122                    let sf = to_scan_filter(f)?;
1123                    // Plan-only: how much of the table survives the predicate,
1124                    // and how much the unfiltered plan would have read.
1125                    let after = skade::plan_stats(&table, Some(&sf)).await.map_err(src)?;
1126                    let before = skade::plan_stats(&table, None).await.map_err(src)?;
1127                    let batches = skade::read_filtered(&table, &sf, &cols).await.map_err(src)?;
1128                    (batches, Some((before, after)))
1129                }
1130                None if !cols.is_empty() => (skade::read_columns(&table, &cols).await.map_err(src)?, None),
1131                None => (skade::read_all(&table).await.map_err(src)?, None),
1132            };
1133
1134            let batches = from_skade_batches(&skade_batches)?;
1135            let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1136            let width = batches.first().map(|b| b.num_columns()).unwrap_or(0);
1137
1138            match planned {
1139                Some((before, after)) => {
1140                    tracing::info!(
1141                        target: "bifrost::iceberg",
1142                        event = "iceberg_read",
1143                        table = %spec.table,
1144                        snapshot = ?snapshot,
1145                        rows,
1146                        columns = width,
1147                        projected = cols.len(),
1148                        files_planned = after.data_files,
1149                        files_baseline = before.data_files,
1150                        files_skipped = before.data_files.saturating_sub(after.data_files),
1151                        rows_planned = after.rows_planned,
1152                        rows_baseline = before.rows_planned,
1153                        "read iceberg snapshot (full, pushdown)",
1154                    );
1155                    crate::testmatrix::emit(
1156                        crate::testmatrix::COMPONENT,
1157                        "source::iceberg_pushdown_read",
1158                        true,
1159                        &format!(
1160                            "{} data file(s) skipped of {}, {} column(s) projected",
1161                            before.data_files.saturating_sub(after.data_files),
1162                            before.data_files,
1163                            cols.len()
1164                        ),
1165                    );
1166                }
1167                None => tracing::info!(
1168                    target: "bifrost::iceberg",
1169                    event = "iceberg_read",
1170                    table = %spec.table,
1171                    snapshot = ?snapshot,
1172                    rows,
1173                    columns = width,
1174                    projected = cols.len(),
1175                    "read iceberg snapshot (full)",
1176                ),
1177            }
1178            Ok(batches)
1179        }
1180
1181        /// **Plan-time data-skipping stats** for `table` under `filter` — how many
1182        /// data files / rows a scan would open, WITHOUT reading any of them
1183        /// (`filter = None` plans the whole table, the baseline to diff against).
1184        ///
1185        /// Exposed so a caller — a test, a bench, a planner UI — can measure the
1186        /// pruning win directly instead of inferring it from wall time.
1187        pub async fn plan_stats(
1188            &self,
1189            table_name: &str,
1190            filter: Option<&RowFilter>,
1191        ) -> Result<skade::ScanPlanStats> {
1192            let table = self.open_table(table_name).await?;
1193            let sf = filter.map(to_scan_filter).transpose()?;
1194            skade::plan_stats(&table, sf.as_ref())
1195                .await
1196                .map_err(|e| BifrostError::Source(format!("{table_name}: {e}")))
1197        }
1198
1199        /// The table's current snapshot id (the delta target), or `None` if it
1200        /// has no snapshots yet. The cursor a `watch` loop polls against.
1201        pub async fn latest_snapshot(&self, table_name: &str) -> Result<Option<i64>> {
1202            let table = self.open_table(table_name).await?;
1203            Ok(table
1204                .metadata()
1205                .current_snapshot()
1206                .map(|s| s.snapshot_id()))
1207        }
1208    }
1209
1210    /// Lower bifrost's backend-neutral [`RowFilter`] onto skade's
1211    /// [`skade::ScanFilter`]. The spec type is engine-free (bifrost's core has
1212    /// no Iceberg dependency); this is the ONE place the two vocabularies meet.
1213    fn to_scan_filter(f: &RowFilter) -> Result<skade::ScanFilter> {
1214        f.validate()?;
1215        if let Some(v) = &f.eq {
1216            return Ok(skade::ScanFilter::eq(&f.column, to_scalar(v)));
1217        }
1218        if let Some(vs) = &f.is_in {
1219            return Ok(skade::ScanFilter::is_in(
1220                &f.column,
1221                vs.iter().map(to_scalar).collect::<Vec<_>>(),
1222            ));
1223        }
1224        Ok(skade::ScanFilter::range(
1225            &f.column,
1226            f.ge.as_ref().map(to_scalar),
1227            f.le.as_ref().map(to_scalar),
1228        ))
1229    }
1230
1231    fn to_scalar(v: &crate::mapping::FilterValue) -> skade::Scalar {
1232        use crate::mapping::FilterValue as F;
1233        match v {
1234            F::Str(s) => skade::Scalar::Str(s.clone()),
1235            F::Int(i) => skade::Scalar::I64(*i),
1236            F::Float(f) => skade::Scalar::F64(*f),
1237            F::Bool(b) => skade::Scalar::Bool(*b),
1238        }
1239    }
1240
1241    /// Drop the `_change_type` column skade's changelog tags every row with, so a
1242    /// pure-append / full-reload window returns batches shaped exactly like an
1243    /// ordinary table read — they flow straight to the upsert pipeline, matching
1244    /// bifrost's pre-cutover behaviour. A batch without the column (defensive)
1245    /// passes through untouched.
1246    /// Whether `batch` holds a row that is not an insert (a DELETE, or an update's
1247    /// pre-image).
1248    fn has_non_insert(batch: &RecordBatch) -> bool {
1249        let Some(col) = batch.column_by_name(change_type::COLUMN) else { return false };
1250        let Some(ops) = col.as_any().downcast_ref::<arrow_array::StringArray>() else { return false };
1251        ops.iter().any(|op| !matches!(op, Some(change_type::INSERT) | Some(change_type::UPDATE_AFTER)))
1252    }
1253
1254    fn strip_change_type(batch: &RecordBatch) -> Result<RecordBatch> {
1255        let Ok(idx) = batch.schema().index_of(change_type::COLUMN) else {
1256            return Ok(batch.clone());
1257        };
1258        let mut fields: Vec<arrow_schema::Field> = batch
1259            .schema()
1260            .fields()
1261            .iter()
1262            .map(|f| f.as_ref().clone())
1263            .collect();
1264        fields.remove(idx);
1265        let mut columns: Vec<arrow_array::ArrayRef> = batch.columns().to_vec();
1266        columns.remove(idx);
1267        RecordBatch::try_new(
1268            std::sync::Arc::new(arrow_schema::Schema::new(fields)),
1269            columns,
1270        )
1271        .map_err(|e| BifrostError::Source(format!("strip _change_type: {e}")))
1272    }
1273
1274    #[async_trait]
1275    impl TableSource for IcebergSource {
1276        /// Always skade: this source holds an embedded warehouse, so there is no
1277        /// Spark to ask and nothing to fall back from. Stated explicitly rather
1278        /// than left `None`, so `state_json` names the path on this route too.
1279        fn cdc_capability(&self, _table: &str) -> Option<crate::cdc::CdcCapability> {
1280            Some(crate::cdc::CdcCapability::skade())
1281        }
1282
1283        async fn fetch_new(
1284            &self,
1285            spec: &TableSpec,
1286            state: &TableState,
1287        ) -> Result<Option<(Vec<RecordBatch>, TableState)>> {
1288            let table = self.open_table(&spec.table).await?;
1289
1290            // The current Iceberg snapshot id; unchanged → nothing to do.
1291            let snapshot = table
1292                .metadata()
1293                .current_snapshot()
1294                .map(|s| s.snapshot_id());
1295            let Some(latest) = snapshot else {
1296                return Ok(None); // table has no snapshots yet
1297            };
1298            if Some(latest) == state.snapshot_id {
1299                tracing::debug!(
1300                    target: "bifrost::iceberg",
1301                    table = %spec.table,
1302                    snapshot = ?snapshot,
1303                    "no snapshot change",
1304                );
1305                return Ok(None);
1306            }
1307
1308            let batches = match spec.mode {
1309                // Full mode: re-read the table at the new snapshot — PROJECTED
1310                // and FILTERED to what the round asked for (see `read_snapshot`).
1311                SyncMode::Full => Self::read_snapshot(&table, spec, snapshot).await?,
1312                // Incremental / CDC: the streaming delta path — read only the
1313                // data files added since the recorded snapshot. First round
1314                // (no cursor) reads from the beginning (whole table).
1315                SyncMode::Incremental | SyncMode::Cdc => {
1316                    // Reuse the table handle already opened above — reopening the
1317                    // same redb warehouse mid-call would deadlock (single-open).
1318                    let (batches, _plan) =
1319                        Self::delta_from_table(&table, spec, state.snapshot_id, latest).await?;
1320                    // ── the predicate means the SAME thing in every mode ──────────
1321                    //
1322                    // The delta scan plans on the manifest (which files were ADDED
1323                    // since the cursor), not on the predicate, so it cannot prune on
1324                    // `spec.filter` the way `read_snapshot` does. Before this it
1325                    // simply DROPPED the filter — and nothing downstream re-applies
1326                    // it, so rows the operator excluded were ingested anyway. The
1327                    // same mapping selected 1000 rows under `Incremental` and 250
1328                    // under `Full` (tests/iceberg_pushdown.rs).
1329                    //
1330                    // Falling back to the in-memory residual keeps the MEANING
1331                    // identical and only changes the cost.
1332                    //
1333                    // UPDATED: the PROJECTION is no longer dropped here either —
1334                    // `delta_from_table` now pushes `spec.columns` through skade's
1335                    // `ChangelogBatch::project_columns`, which re-adds the
1336                    // `_change_type` sentinel itself, so Cdc's
1337                    // `partition_change_rows` still sees which rows are deletes.
1338                    // (The earlier note that projecting the delta path "would
1339                    // strip it" was true of a naive projection, not of skade's.)
1340                    //
1341                    // NULL POLICY. A changelog batch's `DELETE` rows are widened
1342                    // to the table schema with **nulls in every non-identity
1343                    // column**, so the scan-planner default (`FilterNulls::Drop`,
1344                    // NULL fails the predicate) would silently remove deletions —
1345                    // the one CDC failure that must never happen quietly. A batch
1346                    // carrying `_change_type` therefore keeps what the predicate
1347                    // cannot judge; an ordinary delta keeps the planner semantics
1348                    // so `Full` and `Incremental` select the same rows.
1349                    match &spec.filter {
1350                        Some(f) => {
1351                            let before: usize = batches.iter().map(|b| b.num_rows()).sum();
1352                            let nulls = if batches.iter().any(crate::transform::is_changelog_batch)
1353                            {
1354                                crate::mapping::FilterNulls::Keep
1355                            } else {
1356                                crate::mapping::FilterNulls::Drop
1357                            };
1358                            let kept = crate::mapping::apply_row_filter_with(&batches, f, nulls)?;
1359                            let after: usize = kept.iter().map(|b| b.num_rows()).sum();
1360                            tracing::info!(
1361                                target: "bifrost::iceberg",
1362                                event = "delta_residual_filter",
1363                                table = %spec.table,
1364                                mode = ?spec.mode,
1365                                column = %f.column,
1366                                rows_before = before,
1367                                rows_after = after,
1368                                rows_dropped = before.saturating_sub(after),
1369                                "applied the mapping's row filter to the delta batches \
1370                                 (residual — the delta scan plans on the manifest, not the \
1371                                 predicate)",
1372                            );
1373                            crate::testmatrix::emit(
1374                                crate::testmatrix::COMPONENT,
1375                                "source::delta_residual_filter",
1376                                true,
1377                                &format!(
1378                                    "{} rows -> {} ({} dropped) by `{}` in {:?} mode",
1379                                    before,
1380                                    after,
1381                                    before.saturating_sub(after),
1382                                    f.column,
1383                                    spec.mode
1384                                ),
1385                            );
1386                            kept
1387                        }
1388                        None => batches,
1389                    }
1390                }
1391            };
1392
1393            let new_state = TableState {
1394                snapshot_id: Some(latest),
1395                ..state.clone()
1396            };
1397            Ok(Some((batches, new_state)))
1398        }
1399    }
1400
1401    /// Bridge skade's batches to the workspace's — now an **identity clone**.
1402    ///
1403    /// knut and skade both build on arrow 59, so `skade::arrow_array::RecordBatch`
1404    /// *is* the workspace `RecordBatch` (same crate, same major). The old
1405    /// C-Data-Interface FFI transmute bridge (arrow 57 → arrow 56), the mirror of
1406    /// `graphar-flight`'s `to_skade_batch`, is gone: a plain `clone()` (Arc bump,
1407    /// no buffer copy, no FFI) hands the batch straight through.
1408    fn from_skade_batches(
1409        skade_batches: &[skade::arrow_array::RecordBatch],
1410    ) -> Result<Vec<RecordBatch>> {
1411        skade_batches.iter().map(from_skade_batch).collect()
1412    }
1413
1414    /// Identity clone of one skade (arrow-59) batch to the workspace's arrow-59
1415    /// `RecordBatch` — same type, no FFI.
1416    fn from_skade_batch(skade_batch: &skade::arrow_array::RecordBatch) -> Result<RecordBatch> {
1417        Ok(skade_batch.clone())
1418    }
1419
1420    #[cfg(test)]
1421    mod tests {
1422        use super::*;
1423        use std::sync::Arc;
1424
1425        #[tokio::test]
1426        async fn reads_iceberg_table_and_tracks_snapshot() {
1427            use skade::arrow_array::{Int64Array, RecordBatch as SkadeBatch, StringArray};
1428            use skade::arrow_schema::{DataType, Field, Schema};
1429
1430            // Stage an Iceberg table via skade (arrow 59).
1431            let dir = tempfile::tempdir().unwrap();
1432            let batch = SkadeBatch::try_new(
1433                Arc::new(Schema::new(vec![
1434                    Field::new("id", DataType::Int64, false),
1435                    Field::new("name", DataType::Utf8, false),
1436                ])),
1437                vec![
1438                    Arc::new(Int64Array::from(vec![1, 2, 3])),
1439                    Arc::new(StringArray::from(vec!["a", "b", "c"])),
1440                ],
1441            )
1442            .unwrap();
1443            {
1444                // Drop the setup warehouse before reading — redb is single-open
1445                // and IcebergSource opens the same dir itself.
1446                let wh = skade::Warehouse::open(dir.path()).await.unwrap();
1447                let mut table = wh.table_or_create("people", &batch.schema()).await.unwrap();
1448                table.append(&[batch]).await.unwrap();
1449            }
1450
1451            // Read it back through IcebergSource (arrow 59).
1452            let src = IcebergSource::new(dir.path());
1453            let spec = TableSpec {
1454                table: "people".into(),
1455                mode: SyncMode::Full,
1456                ..Default::default()
1457            };
1458            let (batches, st) = src
1459                .fetch_new(&spec, &TableState::default())
1460                .await
1461                .unwrap()
1462                .expect("a snapshot to read");
1463            let rows = batches.iter().map(|b| b.num_rows()).sum::<usize>();
1464            assert_eq!(rows, 3);
1465            assert!(st.snapshot_id.is_some(), "tracked the snapshot id");
1466
1467            // Re-poll with the recorded snapshot → no change.
1468            let idle_none = src.fetch_new(&spec, &st).await.unwrap().is_none();
1469            assert!(idle_none, "unchanged snapshot → None");
1470            crate::testmatrix::emit(
1471                crate::testmatrix::COMPONENT,
1472                "source::reads_iceberg_table_and_tracks_snapshot",
1473                rows == 3 && st.snapshot_id.is_some() && idle_none,
1474                &format!(
1475                    "read {} rows, tracked snapshot {:?}, idle→None",
1476                    rows, st.snapshot_id
1477                ),
1478            );
1479        }
1480
1481        use skade::arrow_array::{Int64Array, RecordBatch as SkadeBatch, StringArray};
1482        use skade::arrow_schema::{DataType, Field, Schema};
1483
1484        fn people_batch(ids: &[i64]) -> SkadeBatch {
1485            let names: Vec<String> = ids.iter().map(|i| format!("p{i}")).collect();
1486            SkadeBatch::try_new(
1487                Arc::new(Schema::new(vec![
1488                    Field::new("id", DataType::Int64, false),
1489                    Field::new("name", DataType::Utf8, false),
1490                ])),
1491                vec![
1492                    Arc::new(Int64Array::from(ids.to_vec())),
1493                    Arc::new(StringArray::from(names)),
1494                ],
1495            )
1496            .unwrap()
1497        }
1498
1499        /// Append `ids` as one new snapshot; return its snapshot id.
1500        async fn append_snapshot(dir: &std::path::Path, ids: &[i64]) -> i64 {
1501            let wh = skade::Warehouse::open(dir).await.unwrap();
1502            let batch = people_batch(ids);
1503            let mut table = wh.table_or_create("people", &batch.schema()).await.unwrap();
1504            table.append(&[batch]).await.unwrap();
1505            table
1506                .inner()
1507                .metadata()
1508                .current_snapshot()
1509                .unwrap()
1510                .snapshot_id()
1511        }
1512
1513        /// LAW inject-assert: a real local Iceberg warehouse, two appends, and
1514        /// the delta reads ONLY the second append's rows — not the whole table.
1515        #[tokio::test]
1516        async fn delta_reads_only_appended_rows() {
1517            let dir = tempfile::tempdir().unwrap();
1518
1519            // Snapshot A: 3 rows.
1520            let snap_a = append_snapshot(dir.path(), &[1, 2, 3]).await;
1521            // Snapshot B: 2 MORE rows (a separate fast_append commit).
1522            let snap_b = append_snapshot(dir.path(), &[4, 5]).await;
1523            assert_ne!(snap_a, snap_b, "two distinct snapshots");
1524
1525            let src = IcebergSource::new(dir.path());
1526
1527            // From the beginning to A → all 3 rows.
1528            let (batches, plan) = src.ingest_delta("people", None, snap_a).await.unwrap();
1529            assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
1530            assert!(!plan.needs_full_reload);
1531            assert_eq!(plan.snapshots, vec![snap_a]);
1532            assert_eq!(plan.added_files.len(), 1, "A added exactly one data file");
1533
1534            // THE DELTA: from A to B → ONLY the 2 new rows, not 5.
1535            let (delta, plan) = src
1536                .ingest_delta("people", Some(snap_a), snap_b)
1537                .await
1538                .unwrap();
1539            assert_eq!(
1540                delta.iter().map(|b| b.num_rows()).sum::<usize>(),
1541                2,
1542                "delta is just B's 2 appended rows (not a full 5-row reload)",
1543            );
1544            assert!(!plan.needs_full_reload);
1545            assert_eq!(plan.snapshots, vec![snap_b], "only snapshot B is new");
1546            assert_eq!(plan.added_files.len(), 1, "B added exactly one data file");
1547            // The delta's ids are exactly {4,5}.
1548            let col = delta[0]
1549                .column(0)
1550                .as_any()
1551                .downcast_ref::<arrow_array::Int64Array>()
1552                .unwrap();
1553            let mut got: Vec<i64> = (0..col.len()).map(|i| col.value(i)).collect();
1554            got.sort();
1555            assert_eq!(got, vec![4, 5]);
1556
1557            // Cursor already at B → empty delta, no files.
1558            let (none, plan) = src
1559                .ingest_delta("people", Some(snap_b), snap_b)
1560                .await
1561                .unwrap();
1562            assert!(none.is_empty(), "caught-up cursor → no rows");
1563            assert!(plan.snapshots.is_empty() && plan.added_files.is_empty());
1564            crate::testmatrix::emit(
1565                crate::testmatrix::COMPONENT,
1566                "source::delta_reads_only_appended_rows",
1567                got == vec![4, 5] && none.is_empty(),
1568                &format!("A→B delta = ids {got:?} (2 rows, not full 5); caught-up→empty"),
1569            );
1570        }
1571
1572        /// The `TableSource::fetch_new` Incremental path advances the cursor and
1573        /// ships only the new snapshot's rows on the second poll.
1574        #[tokio::test]
1575        async fn incremental_fetch_new_advances_cursor_and_ships_delta() {
1576            let dir = tempfile::tempdir().unwrap();
1577            append_snapshot(dir.path(), &[1, 2, 3]).await;
1578
1579            let src = IcebergSource::new(dir.path());
1580            let spec = TableSpec {
1581                table: "people".into(),
1582                mode: SyncMode::Incremental,
1583                ..Default::default()
1584            };
1585
1586            // First poll (no cursor) → whole table (3 rows), cursor set.
1587            let (b1, st1) = src
1588                .fetch_new(&spec, &TableState::default())
1589                .await
1590                .unwrap()
1591                .expect("first snapshot");
1592            assert_eq!(b1.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
1593            assert!(st1.snapshot_id.is_some());
1594
1595            // Idle: unchanged snapshot → None.
1596            assert!(src.fetch_new(&spec, &st1).await.unwrap().is_none());
1597
1598            // Append B (2 rows), poll again → ONLY the 2 new rows.
1599            append_snapshot(dir.path(), &[4, 5]).await;
1600            let (b2, st2) = src
1601                .fetch_new(&spec, &st1)
1602                .await
1603                .unwrap()
1604                .expect("delta snapshot");
1605            let b2_rows = b2.iter().map(|b| b.num_rows()).sum::<usize>();
1606            assert_eq!(b2_rows, 2, "incremental ships only the delta");
1607            assert_ne!(st2.snapshot_id, st1.snapshot_id, "cursor advanced");
1608            crate::testmatrix::emit(
1609                crate::testmatrix::COMPONENT,
1610                "source::incremental_fetch_new_advances_cursor_and_ships_delta",
1611                b2_rows == 2 && st2.snapshot_id != st1.snapshot_id,
1612                &format!("second poll shipped {b2_rows} delta rows; cursor advanced"),
1613            );
1614        }
1615
1616        /// A three-step append chain (A→B→C): the delta from A to C spans BOTH
1617        /// new snapshots and reads only their files (not A's). Proves the
1618        /// lineage walk collects multiple snapshots in one delta.
1619        #[tokio::test]
1620        async fn delta_spans_multiple_snapshots() {
1621            let dir = tempfile::tempdir().unwrap();
1622            let snap_a = append_snapshot(dir.path(), &[1, 2]).await;
1623            let snap_b = append_snapshot(dir.path(), &[3, 4]).await;
1624            let snap_c = append_snapshot(dir.path(), &[5, 6]).await;
1625
1626            let src = IcebergSource::new(dir.path());
1627            let (delta, plan) = src
1628                .ingest_delta("people", Some(snap_a), snap_c)
1629                .await
1630                .unwrap();
1631            assert_eq!(
1632                delta.iter().map(|b| b.num_rows()).sum::<usize>(),
1633                4,
1634                "B+C rows only (4), not A's 2 and not a full 6",
1635            );
1636            let delta_rows = delta.iter().map(|b| b.num_rows()).sum::<usize>();
1637            assert_eq!(plan.snapshots, vec![snap_b, snap_c], "oldest-first lineage");
1638            assert_eq!(plan.added_files.len(), 2, "B and C each added one file");
1639            crate::testmatrix::emit(
1640                crate::testmatrix::COMPONENT,
1641                "source::delta_spans_multiple_snapshots",
1642                delta_rows == 4
1643                    && plan.snapshots == vec![snap_b, snap_c]
1644                    && plan.added_files.len() == 2,
1645                &format!("A→C delta spans 2 snapshots, {delta_rows} rows, 2 files"),
1646            );
1647        }
1648
1649        /// Delete `ids` from `people` via a real Iceberg equality-delete
1650        /// snapshot (skade), returning its snapshot id.
1651        async fn delete_snapshot(dir: &std::path::Path, ids: &[i64]) -> i64 {
1652            let wh = skade::Warehouse::open(dir).await.unwrap();
1653            let mut table = wh.table("people").await.unwrap();
1654            let key = SkadeBatch::try_new(
1655                Arc::new(skade::arrow_schema::Schema::new(vec![
1656                    skade::arrow_schema::Field::new(
1657                        "id",
1658                        skade::arrow_schema::DataType::Int64,
1659                        false,
1660                    ),
1661                ])),
1662                vec![Arc::new(Int64Array::from(ids.to_vec()))],
1663            )
1664            .unwrap();
1665            table.delete_equality(&key, &["id"]).await.unwrap();
1666            table.current_snapshot_id().unwrap()
1667        }
1668
1669        /// Collect (id, change_type) from a CDC delta's batches.
1670        fn id_change_pairs(batches: &[RecordBatch]) -> Vec<(i64, String)> {
1671            use arrow_array::StringArray;
1672            let mut out = Vec::new();
1673            for b in batches {
1674                let id = b
1675                    .column(b.schema().index_of("id").unwrap())
1676                    .as_any()
1677                    .downcast_ref::<arrow_array::Int64Array>()
1678                    .unwrap();
1679                let ct = b
1680                    .column(b.schema().index_of(SyncMode::CHANGE_TYPE_COLUMN).unwrap())
1681                    .as_any()
1682                    .downcast_ref::<StringArray>()
1683                    .unwrap();
1684                for i in 0..b.num_rows() {
1685                    out.push((id.value(i), ct.value(i).to_string()));
1686                }
1687            }
1688            out
1689        }
1690
1691        /// CDC inject-assert: snapshot A inserts {1,2,3}; snapshot B is a real
1692        /// Iceberg equality-delete of id=2. The Incremental delta over A→B must
1693        /// emit CDC `_change_type` rows — id=2 tagged DELETE — so the listener
1694        /// removes that node. (The pure-Rust delete path, no Spark.)
1695        #[tokio::test]
1696        async fn cdc_delta_emits_change_type_delete_for_equality_delete() {
1697            let dir = tempfile::tempdir().unwrap();
1698            let snap_a = append_snapshot(dir.path(), &[1, 2, 3]).await;
1699
1700            let src = IcebergSource::new(dir.path());
1701
1702            // Pure-append delta carries NO _change_type (back-compat).
1703            let (appended, plan) = src.ingest_delta("people", None, snap_a).await.unwrap();
1704            assert!(plan.delete_files.is_empty(), "no deletes yet");
1705            assert!(
1706                appended
1707                    .iter()
1708                    .all(|b| b.schema().index_of(SyncMode::CHANGE_TYPE_COLUMN).is_err()),
1709                "an append-only delta is untagged (flows straight to upserts)",
1710            );
1711
1712            // Snapshot B: equality-delete id=2.
1713            let snap_b = delete_snapshot(dir.path(), &[2]).await;
1714            assert_ne!(snap_a, snap_b);
1715
1716            // THE CDC DELTA A→B: a _change_type column, id=2 = DELETE.
1717            let (delta, plan) = src
1718                .ingest_delta("people", Some(snap_a), snap_b)
1719                .await
1720                .unwrap();
1721            assert!(!plan.needs_full_reload, "equality-delete needs no reload");
1722            assert_eq!(plan.delete_files.len(), 1, "one equality-delete file");
1723            assert!(
1724                plan.added_files.is_empty(),
1725                "the delete added no data files"
1726            );
1727            let pairs = id_change_pairs(&delta);
1728            assert_eq!(
1729                pairs,
1730                vec![(2i64, "DELETE".to_string())],
1731                "the delta is exactly: delete id=2",
1732            );
1733
1734            // And it routes through the listener as a real vertex delete.
1735            let change = crate::transform::partition_change_rows(&delta).unwrap();
1736            assert!(change.upserts.iter().all(|b| b.num_rows() == 0));
1737            assert_eq!(
1738                change.deletes.iter().map(|b| b.num_rows()).sum::<usize>(),
1739                1,
1740                "one delete row routed to the delete pipeline",
1741            );
1742            let vt: Arc<dyn crate::transform::Transform> =
1743                Arc::new(crate::transform::VertexTransform {
1744                    label: "Person".into(),
1745                    id_column: "id".into(),
1746                    properties: vec![],
1747                    rename: Default::default(),
1748                });
1749            let ctx = crate::transform::TableContext {
1750                table: "people".into(),
1751            };
1752            let dels = crate::transform::deletes_from_change_rows(
1753                std::slice::from_ref(&vt),
1754                &ctx,
1755                &change.deletes,
1756            )
1757            .unwrap();
1758            assert_eq!(dels.len(), 1, "one VertexDelete emitted");
1759            assert_eq!(dels[0].label, "Person");
1760            crate::testmatrix::emit(
1761                crate::testmatrix::COMPONENT,
1762                "source::cdc_delta_emits_change_type_delete_for_equality_delete",
1763                pairs == vec![(2i64, "DELETE".to_string())]
1764                    && dels.len() == 1
1765                    && dels[0].label == "Person",
1766                &format!(
1767                    "equality-delete A→B tagged id=2 DELETE → 1 VertexDelete; pairs={pairs:?}"
1768                ),
1769            );
1770        }
1771    }
1772}
1773
1774// NOTE: row-level deletes now flow through the pure-Rust path as Iceberg
1775// *equality* deletes (skade's `Table::delete_equality` writes them, `read_delta`
1776// surfaces them, and `delta_from_table` tags them `_change_type = DELETE`). The
1777// `needs_full_reload` branch remains for *position* deletes (delete-by-offset,
1778// unmappable to a graph identity) and overwrite/replace commits, which an
1779// additive delta can't express. skade can't *produce* those via `fast_append`,
1780// so that branch is covered by the code path + the design doc, not a unit test.