[rust] Add V1 batch statistics collection - #4011
Conversation
7595c4d to
4692a22
Compare
Adds the pieces a writer needs to produce V1 log record batch statistics: `AlignedRowWriter` for the min and max rows, `record::statistics` to reduce an Arrow batch to per-column bounds and null counts, and the `table.statistics.columns` plumbing that selects the columns. Nothing emits them yet, so batches stay in the V0 format.
4692a22 to
4bbfbd0
Compare
|
@fresh-borzoni @charlesdong1991 Appreciate review here 🙏 |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@leekeiabstraction Thank you for the PR, left a couple of comments, PTAL
| let mut null_counts = Vec::with_capacity(mapping.len()); | ||
| let mut bounds = Vec::with_capacity(mapping.len()); | ||
| for &column_index in mapping { | ||
| let column = batch.column(column_index); |
There was a problem hiding this comment.
mapping is checked against the table schema but not against the batch, and batch.column(i) panics out of range.
There was a problem hiding this comment.
Well spotted, added mapped index range checks. Note that check against table schema is done in column_bounds
| /// | ||
| /// # Errors | ||
| /// Returns an error if a named column is absent from the table schema. | ||
| pub fn get_stats_index_mapping(&self) -> Result<Vec<usize>> { |
There was a problem hiding this comment.
Java rejects unsupported statistics columns at table creation, not at write time. Why do we prefer to differ here?
There was a problem hiding this comment.
Well spotted, I've made an error in the documentation. The implementation is already mirroring Java's TableInfo.getStatsIndexMapping() which is called at write time.
| fn write_decimal(&mut self, value: &Decimal, precision: u32) { | ||
| let pos = self.current_pos; | ||
| if Decimal::is_compact_precision(precision) { | ||
| let unscaled = value.to_unscaled_long().unwrap_or(0); |
There was a problem hiding this comment.
unwrap_or(0) turns a decimal that doesn't fit in i64 into a silent 0, which then ships as a min or max. Java throws here. Shall we propagate it or there is a reason for clamping?
There was a problem hiding this comment.
Well spotted, I've updated to mirror Java side client. Using panic instead of Result::Error due to existing signature of writers. Changing the signature or the reducing writer visibility to pub(crate) probably requires some discussion and is out of scope of this PR.
There was a problem hiding this comment.
The module row::aligned is introduced in this PR, so pub(crate) mod aligned would not change any writer signature and would keep the ARRAY and MAP panics out of the public API.
ValueWriter::write_value routes ARRAY and MAP into any BinaryWriter, and Java's AlignedRow supports both through AlignedArray and AlignedMap, so a caller using this writer outside statistics would hit an abort rather than an error. With the module made internal, the only new warning is that size_in_bytes has no caller, which is already true today.
Would it make sense to keep the module internal until the writers can return Result? The visibility can be widened later without a compatibility concern, while the reverse is not true.
Bounds-check the statistics mapping against the batch rather than panicking on a mismatch, and reject a decimal built at a precision other than the column's instead of writing a silent zero bound.
The Arrow array holds seconds for TIME(0) while the statistics format is always millis of day, so the bounds were a thousand times too small.
Adds tests for the Time64 micro and nano, timestamp second and nano, and non compact decimal arms, which were the conversions still going unchecked.
9962e3a to
0d7b031
Compare
|
@fresh-borzoni TY for your review, I've addressed your comments and also push a separate fix to a bug I found. PTAL. |
naivedogger
left a comment
There was a problem hiding this comment.
Thanks @leekeiabstraction for working on this! Overall, LGTM. I left one minor comment, PTAL.
|
|
||
| /// Inlines a payload of at most 7 bytes into the field slot, with | ||
| /// `len | 0x80` in the slot's high byte as Java's `writeBytesToFixLenPart`. | ||
| fn write_bytes_to_fix_len_part(&mut self, pos: usize, bytes: &[u8]) { |
There was a problem hiding this comment.
The low-level encoding logic here substantially overlaps with FlussArrayWriter in binary_array.rs. Both implement the same wire-format rules for inline bytes, aligned variable-length storage, offset/size packing, and non-compact Decimal/Timestamp encoding, but through separate code paths.
On the Java side, the common encoding logic is centralized in AbstractBinaryWriter and reused by both AlignedRowWriter and BinaryArrayWriter, while writer-specific field addressing remains separate.
Would it make sense to extract a small internal encoding/buffer helper in Rust so that AlignedRowWriter and FlussArrayWriter can share these rules without requiring the same API or trait? This would reduce duplication and help prevent format divergence.
There was a problem hiding this comment.
+1, and there is a third copy: PaimonBinaryRowWriter implements the same rules again
fresh-borzoni
left a comment
There was a problem hiding this comment.
@leekeiabstraction Thank you, left some additional comments and question, PTAL
| .as_any() | ||
| .downcast_ref::<PrimitiveArray<Decimal128Type>>() | ||
| .ok_or_else(|| unexpected_array(column, data_type))?; | ||
| let (precision, scale) = (decimal_type.precision(), decimal_type.scale()); |
There was a problem hiding this comment.
DECIMAL takes the scale from the type, not the array, so a Decimal128(10,2) batch on a DECIMAL(10,3) column gives 1.234 instead of 12.34. column_vector.rs uses Decimal::from_arrow_decimal128 for this. Should the source scale come from column.data_type() here too?
| ), | ||
| _ => Err(unexpected_array(column, data_type)), | ||
| }, | ||
| DataType::String(_) | DataType::Char(_) => { |
There was a problem hiding this comment.
Java's collector has no CHAR branch so it ships a null bound, while we ship a real one. A CHAR predicate would prune our batches and never Java's.
Should we file an issue on the Java side?
| /// | ||
| /// # Errors | ||
| /// Returns an error if a named column is absent from the table schema. | ||
| pub fn get_stats_index_mapping(&self) -> Result<Vec<usize>> { |
There was a problem hiding this comment.
Java caches this because it's on the per-batch path, here every call re-splits the property string. Should we cache it on TableInfo?
| for &column_index in mapping { | ||
| let column = batch.column(column_index); | ||
| null_counts.push(column.null_count() as i32); | ||
| bounds.push(column_bounds( |
There was a problem hiding this comment.
Java never fails a write over statistics, MemoryLogRecordsArrowBuilder catches and writes length 0, while here any column_bounds error reaches the caller.
Is it intended?
Summary
AlignedRowWriterfor the min and max rows,record::statisticsto reduce an Arrow batch to per-column bounds and null counts, and thetable.statistics.columnsplumbing that selects the columns.append_arrow_batchpaths are covered by one implementation.Test Plan
cargo test -p fluss-rs --lib: 646 passed, including 16 tests assertingAlignedRowWriteroutput byte by byte against Java's layout and 5 covering the statistics block.cargo fmt --all --checkandcargo clippy -p fluss-rs --all-targetsclean.🤖 AI-assisted changes - reviewed by human developer