Skip to main content

faucet_core/
compression.rs

1//! Transparent gzip / zstd compression wrappers for file-shaped connectors.
2//!
3//! Behind the `compression` feature. Exposes:
4//! - [`CompressionConfig`] — user-facing enum (`None`, `Gzip`, `Zstd`, `Auto`).
5//! - [`Compression`] — internal post-resolution enum (no `Auto`).
6//! - `wrap_async_reader` / `wrap_async_writer` — for `stream_pages` and async sinks.
7//! - `wrap_sync_reader` / `wrap_sync_writer` — for `spawn_blocking` paths.
8//! - [`compress_buf`] — one-shot in-memory compression for S3/GCS sink uploads.
9//! - [`warn_mismatch`] — log-once helper when explicit codec disagrees with the
10//!   filename extension.
11
12use crate::FaucetError;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::pin::Pin;
16use tokio::io::{AsyncBufRead, AsyncWrite};
17
18/// User-facing compression config. Defaults to [`Auto`](Self::Auto).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum CompressionConfig {
22    None,
23    Gzip,
24    Zstd,
25    /// Detect from filename suffix: `.gz` → Gzip, `.zst` → Zstd, anything else → None.
26    #[default]
27    Auto,
28}
29
30/// Internal post-resolution codec. No `Auto` variant — call [`CompressionConfig::resolve`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Compression {
33    None,
34    Gzip,
35    Zstd,
36}
37
38impl CompressionConfig {
39    /// Resolve `Auto` against a filename. Non-`Auto` variants pass through.
40    pub fn resolve(self, path: &str) -> Compression {
41        match self {
42            Self::None => Compression::None,
43            Self::Gzip => Compression::Gzip,
44            Self::Zstd => Compression::Zstd,
45            Self::Auto => detect_from_path(path),
46        }
47    }
48}
49
50/// Codec detection by filename suffix. Case-insensitive. Looks at the final
51/// extension only: `foo.csv.gz` → Gzip, `foo.gz.zst` → Zstd.
52pub fn detect_from_path(path: &str) -> Compression {
53    let lower = path.to_ascii_lowercase();
54    if lower.ends_with(".gz") {
55        Compression::Gzip
56    } else if lower.ends_with(".zst") {
57        Compression::Zstd
58    } else {
59        Compression::None
60    }
61}
62
63/// Wrap an async buffered reader with a streaming decoder. `None` passes through.
64pub fn wrap_async_reader<'a, R>(
65    r: R,
66    c: Compression,
67) -> Pin<Box<dyn AsyncBufRead + Send + Unpin + 'a>>
68where
69    R: AsyncBufRead + Send + Unpin + 'a,
70{
71    match c {
72        Compression::None => Box::pin(r),
73        Compression::Gzip => {
74            let mut dec = async_compression::tokio::bufread::GzipDecoder::new(r);
75            dec.multiple_members(true);
76            Box::pin(tokio::io::BufReader::new(dec))
77        }
78        // `async-compression`'s `multiple_members` flag is codec-agnostic and
79        // defaults to `false`, so the async zstd decoder stops after the FIRST
80        // frame — silently dropping every later frame of a multi-frame `.zst`
81        // file (the jsonl/csv sinks emit one frame per flush segment). Enable it
82        // so concatenated frames are all read (audit #321 H8). The sync
83        // `zstd::stream::read::Decoder` already concatenates frames until EOF by
84        // default (only `.single_frame()` limits it), so it needs no change.
85        Compression::Zstd => {
86            let mut dec = async_compression::tokio::bufread::ZstdDecoder::new(r);
87            dec.multiple_members(true);
88            Box::pin(tokio::io::BufReader::new(dec))
89        }
90    }
91}
92
93/// Wrap an async writer with a streaming encoder. `None` passes through.
94/// Callers must call [`tokio::io::AsyncWriteExt::shutdown`] on the returned
95/// writer to flush the trailer before the underlying writer is dropped.
96pub fn wrap_async_writer<'a, W>(
97    w: W,
98    c: Compression,
99) -> Pin<Box<dyn AsyncWrite + Send + Unpin + 'a>>
100where
101    W: AsyncWrite + Send + Unpin + 'a,
102{
103    match c {
104        Compression::None => Box::pin(w),
105        Compression::Gzip => Box::pin(async_compression::tokio::write::GzipEncoder::new(w)),
106        Compression::Zstd => Box::pin(async_compression::tokio::write::ZstdEncoder::new(w)),
107    }
108}
109
110/// Wrap a sync reader with a streaming decoder. `None` passes through.
111pub fn wrap_sync_reader<'a, R>(r: R, c: Compression) -> Box<dyn std::io::Read + Send + 'a>
112where
113    R: std::io::Read + Send + 'a,
114{
115    match c {
116        Compression::None => Box::new(r),
117        Compression::Gzip => Box::new(flate2::read::MultiGzDecoder::new(r)),
118        Compression::Zstd => Box::new(
119            zstd::stream::read::Decoder::new(r)
120                .expect("zstd decoder construction is infallible for any Read"),
121        ),
122    }
123}
124
125/// Wrap a sync writer with a streaming encoder. `None` passes through.
126///
127/// The returned writer finalises the encoder when dropped (gzip writes its
128/// 8-byte trailer; zstd's `auto_finish` adapter writes the frame epilogue).
129/// Because the concrete encoder type is erased behind `Box<dyn Write>`,
130/// callers cannot invoke `flate2`'s or `zstd`'s `finish()` to capture the
131/// trailer-write `io::Error`. Callers can `flush()` to drain the encoder's
132/// internal buffer mid-stream, but trailer-write errors on drop are
133/// negligibly rare and are silently swallowed.
134///
135/// Connectors using this wrapper should drop the box inside a
136/// `spawn_blocking` task body so the trailer write does not block the
137/// async runtime, and rely on the surrounding `write_all` / `flush` calls
138/// to surface earlier I/O errors.
139pub fn wrap_sync_writer<'a, W>(w: W, c: Compression) -> Box<dyn std::io::Write + Send + 'a>
140where
141    W: std::io::Write + Send + 'a,
142{
143    match c {
144        Compression::None => Box::new(w),
145        Compression::Gzip => Box::new(flate2::write::GzEncoder::new(
146            w,
147            flate2::Compression::default(),
148        )),
149        Compression::Zstd => Box::new(
150            zstd::stream::write::Encoder::new(w, 0)
151                .expect("zstd encoder construction is infallible")
152                .auto_finish(),
153        ),
154    }
155}
156
157/// A sync compressing writer that **retains the concrete encoder** so its
158/// finalisation error can be captured, unlike the `Box<dyn Write>` returned by
159/// [`wrap_sync_writer`] (#78/#41).
160///
161/// Call [`finish`](Self::finish) when done: it writes the codec's trailer
162/// (gzip's 8-byte CRC/length footer, zstd's frame epilogue) and returns the
163/// inner writer, surfacing any I/O error instead of swallowing it on drop.
164/// If the value is dropped without `finish`, the zstd variant does **not**
165/// emit its epilogue — always `finish` for a valid stream.
166pub enum SyncCompressWriter<W: std::io::Write> {
167    Plain(W),
168    Gzip(flate2::write::GzEncoder<W>),
169    Zstd(zstd::stream::write::Encoder<'static, W>),
170}
171
172impl<W: std::io::Write> SyncCompressWriter<W> {
173    /// Finalise the stream and return the inner writer, propagating any
174    /// trailer-write error.
175    pub fn finish(self) -> std::io::Result<W> {
176        match self {
177            SyncCompressWriter::Plain(w) => Ok(w),
178            SyncCompressWriter::Gzip(e) => e.finish(),
179            SyncCompressWriter::Zstd(e) => e.finish(),
180        }
181    }
182}
183
184impl<W: std::io::Write> std::io::Write for SyncCompressWriter<W> {
185    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
186        match self {
187            SyncCompressWriter::Plain(w) => w.write(buf),
188            SyncCompressWriter::Gzip(e) => e.write(buf),
189            SyncCompressWriter::Zstd(e) => e.write(buf),
190        }
191    }
192
193    fn flush(&mut self) -> std::io::Result<()> {
194        match self {
195            SyncCompressWriter::Plain(w) => w.flush(),
196            SyncCompressWriter::Gzip(e) => e.flush(),
197            SyncCompressWriter::Zstd(e) => e.flush(),
198        }
199    }
200}
201
202/// Wrap a sync writer in a [`SyncCompressWriter`] for the given codec. Prefer
203/// this over [`wrap_sync_writer`] when you need to detect a finalisation
204/// error: call [`SyncCompressWriter::finish`] before dropping.
205pub fn sync_compress_writer<W: std::io::Write>(w: W, c: Compression) -> SyncCompressWriter<W> {
206    match c {
207        Compression::None => SyncCompressWriter::Plain(w),
208        Compression::Gzip => SyncCompressWriter::Gzip(flate2::write::GzEncoder::new(
209            w,
210            flate2::Compression::default(),
211        )),
212        Compression::Zstd => SyncCompressWriter::Zstd(
213            zstd::stream::write::Encoder::new(w, 0)
214                .expect("zstd encoder construction is infallible"),
215        ),
216    }
217}
218
219/// One-shot in-memory compression. Used by S3 and GCS sinks that build a full
220/// `Vec<u8>` body before upload.
221pub fn compress_buf(data: &[u8], c: Compression) -> Result<Vec<u8>, FaucetError> {
222    use std::io::Write;
223    match c {
224        Compression::None => Ok(data.to_vec()),
225        Compression::Gzip => {
226            let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
227            enc.write_all(data)
228                .map_err(|e| FaucetError::Sink(format!("gzip compress failed: {e}")))?;
229            enc.finish()
230                .map_err(|e| FaucetError::Sink(format!("gzip finalise failed: {e}")))
231        }
232        Compression::Zstd => zstd::stream::encode_all(data, 0)
233            .map_err(|e| FaucetError::Sink(format!("zstd compress failed: {e}"))),
234    }
235}
236
237/// Log a one-shot warning when the explicit codec disagrees with the
238/// filename's detected codec. Deduplicates per `(path, declared)` pair across
239/// the whole process so a million-object scan does not flood logs.
240pub fn warn_mismatch(path: &str, declared: Compression) {
241    use std::collections::HashSet;
242    use std::sync::{Mutex, OnceLock};
243    /// Hard cap on the dedup set so a run touching an unbounded number of
244    /// *distinct* mismatched paths can't leak memory for the process lifetime
245    /// (#146 R). Beyond this, mismatches re-warn rather than being tracked —
246    /// hitting thousands of distinct mismatched paths is already pathological.
247    const MAX_SEEN: usize = 4096;
248    static SEEN: OnceLock<Mutex<HashSet<(String, Compression)>>> = OnceLock::new();
249    let detected = detect_from_path(path);
250    if detected == declared {
251        return;
252    }
253    let key = (path.to_string(), declared);
254    let should_warn = {
255        let mut seen = SEEN
256            .get_or_init(|| Mutex::new(HashSet::new()))
257            .lock()
258            .expect("compression mismatch log mutex poisoned");
259        if seen.len() >= MAX_SEEN {
260            true
261        } else {
262            seen.insert(key)
263        }
264    };
265    if should_warn {
266        tracing::warn!(
267            path = %path,
268            declared = ?declared,
269            detected = ?detected,
270            "compression codec mismatch — explicit config wins, filename extension ignored",
271        );
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
279
280    #[test]
281    fn detect_extensions() {
282        assert_eq!(detect_from_path("foo.jsonl"), Compression::None);
283        assert_eq!(detect_from_path("foo.json.gz"), Compression::Gzip);
284        assert_eq!(detect_from_path("foo.csv.zst"), Compression::Zstd);
285        assert_eq!(detect_from_path("FOO.GZ"), Compression::Gzip);
286        assert_eq!(detect_from_path("a.gz.zst"), Compression::Zstd);
287        assert_eq!(detect_from_path(""), Compression::None);
288    }
289
290    #[test]
291    fn resolve_auto_uses_path() {
292        assert_eq!(CompressionConfig::Auto.resolve("foo.gz"), Compression::Gzip);
293        assert_eq!(
294            CompressionConfig::Auto.resolve("foo.zst"),
295            Compression::Zstd
296        );
297        assert_eq!(CompressionConfig::Auto.resolve("foo"), Compression::None);
298    }
299
300    #[test]
301    fn resolve_explicit_ignores_path() {
302        assert_eq!(
303            CompressionConfig::Gzip.resolve("foo.txt"),
304            Compression::Gzip
305        );
306        assert_eq!(CompressionConfig::None.resolve("foo.gz"), Compression::None);
307    }
308
309    #[test]
310    fn config_default_is_auto() {
311        assert_eq!(CompressionConfig::default(), CompressionConfig::Auto);
312    }
313
314    #[test]
315    fn config_serde_lowercase() {
316        // All four variants round-trip as lowercase strings.
317        for (variant, expected) in [
318            (CompressionConfig::None, "\"none\""),
319            (CompressionConfig::Gzip, "\"gzip\""),
320            (CompressionConfig::Zstd, "\"zstd\""),
321            (CompressionConfig::Auto, "\"auto\""),
322        ] {
323            let serialised = serde_json::to_string(&variant).unwrap();
324            assert_eq!(serialised, expected);
325            let deserialised: CompressionConfig = serde_json::from_str(expected).unwrap();
326            assert_eq!(deserialised, variant);
327        }
328    }
329
330    #[tokio::test]
331    async fn async_roundtrip_gzip() {
332        let original = b"hello, compressed world!\n".repeat(100);
333        let mut buf = Vec::new();
334        {
335            let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
336            w.write_all(&original).await.unwrap();
337            w.shutdown().await.unwrap();
338        }
339        let mut decompressed = Vec::new();
340        let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
341        r.read_to_end(&mut decompressed).await.unwrap();
342        assert_eq!(decompressed, original);
343    }
344
345    #[tokio::test]
346    async fn async_roundtrip_zstd() {
347        let original = b"zstd payload\n".repeat(50);
348        let mut buf = Vec::new();
349        {
350            let mut w = wrap_async_writer(&mut buf, Compression::Zstd);
351            w.write_all(&original).await.unwrap();
352            w.shutdown().await.unwrap();
353        }
354        let mut decompressed = Vec::new();
355        let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Zstd);
356        r.read_to_end(&mut decompressed).await.unwrap();
357        assert_eq!(decompressed, original);
358    }
359
360    #[tokio::test]
361    async fn async_multi_frame_zstd_reads_every_frame() {
362        // #321 H8: two concatenated zstd frames (as the file sinks produce, one
363        // per flush segment). Before the fix the async decoder stopped after the
364        // first frame and silently dropped the second.
365        let frame_a = b"first-frame-payload\n".repeat(10);
366        let frame_b = b"second-frame-payload\n".repeat(10);
367        let mut buf = Vec::new();
368        for frame in [&frame_a, &frame_b] {
369            let mut w = wrap_async_writer(&mut buf, Compression::Zstd);
370            w.write_all(frame).await.unwrap();
371            w.shutdown().await.unwrap();
372        }
373        let mut decompressed = Vec::new();
374        let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Zstd);
375        r.read_to_end(&mut decompressed).await.unwrap();
376        let mut expected = frame_a.clone();
377        expected.extend_from_slice(&frame_b);
378        assert_eq!(decompressed, expected, "both zstd frames must be read back");
379    }
380
381    #[test]
382    fn sync_multi_frame_zstd_reads_every_frame() {
383        // The sync decoder already concatenates frames by default; guard against
384        // a regression (e.g. an accidental `.single_frame()`).
385        use std::io::Read;
386        let frame_a = b"sync-frame-a\n".repeat(8);
387        let frame_b = b"sync-frame-b\n".repeat(8);
388        let mut buf = compress_buf(&frame_a, Compression::Zstd).unwrap();
389        buf.extend_from_slice(&compress_buf(&frame_b, Compression::Zstd).unwrap());
390        let mut decompressed = Vec::new();
391        wrap_sync_reader(&buf[..], Compression::Zstd)
392            .read_to_end(&mut decompressed)
393            .unwrap();
394        let mut expected = frame_a.clone();
395        expected.extend_from_slice(&frame_b);
396        assert_eq!(decompressed, expected);
397    }
398
399    #[tokio::test]
400    async fn async_none_passthrough() {
401        let original = b"plain text";
402        let mut buf = Vec::new();
403        {
404            let mut w = wrap_async_writer(&mut buf, Compression::None);
405            w.write_all(original).await.unwrap();
406            w.shutdown().await.unwrap();
407        }
408        assert_eq!(&buf[..], original);
409    }
410
411    #[test]
412    fn sync_roundtrip_gzip() {
413        use std::io::{Read, Write};
414        let original = b"sync gzip data".repeat(20);
415        let mut buf = Vec::new();
416        {
417            let mut w = wrap_sync_writer(&mut buf, Compression::Gzip);
418            w.write_all(&original).unwrap();
419            // GzEncoder finalises on drop (writes the 8-byte trailer).
420            // The explicit flush drains the deflate buffer; the drop at the
421            // end of this scope writes the trailer so the reader below
422            // sees a complete stream.
423            w.flush().unwrap();
424        }
425        let mut r = wrap_sync_reader(&buf[..], Compression::Gzip);
426        let mut decompressed = Vec::new();
427        r.read_to_end(&mut decompressed).unwrap();
428        assert_eq!(decompressed, original);
429    }
430
431    #[test]
432    fn sync_roundtrip_zstd() {
433        use std::io::{Read, Write};
434        let original = b"sync zstd data".repeat(20);
435        let mut buf = Vec::new();
436        {
437            let mut w = wrap_sync_writer(&mut buf, Compression::Zstd);
438            w.write_all(&original).unwrap();
439            w.flush().unwrap();
440        }
441        let mut r = wrap_sync_reader(&buf[..], Compression::Zstd);
442        let mut decompressed = Vec::new();
443        r.read_to_end(&mut decompressed).unwrap();
444        assert_eq!(decompressed, original);
445    }
446
447    #[test]
448    fn compress_buf_roundtrip_gzip() {
449        use std::io::Read;
450        let original = b"buffer compression".repeat(10);
451        let compressed = compress_buf(&original, Compression::Gzip).unwrap();
452        assert_ne!(compressed, original);
453        let mut r = wrap_sync_reader(&compressed[..], Compression::Gzip);
454        let mut decompressed = Vec::new();
455        r.read_to_end(&mut decompressed).unwrap();
456        assert_eq!(decompressed, original);
457    }
458
459    #[test]
460    fn compress_buf_roundtrip_zstd() {
461        use std::io::Read;
462        let original = b"buffer zstd".repeat(10);
463        let compressed = compress_buf(&original, Compression::Zstd).unwrap();
464        assert_ne!(compressed, original);
465        let mut r = wrap_sync_reader(&compressed[..], Compression::Zstd);
466        let mut decompressed = Vec::new();
467        r.read_to_end(&mut decompressed).unwrap();
468        assert_eq!(decompressed, original);
469    }
470
471    #[test]
472    fn compress_buf_none_is_clone() {
473        let original = b"unchanged";
474        let out = compress_buf(original, Compression::None).unwrap();
475        assert_eq!(out, original);
476    }
477
478    #[tokio::test]
479    async fn empty_compressed_stream_yields_zero_bytes() {
480        // Produce a valid empty gzip stream.
481        let mut buf = Vec::new();
482        {
483            let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
484            w.shutdown().await.unwrap();
485        }
486        // Decompressing it should yield zero bytes, not an error.
487        let mut decompressed = Vec::new();
488        let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
489        r.read_to_end(&mut decompressed).await.unwrap();
490        assert!(decompressed.is_empty());
491    }
492
493    #[tokio::test]
494    async fn truncated_gzip_stream_errors() {
495        let original = b"this will be truncated mid-stream".repeat(50);
496        let mut buf = Vec::new();
497        {
498            let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
499            w.write_all(&original).await.unwrap();
500            w.shutdown().await.unwrap();
501        }
502        // Truncate to half.
503        buf.truncate(buf.len() / 2);
504        let mut decompressed = Vec::new();
505        let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
506        let err = r.read_to_end(&mut decompressed).await.unwrap_err();
507        assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
508    }
509
510    #[test]
511    fn warn_mismatch_dedups_per_path_and_codec() {
512        // Calling twice with the same (path, declared) pair must produce a
513        // single log line. We verify via the internal HashSet: after two
514        // calls, the set contains exactly one entry. The static is shared
515        // across the whole process, so we use a unique path to avoid
516        // collisions with other tests.
517        let unique_path = format!("warn_mismatch_dedup_fixture_{}.txt", line!());
518        // First call: detected = None (no extension), declared = Gzip → mismatch, logs.
519        warn_mismatch(&unique_path, Compression::Gzip);
520        // Second call with identical args: must not log a second time.
521        warn_mismatch(&unique_path, Compression::Gzip);
522        // Third call with different declared: separate dedup key, logs once.
523        warn_mismatch(&unique_path, Compression::Zstd);
524        // Matching pair: no log (early-exit before touching the HashSet).
525        warn_mismatch("file.gz", Compression::Gzip);
526        // (Behaviour is verified through log absence in production. Here we
527        // only assert the function runs to completion without panicking,
528        // which exercises the OnceLock init, the Mutex acquisition, and the
529        // HashSet insertion paths.)
530    }
531}