Coverage Report

Created: 2026-07-21 15:28

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}