laskoviymishka commented on code in PR #2985:
URL: https://github.com/apache/iceberg-rust/pull/2985#discussion_r3756223517


##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -250,6 +252,42 @@ impl FileScanTaskReader {
         let mut record_batch_stream_builder =
             
ParquetRecordBatchStreamBuilder::new_with_metadata(parquet_file_reader, 
arrow_metadata);
 
+        // Whether the file physically carries the 
`_last_updated_sequence_number` column
+        // (some engines, e.g. Iceberg Java on rewrite, write it per-row), 
resolved by its
+        // embedded field id against the Parquet schema.
+        let project_last_updated_seq = task
+            .project_field_ids()
+            .contains(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER);
+
+        // Parquet leaf index of the physical column, if present by embedded 
field id.
+        // `build_field_id_map` is all-or-nothing (`None` if any column lacks 
an id), so a
+        // file mixing id-bearing and id-less columns is rejected below rather 
than
+        // coalesced -- safe, and consistent with the rest of the reader.
+        let phys_last_updated_seq_leaf = if project_last_updated_seq {

Review Comment:
   `build_field_id_map` is all-or-nothing, so a single id-less sibling column 
turns a file that *does* carry `_last_updated_sequence_number` by its reserved 
id into a hard `FeatureUnsupported` — the name-only branch fires and we reject 
a file we could actually coalesce.
   
   The comment frames this as safe, and it's not wrong, but it's a behavior 
choice (reject a readable file), not a pure safety guard. Would a targeted scan 
of the parquet leaves for just `RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER` 
be worth it, or do we accept the all-or-nothing and just make the comment say 
plainly that a mixed-id file is rejected? wdyt?



##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -250,6 +252,42 @@ impl FileScanTaskReader {
         let mut record_batch_stream_builder =
             
ParquetRecordBatchStreamBuilder::new_with_metadata(parquet_file_reader, 
arrow_metadata);
 
+        // Whether the file physically carries the 
`_last_updated_sequence_number` column
+        // (some engines, e.g. Iceberg Java on rewrite, write it per-row), 
resolved by its
+        // embedded field id against the Parquet schema.
+        let project_last_updated_seq = task
+            .project_field_ids()
+            .contains(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER);
+
+        // Parquet leaf index of the physical column, if present by embedded 
field id.
+        // `build_field_id_map` is all-or-nothing (`None` if any column lacks 
an id), so a
+        // file mixing id-bearing and id-less columns is rejected below rather 
than
+        // coalesced -- safe, and consistent with the rest of the reader.
+        let phys_last_updated_seq_leaf = if project_last_updated_seq {
+            
build_field_id_map(record_batch_stream_builder.parquet_schema())?.and_then(|m| {
+                m.get(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER)
+                    .copied()
+            })
+        } else {
+            None
+        };
+
+        // Present by name but not by the embedded id (only meaningful when no 
by-id column
+        // was found). An unthreadable shape we reject rather than coalesce 
incorrectly.
+        let last_updated_seq_present_by_name_only = project_last_updated_seq
+            && phys_last_updated_seq_leaf.is_none()
+            && record_batch_stream_builder
+                .schema()
+                .fields()
+                .iter()
+                .any(|f| f.name() == 
RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER);
+
+        // We read + coalesce the physical column only when it is present by 
id AND the file
+        // is row-lineage-bearing (first_row_id set) with a data sequence 
number to fall
+        // back to. When first_row_id is null the column is nulled, so we must 
not read it.
+        let coalesce_last_updated_seq_leaf = phys_last_updated_seq_leaf
+            .filter(|_| task.first_row_id.is_some() && 
task.data_sequence_number.is_some());

Review Comment:
   This `.filter(...)` couples two separate decisions: whether we can compute a 
fallback (needs `data_sequence_number`) and whether we should read the physical 
column at all. When `first_row_id` is `None` we drop the leaf entirely, so a 
file that physically carries non-null per-row values comes back all-null:
   
   ```
   file column:   [Some(5), None, Some(8)]   (first_row_id = None)
   reader output: [null,    null, null]       (leaf not projected -> (None,_) 
arm)
   ```
   
   Java does the same (`ValueReaders.lastUpdated` nulls when the base row id is 
null), so this isn't an interop regression — but it's silent data loss for a 
file the spec doesn't forbid. The `data_sequence_number = None` half is more 
anomalous, but the same shape: non-null per-row values vanish.
   
   I'd either project the column and pass non-null values through (leaving null 
rows null when there's no fallback), or keep the current behavior but say in 
the comment that it matches Java and file a follow-up tracking the divergence. 
Right now the divergence is invisible.



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -716,6 +757,25 @@ impl RecordBatchTransformer {
                             target_type: arrow_type.clone(),
                         });
                     }
+                    Some(ColumnConstant::Coalesce(datum)) => {

Review Comment:
   Nit: this arm returns `ColumnSource::Coalesce` unconditionally, skipping the 
`is_metadata_field || !present_in_file` guard the `Scalar` arm applies. Correct 
today since `_last_updated_sequence_number` is always a metadata field — 
nothing enforces it, though. A one-line comment noting the invariant (or a 
`debug_assert!`) would be plenty; not worth more than that.



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -870,11 +930,53 @@ impl RecordBatchTransformer {
                         fields,
                         child_values,
                     } => Self::create_struct_column(fields, child_values, 
num_rows)?,
+
+                    ColumnSource::Coalesce {
+                        source_index,
+                        fallback,
+                        target_type,
+                    } => Self::create_coalesce_column(
+                        &columns[*source_index],
+                        fallback,
+                        target_type,
+                    )?,
                 })
             })
             .collect()
     }
 
+    /// Builds a coalesced column: the source column's per-row value where 
non-null,
+    /// else the scalar `fallback`, cast to `target_type` (the run-end-encoded 
type
+    /// the column's other paths produce).
+    fn create_coalesce_column(
+        source: &ArrayRef,
+        fallback: &PrimitiveLiteral,
+        target_type: &DataType,
+    ) -> Result<ArrayRef> {
+        if source.data_type() != &DataType::Int64 {

Review Comment:
   A file could legitimately carry the reserved field id on an `Int32` column — 
that's a data-validity problem, not an internal invariant violation, so I'd use 
`ErrorKind::DataInvalid` here (and `{}` rather than `{:?}`, since `DataType` 
implements `Display`).
   
   The `PrimitiveLiteral::Long` guard just below is genuinely internal wiring, 
so `Unexpected` is fine there.



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -870,11 +930,53 @@ impl RecordBatchTransformer {
                         fields,
                         child_values,
                     } => Self::create_struct_column(fields, child_values, 
num_rows)?,
+
+                    ColumnSource::Coalesce {

Review Comment:
   `ColumnSource::Coalesce` carries a generic `fallback: PrimitiveLiteral` and 
`target_type: DataType`, but `create_coalesce_column` silently pins both to 
`Long` / `Int64`. Combined with `with_coalesced_metadata_column` being 
infallible, a future caller with a non-Long datum gets no signal at 
registration and instead hits `ErrorKind::Unexpected` deep in batch processing.
   
   I'd make the constraint explicit — either rename the variant to something 
like `CoalesceLastUpdatedSeq` so no one reuses it for another primitive, or 
validate the datum type in `with_coalesced_metadata_column` so it fails at the 
call site. Renaming is less code and states the intent.



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -2293,6 +2395,80 @@ mod test {
         assert!((0..3).all(|i| seq_col.is_null(i)));
     }
 
+    /// Builds a transformer + a file batch for the coalesce case: `id` plus a 
physical
+    /// `_last_updated_sequence_number` column carrying `seq_values`.
+    fn coalesce_transformer_and_batch(
+        seq_values: Vec<Option<i64>>,
+        id_values: Vec<i32>,
+        fallback: i64,
+    ) -> (RecordBatchTransformer, RecordBatch) {
+        use crate::metadata_columns::{
+            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
+            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+        };
+        use crate::spec::Datum;
+
+        let snapshot_schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(0)
+                .with_fields(vec![
+                    NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let parquet_schema = Arc::new(ArrowSchema::new(vec![
+            field_with_id("id", DataType::Int32, false, 1),
+            field_with_id(
+                RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
+                DataType::Int64,
+                true,
+                RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+            ),
+        ]));
+        let projected_field_ids = [1, 
RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER];
+        let transformer = RecordBatchTransformerBuilder::new(snapshot_schema, 
&projected_field_ids)
+            .with_coalesced_metadata_column(
+                RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+                Datum::long(fallback),
+            )
+            .build();
+        let batch = RecordBatch::try_new(parquet_schema, vec![
+            Arc::new(Int32Array::from(id_values)),
+            Arc::new(Int64Array::from(seq_values)),
+        ])
+        .unwrap();
+        (transformer, batch)
+    }
+
+    #[test]
+    fn last_updated_sequence_number_coalesce_column() {
+        let (mut transformer, batch) =
+            coalesce_transformer_and_batch(vec![Some(5), None, Some(8)], 
vec![10, 20, 30], 9);
+        let result = transformer.process_record_batch(batch).unwrap();
+
+        // Per-row value where non-null; the fallback (9) where null.
+        let seq_col = cast(result.column(1), &DataType::Int64).unwrap();

Review Comment:
   Nit: these assertions index by `result.column(1)`, so they'd silently test 
the wrong column if projection order ever shifts. 
`result.column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER)` would 
be sturdier — same for `assert_last_updated_seq_column` if it also goes by 
position.



##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -301,59 +349,38 @@ impl FileScanTaskReader {
                 .with_constant(RESERVED_FIELD_ID_SPEC_ID, spec_id_datum);
         }
 
-        if task
-            .project_field_ids()
-            .contains(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER)
-        {
-            // A data file may physically carry a per-row 
`_last_updated_sequence_number`
-            // column, e.g. one written by another engine such as Iceberg Java 
when carrying
-            // rows forward across a rewrite. The spec requires reading such 
non-null
-            // per-row values unmodified, falling back to the derived value 
only where
-            // null. That per-row coalesce is not implemented yet, so rather 
than silently
-            // overwrite genuine per-row values with the derived value, reject 
the file
-            // loudly. Checks the full pre-projection file schema, since the 
column is
-            // stripped from the projection mask. Matches on the embedded 
field id when
-            // present (propagating a malformed id rather than treating it as 
absent) and
-            // falls back to the column name, so a file read via name mapping 
or positional
-            // fallback ids (which never equal the reserved id) is still 
caught.
-            let mut file_has_column = false;
-            for field in record_batch_stream_builder.schema().fields() {
-                let field_id = match 
field.metadata().get(PARQUET_FIELD_ID_META_KEY) {
-                    Some(id) => Some(id.parse::<i32>().map_err(|e| {
-                        Error::new(
-                            ErrorKind::DataInvalid,
-                            format!("field id not parseable as an i32: {e}"),
-                        )
-                    })?),
-                    None => None,
-                };
-                if field_id == 
Some(RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER)
-                    || field.name() == 
RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER
-                {
-                    file_has_column = true;
-                    break;
-                }
-            }
-            if file_has_column {
-                return Err(Error::new(
-                    ErrorKind::FeatureUnsupported,
-                    "Reading a physically-stored _last_updated_sequence_number 
column is \
-                     not yet supported; only the derived 
(data-sequence-number) value is \
-                     implemented",
-                ));
-            }
-
-            // Derive the column, gated on the data file's `first_row_id`. 
Java gates
-            // both lineage columns this way (`ValueReaders.lastUpdated` 
returns nulls
-            // when the base row id is null); the spec itself only says the 
column is
-            // assigned the manifest entry's sequence number on read.
+        if project_last_updated_seq {
+            // Materialize the column, gated on the data file's 
`first_row_id`. Java gates
+            // it this way (`ValueReaders.lastUpdated` returns nulls when the 
base row id is
+            // null); the spec itself only says the column is assigned the 
manifest entry's
+            // sequence number on read.
             record_batch_transformer_builder = match (task.first_row_id, 
task.data_sequence_number)
             {
-                // Non-null first_row_id: inherit from the data sequence 
number.
-                (Some(_), Some(seq)) => 
record_batch_transformer_builder.with_constant(
-                    RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
-                    Datum::long(seq),
-                ),
+                (Some(_), Some(seq)) => {
+                    let datum = Datum::long(seq);
+                    if coalesce_last_updated_seq_leaf.is_some() {
+                        // The file physically carries the column: read the 
per-row value,
+                        // falling back to the data sequence number only where 
null.
+                        
record_batch_transformer_builder.with_coalesced_metadata_column(
+                            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+                            datum,
+                        )
+                    } else if last_updated_seq_present_by_name_only {

Review Comment:
   The `last_updated_seq_present_by_name_only` reject only fires inside the 
`(Some, Some)` arm. For `(Some, None)` or `(None, _)` with a name-only column 
present, the flag is computed but never evaluated, so that file gets silently 
nulled instead of the loud `FeatureUnsupported`.
   
   I'd hoist this check to just before the `match` so it fires regardless of 
the `first_row_id` / `data_sequence_number` state. The `(Some, None)` arm also 
isn't exercised — could we add a test for `first_row_id = Some`, 
`data_sequence_number = None` with a physical column, so the intended behavior 
there is pinned down?



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -870,11 +930,53 @@ impl RecordBatchTransformer {
                         fields,
                         child_values,
                     } => Self::create_struct_column(fields, child_values, 
num_rows)?,
+
+                    ColumnSource::Coalesce {
+                        source_index,
+                        fallback,
+                        target_type,
+                    } => Self::create_coalesce_column(
+                        &columns[*source_index],
+                        fallback,
+                        target_type,
+                    )?,
                 })
             })
             .collect()
     }
 
+    /// Builds a coalesced column: the source column's per-row value where 
non-null,
+    /// else the scalar `fallback`, cast to `target_type` (the run-end-encoded 
type
+    /// the column's other paths produce).
+    fn create_coalesce_column(
+        source: &ArrayRef,
+        fallback: &PrimitiveLiteral,
+        target_type: &DataType,
+    ) -> Result<ArrayRef> {
+        if source.data_type() != &DataType::Int64 {
+            return Err(Error::new(
+                ErrorKind::Unexpected,
+                format!(
+                    "coalesce source must be Int64, got {:?}",
+                    source.data_type()
+                ),
+            ));
+        }
+        let PrimitiveLiteral::Long(seq) = fallback else {
+            return Err(Error::new(
+                ErrorKind::Unexpected,
+                format!("coalesce fallback must be a long, got {fallback:?}"),
+            ));
+        };
+        let scalar = Int64Array::new_scalar(*seq);
+        let mask = is_not_null(source)?;

Review Comment:
   The coalesce tests all use mixed `[Some(5), None, Some(8)]`, which would 
still pass if this mask polarity were flipped to `is_null`. An all-null `[None, 
None, None]` case (expecting `[fallback, fallback, fallback]`) would lock this 
line down — it's the only input that distinguishes `is_not_null` from `is_null`.
   
   An all-non-null case is worth adding too, to confirm the REE cast produces a 
valid array when nothing falls back.



##########
crates/iceberg/src/arrow/reader/pipeline.rs:
##########
@@ -1171,32 +1213,175 @@ mod tests {
             .await
             .unwrap();
 
-        assert_eq!(batches.len(), 2);
-        // Identical schema across files -> concat succeeds.
+        assert_eq!(batches.len(), 3);
+        // Identical schema across all three paths -> concat succeeds.
         let schema = batches[0].schema();
         concat_batches(&schema, &batches)
-            .expect("batches from value and null files must share one schema");
+            .expect("constant, null and coalesce files must share one column 
type");
+    }
+
+    /// A parquet field carrying the embedded `_last_updated_sequence_number` 
field id.
+    fn physical_last_updated_seq_field() -> Field {
+        use crate::metadata_columns::{
+            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
+            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+        };
+        Field::new(
+            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
+            DataType::Int64,
+            true,
+        )
+        .with_metadata(HashMap::from([(
+            PARQUET_FIELD_ID_META_KEY.to_string(),
+            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER.to_string(),
+        )]))
     }
 
     #[tokio::test]
-    async fn test_last_updated_sequence_number_physical_column_unsupported() {
-        use 
crate::metadata_columns::RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER;
+    async fn test_last_updated_sequence_number_physical_column_coalesced() {
+        let tmp_dir = TempDir::new().unwrap();
+        let dir = tmp_dir.path().to_str().unwrap();
+        // A file that physically carries the column, as Iceberg Java writes 
when
+        // carrying rows forward across a rewrite: some rows have a stored 
value, some
+        // are null (added/modified rows, inherited on read).
+        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) 
as ArrayRef;
+        let file_path = write_plain_parquet(
+            dir,
+            "with_seq.parquet",
+            vec![physical_last_updated_seq_field()],
+            vec![seq_col],
+        );
+
+        let task = last_updated_seq_task(file_path, Some(100), Some(9));
+
+        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), 
Runtime::current()).build();
+        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as 
FileScanTaskStream;
+        let batches: Vec<RecordBatch> = reader
+            .read(tasks)
+            .unwrap()
+            .stream()
+            .try_collect()
+            .await
+            .unwrap();
+
+        // Per-row value where non-null; the data sequence number (9) where 
null.
+        assert_last_updated_seq_column(&batches, &[Some(5), Some(9), Some(8)]);
+    }
+
+    #[tokio::test]
+    async fn test_last_updated_sequence_number_coalesced_with_pos_column() {
+        use crate::metadata_columns::{
+            RESERVED_COL_NAME_POS, 
RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
+            RESERVED_FIELD_ID_POS,
+        };
 
         let tmp_dir = TempDir::new().unwrap();
         let dir = tmp_dir.path().to_str().unwrap();
-        // A file that physically carries the _last_updated_sequence_number 
column, as
-        // Iceberg Java writes when carrying rows forward across a rewrite.
-        let seq_field = Field::new("_last_updated_sequence_number", 
DataType::Int64, true)
-            .with_metadata(HashMap::from([(
-                PARQUET_FIELD_ID_META_KEY.to_string(),
-                RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER.to_string(),
-            )]));
+        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) 
as ArrayRef;
+        let file_path = write_plain_parquet(
+            dir,
+            "with_seq_and_pos.parquet",
+            vec![physical_last_updated_seq_field()],
+            vec![seq_col],
+        );
+
+        // Co-project `_pos` (a virtual column appended to the Arrow output 
schema) with the
+        // physical coalesce column. This guards that the physical column's 
index is
+        // resolved in the Parquet schema, not the Arrow schema (whose indices 
shift once
+        // virtual columns are appended).
+        let schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),

Review Comment:
   This task schema declares `id` as `required` and projects field 1, but 
`write_plain_parquet` only writes the physical `_last_updated_sequence_number` 
column here — no `id` column. A required field absent from the file usually 
surfaces as `DataInvalid("Missing required field: id")`, yet the test expects 
success.
   
   Either `write_plain_parquet` is quietly inserting an `id` column (in which 
case the test passes for a reason it doesn't state), or this should be 
`optional` / carry an initial default. Could we double-check which, so the test 
verifies what it claims?



-- 
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