1use chronix_core::config::CompressionCodec;
20
21use crate::segment::error::{Result, SegmentError};
22
23const MAX_BLOCK_SIZE: usize = u32::MAX as usize;
25
26const AUTO_SKIP_RATIO: f64 = 8.0;
30
31const COMPRESSION_HEADER_SIZE: usize = 9;
35
36const CODEC_TAG_NONE: u8 = 0x00;
38const CODEC_TAG_LZ4: u8 = 0x01;
40const CODEC_TAG_ZSTD: u8 = 0x02;
42const CODEC_TAG_ZSTD_DICT: u8 = 0x03;
44
45const 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
54pub fn compress_block(data: &[u8]) -> Result<Vec<u8>> {
63 compress_block_with_codec(data, CompressionCodec::Lz4)
64}
65
66pub 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 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
113const DEFAULT_ZSTD_LEVEL: i32 = 3;
115
116pub const ZSTD_HIGH_LEVEL: i32 = 9;
118
119pub 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()); 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
197const MIN_DICT_SAMPLES: usize = 8;
203const MIN_DICT_SAMPLE_BYTES: usize = 16_384;
204
205const DICT_MAX_SIZE: usize = 16 * 1024;
207
208pub 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
235pub 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
297pub 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
331const MAX_UNCOMPRESSED_SIZE: usize = 256 * 1024 * 1024;
336
337pub fn decompress_block(data: &[u8]) -> Result<Vec<u8>> {
350 decompress_block_with_optional_dict(data, None)
351}
352
353pub 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 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 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#[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 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 assert!(should_skip_compression(100, 1000));
490 assert!(!should_skip_compression(500, 1000));
492 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 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 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 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 let mut data = vec![0xFF, 0, 0, 0, 1, 5, 0, 0, 0];
556 data.extend_from_slice(&[1, 2, 3, 4, 5]); 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]; 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 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 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 let decompressed = decompress_block_with_optional_dict(&compressed, Some(&dict)).unwrap();
594 assert_eq!(decompressed, payload.as_slice());
595 }
596}