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.