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;