Skip to main content

knut_bifrost/
cdc.rs

1//! **The CDC seam** — one change-data-capture vocabulary, three backends, and
2//! the Arrow row shape a UI renders change-by-change.
3//!
4//! # Why a seam at all
5//!
6//! knut can learn "what changed in this table since snapshot N" three different
7//! ways, and which one is *available* is a property of the server it is talking
8//! to, not of anything an operator types into a YAML file:
9//!
10//! | backend | how | needs |
11//! |---|---|---|
12//! | [`CdcKind::SparkNative`] | Spark 4.2's `… CHANGES FROM VERSION a TO VERSION b` | a Spark **4.2+** Connect server **and** a DSv2 connector implementing `Changelog` |
13//! | [`CdcKind::IcebergChangelogView`] | Iceberg's `system.create_changelog_view` procedure | any Spark with the Iceberg runtime loaded (4.0 / 4.1 / …) |
14//! | [`CdcKind::SkadeDelta`] | `skade::Table::read_changelog` — manifest-level delta, pure Rust | no Spark at all |
15//!
16//! ## What Spark 4.2 actually added (and what it does not give us *yet*)
17//!
18//! Spark 4.2.0 (GA 2026-07-14) landed first-class CDC as SPARK-55668: a SQL
19//! `CHANGES` clause, a DSv2 connector interface
20//! `org.apache.spark.sql.connector.catalog.Changelog` (`@since 4.2.0`), and
21//! DataFrame / PySpark / **Spark Connect** bindings (SPARK-55949). The grammar,
22//! from Spark's own `SqlBaseParser.g4` at tag `v4.2.0`:
23//!
24//! ```text
25//! changesClause
26//!   : CHANGES FROM (SYSTEM_VERSION | VERSION) startingVersion (INCLUSIVE | EXCLUSIVE)?
27//!       (TO (SYSTEM_VERSION | VERSION) endingVersion (INCLUSIVE | EXCLUSIVE)?)?
28//!   | CHANGES FROM (SYSTEM_TIME | TIMESTAMP) startingTimestamp (INCLUSIVE | EXCLUSIVE)?
29//!       (TO (SYSTEM_TIME | TIMESTAMP) endingTimestamp (INCLUSIVE | EXCLUSIVE)?)?
30//!   ;
31//! ```
32//!
33//! Its changelog rows carry `_change_type` (`insert` / `delete` /
34//! `update_preimage` / `update_postimage` — **lower case**), `_commit_version`
35//! and `_commit_timestamp`.
36//!
37//! **But**: the `CHANGES` clause only resolves for a table whose connector
38//! implements `Changelog`. Apache Iceberg does not — as of 2026-07-22 Iceberg
39//! 1.11.0 publishes `iceberg-spark-runtime-{3.4,3.5,4.0,4.1}` and **no 4.2
40//! runtime at all** (apache/iceberg#14984 "Spark: Add support for 4.2.0" is
41//! still open; #16751 was closed unmerged), and no Iceberg source references
42//! `org.apache.spark.sql.connector.catalog.Changelog`. So on a *4.2 server
43//! holding Iceberg tables* the native path is present in the engine and
44//! unreachable for our data. That is not a reason to pretend: it is exactly why
45//! [`detect`] **probes** instead of trusting the version string, and why a
46//! failed probe records [`CdcCapability::fell_back`] rather than silently
47//! choosing the other path.
48//!
49//! ## The renderable row
50//!
51//! Every backend is normalised onto ONE Arrow shape — [`change_row_schema`]:
52//!
53//! ```text
54//! op       : Utf8                          non-null   insert | delete | update_before | update_after | update
55//! before   : Struct<data columns, nullable> nullable  the pre-image  (null for an insert)
56//! after    : Struct<data columns, nullable> nullable  the post-image (null for a delete)
57//! snapshot : Int64                          nullable  Iceberg snapshot id / Spark _commit_version
58//! ts       : Timestamp(Microsecond, UTC)    nullable  commit timestamp
59//! ```
60//!
61//! One Arrow row per change event, delivered batch-by-batch over a
62//! [`ChangeStream`] so a UI pane can paint changes as they arrive instead of
63//! blocking on a whole scan.
64
65use std::sync::Arc;
66
67use arrow_array::{
68    Array, ArrayRef, Int64Array, RecordBatch, StringArray, StructArray, TimestampMicrosecondArray,
69};
70use arrow_schema::{DataType, Field, Fields, Schema, SchemaRef, TimeUnit};
71
72use crate::{
73    error::{BifrostError, Result},
74    mapping::{RowFilter, SyncMode},
75};
76
77// ── vocabulary ──────────────────────────────────────────────────────────────
78
79/// The kind of a single change event, normalised across backends.
80///
81/// The two engines spell these differently and knut refuses to guess: Iceberg's
82/// changelog view emits SCREAMING `INSERT` / `DELETE` / `UPDATE_BEFORE` /
83/// `UPDATE_AFTER`, Spark 4.2's `Changelog` contract emits lower-case `insert` /
84/// `delete` / `update_preimage` / `update_postimage`. [`ChangeOp::parse`]
85/// accepts **both** dialects; [`ChangeOp::as_str`] emits knut's canonical one.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum ChangeOp {
89    /// A row was added.
90    Insert,
91    /// A row was removed.
92    Delete,
93    /// The pre-image of an in-place update (Spark: `update_preimage`).
94    UpdateBefore,
95    /// The post-image of an in-place update (Spark: `update_postimage`).
96    UpdateAfter,
97    /// A pre/post-image **pair** folded into one row by [`fold_updates`]; both
98    /// `before` and `after` are populated. No engine emits this — knut does.
99    Update,
100}
101
102impl ChangeOp {
103    /// knut's canonical spelling (what lands in the `op` column).
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            ChangeOp::Insert => "insert",
107            ChangeOp::Delete => "delete",
108            ChangeOp::UpdateBefore => "update_before",
109            ChangeOp::UpdateAfter => "update_after",
110            ChangeOp::Update => "update",
111        }
112    }
113
114    /// Parse either engine's spelling (case-insensitive), plus knut's own.
115    ///
116    /// Returns `None` for anything unrecognised — the caller must fail loudly
117    /// rather than drop the row, because a silently dropped `delete` is the
118    /// single worst failure a change feed can have.
119    pub fn parse(s: &str) -> Option<ChangeOp> {
120        match s.to_ascii_lowercase().as_str() {
121            "insert" => Some(ChangeOp::Insert),
122            "delete" => Some(ChangeOp::Delete),
123            // Iceberg spelling            | Spark 4.2 spelling
124            "update_before" | "update_preimage" => Some(ChangeOp::UpdateBefore),
125            "update_after" | "update_postimage" => Some(ChangeOp::UpdateAfter),
126            "update" => Some(ChangeOp::Update),
127            _ => None,
128        }
129    }
130
131    /// Does this event carry a pre-image (fills the `before` struct)?
132    pub const fn has_before(self) -> bool {
133        matches!(
134            self,
135            ChangeOp::Delete | ChangeOp::UpdateBefore | ChangeOp::Update
136        )
137    }
138
139    /// Does this event carry a post-image (fills the `after` struct)?
140    pub const fn has_after(self) -> bool {
141        matches!(
142            self,
143            ChangeOp::Insert | ChangeOp::UpdateAfter | ChangeOp::Update
144        )
145    }
146}
147
148/// Column names of the renderable change-row schema. Public so the UI does not
149/// have to string-match knut's internals.
150pub mod col {
151    /// The [`super::ChangeOp`] as a canonical lower-case string.
152    pub const OP: &str = "op";
153    /// The pre-image struct (null when the event has no pre-image).
154    pub const BEFORE: &str = "before";
155    /// The post-image struct (null when the event has no post-image).
156    pub const AFTER: &str = "after";
157    /// Iceberg snapshot id / Spark `_commit_version`.
158    pub const SNAPSHOT: &str = "snapshot";
159    /// Commit timestamp, microseconds UTC.
160    pub const TS: &str = "ts";
161}
162
163/// Spark 4.2's `Changelog` metadata columns, and Iceberg's changelog-view ones.
164/// Named here so the two SQL builders and the encoder agree on one spelling.
165pub mod meta {
166    /// Both engines agree on this one (values differ — see [`super::ChangeOp`]).
167    pub const CHANGE_TYPE: &str = "_change_type";
168    /// Spark 4.2 (`Changelog` contract): the commit version of the change.
169    pub const COMMIT_VERSION: &str = "_commit_version";
170    /// Spark 4.2 (`Changelog` contract): the commit timestamp of the change.
171    pub const COMMIT_TIMESTAMP: &str = "_commit_timestamp";
172    /// Iceberg `create_changelog_view`: the snapshot the change was committed in.
173    pub const COMMIT_SNAPSHOT_ID: &str = "_commit_snapshot_id";
174    /// Iceberg `create_changelog_view`: the ordering of changes.
175    pub const CHANGE_ORDINAL: &str = "_change_ordinal";
176
177    /// Every metadata column either engine may add — these are stripped out of
178    /// the `before` / `after` structs so a UI renders only real table columns.
179    pub const ALL: [&str; 5] = [
180        CHANGE_TYPE,
181        COMMIT_VERSION,
182        COMMIT_TIMESTAMP,
183        COMMIT_SNAPSHOT_ID,
184        CHANGE_ORDINAL,
185    ];
186
187    /// Is `name` one of the changelog metadata columns?
188    pub fn is_meta(name: &str) -> bool {
189        ALL.contains(&name)
190    }
191}
192
193// ── the renderable row shape ────────────────────────────────────────────────
194
195/// The data-only fields of `wide` — every column that is **not** changelog
196/// metadata, made nullable (a `delete` row populates only its identity columns,
197/// an `insert`'s `before` is entirely absent).
198fn data_fields(wide: &Schema) -> Fields {
199    wide.fields()
200        .iter()
201        .filter(|f| !meta::is_meta(f.name()))
202        .map(|f| Arc::new(f.as_ref().clone().with_nullable(true)))
203        .collect()
204}
205
206/// Build the renderable change-row schema for a table whose data columns are
207/// the non-metadata columns of `wide`.
208///
209/// See the module docs for the shape. The `before` / `after` structs share ONE
210/// [`Fields`] instance, so the two columns are guaranteed field-identical — a UI
211/// can render a diff by zipping them positionally.
212pub fn change_row_schema(wide: &Schema) -> SchemaRef {
213    let fields = data_fields(wide);
214    Arc::new(Schema::new(vec![
215        Field::new(col::OP, DataType::Utf8, false),
216        Field::new(col::BEFORE, DataType::Struct(fields.clone()), true),
217        Field::new(col::AFTER, DataType::Struct(fields), true),
218        Field::new(col::SNAPSHOT, DataType::Int64, true),
219        Field::new(
220            col::TS,
221            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
222            true,
223        ),
224    ]))
225}
226
227/// The data (non-metadata) column names of a wide changelog batch, in order.
228pub fn data_columns(wide: &Schema) -> Vec<String> {
229    wide.fields()
230        .iter()
231        .filter(|f| !meta::is_meta(f.name()))
232        .map(|f| f.name().clone())
233        .collect()
234}
235
236/// Take `col` at the rows in `idx` (a gather), returning a new array.
237fn take_rows(array: &ArrayRef, idx: &[u32]) -> Result<ArrayRef> {
238    let indices = arrow_array::UInt32Array::from(idx.to_vec());
239    arrow::compute::take(array.as_ref(), &indices, None)
240        .map_err(|e| BifrostError::Source(format!("cdc take: {e}")))
241}
242
243/// Build a `Struct` array over `fields` whose row *i* is `wide` row `rows[i]`,
244/// null wherever `valid[i]` is false.
245fn image_struct(
246    wide: &RecordBatch,
247    fields: &Fields,
248    rows: &[u32],
249    valid: &[bool],
250) -> Result<ArrayRef> {
251    let mut cols: Vec<ArrayRef> = Vec::with_capacity(fields.len());
252    for f in fields.iter() {
253        let src = wide.column_by_name(f.name()).ok_or_else(|| {
254            BifrostError::Source(format!("changelog batch is missing column `{}`", f.name()))
255        })?;
256        let gathered = take_rows(src, rows)?;
257        let gathered = if gathered.data_type() == f.data_type() {
258            gathered
259        } else {
260            arrow::compute::cast(gathered.as_ref(), f.data_type())
261                .map_err(|e| BifrostError::Source(format!("cdc cast {}: {e}", f.name())))?
262        };
263        cols.push(gathered);
264    }
265    let nulls = arrow::buffer::NullBuffer::from(valid.to_vec());
266    Ok(Arc::new(
267        StructArray::try_new(fields.clone(), cols, Some(nulls))
268            .map_err(|e| BifrostError::Source(format!("cdc struct: {e}")))?,
269    ))
270}
271
272/// Read the change kind of every row of a wide changelog batch.
273///
274/// Fails on a missing / non-string / null `_change_type`, and on any spelling
275/// neither engine defines — never drops the row.
276fn ops_of(wide: &RecordBatch) -> Result<Vec<ChangeOp>> {
277    let idx = wide.schema().index_of(meta::CHANGE_TYPE).map_err(|_| {
278        BifrostError::Source(format!(
279            "changelog batch has no `{}` column",
280            meta::CHANGE_TYPE
281        ))
282    })?;
283    let kinds = wide
284        .column(idx)
285        .as_any()
286        .downcast_ref::<StringArray>()
287        .ok_or_else(|| {
288            BifrostError::Source(format!("`{}` must be a string column", meta::CHANGE_TYPE))
289        })?;
290    (0..wide.num_rows())
291        .map(|r| {
292            if kinds.is_null(r) {
293                return Err(BifrostError::Source(format!(
294                    "null `{}` at row {r}",
295                    meta::CHANGE_TYPE
296                )));
297            }
298            ChangeOp::parse(kinds.value(r)).ok_or_else(|| {
299                BifrostError::Source(format!(
300                    "unknown `{}` `{}` at row {r} — refusing to drop a change row",
301                    meta::CHANGE_TYPE,
302                    kinds.value(r)
303                ))
304            })
305        })
306        .collect()
307}
308
309/// Pull the per-row snapshot id: `_commit_snapshot_id` (Iceberg) or
310/// `_commit_version` (Spark 4.2), falling back to `default_snapshot` for the
311/// pure-Rust skade path, which carries the cursor out-of-band.
312fn snapshots_of(wide: &RecordBatch, default_snapshot: Option<i64>) -> Result<ArrayRef> {
313    for name in [meta::COMMIT_SNAPSHOT_ID, meta::COMMIT_VERSION] {
314        let Some(c) = wide.column_by_name(name) else {
315            continue;
316        };
317        // `_commit_version` is LongType *or* StringType per Spark's contract.
318        return arrow::compute::cast(c.as_ref(), &DataType::Int64)
319            .map_err(|e| BifrostError::Source(format!("cdc snapshot cast {name}: {e}")));
320    }
321    Ok(Arc::new(Int64Array::from(vec![
322        default_snapshot;
323        wide.num_rows()
324    ])))
325}
326
327/// Pull the per-row commit timestamp: `_commit_timestamp` (Spark 4.2) cast to
328/// microseconds UTC, else all-null (Iceberg's changelog view has no timestamp
329/// column — the ordinal is its ordering, and knut carries the snapshot id).
330fn timestamps_of(wide: &RecordBatch) -> Result<ArrayRef> {
331    let want = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()));
332    match wide.column_by_name(meta::COMMIT_TIMESTAMP) {
333        Some(c) => arrow::compute::cast(c.as_ref(), &want)
334            .map_err(|e| BifrostError::Source(format!("cdc ts cast: {e}"))),
335        None => Ok(Arc::new(
336            TimestampMicrosecondArray::from(vec![None::<i64>; wide.num_rows()])
337                .with_timezone("UTC"),
338        )),
339    }
340}
341
342/// **The encoder.** Turn one *wide* changelog batch (data columns +
343/// `_change_type`, optionally `_commit_version` / `_commit_timestamp` /
344/// `_commit_snapshot_id`) into the renderable [`change_row_schema`] shape.
345///
346/// `default_snapshot` fills the `snapshot` column when the batch carries no
347/// per-row snapshot metadata (the skade path, whose cursor is out-of-band).
348///
349/// Every input row becomes exactly one output row — the encoder never merges,
350/// filters or reorders. Call [`fold_updates`] afterwards to pair pre/post-images.
351pub fn encode_change_rows(
352    wide: &RecordBatch,
353    default_snapshot: Option<i64>,
354) -> Result<RecordBatch> {
355    let schema = change_row_schema(wide.schema_ref());
356    let DataType::Struct(fields) = schema.field(1).data_type().clone() else {
357        unreachable!("change_row_schema builds `before` as a Struct");
358    };
359    let ops = ops_of(wide)?;
360
361    let rows: Vec<u32> = (0..wide.num_rows() as u32).collect();
362    let before_valid: Vec<bool> = ops.iter().map(|o| o.has_before()).collect();
363    let after_valid: Vec<bool> = ops.iter().map(|o| o.has_after()).collect();
364
365    let op_col = StringArray::from(ops.iter().map(|o| o.as_str()).collect::<Vec<_>>());
366    let before = image_struct(wide, &fields, &rows, &before_valid)?;
367    let after = image_struct(wide, &fields, &rows, &after_valid)?;
368    let snapshot = snapshots_of(wide, default_snapshot)?;
369    let ts = timestamps_of(wide)?;
370
371    RecordBatch::try_new(schema, vec![Arc::new(op_col), before, after, snapshot, ts])
372        .map_err(|e| BifrostError::Source(format!("cdc encode: {e}")))
373}
374
375/// Pair an `update_before` with its `update_after` into a single `update` row
376/// carrying **both** images — the shape a diff pane actually wants.
377///
378/// Pairing is keyed on `(snapshot, identity columns of the pre-image)`. Rows
379/// that do not pair (an unmatched pre-image, an insert, a delete) pass through
380/// untouched and IN ORDER; an empty `identity` disables folding entirely and
381/// returns the batch unchanged. Nothing is ever dropped.
382pub fn fold_updates(rows: &RecordBatch, identity: &[String]) -> Result<RecordBatch> {
383    if identity.is_empty() || rows.num_rows() == 0 {
384        return Ok(rows.clone());
385    }
386    let op = rows
387        .column_by_name(col::OP)
388        .and_then(|c| c.as_any().downcast_ref::<StringArray>())
389        .ok_or_else(|| BifrostError::Source("change rows missing `op`".into()))?;
390    let before = struct_col(rows, col::BEFORE)?;
391    let after = struct_col(rows, col::AFTER)?;
392    let snapshot = rows
393        .column_by_name(col::SNAPSHOT)
394        .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
395        .ok_or_else(|| BifrostError::Source("change rows missing `snapshot`".into()))?;
396
397    // key → index of a pending `update_before` awaiting its `update_after`.
398    let mut pending: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
399    // Output plan: (op, row supplying `before`, row supplying `after`).
400    let mut plan: Vec<(ChangeOp, Option<usize>, Option<usize>)> = Vec::new();
401    // Where in `plan` a pending pre-image sits, so the matching post-image can
402    // upgrade it in place instead of appending a second row.
403    let mut slot: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
404
405    for r in 0..rows.num_rows() {
406        let kind = ChangeOp::parse(op.value(r))
407            .ok_or_else(|| BifrostError::Source(format!("unknown op `{}`", op.value(r))))?;
408        match kind {
409            ChangeOp::UpdateBefore => {
410                let key = image_key(before, identity, r, snapshot, r)?;
411                slot.insert(key.clone(), plan.len());
412                pending.insert(key, r);
413                plan.push((ChangeOp::UpdateBefore, Some(r), None));
414            }
415            ChangeOp::UpdateAfter => {
416                let key = image_key(after, identity, r, snapshot, r)?;
417                match (pending.remove(&key), slot.remove(&key)) {
418                    (Some(pre), Some(at)) => plan[at] = (ChangeOp::Update, Some(pre), Some(r)),
419                    _ => plan.push((ChangeOp::UpdateAfter, None, Some(r))),
420                }
421            }
422            other => plan.push((
423                other,
424                other.has_before().then_some(r),
425                other.has_after().then_some(r),
426            )),
427        }
428    }
429    if plan.len() == rows.num_rows() {
430        return Ok(rows.clone()); // nothing paired — keep the original allocation
431    }
432
433    // Gather. A `None` side becomes a null image; `take` needs *some* index, so
434    // point it at row 0 and mask it off with the validity buffer.
435    let ops: Vec<&str> = plan.iter().map(|(o, _, _)| o.as_str()).collect();
436    let b_idx: Vec<u32> = plan.iter().map(|(_, b, _)| b.unwrap_or(0) as u32).collect();
437    let b_valid: Vec<bool> = plan.iter().map(|(_, b, _)| b.is_some()).collect();
438    let a_idx: Vec<u32> = plan.iter().map(|(_, _, a)| a.unwrap_or(0) as u32).collect();
439    let a_valid: Vec<bool> = plan.iter().map(|(_, _, a)| a.is_some()).collect();
440    // Snapshot/ts come from whichever side exists (they agree within a pair).
441    let m_idx: Vec<u32> = plan
442        .iter()
443        .map(|(_, b, a)| b.or(*a).unwrap_or(0) as u32)
444        .collect();
445
446    let fields = struct_fields(rows, col::BEFORE)?;
447    RecordBatch::try_new(
448        rows.schema(),
449        vec![
450            Arc::new(StringArray::from(ops)),
451            gather_struct(before, &fields, &b_idx, &b_valid)?,
452            gather_struct(after, &fields, &a_idx, &a_valid)?,
453            take_rows(rows.column(3), &m_idx)?,
454            take_rows(rows.column(4), &m_idx)?,
455        ],
456    )
457    .map_err(|e| BifrostError::Source(format!("cdc fold: {e}")))
458}
459
460fn struct_col<'a>(rows: &'a RecordBatch, name: &str) -> Result<&'a StructArray> {
461    rows.column_by_name(name)
462        .and_then(|c| c.as_any().downcast_ref::<StructArray>())
463        .ok_or_else(|| BifrostError::Source(format!("change rows missing `{name}` struct")))
464}
465
466fn struct_fields(rows: &RecordBatch, name: &str) -> Result<Fields> {
467    match rows
468        .schema()
469        .field_with_name(name)
470        .map(|f| f.data_type().clone())
471    {
472        Ok(DataType::Struct(f)) => Ok(f),
473        _ => Err(BifrostError::Source(format!("`{name}` is not a struct"))),
474    }
475}
476
477/// `(snapshot, identity values)` of row `r` inside an image struct — the fold key.
478fn image_key(
479    image: &StructArray,
480    identity: &[String],
481    r: usize,
482    snapshot: &Int64Array,
483    snap_row: usize,
484) -> Result<String> {
485    let mut key = String::new();
486    if snapshot.is_valid(snap_row) {
487        key.push_str(&snapshot.value(snap_row).to_string());
488    }
489    for name in identity {
490        key.push('\u{1f}');
491        let Some(c) = image.column_by_name(name) else {
492            continue;
493        };
494        key.push_str(
495            &arrow::util::display::array_value_to_string(c, r)
496                .map_err(|e| BifrostError::Source(format!("cdc key {name}: {e}")))?,
497        );
498    }
499    Ok(key)
500}
501
502fn gather_struct(
503    src: &StructArray,
504    fields: &Fields,
505    idx: &[u32],
506    valid: &[bool],
507) -> Result<ArrayRef> {
508    let mut cols: Vec<ArrayRef> = Vec::with_capacity(fields.len());
509    for f in fields.iter() {
510        let c = src
511            .column_by_name(f.name())
512            .ok_or_else(|| BifrostError::Source(format!("image struct missing `{}`", f.name())))?;
513        cols.push(take_rows(c, idx)?);
514    }
515    let nulls = arrow::buffer::NullBuffer::from(valid.to_vec());
516    Ok(Arc::new(
517        StructArray::try_new(fields.clone(), cols, Some(nulls))
518            .map_err(|e| BifrostError::Source(format!("cdc gather: {e}")))?,
519    ))
520}
521
522// ── the ask ─────────────────────────────────────────────────────────────────
523
524/// What a caller asks a [`CdcBackend`] for.
525///
526/// `columns` and `filter` are the **pushdown** half: they narrow what the CDC
527/// read produces, exactly as [`crate::source::TableSpec`] does for a snapshot
528/// read. Before this existed, `SyncMode::Cdc` was full-width by construction —
529/// `SELECT *` from the changelog view / an unprojected `read_changelog` — so the
530/// projection that landed for `SyncMode::Full` did not reach the CDC path at all.
531#[derive(Debug, Clone, Default)]
532pub struct ChangeRequest {
533    /// Fully qualified table name as the engine sees it.
534    pub table: String,
535    /// Exclusive lower bound: the last snapshot/version already applied.
536    /// `None` = from the beginning of the table's history.
537    pub from_snapshot: Option<i64>,
538    /// Inclusive upper bound. `None` = the table's current snapshot/version.
539    pub to_snapshot: Option<i64>,
540    /// The projection. **Empty = every column** (the pre-pushdown behaviour).
541    /// The changelog metadata columns are always retained regardless.
542    pub columns: Vec<String>,
543    /// A row predicate applied to the change rows. `None` = no filter.
544    pub filter: Option<RowFilter>,
545    /// Collapse intermediate states so the window reports the *net* effect per
546    /// row identity (Iceberg `net_changes => true`; Spark 4.2 `netChanges`).
547    pub net_changes: bool,
548    /// Identity columns used to pair pre/post-images in [`fold_updates`]. Empty
549    /// leaves `update_before` / `update_after` as separate rows.
550    pub identity: Vec<String>,
551}
552
553impl ChangeRequest {
554    /// A window over `table` from `from` (exclusive) to the current snapshot.
555    pub fn new(table: impl Into<String>, from: Option<i64>) -> Self {
556        Self {
557            table: table.into(),
558            from_snapshot: from,
559            ..Default::default()
560        }
561    }
562
563    /// Narrow the read to `columns` (empty leaves it unprojected).
564    pub fn with_columns(mut self, columns: Vec<String>) -> Self {
565        self.columns = columns;
566        self
567    }
568
569    /// Push `filter` into the change read.
570    pub fn with_filter(mut self, filter: Option<RowFilter>) -> Self {
571        self.filter = filter;
572        self
573    }
574
575    /// Set the upper bound (inclusive).
576    pub fn to(mut self, to: Option<i64>) -> Self {
577        self.to_snapshot = to;
578        self
579    }
580
581    /// Pair pre/post-images on these identity columns.
582    pub fn with_identity(mut self, identity: Vec<String>) -> Self {
583        self.identity = identity;
584        self
585    }
586
587    /// The projection as the engine must see it: the asked-for columns plus
588    /// every changelog metadata column, which must survive so the encoder can
589    /// read `op` / `snapshot` / `ts`. Empty stays empty (= all columns).
590    pub fn engine_columns(&self) -> Vec<String> {
591        if self.columns.is_empty() {
592            return Vec::new();
593        }
594        let mut out = self.columns.clone();
595        for id in &self.identity {
596            if !out.iter().any(|c| c == id) {
597                out.push(id.clone());
598            }
599        }
600        if let Some(f) = &self.filter
601            && !out.iter().any(|c| c == &f.column)
602        {
603            out.push(f.column.clone());
604        }
605        out
606    }
607}
608
609// ── which backend, and why ──────────────────────────────────────────────────
610
611/// Which CDC implementation a feed is actually running on.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
613#[serde(rename_all = "snake_case")]
614pub enum CdcKind {
615    /// Spark 4.2's native `CHANGES FROM VERSION … TO VERSION …` (SPARK-55668).
616    SparkNative,
617    /// Iceberg's `system.create_changelog_view` procedure, over Spark Connect.
618    IcebergChangelogView,
619    /// `skade::Table::read_changelog` — pure Rust, no Spark.
620    SkadeDelta,
621}
622
623impl CdcKind {
624    pub const fn as_str(self) -> &'static str {
625        match self {
626            CdcKind::SparkNative => "spark_native",
627            CdcKind::IcebergChangelogView => "iceberg_changelog_view",
628            CdcKind::SkadeDelta => "skade_delta",
629        }
630    }
631}
632
633/// **What detection concluded** — the observable half of "pick the backend by
634/// what the server actually is".
635///
636/// This is logged as a `cdc_backend` tracing event and serialised into the
637/// listener's `state_json`, so an operator can read *which* path ran and, when
638/// it is not the one they expected, *why* — instead of inferring it from
639/// timings. A fallback is never silent: [`fell_back`](Self::fell_back) is true
640/// and [`reason`](Self::reason) carries the engine's own error text.
641#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
642pub struct CdcCapability {
643    /// The backend that will run.
644    pub chosen: CdcKind,
645    /// The Spark Connect server's version string, verbatim (`None` off-Spark).
646    pub server_version: Option<String>,
647    /// Parsed `(major, minor)` of `server_version`.
648    pub server_major_minor: Option<(u32, u32)>,
649    /// Is the server new enough that the native CDC *engine* exists (>= 4.2)?
650    pub native_expected: bool,
651    /// Did the native path actually resolve for this table? Spark 4.2 only
652    /// answers `CHANGES` for a connector implementing
653    /// `org.apache.spark.sql.connector.catalog.Changelog`; Iceberg does not.
654    pub native_usable: bool,
655    /// True when [`native_expected`](Self::native_expected) held but
656    /// [`native_usable`](Self::native_usable) did not — the honest degrade.
657    pub fell_back: bool,
658    /// Human-readable justification, including the probe's error when it failed.
659    pub reason: String,
660}
661
662impl CdcCapability {
663    /// The off-Spark, pure-Rust conclusion: skade reads the changelog directly.
664    pub fn skade() -> Self {
665        Self {
666            chosen: CdcKind::SkadeDelta,
667            server_version: None,
668            server_major_minor: None,
669            native_expected: false,
670            native_usable: false,
671            fell_back: false,
672            reason: "no Spark Connect session — reading Iceberg changelog in-process via skade"
673                .into(),
674        }
675    }
676
677    /// The `state_json` / UI projection of this conclusion.
678    pub fn to_json(&self) -> serde_json::Value {
679        serde_json::json!({
680            "cdc_backend": self.chosen.as_str(),
681            "server_version": self.server_version,
682            "server_major_minor": self.server_major_minor.map(|(a, b)| format!("{a}.{b}")),
683            "native_expected": self.native_expected,
684            "native_usable": self.native_usable,
685            "fell_back": self.fell_back,
686            "reason": self.reason,
687        })
688    }
689
690    /// Emit the decision as a structured tracing event (target `bifrost::cdc`).
691    pub fn log(&self) {
692        if self.fell_back {
693            tracing::warn!(
694                target: "bifrost::cdc",
695                event = "cdc_backend",
696                backend = self.chosen.as_str(),
697                server_version = ?self.server_version,
698                native_expected = self.native_expected,
699                native_usable = self.native_usable,
700                fell_back = true,
701                reason = %self.reason,
702                "native Spark CDC was expected but is not usable here — FELL BACK",
703            );
704        } else {
705            tracing::info!(
706                target: "bifrost::cdc",
707                event = "cdc_backend",
708                backend = self.chosen.as_str(),
709                server_version = ?self.server_version,
710                native_expected = self.native_expected,
711                native_usable = self.native_usable,
712                fell_back = false,
713                reason = %self.reason,
714                "selected CDC backend",
715            );
716        }
717    }
718}
719
720/// Parse `"4.2.0"` / `"4.1.2-SNAPSHOT"` → `(4, 2)` / `(4, 1)`.
721pub fn parse_major_minor(v: &str) -> Option<(u32, u32)> {
722    let mut it = v.split(['.', '-', '+']);
723    let major = it.next()?.trim().parse().ok()?;
724    let minor = it.next()?.trim().parse().ok()?;
725    Some((major, minor))
726}
727
728/// Is this Spark new enough to have the native CDC engine (SPARK-55668, 4.2.0)?
729pub fn has_native_cdc_engine(major_minor: (u32, u32)) -> bool {
730    major_minor >= (4, 2)
731}
732
733// ── the seam ────────────────────────────────────────────────────────────────
734
735/// **The one CDC seam.** Every backend answers the same ask with the same
736/// renderable rows, so the listener/UI never branches on which engine ran.
737#[async_trait::async_trait]
738pub trait CdcBackend: Send + Sync {
739    /// Which implementation this is.
740    fn kind(&self) -> CdcKind;
741
742    /// Open a change feed for `req`. The stream yields
743    /// [`change_row_schema`]-shaped batches as they become available.
744    async fn changes(&self, req: &ChangeRequest) -> Result<ChangeStream>;
745}
746
747/// An incremental change feed: renderable batches delivered as they are
748/// produced, so a UI pane paints arriving changes instead of waiting for the
749/// whole scan.
750///
751/// Backed by a bounded channel, so a slow consumer applies backpressure to the
752/// producer rather than growing an unbounded buffer.
753pub struct ChangeStream {
754    /// The renderable schema every batch carries (see [`change_row_schema`]).
755    pub schema: SchemaRef,
756    /// Which backend produced this feed and why (see [`CdcCapability`]).
757    pub capability: CdcCapability,
758    rx: tokio::sync::mpsc::Receiver<Result<RecordBatch>>,
759}
760
761/// The producer half of a [`ChangeStream`].
762pub struct ChangeSink(tokio::sync::mpsc::Sender<Result<RecordBatch>>);
763
764impl ChangeSink {
765    /// Publish one renderable batch. Returns `false` once the consumer is gone.
766    pub async fn send(&self, batch: Result<RecordBatch>) -> bool {
767        self.0.send(batch).await.is_ok()
768    }
769}
770
771impl ChangeStream {
772    /// Create a stream/sink pair with a `capacity`-batch buffer.
773    pub fn channel(
774        schema: SchemaRef,
775        capability: CdcCapability,
776        capacity: usize,
777    ) -> (ChangeSink, ChangeStream) {
778        let (tx, rx) = tokio::sync::mpsc::channel(capacity.max(1));
779        (
780            ChangeSink(tx),
781            ChangeStream {
782                schema,
783                capability,
784                rx,
785            },
786        )
787    }
788
789    /// An already-complete feed — the batches are handed over as-is. Used by
790    /// backends whose engine materialises the whole result before returning
791    /// (Spark Connect's `collect` does); the consumer still sees them arrive
792    /// batch-by-batch.
793    pub fn ready(
794        schema: SchemaRef,
795        capability: CdcCapability,
796        batches: Vec<RecordBatch>,
797    ) -> ChangeStream {
798        let (tx, rx) = tokio::sync::mpsc::channel(batches.len().max(1));
799        for b in batches {
800            // Capacity was sized to the batch count, so this cannot block.
801            let _ = tx.try_send(Ok(b));
802        }
803        drop(tx);
804        ChangeStream {
805            schema,
806            capability,
807            rx,
808        }
809    }
810
811    /// The next renderable batch, or `None` once the feed is exhausted.
812    pub async fn next(&mut self) -> Option<Result<RecordBatch>> {
813        self.rx.recv().await
814    }
815
816    /// Drain the whole feed (tests, benches, and any caller that genuinely
817    /// wants the full window before acting).
818    pub async fn collect(mut self) -> Result<Vec<RecordBatch>> {
819        let mut out = Vec::new();
820        while let Some(b) = self.next().await {
821            out.push(b?);
822        }
823        Ok(out)
824    }
825}
826
827// ── predicate over change rows ──────────────────────────────────────────────
828
829/// Apply a [`RowFilter`] to a *wide* changelog batch, keeping only rows whose
830/// filter column matches.
831///
832/// This is a **post-scan** filter, and is named as such everywhere it is logged:
833/// neither Iceberg's changelog view nor skade's manifest delta accepts a scan
834/// predicate, so knut cannot claim plan-time file skipping here the way
835/// `SyncMode::Full` legitimately can. What it does buy is real: the rows never
836/// reach the encoder, the transforms or the graph sink.
837///
838/// A `delete` row whose non-identity columns are null (skade widens them) is
839/// **kept** when the filter column is null, because dropping it would silently
840/// lose a deletion — the one CDC failure that must never happen quietly.
841pub fn post_filter(wide: &RecordBatch, filter: &RowFilter) -> Result<RecordBatch> {
842    let out = crate::mapping::apply_row_filter_with(
843        std::slice::from_ref(wide),
844        filter,
845        crate::mapping::FilterNulls::Keep,
846    )?;
847    Ok(out.into_iter().next().unwrap_or_else(|| wide.clone()))
848}
849
850/// Bridge: the legacy `_change_type` sentinel name [`SyncMode`] exposes is the
851/// same column this module calls [`meta::CHANGE_TYPE`]. Asserted, not assumed —
852/// the listener's `partition_change_rows` and the CDC encoder must agree or a
853/// delete goes to the wrong pipeline.
854const _: () = assert!(
855    matches!(SyncMode::CHANGE_TYPE_COLUMN.as_bytes(), b"_change_type"),
856    "SyncMode::CHANGE_TYPE_COLUMN must equal cdc::meta::CHANGE_TYPE"
857);
858
859/// The Spark-side backends (`CHANGES` clause and `create_changelog_view`) plus
860/// the capability probe that chooses between them.
861#[cfg(feature = "spark")]
862pub mod spark;
863#[cfg(feature = "spark")]
864pub use spark::{SparkIcebergCdc, SparkNativeCdc, backend_for, detect};
865
866/// The pure-Rust backend: skade reads the Iceberg changelog in-process.
867#[cfg(feature = "skade")]
868pub mod skade;
869#[cfg(feature = "skade")]
870pub use skade::SkadeCdc;
871
872#[cfg(test)]
873mod tests;