GeezerVison is showing github.com — [iceberg] Read deletion vectors in follow mode by swanandx · Pull Request #7590 · feldera/feldera
Conversation
swanandx
commented
Oct 22, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
Follow mode now reads Iceberg V3 deletion vectors (DVs), so merge-on-read DELETE, UPDATE and MERGE stream as changes. Before, the connector stopped with an error.
for each snapshot, pair every DV with its data file:
added file → insert its rows, minus its DV
removed file → delete its rows, minus its old DV
kept file, new DV → delete only the rows in (new DV − old DV)
V2 delete file → error
Writers before Iceberg 1.10 leave the DV of a removed file in the table. The connector applies that DV to the removed file, as the Java reader does, and ignores it after the file is gone.
Cost: for a new DV, the connector reads the whole data file once and keeps only the newly deleted rows. A copy-on-write change reads two files: the old file and its rewrite. On MinIO, a DELETE of 1% of 10M rows in 100 files took 4.5 s and 113 MB with DVs, and 19.6 s and 2.0 GB with copy-on-write. The connector keeps a small summary of each delete manifest that it read, so a snapshot reads only new delete manifests and those that name a removed file. When a snapshot drops a leftover DV, the connector reads the data manifests old enough to hold its data file, to make sure that the file is gone.
Manifest and deletion vector reads now retry like data-file reads, and each retry marks the connector unhealthy. A read that a retry cannot fix, such as a read of a missing file in follow mode, stops at once. On a table on the local disk, an invalid-data error still retries, because iceberg-rust's local reader reports a temporary I/O error the same way.
Linked Issue
Part of #6165.
Describe Manual Test Plan
A Spark fixture writes a partitioned V3 table, whose partition spec changes after the first insert, with merge-on-read DELETE, UPDATE and MERGE, a copy-on-write DELETE and a compaction. Each data file has many row groups. A second fixture writes with Iceberg 1.9.2 and then 1.10.0, so the table has a leftover DV. It also writes a V2 table with a position delete file. The e2e tests check the final rows and the number of rows read. If any branch above breaks, a test fails. A third fixture writes every type that Spark can write, then deletes and updates rows. That test checks that follow mode gives the same rows as a snapshot read of the last snapshot. Spark cannot write time or fixed, and a unit test runs the row filter on every type family.
Checklist
- Unit tests added/updated
- Integration tests added/updated
- Documentation updated
- Changelog updated
Breaking Changes?
Mark if you think the answer is yes for any of these components:
Describe Incompatible Changes
Follow mode now stops with an error when a snapshot deletes rows and the table still has a V2 delete file anywhere. Before, it went on, and emitted rows that were already deleted whenever that delete file applied to a removed file.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector |
✅ 2/2 pass |
pre-commit run --files <changed files> (clippy, fmt, machete, crypto deps) |
✅ pass |
| 2 extra QA unit tests (diff below) |
✅ pass |
e2e iceberg-tests-follow (needs a REST catalog, object storage and Spark) |
not run |
missing (medium): unit coverage. The happy path of pair_deletion_vectors (replaced DV, added file with its DV, removed file with its DV) and select_positions are tested only by the Spark e2e test. That test is the one most likely to flake (see the inline comment). Suggested tests, which pass locally:
diff
@@ -3723,6 +3723,53 @@ mod deletion_vector_tests {
})
.contains("two deletion vectors"));
}
+
+ #[test]
+ fn pairs_deletion_vectors_with_their_data_files() {
+ let paired = pair_deletion_vectors(
+ 7,
+ SnapshotFiles {
+ added: vec![data_file("new.parquet")],
+ removed: vec![data_file("old.parquet")],
+ added_dvs: vec![
+ dv("n.puffin", "new.parquet"),
+ dv("k2.puffin", "kept.parquet"),
+ ],
+ removed_dvs: vec![
+ dv("o.puffin", "old.parquet"),
+ dv("k1.puffin", "kept.parquet"),
+ ],
+ },
+ )
+ .unwrap();
+ assert_eq!(paired.added.len(), 1);
+ assert!(paired.added[0].deletion_vector.is_some());
+ assert_eq!(paired.removed.len(), 1);
+ assert_eq!(
+ paired.removed[0].deletion_vector.as_ref().unwrap().file_path,
+ "o.puffin"
+ );
+ assert_eq!(paired.new_deletes.len(), 1);
+ let nd = &paired.new_deletes[0];
+ assert_eq!(nd.data_file_path, "kept.parquet");
+ assert_eq!(nd.replaced.as_ref().unwrap().data_file.file_path(), "k1.puffin");
+ }
+
+ #[test]
+ fn select_positions_keeps_only_selected_rows() {
+ use datafusion::arrow::array::{Int64Array, StringArray, ArrayRef};
+ let batch = RecordBatch::try_from_iter([
+ ("v", Arc::new(StringArray::from(vec!["a", "b", "c", "d"])) as ArrayRef),
+ ("_file", Arc::new(StringArray::from(vec!["f"; 4])) as ArrayRef),
+ ("_pos", Arc::new(Int64Array::from(vec![10, 11, 12, 13])) as ArrayRef),
+ ])
+ .unwrap();
+ let selected = HashMap::from([("f".to_string(), Arc::new(RoaringTreemap::from_iter([11u64, 13])))]);
+ let out = select_positions(batch, &selected).unwrap();
+ assert_eq!(out.num_columns(), 1);
+ let v = out.column(0).as_string::<i32>();
+ assert_eq!(v.iter().flatten().collect::<Vec<_>>(), ["b", "d"]);
+ }
}
#[cfg(test)]
Uncovered cases (medium):
- A followed snapshot that adds or removes a V2 delete file (the error path in
snapshot_files). Only the "live V2 file at check time" path has a test.
- The error for equality delete files.
- A data file that is not Parquet behind a new DV:
new_deletes_read hardcodes DataFileFormat::Parquet, so such a file fails with an unclear reader error.
Gate: SQL type coverage (medium). The new _file/_pos projection and filter path runs only on BIGINT/STRING/TIMESTAMP (S3TablesTestStruct). filter_record_batch is type-generic, so the risk is low. Still, one MoR DELETE on a table with the full type set (including nested types) would satisfy the connector type-coverage rule.
The other gates are fine: docs are updated, the new deps are MIT/Apache, there is no unsafe, and the commit message is good.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector |
✅ 3/3 |
New local select_positions tests (plain and run-end-encoded _file, unknown file, empty batch) |
✅ 4/4 (diff inline) |
cargo check --locked -p dbsp_adapters --features iceberg-tests-follow --tests |
✅ |
pre-commit run --files <changed> |
✅ |
e2e iceberg_rest_v3_* / iceberg_rest_follow_rejects_v2_delete_files |
⚠️ not run: they need the REST catalog and object store. Only the Main CI check has run so far. |
Gates
- SQL type coverage (medium): the new selected-positions path (projecting
_file/_pos, then filter_record_batch) only runs on id BIGINT, name STRING, created_at TIMESTAMP. Under the connector rule, this needs a merge-on-read DELETE against a table with the full IcebergTestStruct type set (decimal, binary, nested struct/array/map, nullable columns).
- Flaky tests (medium): see the inline comment on the Spark fixture's network downloads.
Missing test cases (medium)
new_deletes_read error paths: a new DV that isn't a superset of the old one, and a record_count that doesn't match the decoded positions.
- Resuming from a checkpoint in the middle of a run of DV snapshots, so no rows are double-deleted after restart.
- One snapshot that adds both a data file and its DV, with a row-count check (
FileRead::whole with a DV on the insert side).
- The error path for an equality delete file.
Logic, docs, licences (roaring and crc32fast are MIT/Apache-2.0) and the breaking-change note all look good. The fixes I'd ask for are the retry pitfall (inline) and the type-coverage gate.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector_tests (5 tests) |
✅ pass |
pre-commit run --files <changed> (clippy, fmt, machete, crypto deps, docs) |
✅ pass |
iceberg_rest_v3_* / iceberg_rest_follow_rejects_v2_delete_files e2e |
⏭️ not run (needs a REST catalog, object store and Spark) |
Gates: tests ✅, docs ✅, breaking change described in the PR and docs ✅. New deps roaring and crc32fast are both MIT/Apache-2.0 and were already in the lockfile ✅. Flakiness: the Spark fixture retries Maven downloads 3× and uses a separate namespace per run, so this looks OK. The e2e test schema has only 3 columns. I accept that here: the new code path only filters batches that the existing reader produces, and select_positions has a unit test over every Arrow type family.
Test cases the PR does not cover (medium):
- A DV on a data file that has several row groups. All fixture files hold about 25 rows, so
_pos is never checked past the first row group (iceberg-rust gets it from the Parquet RowNumber, so the risk is low).
- DVs across partition-spec evolution: the selected read takes the DV's
partition_spec_id as the data file's.
- A V3 table that was upgraded from V2 and still has live V2 position deletes, which is the main real-world case for the new error.
- A snapshot whose DV deletes no new rows (
positions.is_empty() → None).
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector_tests |
✅ 5/5 pass |
cargo check --tests -p dbsp_adapters --features iceberg-tests-follow |
✅ compiles. I did not run the Spark/REST e2e tests (they need the catalog and object store) |
pre-commit run --files <changed> |
✅ all pass |
| Extra QA unit tests (diff below) |
✅ pass |
The pairing logic, spec reasoning, docs and breaking-change note look right. Licenses are fine: roaring and crc32fast are both MIT/Apache.
- pitfall (medium, flakiness): Every CI run of
spark_tables() downloads PySpark from PyPI and the Iceberg Spark/GCP bundles from Maven Central. The 3× retry helps, but red CI will still depend on two external registries. Consider baking the jars and wheel into the CI image, or caching them under ~/.ivy2 and the uv cache.
- missing (medium): Nothing tests the
checked_for_v2_deletes caching. Untested case: a V2 table where the first snapshot that removes files has no live V2 deletes, followed by a later snapshot with a removal. Also see the inline comment on new_deletes_read.
QA tests I added locally (pass)
+ #[test]
+ fn qa_rejects_trailing_bytes_after_bitmap() {
+ let mut body = vec![0xD1, 0xD3, 0x39, 0x64];
+ RoaringTreemap::from_iter([1, 2]).serialize_into(&mut body).unwrap();
+ body.push(0);
+ let mut blob = (body.len() as u32).to_be_bytes().to_vec();
+ blob.extend_from_slice(&body);
+ blob.extend_from_slice(&crc32fast::hash(&body).to_be_bytes());
+ let e = decode_deletion_vector(&blob).unwrap_err().to_string();
+ assert!(e.contains("follow the bitmap"), "{e}");
+ }
+
+ #[test]
+ fn qa_pairs_plain_files_and_first_dv() {
+ let paired = pair_deletion_vectors(1, SnapshotFiles {
+ added: vec![data_file("a.parquet")],
+ removed: vec![data_file("r.parquet")],
+ added_dvs: vec![dv("x.puffin", "kept.parquet")],
+ removed_dvs: vec![],
+ }).unwrap();
+ assert!(paired.added[0].deletion_vector.is_none());
+ assert!(paired.removed[0].deletion_vector.is_none());
+ assert!(paired.new_deletes[0].replaced.is_none());
+ pair_deletion_vectors(2, SnapshotFiles {
+ removed: vec![data_file("f.parquet")],
+ removed_dvs: vec![dv("x.puffin", "f.parquet")],
+ ..Default::default()
+ }).unwrap();
+ }
No hard gate fails. LGTM once the new_deletes_read error paths have tests.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector_tests |
✅ 6/6 pass |
cargo check --locked -p dbsp_adapters --features iceberg-tests-follow --tests |
✅ compiles |
e2e iceberg_rest_v3_* / iceberg_rest_follow_rejects_v2_delete_files |
⚠️ not run here (needs a REST catalog, object store and Spark). CI runs them through iceberg-tests-follow. |
Gates: tests, docs, breaking-change notice, licences (roaring, crc32fast are MIT/Apache-2.0) and flakiness all look fine. The fixture uses a per-run namespace, and its Maven retry with sleep has a good reason. SQL type coverage: the e2e schema has only 3 columns, but the new path only filters rows, and select_positions gets a unit test on every type family, so I accept that.
Missing tests (medium):
new_deletes_read with replaced: None (a file's first DV). Only the e2e covers it.
- A transient object-store error during DV or HEAD reads. See the inline comment: these reads are not retried.
- A V2 delete file removed by a followed snapshot (the
snapshot_files error path). The V2 e2e reaches only find_v2_delete_file.
Pitfall (medium): a writer that removes a data file but keeps its DV breaks the spec. The connector then silently emits deletes for rows it never inserted (negative weights downstream), and nothing fails. The docs call this out. Nothing cheap can detect it, so please describe this symptom (negative weights after a copy-on-write change) in the docs, so users can diagnose it.
Otherwise LGTM once the retry question is resolved.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
|
Ran
| Step |
Result |
cargo test --locked -p feldera-iceberg (all 31, including 7 new DV tests) |
✅ pass |
cargo check --locked -p dbsp_adapters --tests --features iceberg-tests-follow |
✅ pass |
pre-commit run --files <changed> |
✅ pass |
e2e iceberg_rest_v3_* / iceberg_rest_follow_rejects_v2_delete_files |
⚠️ not run: they need the REST catalog, object storage and Spark. CI was still pending. |
Gates
- missing (medium), SQL type coverage: the e2e table has only
id BIGINT, name STRING, created_at TIMESTAMP_NTZ. The unit test runs select_positions on every Arrow type family, which supports the argument that type handling is unchanged. But the DV path is new: it adds _file/_pos to the projection, reads without a manifest record_count, and fills partition constants from the DV's partition. Spark can write the wide schema the other follow tests use, so please give v3_dv those columns. That would meet the gate outright.
- Tests, docs, breaking-change note, commit message, dependency licences (
roaring, crc32fast: MIT/Apache) and unsafe (none): OK.
Missing coverage (medium): new_deletes_read is never unit-tested with replaced: None, which is the first DV on a file and the most common case. Only the e2e test covers it. I wrote this test and it passes:
- replaced: Some(write_dv("old.puffin", old, old.len() as u64)),
+ replaced: (!old.is_empty()).then(|| write_dv("old.puffin", old, old.len() as u64)),
...
+ #[tokio::test]
+ async fn new_deletes_read_without_old_vector_selects_all_positions() {
+ let selected = newly_deleted(&[], &[0, 2, 1 << 33], 3).await.unwrap();
+ assert_eq!(selected, Some(vec![0, 2, 1 << 33]));
+ assert_eq!(newly_deleted(&[], &[], 0).await.unwrap(), None);
+ }
Other cases with no test:
- A snapshot that adds a V2 delete file to a V3 table after
checked_for_v2_deletes is set.
- A connector restart in the middle of a run of DV snapshots: a checkpoint resume followed by a DV replacement.
- An equality-delete snapshot in a V3 table, which should hit the error path.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
LGTM. I traced the DV pairing (added / removed / kept file with a replaced DV) against the spec and found no correctness bugs. Docs are updated, the commit message is good, and the new deps (crc32fast, plus the existing workspace dep roaring) are MIT/Apache.
What I ran
| Command |
Result |
cargo test --locked -p feldera-iceberg deletion_vector_tests |
7/7 pass |
cargo test --locked -p feldera-iceberg |
31/31 pass |
cargo check --locked -p dbsp_adapters --tests --features iceberg-tests-follow |
clean |
e2e iceberg_rest_v3_* / iceberg_rest_follow_rejects_v2_delete_files |
not run: needs the REST catalog, object storage and Spark |
pitfall (medium): the e2e fixture depends on the network. create_v3_tables.py downloads PySpark (via uv) and the Iceberg Spark and bundle jars from Maven Central at test time. The 3× retry helps, but a Maven outage will still fail CI. Consider pre-fetching the jars into the CI image or caching the Ivy directory.
Cases the PR's tests don't cover (none of them block):
- An equality delete file only has a unit test (error text); no e2e test rejects one.
- A data file added in the same snapshot as its DV only has a unit test of the pairing logic; the e2e test doesn't guarantee one.
- A writer that leaves a dangling DV when it removes a data file would make the connector delete those rows twice. The docs state the spec requirement, and nothing detects it at runtime. That is acceptable, but worth knowing if users run non-Java writers.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Review: LGTM. I found no correctness bugs, and every feldera-pr-checks gate passes: tests, docs, commit message, deps (roaring and crc32fast are MIT/Apache) and error messages that tell the user what to do.
What I ran
| Check |
Result |
cargo test --locked -p feldera-iceberg deletion_vector |
5/5 pass |
pre-commit run --files <changed files> (clippy, fmt, machete, ruff-format, docs) |
pass |
Spark e2e (rest_follow_v3_*, rest_follow_rejects_v2_*) |
not run (needs S3/REST catalog); CI image has JDK 21 and uv |
Cases I checked by reading the code: added file + DV, removed file + old DV, kept file with first / replacement DV, DV that shrinks (rejected), DV removed without replacement (rejected), CoW rewrite of a file with a DV, compaction skipped, V2 file left over from before the start snapshot (caught on the first delete). Type coverage is fine: the new path only filters batches, and select_positions_keeps_selected_rows_of_any_type covers every type family.
Missing tests (medium):
- Restart from a checkpoint in the middle of DV snapshots. No test resumes a follow across DELETE/UPDATE snapshots.
- Manifest or DV read retries, and the connector going unhealthy and then healthy again. The new
retry() has no test.
- The equality-delete error and the V2/DV classification have no test. Here is one I wrote and ran (passes):
+ #[test]
+ fn classifies_delete_files() {
+ let file = |content, format| {
+ DataFileBuilder::default().file_path("d".to_string()).file_format(format)
+ .content(content).record_count(1).partition(Struct::empty())
+ .file_size_in_bytes(1).build().unwrap()
+ };
+ let pos_v2 = file(DataContentType::PositionDeletes, DataFileFormat::Parquet);
+ let eq = file(DataContentType::EqualityDeletes, DataFileFormat::Parquet);
+ let dv = file(DataContentType::PositionDeletes, DataFileFormat::Puffin);
+ assert!(is_deletion_vector(&dv));
+ assert!(!is_deletion_vector(&pos_v2) && !is_deletion_vector(&eq));
+ assert!(v2_delete_file_error(&eq).to_string().contains("equality delete file ('d')"));
+ assert!(v2_delete_file_error(&pos_v2).to_string().contains("V2 position delete file ('d')"));
+ }
Pitfall (medium, flakiness): at test time, the e2e fixture downloads pyspark from PyPI and the Iceberg jars from Maven Central. The 3× retry helps, but the jobs still depend on the network. Consider caching the uv and ivy directories in test-adapters.yml, or baking the jars into the CI image.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
I asked the SI whether the previously found issues are fixed.
Lines 2 and 4 below should be easy fixes.
Line 1 - much harder, but please do evaluate claude's suggestion above: "- Fix: a new-DV read yields few rows, so collect each file's rows before queuing them; a failure then leaves nothing partial. The general fix is to record which files are fully pushed and retry only the rest."
┌───────────────────────┬─────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Point in your comment │ Status │ Evidence │
├───────────────────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ a. Replays become │ Not │ The only change in IcebergInputEndpointInner::push_changed_files is where it reports health. It still pushes │
│ double deletes │ fixed │ QueueEntry::Rollback and re-reads every file, and IcebergInputEndpointInner::drain_batch_stream is unchanged. I compared the │
│ │ │ code; I did not run it. │
├───────────────────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Errors that can never │ Not │ input::is_permanent_read_error still matches only FeatureUnsupported, and IcebergReaderConfig::max_retries() still defaults │
│ succeed are retried │ fixed │ to u32::MAX. │
│ forever │ │ │
├───────────────────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ b. Never reports a │ Fixed │ The new IcebergInputEndpointInner::retry marks the connector unhealthy on every failed attempt. push_changed_files now does │
│ problem │ │ too; before, it did only when it gave up. The follow loop marks the connector healthy once a snapshot's changes are read. │
├───────────────────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ b. Never gives up; │ Not │ Errors are still turned into strings before the check. I tested a missing deletion vector with default settings: after 8 s │
│ discards the error │ fixed │ it was still retrying (4 attempts, 4 unhealthy reports). The error DataInvalid => Failed to open file … No such file or │
│ kind │ │ directory is not treated as permanent. │
├───────────────────────┼─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ c. Manifest reads │ Fixed │ read_manifest_list and read_manifest now go through retry. Manifest, deletion-vector, file-size and data-file reads now │
│ never retried │ │ follow one retry policy. │
└───────────────────────┴─────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
@ryzhyk for 1, I think based on what i evaluated, it should be follow-up instead of making this big PR even bigger
from my friend:
by itself it does not stop repeated rows, and a full fix costs a lot of memory
A retry reads all files of the snapshot again, also the files that it already
queued. A full fix must remember which files it queued and retry only the
others. It must also hold each file in memory until it reads the file to the
end. That is cheap for a new deletion vector, because it keeps only the deleted
rows. But a removed file can be large, and holding it costs as much memory as
the file. Inserts and copy-on-write deletes on main already repeat rows the same
way, so the follow-up will fix all of them, with a test that makes a read fail.
for 2 i initially thought let's not categorize errors and it is safe to keep retrying if we're marking connector unhealthy. but now it is fixed, same for 4, fixed it!
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
if u don't mind bigger PR to review, i can add 1 in this PR as well :P
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
A separate PR is fine by me.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Since you asked for a detailed review, I've got one for you.
I'd hold this PR: the fresh review found a new high-severity bug, I reproduced it through the connector, and a second finding comes with it. The two fixes you asked for landed (a missing file now stops at once), but the new check has a flaw of its own.
Your table (comment 6636088929), re-checked at 2e3a140c9
The only code change since my last review: input::is_permanent_read_error now also matches "datainvalid" and "notfound". The author also added a test and one docs sentence.
┌───────────────────────┬─────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Point │ Status │ Evidence │
├───────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ a. Replays become │ Not fixed; the │ His main argument is right: on main, a retry already re-queues inserts and copy-on-write deletes; the │
│ double deletes │ author wants a │ QueueEntry::Rollback doc comment says so. His cost argument is wrong: a per-file count of rows already queued │
│ │ follow-up │ would let a retry skip them without holding any file in memory (my inference; I haven't built it). │
├───────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Errors that can never │ │ A missing deletion vector stops after 1 attempt with 1 unhealthy report, on the local filesystem, opendal's │
│ succeed are retried │ Fixed │ filesystem and S3 (MinIO). I ran all three. │
│ forever │ │ │
├───────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ b. Never reports a │ Fixed (unchanged) │ │
│ problem │ │ │
├───────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ b. Never gives up; │ │ │
│ discards the error │ Partly fixed │ It gives up now, but it still decides by matching words anywhere in the message, file path included (finding 3). │
│ kind │ │ │
├───────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ c. Manifest reads │ Fixed (unchanged) │ │
│ never retried │ │ │
└───────────────────────┴─────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
New findings
1. High: deletion vectors left behind by Iceberg Java 1.9 or earlier cause double deletes.
- Cause: before 1.10.0, Iceberg Java left a data file's deletion vector in place when a copy-on-write change removed the file. The spec rule ("When removing a data file, writers must also remove any deletion vector that applies to that data file") and Java's removeDanglingDeletesFor both arrived in 1.10.0. I checked the apache/iceberg 1.9.0, 1.9.2 and 1.10.0 tags.
- Effect: input::pair_deletion_vectors finds no removed vector for the file (/Users/leonid/projects/feldera.pr7590/crates/iceberg/src/input.rs:669). The connector then deletes every row of the file, including rows the vector had already deleted.
- Reproduced: I ran the PR's own e2e test on its own fixture, written by Spark 3.5.7 with Iceberg 1.9.2 instead of 1.10.0. The connector emits weight −1 for ids 50, 51, 52, 60, 61, 62, 70, 71 and 72. The read-count check still passes (104 = 104); only the row comparison catches it.
- Keyed tables: a delete removes the key's current value (per add_input_map's documented semantics). So the live rows u52, u62 and u72 would disappear from Feldera. This is inference; I didn't run a keyed table.
2. Medium: a valid later commit stops follow mode with a fatal error that blames the writer.
- Cause: Java drops a leftover vector (dropDeleteFilesOlderThan, present in both 1.9 and 1.10) once every live data file is newer than it. So an ordinary later DELETE removes the vector, and input::pair_deletion_vectors treats that as a spec violation (/Users/leonid/projects/feldera.pr7590/crates/iceberg/src/input.rs:692).
- Reproduced on the REST catalog:
- Iceberg 1.9.2 runs a merge-on-read DELETE, then a copy-on-write DELETE.
- Iceberg 1.10.0 then appends and runs a copy-on-write DELETE of another row.
- The connector stops with: FATAL error … removes the deletion vector of data file '…' without replacing it or removing the file; the table breaks the Iceberg spec … Fix the writer and rewrite the table …
- Why the message misleads: the 1.10 writer is following the spec, and the writer that left the vector behind was following the spec of its time. Telling the user to fix the writer doesn't help.
An independent reviewer that I ran in parallel found 1 and 2; I reproduced both myself.
3. Medium-low: the new error check matches words inside file paths.
- input::is_permanent_read_error (/Users/leonid/projects/feldera.pr7590/crates/iceberg/src/input.rs:383) searches the whole message, which contains the path twice.
- Reproduced: I pointed opendal's S3 client at a closed port and read three paths:
- code=Timeout: still retrying after 30 s.
- code=NotFound or result=DataInvalid: gave up after the first attempt (7 s).
- Partition values are user data, so a table partitioned by an error code fails on its first temporary error.
- The local-filesystem storage labels every I/O error DataInvalid, so temporary local errors are now permanent too. I found this by reading local_fs.rs; it affects only metadata_location tables.
- Fix: check iceberg::Error::kind() and the source error's kind, not the formatted text.
4. Low: docs and tests.
- There's no changelog entry, though the PR marks the V2 rejection as a breaking change.
- The README's local catalog, tabulario/iceberg-rest:1.6.0, cannot run the new V3 test: Spark fails with "Unsupported format version: v3 (supported: v2)". The image CI uses works.
- The fixture pins Iceberg 1.10.0, so the tests cannot catch findings 1 and 2.
- The fixture's comment that the reader fills created_at from the partition is wrong (/Users/leonid/projects/feldera.pr7590/crates/iceberg/src/test/create_v3_tables.py:82). Spark writes created_at into every file, and iceberg-rust uses the partition value only when the column is missing. So the partition values the PR passes for deletion-vector reads are never exercised.
- The max_retries doc still describes snapshot reads only.
Tests I ran
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────────┐
│ Test │ Result │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ feldera-iceberg unit tests │ 32/32 pass │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ rest_follow_v3_reads_only_changed_rows and rest_follow_rejects_v2_delete_files, against CI's catalog image │ both pass │
│ (apache/iceberg-rest-fixture:1.9.1) with MinIO │ │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ Same V3 test, table written by Iceberg 1.9.2 │ fails (finding 1) │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤
│ Same harness, 1.9.2 writes then 1.10.0 writes │ fatal error (finding │
│ │ 2) │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────┘
On GitHub, still only the main check has run on this commit.
Possible fixes for 1 and 2 (the author's choice)
- Index the live vectors. IcebergInputEndpointInner::find_v2_delete_file already reads every delete manifest once. That same scan could build a map from data-file path to live deletion vector, updated per snapshot. The connector would apply the live vector when it reads a removed file, and ignore a removed vector whose data file is no longer live.
- Cheaper partial fix for 2. Ignore an unmatched removed vector whose sequence number is below the smallest min_sequence_number among the snapshot's data manifests, which mirrors the rule Java uses to drop it. Such a vector can't apply to any live file. This is my inference; I haven't built it.
- At the least, document that the table's writers must be Iceberg Java 1.10 or later.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
@ryzhyk thanks! fixed the findings! 🫡
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
|
deletion_vectors: live_dvs,
|
|
|
v2_delete_file,
|
|
|
} = self
|
|
|
.live_deletes(table, &manifest_list, &unpaired_removed, find_v2)
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perf, medium: unpaired_removed is non-empty for every snapshot that removes a data file which never had a DV (a plain copy-on-write DELETE/overwrite, the common case after 1.10), so each such snapshot re-reads every live delete manifest of the table. On a large merge-on-read table with many delete manifests, follow cost becomes proportional to table size per snapshot, not to the change. Cheap mitigations: skip when the snapshot summary has total-delete-files == 0, prune delete manifests by partition summary of the removed files, or cache the live-DV index across snapshots (it only changes by each snapshot's added/removed DVs).
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
|
// Delta Spark fixtures.
|
|
|
let mut attempt = 1;
|
|
|
let output = loop {
|
|
|
let output = std::process::Command::new("uv")
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flaky-risk, medium: each case downloads a pyspark wheel from PyPI (uv run --with pyspark==…) and the Iceberg runtime/bundle jars from Maven Central at test time, and cases run as parallel processes sharing ~/.ivy2. That is an unmocked network dependency in the adapters CI job; the 3× retry papers over it rather than removing it. Consider pre-fetching pyspark (e.g. requirements.ci.txt) and the jars in a CI step (or giving each process its own spark.jars.ivy) so the test itself never hits the network.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
What I ran
| Step |
Result |
cargo check --locked -p feldera-iceberg |
✅ |
cargo test --locked -p feldera-iceberg --lib (35 tests, including 9 new DV tests) |
✅ |
pre-commit run --files <changed files> (clippy, fmt, machete, crypto deps, docs) |
✅ |
Spark/S3 e2e tests (rest_follow_v3_*, rest_follow_rejects_v2_delete_files) |
not run here (they need the REST catalog, object store, JDK and Spark) |
Gates: tests, docs, changelog, breaking-change note and licences (roaring, opendal and crc32fast are MIT/Apache, and opendal uses default-features = false) all pass. There is no unsafe code. SQL type coverage: the e2e schema only has BIGINT/STRING/TIMESTAMP, but the new path only filters rows, and select_positions_keeps_selected_rows_of_any_type covers that, so I accept it.
Issues (see the inline comments)
- flaky-risk, medium: the e2e fixtures download pyspark and Maven jars when the tests run.
- perf, medium: each copy-on-write removal re-reads every live delete manifest.
- nit, commit: the subject ends with a period (
…in follow mode.).
Gaps in QA coverage
- missing, low: there were no tests for
deletion_vector_range errors (encrypted DV, missing or negative offset) or for select_positions on an empty batch or an unknown _file. I wrote these tests locally and they pass:
+ #[test]
+ fn deletion_vector_range_errors() {
+ assert_eq!(deletion_vector_range(&dv_file(Some(4), Some(10), None)).unwrap(), 4..14);
+ assert!(deletion_vector_range(&dv_file(None, Some(10), None)).unwrap_err().to_string().contains("no content offset or size"));
+ assert!(deletion_vector_range(&dv_file(Some(4), Some(10), Some(vec![1]))).unwrap_err().to_string().contains("encrypted"));
+ assert!(deletion_vector_range(&dv_file(Some(-1), Some(10), None)).is_err());
+ }
+ #[test]
+ fn select_positions_empty_and_unknown_file() { /* 0-row batch drops _file/_pos; unknown _file → "no selected positions" */ }
- missing, medium: no test covers the following:
- A restart from a checkpoint between snapshots that carry DVs. This resets
checked_for_v2_deletes and re-pairs the DVs.
- A transient failure while reading a DV or manifest, followed by recovery, end to end.
- A snapshot that removes a DV without replacing it while its data file is still live. The pairing unit test covers this, but nothing tests the error through
snapshot_delta and live_data_files.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No more bugs found. There's a performance issue for you to assess. Sounds like it's time to merge this.
New findings
- Medium (cost, not correctness): the leftover lookup rereads every delete manifest of the table.
- When: every snapshot that removes a data file without its deletion vector triggers it. That covers copy-on-write DML, INSERT OVERWRITE and partition deletes on any table that has deletion vectors.
- Wasted on new writers: this includes tables written only by Iceberg 1.10+, where the lookup can never find anything, because 1.10 removes a file's vector in the same commit.
- Where: IcebergInputEndpointInner::live_deletes (input.rs:2058), called from snapshot_delta (:1932-1947). iceberg-rust's ManifestReader::read has no cache.
- Observed: in the reviewer's trace, which I checked, a table with 39 delete manifests and 4 followed copy-on-write deletes fetched each delete manifest 5 times: once for the initial read and once per snapshot.
- Cheap fix: Java writes its version into every snapshot summary; our test table shows iceberg-version: "Apache Iceberg 1.10.0 (commit …)". Skip the lookup for Java 1.10+ writers. Pruning manifests by partition, or caching them per poll, would also help.
- Wrong comment: the comment in IcebergInputEndpointInner::live_data_files (:2113-2116) says this check "reads no manifest". In the reviewer's table history, a 1.9.2 partition delete and a 1.10 INSERT OVERWRITE each dropped a leftover vector while an older data file stayed in the table, so the method did read manifests. The code handles this correctly; only the comment is wrong.
- Low (test gap, confirmed by mutation): nothing tests whether a vector's data file is still in the table.
- The check: when a snapshot removes a vector on its own, snapshot_delta asks live_data_files whether the vector's data file is still in the table. If it is, the connector stops with a spec-violation error.
- Mutation: I made live_data_files report every data file as gone. All three Spark e2e tests still passed. No unit test reaches snapshot_delta; that code isn't even built into the unit-test binary.
- Risk: a regression here would silently ignore a vector removed from a file still in the table, so rows the table now has again would stay deleted in Feldera.
- Fix: move the check into a plain function with unit tests, or add a 1.10 INSERT OVERWRITE to the test table that drops a leftover vector while an older file stays.
- Minor: both deletion-vector e2e tests start before the first vector, so the initial table read never meets one.
- Low (observed): on local tables, a temporary I/O error now stops the connector at once.
- Cause: local tables are read through FileIO::new_with_fs(). That storage reports every I/O error as DataInvalid, keeping only the message text, and input::is_permanent_read_error treats DataInvalid as permanent.
- Observed: with all file descriptors in use, a vector read gave up after one attempt in 1 ms, with Too many open files (os error 24).
- Before this PR: these errors were retried, in snapshot mode too.
- Fix: in the iceberg-rust fork, keep the original I/O error attached and classify by its kind.
- Low (inferred, not run): retries can't recover from an error that needs a fresh table.
- What changed: manifest and vector reads now retry; before, one failure was fatal. The retries reuse the table opened for the current poll, and max_retries has no limit by default.
- Scenario: a REST catalog hands out short-lived S3 credentials, and a catch-up runs past their expiry. The connector would then retry forever instead of failing. Data-file reads already behaved this way.
- Suggestion: handle this in the retry follow-up PR (item a).
- Low (inferred, not run): stopping at once on a missing file is new for most reads. It could misfire where S3 replication or Multi-Region Access Points briefly return "not found" for objects that exist. That is a rare setup.
- Low (observed): the changelog entries land in a released section. After merging into current main, the three entries end up under ## v0.377.0 instead of ## Unreleased.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
|
.output()
|
|
|
.unwrap_or_else(|e| panic!("failed to run '{script}' (is `uv` on PATH?): {e}"))
|
|
|
};
|
|
|
// Jar downloads from Maven Central fail now and then; retry like the
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): flakiness. Every CI run of these four tests resolves PyPI (pyspark 4.0.1 and 3.5.7) and Maven Central (Spark 4/3.5 Iceberg runtimes plus AWS bundles), and only three attempts with 5 s and 10 s back-off protect it. A Maven or PyPI outage longer than about 15 s turns test-adapters red for reasons the PR does not control. Could CI cache the Ivy dir (~/.ivy2) and the uv cache (actions/cache keyed on the script hash)? Or could the fixture tables be pre-built in a pinned image, as iceberg-rest already is?
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
Review: Iceberg follow mode reads deletion vectors
What I ran
| Step |
Result |
cargo check --locked -p feldera-iceberg --tests |
✅ |
cargo test --locked -p feldera-iceberg --lib |
✅ 39/39, including the new deletion_vector_tests and the permanent-error tests |
Spark/MinIO e2e (rest_follow_v3_*, rest_follow_rejects_v2_delete_files) |
not run: needs Docker, so I could not run them here |
Gates: tests ✅, docs and changelog ✅, breaking change declared ✅, new deps roaring and opendal (Apache-2.0, default-features = false) and crc32fast (dev, MIT/Apache) ✅, no unsafe ✅. SQL type coverage: Spark cannot write TIME, FIXED, unsigned or interval types, and the row-filter unit test covers every type family, so this is acceptable.
Correctness checks: I checked that a transient S3 or Parquet error does not become permanent. iceberg-rust maps ParquetError and OpenDAL errors to Unexpected, not DataInvalid, so the new is_permanent_read_error keeps retrying object-store failures as before.
Missing test (medium): no test covers live_deletes reusing a cached DeleteManifestSummary across snapshots: skipping manifests with no hash match, re-reading one after a hash collision, and pruning summaries of manifests that are gone. A bug in this cache would silently miss a leftover DV, and the deleted rows would be emitted twice.
Flakiness (medium): see the inline comment about the Maven/PyPI downloads in the e2e fixture.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Follow mode now reads V3 deletion vectors, so merge-on-read DELETE,
UPDATE and MERGE stream as changes. For a new vector it reads the data
file and keeps only the newly deleted rows. It also applies the vectors
that writers before Iceberg 1.10 leave in the table. V2 delete files
still stop the connector with an error. A summary of each delete
manifest is kept, so a snapshot reads only new delete manifests.
Manifest and deletion vector reads now retry like data-file reads, and
each retry marks the connector unhealthy. A read that a retry cannot
fix, such as a read of a missing file, stops at once.
Signed-off-by: Swanand Mulay <[email protected]>
|
Review: LGTM, with one gap to note.
What I ran
| Check |
Result |
cargo check --locked -p feldera-iceberg |
✅ |
cargo test --locked -p feldera-iceberg --lib |
✅ 40/40 (includes the 12 new deletion_vector_tests) |
pre-commit run --files <changed files> (clippy, fmt, machete, crypto deps, ruff-format) |
✅ |
iceberg-tests-follow e2e (Spark, MinIO, REST catalog) |
⏭️ not run, needs services |
I also checked that the IcebergError(<kind>) label matched in is_permanent_read_error is the exact text that iceberg-datafusion's to_datafusion_error produces. num_parsers == 0 is rejected at startup, so the new buffer_unordered(num_parsers) calls can't hang. The new deps are fine: opendal (Apache-2.0, default-features = false) and crc32fast (MIT/Apache-2.0, dev-only).
tech-debt (medium): the new e2e tests don't run in CI. No workflow enables iceberg-tests-follow. The four new rest_follow_v3_* / rest_follow_rejects_v2_delete_files tests are the only coverage for the pieces that cross the reader boundary: _file/_pos projection over many row groups, leftover-DV pairing against a real 1.9 writer, and the V2 rejection. They also download PySpark and the Iceberg jars from PyPI and Maven Central on every run, so they can fail for reasons unrelated to the code. That's acceptable for an opt-in suite, but it shouldn't become a gating job as it is. Please follow up with a CI job that caches the jars, or run these in a nightly job.
Cases the PR's tests don't cover
- A pipeline restarting from a checkpoint in the middle of a run of DV snapshots. The
DeleteManifests cache and checked_for_v2_deletes are in memory only, so after a restart they start empty and the code reads the manifests again. I expect this to be correct, but no test covers it.
- An equality delete file in a V3 table. Only the classification is unit-tested. The e2e test only covers a V2 position delete file.
- One snapshot that adds a DV to a kept file and also removes a different file that has a leftover DV, so the
new_deletes read and the whole-file read go through the same stream.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
ryzhyk
ryzhyk approved these changes
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|