This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new f64575819 feat(writer): add PositionDeleteInput builder (#3230)
f64575819 is described below
commit f64575819ec5d61a1d8ffd7740831a2cb64df5cd
Author: Andrei Tserakhau <[email protected]>
AuthorDate: Sun Sep 27 02:03:28 2026 +0000
feat(writer): add PositionDeleteInput builder (#3230)
* feat(writer): add PositionDeleteInput builder
Add PositionDeleteInputBuilder, a small helper that assembles a
spec-conforming position delete RecordBatch from (path, pos) rows.
The batch has exactly the two required position delete columns
(file_path: Utf8, pos: Int64), both non-null and carrying the reserved
field ids, so it passes PositionDeleteFileWriter's per-batch validation
and can be handed straight to that writer. An empty builder still
produces a valid 0-row batch.
To keep a single source of truth for the field-id wiring, the Arrow
schema is no longer hand-wired in the builder. This commit un-gates the
previously test-only POSITION_DELETE_ARROW_SCHEMA static and
position_delete_arrow_schema() helper in position_delete_writer.rs,
making the helper pub(crate), and the builder now obtains the shared
Arrow projection through it as a cheap Arc clone.
extend accepts any iterator of (Into<String>, i64) rows, and build
debug-asserts the paths/positions length invariant. The module docs
note that rows are materialized into two heap Vecs before the batch is
built (so very large inputs should use one builder per write batch),
that the optional third row column is not supported yet, and that
position delete files are a v2 construct which v3 forbids, so a
format-version gate must be applied at the transaction/commit layer.
Relates to #340 and #2218.
* refactor(writer): replace PositionDeleteInput builder with a sorted
position_delete_batch fn
Per review, collapse the PositionDeleteInputBuilder surface
(new/with_capacity/push/extend/len/is_empty/build) into a single free
function, position_delete_batch(rows). A FooBuilder is expected to build a
Foo, but the builder's build() only returned a bare RecordBatch, so a plain
function reads more clearly and keeps the API as tight as possible.
The function collects rows into a BTreeMap<String, RoaringTreemap> keyed by
file path. This deduplicates repeated (file_path, pos) pairs and, when
iterated, yields the spec-required ascending (file_path, pos) order for
free,
so callers no longer have to pre-sort their input. A negative pos is
rejected
with DataInvalid. The batch is still built from the writer's shared crate
internal Arrow schema, and empty input yields a valid 0-row batch.
* refactor(writer): model position deletes as a crate-internal
PositionDeletes accumulator
Following blackmwk's review of the redesigned position delete assembly,
replace the free position_delete_batch function with a small crate-internal
PositionDeletes accumulator. Callers build up deletes with insert(path, pos)
and render them once via to_record_batch().
Positions are now taken as u64: a row position is non-negative by
construction, so the old negative-i64 rejection is gone. The batch's Int64
pos column is filled with a checked u64-to-i64 conversion that returns
DataInvalid rather than panicking on a value past i64::MAX. Rows stay keyed
by a BTreeMap<String, RoaringTreemap>, so iteration yields the spec-required
(file_path, pos) order with duplicate pairs removed, and the two columns are
now built with Arrow StringBuilder and Int64Builder instead of intermediate
Vecs. The batch is assembled against the writer's shared
position_delete_arrow_schema, and an empty accumulator still yields a valid
0-row batch.
Nothing outside the crate consumes this yet, so the whole surface is
pub(crate) and the module carries a dead-code allowance until the write-path
plumbing lands. The public API drops the position_delete_batch entry
accordingly.
* refactor(writer): stream PositionDeletes as fixed-size record batches
Follow-up to blackmwk's review on #3230.
- Add PositionDeletes::to_record_batches(max_rows), a lazy iterator that
builds each fixed-size batch on demand instead of materializing the whole
delete set at once, bounding peak memory over large delete sets. It walks
the paths and positions in sorted (file_path, pos) order, splitting on
max_rows boundaries; a path whose positions overflow one batch resumes in
the next. A max_rows of 0 yields a single DataInvalid error rather than
panicking, and the stream stops after any error.
- Build the file_path column with StringBuilder::append_value_n, appending
each path's run within a batch in one call rather than once per row.
- Factor a shared private build_batch helper so to_record_batch (single
batch) and to_record_batches share the column-building logic.
- Document that duplicate (file_path, pos) inserts collapse silently, which
is intentional for position deletes.
* refactor(writer): debug_assert against duplicate and oversized position
deletes
---
crates/iceberg/src/writer/base_writer/mod.rs | 1 +
.../writer/base_writer/position_delete_input.rs | 673 +++++++++++++++++++++
.../writer/base_writer/position_delete_writer.rs | 12 +-
3 files changed, 681 insertions(+), 5 deletions(-)
diff --git a/crates/iceberg/src/writer/base_writer/mod.rs
b/crates/iceberg/src/writer/base_writer/mod.rs
index 5a23931f2..005260dcf 100644
--- a/crates/iceberg/src/writer/base_writer/mod.rs
+++ b/crates/iceberg/src/writer/base_writer/mod.rs
@@ -19,4 +19,5 @@
pub mod data_file_writer;
pub mod equality_delete_writer;
+pub(crate) mod position_delete_input;
pub mod position_delete_writer;
diff --git a/crates/iceberg/src/writer/base_writer/position_delete_input.rs
b/crates/iceberg/src/writer/base_writer/position_delete_input.rs
new file mode 100644
index 000000000..483739f68
--- /dev/null
+++ b/crates/iceberg/src/writer/base_writer/position_delete_input.rs
@@ -0,0 +1,673 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Accumulates position delete rows and renders them as spec-conforming
[`RecordBatch`]es.
+//!
+//! [`PositionDeletes`] collects `(file_path, pos)` pairs and emits the two
required position
+//! delete columns — `file_path` (`Utf8`) and `pos` (`Int64`) — sorted by
`(file_path, pos)`
+//! with duplicate pairs removed, as the spec requires. Rows can be drawn
either as one batch
+//! ([`to_record_batch`](PositionDeletes::to_record_batch)) or, to bound peak
memory over a
+//! large delete set, lazily as fixed-size batches
+//! ([`to_record_batches`](PositionDeletes::to_record_batches)). Either batch
is exactly what
+//!
[`PositionDeleteFileWriter`](super::position_delete_writer::PositionDeleteFileWriter)
+//! accepts, so it can be handed straight to that writer.
+//!
+//! Position delete files are a v2 construct. v3 tables use deletion vectors
and forbid adding
+//! new position delete files, so a format-version gate must be applied at the
+//! transaction/commit layer before routing v3 writes here.
+
+// Nothing wires `PositionDeletes` into a writer yet; the write-path plumbing
lands in a follow-up.
+#![allow(dead_code)]
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use arrow_array::builder::{Int64Builder, StringBuilder};
+use arrow_array::{ArrayRef, RecordBatch};
+use roaring::RoaringTreemap;
+
+use crate::{Error, ErrorKind, Result};
+
+/// Accumulates `(file_path, pos)` position delete rows.
+///
+/// Keying paths in a [`BTreeMap`] and positions in a [`RoaringTreemap`]
deduplicates repeated
+/// `(file_path, pos)` pairs and, when iterated, yields the spec-required
ascending
+/// `(file_path, pos)` order for free, so callers may [`insert`](Self::insert)
rows in any
+/// order.
+#[derive(Debug, Default)]
+pub(crate) struct PositionDeletes {
+ rows: BTreeMap<String, RoaringTreemap>,
+}
+
+impl PositionDeletes {
+ /// Creates an empty accumulator.
+ pub(crate) fn new() -> Self {
+ Self::default()
+ }
+
+ /// Records that row `pos` of `path` is deleted. `pos` is a row position,
so it is
+ /// non-negative by construction.
+ ///
+ /// A duplicate `(file_path, pos)` or a position exceeding `i64::MAX` is a
caller bug,
+ /// caught by [`debug_assert!`] in debug builds so it surfaces early in
debug/CI (a
+ /// position delete file must not list the same `(file_path, pos)` twice,
and the `pos`
+ /// column is `Int64`). In release those assertions compile out and the
old behavior
+ /// stands: the backing [`RoaringTreemap`] silently collapses a duplicate
pair, and an
+ /// oversized position is still rejected as [`ErrorKind::DataInvalid`]
when the batch is
+ /// built.
+ pub(crate) fn insert(&mut self, path: impl Into<String>, pos: u64) {
+ debug_assert!(pos <= i64::MAX as u64, "position {pos} exceeds
i64::MAX");
+ let inserted = self.rows.entry(path.into()).or_default().insert(pos);
+ debug_assert!(
+ inserted,
+ "duplicate position delete: the same (file_path, pos) was inserted
twice"
+ );
+ }
+
+ /// Returns whether no positions have been recorded. `insert` never leaves
an empty
+ /// treemap behind, so an empty map means no positions.
+ pub(crate) fn is_empty(&self) -> bool {
+ self.rows.is_empty()
+ }
+
+ /// Returns the total number of recorded positions across all paths.
+ pub(crate) fn len(&self) -> usize {
+ self.rows
+ .values()
+ .map(|positions| positions.len())
+ .sum::<u64>() as usize
+ }
+
+ /// Renders all accumulated rows as one spec-conforming position delete
[`RecordBatch`].
+ ///
+ /// The batch has exactly the two required position delete columns, built
against the
+ /// writer's shared
[`position_delete_arrow_schema`](super::position_delete_writer::position_delete_arrow_schema)
+ /// so the reserved field ids stay wired in. Rows are emitted sorted by
`(file_path, pos)`
+ /// with duplicate pairs removed. An empty accumulator yields a valid
0-row batch.
+ ///
+ /// This materializes every row at once; prefer
[`to_record_batches`](Self::to_record_batches)
+ /// to bound peak memory over a large delete set.
+ pub(crate) fn to_record_batch(&self) -> Result<RecordBatch> {
+ // `usize::MAX` never caps a real position count, so this drains
everything into one
+ // batch (a 0-row batch when empty).
+ Self::build_batch(&mut self.stream().peekable(), usize::MAX)
+ }
+
+ /// Lazily renders the accumulated rows as position delete
[`RecordBatch`]es of at most
+ /// `max_rows` rows each, so the whole delete set is never materialized at
once.
+ ///
+ /// Batches carry the two required columns in ascending `(file_path, pos)`
order with
+ /// duplicate pairs removed, split on `max_rows` boundaries; a single path
whose positions
+ /// overflow one batch simply resumes in the next. Each batch is built on
demand as the
+ /// iterator is polled — nothing is pre-collected — and uses the writer's
shared
+ ///
[`position_delete_arrow_schema`](super::position_delete_writer::position_delete_arrow_schema).
+ /// An empty accumulator yields no batches.
+ ///
+ /// `max_rows` must be greater than zero. Rather than panic, a `max_rows`
of `0` makes the
+ /// iterator yield a single [`ErrorKind::DataInvalid`] error and then stop.
+ pub(crate) fn to_record_batches(
+ &self,
+ max_rows: usize,
+ ) -> impl Iterator<Item = Result<RecordBatch>> + '_ {
+ let mut rows = self.stream().peekable();
+ let mut done = false;
+ std::iter::from_fn(move || {
+ if done {
+ return None;
+ }
+ if max_rows == 0 {
+ // Invalid, but never panic: surface it once and stop the
iterator.
+ done = true;
+ return Some(Err(Error::new(
+ ErrorKind::DataInvalid,
+ "max_rows must be greater than 0",
+ )));
+ }
+ // Nothing left to emit -> end the iteration.
+ rows.peek()?;
+ let batch = Self::build_batch(&mut rows, max_rows);
+ // A failed batch (e.g. a position overflowing `i64`) leaves its
offending row
+ // unconsumed, so stop after the first error rather than retry it
forever.
+ if batch.is_err() {
+ done = true;
+ }
+ Some(batch)
+ })
+ }
+
+ /// Flattens the accumulator into its rows in ascending `(file_path, pos)`
order.
+ ///
+ /// The `BTreeMap` iterates paths ascending and each `RoaringTreemap`
iterates positions
+ /// ascending, so the stream is sorted by `(file_path, pos)` and already
deduplicated.
+ fn stream(&self) -> impl Iterator<Item = (&str, u64)> + '_ {
+ self.rows
+ .iter()
+ .flat_map(|(path, positions)| positions.iter().map(move |pos|
(path.as_str(), pos)))
+ }
+
+ /// Builds one position delete [`RecordBatch`] of at most `max_rows` rows
drained from
+ /// `rows`, which must yield rows already in `(file_path, pos)` order.
+ ///
+ /// Consecutive rows sharing a path form a run appended to the `file_path`
column in a
+ /// single [`StringBuilder::append_value_n`] call, keeping that column
run-length friendly
+ /// rather than repeating the path per row. The batch is built against the
writer's shared
+ /// Arrow projection (a cheap `Arc` clone) so the reserved field ids stay
wired in. An
+ /// exhausted `rows` yields a valid 0-row batch.
+ fn build_batch<'a, I>(
+ rows: &mut std::iter::Peekable<I>,
+ max_rows: usize,
+ ) -> Result<RecordBatch>
+ where
+ I: Iterator<Item = (&'a str, u64)>,
+ {
+ let mut path_builder = StringBuilder::new();
+ let mut pos_builder = Int64Builder::new();
+
+ let mut taken = 0usize;
+ while taken < max_rows {
+ let Some(&(path, _)) = rows.peek() else {
+ break;
+ };
+ // Consume this path's run: contiguous rows sharing `path`, up to
the batch's cap.
+ let mut run = 0usize;
+ while taken < max_rows {
+ let Some(&(next_path, pos)) = rows.peek() else {
+ break;
+ };
+ if next_path != path {
+ break;
+ }
+ // The `pos` column is `Int64`; positions are `u64`, so guard
the top of the
+ // range with a checked conversion rather than a silent wrap.
+ let pos = i64::try_from(pos)
+ .map_err(|_| Error::new(ErrorKind::DataInvalid, "position
exceeds i64::MAX"))?;
+ pos_builder.append_value(pos);
+ rows.next();
+ run += 1;
+ taken += 1;
+ }
+ // One append per run of `run` rows, not one per row.
+ path_builder.append_value_n(path, run);
+ }
+
+ let schema =
super::position_delete_writer::position_delete_arrow_schema();
+ RecordBatch::try_new(schema, vec![
+ Arc::new(path_builder.finish()) as ArrayRef,
+ Arc::new(pos_builder.finish()) as ArrayRef,
+ ])
+ .map_err(|e| {
+ Error::new(
+ ErrorKind::DataInvalid,
+ format!("Failed to build position delete record batch: {e}"),
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use arrow_array::{Array, Int64Array, StringArray};
+ use arrow_schema::DataType;
+ use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+ use parquet::file::properties::WriterProperties;
+ use tempfile::TempDir;
+
+ use super::*;
+ use crate::io::FileIO;
+ use crate::metadata_columns::{
+ RESERVED_COL_NAME_DELETE_FILE_PATH, RESERVED_COL_NAME_DELETE_FILE_POS,
+ RESERVED_FIELD_ID_DELETE_FILE_PATH, RESERVED_FIELD_ID_DELETE_FILE_POS,
+ };
+ use crate::spec::{DataContentType, DataFileFormat};
+ use crate::writer::base_writer::position_delete_writer::{
+ PositionDeleteFileWriterBuilder, position_delete_schema,
+ };
+ use crate::writer::file_writer::ParquetWriterBuilder;
+ use crate::writer::file_writer::location_generator::{
+ DefaultFileNameGenerator, DefaultLocationGenerator,
+ };
+ use crate::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
+ use crate::writer::{IcebergWriter, IcebergWriterBuilder};
+
+ /// Asserts a batch has the exact two-column position delete schema:
names, Arrow types,
+ /// non-null flags, and the reserved field ids in the Parquet field-id
metadata.
+ fn assert_position_delete_schema(batch: &RecordBatch) {
+ assert_eq!(batch.num_columns(), 2);
+ let schema = batch.schema();
+
+ let path = schema.field(0);
+ assert_eq!(path.name(), RESERVED_COL_NAME_DELETE_FILE_PATH);
+ assert_eq!(path.data_type(), &DataType::Utf8);
+ assert!(!path.is_nullable());
+ assert_eq!(
+ path.metadata().get(PARQUET_FIELD_ID_META_KEY).unwrap(),
+ &RESERVED_FIELD_ID_DELETE_FILE_PATH.to_string()
+ );
+
+ let pos = schema.field(1);
+ assert_eq!(pos.name(), RESERVED_COL_NAME_DELETE_FILE_POS);
+ assert_eq!(pos.data_type(), &DataType::Int64);
+ assert!(!pos.is_nullable());
+ assert_eq!(
+ pos.metadata().get(PARQUET_FIELD_ID_META_KEY).unwrap(),
+ &RESERVED_FIELD_ID_DELETE_FILE_POS.to_string()
+ );
+ }
+
+ /// Extracts the `(file_path, pos)` columns of a batch as parallel vectors.
+ fn columns(batch: &RecordBatch) -> (Vec<String>, Vec<i64>) {
+ let paths = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let positions = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ assert_eq!(paths.null_count(), 0);
+ assert_eq!(positions.null_count(), 0);
+ (
+ paths.iter().map(|s| s.unwrap().to_string()).collect(),
+ positions.values().to_vec(),
+ )
+ }
+
+ #[test]
+ fn test_column_shape_and_field_ids() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+ let batch = deletes.to_record_batch().unwrap();
+ assert_position_delete_schema(&batch);
+ }
+
+ #[test]
+ fn test_unsorted_inserts_sorted_by_path_then_pos() {
+ let mut deletes = PositionDeletes::new();
+ // Inserted out of order, across multiple paths; both the paths and
the positions
+ // within a path must come out sorted.
+ deletes.insert("s3://bucket/data/f1.parquet", 2);
+ deletes.insert("s3://bucket/data/f0.parquet", 4);
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+
+ let batch = deletes.to_record_batch().unwrap();
+ let (paths, positions) = columns(&batch);
+ assert_eq!(paths, vec![
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f1.parquet",
+ ]);
+ assert_eq!(positions, vec![1, 4, 2]);
+ }
+
+ // Release behavior: a duplicate `(file_path, pos)` is silently deduped.
In debug builds
+ // that same input trips a `debug_assert!` (see
`insert_duplicate_pair_debug_asserts`), so
+ // this test is release-only.
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn test_duplicate_pairs_deduped() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+
+ assert_eq!(deletes.len(), 2);
+ let batch = deletes.to_record_batch().unwrap();
+ let (paths, positions) = columns(&batch);
+ assert_eq!(batch.num_rows(), 2);
+ assert_eq!(paths, vec![
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ ]);
+ assert_eq!(positions, vec![1, 3]);
+ }
+
+ // Debug behavior: re-inserting the same `(file_path, pos)` is a caller
bug that trips a
+ // `debug_assert!` rather than silently deduping (the release behavior
asserted by
+ // `test_duplicate_pairs_deduped`).
+ #[cfg(debug_assertions)]
+ #[test]
+ #[should_panic(expected = "duplicate position delete")]
+ fn insert_duplicate_pair_debug_asserts() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ }
+
+ #[test]
+ fn test_len_and_is_empty() {
+ let mut deletes = PositionDeletes::new();
+ assert!(deletes.is_empty());
+ assert_eq!(deletes.len(), 0);
+
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+ deletes.insert("s3://bucket/data/f0.parquet", 2);
+ deletes.insert("s3://bucket/data/f1.parquet", 9);
+ assert!(!deletes.is_empty());
+ assert_eq!(deletes.len(), 3);
+
+ // A duplicate pair does not change the count. This is release
behavior; in debug the
+ // duplicate insert trips a `debug_assert!`, so the check is
release-only here.
+ #[cfg(not(debug_assertions))]
+ {
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+ assert_eq!(deletes.len(), 3);
+ }
+ }
+
+ #[test]
+ fn test_empty_builds_valid_zero_row_batch() {
+ let deletes = PositionDeletes::new();
+ let batch = deletes.to_record_batch().unwrap();
+ assert_eq!(batch.num_rows(), 0);
+ // Even with no rows the schema must be the full position delete
schema.
+ assert_position_delete_schema(&batch);
+ }
+
+ // Release behavior: a position beyond `i64::MAX` cannot fit the `Int64`
column and is
+ // rejected as `DataInvalid` when the batch is built (the release safety
net). In debug
+ // builds that same input trips a `debug_assert!` in `insert` up front (see
+ // `insert_position_above_i64_max_debug_asserts`), so this test is
release-only.
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn test_position_above_i64_max_rejected() {
+ let mut deletes = PositionDeletes::new();
+ // A position beyond `i64::MAX` cannot fit the `Int64` column and must
be rejected
+ // rather than silently wrapped.
+ deletes.insert("s3://bucket/data/f0.parquet", i64::MAX as u64 + 1);
+ let err = deletes.to_record_batch().unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+ }
+
+ // Debug behavior: a position beyond `i64::MAX` is a caller bug that trips
a
+ // `debug_assert!` in `insert` up front, rather than being rejected only
when the batch is
+ // built (the release behavior asserted by
`test_position_above_i64_max_rejected`).
+ #[cfg(debug_assertions)]
+ #[test]
+ #[should_panic(expected = "exceeds i64::MAX")]
+ fn insert_position_above_i64_max_debug_asserts() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", i64::MAX as u64 + 1);
+ }
+
+ /// Drains [`PositionDeletes::to_record_batches`] into per-batch `(paths,
positions)`
+ /// column pairs, unwrapping each batch.
+ fn batched_columns(deletes: &PositionDeletes, max_rows: usize) ->
Vec<(Vec<String>, Vec<i64>)> {
+ deletes
+ .to_record_batches(max_rows)
+ .map(|batch| columns(&batch.unwrap()))
+ .collect()
+ }
+
+ /// Concatenates per-batch column pairs into one flattened `(paths,
positions)` pair.
+ fn flatten(batches: &[(Vec<String>, Vec<i64>)]) -> (Vec<String>, Vec<i64>)
{
+ let mut paths = Vec::new();
+ let mut positions = Vec::new();
+ for (batch_paths, batch_positions) in batches {
+ paths.extend(batch_paths.iter().cloned());
+ positions.extend(batch_positions.iter().copied());
+ }
+ (paths, positions)
+ }
+
+ #[test]
+ fn test_to_record_batches_splits_and_preserves_order() {
+ let mut deletes = PositionDeletes::new();
+ // Unsorted inserts across three paths; 6 rows total.
+ for (path, pos) in [
+ ("s3://bucket/data/f1.parquet", 5),
+ ("s3://bucket/data/f0.parquet", 7),
+ ("s3://bucket/data/f0.parquet", 1),
+ ("s3://bucket/data/f2.parquet", 9),
+ ("s3://bucket/data/f1.parquet", 2),
+ ("s3://bucket/data/f0.parquet", 4),
+ ] {
+ deletes.insert(path, pos);
+ }
+
+ // 6 rows at 2 per batch -> exactly 3 full batches.
+ let batches = batched_columns(&deletes, 2);
+ assert_eq!(batches.len(), 3);
+ for (paths, positions) in &batches {
+ assert_eq!(paths.len(), 2);
+ assert_eq!(positions.len(), 2);
+ }
+ // Every streamed batch carries the full position delete schema.
+ for batch in deletes.to_record_batches(2) {
+ assert_position_delete_schema(&batch.unwrap());
+ }
+
+ // Flattened, the stream is the same rows in the same sorted order as
the single batch.
+ let (flat_paths, flat_positions) = flatten(&batches);
+ let (whole_paths, whole_positions) =
columns(&deletes.to_record_batch().unwrap());
+ assert_eq!(flat_paths, whole_paths);
+ assert_eq!(flat_positions, whole_positions);
+ assert_eq!(whole_paths, vec![
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f1.parquet",
+ "s3://bucket/data/f1.parquet",
+ "s3://bucket/data/f2.parquet",
+ ]);
+ assert_eq!(whole_positions, vec![1, 4, 7, 2, 5, 9]);
+ }
+
+ #[test]
+ fn test_to_record_batches_uneven_last_batch() {
+ let mut deletes = PositionDeletes::new();
+ for pos in 0..5 {
+ deletes.insert("s3://bucket/data/f0.parquet", pos);
+ }
+
+ // 5 rows at 2 per batch -> two full batches and a 1-row remainder.
+ let sizes: Vec<usize> = deletes
+ .to_record_batches(2)
+ .map(|batch| batch.unwrap().num_rows())
+ .collect();
+ assert_eq!(sizes, vec![2, 2, 1]);
+ }
+
+ #[test]
+ fn test_to_record_batches_path_spans_chunk_boundary() {
+ let mut deletes = PositionDeletes::new();
+ // A single path with more positions than fit one batch: its run must
resume across
+ // batch boundaries and stay correct.
+ for pos in 1..=5 {
+ deletes.insert("s3://bucket/data/f0.parquet", pos);
+ }
+
+ let batches = batched_columns(&deletes, 2);
+ assert_eq!(batches.len(), 3);
+ // Same path in every batch, positions still ascending and contiguous
across the split.
+ for (paths, _) in &batches {
+ assert!(paths.iter().all(|p| p == "s3://bucket/data/f0.parquet"));
+ }
+ let (paths, positions) = flatten(&batches);
+ assert_eq!(paths.len(), 5);
+ assert_eq!(positions, vec![1, 2, 3, 4, 5]);
+ }
+
+ #[test]
+ fn test_to_record_batches_zero_max_rows_yields_error_without_panic() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+
+ let mut it = deletes.to_record_batches(0);
+ // A `max_rows` of 0 is invalid, but must surface as an error rather
than a panic...
+ let err = it.next().expect("expected one error item").unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+ // ...exactly once, after which the iterator is exhausted.
+ assert!(it.next().is_none());
+ }
+
+ // Release-only: inserting a position beyond `i64::MAX` trips a
`debug_assert!` in debug
+ // builds, so the streaming overflow safety net can only be exercised in
release.
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn test_to_record_batches_stops_after_overflow_error() {
+ let mut deletes = PositionDeletes::new();
+ // A position beyond `i64::MAX` cannot fit the `Int64` column; the
streaming path must
+ // surface the error and then stop rather than retry the unconsumed
row forever.
+ deletes.insert("s3://bucket/data/f0.parquet", i64::MAX as u64 + 1);
+
+ let mut it = deletes.to_record_batches(2);
+ let err = it.next().expect("expected one error item").unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+ assert!(it.next().is_none());
+ }
+
+ #[test]
+ fn test_to_record_batches_empty_yields_no_batches() {
+ let deletes = PositionDeletes::new();
+ // Empty accumulator streams nothing...
+ assert_eq!(deletes.to_record_batches(4).count(), 0);
+ // ...while the single-batch path still returns a valid 0-row batch.
+ let batch = deletes.to_record_batch().unwrap();
+ assert_eq!(batch.num_rows(), 0);
+ assert_position_delete_schema(&batch);
+ }
+
+ // Release-only: inserting the same `(file_path, pos)` twice trips a
`debug_assert!` in
+ // debug builds, so the streaming dedup behavior can only be exercised in
release.
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn test_to_record_batches_dedups_repeated_pairs() {
+ let mut deletes = PositionDeletes::new();
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ deletes.insert("s3://bucket/data/f0.parquet", 3);
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+
+ // The duplicate collapses, so a generous batch holds just the two
distinct rows.
+ let batches = batched_columns(&deletes, 100);
+ assert_eq!(batches.len(), 1);
+ assert_eq!(batches[0].0, vec![
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ ]);
+ assert_eq!(batches[0].1, vec![1, 3]);
+ }
+
+ #[test]
+ fn test_to_record_batches_repeats_path_across_run() {
+ let mut deletes = PositionDeletes::new();
+ // A run of one path followed by a second path, all within a single
batch: the
+ // `append_value_n` run must repeat the file_path value once per
position.
+ for pos in [1, 2, 3, 4] {
+ deletes.insert("s3://bucket/data/f0.parquet", pos);
+ }
+ deletes.insert("s3://bucket/data/f1.parquet", 8);
+
+ let batches = batched_columns(&deletes, 100);
+ assert_eq!(batches.len(), 1);
+ let (paths, positions) = &batches[0];
+ assert_eq!(paths, &vec![
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f0.parquet",
+ "s3://bucket/data/f1.parquet",
+ ]);
+ assert_eq!(positions, &vec![1, 2, 3, 4, 8]);
+ }
+
+ /// Wires a real [`PositionDeleteFileWriter`] over a local filesystem for
the integration
+ /// test: the file IO, location/name generators, and the Parquet-backed
rolling writer
+ /// configured with the [`position_delete_schema`].
+ fn writer_setup(
+ temp_dir: &TempDir,
+ ) -> PositionDeleteFileWriterBuilder<
+ ParquetWriterBuilder,
+ DefaultLocationGenerator,
+ DefaultFileNameGenerator,
+ > {
+ let file_io = FileIO::new_with_fs();
+ let location_gen = DefaultLocationGenerator::with_data_location(
+ temp_dir.path().to_str().unwrap().to_string(),
+ );
+ let file_name_gen =
+ DefaultFileNameGenerator::new("test".to_string(), None,
DataFileFormat::Parquet);
+ let parquet_writer_builder = ParquetWriterBuilder::new(
+ WriterProperties::builder().build(),
+ position_delete_schema(),
+ );
+ let rolling_writer_builder =
RollingFileWriterBuilder::new_with_default_file_size(
+ parquet_writer_builder,
+ file_io,
+ location_gen,
+ file_name_gen,
+ );
+ PositionDeleteFileWriterBuilder::new(rolling_writer_builder)
+ }
+
+ #[tokio::test]
+ async fn test_batch_feeds_position_delete_writer() -> Result<()> {
+ let temp_dir = TempDir::new().unwrap();
+ let builder = writer_setup(&temp_dir);
+ let mut writer = builder.build(None).await?;
+
+ let mut deletes = PositionDeletes::new();
+ // Deliberately unsorted; `to_record_batch` sorts to the order the
writer needs.
+ deletes.insert("s3://bucket/data/f1.parquet", 2);
+ deletes.insert("s3://bucket/data/f0.parquet", 4);
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+
+ writer.write(deletes.to_record_batch()?).await?;
+ let data_files = writer.close().await?;
+
+ assert_eq!(data_files.len(), 1);
+ let data_file = &data_files[0];
+ assert_eq!(data_file.content_type(), DataContentType::PositionDeletes);
+ assert_eq!(data_file.record_count, deletes.len() as u64);
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_streamed_batches_feed_position_delete_writer() -> Result<()>
{
+ let temp_dir = TempDir::new().unwrap();
+ let builder = writer_setup(&temp_dir);
+ let mut writer = builder.build(None).await?;
+
+ let mut deletes = PositionDeletes::new();
+ // Deliberately unsorted, across paths, more rows than the batch cap
so more than one
+ // batch is streamed to the writer.
+ deletes.insert("s3://bucket/data/f1.parquet", 2);
+ deletes.insert("s3://bucket/data/f0.parquet", 4);
+ deletes.insert("s3://bucket/data/f0.parquet", 1);
+ deletes.insert("s3://bucket/data/f1.parquet", 5);
+ deletes.insert("s3://bucket/data/f2.parquet", 9);
+
+ // Each fixed-size batch is a valid position delete batch the writer
accepts, and the
+ // batches arrive in the sorted order the writer requires.
+ let mut streamed = 0usize;
+ for batch in deletes.to_record_batches(2) {
+ writer.write(batch?).await?;
+ streamed += 1;
+ }
+ assert_eq!(streamed, 3);
+
+ let data_files = writer.close().await?;
+ assert_eq!(data_files.len(), 1);
+ let data_file = &data_files[0];
+ assert_eq!(data_file.content_type(), DataContentType::PositionDeletes);
+ assert_eq!(data_file.record_count, deletes.len() as u64);
+
+ Ok(())
+ }
+}
diff --git a/crates/iceberg/src/writer/base_writer/position_delete_writer.rs
b/crates/iceberg/src/writer/base_writer/position_delete_writer.rs
index 29b65b74a..3b023c9c6 100644
--- a/crates/iceberg/src/writer/base_writer/position_delete_writer.rs
+++ b/crates/iceberg/src/writer/base_writer/position_delete_writer.rs
@@ -65,9 +65,9 @@ static POSITION_DELETE_SCHEMA: Lazy<SchemaRef> = Lazy::new(||
{
});
/// [`POSITION_DELETE_SCHEMA`] converted to Arrow, keeping the reserved field
ids in each
-/// field's Parquet field-id metadata. Test-only for now: callers configure
the writer with
-/// the Iceberg [`position_delete_schema`], so no non-test code needs the
Arrow form yet.
-#[cfg(test)]
+/// field's Parquet field-id metadata. This is the crate-internal Arrow
projection of a
+/// position delete file, shared with
+/// [`PositionDeletes`](super::position_delete_input::PositionDeletes).
static POSITION_DELETE_ARROW_SCHEMA: Lazy<arrow_schema::SchemaRef> =
Lazy::new(|| {
Arc::new(
crate::arrow::schema_to_arrow_schema(&POSITION_DELETE_SCHEMA)
@@ -85,8 +85,10 @@ pub fn position_delete_schema() -> SchemaRef {
}
/// Returns the canonical Arrow schema of a position delete file.
-#[cfg(test)]
-fn position_delete_arrow_schema() -> arrow_schema::SchemaRef {
+///
+/// A cheap [`Arc`] clone of the shared static, used by
+/// [`PositionDeletes`](super::position_delete_input::PositionDeletes).
+pub(crate) fn position_delete_arrow_schema() -> arrow_schema::SchemaRef {
POSITION_DELETE_ARROW_SCHEMA.clone()
}