Skip to main content

meathook/
encode.rs

1//! Pluggable window encoders: an [`Encoder`] turns one window of records
2//! into the bytes of a single file. Records stay plain
3//! `#[derive(Serialize)]` structs — no format-specific builders — and a
4//! terminal sink's wire format is swapped by choosing an encoder.
5//! [`ParquetEncoder`] (feature `parquet`) stays the `HfSink` default;
6//! [`JsonEncoder`] is always available; [`CsvEncoder`] is gated behind the
7//! `csv` feature.
8
9use std::error;
10#[cfg(feature = "parquet")]
11use std::marker::PhantomData;
12
13#[cfg(feature = "parquet")]
14use arrow::datatypes::FieldRef;
15#[cfg(feature = "parquet")]
16use parquet::arrow::ArrowWriter;
17#[cfg(feature = "parquet")]
18use parquet::basic::{Compression, ZstdLevel};
19#[cfg(feature = "parquet")]
20use parquet::errors::ParquetError;
21#[cfg(feature = "parquet")]
22use parquet::file::properties::WriterProperties;
23use serde::Serialize;
24use serde::de::DeserializeOwned;
25#[cfg(feature = "parquet")]
26use serde_arrow::schema::{SchemaLike, TracingOptions};
27
28/// Encodes one window of records into the bytes of a single file.
29///
30/// Implementations must succeed on an empty slice (a valid empty file),
31/// since callers may hand over drained-but-empty windows.
32pub trait Encoder: Send + Sync + 'static {
33    /// Error produced when encoding fails.
34    type Error: error::Error + Send + Sync + 'static;
35
36    /// File extension (no leading dot) for files this encoder produces,
37    /// e.g. `"parquet"`.
38    const EXT: &'static str;
39
40    /// Encode records into an in-memory file.
41    ///
42    /// # Errors
43    ///
44    /// Returns the encoder's error if serialization fails.
45    fn encode<R: Serialize + DeserializeOwned>(
46        &self,
47        records: &[R],
48    ) -> Result<Vec<u8>, Self::Error>;
49}
50
51/// Encodes a window as one JSON array per file.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct JsonEncoder;
54
55impl Encoder for JsonEncoder {
56    type Error = serde_json::Error;
57    const EXT: &'static str = "json";
58
59    fn encode<R: Serialize + DeserializeOwned>(
60        &self,
61        records: &[R],
62    ) -> Result<Vec<u8>, Self::Error> {
63        serde_json::to_vec(records)
64    }
65}
66
67/// Error encoding records to parquet.
68#[cfg(feature = "parquet")]
69#[derive(Debug, thiserror::Error)]
70pub enum ParquetEncodeError {
71    #[error("failed to derive arrow schema from record type: {0}")]
72    Schema(#[source] serde_arrow::Error),
73    #[error("failed to build record batch: {0}")]
74    Batch(#[source] serde_arrow::Error),
75    #[error("failed to write parquet: {0}")]
76    Parquet(#[from] ParquetError),
77}
78
79#[cfg(feature = "parquet")]
80mod private {
81    pub trait Sealed {}
82}
83
84/// A type-level parquet compression policy.
85///
86/// This trait is sealed. The built-in policies are [`Uncompressed`] and
87/// [`Zstd<LEVEL>`](Zstd), where only levels `1..=22` implement this trait.
88#[cfg(feature = "parquet")]
89pub trait ParquetCompression: private::Sealed + Send + Sync + 'static {
90    #[doc(hidden)]
91    fn parquet_compression() -> Result<Compression, ParquetError>;
92}
93
94/// Type-level policy for uncompressed parquet output.
95#[cfg(feature = "parquet")]
96#[derive(Debug, Clone, Copy, Default)]
97pub struct Uncompressed;
98
99#[cfg(feature = "parquet")]
100impl private::Sealed for Uncompressed {}
101
102#[cfg(feature = "parquet")]
103impl ParquetCompression for Uncompressed {
104    fn parquet_compression() -> Result<Compression, ParquetError> {
105        Ok(Compression::UNCOMPRESSED)
106    }
107}
108
109/// Type-level policy for zstd-compressed parquet output.
110///
111/// `LEVEL` defaults to `1`. Only levels `1..=22` implement
112/// [`ParquetCompression`], so an encoder with an invalid level cannot be
113/// constructed.
114///
115/// ```compile_fail
116/// use meathook::{ParquetEncoder, Zstd};
117///
118/// let encoder = ParquetEncoder::<Zstd<23>>::new();
119/// ```
120#[cfg(feature = "parquet")]
121#[derive(Debug, Clone, Copy, Default)]
122pub struct Zstd<const LEVEL: u8 = 1>;
123
124#[cfg(feature = "parquet")]
125macro_rules! impl_zstd_levels {
126    ($($level:literal),* $(,)?) => {
127        $(
128            impl private::Sealed for Zstd<$level> {}
129
130            impl ParquetCompression for Zstd<$level> {
131                fn parquet_compression() -> Result<Compression, ParquetError> {
132                    ZstdLevel::try_new($level).map(Compression::ZSTD)
133                }
134            }
135        )*
136    };
137}
138
139#[cfg(feature = "parquet")]
140impl_zstd_levels!(
141    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
142);
143
144/// Encodes a window into a parquet file held in memory.
145///
146/// The arrow schema is derived from `R` itself (not sampled from values, so
147/// an empty slice still produces a valid zero-row file), which is why
148/// `DeserializeOwned` is required alongside `Serialize`.
149///
150/// The default compression policy is [`Uncompressed`]. Select zstd and its
151/// level in the encoder type:
152///
153/// ```
154/// use meathook::{ParquetEncoder, Zstd};
155///
156/// let uncompressed = ParquetEncoder::default();
157/// let zstd_1 = ParquetEncoder::<Zstd>::new();
158/// let zstd_3 = ParquetEncoder::<Zstd<3>>::new();
159/// ```
160#[cfg(feature = "parquet")]
161#[derive(Debug, Clone, Copy)]
162pub struct ParquetEncoder<C = Uncompressed> {
163    _compression: PhantomData<C>,
164}
165
166#[cfg(feature = "parquet")]
167impl<C: ParquetCompression> ParquetEncoder<C> {
168    /// Creates an encoder using the compression policy `C`.
169    #[must_use]
170    pub const fn new() -> Self {
171        Self {
172            _compression: PhantomData,
173        }
174    }
175
176    fn writer_properties() -> Result<WriterProperties, ParquetError> {
177        Ok(WriterProperties::builder()
178            .set_compression(C::parquet_compression()?)
179            .build())
180    }
181}
182
183#[cfg(feature = "parquet")]
184impl Default for ParquetEncoder {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190#[cfg(feature = "parquet")]
191impl<C: ParquetCompression> Encoder for ParquetEncoder<C> {
192    type Error = ParquetEncodeError;
193    const EXT: &'static str = "parquet";
194
195    /// # Errors
196    ///
197    /// Returns an error if schema derivation, record batch construction, or
198    /// parquet writing fails.
199    fn encode<R: Serialize + DeserializeOwned>(
200        &self,
201        records: &[R],
202    ) -> Result<Vec<u8>, Self::Error> {
203        let fields = Vec::<FieldRef>::from_type::<R>(TracingOptions::default())
204            .map_err(ParquetEncodeError::Schema)?;
205        let batch =
206            serde_arrow::to_record_batch(&fields, &records).map_err(ParquetEncodeError::Batch)?;
207
208        let mut buf = vec![];
209        let mut writer =
210            ArrowWriter::try_new(&mut buf, batch.schema(), Some(Self::writer_properties()?))?;
211        writer.write(&batch)?;
212        writer.close()?;
213        Ok(buf)
214    }
215}
216
217/// Error encoding records to CSV.
218#[cfg(feature = "csv")]
219#[derive(Debug, thiserror::Error)]
220pub enum CsvError {
221    #[error("failed to serialize record to csv: {0}")]
222    Serialize(#[from] csv::Error),
223    #[error("failed to flush csv writer: {0}")]
224    Flush(#[from] std::io::Error),
225}
226
227/// Encodes a window as one CSV file.
228///
229/// The header row is derived from the record's field names (the csv
230/// crate's default); records must be flat — nested structs fail with
231/// [`CsvError::Serialize`]. An empty slice encodes to an empty file, since
232/// headers are only written together with the first record.
233#[cfg(feature = "csv")]
234#[derive(Debug, Clone, Copy, Default)]
235pub struct CsvEncoder;
236
237#[cfg(feature = "csv")]
238impl Encoder for CsvEncoder {
239    type Error = CsvError;
240    const EXT: &'static str = "csv";
241
242    fn encode<R: Serialize + DeserializeOwned>(
243        &self,
244        records: &[R],
245    ) -> Result<Vec<u8>, Self::Error> {
246        let mut writer = csv::Writer::from_writer(Vec::new());
247        for record in records {
248            writer.serialize(record)?;
249        }
250        writer
251            .into_inner()
252            .map_err(|e| CsvError::Flush(e.into_error()))
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use serde::Deserialize;
259
260    use super::*;
261
262    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263    struct Sample {
264        station_id: String,
265        timestamp: String,
266        value: f64,
267    }
268
269    fn samples() -> Vec<Sample> {
270        vec![
271            Sample {
272                station_id: "S100".into(),
273                timestamp: "2026-06-12T08:00:00+08:00".into(),
274                value: 29.4,
275            },
276            Sample {
277                station_id: "S117".into(),
278                timestamp: "2026-06-12T08:00:00+08:00".into(),
279                value: 30.1,
280            },
281        ]
282    }
283
284    #[cfg(feature = "parquet")]
285    mod parquet_encoder {
286        use arrow::array::RecordBatch;
287        use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
288        use parquet::basic::{Compression, ZstdLevel};
289        use parquet::file::metadata::{ColumnChunkMetaData, RowGroupMetaData};
290
291        use super::*;
292
293        fn compressions(bytes: Vec<u8>) -> Vec<Compression> {
294            let builder =
295                ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes)).unwrap();
296            builder
297                .metadata()
298                .row_groups()
299                .iter()
300                .flat_map(RowGroupMetaData::columns)
301                .map(ColumnChunkMetaData::compression)
302                .collect()
303        }
304
305        #[test]
306        fn parquet_round_trip() {
307            let records = samples();
308
309            let encoder = ParquetEncoder::default();
310            let bytes = encoder.encode(&records).unwrap();
311
312            assert!(
313                compressions(bytes.clone())
314                    .iter()
315                    .all(|compression| *compression == Compression::UNCOMPRESSED)
316            );
317
318            let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
319                .unwrap()
320                .build()
321                .unwrap();
322            let batches: Vec<_> = reader.collect::<Result<_, _>>().unwrap();
323            assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
324
325            let round_tripped = serde_arrow::from_record_batch::<Vec<Sample>>(&batches[0]).unwrap();
326
327            assert_eq!(round_tripped, records);
328        }
329
330        #[test]
331        fn default_policy_uses_uncompressed_codec() {
332            let bytes = ParquetEncoder::default().encode(&samples()).unwrap();
333
334            assert!(
335                compressions(bytes)
336                    .iter()
337                    .all(|compression| *compression == Compression::UNCOMPRESSED)
338            );
339        }
340
341        #[test]
342        fn empty_slice_encodes_zero_row_file() {
343            let encoded = [
344                ParquetEncoder::default().encode::<Sample>(&[]).unwrap(),
345                ParquetEncoder::<Zstd>::new().encode::<Sample>(&[]).unwrap(),
346            ];
347
348            for bytes in encoded {
349                let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
350                    .unwrap()
351                    .build()
352                    .unwrap();
353                let rows: usize = reader.map(|b| b.unwrap().num_rows()).sum();
354                assert_eq!(rows, 0);
355            }
356        }
357
358        #[test]
359        fn zstd_default_level_round_trips() {
360            let records = samples();
361            let bytes = ParquetEncoder::<Zstd>::new().encode(&records).unwrap();
362
363            assert!(
364                compressions(bytes.clone())
365                    .iter()
366                    .all(|compression| { *compression == Compression::ZSTD(ZstdLevel::default()) })
367            );
368
369            let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
370                .unwrap()
371                .build()
372                .unwrap();
373            let batches: Vec<_> = reader.collect::<Result<_, _>>().unwrap();
374            let round_tripped = serde_arrow::from_record_batch::<Vec<Sample>>(&batches[0]).unwrap();
375
376            assert_eq!(round_tripped, records);
377        }
378
379        #[test]
380        fn zstd_explicit_level_is_applied() {
381            let level = ZstdLevel::try_new(7).unwrap();
382            let encoder = ParquetEncoder::<Zstd<7>>::new();
383            assert_eq!(
384                Zstd::<7>::parquet_compression().unwrap(),
385                Compression::ZSTD(level)
386            );
387
388            let bytes = encoder.encode(&samples()).unwrap();
389
390            assert!(
391                compressions(bytes.clone())
392                    .iter()
393                    .all(|compression| matches!(compression, Compression::ZSTD(_)))
394            );
395            ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
396                .unwrap()
397                .build()
398                .unwrap()
399                .collect::<Result<Vec<_>, _>>()
400                .unwrap();
401        }
402
403        #[test]
404        fn zstd_boundary_levels_are_applied() {
405            let policies = [
406                Zstd::<1>::parquet_compression().unwrap(),
407                Zstd::<22>::parquet_compression().unwrap(),
408            ];
409
410            assert_eq!(
411                policies,
412                [
413                    Compression::ZSTD(ZstdLevel::try_new(1).unwrap()),
414                    Compression::ZSTD(ZstdLevel::try_new(22).unwrap()),
415                ]
416            );
417        }
418
419        #[test]
420        fn zstd_shrinks_compressible_records() {
421            let records: Vec<_> = (0..4_096)
422                .map(|index| Sample {
423                    station_id: format!("weather-station-{index:08}"),
424                    timestamp: format!("2026-07-13T12:{:02}:{:02}+08:00", index / 60, index % 60),
425                    value: 29.0 + f64::from(index % 10) / 10.0,
426                })
427                .collect();
428
429            let uncompressed = ParquetEncoder::default().encode(&records).unwrap();
430            let compressed = ParquetEncoder::<Zstd>::new().encode(&records).unwrap();
431
432            assert!(compressed.len() < uncompressed.len());
433        }
434    }
435
436    mod json_encoder {
437        use super::*;
438
439        #[test]
440        fn json_round_trip() {
441            let records = samples();
442            let bytes = JsonEncoder.encode(&records).unwrap();
443            let round_tripped: Vec<Sample> = serde_json::from_slice(&bytes).unwrap();
444            assert_eq!(round_tripped, records);
445        }
446
447        #[test]
448        fn empty_slice_encodes_empty_array() {
449            assert_eq!(JsonEncoder.encode::<Sample>(&[]).unwrap(), b"[]");
450        }
451    }
452
453    #[cfg(feature = "csv")]
454    mod csv_encoder {
455        use super::*;
456
457        #[test]
458        fn csv_round_trip_with_headers() {
459            let records = samples();
460            let bytes = CsvEncoder.encode(&records).unwrap();
461            let round_tripped = ::csv::Reader::from_reader(bytes.as_slice())
462                .deserialize()
463                .collect::<Result<Vec<Sample>, _>>()
464                .unwrap();
465            assert_eq!(round_tripped, records);
466        }
467
468        #[test]
469        fn empty_slice_encodes_empty_output() {
470            assert!(CsvEncoder.encode::<Sample>(&[]).unwrap().is_empty());
471        }
472    }
473}