/build/source/nativelink-scheduler/src/api_worker_scheduler.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::ops::{Deref, DerefMut}; |
16 | | use core::sync::atomic::{AtomicU64, Ordering}; |
17 | | use core::time::Duration; |
18 | | use std::sync::Arc; |
19 | | use std::time::{Instant, UNIX_EPOCH}; |
20 | | |
21 | | use async_lock::Mutex; |
22 | | use lru::LruCache; |
23 | | use nativelink_config::schedulers::WorkerAllocationStrategy; |
24 | | use nativelink_error::{Code, Error, ResultExt, error_if, make_err, make_input_err}; |
25 | | use nativelink_metric::{ |
26 | | MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, |
27 | | RootMetricsComponent, group, |
28 | | }; |
29 | | use nativelink_proto::com::github::trace_machina::nativelink::events::{ |
30 | | Event, OriginEvent, ResponseEvent, event, response_event, |
31 | | }; |
32 | | use nativelink_proto::com::github::trace_machina::nativelink::remote_execution::ActionResourceUsage; |
33 | | use nativelink_util::action_messages::{OperationId, WorkerId}; |
34 | | use nativelink_util::operation_state_manager::{UpdateOperationType, WorkerStateManager}; |
35 | | use nativelink_util::origin_event::get_node_id; |
36 | | use nativelink_util::platform_properties::PlatformProperties; |
37 | | use nativelink_util::shutdown_guard::ShutdownGuard; |
38 | | use tokio::sync::{Notify, mpsc}; |
39 | | use tonic::async_trait; |
40 | | use tracing::{error, info, trace, warn}; |
41 | | use uuid::Uuid; |
42 | | |
43 | | /// Metrics for tracking scheduler performance. |
44 | | #[derive(Debug, Default)] |
45 | | pub struct SchedulerMetrics { |
46 | | /// Total number of worker additions. |
47 | | pub workers_added: AtomicU64, |
48 | | /// Total number of worker removals. |
49 | | pub workers_removed: AtomicU64, |
50 | | /// Total number of `find_worker_for_action` calls. |
51 | | pub find_worker_calls: AtomicU64, |
52 | | /// Total number of successful worker matches. |
53 | | pub find_worker_hits: AtomicU64, |
54 | | /// Total number of failed worker matches (no worker found). |
55 | | pub find_worker_misses: AtomicU64, |
56 | | /// Total time spent in `find_worker_for_action` (nanoseconds). |
57 | | pub find_worker_time_ns: AtomicU64, |
58 | | /// Total number of workers iterated during find operations. |
59 | | pub workers_iterated: AtomicU64, |
60 | | /// Total number of action dispatches. |
61 | | pub actions_dispatched: AtomicU64, |
62 | | /// Total number of keep-alive updates. |
63 | | pub keep_alive_updates: AtomicU64, |
64 | | /// Total number of worker timeouts. |
65 | | pub worker_timeouts: AtomicU64, |
66 | | } |
67 | | |
68 | | use crate::platform_property_manager::PlatformPropertyManager; |
69 | | use crate::worker::{ActionInfoWithProps, Worker, WorkerTimestamp, WorkerUpdate}; |
70 | | use crate::worker_capability_index::WorkerCapabilityIndex; |
71 | | use crate::worker_registry::SharedWorkerRegistry; |
72 | | use crate::worker_scheduler::WorkerScheduler; |
73 | | |
74 | | #[derive(Debug)] |
75 | | struct Workers(LruCache<WorkerId, Worker>); |
76 | | |
77 | | impl Deref for Workers { |
78 | | type Target = LruCache<WorkerId, Worker>; |
79 | | |
80 | 178 | fn deref(&self) -> &Self::Target { |
81 | 178 | &self.0 |
82 | 178 | } |
83 | | } |
84 | | |
85 | | impl DerefMut for Workers { |
86 | 141 | fn deref_mut(&mut self) -> &mut Self::Target { |
87 | 141 | &mut self.0 |
88 | 141 | } |
89 | | } |
90 | | |
91 | | // Note: This could not be a derive macro because this derive-macro |
92 | | // does not support LruCache and nameless field structs. |
93 | | impl MetricsComponent for Workers { |
94 | 0 | fn publish( |
95 | 0 | &self, |
96 | 0 | _kind: MetricKind, |
97 | 0 | _field_metadata: MetricFieldData, |
98 | 0 | ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> { |
99 | 0 | let _enter = group!("workers").entered(); |
100 | 0 | for (worker_id, worker) in self.iter() { |
101 | 0 | let _enter = group!(worker_id).entered(); |
102 | 0 | worker.publish(MetricKind::Component, MetricFieldData::default())?; |
103 | | } |
104 | 0 | Ok(MetricPublishKnownKindData::Component) |
105 | 0 | } |
106 | | } |
107 | | |
108 | | /// A collection of workers that are available to run tasks. |
109 | | #[derive(MetricsComponent)] |
110 | | struct ApiWorkerSchedulerImpl { |
111 | | /// A `LruCache` of workers available based on `allocation_strategy`. |
112 | | #[metric(group = "workers")] |
113 | | workers: Workers, |
114 | | |
115 | | /// The worker state manager. |
116 | | #[metric(group = "worker_state_manager")] |
117 | | worker_state_manager: Arc<dyn WorkerStateManager>, |
118 | | /// The allocation strategy for workers. |
119 | | allocation_strategy: WorkerAllocationStrategy, |
120 | | /// A channel to notify the matching engine that the worker pool has changed. |
121 | | worker_change_notify: Arc<Notify>, |
122 | | /// Worker registry for tracking worker liveness. |
123 | | worker_registry: SharedWorkerRegistry, |
124 | | |
125 | | /// Whether the worker scheduler is shutting down. |
126 | | shutting_down: bool, |
127 | | |
128 | | /// Index for fast worker capability lookup. |
129 | | /// Used to accelerate `find_worker_for_action` by filtering candidates |
130 | | /// based on properties before doing linear scan. |
131 | | capability_index: WorkerCapabilityIndex, |
132 | | } |
133 | | |
134 | | impl core::fmt::Debug for ApiWorkerSchedulerImpl { |
135 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
136 | 0 | f.debug_struct("ApiWorkerSchedulerImpl") |
137 | 0 | .field("workers", &self.workers) |
138 | 0 | .field("allocation_strategy", &self.allocation_strategy) |
139 | 0 | .field("worker_change_notify", &self.worker_change_notify) |
140 | 0 | .field( |
141 | 0 | "capability_index_size", |
142 | 0 | &self.capability_index.worker_count(), |
143 | 0 | ) |
144 | 0 | .field("worker_registry", &self.worker_registry) |
145 | 0 | .finish_non_exhaustive() |
146 | 0 | } |
147 | | } |
148 | | |
149 | | impl ApiWorkerSchedulerImpl { |
150 | | /// Refreshes the lifetime of the worker with the given timestamp. |
151 | | /// |
152 | | /// Instead of sending N keepalive messages (one per operation), |
153 | | /// we now send a single worker heartbeat. The worker registry tracks worker liveness, |
154 | | /// and timeout detection checks the worker's `last_seen` instead of per-operation timestamps. |
155 | | /// |
156 | | /// Note: This only updates the local worker state. The worker registry is updated |
157 | | /// separately after releasing the inner lock to reduce contention. |
158 | 2 | fn refresh_lifetime( |
159 | 2 | &mut self, |
160 | 2 | worker_id: &WorkerId, |
161 | 2 | timestamp: WorkerTimestamp, |
162 | 2 | ) -> Result<(), Error> { |
163 | 2 | let worker = self.workers.0.peek_mut(worker_id).ok_or_else(|| {0 |
164 | 0 | make_input_err!( |
165 | | "Worker not found in worker map in refresh_lifetime() {}", |
166 | | worker_id |
167 | | ) |
168 | 0 | })?; |
169 | 0 | error_if!( |
170 | 2 | worker.last_update_timestamp > timestamp, |
171 | | "Worker already had a timestamp of {}, but tried to update it with {}", |
172 | | worker.last_update_timestamp, |
173 | | timestamp |
174 | | ); |
175 | 2 | worker.last_update_timestamp = timestamp; |
176 | | |
177 | 2 | trace!( |
178 | | ?worker_id, |
179 | 2 | running_operations = worker.running_action_infos.len(), |
180 | | "Worker keepalive received" |
181 | | ); |
182 | | |
183 | 2 | Ok(()) |
184 | 2 | } |
185 | | |
186 | | /// Adds a worker to the pool. |
187 | | /// Note: This function will not do any task matching. |
188 | 40 | fn add_worker(&mut self, worker: Worker) -> Result<(), Error> { |
189 | 40 | let worker_id = worker.id.clone(); |
190 | 40 | let platform_properties = worker.platform_properties.clone(); |
191 | 40 | self.workers.put(worker_id.clone(), worker); |
192 | | |
193 | | // Add to capability index for fast matching |
194 | 40 | self.capability_index |
195 | 40 | .add_worker(&worker_id, &platform_properties); |
196 | | |
197 | | // Worker is not cloneable, and we do not want to send the initial connection results until |
198 | | // we have added it to the map, or we might get some strange race conditions due to the way |
199 | | // the multi-threaded runtime works. |
200 | 40 | let worker = self.workers.peek_mut(&worker_id).unwrap(); |
201 | 40 | let res = worker |
202 | 40 | .send_initial_connection_result() |
203 | 40 | .err_tip(|| "Failed to send initial connection result to worker"); |
204 | 40 | if let Err(err0 ) = &res { |
205 | 0 | error!( |
206 | | ?worker_id, |
207 | | ?err, |
208 | | "Worker connection appears to have been closed while adding to pool" |
209 | | ); |
210 | 40 | } |
211 | 40 | self.worker_change_notify.notify_one(); |
212 | 40 | res |
213 | 40 | } |
214 | | |
215 | | /// Removes worker from pool. |
216 | | /// Note: The caller is responsible for any rescheduling of any tasks that might be |
217 | | /// running. |
218 | 10 | fn remove_worker(&mut self, worker_id: &WorkerId) -> Option<Worker> { |
219 | | // Remove from capability index |
220 | 10 | self.capability_index.remove_worker(worker_id); |
221 | | |
222 | 10 | let result = self.workers.pop(worker_id); |
223 | 10 | self.worker_change_notify.notify_one(); |
224 | 10 | result |
225 | 10 | } |
226 | | |
227 | | /// Sets if the worker is draining or not. |
228 | 2 | async fn set_drain_worker( |
229 | 2 | &mut self, |
230 | 2 | worker_id: &WorkerId, |
231 | 2 | is_draining: bool, |
232 | 2 | ) -> Result<(), Error> { |
233 | 2 | let worker = self |
234 | 2 | .workers |
235 | 2 | .get_mut(worker_id) |
236 | 2 | .err_tip(|| format!0 ("Worker {worker_id} doesn't exist in the pool"))?0 ; |
237 | 2 | worker.is_draining = is_draining; |
238 | 2 | self.worker_change_notify.notify_one(); |
239 | 2 | Ok(()) |
240 | 2 | } |
241 | | |
242 | 60 | fn inner_find_worker_for_action( |
243 | 60 | &self, |
244 | 60 | platform_properties: &PlatformProperties, |
245 | 60 | full_worker_logging: bool, |
246 | 60 | ) -> Option<WorkerId> { |
247 | | // Do a fast check to see if any workers are available at all for work allocation |
248 | 60 | if !self.workers.iter().any(|(_, w)| w48 .can_accept_work48 ()) { |
249 | 14 | if full_worker_logging { |
250 | 2 | info!("All workers are fully allocated"); |
251 | 12 | } |
252 | 14 | return None; |
253 | 46 | } |
254 | | |
255 | | // Use capability index to get candidate workers that match STATIC properties |
256 | | // (Exact, Unknown) and have the required property keys (Priority, Minimum). |
257 | | // This reduces complexity from O(W × P) to O(P × log(W)) for exact properties. |
258 | 46 | let candidates = self |
259 | 46 | .capability_index |
260 | 46 | .find_matching_workers(platform_properties, full_worker_logging); |
261 | | |
262 | 46 | if candidates.is_empty() { |
263 | 1 | if full_worker_logging { |
264 | 0 | info!("No workers in capability index match required properties"); |
265 | 1 | } |
266 | 1 | return None; |
267 | 45 | } |
268 | | |
269 | | // Check function for availability AND dynamic Minimum property verification. |
270 | | // The index only does presence checks for Minimum properties since their |
271 | | // values change dynamically as jobs are assigned to workers. |
272 | 45 | let worker_matches = |(worker_id, w): &(&WorkerId, &Worker)| -> bool { |
273 | 45 | if !w.can_accept_work() { |
274 | 0 | if full_worker_logging { |
275 | 0 | info!( |
276 | | "Worker {worker_id} cannot accept work: is_paused={}, is_draining={}, inflight={}/{}", |
277 | | w.is_paused, |
278 | | w.is_draining, |
279 | 0 | w.running_action_infos.len(), |
280 | | w.max_inflight_tasks |
281 | | ); |
282 | 0 | } |
283 | 0 | return false; |
284 | 45 | } |
285 | | |
286 | | // Verify Minimum properties at runtime (their values are dynamic) |
287 | 45 | if !platform_properties.is_satisfied_by(&w.platform_properties, full_worker_logging) { |
288 | 5 | return false; |
289 | 40 | } |
290 | | |
291 | 40 | true |
292 | 45 | }; |
293 | | |
294 | | // Now check constraints on filtered candidates. |
295 | | // Iterate in LRU order based on allocation strategy. |
296 | 45 | let workers_iter = self.workers.iter(); |
297 | | |
298 | 45 | let worker_id = match self.allocation_strategy { |
299 | | // Use rfind to get the least recently used that satisfies the properties. |
300 | 45 | WorkerAllocationStrategy::LeastRecentlyUsed => workers_iter |
301 | 45 | .rev() |
302 | 46 | .filter45 (|(worker_id, _)| candidates.contains(worker_id)) |
303 | 45 | .find(&worker_matches) |
304 | 45 | .map(|(_, w)| w.id40 .clone40 ()), |
305 | | |
306 | | // Use find to get the most recently used that satisfies the properties. |
307 | 0 | WorkerAllocationStrategy::MostRecentlyUsed => workers_iter |
308 | 0 | .filter(|(worker_id, _)| candidates.contains(worker_id)) |
309 | 0 | .find(&worker_matches) |
310 | 0 | .map(|(_, w)| w.id.clone()), |
311 | | }; |
312 | 45 | if full_worker_logging && worker_id8 .is_none8 () { |
313 | 1 | warn!("No workers matched!"); |
314 | 44 | } |
315 | 45 | worker_id |
316 | 60 | } |
317 | | |
318 | 12 | async fn update_action( |
319 | 12 | &mut self, |
320 | 12 | worker_id: &WorkerId, |
321 | 12 | operation_id: &OperationId, |
322 | 12 | update: UpdateOperationType, |
323 | 12 | ) -> Result<(), Error> { |
324 | 12 | let worker = self.workers.get_mut(worker_id).err_tip(|| {0 |
325 | 0 | format!("Worker {worker_id} does not exist in SimpleScheduler::update_action") |
326 | 0 | })?; |
327 | | |
328 | | // Ensure the worker is supposed to be running the operation. |
329 | 12 | if !worker.running_action_infos.contains_key(operation_id) { |
330 | 1 | let err = make_err!( |
331 | 1 | Code::Internal, |
332 | | "Operation {operation_id} should not be running on worker {worker_id} in SimpleScheduler::update_action" |
333 | | ); |
334 | 1 | return Result::<(), _>::Err(err.clone()) |
335 | 1 | .merge(self.immediate_evict_worker(worker_id, err, false).await); |
336 | 11 | } |
337 | | |
338 | 11 | let (is_finished, due_to_backpressure) = match &update { |
339 | 6 | UpdateOperationType::UpdateWithActionStage(action_stage) => { |
340 | 6 | (action_stage.is_finished(), false) |
341 | | } |
342 | 0 | UpdateOperationType::KeepAlive => (false, false), |
343 | 3 | UpdateOperationType::UpdateWithError(err) => { |
344 | 3 | (true, err.code == Code::ResourceExhausted) |
345 | | } |
346 | 2 | UpdateOperationType::UpdateWithDisconnect => (true, false), |
347 | | UpdateOperationType::ExecutionComplete => { |
348 | | // No update here, just restoring platform properties. |
349 | 0 | worker.execution_complete(operation_id); |
350 | 0 | self.worker_change_notify.notify_one(); |
351 | 0 | return Ok(()); |
352 | | } |
353 | | }; |
354 | | |
355 | | // Update the operation in the worker state manager. |
356 | | { |
357 | 11 | let update_operation_res10 = self |
358 | 11 | .worker_state_manager |
359 | 11 | .update_operation(operation_id, worker_id, update) |
360 | 11 | .await |
361 | 10 | .err_tip(|| "in update_operation on SimpleScheduler::update_action"); |
362 | 10 | if let Err(err0 ) = update_operation_res { |
363 | 0 | error!( |
364 | | %operation_id, |
365 | | ?worker_id, |
366 | | ?err, |
367 | | "Failed to update_operation on update_action" |
368 | | ); |
369 | 0 | return Err(err); |
370 | 10 | } |
371 | | } |
372 | | |
373 | 10 | if !is_finished { |
374 | 0 | return Ok(()); |
375 | 10 | } |
376 | | |
377 | | // Clear this action from the current worker if finished. |
378 | 10 | let complete_action_res = { |
379 | | // Note: We need to run this before dealing with backpressure logic. |
380 | 10 | let complete_action_res = worker.complete_action(operation_id).await; |
381 | | |
382 | 10 | if (due_to_backpressure || !worker.can_accept_work()) && worker0 .has_actions0 () { |
383 | 0 | worker.is_paused = true; |
384 | 10 | } |
385 | 10 | complete_action_res |
386 | | }; |
387 | | |
388 | 10 | self.worker_change_notify.notify_one(); |
389 | | |
390 | 10 | complete_action_res |
391 | 11 | } |
392 | | |
393 | | /// Notifies the specified worker to run the given action and handles errors by evicting |
394 | | /// the worker if the notification fails. |
395 | 36 | async fn worker_notify_run_action( |
396 | 36 | &mut self, |
397 | 36 | worker_id: WorkerId, |
398 | 36 | operation_id: OperationId, |
399 | 36 | action_info: ActionInfoWithProps, |
400 | 36 | ) -> Result<(), Error> { |
401 | 36 | if let Some(worker) = self.workers.get_mut(&worker_id) { |
402 | 36 | let notify_worker_result = worker |
403 | 36 | .notify_update(WorkerUpdate::RunAction(Box::new(( |
404 | 36 | operation_id, |
405 | 36 | action_info.clone(), |
406 | 36 | )))) |
407 | 36 | .await; |
408 | | |
409 | 36 | if let Err(notify_worker_result1 ) = notify_worker_result { |
410 | 1 | warn!( |
411 | | ?worker_id, |
412 | | ?action_info, |
413 | | ?notify_worker_result, |
414 | | "Worker command failed, removing worker", |
415 | | ); |
416 | | |
417 | | // A slightly nasty way of figuring out that the worker disconnected |
418 | | // from send_msg_to_worker without introducing complexity to the |
419 | | // code path from here to there. |
420 | 1 | let is_disconnect = notify_worker_result.code == Code::Internal |
421 | 1 | && notify_worker_result.messages.len() == 1 |
422 | 0 | && notify_worker_result.messages[0] == "Worker Disconnected"; |
423 | | |
424 | 1 | let err = make_err!( |
425 | 1 | Code::Internal, |
426 | | "Worker command failed, removing worker {worker_id} -- {notify_worker_result:?}", |
427 | | ); |
428 | | |
429 | 1 | return Result::<(), _>::Err(err.clone()).merge( |
430 | 1 | self.immediate_evict_worker(&worker_id, err, is_disconnect) |
431 | 1 | .await, |
432 | | ); |
433 | 35 | } |
434 | 35 | Ok(()) |
435 | | } else { |
436 | 0 | warn!( |
437 | | ?worker_id, |
438 | | %operation_id, |
439 | | ?action_info, |
440 | | "Worker not found in worker map in worker_notify_run_action" |
441 | | ); |
442 | | // Ensure the operation is put back to queued state. |
443 | 0 | self.worker_state_manager |
444 | 0 | .update_operation( |
445 | 0 | &operation_id, |
446 | 0 | &worker_id, |
447 | 0 | UpdateOperationType::UpdateWithDisconnect, |
448 | 0 | ) |
449 | 0 | .await |
450 | | } |
451 | 36 | } |
452 | | |
453 | | /// Evicts the worker from the pool and puts items back into the queue if anything was being executed on it. |
454 | 10 | async fn immediate_evict_worker( |
455 | 10 | &mut self, |
456 | 10 | worker_id: &WorkerId, |
457 | 10 | err: Error, |
458 | 10 | is_disconnect: bool, |
459 | 10 | ) -> Result<(), Error> { |
460 | 10 | let mut result = Ok(()); |
461 | 10 | if let Some(mut worker) = self.remove_worker(worker_id) { |
462 | | // We don't care if we fail to send message to worker, this is only a best attempt. |
463 | 10 | drop(worker.notify_update(WorkerUpdate::Disconnect).await); |
464 | 10 | let update = if is_disconnect { |
465 | 0 | UpdateOperationType::UpdateWithDisconnect |
466 | | } else { |
467 | 10 | UpdateOperationType::UpdateWithError(err) |
468 | | }; |
469 | 10 | for (operation_id8 , _) in worker.running_action_infos.drain() { |
470 | 8 | result = result.merge( |
471 | 8 | self.worker_state_manager |
472 | 8 | .update_operation(&operation_id, worker_id, update.clone()) |
473 | 8 | .await, |
474 | | ); |
475 | | } |
476 | 0 | } |
477 | | // Note: Calling this many time is very cheap, it'll only trigger `do_try_match` once. |
478 | | // TODO(palfrey) This should be moved to inside the Workers struct. |
479 | 10 | self.worker_change_notify.notify_one(); |
480 | 10 | result |
481 | 10 | } |
482 | | } |
483 | | |
484 | | #[derive(Debug, MetricsComponent)] |
485 | | pub struct ApiWorkerScheduler { |
486 | | #[metric] |
487 | | inner: Mutex<ApiWorkerSchedulerImpl>, |
488 | | #[metric(group = "platform_property_manager")] |
489 | | platform_property_manager: Arc<PlatformPropertyManager>, |
490 | | |
491 | | #[metric( |
492 | | help = "Timeout of how long to evict workers if no response in this given amount of time in seconds." |
493 | | )] |
494 | | worker_timeout_s: u64, |
495 | | /// Shared worker registry for checking worker liveness. |
496 | | worker_registry: SharedWorkerRegistry, |
497 | | |
498 | | /// Performance metrics for observability. |
499 | | metrics: Arc<SchedulerMetrics>, |
500 | | |
501 | | /// Channel for publishing origin events such as worker-observed action |
502 | | /// resource usage. `None` when origin events are disabled. |
503 | | maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>, |
504 | | } |
505 | | |
506 | | impl ApiWorkerScheduler { |
507 | 34 | pub fn new( |
508 | 34 | worker_state_manager: Arc<dyn WorkerStateManager>, |
509 | 34 | platform_property_manager: Arc<PlatformPropertyManager>, |
510 | 34 | allocation_strategy: WorkerAllocationStrategy, |
511 | 34 | worker_change_notify: Arc<Notify>, |
512 | 34 | worker_timeout_s: u64, |
513 | 34 | worker_registry: SharedWorkerRegistry, |
514 | 34 | maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>, |
515 | 34 | ) -> Arc<Self> { |
516 | 34 | Arc::new(Self { |
517 | 34 | inner: Mutex::new(ApiWorkerSchedulerImpl { |
518 | 34 | workers: Workers(LruCache::unbounded()), |
519 | 34 | worker_state_manager, |
520 | 34 | allocation_strategy, |
521 | 34 | worker_change_notify, |
522 | 34 | worker_registry: worker_registry.clone(), |
523 | 34 | shutting_down: false, |
524 | 34 | capability_index: WorkerCapabilityIndex::new(), |
525 | 34 | }), |
526 | 34 | platform_property_manager, |
527 | 34 | worker_timeout_s, |
528 | 34 | worker_registry, |
529 | 34 | metrics: Arc::new(SchedulerMetrics::default()), |
530 | 34 | maybe_origin_event_tx, |
531 | 34 | }) |
532 | 34 | } |
533 | | |
534 | | /// Returns a reference to the worker registry. |
535 | 0 | pub const fn worker_registry(&self) -> &SharedWorkerRegistry { |
536 | 0 | &self.worker_registry |
537 | 0 | } |
538 | | |
539 | 36 | pub async fn worker_notify_run_action( |
540 | 36 | &self, |
541 | 36 | worker_id: WorkerId, |
542 | 36 | operation_id: OperationId, |
543 | 36 | action_info: ActionInfoWithProps, |
544 | 36 | ) -> Result<(), Error> { |
545 | 36 | self.metrics |
546 | 36 | .actions_dispatched |
547 | 36 | .fetch_add(1, Ordering::Relaxed); |
548 | 36 | let mut inner = self.inner.lock().await; |
549 | 36 | inner |
550 | 36 | .worker_notify_run_action(worker_id, operation_id, action_info) |
551 | 36 | .await |
552 | 36 | } |
553 | | |
554 | 1 | pub async fn running_action_info( |
555 | 1 | &self, |
556 | 1 | worker_id: &WorkerId, |
557 | 1 | operation_id: &OperationId, |
558 | 1 | ) -> Option<ActionInfoWithProps> { |
559 | 1 | let inner = self.inner.lock().await; |
560 | 1 | inner |
561 | 1 | .workers |
562 | 1 | .peek(worker_id) |
563 | 1 | .and_then(|worker| worker.running_action_infos.get(operation_id)) |
564 | 1 | .map(|pending_action_info| pending_action_info.action_info.clone()) |
565 | 1 | } |
566 | | |
567 | | /// Returns the scheduler metrics for observability. |
568 | | #[must_use] |
569 | 0 | pub const fn get_metrics(&self) -> &Arc<SchedulerMetrics> { |
570 | 0 | &self.metrics |
571 | 0 | } |
572 | | |
573 | | /// Attempts to find a worker that is capable of running this action. |
574 | | // TODO(palfrey) This algorithm is not very efficient. Simple testing using a tree-like |
575 | | // structure showed worse performance on a 10_000 worker * 7 properties * 1000 queued tasks |
576 | | // simulation of worst cases in a single threaded environment. |
577 | 60 | pub async fn find_worker_for_action( |
578 | 60 | &self, |
579 | 60 | platform_properties: &PlatformProperties, |
580 | 60 | full_worker_logging: bool, |
581 | 60 | ) -> Option<WorkerId> { |
582 | 60 | let start = Instant::now(); |
583 | 60 | self.metrics |
584 | 60 | .find_worker_calls |
585 | 60 | .fetch_add(1, Ordering::Relaxed); |
586 | | |
587 | 60 | let inner = self.inner.lock().await; |
588 | 60 | let worker_count = inner.workers.len() as u64; |
589 | 60 | let result = inner.inner_find_worker_for_action(platform_properties, full_worker_logging); |
590 | | |
591 | | // Track workers iterated (worst case is all workers) |
592 | 60 | self.metrics |
593 | 60 | .workers_iterated |
594 | 60 | .fetch_add(worker_count, Ordering::Relaxed); |
595 | | |
596 | 60 | if result.is_some() { |
597 | 40 | self.metrics |
598 | 40 | .find_worker_hits |
599 | 40 | .fetch_add(1, Ordering::Relaxed); |
600 | 40 | } else { |
601 | 20 | self.metrics |
602 | 20 | .find_worker_misses |
603 | 20 | .fetch_add(1, Ordering::Relaxed); |
604 | 20 | } |
605 | | |
606 | | #[allow(clippy::cast_possible_truncation)] |
607 | 60 | self.metrics |
608 | 60 | .find_worker_time_ns |
609 | 60 | .fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed); |
610 | 60 | result |
611 | 60 | } |
612 | | |
613 | | /// Checks to see if the worker exists in the worker pool. Should only be used in unit tests. |
614 | | #[must_use] |
615 | 7 | pub async fn contains_worker_for_test(&self, worker_id: &WorkerId) -> bool0 { |
616 | 7 | let inner = self.inner.lock().await; |
617 | 7 | inner.workers.contains(worker_id) |
618 | 7 | } |
619 | | |
620 | | /// A unit test function used to send the keep alive message to the worker from the server. |
621 | 1 | pub async fn send_keep_alive_to_worker_for_test( |
622 | 1 | &self, |
623 | 1 | worker_id: &WorkerId, |
624 | 1 | ) -> Result<(), Error> { |
625 | 1 | let mut inner = self.inner.lock().await; |
626 | 1 | let worker = inner.workers.get_mut(worker_id).ok_or_else(|| {0 |
627 | 0 | make_input_err!("WorkerId '{}' does not exist in workers map", worker_id) |
628 | 0 | })?; |
629 | 1 | worker.keep_alive() |
630 | 1 | } |
631 | | } |
632 | | |
633 | | #[async_trait] |
634 | | impl WorkerScheduler for ApiWorkerScheduler { |
635 | 2 | fn get_platform_property_manager(&self) -> &PlatformPropertyManager { |
636 | 2 | self.platform_property_manager.as_ref() |
637 | 2 | } |
638 | | |
639 | | async fn record_action_resource_usage( |
640 | | &self, |
641 | | worker_id: &WorkerId, |
642 | | operation_id: &OperationId, |
643 | | mut resource_usage: ActionResourceUsage, |
644 | 1 | ) -> Result<(), Error> { |
645 | | // The worker API talks to this `ApiWorkerScheduler` (it is the |
646 | | // `WorkerScheduler` returned by `SimpleScheduler::new`), so the |
647 | | // resource-usage origin event must be published here. Previously the |
648 | | // only override lived on `SimpleScheduler`, which this path never |
649 | | // reaches, so the event was silently dropped by the trait's no-op |
650 | | // default and `observed_worker_peak_memory_mib` was never recorded. |
651 | | let Some(origin_event_tx) = self.maybe_origin_event_tx.as_ref() else { |
652 | | return Ok(()); |
653 | | }; |
654 | | let Some(action_info) = self.running_action_info(worker_id, operation_id).await else { |
655 | | return Ok(()); |
656 | | }; |
657 | | |
658 | | if resource_usage.operation_id.is_empty() { |
659 | | resource_usage.operation_id = operation_id.to_string(); |
660 | | } |
661 | | if resource_usage.worker_id.is_empty() { |
662 | | resource_usage.worker_id = worker_id.to_string(); |
663 | | } |
664 | | |
665 | | let event = Event { |
666 | | event: Some(event::Event::Response(ResponseEvent { |
667 | | event: Some(response_event::Event::ActionResourceUsage(resource_usage)), |
668 | | })), |
669 | | }; |
670 | | let origin_event = OriginEvent { |
671 | | version: 0, |
672 | | event_id: Uuid::now_v6(&get_node_id(Some(&event))) |
673 | | .hyphenated() |
674 | | .to_string(), |
675 | | parent_event_id: action_info |
676 | | .scheduler_start_execute_event_id |
677 | | .clone() |
678 | | .unwrap_or_default(), |
679 | | bazel_request_metadata: action_info.origin_metadata.bazel_metadata.clone(), |
680 | | identity: action_info.origin_metadata.identity, |
681 | | event: Some(event), |
682 | | }; |
683 | | // Awaited send (not try_send): apply backpressure when the publisher |
684 | | // queue is full instead of silently dropping the resource-usage event, |
685 | | // which is what drives action-level resource sizing in the UI. |
686 | | if let Err(err) = origin_event_tx.send(origin_event).await { |
687 | | warn!(?err, "Failed to publish action resource usage origin event"); |
688 | | } |
689 | | Ok(()) |
690 | 1 | } |
691 | | |
692 | 40 | async fn add_worker(&self, worker: Worker) -> Result<(), Error> { |
693 | | let worker_id = worker.id.clone(); |
694 | | let worker_timestamp = worker.last_update_timestamp; |
695 | | let mut inner = self.inner.lock().await; |
696 | | if inner.shutting_down { |
697 | | warn!("Rejected worker add during shutdown: {}", worker_id); |
698 | | return Err(make_err!( |
699 | | Code::Unavailable, |
700 | | "Received request to add worker while shutting down" |
701 | | )); |
702 | | } |
703 | | let result = inner |
704 | | .add_worker(worker) |
705 | | .err_tip(|| "Error while adding worker, removing from pool"); |
706 | | if let Err(err) = result { |
707 | | return Result::<(), _>::Err(err.clone()) |
708 | | .merge(inner.immediate_evict_worker(&worker_id, err, false).await); |
709 | | } |
710 | | |
711 | | let now = UNIX_EPOCH + Duration::from_secs(worker_timestamp); |
712 | | self.worker_registry.register_worker(&worker_id, now).await; |
713 | | |
714 | | self.metrics.workers_added.fetch_add(1, Ordering::Relaxed); |
715 | | Ok(()) |
716 | 40 | } |
717 | | |
718 | | async fn update_action( |
719 | | &self, |
720 | | worker_id: &WorkerId, |
721 | | operation_id: &OperationId, |
722 | | update: UpdateOperationType, |
723 | 12 | ) -> Result<(), Error> { |
724 | | let mut inner = self.inner.lock().await; |
725 | | inner.update_action(worker_id, operation_id, update).await |
726 | 12 | } |
727 | | |
728 | | async fn worker_keep_alive_received( |
729 | | &self, |
730 | | worker_id: &WorkerId, |
731 | | timestamp: WorkerTimestamp, |
732 | 2 | ) -> Result<(), Error> { |
733 | | { |
734 | | let mut inner = self.inner.lock().await; |
735 | | inner |
736 | | .refresh_lifetime(worker_id, timestamp) |
737 | | .err_tip(|| "Error refreshing lifetime in worker_keep_alive_received()")?; |
738 | | } |
739 | | let now = UNIX_EPOCH + Duration::from_secs(timestamp); |
740 | | self.worker_registry |
741 | | .update_worker_heartbeat(worker_id, now) |
742 | | .await; |
743 | | Ok(()) |
744 | 2 | } |
745 | | |
746 | 6 | async fn remove_worker(&self, worker_id: &WorkerId) -> Result<(), Error> { |
747 | | self.worker_registry.remove_worker(worker_id).await; |
748 | | |
749 | | let mut inner = self.inner.lock().await; |
750 | | inner |
751 | | .immediate_evict_worker( |
752 | | worker_id, |
753 | | make_err!(Code::Internal, "Received request to remove worker"), |
754 | | false, |
755 | | ) |
756 | | .await |
757 | 6 | } |
758 | | |
759 | 0 | async fn shutdown(&self, shutdown_guard: ShutdownGuard) { |
760 | | let mut inner = self.inner.lock().await; |
761 | | inner.shutting_down = true; // should reject further worker registration |
762 | | while let Some(worker_id) = inner |
763 | | .workers |
764 | | .peek_lru() |
765 | 0 | .map(|(worker_id, _worker)| worker_id.clone()) |
766 | | { |
767 | | if let Err(err) = inner |
768 | | .immediate_evict_worker( |
769 | | &worker_id, |
770 | | make_err!(Code::Internal, "Scheduler shutdown"), |
771 | | true, |
772 | | ) |
773 | | .await |
774 | | { |
775 | | error!(?err, "Error evicting worker on shutdown."); |
776 | | } |
777 | | } |
778 | | drop(shutdown_guard); |
779 | 0 | } |
780 | | |
781 | 5 | async fn remove_timedout_workers(&self, now_timestamp: WorkerTimestamp) -> Result<(), Error> { |
782 | | // Check worker liveness using both the local timestamp (from LRU) |
783 | | // and the worker registry. A worker is alive if either source says it's alive. |
784 | | let timeout = Duration::from_secs(self.worker_timeout_s); |
785 | | let now = UNIX_EPOCH + Duration::from_secs(now_timestamp); |
786 | | let timeout_threshold = now_timestamp.saturating_sub(self.worker_timeout_s); |
787 | | |
788 | | let workers_to_check: Vec<(WorkerId, bool)> = { |
789 | | let inner = self.inner.lock().await; |
790 | | inner |
791 | | .workers |
792 | | .iter() |
793 | 6 | .map(|(worker_id, worker)| { |
794 | 6 | let local_alive = worker.last_update_timestamp > timeout_threshold; |
795 | 6 | (worker_id.clone(), local_alive) |
796 | 6 | }) |
797 | | .collect() |
798 | | }; |
799 | | |
800 | | let mut worker_ids_to_remove = Vec::new(); |
801 | | for (worker_id, local_alive) in workers_to_check { |
802 | | if local_alive { |
803 | | continue; |
804 | | } |
805 | | |
806 | | let registry_alive = self |
807 | | .worker_registry |
808 | | .is_worker_alive(&worker_id, timeout, now) |
809 | | .await; |
810 | | |
811 | | if !registry_alive { |
812 | | trace!( |
813 | | ?worker_id, |
814 | | local_alive, |
815 | | registry_alive, |
816 | | timeout_threshold, |
817 | | "Worker timed out - neither local nor registry shows alive" |
818 | | ); |
819 | | worker_ids_to_remove.push(worker_id); |
820 | | } |
821 | | } |
822 | | |
823 | | if worker_ids_to_remove.is_empty() { |
824 | | return Ok(()); |
825 | | } |
826 | | |
827 | | let mut inner = self.inner.lock().await; |
828 | | let mut result = Ok(()); |
829 | | |
830 | | for worker_id in &worker_ids_to_remove { |
831 | | warn!(?worker_id, "Worker timed out, removing from pool"); |
832 | | result = result.merge( |
833 | | inner |
834 | | .immediate_evict_worker( |
835 | | worker_id, |
836 | | make_err!( |
837 | | Code::Internal, |
838 | | "Worker {worker_id} timed out, removing from pool" |
839 | | ), |
840 | | false, |
841 | | ) |
842 | | .await, |
843 | | ); |
844 | | } |
845 | | |
846 | | result |
847 | 5 | } |
848 | | |
849 | 2 | async fn set_drain_worker(&self, worker_id: &WorkerId, is_draining: bool) -> Result<(), Error> { |
850 | | let mut inner = self.inner.lock().await; |
851 | | inner.set_drain_worker(worker_id, is_draining).await |
852 | 2 | } |
853 | | } |
854 | | |
855 | | impl RootMetricsComponent for ApiWorkerScheduler {} |