Repository navigation
Conversation
Adds an OverwriteAction to Transaction for atomic replacement of data files via an Operation::Overwrite snapshot. add_data_files() stages new files; delete_data_files() rewrites affected manifests with matching entries marked ManifestStatus::Deleted (copy-on-write). Manifests are loaded through the Table::manifest_list_reader abstraction.
The SnapshotProducer precondition check blocked snapshots with only deleted files and no added files or snapshot properties. A delete-only Overwrite snapshot is valid per the Iceberg spec — the existing manifests are rewritten with the target entries marked as ManifestStatus::Deleted. Relax the check to also allow the case when deleted_data_files is non-empty, so callers can clear a table without simultaneously adding new files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Import Manifest and DataFile at the top instead of using fully-qualified crate::spec:: paths inline in a signature and a test helper.
# Conflicts: # crates/iceberg/public-api.txt # crates/iceberg/src/transaction/append.rs # crates/iceberg/src/transaction/mod.rs
A manifest rewritten by a second overwrite fell through to add_existing_entry for entries that were already Deleted, forcing their status back to Existing and resurrecting the files as live data. Guard on is_alive() first and re-add prior tombstones as Deleted with their original snapshot id, matching Java's filterManifestWithDeletedFiles, which only re-adds live entries.
The rewrite used the table's current schema and default partition spec, so after a schema or partition evolution the Avro header carried the wrong schema-id and partition-spec-id and readers interpreted the partition values against the wrong mapping. Take both from the manifest being rewritten, as Java does via newManifestWriter(reader.spec()).
Both manifest filters dropped a manifest whose entries were all Deleted, which lets the removed files reappear as live data. This is the same fix FastAppend took in apache#2545 for apache#2148, applied to the no-deletes fast path and to the rewrite loop.
The summary counted every file the caller passed to delete_data_files, so a path that was never committed or was passed twice inflated deleted-data-files and deleted-records. The rewrite pass now records the entries it marked Deleted, with the schema and partition spec of the manifest each was recorded in, and the summary counts those. Two changes follow from it. The summary is built after the manifests rather than before, since the counts are only known once the rewrite has run. And the full-table truncate path is now gated on an operation hook that a partial overwrite answers false, instead of on the operation being Overwrite; with truncate off, update_totals needs a saturating subtraction so an overwrite that deletes more than the previous total does not underflow.
The rewrite hardcoded {location}/metadata and a fresh Uuid::now_v7, so a table
with a custom write.metadata.path got its rewritten manifests in the wrong
prefix, and orphan cleanup could not find them after a failed commit because
the name was not tied to the commit. Reuse the path SnapshotProducer already
builds from metadata_location(), the commit uuid and the manifest counter; the
counter becomes an AtomicU64 because the rewrite runs behind a shared
reference.
rewrite_manifest built its own plain output file, so on an encrypted table the rewritten manifest went out in plaintext while its key metadata said otherwise. It now goes through SnapshotProducer::new_manifest_writer, which already branches on the table's encryption manager; the writer takes the schema and partition spec as arguments so the rewrite can keep the ones the manifest was written under. That leaves new_manifest_path with no caller outside this module, so it becomes private again.
add_deleted_entry set the status but left snapshot_id to the caller, while add_delete_entry right above it stamps both; the two differed only by tense and a tombstone written with the wrong snapshot_id misattributes the delete, which expire_snapshots then acts on. Taking the reviewer's second option: rename rather than stamp, because carrying an earlier tombstone forward has to keep its original snapshot_id, and document that the caller owns the field. Named add_tombstone_entry rather than something closer to main's add_delete_entry (apache#2367) precisely so the two cannot be confused.
A delete-only overwrite reported Operation::Overwrite, so tools keying off the summary read it as a replacement. Match Java's BaseOverwriteFiles: DELETE when there are only deletes, APPEND when there are only adds, OVERWRITE when there are both. test_overwrite_basic adds without deleting, so its expected operation becomes APPEND. Also names deleted data files in the precondition error, which has allowed a delete-only commit since the guard was relaxed.
is_err() alone passes on any error, so the test would stay green if the commit started failing earlier for an unrelated reason. Assert the kind and the message instead.
An overwrite silently ignored a delete path that was not live in any manifest, so a typo or an already-deleted file committed as a no-op delete. Java's BaseOverwriteFiles validates the same thing and fails with "Missing required files to delete"; the rewrite pass already records what it marked Deleted, so the check is a comparison against the requested set.
Regenerated with make generate-public-api (cargo-public-api 0.51.0); the snapshot was missing OverwriteAction and its builder methods.
The snapshot summary could only count what the caller asked to remove, so an operation that skips a path it never had, or is handed the same path twice, inflated deleted-data-files and deleted-records. SnapshotProduceOperation now reports the entries it actually marked Deleted, each with the schema and partition spec of the manifest that recorded it, and the summary counts those. Two changes follow from it. The manifests are produced before the summary, since the counts are only known once the operation has run, which means the added data files are cloned rather than taken. And the full-table truncate that drops the previous totals is gated on a new operation hook instead of on the operation being Overwrite: a partial overwrite keeps the totals, and with truncate off update_totals needs a saturating subtraction so removing more than the previous total does not underflow.
The manifest name is built inline in new_manifest_writer from the metadata location, the commit uuid and a counter, so anything else that needs to write a manifest for the same commit has to reinvent it, and a name invented elsewhere is neither unique within the commit nor findable by orphan cleanup after a failed one. Pull the name out into new_manifest_path, and make the counter an AtomicU64 so it can be bumped behind a shared reference.
new_manifest_writer hardcoded the table's current schema and default partition spec, so an operation that rewrites an existing manifest could not keep the schema and spec that manifest's entries were recorded under, and building a writer by hand instead means losing the encryption manager the method already branches on: on an encrypted table the manifest would go out in plaintext while its key metadata said otherwise. The schema and partition spec become arguments, and the added-files path passes the table's current ones.
…hot summary Co-authored-by: Marco Kroll <216147982+mkroll-db@users.noreply.github.com>
laskoviymishka
left a comment
There was a problem hiding this comment.
This picks up cleanly from the #2185 round — the things I flagged there have landed: rewritten manifests now stamp the schema and partition-spec they were written with, delete-only manifests survive a later overwrite, the summary reports the files actually marked deleted (and we reject deleting a path the table doesn't have, matching BaseOverwriteFiles), and the rewrite routes through new_manifest_writer so the commit-uuid naming and encryption come along. Test coverage on all of that is solid.
On the two things you flagged: the operation mapping is right and lines up with BaseOverwriteFiles — adds+deletes → Overwrite, deletes-only → Delete, add-only → Append; the only wrinkle is a property-only overwrite falling through to Append, which is the existing #1548 workaround rather than anything new (noted inline). And the add_deleted_entry → add_tombstone_entry rename is fine by me — it's pub(crate), so nothing moves on the published surface; the only thing there is the doc pointing at dead code.
The one thing I'd want fixed before this merges is first_row_id on the rewritten V3 manifests: write_manifest_file() returns None and add_manifests then stamps a fresh id above the row IDs the surviving entries actually carry, so row-ID-range pruning — and Java/PyIceberg readers — can skip real rows. It's latent until MOR lands on top, but I'd rather not bake it in. Details inline.
Two more I'd sort out in the same pass — not correctness, but I wouldn't ship them: the produce-then-summarize reorder means write_added_manifest deep-clones the whole added-file vec on every commit, and that path is shared with FastAppendAction, so plain appends pay for it too; and the new deleted_data_files on SnapshotProducer is dead weight — cloned in only to answer is_empty(), and it reads like it drives the summary when it doesn't. Both trace back to the same reorder, so they're natural to fix together. Smaller stuff (intra-batch dedup, the "fast append" error string) is inline.
Carry the first_row_id through and tidy up the clone/field, and I'm happy to take another pass.
Java and PyIceberg derive totals from the files an operation marks deleted, with no truncation path.
Drops the removed_data_files hook and the manifest-first reorder, and truncation defaults back to every overwrite.
Overwrite gets the dedup fast append already had, and the shared error no longer says fast append.
…a and pyiceberg do Drops add_tombstone_entry, the removed-files bookkeeping and the duplicate tests.
|
@laskoviymishka reworked on top of the reshaped #3253: one pass over the manifests, only live entries carried like Java, dedup in the producer. Could you take another look? |
laskoviymishka
left a comment
There was a problem hiding this comment.
The round-1 cleanups all landed — write_added_manifest uses mem::take now, deleted_data_files feeds the summary instead of being dead weight, and the error-string / dead-doc / intra-batch-dedup nits are all sorted. Thanks for working through those.
On first_row_id: I went back through it and I think my round-1 call was wrong, so I'll name that directly. The per-entry data_file.first_row_id survives the rewrite — the reader materializes it for live entries, add_existing_entry carries it through, and _serde writes it back — and the fresh manifest-level first_row_id only fills null ids, which a rewrite of existing files never has. That lines up with how Java's V3 writer assigns first_row_id, so I'm withdrawing the corruption concern. The one piece still open from last round is a regression test: the behavior rests entirely on that reader→serde chain and nothing in the suite asserts row lineage, so I'd want a test that appends A/B/C on a V3 table, overwrite-deletes B, and checks A and C keep their original first_row_id with next_row_id advancing as expected.
Two things I didn't catch last round that I'd want before this merges, both inline. The deleted-file summary and the running totals are built from the DataFiles the caller passes, not the manifest entries we match — so a caller with the right path but a wrong record_count or size writes a snapshot whose summary and totals disagree with its own manifests, and the new update_totals guard mostly just hides the negative case. And delete_data_files never checks content type while existing_manifest walks Deletes manifests too, so a path that matches a delete file or DV gets tombstoned and silently stops applying its deletes — Java keeps those on separate paths for exactly this reason.
Fix those two and add the lineage test and I think we're there — this is close.
|
@laskoviymishka the deletion checks and V3 lineage test are in, along with the producer fixes. the library suite, fmt, clippy and public API check pass locally. could you take another look? |
# Conflicts: # crates/iceberg/public-api.txt # crates/iceberg/src/transaction/append.rs # crates/iceberg/src/transaction/snapshot.rs
|
@laskoviymishka merged main now that #3253 is in, so the diff is only the overwrite action, and it's moved onto the new stateful TransactionAction trait. still passes the library suite, fmt, clippy and the public API check. |
feat(transaction): add overwrite action (continues apache#2185) Squash of apache#3300 at 4eda47f (35 commits by glitchy, Tim Saucer, Gerald Berger and Jegor van Opdorp), applied as the PR's final diff against its base f83bec4. The PR's own commits carry the producer redesign that was merged separately as apache#3253, which is the previous commit on this branch, so they could not be replayed one by one against it. Adapted to this branch: - OverwriteAction implements the pre-apache#3265 TransactionAction trait (commit(self: Arc<Self>, table); no State or cleanup) and builds the producer through its builder, giving the deleted files as removed_data_files. - SnapshotProducer de-duplicates added and removed data files by path in commit(), replacing FastAppend's own dedupe; produce_manifests returns the summary, computed after existing_manifest so the overwrite's validation errors come first. - The tests drive the action through Arc::new(action).commit(&table) instead of the apache#3265 test helper. - Regenerate public-api.txt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
laskoviymishka
left a comment
There was a problem hiding this comment.
The round-2 asks are all in: non-data deletions are rejected and Deletes manifests pass through untouched, caller metadata is checked against the matched entry before the summary is built, and dedup borrows the paths. The V3 lineage test pins A and C to their original first_row_id, so that thread is closed.
One real thing left, and it's a panic. If an identity-partitioned int source was later promoted to long, overwrite-deleting a file written under the old spec hits unreachable!() in Datum's Display — the removed-file summary renders that partition with current_schema() and builds a (Long, Int) datum. The code is in main, but this PR is the first caller that sends old-spec files through it, so I'd fix it here; either option inline works, as long as there's a test that promotes the source before deleting.
The rest is small cleanup: document the strict-match contract on delete_data_files, name the mismatched field in its error, settle delete-plus-re-add of one path under check_duplicate, and add the positive Deletes-manifest test. Panic fix plus that cleanup and we're good to merge.
|
@laskoviymishka The partition panic is fixed. The deletion contract and errors are clearer, and the missing tests are in. Reusing a deleted path still requires disabling the duplicate check; that is now documented and tested. The library suite, formatting and clippy pass locally. Could you take another look? |
[Only datum.rs and its partition.rs test; the overwrite.rs changes of this commit are left out.] [Upstream PR apache#3300, open on 2026-10-25.] (cherry picked from commit 3966f6b)
[Only datum.rs and its partition.rs test; the overwrite.rs changes of this commit are left out.] [Upstream PR apache#3300, open on 2026-10-25.] (cherry picked from commit 3966f6b)
The deleted entry is no longer live in the new snapshot, so reusing its path is not a duplicate.
|
@laskoviymishka the last piece of the 10-20 round is in too, deleted paths are excluded from the duplicate check in b106b0a. Ready for another look. |
laskoviymishka
left a comment
There was a problem hiding this comment.
It's almost there. The round-3 panic fix and the check_duplicate exclusion are in, thanks. I said last round that would be enough to merge, but this pass turned up one new thing: a debug_assert_eq! panic when a deleted path is live in two Data manifests (inline on overwrite.rs). The rest are small doc and test nits. I'll do at least one more round, then I'd like @CTTY to look at the SnapshotProducer and FastAppend changes. Could the description also drop the #3253 paragraph now that it's merged?
| write!(f, "{}", decimal_from_i128_with_scale(*val, *scale)) | ||
| } | ||
| // The scale lives on the dropped source type, so only the unscaled value is left. | ||
| (PrimitiveType::Unknown, PrimitiveLiteral::Int128(val)) => write!(f, "{val}"), |
There was a problem hiding this comment.
Could we open an issue for this arm? With the scale gone, a dropped decimal source renders 123 for 1.23. That's harmless for changed-partition-count, but it lands in the partitions.* summary keys once write.summary.partition-limit is above 0. We settled on the Display arms last round, so this is only about tracking it.
| /// OverwriteAction is a transaction action for overwriting data files in the table. | ||
| /// | ||
| /// Creates a snapshot with `Operation::Overwrite` semantics — adds new data files and | ||
| /// optionally removes existing data files by rewriting affected manifests with those | ||
| /// entries marked as `ManifestStatus::Deleted`. |
There was a problem hiding this comment.
Could the doc say which operation this reports and what it doesn't check? operation() returns Delete or Append for one-sided commits, and there's no row-filter mode or validation of concurrent commits. Java's default OverwriteFiles skips that validation too, so it's scope to state rather than a gap.
| /// OverwriteAction is a transaction action for overwriting data files in the table. | |
| /// | |
| /// Creates a snapshot with `Operation::Overwrite` semantics — adds new data files and | |
| /// optionally removes existing data files by rewriting affected manifests with those | |
| /// entries marked as `ManifestStatus::Deleted`. | |
| /// Adds data files and removes listed data files by path. | |
| /// | |
| /// The operation is `Overwrite` when files are both added and removed, otherwise `Delete` or | |
| /// `Append`. There is no row-filter mode, and files committed concurrently are not checked. |
| let mut deleted_files = HashMap::new(); | ||
| for file in &self.deleted_data_files { | ||
| if file.content_type() != crate::spec::DataContentType::Data { | ||
| return Err(invalid_data!("Only data content type is allowed")); |
There was a problem hiding this comment.
Could this error name the file and its content type? When several files go to delete_data_files, there's no telling which one was rejected. Also, crate::spec::DataContentType here and crate::spec::ManifestContentType at L185 could move into the use crate::spec::{…} import, though snapshot.rs writes the same path inline today.
| continue; | ||
| } | ||
|
|
||
| let manifest = table.manifest_reader().read(manifest_file).await?; |
There was a problem hiding this comment.
Could these reads run concurrently, the way validate_duplicate_files does, or at least carry a TODO? Here every non-empty Data manifest is read one after another, with no pruning and no early exit once every delete is matched. With check_duplicate on, the same commit has already read each manifest once in that concurrent pass.
| for entry in manifest.entries().iter().filter(|entry| entry.is_alive()) { | ||
| if let Some(requested) = self.deleted_files.get(entry.file_path()) { | ||
| let actual = entry.data_file(); | ||
| let mismatch = if requested.record_count != actual.record_count { |
There was a problem hiding this comment.
Could this check move into a small helper that returns Option<String>? The continue in the last else of the let initializer is easy to misread as falling through to the return Err, and the loop at L239 then filters the live entries a second time. The behavior is right as written. This is only about readability.
| }; | ||
| use crate::table::Table; | ||
| use crate::test_utils::commit_with_fresh_state; | ||
| use crate::transaction::tests::make_v3_minimal_table_in_catalog; |
There was a problem hiding this comment.
Could one of these tests run on a V2 table? Every test here uses make_v3_minimal_table_in_catalog, but V2 rewrites go through a different manifest builder. As I read it, nothing in the rewrite is V2-specific, so this is about coverage, not a suspected bug. The promotion test's catalog create at L610 shows how to pick the format version.
| assert!( | ||
| !entries.iter().any( | ||
| |(status, path)| path == "test/a.parquet" && *status != ManifestStatus::Deleted | ||
| ), | ||
| "the first overwrite's delete must not come back, entries: {entries:?}" | ||
| ); |
There was a problem hiding this comment.
Could this assert the whole entry list? It also passes when a.parquet is missing entirely. As I read the rewrite loop, missing is the expected end state, since the second rewrite carries only live entries and drops a's tombstone. An assert_eq! against the expected list (no a.parquet, b.parquet Deleted, c and d live) would pin the delete and the tombstone drop together, like the new re-add test does.
| partition: Struct::from_iter([Some(Literal::long(999))]), | ||
| ..file.clone() | ||
| }, | ||
| "partition: requested Struct { fields: [Some(Primitive(Long(999)))] }, table has Struct { fields: [Some(Primitive(Long(300)))] }", |
There was a problem hiding this comment.
Could both partition cases build the expected message from the two Structs, e.g. format!("partition: requested {:?}, table has {:?}", …)? This line hard-codes the derived Debug text, which changes on any rename in Struct or Literal. Meanwhile the Literal::string case at L752 only checks the partition: requested prefix.
| } | ||
|
|
||
| let existing_manifests = snapshot_produce_operation.existing_manifest(self).await?; | ||
| let summary = self.summary(snapshot_produce_operation).map_err(|err| { |
There was a problem hiding this comment.
Could we keep the ordering note main had? summary() still has to run before write_added_manifest, which takes added_data_files, and since the move nothing here says so.
| let summary = self.summary(snapshot_produce_operation).map_err(|err| { | |
| // Must run before `write_added_manifest`, which takes `added_data_files`. | |
| let summary = self.summary(snapshot_produce_operation).map_err(|err| { |
Which issue does this PR close?
Part of #2186.
What changes are included in this PR?
Continues #2185 by @glitchy, with their commits kept as they were, and addresses @laskoviymishka's review on top:
SnapshotProducer::new_manifest_writer, carrying only live entries, like Java'sManifestFilterManager, so an earlier delete can't come back.public-api.txt.It includes the producer changes from #3253; those drop out of this diff once that merges. It will move onto
MergingSnapshotProducerwhen #3046 lands.Are these changes tested?
Tests in
transaction::overwrite::testsread the committed manifests back: overwrite with deletes, delete-only reports Delete, a second overwrite doesn't resurrect an earlier delete, schema and spec evolution, a missing delete is rejected, a duplicated delete path counts once, incorrect deletion metadata and delete-file targets are rejected, and V3 row lineage survives a rewrite.cargo test -p iceberg --lib, the public API check, fmt and clippy with-D warningsare clean.AI Disclosure
The review fixes, their tests and the merges were drafted with an AI coding assistant, then reviewed, run and checked against the Java and pyiceberg implementations by us. Worth a close look: which operation a commit reports, and that rewritten manifests drop earlier tombstones like Java does.