Skip to content

iceberg: add a copy-on-write merge strategy to the output - #4666

Draft
Jeffail wants to merge 12 commits into
mainfrom
iceberg-cow-merge-strategy
Draft

iceberg: add a copy-on-write merge strategy to the output#4666
Jeffail wants to merge 12 commits into
mainfrom
iceberg-cow-merge-strategy

Conversation

@Jeffail

@Jeffail Jeffail commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The iceberg output's row-level upsert/delete support writes merge-on-read equality-delete files, which several engine-backed catalogs can't read — Snowflake's engine ignores equality deletes entirely, and the Databricks Unity Catalog rejects table creation carrying identifier-field-ids and can't read v2 delete files at all. So the current mutation support only lands correctly on catalog-only targets (Polaris and friends), which came up in the field pretty quickly.

This adds an opt-in merge_strategy: copy-on-write that materialises mutations by rewriting whole data files instead — the table only ever contains plain data files, which every engine can read. Under copy-on-write the identifier_fields act as a connector-side merge key only and aren't registered as Iceberg identifier-field-ids, which is what lets Unity Catalog accept the auto-created tables. The default stays merge-on-read; existing configs are untouched.

Support matrix: all flat primitive column types plus nested struct/list/map; merge keys of int/long/string/date/time/timestamp/timestamptz/uuid (decimal and boolean keys are rejected with actionable errors — upstream filter-rewrite limitations); partitioned tables including transforms and cross-partition key moves; works on v1 or v2 tables without forcing the irreversible v1→v2 upgrade. The trade-off is write amplification (each mutating batch rewrites every file containing a touched key), so it's documented as a batch/moderate-throughput mode with a decision guide vs merge-on-read.

Behaviour changes reviewers should look at first:

  1. Timestamp parquet encoding (all writers, not just copy-on-write): no-tz timestamp columns were written with isAdjustedToUTC=true, which is spec-incorrect and made them read back as timestamptz. New files are spec-correct — but to guarantee an existing table never changes or mixes encodings, the choice is pinned per table via a redpanda-connect.timestamp-encoding property (stamped at creation for new tables, footer-probed once and pinned for pre-existing ones). Copy-on-write against a legacy-pinned table fails upfront with a migration message.
  2. Mutation commits are now idempotent across ambiguous catalog responses (a commit-id token in the snapshot summary). This is a bug fix: previously an ambiguous response was surfaced as an error and at-least-once redelivery could duplicate insert rows. Mutation snapshots now carry a redpanda-connect.commit-id summary property.
  3. Catalog-prohibited table properties (e.g. Unity Catalog forbids clients setting schema.name-mapping.default, which the copy-on-write library path injects) are learned from the catalog's rejection and stripped from commits, with the connector's own properties exempt.

Validation: full unit coverage (round-trip fidelity tests per column/key type, failure-path and idempotency tests incl. landed-but-unreported commits, race-checked concurrency); 13 integration tests against a REST catalog with DuckDB as an independent reader (zero-delete-files asserted throughout); and a live Databricks Unity Catalog run via the new e2e/databricks harness — the copy-on-write round-trip reads back exactly through Databricks SQL (timestamp_ntz/timestamp typed correctly), the identifier-field rejection reproduces verbatim, and equality-delete commits are confirmed rejected loudly. Commit-latency benchmarks against the live catalog are included in the docs guidance.

Draft while I collect review; the e2e harness needs live credentials so it skips by default. Happy to split anything out if the reviewers would rather take this in pieces.

🤖 Generated with Claude Code

Jeffail added 9 commits August 3, 2026 16:39
The row-level upsert/delete path writes merge-on-read equality-delete
files, which several query engines can't read — notably Snowflake and the
Databricks Unity Catalog, and UC also rejects tables carrying
identifier-field-ids. This adds an opt-in `merge_strategy: copy-on-write`
that rewrites whole data files so the table only ever holds plain data
files any engine can read, and skips registering identifier-field-ids on
auto-created tables. The default stays merge-on-read, so existing configs
are unaffected.

copy-on-write trades write amplification for read compatibility (each
mutating batch rewrites every data file holding a touched key), so it's a
batch/moderate-throughput mode. The prototype supports flat-primitive
schemas with int/long/string/boolean merge keys on unpartitioned tables;
other cases fail loudly or stay on merge-on-read. New columns in a
mutating batch trigger schema evolution (parity with the append path),
and files left behind by a failed copy-on-write commit are cleaned up.
Extends the opt-in copy-on-write merge strategy from the initial
flat-primitive/unpartitioned prototype to the breadth merge-on-read
already supports, and tightens a couple of edges:

- Column types: adds binary/fixed and nested struct/list/map (whose
  leaves must be supported primitives), via a recursive value massager
  that applies the same canonicalisation at every depth. This fixes
  silent truncation of integers nested beyond 2^53 and reshapes maps to
  the Arrow map JSON encoding.
- Merge-key types: adds date/time/timestamp/timestamptz/uuid, each with a
  round-trip test that proves the filter matched the intended row (not
  just "no error"). Decimal merge keys are rejected up front with a clear
  message — an upstream substrait conversion panics on a decimal filter
  literal — pointing at merge-on-read instead.
- Partitioned tables: supported. Copy-on-write rewrites by filter and
  appends value-routed rows, so it needs no partition-vs-identifier
  constraint and can even move a key across partitions.
- Format version: copy-on-write no longer forces the irreversible v1->v2
  upgrade, since it only ever writes plain data files.

Also adds scale and memory benchmarks and a docs pass: a merge-on-read vs
copy-on-write decision guide, a worked Snowflake/Databricks example, a
maintenance runbook, and the current limitations.
Retry idempotency: an ambiguous catalog response (ErrCommitStateUnknown,
e.g. a 5xx/timeout after the write) previously failed the batch on the
mutation paths, since blindly retrying a possibly-landed commit could
duplicate it. Each mutation commit now stamps a per-call commit-id token
into its snapshot summary; on a retry after a reload, if a snapshot
already carries the token the prior attempt landed, so the commit returns
success instead of re-applying. This makes both copy-on-write
(Overwrite/Delete) and merge-on-read (RowDelta) safe to retry on an
unknown state. The append path is unchanged — it is already idempotent via
file-path dedupe.

Also:
- multi-writer concurrency test: two committers doing copy-on-write on one
  table converge via the optimistic-concurrency conflict + retry path with
  no lost updates (race-clean).
- partition-transform integration coverage: truncate plus day/month
  temporal transforms round-trip under copy-on-write (identity and bucket
  were already covered), leaving zero delete files.
- a one-time startup log when copy-on-write is configured with a mutating
  operation, noting the write-amplification trade-off and the sort-by-key /
  large-batch mitigations.
…, coverage

Acts on a full review of the copy-on-write merge strategy. Two behavioural
fixes and a large coverage expansion; no change to the merge-on-read or
append defaults.

Correctness:
- The copy-on-write rewrite rejected numeric-epoch temporal values in data
  columns, diverging from the insert path and ignoring schema_metadata (so a
  CDC stream carrying epoch-number timestamps would create the table, ingest
  the first insert, then fail every upsert). The rewrite now runs numeric
  temporals through the same schema-metadata/unit-aware conversion the
  shredder uses, via a shared helper with an equivalence test that keeps the
  two paths from drifting. Temporal merge keys stay strict (a bare number is
  still rejected up front) so the filter literal can never disagree with the
  stored value.
- No-timezone `timestamp` columns were written to parquet as UTC-adjusted
  (isAdjustedToUTC=true), so they read back as `timestamptz` and a
  copy-on-write file rewrite failed with "cannot promote timestamptz to
  timestamp". No-tz timestamps are now written with isAdjustedToUTC=false per
  the Iceberg spec; `timestamptz` is unchanged. (Tables whose data files were
  written before this fix keep the old annotation and would need their data
  rewritten — noted in the tests.)
- Orphaned parquet files written by a copy-on-write attempt that hit a clean
  conflict and then succeeded on retry are now cleaned up (previously only the
  final-failure path cleaned up); cleanup is still skipped on an ambiguous
  (possibly-landed) commit. max_retries gained a lower bound.

Boolean merge keys join decimal as gated (an upstream filter-rewrite
limitation); boolean/decimal remain fine as ordinary columns.

Coverage: numeric/production merge-key input shapes, case-insensitive
matching, malformed nested input, the schema-evolution evolve-and-retry loop,
the terminal-unknown cleanup-skip and lost-ack retry paths, runtime-warning
wiring, and a set of copy-on-write integration tests that read back through
DuckDB (an independent engine) — merge-key types, nested struct/list/map,
schema evolution, v1 tables, delete-only and cross-partition key moves, and
multi-file rewrites.
…ange

The earlier spec fix (no-timezone `timestamp` columns written with
isAdjustedToUTC=false) would silently change the on-disk encoding of
existing tables, leaving them with mixed parquet annotations after an
upgrade. This makes the choice per-table, permanent, and visible via the
table property `redpanda-connect.timestamp-encoding`:

- Tables created by this output are pinned to `spec` at creation.
- An existing table without the property is bootstrapped on first write
  by probing a data file's parquet footer: files carrying the old
  UTC-adjusted annotation pin the table to `legacy`, so its encoding
  never changes and never mixes; an empty table, a schema without no-tz
  timestamp columns, or spec-encoded files pin `spec`. The resolved
  value is stamped onto the table so every future writer agrees; probe
  or stamp failures are hard errors rather than guesses.
- `legacy` writes byte-identical output to pre-fix releases;
  `timestamptz` columns are unaffected in both modes.

Mutating copy-on-write against a legacy-pinned table with no-tz
timestamp columns now fails upfront with a migration message (compact
the data files, then set the property to `spec`) instead of surfacing
iceberg-go's "cannot promote timestamptz to timestamp" mid-commit.
Insert-only copy-on-write and merge-on-read keep writing the table's
own encoding and are unaffected.
A scripted setup/test/teardown harness for validating the iceberg
output against a real Databricks Unity Catalog, following the existing
e2e pattern (terraform + Taskfile + flag-driven tests driving the real
router). Terraform provisions an isolated random-suffixed catalog, a
pre-created schema, a 2X-Small serverless SQL warehouse with one-minute
auto-stop, and the grants an external Iceberg REST client needs
(including EXTERNAL USE SCHEMA); teardown is a terraform destroy.

The tests cover the questions that only a real engine-backed catalog
can answer: a copy-on-write insert/upsert/delete round-trip read back
through Databricks' own SQL engine (via the statement-execution API,
asserting exact final state, timestamp types, and zero delete files); a
reproduction of Unity Catalog rejecting table creation that registers
identifier-field-ids (which fails loudly in reverse if that behaviour
ever changes); a non-fatal diagnostic recording how Unity Catalog
actually handles a merge-on-read equality-delete commit; and a
flag-gated commit-latency benchmark.

The auth token travels via environment only — never terraform state,
outputs, or flags. Everything skips cleanly when unconfigured, and the
attributes that cannot be verified without live workspace access are
marked in comments and the README.
Databricks workspaces on default (Databricks-managed) storage cannot
vend credentials to external Iceberg REST clients, so the harness's
tables must live in a catalog backed by customer-owned S3 — which is
also the fix when a metastore has no storage root. A new opt-in
`create_storage` variable provisions the full chain: S3 bucket, an IAM
role built from the provider's Unity Catalog assume-role-policy data
sources (the documented way around the credential/role trust-policy
circular dependency), a storage credential, an external location, and
the catalog's storage root resolving to it (an explicit storage_root
variable still wins; otherwise the metastore root is inherited).

The AWS provider is only truly configured when storage creation is
enabled, so the default path still needs no AWS setup at all. The
README gains a trial-account quick-start covering the express-trial
signup, the metastore external-access toggle, and the default-storage
limitation that makes customer-owned storage necessary; the provider
lock file is now tracked, matching the sibling harnesses.
Unity Catalog rejects commits whose property updates carry keys it
manages itself — iceberg-go's copy-on-write path defensively sets
schema.name-mapping.default on tables without a name mapping, which UC
prohibits external clients from writing, so every mutating copy-on-write
commit against a UC table failed with "Table properties contain
prohibited keys".

The committer now learns prohibited keys from the catalog's own
rejection (parsing the keys named in the error), warns once per key, and
retries with those keys stripped from set-properties updates — via a
CatalogIO wrapper that filters only property updates and passes every
other update through untouched. Stripping the name mapping is safe: it
exists as a read fallback for data files without parquet field IDs,
which the Iceberg spec requires and this output always writes. The
connector's own redpanda-connect.* properties are never stripped — a
catalog prohibiting those fails loudly instead, since silently dropping
the timestamp-encoding pin would change write semantics.

NewCommitter now takes the table's catalog explicitly (the table type
exposes no accessor for it), provided by catalogx so reloaded tables are
rebound onto the wrapper too.

Verified live against a Databricks Unity Catalog trial workspace: the
first mutating commit is rejected, the key is learned and stripped, and
the retried commit lands; the copy-on-write round-trip then reads back
correctly through Databricks SQL with zero delete files.
Matches the short-description convention the other row-operation fields
gained upstream.
Comment on lines +388 to +393
case iceberg.Int32Type:
n, err := cowValueToInt64(v)
if err != nil {
return nil, fmt.Errorf("%s %q: %w", ioFieldIdentifierFields, name, err)
}
return iceberg.NewLiteral(int32(n)), nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent int32 wrap can build a filter that deletes the wrong row.

cowValueToInt64 only guards against float64 precision loss (>= 1<<53); it never bounds the result to int32. For an int (Iceberg Int32Type) merge key, int32(n) then wraps silently: a message with {"id": 4294967297} (decoded as json.Number, so the float64 guard never fires) yields int32(4294967297) == 1, and buildCOWFilter produces id IN (1).

On a delete-only copy-on-write batch there is no NewReader, so nothing else validates the value — commitOverwrite runs txn.Delete with that filter and removes the row with id = 1. That is silent data loss on an unrelated row, and it's exactly the "filter must agree with the stored value" invariant the rest of this function is written to protect (the doc comment calls it the CON-490 hazard, and the sibling decimal/boolean cases fail loudly rather than risk it).

Suggested fix: range-check before narrowing — reject values outside [math.MinInt32, math.MaxInt32] for an Int32Type key with an actionable error naming identifier_fields and the offending value, in the same style as the other rejections here.

Ref: cow.go#L386-L399, and the no-silent-corruption bar in CONTRIBUTING.md §3.1.4 / §3.2.2.

//
// Cleanup is best-effort: skipped when the filesystem can't be listed
// (before == nil).
if before != nil && !errors.Is(err, rest.ErrCommitStateUnknown) && (err != nil || retried) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diff-based orphan cleanup can delete a concurrent committer's in-flight files.

The safety argument in the comment above holds only for committed files. referencedDataFilePaths reads c.table's current snapshot, so it protects files that some snapshot already points at — but a second committer writing copy-on-write to the same table has, in the window between txn.Overwrite writing its parquet and its catalog commit landing, files that are (a) newer than our before snapshot and (b) referenced by no snapshot at all. Both cleanup triggers on this line reach them:

  • retried success: committer A conflicts, reloads, succeeds on attempt 2, then walks the data dir. Committer B is mid-Overwrite for its next batch → B's freshly written files are diffed out and removed → B's commit lands referencing deleted files.
  • err != nil: same window, and this path cleans unconditionally.

The comment explicitly reasons that "by the time it succeeds c.table has been reloaded onto the latest committed state, so referencedDataFilePaths protects every live file" — that's true of committed files but not of another writer's uncommitted ones, which is the case the first-attempt guard was added to avoid. TestCOWConcurrentCommittersConverge doesn't cover it: there, the winner has fully committed before the loser's cleanup runs.

Note this is reachable with a single pipeline too (two Connect instances, or two iceberg outputs, against one table); max_in_flight: 1 only serialises within one output.

Suggested direction: scope cleanup to paths this committer actually authored rather than a directory diff (e.g. have the overwrite stage record the files it wrote, or restrict the diff to a per-commit path prefix), so cleanup can never touch a file another writer produced.

Ref: committer.go#L336-L352, cleanupOrphanedOverwriteFiles, CONTRIBUTING.md §3.1.4.

Jeffail added 2 commits August 4, 2026 16:40
This change roughly triples the package's integration test count, so the
runner's default five-minute go test timeout no longer fits — the run was
killed mid-test in CI. Grant the package fifteen minutes, in line with
the other larger suites. Also rewords a few code comments to describe
their hazards directly rather than by internal reference.
Review follow-ups on the newest machinery:

- The prohibited-keys error parse now requires the colon-delimited list
  form, stops at the first token that isn't shaped like a property key,
  and only learns keys the failed commit actually sent — so prose or
  unrelated errors mentioning the phrase can no longer poison the strip
  set. The reserved-prefix refusal is case-insensitive.
- Learned prohibited keys now live on the router's table entry and seed
  each new committer, so writer recreation (schema evolution, transient
  errors) no longer costs a rejected commit per generation to re-learn
  them — which also removes a latent no-progress loop against catalogs
  that name one key per rejection.
- An ambiguous commit response is handled by the idempotency check even
  when its text mentions prohibited keys, so a landed-but-unreported
  commit can never be retried without the duplicate guard.
- NewCommitter rejects a nil reload function instead of panicking later.
- The copy-on-write legacy-encoding guard and docs now tell operators to
  stop connector writers around an encoding migration, and comments on
  the stamp path describe the REST-catalog race semantics honestly.
- Adds the previously-unexercised probe test for a table whose
  timestamptz column sorts ahead of its timestamp column.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant