Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-sim/src/cas_load.rs
Line
Count
Source
1
// Copyright 2026 Trace Machina, Inc. 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
//! The CAS load generator (RFC-0061 §3.1): the three budgets of TESTING §2
16
//! against one CAS endpoint. Each phase checks what it gets back, not only
17
//! how long it took: `FindMissingBlobs` must name exactly the blobs that
18
//! were never uploaded, and every read must hash to its digest.
19
20
use core::fmt;
21
use core::future::Future;
22
use core::time::Duration;
23
use std::collections::HashSet;
24
25
use bytes::Bytes;
26
use futures::{StreamExt, stream};
27
use nativelink_error::{Code, Error, make_err};
28
use nativelink_proto::build::bazel::remote::execution::v2::batch_update_blobs_request::Request as BlobRequest;
29
use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_client::ContentAddressableStorageClient;
30
use nativelink_proto::build::bazel::remote::execution::v2::{
31
    BatchUpdateBlobsRequest, FindMissingBlobsRequest, digest_function,
32
};
33
use nativelink_proto::google::bytestream::byte_stream_client::ByteStreamClient;
34
use nativelink_proto::google::bytestream::{ReadRequest, WriteRequest};
35
use nativelink_util::common::DigestInfo;
36
use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc};
37
use tokio::time::Instant;
38
use tonic::transport::Channel;
39
40
use crate::property::Rng;
41
42
/// What one run drives.
43
#[derive(Clone, Debug)]
44
pub struct CasLoad {
45
    pub instance: String,
46
    pub seed: u64,
47
    /// Digests in the one `FindMissingBlobs` call, a tenth of them uploaded
48
    /// first so the answer has both kinds.
49
    pub find_missing_digests: usize,
50
    /// Large blobs uploaded at once through `ByteStream`, then read back.
51
    pub large_uploads: usize,
52
    pub large_mib: usize,
53
    /// Small blobs uploaded, then read at once.
54
    pub reads: usize,
55
    pub read_kib: usize,
56
}
57
58
/// Latencies of one phase.
59
#[derive(Clone, Copy, Debug, Default)]
60
pub struct Phase {
61
    pub name: &'static str,
62
    pub count: usize,
63
    pub errors: usize,
64
    pub total: Duration,
65
    pub p50: Duration,
66
    pub p95: Duration,
67
    pub max: Duration,
68
}
69
70
impl Phase {
71
0
    fn new(
72
0
        name: &'static str,
73
0
        total: Duration,
74
0
        mut latencies: Vec<Duration>,
75
0
        errors: usize,
76
0
    ) -> Self {
77
0
        latencies.sort();
78
0
        let at = |percent: usize| {
79
0
            latencies
80
0
                .get(latencies.len().saturating_sub(1) * percent / 100)
81
0
                .copied()
82
0
                .unwrap_or_default()
83
0
        };
84
0
        Self {
85
0
            name,
86
0
            count: latencies.len(),
87
0
            errors,
88
0
            total,
89
0
            p50: at(50),
90
0
            p95: at(95),
91
0
            max: at(100),
92
0
        }
93
0
    }
94
}
95
96
impl fmt::Display for Phase {
97
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98
0
        write!(
99
0
            f,
100
            "{}: n={} errors={} total={}ms p50={}ms p95={}ms max={}ms",
101
            self.name,
102
            self.count,
103
            self.errors,
104
0
            self.total.as_millis(),
105
0
            self.p50.as_millis(),
106
0
            self.p95.as_millis(),
107
0
            self.max.as_millis(),
108
        )
109
0
    }
110
}
111
112
/// One MiB of the bytes of blob `blob`, block `block`: a function of the
113
/// seed, so a reader can check what it got without holding the blob.
114
8
fn block(seed: u64, blob: u64, block: u64, len: usize) -> Vec<u8> {
115
8
    let mut rng = Rng::new(seed ^ blob.rotate_left(32) ^ block.wrapping_mul(0x9e37_79b9));
116
8
    let mut out = Vec::with_capacity(len + 8);
117
527k
    while out.len() < len {
118
527k
        out.extend_from_slice(&rng.next_u64().to_le_bytes());
119
527k
    }
120
8
    out.truncate(len);
121
8
    out
122
8
}
123
124
const BLOCK: usize = 1024 * 1024;
125
126
/// The digest of a generated blob of `size` bytes, hashed block by block.
127
1
fn generated_digest(seed: u64, blob: u64, size: usize) -> DigestInfo {
128
1
    let mut hasher = DigestHasherFunc::Sha256.hasher();
129
1
    let mut offset = 0;
130
1
    let mut index = 0;
131
4
    while offset < size {
132
3
        let len = BLOCK.min(size - offset);
133
3
        hasher.update(&block(seed, blob, index, len));
134
3
        offset += len;
135
3
        index += 1;
136
3
    }
137
1
    hasher.finalize_digest()
138
1
}
139
140
0
fn digest_of(bytes: &[u8]) -> DigestInfo {
141
0
    let mut hasher = DigestHasherFunc::Sha256.hasher();
142
0
    hasher.update(bytes);
143
0
    hasher.finalize_digest()
144
0
}
145
146
/// `instance/` as a resource name prefix: none for the empty instance, as
147
/// Bazel sends it.
148
0
fn prefix(instance: &str) -> String {
149
0
    if instance.is_empty() {
150
0
        String::new()
151
    } else {
152
0
        format!("{instance}/")
153
    }
154
0
}
155
156
0
fn blob_name(instance: &str, digest: &DigestInfo) -> String {
157
0
    format!(
158
        "{}blobs/{}/{}",
159
0
        prefix(instance),
160
0
        digest.packed_hash(),
161
0
        digest.size_bytes()
162
    )
163
0
}
164
165
0
async fn upload_small(
166
0
    cas: &mut ContentAddressableStorageClient<Channel>,
167
0
    instance: &str,
168
0
    blobs: &[(DigestInfo, Vec<u8>)],
169
0
) -> Result<(), Error> {
170
    // Batched by bytes, as Bazel does: a request over the server's 4 MiB
171
    // message limit is refused whole.
172
    const BATCH_BYTES: usize = 3 * 1024 * 1024;
173
0
    let mut batches: Vec<&[(DigestInfo, Vec<u8>)]> = Vec::new();
174
0
    let mut start = 0;
175
0
    let mut bytes = 0;
176
0
    for (i, (_, data)) in blobs.iter().enumerate() {
177
0
        if i > start && bytes + data.len() > BATCH_BYTES {
178
0
            batches.push(&blobs[start..i]);
179
0
            start = i;
180
0
            bytes = 0;
181
0
        }
182
0
        bytes += data.len();
183
    }
184
0
    if start < blobs.len() {
185
0
        batches.push(&blobs[start..]);
186
0
    }
187
0
    for chunk in batches {
188
0
        let response = cas
189
0
            .batch_update_blobs(BatchUpdateBlobsRequest {
190
0
                instance_name: instance.to_string(),
191
0
                requests: chunk
192
0
                    .iter()
193
0
                    .map(|(digest, data)| BlobRequest {
194
0
                        digest: Some((*digest).into()),
195
0
                        data: Bytes::from(data.clone()),
196
                        compressor: 0,
197
0
                    })
198
0
                    .collect(),
199
0
                digest_function: digest_function::Value::Sha256.into(),
200
            })
201
0
            .await
202
0
            .map_err(|e| make_err!(Code::Internal, "BatchUpdateBlobs failed: {e}"))?
203
0
            .into_inner();
204
0
        if let Some(bad) = response
205
0
            .responses
206
0
            .iter()
207
0
            .find(|r| r.status.as_ref().is_some_and(|s| s.code != 0))
208
        {
209
0
            return Err(make_err!(
210
0
                Code::Internal,
211
0
                "BatchUpdateBlobs refused a blob: {bad:?}"
212
0
            ));
213
0
        }
214
    }
215
0
    Ok(())
216
0
}
217
218
/// One `FindMissingBlobs` over `n` digests, a tenth uploaded first; the
219
/// answer must be exactly the other nine tenths.
220
0
async fn find_missing(
221
0
    cas: &mut ContentAddressableStorageClient<Channel>,
222
0
    load: &CasLoad,
223
0
) -> Result<Phase, Error> {
224
0
    let n = load.find_missing_digests;
225
0
    let mut present = Vec::new();
226
0
    let mut digests = Vec::with_capacity(n);
227
0
    for i in 0..n {
228
0
        let data = format!("fleet-sim-fmb-{}-{i}", load.seed).into_bytes();
229
0
        let digest = digest_of(&data);
230
0
        if i % 10 == 0 {
231
0
            present.push((digest, data));
232
0
        }
233
0
        digests.push(digest);
234
    }
235
0
    upload_small(cas, &load.instance, &present).await?;
236
0
    let expected: HashSet<DigestInfo> = digests
237
0
        .iter()
238
0
        .copied()
239
0
        .filter(|d| !present.iter().any(|(p, _)| p == d))
240
0
        .collect();
241
242
0
    let start = Instant::now();
243
0
    let response = cas
244
0
        .find_missing_blobs(FindMissingBlobsRequest {
245
0
            instance_name: load.instance.clone(),
246
0
            blob_digests: digests.iter().map(|d| (*d).into()).collect(),
247
0
            digest_function: digest_function::Value::Sha256.into(),
248
        })
249
0
        .await
250
0
        .map_err(|e| make_err!(Code::Internal, "FindMissingBlobs failed: {e}"))?
251
0
        .into_inner();
252
0
    let took = start.elapsed();
253
0
    let missing: HashSet<DigestInfo> = response
254
0
        .missing_blob_digests
255
0
        .iter()
256
0
        .filter_map(|d| DigestInfo::try_from(d).ok())
257
0
        .collect();
258
0
    let wrong = missing.symmetric_difference(&expected).count();
259
0
    Ok(Phase::new("find-missing", took, vec![took], wrong))
260
0
}
261
262
/// Streams one generated blob through `ByteStream` `Write`.
263
0
async fn write_large(
264
0
    mut bytestream: ByteStreamClient<Channel>,
265
0
    instance: String,
266
0
    seed: u64,
267
0
    blob: u64,
268
0
    size: usize,
269
0
) -> Result<DigestInfo, Error> {
270
0
    let digest = generated_digest(seed, blob, size);
271
0
    let resource = format!(
272
        "{}uploads/{:032x}/blobs/{}/{}",
273
0
        prefix(&instance),
274
0
        u128::from(seed) << 64 | u128::from(blob),
275
0
        digest.packed_hash(),
276
0
        digest.size_bytes()
277
    );
278
0
    let blocks = size.div_ceil(BLOCK);
279
0
    let requests = stream::iter(0..blocks).map(move |index| {
280
0
        let offset = index * BLOCK;
281
0
        let len = BLOCK.min(size - offset);
282
        WriteRequest {
283
0
            resource_name: if index == 0 {
284
0
                resource.clone()
285
            } else {
286
0
                String::new()
287
            },
288
0
            write_offset: i64::try_from(offset).unwrap_or(i64::MAX),
289
0
            finish_write: index + 1 == blocks,
290
0
            data: Bytes::from(block(seed, blob, index as u64, len)),
291
        }
292
0
    });
293
0
    let response = bytestream
294
0
        .write(requests)
295
0
        .await
296
0
        .map_err(|e| make_err!(Code::Internal, "ByteStream write failed: {e}"))?
297
0
        .into_inner();
298
0
    if usize::try_from(response.committed_size).ok() != Some(size) {
299
0
        return Err(make_err!(
300
0
            Code::Internal,
301
0
            "committed {} of {size} bytes",
302
0
            response.committed_size
303
0
        ));
304
0
    }
305
0
    Ok(digest)
306
0
}
307
308
/// Reads `digest` through `ByteStream` and checks it hashes to itself.
309
0
async fn read_and_verify(
310
0
    mut bytestream: ByteStreamClient<Channel>,
311
0
    instance: String,
312
0
    digest: DigestInfo,
313
0
) -> Result<(), Error> {
314
0
    let mut stream = bytestream
315
0
        .read(ReadRequest {
316
0
            resource_name: blob_name(&instance, &digest),
317
0
            read_offset: 0,
318
0
            read_limit: 0,
319
0
        })
320
0
        .await
321
0
        .map_err(|e| make_err!(Code::Internal, "ByteStream read failed: {e}"))?
322
0
        .into_inner();
323
0
    let mut hasher = DigestHasherFunc::Sha256.hasher();
324
0
    while let Some(chunk) = stream.next().await {
325
0
        let chunk = chunk.map_err(|e| make_err!(Code::Internal, "Read stream failed: {e}"))?;
326
0
        hasher.update(&chunk.data);
327
    }
328
0
    let got = hasher.finalize_digest();
329
0
    if got != digest {
330
0
        return Err(make_err!(Code::DataLoss, "read {got}, expected {digest}"));
331
0
    }
332
0
    Ok(())
333
0
}
334
335
/// Runs `jobs` at once and times each; errors are counted, not fatal.
336
0
async fn timed<F>(jobs: Vec<F>) -> (Duration, Vec<Duration>, Vec<Error>)
337
0
where
338
0
    F: Future<Output = Result<(), Error>> + Send + 'static,
339
0
{
340
0
    let start = Instant::now();
341
0
    let handles: Vec<_> = jobs
342
0
        .into_iter()
343
0
        .map(|job| {
344
0
            nativelink_util::background_spawn!("fleet_sim_cas_job", async move {
345
0
                let begun = Instant::now();
346
0
                job.await.map(|()| begun.elapsed())
347
0
            })
348
0
        })
349
0
        .collect();
350
0
    let mut latencies = Vec::new();
351
0
    let mut errors = Vec::new();
352
0
    for handle in handles {
353
0
        match handle.await {
354
0
            Ok(Ok(took)) => latencies.push(took),
355
0
            Ok(Err(err)) => errors.push(err),
356
0
            Err(err) => errors.push(make_err!(Code::Internal, "Job panicked: {err}")),
357
        }
358
    }
359
0
    (start.elapsed(), latencies, errors)
360
0
}
361
362
/// The three phases, in order. `channels` spreads the streams over several
363
/// connections.
364
0
pub async fn run(load: &CasLoad, channels: &[Channel]) -> Result<(Vec<Phase>, Vec<Error>), Error> {
365
0
    let channel = |i: usize| channels[i % channels.len()].clone();
366
0
    let mut cas = ContentAddressableStorageClient::new(channel(0))
367
0
        .max_decoding_message_size(64 * 1024 * 1024)
368
0
        .max_encoding_message_size(64 * 1024 * 1024);
369
0
    let mut phases = Vec::new();
370
0
    let mut failures = Vec::new();
371
372
0
    if load.find_missing_digests > 0 {
373
0
        phases.push(find_missing(&mut cas, load).await?);
374
0
    }
375
376
0
    if load.large_uploads > 0 {
377
0
        let size = load.large_mib * BLOCK;
378
0
        let jobs: Vec<_> = (0..load.large_uploads)
379
0
            .map(|i| {
380
0
                let client = ByteStreamClient::new(channel(i));
381
0
                let instance = load.instance.clone();
382
0
                let seed = load.seed;
383
0
                async move {
384
0
                    let digest =
385
0
                        write_large(client.clone(), instance.clone(), seed, i as u64, size).await?;
386
0
                    read_and_verify(client, instance, digest).await
387
0
                }
388
0
            })
389
0
            .collect();
390
0
        let (total, latencies, errors) = timed(jobs).await;
391
0
        phases.push(Phase::new(
392
            "large-write-read",
393
0
            total,
394
0
            latencies,
395
0
            errors.len(),
396
        ));
397
0
        failures.extend(errors);
398
0
    }
399
400
0
    if load.reads > 0 {
401
0
        let blobs: Vec<_> = (0..load.reads)
402
0
            .map(|i| {
403
0
                let data = block(load.seed, 1_000_000 + i as u64, 0, load.read_kib * 1024);
404
0
                (digest_of(&data), data)
405
0
            })
406
0
            .collect();
407
0
        upload_small(&mut cas, &load.instance, &blobs).await?;
408
0
        let jobs: Vec<_> = blobs
409
0
            .iter()
410
0
            .enumerate()
411
0
            .map(|(i, (digest, _))| {
412
0
                let client = ByteStreamClient::new(channel(i));
413
0
                let instance = load.instance.clone();
414
0
                let digest = *digest;
415
0
                async move { read_and_verify(client, instance, digest).await }
416
0
            })
417
0
            .collect();
418
0
        let (total, latencies, errors) = timed(jobs).await;
419
0
        phases.push(Phase::new(
420
            "concurrent-reads",
421
0
            total,
422
0
            latencies,
423
0
            errors.len(),
424
        ));
425
0
        failures.extend(errors);
426
0
    }
427
0
    Ok((phases, failures))
428
0
}
429
430
/// The bytes of generated blob `blob`, whole, for tests of the generator.
431
3
pub fn generated_bytes(seed: u64, blob: u64, size: usize) -> Vec<u8> {
432
3
    let mut out = Vec::with_capacity(size);
433
3
    let mut index = 0;
434
8
    while out.len() < size {
435
5
        let len = BLOCK.min(size - out.len());
436
5
        out.extend(block(seed, blob, index, len));
437
5
        index += 1;
438
5
    }
439
3
    out
440
3
}
441
442
/// The digest the generator streams for blob `blob`, for tests.
443
1
pub fn generated_digest_of(seed: u64, blob: u64, size: usize) -> DigestInfo {
444
1
    generated_digest(seed, blob, size)
445
1
}