/build/source/nativelink-service/src/cas_server.rs
Line | Count | Source |
1 | | // Copyright 2024 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 | | use core::convert::Into; |
16 | | use core::pin::{Pin, pin}; |
17 | | use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; |
18 | | use core::time::Duration; |
19 | | use std::collections::{HashMap, VecDeque}; |
20 | | |
21 | | use bytes::Bytes; |
22 | | use fastcdc::v2020::{AsyncStreamCDC, Normalization}; |
23 | | use futures::stream::{FuturesUnordered, Stream}; |
24 | | use futures::{StreamExt, TryStreamExt}; |
25 | | use nativelink_config::cas_server::{CasStoreConfig, InstanceName, WithInstanceName}; |
26 | | use nativelink_error::{Code, Error, ResultExt, error_if, make_err, make_input_err}; |
27 | | use nativelink_metric::{ |
28 | | MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, group, publish, |
29 | | }; |
30 | | use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_server::{ |
31 | | ContentAddressableStorage, ContentAddressableStorageServer as Server, |
32 | | }; |
33 | | use nativelink_proto::build::bazel::remote::execution::v2::{ |
34 | | BatchReadBlobsRequest, BatchReadBlobsResponse, BatchUpdateBlobsRequest, |
35 | | BatchUpdateBlobsResponse, Digest, Directory, FindMissingBlobsRequest, FindMissingBlobsResponse, |
36 | | GetTreeRequest, GetTreeResponse, SpliceBlobRequest, SpliceBlobResponse, SplitBlobRequest, |
37 | | SplitBlobResponse, batch_read_blobs_response, batch_update_blobs_response, chunking_function, |
38 | | compressor, digest_function, |
39 | | }; |
40 | | use nativelink_proto::google::rpc::Status as GrpcStatus; |
41 | | use nativelink_store::ac_utils::get_and_decode_digest; |
42 | | use nativelink_store::grpc_store::GrpcStore; |
43 | | use nativelink_store::store_manager::StoreManager; |
44 | | use nativelink_util::buf_channel::make_buf_channel_pair; |
45 | | use nativelink_util::common::DigestInfo; |
46 | | use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc, make_ctx_for_hash_func}; |
47 | | use nativelink_util::spawn_blocking; |
48 | | use nativelink_util::store_trait::{Store, StoreLike, UploadSizeInfo}; |
49 | | use opentelemetry::context::FutureExt; |
50 | | use prost::Message; |
51 | | use tokio_util::io::StreamReader; |
52 | | use tonic::{Request, Response, Status}; |
53 | | use tracing::{Instrument, Level, debug, error_span, instrument, warn}; |
54 | | |
55 | | use crate::wire_compression::RemoteCacheCompressionInstances; |
56 | | |
57 | | /// Metrics for the experimental `SplitBlob`/`SpliceBlob` chunking RPCs. |
58 | | /// The split hit rate (`split_hits` / `split_requests_total`) indicates how |
59 | | /// often chunked downloads could be served; the spliced/split byte totals |
60 | | /// bound the transfer volume flowing through the chunked paths. |
61 | | #[derive(Debug, Default)] |
62 | | pub struct ChunkingMetrics { |
63 | | /// Total `SpliceBlob` requests received on chunking-enabled instances. |
64 | | pub splice_requests_total: AtomicU64, |
65 | | /// `SpliceBlob` requests that were no-ops because the blob and its chunk |
66 | | /// layout were already registered. |
67 | | pub splice_already_exists: AtomicU64, |
68 | | /// `SpliceBlob` requests rejected because the re-assembled blob did not |
69 | | /// match the expected digest or size. |
70 | | pub splice_verification_failures: AtomicU64, |
71 | | /// Total bytes of blobs successfully re-assembled by `SpliceBlob`. |
72 | | pub splice_bytes_total: AtomicU64, |
73 | | /// Total `SplitBlob` requests received on chunking-enabled instances. |
74 | | pub split_requests_total: AtomicU64, |
75 | | /// `SplitBlob` requests served from a stored chunk layout. |
76 | | pub split_hits: AtomicU64, |
77 | | /// `SplitBlob` requests that could not be served because the blob was |
78 | | /// not present in the CAS. |
79 | | pub split_misses: AtomicU64, |
80 | | /// `SplitBlob` requests served by chunking the blob on demand because |
81 | | /// no stored layout was available (or its chunks were evicted). |
82 | | pub split_chunked_on_demand: AtomicU64, |
83 | | /// Total bytes of blobs served as chunk layouts by `SplitBlob`. |
84 | | pub split_bytes_total: AtomicU64, |
85 | | } |
86 | | |
87 | | impl MetricsComponent for ChunkingMetrics { |
88 | 0 | fn publish( |
89 | 0 | &self, |
90 | 0 | _kind: MetricKind, |
91 | 0 | field_metadata: MetricFieldData, |
92 | 0 | ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> { |
93 | 0 | let _enter = group!(field_metadata.name).entered(); |
94 | | |
95 | 0 | publish!( |
96 | 0 | "splice_requests_total", |
97 | 0 | &self.splice_requests_total, |
98 | 0 | MetricKind::Counter, |
99 | 0 | "Total SpliceBlob requests received" |
100 | | ); |
101 | 0 | publish!( |
102 | 0 | "splice_already_exists", |
103 | 0 | &self.splice_already_exists, |
104 | 0 | MetricKind::Counter, |
105 | 0 | "SpliceBlob requests that were no-ops because blob and layout already existed" |
106 | | ); |
107 | 0 | publish!( |
108 | 0 | "splice_verification_failures", |
109 | 0 | &self.splice_verification_failures, |
110 | 0 | MetricKind::Counter, |
111 | 0 | "SpliceBlob requests rejected due to digest or size mismatch" |
112 | | ); |
113 | 0 | publish!( |
114 | 0 | "splice_bytes_total", |
115 | 0 | &self.splice_bytes_total, |
116 | 0 | MetricKind::Counter, |
117 | 0 | "Total bytes of blobs re-assembled by SpliceBlob" |
118 | | ); |
119 | 0 | publish!( |
120 | 0 | "split_requests_total", |
121 | 0 | &self.split_requests_total, |
122 | 0 | MetricKind::Counter, |
123 | 0 | "Total SplitBlob requests received" |
124 | | ); |
125 | 0 | publish!( |
126 | 0 | "split_hits", |
127 | 0 | &self.split_hits, |
128 | 0 | MetricKind::Counter, |
129 | 0 | "SplitBlob requests served from a stored chunk layout" |
130 | | ); |
131 | 0 | publish!( |
132 | 0 | "split_misses", |
133 | 0 | &self.split_misses, |
134 | 0 | MetricKind::Counter, |
135 | 0 | "SplitBlob requests where the blob was not present" |
136 | | ); |
137 | 0 | publish!( |
138 | 0 | "split_chunked_on_demand", |
139 | 0 | &self.split_chunked_on_demand, |
140 | 0 | MetricKind::Counter, |
141 | 0 | "SplitBlob requests served by chunking the blob on demand" |
142 | | ); |
143 | 0 | publish!( |
144 | 0 | "split_bytes_total", |
145 | 0 | &self.split_bytes_total, |
146 | 0 | MetricKind::Counter, |
147 | 0 | "Total bytes of blobs served as chunk layouts by SplitBlob" |
148 | | ); |
149 | | |
150 | 0 | Ok(MetricPublishKnownKindData::Component) |
151 | 0 | } |
152 | | } |
153 | | |
154 | | /// Per-instance state for the experimental chunking RPCs. |
155 | | #[derive(Debug, Clone)] |
156 | | struct ChunkingInstance { |
157 | | /// Store holding blob-digest -> chunk-layout mappings. |
158 | | index_store: Store, |
159 | | /// Average chunk size used for server-side `FastCDC` 2020 chunking. |
160 | | avg_chunk_size_bytes: u32, |
161 | | /// Maximum number of chunks accepted in a `SpliceBlob` request or |
162 | | /// produced by on-demand chunking. |
163 | | max_chunk_count: usize, |
164 | | } |
165 | | |
166 | | impl ChunkingInstance { |
167 | | /// Maximum serialized layout size consistent with `max_chunk_count`. |
168 | 8 | const fn max_layout_size(&self) -> u64 { |
169 | 8 | self.max_chunk_count as u64 * MAX_LAYOUT_BYTES_PER_CHUNK |
170 | 8 | } |
171 | | } |
172 | | |
173 | | #[derive(Debug)] |
174 | | pub struct CasServer { |
175 | | stores: HashMap<InstanceName, Store>, |
176 | | remote_cache_compression_instances: RemoteCacheCompressionInstances, |
177 | | chunking_instances: HashMap<InstanceName, ChunkingInstance>, |
178 | | chunking_metrics: ChunkingMetrics, |
179 | | } |
180 | | |
181 | | type GetTreeStream = Pin<Box<dyn Stream<Item = Result<GetTreeResponse, Status>> + Send + 'static>>; |
182 | | |
183 | | /// Per-blob deadline applied inside `BatchReadBlobs` / `BatchUpdateBlobs`. |
184 | | const BATCH_PER_BLOB_TIMEOUT: Duration = Duration::from_secs(30); |
185 | | |
186 | | /// Maximum size of a single chunk accepted in a `SpliceBlob` request. |
187 | | /// Deliberately looser than the largest chunk the server ever advertises |
188 | | /// (4x the maximum allowed average = 4 MiB) so clients using their own |
189 | | /// chunking function are still accepted. Together with `CHUNK_CONCURRENCY` |
190 | | /// this bounds the memory a single splice request can pin. |
191 | | const MAX_SPLICE_CHUNK_SIZE: u64 = 16 * 1024 * 1024; |
192 | | |
193 | | /// Generous upper bound for the serialized size of one chunk entry in a |
194 | | /// stored layout (hash string of up to 128 hex characters plus varints and |
195 | | /// field tags). Multiplied by the configured `max_chunk_count` this caps |
196 | | /// layout reads from the index store; a larger entry is corrupt. A truncated |
197 | | /// read is detected (and treated as no layout) by the size consistency check |
198 | | /// in `read_chunk_layout`. |
199 | | const MAX_LAYOUT_BYTES_PER_CHUNK: u64 = 160; |
200 | | |
201 | | /// Number of chunk reads/writes kept in flight while re-assembling or |
202 | | /// chunking a blob. Matches the `DedupStore` concurrency default. |
203 | | const CHUNK_CONCURRENCY: usize = 10; |
204 | | |
205 | | impl CasServer { |
206 | 28 | pub fn new( |
207 | 28 | configs: &[WithInstanceName<CasStoreConfig>], |
208 | 28 | store_manager: &StoreManager, |
209 | 28 | remote_cache_compression_instances: &RemoteCacheCompressionInstances, |
210 | 28 | ) -> Result<Self, Error> { |
211 | 28 | let mut stores = HashMap::with_capacity(configs.len()); |
212 | 28 | let mut chunking_instances = HashMap::new(); |
213 | 28 | for config in configs { |
214 | 28 | let store = store_manager.get_store(&config.cas_store).ok_or_else(|| {0 |
215 | 0 | make_input_err!("'cas_store': '{}' does not exist", config.cas_store) |
216 | 0 | })?; |
217 | 28 | if let Some(chunking_config13 ) = &config.experimental_chunking { |
218 | 13 | let avg_chunk_size_bytes = chunking_config |
219 | 13 | .validated_avg_chunk_size_bytes() |
220 | 13 | .err_tip(|| {0 |
221 | 0 | format!( |
222 | | "In 'experimental_chunking' of instance '{}'", |
223 | | config.instance_name |
224 | | ) |
225 | 0 | })?; |
226 | 13 | if store.downcast_ref::<GrpcStore>(None).is_some() { |
227 | | // SplitBlob/SpliceBlob for grpc-store-backed instances |
228 | | // are forwarded to the backend, which owns the chunk |
229 | | // layouts; a local index store is meaningless there. |
230 | 1 | error_if!( |
231 | 2 | chunking_config.index_store.is_some(), |
232 | | "'experimental_chunking.index_store' of instance '{}' must not be set when 'cas_store' is a grpc store: SplitBlob/SpliceBlob are forwarded to the backend", |
233 | | config.instance_name |
234 | | ); |
235 | | // No ChunkingInstance: the forwarding shortcut in the |
236 | | // handlers takes over before local chunking is reached. |
237 | 1 | stores.insert(config.instance_name.clone(), store); |
238 | 1 | continue; |
239 | 11 | } |
240 | 11 | let index_store_name = chunking_config.index_store.as_ref().ok_or_else(|| {0 |
241 | 0 | make_input_err!( |
242 | | "'experimental_chunking.index_store' of instance '{}' is required", |
243 | | config.instance_name |
244 | | ) |
245 | 0 | })?; |
246 | | // Chunk layouts are stored under the digests of the blobs |
247 | | // they describe but do not hash to them, so writing them |
248 | | // into the CAS itself would overwrite blob content. |
249 | 1 | error_if!( |
250 | 11 | index_store_name == &config.cas_store, |
251 | | "'experimental_chunking.index_store' of instance '{}' must not be the same store as 'cas_store'", |
252 | | config.instance_name |
253 | | ); |
254 | 10 | let index_store = store_manager.get_store(index_store_name).ok_or_else(|| {0 |
255 | 0 | make_input_err!( |
256 | | "'experimental_chunking.index_store': '{index_store_name}' does not exist" |
257 | | ) |
258 | 0 | })?; |
259 | 10 | let avg_chunk_size_bytes = u32::try_from(avg_chunk_size_bytes) |
260 | 10 | .err_tip(|| "avg_chunk_size_bytes did not fit in u32")?0 ; |
261 | 10 | let max_chunk_count = usize::try_from(chunking_config.resolved_max_chunk_count()) |
262 | 10 | .err_tip(|| "max_chunk_count did not fit in usize")?0 ; |
263 | 10 | chunking_instances.insert( |
264 | 10 | config.instance_name.clone(), |
265 | 10 | ChunkingInstance { |
266 | 10 | index_store, |
267 | 10 | avg_chunk_size_bytes, |
268 | 10 | max_chunk_count, |
269 | 10 | }, |
270 | | ); |
271 | 15 | } |
272 | 25 | stores.insert(config.instance_name.clone(), store); |
273 | | } |
274 | 26 | Ok(Self { |
275 | 26 | stores, |
276 | 26 | remote_cache_compression_instances: remote_cache_compression_instances.clone(), |
277 | 26 | chunking_instances, |
278 | 26 | chunking_metrics: ChunkingMetrics::default(), |
279 | 26 | }) |
280 | 28 | } |
281 | | |
282 | 0 | pub fn into_service(self) -> Server<Self> { |
283 | 0 | Server::new(self) |
284 | 0 | } |
285 | | |
286 | | /// Metrics for the experimental `SplitBlob`/`SpliceBlob` RPCs. |
287 | 6 | pub const fn chunking_metrics(&self) -> &ChunkingMetrics { |
288 | 6 | &self.chunking_metrics |
289 | 6 | } |
290 | | |
291 | 4 | async fn inner_find_missing_blobs( |
292 | 4 | &self, |
293 | 4 | request: FindMissingBlobsRequest, |
294 | 4 | ) -> Result<Response<FindMissingBlobsResponse>, Error> { |
295 | 4 | let instance_name = &request.instance_name; |
296 | 4 | let store = self |
297 | 4 | .stores |
298 | 4 | .get(instance_name) |
299 | 4 | .err_tip(|| format!0 ("'instance_name' not configured for '{instance_name}'"))?0 |
300 | 4 | .clone(); |
301 | | |
302 | 4 | let mut requested_blobs = Vec::with_capacity(request.blob_digests.len()); |
303 | 7 | for digest in &request.blob_digests4 { |
304 | 7 | requested_blobs.push6 (DigestInfo::try_from(digest.clone())?1 .into6 ()); |
305 | | } |
306 | 3 | let sizes = store |
307 | 3 | .has_many(&requested_blobs) |
308 | 3 | .await |
309 | 3 | .err_tip(|| "In find_missing_blobs")?0 ; |
310 | 3 | let missing_blob_digests = sizes |
311 | 3 | .into_iter() |
312 | 3 | .zip(request.blob_digests) |
313 | 5 | .filter_map3 (|(maybe_size, digest)| maybe_size.map_or_else(|| Some(digest2 ), |_| None)) |
314 | 3 | .collect(); |
315 | | |
316 | 3 | Ok(Response::new(FindMissingBlobsResponse { |
317 | 3 | missing_blob_digests, |
318 | 3 | })) |
319 | 4 | } |
320 | | |
321 | 6 | async fn inner_batch_update_blobs( |
322 | 6 | &self, |
323 | 6 | request: BatchUpdateBlobsRequest, |
324 | 6 | ) -> Result<Response<BatchUpdateBlobsResponse>, Error> { |
325 | 6 | let instance_name = &request.instance_name; |
326 | | |
327 | 6 | let store = self |
328 | 6 | .stores |
329 | 6 | .get(instance_name) |
330 | 6 | .err_tip(|| format!0 ("'instance_name' not configured for '{instance_name}'"))?0 |
331 | 6 | .clone(); |
332 | | |
333 | | // If we are a GrpcStore we shortcut here, as this is a special store. |
334 | | // Note: We don't know the digests here, so we try perform a very shallow |
335 | | // check to see if it's a grpc store. |
336 | 6 | if let Some(grpc_store0 ) = store.downcast_ref::<GrpcStore>(None) { |
337 | 0 | return grpc_store.batch_update_blobs(Request::new(request)).await; |
338 | 6 | } |
339 | | |
340 | 6 | let store_ref = &store; |
341 | 6 | let remote_cache_compression_enabled = self |
342 | 6 | .remote_cache_compression_instances |
343 | 6 | .enabled_for(instance_name); |
344 | 6 | let update_futures: FuturesUnordered<_> = request |
345 | 6 | .requests |
346 | 6 | .into_iter() |
347 | 7 | .map6 (|request| { |
348 | 7 | async move { |
349 | 7 | let digest = request |
350 | 7 | .digest |
351 | 7 | .clone() |
352 | 7 | .err_tip(|| "Digest not found in request")?0 ; |
353 | 7 | let digest_info = DigestInfo::try_from(digest.clone())?0 ; |
354 | 7 | let size_bytes = usize::try_from(digest_info.size_bytes()) |
355 | 7 | .err_tip(|| "Digest size_bytes was not convertible to usize")?0 ; |
356 | | |
357 | 7 | let store_data6 = crate::wire_compression::decompress_batch_update( |
358 | 7 | request.data, |
359 | 7 | request.compressor, |
360 | 7 | size_bytes, |
361 | 7 | remote_cache_compression_enabled, |
362 | 7 | ) |
363 | 7 | .await?1 ; |
364 | | // Apply a per-blob deadline so one slow upload does not |
365 | | // make the whole batch hit the client's overall deadline. |
366 | 6 | let result = match tokio::time::timeout( |
367 | | BATCH_PER_BLOB_TIMEOUT, |
368 | 6 | store_ref.update_oneshot(digest_info, store_data), |
369 | | ) |
370 | 6 | .await |
371 | | { |
372 | 5 | Ok(r) => r.err_tip(|| "Error writing to store"), |
373 | 1 | Err(_elapsed) => Err(make_err!( |
374 | 1 | Code::DeadlineExceeded, |
375 | 1 | "BatchUpdateBlobs per-blob timeout ({} s) elapsed for digest {}", |
376 | 1 | BATCH_PER_BLOB_TIMEOUT.as_secs(), |
377 | 1 | digest_info, |
378 | 1 | )), |
379 | | }; |
380 | | Ok::<_, Error>(batch_update_blobs_response::Response { |
381 | 6 | digest: Some(digest), |
382 | 6 | status: Some(result.map_or_else(Into::into, |()| GrpcStatus::default5 ())), |
383 | | }) |
384 | 7 | } |
385 | 7 | }) |
386 | 6 | .collect(); |
387 | 6 | let responses5 = update_futures |
388 | 6 | .try_collect::<Vec<batch_update_blobs_response::Response>>() |
389 | 6 | .await?1 ; |
390 | | |
391 | 5 | Ok(Response::new(BatchUpdateBlobsResponse { responses })) |
392 | 6 | } |
393 | | |
394 | 5 | async fn inner_batch_read_blobs( |
395 | 5 | &self, |
396 | 5 | request: BatchReadBlobsRequest, |
397 | 5 | ) -> Result<Response<BatchReadBlobsResponse>, Error> { |
398 | 5 | let instance_name = &request.instance_name; |
399 | | |
400 | 5 | let store = self |
401 | 5 | .stores |
402 | 5 | .get(instance_name) |
403 | 5 | .err_tip(|| format!0 ("'instance_name' not configured for '{instance_name}'"))?0 |
404 | 5 | .clone(); |
405 | | |
406 | | // If we are a GrpcStore we shortcut here, as this is a special store. |
407 | | // Note: We don't know the digests here, so we try perform a very shallow |
408 | | // check to see if it's a grpc store. |
409 | 5 | if let Some(grpc_store0 ) = store.downcast_ref::<GrpcStore>(None) { |
410 | 0 | return grpc_store.batch_read_blobs(Request::new(request)).await; |
411 | 5 | } |
412 | | |
413 | 5 | let store_ref = &store; |
414 | 5 | let remote_cache_compression_enabled = self |
415 | 5 | .remote_cache_compression_instances |
416 | 5 | .enabled_for(instance_name); |
417 | 5 | let client_accepts_zstd = remote_cache_compression_enabled |
418 | 4 | && request.acceptable_compressors.iter()3 .any3 (|compressor_i32| { |
419 | 4 | compressor::Value::try_from(*compressor_i32) |
420 | 4 | .is_ok_and(|compressor| compressor3 == compressor::Value::Zstd3 ) |
421 | 4 | }); |
422 | 5 | let read_futures: FuturesUnordered<_> = request |
423 | 5 | .digests |
424 | 5 | .into_iter() |
425 | 7 | .map5 (|digest| { |
426 | 7 | async move { |
427 | 7 | let digest_copy = DigestInfo::try_from(digest.clone())?0 ; |
428 | | // TODO(palfrey) There is a security risk here of someone taking all the memory on the instance. |
429 | | // Apply a per-blob deadline so one slow read does not |
430 | | // make the whole batch hit the client's overall deadline. |
431 | 7 | let result = match tokio::time::timeout( |
432 | | BATCH_PER_BLOB_TIMEOUT, |
433 | 7 | store_ref.get_part_unchunked(digest_copy, 0, None), |
434 | | ) |
435 | 7 | .await |
436 | | { |
437 | 6 | Ok(r) => r.err_tip(|| "Error reading from store"), |
438 | 1 | Err(_elapsed) => Err(make_err!( |
439 | 1 | Code::DeadlineExceeded, |
440 | 1 | "BatchReadBlobs per-blob timeout ({} s) elapsed for digest {}", |
441 | 1 | BATCH_PER_BLOB_TIMEOUT.as_secs(), |
442 | 1 | digest_copy, |
443 | 1 | )), |
444 | | }; |
445 | 7 | let (status, data, response_compressor) = match result { |
446 | 5 | Ok(raw_data) => { |
447 | 5 | let (output_data, chosen_compressor) = if client_accepts_zstd { |
448 | 2 | let data_for_compression = raw_data.clone(); |
449 | 2 | match spawn_blocking!("cas_encode_compressed_download", move || { |
450 | 2 | crate::wire_compression::compress_for_batch_read( |
451 | 2 | data_for_compression, |
452 | | ) |
453 | 2 | }) |
454 | 2 | .await |
455 | | { |
456 | 2 | Ok(compressed_result) => compressed_result, |
457 | 0 | Err(e) => { |
458 | 0 | warn!("Wire compression task failed for digest {:?}, falling back to identity: {}", digest, e); |
459 | 0 | (raw_data, compressor::Value::Identity) |
460 | | } |
461 | | } |
462 | | } else { |
463 | 3 | (raw_data, compressor::Value::Identity) |
464 | | }; |
465 | | |
466 | 5 | (GrpcStatus::default(), output_data, chosen_compressor) |
467 | | } |
468 | 2 | Err(mut e) => { |
469 | 2 | if e.code == Code::NotFound { |
470 | 1 | // Trim the error code. Not Found is quite common and we don't want to send a large |
471 | 1 | // error (debug) message for something that is common. We resize to just the last |
472 | 1 | // message as it will be the most relevant. |
473 | 1 | e.messages.resize_with(1, String::new); |
474 | 1 | } |
475 | 2 | (e.into(), Bytes::new(), compressor::Value::Identity) |
476 | | } |
477 | | }; |
478 | 7 | Ok::<_, Error>(batch_read_blobs_response::Response { |
479 | 7 | status: Some(status), |
480 | 7 | digest: Some(digest), |
481 | 7 | compressor: response_compressor.into(), |
482 | 7 | data, |
483 | 7 | }) |
484 | 7 | } |
485 | 7 | }) |
486 | 5 | .collect(); |
487 | 5 | let responses = read_futures |
488 | 5 | .try_collect::<Vec<batch_read_blobs_response::Response>>() |
489 | 5 | .await?0 ; |
490 | | |
491 | 5 | Ok(Response::new(BatchReadBlobsResponse { responses })) |
492 | 5 | } |
493 | | |
494 | 6 | async fn inner_get_tree( |
495 | 6 | &self, |
496 | 6 | request: GetTreeRequest, |
497 | 6 | ) -> Result<impl Stream<Item = Result<GetTreeResponse, Status>> + Send + use<>, Error> { |
498 | 6 | let instance_name = &request.instance_name; |
499 | | |
500 | 6 | let store = self |
501 | 6 | .stores |
502 | 6 | .get(instance_name) |
503 | 6 | .err_tip(|| format!0 ("'instance_name' not configured for '{instance_name}'"))?0 |
504 | 6 | .clone(); |
505 | | |
506 | | // If we are a GrpcStore we shortcut here, as this is a special store. |
507 | | // Note: We don't know the digests here, so we try perform a very shallow |
508 | | // check to see if it's a grpc store. |
509 | 6 | if let Some(grpc_store0 ) = store.downcast_ref::<GrpcStore>(None) { |
510 | 0 | let stream = grpc_store |
511 | 0 | .get_tree(Request::new(request)) |
512 | 0 | .await? |
513 | 0 | .into_inner(); |
514 | 0 | return Ok(stream.left_stream()); |
515 | 6 | } |
516 | 6 | let root_digest: DigestInfo = request |
517 | 6 | .root_digest |
518 | 6 | .err_tip(|| "Expected root_digest to exist in GetTreeRequest")?0 |
519 | 6 | .try_into() |
520 | 6 | .err_tip(|| "In GetTreeRequest::root_digest")?0 ; |
521 | | |
522 | 6 | let mut deque: VecDeque<DigestInfo> = VecDeque::new(); |
523 | 6 | let mut directories: Vec<Directory> = Vec::new(); |
524 | | // `page_token` will return the `{hash_str}-{size_bytes}` of the current request's first directory digest. |
525 | 6 | let page_token_digest = if request.page_token.is_empty() { |
526 | 2 | root_digest |
527 | | } else { |
528 | 4 | let mut page_token_parts = request.page_token.split('-'); |
529 | 4 | DigestInfo::try_new( |
530 | 4 | page_token_parts |
531 | 4 | .next() |
532 | 4 | .err_tip(|| "Failed to parse `hash_str` in `page_token`")?0 , |
533 | 4 | page_token_parts |
534 | 4 | .next() |
535 | 4 | .err_tip(|| "Failed to parse `size_bytes` in `page_token`")?0 |
536 | 4 | .parse::<i64>() |
537 | 4 | .err_tip(|| "Failed to parse `size_bytes` as i64")?0 , |
538 | | ) |
539 | 4 | .err_tip(|| "Failed to parse `page_token` as `Digest` in `GetTreeRequest`")?0 |
540 | | }; |
541 | 6 | let page_size = request.page_size; |
542 | | // If `page_size` is 0, paging is not necessary. |
543 | 6 | let mut page_token_matched = page_size == 0; |
544 | 6 | deque.push_back(root_digest); |
545 | | |
546 | 28 | while !deque.is_empty() { |
547 | 26 | let digest: DigestInfo = deque.pop_front().err_tip(|| "In VecDeque::pop_front")?0 ; |
548 | 26 | let directory = get_and_decode_digest::<Directory>(&store, digest.into()) |
549 | 26 | .await |
550 | 26 | .err_tip(|| "Converting digest to Directory")?0 ; |
551 | 26 | if digest == page_token_digest { |
552 | 6 | page_token_matched = true; |
553 | 20 | } |
554 | 30 | for directory in &directory.directories26 { |
555 | 30 | let digest: DigestInfo = directory |
556 | 30 | .digest |
557 | 30 | .clone() |
558 | 30 | .err_tip(|| "Expected Digest to exist in Directory::directories::digest")?0 |
559 | 30 | .try_into() |
560 | 30 | .err_tip(|| "In Directory::file::digest")?0 ; |
561 | 30 | deque.push_back(digest); |
562 | | } |
563 | | |
564 | 26 | let page_size_usize = usize::try_from(page_size).unwrap_or(usize::MAX); |
565 | | |
566 | 26 | if page_token_matched { |
567 | 20 | directories.push(directory); |
568 | 20 | if directories.len() == page_size_usize { |
569 | 4 | break; |
570 | 16 | } |
571 | 6 | } |
572 | | } |
573 | | // `next_page_token` will return the `{hash_str}:{size_bytes}` of the next request's first directory digest. |
574 | | // It will be an empty string when it reached the end of the directory tree. |
575 | 6 | let next_page_token: String = deque |
576 | 6 | .front() |
577 | 6 | .map_or_else(String::new, |value| format!3 ("{value}")); |
578 | | |
579 | 6 | Ok(futures::stream::once(async { |
580 | 6 | Ok(GetTreeResponse { |
581 | 6 | directories, |
582 | 6 | next_page_token, |
583 | 6 | }) |
584 | 6 | }) |
585 | 6 | .right_stream()) |
586 | 6 | } |
587 | | |
588 | | /// Returns the CAS store for an instance and its chunking state, or |
589 | | /// `Unimplemented` when chunking is not enabled for it. Grpc-store-backed |
590 | | /// instances never reach this: their handlers forward the RPC to the |
591 | | /// backend first. |
592 | 16 | fn chunking_instance(&self, instance_name: &str) -> Result<(Store, ChunkingInstance), Error> { |
593 | 16 | let store = self |
594 | 16 | .stores |
595 | 16 | .get(instance_name) |
596 | 16 | .err_tip(|| format!0 ("'instance_name' not configured for '{instance_name}'"))?0 |
597 | 16 | .clone(); |
598 | 16 | let chunking_instance14 = self |
599 | 16 | .chunking_instances |
600 | 16 | .get(instance_name) |
601 | 16 | .ok_or_else(|| {2 |
602 | 2 | make_err!( |
603 | 2 | Code::Unimplemented, |
604 | | "Blob chunking is not enabled for instance '{instance_name}'" |
605 | | ) |
606 | 2 | })? |
607 | 14 | .clone(); |
608 | 14 | Ok((store, chunking_instance)) |
609 | 16 | } |
610 | | |
611 | | /// Returns the backend `GrpcStore` when the instance's CAS is a grpc |
612 | | /// proxy store, in which case chunking RPCs are forwarded verbatim. |
613 | 16 | fn grpc_store_for_instance(&self, instance_name: &str) -> Option<&GrpcStore> { |
614 | 16 | self.stores |
615 | 16 | .get(instance_name) |
616 | 16 | .and_then(|store| store.downcast_ref::<GrpcStore>(None)) |
617 | 16 | } |
618 | | |
619 | | /// Returns the digest function explicitly requested by the client, or |
620 | | /// `None` when the field was left unset. REAPI's length-based inference |
621 | | /// cannot be used as a fallback here: SHA256 and BLAKE3 digests are both |
622 | | /// 32 bytes, and `NativeLink` announces support for both. Notably Bazel |
623 | | /// (9.1.1) leaves this field unset even when running with |
624 | | /// `--digest_function=blake3`. |
625 | 8 | fn explicit_hasher_func(digest_function_value: i32) -> Option<DigestHasherFunc> { |
626 | 8 | digest_function::Value::try_from(digest_function_value) |
627 | 8 | .ok() |
628 | 8 | .and_then(|value| DigestHasherFunc::try_from(value).ok()) |
629 | 8 | } |
630 | | |
631 | | /// Determines the digest function of a blob already present in the CAS |
632 | | /// by hashing its content with each supported function and returning the |
633 | | /// one that reproduces `blob_digest`. |
634 | 1 | async fn infer_blob_hasher_func( |
635 | 1 | store: &Store, |
636 | 1 | blob_digest: DigestInfo, |
637 | 1 | ) -> Result<DigestHasherFunc, Error> { |
638 | | const CANDIDATES: [DigestHasherFunc; 2] = |
639 | | [DigestHasherFunc::Sha256, DigestHasherFunc::Blake3]; |
640 | 1 | let (tx, rx) = make_buf_channel_pair(); |
641 | 1 | let read_store = store.clone(); |
642 | 1 | let read_fut = async move { |
643 | 1 | let mut tx = tx; |
644 | 1 | read_store |
645 | 1 | .get_part(blob_digest, &mut tx, 0, None) |
646 | 1 | .await |
647 | 1 | .err_tip(|| "Failed to read blob in infer_blob_hasher_func") |
648 | 1 | }; |
649 | 1 | let hash_fut = async move { |
650 | 1 | let mut rx = rx; |
651 | 2 | let mut hashers1 = CANDIDATES.map1 (|func| func.hasher()); |
652 | | loop { |
653 | 2 | let data = rx |
654 | 2 | .recv() |
655 | 2 | .await |
656 | 2 | .err_tip(|| "In infer_blob_hasher_func::recv")?0 ; |
657 | 2 | if data.is_empty() { |
658 | 1 | break; // EOF. |
659 | 1 | } |
660 | 2 | for hasher in &mut hashers1 { |
661 | 2 | hasher.update(&data); |
662 | 2 | } |
663 | | } |
664 | 2 | Ok::<_, Error>(hashers1 .map1 (|mut hasher| hasher.finalize_digest())) |
665 | 1 | }; |
666 | 1 | let (read_res, hash_res) = futures::join!(read_fut, hash_fut); |
667 | 1 | let computed_digests = read_res.merge(hash_res)?0 ; |
668 | 1 | CANDIDATES |
669 | 1 | .iter() |
670 | 1 | .zip(computed_digests) |
671 | 2 | .find1 (|(_, computed)| *computed == blob_digest) |
672 | 1 | .map(|(func, _)| *func) |
673 | 1 | .ok_or_else(|| {0 |
674 | 0 | make_err!( |
675 | 0 | Code::NotFound, |
676 | | "Blob {blob_digest} does not match any supported digest function; no split information available" |
677 | | ) |
678 | 0 | }) |
679 | 1 | } |
680 | | |
681 | | /// Returns the display names of the chunks missing from the CAS. The |
682 | | /// existence check also touches present chunks, which extends their |
683 | | /// lifetimes on a best-effort basis (stores that answer existence from a |
684 | | /// cache may not promote the underlying entries). |
685 | 7 | async fn missing_chunks(store: &Store, chunk_digests: &[Digest]) -> Result<Vec<String>, Error> { |
686 | 7 | let mut digest_infos = Vec::with_capacity(chunk_digests.len()); |
687 | 24 | for digest in chunk_digests7 { |
688 | 24 | digest_infos |
689 | 24 | .push(DigestInfo::try_from(digest.clone()).err_tip(|| "Invalid chunk digest")?0 ); |
690 | | } |
691 | 24 | let chunk_keys7 : Vec<_>7 = digest_infos.iter()7 .map7 (|digest| (*digest).into()).collect7 (); |
692 | 7 | let sizes = store |
693 | 7 | .has_many(&chunk_keys) |
694 | 7 | .await |
695 | 7 | .err_tip(|| "In missing_chunks")?0 ; |
696 | 7 | Ok(sizes |
697 | 7 | .iter() |
698 | 7 | .zip(&digest_infos) |
699 | 24 | .filter7 (|(maybe_size, _)| maybe_size.is_none()) |
700 | 7 | .map(|(_, digest)| digest2 .to_string2 ()) |
701 | 7 | .collect()) |
702 | 7 | } |
703 | | |
704 | | /// Reads the chunk layout registered for a blob. Returns `None` when no |
705 | | /// usable layout exists: not registered, undecodable, or inconsistent |
706 | | /// with the blob size (which also rejects entries truncated by the read |
707 | | /// cap below). |
708 | 8 | async fn read_chunk_layout( |
709 | 8 | chunking_instance: &ChunkingInstance, |
710 | 8 | blob_digest: DigestInfo, |
711 | 8 | ) -> Option<SplitBlobResponse> { |
712 | 8 | let layout_bytes3 = chunking_instance |
713 | 8 | .index_store |
714 | 8 | .get_part_unchunked(blob_digest, 0, Some(chunking_instance.max_layout_size())) |
715 | 8 | .await |
716 | 8 | .ok()?5 ; |
717 | 3 | let layout = SplitBlobResponse::decode(layout_bytes).ok()?0 ; |
718 | | // A usable layout must reproduce the blob exactly, so the chunk |
719 | | // sizes have to add up to the blob size. |
720 | 3 | let mut total_size: u64 = 0; |
721 | 17 | for digest in &layout.chunk_digests3 { |
722 | 17 | total_size = total_size.checked_add(u64::try_from(digest.size_bytes).ok()?0 )?0 ; |
723 | | } |
724 | 3 | (total_size == blob_digest.size_bytes()).then_some(layout) |
725 | 8 | } |
726 | | |
727 | | /// Writes the chunk layout for a blob to the index store. This is the |
728 | | /// write side of the format `read_chunk_layout` expects. |
729 | 6 | async fn write_chunk_layout( |
730 | 6 | index_store: &Store, |
731 | 6 | blob_digest: DigestInfo, |
732 | 6 | layout: &SplitBlobResponse, |
733 | 6 | ) -> Result<(), Error> { |
734 | 6 | index_store |
735 | 6 | .update_oneshot(blob_digest, layout.encode_to_vec().into()) |
736 | 6 | .await |
737 | 6 | .err_tip(|| "Failed to write chunk layout to index store") |
738 | 6 | } |
739 | | |
740 | 9 | async fn inner_split_blob( |
741 | 9 | &self, |
742 | 9 | request: SplitBlobRequest, |
743 | 9 | ) -> Result<Response<SplitBlobResponse>, Error> { |
744 | | // If we are a GrpcStore we forward the RPC to the backend, which |
745 | | // owns chunking and the layout index for proxied instances. |
746 | 9 | if let Some(grpc_store0 ) = self.grpc_store_for_instance(&request.instance_name) { |
747 | 0 | return grpc_store.split_blob(Request::new(request)).await; |
748 | 9 | } |
749 | 9 | let (store8 , chunking_instance8 ) = self.chunking_instance(&request.instance_name)?1 ; |
750 | 8 | self.chunking_metrics |
751 | 8 | .split_requests_total |
752 | 8 | .fetch_add(1, Ordering::Relaxed); |
753 | | |
754 | 8 | let blob_digest: DigestInfo = request |
755 | 8 | .blob_digest |
756 | 8 | .err_tip(|| "Expected blob_digest to exist in SplitBlobRequest")?0 |
757 | 8 | .try_into() |
758 | 8 | .err_tip(|| "In SplitBlobRequest::blob_digest")?0 ; |
759 | | |
760 | | // The existence check also touches the blob, extending its lifetime |
761 | | // (best effort) as suggested by the REAPI spec for SplitBlob. |
762 | 8 | let (blob_exists, maybe_layout) = futures::join!( |
763 | 8 | store.has(blob_digest), |
764 | 8 | Self::read_chunk_layout(&chunking_instance, blob_digest), |
765 | | ); |
766 | 8 | if blob_exists.err_tip(|| "In split_blob")?0 .is_none() { |
767 | 1 | self.chunking_metrics |
768 | 1 | .split_misses |
769 | 1 | .fetch_add(1, Ordering::Relaxed); |
770 | 1 | return Err(make_err!( |
771 | 1 | Code::NotFound, |
772 | 1 | "Blob {blob_digest} not present in the CAS in split_blob" |
773 | 1 | )); |
774 | 7 | } |
775 | | |
776 | | // Serve the registered layout if it is still fully backed by chunks |
777 | | // in the CAS. Any problem with it (missing, corrupt, evicted chunks, |
778 | | // or a transient chunk existence-check failure) falls back to |
779 | | // re-chunking the blob below. |
780 | 7 | if let Some(layout3 ) = maybe_layout |
781 | 2 | && matches!( |
782 | 3 | Self::missing_chunks(&store, &layout.chunk_digests).await, |
783 | 3 | Ok(missing2 ) if missing.is_empty()2 |
784 | | ) |
785 | | { |
786 | 2 | self.chunking_metrics |
787 | 2 | .split_hits |
788 | 2 | .fetch_add(1, Ordering::Relaxed); |
789 | 2 | self.chunking_metrics |
790 | 2 | .split_bytes_total |
791 | 2 | .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); |
792 | 2 | return Ok(Response::new(layout)); |
793 | 5 | } |
794 | | |
795 | | // No usable layout: chunk the blob on demand with FastCDC 2020, |
796 | | // store the chunks and the layout, and serve the result. This is the |
797 | | // path taken for blobs that were uploaded whole (e.g. outputs |
798 | | // produced by remote execution workers). |
799 | 5 | let split_response4 = self |
800 | 5 | .chunk_blob_on_demand( |
801 | 5 | &store, |
802 | 5 | &chunking_instance, |
803 | 5 | blob_digest, |
804 | 5 | request.digest_function, |
805 | 5 | ) |
806 | 5 | .await?1 ; |
807 | 4 | self.chunking_metrics |
808 | 4 | .split_chunked_on_demand |
809 | 4 | .fetch_add(1, Ordering::Relaxed); |
810 | 4 | self.chunking_metrics |
811 | 4 | .split_bytes_total |
812 | 4 | .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); |
813 | 4 | Ok(Response::new(split_response)) |
814 | 9 | } |
815 | | |
816 | | /// Chunks the blob with `FastCDC` 2020 (normalization level 2, parameters |
817 | | /// derived from the configured average chunk size per the REAPI spec), |
818 | | /// uploads any missing chunks to the CAS, registers the layout in the |
819 | | /// index store, and returns it. |
820 | 5 | async fn chunk_blob_on_demand( |
821 | 5 | &self, |
822 | 5 | store: &Store, |
823 | 5 | chunking_instance: &ChunkingInstance, |
824 | 5 | blob_digest: DigestInfo, |
825 | 5 | digest_function_value: i32, |
826 | 5 | ) -> Result<SplitBlobResponse, Error> { |
827 | 5 | let avg_size = chunking_instance.avg_chunk_size_bytes; |
828 | 5 | let (min_size, max_size) = (avg_size / 4, avg_size * 4); |
829 | | // Chunk digests MUST use the blob's digest function. When the client |
830 | | // leaves the field unset it has to be inferred from the blob content |
831 | | // (an extra read pass) since the hash length alone is ambiguous. |
832 | 5 | let hasher_func = match Self::explicit_hasher_func(digest_function_value) { |
833 | 4 | Some(hasher_func) => hasher_func, |
834 | 1 | None => Self::infer_blob_hasher_func(store, blob_digest).await?0 , |
835 | | }; |
836 | | |
837 | 5 | let (tx, rx) = make_buf_channel_pair(); |
838 | 5 | let read_store = store.clone(); |
839 | | // `tx` is moved into the future so that when the read finishes or |
840 | | // fails it is dropped, which terminates the chunking stream. |
841 | 5 | let read_fut = async move { |
842 | 5 | let mut tx = tx; |
843 | 5 | read_store |
844 | 5 | .get_part(blob_digest, &mut tx, 0, None) |
845 | 5 | .await |
846 | 5 | .err_tip(|| format!0 ("Failed to read blob {blob_digest} in chunk_blob_on_demand")) |
847 | 5 | }; |
848 | | // `rx` is owned by this future so an early error return drops it, |
849 | | // which aborts the in-flight read instead of leaving it blocked. |
850 | 5 | let chunk_fut = async move { |
851 | 5 | let mut bytes_reader = StreamReader::new(rx); |
852 | 5 | let mut cdc = AsyncStreamCDC::with_level( |
853 | 5 | &mut bytes_reader, |
854 | 5 | min_size, |
855 | 5 | avg_size, |
856 | 5 | max_size, |
857 | 5 | Normalization::Level2, |
858 | | ); |
859 | | // Chunks are hashed and stored CHUNK_CONCURRENCY at a time while |
860 | | // the blob keeps streaming; `buffered` preserves chunk order. |
861 | 5 | let chunk_digests: Vec<Digest> = pin!(cdc.as_stream()) |
862 | 31 | .map5 (|chunk_result| async { |
863 | 31 | let chunk = chunk_result |
864 | 31 | .map_err(|e| make_err!0 (Code::Internal0 , "Failed to chunk blob: {e:?}")) |
865 | 31 | .err_tip(|| "In chunk_blob_on_demand")?0 ; |
866 | 31 | let mut hasher = hasher_func.hasher(); |
867 | 31 | hasher.update(&chunk.data); |
868 | 31 | let chunk_digest = hasher.finalize_digest(); |
869 | | // The existence check also touches pre-existing chunks, |
870 | | // extending their lifetimes (best effort). FastCDC is |
871 | | // deterministic, so repeated splits of similar blobs |
872 | | // mostly find their chunks present. |
873 | 31 | if store |
874 | 31 | .has(chunk_digest) |
875 | 31 | .await |
876 | 31 | .err_tip(|| "In chunk_blob_on_demand")?0 |
877 | 31 | .is_none() |
878 | | { |
879 | 28 | store |
880 | 28 | .update_oneshot(chunk_digest, chunk.data.into()) |
881 | 28 | .await |
882 | 28 | .err_tip(|| {0 |
883 | 0 | format!( |
884 | | "Failed to store chunk {chunk_digest} in chunk_blob_on_demand" |
885 | | ) |
886 | 0 | })?; |
887 | 3 | } |
888 | 31 | Ok::<Digest, Error>(chunk_digest.into()) |
889 | 62 | }) |
890 | 5 | .buffered(CHUNK_CONCURRENCY) |
891 | 5 | .try_collect() |
892 | 5 | .await?0 ; |
893 | 5 | Ok::<Vec<Digest>, Error>(chunk_digests) |
894 | 5 | }; |
895 | 5 | let (read_res, chunk_res) = futures::join!(read_fut, chunk_fut); |
896 | | // Prefer the read error (the chunker error is usually a consequence |
897 | | // of it); merge keeps both messages when both fail. |
898 | 5 | let chunk_digests = read_res |
899 | 5 | .merge(chunk_res) |
900 | 5 | .err_tip(|| "Failed to chunk blob in chunk_blob_on_demand")?0 ; |
901 | 5 | if chunk_digests.len() > chunking_instance.max_chunk_count { |
902 | 1 | return Err(make_err!( |
903 | 1 | Code::NotFound, |
904 | 1 | "Blob {blob_digest} produced {} chunks, exceeding the configured max_chunk_count of {}; no split information available", |
905 | 1 | chunk_digests.len(), |
906 | 1 | chunking_instance.max_chunk_count |
907 | 1 | )); |
908 | 4 | } |
909 | | |
910 | 4 | let split_response = SplitBlobResponse { |
911 | 4 | chunk_digests, |
912 | 4 | chunking_function: chunking_function::Value::FastCdc2020.into(), |
913 | 4 | }; |
914 | 4 | Self::write_chunk_layout(&chunking_instance.index_store, blob_digest, &split_response) |
915 | 4 | .await?0 ; |
916 | 4 | Ok(split_response) |
917 | 5 | } |
918 | | |
919 | 7 | async fn inner_splice_blob( |
920 | 7 | &self, |
921 | 7 | request: SpliceBlobRequest, |
922 | 7 | ) -> Result<Response<SpliceBlobResponse>, Error> { |
923 | | // If we are a GrpcStore we forward the RPC to the backend, which |
924 | | // owns chunking and the layout index for proxied instances. |
925 | 7 | if let Some(grpc_store0 ) = self.grpc_store_for_instance(&request.instance_name) { |
926 | 0 | return grpc_store.splice_blob(Request::new(request)).await; |
927 | 7 | } |
928 | 7 | let (store6 , chunking_instance6 ) = self.chunking_instance(&request.instance_name)?1 ; |
929 | 6 | let index_store = chunking_instance.index_store; |
930 | 6 | self.chunking_metrics |
931 | 6 | .splice_requests_total |
932 | 6 | .fetch_add(1, Ordering::Relaxed); |
933 | | |
934 | 6 | let blob_digest: DigestInfo = request |
935 | 6 | .blob_digest |
936 | 6 | .err_tip(|| "Expected blob_digest to exist in SpliceBlobRequest")?0 |
937 | 6 | .try_into() |
938 | 6 | .err_tip(|| "In SpliceBlobRequest::blob_digest")?0 ; |
939 | | |
940 | 0 | error_if!( |
941 | 6 | request.chunk_digests.is_empty(), |
942 | | "chunk_digests must not be empty in splice_blob" |
943 | | ); |
944 | 1 | error_if!( |
945 | 6 | request.chunk_digests.len() > chunking_instance.max_chunk_count, |
946 | | "Request has {} chunk_digests, expected at most {} in splice_blob", |
947 | 1 | request.chunk_digests.len(), |
948 | | chunking_instance.max_chunk_count |
949 | | ); |
950 | 5 | let mut chunk_digests = Vec::with_capacity(request.chunk_digests.len()); |
951 | 5 | let mut total_size: u64 = 0; |
952 | 9 | for digest in &request.chunk_digests5 { |
953 | 9 | let digest_info = DigestInfo::try_from(digest.clone()) |
954 | 9 | .err_tip(|| "In SpliceBlobRequest::chunk_digests")?0 ; |
955 | 0 | error_if!( |
956 | 9 | digest_info.size_bytes() == 0 || digest_info.size_bytes() > MAX_SPLICE_CHUNK_SIZE, |
957 | | "Chunk {digest_info} has invalid size, expected to be in range (0, {MAX_SPLICE_CHUNK_SIZE}] in splice_blob" |
958 | | ); |
959 | 9 | total_size += digest_info.size_bytes(); |
960 | 9 | chunk_digests.push(digest_info); |
961 | | } |
962 | 5 | if total_size != blob_digest.size_bytes() { |
963 | 1 | self.chunking_metrics |
964 | 1 | .splice_verification_failures |
965 | 1 | .fetch_add(1, Ordering::Relaxed); |
966 | 1 | return Err(make_err!( |
967 | 1 | Code::InvalidArgument, |
968 | 1 | "Sum of chunk sizes ({total_size}) does not match the expected blob size ({}) in splice_blob", |
969 | 1 | blob_digest.size_bytes() |
970 | 1 | )); |
971 | 4 | } |
972 | | |
973 | | // One round of existence checks: the chunks (which also touches |
974 | | // them, best-effort extending their lifetimes), the blob, and the |
975 | | // registered layout. |
976 | 4 | let (missing_chunks, blob_exists, layout_exists) = futures::join!( |
977 | 4 | Self::missing_chunks(&store, &request.chunk_digests), |
978 | 4 | store.has(blob_digest), |
979 | 4 | index_store.has(blob_digest), |
980 | | ); |
981 | 4 | let missing_chunks = missing_chunks.err_tip(|| "In splice_blob")?0 ; |
982 | 4 | if !missing_chunks.is_empty() { |
983 | 1 | return Err(make_err!( |
984 | 1 | Code::NotFound, |
985 | 1 | "Chunk(s) [{}] not present in the CAS in splice_blob", |
986 | 1 | missing_chunks.join(", ") |
987 | 1 | )); |
988 | 3 | } |
989 | | // Fast path: if the blob and its chunk layout are already registered |
990 | | // this request is a no-op. |
991 | 3 | if blob_exists.err_tip(|| "In splice_blob")?0 .is_some() |
992 | 1 | && layout_exists.err_tip(|| "In splice_blob")?0 .is_some() |
993 | | { |
994 | 0 | self.chunking_metrics |
995 | 0 | .splice_already_exists |
996 | 0 | .fetch_add(1, Ordering::Relaxed); |
997 | 0 | return Ok(Response::new(SpliceBlobResponse { |
998 | 0 | blob_digest: Some(blob_digest.into()), |
999 | 0 | })); |
1000 | 3 | } |
1001 | | |
1002 | | // Re-assemble the blob into the store: chunk reads are pipelined |
1003 | | // CHUNK_CONCURRENCY at a time while hashing and channel writes stay |
1004 | | // in chunk order. The digest is verified before the final EOF is |
1005 | | // sent, so a digest mismatch aborts the upload before the store |
1006 | | // commits it. |
1007 | | // When the client sets the digest function, verify with exactly that |
1008 | | // function. When it is unset the hash length is ambiguous (SHA256 |
1009 | | // and BLAKE3 are both 32 bytes), so hash with both candidates and |
1010 | | // accept whichever reproduces the expected digest. |
1011 | 3 | let candidate_hasher_funcs: Vec<DigestHasherFunc> = |
1012 | 3 | match Self::explicit_hasher_func(request.digest_function) { |
1013 | 2 | Some(hasher_func) => vec![hasher_func], |
1014 | 1 | None => vec![DigestHasherFunc::Sha256, DigestHasherFunc::Blake3], |
1015 | | }; |
1016 | 3 | let verification_failed = AtomicBool::new(false); |
1017 | 3 | let verification_failed_ref = &verification_failed; |
1018 | 3 | let (tx, rx) = make_buf_channel_pair(); |
1019 | 3 | let send_store = store.clone(); |
1020 | | // `tx` is moved into the future so that an early error return drops |
1021 | | // it without an EOF, which aborts the in-flight store update instead |
1022 | | // of leaving it waiting for more data. |
1023 | 3 | let send_fut = async move { |
1024 | 3 | let mut tx = tx; |
1025 | 3 | let mut hashers: Vec<_> = candidate_hasher_funcs |
1026 | 3 | .iter() |
1027 | 3 | .map(DigestHasherFunc::hasher) |
1028 | 3 | .collect(); |
1029 | 3 | let mut fetch_stream = futures::stream::iter(chunk_digests.into_iter().map( |
1030 | 5 | move |chunk_digest| { |
1031 | 5 | let store = send_store.clone(); |
1032 | 5 | async move { |
1033 | 5 | let data = store |
1034 | 5 | .get_part_unchunked(chunk_digest, 0, None) |
1035 | 5 | .await |
1036 | 5 | .err_tip(|| {0 |
1037 | 0 | format!("Failed to read chunk {chunk_digest} in splice_blob") |
1038 | 0 | })?; |
1039 | 5 | if u64::try_from(data.len()).unwrap_or(0) != chunk_digest.size_bytes() { |
1040 | 0 | return Err(make_err!( |
1041 | 0 | Code::Internal, |
1042 | 0 | "Chunk {chunk_digest} content has length {}, expected {}, in splice_blob", |
1043 | 0 | data.len(), |
1044 | 0 | chunk_digest.size_bytes() |
1045 | 0 | )); |
1046 | 5 | } |
1047 | 5 | Ok::<Bytes, Error>(data) |
1048 | 5 | } |
1049 | 5 | }, |
1050 | | )) |
1051 | 3 | .buffered(CHUNK_CONCURRENCY); |
1052 | 8 | while let Some(data5 ) = fetch_stream.next().await { |
1053 | 5 | let data = data?0 ; |
1054 | 6 | for hasher in &mut hashers5 { |
1055 | 6 | hasher.update(&data); |
1056 | 6 | } |
1057 | 5 | tx.send(data) |
1058 | 5 | .await |
1059 | 5 | .err_tip(|| "Failed to send chunk data in splice_blob")?0 ; |
1060 | | } |
1061 | 3 | drop(fetch_stream); |
1062 | 3 | let computed_digests: Vec<DigestInfo> = hashers |
1063 | 3 | .iter_mut() |
1064 | 3 | .map(DigestHasher::finalize_digest) |
1065 | 3 | .collect(); |
1066 | 3 | if !computed_digests.contains(&blob_digest) { |
1067 | 1 | verification_failed_ref.store(true, Ordering::Relaxed); |
1068 | 1 | return Err(make_err!( |
1069 | 1 | Code::InvalidArgument, |
1070 | 1 | "Digest of spliced blob ({}) does not match the expected digest ({blob_digest}) in splice_blob", |
1071 | 1 | computed_digests |
1072 | 1 | .iter() |
1073 | 1 | .map(ToString::to_string) |
1074 | 1 | .collect::<Vec<_>>() |
1075 | 1 | .join(" / ") |
1076 | 1 | )); |
1077 | 2 | } |
1078 | 2 | tx.send_eof() |
1079 | 2 | .err_tip(|| "Failed to send EOF in splice_blob")?0 ; |
1080 | 2 | Ok::<(), Error>(()) |
1081 | 3 | }; |
1082 | 3 | let update_fut = store.update( |
1083 | 3 | blob_digest, |
1084 | 3 | rx, |
1085 | 3 | UploadSizeInfo::ExactSize(blob_digest.size_bytes()), |
1086 | | ); |
1087 | 3 | let (send_res, update_res) = futures::join!(send_fut, update_fut); |
1088 | 3 | if verification_failed.load(Ordering::Relaxed) { |
1089 | 1 | self.chunking_metrics |
1090 | 1 | .splice_verification_failures |
1091 | 1 | .fetch_add(1, Ordering::Relaxed); |
1092 | 2 | } |
1093 | | // Prefer the sender error: it carries the reason the upload was |
1094 | | // aborted (e.g. the digest mismatch), the store error is usually a |
1095 | | // consequence; merge keeps both messages when both fail. |
1096 | 3 | send_res |
1097 | 3 | .merge(update_res) |
1098 | 3 | .err_tip(|| "Failed to write spliced blob to store in splice_blob")?1 ; |
1099 | | |
1100 | | // Persist the chunk layout so SplitBlob can serve it later. |
1101 | 2 | let split_response = SplitBlobResponse { |
1102 | 2 | chunk_digests: request.chunk_digests, |
1103 | 2 | chunking_function: request.chunking_function, |
1104 | 2 | }; |
1105 | 2 | Self::write_chunk_layout(&index_store, blob_digest, &split_response).await?0 ; |
1106 | | |
1107 | 2 | self.chunking_metrics |
1108 | 2 | .splice_bytes_total |
1109 | 2 | .fetch_add(blob_digest.size_bytes(), Ordering::Relaxed); |
1110 | 2 | Ok(Response::new(SpliceBlobResponse { |
1111 | 2 | blob_digest: Some(blob_digest.into()), |
1112 | 2 | })) |
1113 | 7 | } |
1114 | | } |
1115 | | |
1116 | | #[tonic::async_trait] |
1117 | | impl ContentAddressableStorage for CasServer { |
1118 | | type GetTreeStream = GetTreeStream; |
1119 | | |
1120 | | #[instrument( |
1121 | | err, |
1122 | | ret(level = Level::DEBUG), |
1123 | | level = Level::ERROR, |
1124 | | skip_all, |
1125 | | fields( |
1126 | | // Mostly to skip request.blob_digests which is sometimes enormous |
1127 | | request.instance_name = ?grpc_request.get_ref().instance_name, |
1128 | | request.digest_function = ?grpc_request.get_ref().digest_function |
1129 | | ) |
1130 | | )] |
1131 | | async fn find_missing_blobs( |
1132 | | &self, |
1133 | | grpc_request: Request<FindMissingBlobsRequest>, |
1134 | | ) -> Result<Response<FindMissingBlobsResponse>, Status> { |
1135 | | let request = grpc_request.into_inner(); |
1136 | | let digest_function = request.digest_function; |
1137 | | self.inner_find_missing_blobs(request) |
1138 | | .instrument(error_span!("cas_server_find_missing_blobs")) |
1139 | | .with_context( |
1140 | | make_ctx_for_hash_func(digest_function) |
1141 | | .err_tip(|| "In CasServer::find_missing_blobs")?, |
1142 | | ) |
1143 | | .await |
1144 | | .err_tip(|| "Failed on find_missing_blobs() command") |
1145 | | .map_err(Into::into) |
1146 | | } |
1147 | | |
1148 | | #[instrument( |
1149 | | err, |
1150 | | ret(level = Level::DEBUG), |
1151 | | level = Level::ERROR, |
1152 | | skip_all, |
1153 | | fields(request = ?grpc_request.get_ref()) |
1154 | | )] |
1155 | | async fn batch_update_blobs( |
1156 | | &self, |
1157 | | grpc_request: Request<BatchUpdateBlobsRequest>, |
1158 | | ) -> Result<Response<BatchUpdateBlobsResponse>, Status> { |
1159 | | let request = grpc_request.into_inner(); |
1160 | | let digest_function = request.digest_function; |
1161 | | |
1162 | | self.inner_batch_update_blobs(request) |
1163 | | .instrument(error_span!("cas_server_batch_update_blobs")) |
1164 | | .with_context( |
1165 | | make_ctx_for_hash_func(digest_function) |
1166 | | .err_tip(|| "In CasServer::batch_update_blobs")?, |
1167 | | ) |
1168 | | .await |
1169 | | .err_tip(|| "Failed on batch_update_blobs() command") |
1170 | | .map_err(Into::into) |
1171 | | } |
1172 | | |
1173 | | #[instrument( |
1174 | | err, |
1175 | | ret(level = Level::INFO), |
1176 | | level = Level::ERROR, |
1177 | | skip_all, |
1178 | | fields(request = ?grpc_request.get_ref()) |
1179 | | )] |
1180 | | async fn batch_read_blobs( |
1181 | | &self, |
1182 | | grpc_request: Request<BatchReadBlobsRequest>, |
1183 | | ) -> Result<Response<BatchReadBlobsResponse>, Status> { |
1184 | | let request = grpc_request.into_inner(); |
1185 | | let digest_function = request.digest_function; |
1186 | | |
1187 | | self.inner_batch_read_blobs(request) |
1188 | | .instrument(error_span!("cas_server_batch_read_blobs")) |
1189 | | .with_context( |
1190 | | make_ctx_for_hash_func(digest_function) |
1191 | | .err_tip(|| "In CasServer::batch_read_blobs")?, |
1192 | | ) |
1193 | | .await |
1194 | | .err_tip(|| "Failed on batch_read_blobs() command") |
1195 | | .map_err(Into::into) |
1196 | | } |
1197 | | |
1198 | | #[instrument( |
1199 | | err, |
1200 | | level = Level::ERROR, |
1201 | | skip_all, |
1202 | | fields(request = ?grpc_request.get_ref()) |
1203 | | )] |
1204 | | async fn get_tree( |
1205 | | &self, |
1206 | | grpc_request: Request<GetTreeRequest>, |
1207 | | ) -> Result<Response<Self::GetTreeStream>, Status> { |
1208 | | let request = grpc_request.into_inner(); |
1209 | | let digest_function = request.digest_function; |
1210 | | |
1211 | | let resp = self |
1212 | | .inner_get_tree(request) |
1213 | | .instrument(error_span!("cas_server_get_tree")) |
1214 | | .with_context( |
1215 | | make_ctx_for_hash_func(digest_function).err_tip(|| "In CasServer::get_tree")?, |
1216 | | ) |
1217 | | .await |
1218 | | .err_tip(|| "Failed on get_tree() command") |
1219 | 6 | .map(|stream| -> Response<Self::GetTreeStream> { Response::new(Box::pin(stream)) }) |
1220 | | .map_err(Into::into); |
1221 | | |
1222 | | if resp.is_ok() { |
1223 | | debug!(return = "Ok(<stream>)"); |
1224 | | } |
1225 | | resp |
1226 | | } |
1227 | | |
1228 | | #[instrument( |
1229 | | err, |
1230 | | ret(level = Level::DEBUG), |
1231 | | level = Level::ERROR, |
1232 | | skip_all, |
1233 | | fields( |
1234 | | request.instance_name = ?grpc_request.get_ref().instance_name, |
1235 | | request.blob_digest = ?grpc_request.get_ref().blob_digest, |
1236 | | request.digest_function = ?grpc_request.get_ref().digest_function, |
1237 | | ) |
1238 | | )] |
1239 | | async fn split_blob( |
1240 | | &self, |
1241 | | grpc_request: Request<SplitBlobRequest>, |
1242 | | ) -> Result<Response<SplitBlobResponse>, Status> { |
1243 | | let request = grpc_request.into_inner(); |
1244 | | let digest_function = request.digest_function; |
1245 | | self.inner_split_blob(request) |
1246 | | .instrument(error_span!("cas_server_split_blob")) |
1247 | | .with_context( |
1248 | | make_ctx_for_hash_func(digest_function).err_tip(|| "In CasServer::split_blob")?, |
1249 | | ) |
1250 | | .await |
1251 | | .err_tip(|| "Failed on split_blob() command") |
1252 | | .map_err(Into::into) |
1253 | | } |
1254 | | |
1255 | | #[instrument( |
1256 | | err, |
1257 | | ret(level = Level::DEBUG), |
1258 | | level = Level::ERROR, |
1259 | | skip_all, |
1260 | | fields( |
1261 | | // Skip request.chunk_digests which is sometimes enormous. |
1262 | | request.instance_name = ?grpc_request.get_ref().instance_name, |
1263 | | request.blob_digest = ?grpc_request.get_ref().blob_digest, |
1264 | | request.digest_function = ?grpc_request.get_ref().digest_function, |
1265 | | ) |
1266 | | )] |
1267 | | async fn splice_blob( |
1268 | | &self, |
1269 | | grpc_request: Request<SpliceBlobRequest>, |
1270 | | ) -> Result<Response<SpliceBlobResponse>, Status> { |
1271 | | let request = grpc_request.into_inner(); |
1272 | | let digest_function = request.digest_function; |
1273 | | self.inner_splice_blob(request) |
1274 | | .instrument(error_span!("cas_server_splice_blob")) |
1275 | | .with_context( |
1276 | | make_ctx_for_hash_func(digest_function).err_tip(|| "In CasServer::splice_blob")?, |
1277 | | ) |
1278 | | .await |
1279 | | .err_tip(|| "Failed on splice_blob() command") |
1280 | | .map_err(Into::into) |
1281 | | } |
1282 | | } |