Conversation
…eberg-rust#202 Not for merge as-is. This pins the `iceberg*` deps to `b087b307`, the head of risingwavelabs/iceberg-rust#202, which adds the `validate_from_snapshot_id` API the previous commit calls. Cargo resolves it because GitHub keeps a PR head reachable in the upstream repository, so CI can build and test the change while that PR is in review. Re-pin to the merge commit on `dev_rebase_main_20260303` once #202 lands, then this commit can be dropped or amended. All five `iceberg*` deps are moved together on purpose: pinning only one would pull in two incompatible copies of the `iceberg` crate.
|
@Li0k We need to take a close look at this PR because it deals with conflict detection between concurrent writes and compaction commits. This is especially important for COW mode, where different branches are involved. |
chenzl25
left a comment
There was a problem hiding this comment.
Requesting changes because this validation can still succeed while concurrent deletes are lost:
- Position deletes with
referenced_data_file=Noneare skipped, even when bounds identify the target file. - Equality deletes are only safe when replacement files use the starting snapshot’s sequence number, but the API does not enforce that prerequisite.
starting_snapshot_idis not verified to be an ancestor of the target branch.
Please enforce these conditions and add negative tests before exposing this as a correctness guarantee.
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
…quence number Follow-up to the review of risingwavelabs/iceberg-rust#202, which hardened the validation: it now requires the replacement files to carry the starting snapshot's sequence number, because that is what keeps pre-existing equality deletes applicable to them. The `use_starting_sequence_number == false` path deliberately does not preserve it, so asking for validation there would just fail the commit. Drop the call on that branch and say why, rather than leave a guarantee that cannot hold. Concurrent-delete protection therefore requires `use_starting_sequence_number`. Also re-pin to the current head of #202 (84b9670b) so CI exercises the hardened validation.
Six tests added, including the two must-not-over-reject directions (a bounds-identified delete for an untouched file; a new equality delete once the prerequisite holds). Each fix is mutation-verified. |
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
Supersedes the temporary pin at risingwavelabs/iceberg-rust#202's head. That only carried the concurrent-delete validation this change depends on; the fork branch carries #200, #201 and #202 together, so a consumer pinning here gets all of them in one hop instead of needing three separate pins. Pinned by revision rather than branch name so the build stays reproducible even as that branch moves. Not for upstream. This is a deliberately divergent pin to unblock downstream work while the three PRs are in review; re-point to upstream revisions as they merge. The upstream PR (nimtable#163) stays pinned to #202's head, which resolves from the upstream repository and is the only form acceptable there.
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
…places Compaction materializes the deletes that were visible at `starting_snapshot_id` into the new data files, then replaces the originals. If another writer commits deletes for one of those originals in between, the new files do not contain them, and removing the old data file also retires the new delete file as dangling — so the rows it deleted come back. Pass `starting_snapshot_id` to the rewrite so the commit fails instead, which is what the existing "support validation of data files and delete files with starting snapshot" TODO asked for. Note the commit retry cannot recover from this: the closure reuses the already-computed output files and only reloads the table, so the conflict is permanent and the retries will exhaust before failing. That is the intended outcome — the compaction has to be planned again from the newer snapshot, which the caller does on its next run. Requires the `validate_from_snapshot_id` API from risingwavelabs/iceberg-rust#202, so the iceberg-rust rev must be bumped first.
|
@chenzl25 friendly ping — all three points were addressed in All three were correct. None were pushed back on. 1. Position deletes with There is a sub-finding here worth calling out: my original note describing that asymmetry as "fail-safe" was wrong, and in the more dangerous direction. For 2. Equality deletes / sequence-number prerequisite. The guarantee was real but conditional on a separate call the caller might simply not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so explicitly when it does not — so the prerequisite is enforced rather than assumed. 3. Tests. Six added, deliberately including the two must-not-over-reject directions — a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds — because all three fixes are easy to "pass" by simply rejecting more. Each fix is mutation-verified. One of those checks caught a bad test: the ancestry test initially still passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically. Happy to split any of the three into its own PR if that is easier to review than one commit. You also pinged @Li0k for a COW/branch perspective — that seems worth keeping, since point 3 is precisely about cross-branch lineage. |
|
@vovacf201 Thanks. I think this PR is worthwhile. Since we're trying to bump iceberg-rust (see #191), it might be better to merge this PR after we switch to 0807. What do you think? |
|
@vovacf201 We finished the branch switch to dev_rebase_main_20260807 now. Could you please migrate this PR to the new branch? |
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
|
Rebased onto Conflict resolutions worth a look:
All 11 new validation tests pass. |
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
|
merged with main |
|
@Li0k Please help to take a look, because we have some pruning optimization for COW table. |
|
Downstream integration note (especially for nimtable/iceberg-compaction#163 and RisingWave):
|
…places Compaction materializes the deletes that were visible at `starting_snapshot_id` into the new data files, then replaces the originals. If another writer commits deletes for one of those originals in between, the new files do not contain them, and removing the old data file also retires the new delete file as dangling — so the rows it deleted come back. Pass `starting_snapshot_id` to the rewrite so the commit fails instead, which is what the existing "support validation of data files and delete files with starting snapshot" TODO asked for. Note the commit retry cannot recover from this: the closure reuses the already-computed output files and only reloads the table, so the conflict is permanent and the retries will exhaust before failing. That is the intended outcome — the compaction has to be planned again from the newer snapshot, which the caller does on its next run. Requires the `validate_from_snapshot_id` API from risingwavelabs/iceberg-rust#202, so the iceberg-rust rev must be bumped first.
…eberg-rust#202 Not for merge as-is. This pins the `iceberg*` deps to `b087b307`, the head of risingwavelabs/iceberg-rust#202, which adds the `validate_from_snapshot_id` API the previous commit calls. Cargo resolves it because GitHub keeps a PR head reachable in the upstream repository, so CI can build and test the change while that PR is in review. Re-pin to the merge commit on `dev_rebase_main_20260303` once #202 lands, then this commit can be dropped or amended. All five `iceberg*` deps are moved together on purpose: pinning only one would pull in two incompatible copies of the `iceberg` crate.
…quence number Follow-up to the review of risingwavelabs/iceberg-rust#202, which hardened the validation: it now requires the replacement files to carry the starting snapshot's sequence number, because that is what keeps pre-existing equality deletes applicable to them. The `use_starting_sequence_number == false` path deliberately does not preserve it, so asking for validation there would just fail the commit. Drop the call on that branch and say why, rather than leave a guarantee that cannot hold. Concurrent-delete protection therefore requires `use_starting_sequence_number`. Also re-pin to the current head of #202 (84b9670b) so CI exercises the hardened validation.
… removes An operation that rewrites data files reads a snapshot, materializes the deletes that applied at that point into new data files, and then removes the originals. If another writer commits deletes for one of those originals in between, those deletes are never materialized — and because removing the data file also retires the new delete file as dangling, the rows it deleted silently come back. Nothing caught this. `validate_data_file_changes` only checks for duplicate adds and that the files being deleted exist, and the data file does still exist, so no conflict was raised. Add `SnapshotProducer::validate_no_new_deletes_for_data_files`, exposed on `ReplaceFilesAction` as `validate_from_snapshot_id`, which fails the commit when a delete file targeting one of the removed data files was added after the given snapshot. The caller can then retry against the current snapshot, where the new deletes are visible. Candidate manifests are narrowed by the manifest sequence number, then entries are filtered on their own sequence number, since a newer manifest can carry older entries forward as `Existing`. Only delete files that record `referenced_data_file` — deletion vectors, and position deletes written for a single data file — are covered, because that is the only case where the association is in the manifest. Position delete files spanning several data files would require reading their contents; equality deletes are retired by sequence number rather than by data file. Both are documented as out of scope. Opt-in, so existing callers are unaffected until they pass a starting snapshot.
Review of risingwavelabs#202 found the validation could still succeed while deletes were lost. All three points were correct. 1. Position deletes with no `referenced_data_file` were skipped even when their path bounds identify a single target. The scan side already infers this, so the validation was strictly weaker than the reader. Worse, the earlier note calling that asymmetry "fail-safe" was wrong: for `is_dangling_delete` an unidentified target means the delete is *kept*, which is safe, but for validation it means no conflict is reported and the commit proceeds. Now attributed via `referenced_data_file` or, failing that, the same equal-bounds inference. A new position delete that cannot be attributed at all is treated as a conflict, since proving it harmless would mean reading its contents. 2. Equality deletes are only safe because the replacement files keep the starting snapshot's sequence number, which kept them applicable — but nothing enforced that, so the guarantee was conditional on a separate call the caller might not make. Validation now requires the new data file sequence number to equal the starting snapshot's, and says so when it does not. 3. `starting_snapshot_id` was only checked for existence, not for being on the target branch. A snapshot from an unrelated lineage would have made every sequence-number comparison meaningless. It is now verified to be an ancestor of the branch head. Six tests added, including the must-not-over-reject directions: a bounds-identified delete for an untouched file, and a new equality delete once the prerequisite holds. Each fix is mutation-verified. The ancestry test initially passed with the ancestry check removed, because it accepted any error mentioning the snapshot id and the sequence-number error also mentions it. It now asserts the ancestry wording specifically.
…hot_id chenzl25's downstream integration note (for iceberg-compaction#163 and RisingWave) covered three things that were previously only in a PR comment, not in the API's own documentation: - it is a same-branch rewrite guard, safe for ordinary compaction and for a copy-on-write rewrite performed on its own branch - it must not be used to guard a copy-on-write *publish* from one branch into another -- that is an authoritative overwrite of the target, not a rewrite that read it, so the source branch's snapshot is never an ancestor of the target and comparing sequence numbers across that boundary is meaningless - it is not a substitute for out-of-band coordination between independent writers that must agree on a commit before either applies it (e.g. a combined-commit protocol with an external index) The ancestry check already enforces the first two mechanically, but the doc comment didn't explain why, which matters for anyone deciding whether to reach for this check versus real coordination. Added a "Scope" section to both the public entry point (ReplaceFilesAction::validate_from_snapshot_id) and the implementation it calls (SnapshotProducer::validate_no_new_deletes_for_data_files), kept in sync with each other. No behavior change. cargo fmt, cargo clippy --all-targets --all-features -- -D warnings, and cargo test -p iceberg --lib (transaction:: subset and full suite) all clean.
|
Two things, per the request above: Addressed the downstream integration note. That guidance (same-branch guard, safe for ordinary compaction / physical COW rewrite, not safe for the COW publish step, not a substitute for external write coordination) was valuable but only lived in a PR comment. Added a "Scope" section to both Rebased onto the current Verified on the final state: |
|
Update: CI is now fully green on the rebased head ( @chenzl25 one more thing worth flagging directly: the PR's review decision is still formally Changes Requested from your |
|
I will continue to review this PR when RisingWave's related iceberg features get merged into the main. |
The bug
An operation that rewrites data files (compaction, most importantly) does this:
SS, materializing them into new data filesIf a concurrent writer commits a deletion vector for one of the data files being removed between (1)
and (3), that DV's deletes are never materialized — and because removing the data file also
retires the DV as a dangling delete, the rows it deleted silently come back.
Nothing catches this today.
validate_data_file_changeschecks for duplicate adds and that thefiles being deleted exist; the data file does still exist, so no conflict is raised.
This is the analogue of Java's
validateNoNewDeletesForDataFiles, which has no equivalent here.The fix
SnapshotProducer::validate_no_new_deletes_for_data_files(starting_snapshot_id), exposed onReplaceFilesActionas:It fails the commit if a delete file targeting one of the removed data files was added after that
snapshot, so the caller can retry against the current snapshot where the new deletes are visible.
Opt-in — existing callers are unaffected until they pass a starting snapshot.
Implementation notes
starting sequence number cannot contain newer entries), then entries are filtered on their own
sequence number, because a newer manifest can carry older entries forward as
Existing.referenced_data_fileare covered — deletion vectors, and positiondeletes written for a single data file. That is the only case where the data-file association is in
the manifest and can be checked without reading file contents. Position delete files spanning
several data files, and equality deletes (retired by sequence number rather than by data file), are
documented as out of scope. This matches the granularity
is_dangling_deletealready uses.starting_snapshot_idis an error rather than a silent pass, since it means thecaller's assumption about the table is wrong.
Tests
Four cases, over a fixture that extends the existing
make_v2_table_with_delete_manifestwith anewer snapshot whose delete manifest holds a DV — i.e. exactly the state a concurrent writer
leaves behind:
test_validate_rejects_new_deletion_vector_for_a_rewritten_data_file— the conflict; asserts theerror names both the data file and the new delete file
test_validate_allows_new_deletion_vector_for_an_untouched_data_file— a DV for an unrelated datafile must not conflict (guards against over-rejecting, which would break compaction)
test_validate_allows_when_nothing_was_committed_concurrentlytest_validate_rejects_unknown_starting_snapshotConfirmed non-vacuous by stubbing the validation to
return Ok(()), which makes both rejection testsfail.
Verification
make check-fmt,make check-clippy(--all-targets --all-features --workspace -- -D warnings,exit 0),
cargo test -p iceberg --lib(1292 passed, 0 failed) on the pinnednightly-2025-10-27.Follow-up
The corresponding change in
iceberg-compactionis to pass the snapshot it planned from — itscompaction/mod.rsalready carries an explicit// TODO: support validation of data files and delete files with starting snapshot. I'll open that once this API settles, since it depends on the shapehere.
Independent of #200 and #201 (different files).