/build/source/nativelink-sim/src/fleet.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 fleet simulator's workers (RFC-0061): production `LocalWorker`s over |
16 | | //! the production gRPC client, against a real scheduler, with execution |
17 | | //! drawn from a seeded distribution instead of a scenario. |
18 | | |
19 | | use core::fmt; |
20 | | use core::sync::atomic::{AtomicU64, Ordering}; |
21 | | use core::time::Duration; |
22 | | use std::collections::HashMap; |
23 | | use std::sync::Arc; |
24 | | |
25 | | use nativelink_config::cas_server::{EndpointConfig, LocalWorkerConfig, WorkerProperty}; |
26 | | use nativelink_error::Error; |
27 | | use nativelink_proto::com::github::trace_machina::nativelink::remote_execution::StartExecute; |
28 | | use nativelink_util::action_messages::OperationId; |
29 | | use nativelink_util::background_spawn; |
30 | | use nativelink_util::shutdown_guard::ShutdownGuard; |
31 | | use nativelink_worker::local_worker::{LocalWorker, connect_to_scheduler}; |
32 | | use tokio::sync::broadcast; |
33 | | use tokio::task::JoinHandle; |
34 | | |
35 | | use crate::actions::{ActionSource, Plan, Run, SimActionsManager}; |
36 | | use crate::property::Rng; |
37 | | use crate::trace::Event; |
38 | | |
39 | | /// What the fleet did, counted as it happens. |
40 | | #[derive(Debug, Default)] |
41 | | pub struct Counters { |
42 | | /// Connection attempts to the scheduler. |
43 | | pub connects: AtomicU64, |
44 | | /// Actions a worker accepted and started. |
45 | | pub started: AtomicU64, |
46 | | /// Executions that ran to the end. |
47 | | pub finished: AtomicU64, |
48 | | /// Executions drawn to exit non-zero. |
49 | | pub failed: AtomicU64, |
50 | | /// Executions cut short by a kill or a disconnect. |
51 | | pub killed: AtomicU64, |
52 | | /// Kill requests from the scheduler. |
53 | | pub kill_requests: AtomicU64, |
54 | | /// `kill_all`, after a worker lost its connection. |
55 | | pub kill_all_calls: AtomicU64, |
56 | | } |
57 | | |
58 | | impl fmt::Display for Counters { |
59 | 0 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
60 | 0 | let get = |c: &AtomicU64| c.load(Ordering::Relaxed); |
61 | 0 | write!( |
62 | 0 | f, |
63 | | "connects={} started={} finished={} failed={} killed={} kill_requests={} kill_all_calls={}", |
64 | 0 | get(&self.connects), |
65 | 0 | get(&self.started), |
66 | 0 | get(&self.finished), |
67 | 0 | get(&self.failed), |
68 | 0 | get(&self.killed), |
69 | 0 | get(&self.kill_requests), |
70 | 0 | get(&self.kill_all_calls), |
71 | | ) |
72 | 0 | } |
73 | | } |
74 | | |
75 | | /// Execution drawn per operation: a run time uniform in `run_ms` and a |
76 | | /// non-zero exit for `fail_percent` of operations. The draw is a function |
77 | | /// of the seed and the operation id, so a requeued operation behaves the |
78 | | /// same on whichever worker gets it. |
79 | | #[derive(Debug)] |
80 | | pub struct Distribution { |
81 | | pub seed: u64, |
82 | | pub run_ms: (u64, u64), |
83 | | pub fail_percent: u64, |
84 | | pub counters: Arc<Counters>, |
85 | | } |
86 | | |
87 | | /// FNV-1a, for a stable draw per operation. |
88 | 12.2k | fn fnv1a(seed: u64, text: &str) -> u64 { |
89 | 12.2k | let mut hash = 0xcbf2_9ce4_8422_2325 ^ seed; |
90 | 81.6k | for byte in text12.2k .bytes12.2k () { |
91 | 81.6k | hash ^= u64::from(byte); |
92 | 81.6k | hash = hash.wrapping_mul(0x0100_0000_01b3); |
93 | 81.6k | } |
94 | 12.2k | hash |
95 | 12.2k | } |
96 | | |
97 | | impl Distribution { |
98 | | /// The duration and outcome of `operation_id`. |
99 | 12.2k | pub fn draw(&self, operation_id: &str) -> (Duration, Run) { |
100 | 12.2k | let mut rng = Rng::new(fnv1a(self.seed, operation_id)); |
101 | 12.2k | let (low, high) = self.run_ms; |
102 | 12.2k | let run_for = Duration::from_millis(rng.range(low, high.max(low))); |
103 | 12.2k | let run = if self.fail_percent > 0 && rng.chance(self.fail_percent) { |
104 | 2.46k | Run::Exit(1) |
105 | | } else { |
106 | 9.73k | Run::Succeed |
107 | | }; |
108 | 12.2k | (run_for, run) |
109 | 12.2k | } |
110 | | } |
111 | | |
112 | | impl ActionSource for Distribution { |
113 | 0 | fn plan(&self, start_execute: &StartExecute) -> Result<Plan, Error> { |
114 | 0 | let (run_for, run) = self.draw(&start_execute.operation_id); |
115 | 0 | self.counters.started.fetch_add(1, Ordering::Relaxed); |
116 | 0 | if run != Run::Succeed { |
117 | 0 | self.counters.failed.fetch_add(1, Ordering::Relaxed); |
118 | 0 | } |
119 | 0 | Ok(Plan { |
120 | 0 | action: start_execute.operation_id.clone(), |
121 | 0 | key: start_execute.operation_id.clone(), |
122 | 0 | prepare: Duration::ZERO, |
123 | 0 | run_for, |
124 | 0 | upload: Duration::ZERO, |
125 | 0 | run, |
126 | 0 | }) |
127 | 0 | } |
128 | | |
129 | 0 | fn record(&self, event: Event) { |
130 | 0 | let counter = match event { |
131 | 0 | Event::ExecEnd { killed: true, .. } => &self.counters.killed, |
132 | 0 | Event::ExecEnd { .. } => &self.counters.finished, |
133 | 0 | Event::KillAll { .. } => &self.counters.kill_all_calls, |
134 | 0 | _ => return, |
135 | | }; |
136 | 0 | counter.fetch_add(1, Ordering::Relaxed); |
137 | 0 | } |
138 | | |
139 | 0 | fn killed(&self, _operation_id: &OperationId) { |
140 | 0 | self.counters.kill_requests.fetch_add(1, Ordering::Relaxed); |
141 | 0 | } |
142 | | } |
143 | | |
144 | | /// One simulated worker's settings. |
145 | | #[derive(Debug, Clone)] |
146 | | pub struct FleetWorker { |
147 | | pub name: String, |
148 | | pub scheduler: String, |
149 | | pub max_inflight: u64, |
150 | | pub properties: HashMap<String, String>, |
151 | | } |
152 | | |
153 | | impl FleetWorker { |
154 | 0 | fn config(&self) -> LocalWorkerConfig { |
155 | | LocalWorkerConfig { |
156 | 0 | name: self.name.clone(), |
157 | 0 | max_inflight_tasks: self.max_inflight, |
158 | 0 | platform_properties: self |
159 | 0 | .properties |
160 | 0 | .iter() |
161 | 0 | .map(|(k, v)| (k.clone(), WorkerProperty::Values(vec![v.clone()]))) |
162 | 0 | .collect(), |
163 | 0 | worker_api_endpoint: EndpointConfig { |
164 | 0 | uri: self.scheduler.clone(), |
165 | 0 | ..Default::default() |
166 | 0 | }, |
167 | 0 | ..Default::default() |
168 | | } |
169 | 0 | } |
170 | | } |
171 | | |
172 | | /// A running simulated worker; dropping it does not stop it, `stop` does. |
173 | | #[derive(Debug)] |
174 | | pub struct RunningWorker { |
175 | | task: JoinHandle<Result<(), Error>>, |
176 | | // Held so the worker's shutdown channel stays open. |
177 | | _shutdown: broadcast::Sender<ShutdownGuard>, |
178 | | } |
179 | | |
180 | | impl RunningWorker { |
181 | | /// The process dies: the task is aborted and its connection closes. |
182 | 0 | pub fn stop(&self) { |
183 | 0 | self.task.abort(); |
184 | 0 | } |
185 | | |
186 | 0 | pub fn is_finished(&self) -> bool { |
187 | 0 | self.task.is_finished() |
188 | 0 | } |
189 | | } |
190 | | |
191 | | /// Starts `worker` with execution from `source`. |
192 | 0 | pub fn spawn_worker( |
193 | 0 | worker: &FleetWorker, |
194 | 0 | source: Arc<dyn ActionSource>, |
195 | 0 | counters: Arc<Counters>, |
196 | 0 | ) -> RunningWorker { |
197 | 0 | let config = Arc::new(worker.config()); |
198 | 0 | let factory_config = config.clone(); |
199 | 0 | let local_worker = LocalWorker::new_with_connection_factory_and_actions_manager( |
200 | 0 | config, |
201 | 0 | Arc::new(SimActionsManager::new(&worker.name, source)), |
202 | 0 | Box::new(move || { |
203 | 0 | let config = factory_config.clone(); |
204 | 0 | let counters = counters.clone(); |
205 | 0 | Box::pin(async move { |
206 | 0 | counters.connects.fetch_add(1, Ordering::Relaxed); |
207 | 0 | connect_to_scheduler(&config).await |
208 | 0 | }) |
209 | 0 | }), |
210 | 0 | Box::new(|duration| Box::pin(tokio::time::sleep(duration))), |
211 | | ); |
212 | 0 | let (shutdown, shutdown_rx) = broadcast::channel(1); |
213 | 0 | let task = background_spawn!("fleet_worker", local_worker.run(shutdown_rx)); |
214 | 0 | RunningWorker { |
215 | 0 | task, |
216 | 0 | _shutdown: shutdown, |
217 | 0 | } |
218 | 0 | } |