1use std::{
2 io,
3 pin::Pin,
4 task::{Context, Poll},
5};
6
7use async_compression::{
8 tokio::write::{GzipEncoder, ZlibEncoder, ZstdEncoder},
9 Level,
10};
11use http::HeaderValue;
12use pin_project::pin_project;
13use tokio::io::AsyncWrite;
14use tracing::trace;
15
16const THRESHOLD_RED_ZONE: f64 = 0.99;
20
21static CONTENT_ENCODING_DEFLATE: HeaderValue = HeaderValue::from_static("deflate");
22static CONTENT_ENCODING_GZIP: HeaderValue = HeaderValue::from_static("gzip");
23static CONTENT_ENCODING_ZSTD: HeaderValue = HeaderValue::from_static("zstd");
24
25#[derive(Copy, Clone, Debug)]
27pub enum CompressionScheme {
28 Noop,
30 Gzip(Level),
32 Zlib(Level),
34 Zstd(Level),
36}
37
38impl CompressionScheme {
39 pub const fn noop() -> Self {
41 Self::Noop
42 }
43
44 pub const fn gzip_default() -> Self {
46 Self::Gzip(Level::Default)
47 }
48
49 pub const fn zlib_default() -> Self {
51 Self::Zlib(Level::Default)
52 }
53
54 pub const fn zstd_default() -> Self {
56 Self::Zstd(Level::Default)
57 }
58
59 pub fn new(scheme: &str, level: i32) -> Self {
65 match scheme {
66 "gzip" => Self::Gzip(Level::Precise(level)),
67 "zlib" => CompressionScheme::zlib_default(),
68 "zstd" => Self::Zstd(Level::Precise(level)),
69 _ => Self::Zstd(Level::Default),
70 }
71 }
72}
73
74#[pin_project]
75pub struct CountingWriter<W> {
76 #[pin]
77 inner: W,
78 total_written: u64,
79}
80
81impl<W> CountingWriter<W> {
82 fn new(inner: W) -> Self {
83 Self {
84 inner,
85 total_written: 0,
86 }
87 }
88
89 fn total_written(&self) -> u64 {
90 self.total_written
91 }
92
93 fn into_inner(self) -> W {
94 self.inner
95 }
96}
97
98pub trait WriteStatistics {
100 fn total_written(&self) -> u64;
102}
103
104impl<W: AsyncWrite> AsyncWrite for CountingWriter<W> {
105 fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<Result<usize, io::Error>> {
106 let mut this = self.project();
107 this.inner.as_mut().poll_write(cx, buf).map(|result| {
108 if let Ok(written) = &result {
109 *this.total_written += *written as u64;
110 }
111
112 result
113 })
114 }
115
116 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
117 self.project().inner.poll_flush(cx)
118 }
119
120 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
121 self.project().inner.poll_shutdown(cx)
122 }
123}
124
125#[pin_project(project = CompressorProjected)]
130pub enum Compressor<W: AsyncWrite> {
131 Noop(#[pin] CountingWriter<W>),
133 Gzip(#[pin] GzipEncoder<CountingWriter<W>>),
135 Zlib(#[pin] ZlibEncoder<W>),
137 Zstd(#[pin] ZstdEncoder<CountingWriter<W>>),
139}
140
141impl<W: AsyncWrite> Compressor<W> {
142 pub fn from_scheme(scheme: CompressionScheme, writer: W) -> Self {
144 match scheme {
145 CompressionScheme::Noop => Self::Noop(CountingWriter::new(writer)),
146 CompressionScheme::Gzip(level) => Self::Gzip(GzipEncoder::with_quality(CountingWriter::new(writer), level)),
147 CompressionScheme::Zlib(level) => Self::Zlib(ZlibEncoder::with_quality(writer, level)),
148 CompressionScheme::Zstd(level) => Self::Zstd(ZstdEncoder::with_quality(CountingWriter::new(writer), level)),
149 }
150 }
151
152 pub fn into_inner(self) -> W {
154 match self {
155 Self::Noop(encoder) => encoder.into_inner(),
156 Self::Gzip(encoder) => encoder.into_inner().into_inner(),
157 Self::Zlib(encoder) => encoder.into_inner(),
158 Self::Zstd(encoder) => encoder.into_inner().into_inner(),
159 }
160 }
161
162 pub fn content_encoding(&self) -> Option<HeaderValue> {
164 match self {
165 Self::Noop(_) => None,
166 Self::Gzip(_) => Some(CONTENT_ENCODING_GZIP.clone()),
167 Self::Zlib(_) => Some(CONTENT_ENCODING_DEFLATE.clone()),
168 Self::Zstd(_) => Some(CONTENT_ENCODING_ZSTD.clone()),
169 }
170 }
171}
172
173impl<W: AsyncWrite> AsyncWrite for Compressor<W> {
174 fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<Result<usize, io::Error>> {
175 match self.project() {
176 CompressorProjected::Noop(encoder) => encoder.poll_write(cx, buf),
177 CompressorProjected::Gzip(encoder) => encoder.poll_write(cx, buf),
178 CompressorProjected::Zlib(encoder) => encoder.poll_write(cx, buf),
179 CompressorProjected::Zstd(encoder) => encoder.poll_write(cx, buf),
180 }
181 }
182
183 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
184 match self.project() {
185 CompressorProjected::Noop(encoder) => encoder.poll_flush(cx),
186 CompressorProjected::Gzip(encoder) => encoder.poll_flush(cx),
187 CompressorProjected::Zlib(encoder) => encoder.poll_flush(cx),
188 CompressorProjected::Zstd(encoder) => encoder.poll_flush(cx),
189 }
190 }
191
192 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
193 match self.project() {
194 CompressorProjected::Noop(encoder) => encoder.poll_shutdown(cx),
195 CompressorProjected::Gzip(encoder) => encoder.poll_shutdown(cx),
196 CompressorProjected::Zlib(encoder) => encoder.poll_shutdown(cx),
197 CompressorProjected::Zstd(encoder) => encoder.poll_shutdown(cx),
198 }
199 }
200}
201
202impl<W: AsyncWrite> WriteStatistics for Compressor<W> {
203 fn total_written(&self) -> u64 {
204 match self {
205 Compressor::Noop(encoder) => encoder.total_written(),
206 Compressor::Gzip(encoder) => encoder.get_ref().total_written(),
207 Compressor::Zlib(encoder) => encoder.total_out(),
208 Compressor::Zstd(encoder) => encoder.get_ref().total_written(),
209 }
210 }
211}
212
213#[derive(Debug, Default)]
238pub struct CompressionEstimator {
239 in_flight_uncompressed_len: usize,
240 total_uncompressed_len: usize,
241 total_compressed_len: u64,
242 current_compression_ratio: f64,
243}
244
245impl CompressionEstimator {
246 pub fn track_write<W>(&mut self, compressor: &W, uncompressed_len: usize)
248 where
249 W: WriteStatistics,
250 {
251 self.in_flight_uncompressed_len += uncompressed_len;
252 self.total_uncompressed_len += uncompressed_len;
253
254 let compressed_len = compressor.total_written();
255 let compressed_len_delta = (compressed_len - self.total_compressed_len) as usize;
256 if compressed_len_delta > 0 {
257 self.current_compression_ratio = compressed_len as f64 / self.total_uncompressed_len as f64;
259 self.total_compressed_len = compressed_len;
260 self.in_flight_uncompressed_len = 0;
261
262 trace!(
263 block_size = compressed_len_delta,
264 uncompressed_len = self.total_uncompressed_len,
265 compressed_len = self.total_compressed_len,
266 compression_ratio = self.current_compression_ratio,
267 "Compressor wrote block to output stream."
268 );
269 }
270 }
271
272 pub fn reset(&mut self) {
274 self.in_flight_uncompressed_len = 0;
275 self.total_uncompressed_len = 0;
276 self.total_compressed_len = 0;
277 self.current_compression_ratio = 0.0;
278 }
279
280 pub fn estimated_len(&self) -> usize {
286 let estimated_in_flight_compressed_len =
287 (self.in_flight_uncompressed_len as f64 * self.current_compression_ratio) as usize;
288
289 self.total_compressed_len as usize + estimated_in_flight_compressed_len
290 }
291
292 pub fn would_write_exceed_threshold(&self, len: usize, threshold: usize) -> bool {
295 if self.total_compressed_len == 0 {
299 return false;
300 }
301
302 let adjusted_threshold = (threshold as f64 * THRESHOLD_RED_ZONE) as usize;
312 self.estimated_len() + len > adjusted_threshold
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use async_compression::tokio::bufread::{GzipDecoder, ZlibDecoder, ZstdDecoder};
319 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
320
321 use super::*;
322
323 struct MockCompressor {
324 current_uncompressed_len: u64,
325 total_uncompressed_len: usize,
326 compressed_len: u64,
327 }
328
329 impl MockCompressor {
330 fn new() -> Self {
331 MockCompressor {
332 current_uncompressed_len: 0,
333 total_uncompressed_len: 0,
334 compressed_len: 0,
335 }
336 }
337
338 fn write(&mut self, n: usize) {
339 self.current_uncompressed_len += n as u64;
340 self.total_uncompressed_len += n;
341 }
342
343 fn flush(&mut self, compression_ratio: f64) {
344 self.compressed_len += (self.current_uncompressed_len as f64 * compression_ratio) as u64;
345 self.current_uncompressed_len = 0;
346 }
347
348 fn total_uncompressed_len(&self) -> usize {
349 self.total_uncompressed_len
350 }
351 }
352
353 impl WriteStatistics for MockCompressor {
354 fn total_written(&self) -> u64 {
355 self.compressed_len
356 }
357 }
358
359 #[test]
360 fn compression_estimator_no_output() {
361 let estimator = CompressionEstimator::default();
362
363 assert!(!estimator.would_write_exceed_threshold(10, 100));
368 assert!(!estimator.would_write_exceed_threshold(100, 90));
369 }
370
371 #[test]
372 fn compression_estimator_single_flush() {
373 const MAX_COMPRESSED_LEN: usize = 100;
374 const COMPRESSION_RATIO: f64 = 0.7;
375 const WRITE_LEN: usize = 50;
376
377 let mut estimator = CompressionEstimator::default();
378
379 let mut compressor = MockCompressor::new();
381 assert!(!estimator.would_write_exceed_threshold(WRITE_LEN, MAX_COMPRESSED_LEN));
382
383 compressor.write(WRITE_LEN);
385 compressor.flush(COMPRESSION_RATIO);
386 assert_eq!(compressor.total_written(), 35);
387
388 estimator.track_write(&compressor, WRITE_LEN);
389
390 assert!(estimator.would_write_exceed_threshold(100, MAX_COMPRESSED_LEN));
393
394 assert!(!estimator.would_write_exceed_threshold(WRITE_LEN, MAX_COMPRESSED_LEN));
397 }
398
399 #[test]
400 fn compression_estimator_multiple_flush_partial() {
401 const MAX_COMPRESSED_LEN: usize = 5000;
402 const FIRST_COMPRESSION_RATIO: f64 = 0.7;
403 const FIRST_WRITE_LEN: usize = 5000;
404 const SECOND_COMPRESSION_RATIO: f64 = 2.1;
405 const SECOND_WRITE_LEN: usize = 300;
406 const THIRD_WRITE_LEN: usize = 820;
407
408 let mut estimator = CompressionEstimator::default();
409
410 let mut compressor = MockCompressor::new();
412 assert!(!estimator.would_write_exceed_threshold(FIRST_WRITE_LEN, MAX_COMPRESSED_LEN));
413
414 compressor.write(FIRST_WRITE_LEN);
416 compressor.flush(FIRST_COMPRESSION_RATIO);
417 assert_eq!(compressor.total_uncompressed_len(), FIRST_WRITE_LEN);
418 assert_eq!(compressor.total_written(), 3500);
419
420 estimator.track_write(&compressor, FIRST_WRITE_LEN);
421
422 compressor.write(SECOND_WRITE_LEN);
431 compressor.flush(SECOND_COMPRESSION_RATIO);
432 assert_eq!(compressor.total_uncompressed_len(), FIRST_WRITE_LEN + SECOND_WRITE_LEN);
433 assert_eq!(compressor.total_written(), 4130);
434
435 estimator.track_write(&compressor, SECOND_WRITE_LEN);
436
437 assert!(!estimator.would_write_exceed_threshold(THIRD_WRITE_LEN, MAX_COMPRESSED_LEN));
443 }
444
445 const COMPRESSIBLE_PAYLOAD: &[u8] =
447 b"the quick brown fox jumps over the lazy dog. the quick brown fox jumps over the lazy dog. \
448 the quick brown fox jumps over the lazy dog. the quick brown fox jumps over the lazy dog.";
449
450 async fn compress_all(scheme: CompressionScheme, data: &[u8]) -> (Vec<u8>, Option<HeaderValue>) {
451 let mut compressor = Compressor::from_scheme(scheme, Vec::new());
453 compressor.write_all(data).await.expect("write should succeed");
454 compressor.flush().await.expect("flush should succeed");
455 compressor.shutdown().await.expect("shutdown should succeed");
456
457 let encoding = compressor.content_encoding();
458 (compressor.into_inner(), encoding)
459 }
460
461 async fn decompress(scheme: CompressionScheme, compressed: &[u8]) -> Vec<u8> {
462 let mut out = Vec::new();
463 match scheme {
464 CompressionScheme::Noop => return compressed.to_vec(),
465 CompressionScheme::Gzip(_) => GzipDecoder::new(compressed)
466 .read_to_end(&mut out)
467 .await
468 .expect("gzip decode should succeed"),
469 CompressionScheme::Zlib(_) => ZlibDecoder::new(compressed)
470 .read_to_end(&mut out)
471 .await
472 .expect("zlib decode should succeed"),
473 CompressionScheme::Zstd(_) => ZstdDecoder::new(compressed)
474 .read_to_end(&mut out)
475 .await
476 .expect("zstd decode should succeed"),
477 };
478 out
479 }
480
481 #[test]
482 fn compression_scheme_constructors_select_expected_variant() {
483 assert!(matches!(CompressionScheme::noop(), CompressionScheme::Noop));
484 assert!(matches!(
485 CompressionScheme::gzip_default(),
486 CompressionScheme::Gzip(Level::Default)
487 ));
488 assert!(matches!(
489 CompressionScheme::zlib_default(),
490 CompressionScheme::Zlib(Level::Default)
491 ));
492 assert!(matches!(
493 CompressionScheme::zstd_default(),
494 CompressionScheme::Zstd(Level::Default)
495 ));
496 }
497
498 #[test]
499 fn compression_scheme_new_maps_string_and_level() {
500 match CompressionScheme::new("gzip", 5) {
502 CompressionScheme::Gzip(Level::Precise(level)) => assert_eq!(level, 5),
503 other => panic!("expected gzip with precise level, got {other:?}"),
504 }
505 match CompressionScheme::new("zstd", 7) {
506 CompressionScheme::Zstd(Level::Precise(level)) => assert_eq!(level, 7),
507 other => panic!("expected zstd with precise level, got {other:?}"),
508 }
509
510 assert!(matches!(
512 CompressionScheme::new("zlib", 9),
513 CompressionScheme::Zlib(Level::Default)
514 ));
515
516 assert!(matches!(
518 CompressionScheme::new("brotli", 9),
519 CompressionScheme::Zstd(Level::Default)
520 ));
521 }
522
523 #[tokio::test]
524 async fn compressor_round_trips_and_reports_encoding_for_each_scheme() {
525 let cases: [(CompressionScheme, Option<&str>); 4] = [
527 (CompressionScheme::noop(), None),
528 (CompressionScheme::gzip_default(), Some("gzip")),
529 (CompressionScheme::zlib_default(), Some("deflate")),
530 (CompressionScheme::zstd_default(), Some("zstd")),
531 ];
532
533 for (scheme, expected_encoding) in cases {
534 let (compressed, encoding) = compress_all(scheme, COMPRESSIBLE_PAYLOAD).await;
535 assert_eq!(
536 encoding.as_ref().map(|value| value.to_str().unwrap()),
537 expected_encoding,
538 "unexpected content-encoding for {scheme:?}"
539 );
540
541 if matches!(scheme, CompressionScheme::Noop) {
542 assert_eq!(compressed, COMPRESSIBLE_PAYLOAD);
544 } else {
545 assert!(
547 compressed.len() < COMPRESSIBLE_PAYLOAD.len(),
548 "{scheme:?} did not shrink the payload ({} >= {})",
549 compressed.len(),
550 COMPRESSIBLE_PAYLOAD.len()
551 );
552 }
553
554 let decompressed = decompress(scheme, &compressed).await;
555 assert_eq!(decompressed, COMPRESSIBLE_PAYLOAD, "round-trip mismatch for {scheme:?}");
556 }
557 }
558
559 #[tokio::test]
560 async fn counting_writer_tracks_total_bytes_written() {
561 let mut writer = CountingWriter::new(Vec::new());
562 assert_eq!(writer.total_written(), 0);
563
564 writer.write_all(b"hello").await.expect("write should succeed");
565 assert_eq!(writer.total_written(), 5);
566
567 writer.write_all(b"!!!").await.expect("write should succeed");
568 assert_eq!(writer.total_written(), 8);
569
570 assert_eq!(writer.into_inner(), b"hello!!!");
572 }
573
574 #[tokio::test]
575 async fn noop_compressor_write_statistics_count_all_bytes() {
576 let mut compressor = Compressor::from_scheme(CompressionScheme::noop(), Vec::new());
578 compressor.write_all(b"1234567890").await.expect("write should succeed");
579
580 assert_eq!(WriteStatistics::total_written(&compressor), 10);
581 }
582}