use arrow::array::RecordBatch;
use arrow::datatypes::{DataType, SchemaRef};
use parquet::arrow::ArrowWriter;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::basic::{Compression, ZstdLevel};
use parquet::file::properties::WriterProperties;
use crate::objstore::error::{ObjStoreError, Result};
const COLD_ZSTD_LEVEL: i32 = 9;
const COLD_ROW_GROUP_ROWS: usize = 65_536;
fn cold_writer_properties(schema: &SchemaRef) -> Result<WriterProperties> {
let mut props = WriterProperties::builder()
.set_compression(Compression::ZSTD(
ZstdLevel::try_new(COLD_ZSTD_LEVEL).map_err(|e| ObjStoreError::InvalidConfig {
detail: format!("invalid cold-tier zstd level: {e}"),
})?,
))
.set_max_row_group_row_count(Some(COLD_ROW_GROUP_ROWS))
.set_dictionary_enabled(false);
for field in schema.fields() {
if matches!(field.data_type(), DataType::Utf8 | DataType::LargeUtf8) {
let path = parquet::schema::types::ColumnPath::from(field.name().as_str());
props = props.set_column_dictionary_enabled(path, true);
}
}
Ok(props.build())
}
pub struct ParquetArchiveWriter {
writer: ArrowWriter<Vec<u8>>,
schema: SchemaRef,
rows: u64,
}
impl ParquetArchiveWriter {
pub fn new(schema: SchemaRef) -> Result<Self> {
let props = cold_writer_properties(&schema)?;
let writer = ArrowWriter::try_new(Vec::new(), schema.clone(), Some(props))
.map_err(|e| ObjStoreError::Parquet(e.to_string()))?;
Ok(Self {
writer,
schema,
rows: 0,
})
}
pub fn write(&mut self, batch: &RecordBatch) -> Result<()> {
if batch.num_rows() == 0 {
return Ok(());
}
if batch.schema() != self.schema {
return Err(ObjStoreError::InvalidConfig {
detail: format!(
"cold archive batch schema {:?} does not match the object schema {:?}",
batch.schema(),
self.schema
),
});
}
self.writer
.write(batch)
.map_err(|e| ObjStoreError::Parquet(e.to_string()))?;
self.rows += batch.num_rows() as u64;
Ok(())
}
#[must_use]
pub const fn rows(&self) -> u64 {
self.rows
}
pub fn finish(self) -> Result<(Vec<u8>, u64)> {
let rows = self.rows;
let bytes = self
.writer
.into_inner()
.map_err(|e| ObjStoreError::Parquet(e.to_string()))?;
Ok((bytes, rows))
}
}
pub fn parquet_to_batches(data: Vec<u8>) -> Result<Vec<RecordBatch>> {
let builder = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(data))
.map_err(|e| ObjStoreError::Parquet(e.to_string()))?;
let reader = builder
.build()
.map_err(|e| ObjStoreError::Parquet(e.to_string()))?;
reader
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| ObjStoreError::Parquet(e.to_string()))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use arrow::array::{Array, Float64Array, Int64Array, StringArray};
use arrow::datatypes::{Field, Schema};
use std::sync::Arc;
fn sample_batch() -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new(chronix_core::TIME_COLUMN, DataType::Int64, false),
Field::new("host", DataType::Utf8, true),
Field::new("value", DataType::Float64, true),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int64Array::from(vec![1_i64, 2, 3, 4])),
Arc::new(StringArray::from(vec![
Some("a"),
Some("a"),
Some("b"),
Some("b"),
])),
Arc::new(Float64Array::from(vec![
Some(1.5),
None,
Some(3.5),
Some(4.5),
])),
],
)
.unwrap()
}
#[test]
fn parquet_round_trip_preserves_values_and_nulls() {
let batch = sample_batch();
let schema = batch.schema();
let props = cold_writer_properties(&schema).unwrap();
let mut buf = Vec::new();
let mut writer = ArrowWriter::try_new(&mut buf, schema.clone(), Some(props)).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
let back = parquet_to_batches(buf).unwrap();
let merged = arrow::compute::concat_batches(&schema, &back).unwrap();
assert_eq!(merged.num_rows(), 4);
assert_eq!(
merged.schema().fields().len(),
3,
"column order and count must survive"
);
assert_eq!(merged.schema().field(0).name(), chronix_core::TIME_COLUMN);
let values = merged
.column(2)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
assert!(
values.is_null(1),
"an absent field must read back as NULL, not as 0.0"
);
assert!((values.value(0) - 1.5).abs() < f64::EPSILON);
assert!((values.value(3) - 4.5).abs() < f64::EPSILON);
}
#[test]
fn tag_columns_are_dictionary_encoded() {
let batch = sample_batch();
let schema = batch.schema();
let props = cold_writer_properties(&schema).unwrap();
let mut buf = Vec::new();
let mut writer = ArrowWriter::try_new(&mut buf, schema, Some(props)).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
let builder = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(buf)).unwrap();
let meta = builder.metadata();
let rg = meta.row_group(0);
let host = rg
.columns()
.iter()
.find(|c| c.column_path().string() == "host")
.expect("host column present");
assert!(
host.dictionary_page_offset().is_some(),
"tag column must be dictionary-encoded"
);
let value = rg
.columns()
.iter()
.find(|c| c.column_path().string() == "value")
.expect("value column present");
assert!(
value.dictionary_page_offset().is_none(),
"a float measurement column must not be dictionary-encoded"
);
}
#[test]
fn archive_writer_streams_batches_and_pins_the_schema() {
let batch = sample_batch();
let mut w = ParquetArchiveWriter::new(batch.schema()).unwrap();
w.write(&batch).unwrap();
w.write(&batch).unwrap();
let mismatched = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new(
"other",
DataType::Int64,
false,
)])),
vec![Arc::new(Int64Array::from(vec![1_i64]))],
)
.unwrap();
assert!(
w.write(&mismatched).is_err(),
"a batch of a different schema must be refused, not silently dropped"
);
let (bytes, rows) = w.finish().unwrap();
assert_eq!(rows, 8, "both batches must be counted");
let back = parquet_to_batches(bytes).unwrap();
let total: usize = back.iter().map(RecordBatch::num_rows).sum();
assert_eq!(total, 8);
}
#[test]
fn archive_writer_ignores_empty_batches() {
let schema = sample_batch().schema();
let mut w = ParquetArchiveWriter::new(schema.clone()).unwrap();
w.write(&RecordBatch::new_empty(schema)).unwrap();
assert_eq!(w.rows(), 0);
let (_, rows) = w.finish().unwrap();
assert_eq!(rows, 0);
}
}