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/bin/fleet_sim.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
//! `fleet-sim` (RFC-0061): simulated workers against a real scheduler, and
16
//! a queue of synthetic actions through real `Execute` calls.
17
18
use core::time::Duration;
19
use std::collections::HashMap;
20
use std::sync::Arc;
21
use std::sync::atomic::{AtomicU64, Ordering};
22
23
use bytes::Bytes;
24
use clap::{Parser, Subcommand};
25
use futures::StreamExt;
26
use nativelink_error::{Code, Error, ResultExt, make_err};
27
use nativelink_proto::build::bazel::remote::execution::v2::batch_update_blobs_request::Request as BlobRequest;
28
use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_client::ContentAddressableStorageClient;
29
use nativelink_proto::build::bazel::remote::execution::v2::execution_client::ExecutionClient;
30
use nativelink_proto::build::bazel::remote::execution::v2::{
31
    Action, BatchUpdateBlobsRequest, Command, Digest, Directory, ExecuteRequest, ExecuteResponse,
32
    WaitExecutionRequest, digest_function,
33
};
34
use nativelink_proto::google::longrunning::operation;
35
use nativelink_sim::cas_load::{self, CasLoad};
36
use nativelink_sim::fleet::{Counters, Distribution, FleetWorker, spawn_worker};
37
use nativelink_sim::property::Rng;
38
use nativelink_util::background_spawn;
39
use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc};
40
use prost::Message;
41
use tokio::sync::Semaphore;
42
use tokio::time::Instant;
43
use tonic::transport::Channel;
44
45
#[derive(Parser, Debug)]
46
#[command(about = "Simulated workers and a synthetic queue against a real NativeLink")]
47
struct Args {
48
    #[command(subcommand)]
49
    command: Command_,
50
}
51
52
#[derive(Subcommand, Debug)]
53
enum Command_ {
54
    /// Registers simulated workers with a scheduler's worker API.
55
    Workers(WorkersArgs),
56
    /// Uploads distinct actions and executes them, reading every stream to
57
    /// the end.
58
    Queue(QueueArgs),
59
    /// Drives the CAS budgets of the L point against one CAS endpoint and
60
    /// checks every answer.
61
    Cas(CasArgs),
62
}
63
64
#[derive(clap::Args, Debug)]
65
struct CasArgs {
66
    /// The CAS endpoint, e.g. `grpc://127.0.0.1:50051`.
67
    #[arg(long)]
68
    cas: String,
69
    #[arg(long, default_value = "main")]
70
    instance: String,
71
    #[arg(long, default_value_t = 1)]
72
    seed: u64,
73
    /// Digests in one `FindMissingBlobs` call.
74
    #[arg(long, default_value_t = 50_000)]
75
    find_missing_digests: usize,
76
    /// Blobs written at once through `ByteStream`, then read back.
77
    #[arg(long, default_value_t = 4)]
78
    large_uploads: usize,
79
    #[arg(long, default_value_t = 256)]
80
    large_mib: usize,
81
    /// Blobs read at once.
82
    #[arg(long, default_value_t = 2_000)]
83
    reads: usize,
84
    #[arg(long, default_value_t = 64)]
85
    read_kib: usize,
86
    /// HTTP/2 connections the streams spread over.
87
    #[arg(long, default_value_t = 16)]
88
    connections: usize,
89
}
90
91
#[derive(clap::Args, Debug)]
92
struct WorkersArgs {
93
    /// The worker API, e.g. `grpc://127.0.0.1:50061`.
94
    #[arg(long)]
95
    scheduler: String,
96
    #[arg(long, default_value_t = 10)]
97
    workers: usize,
98
    #[arg(long, default_value_t = 1)]
99
    max_inflight: u64,
100
    /// Run time per action, uniform in `MIN-MAX` milliseconds.
101
    #[arg(long, default_value = "500-2000")]
102
    run_ms: String,
103
    #[arg(long, default_value_t = 0)]
104
    fail_percent: u64,
105
    #[arg(long, default_value_t = 1)]
106
    seed: u64,
107
    /// Platform properties each worker advertises, `name=value`.
108
    #[arg(long = "property")]
109
    properties: Vec<String>,
110
    /// Restart one worker, chosen by the seed, this often (0 never).
111
    #[arg(long, default_value_t = 0)]
112
    restart_every_s: u64,
113
    /// Stop after this long (0 runs until killed).
114
    #[arg(long, default_value_t = 0)]
115
    duration_s: u64,
116
    #[arg(long, default_value_t = 10)]
117
    report_every_s: u64,
118
}
119
120
#[derive(clap::Args, Debug)]
121
struct QueueArgs {
122
    /// The CAS endpoint, e.g. `grpc://127.0.0.1:50051`.
123
    #[arg(long)]
124
    cas: String,
125
    /// The execution endpoint; the CAS endpoint when unset.
126
    #[arg(long)]
127
    execution: Option<String>,
128
    #[arg(long, default_value = "main")]
129
    instance: String,
130
    #[arg(long, default_value_t = 100)]
131
    actions: u64,
132
    /// Executes started per second (0 all at once).
133
    #[arg(long, default_value_t = 0)]
134
    rate: u64,
135
    /// Streams open at once.
136
    #[arg(long, default_value_t = 10_000)]
137
    concurrency: usize,
138
    /// HTTP/2 connections the streams spread over, as Bazel's
139
    /// `--remote_max_connections` does; one connection carrying thousands
140
    /// of streams is closed by the server's reset limit.
141
    #[arg(long, default_value_t = 16)]
142
    connections: usize,
143
    /// Distinguishes this run's actions from an earlier run's.
144
    #[arg(long, default_value_t = 1)]
145
    seed: u64,
146
    /// Platform properties each action requires, `name=value`.
147
    #[arg(long = "property")]
148
    properties: Vec<String>,
149
}
150
151
0
fn parse_properties(properties: &[String]) -> Result<HashMap<String, String>, Error> {
152
0
    properties
153
0
        .iter()
154
0
        .map(|p| {
155
0
            p.split_once('=')
156
0
                .map(|(k, v)| (k.to_string(), v.to_string()))
157
0
                .ok_or_else(|| make_err!(Code::InvalidArgument, "Property {p} is not name=value"))
158
0
        })
159
0
        .collect()
160
0
}
161
162
0
fn parse_range(range: &str) -> Result<(u64, u64), Error> {
163
0
    let (low, high) = range.split_once('-').unwrap_or((range, range));
164
0
    let parse = |v: &str| {
165
0
        v.parse::<u64>()
166
0
            .map_err(|e| make_err!(Code::InvalidArgument, "Bad range {range}: {e}"))
167
0
    };
168
0
    Ok((parse(low)?, parse(high)?))
169
0
}
170
171
0
async fn run_workers(args: WorkersArgs) -> Result<(), Error> {
172
    let WorkersArgs {
173
0
        scheduler,
174
0
        workers,
175
0
        max_inflight,
176
0
        run_ms,
177
0
        fail_percent,
178
0
        seed,
179
0
        properties,
180
0
        restart_every_s,
181
0
        duration_s,
182
0
        report_every_s,
183
0
    } = args;
184
0
    let run_ms = parse_range(&run_ms)?;
185
0
    let properties = parse_properties(&properties)?;
186
0
    let counters = Arc::new(Counters::default());
187
0
    let source = Arc::new(Distribution {
188
0
        seed,
189
0
        run_ms,
190
0
        fail_percent,
191
0
        counters: counters.clone(),
192
0
    });
193
0
    let fleet: Vec<FleetWorker> = (0..workers)
194
0
        .map(|i| FleetWorker {
195
0
            name: format!("sim-{i}-"),
196
0
            scheduler: scheduler.clone(),
197
0
            max_inflight,
198
0
            properties: properties.clone(),
199
0
        })
200
0
        .collect();
201
0
    let mut running: Vec<_> = fleet
202
0
        .iter()
203
0
        .map(|w| spawn_worker(w, source.clone(), counters.clone()))
204
0
        .collect();
205
0
    eprintln!("fleet-sim: {workers} workers against {scheduler}");
206
0
    let lag = spawn_lag_probe();
207
208
0
    let start = Instant::now();
209
0
    let mut report = tokio::time::interval(Duration::from_secs(report_every_s.max(1)));
210
0
    let mut restart = tokio::time::interval(Duration::from_secs(restart_every_s.max(1)));
211
0
    report.tick().await;
212
0
    restart.tick().await;
213
0
    let mut rng = Rng::new(seed);
214
0
    let mut restarts = 0u64;
215
    loop {
216
0
        tokio::select! {
217
0
            _ = report.tick() => {
218
0
                let alive = running.iter().filter(|w| !w.is_finished()).count();
219
0
                let max_lag_ms = lag.swap(0, Ordering::Relaxed);
220
0
                eprintln!(
221
                    "fleet-sim: t={}s alive={alive} restarts={restarts} {counters} max_lag_ms={max_lag_ms}",
222
0
                    start.elapsed().as_secs()
223
                );
224
            }
225
0
            _ = restart.tick(), if restart_every_s > 0 => {
226
0
                let i = rng.index(running.len());
227
0
                running[i].stop();
228
0
                running[i] = spawn_worker(&fleet[i], source.clone(), counters.clone());
229
0
                restarts += 1;
230
0
            }
231
        }
232
0
        if duration_s > 0 && start.elapsed() >= Duration::from_secs(duration_s) {
233
0
            break;
234
0
        }
235
    }
236
0
    for worker in &running {
237
0
        worker.stop();
238
0
    }
239
0
    eprintln!("fleet-sim: done restarts={restarts} {counters}");
240
0
    Ok(())
241
0
}
242
243
/// How late, at most, a 100 ms timer has fired since the last read: a
244
/// simulator starved of CPU shows here, before it shows as a slow
245
/// scheduler (RFC-0061 ยง6).
246
0
fn spawn_lag_probe() -> Arc<AtomicU64> {
247
    const TICK: Duration = Duration::from_millis(100);
248
0
    let max_lag_ms = Arc::new(AtomicU64::new(0));
249
0
    let probe = max_lag_ms.clone();
250
0
    drop(background_spawn!("fleet_sim_lag_probe", async move {
251
        loop {
252
0
            let due = Instant::now() + TICK;
253
0
            tokio::time::sleep_until(due).await;
254
0
            let late =
255
0
                u64::try_from(Instant::now().duration_since(due).as_millis()).unwrap_or(u64::MAX);
256
0
            probe.fetch_max(late, Ordering::Relaxed);
257
        }
258
    }));
259
0
    max_lag_ms
260
0
}
261
262
0
async fn run_cas(args: CasArgs) -> Result<(), Error> {
263
0
    let mut channels = Vec::with_capacity(args.connections.max(1));
264
0
    for _ in 0..args.connections.max(1) {
265
0
        channels.push(connect(&args.cas).await?);
266
    }
267
0
    let load = CasLoad {
268
0
        instance: args.instance,
269
0
        seed: args.seed,
270
0
        find_missing_digests: args.find_missing_digests,
271
0
        large_uploads: args.large_uploads,
272
0
        large_mib: args.large_mib,
273
0
        reads: args.reads,
274
0
        read_kib: args.read_kib,
275
0
    };
276
0
    let (phases, failures) = cas_load::run(&load, &channels).await?;
277
0
    for phase in &phases {
278
0
        eprintln!("fleet-sim: {phase}");
279
0
    }
280
0
    for failure in failures.iter().take(5) {
281
0
        eprintln!("fleet-sim: error: {failure}");
282
0
    }
283
0
    if phases.iter().any(|p| p.errors > 0) {
284
0
        return Err(make_err!(
285
0
            Code::Internal,
286
0
            "the CAS gave wrong or failed answers"
287
0
        ));
288
0
    }
289
0
    Ok(())
290
0
}
291
292
0
fn digest_of(bytes: &[u8]) -> Digest {
293
0
    let mut hasher = DigestHasherFunc::Sha256.hasher();
294
0
    hasher.update(bytes);
295
0
    hasher.finalize_digest().into()
296
0
}
297
298
0
async fn connect(uri: &str) -> Result<Channel, Error> {
299
0
    nativelink_util::tls_utils::endpoint_from(uri, None)?
300
0
        .connect()
301
0
        .await
302
0
        .map_err(|e| make_err!(Code::Unavailable, "Could not connect to {uri}: {e}"))
303
0
}
304
305
#[derive(Debug, Default)]
306
struct QueueCounters {
307
    done: AtomicU64,
308
    exit_zero: AtomicU64,
309
    exit_nonzero: AtomicU64,
310
    failed: AtomicU64,
311
    reconnects: AtomicU64,
312
}
313
314
/// One client: execute, read to the end, re-subscribe on a dropped stream.
315
0
async fn execute_one(
316
0
    mut execution: ExecutionClient<Channel>,
317
0
    request: ExecuteRequest,
318
0
    counters: Arc<QueueCounters>,
319
0
) -> Duration {
320
0
    let start = Instant::now();
321
0
    let mut name: Option<String> = None;
322
0
    for _ in 0..10 {
323
0
        let response = match &name {
324
0
            None => execution.execute(request.clone()).await,
325
0
            Some(name) => {
326
0
                execution
327
0
                    .wait_execution(WaitExecutionRequest { name: name.clone() })
328
0
                    .await
329
            }
330
        };
331
0
        let mut stream = match response {
332
0
            Ok(response) => response.into_inner(),
333
0
            Err(status) if status.code() == Code::NotFound && name.is_some() => {
334
0
                name = None;
335
0
                continue;
336
            }
337
            Err(_) => {
338
0
                counters.reconnects.fetch_add(1, Ordering::Relaxed);
339
0
                tokio::time::sleep(Duration::from_secs(1)).await;
340
0
                continue;
341
            }
342
        };
343
0
        while let Some(item) = stream.next().await {
344
0
            let Ok(operation) = item else { break };
345
0
            name = Some(operation.name.clone());
346
0
            if !operation.done {
347
0
                continue;
348
0
            }
349
0
            counters.done.fetch_add(1, Ordering::Relaxed);
350
0
            let exit = match operation.result {
351
0
                Some(operation::Result::Response(any)) => {
352
0
                    ExecuteResponse::decode(any.value.as_slice())
353
0
                        .ok()
354
0
                        .filter(|r| r.status.as_ref().is_none_or(|s| s.code == 0))
355
0
                        .map(|r| r.result.map_or(0, |a| a.exit_code))
356
                }
357
0
                _ => None,
358
            };
359
0
            match exit {
360
0
                Some(0) => counters.exit_zero.fetch_add(1, Ordering::Relaxed),
361
0
                Some(_) => counters.exit_nonzero.fetch_add(1, Ordering::Relaxed),
362
0
                None => counters.failed.fetch_add(1, Ordering::Relaxed),
363
            };
364
0
            return start.elapsed();
365
        }
366
0
        counters.reconnects.fetch_add(1, Ordering::Relaxed);
367
0
        tokio::time::sleep(Duration::from_secs(1)).await;
368
    }
369
0
    counters.done.fetch_add(1, Ordering::Relaxed);
370
0
    counters.failed.fetch_add(1, Ordering::Relaxed);
371
0
    start.elapsed()
372
0
}
373
374
0
async fn run_queue(args: QueueArgs) -> Result<(), Error> {
375
    let QueueArgs {
376
0
        cas,
377
0
        execution,
378
0
        instance,
379
0
        actions,
380
0
        rate,
381
0
        concurrency,
382
0
        connections,
383
0
        seed,
384
0
        properties,
385
0
    } = args;
386
0
    let properties = parse_properties(&properties)?;
387
0
    let cas_channel = connect(&cas).await?;
388
0
    let execution_uri = execution.as_deref().unwrap_or(&cas);
389
0
    let mut execution_clients = Vec::with_capacity(connections.max(1));
390
0
    for _ in 0..connections.max(1) {
391
0
        execution_clients.push(ExecutionClient::new(connect(execution_uri).await?));
392
    }
393
0
    let mut cas_client = ContentAddressableStorageClient::new(cas_channel);
394
395
    // Every action shares the empty input root; each has its own command.
396
0
    let input_root = Directory::default().encode_to_vec();
397
0
    let input_root_digest = digest_of(&input_root);
398
0
    let mut blobs = vec![(input_root_digest.clone(), input_root)];
399
0
    let mut digests = Vec::new();
400
0
    let platform = (!properties.is_empty()).then(|| {
401
        nativelink_proto::build::bazel::remote::execution::v2::Platform {
402
0
            properties: properties
403
0
                .iter()
404
0
                .map(|(name, value)| {
405
0
                    nativelink_proto::build::bazel::remote::execution::v2::platform::Property {
406
0
                        name: name.clone(),
407
0
                        value: value.clone(),
408
0
                    }
409
0
                })
410
0
                .collect(),
411
        }
412
0
    });
413
0
    for i in 0..actions {
414
0
        let command = Command {
415
0
            arguments: vec!["fleet-sim".into(), format!("{seed}-{i}")],
416
0
            ..Default::default()
417
0
        }
418
0
        .encode_to_vec();
419
0
        let command_digest = digest_of(&command);
420
0
        let action = Action {
421
0
            command_digest: Some(command_digest.clone()),
422
0
            input_root_digest: Some(input_root_digest.clone()),
423
0
            platform: platform.clone(),
424
0
            ..Default::default()
425
0
        }
426
0
        .encode_to_vec();
427
0
        let action_digest = digest_of(&action);
428
0
        blobs.push((command_digest, command));
429
0
        blobs.push((action_digest.clone(), action));
430
0
        digests.push(action_digest);
431
0
    }
432
0
    for chunk in blobs.chunks(500) {
433
0
        let response = cas_client
434
0
            .batch_update_blobs(BatchUpdateBlobsRequest {
435
0
                instance_name: instance.clone(),
436
0
                requests: chunk
437
0
                    .iter()
438
0
                    .map(|(digest, data)| BlobRequest {
439
0
                        digest: Some(digest.clone()),
440
0
                        data: Bytes::from(data.clone()),
441
                        compressor: 0,
442
0
                    })
443
0
                    .collect(),
444
0
                digest_function: digest_function::Value::Sha256.into(),
445
            })
446
0
            .await
447
0
            .map_err(|e| make_err!(Code::Internal, "Upload failed: {e}"))?
448
0
            .into_inner();
449
        // The RPC can succeed while a blob is refused in its own status;
450
        // an action whose blob was refused fails at execute with a missing
451
        // blob that looks like the scheduler's.
452
0
        if let Some(bad) = response
453
0
            .responses
454
0
            .iter()
455
0
            .find(|r| r.status.as_ref().is_some_and(|s| s.code != 0))
456
        {
457
0
            return Err(make_err!(Code::Internal, "Upload refused a blob: {bad:?}"));
458
0
        }
459
    }
460
0
    eprintln!("fleet-sim: uploaded {actions} actions to {cas}");
461
462
0
    let counters = Arc::new(QueueCounters::default());
463
0
    let permits = Arc::new(Semaphore::new(concurrency.max(1)));
464
0
    let start = Instant::now();
465
0
    let mut tasks = Vec::with_capacity(digests.len());
466
0
    for (i, digest) in digests.into_iter().enumerate() {
467
0
        if rate > 0 {
468
0
            let due = Duration::from_secs_f64(i as f64 / rate as f64);
469
0
            tokio::time::sleep_until(start + due).await;
470
0
        }
471
0
        let permit = permits
472
0
            .clone()
473
0
            .acquire_owned()
474
0
            .await
475
0
            .err_tip(|| "Semaphore closed")?;
476
0
        let request = ExecuteRequest {
477
0
            instance_name: instance.clone(),
478
0
            skip_cache_lookup: true,
479
0
            action_digest: Some(digest),
480
0
            execution_policy: None,
481
0
            results_cache_policy: None,
482
0
            digest_function: digest_function::Value::Sha256.into(),
483
0
            inline_stdout: false,
484
0
            inline_stderr: false,
485
0
            inline_output_files: Vec::new(),
486
0
        };
487
0
        let client = execution_clients[i % execution_clients.len()].clone();
488
0
        let counters = counters.clone();
489
0
        tasks.push(background_spawn!("fleet_sim_client", async move {
490
0
            let took = execute_one(client, request, counters).await;
491
0
            drop(permit);
492
0
            took
493
0
        }));
494
    }
495
0
    let mut report = tokio::time::interval(Duration::from_secs(5));
496
0
    report.tick().await;
497
0
    let waiting = futures::future::join_all(tasks);
498
0
    tokio::pin!(waiting);
499
0
    let latencies = loop {
500
0
        tokio::select! {
501
0
            done = &mut waiting => break done,
502
0
            _ = report.tick() => eprintln!(
503
                "fleet-sim: t={}s done={} of {actions}",
504
0
                start.elapsed().as_secs(),
505
0
                counters.done.load(Ordering::Relaxed)
506
            ),
507
        }
508
    };
509
0
    let mut latencies: Vec<Duration> = latencies.into_iter().filter_map(Result::ok).collect();
510
0
    latencies.sort();
511
0
    let at = |percent: usize| {
512
0
        latencies
513
0
            .get(latencies.len().saturating_sub(1) * percent / 100)
514
0
            .map_or(0, Duration::as_millis)
515
0
    };
516
0
    eprintln!(
517
        "fleet-sim: done in {}s: exit0={} exit_nonzero={} failed={} reconnects={} p50={}ms p95={}ms max={}ms",
518
0
        start.elapsed().as_secs(),
519
0
        counters.exit_zero.load(Ordering::Relaxed),
520
0
        counters.exit_nonzero.load(Ordering::Relaxed),
521
0
        counters.failed.load(Ordering::Relaxed),
522
0
        counters.reconnects.load(Ordering::Relaxed),
523
0
        at(50),
524
0
        at(95),
525
0
        at(100),
526
    );
527
0
    if counters.failed.load(Ordering::Relaxed) > 0 {
528
0
        return Err(make_err!(Code::Internal, "some operations failed"));
529
0
    }
530
0
    Ok(())
531
0
}
532
533
0
fn main() -> Result<(), Box<dyn core::error::Error>> {
534
0
    let args = Args::parse();
535
    #[expect(clippy::disallowed_methods, reason = "the binary's runtime")]
536
0
    let runtime = tokio::runtime::Builder::new_multi_thread()
537
0
        .enable_all()
538
0
        .build()?;
539
    #[expect(clippy::disallowed_methods, reason = "the binary's main future")]
540
0
    runtime.block_on(async move {
541
0
        match args.command {
542
0
            Command_::Workers(args) => run_workers(args).await,
543
0
            Command_::Queue(args) => run_queue(args).await,
544
0
            Command_::Cas(args) => run_cas(args).await,
545
        }
546
0
    })?;
547
0
    Ok(())
548
0
}