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

nordisk/skade

.nornir/merge-into-design.md

MERGE INTO — the row-level write path

Status: landed 2026-09-03, lane SK. Builds on lane FR (b4613a9, the InsertOp refusal) and lane FR's merge-on-read proof.

The hole this fills

Before this, skade could append and it could delete by equality. It could not change a row in place through SQL. Concretely:

verb before after
INSERT INTO append (works) unchanged
INSERT OVERWRITE refused by name (lane FR) still refused — an INSERT names no keys to overwrite
skade::Table::delete_equality equality-delete file (works) unchanged; this is the merge-on-read half
MERGE INTO planned by DataFusion, refused at execution: MERGE INTO operation on table 'tgt' executes, copy-on-write

The refusal was not a skade decision. DataFusion 55 plans MERGE INTO end to end — datafusion-sql/src/statement.rs:2422 merge_to_plan → WriteOp::MergeInto → datafusion/src/physical_planner.rs:838 — and then calls TableProvider::merge_into, whose default is not_impl_err!. No provider in this tree implemented it. And it could not: the vendored iceberg crate's src/transaction/ had exactly one data verb, fast_append. There was no overwrite, rewrite-files, RowDelta or delete-file action, and upstream iceberg-rust 0.10.1 (newest, Aug 2026) still has none — issues #1104 "Support RowDeltaAction" and #735 "Delete support" are open with prerequisites in progress.

So this lane is two pieces that only work together:

  1. iceberg::transaction::OverwriteFilesAction — a real Iceberg overwrite commit, in the vendored core.
  2. IcebergTableProvider::merge_into — the DataFusion hook that drives it.

Piece 1 — OverwriteFilesAction

vendor/iceberg-arrow59/src/transaction/overwrite.rs, beside append.rs, in the same shape.

The generic machinery was already there and already named the operation: SnapshotProducer::summary (snapshot.rs:421) branches on operation() == Operation::Overwrite, and the SnapshotProduceOperation trait doc (snapshot.rs:80) says an overwrite "may exclude manifests for partitions being overwritten". existing_manifest IS the seam where replaced files are dropped. FastAppendOperation uses it to carry everything forward; this uses it to carry most things forward.

let tx = Transaction::new(&table);
tx.overwrite_files()
    .add_data_files(new_files)          // the rewritten rows
    .delete_files(replaced_paths)       // the files they replace
    .apply(tx)?
    .commit(catalog)
    .await?;

One snapshot, operation = overwrite, one catalog pointer swap.

How a data file is removed

A manifest is immutable, so removing a file means writing a new manifest in its place. Per data manifest in the current snapshot's manifest list:

live entries hit by delete_files what happens
none carried forward byte-identical — no rewrite, no read amplification
some, not all rewritten: survivors as Existing (original snapshot id + both sequence numbers preserved), removed ones as Deleted
all the manifest is dropped from the list

Dropping a wholly-dead manifest is what FastAppendOperation::existing_manifest already does (has_added_files() || has_existing_files()), and it is what stops a table merged N times from accumulating N empty manifests. The vendored crate has no expire_snapshots, so nothing needs the dropped tombstones.

Delete manifests (ManifestContentType::Deletes — the equality-delete files delete_equality writes) are carried forward untouched. This action replaces DATA files; it does not reason about somebody else's merge-on-read tombstones.

The three refusals inside the action

Piece 2 — IcebergTableProvider::merge_into

vendor/iceberg-datafusion-arrow59/src/physical_plan/merge.rs + the hook in src/table/mod.rs.

DataFusion hands the provider (datafusion-session-55.0.0/src/table.rs:394):

Every expression is compiled to a PhysicalExpr at plan time, against the caller's &dyn Session. That is the only point where a user-defined function inside a clause can still be resolved: the returned plan executes with a TaskContext only.

Execution, per data file

for each live data file F:
    read F's rows            (through ArrowReader → schema evolution + any
                              committed equality-delete file already applied)
    FULL hash join F ⋈ source on the ON equality keys
    per joined row, pick the FIRST clause of the applicable category whose
      predicate holds
    if no row of F was updated or deleted  →  leave F alone
    else                                   →  write F's surviving/updated rows
                                              to a new data file, mark F removed
then: source rows that matched nothing anywhere → the INSERT clauses → one more
      new data file
then: ONE overwrite_files commit, or none at all

The join is HashJoinExec in PartitionMode::CollectLeft with the file on the build side, so the cost is O(rows in F + source rows) per file rather than a nested loop. Unmatched-vs-NULL is disambiguated by a synthetic non-null ordinal appended to the END of each side (__iceberg_merge_target_ordinal, __iceberg_merge_source_ordinal), so the real columns keep the indices merge_schema gave them; the two ordinals are projected back out before any compiled expression sees the batch.

The count

The single count: UInt64 row is updated + deleted + inserted, each counted off the work that produced the commit. It is never the input row count. The test the_count_is_the_warehouses_not_the_inputs pins it in both directions — one merge whose count is below the source row count (4 source rows, 2 matched → 2) and one above it (1 source row, 3 target rows deleted → 3). Returning source.num_rows() fails the first; returning the target row count fails both.

What executes and what refuses, by name

shape verdict
WHEN MATCHED THEN UPDATE SET … executes
WHEN MATCHED THEN DELETE executes
WHEN MATCHED AND <pred> THEN … executes
WHEN NOT MATCHED [BY TARGET] THEN INSERT (cols) VALUES (…) executes
WHEN NOT MATCHED [BY TARGET] THEN INSERT VALUES (…) (all columns) executes
WHEN NOT MATCHED BY SOURCE THEN UPDATE SET … executes
WHEN NOT MATCHED BY SOURCE THEN DELETE executes
ON that is not a conjunction of target.col = source.col refused: names the shape it wanted
ON whose two key columns have different types refused: names both types
a partitioned target table refused: a rewritten row needs its partition values recomputed
WHEN NOT MATCHED BY SOURCE THEN INSERT refused: names no row to act on
WHEN NOT MATCHED THEN DELETE refused by sqlparser, before DataFusion
one target row matched by two source rows refused: the result would depend on which won
a non-Parquet data file refused by name

NotMatched and NotMatchedByTarget are collapsed through MergeIntoClauseKind::canonical(), as the DataFusion type doc requires.

Copy-on-write versus merge-on-read — the measurement

skade-sql/tests/merge_cow_vs_mor.rs, oden, 2026-09-03, release build, local filesystem. 200 000 rows in 10 data files; 8 rounds of 100 updated keys.

Keys SPREAD across all 10 files (worst case for copy-on-write):

round cow ms cow MB cow read ms mor ms mor MB mor read ms
1 66 1.91 7 2 0.03 38
2 40 1.91 5 2 0.04 59
4 34 1.91 4 2 0.04 87
6 34 1.91 4 2 0.05 130
8 33 1.92 4 2 0.06 173

after 8 rounds on disk: cow 18.8 MB · mor 3.9 MB

Keys CONCENTRATED in 1 file (best case for copy-on-write):

round cow ms cow MB cow read ms mor ms mor MB mor read ms
1 39 0.20 9 2 0.03 33
4 36 0.21 12 1 0.04 112
8 54 0.21 13 2 0.06 207

after 8 rounds on disk: cow 5.2 MB · mor 3.9 MB

Read it honestly, in both directions:

Copy-on-write is the default, and MERGE INTO is copy-on-write. The argument is the read column, and it is measured rather than asserted: merge-on-read's read cost is not a constant tax, it is a slope, and a warehouse table is read far more than it is merged.

Merge-on-read stays available and unchanged as skade::Table::delete_equality + append, and it is the right call for exactly two shapes: a high-frequency CDC sink where the reader is a delta consumer rather than a full scan, and a table compacted often enough that the delete files never accumulate. Neither is reachable from SQL, on purpose — MERGE INTO should not silently pick the mechanism that makes the next reader slower.

What is NOT done

Upstreamability

overwrite.rs (≈330 lines) has no skade type, no korp vocabulary and no assumption about skade's catalog in it — it is an Iceberg transaction action that happens to live here, written to be offered against iceberg-rust #1104 / #735. The only edit it needs in an existing upstream file is three pub(crate) accessors on SnapshotProducer (snapshot_id, commit_uuid, key_metadata), because existing_manifest is handed &self and cannot reach the &mut self manifest-name counter, plus two lines in transaction/mod.rs.

merge.rs is upstreamable in the same sense but against a moving target: it is written for DataFusion 55's merge_into hook, which is newer than any iceberg-datafusion release.