hudi-agent commented on code in PR #669:
URL: https://github.com/apache/hudi-rs/pull/669#discussion_r3771771928


##########
crates/core/src/file_group/log_file/content.rs:
##########
@@ -110,23 +152,48 @@ impl Decoder {
     ) -> Result<RecordBatches> {
         Decoder::validate_log_block_version(&mut reader)?;
 
-        let writer_schema = 
header.get(&BlockMetadataKey::Schema).ok_or_else(|| {
+        let writer_schema_json = 
header.get(&BlockMetadataKey::Schema).ok_or_else(|| {
             CoreError::LogBlockError("Schema not found in block 
header".to_string())
         })?;
-        let writer_schema = Arc::new(AvroSchema::parse_str(writer_schema)?);
 
         let mut record_count_buf = [0u8; 4];
         reader.read_exact(&mut record_count_buf)?;
         let record_count = u32::from_be_bytes(record_count_buf);
 
-        let record_content_reader =
-            AvroDataBlockContentReader::new(reader, writer_schema.as_ref(), 
record_count);
-        let mut avro_arrow_array_reader =
-            AvroArrowArrayReader::try_new(record_content_reader, 
writer_schema.as_ref())?;
+        // A partial-update block carries only the columns that were written, 
and
+        // the merge needs to know which those are. Resolving it up to the 
table
+        // schema would fabricate the rest, so it decodes against its own 
schema.
+        let is_partial = header.contains_key(&BlockMetadataKey::IsPartial);
+        let reader_schema_json = if is_partial {
+            None
+        } else {
+            self.required_schema_json.as_deref()
+        };
+        let mut decoder = AvroBlockDecoder::try_new_with_reader(

Review Comment:
   🤖 When `required_schema_json` promotes a float→double column, resolving the 
block through the Avro reader schema uses arrow-avro's native widening (1.1f32 
→ 1.100000023841858), whereas `batch_evolution` deliberately string-mediates 
Float32→Float64 for base files to match the Java gold. Would base-file rows and 
log-updated rows for that column then carry different doubles (and diverge from 
Java Hudi)? The test's int→long promotion is exact, so it wouldn't surface this.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
crates/core/src/schema/batch_evolution.rs:
##########
@@ -0,0 +1,1103 @@
+/*
+ * 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.
+ */
+
+//! Ported from the merge-on-read reader. Nothing consumes it yet, so its
+//! items are unreachable from the crate's call graph until the reader wires 
in.
+#![allow(dead_code)]
+
+//! Batch-level schema-evolution projector.
+//!
+//! Equivalent of gold's record rewrite 
(`HoodieAvroUtils.rewriteRecordWithNewSchema`,
+//! avro log path) and cast projection 
(`HoodieParquetFileFormatHelper.generateUnsafeProjection`,
+//! parquet base path): reorder columns by name, null-fill added columns, cast
+//! promoted types. Gold-parity cast rules:
+//!   * Float32→Float64: STRING-MEDIATED (both gold paths do this; C6 
value-exactness)
+//!   * numeric→Utf8: Java `String.valueOf` formatting
+//!   * struct/list/map: recursive
+//!   * everything else: `arrow_cast::cast`
+
+use crate::Result;
+use crate::error::CoreError;
+use arrow_array::{Array, ArrayRef, RecordBatch, StringArray, new_null_array};
+use arrow_schema::{DataType, FieldRef, SchemaRef, TimeUnit};
+use std::sync::Arc;
+
+/// Microseconds per millisecond — the ÷1000 factor for the NTZ 
(local-timestamp)
+/// micros→millis arithmetic conversion. Mirrors Java 
`DateTimeUtils.MICROS_PER_MILLIS`.
+const MICROS_PER_MILLIS: i64 = 1000;
+
+/// Project `batch` to `target` schema: reorder by name, null-fill missing
+/// nullable columns, evolve types. Identity-cheap when schemas already match.
+pub fn project_batch_to_schema(batch: &RecordBatch, target: &SchemaRef) -> 
Result<RecordBatch> {
+    if batch.schema() == *target {
+        return Ok(batch.clone());
+    }
+    let num_rows = batch.num_rows();
+    let batch_schema = batch.schema();
+    let mut columns: Vec<ArrayRef> = Vec::with_capacity(target.fields().len());
+    for tf in target.fields() {
+        match index_of_ci(&batch_schema, tf.name())? {
+            Some(idx) => columns.push(evolve_array(batch.column(idx), tf)?),
+            None => {
+                if tf.is_nullable() {
+                    columns.push(new_null_array(tf.data_type(), num_rows));
+                } else {
+                    return Err(CoreError::Schema(format!(
+                        "evolution: non-nullable column '{}' absent from 
source batch",
+                        tf.name()
+                    )));
+                }
+            }
+        }
+    }
+    RecordBatch::try_new(target.clone(), columns)
+        .map_err(|e| CoreError::Schema(format!("evolution: rebuild under 
target schema: {e}")))
+}
+
+/// Locate a column by name, preferring an exact match and falling back to a
+/// case-insensitive match (gold/Spark resolve field names case-insensitively).
+///
+/// Returns `Ok(None)` when no field matches (the caller null-fills) and an
+/// error when more than one field matches case-insensitively without an exact
+/// match — ambiguous, so fail loudly rather than silently picking one.
+pub(crate) fn index_of_ci(schema: &arrow_schema::Schema, name: &str) -> 
Result<Option<usize>> {
+    if let Ok(idx) = schema.index_of(name) {
+        return Ok(Some(idx));
+    }
+    let mut found: Option<usize> = None;
+    for (idx, field) in schema.fields().iter().enumerate() {
+        if field.name().eq_ignore_ascii_case(name) {
+            if found.is_some() {
+                return Err(CoreError::Schema(format!(
+                    "evolution: column '{name}' matches multiple source 
columns \
+                     case-insensitively; ambiguous projection"
+                )));
+            }
+            found = Some(idx);
+        }
+    }
+    Ok(found)
+}
+
+/// True for any nested/container Arrow type the recursion arms care about.
+/// Matching variants (List/Struct/Map) are handled by the recursion arms above
+/// the guard; this catches everything else (LargeList, FixedSizeList, and any
+/// container present on only one side) so it errors instead of silently 
routing
+/// through `arrow_cast`.
+fn is_container(dt: &DataType) -> bool {
+    matches!(
+        dt,
+        DataType::List(_)
+            | DataType::LargeList(_)
+            | DataType::FixedSizeList(_, _)
+            | DataType::Struct(_)
+            | DataType::Map(_, _)
+    )
+}
+
+fn evolve_array(src: &ArrayRef, target_field: &FieldRef) -> Result<ArrayRef> {
+    let st = src.data_type();
+    let tt = target_field.data_type();
+    if st == tt {
+        return Ok(src.clone());
+    }
+    match (st, tt) {
+        // Gold C6: float→double via string round-trip (both gold paths).
+        (DataType::Float32, DataType::Float64) => {
+            let s = float_to_java_string_array(src)?;
+            arrow_cast::cast(&s, &DataType::Float64)
+                .map_err(|e| CoreError::Schema(format!("evolution f32->f64: 
{e}")))
+        }
+        // numeric → string with Java String.valueOf formatting.
+        // Widening an integer is exact, so a direct cast matches Java. Avro

Review Comment:
   🤖 nit: the pre-existing `// numeric → string with Java String.valueOf 
formatting.` comment immediately precedes this arm, so it now reads as the 
annotation for the int→long widening rather than for the string-conversion arms 
below. Could you move the new arm (and its own comment) to before that section 
header so the comment still points at the float/int→Utf8 block?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
crates/core/src/file_group/log_file/content.rs:
##########
@@ -110,23 +152,48 @@ impl Decoder {
     ) -> Result<RecordBatches> {
         Decoder::validate_log_block_version(&mut reader)?;
 
-        let writer_schema = 
header.get(&BlockMetadataKey::Schema).ok_or_else(|| {
+        let writer_schema_json = 
header.get(&BlockMetadataKey::Schema).ok_or_else(|| {
             CoreError::LogBlockError("Schema not found in block 
header".to_string())
         })?;
-        let writer_schema = Arc::new(AvroSchema::parse_str(writer_schema)?);
 
         let mut record_count_buf = [0u8; 4];
         reader.read_exact(&mut record_count_buf)?;
         let record_count = u32::from_be_bytes(record_count_buf);
 
-        let record_content_reader =
-            AvroDataBlockContentReader::new(reader, writer_schema.as_ref(), 
record_count);
-        let mut avro_arrow_array_reader =
-            AvroArrowArrayReader::try_new(record_content_reader, 
writer_schema.as_ref())?;
+        // A partial-update block carries only the columns that were written, 
and
+        // the merge needs to know which those are. Resolving it up to the 
table
+        // schema would fabricate the rest, so it decodes against its own 
schema.
+        let is_partial = header.contains_key(&BlockMetadataKey::IsPartial);

Review Comment:
   🤖 Worth digging into, because the merge stage doesn't appear to backstop 
this. `schema_is_partial` (key_based.rs:1211) detects partial-ness by missing 
target fields, so a *full* block that got mis-decoded writer-only here would 
still carry every target field and be treated as full at merge — meaning the 
int→long promotion never happens and the wrong type reaches merge silently 
rather than being caught. So the presence-vs-value distinction matters. Do we 
know whether Java's writer ever actually emits an explicit `IS_PARTIAL=false` 
header, or does it only add the header for true partials? That determines 
whether this is reachable in practice.



##########
crates/core/src/file_group/log_file/content.rs:
##########
@@ -39,13 +46,48 @@ use std::sync::Arc;
 pub struct Decoder {
     batch_size: usize,
     hudi_configs: Arc<HudiConfigs>,
+    /// Predicate to push into a parquet log block, when the caller has decided
+    /// it is safe to evaluate before the merge. See
+    /// [`Decoder::with_row_filter`].
+    row_filter: Option<RowFilterBuilder>,
+    /// Schema an Avro block is resolved up to, as Avro JSON. See
+    /// [`Decoder::with_required_schema`].
+    required_schema_json: Option<String>,

Review Comment:
   🤖 nit: the same concept is `reader_schema_json` in 
`AvroBlockDecoder::try_new_with_reader` (Avro's own name for the resolving 
schema) but `required_schema_json` here and in `LogFileReader`. Could you pick 
one name — `reader_schema_json` matches the Avro spec terminology and the 
lower-level API — and use it consistently across both layers?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



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

Reply via email to