jecsand838 commented on code in PR #10713:
URL: https://github.com/apache/arrow-rs/pull/10713#discussion_r3833146137
##########
arrow-avro/src/reader/mod.rs:
##########
@@ -736,6 +741,31 @@ impl Decoder {
Ok(total_consumed)
}
+ /// Decode exactly one unframed Avro datum with the active writer schema.
+ ///
+ /// This is intended for transports such as Kafka where the message
boundary is external to
+ /// Avro. It returns the number of datum bytes consumed, allowing the
caller to ignore transport
+ /// payload bytes after the first datum when its format contract requires
that behavior.
+ /// Consecutive unframed datums can be decoded by repeatedly passing the
unconsumed suffix.
+ /// If the current batch is full, this method returns `Ok(0)` until
[`Self::flush`] is called.
+ ///
+ /// The decoder must already have the desired active fingerprint, and this
method does not
+ /// inspect or switch framing fingerprints.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the datum is incomplete, malformed, or
incompatible with the active
+ /// writer schema.
+ pub fn decode_datum(&mut self, data: &[u8]) -> Result<usize, AvroError> {
+ if self.remaining_capacity == 0 {
+ return Ok(0);
+ }
Review Comment:
The new unframed mode may make `Ok(0)` too ambiguous. An empty record or a
record containing only `null` fields is a valid datum that consumes zero bytes
but still appends one row. The same result currently means the batch was
already full and no row was appended. A caller that interprets zero as flush
and retry can therefore duplicate a successfully decoded row.
I'd recommending giving batch-full a distinct outcome such as
`AvroError::BatchFull` or something along those lines:
```rust
if self.capacity == 0 {
return Err(AvroError::BatchFull);
}
// A successful zero-width datum remains unambiguously Ok(0)
let consumed = self.active_decoder.decode(data, 1)?;
self.capacity -= 1;
Ok(consumed)
```
##########
arrow-avro/src/reader/record.rs:
##########
@@ -717,9 +719,61 @@ impl Decoder {
inner.append_null()?;
}
Self::Union(u) => u.append_null()?,
- Self::Nullable(_, null_buffer, inner) => {
+ Self::Nullable(_, null_buffer, _, pending_nulls) => {
Review Comment:
This defers child placeholders, but each null still calls
`NullBufferBuilder::append(false)`, so long null runs retain one bitmap write
per row. The non-null path also calls the large `append_nulls` dispatcher when
`pending_nulls == 0`.
Could we defer both the validity suffix and child placeholders behind one
helper?
```rust
#[inline]
fn materialize_pending(
validity: &mut NullBufferBuilder,
values: &mut Decoder,
pending: &mut usize,
) -> Result<(), AvroError> {
let count = *pending;
if count == 0 {
return Ok(());
}
values.append_nulls(count)?;
validity.append_n_nulls(count);
*pending = 0;
Ok(())
}
```
Null branches would only increment `pending_nulls`. Before a
non-null/default value or flush, call this helper; after successfully decoding
a value, call `validity.append_non_null()`. This makes long and all-null runs
bulk operations and avoids zero-count dispatch on dense nullable data.
##########
arrow-avro/src/reader/record.rs:
##########
@@ -717,9 +719,61 @@ impl Decoder {
inner.append_null()?;
}
Self::Union(u) => u.append_null()?,
- Self::Nullable(_, null_buffer, inner) => {
+ Self::Nullable(_, null_buffer, _, pending_nulls) => {
null_buffer.append(false);
- inner.append_null()?;
+ *pending_nulls += 1;
+ }
+ }
+ Ok(())
+ }
+
+ /// Append a run of null placeholders, deferring nullable children until
their next value or
+ /// flush so sparse record subtrees can be materialized in bulk.
+ fn append_nulls(&mut self, count: usize) -> Result<(), AvroError> {
+ if count == 0 {
+ return Ok(());
+ }
+ match self {
+ Self::Null(size) => *size += count,
+ Self::Boolean(values) => values.append_n(count, false),
+ Self::Int32(values) | Self::Date32(values) |
Self::TimeMillis(values) => {
+ values.resize(values.len() + count, 0)
+ }
+ Self::Int64(values)
+ | Self::Int32ToInt64(values)
+ | Self::TimeMicros(values)
+ | Self::TimestampMillis(_, values)
+ | Self::TimestampMicros(_, values)
+ | Self::TimestampNanos(_, values) => values.resize(values.len() +
count, 0),
+ Self::Float32(values) | Self::Int32ToFloat32(values) |
Self::Int64ToFloat32(values) => {
+ values.resize(values.len() + count, 0.0)
+ }
+ Self::Float64(values)
+ | Self::Int32ToFloat64(values)
+ | Self::Int64ToFloat64(values)
+ | Self::Float32ToFloat64(values) => values.resize(values.len() +
count, 0.0),
+ Self::Binary(offsets, _)
+ | Self::String(offsets, _)
+ | Self::StringView(offsets, _)
+ | Self::BytesToString(offsets, _)
+ | Self::StringToBytes(offsets, _) => {
+ for _ in 0..count {
+ offsets.push_length(0);
+ }
+ }
+ Self::Record(_, children, _, _) => {
+ for child in children {
+ child.append_nulls(count)?;
+ }
+ }
+ Self::Nullable(_, null_buffer, _, pending_nulls) => {
+ null_buffer.append_n_nulls(count);
+ *pending_nulls += count;
+ }
+ other => {
+ for _ in 0..count {
+ other.append_null()?;
+ }
Review Comment:
The catch-all calls `append_null()` `count` times, re-running the full
`Decoder` match for every placeholder. Consequently, nullable arrays, maps,
fixed values, UUIDs, enums, decimals, custom primitives, and REE do not receive
the intended bulk optimization.
Perhaps add direct bulk arms where possible? for example:
```rust
Self::Uuid(values) => {
values.resize(values.len() + 16 * count, 0);
}
Self::Fixed(width, values) => {
values.resize(values.len() + (*width as usize) * count, 0);
}
Self::Enum(values, _, _) => {
values.resize(values.len() + count, 0);
}
Self::Decimal128(_, _, _, builder) => {
builder.append_value_n(0, count);
}
Self::Decimal256(_, _, _, builder) => {
builder.append_value_n(i256::ZERO, count);
}
Self::RunEndEncoded(_, len, inner) => {
inner.append_nulls(count)?;
*len += count;
}
```
For offset-backed types, we could reserve `count` offsets before pushing
repeated zero lengths. I would also make the match exhaustive and keep any
necessarily per-row union handling explicit, so future variants cannot silently
fall back to a slower implementation. This could always be looked into as a
follow-up.
##########
arrow-avro/src/reader/mod.rs:
##########
@@ -736,6 +741,31 @@ impl Decoder {
Ok(total_consumed)
}
+ /// Decode exactly one unframed Avro datum with the active writer schema.
+ ///
+ /// This is intended for transports such as Kafka where the message
boundary is external to
+ /// Avro. It returns the number of datum bytes consumed, allowing the
caller to ignore transport
+ /// payload bytes after the first datum when its format contract requires
that behavior.
+ /// Consecutive unframed datums can be decoded by repeatedly passing the
unconsumed suffix.
+ /// If the current batch is full, this method returns `Ok(0)` until
[`Self::flush`] is called.
+ ///
+ /// The decoder must already have the desired active fingerprint, and this
method does not
+ /// inspect or switch framing fingerprints.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the datum is incomplete, malformed, or
incompatible with the active
+ /// writer schema.
+ pub fn decode_datum(&mut self, data: &[u8]) -> Result<usize, AvroError> {
+ if self.remaining_capacity == 0 {
+ return Ok(0);
+ }
+ let consumed = self.active_decoder.decode(data, 1)?;
+ self.remaining_capacity -= 1;
+ self.awaiting_body = false;
+ Ok(consumed)
+ }
Review Comment:
I'd recommend we avoid adding a second public decoding method and instead
select the input grammar when building the decoder.
A decoder should generally consume one stable wire format for its lifetime,
and this follows existing arrow-rs patterns such as `arrow_json::StructMode`,
IPC’s `DictionaryHandling`, and the Avro writer’s construction-time choice
between `AvroSoeFormat` and `AvroBinaryFormat`.
I suggest replacing `decode_datum` with:
```rust
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DecoderMode {
#[default]
Framed,
UnframedDatum,
}
```
Then expose:
```rust
pub fn with_decoder_mode(mut self, mode: DecoderMode) -> Self {
self.decoder_mode = mode;
self
}
```
`Decoder::decode` can dispatch internally:
```rust
pub fn decode(&mut self, data: &[u8]) -> Result<usize, AvroError> {
match self.mode {
DecoderMode::Framed => self.decode_framed(data),
DecoderMode::UnframedDatum => self.decode_unframed(data),
}
}
```
##########
arrow-avro/src/reader/record.rs:
##########
@@ -276,7 +276,8 @@ enum Decoder {
#[cfg(feature = "avro_custom_types")]
RunEndEncoded(u8, usize, Box<Decoder>),
Union(UnionDecoder),
- Nullable(NullablePlan, NullBufferBuilder, Box<Decoder>),
+ /// Nullable value plus trailing null placeholders not yet materialized in
the child decoder.
+ Nullable(NullablePlan, NullBufferBuilder, Box<Decoder>, usize),
Review Comment:
Adding `pending_nulls` as a fourth positional field makes the nullable state
transitions difficult to audit across `append_null`, `append_nulls`,
`append_default`, `decode`, and `flush`.
Could this state be represented by a named structure?
```rust
struct NullableDecoder {
plan: NullablePlan,
validity: NullBufferBuilder,
values: Box<Decoder>,
pending_nulls: usize,
}
```
A `materialize_pending()` method on this structure would centralize the
invariant and ensure every transition updates the child, validity bitmap, and
pending count consistently. If `append_nulls` is made exhaustive,
`append_null()` could also delegate to `append_nulls(1)`, removing the
duplicated per-variant implementation.
--
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]