/build/source/nativelink-service/src/wire_compression.rs
Line | Count | Source |
1 | | // Copyright 2026 The NativeLink Authors. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! Wire compression utilities for REAPI compressed-blobs. |
16 | | //! |
17 | | //! This module handles compression/decompression of blob data on the gRPC wire |
18 | | //! between client and server, per the REAPI compressed-blobs specification. |
19 | | //! This is orthogonal to at-rest compression (`CompressionStore` with LZ4). |
20 | | |
21 | | use std::collections::HashSet; |
22 | | use std::io::Read; |
23 | | |
24 | | use bytes::Bytes; |
25 | | use nativelink_config::cas_server::{CapabilitiesConfig, InstanceName, WithInstanceName}; |
26 | | use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err}; |
27 | | use nativelink_proto::build::bazel::remote::execution::v2::compressor; |
28 | | use nativelink_util::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf}; |
29 | | use nativelink_util::common::DigestInfo; |
30 | | use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc}; |
31 | | use nativelink_util::spawn_blocking; |
32 | | use tracing::warn; |
33 | | |
34 | | /// Zstd compression level for wire compression. |
35 | | /// Level 0 in the zstd crate means "use default" (currently 3). |
36 | | /// We use an explicit level for clarity. |
37 | | pub const ZSTD_COMPRESSION_LEVEL: i32 = 3; |
38 | | |
39 | | /// Upper bound on the buffer `decompress` reserves up front for a zstd blob. |
40 | | /// `expected_size` comes from the client's claimed digest, so it must never be |
41 | | /// used as an allocation hint directly: a small payload claiming a huge size |
42 | | /// would otherwise force a large pre-emptive allocation before the real |
43 | | /// decompressed length is ever known. We reserve `min(expected_size, this)` |
44 | | /// so common payloads never reallocate while a hostile claim allocates at most |
45 | | /// this. Sized comfortably above any honest `BatchUpdateBlobs` payload (the |
46 | | /// only caller of this bulk path; large blobs stream through `ByteStream`). |
47 | | const ZSTD_DECOMPRESS_PREALLOC_CAP: usize = 1024 * 1024; |
48 | | |
49 | | /// Which instances accept and advertise REAPI compressed-blobs (zstd). |
50 | | /// |
51 | | /// Derived from `CapabilitiesConfig.remote_cache_compression` so that |
52 | | /// advertisement (capabilities) and acceptance (ByteStream/CAS) cannot drift. |
53 | | #[derive(Debug, Clone, Default)] |
54 | | pub struct RemoteCacheCompressionInstances(HashSet<InstanceName>); |
55 | | |
56 | | impl RemoteCacheCompressionInstances { |
57 | | #[must_use] |
58 | 3 | pub fn from_capabilities_configs(configs: &[WithInstanceName<CapabilitiesConfig>]) -> Self { |
59 | | Self( |
60 | 3 | configs |
61 | 3 | .iter() |
62 | 3 | .filter(|config| config.remote_cache_compression) |
63 | 3 | .map(|config| config.instance_name2 .clone2 ()) |
64 | 3 | .collect(), |
65 | | ) |
66 | 3 | } |
67 | | |
68 | | #[must_use] |
69 | 13 | pub fn from_enabled_instance_names( |
70 | 13 | instance_names: impl IntoIterator<Item = InstanceName>, |
71 | 13 | ) -> Self { |
72 | 13 | Self(instance_names.into_iter().collect()) |
73 | 13 | } |
74 | | |
75 | | #[must_use] |
76 | 43 | pub fn enabled_for(&self, instance_name: &str) -> bool { |
77 | 43 | self.0.contains(instance_name) |
78 | 43 | } |
79 | | } |
80 | | |
81 | | /// Resolve a wire compressor from a URI compressor string (as it appears in |
82 | | /// the `compressed-blobs/{compressor}/...` resource name) and validate it |
83 | | /// against whether the instance supports remote cache compression. |
84 | | /// |
85 | | /// Returns `compressor::Value::Identity` when `compressor_str` is `None` or |
86 | | /// `"identity"`. This is the single source of truth for URI-to-compressor |
87 | | /// parsing so that `ByteStream` and any other callers stay consistent. |
88 | 39 | pub fn resolve_wire_compressor( |
89 | 39 | compressor_str: Option<&str>, |
90 | 39 | remote_cache_compression_enabled: bool, |
91 | 39 | ) -> Result<compressor::Value, Error> { |
92 | 39 | match compressor_str { |
93 | 24 | None | Some("identity"18 ) => Ok(compressor::Value::Identity), |
94 | 15 | Some("zstd") => { |
95 | 14 | if remote_cache_compression_enabled { |
96 | 11 | Ok(compressor::Value::Zstd) |
97 | | } else { |
98 | 3 | Err(make_input_err!( |
99 | 3 | "Remote cache compression is not supported by this instance" |
100 | 3 | )) |
101 | | } |
102 | | } |
103 | 1 | Some(other) => Err(make_input_err!("Unsupported wire compressor: '{}'", other)), |
104 | | } |
105 | 39 | } |
106 | | |
107 | | /// Compress data using the specified wire compressor. |
108 | | /// |
109 | | /// `data` is the raw (uncompressed) bytes from the store. |
110 | | /// Returns the compressed bytes suitable for sending on the wire. |
111 | 7 | pub fn compress(data: Bytes, compressor_value: compressor::Value) -> Result<Bytes, Error> { |
112 | 7 | match compressor_value { |
113 | 1 | compressor::Value::Identity => Ok(data), |
114 | | compressor::Value::Zstd => { |
115 | 6 | let compressed = zstd::bulk::compress(&data, ZSTD_COMPRESSION_LEVEL) |
116 | 6 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
117 | 6 | Ok(Bytes::from(compressed)) |
118 | | } |
119 | 0 | _ => Err(make_input_err!( |
120 | 0 | "Unsupported wire compressor for compression: {:?}", |
121 | 0 | compressor_value |
122 | 0 | )), |
123 | | } |
124 | 7 | } |
125 | | |
126 | | /// Decompress data using the specified wire compressor. |
127 | | /// |
128 | | /// `data` is the compressed bytes received from the wire. |
129 | | /// `expected_size` is the uncompressed size (from the client's digest). It is |
130 | | /// the hard cap on the decompressed output, but never a direct allocation |
131 | | /// hint: the buffer grows with the real decoded bytes so a small payload |
132 | | /// claiming a huge size cannot force a large up-front allocation. |
133 | | /// Returns the decompressed bytes suitable for storing. |
134 | 7 | pub fn decompress( |
135 | 7 | data: &[u8], |
136 | 7 | compressor_value: compressor::Value, |
137 | 7 | expected_size: usize, |
138 | 7 | ) -> Result<Bytes, Error> { |
139 | 7 | match compressor_value { |
140 | | compressor::Value::Identity => { |
141 | 2 | if data.len() != expected_size { |
142 | 1 | return Err(make_err!( |
143 | 1 | Code::InvalidArgument, |
144 | 1 | "Identity data size {} does not match expected size {}", |
145 | 1 | data.len(), |
146 | 1 | expected_size |
147 | 1 | )); |
148 | 1 | } |
149 | 1 | Ok(Bytes::copy_from_slice(data)) |
150 | | } |
151 | | compressor::Value::Zstd => { |
152 | | // Decode incrementally so `expected_size` (which is attacker |
153 | | // controlled — it is the client's claimed digest size) can bound |
154 | | // the output without being trusted as an allocation size. We |
155 | | // reserve only `min(expected_size, ZSTD_DECOMPRESS_PREALLOC_CAP)`, |
156 | | // then `take(expected_size + 1)` hard-caps the decoder so a |
157 | | // decompression bomb is rejected as soon as it overshoots. This |
158 | | // mirrors the real-byte-count validation the identity arm and the |
159 | | // streaming upload path already perform. |
160 | 5 | let decoder = zstd::stream::read::Decoder::new(data) |
161 | 5 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {e}"))?0 ; |
162 | 5 | let mut output = Vec::with_capacity(expected_size.min(ZSTD_DECOMPRESS_PREALLOC_CAP)); |
163 | | // `+ 1` lets an oversized stream produce one byte past the cap so |
164 | | // the size check below rejects it rather than silently truncating. |
165 | 5 | let cap = u64::try_from(expected_size) |
166 | 5 | .err_tip(|| "expected_size did not fit in u64")?0 |
167 | 5 | .saturating_add(1); |
168 | 5 | decoder |
169 | 5 | .take(cap) |
170 | 5 | .read_to_end(&mut output) |
171 | 5 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {e}"))?0 ; |
172 | 5 | if output.len() != expected_size { |
173 | 3 | return Err(make_err!( |
174 | 3 | Code::InvalidArgument, |
175 | 3 | "Decompressed size {} does not match expected size {}", |
176 | 3 | output.len(), |
177 | 3 | expected_size |
178 | 3 | )); |
179 | 2 | } |
180 | 2 | Ok(Bytes::from(output)) |
181 | | } |
182 | 0 | _ => Err(make_input_err!( |
183 | 0 | "Unsupported wire compressor for decompression: {:?}", |
184 | 0 | compressor_value |
185 | 0 | )), |
186 | | } |
187 | 7 | } |
188 | | |
189 | | #[must_use] |
190 | 2 | pub fn compress_for_batch_read(data: Bytes) -> (Bytes, compressor::Value) { |
191 | 2 | match compress(data.clone(), compressor::Value::Zstd) { |
192 | 2 | Ok(compressed) => { |
193 | 2 | if compressed.len() < data.len() { |
194 | 1 | (compressed, compressor::Value::Zstd) |
195 | | } else { |
196 | 1 | (data, compressor::Value::Identity) |
197 | | } |
198 | | } |
199 | 0 | Err(err) => { |
200 | 0 | warn!("Wire compression failed, falling back to identity: {}", err); |
201 | 0 | (data, compressor::Value::Identity) |
202 | | } |
203 | | } |
204 | 2 | } |
205 | | |
206 | 6 | fn validate_identity_batch_update(data: Bytes, expected_size: usize) -> Result<Bytes, Error> { |
207 | 6 | if data.len() != expected_size { |
208 | 0 | return Err(make_err!( |
209 | 0 | Code::InvalidArgument, |
210 | 0 | "Identity data size {} does not match expected size {}", |
211 | 0 | data.len(), |
212 | 0 | expected_size |
213 | 0 | )); |
214 | 6 | } |
215 | 6 | Ok(data) |
216 | 6 | } |
217 | | |
218 | 8 | pub async fn decompress_batch_update( |
219 | 8 | data: Bytes, |
220 | 8 | compressor_i32: i32, |
221 | 8 | expected_size: usize, |
222 | 8 | remote_cache_compression_enabled: bool, |
223 | 8 | ) -> Result<Bytes, Error> { |
224 | 8 | let request_compressor = compressor::Value::try_from(compressor_i32) |
225 | 8 | .map_err(|_| make_input_err!0 ("Unknown compressor value: {}", compressor_i32))?0 ; |
226 | | |
227 | 8 | match request_compressor { |
228 | 6 | compressor::Value::Identity => validate_identity_batch_update(data, expected_size), |
229 | | compressor::Value::Zstd => { |
230 | 2 | if !remote_cache_compression_enabled { |
231 | 1 | return Err(make_input_err!( |
232 | 1 | "Remote cache compression is not supported by this instance" |
233 | 1 | )); |
234 | 1 | } |
235 | 1 | spawn_blocking!("cas_decode_compressed_upload", move || { |
236 | 1 | decompress(&data, compressor::Value::Zstd, expected_size) |
237 | 1 | }) |
238 | 1 | .await |
239 | 1 | .map_err(|e| make_err!0 (Code::Internal0 , "Decompression task failed: {}", e))?0 |
240 | | } |
241 | 0 | other => Err(make_input_err!("Unsupported wire compressor: {:?}", other)), |
242 | | } |
243 | 8 | } |
244 | | |
245 | | /// Decode a client's zstd wire stream into raw bytes on `tx`, asynchronously. |
246 | | /// |
247 | | /// Like [`stream_encode_compressed_download`], this must not occupy a tokio |
248 | | /// blocking-pool thread for the stream's lifetime: the input arrives at the |
249 | | /// client's upload pace and `tx` drains at the store's write pace, so a |
250 | | /// blocking implementation parks a pool thread on whichever side is slower |
251 | | /// for as long as the upload lasts. The zstd frame is consumed incrementally |
252 | | /// with the raw streaming API instead; per-chunk decode cost is bounded by |
253 | | /// the channel chunk size, so it runs inline on the async runtime with |
254 | | /// channel-native backpressure on both sides. |
255 | | /// |
256 | | /// Validation semantics match the REAPI compressed-blobs contract: the |
257 | | /// decoded byte count may never exceed the digest size (checked per chunk so |
258 | | /// a decompression bomb is rejected as soon as it overshoots), the final |
259 | | /// count must equal it exactly, and the decoded bytes must hash to `digest`. |
260 | 6 | pub async fn stream_decode_compressed_upload( |
261 | 6 | mut compressed_rx: DropCloserReadHalf, |
262 | 6 | wire_compressor: compressor::Value, |
263 | 6 | digest: DigestInfo, |
264 | 6 | digest_function: DigestHasherFunc, |
265 | 6 | mut tx: DropCloserWriteHalf, |
266 | 6 | ) -> Result<(), Error> { |
267 | | use zstd::stream::raw::{Decoder, InBuffer, Operation, OutBuffer}; |
268 | | |
269 | 6 | if wire_compressor != compressor::Value::Zstd { |
270 | 0 | return Err(make_input_err!( |
271 | 0 | "Streaming upload decompression only supports zstd, got {:?}", |
272 | 0 | wire_compressor |
273 | 0 | )); |
274 | 6 | } |
275 | | |
276 | 6 | let expected_size = digest.size_bytes(); |
277 | 6 | let mut hasher = digest_function.hasher(); |
278 | 6 | let mut decoded_size = 0u64; |
279 | 6 | let mut decoder = Decoder::new() |
280 | 6 | .map_err(|e| make_err!0 (Code::InvalidArgument0 , "Zstd decompression failed: {}", e))?0 ; |
281 | | // `DCtx::out_size()` guarantees a full decompressed block always fits, so |
282 | | // the decoder never stalls for lack of output space within one `run`. |
283 | 6 | let mut out_buf = vec![0u8; zstd::zstd_safe::DCtx::out_size()]; |
284 | | // Last input-size hint from the decoder: nonzero at input EOF means the |
285 | | // stream ended in the middle of a frame and must be rejected. |
286 | 6 | let mut frame_input_hint = 0usize; |
287 | | loop { |
288 | 17 | let chunk16 = compressed_rx |
289 | 17 | .recv() |
290 | 17 | .await |
291 | 17 | .err_tip(|| "Failed to receive compressed data in stream_decode_compressed_upload")?1 ; |
292 | 16 | if chunk.is_empty() { |
293 | 5 | break; // EOF. |
294 | 11 | } |
295 | 11 | let mut in_buffer = InBuffer::around(&chunk); |
296 | | loop { |
297 | 11 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
298 | 11 | frame_input_hint = decoder.run(&mut in_buffer, &mut out_buffer).map_err(|e| {0 |
299 | 0 | make_err!(Code::InvalidArgument, "Zstd decompression failed: {}", e) |
300 | 0 | })?; |
301 | 11 | let produced = Bytes::copy_from_slice(out_buffer.as_slice()); |
302 | | // A completely full output buffer means the decoder may still |
303 | | // have buffered output to flush, even with no input left. |
304 | 11 | let output_was_full = produced.len() == out_buf.len(); |
305 | 11 | if !produced.is_empty() { |
306 | 5 | let produced_u64 = u64::try_from(produced.len()) |
307 | 5 | .err_tip(|| "Decoded chunk size was not convertible to u64")?0 ; |
308 | 5 | decoded_size = decoded_size.checked_add(produced_u64).ok_or_else(|| {0 |
309 | 0 | make_err!( |
310 | 0 | Code::InvalidArgument, |
311 | | "Decoded compressed upload size overflow" |
312 | | ) |
313 | 0 | })?; |
314 | 5 | if decoded_size > expected_size { |
315 | 0 | return Err(make_err!( |
316 | 0 | Code::InvalidArgument, |
317 | 0 | "Decoded compressed upload size {} bytes exceeds digest size {} bytes", |
318 | 0 | decoded_size, |
319 | 0 | expected_size |
320 | 0 | )); |
321 | 5 | } |
322 | 5 | hasher.update(&produced); |
323 | 5 | tx.send(produced).await?0 ; |
324 | 6 | } |
325 | 11 | if in_buffer.pos() == in_buffer.src.len() && !output_was_full { |
326 | 11 | break; |
327 | 0 | } |
328 | | } |
329 | | } |
330 | 5 | if frame_input_hint != 0 { |
331 | 0 | return Err(make_err!( |
332 | 0 | Code::InvalidArgument, |
333 | 0 | "Compressed upload stream ended in the middle of a zstd frame" |
334 | 0 | )); |
335 | 5 | } |
336 | | |
337 | 5 | if decoded_size != expected_size { |
338 | 0 | return Err(make_err!( |
339 | 0 | Code::InvalidArgument, |
340 | 0 | "Decompressed size {} does not match expected size {}", |
341 | 0 | decoded_size, |
342 | 0 | expected_size |
343 | 0 | )); |
344 | 5 | } |
345 | 5 | let actual_digest = hasher.finalize_digest(); |
346 | 5 | if actual_digest != digest { |
347 | 1 | return Err(make_err!( |
348 | 1 | Code::InvalidArgument, |
349 | 1 | "Decompressed digest {} does not match expected digest {}", |
350 | 1 | actual_digest, |
351 | 1 | digest |
352 | 1 | )); |
353 | 4 | } |
354 | | |
355 | 4 | tx.send_eof() |
356 | 4 | .err_tip(|| "Failed to send decompressed upload EOF")?0 ; |
357 | 4 | Ok(()) |
358 | 6 | } |
359 | | |
360 | | /// Encode a raw byte stream into a single zstd frame on `tx`, asynchronously. |
361 | | /// |
362 | | /// This runs entirely on the async runtime and must never occupy a tokio |
363 | | /// blocking-pool thread for the stream's lifetime: `tx` drains at the gRPC |
364 | | /// client's pace, so a blocking implementation (blocking reads from `raw_rx` |
365 | | /// plus `blocking_send` into `tx`) parks one pool thread per concurrent |
366 | | /// compressed download until the client finishes. Enough concurrent downloads |
367 | | /// with slow consumers then exhaust the blocking pool and starve every other |
368 | | /// `spawn_blocking` user (filesystem store I/O, upload decode, credential |
369 | | /// resolution). The zstd frame is instead produced incrementally with the raw |
370 | | /// streaming API: the CPU cost per iteration is bounded by the channel chunk |
371 | | /// size (small — micro/milliseconds), so it is acceptable inline on a worker |
372 | | /// thread, and `tx.send(...).await` gives backpressure without a parked |
373 | | /// thread. |
374 | | /// |
375 | | /// The output is one well-formed zstd frame, identical in wire format to what |
376 | | /// `zstd::stream::read::Encoder` produces (both drive `ZSTD_compressStream` |
377 | | /// on a fresh `CCtx`). |
378 | 11 | pub async fn stream_encode_compressed_download( |
379 | 11 | mut raw_rx: DropCloserReadHalf, |
380 | 11 | wire_compressor: compressor::Value, |
381 | 11 | mut tx: DropCloserWriteHalf, |
382 | 11 | ) -> Result<(), Error> {6 |
383 | | use zstd::stream::raw::{Encoder, InBuffer, Operation, OutBuffer}; |
384 | | |
385 | 6 | if wire_compressor != compressor::Value::Zstd { |
386 | 0 | return Err(make_input_err!( |
387 | 0 | "Streaming download compression only supports zstd, got {:?}", |
388 | 0 | wire_compressor |
389 | 0 | )); |
390 | 6 | } |
391 | | |
392 | 6 | let mut encoder = Encoder::new(ZSTD_COMPRESSION_LEVEL) |
393 | 6 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
394 | | // `CCtx::out_size()` guarantees a full compressed block always fits, so |
395 | | // the encoder never stalls for lack of output space within one `run`. |
396 | 6 | let mut out_buf = vec![0u8; zstd::zstd_safe::CCtx::out_size()]; |
397 | | loop { |
398 | 11 | let chunk10 = raw_rx |
399 | 11 | .recv() |
400 | 11 | .await |
401 | 10 | .err_tip(|| "Failed to receive raw data in stream_encode_compressed_download")?0 ; |
402 | 10 | if chunk.is_empty() { |
403 | 3 | break; // EOF. |
404 | 7 | } |
405 | 7 | let mut in_buffer = InBuffer::around(&chunk); |
406 | 12 | while in_buffer.pos() < in_buffer.src.len() { |
407 | 7 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
408 | 7 | encoder |
409 | 7 | .run(&mut in_buffer, &mut out_buffer) |
410 | 7 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
411 | 7 | let produced = out_buffer.as_slice(); |
412 | 7 | if !produced.is_empty() { |
413 | 2 | tx.send(Bytes::copy_from_slice(produced)).await?0 ; |
414 | 5 | } |
415 | | } |
416 | | } |
417 | | |
418 | | // Finish the frame: flush any internally buffered compressed data plus |
419 | | // the frame epilogue. `finish` reports the bytes still pending, so loop |
420 | | // until it reports none. |
421 | | loop { |
422 | 3 | let mut out_buffer = OutBuffer::around(out_buf.as_mut_slice()); |
423 | 3 | let remaining = encoder |
424 | 3 | .finish(&mut out_buffer, true) |
425 | 3 | .map_err(|e| make_err!0 (Code::Internal0 , "Zstd compression failed: {}", e))?0 ; |
426 | 3 | let produced = out_buffer.as_slice(); |
427 | 3 | if !produced.is_empty() { |
428 | 3 | tx.send(Bytes::copy_from_slice(produced)).await?0 ; |
429 | 0 | } |
430 | 3 | if remaining == 0 { |
431 | 3 | break; |
432 | 0 | } |
433 | | } |
434 | | |
435 | 3 | tx.send_eof() |
436 | 3 | .err_tip(|| "Failed to send compressed download EOF")?0 ; |
437 | 3 | Ok(()) |
438 | 3 | } |