Support format-version 3 in icebergRewrite (preserve row lineage)#24
Conversation
A v3 rewrite must not renumber existing rows: each surviving row's _row_id and _last_updated_sequence_number are now materialized as explicit columns (reserved field ids 2147483540 / 2147483539) in the rewritten parquet files, since the global sort destroys the positional ordering that derivation relies on. When every live row carries lineage, the new manifest's first_row_id is pinned to the minimum carried _row_id so assignFirstRowIds consumes no new ids and next-row-id does not advance. Tables upgraded from v2 whose rows were never assigned ids fall back to spec-default commit-time assignment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dual-agent review —
|
| Source | Finding (severity, evidence) | Intersects |
|---|---|---|
| Claude | README still documents icebergRewrite as v2-only (minor, README.md:176, README.md:227) | No — doc-only; outside the impact surface |
Codex review
Fix Validations
v3 rewrite previously rejected / row lineage not preserved
- Status: correct
- Evidence:
src/write/rewrite.js:59,src/write/rewrite.js:117,src/write/rewrite.js:198,src/write/snapshot.js:72,test/write/rewrite.test.js:233 - Assessment: The guard now permits format-version 3, v3 rewrites write reserved lineage columns, and pure rewrites pin the manifest so
assignFirstRowIdsassigns zero new rows. The tests cover sorted rewrites, split files, deletion vectors, rewrite-of-rewrite, upgraded-table fallback, and stalenext-row-idrejection.
upgraded v2 to v3 rows without lineage get commit-time assignment
- Status: correct
- Evidence:
src/write/rewrite.js:97,src/write/rewrite.js:198,src/write/snapshot.js:162,src/read.js:608,test/write/rewrite.test.js:339 - Assessment: Mixed or null lineage leaves
first_row_idunset on the new manifest, so the shared v3 assignment path assigns a fresh range and the read path derives values only where stored lineage is null.
Findings
No new issues identified.
No Finding
- Behavioral Correctness
- Contract & Interface Fidelity
- Change Impact / Blast Radius
- Concurrency, Ordering & State Safety
- Error Handling & Resilience
- Security Surface
- Resource Lifecycle & Cleanup
- Release Safety
- Test Evidence Quality
- Architectural Consistency
- Debuggability & Operability
Evidence Bundle
- Changed hot paths:
icebergStageRewritev2/v3 gate and lineage write schema atsrc/write/rewrite.js:59,src/write/rewrite.js:117; manifest row-id pinning atsrc/write/rewrite.js:198; shared v3 row-id assignment atsrc/write/snapshot.js:72. - Impacted callers: one-call rewrite API exercises staged rewrite via
test/write/rewrite.test.js:386; commit applies staged requirements and snapshot updates atsrc/write/commit.js:41,src/write/commit.js:222. - Impacted tests: v3 preservation
test/write/rewrite.test.js:233; split outputtest/write/rewrite.test.js:262; deletion vector rewritetest/write/rewrite.test.js:276; append after rewritetest/write/rewrite.test.js:306; fallback assignmenttest/write/rewrite.test.js:339; next-row-id conflicttest/write/rewrite.test.js:365. - Unresolved uncertainty: I did not independently verify the Parquet physical schema writer beyond the diff’s use of
writeSchema; the provided tests also do not assert physical field IDs directly.
Claude review
Claude review
README still documents icebergRewrite as v2-only
- Severity: minor
- Confidence: 85
- Evidence: README.md:176, README.md:227
- Why it matters: The PR's headline feature is v3 rewrite support, but the user-facing docs still say "via a
replacesnapshot (v2 tables)" and "icebergRewritecompacts to sorted, non-overlapping files (v2)" — the restriction was added in the same commit as the now-removed guard (3edb15b), and this PR lifts the guard and the test but leaves the README advertising the restriction it removed. - Suggested fix: Update README.md lines 176 and 227 to state v2 and v3 are supported, noting that v3 rewrites preserve row lineage.
Reports: .git/dual-review/pr-24
🧭 Decision map — where to spend your attentionCompanion to the dual-review verdict. This casts no verdict — it points at the 4 forks where the author made a real choice, so you can skim the rest. Scanned: 10 hunks across 2 files. Most is mechanical: ~140 added test lines (the 1. Pin the rewritten manifest's
|
Implements the v3 rewrite follow-up deferred in #23:
icebergRewritenow works on format-version 3 tables and preserves row lineage exactly, instead of throwingformat-version 2 only.The problem
Iceberg v3 gives every row a stable
_row_idand_last_updated_sequence_number, normally derived from the data file'sfirst_row_id+ row position. Rewrite reads every live row, sorts globally, and regroups — which destroys the positional ordering derivation relies on. LettingassignFirstRowIdshand the rewritten manifest a fresh id range would renumber every row, making lineage consumers see the whole table as deleted-and-reinserted.The spec's rules for moved rows (format spec, Row Lineage): a row's existing non-null
_row_idmust be copied into the new data file, and — since rewrite doesn't modify rows — its existing non-null_last_updated_sequence_numbermust be copied too. Commit-timefirst_row_idassignment to a manifest "is optional if the writer knows that existing data files in the manifest have assigned first_row_id values."What this PR does
All production changes are in
src/write/rewrite.js; everything else already did its half (the read path surfaces lineage per row with stored-value-wins semantics, the parquet writer threads field ids and embedsiceberg.schema, andassignFirstRowIdsskips manifests that already have afirst_row_id)._row_id/_last_updated_sequence_number(reserved field ids 2147483540 / 2147483539, per-spec). Values are copied through the sort untouched. The field ids land both in the parquetSchemaElements (external-reader interop) and in the embeddediceberg.schemakv metadata (icebird's own discovery path). Stats, partitioning, and the manifest's embedded schema stay user-only.first_row_idis pinned to the minimum carried_row_id. Carried ids are distinct and belownext-row-id, somin + row count ≤ next-row-id:assignFirstRowIdsassigns nothing, the snapshot getsadded-rows: 0, andnext-row-iddoes not advance. Per-filefirst_row_idstays null; inherited values are never consulted because every stored_row_idis non-null.next-row-idadvances by the manifest row count — matching the spec's worked example.buildSnapshotUpdatealready emitsassert-next-row-idfor v3, so a rewrite staged against stale metadata fails on commit if a concurrent v3 append moved the id watermark (on top of the existing CAS onmain).prepareAppend(unsupported format-version).Tests & verification
npm run lintandnpm run build:typesclean. v2 rewrite behavior unchanged.targetFileRowssplits, deletion vectors consumed with survivors keeping their ids, appends after a rewrite continuing from the unchangednext-row-idwith no collisions, rewrite-of-a-rewrite stability, the upgraded-table assignment fallback, and theassert-next-row-idrequirement rejecting a moved watermark.next-row-idunchanged.Deliberate limitations
🤖 Generated with Claude Code