This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-mosaic.git
The following commit(s) were added to refs/heads/main by this push:
new 400f2c4 perf(core): compact integer writer buffers and stream
encoding (#98)
400f2c4 is described below
commit 400f2c4e228c5c2d3a89ed5248a0d11930fa29d7
Author: jianguotian <[email protected]>
AuthorDate: Mon Oct 12 16:28:58 2026 +0800
perf(core): compact integer writer buffers and stream encoding (#98)
---
core/INTEGER_WRITER_MEMORY.md | 127 +++++++
core/examples/integer_writer_memory.rs | 140 +++++++
core/src/bucket_writer.rs | 672 +++++++++++++++++++++++----------
core/src/integer_buffer.rs | 287 ++++++++++++++
core/src/lib.rs | 1 +
core/src/writer.rs | 210 +++++++++++
core/src/writer_integer_tests.rs | 426 +++++++++++++++++++++
7 files changed, 1660 insertions(+), 203 deletions(-)
diff --git a/core/INTEGER_WRITER_MEMORY.md b/core/INTEGER_WRITER_MEMORY.md
new file mode 100644
index 0000000..6fa0816
--- /dev/null
+++ b/core/INTEGER_WRITER_MEMORY.md
@@ -0,0 +1,127 @@
+<!--
+ ~ 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.
+-->
+
+# Integer writer memory
+
+The writer compacts `Int16`, `Int32`, and `Int64` value buffers automatically.
+This includes integer children of lists/maps and their `Int32` length columns.
+`Int8` already occupies one byte. Floating-point, date/time, timestamp,
decimal,
+string and binary caches retain their existing representation. Input Arrow
+schemas, `WriterOptions`, the C/JNI APIs and on-disk column types are
unchanged.
+
+## Representation and range changes
+
+Each integer block contains at most 4,096 non-null values. A block uses 1, 2,
4,
+or 8 bytes per value, capped at the logical type width. A block-sized OR
reduction
+selects the required width before a batch copy. Positive values such as widened
+UInt16 data therefore occupy two bytes even in an Int32 column. Negative values
+retain their logical two's-complement width.
+
+A later outlier widens only the current block, copying backwards in the same
+vector. Completed blocks stay compact. Range checking and promotion never scan
+or expand the whole buffered column. A new block can narrow again. Reset drops
+block payload allocations; the allocator may retain freed pages in process RSS.
+
+Dictionary keys, constant tracking, statistics, dictionary/plain selection,
+row-group accounting and page-layout selection still use logical values and
+logical byte sizes. Saving memory does not cause the same row group to
accumulate
+more rows. Existing large row-group settings remain effective. Constant columns
+keep dictionary tracking pending; the first differing value activates the
+dictionary with the constant prefix as its first key. Entry limits and reset
+retain the same tracking lifecycle as non-integer columns.
+
+## Encoding and compression
+
+Integer values are expanded into a reusable 16 KiB scratch buffer at encoding
+time. Nullable append gathers at most 4,096 i64 values (32 KiB). Dictionary
indices
+are also emitted incrementally. Buckets containing compact integer columns use
+this sink-based encoder rather than assembling the full bucket/page first.
+Uncompressed integer buckets use an additional 16 KiB output buffer to coalesce
+small writes before invoking file or C/JNI output callbacks. Pending bytes are
+drained explicitly at the end of the bucket; errors or unwinding discard them
+without retrying a potentially partial sink write.
+
+Encoded units up to 1 MiB use bounded staging and the original one-shot Zstd
+compressor to avoid streaming overhead on small pages. Larger units stream to
+one Zstd frame per original bucket/page, with the exact uncompressed size
pledged
+to Zstd. Scratch chunks do not create new pages, frames or row groups.
+
+Paged files place slot lengths before their compressed data, so the writer
still
+retains compressed column slots until it can emit the directory. This change
+does not bound all writer memory: row-group caches, null bitmaps, dictionaries,
+Zstd context/window memory, compressed slots and the caller's Arrow input
remain.
+It requires neither seekable output callbacks nor temporary files. The public
+`BucketWriter::finish`/`finish_paged` convenience methods still materialize
their
+returned data; `MosaicWriter` uses the incremental path for integer buckets.
+
+Streaming Zstd output can differ bytewise from one-shot compression for some
+inputs. Schema, decoded values, encoding choices and compression-unit
boundaries
+remain unchanged; file-size validation is part of the test matrix.
+
+## Validation and reproduction
+
+Unit tests cover width changes within/across blocks, signed extremes, nullable
+and sliced Arrow arrays, ALL_NULL/CONST/DICT/PLAIN transitions, mixed columns,
+logical row-group accounting, statistics, reset, bounded expansion, sink
errors,
+and Zstd block boundaries. A wide-buffer reference path exercises the previous
+writer/compression behavior. The comparison matrix checks input-value round
trips,
+row-group/schema/statistics equality and no file-size increase in that corpus.
+
+Run the synthetic example on the base and candidate revisions using independent
+build directories and separate processes:
+
+```sh
+cargo test --locked --workspace
+cargo clippy --all-targets --workspace -- -D warnings
+cargo build --release -p paimon-mosaic-core --example integer_writer_memory
+/usr/bin/time -v target/release/examples/integer_writer_memory 250000 20 paged
unsigned output.mosaic
+```
+
+Use `monolithic` for the other bucket layout, `negative` for full-range signed
+random data, and `outlier` for a late full-range value. Copy the same example
to
+the baseline revision before building. Compare output hashes, kernel peak RSS,
+phase RSS/PSS snapshots, CPU time and append/close timings. Run each pair in
both
+orders; do not compile or run the two workloads concurrently.
+
+ARM64 Linux results against official `main` at `c082354` (2026-10-12, Rust
+1.97.1, Neoverse-N2; 5 repetitions per version/scenario, separate build
directories,
+alternating version order, 20 batches of 250,000 rows, three integer columns,
+Zstd level 1, dictionary disabled). These are fresh measurements of this change
+on that upstream baseline. Medians:
+
+| Layout | Input | Peak RSS before → after (MiB) | Append total before → after
(ms) | Close before → after (ms) |
+| --- | --- | ---: | ---: | ---: |
+| Paged | Widened unsigned | 232.3 → 79.9 | 47.4 → 14.1 | 271.4 → 231.7 |
+| Paged | Full-range signed | 276.7 → 142.6 | 46.7 → 23.4 | 136.8 → 75.4 |
+| Paged | Late outlier | 232.3 → 79.9 | 47.3 → 14.7 | 270.3 → 232.9 |
+| Monolithic | Widened unsigned | 180.7 → 42.3 | 46.8 → 14.2 | 233.1 → 219.4 |
+| Monolithic | Full-range signed | 210.1 → 75.7 | 47.2 → 23.4 | 90.4 → 50.9 |
+| Monolithic | Late outlier | 180.7 → 42.3 | 46.6 → 13.8 | 231.8 → 218.6 |
+
+All six scenarios produced byte-identical files across all baseline/candidate
+runs. All 90 million integer values in the six candidate files were also
decoded
+and checked against the generated input. Native ARM64 core tests passed (183),
+and the release FFI library built successfully. An additional review harness
+performed 1,728 differential comparisons against the original `c082354` bucket
+encoder, including nested List/Map columns, nulls, slices, dictionary limits
and
+reset; this harness is separate from the committed test suite.
+
+Figures are medians of synthetic runs, not measured savings for a particular
+Android application. Source Arrow arrays are still widened by the caller;
+avoiding those arrays requires a separate input API/client integration change.
diff --git a/core/examples/integer_writer_memory.rs
b/core/examples/integer_writer_memory.rs
new file mode 100644
index 0000000..a9c39d6
--- /dev/null
+++ b/core/examples/integer_writer_memory.rs
@@ -0,0 +1,140 @@
+// 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.
+
+//! Reproducible write-only workload for comparing integer cache memory.
+//! Run the same example on the base and candidate revisions in separate
processes:
+//! cargo run --release -p paimon-mosaic-core --example integer_writer_memory
-- 250000 20 paged unsigned output.mosaic
+//! Use /usr/bin/time -v on Linux for peak RSS and CPU; this example also
reports
+//! RSS/PSS snapshots at append/close boundaries. Data is synthetic, not a
vehicle trace.
+
+use arrow_array::{ArrayRef, Int16Array, Int32Array, Int64Array, RecordBatch};
+use arrow_schema::{DataType, Field, Schema};
+use paimon_mosaic_core::writer::{FileSink, MosaicWriter, OutputFile,
WriterOptions};
+use std::sync::Arc;
+use std::time::Instant;
+
+fn memory(stage: &str) {
+ let status =
std::fs::read_to_string("/proc/self/status").unwrap_or_default();
+ let smaps =
std::fs::read_to_string("/proc/self/smaps_rollup").unwrap_or_default();
+ let field = |text: &str, name: &str| -> u64 {
+ text.lines()
+ .find_map(|line| line.strip_prefix(name))
+ .and_then(|v| v.split_whitespace().next())
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0)
+ };
+ println!(
+ "stage={stage} rss_kib={} peak_rss_kib={} pss_kib={} anon_kib={}",
+ field(&status, "VmRSS:"),
+ field(&status, "VmHWM:"),
+ field(&smaps, "Pss:"),
+ field(&status, "RssAnon:")
+ );
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let args: Vec<_> = std::env::args().collect();
+ if args.len() != 6 {
+ return Err(
+ "expected: ROWS_PER_BATCH BATCHES paged|monolithic
unsigned|negative|outlier OUTPUT"
+ .into(),
+ );
+ }
+ let rows: usize = args[1].parse()?;
+ let batches: usize = args[2].parse()?;
+ let paged = match args[3].as_str() {
+ "paged" => true,
+ "monolithic" => false,
+ _ => return Err("invalid layout".into()),
+ };
+ let pattern = args[4].as_str();
+ if !matches!(pattern, "unsigned" | "negative" | "outlier") {
+ return Err("invalid pattern".into());
+ }
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("u8_as_i16", DataType::Int16, false),
+ Field::new("u16_as_i32", DataType::Int32, false),
+ Field::new("u32_as_i64", DataType::Int64, false),
+ ]));
+ let options = WriterOptions {
+ num_buckets: 1,
+ row_group_max_size: u64::MAX,
+ zstd_level: 1,
+ max_dict_entries: 0,
+ page_size_threshold: if paged { 1 } else { usize::MAX },
+ ..Default::default()
+ };
+ let sink = FileSink::create(std::path::Path::new(&args[5]))?;
+ let mut writer = MosaicWriter::new(sink, &schema, options)?;
+ let mut append_ns = 0u128;
+ let mut max_append_ns = 0u128;
+ memory("open");
+ for batch in 0..batches {
+ let mut v16 = Vec::with_capacity(rows);
+ let mut v32 = Vec::with_capacity(rows);
+ let mut v64 = Vec::with_capacity(rows);
+ for row in 0..rows {
+ let mut x = (row + batch * rows) as u64;
+ x ^= x >> 30;
+ x = x.wrapping_mul(0xbf58476d1ce4e5b9);
+ x ^= x >> 27;
+ x = x.wrapping_mul(0x94d049bb133111eb);
+ x ^= x >> 31;
+ if pattern == "negative"
+ || (pattern == "outlier" && batch + 1 == batches && row + 1 ==
rows)
+ {
+ v16.push(x as i16);
+ v32.push(x as i32);
+ v64.push(x as i64);
+ } else {
+ v16.push((x as u8) as i16);
+ v32.push((x as u16) as i32);
+ v64.push((x as u32) as i64);
+ }
+ }
+ let arrays: Vec<ArrayRef> = vec![
+ Arc::new(Int16Array::from(v16)),
+ Arc::new(Int32Array::from(v32)),
+ Arc::new(Int64Array::from(v64)),
+ ];
+ let input = RecordBatch::try_new(schema.clone(), arrays)?;
+ let start = Instant::now();
+ writer.write_batch(&input)?;
+ let elapsed = start.elapsed().as_nanos();
+ append_ns += elapsed;
+ max_append_ns = max_append_ns.max(elapsed);
+ if batch + 1 == batches {
+ memory("last_append");
+ }
+ }
+ memory("before_close");
+ let start = Instant::now();
+ writer.close()?;
+ let close_ns = start.elapsed().as_nanos();
+ memory("after_close");
+ println!(
+ "rows={} append_ns={} max_append_ns={} close_ns={} file_bytes={}",
+ rows * batches,
+ append_ns,
+ max_append_ns,
+ close_ns,
+ writer.output().pos()
+ );
+ drop(writer);
+ memory("after_free");
+ Ok(())
+}
diff --git a/core/src/bucket_writer.rs b/core/src/bucket_writer.rs
index 9828c90..f1da9e2 100644
--- a/core/src/bucket_writer.rs
+++ b/core/src/bucket_writer.rs
@@ -16,18 +16,27 @@
// under the License.
use std::collections::HashMap;
-use std::io;
+use std::io::{self, Write};
use std::sync::Arc;
use arrow_array::*;
use arrow_buffer::{BooleanBuffer, Buffer, NullBuffer, OffsetBuffer,
ScalarBuffer};
use arrow_schema::{DataType, Field};
+use crate::integer_buffer::IntegerBuffer;
use crate::spec::*;
use crate::types;
use crate::values;
use crate::varint;
+pub(crate) struct BucketEncodingPlan {
+ pub(crate) encodings: Vec<u8>,
+ pub(crate) has_nulls: Vec<bool>,
+ // The original finish_paged payload sizes, excluding const metadata and
+ // the two-byte page prefix. Keep these for the existing layout heuristic.
+ pub(crate) page_sizes: Vec<Option<usize>>,
+}
+
pub struct PagedBucketOutput {
pub encodings: Vec<u8>,
pub has_nulls: Vec<bool>,
@@ -68,6 +77,7 @@ pub struct BucketWriter {
null_bitmaps: Vec<Vec<u8>>,
value_buffers: Vec<Vec<u8>>,
+ integer_buffers: Vec<Option<Box<IntegerBuffer>>>,
non_null_counts: Vec<usize>,
const_tracking: Vec<bool>,
@@ -94,13 +104,22 @@ impl BucketWriter {
let (physical_types, children) = expand_col_types(col_types);
let total_columns = physical_types.len();
let fixed_widths: Vec<i32> =
physical_types.iter().map(types::fixed_width).collect();
-
+ let integer_buffers: Vec<_> = physical_types
+ .iter()
+ .map(|dt| match dt {
+ DataType::Int16 => Some(Box::new(IntegerBuffer::new(2))),
+ DataType::Int32 => Some(Box::new(IntegerBuffer::new(4))),
+ DataType::Int64 => Some(Box::new(IntegerBuffer::new(8))),
+ _ => None,
+ })
+ .collect();
BucketWriter {
num_primary,
total_columns,
fixed_widths,
null_bitmaps: vec![vec![0u8; 128]; total_columns],
- value_buffers: vec![Vec::with_capacity(1024); total_columns],
+ value_buffers: vec![Vec::new(); total_columns],
+ integer_buffers,
non_null_counts: vec![0; total_columns],
const_tracking: vec![true; total_columns],
first_value_len: vec![0; total_columns],
@@ -325,6 +344,15 @@ impl BucketWriter {
return Ok(0);
}
+ if self.integer_buffers[col].is_some() {
+ return match &typed {
+ TypedArrayRef::Int16(a) => self.append_integer_column(col, a,
start_row),
+ TypedArrayRef::Int32(a) => self.append_integer_column(col, a,
start_row),
+ TypedArrayRef::Int64(a) => self.append_integer_column(col, a,
start_row),
+ _ => unreachable!("integer buffer requires an integer array"),
+ };
+ }
+
if null_count == 0 {
if let Some(col_size) =
self.append_no_null_batch(col, &typed, start_row,
num_new_rows)?
@@ -360,6 +388,100 @@ impl BucketWriter {
Ok(col_size)
}
+ fn append_integer_column<T: arrow_array::types::ArrowPrimitiveType>(
+ &mut self,
+ col: usize,
+ array: &PrimitiveArray<T>,
+ start_row: usize,
+ ) -> io::Result<usize>
+ where
+ T::Native: Into<i64>,
+ {
+ if array.null_count() == 0 {
+ return Ok(self.append_integer_values(col, array.values()));
+ }
+ let nulls = array.nulls().expect("nullable integer batch");
+ append_null_bitmap(&mut self.null_bitmaps[col], start_row, nulls);
+ let mut scratch = [0i64; 4096];
+ let mut used = 0;
+ let mut size = 0;
+ for row in nulls.valid_indices() {
+ scratch[used] = array.value(row).into();
+ used += 1;
+ if used == scratch.len() {
+ size += self.append_integer_values(col, &scratch);
+ used = 0;
+ }
+ }
+ size += self.append_integer_values(col, &scratch[..used]);
+ Ok(size)
+ }
+
+ fn append_integer_values<T: Copy + Into<i64>>(&mut self, col: usize,
values: &[T]) -> usize {
+ if values.is_empty() {
+ return 0;
+ }
+ let width = self.fixed_widths[col] as usize;
+ let mask = u64::MAX >> (64 - width * 8);
+ let mut dict_start = 0;
+ if self.const_tracking[col] {
+ let first = self.integer_buffers[col]
+ .as_ref()
+ .unwrap()
+ .keys()
+ .next()
+ .unwrap_or(values[0].into() as u64 & mask);
+ self.first_value_len[col] = width;
+ if let Some(first_different) = values
+ .iter()
+ .position(|&v| (v.into() as u64 & mask) != first)
+ {
+ // Preserve lazy dictionary activation: seed the constant
prefix
+ // once, then track values starting at the first different key.
+ debug_assert_eq!(self.dict_tracking[col],
DictTracking::Pending);
+ self.const_tracking[col] = false;
+ let dict =
self.long_dict_maps[col].get_or_insert_with(HashMap::new);
+ dict.clear();
+ dict.insert(first, 0);
+ self.dict_tracking[col] = DictTracking::Active;
+ dict_start = first_different;
+ } else {
+ dict_start = values.len();
+ }
+ }
+ if self.dict_tracking[col] == DictTracking::Active {
+ let dict = self.long_dict_maps[col].as_mut().unwrap();
+ if dict.len() <= self.max_dict_entries {
+ for &value in &values[dict_start..] {
+ let key = value.into() as u64 & mask;
+ let next = dict.len();
+ dict.entry(key).or_insert(next);
+ if dict.len() > self.max_dict_entries {
+ break;
+ }
+ }
+ }
+ if dict.len() > self.max_dict_entries {
+ self.long_dict_maps[col] = None;
+ self.dict_tracking[col] = DictTracking::Disabled;
+ }
+ }
+ self.integer_buffers[col]
+ .as_mut()
+ .unwrap()
+ .extend_from_slice(values);
+ self.non_null_counts[col] += values.len();
+ // Row-group boundaries and dictionary decisions use the logical width,
+ // never the compact allocation size.
+ values.len() * width
+ }
+
+ fn logical_value_size(&self, col: usize) -> usize {
+ self.integer_buffers[col]
+ .as_ref()
+ .map_or(self.value_buffers[col].len(), |b| b.logical_len())
+ }
+
fn append_no_null_batch(
&mut self,
col: usize,
@@ -617,7 +739,7 @@ impl BucketWriter {
let dict_size = self.get_dict_size(i);
if dict_size >= 2
&& dict_size <= self.max_dict_entries
- && self.dict_encoded_size(i) < self.value_buffers[i].len()
+ && self.dict_encoded_size(i) < self.logical_value_size(i)
{
encodings[i] = ENCODING_DICT;
} else {
@@ -629,104 +751,142 @@ impl BucketWriter {
(encodings, has_nulls)
}
- #[allow(clippy::needless_range_loop)]
- pub fn finish(&self) -> Vec<u8> {
+ /// Encode into a caller-owned sink without materializing the whole bucket.
+ pub(crate) fn write_encoded(&self, out: &mut impl Write) -> io::Result<()>
{
if self.num_rows == 0 {
- return Vec::new();
+ return Ok(());
}
-
- let (encodings, has_nulls) = self.compute_encodings();
-
- let mut out = Vec::new();
-
- // Header: only written when ARRAY columns exist (backward compatible
with v1)
+ let plan = self.encoding_plan();
if !self.children.is_empty() {
- varint::encode(&mut out, self.num_primary as u32);
- varint::encode(&mut out, self.children.len() as u32);
+ write_varint(out, self.num_primary as u32)?;
+ write_varint(out, self.children.len() as u32)?;
for child in &self.children {
- varint::encode(&mut out, child.num_elements as u32);
+ write_varint(out, child.num_elements as u32)?;
}
}
-
- // Encoding flags: 2 bits per column
- let encoding_flags_bytes = (self.total_columns * 2).div_ceil(8);
- let ef_start = out.len();
- out.resize(ef_start + encoding_flags_bytes, 0);
- for i in 0..self.total_columns {
- let byte_idx = (i * 2) / 8;
- let bit_idx = (i * 2) % 8;
- out[ef_start + byte_idx] |= encodings[i] << bit_idx;
+ let mut flags = vec![0; (self.total_columns * 2).div_ceil(8)];
+ for (i, &encoding) in plan.encodings.iter().enumerate() {
+ flags[i / 4] |= encoding << ((i % 4) * 2);
}
-
- // Has-nulls flags: 1 bit per column
- let has_nulls_bytes = self.total_columns.div_ceil(8);
- let hn_start = out.len();
- out.resize(hn_start + has_nulls_bytes, 0);
- for i in 0..self.total_columns {
- if has_nulls[i] {
- out[hn_start + i / 8] |= 1 << (i % 8);
+ out.write_all(&flags)?;
+ flags.clear();
+ flags.resize(self.total_columns.div_ceil(8), 0);
+ for (i, &has_nulls) in plan.has_nulls.iter().enumerate() {
+ if has_nulls {
+ flags[i / 8] |= 1 << (i % 8);
}
}
-
- // CONST metadata
- for i in 0..self.total_columns {
- if encodings[i] == ENCODING_CONST {
- let len = self.first_value_len[i];
- out.extend_from_slice(&self.value_buffers[i][..len]);
+ out.write_all(&flags)?;
+ for (col, &encoding) in plan.encodings.iter().enumerate() {
+ if encoding == ENCODING_CONST {
+ self.write_const(col, out)?;
}
}
-
- // Dict metadata
- for i in 0..self.total_columns {
- if encodings[i] == ENCODING_DICT {
- if let Some(ref dict) = self.long_dict_maps[i] {
- let num_entries = dict.len();
- varint::encode(&mut out, num_entries as u32);
- let w = self.fixed_widths[i];
- let mut keys = vec![0u64; num_entries];
- for (&key, &idx) in dict {
- keys[idx] = key;
- }
- for key in &keys {
- write_fixed_key_to_vec(&mut out, *key, w);
- }
- } else if let Some(ref dict) = self.byte_dict_maps[i] {
- let num_entries = dict.len();
- varint::encode(&mut out, num_entries as u32);
- let mut keys: Vec<(&Vec<u8>, &usize)> =
dict.iter().collect();
- keys.sort_by_key(|&(_, idx)| *idx);
- for (key, _) in keys {
- out.extend_from_slice(key);
- }
- }
+ for (col, &encoding) in plan.encodings.iter().enumerate() {
+ if encoding == ENCODING_DICT {
+ self.write_dictionary(col, out)?;
}
}
-
- // Null bitmaps (per-column row count)
- for i in 0..self.total_columns {
- if has_nulls[i] && encodings[i] != ENCODING_ALL_NULL {
- let nbytes = self.col_num_rows(i).div_ceil(8);
- out.extend_from_slice(&self.null_bitmaps[i][..nbytes]);
+ for (col, &has_nulls) in plan.has_nulls.iter().enumerate() {
+ if has_nulls && plan.encodings[col] != ENCODING_ALL_NULL {
+ self.write_nulls(col, out)?;
}
}
+ for (col, &encoding) in plan.encodings.iter().enumerate() {
+ self.write_values(col, encoding, out)?;
+ }
+ Ok(())
+ }
- // Column data
- for i in 0..self.total_columns {
- if encodings[i] == ENCODING_PLAIN {
- out.extend_from_slice(&self.value_buffers[i]);
- } else if encodings[i] == ENCODING_DICT {
- let bw = bit_width(self.get_dict_size(i));
- let packed_bytes = (self.non_null_counts[i] * bw).div_ceil(8);
- let data_start = out.len();
- out.resize(data_start + packed_bytes, 0);
- self.write_dict_bit_packed(i, &mut out, data_start);
+ pub fn finish(&self) -> Vec<u8> {
+ let mut out = Vec::new();
+ self.write_encoded(&mut out)
+ .expect("writing into a Vec cannot fail");
+ out
+ }
+
+ pub(crate) fn has_integer_buffers(&self) -> bool {
+ self.integer_buffers.iter().any(Option::is_some)
+ }
+
+ #[cfg(test)]
+ pub(crate) fn use_wide_integer_buffers(&mut self) {
+ assert_eq!(self.num_rows, 0);
+ for buffer in &mut self.integer_buffers {
+ *buffer = None;
+ }
+ }
+
+ pub(crate) fn encoding_plan(&self) -> BucketEncodingPlan {
+ let (encodings, has_nulls) = self.compute_encodings();
+ let page_sizes = (0..self.total_columns)
+ .map(|col| {
+ let nulls = if has_nulls[col] {
+ self.col_num_rows(col).div_ceil(8)
+ } else {
+ 0
+ };
+ match encodings[col] {
+ ENCODING_ALL_NULL => None,
+ ENCODING_CONST => has_nulls[col].then_some(nulls),
+ ENCODING_DICT => Some(nulls + self.dict_encoded_size(col)),
+ ENCODING_PLAIN => Some(nulls +
self.logical_value_size(col)),
+ _ => unreachable!(),
+ }
+ })
+ .collect();
+ BucketEncodingPlan {
+ encodings,
+ has_nulls,
+ page_sizes,
+ }
+ }
+
+ pub(crate) fn page_size(&self, plan: &BucketEncodingPlan, col: usize) ->
usize {
+ if plan.encodings[col] == ENCODING_ALL_NULL {
+ return 0;
+ }
+ 2 + plan.page_sizes[col].unwrap_or(0)
+ + if plan.encodings[col] == ENCODING_CONST {
+ self.first_value_len[col]
+ } else {
+ 0
}
+ }
+
+ pub(crate) fn write_page(
+ &self,
+ plan: &BucketEncodingPlan,
+ col: usize,
+ out: &mut impl Write,
+ ) -> io::Result<()> {
+ let encoding = plan.encodings[col];
+ if encoding == ENCODING_ALL_NULL {
+ return Ok(());
}
+ out.write_all(&[encoding, u8::from(plan.has_nulls[col])])?;
+ if encoding == ENCODING_CONST {
+ self.write_const(col, out)?;
+ }
+ self.write_page_data(plan, col, out)
+ }
- out
+ fn write_page_data(
+ &self,
+ plan: &BucketEncodingPlan,
+ col: usize,
+ out: &mut impl Write,
+ ) -> io::Result<()> {
+ let encoding = plan.encodings[col];
+ if encoding == ENCODING_DICT {
+ self.write_dictionary(col, out)?;
+ }
+ if plan.has_nulls[col] {
+ self.write_nulls(col, out)?;
+ }
+ self.write_values(col, encoding, out)
}
- #[allow(clippy::needless_range_loop)]
pub fn finish_paged(&self) -> PagedBucketOutput {
if self.num_rows == 0 {
return PagedBucketOutput {
@@ -738,73 +898,24 @@ impl BucketWriter {
children: self.children.clone(),
};
}
-
- let (encodings, has_nulls) = self.compute_encodings();
-
+ let plan = self.encoding_plan();
let mut const_data = vec![Vec::new(); self.total_columns];
- let mut column_pages: Vec<Option<Vec<u8>>> = vec![None;
self.total_columns];
-
- for i in 0..self.total_columns {
- let col_rows = self.col_num_rows(i);
- let null_bitmap_bytes = col_rows.div_ceil(8);
-
- match encodings[i] {
- ENCODING_ALL_NULL => {}
- ENCODING_CONST => {
- let len = self.first_value_len[i];
- const_data[i] = self.value_buffers[i][..len].to_vec();
- if has_nulls[i] {
- column_pages[i] =
Some(self.null_bitmaps[i][..null_bitmap_bytes].to_vec());
- }
- }
- ENCODING_DICT => {
- let mut page = Vec::new();
- if let Some(ref dict) = self.long_dict_maps[i] {
- let num_entries = dict.len();
- varint::encode(&mut page, num_entries as u32);
- let w = self.fixed_widths[i];
- let mut keys = vec![0u64; num_entries];
- for (&key, &idx) in dict {
- keys[idx] = key;
- }
- for key in &keys {
- write_fixed_key_to_vec(&mut page, *key, w);
- }
- } else if let Some(ref dict) = self.byte_dict_maps[i] {
- let num_entries = dict.len();
- varint::encode(&mut page, num_entries as u32);
- let mut keys: Vec<(&Vec<u8>, &usize)> =
dict.iter().collect();
- keys.sort_by_key(|&(_, idx)| *idx);
- for (key, _) in keys {
- page.extend_from_slice(key);
- }
- }
- if has_nulls[i] {
-
page.extend_from_slice(&self.null_bitmaps[i][..null_bitmap_bytes]);
- }
- let dict_size = self.get_dict_size(i);
- let bw = bit_width(dict_size);
- let packed_bytes = (self.non_null_counts[i] *
bw).div_ceil(8);
- let data_start = page.len();
- page.resize(data_start + packed_bytes, 0);
- self.write_dict_bit_packed(i, &mut page, data_start);
- column_pages[i] = Some(page);
- }
- ENCODING_PLAIN => {
- let mut page = Vec::new();
- if has_nulls[i] {
-
page.extend_from_slice(&self.null_bitmaps[i][..null_bitmap_bytes]);
- }
- page.extend_from_slice(&self.value_buffers[i]);
- column_pages[i] = Some(page);
- }
- _ => {}
+ let mut column_pages = vec![None; self.total_columns];
+ for col in 0..self.total_columns {
+ if plan.encodings[col] == ENCODING_CONST {
+ self.write_const(col, &mut const_data[col])
+ .expect("Vec write");
+ }
+ if let Some(size) = plan.page_sizes[col] {
+ let mut page = Vec::with_capacity(size);
+ self.write_page_data(&plan, col, &mut page)
+ .expect("Vec write");
+ column_pages[col] = Some(page);
}
}
-
PagedBucketOutput {
- encodings,
- has_nulls,
+ encodings: plan.encodings,
+ has_nulls: plan.has_nulls,
const_data,
column_pages,
num_primary: self.num_primary,
@@ -812,10 +923,110 @@ impl BucketWriter {
}
}
+ fn write_const(&self, col: usize, out: &mut impl Write) -> io::Result<()> {
+ if let Some(buffer) = &self.integer_buffers[col] {
+ let key = buffer.keys().next().expect("non-null constant");
+ out.write_all(&key.to_be_bytes()[8 - self.fixed_widths[col] as
usize..])
+ } else {
+
out.write_all(&self.value_buffers[col][..self.first_value_len[col]])
+ }
+ }
+
+ fn write_dictionary(&self, col: usize, out: &mut impl Write) ->
io::Result<()> {
+ if let Some(dict) = &self.long_dict_maps[col] {
+ write_varint(out, dict.len() as u32)?;
+ let mut keys = vec![0; dict.len()];
+ for (&key, &index) in dict {
+ keys[index] = key;
+ }
+ for key in keys {
+ out.write_all(&key.to_be_bytes()[8 - self.fixed_widths[col] as
usize..])?;
+ }
+ } else if let Some(dict) = &self.byte_dict_maps[col] {
+ write_varint(out, dict.len() as u32)?;
+ let mut keys: Vec<_> = dict.iter().collect();
+ keys.sort_unstable_by_key(|(_, index)| **index);
+ for (key, _) in keys {
+ out.write_all(key)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn write_nulls(&self, col: usize, out: &mut impl Write) -> io::Result<()> {
+
out.write_all(&self.null_bitmaps[col][..self.col_num_rows(col).div_ceil(8)])
+ }
+
+ fn write_values(&self, col: usize, encoding: u8, out: &mut impl Write) ->
io::Result<()> {
+ match encoding {
+ ENCODING_PLAIN => match &self.integer_buffers[col] {
+ Some(buffer) => buffer.write_to(out),
+ None => out.write_all(&self.value_buffers[col]),
+ },
+ ENCODING_DICT => self.write_dict_indices(col, out),
+ _ => Ok(()),
+ }
+ }
+
+ fn write_dict_indices(&self, col: usize, out: &mut impl Write) ->
io::Result<()> {
+ let width = self.fixed_widths[col];
+ let bits = bit_width(self.get_dict_size(col));
+ let mut integer_keys = self.integer_buffers[col].as_ref().map(|b|
b.keys());
+ let mut pos = 0;
+ let mut scratch = [0; 16 * 1024];
+ let mut used = 0;
+ let mut pending = 0u128;
+ let mut pending_bits = 0;
+ // Value buffers contain only non-null values, in row order.
+ for _ in 0..self.non_null_counts[col] {
+ let index = if let Some(dict) = &self.long_dict_maps[col] {
+ let key = if let Some(keys) = &mut integer_keys {
+ keys.next().expect("non-null integer key")
+ } else {
+ let key =
values::extract_fixed_key(&self.value_buffers[col], pos, width);
+ pos += width as usize;
+ key
+ };
+ dict[&key]
+ } else {
+ let len = if width > 0 {
+ width as usize
+ } else {
+ let len = varint::decode(&self.value_buffers[col], &mut
pos.clone())
+ .expect("internal varint in value buffer");
+ varint::encoded_size(len) + len as usize
+ };
+ let key = &self.value_buffers[col][pos..pos + len];
+ pos += len;
+ self.byte_dict_maps[col].as_ref().unwrap()[key]
+ };
+ pending |= (index as u128) << pending_bits;
+ pending_bits += bits;
+ while pending_bits >= 8 {
+ scratch[used] = pending as u8;
+ used += 1;
+ pending >>= 8;
+ pending_bits -= 8;
+ if used == scratch.len() {
+ out.write_all(&scratch)?;
+ used = 0;
+ }
+ }
+ }
+ if pending_bits > 0 {
+ scratch[used] = pending as u8;
+ used += 1;
+ }
+ out.write_all(&scratch[..used])
+ }
+
pub fn reset(&mut self) {
for i in 0..self.total_columns {
self.null_bitmaps[i].fill(0);
self.value_buffers[i].clear();
+ if let Some(buffer) = &mut self.integer_buffers[i] {
+ buffer.clear();
+ }
self.non_null_counts[i] = 0;
self.const_tracking[i] = true;
self.first_value_len[i] = 0;
@@ -848,42 +1059,6 @@ impl BucketWriter {
0
}
- fn write_dict_bit_packed(&self, col: usize, buf: &mut [u8], data_start:
usize) -> usize {
- let dict_size = self.get_dict_size(col);
- let bw = bit_width(dict_size);
- let w = self.fixed_widths[col];
- let mut bit_offset = 0usize;
- let mut val_pos = 0usize;
-
- for r in 0..self.col_num_rows(col) {
- let is_null = (self.null_bitmaps[col][r / 8] & (1 << (r % 8))) !=
0;
- if !is_null {
- let idx = if let Some(ref dict) = self.long_dict_maps[col] {
- let key =
values::extract_fixed_key(&self.value_buffers[col], val_pos, w);
- val_pos += w as usize;
- *dict.get(&key).unwrap()
- } else if let Some(ref dict) = self.byte_dict_maps[col] {
- let value_len = if w > 0 {
- w as usize
- } else {
- let var_len =
- varint::decode(&self.value_buffers[col], &mut
val_pos.clone())
- .expect("internal varint in value buffer");
- varint::encoded_size(var_len) + var_len as usize
- };
- let slice = &self.value_buffers[col][val_pos..val_pos +
value_len];
- val_pos += value_len;
- *dict.get(slice).unwrap()
- } else {
- unreachable!()
- };
- write_bit_packed(buf, data_start, bit_offset, idx, bw);
- bit_offset += bw;
- }
- }
- bit_offset
- }
-
fn dict_encoded_size(&self, col: usize) -> usize {
if self.dict_tracking[col] != DictTracking::Active {
return usize::MAX;
@@ -937,7 +1112,7 @@ impl BucketWriter {
}
}
ENCODING_PLAIN => {
- size += self.value_buffers[i].len();
+ size += self.logical_value_size(i);
}
_ => {}
}
@@ -1662,33 +1837,20 @@ fn bit_width(num_entries: usize) -> usize {
usize::BITS as usize - (num_entries - 1).leading_zeros() as usize
}
-fn write_bit_packed(buf: &mut [u8], byte_base: usize, bit_offset: usize,
value: usize, bw: usize) {
- if bw == 0 {
- return;
- }
- let start_byte = byte_base + bit_offset / 8;
- let bit_shift = bit_offset % 8;
- let mut bits = (value as u64) << bit_shift;
- let total_bits = bit_shift + bw;
- let num_bytes = total_bits.div_ceil(8);
- for i in 0..num_bytes {
- buf[start_byte + i] |= (bits & 0xFF) as u8;
- bits >>= 8;
- }
-}
-
fn equals_first_value(buf: &[u8], offset: usize, len: usize) -> bool {
buf[..len] == buf[offset..offset + len]
}
-fn write_fixed_key_to_vec(buf: &mut Vec<u8>, key: u64, width: i32) {
- match width {
- 1 => buf.push(key as u8),
- 2 => buf.extend_from_slice(&(key as u16).to_be_bytes()),
- 4 => buf.extend_from_slice(&(key as u32).to_be_bytes()),
- 8 => buf.extend_from_slice(&key.to_be_bytes()),
- _ => {}
+fn write_varint(out: &mut impl Write, mut value: u32) -> io::Result<()> {
+ let mut bytes = [0; 5];
+ let mut len = 0;
+ while value >= 128 {
+ bytes[len] = (value as u8) | 0x80;
+ value >>= 7;
+ len += 1;
}
+ bytes[len] = value as u8;
+ out.write_all(&bytes[..len + 1])
}
#[cfg(test)]
@@ -1820,6 +1982,110 @@ mod tests {
assert_eq!(data[h] & 0x03, ENCODING_CONST);
}
+ #[test]
+ fn test_all_null_wide_columns_do_not_preallocate_value_payloads() {
+ let types = vec![&DataType::Float64; 10_000];
+ let mut writer = BucketWriter::new(&types, 32768, 255);
+ let nulls = Float64Array::new_null(65);
+ let arrays = vec![&nulls as &dyn Array; types.len()];
+ for _ in 0..2 {
+ writer.write_columns(&arrays, &types).unwrap();
+ let allocated: usize =
writer.value_buffers.iter().map(Vec::capacity).sum();
+ assert!(allocated <= 1024, "all-null payload capacity:
{allocated}");
+ writer.reset();
+ }
+ }
+
+ fn compact_integer_test_array(dt: &DataType, values: &[Option<i64>]) ->
ArrayRef {
+ match dt {
+ DataType::Int16 => Arc::new(Int16Array::from(
+ values
+ .iter()
+ .map(|v| v.map(|v| v as i16))
+ .collect::<Vec<_>>(),
+ )),
+ DataType::Int32 => Arc::new(Int32Array::from(
+ values
+ .iter()
+ .map(|v| v.map(|v| v as i32))
+ .collect::<Vec<_>>(),
+ )),
+ DataType::Int64 => Arc::new(Int64Array::from(values.to_vec())),
+ _ => unreachable!(),
+ }
+ }
+
+ #[test]
+ fn test_compact_integer_lazy_dictionary_after_nulls_promotion_and_reset() {
+ for dt in [DataType::Int16, DataType::Int32, DataType::Int64] {
+ let mut compact = BucketWriter::new(&[&dt], 32768, 2);
+ let mut wide = BucketWriter::new(&[&dt], 32768, 2);
+ wide.use_wide_integer_buffers();
+ let append =
+ |compact: &mut BucketWriter, wide: &mut BucketWriter, values:
&[Option<i64>]| {
+ // Slice the source and append at non-byte-aligned
destination offsets.
+ let mut padded = vec![Some(99)];
+ padded.extend_from_slice(values);
+ let array = compact_integer_test_array(&dt,
&padded).slice(1, values.len());
+ assert_eq!(
+ compact.write_columns(&[array.as_ref()],
&[&dt]).unwrap(),
+ wide.write_columns(&[array.as_ref()], &[&dt]).unwrap()
+ );
+ assert_eq!(compact.estimated_raw_size(),
wide.estimated_raw_size());
+ assert_eq!(compact.finish(), wide.finish());
+ assert_eq!(
+ compact.finish_paged().column_pages,
+ wide.finish_paged().column_pages
+ );
+ };
+ append(&mut compact, &mut wide, &[None; 65]);
+ append(&mut compact, &mut wide, &vec![Some(255); 4095]);
+ assert_eq!(compact.dict_tracking[0], DictTracking::Pending);
+ assert!(compact.long_dict_maps[0].is_none());
+ append(
+ &mut compact,
+ &mut wide,
+ &[None, Some(255), Some(-1), None, Some(-1)],
+ );
+ assert_eq!(compact.dict_tracking[0], DictTracking::Active);
+ assert_eq!(compact.long_dict_maps[0].as_ref().unwrap().len(), 2);
+ assert_eq!(compact.compute_encodings().0, vec![ENCODING_DICT]);
+
+ compact.reset();
+ wide.reset();
+ append(&mut compact, &mut wide, &vec![Some(7); 100]);
+ assert_eq!(compact.dict_tracking[0], DictTracking::Pending);
+ assert!(compact.long_dict_maps[0].as_ref().unwrap().is_empty());
+ append(&mut compact, &mut wide, &[Some(8), None, Some(7)]);
+ let dict = compact.long_dict_maps[0].as_ref().unwrap();
+ assert_eq!(dict.len(), 2);
+ assert_eq!(dict[&7], 0);
+ assert_eq!(dict[&8], 1);
+ }
+ }
+
+ #[test]
+ fn test_compact_integer_dictionary_limit_stays_disabled_until_reset() {
+ for limit in [0, 1, 2] {
+ let dt = DataType::Int64;
+ let mut compact = BucketWriter::new(&[&dt], 32768, limit);
+ let prefix = Int64Array::from(vec![-1; 100]);
+ compact.write_columns(&[&prefix], &[&dt]).unwrap();
+ assert!(compact.long_dict_maps[0].is_none());
+ let varied = Int64Array::from(vec![Some(255), None, Some(256),
Some(-1)]);
+ compact.write_columns(&[&varied], &[&dt]).unwrap();
+ assert_eq!(compact.dict_tracking[0], DictTracking::Disabled);
+ assert!(compact.long_dict_maps[0].is_none());
+ compact.write_columns(&[&prefix], &[&dt]).unwrap();
+ assert_eq!(compact.dict_tracking[0], DictTracking::Disabled);
+ assert_eq!(compact.compute_encodings().0, vec![ENCODING_PLAIN]);
+ compact.reset();
+ compact.write_columns(&[&prefix], &[&dt]).unwrap();
+ assert_eq!(compact.dict_tracking[0], DictTracking::Pending);
+ assert_eq!(compact.compute_encodings().0, vec![ENCODING_CONST]);
+ }
+ }
+
#[test]
fn test_const_columns_do_not_build_dictionaries() {
let types = [DataType::Int32, DataType::Utf8];
diff --git a/core/src/integer_buffer.rs b/core/src/integer_buffer.rs
new file mode 100644
index 0000000..795d40f
--- /dev/null
+++ b/core/src/integer_buffer.rs
@@ -0,0 +1,287 @@
+// 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.
+
+use std::io::{self, Write};
+
+// A late outlier may widen this block, never the entire buffered column.
+const BLOCK_VALUES: usize = 4096;
+const OUTPUT_BYTES: usize = 16 * 1024;
+
+#[derive(Default)]
+struct Block {
+ bytes: Vec<u8>,
+ width: usize,
+}
+
+/// In-memory representation only. Keys retain the original integer's
big-endian
+/// bit pattern; negative values use the logical width, positive values may use
+/// 1/2/4 bytes. The file schema, dictionary keys and encoded bytes never
change.
+pub(crate) struct IntegerBuffer {
+ blocks: Vec<Block>,
+ logical_width: usize,
+ len: usize,
+}
+
+impl IntegerBuffer {
+ pub(crate) fn new(logical_width: usize) -> Self {
+ debug_assert!(matches!(logical_width, 2 | 4 | 8));
+ Self {
+ blocks: Vec::new(),
+ logical_width,
+ len: 0,
+ }
+ }
+
+ #[cfg(test)]
+ fn push(&mut self, value: i64) -> u64 {
+ self.extend_from_slice(&[value]);
+ (value as u64) & (u64::MAX >> (64 - self.logical_width * 8))
+ }
+
+ pub(crate) fn extend_from_slice<T: Copy + Into<i64>>(&mut self, mut
values: &[T]) {
+ let mask = u64::MAX >> (64 - self.logical_width * 8);
+ while !values.is_empty() {
+ let count = values.len().min(BLOCK_VALUES - self.len %
BLOCK_VALUES);
+ let input = &values[..count];
+ // A block-sized range check is vectorizable, unlike a width branch
+ // on every value in the copy loop. Negative keys require full
width.
+ let bits = input.iter().fold(0u64, |bits, &v| bits | v.into() as
u64) & mask;
+ let width = if bits <= u8::MAX as u64 {
+ 1
+ } else if bits <= u16::MAX as u64 {
+ 2
+ } else if bits <= u32::MAX as u64 {
+ 4
+ } else {
+ 8
+ };
+ if self.len.is_multiple_of(BLOCK_VALUES) {
+ self.blocks.push(Block {
+ bytes: Vec::new(),
+ width,
+ });
+ }
+ let block = self.blocks.last_mut().unwrap();
+ if width > block.width {
+ let old_width = block.width;
+ let old_count = block.bytes.len() / old_width;
+ block.bytes.resize(old_count * width, 0);
+ for i in (0..old_count).rev() {
+ let key = read_key(&block.bytes[i * old_width..(i + 1) *
old_width]);
+ block.bytes[i * width..(i + 1) * width]
+ .copy_from_slice(&key.to_be_bytes()[8 - width..]);
+ }
+ block.width = width;
+ }
+ let offset = block.bytes.len();
+ block.bytes.resize(offset + count * block.width, 0);
+ let output = &mut block.bytes[offset..];
+ match block.width {
+ 1 => {
+ for (out, &v) in output.iter_mut().zip(input) {
+ *out = v.into() as u8;
+ }
+ }
+ 2 => {
+ for (out, &v) in output.chunks_exact_mut(2).zip(input) {
+ out.copy_from_slice(&(v.into() as u16).to_be_bytes());
+ }
+ }
+ 4 => {
+ for (out, &v) in output.chunks_exact_mut(4).zip(input) {
+ out.copy_from_slice(&(v.into() as u32).to_be_bytes());
+ }
+ }
+ 8 => {
+ for (out, &v) in output.chunks_exact_mut(8).zip(input) {
+ out.copy_from_slice(&v.into().to_be_bytes());
+ }
+ }
+ _ => unreachable!(),
+ }
+ self.len += count;
+ values = &values[count..];
+ }
+ }
+
+ pub(crate) fn keys(&self) -> impl Iterator<Item = u64> + '_ {
+ self.blocks
+ .iter()
+ .flat_map(|block|
block.bytes.chunks_exact(block.width).map(read_key))
+ }
+
+ pub(crate) fn logical_len(&self) -> usize {
+ self.len * self.logical_width
+ }
+
+ pub(crate) fn write_to(&self, out: &mut impl Write) -> io::Result<()> {
+ let mut scratch = [0; OUTPUT_BYTES];
+ for block in &self.blocks {
+ match (block.width, self.logical_width) {
+ (src, dst) if src == dst => {
+ for chunk in block.bytes.chunks(OUTPUT_BYTES) {
+ out.write_all(chunk)?;
+ }
+ }
+ (1, 2) => widen::<1, 2>(&block.bytes, &mut scratch, out)?,
+ (1, 4) => widen::<1, 4>(&block.bytes, &mut scratch, out)?,
+ (1, 8) => widen::<1, 8>(&block.bytes, &mut scratch, out)?,
+ (2, 4) => widen::<2, 4>(&block.bytes, &mut scratch, out)?,
+ (2, 8) => widen::<2, 8>(&block.bytes, &mut scratch, out)?,
+ (4, 8) => widen::<4, 8>(&block.bytes, &mut scratch, out)?,
+ _ => unreachable!(),
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn clear(&mut self) {
+ // Do not retain a wide allocation after an outlier or a large row
group.
+ self.blocks.clear();
+ self.len = 0;
+ }
+
+ #[cfg(test)]
+ pub(crate) fn allocated_bytes(&self) -> usize {
+ self.blocks.capacity() * std::mem::size_of::<Block>()
+ + self
+ .blocks
+ .iter()
+ .map(|b| b.bytes.capacity())
+ .sum::<usize>()
+ }
+}
+
+fn read_key(bytes: &[u8]) -> u64 {
+ match bytes.len() {
+ 1 => bytes[0] as u64,
+ 2 => u16::from_be_bytes(bytes.try_into().unwrap()) as u64,
+ 4 => u32::from_be_bytes(bytes.try_into().unwrap()) as u64,
+ 8 => u64::from_be_bytes(bytes.try_into().unwrap()),
+ _ => unreachable!(),
+ }
+}
+
+fn widen<const SRC: usize, const DST: usize>(
+ bytes: &[u8],
+ scratch: &mut [u8; OUTPUT_BYTES],
+ out: &mut impl Write,
+) -> io::Result<()> {
+ for input in bytes.chunks(OUTPUT_BYTES / DST * SRC) {
+ let output = &mut scratch[..input.len() / SRC * DST];
+ for (src, dst) in
input.chunks_exact(SRC).zip(output.chunks_exact_mut(DST)) {
+ // Clear the prefix: the previous block may have used a wider
+ // compact representation in the same reusable scratch space.
+ dst[..DST - SRC].fill(0);
+ dst[DST - SRC..].copy_from_slice(src);
+ }
+ out.write_all(output)?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn unsigned_ranges_negative_values_and_late_outliers() {
+ for width in [2, 4, 8] {
+ let max = i64::MAX >> (64 - width * 8);
+ let min = -max - 1;
+ let mut buffer = IntegerBuffer::new(width);
+ let mut expected = Vec::new();
+ for value in (0..BLOCK_VALUES * 3)
+ .map(|i| (i % 256) as i64)
+ .chain([max, min, -1, 0, 127, 128, 255, 256])
+ {
+ buffer.push(value);
+ expected.extend_from_slice(&value.to_be_bytes()[8 - width..]);
+ }
+ assert_eq!(buffer.blocks[0].width, 1);
+ assert_eq!(buffer.blocks[1].width, 1);
+ assert_eq!(buffer.blocks.last().unwrap().width, width);
+ let mut actual = Vec::new();
+ buffer.write_to(&mut actual).unwrap();
+ assert_eq!(actual, expected);
+ assert_eq!(buffer.logical_len(), expected.len());
+ }
+ }
+
+ #[test]
+ fn promotions_within_a_block_preserve_previous_values() {
+ let values = [0, 255, 256, 65535, 65536, u32::MAX as i64, i64::MAX,
-1];
+ let mut buffer = IntegerBuffer::new(8);
+ for value in values {
+ buffer.push(value);
+ }
+ assert_eq!(buffer.keys().collect::<Vec<_>>(), values.map(|v| v as
u64));
+ buffer.clear();
+ buffer.push(255);
+ assert_eq!(buffer.blocks[0].width, 1);
+ assert_eq!(buffer.keys().collect::<Vec<_>>(), vec![255]);
+ }
+
+ #[test]
+ fn uint16_values_use_half_the_int32_payload_plus_bounded_metadata() {
+ let mut buffer = IntegerBuffer::new(4);
+ for i in 0..1_000_000 {
+ buffer.push((i % 65536) as i64);
+ }
+ assert_eq!(buffer.logical_len(), 4_000_000);
+ assert!(buffer.allocated_bytes() < 2_100_000);
+ }
+
+ #[test]
+ fn widening_output_is_bounded_and_propagates_sink_errors() {
+ struct Sink {
+ bytes: usize,
+ fail_after: usize,
+ }
+ impl Write for Sink {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ assert!(bytes.len() <= OUTPUT_BYTES);
+ if self.bytes >= self.fail_after {
+ return Err(io::Error::other("sentinel"));
+ }
+ self.bytes += bytes.len();
+ Ok(bytes.len())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ }
+ let mut buffer = IntegerBuffer::new(8);
+ for i in 0..100_003 {
+ buffer.push(i);
+ }
+ let mut sink = Sink {
+ bytes: 0,
+ fail_after: usize::MAX,
+ };
+ buffer.write_to(&mut sink).unwrap();
+ assert_eq!(sink.bytes, 800_024);
+ let mut sink = Sink {
+ bytes: 0,
+ fail_after: OUTPUT_BYTES,
+ };
+ assert_eq!(
+ buffer.write_to(&mut sink).unwrap_err().to_string(),
+ "sentinel"
+ );
+ }
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 3f28500..a240073 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -18,6 +18,7 @@
pub mod bpe;
pub mod bucket_reader;
pub mod bucket_writer;
+mod integer_buffer;
pub mod reader;
pub mod schema;
pub mod spec;
diff --git a/core/src/writer.rs b/core/src/writer.rs
index d503a06..6ce79f4 100644
--- a/core/src/writer.rs
+++ b/core/src/writer.rs
@@ -440,6 +440,20 @@ impl<S: OutputFile> MosaicWriter<S> {
if bw.is_empty() {
continue;
}
+ if bw.has_integer_buffers() {
+ let offset = self.out.pos();
+ let (layout, uncompressed_size) = write_integer_bucket(
+ &mut self.out,
+ bw,
+ self.compression,
+ self.zstd_level,
+ self.page_size_threshold,
+ )?;
+ bucket_offsets[b] = offset;
+ bucket_layouts[b] = layout;
+ actual_uncompressed_sizes[b] = uncompressed_size;
+ continue;
+ }
let est_size = bw.estimated_raw_size();
let try_paged =
self.compression == COMPRESSION_ZSTD && est_size >=
self.page_size_threshold;
@@ -723,6 +737,198 @@ impl<S: OutputFile> MosaicWriter<S> {
}
}
+/// Adapt the existing output API without adding seek or temporary-file
+/// requirements to C, JNI or custom output callbacks.
+struct OutputWriter<'a, S>(&'a mut S);
+
+fn terminal_output_error(error: io::Error) -> io::Error {
+ // OutputFile may fail after writing a prefix. Prevent Write::write_all and
+ // Zstd from retrying Interrupted errors and duplicating that prefix.
+ if error.kind() == io::ErrorKind::Interrupted {
+ io::Error::other(error)
+ } else {
+ error
+ }
+}
+
+impl<S: OutputFile> io::Write for OutputWriter<'_, S> {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ self.0.write(bytes).map_err(terminal_output_error)?;
+ Ok(bytes.len())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.0.flush()
+ }
+}
+
+const INTEGER_OUTPUT_BUFFER_SIZE: usize = 16 * 1024;
+
+// Coalesce flags, dictionary entries and value chunks before calling the sink.
+// Unlike BufWriter, this has no flushing Drop: a failed callback may already
+// have written a prefix, so neither errors nor unwinding may retry its bytes.
+struct BufferedOutputWriter<'a, S> {
+ out: &'a mut S,
+ buffer: Vec<u8>,
+}
+
+impl<'a, S: OutputFile> BufferedOutputWriter<'a, S> {
+ fn new(out: &'a mut S) -> Self {
+ Self {
+ out,
+ buffer: Vec::with_capacity(INTEGER_OUTPUT_BUFFER_SIZE),
+ }
+ }
+
+ fn drain(&mut self) -> io::Result<()> {
+ if !self.buffer.is_empty() {
+ self.out
+ .write(&self.buffer)
+ .map_err(terminal_output_error)?;
+ self.buffer.clear();
+ }
+ Ok(())
+ }
+
+ fn finish(mut self) -> io::Result<()> {
+ // Drain the encoding buffer only; the sink is flushed when the file
closes.
+ self.drain()
+ }
+}
+
+impl<S: OutputFile> io::Write for BufferedOutputWriter<'_, S> {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ if bytes.len() > INTEGER_OUTPUT_BUFFER_SIZE - self.buffer.len() {
+ self.drain()?;
+ }
+ if bytes.len() >= INTEGER_OUTPUT_BUFFER_SIZE {
+ self.out.write(bytes).map_err(terminal_output_error)?;
+ } else {
+ self.buffer.extend_from_slice(bytes);
+ }
+ Ok(bytes.len())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.drain()?;
+ self.out.flush()
+ }
+}
+
+// Small encoded units use the existing one-shot compressor. This bounds the
+// materialized input while avoiding streaming context overhead for tiny pages.
+const INTEGER_ENCODING_BUFFER_LIMIT: usize = 1024 * 1024;
+
+fn compress_integer_unit<W: io::Write>(
+ mut output: W,
+ level: i32,
+ size: usize,
+ encode: impl FnOnce(&mut dyn io::Write) -> io::Result<()>,
+) -> io::Result<W> {
+ check_zstd_block_size(size, "integer encoded unit size")?;
+ if size <= INTEGER_ENCODING_BUFFER_LIMIT {
+ let mut raw = Vec::with_capacity(size);
+ encode(&mut raw)?;
+ if raw.len() != size {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "integer encoded size mismatch",
+ ));
+ }
+ let compressed = zstd::bulk::compress(&raw,
level).map_err(io::Error::other)?;
+ output.write_all(&compressed)?;
+ Ok(output)
+ } else {
+ let mut encoder = zstd::stream::write::Encoder::new(output, level)?;
+ encoder.set_pledged_src_size(Some(size as u64))?;
+ encode(&mut encoder)?;
+ encoder.finish()
+ }
+}
+
+fn write_integer_bucket<S: OutputFile>(
+ out: &mut S,
+ bucket: &BucketWriter,
+ compression: u8,
+ level: i32,
+ page_threshold: usize,
+) -> io::Result<(BucketLayout, usize)> {
+ let raw_size = bucket.estimated_raw_size();
+ let plan = bucket.encoding_plan();
+ let page_count = plan.page_sizes.iter().filter(|p| p.is_some()).count();
+ let page_bytes: usize = plan.page_sizes.iter().flatten().sum();
+ let paged = compression == COMPRESSION_ZSTD
+ && raw_size >= page_threshold
+ && page_bytes
+ .checked_div(page_count)
+ .is_some_and(|avg| avg >= page_threshold);
+ let start = out.pos();
+ if !paged {
+ match compression {
+ COMPRESSION_NONE => {
+ let mut buffered = BufferedOutputWriter::new(out);
+ bucket.write_encoded(&mut buffered)?;
+ buffered.finish()?;
+ }
+ COMPRESSION_ZSTD => {
+ check_zstd_block_size(raw_size, "bucket uncompressed size")?;
+ compress_integer_unit(OutputWriter(out), level, raw_size, |mut
sink| {
+ bucket.write_encoded(&mut sink)
+ })?;
+ }
+ _ => {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "Unsupported compression",
+ ))
+ }
+ }
+ return Ok((
+ BucketLayout::Monolithic {
+ compressed_size: (out.pos() - start) as usize,
+ uncompressed_size: raw_size,
+ },
+ raw_size,
+ ));
+ }
+
+ // The format puts compressed slot lengths before the slots. Retain only
+ // compressed slots here, not full-width values or uncompressed pages.
+ let mut slots = Vec::with_capacity(plan.encodings.len());
+ for col in 0..plan.encodings.len() {
+ let size = bucket.page_size(&plan, col);
+ let mut slot = Vec::new();
+ if size != 0 {
+ check_zstd_block_size(size, "page uncompressed size")?;
+ varint::encode(&mut slot, to_u32(size, "page uncompressed size")?);
+ slot = compress_integer_unit(slot, level, size, |mut sink| {
+ bucket.write_page(&plan, col, &mut sink)
+ })?;
+ }
+ slots.push(slot);
+ }
+ if !bucket.children().is_empty() {
+ out.write(&(bucket.children().len() as u16).to_le_bytes())?;
+ for child in bucket.children() {
+ out.write(&(child.num_elements as u32).to_le_bytes())?;
+ }
+ }
+ for slot in &slots {
+ out.write(&to_u32(slot.len(), "paged slot size")?.to_le_bytes())?;
+ }
+ for slot in &slots {
+ if !slot.is_empty() {
+ out.write(slot)?;
+ }
+ }
+ Ok((
+ BucketLayout::Paged {
+ total_size: (out.pos() - start) as usize,
+ },
+ page_bytes,
+ ))
+}
+
impl<S: OutputFile> Drop for MosaicWriter<S> {
fn drop(&mut self) {
if self.state == WriterState::Open {
@@ -1320,3 +1526,7 @@ mod tests {
assert_eq!(stats[1].column_index, 2);
}
}
+
+#[cfg(test)]
+#[path = "writer_integer_tests.rs"]
+mod integer_tests;
diff --git a/core/src/writer_integer_tests.rs b/core/src/writer_integer_tests.rs
new file mode 100644
index 0000000..2983cc5
--- /dev/null
+++ b/core/src/writer_integer_tests.rs
@@ -0,0 +1,426 @@
+// 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.
+
+use super::*;
+use crate::reader::{InputFile, MosaicReader, ReaderAccess};
+use arrow_schema::{DataType, Field, Schema};
+use std::sync::{Arc, Mutex};
+
+impl InputFile for Bytes {
+ fn read_at(&self, offset: u64, out: &mut [u8]) -> io::Result<()> {
+ let start = offset as usize;
+ out.copy_from_slice(&self.0[start..start + out.len()]);
+ Ok(())
+ }
+}
+
+#[derive(Default)]
+struct Bytes(Vec<u8>);
+impl OutputFile for Bytes {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
+ self.0.extend_from_slice(bytes);
+ Ok(())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+ fn pos(&self) -> u64 {
+ self.0.len() as u64
+ }
+}
+
+#[derive(Default)]
+struct OutputCalls {
+ bytes: Vec<u8>,
+ writes: Vec<usize>,
+ flushes: usize,
+}
+
+struct RecordingOutput {
+ calls: Arc<Mutex<OutputCalls>>,
+ fail_on_write: Option<(usize, io::ErrorKind)>,
+}
+
+impl OutputFile for RecordingOutput {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
+ let mut calls = self.calls.lock().unwrap();
+ calls.writes.push(bytes.len());
+ if let Some((_, kind)) = self
+ .fail_on_write
+ .filter(|(write, _)| *write == calls.writes.len())
+ {
+ // OutputFile callbacks can fail after writing a prefix. Retrying
+ // the buffered bytes would duplicate that prefix in the file.
+ calls.bytes.extend_from_slice(&bytes[..bytes.len() / 2]);
+ return Err(io::Error::new(kind, "buffered output failure"));
+ }
+ calls.bytes.extend_from_slice(bytes);
+ Ok(())
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.calls.lock().unwrap().flushes += 1;
+ Ok(())
+ }
+
+ fn pos(&self) -> u64 {
+ self.calls.lock().unwrap().bytes.len() as u64
+ }
+}
+
+fn dictionary_input(rows: usize) -> RecordBatch {
+ let schema = Arc::new(Schema::new(
+ (0..100)
+ .map(|col| Field::new(format!("c{col:03}"), DataType::Int32,
false))
+ .collect::<Vec<_>>(),
+ ));
+ let values: ArrayRef = Arc::new(Int32Array::from(
+ (0..rows).map(|row| (row % 200) as i32).collect::<Vec<_>>(),
+ ));
+ RecordBatch::try_new(schema, vec![values; 100]).unwrap()
+}
+
+#[test]
+fn uncompressed_integer_output_coalesces_dictionary_writes() {
+ let input = dictionary_input(10_000);
+ let options = || WriterOptions {
+ compression: COMPRESSION_NONE,
+ num_buckets: 1,
+ row_group_max_size: 1,
+ ..Default::default()
+ };
+ let calls = Arc::new(Mutex::new(OutputCalls::default()));
+ let mut compact = MosaicWriter::new(
+ RecordingOutput {
+ calls: calls.clone(),
+ fail_on_write: None,
+ },
+ &input.schema(),
+ options(),
+ )
+ .unwrap();
+ let mut wide = MosaicWriter::new(Bytes::default(), &input.schema(),
options()).unwrap();
+ for bucket in wide.bucket_writers.iter_mut().flatten() {
+ bucket.use_wide_integer_buffers();
+ }
+ compact.write_batch(&input).unwrap();
+ wide.write_batch(&input).unwrap();
+ {
+ let calls = calls.lock().unwrap();
+ assert_eq!(calls.bytes, wide.output().0);
+ assert!(calls.writes.len() <=
calls.bytes.len().div_ceil(INTEGER_OUTPUT_BUFFER_SIZE / 2));
+ assert!(calls
+ .writes
+ .iter()
+ .all(|&len| len <= INTEGER_OUTPUT_BUFFER_SIZE));
+ assert_eq!(
+ calls.flushes, 0,
+ "bucket completion must not flush the sink"
+ );
+ }
+ compact.close().unwrap();
+ wide.close().unwrap();
+ let calls = calls.lock().unwrap();
+ assert_eq!(calls.bytes, wide.output().0);
+ assert_eq!(calls.flushes, 1);
+}
+
+#[test]
+fn integer_output_failure_does_not_retry_partial_writes() {
+ let plain = RecordBatch::try_from_iter([(
+ "id",
+ Arc::new(Int64Array::from_iter_values(0..200_003)) as ArrayRef,
+ )])
+ .unwrap();
+ // Cover final drain, buffered dictionary writes, direct value chunks,
+ // one-shot compression and streaming compression (> 1 MiB).
+ for (input, compression, fail_on_write) in [
+ (dictionary_input(1), COMPRESSION_NONE, 1),
+ (dictionary_input(10_000), COMPRESSION_NONE, 2),
+ (plain.clone(), COMPRESSION_NONE, 2),
+ (dictionary_input(1_000), COMPRESSION_ZSTD, 1),
+ (plain, COMPRESSION_ZSTD, 1),
+ ] {
+ for kind in [io::ErrorKind::Other, io::ErrorKind::Interrupted] {
+ for row_group_max_size in [1, u64::MAX] {
+ let calls = Arc::new(Mutex::new(OutputCalls::default()));
+ let mut writer = MosaicWriter::new(
+ RecordingOutput {
+ calls: calls.clone(),
+ fail_on_write: Some((fail_on_write, kind)),
+ },
+ &input.schema(),
+ WriterOptions {
+ compression,
+ num_buckets: 1,
+ row_group_max_size,
+ page_size_threshold: usize::MAX,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ let result = writer.write_batch(&input);
+ let error = if row_group_max_size == 1 {
+ result.unwrap_err()
+ } else {
+ result.unwrap();
+ writer.close().unwrap_err()
+ };
+ assert_eq!(error.to_string(), "buffered output failure");
+ assert_eq!(calls.lock().unwrap().writes.len(), fail_on_write);
+ let bytes_after_failure = calls.lock().unwrap().bytes.clone();
+ assert!(writer
+ .write_batch(&input)
+ .unwrap_err()
+ .to_string()
+ .contains("aborted"));
+
assert!(writer.close().unwrap_err().to_string().contains("aborted"));
+ drop(writer);
+ let calls = calls.lock().unwrap();
+ assert_eq!(calls.writes.len(), fail_on_write);
+ assert_eq!(calls.bytes, bytes_after_failure);
+ assert_eq!(calls.flushes, 0);
+ }
+ }
+ }
+}
+
+fn schema() -> Arc<Schema> {
+ Arc::new(Schema::new(vec![
+ Field::new("i8", DataType::Int8, true),
+ Field::new("i16", DataType::Int16, true),
+ Field::new("i32", DataType::Int32, true),
+ Field::new("i64", DataType::Int64, true),
+ Field::new("float", DataType::Float64, false),
+ Field::new("string", DataType::Utf8, true),
+ ]))
+}
+
+fn batch(schema: Arc<Schema>, mode: usize, batch: usize, rows: usize) ->
RecordBatch {
+ let value = |row: usize| -> Option<i64> {
+ let i = row + batch * rows;
+ match mode {
+ 0 => None,
+ 1 => Some(255),
+ 2 => {
+ if i.is_multiple_of(7) {
+ None
+ } else {
+ Some(65535)
+ }
+ }
+ 3 => {
+ if i.is_multiple_of(11) {
+ None
+ } else {
+ Some((i % 103) as i64)
+ }
+ }
+ _ => {
+ if i.is_multiple_of(13) {
+ None
+ } else {
+ Some(i as i64)
+ }
+ }
+ }
+ };
+ let ints: Vec<_> = (0..rows + 2).map(value).collect();
+ let i8 = Int8Array::from(ints.iter().map(|v| v.map(|v| v as
i8)).collect::<Vec<_>>());
+ let i16 = Int16Array::from(
+ ints.iter()
+ .map(|v| v.map(|v| (v % 256) as i16))
+ .collect::<Vec<_>>(),
+ );
+ let mut i32 = ints
+ .iter()
+ .map(|v| v.map(|v| (v % 65536) as i32))
+ .collect::<Vec<_>>();
+ let mut i64 = ints
+ .iter()
+ .map(|v| v.map(|v| (v * 2654435761) & 0xffff_ffff))
+ .collect::<Vec<_>>();
+ if mode == 5 && batch == 3 {
+ i32[rows - 1] = Some(i32::MIN);
+ i32[rows] = Some(i32::MAX);
+ i64[rows - 1] = Some(i64::MIN);
+ i64[rows] = Some(i64::MAX);
+ }
+ let arrays: Vec<ArrayRef> = vec![
+ Arc::new(i8),
+ Arc::new(i16),
+ Arc::new(Int32Array::from(i32)),
+ Arc::new(Int64Array::from(i64)),
+ Arc::new(Float64Array::from(
+ (0..rows + 2).map(|i| i as f64 / 7.0).collect::<Vec<_>>(),
+ )),
+ Arc::new(StringArray::from(
+ (0..rows + 2)
+ .map(|i| {
+ if i.is_multiple_of(17) {
+ None
+ } else {
+ Some("mixed-column")
+ }
+ })
+ .collect::<Vec<_>>(),
+ )),
+ ];
+ // A nonzero offset exercises Arrow slicing and nullable arrays together.
+ RecordBatch::try_new(
+ schema,
+ arrays.into_iter().map(|a| a.slice(1, rows)).collect(),
+ )
+ .unwrap()
+}
+
+#[test]
+fn integer_stream_preserves_values_layout_stats_and_compression() {
+ let schema = schema();
+ for compression in [COMPRESSION_NONE, COMPRESSION_ZSTD] {
+ for threshold in [1, 4096, usize::MAX] {
+ for row_group in [60_000, u64::MAX] {
+ for mode in 0..6 {
+ let options = || WriterOptions {
+ compression,
+ num_buckets: 1,
+ page_size_threshold: threshold,
+ row_group_max_size: row_group,
+ zstd_level: 3,
+ stats_columns: vec!["i16".into(), "i32".into(),
"i64".into()],
+ ..Default::default()
+ };
+ let mut compact =
+ MosaicWriter::new(Bytes::default(), &schema,
options()).unwrap();
+ let mut wide = MosaicWriter::new(Bytes::default(),
&schema, options()).unwrap();
+ for bucket in wide.bucket_writers.iter_mut().flatten() {
+ bucket.use_wide_integer_buffers();
+ }
+ for i in 0..5 {
+ let input = batch(schema.clone(), mode, i, 10_003);
+ compact.write_batch(&input).unwrap();
+ wide.write_batch(&input).unwrap();
+ assert_eq!(compact.current_buffered_size,
wide.current_buffered_size);
+ assert_eq!(compact.row_group_metas.len(),
wide.row_group_metas.len());
+ assert_eq!(compact.estimated_file_size(),
wide.estimated_file_size());
+ }
+ compact.close().unwrap();
+ wide.close().unwrap();
+ let actual = &compact.output().0;
+ let expected = &wide.output().0;
+ assert!(actual.len() <= expected.len(),
+ "larger file: compression={compression},
threshold={threshold}, row_group={row_group}, mode={mode}; {} vs {}",
actual.len(), expected.len());
+ if compression == COMPRESSION_NONE {
+ assert!(actual == expected, "uncompressed encoding
changed");
+ }
+ let a = MosaicReader::new(Bytes(actual.clone()),
actual.len() as u64).unwrap();
+ let b =
+ MosaicReader::new(Bytes(expected.clone()),
expected.len() as u64).unwrap();
+ assert_eq!(a.num_row_groups(), b.num_row_groups());
+ assert_eq!(a.schema().serialize(), b.schema().serialize());
+ let mut consumed = 0;
+ for rg in 0..a.num_row_groups() {
+ assert_eq!(
+ a.row_group_num_rows(rg).unwrap(),
+ b.row_group_num_rows(rg).unwrap()
+ );
+ assert_eq!(
+ stats::serialize_stats(
+ a.row_group_stats(rg).unwrap(),
+ &a.schema().columns
+ ),
+ stats::serialize_stats(
+ b.row_group_stats(rg).unwrap(),
+ &b.schema().columns
+ )
+ );
+ let a_batch =
a.row_group_reader(rg).unwrap().read_columns().unwrap();
+ let b_batch =
b.row_group_reader(rg).unwrap().read_columns().unwrap();
+ assert!(a_batch == b_batch, "decoded values changed");
+ let mut row = 0;
+ while row < a_batch.num_rows() {
+ let input = batch(schema.clone(), mode, consumed /
10_003, 10_003);
+ let offset = consumed % 10_003;
+ let count = (10_003 -
offset).min(a_batch.num_rows() - row);
+ for field in schema.fields() {
+ let actual = a_batch
+ .column_by_name(field.name())
+ .unwrap()
+ .slice(row, count);
+ let expected = input
+ .column_by_name(field.name())
+ .unwrap()
+ .slice(offset, count);
+ assert!(
+ actual.to_data() == expected.to_data(),
+ "input values changed: {}",
+ field.name()
+ );
+ }
+ row += count;
+ consumed += count;
+ }
+ }
+ assert_eq!(consumed, 50_015);
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn integer_only_stream_matches_one_shot_at_zstd_block_boundaries() {
+ let schema = Schema::new(vec![Field::new("id", DataType::Int64, false)]);
+ for rows in [
+ 0, 1, 4095, 4096, 4097, 16383, 16384, 16385, 100_003, 200_003,
+ ] {
+ for level in [-1, 1, 9] {
+ for threshold in [1, usize::MAX] {
+ let options = || WriterOptions {
+ num_buckets: 1,
+ max_dict_entries: 0,
+ page_size_threshold: threshold,
+ zstd_level: level,
+ row_group_max_size: u64::MAX,
+ ..Default::default()
+ };
+ let mut compact = MosaicWriter::new(Bytes::default(), &schema,
options()).unwrap();
+ let mut wide = MosaicWriter::new(Bytes::default(), &schema,
options()).unwrap();
+ for bucket in wide.bucket_writers.iter_mut().flatten() {
+ bucket.use_wide_integer_buffers();
+ }
+ let input = RecordBatch::try_new(
+ Arc::new(schema.clone()),
+ vec![Arc::new(Int64Array::from(
+ (0..rows)
+ .map(|i| ((i as u64 * 2654435761) & 0xffff_ffff)
as i64)
+ .collect::<Vec<_>>(),
+ ))],
+ )
+ .unwrap();
+ compact.write_batch(&input).unwrap();
+ wide.write_batch(&input).unwrap();
+ compact.close().unwrap();
+ wide.close().unwrap();
+ assert!(
+ compact.output().0 == wide.output().0,
+ "rows={rows}, level={level}, threshold={threshold}"
+ );
+ }
+ }
+ }
+}