unikdahal commented on code in PR #5780:
URL: https://github.com/apache/datafusion-comet/pull/5780#discussion_r3962403226


##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -581,13 +654,224 @@ impl ClusteredBatchSplitter {
     }
 }
 
-/// Gather the rows selected by `indices` out of `batch`. A zero-copy 
`RecordBatch::slice` would
-/// be cheaper, but the parquet writer's NaN-count visitor reads list/map 
children via
-/// `list_array.values()`, which ignores a slice's offset window -- sliced 
list-of-float columns
-/// would over-count NaNs. `take` gathers the referenced children into fresh 
compacted arrays,
-/// keeping those counts correct.
-fn materialize_run(batch: &RecordBatch, indices: &UInt32Array) -> 
DFResult<RecordBatch> {
-    arrow::compute::take_record_batch(batch, 
indices).map_err(DataFusionError::from)
+/// Cuts contiguous row ranges out of a batch: the partition runs the 
clustered writer needs, and
+/// the [`ROWS_DIVISOR`]-row pieces the rolling writer needs.
+///
+/// A zero-copy `RecordBatch::slice` is the cheap way to cut a range, but it 
is only exact when
+/// the parquet writer's NaN-count visitor sees every float through the slice. 
The visitor reaches
+/// list elements and map entries through `list_array.values()` / 
`map_array.entries()`, which
+/// ignore the parent's offset window, so a sliced `list<float>` column would 
have every NaN in
+/// the batch counted once per range cut from it -- and since the JVM carries 
the native writer's
+/// NaN counts into the manifest, a `nan_value_count` that reaches 
`record_count` makes Iceberg's
+/// metrics evaluator prune the file from ordinary comparison predicates. 
Struct children are
+/// safe, because `StructArray::slice` slices them, so the only schemas that 
need the fix are the
+/// ones with a float or double under a list or map; those ranges go through 
`take`, which gathers
+/// the referenced children into fresh compacted arrays.
+#[derive(Clone, Copy)]
+struct RowSlicer {
+    gather: bool,
+}
+
+impl RowSlicer {
+    /// `schema` is the field-id-decorated target schema every batch is cast 
to, so this decision
+    /// is made once per task rather than per batch.
+    fn for_schema(schema: &ArrowSchema) -> Self {
+        Self {
+            gather: schema
+                .fields()
+                .iter()
+                .any(|field| float_under_list_or_map(field.data_type())),
+        }
+    }
+
+    fn slice(&self, batch: &RecordBatch, offset: usize, len: usize) -> 
DFResult<RecordBatch> {
+        if offset == 0 && len == batch.num_rows() {
+            return Ok(batch.clone());
+        }
+        if self.gather {
+            gather_rows(batch, offset, len)
+        } else {
+            Ok(batch.slice(offset, len))
+        }
+    }
+
+    /// Cuts a range that outlives the batch it came from, because it is 
waiting for the rows that
+    /// complete its unit. Always gathers: a zero-copy slice would pin every 
buffer of its parent
+    /// batch for the wait, and a partition that receives a handful of rows 
per batch would pin one
+    /// parent per batch until its unit fills.
+    fn detach(&self, batch: &RecordBatch, offset: usize, len: usize) -> 
DFResult<RecordBatch> {
+        if offset == 0 && len == batch.num_rows() {
+            // The range is the whole batch, so it pins nothing beyond the 
rows it holds.
+            return Ok(batch.clone());
+        }
+        gather_rows(batch, offset, len)
+    }
+}
+
+fn gather_rows(batch: &RecordBatch, offset: usize, len: usize) -> 
DFResult<RecordBatch> {
+    let indices = UInt32Array::from_iter_values(offset as u32..(offset + len) 
as u32);
+    arrow::compute::take_record_batch(batch, 
&indices).map_err(DataFusionError::from)
+}
+
+/// How this task cuts rows and when the rolling writer's size check can 
matter: everything needed
+/// to open a [`RowPacer`] for one more file.
+#[derive(Clone, Copy)]
+struct PacingPolicy {
+    slicer: RowSlicer,
+    target_file_size_bytes: usize,
+}
+
+impl PacingPolicy {
+    fn pacer(&self) -> RowPacer {
+        RowPacer::new(*self)
+    }
+}
+
+/// Hands one iceberg-rust writer exactly [`ROWS_DIVISOR`] rows at a time, so 
the rolling writer
+/// underneath only ever gets to roll on the row boundaries iceberg-java's 
`RollingFileWriter` rolls
+/// on -- multiples of 1000 rows since the current file opened -- however 
Spark happened to batch
+/// the rows.
+///
+/// Pacing, rather than just cutting each batch up, is what makes the roll 
point independent of the
+/// batch shape: a task fed 800-row batches would otherwise be offered a 
boundary every 800 rows
+/// and roll into 800-row files where the JVM writer produces 1000-row ones. 
Rows left over from a
+/// batch wait here for the rows that complete their unit, so at most 
`ROWS_DIVISOR - 1` rows per
+/// open file are held back.
+///
+/// Pacing costs one writer call per 1000 rows instead of one per batch, which 
is measurable on a
+/// wide schema, so it stays off until it can change an outcome: see `pacing`.
+struct RowPacer {
+    policy: PacingPolicy,
+    /// Whether rows are being paced yet. While off, batches go over whole, 
because a batch
+    /// boundary the writer sees can only matter if its size check might fire 
there -- and it cannot
+    /// while the file is still smaller than the target.
+    ///
+    /// It never goes back off. A roll starts the file's size over, but the 
pacer cannot see rolls,
+    /// so from the first file that reaches the target it paces for the rest 
of the task.
+    pacing: bool,
+    /// What has been handed over so far, as an upper bound on what the open 
file holds: Arrow's
+    /// in-memory footprint of a batch (allocated capacity, so it over-counts 
if anything) bounds
+    /// what parquet writes for the same rows, since the writer encodes and 
compresses them. If that
+    /// ever failed to hold for some schema, the only consequence is a roll up 
to `ROWS_DIVISOR`
+    /// rows late -- the same order as the divergence the two size estimates 
already allow.
+    handed_bytes: usize,
+    /// Rows of the current 1000-row block already handed over. Non-zero only 
for the first block
+    /// after pacing turns on, where it is what the whole batches before it 
left in the open file --
+    /// which is all of them, since nothing could have rolled yet. Carrying it 
over is what keeps
+    /// the blocks aligned to the file's own row count rather than to where 
pacing started.
+    block_rows: usize,
+    /// Rows handed in but not yet handed over; `block_rows + pending_rows < 
ROWS_DIVISOR`.
+    pending: Vec<RecordBatch>,
+    pending_rows: usize,
+}
+
+impl RowPacer {
+    fn new(policy: PacingPolicy) -> Self {
+        Self {
+            policy,
+            pacing: false,
+            handed_bytes: 0,
+            block_rows: 0,
+            pending: Vec::new(),
+            pending_rows: 0,
+        }
+    }
+
+    /// The complete units `batch` makes available, in row order. Whatever 
does not fill a unit is
+    /// held for the next call.
+    fn push(&mut self, batch: RecordBatch) -> DFResult<Vec<RecordBatch>> {
+        debug_assert!(
+            self.block_rows + self.pending_rows < ROWS_DIVISOR,
+            "a complete unit was left pending"
+        );
+        let rows = batch.num_rows();
+        if !self.pacing {
+            self.handed_bytes += batch.get_array_memory_size();

Review Comment:
   I dug a bit further into the pinned iceberg-rust/parquet-rs code here, and I 
don't think this bound is safe.
   
   `get_array_memory_size()` measures Arrow-side allocation, whereas the 
rolling decision uses Parquet's encoded-size estimate, which also includes 
things like dictionary/data-page state. I couldn't find an invariant 
guaranteeing the Arrow size is always >= that estimate.
   
   If Parquet reaches the target while `pacing` is still false, the rolling 
writer can roll before the next batch is written. `block_rows` would then still 
be tracking the previous file, so subsequent 1000-row blocks can be aligned 
from the wrong file boundary.
   
   I think this needs to avoid using the Arrow-memory estimate as a correctness 
gate, either pace unconditionally, or make the decision from actual writer 
state.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to