Skip to content

DeleteVectorIterator::advance_to mishandles positions at the 2^32 boundary #3400

Open

Description

@mfellner

Related to #2411 (v3 tracking).

Related to #1806, which fixes the stale cached delete in build_deletes_row_selection. The cases below are not covered by that fix.

Apache Iceberg Rust version

main at a766783. The advance_to body is identical in v0.10.1 (compared the two tagged sources).

Describe the bug

DeleteVectorIterator::advance_to (crates/iceberg/src/delete_vector.rs, lines 256-276) splits the target into a high 32-bit bucket and a low 32-bit offset. It mishandles two cases that become reachable once positions reach 2^32:

  1. The target is in a later bucket, but the outer bitmap iterator is exhausted (lines 264-267). The function returns without clearing the current bucket, so positions below the target are still yielded.
  2. The target's bucket is lower than the current bucket. The low 32 bits of the target are applied to the current, higher bucket (line 275), so valid positions in that bucket are dropped.

A latent third case: calling advance_to before the first next() does nothing (lines 260-262), so positions below the target are yielded. build_deletes_row_selection always calls next() first (crates/iceberg/src/arrow/reader/positional_deletes.rs, line 44), so it does not hit this today.

Effects through ArrowReader::build_deletes_row_selection:

  • Case 1, with a skipped row group crossing 2^32 followed by a selected row group: the call does not return. The hang is reproduced (see below). The mechanism is from code reading only: the stale cached delete stays below the selected row group's base, and the 'chunks loop neither selects nor skips, so it does not advance.
  • Case 2: the returned RowSelection omits a delete (reproduced below).

Both need positions of at least 2^32, which requires a data file with at least 2^32 rows. The spec's deletion vector section states that deletion vectors support positive 64-bit positions.

To Reproduce

On a checkout of main at a766783, append the two test modules below to crates/iceberg/src/delete_vector.rs and crates/iceberg/src/arrow/reader/positional_deletes.rs (no other source changes), then run:

cargo test --offline -p iceberg --lib repro_advance_to

Result on main: 0 passed; 5 failed, finished in 10.01s. The row-selection tests run on a worker thread with a 10 second receive timeout, so the hang fails the test instead of blocking the run. In the failures below, "left" is the actual result.

Failures:

  • delete_vector::repro_advance_to::advance_to_past_last_bucket_leaves_no_stale_positions: left [7], right []
  • delete_vector::repro_advance_to::advance_to_lower_target_keeps_later_positions_in_current_bucket: left [], right [4294967316]
  • delete_vector::repro_advance_to::advance_to_on_fresh_iterator_skips_positions_below_target: left [5, 200], right [200]
  • arrow::reader::positional_deletes::repro_advance_to::skipped_row_group_crossing_2_32_then_selected_row_group_terminates: build_deletes_row_selection did not terminate within 10s
  • arrow::reader::positional_deletes::repro_advance_to::skipped_row_group_ending_at_50_keeps_delete_in_bucket_1: left [select(4294967256), skip(1), select(89)], right [select(4294967256), skip(1), select(9), skip(1), select(79)]

As a control, a skipped row group followed by a selected row group with deletes below 2^32 (positions 3, 7 and 60, row groups of 50 and 50 rows, selected [1]) returns the expected selection on main.

The test bodies:

Test code (crates/iceberg/src/delete_vector.rs)
#[cfg(test)]
mod repro_advance_to {
    use super::*;

    const BUCKET_1: u64 = 1 << 32;

    #[test]
    fn advance_to_past_last_bucket_leaves_no_stale_positions() {
        let mut dv = DeleteVector::default();
        dv.insert(5);
        dv.insert(7);
        let mut it = dv.iter();
        assert_eq!(it.next(), Some(5));
        it.advance_to(BUCKET_1 + 10);
        assert_eq!(it.collect::<Vec<u64>>(), Vec::<u64>::new());
    }

    #[test]
    fn advance_to_lower_target_keeps_later_positions_in_current_bucket() {
        let mut dv = DeleteVector::default();
        dv.insert(BUCKET_1 + 10);
        dv.insert(BUCKET_1 + 20);
        let mut it = dv.iter();
        assert_eq!(it.next(), Some(BUCKET_1 + 10));
        it.advance_to(50);
        assert_eq!(it.collect::<Vec<u64>>(), vec![BUCKET_1 + 20]);
    }

    #[test]
    fn advance_to_on_fresh_iterator_skips_positions_below_target() {
        let mut dv = DeleteVector::default();
        dv.insert(5);
        dv.insert(200);
        let mut it = dv.iter();
        it.advance_to(100);
        assert_eq!(it.collect::<Vec<u64>>(), vec![200]);
    }
}
Test code (crates/iceberg/src/arrow/reader/positional_deletes.rs)
#[cfg(test)]
mod repro_advance_to {
    use std::sync::Arc;
    use std::sync::mpsc;
    use std::time::Duration;

    use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
    use parquet::file::metadata::{ColumnChunkMetaData, RowGroupMetaData};
    use parquet::schema::types::{SchemaDescPtr, SchemaDescriptor, Type as SchemaType};

    use super::ArrowReader;
    use crate::delete_vector::DeleteVector;

    const BUCKET_1: i64 = 1 << 32;

    fn schema_descr() -> SchemaDescPtr {
        let field = SchemaType::primitive_type_builder("a", parquet::basic::Type::INT32)
            .build()
            .unwrap();
        let schema = SchemaType::group_type_builder("schema")
            .with_fields(vec![Arc::new(field)])
            .build()
            .unwrap();
        Arc::new(SchemaDescriptor::new(Arc::new(schema)))
    }

    fn row_group(schema: &SchemaDescPtr, num_rows: i64, ordinal: i16) -> RowGroupMetaData {
        let columns: Vec<ColumnChunkMetaData> = schema
            .columns()
            .iter()
            .map(|c| ColumnChunkMetaData::builder(c.clone()).build().unwrap())
            .collect();
        RowGroupMetaData::builder(schema.clone())
            .set_num_rows(num_rows)
            .set_column_metadata(columns)
            .set_ordinal(ordinal)
            .build()
            .unwrap()
    }

    // Runs `build_deletes_row_selection` on a worker thread so a non-terminating loop fails the test
    // instead of hanging the test binary.
    fn build_selection_bounded(
        row_groups: Vec<RowGroupMetaData>,
        selected_row_groups: Option<Vec<usize>>,
        deletes: DeleteVector,
    ) -> Option<RowSelection> {
        let (tx, rx) = mpsc::channel();
        std::thread::spawn(move || {
            let selection = ArrowReader::build_deletes_row_selection(
                &row_groups,
                &selected_row_groups,
                &deletes,
            )
            .unwrap();
            let _ = tx.send(selection);
        });
        rx.recv_timeout(Duration::from_secs(10)).ok()
    }

    fn deletes_of(positions: &[i64]) -> DeleteVector {
        let mut dv = DeleteVector::default();
        for &pos in positions {
            dv.insert(pos as u64);
        }
        dv
    }

    #[test]
    fn skipped_row_group_crossing_2_32_then_selected_row_group_terminates() {
        let schema = schema_descr();
        let row_groups = vec![
            row_group(&schema, BUCKET_1 + 10, 0),
            row_group(&schema, 10, 1),
        ];
        let selection = build_selection_bounded(
            row_groups,
            Some(vec![1]),
            deletes_of(&[3, 7]),
        )
        .expect("build_deletes_row_selection did not terminate within 10s");
        assert_eq!(
            selection,
            RowSelection::from(vec![RowSelector::select(10)])
        );
    }

    #[test]
    fn skipped_row_group_ending_at_50_keeps_delete_in_bucket_1() {
        let schema = schema_descr();
        let row_groups = vec![
            row_group(&schema, 50, 0),
            row_group(&schema, BUCKET_1 + 50, 1),
        ];
        let selection = build_selection_bounded(
            row_groups,
            Some(vec![1]),
            deletes_of(&[BUCKET_1 + 10, BUCKET_1 + 20]),
        )
        .expect("build_deletes_row_selection did not terminate within 10s");
        assert_eq!(
            selection,
            RowSelection::from(vec![
                RowSelector::select((BUCKET_1 - 40) as usize),
                RowSelector::skip(1),
                RowSelector::select(9),
                RowSelector::skip(1),
                RowSelector::select(79),
            ])
        );
    }
}

Upstream roaring

The comment at delete_vector.rs lines 213-218 says the roaring treemap iterator has no advance_to, and that roaring-rs PR #314 is open. PR #314 was merged on 2025-04-03, and roaring 0.11.4 (the version in Cargo.lock) provides treemap::Iter::advance_to. The note at lines 220-222 about a temporary git reference is also out of date: Cargo.toml depends on roaring = "0.11" from crates.io.

Using RoaringTreemap::iter() from roaring 0.11.4 with the same inputs, the case 1 input gives the expected result. For the case 2 input (after next() returns 4294967306, advance_to(50) yields [] instead of [4294967316]). Replacing the hand-written method with the upstream one would therefore not by itself fix case 2.

Expected behavior

After advance_to(target), the iterator yields only positions at or above target, including when the target is in a later 2^32 bucket. build_deletes_row_selection returns a RowSelection that matches the deletes for any row-group layout, including row groups that cross 2^32, and returns in all cases.

Willingness to contribute

I would be willing to contribute a fix with guidance from the Iceberg community (any contribution would be agent-assisted and I would disclose that).

Found during an agent-assisted audit of v3 support (see #2411). The reproduction above was run against main at a766783.

Activity

  1. akshat-lakhera commented on Oct 12, 2026

    @akshat-lakhera

    Hi @mfellner, are you planning to send a fix for this yourself? If not, I'd be glad to take it.

    From the repro, advance_to keeps the current bucket when the target bucket is past it and the outer iterator is exhausted, and applies the target's low bits to the wrong bucket. I'd fix both cases, make it work before the first next(), and add boundary tests around 2^32. cc @comphead

  2. JeonDaehong commented on Oct 14, 2026

    @JeonDaehong
    Contributor

    FYI @akshat-lakhera @mfellner: I'm working on a perf change to build_deletes_row_selection that reads the delete vector as ranges. It no longer uses DeleteVectorIterator::advance_to, so that function goes away, and the new skip logic has tests for both cases in this issue. I haven't seen a PR for this yet; if you've already started, let me know and I'll coordinate with you before opening mine.

  3. JeonDaehong commented on Oct 15, 2026

    @JeonDaehong
    Contributor

    Correction to my note above: #3461 made DeleteVectorIterator::advance_to public, so #3454 now keeps it unchanged. The reader there no longer calls it, but the two cases in this issue still apply to the public function and still need a fix.

  4. akshat-lakhera commented on Oct 15, 2026

    @akshat-lakhera

    Thanks for the heads-up @JeonDaehong. I hadn't started a PR, so go ahead with the range-based change. I can review it or help with test cases for the 2^32 boundary if useful.

  5. added 2 commits that reference this issue on Oct 16, 2026
  6. JeonDaehong commented on Oct 16, 2026

    @JeonDaehong
    Contributor

    Update: following review, #3454 now fixes advance_to too (all three cases above, each with a test), so it closes this issue. Thanks @akshat-lakhera, a review there would be welcome.

  7. mfellner commented on Oct 16, 2026

    @mfellner
    Author

    Thank you @akshat-lakhera and @JeonDaehong! I'm happy to help with review and verification if I can.

  8. JeonDaehong commented on Oct 16, 2026

    @JeonDaehong
    Contributor

    Thanks @mfellner! The fix is in #3454, which also adds tests for the cases in this issue. A review there, or a check against your original case, would be very welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions