Gunnar

nordisk/knut

.nornir/cdc-reasoning.md

<!-- nornir:class: reasoning -->
# 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.