Skip to main content

chronix_engine/segment/
compression.rs

1//! Block compression for encoded column data (LZ4 and Zstd).
2//!
3//! Each encoded column block can be optionally compressed after
4//! type-specific encoding. The compressed format prepends a codec tag
5//! and size header to enable deterministic decompression.
6//!
7//! ## Compressed block format (v2)
8//!
9//! ```text
10//! [codec_tag: u8]              — 0x00=None, 0x01=Lz4, 0x02=Zstd
11//! [compressed_size: u32]
12//! [uncompressed_size: u32]
13//! [compressed_data: compressed_size bytes]
14//! ```
15//!
16//! When `compressed_size == 0`, the block is stored uncompressed.
17//! The codec tag enables deterministic dispatch — no trial decompression.
18
19use chronix_core::config::CompressionCodec;
20
21use crate::segment::error::{Result, SegmentError};
22
23/// Maximum block size that fits in a `u32` length header.
24const MAX_BLOCK_SIZE: usize = u32::MAX as usize;
25
26/// Minimum compression ratio for auto-skip.
27///
28/// If type-specific encoding already achieved this ratio, LZ4 is skipped.
29const AUTO_SKIP_RATIO: f64 = 8.0;
30
31/// Size of the compression block header in bytes.
32///
33/// Layout: `[codec_tag: u8][compressed_size: u32][uncompressed_size: u32]`.
34const COMPRESSION_HEADER_SIZE: usize = 9;
35
36/// Codec tag: data stored without compression.
37const CODEC_TAG_NONE: u8 = 0x00;
38/// Codec tag: LZ4 block compression.
39const CODEC_TAG_LZ4: u8 = 0x01;
40/// Codec tag: Zstd compression.
41const CODEC_TAG_ZSTD: u8 = 0x02;
42/// Codec tag: Zstd compression with trained dictionary.
43const CODEC_TAG_ZSTD_DICT: u8 = 0x03;
44
45/// Map a [`CompressionCodec`] to its wire tag.
46const fn codec_to_tag(codec: CompressionCodec) -> u8 {
47    match codec {
48        CompressionCodec::None => CODEC_TAG_NONE,
49        CompressionCodec::Lz4 => CODEC_TAG_LZ4,
50        CompressionCodec::Zstd => CODEC_TAG_ZSTD,
51    }
52}
53
54/// Compress a block using LZ4.
55///
56/// Returns the compressed data with the 8-byte header, or the original data
57/// if compression didn't achieve meaningful savings.
58///
59/// # Errors
60///
61/// Returns an error if the data exceeds `u32::MAX` bytes.
62pub fn compress_block(data: &[u8]) -> Result<Vec<u8>> {
63    compress_block_with_codec(data, CompressionCodec::Lz4)
64}
65
66/// Compress a block using the specified compression codec.
67///
68/// # Errors
69///
70/// Returns an error if the data exceeds `u32::MAX` bytes or compression fails.
71pub fn compress_block_with_codec(data: &[u8], codec: CompressionCodec) -> Result<Vec<u8>> {
72    if data.len() > MAX_BLOCK_SIZE {
73        return Err(SegmentError::CorruptFile {
74            detail: format!(
75                "block too large for compression header: {} bytes (max {})",
76                data.len(),
77                MAX_BLOCK_SIZE
78            ),
79        });
80    }
81
82    if data.is_empty() {
83        let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE);
84        buf.push(codec_to_tag(codec));
85        buf.extend_from_slice(&0u32.to_le_bytes());
86        buf.extend_from_slice(&0u32.to_le_bytes());
87        return Ok(buf);
88    }
89
90    if matches!(codec, CompressionCodec::None) {
91        return Ok(write_uncompressed(data));
92    }
93
94    let compressed = match codec {
95        CompressionCodec::Lz4 => compress_lz4(data),
96        CompressionCodec::Zstd => compress_zstd(data, DEFAULT_ZSTD_LEVEL),
97        CompressionCodec::None => return Ok(write_uncompressed(data)),
98    };
99
100    // If compression didn't help, store uncompressed
101    if compressed.is_empty() || compressed.len() >= data.len() {
102        return Ok(write_uncompressed(data));
103    }
104
105    let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE + compressed.len());
106    buf.push(codec_to_tag(codec));
107    buf.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
108    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
109    buf.extend_from_slice(&compressed);
110    Ok(buf)
111}
112
113/// Default Zstd compression level (balanced speed/ratio).
114const DEFAULT_ZSTD_LEVEL: i32 = 3;
115
116/// Maximum Zstd compression level for cold-tier recompression.
117pub const ZSTD_HIGH_LEVEL: i32 = 9;
118
119/// Compress a block using Zstd at a specific level.
120///
121/// # Errors
122///
123/// Returns an error if compression fails.
124pub fn compress_block_zstd(data: &[u8], level: i32) -> Result<Vec<u8>> {
125    if data.len() > MAX_BLOCK_SIZE {
126        return Err(SegmentError::CorruptFile {
127            detail: format!(
128                "block too large for compression header: {} bytes (max {})",
129                data.len(),
130                MAX_BLOCK_SIZE
131            ),
132        });
133    }
134
135    if data.is_empty() {
136        let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE);
137        buf.push(CODEC_TAG_ZSTD);
138        buf.extend_from_slice(&0u32.to_le_bytes());
139        buf.extend_from_slice(&0u32.to_le_bytes());
140        return Ok(buf);
141    }
142
143    let compressed = compress_zstd(data, level);
144    if compressed.is_empty() || compressed.len() >= data.len() {
145        return Ok(write_uncompressed(data));
146    }
147
148    let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE + compressed.len());
149    buf.push(CODEC_TAG_ZSTD);
150    buf.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
151    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
152    buf.extend_from_slice(&compressed);
153    Ok(buf)
154}
155
156fn write_uncompressed(data: &[u8]) -> Vec<u8> {
157    let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE + data.len());
158    buf.push(CODEC_TAG_NONE);
159    buf.extend_from_slice(&0u32.to_le_bytes()); // compressed_size = 0 → uncompressed
160    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
161    buf.extend_from_slice(data);
162    buf
163}
164
165fn compress_lz4(data: &[u8]) -> Vec<u8> {
166    let max_compressed = lz4_flex::block::get_maximum_output_size(data.len());
167    let mut compressed = vec![0u8; max_compressed];
168    match lz4_flex::compress_into(data, &mut compressed) {
169        Ok(len) if len > 0 => {
170            compressed.truncate(len);
171            compressed
172        }
173        Ok(_) => {
174            tracing::warn!(
175                len = data.len(),
176                "lz4 compression produced empty output, falling back to uncompressed"
177            );
178            Vec::new()
179        }
180        Err(e) => {
181            tracing::warn!(error = %e, len = data.len(), "lz4 compression failed, falling back to uncompressed");
182            Vec::new()
183        }
184    }
185}
186
187fn compress_zstd(data: &[u8], level: i32) -> Vec<u8> {
188    match zstd::bulk::compress(data, level) {
189        Ok(compressed) => compressed,
190        Err(e) => {
191            tracing::warn!(error = %e, len = data.len(), level, "zstd compression failed, falling back to uncompressed");
192            Vec::new()
193        }
194    }
195}
196
197// ── Zstd dictionary training and compression ─────────────────
198
199/// Minimum number of samples and combined byte size before dictionary
200/// training is worthwhile. Below these thresholds the dictionary would
201/// over-fit to noise.
202const MIN_DICT_SAMPLES: usize = 8;
203const MIN_DICT_SAMPLE_BYTES: usize = 16_384;
204
205/// Default dictionary size produced by `zstd::dict::from_samples`.
206const DICT_MAX_SIZE: usize = 16 * 1024;
207
208/// Train a Zstd compression dictionary from a set of sample buffers.
209///
210/// Returns `None` if the input is too small to produce a useful dictionary.
211///
212/// # Errors
213///
214/// Returns an error only when dictionary training itself fails (OOM, etc.).
215pub fn train_zstd_dictionary(samples: &[&[u8]]) -> Result<Option<Vec<u8>>> {
216    if samples.len() < MIN_DICT_SAMPLES {
217        return Ok(None);
218    }
219    let total: usize = samples.iter().map(|s| s.len()).sum();
220    if total < MIN_DICT_SAMPLE_BYTES {
221        return Ok(None);
222    }
223
224    let owned: Vec<Vec<u8>> = samples.iter().map(|s| s.to_vec()).collect();
225    match zstd::dict::from_samples(&owned, DICT_MAX_SIZE) {
226        Ok(dict) if !dict.is_empty() => Ok(Some(dict)),
227        Ok(_) => Ok(None),
228        Err(e) => {
229            tracing::warn!(error = %e, samples = samples.len(), "zstd dictionary training failed");
230            Ok(None)
231        }
232    }
233}
234
235/// Compress a block with a trained Zstd dictionary.
236///
237/// The output uses `CODEC_TAG_ZSTD_DICT` so the reader can route to
238/// [`decompress_block_zstd_dict`] with the segment's stored dictionary.
239///
240/// # Errors
241///
242/// Returns an error if the block exceeds `MAX_BLOCK_SIZE`.
243pub fn compress_block_zstd_dict(data: &[u8], level: i32, dict: &[u8]) -> Result<Vec<u8>> {
244    if data.len() > MAX_BLOCK_SIZE {
245        return Err(SegmentError::CorruptFile {
246            detail: format!(
247                "block too large for compression header: {} bytes (max {})",
248                data.len(),
249                MAX_BLOCK_SIZE
250            ),
251        });
252    }
253
254    if data.is_empty() {
255        let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE);
256        buf.push(CODEC_TAG_ZSTD_DICT);
257        buf.extend_from_slice(&0u32.to_le_bytes());
258        buf.extend_from_slice(&0u32.to_le_bytes());
259        return Ok(buf);
260    }
261
262    let compressed = {
263        let cdict = zstd::dict::EncoderDictionary::copy(dict, level);
264        let mut out = Vec::new();
265        match zstd::Encoder::with_prepared_dictionary(&mut out, &cdict) {
266            Ok(mut encoder) => {
267                use std::io::Write;
268                if encoder.write_all(data).is_err() || encoder.finish().is_err() {
269                    tracing::warn!(
270                        len = data.len(),
271                        "zstd dict compression failed, falling back to uncompressed"
272                    );
273                    Vec::new()
274                } else {
275                    out
276                }
277            }
278            Err(e) => {
279                tracing::warn!(error = %e, "zstd dict encoder creation failed, falling back to uncompressed");
280                Vec::new()
281            }
282        }
283    };
284
285    if compressed.is_empty() || compressed.len() >= data.len() {
286        return Ok(write_uncompressed(data));
287    }
288
289    let mut buf = Vec::with_capacity(COMPRESSION_HEADER_SIZE + compressed.len());
290    buf.push(CODEC_TAG_ZSTD_DICT);
291    buf.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
292    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
293    buf.extend_from_slice(&compressed);
294    Ok(buf)
295}
296
297/// Decompress a block that was compressed with a Zstd dictionary.
298///
299/// # Errors
300///
301/// Returns an error if dictionary decompression fails or sizes mismatch.
302pub fn decompress_block_zstd_dict(
303    payload: &[u8],
304    uncompressed_size: usize,
305    dict: &[u8],
306) -> Result<Vec<u8>> {
307    use std::io::Read;
308    let ddict = zstd::dict::DecoderDictionary::copy(dict);
309    let mut decoder = zstd::Decoder::with_prepared_dictionary(payload, &ddict).map_err(|e| {
310        SegmentError::CorruptFile {
311            detail: format!("zstd dict decoder creation failed: {e}"),
312        }
313    })?;
314    let mut out = Vec::with_capacity(uncompressed_size);
315    decoder
316        .read_to_end(&mut out)
317        .map_err(|e| SegmentError::CorruptFile {
318            detail: format!("zstd dict decompression failed: {e}"),
319        })?;
320    if out.len() != uncompressed_size {
321        return Err(SegmentError::CorruptFile {
322            detail: format!(
323                "zstd dict decompressed size mismatch: expected {uncompressed_size}, got {}",
324                out.len()
325            ),
326        });
327    }
328    Ok(out)
329}
330
331/// Maximum allowed uncompressed block size (256 MiB).
332///
333/// Guards against adversarial sizes in corrupt segment files that could
334/// cause multi-gigabyte allocations.
335const MAX_UNCOMPRESSED_SIZE: usize = 256 * 1024 * 1024;
336
337/// Decompress a block that was compressed with [`compress_block`] or
338/// [`compress_block_with_codec`].
339///
340/// Reads the codec tag from the header to deterministically select
341/// the decompression algorithm — no trial decompression needed.
342///
343/// For blocks compressed with a Zstd dictionary (`CODEC_TAG_ZSTD_DICT`),
344/// use [`decompress_block_with_optional_dict`] instead.
345///
346/// # Errors
347///
348/// Returns an error if the data is malformed or decompression fails.
349pub fn decompress_block(data: &[u8]) -> Result<Vec<u8>> {
350    decompress_block_with_optional_dict(data, None)
351}
352
353/// Decompress a block, optionally using a Zstd dictionary for
354/// `CODEC_TAG_ZSTD_DICT` blocks.
355///
356/// # Errors
357///
358/// Returns an error if the data is malformed, decompression fails,
359/// or a dictionary-compressed block is encountered without a dictionary.
360pub fn decompress_block_with_optional_dict(data: &[u8], dict: Option<&[u8]>) -> Result<Vec<u8>> {
361    if data.len() < COMPRESSION_HEADER_SIZE {
362        return Err(crate::segment::error::SegmentError::CorruptFile {
363            detail: "compressed block too short for header".to_string(),
364        });
365    }
366
367    let codec_tag = data[0];
368    let compressed_size = u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as usize;
369    let uncompressed_size = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
370
371    if uncompressed_size > MAX_UNCOMPRESSED_SIZE {
372        return Err(crate::segment::error::SegmentError::CorruptFile {
373            detail: format!(
374                "uncompressed size {uncompressed_size} exceeds maximum ({MAX_UNCOMPRESSED_SIZE})"
375            ),
376        });
377    }
378
379    if compressed_size == 0 {
380        // Data was stored uncompressed
381        if data.len() < COMPRESSION_HEADER_SIZE + uncompressed_size {
382            return Err(crate::segment::error::SegmentError::CorruptFile {
383                detail: "uncompressed block data truncated".to_string(),
384            });
385        }
386        return Ok(
387            data[COMPRESSION_HEADER_SIZE..COMPRESSION_HEADER_SIZE + uncompressed_size].to_vec(),
388        );
389    }
390
391    if data.len() < COMPRESSION_HEADER_SIZE + compressed_size {
392        return Err(crate::segment::error::SegmentError::CorruptFile {
393            detail: "compressed block data truncated".to_string(),
394        });
395    }
396
397    let payload = &data[COMPRESSION_HEADER_SIZE..COMPRESSION_HEADER_SIZE + compressed_size];
398
399    let decompressed = match codec_tag {
400        CODEC_TAG_LZ4 => lz4_flex::decompress(payload, uncompressed_size).map_err(|e| {
401            SegmentError::CorruptFile {
402                detail: format!("LZ4 decompression failed: {e}"),
403            }
404        })?,
405        CODEC_TAG_ZSTD => zstd::bulk::decompress(payload, uncompressed_size).map_err(|e| {
406            SegmentError::CorruptFile {
407                detail: format!("Zstd decompression failed: {e}"),
408            }
409        })?,
410        CODEC_TAG_ZSTD_DICT => {
411            // Dictionary-compressed block
412            let d = dict.ok_or_else(|| SegmentError::CorruptFile {
413                detail: "ZSTD_DICT block but no dictionary provided".to_string(),
414            })?;
415            decompress_block_zstd_dict(payload, uncompressed_size, d)?
416        }
417        unknown => {
418            return Err(SegmentError::CorruptFile {
419                detail: format!("unknown compression codec tag: 0x{unknown:02x}"),
420            });
421        }
422    };
423
424    if decompressed.len() != uncompressed_size {
425        return Err(SegmentError::CorruptFile {
426            detail: format!(
427                "decompressed size mismatch: expected {uncompressed_size}, got {}",
428                decompressed.len()
429            ),
430        });
431    }
432
433    Ok(decompressed)
434}
435
436/// Returns `true` if the type-specific encoding already achieved enough
437/// compression that LZ4 should be skipped.
438#[must_use]
439#[allow(clippy::cast_precision_loss)]
440pub fn should_skip_compression(encoded_size: usize, raw_size: usize) -> bool {
441    if raw_size == 0 {
442        return true;
443    }
444    let ratio = raw_size as f64 / encoded_size as f64;
445    ratio >= AUTO_SKIP_RATIO
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn roundtrip_compressible_data() {
454        let data = vec![42u8; 10_000];
455        let compressed = compress_block(&data).unwrap();
456        assert!(compressed.len() < data.len());
457        let decompressed = decompress_block(&compressed).unwrap();
458        assert_eq!(data, decompressed);
459    }
460
461    #[test]
462    fn roundtrip_incompressible_data() {
463        // Random-looking data that doesn't compress well
464        let data: Vec<u8> = (0..1000).map(|i| (i * 37 + 13) as u8).collect();
465        let compressed = compress_block(&data).unwrap();
466        let decompressed = decompress_block(&compressed).unwrap();
467        assert_eq!(data, decompressed);
468    }
469
470    #[test]
471    fn roundtrip_small_data() {
472        let data = vec![1, 2, 3];
473        let compressed = compress_block(&data).unwrap();
474        let decompressed = decompress_block(&compressed).unwrap();
475        assert_eq!(data, decompressed);
476    }
477
478    #[test]
479    fn roundtrip_empty() {
480        let data: Vec<u8> = vec![];
481        let compressed = compress_block(&data).unwrap();
482        let decompressed = decompress_block(&compressed).unwrap();
483        assert_eq!(data, decompressed);
484    }
485
486    #[test]
487    fn should_skip_compression_logic() {
488        // 1000 raw, 100 encoded → 10× → skip
489        assert!(should_skip_compression(100, 1000));
490        // 1000 raw, 500 encoded → 2× → don't skip
491        assert!(!should_skip_compression(500, 1000));
492        // 0 raw → skip
493        assert!(should_skip_compression(100, 0));
494    }
495
496    #[test]
497    fn roundtrip_zstd_compressible() {
498        let data = vec![42u8; 10_000];
499        let compressed = compress_block_with_codec(&data, CompressionCodec::Zstd).unwrap();
500        assert!(compressed.len() < data.len());
501        let decompressed = decompress_block(&compressed).unwrap();
502        assert_eq!(data, decompressed);
503    }
504
505    #[test]
506    fn roundtrip_zstd_level_9() {
507        let data = vec![42u8; 10_000];
508        let compressed = compress_block_zstd(&data, 9).unwrap();
509        let decompressed = decompress_block(&compressed).unwrap();
510        assert_eq!(data, decompressed);
511    }
512
513    #[test]
514    fn roundtrip_zstd_incompressible() {
515        let data: Vec<u8> = (0..1000).map(|i| (i * 37 + 13) as u8).collect();
516        let compressed = compress_block_with_codec(&data, CompressionCodec::Zstd).unwrap();
517        let decompressed = decompress_block(&compressed).unwrap();
518        assert_eq!(data, decompressed);
519    }
520
521    #[test]
522    fn roundtrip_no_compression_codec() {
523        let data = vec![42u8; 100];
524        let compressed = compress_block_with_codec(&data, CompressionCodec::None).unwrap();
525        // Should store uncompressed
526        assert_eq!(compressed.len(), 9 + data.len());
527        let decompressed = decompress_block(&compressed).unwrap();
528        assert_eq!(data, decompressed);
529    }
530
531    #[test]
532    fn zstd_better_ratio_than_lz4() {
533        // Regular repeating data — Zstd should compress better
534        let data: Vec<u8> = (0..50_000).map(|i| (i % 256) as u8).collect();
535        let lz4 = compress_block_with_codec(&data, CompressionCodec::Lz4).unwrap();
536        let zstd = compress_block_with_codec(&data, CompressionCodec::Zstd).unwrap();
537        assert!(
538            zstd.len() <= lz4.len(),
539            "Zstd {} should be <= LZ4 {}",
540            zstd.len(),
541            lz4.len()
542        );
543    }
544
545    #[test]
546    fn corrupt_data_detected() {
547        // Less than 9-byte header → error
548        assert!(decompress_block(&[0; 3]).is_err());
549        assert!(decompress_block(&[0; 8]).is_err());
550    }
551
552    #[test]
553    fn unknown_codec_tag_rejected() {
554        // Valid header size but unknown codec tag 0xFF
555        let mut data = vec![0xFF, 0, 0, 0, 1, 5, 0, 0, 0];
556        data.extend_from_slice(&[1, 2, 3, 4, 5]); // fake payload
557        assert!(decompress_block(&data).is_err());
558    }
559
560    #[test]
561    fn p09_train_dict_too_few_samples() {
562        let samples: Vec<&[u8]> = vec![b"hello"; 3]; // below MIN_DICT_SAMPLES
563        let result = train_zstd_dictionary(&samples).unwrap();
564        assert!(result.is_none(), "should return None for too few samples");
565    }
566
567    #[test]
568    fn p09_zstd_dict_compress_decompress_roundtrip() {
569        // Generate enough samples for dictionary training (need >= MIN_DICT_SAMPLE_BYTES total)
570        let sample_data: Vec<Vec<u8>> = (0..300)
571            .map(|i| {
572                format!(
573                    "host-{i},cpu=idle,region=us-east-1,dc=dc-{},rack=rack-{},value={}",
574                    i % 4,
575                    i % 16,
576                    i * 42
577                )
578                .into_bytes()
579            })
580            .collect();
581        let sample_refs: Vec<&[u8]> = sample_data.iter().map(std::vec::Vec::as_slice).collect();
582
583        let dict = train_zstd_dictionary(&sample_refs)
584            .unwrap()
585            .expect("should produce a dictionary");
586        assert!(!dict.is_empty());
587
588        // Compress a block with the dictionary
589        let payload = b"host-99,cpu=idle,region=us-east-1,dc=dc-3,rack=rack-15,value=4242";
590        let compressed = compress_block_zstd_dict(payload, 3, &dict).unwrap();
591
592        // Decompress with dictionary
593        let decompressed = decompress_block_with_optional_dict(&compressed, Some(&dict)).unwrap();
594        assert_eq!(decompressed, payload.as_slice());
595    }
596}