linliu-code commented on code in PR #668:
URL: https://github.com/apache/hudi-rs/pull/668#discussion_r3779923400


##########
crates/core/src/file_group/log_file/avro.rs:
##########
@@ -16,77 +16,148 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-use apache_avro::AvroResult;
-use apache_avro::types::Value as AvroValue;
-use apache_avro::{Schema as AvroSchema, from_avro_datum};
-use std::io::Read;
+use crate::Result;
+use crate::error::CoreError;
+use arrow_array::{ArrayRef, RecordBatch};
+use arrow_avro::reader::{Decoder as ArrowAvroDecoder, ReaderBuilder};
+use arrow_avro::schema::{AvroSchema as ArrowAvroSchema, SINGLE_OBJECT_MAGIC, 
SchemaStore};
+use arrow_cast::cast;
+use arrow_schema::{DataType, Field, Schema};
+use std::sync::Arc;
 
-pub struct AvroDataBlockContentReader<R: Read> {
-    reader: R,
-    writer_schema: AvroSchema,
-    remaining_records: u32,
+/// Decodes the bare Avro record bodies in a Hudi log block straight into 
Arrow.
+///
+/// A Hudi data block frames each datum with a four-byte length and no Avro
+/// framing of its own, while `arrow-avro`'s decoder expects each record to be
+/// introduced by a Single Object Encoding prefix — the two-byte marker and the
+/// writer schema's fingerprint. The two are reconciled by synthesizing that
+/// prefix: the schema is registered once, and the ten bytes it yields are
+/// written ahead of every body.
+///
+/// The alternative is `arrow-avro`'s `AvroBodyDecoder`, which takes bare 
bodies
+/// directly but is not released yet.
+pub struct AvroBlockDecoder {
+    decoder: ArrowAvroDecoder,
+    /// Marker plus fingerprint, identical for every record in the block.
+    prefix: [u8; 10],
+    /// Reused across records so framing costs one copy, not one allocation.
+    framed: Vec<u8>,
 }
 
-impl<R: Read> AvroDataBlockContentReader<R> {
-    pub fn new(reader: R, writer_schema: &AvroSchema, num_records: u32) -> 
Self {
-        Self {
-            reader,
-            writer_schema: writer_schema.clone(),
-            remaining_records: num_records,
-        }
-    }
-}
+impl AvroBlockDecoder {
+    /// Build a decoder for a block written with `writer_schema_json`.
+    pub fn try_new(writer_schema_json: &str, batch_size: usize) -> 
Result<Self> {
+        let mut store = SchemaStore::new();
+        let fingerprint = store
+            .register(ArrowAvroSchema::new(writer_schema_json.to_string()))
+            .map_err(|e| {
+                CoreError::LogBlockError(format!("Failed to register block 
writer schema: {e}"))
+            })?;
 
-impl<R: Read> Iterator for AvroDataBlockContentReader<R> {
-    type Item = AvroResult<AvroValue>;
+        let arrow_avro::schema::Fingerprint::Rabin(rabin) = fingerprint else {
+            return Err(CoreError::LogBlockError(format!(
+                "Expected a Rabin fingerprint for the block writer schema, got 
{fingerprint:?}"
+            )));
+        };
 
-    fn next(&mut self) -> Option<Self::Item> {
-        if self.remaining_records == 0 {
-            return None;
-        }
+        let decoder = ReaderBuilder::new()
+            .with_writer_schema_store(store)
+            .with_active_fingerprint(fingerprint)
+            .with_batch_size(batch_size)
+            .build_decoder()
+            .map_err(|e| {
+                CoreError::LogBlockError(format!("Failed to build the Avro 
decoder: {e}"))
+            })?;
 
-        self.remaining_records -= 1;
+        let mut prefix = [0u8; 10];
+        prefix[..2].copy_from_slice(&SINGLE_OBJECT_MAGIC);
+        prefix[2..].copy_from_slice(&rabin.to_le_bytes());
 
-        let mut record_content_length = [0u8; 4];
-        match self.reader.read_exact(&mut record_content_length) {
-            Ok(_) => {}
-            Err(e) => {
-                return Some(Err(apache_avro::Error::new(
-                    apache_avro::error::Details::ReadBytes(e),
-                )));
-            }
-        }
+        Ok(Self {
+            decoder,
+            prefix,
+            framed: Vec::new(),
+        })
+    }
 
-        let record_content_length = u32::from_be_bytes(record_content_length);
+    /// Decode one record body, returning a batch once enough rows have 
accrued.
+    pub fn decode(&mut self, body: &[u8]) -> Result<Option<RecordBatch>> {
+        self.framed.clear();
+        self.framed.reserve(self.prefix.len() + body.len());
+        self.framed.extend_from_slice(&self.prefix);
+        self.framed.extend_from_slice(body);
 
-        let mut record_reader = (&mut self.reader).take(record_content_length 
as u64);
+        let consumed = self
+            .decoder
+            .decode(&self.framed)
+            .map_err(|e| CoreError::LogBlockError(format!("Failed to decode a 
log record: {e}")))?;
+        if consumed != self.framed.len() {
+            return Err(CoreError::LogBlockError(format!(
+                "Log record decoded partially: {consumed} of {} bytes",
+                self.framed.len()
+            )));
+        }
 
-        let result = from_avro_datum(&self.writer_schema, &mut record_reader, 
None);
+        if self.decoder.batch_is_full() {
+            return self.flush();
+        }
+        Ok(None)
+    }
 
-        Some(result)
+    /// Drain whatever rows are held, if any.
+    pub fn flush(&mut self) -> Result<Option<RecordBatch>> {
+        let batch = self.decoder.flush().map_err(|e| {
+            CoreError::LogBlockError(format!("Failed to flush decoded records: 
{e}"))
+        })?;
+        batch.map(normalize_utc_timestamps).transpose()
     }
 }
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::io::Cursor;
-
-    #[test]
-    fn test_read_error_on_truncated_data() {
-        // Create a simple Avro schema
-        let schema = AvroSchema::parse_str(r#"{"type": "null"}"#).unwrap();
-
-        // Create a reader with only 2 bytes when we need 4 bytes for record 
length
-        let truncated_data = vec![0u8, 1u8];
-        let reader = Cursor::new(truncated_data);
+/// Spell a UTC timestamp's zone the way the parquet reader does.
+///
+/// `arrow-avro` writes the zone of an Avro `timestamp-*` as the offset
+/// `+00:00`; parquet writes `UTC`. They denote the same zone, but Arrow
+/// compares timezones as strings, so a log batch and the base batch it merges
+/// with would be judged to have different types and refuse to concatenate.
+///
+/// Only the field's type label changes — the values are already UTC instants,
+/// so this rebinds metadata rather than converting anything.
+fn normalize_utc_timestamps(batch: RecordBatch) -> Result<RecordBatch> {
+    fn is_utc_alias(tz: &str) -> bool {
+        matches!(tz, "+00:00" | "+0000" | "00:00" | "Z" | "z")
+    }
 
-        // Create reader expecting 1 record but with insufficient data
-        let mut avro_reader = AvroDataBlockContentReader::new(reader, &schema, 
1);
+    let needs_fix = batch
+        .schema()
+        .fields()
+        .iter()
+        .any(|f| matches!(f.data_type(), DataType::Timestamp(_, Some(tz)) if 
is_utc_alias(tz)));

Review Comment:
   You're right that the normalizer only inspects top-level fields, and no, 
`reconcile_batch_to_schema` does not normalize nested timezones downstream — so 
a timestamp nested in a struct, list or map would keep arrow-avro's `+00:00`.
   
   I checked whether it's reachable: I walked every parquet file across the 
whole fixture corpus looking for a timestamp nested inside a struct/list/map, 
and there are **zero**. So this is untested territory rather than a known 
break, and writing recursive retyping for `StructArray`/`ListArray`/`MapArray` 
would mean shipping code no fixture can exercise — which is the thing this port 
has been trying not to do.
   
   So I've documented the scope on the function instead of guessing at the 
implementation: "Top-level fields only. A timestamp nested in a struct, list or 
map keeps arrow-avro's spelling; no fixture in the corpus has one, so recursing 
here would be untested code standing in for a case nothing can currently 
exercise. Not yet implemented, deliberately." If a fixture with a nested 
timestamp shows up, that's the trigger to implement it properly with a gold 
comparison behind it.



##########
crates/core/src/file_group/log_file/avro.rs:
##########
@@ -16,77 +16,148 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-use apache_avro::AvroResult;
-use apache_avro::types::Value as AvroValue;
-use apache_avro::{Schema as AvroSchema, from_avro_datum};
-use std::io::Read;
+use crate::Result;
+use crate::error::CoreError;
+use arrow_array::{ArrayRef, RecordBatch};
+use arrow_avro::reader::{Decoder as ArrowAvroDecoder, ReaderBuilder};
+use arrow_avro::schema::{AvroSchema as ArrowAvroSchema, SINGLE_OBJECT_MAGIC, 
SchemaStore};
+use arrow_cast::cast;
+use arrow_schema::{DataType, Field, Schema};
+use std::sync::Arc;
 
-pub struct AvroDataBlockContentReader<R: Read> {
-    reader: R,
-    writer_schema: AvroSchema,
-    remaining_records: u32,
+/// Decodes the bare Avro record bodies in a Hudi log block straight into 
Arrow.
+///
+/// A Hudi data block frames each datum with a four-byte length and no Avro
+/// framing of its own, while `arrow-avro`'s decoder expects each record to be
+/// introduced by a Single Object Encoding prefix — the two-byte marker and the
+/// writer schema's fingerprint. The two are reconciled by synthesizing that
+/// prefix: the schema is registered once, and the ten bytes it yields are
+/// written ahead of every body.
+///
+/// The alternative is `arrow-avro`'s `AvroBodyDecoder`, which takes bare 
bodies
+/// directly but is not released yet.
+pub struct AvroBlockDecoder {
+    decoder: ArrowAvroDecoder,
+    /// Marker plus fingerprint, identical for every record in the block.
+    prefix: [u8; 10],
+    /// Reused across records so framing costs one copy, not one allocation.
+    framed: Vec<u8>,
 }
 
-impl<R: Read> AvroDataBlockContentReader<R> {
-    pub fn new(reader: R, writer_schema: &AvroSchema, num_records: u32) -> 
Self {
-        Self {
-            reader,
-            writer_schema: writer_schema.clone(),
-            remaining_records: num_records,
-        }
-    }
-}
+impl AvroBlockDecoder {
+    /// Build a decoder for a block written with `writer_schema_json`.
+    pub fn try_new(writer_schema_json: &str, batch_size: usize) -> 
Result<Self> {
+        let mut store = SchemaStore::new();
+        let fingerprint = store
+            .register(ArrowAvroSchema::new(writer_schema_json.to_string()))
+            .map_err(|e| {
+                CoreError::LogBlockError(format!("Failed to register block 
writer schema: {e}"))
+            })?;
 
-impl<R: Read> Iterator for AvroDataBlockContentReader<R> {
-    type Item = AvroResult<AvroValue>;
+        let arrow_avro::schema::Fingerprint::Rabin(rabin) = fingerprint else {
+            return Err(CoreError::LogBlockError(format!(
+                "Expected a Rabin fingerprint for the block writer schema, got 
{fingerprint:?}"
+            )));
+        };
 
-    fn next(&mut self) -> Option<Self::Item> {
-        if self.remaining_records == 0 {
-            return None;
-        }
+        let decoder = ReaderBuilder::new()
+            .with_writer_schema_store(store)
+            .with_active_fingerprint(fingerprint)
+            .with_batch_size(batch_size)
+            .build_decoder()
+            .map_err(|e| {
+                CoreError::LogBlockError(format!("Failed to build the Avro 
decoder: {e}"))
+            })?;
 
-        self.remaining_records -= 1;
+        let mut prefix = [0u8; 10];
+        prefix[..2].copy_from_slice(&SINGLE_OBJECT_MAGIC);
+        prefix[2..].copy_from_slice(&rabin.to_le_bytes());
 
-        let mut record_content_length = [0u8; 4];
-        match self.reader.read_exact(&mut record_content_length) {
-            Ok(_) => {}
-            Err(e) => {
-                return Some(Err(apache_avro::Error::new(
-                    apache_avro::error::Details::ReadBytes(e),
-                )));
-            }
-        }
+        Ok(Self {
+            decoder,
+            prefix,
+            framed: Vec::new(),
+        })
+    }
 
-        let record_content_length = u32::from_be_bytes(record_content_length);
+    /// Decode one record body, returning a batch once enough rows have 
accrued.
+    pub fn decode(&mut self, body: &[u8]) -> Result<Option<RecordBatch>> {
+        self.framed.clear();
+        self.framed.reserve(self.prefix.len() + body.len());
+        self.framed.extend_from_slice(&self.prefix);
+        self.framed.extend_from_slice(body);
 
-        let mut record_reader = (&mut self.reader).take(record_content_length 
as u64);
+        let consumed = self
+            .decoder
+            .decode(&self.framed)
+            .map_err(|e| CoreError::LogBlockError(format!("Failed to decode a 
log record: {e}")))?;
+        if consumed != self.framed.len() {
+            return Err(CoreError::LogBlockError(format!(
+                "Log record decoded partially: {consumed} of {} bytes",
+                self.framed.len()
+            )));
+        }
 
-        let result = from_avro_datum(&self.writer_schema, &mut record_reader, 
None);
+        if self.decoder.batch_is_full() {
+            return self.flush();
+        }
+        Ok(None)
+    }
 
-        Some(result)
+    /// Drain whatever rows are held, if any.
+    pub fn flush(&mut self) -> Result<Option<RecordBatch>> {
+        let batch = self.decoder.flush().map_err(|e| {
+            CoreError::LogBlockError(format!("Failed to flush decoded records: 
{e}"))
+        })?;
+        batch.map(normalize_utc_timestamps).transpose()
     }
 }
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::io::Cursor;
-
-    #[test]
-    fn test_read_error_on_truncated_data() {
-        // Create a simple Avro schema
-        let schema = AvroSchema::parse_str(r#"{"type": "null"}"#).unwrap();
-
-        // Create a reader with only 2 bytes when we need 4 bytes for record 
length
-        let truncated_data = vec![0u8, 1u8];
-        let reader = Cursor::new(truncated_data);
+/// Spell a UTC timestamp's zone the way the parquet reader does.
+///
+/// `arrow-avro` writes the zone of an Avro `timestamp-*` as the offset
+/// `+00:00`; parquet writes `UTC`. They denote the same zone, but Arrow
+/// compares timezones as strings, so a log batch and the base batch it merges
+/// with would be judged to have different types and refuse to concatenate.
+///
+/// Only the field's type label changes — the values are already UTC instants,
+/// so this rebinds metadata rather than converting anything.
+fn normalize_utc_timestamps(batch: RecordBatch) -> Result<RecordBatch> {
+    fn is_utc_alias(tz: &str) -> bool {
+        matches!(tz, "+00:00" | "+0000" | "00:00" | "Z" | "z")
+    }
 
-        // Create reader expecting 1 record but with insufficient data
-        let mut avro_reader = AvroDataBlockContentReader::new(reader, &schema, 
1);
+    let needs_fix = batch
+        .schema()
+        .fields()
+        .iter()
+        .any(|f| matches!(f.data_type(), DataType::Timestamp(_, Some(tz)) if 
is_utc_alias(tz)));
+    if !needs_fix {
+        return Ok(batch);
+    }
 
-        // Should return an error because read_exact fails
-        let result = avro_reader.next();
-        assert!(result.is_some());
-        assert!(result.unwrap().is_err());
+    let mut fields: Vec<Field> = Vec::with_capacity(batch.num_columns());
+    let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
+    for (field, column) in batch.schema().fields().iter().zip(batch.columns()) 
{
+        match field.data_type() {
+            DataType::Timestamp(unit, Some(tz)) if is_utc_alias(tz) => {
+                let retimed = cast(column, &DataType::Timestamp(*unit, 
Some("UTC".into())))

Review Comment:
   Fixed. Renamed to `relabeled`, with a comment at the binding: "Relabeled, 
not converted: same instants, different spelling of the same zone."



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