Skip to main content

runmat_snapshot/
compression.rs

1//! High-performance compression for snapshot data
2//!
3//! Multi-tier compression system with adaptive algorithm selection.
4//! Optimized for fast decompression during runtime startup.
5
6use runmat_time::Instant;
7use std::collections::HashMap;
8#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
9use std::convert::TryFrom;
10#[cfg(all(feature = "compression", target_arch = "wasm32"))]
11use std::io::{Cursor, Read};
12
13use crate::format::{CompressionAlgorithm, CompressionInfo};
14use crate::{SnapshotError, SnapshotResult};
15#[cfg(all(feature = "compression", target_arch = "wasm32"))]
16use ruzstd::decoding::StreamingDecoder;
17
18/// Compression engine with adaptive algorithm selection
19pub struct CompressionEngine {
20    /// Configuration
21    config: CompressionConfig,
22
23    /// Performance statistics
24    stats: CompressionStats,
25}
26
27/// Compression configuration
28#[derive(Debug, Clone)]
29pub struct CompressionConfig {
30    /// Default compression level (1-9)
31    pub default_level: u32,
32
33    /// Enable adaptive algorithm selection
34    pub adaptive_selection: bool,
35
36    /// Size threshold for compression (bytes)
37    pub size_threshold: usize,
38
39    /// Target compression ratio
40    pub target_ratio: f64,
41
42    /// Maximum compression time
43    pub max_compression_time: std::time::Duration,
44
45    /// Prefer speed over ratio
46    pub prefer_speed: bool,
47}
48
49/// Compression performance statistics
50#[derive(Debug, Clone, Default)]
51pub struct CompressionStats {
52    /// Compression attempts by algorithm
53    pub attempts: HashMap<String, u64>,
54
55    /// Average compression ratios
56    pub ratios: HashMap<String, f64>,
57
58    /// Average compression times
59    pub times: HashMap<String, std::time::Duration>,
60
61    /// Total bytes processed
62    pub total_bytes: u64,
63
64    /// Total time spent compressing
65    pub total_time: std::time::Duration,
66}
67
68/// Compression result with metadata
69#[derive(Debug)]
70pub struct CompressionResult {
71    /// Compressed data
72    pub data: Vec<u8>,
73
74    /// Compression information
75    pub info: CompressionInfo,
76
77    /// Performance metrics
78    pub metrics: CompressionMetrics,
79}
80
81/// Compression performance metrics
82#[derive(Debug)]
83pub struct CompressionMetrics {
84    /// Compression time
85    pub compression_time: std::time::Duration,
86
87    /// Compression ratio (compressed/original)
88    pub compression_ratio: f64,
89
90    /// Compression throughput (bytes/second)
91    pub throughput: f64,
92
93    /// Memory usage during compression
94    pub memory_usage: usize,
95}
96
97impl CompressionEngine {
98    /// Create a new compression engine
99    pub fn new(config: CompressionConfig) -> Self {
100        Self {
101            config,
102            stats: CompressionStats::default(),
103        }
104    }
105
106    /// Compress data using optimal algorithm
107    pub fn compress(&mut self, data: &[u8]) -> SnapshotResult<CompressionResult> {
108        // Skip compression for small data
109        if data.len() < self.config.size_threshold {
110            return Ok(CompressionResult {
111                data: data.to_vec(),
112                info: CompressionInfo {
113                    algorithm: CompressionAlgorithm::None,
114                    level: 0,
115                    parameters: HashMap::new(),
116                },
117                metrics: CompressionMetrics {
118                    compression_time: std::time::Duration::ZERO,
119                    compression_ratio: 1.0,
120                    throughput: 0.0,
121                    memory_usage: data.len(),
122                },
123            });
124        }
125
126        let algorithm = if self.config.adaptive_selection {
127            self.select_optimal_algorithm(data)?
128        } else {
129            CompressionAlgorithm::Lz4 {
130                fast: self.config.prefer_speed,
131            }
132        };
133
134        self.compress_with_algorithm(data, algorithm)
135    }
136
137    /// Decompress data
138    pub fn decompress(&self, data: &[u8], info: &CompressionInfo) -> SnapshotResult<Vec<u8>> {
139        let start = Instant::now();
140
141        let result = match &info.algorithm {
142            CompressionAlgorithm::None => data.to_vec(),
143
144            #[cfg(feature = "compression")]
145            CompressionAlgorithm::Lz4 { .. } => {
146                // Extract the original size from parameters
147                let original_size = info
148                    .parameters
149                    .get("original_size")
150                    .and_then(|s| s.parse::<usize>().ok())
151                    .unwrap_or_else(|| {
152                        // Fallback: estimate from compressed size
153                        data.len() * 4
154                    });
155
156                decompress_lz4_block(data, original_size)?
157            }
158
159            #[cfg(feature = "compression")]
160            CompressionAlgorithm::Zstd { dictionary } => {
161                if dictionary.is_some() {
162                    // Dictionary decompression not supported by current backend, fall back to standard
163                    log::debug!(
164                        "Dictionary decompression not available, using standard decompression"
165                    );
166                }
167
168                decompress_zstd_block(data)?
169            }
170
171            #[cfg(not(feature = "compression"))]
172            _ => {
173                return Err(SnapshotError::Configuration {
174                    message: "Compression feature not enabled".to_string(),
175                });
176            }
177        };
178
179        let duration = start.elapsed();
180        log::debug!(
181            "Decompressed {} bytes to {} bytes in {:?}",
182            data.len(),
183            result.len(),
184            duration
185        );
186
187        Ok(result)
188    }
189
190    /// Select optimal compression algorithm for data
191    pub fn select_optimal_algorithm(&self, data: &[u8]) -> SnapshotResult<CompressionAlgorithm> {
192        // Analyze data characteristics
193        let characteristics = self.analyze_data(data);
194
195        // Choose algorithm based on characteristics and config
196        if characteristics.entropy > 0.9 {
197            // High entropy data - compression won't help much
198            return Ok(CompressionAlgorithm::None);
199        }
200
201        if self.config.prefer_speed {
202            Ok(CompressionAlgorithm::Lz4 { fast: true })
203        } else if characteristics.repetition_ratio > 0.7 {
204            // High repetition - ZSTD will work well
205            Ok(CompressionAlgorithm::Zstd { dictionary: None })
206        } else {
207            // Balanced choice
208            Ok(CompressionAlgorithm::Lz4 { fast: false })
209        }
210    }
211
212    /// Compress with specific algorithm
213    pub fn compress_with_algorithm(
214        &mut self,
215        data: &[u8],
216        algorithm: CompressionAlgorithm,
217    ) -> SnapshotResult<CompressionResult> {
218        let start = Instant::now();
219        let start_memory = self.estimate_memory_usage();
220
221        let (compressed_data, algorithm_used) = match algorithm {
222            CompressionAlgorithm::None => (data.to_vec(), algorithm),
223
224            #[cfg(feature = "compression")]
225            CompressionAlgorithm::Lz4 { fast } => {
226                let compressed = compress_lz4_block(data, fast)?;
227                (compressed, CompressionAlgorithm::Lz4 { fast })
228            }
229
230            #[cfg(feature = "compression")]
231            CompressionAlgorithm::Zstd { dictionary } => {
232                let level = if self.config.prefer_speed {
233                    1
234                } else {
235                    self.config.default_level as i32
236                };
237
238                if dictionary.is_some() {
239                    // Dictionary compression not supported by current backend, fall back to standard
240                    log::debug!("Dictionary compression not available, using standard compression");
241                }
242
243                let compressed = compress_zstd_block(data, level)?;
244
245                (compressed, CompressionAlgorithm::Zstd { dictionary })
246            }
247
248            #[cfg(not(feature = "compression"))]
249            _ => {
250                return Err(SnapshotError::Configuration {
251                    message: "Compression feature not enabled".to_string(),
252                });
253            }
254        };
255
256        let compression_time = start.elapsed();
257        let end_memory = self.estimate_memory_usage();
258
259        // Check if compression was effective
260        let compression_ratio = compressed_data.len() as f64 / data.len() as f64;
261        if compression_ratio > 0.95 && !matches!(algorithm_used, CompressionAlgorithm::None) {
262            // Compression wasn't effective, use uncompressed
263            log::debug!(
264                "Compression ratio {compression_ratio:.3} not effective, using uncompressed data"
265            );
266            return Ok(CompressionResult {
267                data: data.to_vec(),
268                info: CompressionInfo {
269                    algorithm: CompressionAlgorithm::None,
270                    level: 0,
271                    parameters: HashMap::new(),
272                },
273                metrics: CompressionMetrics {
274                    compression_time,
275                    compression_ratio: 1.0,
276                    throughput: data.len() as f64 / compression_time.as_secs_f64(),
277                    memory_usage: end_memory.saturating_sub(start_memory),
278                },
279            });
280        }
281
282        // Update statistics
283        self.update_stats(
284            &algorithm_used,
285            data.len(),
286            compressed_data.len(),
287            compression_time,
288        );
289
290        let metrics = CompressionMetrics {
291            compression_time,
292            compression_ratio,
293            throughput: data.len() as f64 / compression_time.as_secs_f64(),
294            memory_usage: end_memory.saturating_sub(start_memory),
295        };
296
297        let mut parameters = HashMap::new();
298        parameters.insert("original_size".to_string(), data.len().to_string());
299        parameters.insert(
300            "compressed_size".to_string(),
301            compressed_data.len().to_string(),
302        );
303
304        Ok(CompressionResult {
305            data: compressed_data,
306            info: CompressionInfo {
307                algorithm: algorithm_used,
308                level: self.config.default_level,
309                parameters,
310            },
311            metrics,
312        })
313    }
314
315    /// Analyze data characteristics for algorithm selection
316    pub fn analyze_data(&self, data: &[u8]) -> DataCharacteristics {
317        let mut characteristics = DataCharacteristics {
318            entropy: 0.0,
319            repetition_ratio: 0.0,
320            pattern_density: 0.0,
321            ascii_ratio: 0.0,
322        };
323
324        if data.is_empty() {
325            return characteristics;
326        }
327
328        // Calculate entropy (simplified)
329        let mut byte_counts = [0u32; 256];
330        let mut ascii_count = 0;
331
332        for &byte in data {
333            byte_counts[byte as usize] += 1;
334            if byte.is_ascii() {
335                ascii_count += 1;
336            }
337        }
338
339        let len = data.len() as f64;
340        let mut entropy = 0.0;
341        for &count in &byte_counts {
342            if count > 0 {
343                let p = count as f64 / len;
344                entropy -= p * p.log2();
345            }
346        }
347        characteristics.entropy = entropy / 8.0; // Normalize to 0-1
348        characteristics.ascii_ratio = ascii_count as f64 / len;
349
350        // Analyze repetition patterns (simplified)
351        let mut repetition_count = 0;
352        let window_size = 64.min(data.len() / 2);
353
354        if data.len() > window_size * 2 {
355            for i in 0..data.len() - window_size {
356                for j in (i + window_size)..data.len() - window_size {
357                    if data[i..i + window_size] == data[j..j + window_size] {
358                        repetition_count += 1;
359                        break;
360                    }
361                }
362            }
363            characteristics.repetition_ratio =
364                repetition_count as f64 / (data.len() - window_size) as f64;
365        }
366
367        characteristics
368    }
369
370    /// Update compression statistics
371    fn update_stats(
372        &mut self,
373        algorithm: &CompressionAlgorithm,
374        original_size: usize,
375        compressed_size: usize,
376        time: std::time::Duration,
377    ) {
378        let algo_name = match algorithm {
379            CompressionAlgorithm::None => "none",
380            CompressionAlgorithm::Lz4 { fast } => {
381                if *fast {
382                    "lz4-fast"
383                } else {
384                    "lz4"
385                }
386            }
387            CompressionAlgorithm::Zstd { .. } => "zstd",
388        };
389
390        let ratio = compressed_size as f64 / original_size as f64;
391
392        *self
393            .stats
394            .attempts
395            .entry(algo_name.to_string())
396            .or_insert(0) += 1;
397
398        // Update running averages
399        let attempts = self.stats.attempts[algo_name];
400        if let Some(existing_ratio) = self.stats.ratios.get_mut(algo_name) {
401            *existing_ratio = (*existing_ratio * (attempts - 1) as f64 + ratio) / attempts as f64;
402        } else {
403            self.stats.ratios.insert(algo_name.to_string(), ratio);
404        }
405
406        if let Some(existing_time) = self.stats.times.get_mut(algo_name) {
407            *existing_time = (*existing_time * (attempts as u32 - 1) + time) / attempts as u32;
408        } else {
409            self.stats.times.insert(algo_name.to_string(), time);
410        }
411
412        self.stats.total_bytes += original_size as u64;
413        self.stats.total_time += time;
414    }
415
416    /// Estimate current memory usage
417    fn estimate_memory_usage(&self) -> usize {
418        // Simplified memory estimation
419        std::mem::size_of::<Self>()
420            + self.stats.attempts.len() * 64
421            + self.stats.ratios.len() * 64
422            + self.stats.times.len() * 64
423    }
424
425    /// Get compression statistics
426    pub fn stats(&self) -> &CompressionStats {
427        &self.stats
428    }
429
430    /// Reset statistics
431    pub fn reset_stats(&mut self) {
432        self.stats = CompressionStats::default();
433    }
434}
435
436/// Data characteristics for algorithm selection
437#[derive(Debug)]
438pub struct DataCharacteristics {
439    /// Shannon entropy (0-1, higher = more random)
440    pub entropy: f64,
441
442    /// Repetition ratio (0-1, higher = more repetitive)
443    pub repetition_ratio: f64,
444
445    /// Pattern density (0-1, higher = more patterns)
446    pub pattern_density: f64,
447
448    /// ASCII text ratio (0-1, higher = more text)
449    pub ascii_ratio: f64,
450}
451
452impl Default for CompressionConfig {
453    fn default() -> Self {
454        Self {
455            default_level: 6,
456            adaptive_selection: true,
457            size_threshold: 1024, // Don't compress < 1KB
458            target_ratio: 0.7,
459            max_compression_time: std::time::Duration::from_secs(30),
460            prefer_speed: false,
461        }
462    }
463}
464
465impl CompressionStats {
466    /// Get overall compression ratio
467    pub fn overall_ratio(&self) -> f64 {
468        if self.ratios.is_empty() {
469            1.0
470        } else {
471            self.ratios.values().sum::<f64>() / self.ratios.len() as f64
472        }
473    }
474
475    /// Get overall throughput
476    pub fn overall_throughput(&self) -> f64 {
477        if self.total_time.as_secs_f64() > 0.0 {
478            self.total_bytes as f64 / self.total_time.as_secs_f64()
479        } else {
480            0.0
481        }
482    }
483
484    /// Get best performing algorithm
485    pub fn best_algorithm(&self) -> Option<String> {
486        self.ratios
487            .iter()
488            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
489            .map(|(name, _)| name.clone())
490    }
491}
492
493#[cfg(feature = "compression")]
494fn decompress_lz4_block(data: &[u8], original_size: usize) -> SnapshotResult<Vec<u8>> {
495    let size_hint = original_size.max(1);
496
497    #[cfg(target_arch = "wasm32")]
498    {
499        let min_uncompressed = size_hint.max(data.len().saturating_mul(4));
500        lz4_flex::block::decompress(data, min_uncompressed).map_err(|e| {
501            SnapshotError::Compression {
502                message: format!("LZ4 decompression failed: {e}"),
503            }
504        })
505    }
506    #[cfg(not(target_arch = "wasm32"))]
507    {
508        let clamped = size_hint.min(i32::MAX as usize);
509        let size_i32 = i32::try_from(clamped).unwrap_or(i32::MAX);
510        lz4::block::decompress(data, Some(size_i32)).map_err(|e| SnapshotError::Compression {
511            message: format!("LZ4 decompression failed: {e}"),
512        })
513    }
514}
515
516#[cfg(all(feature = "compression", target_arch = "wasm32"))]
517fn decompress_zstd_block(data: &[u8]) -> SnapshotResult<Vec<u8>> {
518    let cursor = Cursor::new(data);
519    let mut decoder = StreamingDecoder::new(cursor).map_err(|e| SnapshotError::Compression {
520        message: format!("ZSTD decompression failed: {e}"),
521    })?;
522    let mut output = Vec::new();
523    decoder
524        .read_to_end(&mut output)
525        .map_err(|e| SnapshotError::Compression {
526            message: format!("ZSTD decompression failed: {e}"),
527        })?;
528    Ok(output)
529}
530
531#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
532fn decompress_zstd_block(data: &[u8]) -> SnapshotResult<Vec<u8>> {
533    zstd::decode_all(data).map_err(|e| SnapshotError::Compression {
534        message: format!("ZSTD decompression failed: {e}"),
535    })
536}
537
538#[cfg(feature = "compression")]
539fn compress_lz4_block(data: &[u8], fast: bool) -> SnapshotResult<Vec<u8>> {
540    #[cfg(target_arch = "wasm32")]
541    {
542        let compressed = lz4_flex::block::compress(data);
543        if !fast {
544            log::trace!("High-compression LZ4 mode not available on wasm; using fast path");
545        }
546        Ok(compressed)
547    }
548    #[cfg(not(target_arch = "wasm32"))]
549    {
550        let result = if fast {
551            lz4::block::compress(data, None, false)
552        } else {
553            lz4::block::compress(
554                data,
555                Some(lz4::block::CompressionMode::HIGHCOMPRESSION(12)),
556                false,
557            )
558        };
559
560        result.map_err(|e| SnapshotError::Compression {
561            message: format!("LZ4 compression failed: {e}"),
562        })
563    }
564}
565
566#[cfg(all(feature = "compression", target_arch = "wasm32"))]
567fn compress_zstd_block(_data: &[u8], _level: i32) -> SnapshotResult<Vec<u8>> {
568    Err(SnapshotError::Configuration {
569        message: "ZSTD compression is not supported on wasm targets".to_string(),
570    })
571}
572
573#[cfg(all(feature = "compression", not(target_arch = "wasm32")))]
574fn compress_zstd_block(data: &[u8], level: i32) -> SnapshotResult<Vec<u8>> {
575    zstd::encode_all(data, level).map_err(|e| SnapshotError::Compression {
576        message: format!("ZSTD compression failed: {e}"),
577    })
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn test_compression_config_default() {
586        let config = CompressionConfig::default();
587        assert_eq!(config.default_level, 6);
588        assert!(config.adaptive_selection);
589        assert_eq!(config.size_threshold, 1024);
590    }
591
592    #[test]
593    fn test_compression_engine_creation() {
594        let config = CompressionConfig::default();
595        let engine = CompressionEngine::new(config);
596        assert_eq!(engine.stats.total_bytes, 0);
597    }
598
599    #[test]
600    fn test_small_data_compression() {
601        let mut engine = CompressionEngine::new(CompressionConfig::default());
602        let small_data = vec![1, 2, 3]; // < threshold
603
604        let result = engine.compress(&small_data).unwrap();
605        assert_eq!(result.data, small_data);
606        assert!(matches!(result.info.algorithm, CompressionAlgorithm::None));
607    }
608
609    #[cfg(feature = "compression")]
610    #[test]
611    fn test_lz4_compression() {
612        let mut engine = CompressionEngine::new(CompressionConfig {
613            adaptive_selection: false,
614            prefer_speed: true,
615            size_threshold: 10, // Lower threshold to ensure compression is attempted
616            ..CompressionConfig::default()
617        });
618
619        let data = b"Hello, World! This is a longer test string for LZ4 compression.".repeat(50);
620        let result = engine.compress(&data).unwrap();
621
622        assert!(
623            result.data.len() <= data.len(),
624            "Compression did not reduce size"
625        );
626
627        // Check that compression was attempted (either compressed or fell back to None if not effective)
628        assert!(matches!(
629            result.info.algorithm,
630            CompressionAlgorithm::Lz4 { .. } | CompressionAlgorithm::None
631        ));
632
633        // Test decompression should always work
634        match engine.decompress(&result.data, &result.info) {
635            Ok(decompressed) => {
636                assert_eq!(decompressed, data);
637            }
638            Err(e) => {
639                // If decompression fails, ensure we're dealing with uncompressed data
640                if matches!(result.info.algorithm, CompressionAlgorithm::None) {
641                    assert_eq!(result.data, data);
642                } else {
643                    panic!("Decompression failed: {e:?}");
644                }
645            }
646        }
647    }
648
649    #[test]
650    fn test_compression_stats() {
651        let stats = CompressionStats::default();
652        assert_eq!(stats.overall_ratio(), 1.0);
653        assert_eq!(stats.overall_throughput(), 0.0);
654        assert!(stats.best_algorithm().is_none());
655    }
656
657    #[test]
658    fn test_data_characteristics() {
659        let engine = CompressionEngine::new(CompressionConfig::default());
660
661        // Test with ASCII data
662        let ascii_data = b"Hello, World!".to_vec();
663        let characteristics = engine.analyze_data(&ascii_data);
664        assert!(characteristics.ascii_ratio > 0.9);
665
666        // Test with binary data
667        let binary_data = vec![0u8, 1u8, 2u8, 255u8];
668        let characteristics = engine.analyze_data(&binary_data);
669        assert!(characteristics.ascii_ratio < 1.0);
670    }
671}