fix(table): validate deletion vector fields in RowDelta (#2004 follow-up) - #2016
badalprasadsingh wants to merge 6 commits into
Conversation
Signed-off-by: badalprasadsingh <badal@datazip.io>
Signed-off-by: badalprasadsingh <badal@datazip.io>
Signed-off-by: badalprasadsingh <badal@datazip.io>
laskoviymishka
left a comment
There was a problem hiding this comment.
The shared validateDeletionVectorToAdd helper is the right move — one place for the DV field checks instead of them drifting between RowDelta and ReplaceFiles, and the new v3 test cases line up nicely with the ReplaceFiles ones.
I'd hold this before merging though. The PR reads as bringing RowDelta to full field-level parity with ReplaceFiles, but one asymmetry is still open and it's the one that matters: RowDelta.Commit only runs the DV checks when IsDeletionVector(f) is true, so a plain Parquet position-delete on a v3 table skips validation entirely and commits. ReplaceFiles rejects that case, and Java gates both paths through the same check, so RowDelta is currently the one door that lets a non-DV pos-delete onto v3. It's a correctness gap, not just a style one — a later DV merge for the same data file won't find the stray pos-delete to fold in, so deleted rows can reappear.
The rest is smaller and I've left it inline: the extracted helper dropped its internal IsDeletionVector guard so the precondition is now invisible, one test stopped exercising the commit path it's named for, and a couple of wording/nil-safety notes.
Things I'd want before merge:
- the symmetric "v3 pos-delete must be a DV" guard in
RowDelta.Commit - either re-guard
validateDeletionVectorToAddonIsDeletionVectoror document the precondition - keep an explicit accept-path assertion in
TestRowDeltaAcceptsDeletionVectorOnV3
Once those are in, happy to take another pass and approve.
|
|
||
| if err := validateDeletionVectorFormatVersion(f, meta.formatVersion, "row delta"); err != nil { | ||
| return err | ||
| if IsDeletionVector(f) { |
There was a problem hiding this comment.
This branch is where the parity claim breaks. It only validates when IsDeletionVector(f) is true, so a plain Parquet EntryContentPosDeletes file on a v3 table skips every check and commits fine.
ReplaceFiles rejects exactly this a few lines up in validateDeleteFilesToAdd (position delete file %s must be a deletion vector for v%d table), and Java gates both RowDelta and RewriteFiles through the same validateDeleteFileForVersion — so today RowDelta is the one path that lets a non-DV pos-delete onto v3. It's not cosmetic: if an engine later writes a DV for the same data file, the merge logic expects at most one DV per data file and won't find the stray pos-delete to fold in, so deleted rows can come back.
I'd add the symmetric guard here, something like if !IsDeletionVector(f) && ct == iceberg.EntryContentPosDeletes && meta.formatVersion >= 3 returning the same "must be a deletion vector" error. wdyt?
| func validateDeletionVectorFormatVersion(df iceberg.DataFile, formatVersion int, operation string) error { | ||
| if IsDeletionVector(df) && formatVersion < 3 { | ||
| return fmt.Errorf("deletion vector %s requires table format version >= 3 for %s", df.FilePath(), operation) | ||
| func validateDeletionVectorToAdd(df iceberg.DataFile, formatVersion int, operation string) error { |
There was a problem hiding this comment.
The old helper self-guarded with IsDeletionVector(df) &&; this one drops that and opens straight into the format-version check. Both call sites happen to wrap it in if IsDeletionVector, so it's correct today, but the contract is now invisible — a future caller passing an equality delete (nil ContentOffset) would get a misleading "missing content_offset".
I'd either make it the first line here (if !IsDeletionVector(df) { return nil }) or drop a one-line doc comment stating the precondition. wdyt?
| return fmt.Errorf("deletion vector %s requires table format version >= 3 for %s", path, operation) | ||
| } | ||
| if ref := df.ReferencedDataFile(); ref == nil || *ref == "" { | ||
| return fmt.Errorf("deletion vector %s is missing referenced_data_file for %s", path, operation) |
There was a problem hiding this comment.
This quietly changes the referenced_data_file message from "deletion vector to add is missing referenced_data_file for %s" to include the path and drop "to add", and it hits the ReplaceFiles path too. Our tests are substring-only so they still pass. Fine by me either way — just flagging it's an intentional contract change so anyone matching on the old string knows.
| if err := validateDeletionVectorToAdd(df, meta.formatVersion, operation); err != nil { | ||
| return nil, err | ||
| } | ||
| ref, offset, length := df.ReferencedDataFile(), df.ContentOffset(), df.ContentSizeInBytes() |
There was a problem hiding this comment.
We re-fetch ref/offset/length here and deref them just below without a nil check — safe only because validateDeletionVectorToAdd just guaranteed them non-nil, but that coupling is invisible at this line. Either return the validated values from the helper, or a short // safe: validateDeletionVectorToAdd guarantees non-nil would make it obvious.
| require.NoError(t, tx.NewRowDelta(nil).AddDeletes(buildDVFile(t, dvPath, dataPath)).Commit(t.Context())) | ||
| tbl, err := tx.Commit(t.Context()) | ||
| require.NoError(t, err) | ||
| tbl, _, dataPath, dv := newTableWithLiveDV(t) |
There was a problem hiding this comment.
This test no longer exercises the RowDelta commit it's named for. newTableWithLiveDV does the commit and hands back an already-committed table, so the body only reads CurrentSnapshot() — the "RowDelta accepts a valid DV without erroring" assertion has effectively moved into the helper.
I'd keep an explicit require.NoError on a fresh RowDelta commit with a valid DV here, or rename the test to say it's checking snapshot structure. Otherwise if the helper changes, we lose the accept-path coverage without noticing.
|
There was a problem as RowDelta now rejects Parquet pos-deletes on v3 tables, which broke four tests that committed one:
Happy to change this if there's a better approach :) |
Signed-off-by: badalprasadsingh <badal@datazip.io>
zeroshade
left a comment
There was a problem hiding this comment.
APPROVE. The prior requested changes are addressed: table/row_delta.go:201-208 now rejects new non-DV position deletes on v3+, validateDeletionVectorToAdd self-guards, the validated dereference is documented, and TestRowDeltaAcceptsDeletionVectorOnV3 exercises the commit path explicitly. The required DV metadata checks match the v3 rules for referenced_data_file, content_offset, and content_size_in_bytes. The legacy positional-delete test changes preserve their assertions by committing on v2 and upgrading before exercising v3 reads, so the -87 lines did not drop coverage.
@laskoviymishka your CHANGES_REQUESTED is the remaining gate, and all five threads are still mechanically unresolved — I went through them and found no substantive item still open. Re-review when you can; I'll leave the merge to you.
laskoviymishka
left a comment
There was a problem hiding this comment.
This closes the gap I held on last round. RowDelta.Commit now rejects a plain Parquet pos-delete on v3 the same way ReplaceFiles and Java do, so a stray non-DV pos-delete can't slip onto a v3 table and get stranded when a later DV merges. All three things I asked for are in:
- the symmetric "v3 pos-delete must be a DV" guard in
RowDelta.Commit - the
IsDeletionVectorprecondition is back, now as a self-guard insidevalidateDeletionVectorToAdd TestRowDeltaAcceptsDeletionVectorOnV3keeps its explicit accept-path assertion
Approving. What I left inline is optional follow-up, not a hold: with the self-guard back in the helper, the outer if IsDeletionVector(df) in validateDeleteFilesToAdd is now dead code, so I'd drop it and let the helper's guard carry the contract, plus two small test-comment nits. Fold them into this PR or a later one, your call.
| @@ -1289,24 +1306,11 @@ func (t *Transaction) validateDeleteFilesToAdd(deleteFiles []rewriteDeleteFileAd | |||
| } | |||
|
|
|||
| if IsDeletionVector(df) { | |||
There was a problem hiding this comment.
The self-guard landed inside validateDeletionVectorToAdd, so the helper is safe to call unconditionally now, good. That does leave two guards for the same predicate though: this outer if IsDeletionVector(df) is dead, since the !IsDeletionVector block above already returns or continues on every non-DV path, so we only reach here on a DV.
Not blocking, but I'd drop the outer wrapper and let the helper's own guard carry it, then add a one-liner on validateDeletionVectorToAdd noting it's safe on any DataFile and no-ops on non-DVs. Keeps the precondition visible and drops a branch a reader would otherwise read as a real else. Happy either way.
| } | ||
|
|
||
| // commitLegacyPosDelete adds posDel while tbl is still v2, then upgrades it to v3. | ||
| // The no-op Delete creates a v3 snapshot, which gives the existing rows a _row_id. |
There was a problem hiding this comment.
Optional: the comment says what this does, but the load-bearing bit is implicit. It leans on Delete(AlwaysFalse) still forcing a snapshot rewrite so the upgraded rows pick up a _row_id. The require.NotNil(FirstRowID) below would catch a regression, so it's not silently fragile, but a clause noting the no-op Delete has to stay a rewrite-triggering op would save a future reader from an empty-filter short-circuit quietly gutting this setup.
| }, | ||
| { | ||
| name: "deletion vector without referenced data file on v2", | ||
| name: "deletion vector missing ref still fails on format version for v2", |
There was a problem hiding this comment.
Small wording thing, non-blocking: this DV is missing ref, offset, and size, but the case name singles out the ref. What it actually proves is that the v2 format-version check fires before any field check, so something like format version check precedes field checks on v2 would read truer to intent.
Follow up to #2004 addressing review.
All the required
DVlevel validations forCommitinRowDeltaare in now. Similar toReplaceFilesvia one shared helper.Changelog Note:
RowDeltanow rejects Parquet pos-deletes onv3iceberg tables.