Coverage Report

Created: 2026-09-18 20:40

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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 futures::{StreamExt, future};
23
use lru::LruCache;
24
use nativelink_config::schedulers::WorkerAllocationStrategy;
25
use nativelink_error::{Code, Error, ResultExt, error_if, make_err, make_input_err};
26
use nativelink_metric::{
27
    MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent,
28
    RootMetricsComponent, group,
29
};
30
use nativelink_proto::com::github::trace_machina::nativelink::events::{
31
    Event, OriginEvent, ResponseEvent, event, response_event,
32
};
33
use nativelink_proto::com::github::trace_machina::nativelink::remote_execution::ActionResourceUsage;
34
use nativelink_util::action_messages::{OperationId, WorkerId};
35
use nativelink_util::metrics::{
36
    WorkerDisconnectReason, record_execution_cpu_time, record_execution_peak_memory,
37
    record_worker_connected, record_worker_disconnected, record_worker_keepalive,
38
    record_worker_state,
39
};
40
use nativelink_util::operation_state_manager::{UpdateOperationType, WorkerStateManager};
41
use nativelink_util::origin_event::get_node_id;
42
use nativelink_util::platform_properties::PlatformProperties;
43
use nativelink_util::shutdown_guard::ShutdownGuard;
44
use tokio::sync::{Notify, mpsc};
45
use tonic::async_trait;
46
use tracing::{debug, error, info, trace, warn};
47
48
/// How many state-manager lookups `kill_revoked_operations` has in flight
49
/// at once while checking which running operations were revoked.
50
const MAX_CONCURRENT_REVOKED_CHECKS: usize = 32;
51
use uuid::Uuid;
52
53
/// Metrics for tracking scheduler performance.
54
#[derive(Debug, Default)]
55
pub struct SchedulerMetrics {
56
    /// Total number of worker additions.
57
    pub workers_added: AtomicU64,
58
    /// Total number of worker removals.
59
    pub workers_removed: AtomicU64,
60
    /// Total number of `find_worker_for_action` calls.
61
    pub find_worker_calls: AtomicU64,
62
    /// Total number of successful worker matches.
63
    pub find_worker_hits: AtomicU64,
64
    /// Total number of failed worker matches (no worker found).
65
    pub find_worker_misses: AtomicU64,
66
    /// Total time spent in `find_worker_for_action` (nanoseconds).
67
    pub find_worker_time_ns: AtomicU64,
68
    /// Total number of workers iterated during find operations.
69
    pub workers_iterated: AtomicU64,
70
    /// Total number of action dispatches.
71
    pub actions_dispatched: AtomicU64,
72
    /// Total number of keep-alive updates.
73
    pub keep_alive_updates: AtomicU64,
74
    /// Total number of worker timeouts.
75
    pub worker_timeouts: AtomicU64,
76
}
77
78
use crate::platform_property_manager::PlatformPropertyManager;
79
use crate::worker::{ActionInfoWithProps, Worker, WorkerTimestamp, WorkerUpdate};
80
use crate::worker_capability_index::WorkerCapabilityIndex;
81
use crate::worker_registry::SharedWorkerRegistry;
82
use crate::worker_scheduler::WorkerScheduler;
83
84
#[derive(Debug)]
85
struct Workers(LruCache<WorkerId, Worker>);
86
87
impl Deref for Workers {
88
    type Target = LruCache<WorkerId, Worker>;
89
90
223
    fn deref(&self) -> &Self::Target {
91
223
        &self.0
92
223
    }
93
}
94
95
impl DerefMut for Workers {
96
168
    fn deref_mut(&mut self) -> &mut Self::Target {
97
168
        &mut self.0
98
168
    }
99
}
100
101
// Note: This could not be a derive macro because this derive-macro
102
// does not support LruCache and nameless field structs.
103
impl MetricsComponent for Workers {
104
0
    fn publish(
105
0
        &self,
106
0
        _kind: MetricKind,
107
0
        _field_metadata: MetricFieldData,
108
0
    ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> {
109
0
        let _enter = group!("workers").entered();
110
0
        for (worker_id, worker) in self.iter() {
111
0
            let _enter = group!(worker_id).entered();
112
0
            worker.publish(MetricKind::Component, MetricFieldData::default())?;
113
        }
114
0
        Ok(MetricPublishKnownKindData::Component)
115
0
    }
116
}
117
118
/// A collection of workers that are available to run tasks.
119
#[derive(MetricsComponent)]
120
struct ApiWorkerSchedulerImpl {
121
    /// A `LruCache` of workers available based on `allocation_strategy`.
122
    #[metric(group = "workers")]
123
    workers: Workers,
124
125
    /// The worker state manager.
126
    #[metric(group = "worker_state_manager")]
127
    worker_state_manager: Arc<dyn WorkerStateManager>,
128
    /// The allocation strategy for workers.
129
    allocation_strategy: WorkerAllocationStrategy,
130
    /// A channel to notify the matching engine that the worker pool has changed.
131
    worker_change_notify: Arc<Notify>,
132
    /// Worker registry for tracking worker liveness.
133
    worker_registry: SharedWorkerRegistry,
134
135
    /// Whether the worker scheduler is shutting down.
136
    shutting_down: bool,
137
138
    /// Index for fast worker capability lookup.
139
    /// Used to accelerate `find_worker_for_action` by filtering candidates
140
    /// based on properties before doing linear scan.
141
    capability_index: WorkerCapabilityIndex,
142
}
143
144
impl core::fmt::Debug for ApiWorkerSchedulerImpl {
145
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
146
0
        f.debug_struct("ApiWorkerSchedulerImpl")
147
0
            .field("workers", &self.workers)
148
0
            .field("allocation_strategy", &self.allocation_strategy)
149
0
            .field("worker_change_notify", &self.worker_change_notify)
150
0
            .field(
151
0
                "capability_index_size",
152
0
                &self.capability_index.worker_count(),
153
0
            )
154
0
            .field("worker_registry", &self.worker_registry)
155
0
            .finish_non_exhaustive()
156
0
    }
157
}
158
159
impl ApiWorkerSchedulerImpl {
160
    /// Refreshes the lifetime of the worker with the given timestamp.
161
    ///
162
    /// Instead of sending N keepalive messages (one per operation),
163
    /// we now send a single worker heartbeat. The worker registry tracks worker liveness,
164
    /// and timeout detection checks the worker's `last_seen` instead of per-operation timestamps.
165
    ///
166
    /// Note: This only updates the local worker state. The worker registry is updated
167
    /// separately after releasing the inner lock to reduce contention.
168
4
    fn refresh_lifetime(
169
4
        &mut self,
170
4
        worker_id: &WorkerId,
171
4
        timestamp: WorkerTimestamp,
172
4
    ) -> Result<(), Error> {
173
4
        let worker = self.workers.0.peek_mut(worker_id).ok_or_else(|| 
{0
174
0
            make_input_err!(
175
                "Worker not found in worker map in refresh_lifetime() {}",
176
                worker_id
177
            )
178
0
        })?;
179
0
        error_if!(
180
4
            worker.last_update_timestamp > timestamp,
181
            "Worker already had a timestamp of {}, but tried to update it with {}",
182
            worker.last_update_timestamp,
183
            timestamp
184
        );
185
4
        worker.last_update_timestamp = timestamp;
186
187
4
        trace!(
188
            ?worker_id,
189
4
            running_operations = worker.running_action_infos.len(),
190
            "Worker keepalive received"
191
        );
192
4
        record_worker_keepalive();
193
194
4
        Ok(())
195
4
    }
196
197
    /// Adds a worker to the pool.
198
    /// Note: This function will not do any task matching.
199
46
    fn add_worker(&mut self, worker: Worker) -> Result<(), Error> {
200
46
        let worker_id = worker.id.clone();
201
46
        let platform_properties = worker.platform_properties.clone();
202
        // A replacement is one out and one in, so the gauge only moves for a
203
        // genuinely new worker.
204
46
        let replaced = self.workers.put(worker_id.clone(), worker);
205
206
        // Add to capability index for fast matching
207
46
        self.capability_index
208
46
            .add_worker(&worker_id, &platform_properties);
209
210
        // Worker is not cloneable, and we do not want to send the initial connection results until
211
        // we have added it to the map, or we might get some strange race conditions due to the way
212
        // the multi-threaded runtime works.
213
46
        let worker = self.workers.peek_mut(&worker_id).unwrap();
214
46
        let res = worker
215
46
            .send_initial_connection_result()
216
46
            .err_tip(|| "Failed to send initial connection result to worker");
217
46
        if let Err(
err0
) = &res {
218
0
            error!(
219
                ?worker_id,
220
                ?err,
221
                "Worker connection appears to have been closed while adding to pool"
222
            );
223
46
        }
224
46
        if let Some(
replaced2
) = &replaced {
225
2
            if replaced.is_draining {
226
0
                record_worker_state("draining", false);
227
2
            }
228
2
            if replaced.is_paused {
229
0
                record_worker_state("paused", false);
230
2
            }
231
44
        } else {
232
44
            record_worker_connected();
233
44
        }
234
46
        self.worker_change_notify.notify_one();
235
46
        res
236
46
    }
237
238
    /// Removes worker from pool.
239
    /// Note: The caller is responsible for any rescheduling of any tasks that might be
240
    /// running.
241
12
    fn remove_worker(&mut self, worker_id: &WorkerId) -> Option<Worker> {
242
        // Remove from capability index
243
12
        self.capability_index.remove_worker(worker_id);
244
245
12
        let result = self.workers.pop(worker_id);
246
12
        self.worker_change_notify.notify_one();
247
12
        result
248
12
    }
249
250
    /// Sets if the worker is draining or not.
251
2
    fn set_drain_worker(&mut self, worker_id: &WorkerId, is_draining: bool) -> Result<(), Error> {
252
2
        let worker = self
253
2
            .workers
254
2
            .get_mut(worker_id)
255
2
            .err_tip(|| 
format!0
("Worker {worker_id} doesn't exist in the pool"))
?0
;
256
2
        if worker.is_draining != is_draining {
257
2
            record_worker_state("draining", is_draining);
258
2
        
}0
259
2
        worker.is_draining = is_draining;
260
2
        self.worker_change_notify.notify_one();
261
2
        Ok(())
262
2
    }
263
264
72
    fn inner_find_worker_for_action(
265
72
        &self,
266
72
        platform_properties: &PlatformProperties,
267
72
        full_worker_logging: bool,
268
72
    ) -> Option<WorkerId> {
269
        // Do a fast check to see if any workers are available at all for work allocation
270
72
        if !self.workers.iter().any(|(_, w)| 
w60
.
can_accept_work60
()) {
271
14
            if full_worker_logging {
272
2
                info!("All workers are fully allocated");
273
12
            }
274
14
            return None;
275
58
        }
276
277
        // Use capability index to get candidate workers that match STATIC properties
278
        // (Exact, Unknown) and have the required property keys (Priority, Minimum).
279
        // This reduces complexity from O(W × P) to O(P × log(W)) for exact properties.
280
58
        let candidates = self
281
58
            .capability_index
282
58
            .find_matching_workers(platform_properties, full_worker_logging);
283
284
58
        if candidates.is_empty() {
285
1
            if full_worker_logging {
286
0
                info!("No workers in capability index match required properties");
287
1
            }
288
1
            return None;
289
57
        }
290
291
        // Check function for availability AND dynamic Minimum property verification.
292
        // The index only does presence checks for Minimum properties since their
293
        // values change dynamically as jobs are assigned to workers.
294
57
        let worker_matches = |(worker_id, w): &(&WorkerId, &Worker)| -> bool {
295
57
            if !w.can_accept_work() {
296
0
                if full_worker_logging {
297
0
                    info!(
298
                        "Worker {worker_id} cannot accept work: is_paused={}, is_draining={}, inflight={}/{}",
299
                        w.is_paused,
300
                        w.is_draining,
301
0
                        w.running_action_infos.len(),
302
                        w.max_inflight_tasks
303
                    );
304
0
                }
305
0
                return false;
306
57
            }
307
308
            // Verify Minimum properties at runtime (their values are dynamic)
309
57
            platform_properties.is_satisfied_by(&w.platform_properties, full_worker_logging)
310
57
        };
311
312
        // Now check constraints on filtered candidates.
313
        // Iterate in LRU order based on allocation strategy.
314
57
        let workers_iter = self.workers.iter();
315
316
57
        let worker_id = match self.allocation_strategy {
317
            // Use rfind to get the least recently used that satisfies the properties.
318
57
            WorkerAllocationStrategy::LeastRecentlyUsed => workers_iter
319
57
                .rev()
320
58
                .
filter57
(|(worker_id, _)| candidates.contains(worker_id))
321
57
                .find(&worker_matches)
322
57
                .map(|(_, w)| 
w.id48
.
clone48
()),
323
324
            // Use find to get the most recently used that satisfies the properties.
325
0
            WorkerAllocationStrategy::MostRecentlyUsed => workers_iter
326
0
                .filter(|(worker_id, _)| candidates.contains(worker_id))
327
0
                .find(&worker_matches)
328
0
                .map(|(_, w)| w.id.clone()),
329
        };
330
57
        if full_worker_logging && 
worker_id10
.
is_none10
() {
331
1
            warn!("No workers matched!");
332
56
        }
333
57
        worker_id
334
72
    }
335
336
14
    async fn update_action(
337
14
        &mut self,
338
14
        worker_id: &WorkerId,
339
14
        operation_id: &OperationId,
340
14
        update: UpdateOperationType,
341
14
    ) -> Result<(), Error> {
342
14
        let worker = self.workers.get_mut(worker_id).err_tip(|| 
{0
343
0
            format!("Worker {worker_id} does not exist in SimpleScheduler::update_action")
344
0
        })?;
345
346
        // Ensure the worker is supposed to be running the operation.
347
14
        if !worker.running_action_infos.contains_key(operation_id) {
348
1
            let err = make_err!(
349
1
                Code::Internal,
350
                "Operation {operation_id} should not be running on worker {worker_id} in SimpleScheduler::update_action"
351
            );
352
1
            return Result::<(), _>::Err(err.clone())
353
1
                .merge(self.immediate_evict_worker(worker_id, err, false).await);
354
13
        }
355
356
13
        let (is_finished, due_to_backpressure) = match &update {
357
7
            UpdateOperationType::UpdateWithActionStage(action_stage) => {
358
7
                (action_stage.is_finished(), false)
359
            }
360
0
            UpdateOperationType::KeepAlive => (false, false),
361
4
            UpdateOperationType::UpdateWithError(err) => {
362
4
                (true, err.code == Code::ResourceExhausted)
363
            }
364
2
            UpdateOperationType::UpdateWithDisconnect => (true, false),
365
            UpdateOperationType::ExecutionComplete => {
366
                // No update here, just restoring platform properties.
367
0
                worker.execution_complete(operation_id);
368
0
                self.worker_change_notify.notify_one();
369
0
                return Ok(());
370
            }
371
        };
372
373
        // Update the operation in the worker state manager.
374
13
        let 
update_operation_res12
= if worker.is_kill_requested(operation_id) {
375
1
            debug!(
376
                %operation_id,
377
                ?worker_id,
378
                "Ignoring update for operation the worker was told to kill"
379
            );
380
1
            Ok(())
381
        } else {
382
12
            let 
update_operation_res11
= self
383
12
                .worker_state_manager
384
12
                .update_operation(operation_id, worker_id, update)
385
12
                .await
386
11
                .err_tip(|| "in update_operation on SimpleScheduler::update_action");
387
11
            if let Err(
err1
) = &update_operation_res {
388
1
                error!(
389
                    %operation_id,
390
                    ?worker_id,
391
                    ?err,
392
                    "Failed to update_operation on update_action"
393
                );
394
10
            }
395
11
            update_operation_res
396
        };
397
398
12
        if !is_finished {
399
0
            return update_operation_res;
400
12
        }
401
        // The worker is done with this action even if the state-manager update
402
        // failed (e.g. the operation was already torn down after its clients
403
        // timed out). The worker bookkeeping below must still run, or the
404
        // worker's platform properties leak until it can never match again.
405
406
        // Clear this action from the current worker if finished.
407
12
        let complete_action_res = {
408
            // Note: We need to run this before dealing with backpressure logic.
409
12
            let was_paused = worker.is_paused;
410
12
            let complete_action_res = worker.complete_action(operation_id);
411
412
12
            if (due_to_backpressure || !worker.can_accept_work()) && 
worker0
.
has_actions0
() {
413
0
                worker.is_paused = true;
414
12
            }
415
            // complete_action clears is_paused on its way through, so compare
416
            // the state either side of it rather than testing the flag after.
417
            // Testing afterwards counts a re-pause every time and never
418
            // unwinds, which leaves the gauge climbing forever.
419
12
            if was_paused != worker.is_paused {
420
0
                record_worker_state("paused", worker.is_paused);
421
12
            }
422
12
            complete_action_res
423
        };
424
425
12
        self.worker_change_notify.notify_one();
426
427
12
        update_operation_res.merge(complete_action_res)
428
13
    }
429
430
    /// Notifies the specified worker to run the given action and handles errors by evicting
431
    /// the worker if the notification fails.
432
44
    async fn worker_notify_run_action(
433
44
        &mut self,
434
44
        worker_id: WorkerId,
435
44
        operation_id: OperationId,
436
44
        action_info: ActionInfoWithProps,
437
44
    ) -> Result<(), Error> {
438
44
        if let Some(worker) = self.workers.get_mut(&worker_id) {
439
44
            let notify_worker_result = worker
440
44
                .notify_update(WorkerUpdate::RunAction(Box::new((
441
44
                    operation_id,
442
44
                    action_info.clone(),
443
44
                ))))
444
44
                .await;
445
446
44
            if let Err(
notify_worker_result1
) = notify_worker_result {
447
1
                warn!(
448
                    ?worker_id,
449
                    ?action_info,
450
                    ?notify_worker_result,
451
                    "Worker command failed, removing worker",
452
                );
453
454
                // A slightly nasty way of figuring out that the worker disconnected
455
                // from send_msg_to_worker without introducing complexity to the
456
                // code path from here to there.
457
1
                let is_disconnect = notify_worker_result.code == Code::Internal
458
1
                    && notify_worker_result.messages.len() == 1
459
0
                    && notify_worker_result.messages[0] == "Worker Disconnected";
460
461
1
                let err = make_err!(
462
1
                    Code::Internal,
463
                    "Worker command failed, removing worker {worker_id} -- {notify_worker_result:?}",
464
                );
465
466
1
                return Result::<(), _>::Err(err.clone()).merge(
467
1
                    self.immediate_evict_worker(&worker_id, err, is_disconnect)
468
1
                        .await,
469
                );
470
43
            }
471
43
            Ok(())
472
        } else {
473
0
            warn!(
474
                ?worker_id,
475
                %operation_id,
476
                ?action_info,
477
                "Worker not found in worker map in worker_notify_run_action"
478
            );
479
            // Ensure the operation is put back to queued state.
480
0
            self.worker_state_manager
481
0
                .update_operation(
482
0
                    &operation_id,
483
0
                    &worker_id,
484
0
                    UpdateOperationType::UpdateWithDisconnect,
485
0
                )
486
0
                .await
487
        }
488
44
    }
489
490
    /// Tells the worker to kill an operation it is still running but the
491
    /// state manager no longer has executing on it. A worker that cannot be
492
    /// reached is evicted, the same as for a failed run request.
493
3
    async fn worker_notify_kill_operation(
494
3
        &mut self,
495
3
        worker_id: &WorkerId,
496
3
        operation_id: OperationId,
497
3
    ) -> Result<(), Error> {
498
3
        let Some(worker) = self.workers.get_mut(worker_id) else {
499
            // Gone between the snapshot and now; its actions were requeued.
500
0
            return Ok(());
501
        };
502
        // Already told, or finished in the meantime; nothing more to send.
503
3
        if !worker.running_action_infos.contains_key(&operation_id)
504
3
            || worker.is_kill_requested(&operation_id)
505
        {
506
0
            return Ok(());
507
3
        }
508
3
        info!(
509
            ?worker_id,
510
            %operation_id,
511
            "Killing operation the state manager no longer has executing on this worker"
512
        );
513
3
        if let Err(
err1
) = worker
514
3
            .notify_update(WorkerUpdate::KillOperation(operation_id.clone()))
515
3
            .await
516
        {
517
1
            warn!(
518
                ?worker_id,
519
                %operation_id,
520
                ?err,
521
                "Worker command failed, removing worker"
522
            );
523
1
            let err = make_err!(
524
1
                Code::Internal,
525
                "Worker command failed, removing worker {worker_id} -- {err:?}",
526
            );
527
1
            return Result::<(), _>::Err(err.clone())
528
1
                .merge(self.immediate_evict_worker(worker_id, err, true).await);
529
2
        }
530
2
        Ok(())
531
3
    }
532
533
    /// Evicts the worker from the pool and puts items back into the queue if anything was being executed on it.
534
12
    async fn immediate_evict_worker(
535
12
        &mut self,
536
12
        worker_id: &WorkerId,
537
12
        err: Error,
538
12
        is_disconnect: bool,
539
12
    ) -> Result<(), Error> {
540
12
        let mut result = Ok(());
541
12
        if let Some(mut worker) = self.remove_worker(worker_id) {
542
            // Log every eviction here rather than in each caller, so a worker
543
            // can never leave the pool unexplained. Without this, a worker that
544
            // vanished mid-build left nothing on the scheduler side to
545
            // attribute it to, and the only visible symptom was the worker
546
            // reconnecting with a fresh id.
547
12
            info!(
548
                ?worker_id,
549
                is_disconnect,
550
12
                running_actions = worker.running_action_infos.len(),
551
12
                reason = %err.message_string(),
552
                "Evicting worker from pool"
553
            );
554
12
            record_worker_disconnected(
555
12
                if is_disconnect {
556
1
                    WorkerDisconnectReason::Disconnected
557
                } else {
558
11
                    WorkerDisconnectReason::Evicted
559
                },
560
12
                worker.is_draining,
561
12
                worker.is_paused,
562
            );
563
            // We don't care if we fail to send message to worker, this is only a best attempt.
564
12
            drop(worker.notify_update(WorkerUpdate::Disconnect).await);
565
12
            let update = if is_disconnect {
566
1
                UpdateOperationType::UpdateWithDisconnect
567
            } else {
568
11
                UpdateOperationType::UpdateWithError(err)
569
            };
570
12
            for (
operation_id10
, _) in worker.running_action_infos.drain() {
571
10
                result = result.merge(
572
10
                    self.worker_state_manager
573
10
                        .update_operation(&operation_id, worker_id, update.clone())
574
10
                        .await,
575
                );
576
            }
577
0
        }
578
        // Note: Calling this many time is very cheap, it'll only trigger `do_try_match` once.
579
        // TODO(palfrey) This should be moved to inside the Workers struct.
580
12
        self.worker_change_notify.notify_one();
581
12
        result
582
12
    }
583
}
584
585
#[derive(Debug, MetricsComponent)]
586
pub struct ApiWorkerScheduler {
587
    #[metric]
588
    inner: Mutex<ApiWorkerSchedulerImpl>,
589
    #[metric(group = "platform_property_manager")]
590
    platform_property_manager: Arc<PlatformPropertyManager>,
591
592
    #[metric(
593
        help = "Timeout of how long to evict workers if no response in this given amount of time in seconds."
594
    )]
595
    worker_timeout_s: u64,
596
    #[metric(
597
        help = "How long a sent kill may go unacknowledged before the worker is evicted, in seconds."
598
    )]
599
    unacknowledged_kill_timeout_s: u64,
600
    /// Shared worker registry for checking worker liveness.
601
    worker_registry: SharedWorkerRegistry,
602
603
    /// Performance metrics for observability.
604
    metrics: Arc<SchedulerMetrics>,
605
606
    /// Channel for publishing origin events such as worker-observed action
607
    /// resource usage. `None` when origin events are disabled.
608
    maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>,
609
}
610
611
impl ApiWorkerScheduler {
612
    #[expect(clippy::too_many_arguments)]
613
40
    pub fn new(
614
40
        worker_state_manager: Arc<dyn WorkerStateManager>,
615
40
        platform_property_manager: Arc<PlatformPropertyManager>,
616
40
        allocation_strategy: WorkerAllocationStrategy,
617
40
        worker_change_notify: Arc<Notify>,
618
40
        worker_timeout_s: u64,
619
40
        unacknowledged_kill_timeout_s: u64,
620
40
        worker_registry: SharedWorkerRegistry,
621
40
        maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>,
622
40
    ) -> Arc<Self> {
623
40
        Arc::new(Self {
624
40
            inner: Mutex::new(ApiWorkerSchedulerImpl {
625
40
                workers: Workers(LruCache::unbounded()),
626
40
                worker_state_manager,
627
40
                allocation_strategy,
628
40
                worker_change_notify,
629
40
                worker_registry: worker_registry.clone(),
630
40
                shutting_down: false,
631
40
                capability_index: WorkerCapabilityIndex::new(),
632
40
            }),
633
40
            platform_property_manager,
634
40
            worker_timeout_s,
635
40
            unacknowledged_kill_timeout_s,
636
40
            worker_registry,
637
40
            metrics: Arc::new(SchedulerMetrics::default()),
638
40
            maybe_origin_event_tx,
639
40
        })
640
40
    }
641
642
    /// Returns a reference to the worker registry.
643
0
    pub const fn worker_registry(&self) -> &SharedWorkerRegistry {
644
0
        &self.worker_registry
645
0
    }
646
647
44
    pub async fn worker_notify_run_action(
648
44
        &self,
649
44
        worker_id: WorkerId,
650
44
        operation_id: OperationId,
651
44
        action_info: ActionInfoWithProps,
652
44
    ) -> Result<(), Error> {
653
44
        self.metrics
654
44
            .actions_dispatched
655
44
            .fetch_add(1, Ordering::Relaxed);
656
44
        let mut inner = self.inner.lock().await;
657
44
        inner
658
44
            .worker_notify_run_action(worker_id, operation_id, action_info)
659
44
            .await
660
44
    }
661
662
1
    pub async fn running_action_info(
663
1
        &self,
664
1
        worker_id: &WorkerId,
665
1
        operation_id: &OperationId,
666
1
    ) -> Option<ActionInfoWithProps> {
667
1
        let inner = self.inner.lock().await;
668
1
        inner
669
1
            .workers
670
1
            .peek(worker_id)
671
1
            .and_then(|worker| worker.running_action_infos.get(operation_id))
672
1
            .map(|pending_action_info| pending_action_info.action_info.clone())
673
1
    }
674
675
    /// Returns the scheduler metrics for observability.
676
    #[must_use]
677
0
    pub const fn get_metrics(&self) -> &Arc<SchedulerMetrics> {
678
0
        &self.metrics
679
0
    }
680
681
    /// Attempts to find a worker that is capable of running this action.
682
    // TODO(palfrey) This algorithm is not very efficient. Simple testing using a tree-like
683
    // structure showed worse performance on a 10_000 worker * 7 properties * 1000 queued tasks
684
    // simulation of worst cases in a single threaded environment.
685
72
    pub async fn find_worker_for_action(
686
72
        &self,
687
72
        platform_properties: &PlatformProperties,
688
72
        full_worker_logging: bool,
689
72
    ) -> Option<WorkerId> {
690
72
        let start = Instant::now();
691
72
        self.metrics
692
72
            .find_worker_calls
693
72
            .fetch_add(1, Ordering::Relaxed);
694
695
72
        let inner = self.inner.lock().await;
696
72
        let worker_count = inner.workers.len() as u64;
697
72
        let result = inner.inner_find_worker_for_action(platform_properties, full_worker_logging);
698
699
        // Track workers iterated (worst case is all workers)
700
72
        self.metrics
701
72
            .workers_iterated
702
72
            .fetch_add(worker_count, Ordering::Relaxed);
703
704
72
        if result.is_some() {
705
48
            self.metrics
706
48
                .find_worker_hits
707
48
                .fetch_add(1, Ordering::Relaxed);
708
48
        } else {
709
24
            self.metrics
710
24
                .find_worker_misses
711
24
                .fetch_add(1, Ordering::Relaxed);
712
24
        }
713
714
        #[allow(clippy::cast_possible_truncation)]
715
72
        self.metrics
716
72
            .find_worker_time_ns
717
72
            .fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
718
72
        result
719
72
    }
720
721
    /// Checks to see if the worker exists in the worker pool. Should only be used in unit tests.
722
    #[must_use]
723
7
    pub async fn contains_worker_for_test(&self, worker_id: &WorkerId) -> bool {
724
7
        let inner = self.inner.lock().await;
725
7
        inner.workers.contains(worker_id)
726
7
    }
727
728
    /// A unit test function used to send the keep alive message to the worker from the server.
729
0
    pub async fn send_keep_alive_to_worker_for_test(
730
0
        &self,
731
0
        worker_id: &WorkerId,
732
1
    ) -> Result<(), Error> {
733
1
        let mut inner = self.inner.lock().await;
734
1
        let worker = inner.workers.get_mut(worker_id).ok_or_else(|| 
{0
735
0
            make_input_err!("WorkerId '{}' does not exist in workers map", worker_id)
736
0
        })?;
737
1
        worker.keep_alive()
738
1
    }
739
}
740
741
#[async_trait]
742
impl WorkerScheduler for ApiWorkerScheduler {
743
2
    fn get_platform_property_manager(&self) -> &PlatformPropertyManager {
744
2
        self.platform_property_manager.as_ref()
745
2
    }
746
747
    async fn record_action_resource_usage(
748
        &self,
749
        worker_id: &WorkerId,
750
        operation_id: &OperationId,
751
        mut resource_usage: ActionResourceUsage,
752
1
    ) -> Result<(), Error> {
753
        // The worker API talks to this `ApiWorkerScheduler` (it is the
754
        // `WorkerScheduler` returned by `SimpleScheduler::new`), so the
755
        // resource-usage origin event must be published here. Previously the
756
        // only override lived on `SimpleScheduler`, which this path never
757
        // reaches, so the event was silently dropped by the trait's no-op
758
        // default and `observed_worker_peak_memory_mib` was never recorded.
759
        // Sampling is optional, so only record when the worker actually took a
760
        // reading. A zero here means "not sampled", not "used no memory".
761
        let maybe_action_info = self.running_action_info(worker_id, operation_id).await;
762
        let action_mnemonic = maybe_action_info
763
            .as_ref()
764
1
            .and_then(|action_info| action_info.origin_metadata.bazel_metadata.as_ref())
765
1
            .map_or_else(String::new, |bazel_metadata| {
766
1
                bazel_metadata.action_mnemonic.clone()
767
1
            });
768
769
        if resource_usage.sampled {
770
            if resource_usage.peak_memory_kb > 0 {
771
                record_execution_peak_memory(resource_usage.peak_memory_kb, "", &action_mnemonic);
772
            }
773
            if resource_usage.cpu_time_ms > 0 {
774
                record_execution_cpu_time(resource_usage.cpu_time_ms, "", &action_mnemonic);
775
            }
776
        }
777
778
        let Some(origin_event_tx) = self.maybe_origin_event_tx.as_ref() else {
779
            return Ok(());
780
        };
781
        let Some(action_info) = maybe_action_info else {
782
            return Ok(());
783
        };
784
785
        if resource_usage.operation_id.is_empty() {
786
            resource_usage.operation_id = operation_id.to_string();
787
        }
788
        if resource_usage.worker_id.is_empty() {
789
            resource_usage.worker_id = worker_id.to_string();
790
        }
791
792
        let event = Event {
793
            event: Some(event::Event::Response(ResponseEvent {
794
                event: Some(response_event::Event::ActionResourceUsage(resource_usage)),
795
            })),
796
        };
797
        let origin_event = OriginEvent {
798
            version: 0,
799
            event_id: Uuid::now_v6(&get_node_id(Some(&event)))
800
                .hyphenated()
801
                .to_string(),
802
            parent_event_id: action_info
803
                .scheduler_start_execute_event_id
804
                .clone()
805
                .unwrap_or_default(),
806
            bazel_request_metadata: action_info.origin_metadata.bazel_metadata.clone(),
807
            identity: action_info.origin_metadata.identity,
808
            event: Some(event),
809
        };
810
        // Awaited send (not try_send): apply backpressure when the publisher
811
        // queue is full instead of silently dropping the resource-usage event,
812
        // which is what drives action-level resource sizing in the UI.
813
        if let Err(err) = origin_event_tx.send(origin_event).await {
814
            warn!(?err, "Failed to publish action resource usage origin event");
815
        }
816
        Ok(())
817
1
    }
818
819
46
    async fn add_worker(&self, worker: Worker) -> Result<(), Error> {
820
        let worker_id = worker.id.clone();
821
        let worker_timestamp = worker.last_update_timestamp;
822
        let mut inner = self.inner.lock().await;
823
        if inner.shutting_down {
824
            warn!("Rejected worker add during shutdown: {}", worker_id);
825
            return Err(make_err!(
826
                Code::Unavailable,
827
                "Received request to add worker while shutting down"
828
            ));
829
        }
830
        let result = inner
831
            .add_worker(worker)
832
            .err_tip(|| "Error while adding worker, removing from pool");
833
        if let Err(err) = result {
834
            return Result::<(), _>::Err(err.clone())
835
                .merge(inner.immediate_evict_worker(&worker_id, err, false).await);
836
        }
837
838
        let now = UNIX_EPOCH + Duration::from_secs(worker_timestamp);
839
        self.worker_registry.register_worker(&worker_id, now).await;
840
841
        self.metrics.workers_added.fetch_add(1, Ordering::Relaxed);
842
        Ok(())
843
46
    }
844
845
    async fn update_action(
846
        &self,
847
        worker_id: &WorkerId,
848
        operation_id: &OperationId,
849
        update: UpdateOperationType,
850
14
    ) -> Result<(), Error> {
851
        let mut inner = self.inner.lock().await;
852
        inner.update_action(worker_id, operation_id, update).await
853
14
    }
854
855
    async fn worker_keep_alive_received(
856
        &self,
857
        worker_id: &WorkerId,
858
        timestamp: WorkerTimestamp,
859
4
    ) -> Result<(), Error> {
860
        {
861
            let mut inner = self.inner.lock().await;
862
            inner
863
                .refresh_lifetime(worker_id, timestamp)
864
                .err_tip(|| "Error refreshing lifetime in worker_keep_alive_received()")?;
865
        }
866
        let now = UNIX_EPOCH + Duration::from_secs(timestamp);
867
        self.worker_registry
868
            .update_worker_heartbeat(worker_id, now)
869
            .await;
870
        Ok(())
871
4
    }
872
873
6
    async fn remove_worker(&self, worker_id: &WorkerId) -> Result<(), Error> {
874
        self.worker_registry.remove_worker(worker_id).await;
875
876
        let mut inner = self.inner.lock().await;
877
        inner
878
            .immediate_evict_worker(
879
                worker_id,
880
                make_err!(Code::Internal, "Received request to remove worker"),
881
                false,
882
            )
883
            .await
884
6
    }
885
886
0
    async fn shutdown(&self, shutdown_guard: ShutdownGuard) {
887
        let mut inner = self.inner.lock().await;
888
        inner.shutting_down = true; // should reject further worker registration
889
        while let Some(worker_id) = inner
890
            .workers
891
            .peek_lru()
892
0
            .map(|(worker_id, _worker)| worker_id.clone())
893
        {
894
            if let Err(err) = inner
895
                .immediate_evict_worker(
896
                    &worker_id,
897
                    make_err!(Code::Internal, "Scheduler shutdown"),
898
                    true,
899
                )
900
                .await
901
            {
902
                error!(?err, "Error evicting worker on shutdown.");
903
            }
904
        }
905
        drop(shutdown_guard);
906
0
    }
907
908
7
    async fn remove_timedout_workers(&self, now_timestamp: WorkerTimestamp) -> Result<(), Error> {
909
        // Check worker liveness using both the local timestamp (from LRU)
910
        // and the worker registry. A worker is alive if either source says it's alive.
911
        let timeout = Duration::from_secs(self.worker_timeout_s);
912
        let now = UNIX_EPOCH + Duration::from_secs(now_timestamp);
913
        let timeout_threshold = now_timestamp.saturating_sub(self.worker_timeout_s);
914
915
        let workers_to_check: Vec<(WorkerId, bool, bool)> = {
916
            let inner = self.inner.lock().await;
917
            inner
918
                .workers
919
                .iter()
920
8
                .map(|(worker_id, worker)| {
921
8
                    let local_alive = worker.last_update_timestamp > timeout_threshold;
922
8
                    let kill_overdue = worker.running_action_infos.values().any(|info| 
{3
923
3
                        info.kill_requested_at.is_some_and(|at| 
{2
924
2
                            now_timestamp.saturating_sub(at) > self.unacknowledged_kill_timeout_s
925
2
                        })
926
3
                    });
927
8
                    (worker_id.clone(), local_alive, kill_overdue)
928
8
                })
929
                .collect()
930
        };
931
932
        let mut worker_ids_to_remove = Vec::new();
933
        for (worker_id, local_alive, kill_overdue) in workers_to_check {
934
            // A healthy worker acknowledges a kill in moments; one that
935
            // cannot is wedged, and its keepalives keep the liveness checks
936
            // below from ever firing while the dead operation holds its
937
            // slot (the nativelink#2672 symptom). Evicting requeues its
938
            // other operations.
939
            if kill_overdue {
940
                warn!(
941
                    ?worker_id,
942
                    unacknowledged_kill_timeout_s = self.unacknowledged_kill_timeout_s,
943
                    "Worker did not acknowledge a kill in time, removing from pool"
944
                );
945
                worker_ids_to_remove.push((worker_id, true));
946
                continue;
947
            }
948
949
            if local_alive {
950
                continue;
951
            }
952
953
            let registry_alive = self
954
                .worker_registry
955
                .is_worker_alive(&worker_id, timeout, now)
956
                .await;
957
958
            if !registry_alive {
959
                trace!(
960
                    ?worker_id,
961
                    local_alive,
962
                    registry_alive,
963
                    timeout_threshold,
964
                    "Worker timed out - neither local nor registry shows alive"
965
                );
966
                worker_ids_to_remove.push((worker_id, false));
967
            }
968
        }
969
970
        if worker_ids_to_remove.is_empty() {
971
            return Ok(());
972
        }
973
974
        let mut inner = self.inner.lock().await;
975
        let mut result = Ok(());
976
977
        for (worker_id, kill_overdue) in &worker_ids_to_remove {
978
            let err = if *kill_overdue {
979
                make_err!(
980
                    Code::Internal,
981
                    "Worker {worker_id} did not acknowledge a kill within {}s, removing from pool",
982
                    self.unacknowledged_kill_timeout_s
983
                )
984
            } else {
985
                warn!(?worker_id, "Worker timed out, removing from pool");
986
                make_err!(
987
                    Code::Internal,
988
                    "Worker {worker_id} timed out, removing from pool"
989
                )
990
            };
991
            result = result.merge(inner.immediate_evict_worker(worker_id, err, false).await);
992
        }
993
994
        result
995
7
    }
996
997
2
    async fn set_drain_worker(&self, worker_id: &WorkerId, is_draining: bool) -> Result<(), Error> {
998
        let mut inner = self.inner.lock().await;
999
        inner.set_drain_worker(worker_id, is_draining)
1000
2
    }
1001
1002
7
    async fn kill_revoked_operations(&self) -> Result<(), Error> {
1003
        let (worker_state_manager, running) = {
1004
            let inner = self.inner.lock().await;
1005
            let running: Vec<(WorkerId, OperationId)> = inner
1006
                .workers
1007
                .iter()
1008
7
                .flat_map(|(worker_id, worker)| {
1009
7
                    worker
1010
7
                        .running_action_infos
1011
7
                        .iter()
1012
                        // An operation already told to die is never swept
1013
                        // again; the kill itself is not re-sent. A worker
1014
                        // that received the kill but never reports back is
1015
                        // evicted by remove_timedout_workers once the kill
1016
                        // has gone unacknowledged longer than
1017
                        // `unacknowledged_kill_timeout_s`; a dead worker by
1018
                        // the ordinary keepalive timeout.
1019
7
                        .filter(|(_, pending_action_info)| {
1020
7
                            pending_action_info.kill_requested_at.is_none()
1021
7
                        })
1022
7
                        .map(|(operation_id, _)| (
worker_id6
.
clone6
(),
operation_id6
.
clone6
()))
1023
7
                })
1024
                .collect();
1025
            (inner.worker_state_manager.clone(), running)
1026
        };
1027
1028
        // On store-backed deployments each check is a network round-trip,
1029
        // so run them lock-free with bounded concurrency.
1030
        let revoked: Vec<(WorkerId, OperationId)> = futures::stream::iter(running)
1031
6
            .map(|(worker_id, operation_id)| {
1032
6
                let worker_state_manager = worker_state_manager.clone();
1033
6
                async move {
1034
6
                    match worker_state_manager
1035
6
                        .is_executing_on_worker(&operation_id, &worker_id)
1036
6
                        .await
1037
                    {
1038
3
                        Ok(true) => None,
1039
3
                        Ok(false) => Some((worker_id, operation_id)),
1040
                        // Only kill on positive evidence; try again next pass.
1041
0
                        Err(err) => {
1042
0
                            warn!(
1043
                                ?worker_id,
1044
                                %operation_id,
1045
                                ?err,
1046
                                "Could not check whether operation is still executing on worker"
1047
                            );
1048
0
                            None
1049
                        }
1050
                    }
1051
6
                }
1052
6
            })
1053
            .buffer_unordered(MAX_CONCURRENT_REVOKED_CHECKS)
1054
            .filter_map(future::ready)
1055
            .collect()
1056
            .await;
1057
1058
        if revoked.is_empty() {
1059
            return Ok(());
1060
        }
1061
1062
        // Re-check lock-free so the scheduler mutex is never held across
1063
        // store I/O; the checks above may be stale by the time we get here.
1064
        // The remaining TOCTOU window is fine: worker_notify_kill_operation
1065
        // re-guards with contains_key + is_kill_requested under the lock.
1066
        let mut confirmed = Vec::new();
1067
        for (worker_id, operation_id) in revoked {
1068
            // A result of Ok(true) or Err(_) means the operation was
1069
            // reassigned to this worker after the first check, or the state
1070
            // manager went quiet: nothing to kill on this pass.
1071
            let revoked = worker_state_manager
1072
                .is_executing_on_worker(&operation_id, &worker_id)
1073
                .await
1074
3
                .is_ok_and(|executing| !executing);
1075
            if revoked {
1076
                confirmed.push((worker_id, operation_id));
1077
            }
1078
        }
1079
1080
        if confirmed.is_empty() {
1081
            return Ok(());
1082
        }
1083
1084
        let mut inner = self.inner.lock().await;
1085
        let mut result = Ok(());
1086
        for (worker_id, operation_id) in confirmed {
1087
            result = result.merge(
1088
                inner
1089
                    .worker_notify_kill_operation(&worker_id, operation_id)
1090
                    .await,
1091
            );
1092
        }
1093
        result
1094
7
    }
1095
}
1096
1097
impl RootMetricsComponent for ApiWorkerScheduler {}