Skip to content

Commit d687716

Browse files
committed
feat(api): compress large data revisions with gzip
1 parent 491a796 commit d687716

5 files changed

Lines changed: 210 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ csv = "1.4.0"
3030
deno_core = "0.394.0"
3131
dotenvy = "0.15.7"
3232
figment = "0.10.19"
33+
flate2 = "1.1.9"
3334
futures = "0.3.32"
3435
handlebars = "6.4.0"
3536
http = "1.4.0"
@@ -96,6 +97,7 @@ default = [
9697
"lettre/tokio1-rustls",
9798
"mediatype/serde",
9899
"postcard/use-std",
100+
"reqwest/gzip",
99101
"reqwest/http2",
100102
"reqwest/json",
101103
"reqwest-middleware/json",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Switch trackers_data.data to EXTERNAL storage since the application now handles
2+
-- compression (gzip) before writing. EXTERNAL tells PostgreSQL to store the value
3+
-- out-of-line (TOAST) when large but skip its own pglz compression pass, avoiding
4+
-- double-compression overhead.
5+
ALTER TABLE trackers_data ALTER COLUMN data SET STORAGE EXTERNAL;

src/network.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,42 @@ pub mod tests {
9494

9595
pub use super::{dns_resolver::tests::*, smtp::tests::*};
9696

97+
/// Gzip-compresses the given bytes.
98+
fn gzip_encode(data: &[u8]) -> Vec<u8> {
99+
use flate2::{Compression, write::GzEncoder};
100+
use std::io::Write;
101+
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
102+
encoder.write_all(data).unwrap();
103+
encoder.finish().unwrap()
104+
}
105+
106+
#[tokio::test]
107+
async fn http_client_negotiates_gzip_compression() -> anyhow::Result<()> {
108+
let server = httpmock::MockServer::start();
109+
let body = serde_json::json!({"message": "hello from gzip"});
110+
let body_bytes = serde_json::to_vec(&body)?;
111+
let compressed = gzip_encode(&body_bytes);
112+
113+
let mock = server.mock(|when, then| {
114+
when.method(httpmock::Method::GET)
115+
.path("/gzip-test")
116+
.header_exists("accept-encoding");
117+
then.status(200)
118+
.header("Content-Type", "application/json")
119+
.header("Content-Encoding", "gzip")
120+
.body(compressed);
121+
});
122+
123+
let client = reqwest::Client::builder().build()?;
124+
let response = client.get(server.url("/gzip-test")).send().await?;
125+
let json: serde_json::Value = response.json().await?;
126+
127+
mock.assert();
128+
assert_eq!(json, body);
129+
130+
Ok(())
131+
}
132+
97133
#[tokio::test]
98134
async fn correctly_checks_public_web_urls() -> anyhow::Result<()> {
99135
let public_network = Network {

src/trackers/database_ext/raw_tracker_data_revision.rs

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1+
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
12
use retrack_types::trackers::{TrackerDataRevision, TrackerDataValue};
23
use serde_json::Value as JsonValue;
3-
use std::collections::VecDeque;
4+
use std::{
5+
collections::VecDeque,
6+
io::{Read, Write},
7+
};
48
use time::OffsetDateTime;
59
use uuid::Uuid;
610

11+
/// Minimum size (in bytes) of Postcard-encoded data before gzip compression is applied.
12+
/// Values smaller than this typically don't benefit from compression.
13+
const COMPRESSION_THRESHOLD: usize = 512;
14+
15+
/// Magic byte prefixed to gzip-compressed data blobs to distinguish them from legacy
16+
/// uncompressed Postcard data. Uncompressed Postcard blobs never start with this byte
17+
/// because the first Postcard byte is a varint-encoded Vec length (0x00 would mean an
18+
/// empty vec, which is rejected on read anyway).
19+
const GZIP_MAGIC: u8 = 0x00;
20+
721
#[derive(Debug, Eq, PartialEq, Clone)]
822
pub(super) struct RawTrackerDataRevision {
923
pub id: Uuid,
@@ -12,11 +26,41 @@ pub(super) struct RawTrackerDataRevision {
1226
pub created_at: OffsetDateTime,
1327
}
1428

29+
fn decompress_data(data: &[u8]) -> anyhow::Result<Vec<u8>> {
30+
if data.first() == Some(&GZIP_MAGIC) {
31+
let mut decoder = GzDecoder::new(&data[1..]);
32+
let mut decompressed = Vec::new();
33+
decoder.read_to_end(&mut decompressed)?;
34+
Ok(decompressed)
35+
} else {
36+
Ok(data.to_vec())
37+
}
38+
}
39+
40+
fn compress_data(postcard_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
41+
if postcard_bytes.len() < COMPRESSION_THRESHOLD {
42+
return Ok(postcard_bytes);
43+
}
44+
45+
let mut buf = Vec::with_capacity(postcard_bytes.len() / 2);
46+
buf.push(GZIP_MAGIC);
47+
let mut encoder = GzEncoder::new(&mut buf, Compression::default());
48+
encoder.write_all(&postcard_bytes)?;
49+
encoder.finish()?;
50+
51+
if buf.len() < postcard_bytes.len() {
52+
Ok(buf)
53+
} else {
54+
Ok(postcard_bytes)
55+
}
56+
}
57+
1558
impl TryFrom<RawTrackerDataRevision> for TrackerDataRevision {
1659
type Error = anyhow::Error;
1760

1861
fn try_from(raw: RawTrackerDataRevision) -> Result<Self, Self::Error> {
19-
let mut original_and_mods = postcard::from_bytes::<Vec<String>>(&raw.data)?
62+
let postcard_bytes = decompress_data(&raw.data)?;
63+
let mut original_and_mods = postcard::from_bytes::<Vec<String>>(&postcard_bytes)?
2064
.into_iter()
2165
.map(|raw_value| serde_json::from_str(&raw_value))
2266
.collect::<Result<VecDeque<JsonValue>, _>>()?;
@@ -41,15 +85,16 @@ impl TryFrom<&TrackerDataRevision> for RawTrackerDataRevision {
4185
type Error = anyhow::Error;
4286

4387
fn try_from(item: &TrackerDataRevision) -> Result<Self, Self::Error> {
88+
let postcard_bytes = postcard::to_stdvec(
89+
&(&item.data)
90+
.into_iter()
91+
.map(|value| value.to_string())
92+
.collect::<Vec<_>>(),
93+
)?;
4494
Ok(Self {
4595
id: item.id,
4696
tracker_id: item.tracker_id,
47-
data: postcard::to_stdvec(
48-
&(&item.data)
49-
.into_iter()
50-
.map(|value| value.to_string())
51-
.collect::<Vec<_>>(),
52-
)?,
97+
data: compress_data(postcard_bytes)?,
5398
created_at: item.created_at,
5499
})
55100
}
@@ -90,4 +135,82 @@ mod tests {
90135

91136
Ok(())
92137
}
138+
139+
#[test]
140+
fn small_data_is_not_compressed() -> anyhow::Result<()> {
141+
let data_revision = TrackerDataRevision {
142+
id: uuid!("00000000-0000-0000-0000-000000000001"),
143+
tracker_id: uuid!("00000000-0000-0000-0000-000000000002"),
144+
data: TrackerDataValue::new(json!("small")),
145+
created_at: OffsetDateTime::from_unix_timestamp(946720800)?,
146+
};
147+
148+
let raw = RawTrackerDataRevision::try_from(&data_revision)?;
149+
assert_ne!(raw.data.first(), Some(&super::GZIP_MAGIC));
150+
assert_eq!(TrackerDataRevision::try_from(raw)?, data_revision);
151+
152+
Ok(())
153+
}
154+
155+
#[test]
156+
fn large_data_is_compressed_and_decompressed() -> anyhow::Result<()> {
157+
let large_json = json!({
158+
"content": "x".repeat(2000),
159+
"nested": { "array": (0..100).collect::<Vec<_>>() }
160+
});
161+
let data_revision = TrackerDataRevision {
162+
id: uuid!("00000000-0000-0000-0000-000000000001"),
163+
tracker_id: uuid!("00000000-0000-0000-0000-000000000002"),
164+
data: TrackerDataValue::new(large_json),
165+
created_at: OffsetDateTime::from_unix_timestamp(946720800)?,
166+
};
167+
168+
let raw = RawTrackerDataRevision::try_from(&data_revision)?;
169+
assert_eq!(raw.data.first(), Some(&super::GZIP_MAGIC));
170+
171+
let postcard_bytes = postcard::to_stdvec(
172+
&(&data_revision.data)
173+
.into_iter()
174+
.map(|v| v.to_string())
175+
.collect::<Vec<_>>(),
176+
)?;
177+
assert!(
178+
raw.data.len() < postcard_bytes.len(),
179+
"compressed ({}) should be smaller than uncompressed ({})",
180+
raw.data.len(),
181+
postcard_bytes.len()
182+
);
183+
184+
assert_eq!(TrackerDataRevision::try_from(raw)?, data_revision);
185+
186+
Ok(())
187+
}
188+
189+
#[test]
190+
fn can_read_legacy_uncompressed_data() -> anyhow::Result<()> {
191+
let data_revision = TrackerDataRevision {
192+
id: uuid!("00000000-0000-0000-0000-000000000001"),
193+
tracker_id: uuid!("00000000-0000-0000-0000-000000000002"),
194+
data: TrackerDataValue::new(json!({"key": "value".repeat(200)})),
195+
created_at: OffsetDateTime::from_unix_timestamp(946720800)?,
196+
};
197+
198+
let postcard_bytes = postcard::to_stdvec(
199+
&(&data_revision.data)
200+
.into_iter()
201+
.map(|v| v.to_string())
202+
.collect::<Vec<_>>(),
203+
)?;
204+
205+
let raw = RawTrackerDataRevision {
206+
id: data_revision.id,
207+
tracker_id: data_revision.tracker_id,
208+
data: postcard_bytes,
209+
created_at: data_revision.created_at,
210+
};
211+
212+
assert_eq!(TrackerDataRevision::try_from(raw)?, data_revision);
213+
214+
Ok(())
215+
}
93216
}

0 commit comments

Comments
 (0)