/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 | } |