Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

.nornir/cdc-reasoning.md

CDC — the research trail and the roads not taken

Sister to cdc-design.md, which names only the chosen design. This file holds the sources the design rests on and the alternatives that were rejected.

Sources (checked 2026-07-22, not recalled)

Spark 4.2 native CDC

claim source
CDC is in 4.2.0, as a CHANGES clause + DataFrame/PySpark/Spark Connect APIs, batch and streaming, plus Auto CDC in Declarative Pipelines Spark 4.2.0 release notes, spark.apache.org/releases/spark-release-4-2-0.html (SPARK-55668, SPARK-56249)
Umbrella issue, Resolved, fix-version 4.2.0, 12 subtasks; example SELECT * FROM my_table CHANGES FROM VERSION 10 TO VERSION 20; changelog schema requires _change_type, _commit_version, _commit_timestamp ASF JIRA SPARK-55668
The DSv2 connector API + analyzer resolution + the SQL clause ASF JIRA SPARK-55948
DataFrame API and Spark Connect support ASF JIRA SPARK-55949
PySpark changes() ASF JIRA SPARK-55950
Exact grammar (changesClause, streamChangesClause, relationPrimary → #changelogTableName) apache/spark tag v4.2.0, sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 lines 928–939, 1119
The connector interface, @since 4.2.0; CHANGE_TYPE_INSERT = "insert", ..DELETE = "delete", ..UPDATE_PREIMAGE = "update_preimage", ..UPDATE_POSTIMAGE = "update_postimage"; _commit_version is Long or String; _commit_timestamp TIMESTAMP; the pushdown contract apache/spark v4.2.0, sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/Changelog.java
Engine-side post-processing (carry-over removal, update detection, netChanges), batch and streaming apache/spark v4.2.0, sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveChangelogTable.scala

Where the docs are wrong-by-omission: the 4.2.0 SQL reference does not index a CHANGES page. docs/latest/sql-ref-syntax.html's Data Retrieval list has no CDC entry and docs/latest/sql-ref-syntax-qry-select-changes.html returns 404. Anyone checking the docs site alone would conclude the feature does not exist. It does; the page is missing. This is why the grammar was read from the source tag.

Iceberg's side

claim source
create_changelog_view produces _change_type (INSERT/DELETE/UPDATE_BEFORE/UPDATE_AFTER), _change_ordinal, _commit_snapshot_id; supports net_changes and identifier_columns Iceberg Spark Procedures docs, iceberg.apache.org/docs/latest/spark-procedures/
Latest release is 1.11.0 iceberg.apache.org/releases/
No iceberg-spark-runtime-4.2_2.13 exists. The published set at 1.11.0 is {3.4,3.5,4.0,4.1}_2.13 (+ older 2.12) Maven Central search API, g:org.apache.iceberg AND a:iceberg-spark-runtime-4.2_2.13 → numFound: 0; a:iceberg-spark-runtime* → the list above
apache/iceberg main has spark/v3.5, spark/v4.0, spark/v4.1 and no v4.2 GitHub contents API on apache/iceberg:spark
#14984 "Spark: Add support for 4.2.0" is OPEN; #16751 "Spark: Add Spark 4.2 module (copy of Spark 4.1)" was closed unmerged on 2026-06-13 GitHub issues/PR API
No Iceberg source implements org.apache.spark.sql.connector.catalog.Changelog GitHub code search, org.apache.spark.sql.connector.catalog.Changelog repo:apache/iceberg → total_count: 0

knut's own tree had already recorded the runtime half of this: containers/spark-iceberg/Containerfile ("no 4.2 runtime yet") and crates/knut-pipelines/src/server.rs (DEFAULT_IMAGE_41, with 4.2.0 GA dated 2026-07-14 as a named alternate). The connector half — that Iceberg implements no Changelog at all, so the clause would not resolve even with a runtime — is new here, and it is the fact that forced detection to be a probe rather than a version comparison.

Alternatives rejected

Choose the backend from config. A cdc_backend: spark_native | iceberg key in the mapping YAML. Rejected: it makes the operator responsible for a fact only the server knows, and it fails in the worst way — a mapping that says spark_native against a 4.1 server errors every round, and one that says iceberg against a future CDC-capable connector silently keeps the slow path forever. Detection is free (one version request + one zero-row probe, cached per table).

Choose from the version string alone. >= 4.2 → native. Rejected by the Iceberg finding above: the engine having CDC and this table having CDC are different questions, and today the answers differ. A version-only check would have shipped a path that errors on every real table.

Trust the probe's absence of an error as "it works". The probe is … CHANGES FROM VERSION 0 LIMIT 0. It proves the clause resolves — the analyzer found a Changelog on the connector — which is exactly the question. It does not prove the data is correct; the round-trip test does that.

One _change_type vocabulary, imposed. Normalise everything to Iceberg's UPPER-CASE spelling at the source and delete the Spark one. Rejected: the wide batches flow to the listener untouched (that is what makes the pushdown a pure narrowing), so the spellings genuinely coexist in the pipeline. Making ChangeOp::parse accept both, and canonicalising only at the renderable edge, keeps one vocabulary without rewriting anyone's data.

A Vec<ChangeRow> struct for the UI. Rejected: the whole pipeline is Arrow, FalkorDB's loader takes RecordBatch, and a row-struct would force a transpose per batch and lose zero-copy. The Struct columns give the UI the same per-row ergonomics with none of that.

futures::Stream for the feed. Rejected: futures is an optional dep (feature flight) and the core is deliberately dependency-light. tokio is already a hard dep, so tokio::sync::mpsc gives bounded, backpressuring delivery for free. If a Stream impl is ever wanted it is a wrapper, not a rewrite.

A second incremental reader in knut. Rejected outright (L5): skade owns read_delta / read_changelog / project_columns and knut consumes them. Where skade's surface is missing something (a projection on the delta scan), that is logged as skade-side work rather than worked around with a private copy.

A second row-filter implementation for CDC. Nearly shipped, then rejected. 89d7e31 had just landed mapping::apply_row_filter, and the CDC path needed one behavioural difference: a row whose filter column is NULL must be kept (that is a widened DELETE), whereas the scan-planner semantics every other mode must match is drop. Twinning the kernel lowering to get that would have been two predicates that could drift. Instead FilterNulls { Drop, Keep } was added to the one implementation. Same code, one explicit policy argument, and the difference is now a named concept instead of an accident.

The bug this work uncovered

mapping::apply_row_filter ends in arrow::compute::filter_record_batch, which treats a NULL mask entry as false — the row is dropped. Since a CDC DELETE row carries nulls in every non-identity column, a mapping with any filter: in mode: cdc silently dropped every delete. It was live on main and no test covered it. See cdc-design.md §f4 and the RED-when-broken test cdc::post_filter_keeps_unjudgeable_rows_where_the_scan_planner_would_drop_them.

That is the concrete argument for why "never lose a delete" is the invariant the CDC module is organised around, rather than a slogan: the failure is one default argument away, it produces no error, and the graph just quietly keeps a node that no longer exists.