/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::collections::{BTreeSet, HashMap, HashSet}; |
19 | | use std::sync::Arc; |
20 | | use std::time::{Instant, SystemTime, UNIX_EPOCH}; |
21 | | |
22 | | use async_lock::Mutex; |
23 | | use futures::{StreamExt, future}; |
24 | | use lru::LruCache; |
25 | | use nativelink_config::schedulers::{MemoryEscalationSpec, WorkerAllocationStrategy}; |
26 | | use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err}; |
27 | | use nativelink_metric::{ |
28 | | MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, |
29 | | RootMetricsComponent, group, |
30 | | }; |
31 | | use nativelink_proto::com::github::trace_machina::nativelink::events::{ |
32 | | Event, OriginEvent, ResponseEvent, event, response_event, |
33 | | }; |
34 | | use nativelink_proto::com::github::trace_machina::nativelink::remote_execution::{ |
35 | | ActionResourceUsage, ResourceOutcome, WorkerLoad, |
36 | | }; |
37 | | use nativelink_util::action_messages::{ActionStage, OperationId, WorkerId}; |
38 | | use nativelink_util::metrics::{ |
39 | | WorkerDisconnectReason, record_dispatch_requeue, record_execution_cpu_time, |
40 | | record_execution_peak_memory, record_worker_connected, record_worker_disconnected, |
41 | | record_worker_keepalive, record_worker_keepalive_gap, record_worker_state, |
42 | | }; |
43 | | use nativelink_util::operation_state_manager::{ |
44 | | Decline, Escalation, UpdateOperationType, WorkerStateManager, |
45 | | }; |
46 | | use nativelink_util::origin_event::get_node_id; |
47 | | use nativelink_util::platform_properties::{PlatformProperties, PlatformPropertyValue}; |
48 | | use nativelink_util::shutdown_guard::ShutdownGuard; |
49 | | use tokio::sync::{Notify, mpsc}; |
50 | | use tonic::async_trait; |
51 | | use tracing::{debug, error, info, trace, warn}; |
52 | | |
53 | | /// How many state-manager lookups `kill_revoked_operations` has in flight |
54 | | /// at once while checking which running operations were revoked. |
55 | | const MAX_CONCURRENT_REVOKED_CHECKS: usize = 32; |
56 | | |
57 | | /// How many of the action's `priority` properties the worker carries with |
58 | | /// the same value. A priority property never restricts placement; it says |
59 | | /// where the action would rather be. |
60 | 7 | fn priority_matches(action: &PlatformProperties, worker: &Worker) -> usize { |
61 | 7 | action |
62 | 7 | .properties |
63 | 7 | .iter() |
64 | 11 | .filter7 (|(name, value)| { |
65 | 11 | matches!7 (value, PlatformPropertyValue::Priority(_)) |
66 | 4 | && worker.platform_properties.properties.get(*name) == Some(*value) |
67 | 11 | }) |
68 | 7 | .count() |
69 | 7 | } |
70 | | |
71 | | /// How much of the worker's advertised capacity would be left after this |
72 | | /// action, summed over the action's `minimum` properties, in thousandths. |
73 | | /// Lower is a tighter fit. An action with no numbers fits every worker |
74 | | /// equally, and a dimension the worker does not advertise is ignored. |
75 | 7 | fn fit_leftover_permille(action: &PlatformProperties, worker: &Worker) -> u64 { |
76 | 7 | let mut leftover = 0u64; |
77 | 11 | for (name, value) in &action.properties7 { |
78 | 11 | let PlatformPropertyValue::Minimum(needed7 ) = value else { |
79 | 4 | continue; |
80 | | }; |
81 | | let ( |
82 | 7 | Some(PlatformPropertyValue::Minimum(available)), |
83 | 7 | Some(PlatformPropertyValue::Minimum(total)), |
84 | | ) = ( |
85 | 7 | worker.platform_properties.properties.get(name), |
86 | 7 | worker.total_platform_properties.properties.get(name), |
87 | | ) |
88 | | else { |
89 | 0 | continue; |
90 | | }; |
91 | 7 | if *total == 0 { |
92 | 0 | continue; |
93 | 7 | } |
94 | 7 | let remaining = available.saturating_sub(*needed); |
95 | 7 | leftover += remaining.saturating_mul(1000) / total; |
96 | | } |
97 | 7 | leftover |
98 | 7 | } |
99 | | |
100 | | /// How many property shapes `static_verdicts` holds before it is emptied. |
101 | | const MAX_STATIC_VERDICTS: usize = 4096; |
102 | | |
103 | | use uuid::Uuid; |
104 | | |
105 | | /// Metrics for tracking scheduler performance. |
106 | | #[derive(Debug, Default)] |
107 | | pub struct SchedulerMetrics { |
108 | | /// Total number of worker additions. |
109 | | pub workers_added: AtomicU64, |
110 | | /// Total number of worker removals. |
111 | | pub workers_removed: AtomicU64, |
112 | | /// Total number of `find_worker_for_action` calls. |
113 | | pub find_worker_calls: AtomicU64, |
114 | | /// Total number of successful worker matches. |
115 | | pub find_worker_hits: AtomicU64, |
116 | | /// Total number of failed worker matches (no worker found). |
117 | | pub find_worker_misses: AtomicU64, |
118 | | /// Total time spent in `find_worker_for_action` (nanoseconds). |
119 | | pub find_worker_time_ns: AtomicU64, |
120 | | /// Total number of workers iterated during find operations. |
121 | | pub workers_iterated: AtomicU64, |
122 | | /// Total number of action dispatches. |
123 | | pub actions_dispatched: AtomicU64, |
124 | | /// Total number of keep-alive updates. |
125 | | pub keep_alive_updates: AtomicU64, |
126 | | /// Total number of worker timeouts. |
127 | | pub worker_timeouts: AtomicU64, |
128 | | /// Dispatches the worker declined, requeued untried. |
129 | | pub dispatches_declined: AtomicU64, |
130 | | /// Dispatches that found the worker's channel full, requeued untried. |
131 | | pub dispatch_channel_full: AtomicU64, |
132 | | /// Dispatches requeued because the worker never acknowledged them. |
133 | | pub dispatches_unacknowledged: AtomicU64, |
134 | | } |
135 | | |
136 | | use crate::match_outcome::{ |
137 | | MatchOutcome, PropertyShape, UnsatisfiableReason, explain_unsatisfiable, |
138 | | }; |
139 | | use crate::platform_property_manager::PlatformPropertyManager; |
140 | | use crate::worker::{ActionInfoWithProps, Worker, WorkerTimestamp, WorkerUpdate}; |
141 | | use crate::worker_capability_index::WorkerCapabilityIndex; |
142 | | use crate::worker_registry::SharedWorkerRegistry; |
143 | | use crate::worker_scheduler::{WorkerScheduler, WorkerSummary}; |
144 | | |
145 | | #[derive(Debug)] |
146 | | struct Workers(LruCache<WorkerId, Worker>); |
147 | | |
148 | | impl Deref for Workers { |
149 | | type Target = LruCache<WorkerId, Worker>; |
150 | | |
151 | 1.86k | fn deref(&self) -> &Self::Target { |
152 | 1.86k | &self.0 |
153 | 1.86k | } |
154 | | } |
155 | | |
156 | | impl DerefMut for Workers { |
157 | 545 | fn deref_mut(&mut self) -> &mut Self::Target { |
158 | 545 | &mut self.0 |
159 | 545 | } |
160 | | } |
161 | | |
162 | | // Note: This could not be a derive macro because this derive-macro |
163 | | // does not support LruCache and nameless field structs. |
164 | | impl MetricsComponent for Workers { |
165 | 0 | fn publish( |
166 | 0 | &self, |
167 | 0 | _kind: MetricKind, |
168 | 0 | _field_metadata: MetricFieldData, |
169 | 0 | ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> { |
170 | 0 | let _enter = group!("workers").entered(); |
171 | 0 | for (worker_id, worker) in self.iter() { |
172 | 0 | let _enter = group!(worker_id).entered(); |
173 | 0 | worker.publish(MetricKind::Component, MetricFieldData::default())?; |
174 | | } |
175 | 0 | Ok(MetricPublishKnownKindData::Component) |
176 | 0 | } |
177 | | } |
178 | | |
179 | | /// A collection of workers that are available to run tasks. |
180 | | #[derive(MetricsComponent)] |
181 | | struct ApiWorkerSchedulerImpl { |
182 | | /// A `LruCache` of workers available based on `allocation_strategy`. |
183 | | #[metric(group = "workers")] |
184 | | workers: Workers, |
185 | | |
186 | | /// The worker state manager. |
187 | | #[metric(group = "worker_state_manager")] |
188 | | worker_state_manager: Arc<dyn WorkerStateManager>, |
189 | | /// The allocation strategy for workers. |
190 | | allocation_strategy: WorkerAllocationStrategy, |
191 | | /// The `minimum` property compared against each worker's reported free |
192 | | /// memory before placement; unset means no veto. |
193 | | live_memory_veto: Option<String>, |
194 | | /// How a memory kill is turned into a retry with a larger reservation; |
195 | | /// unset means the kill fails the action. |
196 | | memory_escalation: Option<MemoryEscalationSpec>, |
197 | | /// A channel to notify the matching engine that the worker pool has changed. |
198 | | worker_change_notify: Arc<Notify>, |
199 | | /// Bumped with every notification, under the same lock as placement, so |
200 | | /// a matching pass can tell that room opened while it was walking the |
201 | | /// queue and offer that room to the oldest waiting action first. |
202 | | capacity_generation: Arc<AtomicU64>, |
203 | | /// Worker registry for tracking worker liveness. |
204 | | worker_registry: SharedWorkerRegistry, |
205 | | |
206 | | /// Whether the worker scheduler is shutting down. |
207 | | shutting_down: bool, |
208 | | |
209 | | /// Index for fast worker capability lookup. |
210 | | /// Used to accelerate `find_worker_for_action` by filtering candidates |
211 | | /// based on properties before doing linear scan. |
212 | | capability_index: WorkerCapabilityIndex, |
213 | | |
214 | | /// Incremented when the set of distinct worker shapes changes, here or |
215 | | /// on a peer. A worker joining or leaving with a shape the fleet already |
216 | | /// has does not count: the unsatisfiable tracker keys its clock on this, |
217 | | /// and a pool that scales up and down with identical workers used to |
218 | | /// reset that clock every time, so an action no shape could ever run |
219 | | /// waited forever behind a fleet that never stopped moving. |
220 | | fleet_generation: u64, |
221 | | /// The distinct shapes behind `fleet_generation`, local and peer. |
222 | | fleet_shape_set: BTreeSet<PropertyShape>, |
223 | | |
224 | | /// What the workers connected to other schedulers on the same state |
225 | | /// registered with, one entry per distinct shape, sorted by shape so |
226 | | /// that an unchanged fleet compares equal. `None` until the peers have |
227 | | /// been read, and whenever reading them fails: an action is then never |
228 | | /// judged unsatisfiable, since a peer might have a worker for it. |
229 | | peer_fleet: Option<Vec<PlatformProperties>>, |
230 | | |
231 | | /// Whether this scheduler shares its state with peers. A non-shared db is |
232 | | /// the whole fleet, so peer-absence is authoritative at once; a shared one |
233 | | /// applies the census warm-up / staleness checks below before it trusts |
234 | | /// peer-absence to fail an action. |
235 | | shared: bool, |
236 | | |
237 | | /// One peer-record TTL, used for both the census warm-up and staleness |
238 | | /// bounds below. |
239 | | record_ttl: Duration, |
240 | | |
241 | | /// When the peer census last became known (a `None` -> `Some` exchange). |
242 | | /// Peer-absence is only trusted to fail an action once it has been known |
243 | | /// for `record_ttl`, so a peer publishing on its own cadence has had time |
244 | | /// to appear. Cleared whenever the census goes unknown. |
245 | | peer_fleet_known_since: Option<SystemTime>, |
246 | | |
247 | | /// When the peer census was last refreshed by a successful exchange. If the |
248 | | /// exchange task dies or stalls this stops advancing, and after `record_ttl` |
249 | | /// the census is treated as stale and peer-absence is no longer trusted |
250 | | /// (fail open). Cleared whenever the census goes unknown. |
251 | | peer_fleet_last_refresh: Option<SystemTime>, |
252 | | |
253 | | /// Woken when a worker joins or leaves this scheduler, so peers hear |
254 | | /// about it without waiting for the next periodic exchange. |
255 | | local_fleet_change_notify: Arc<Notify>, |
256 | | |
257 | | /// Whether an action with a given property shape could run on some |
258 | | /// connected worker when that worker is idle. `None` means it could. This |
259 | | /// depends only on what workers registered with, so it holds until the |
260 | | /// fleet changes, at which point the map is emptied. |
261 | | static_verdicts: HashMap<PropertyShape, Option<Arc<UnsatisfiableReason>>>, |
262 | | } |
263 | | |
264 | | /// Whether peer-absence can be trusted to fail an action, given the census |
265 | | /// state and the current time. A free function so its edge cases (warm-up, |
266 | | /// staleness, clock skew, db kind) can be unit-tested without a scheduler. |
267 | 132 | fn peer_census_trusted( |
268 | 132 | shared: bool, |
269 | 132 | peer_fleet_known: bool, |
270 | 132 | peer_fleet_known_since: Option<SystemTime>, |
271 | 132 | peer_fleet_last_refresh: Option<SystemTime>, |
272 | 132 | record_ttl: Duration, |
273 | 132 | now: SystemTime, |
274 | 132 | ) -> bool { |
275 | | // Unknown (a shared db before its first exchange or after a failed one): a |
276 | | // peer we cannot see might run the action. Checked before the non-shared |
277 | | // short-circuit so a deliberately-unknown census is never trusted. |
278 | 132 | if !peer_fleet_known { |
279 | 2 | return false; |
280 | 130 | } |
281 | | // A non-shared db is the whole fleet; peer-absence is authoritative at |
282 | | // once, with no warm-up or staleness delay. |
283 | 130 | if !shared { |
284 | 119 | return true; |
285 | 11 | } |
286 | 11 | let (Some(known_since9 ), Some(last_refresh9 )) = (peer_fleet_known_since, peer_fleet_last_refresh) |
287 | | else { |
288 | 2 | return false; |
289 | | }; |
290 | | // Warm: the census must have been known for a full record TTL, so a peer |
291 | | // publishing on its own ~interval cadence has had time to appear. A clock |
292 | | // going backwards (Err) is treated as not-yet-warm: don't fail. |
293 | 9 | let warm = now |
294 | 9 | .duration_since(known_since) |
295 | 9 | .is_ok_and(|elapsed| elapsed >= record_ttl); |
296 | | // Fresh: a successful exchange within the last record TTL. If the exchange |
297 | | // task died or stalled this goes stale and we stop trusting peer-absence |
298 | | // (fail open). A future-dated refresh (clock skew) counts as fresh. |
299 | 9 | let fresh = now |
300 | 9 | .duration_since(last_refresh) |
301 | 9 | .map_or(true, |elapsed| elapsed8 <= record_ttl8 ); |
302 | 9 | warm && fresh8 |
303 | 132 | } |
304 | | |
305 | | impl core::fmt::Debug for ApiWorkerSchedulerImpl { |
306 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
307 | 0 | f.debug_struct("ApiWorkerSchedulerImpl") |
308 | 0 | .field("workers", &self.workers) |
309 | 0 | .field("allocation_strategy", &self.allocation_strategy) |
310 | 0 | .field("worker_change_notify", &self.worker_change_notify) |
311 | 0 | .field( |
312 | 0 | "capability_index_size", |
313 | 0 | &self.capability_index.worker_count(), |
314 | 0 | ) |
315 | 0 | .field("worker_registry", &self.worker_registry) |
316 | 0 | .finish_non_exhaustive() |
317 | 0 | } |
318 | | } |
319 | | |
320 | | impl ApiWorkerSchedulerImpl { |
321 | | /// Refreshes the lifetime of the worker with the given timestamp. |
322 | | /// |
323 | | /// Instead of sending N keepalive messages (one per operation), |
324 | | /// we now send a single worker heartbeat. The worker registry tracks worker liveness, |
325 | | /// and timeout detection checks the worker's `last_seen` instead of per-operation timestamps. |
326 | | /// |
327 | | /// Note: This only updates the local worker state. The worker registry is updated |
328 | | /// separately after releasing the inner lock to reduce contention. |
329 | 50 | fn refresh_lifetime( |
330 | 50 | &mut self, |
331 | 50 | worker_id: &WorkerId, |
332 | 50 | timestamp: WorkerTimestamp, |
333 | 50 | load: Option<WorkerLoad>, |
334 | 50 | keepalive: bool, |
335 | 50 | ) -> Result<(), Error> { |
336 | 50 | let worker49 = self.workers.0.peek_mut(worker_id).ok_or_else(|| {1 |
337 | 1 | make_input_err!( |
338 | | "Worker not found in worker map in refresh_lifetime() {}", |
339 | | worker_id |
340 | | ) |
341 | 1 | })?; |
342 | | // The scheduler's wall clock can step backwards (NTP, VM time |
343 | | // sync). The worker is no less alive for it, so a stale-looking |
344 | | // timestamp keeps the newer one instead of failing: an error here |
345 | | // propagates up through `touch_liveness` and would discard the |
346 | | // message that proved liveness — including a finished |
347 | | // `ExecuteResult`, stranding the operation in `Executing`. |
348 | 49 | if worker.last_update_timestamp > timestamp { |
349 | 2 | warn!( |
350 | | %worker_id, |
351 | | last_update_timestamp = worker.last_update_timestamp, |
352 | | timestamp, |
353 | | "Scheduler clock stepped backwards; keeping newer liveness timestamp", |
354 | | ); |
355 | 47 | } |
356 | 49 | record_worker_keepalive_gap(timestamp.saturating_sub(worker.last_update_timestamp)); |
357 | 49 | worker.last_update_timestamp = worker.last_update_timestamp.max(timestamp); |
358 | | // Any other message (an acknowledgement, a decline, an execute |
359 | | // result) proves the worker is alive and nothing more. A decline in |
360 | | // particular is not the worker saying it is ready to be asked |
361 | | // again, so the pause it took stands until a real keepalive. |
362 | 49 | if !keepalive { |
363 | 8 | return Ok(()); |
364 | 41 | } |
365 | | // A keepalive without a load (an older worker) leaves the last |
366 | | // report in place. A report with |
367 | | // more room than the last one can make a vetoed action eligible |
368 | | // again; less room never does. |
369 | 41 | let more_room = load.is_some_and(|load| {26 |
370 | 26 | let more = worker |
371 | 26 | .last_load |
372 | 26 | .as_ref() |
373 | 26 | .is_none_or(|last| load.free_memory_kb13 > last.free_memory_kb13 ); |
374 | 26 | worker.last_load = Some(load); |
375 | 26 | more |
376 | 26 | }); |
377 | | // A keepalive is the worker saying it is ready to be asked again, |
378 | | // so a pause from backpressure lasts one keepalive interval. A |
379 | | // pause taken for a decline for load is the exception: it waits |
380 | | // for a keepalive whose free memory, read above, covers what the |
381 | | // declined action wanted. A worker that reports no load is taken |
382 | | // at its word. So is a worker that admits when idle and holds |
383 | | // nothing here: it declined while something of its own was still |
384 | | // finishing, and once idle it admits whatever it is offered, so |
385 | | // waiting for free memory it may never report would strand it. |
386 | 41 | let idle_and_admitting = worker.admits_when_idle && worker.running_action_infos4 .is_empty4 (); |
387 | 41 | let load_fits = idle_and_admitting |
388 | 39 | || match (worker.pause_needs_kb, worker.last_load) { |
389 | 6 | (Some(needs_kb), Some(load)) => load.free_memory_kb >= needs_kb, |
390 | 33 | _ => true, |
391 | | }; |
392 | | // A keepalive reporting room for what the worker declined while |
393 | | // idle makes that size worth offering it again. |
394 | 41 | let idle_decline_lifted = match (worker.idle_declined_kb, load) { |
395 | 2 | (Some(declined_kb), Some(load)) => load.free_memory_kb >= declined_kb, |
396 | 39 | _ => false, |
397 | | }; |
398 | 41 | if idle_decline_lifted { |
399 | 1 | worker.idle_declined_kb = None; |
400 | 40 | } |
401 | 41 | let resumed = worker.is_paused && load_fits12 ; |
402 | 41 | if resumed { |
403 | 9 | worker.is_paused = false; |
404 | 9 | worker.pause_needs_kb = None; |
405 | 9 | record_worker_state("paused", false); |
406 | 32 | } |
407 | | // Either is room the fleet did not have a moment ago, so the |
408 | | // capacity generation moves and the matcher is woken, together: |
409 | | // a pass in flight offers the room to its parked actions first, |
410 | | // and a pass not in flight starts. `capacity_changed` by hand, |
411 | | // since `worker` still borrows the map. |
412 | 41 | if resumed || more_room32 || idle_decline_lifted11 { |
413 | 30 | self.capacity_generation.fetch_add(1, Ordering::Relaxed); |
414 | 30 | self.worker_change_notify.notify_one(); |
415 | 30 | }11 |
416 | | |
417 | 41 | trace!( |
418 | | ?worker_id, |
419 | 41 | running_operations = worker.running_action_infos.len(), |
420 | | "Worker keepalive received" |
421 | | ); |
422 | 41 | record_worker_keepalive(); |
423 | | |
424 | 41 | Ok(()) |
425 | 50 | } |
426 | | |
427 | | /// Adds a worker to the pool. |
428 | | /// Note: This function will not do any task matching. |
429 | 136 | fn add_worker(&mut self, worker: Worker) -> Result<(), Error> { |
430 | 136 | let worker_id = worker.id.clone(); |
431 | 136 | let platform_properties = worker.platform_properties.clone(); |
432 | | // A replacement is one out and one in, so the gauge only moves for a |
433 | | // genuinely new worker. |
434 | 136 | let replaced = self.workers.put(worker_id.clone(), worker); |
435 | | |
436 | | // Add to capability index for fast matching |
437 | 136 | self.capability_index |
438 | 136 | .add_worker(&worker_id, &platform_properties); |
439 | 136 | self.fleet_changed(); |
440 | 136 | self.local_fleet_change_notify.notify_one(); |
441 | | |
442 | | // Worker is not cloneable, and we do not want to send the initial connection results until |
443 | | // we have added it to the map, or we might get some strange race conditions due to the way |
444 | | // the multi-threaded runtime works. |
445 | 136 | let worker = self.workers.peek_mut(&worker_id).unwrap(); |
446 | 136 | let res = worker |
447 | 136 | .send_initial_connection_result(self.live_memory_veto.as_deref()) |
448 | 136 | .err_tip(|| "Failed to send initial connection result to worker"); |
449 | 136 | if let Err(err0 ) = &res { |
450 | 0 | error!( |
451 | | ?worker_id, |
452 | | ?err, |
453 | | "Worker connection appears to have been closed while adding to pool" |
454 | | ); |
455 | 136 | } |
456 | 136 | if let Some(replaced2 ) = &replaced { |
457 | 2 | if replaced.is_draining { |
458 | 0 | record_worker_state("draining", false); |
459 | 2 | } |
460 | 2 | if replaced.is_paused { |
461 | 0 | record_worker_state("paused", false); |
462 | 2 | } |
463 | 134 | } else { |
464 | 134 | record_worker_connected(); |
465 | 134 | } |
466 | 136 | self.capacity_changed(); |
467 | 136 | res |
468 | 136 | } |
469 | | |
470 | | /// Removes worker from pool. |
471 | | /// Note: The caller is responsible for any rescheduling of any tasks that might be |
472 | | /// running. |
473 | 23 | fn remove_worker(&mut self, worker_id: &WorkerId) -> Option<Worker> { |
474 | | // Remove from capability index |
475 | 23 | self.capability_index.remove_worker(worker_id); |
476 | | // Pop before recomputing the shapes, or the departing worker still |
477 | | // counts and a cached verdict that only it satisfied survives it. |
478 | 23 | let result = self.workers.pop(worker_id); |
479 | 23 | self.fleet_changed(); |
480 | 23 | self.local_fleet_change_notify.notify_one(); |
481 | | |
482 | 23 | self.capacity_changed(); |
483 | 23 | result |
484 | 23 | } |
485 | | |
486 | 173 | fn fleet_changed(&mut self) { |
487 | 173 | let mut shapes: BTreeSet<PropertyShape> = self |
488 | 173 | .workers |
489 | 173 | .iter() |
490 | 176 | .map173 (|(_, w)| PropertyShape::from(&w.total_platform_properties)) |
491 | 173 | .collect(); |
492 | 173 | if let Some(peer_fleet169 ) = &self.peer_fleet { |
493 | 169 | shapes.extend(peer_fleet.iter().map(PropertyShape::from)); |
494 | 169 | }4 |
495 | 173 | if shapes == self.fleet_shape_set { |
496 | 26 | return; |
497 | 147 | } |
498 | 147 | self.fleet_shape_set = shapes; |
499 | 147 | self.fleet_generation += 1; |
500 | 147 | self.static_verdicts.clear(); |
501 | 173 | } |
502 | | |
503 | | /// Something about the fleet's room to run actions changed: a worker |
504 | | /// joined, left, paused, resumed, drained or finished an action. |
505 | 235 | fn capacity_changed(&self) { |
506 | 235 | self.capacity_generation.fetch_add(1, Ordering::Relaxed); |
507 | 235 | self.worker_change_notify.notify_one(); |
508 | 235 | } |
509 | | |
510 | | /// The distinct properties this scheduler's workers registered with. |
511 | 53 | fn fleet_shapes(&self) -> Vec<PlatformProperties> { |
512 | 53 | let mut seen = HashSet::new(); |
513 | 53 | self.workers |
514 | 53 | .iter() |
515 | 53 | .map(|(_, w)| &w.total_platform_properties) |
516 | 53 | .filter(|totals| seen42 .insert42 (PropertyShape::from42 (*totals42 ))) |
517 | 53 | .cloned() |
518 | 53 | .collect() |
519 | 53 | } |
520 | | |
521 | 61 | fn set_peer_fleet(&mut self, peer_fleet: Option<Vec<PlatformProperties>>, now: SystemTime) { |
522 | | // Peers list their workers in no particular order and may share |
523 | | // shapes, so sort and dedup before comparing, or an unchanged fleet |
524 | | // would look changed on every exchange and empty `static_verdicts`. |
525 | 61 | let peer_fleet = peer_fleet.map(|peer_fleet| {59 |
526 | 59 | let mut keyed: Vec<_> = peer_fleet |
527 | 59 | .into_iter() |
528 | 59 | .map(|totals| (PropertyShape::from34 (&totals34 ), totals34 )) |
529 | 59 | .collect(); |
530 | 59 | keyed.sort_unstable_by(|a, b| a.03 .cmp3 (&b.03 )); |
531 | 59 | keyed.dedup_by(|a, b| a.03 == b.03 ); |
532 | 59 | keyed.into_iter().map(|(_, totals)| totals).collect() |
533 | 59 | }); |
534 | | // Freshness bookkeeping: update the warm-up / |
535 | | // staleness clocks BEFORE the unchanged-shapes early return, so a |
536 | | // successful exchange that happens to return the same shapes still |
537 | | // refreshes the staleness clock (otherwise a steady fleet would look |
538 | | // like a stalled exchange task). |
539 | 61 | if peer_fleet.is_some() { |
540 | 59 | self.peer_fleet_last_refresh = Some(now); |
541 | | // Start the warm-up only on the unknown -> known transition. |
542 | 59 | if self.peer_fleet.is_none() { |
543 | 6 | self.peer_fleet_known_since = Some(now); |
544 | 53 | } |
545 | 2 | } else { |
546 | 2 | self.peer_fleet_known_since = None; |
547 | 2 | self.peer_fleet_last_refresh = None; |
548 | 2 | } |
549 | 61 | if self.peer_fleet == peer_fleet { |
550 | 47 | return; |
551 | 14 | } |
552 | | // Unknown peers make every verdict "maybe satisfiable elsewhere", |
553 | | // and that verdict is cached. Peers becoming known (or unknown |
554 | | // again) changes what a verdict means even when the union of shapes |
555 | | // does not move, so the cache goes, though the generation stays: |
556 | | // clocks already running were right and keep their start. |
557 | 14 | if self.peer_fleet.is_some() != peer_fleet.is_some() { |
558 | 8 | self.static_verdicts.clear(); |
559 | 8 | }6 |
560 | 14 | self.peer_fleet = peer_fleet; |
561 | 14 | self.fleet_changed(); |
562 | 61 | } |
563 | | |
564 | | /// Sets if the worker is draining or not. |
565 | 5 | fn set_drain_worker(&mut self, worker_id: &WorkerId, is_draining: bool) -> Result<(), Error> { |
566 | 5 | let worker = self |
567 | 5 | .workers |
568 | 5 | .get_mut(worker_id) |
569 | 5 | .err_tip(|| format!0 ("Worker {worker_id} doesn't exist in the pool"))?0 ; |
570 | 5 | if worker.is_draining != is_draining { |
571 | 3 | record_worker_state("draining", is_draining); |
572 | 3 | }2 |
573 | 5 | worker.is_draining = is_draining; |
574 | 5 | self.capacity_changed(); |
575 | 5 | Ok(()) |
576 | 5 | } |
577 | | |
578 | | /// Whether peer-absence can be trusted to fail an action right now — a |
579 | | /// shared census must be known, warm and fresh; a non-shared db is trusted |
580 | | /// as soon as it is known. See the free `peer_census_trusted`. |
581 | 122 | fn peer_census_trusted(&self, now: SystemTime) -> bool { |
582 | 122 | peer_census_trusted( |
583 | 122 | self.shared, |
584 | 122 | self.peer_fleet.is_some(), |
585 | 122 | self.peer_fleet_known_since, |
586 | 122 | self.peer_fleet_last_refresh, |
587 | 122 | self.record_ttl, |
588 | 122 | now, |
589 | | ) |
590 | 122 | } |
591 | | |
592 | | /// Works out whether any of the `candidates` the capability index picked |
593 | | /// could run an action with these properties when idle, and if not, why. |
594 | 118 | fn static_verdict( |
595 | 118 | &self, |
596 | 118 | platform_properties: &PlatformProperties, |
597 | 118 | candidates: &HashSet<WorkerId>, |
598 | 118 | full_worker_logging: bool, |
599 | 118 | ) -> Option<Arc<UnsatisfiableReason>> { |
600 | | // The index only checks that Minimum keys are present, so the values |
601 | | // are checked here against what each candidate registered with. |
602 | 118 | let satisfiable = candidates.iter().any(|worker_id| {114 |
603 | 114 | self.workers.peek(worker_id).is_some_and(|w| { |
604 | 114 | platform_properties |
605 | 114 | .is_satisfied_by(&w.total_platform_properties, full_worker_logging) |
606 | 114 | }) |
607 | 114 | }); |
608 | 118 | if satisfiable { |
609 | 26 | return None; |
610 | 92 | } |
611 | | // A peer's worker counts too: the action would run there once that |
612 | | // worker has room, and this scheduler cannot see how busy it is. |
613 | | // While the peers are unknown, any of them might have one. |
614 | 92 | let peer_fleet88 = self.peer_fleet.as_deref()?4 ; |
615 | 88 | if peer_fleet |
616 | 88 | .iter() |
617 | 88 | .any(|totals| platform_properties5 .is_satisfied_by5 (totals5 , false)) |
618 | | { |
619 | 5 | return None; |
620 | 83 | } |
621 | 83 | let fleet: Vec<_> = self |
622 | 83 | .workers |
623 | 83 | .iter() |
624 | 83 | .map(|(_, w)| &w.total_platform_properties) |
625 | 83 | .chain(peer_fleet.iter()) |
626 | 83 | .collect(); |
627 | 83 | Some(Arc::new(explain_unsatisfiable(platform_properties, &fleet))) |
628 | 118 | } |
629 | | |
630 | | /// `static_verdict`, cached per property shape until the fleet changes. |
631 | 179 | fn cached_static_verdict( |
632 | 179 | &mut self, |
633 | 179 | platform_properties: &PlatformProperties, |
634 | 179 | candidates: Option<&HashSet<WorkerId>>, |
635 | 179 | full_worker_logging: bool, |
636 | 179 | ) -> Option<Arc<UnsatisfiableReason>> { |
637 | 179 | let shape = PropertyShape::from(platform_properties); |
638 | | // A cached verdict skips the per-property logging, so recompute when |
639 | | // that logging was asked for. |
640 | 179 | if !full_worker_logging && let Some(verdict61 ) = self.static_verdicts107 .get(&shape) { |
641 | 61 | return verdict.clone(); |
642 | 118 | } |
643 | 118 | let verdict = if let Some(candidates101 ) = candidates { |
644 | 101 | self.static_verdict(platform_properties, candidates, full_worker_logging) |
645 | | } else { |
646 | 17 | let candidates = self |
647 | 17 | .capability_index |
648 | 17 | .find_matching_workers(platform_properties, full_worker_logging); |
649 | 17 | self.static_verdict(platform_properties, &candidates, full_worker_logging) |
650 | | }; |
651 | 118 | if self.static_verdicts.len() >= MAX_STATIC_VERDICTS { |
652 | 0 | self.static_verdicts.clear(); |
653 | 118 | } |
654 | 118 | self.static_verdicts.insert(shape, verdict.clone()); |
655 | 118 | verdict |
656 | 179 | } |
657 | | |
658 | | /// The memory the action reserves under the `live_memory_veto` property, |
659 | | /// if the veto is on and the action declares it. |
660 | 288 | fn live_memory_veto_kb(&self, platform_properties: &PlatformProperties) -> Option<u64> { |
661 | 288 | let property19 = self.live_memory_veto.as_deref()?269 ; |
662 | 19 | match platform_properties.properties.get(property) { |
663 | 19 | Some(PlatformPropertyValue::Minimum(kb)) => Some(*kb), |
664 | 0 | _ => None, |
665 | | } |
666 | 288 | } |
667 | | |
668 | | /// Finds an idle worker among `candidates` that can run the action now. |
669 | 288 | fn find_available_worker( |
670 | 288 | &self, |
671 | 288 | platform_properties: &PlatformProperties, |
672 | 288 | candidates: &HashSet<WorkerId>, |
673 | 288 | full_worker_logging: bool, |
674 | 288 | ) -> Option<WorkerId> { |
675 | | // Check function for availability AND dynamic Minimum property verification. |
676 | | // The index only does presence checks for Minimum properties since their |
677 | | // values change dynamically as jobs are assigned to workers. |
678 | 288 | let needed_kb = self.live_memory_veto_kb(platform_properties); |
679 | 305 | let worker_matches288 = |(worker_id, w): &(&WorkerId, &Worker)| -> bool { |
680 | 305 | if !w.can_accept_work() { |
681 | 11 | if full_worker_logging { |
682 | 6 | info!( |
683 | | "Worker {worker_id} cannot accept work: is_paused={}, is_draining={}, inflight={}/{}", |
684 | | w.is_paused, |
685 | | w.is_draining, |
686 | 6 | w.running_action_infos.len(), |
687 | | w.max_inflight_tasks |
688 | | ); |
689 | 5 | } |
690 | 11 | return false; |
691 | 294 | } |
692 | | |
693 | 294 | if let (Some(needed_kb6 ), Some(declined_kb6 )) = (needed_kb, w.idle_declined_kb) |
694 | 6 | && needed_kb >= declined_kb |
695 | | { |
696 | 5 | if full_worker_logging { |
697 | 0 | info!( |
698 | | "Worker {worker_id} skipped for this action: it declined {declined_kb} KiB while idle, the action asks {needed_kb} KiB" |
699 | | ); |
700 | 5 | } |
701 | 5 | return false; |
702 | 289 | } |
703 | | |
704 | | // The ledger only knows what actions declared; the worker's own |
705 | | // report of what it has left catches the ones that declared too |
706 | | // little. A worker that reports nothing is never vetoed. |
707 | | // An action that asks for the worker's whole advertised memory |
708 | | // (the last escalation step) is not vetoed: a worker seldom |
709 | | // reports that much free, since its own process memory and |
710 | | // active page cache still count as used, so the veto could |
711 | | // hold it back indefinitely, and the ledger already keeps it |
712 | | // alone there. Nor is a worker that admits when idle and holds |
713 | | // nothing: it accepts whatever it reads free, so the veto would |
714 | | // only keep from it the actions it is idle for. |
715 | 14 | if let (Some(property), Some(needed_kb), Some(load)) = |
716 | 289 | (self.live_memory_veto.as_deref(), needed_kb, w.last_load) |
717 | 14 | && !(w.admits_when_idle && w.running_action_infos1 .is_empty1 ()) |
718 | 13 | && needed_kb > load.free_memory_kb |
719 | 5 | && matches!( |
720 | 5 | w.total_platform_properties.properties.get(property), |
721 | 5 | Some(PlatformPropertyValue::Minimum(advertised_kb)) if needed_kb < *advertised_kb |
722 | | ) |
723 | | { |
724 | 5 | if full_worker_logging { |
725 | 0 | info!( |
726 | | "Worker {worker_id} vetoed for this action: it reports {} KiB free, the action asks {needed_kb} KiB ({property})", |
727 | | load.free_memory_kb |
728 | | ); |
729 | 5 | } |
730 | 5 | return false; |
731 | 284 | } |
732 | | |
733 | | // Verify Minimum properties at runtime (their values are dynamic) |
734 | 284 | platform_properties.is_satisfied_by(&w.platform_properties, full_worker_logging) |
735 | 305 | }; |
736 | | |
737 | | // Now check constraints on filtered candidates. |
738 | | // Iterate in LRU order based on allocation strategy. |
739 | 288 | let workers_iter = self.workers.iter(); |
740 | | |
741 | 288 | match self.allocation_strategy { |
742 | | // Use rfind to get the least recently used that satisfies the properties. |
743 | 281 | WorkerAllocationStrategy::LeastRecentlyUsed => workers_iter |
744 | 281 | .rev() |
745 | 298 | .filter281 (|(worker_id, _)| candidates.contains(worker_id)) |
746 | 281 | .find(&worker_matches) |
747 | 281 | .map(|(_, w)| w.id136 .clone136 ()), |
748 | | |
749 | | // Use find to get the most recently used that satisfies the properties. |
750 | 0 | WorkerAllocationStrategy::MostRecentlyUsed => workers_iter |
751 | 0 | .filter(|(worker_id, _)| candidates.contains(worker_id)) |
752 | 0 | .find(&worker_matches) |
753 | 0 | .map(|(_, w)| w.id.clone()), |
754 | | |
755 | | // Fewest running actions wins; the scan runs from the least |
756 | | // recently used end so `min_by_key` keeps that one on a tie. |
757 | 3 | WorkerAllocationStrategy::LeastLoaded => workers_iter |
758 | 3 | .rev() |
759 | 4 | .filter3 (|(worker_id, _)| candidates.contains(worker_id)) |
760 | 3 | .filter(&worker_matches) |
761 | 4 | .min_by_key3 (|(_, w)| w.running_action_infos.len()) |
762 | 3 | .map(|(_, w)| w.id.clone()), |
763 | | |
764 | 4 | WorkerAllocationStrategy::BestFit => workers_iter |
765 | 4 | .rev() |
766 | 8 | .filter4 (|(worker_id, _)| candidates.contains(worker_id)) |
767 | 4 | .filter(&worker_matches) |
768 | 7 | .min_by_key4 (|(_, w)| { |
769 | 7 | ( |
770 | 7 | // More matching priority properties first. |
771 | 7 | usize::MAX - priority_matches(platform_properties, w), |
772 | 7 | fit_leftover_permille(platform_properties, w), |
773 | 7 | w.running_action_infos.len(), |
774 | 7 | ) |
775 | 7 | }) |
776 | 4 | .map(|(_, w)| w.id.clone()), |
777 | | } |
778 | 288 | } |
779 | | |
780 | 360 | fn inner_find_worker_for_action( |
781 | 360 | &mut self, |
782 | 360 | platform_properties: &PlatformProperties, |
783 | 360 | full_worker_logging: bool, |
784 | 360 | now: SystemTime, |
785 | 360 | ) -> MatchOutcome { |
786 | 360 | if self.workers.is_empty() { |
787 | 38 | if full_worker_logging { |
788 | 10 | info!("No workers available to match!"); |
789 | 28 | } |
790 | 38 | return MatchOutcome::NoWorkersConnected; |
791 | 322 | } |
792 | | |
793 | | // Do a fast check to see if any workers are available at all for work allocation |
794 | 324 | let candidates179 = if self.workers.iter()322 .any322 (|(_, w)| w.can_accept_work()) { |
795 | | // Use capability index to get candidate workers that match STATIC properties |
796 | | // (Exact, Unknown) and have the required property keys (Priority, Minimum). |
797 | | // This reduces complexity from O(W × P) to O(P × log(W)) for exact properties. |
798 | 288 | let candidates = self |
799 | 288 | .capability_index |
800 | 288 | .find_matching_workers(platform_properties, full_worker_logging); |
801 | 143 | if let Some(worker_id) = |
802 | 288 | self.find_available_worker(platform_properties, &candidates, full_worker_logging) |
803 | | { |
804 | 143 | return MatchOutcome::Matched(worker_id); |
805 | 145 | } |
806 | 145 | Some(candidates) |
807 | | } else { |
808 | 34 | if full_worker_logging { |
809 | 3 | info!("All workers are fully allocated"); |
810 | 31 | } |
811 | 34 | None |
812 | | }; |
813 | | |
814 | | // Nothing can take the action now. Only then is it worth working out |
815 | | // whether anything ever could, so a match pays nothing for this. |
816 | 179 | if let Some(reason115 ) = self.cached_static_verdict( |
817 | 179 | platform_properties, |
818 | 179 | candidates.as_ref(), |
819 | 179 | full_worker_logging, |
820 | 179 | ) { |
821 | | // The intrinsic verdict (nobody could run it) is cached by shape; |
822 | | // the census freshness check is applied live here, OUTSIDE the |
823 | | // cache, so a verdict computed while the census was trusted can |
824 | | // never outlive a staleness transition (nor a warm-up one). On a |
825 | | // shared db, trust peer-absence to fail only once the census is |
826 | | // warm and fresh; otherwise a peer we cannot fully account for |
827 | | // might run it, so wait rather than fail. |
828 | 115 | if !self.peer_census_trusted(now) { |
829 | 0 | if full_worker_logging { |
830 | 0 | info!("No idle worker matched; peer census not yet trusted, waiting"); |
831 | 0 | } |
832 | 0 | return MatchOutcome::WaitingForCapacity; |
833 | 115 | } |
834 | 115 | if full_worker_logging { |
835 | 63 | info!(%reason, "No connected worker can ever run this action"); |
836 | 52 | } |
837 | 115 | return MatchOutcome::Unsatisfiable(reason); |
838 | 64 | } |
839 | 64 | if full_worker_logging && candidates9 .is_some9 () { |
840 | 8 | warn!("No workers matched!"); |
841 | 56 | } |
842 | 64 | MatchOutcome::WaitingForCapacity |
843 | 360 | } |
844 | | |
845 | | /// The largest memory reservation any connected worker could hold. |
846 | | /// The largest memory any connected worker advertises under `property`, |
847 | | /// and that worker's whole CPU under `cpu_property` (`None` when the |
848 | | /// property is empty or the worker does not advertise it). Reserving |
849 | | /// both is what runs an action alone there. |
850 | 14 | fn fleet_largest_worker(&self, property: &str, cpu_property: &str) -> (u64, Option<u64>) { |
851 | 28 | let minimum14 = |worker: &Worker, name: &str| match worker |
852 | 28 | .total_platform_properties |
853 | 28 | .properties |
854 | 28 | .get(name) |
855 | | { |
856 | 14 | Some(PlatformPropertyValue::Minimum(value)) => Some(*value), |
857 | 14 | _ => None, |
858 | 28 | }; |
859 | | // A draining worker is leaving: escalating to it would strand the |
860 | | // action at a size the remaining fleet cannot serve. |
861 | 14 | self.workers |
862 | 14 | .iter() |
863 | 14 | .filter(|(_, worker)| !worker.is_draining) |
864 | 14 | .filter_map(|(_, worker)| { |
865 | 14 | minimum(worker, property).map(|memory_kb| { |
866 | 14 | let cpu = if cpu_property.is_empty() { |
867 | 0 | None |
868 | | } else { |
869 | 14 | minimum(worker, cpu_property) |
870 | | }; |
871 | 14 | (memory_kb, cpu) |
872 | 14 | }) |
873 | 14 | }) |
874 | 14 | .max_by_key(|(memory_kb, _)| *memory_kb) |
875 | 14 | .unwrap_or((0, None)) |
876 | 14 | } |
877 | | |
878 | | /// Turns a result that the worker's last usage report says was a memory |
879 | | /// kill into a requeue with a larger reservation, when escalation is |
880 | | /// configured and there is room to grow. Returns the update to apply |
881 | | /// and whether it was escalated. |
882 | 46 | fn maybe_escalate( |
883 | 46 | &self, |
884 | 46 | worker_id: &WorkerId, |
885 | 46 | operation_id: &OperationId, |
886 | 46 | update: UpdateOperationType, |
887 | 46 | ) -> (UpdateOperationType, bool) { |
888 | 46 | let Some(policy14 ) = &self.memory_escalation else { |
889 | 32 | return (update, false); |
890 | | }; |
891 | 14 | let UpdateOperationType::UpdateWithActionStage(ActionStage::Completed(result)) = &update |
892 | | else { |
893 | 0 | return (update, false); |
894 | | }; |
895 | 14 | let Some(pending) = self |
896 | 14 | .workers |
897 | 14 | .peek(worker_id) |
898 | 14 | .and_then(|worker| worker.running_action_infos.get(operation_id)) |
899 | | else { |
900 | 0 | return (update, false); |
901 | | }; |
902 | 14 | let Some(usage) = pending.last_usage.as_ref() else { |
903 | 0 | return (update, false); |
904 | | }; |
905 | 14 | if usage.outcome != ResourceOutcome::KilledMemory as i32 { |
906 | 0 | return (update, false); |
907 | 14 | } |
908 | | // What it ran under: the worker's echo, else what the scheduler sent. |
909 | 14 | let reserved_kb = usage |
910 | 14 | .reserved |
911 | 14 | .map(|r| r.memory_kb) |
912 | 14 | .filter(|kb| *kb > 0) |
913 | 14 | .or_else(|| {0 |
914 | 0 | match pending |
915 | 0 | .action_info |
916 | 0 | .platform_properties |
917 | 0 | .properties |
918 | 0 | .get(&policy.property) |
919 | | { |
920 | 0 | Some(PlatformPropertyValue::Minimum(kb)) => Some(*kb), |
921 | 0 | _ => None, |
922 | | } |
923 | 0 | }) |
924 | 14 | .unwrap_or(0); |
925 | 14 | let (fleet_memory_kb, fleet_cpu) = |
926 | 14 | self.fleet_largest_worker(&policy.property, &policy.cpu_property); |
927 | | // `max_kb` caps the fleet's ceiling; it cannot raise it past what a |
928 | | // worker declares, or the escalation lands where nothing can run. |
929 | 14 | let ceiling_kb = if policy.max_kb > 0 { |
930 | 0 | fleet_memory_kb.min(policy.max_kb) |
931 | | } else { |
932 | 14 | fleet_memory_kb |
933 | | }; |
934 | 14 | if ceiling_kb == 0 { |
935 | 0 | return (update, false); |
936 | 14 | } |
937 | | // The last step reserves the largest worker whole: its memory and, |
938 | | // when the fleet advertises one, its CPU, so nothing shares the |
939 | | // worker with the action. A kill there is the end: no worker in |
940 | | // the fleet can run the action, and the client hears exactly that. |
941 | 14 | let cpu_held = match pending |
942 | 14 | .action_info |
943 | 14 | .platform_properties |
944 | 14 | .properties |
945 | 14 | .get(&policy.cpu_property) |
946 | | { |
947 | 0 | Some(PlatformPropertyValue::Minimum(cores)) => *cores, |
948 | 14 | _ => 0, |
949 | | }; |
950 | 14 | let whole_worker_cpu = fleet_cpu.filter(|cores| *cores0 > 0); |
951 | 14 | let already_whole = |
952 | 14 | reserved_kb >= ceiling_kb && whole_worker_cpu2 .is_none_or2 (|cores| cpu_held0 >= cores0 ); |
953 | 14 | if already_whole { |
954 | 2 | warn!( |
955 | | ?worker_id, |
956 | | ?operation_id, |
957 | | reserved_kb, |
958 | | ceiling_kb, |
959 | | "Action killed for memory with the largest worker reserved whole; no worker in the fleet can run it" |
960 | | ); |
961 | 2 | let mut failed = result.clone(); |
962 | 2 | let no_worker = make_err!( |
963 | 2 | Code::FailedPrecondition, |
964 | | "Action was killed for memory at {reserved_kb} KiB with the largest worker in the fleet ({ceiling_kb} KiB) reserved for it alone; no worker can run it. It needs a bigger worker or less memory" |
965 | | ); |
966 | 2 | failed.error = Some(match result.error.clone() { |
967 | 2 | Some(worker_error) => no_worker.merge(worker_error), |
968 | 0 | None => no_worker, |
969 | | }); |
970 | 2 | return ( |
971 | 2 | UpdateOperationType::UpdateWithActionStage(ActionStage::Completed(failed)), |
972 | 2 | false, |
973 | 2 | ); |
974 | 12 | } |
975 | 12 | let base_kb = if reserved_kb > 0 { |
976 | 12 | reserved_kb |
977 | | } else { |
978 | 0 | usage.peak_memory_kb |
979 | | }; |
980 | 12 | if base_kb == 0 { |
981 | | // Nothing to grow from: no reservation and an unsampled kill. |
982 | | // Scaling zero would hand out the smallest steps the ladder or |
983 | | // the percent allow and burn the budget on them. |
984 | 0 | warn!( |
985 | | ?worker_id, |
986 | | ?operation_id, |
987 | | "Action killed for memory with no reservation and no sampled peak; not escalating" |
988 | | ); |
989 | 0 | return (update, false); |
990 | 12 | } |
991 | | // With a ladder the next class up; without one, scale. Past the |
992 | | // top class or the ceiling, the whole worker. |
993 | 12 | let next = if policy.ladder_kb.is_empty() { |
994 | 9 | Some( |
995 | 9 | base_kb |
996 | 9 | .saturating_mul(policy.percent) |
997 | 9 | .checked_div(100) |
998 | 9 | .unwrap_or(base_kb) |
999 | 9 | .max(base_kb.saturating_add(1)), |
1000 | 9 | ) |
1001 | | } else { |
1002 | 3 | policy |
1003 | 3 | .ladder_kb |
1004 | 3 | .iter() |
1005 | 3 | .copied() |
1006 | 9 | .filter3 (|kb| *kb > base_kb) |
1007 | 3 | .min() |
1008 | | }; |
1009 | 12 | let value = next.map_or(ceiling_kb, |kb| kb11 .min11 (ceiling_kb11 )); |
1010 | 12 | let cpu = if value >= ceiling_kb { |
1011 | 2 | whole_worker_cpu.map(|cores| (policy.cpu_property0 .clone0 (), cores0 )) |
1012 | | } else { |
1013 | 10 | None |
1014 | | }; |
1015 | 12 | let reason = result.error.clone().unwrap_or_else(|| {0 |
1016 | 0 | make_err!( |
1017 | 0 | Code::FailedPrecondition, |
1018 | | "Action was killed for memory at {} KiB", |
1019 | | usage.peak_memory_kb |
1020 | | ) |
1021 | 0 | }); |
1022 | 12 | warn!( |
1023 | | ?worker_id, |
1024 | | ?operation_id, |
1025 | | reserved_kb, |
1026 | | peak_memory_kb = usage.peak_memory_kb, |
1027 | | escalated_kb = value, |
1028 | | ceiling_kb, |
1029 | | property = %policy.property, |
1030 | | "Action killed for memory; requeuing with a larger reservation" |
1031 | | ); |
1032 | 12 | ( |
1033 | 12 | UpdateOperationType::UpdateWithEscalation(Escalation { |
1034 | 12 | property: policy.property.clone(), |
1035 | 12 | value, |
1036 | 12 | reason, |
1037 | 12 | cpu, |
1038 | 12 | }), |
1039 | 12 | true, |
1040 | 12 | ) |
1041 | 46 | } |
1042 | | |
1043 | 54 | async fn update_action( |
1044 | 54 | &mut self, |
1045 | 54 | worker_id: &WorkerId, |
1046 | 54 | operation_id: &OperationId, |
1047 | 54 | update: UpdateOperationType, |
1048 | 54 | ) -> Result<(), Error> { |
1049 | 54 | let worker52 = self.workers.get_mut(worker_id).err_tip(|| {2 |
1050 | 2 | format!("Worker {worker_id} does not exist in SimpleScheduler::update_action") |
1051 | 2 | })?; |
1052 | | |
1053 | | // Ensure the worker is supposed to be running the operation. A |
1054 | | // result for something this worker no longer holds is stale, not |
1055 | | // rogue: the operation was requeued after a timeout or a kill and |
1056 | | // the worker finished it anyway. Refuse it and keep the worker; |
1057 | | // evicting it here requeued every other action it held for one |
1058 | | // late message. |
1059 | 52 | if !worker.running_action_infos.contains_key(operation_id) { |
1060 | 1 | warn!( |
1061 | | %operation_id, |
1062 | | ?worker_id, |
1063 | 1 | running_actions = worker.running_action_infos.len(), |
1064 | | "Dropping update for an operation the worker is not running" |
1065 | | ); |
1066 | 1 | return Err(make_err!( |
1067 | 1 | Code::Internal, |
1068 | 1 | "Operation {operation_id} should not be running on worker {worker_id} in SimpleScheduler::update_action" |
1069 | 1 | )); |
1070 | 51 | } |
1071 | | |
1072 | 51 | let (is_finished49 , due_to_backpressure49 ) = match &update { |
1073 | 20 | UpdateOperationType::UpdateWithActionStage(action_stage) => { |
1074 | 20 | (action_stage.is_finished(), false) |
1075 | | } |
1076 | 0 | UpdateOperationType::KeepAlive => (false, false), |
1077 | 7 | UpdateOperationType::UpdateWithError(err) => { |
1078 | 7 | (true, err.code == Code::ResourceExhausted) |
1079 | | } |
1080 | | // A decline pauses like backpressure: the worker said it has no |
1081 | | // room, so it gets nothing more until it says otherwise. |
1082 | 8 | UpdateOperationType::UpdateWithDecline(_) => (true, true), |
1083 | | UpdateOperationType::UpdateWithDisconnect |
1084 | 14 | | UpdateOperationType::UpdateWithEscalation(_) => (true, false), |
1085 | | UpdateOperationType::ExecutionComplete => { |
1086 | | // The process has exited but the action is still resident on |
1087 | | // the worker until its result arrives, so nothing is released |
1088 | | // here; `complete_action` returns the budget and the slot |
1089 | | // together. |
1090 | 2 | return Ok(()); |
1091 | | } |
1092 | | }; |
1093 | | |
1094 | | // Update the operation in the worker state manager. |
1095 | 49 | let update_operation_res48 = if worker.is_kill_requested(operation_id) { |
1096 | 1 | debug!( |
1097 | | %operation_id, |
1098 | | ?worker_id, |
1099 | | "Ignoring update for operation the worker was told to kill" |
1100 | | ); |
1101 | 1 | Ok(()) |
1102 | | } else { |
1103 | 48 | let update_operation_res47 = self |
1104 | 48 | .worker_state_manager |
1105 | 48 | .update_operation(operation_id, worker_id, update) |
1106 | 48 | .await |
1107 | 47 | .err_tip(|| "in update_operation on SimpleScheduler::update_action"); |
1108 | 47 | if let Err(err1 ) = &update_operation_res { |
1109 | 1 | error!( |
1110 | | %operation_id, |
1111 | | ?worker_id, |
1112 | | ?err, |
1113 | | "Failed to update_operation on update_action" |
1114 | | ); |
1115 | 46 | } |
1116 | 47 | update_operation_res |
1117 | | }; |
1118 | | |
1119 | 48 | if !is_finished { |
1120 | 0 | return update_operation_res; |
1121 | 48 | } |
1122 | | // The worker is done with this action even if the state-manager update |
1123 | | // failed (e.g. the operation was already torn down after its clients |
1124 | | // timed out). The worker bookkeeping below must still run, or the |
1125 | | // worker's platform properties leak until it can never match again. |
1126 | | |
1127 | | // Clear this action from the current worker if finished. |
1128 | 48 | let complete_action_res = { |
1129 | | // Note: We need to run this before dealing with backpressure logic. |
1130 | 48 | let was_paused = worker.is_paused; |
1131 | 48 | let complete_action_res = worker.complete_action(operation_id); |
1132 | | |
1133 | | // Backpressure pauses the worker even when it holds nothing |
1134 | | // else: an idle worker that refuses work and is not paused is |
1135 | | // offered the same action again at once, and the pair spin |
1136 | | // until something changes. The next keepalive clears it. |
1137 | 48 | if due_to_backpressure || (!worker.can_accept_work()39 && worker0 .has_actions0 ()) { |
1138 | 9 | worker.is_paused = true; |
1139 | 39 | } |
1140 | | // complete_action clears is_paused on its way through, so compare |
1141 | | // the state either side of it rather than testing the flag after. |
1142 | | // Testing afterwards counts a re-pause every time and never |
1143 | | // unwinds, which leaves the gauge climbing forever. |
1144 | 48 | if was_paused != worker.is_paused { |
1145 | 9 | record_worker_state("paused", worker.is_paused); |
1146 | 39 | } |
1147 | 48 | complete_action_res |
1148 | | }; |
1149 | | |
1150 | 48 | self.capacity_changed(); |
1151 | | |
1152 | 48 | update_operation_res.merge(complete_action_res) |
1153 | 53 | } |
1154 | | |
1155 | | /// Notifies the specified worker to run the given action and handles errors by evicting |
1156 | | /// the worker if the notification fails. |
1157 | 158 | async fn worker_notify_run_action( |
1158 | 158 | &mut self, |
1159 | 158 | worker_id: WorkerId, |
1160 | 158 | operation_id: OperationId, |
1161 | 158 | action_info: ActionInfoWithProps, |
1162 | 158 | dispatched_at: WorkerTimestamp, |
1163 | 158 | ) -> Result<(), Error> { |
1164 | 158 | if let Some(worker) = self.workers.get_mut(&worker_id) { |
1165 | 158 | let notify_worker_result = worker |
1166 | 158 | .notify_update(WorkerUpdate::RunAction(Box::new(( |
1167 | 158 | operation_id.clone(), |
1168 | 158 | action_info.clone(), |
1169 | 158 | dispatched_at, |
1170 | 158 | )))) |
1171 | 158 | .await; |
1172 | | |
1173 | 158 | if let Err(notify_worker_result2 ) = notify_worker_result { |
1174 | 2 | if notify_worker_result.code == Code::ResourceExhausted { |
1175 | | // The worker's queue is full: it has stopped reading, or |
1176 | | // it is far behind. Nothing was charged; the action goes |
1177 | | // back untried and the worker waits for its next |
1178 | | // keepalive before it is offered anything else. |
1179 | 1 | warn!( |
1180 | | ?worker_id, |
1181 | | %operation_id, |
1182 | | "Worker channel full, requeuing dispatch and pausing the worker" |
1183 | | ); |
1184 | 1 | record_dispatch_requeue("channel_full"); |
1185 | 1 | if !worker.is_paused { |
1186 | 1 | worker.is_paused = true; |
1187 | 1 | record_worker_state("paused", true); |
1188 | 1 | }0 |
1189 | 1 | self.worker_change_notify.notify_one(); |
1190 | 1 | return self |
1191 | 1 | .worker_state_manager |
1192 | 1 | .update_operation( |
1193 | 1 | &operation_id, |
1194 | 1 | &worker_id, |
1195 | 1 | UpdateOperationType::UpdateWithDecline(Decline { |
1196 | 1 | reason: "channel_full".to_string(), |
1197 | 1 | }), |
1198 | 1 | ) |
1199 | 1 | .await |
1200 | 1 | .err_tip(|| "requeuing a dispatch the worker's channel refused"); |
1201 | 1 | } |
1202 | 1 | warn!( |
1203 | | ?worker_id, |
1204 | | ?action_info, |
1205 | | ?notify_worker_result, |
1206 | | "Worker command failed, removing worker", |
1207 | | ); |
1208 | | |
1209 | | // A slightly nasty way of figuring out that the worker disconnected |
1210 | | // from send_msg_to_worker without introducing complexity to the |
1211 | | // code path from here to there. |
1212 | 1 | let is_disconnect = notify_worker_result.code == Code::Internal |
1213 | 1 | && notify_worker_result.messages.len() == 1 |
1214 | 0 | && notify_worker_result.messages[0] == "Worker Disconnected"; |
1215 | | |
1216 | 1 | let err = make_err!( |
1217 | 1 | Code::Internal, |
1218 | | "Worker command failed, removing worker {worker_id} -- {notify_worker_result:?}", |
1219 | | ); |
1220 | | |
1221 | 1 | let reason = if is_disconnect { |
1222 | 0 | WorkerDisconnectReason::Disconnected |
1223 | | } else { |
1224 | 1 | WorkerDisconnectReason::Error |
1225 | | }; |
1226 | 1 | let evicted = self |
1227 | 1 | .immediate_evict_worker(&worker_id, err.clone(), is_disconnect, reason) |
1228 | 1 | .await; |
1229 | | // The dispatch was never charged to the worker (the send |
1230 | | // comes first), so the eviction did not see this operation; |
1231 | | // send it back to the queue here, untried: nothing ran, so |
1232 | | // no attempt is counted against it. |
1233 | 1 | let requeued = self |
1234 | 1 | .worker_state_manager |
1235 | 1 | .update_operation( |
1236 | 1 | &operation_id, |
1237 | 1 | &worker_id, |
1238 | 1 | UpdateOperationType::UpdateWithDecline(Decline { |
1239 | 1 | reason: "worker_disconnected".to_string(), |
1240 | 1 | }), |
1241 | 1 | ) |
1242 | 1 | .await; |
1243 | 1 | return Result::<(), _>::Err(err).merge(evicted).merge(requeued); |
1244 | 156 | } |
1245 | 156 | Ok(()) |
1246 | | } else { |
1247 | 0 | warn!( |
1248 | | ?worker_id, |
1249 | | %operation_id, |
1250 | | ?action_info, |
1251 | | "Worker not found in worker map in worker_notify_run_action" |
1252 | | ); |
1253 | | // Ensure the operation is put back to queued state. |
1254 | 0 | self.worker_state_manager |
1255 | 0 | .update_operation( |
1256 | 0 | &operation_id, |
1257 | 0 | &worker_id, |
1258 | 0 | UpdateOperationType::UpdateWithDisconnect, |
1259 | 0 | ) |
1260 | 0 | .await |
1261 | | } |
1262 | 158 | } |
1263 | | |
1264 | | /// Tells the worker to kill an operation it is still running but the |
1265 | | /// state manager no longer has executing on it. A worker that cannot be |
1266 | | /// reached is evicted, the same as for a failed run request. |
1267 | | /// The worker acknowledged a dispatch. A late acknowledgement for an |
1268 | | /// operation no longer on the worker is nothing to act on. |
1269 | 4 | fn dispatch_accepted(&mut self, worker_id: &WorkerId, operation_id: &OperationId) { |
1270 | 4 | let Some(worker) = self.workers.get_mut(worker_id) else { |
1271 | 0 | return; |
1272 | | }; |
1273 | 4 | if worker.mark_accepted(operation_id) { |
1274 | 3 | return; |
1275 | 1 | } |
1276 | | // Messages from a worker arrive in order and the acknowledgement |
1277 | | // comes before the run, so an acknowledgement for an operation not |
1278 | | // on this worker means the sweep took the dispatch back already: the |
1279 | | // worker is about to run an action that now belongs elsewhere. Tell |
1280 | | // it to stop now, not at the next revoked-operation sweep. |
1281 | 1 | info!( |
1282 | | ?worker_id, |
1283 | | %operation_id, |
1284 | | "Acknowledgement for a dispatch already taken back; telling the worker to stop it" |
1285 | | ); |
1286 | 1 | if let Err(err0 ) = worker.kill_unknown_operation(operation_id) { |
1287 | 0 | warn!(?worker_id, %operation_id, ?err, "Could not tell the worker to stop the operation"); |
1288 | 1 | } |
1289 | 4 | } |
1290 | | |
1291 | | /// The worker declined a dispatch: the ledger is restored, the action |
1292 | | /// requeued untried, and the worker paused; for a decline for load the |
1293 | | /// pause holds until the worker reports that much free memory. |
1294 | | /// Returns whether there was a dispatch to take back. A decline for an |
1295 | | /// operation the worker no longer holds (it finished, or the sweep |
1296 | | /// already requeued it) is nothing to act on, like a late |
1297 | | /// acknowledgement, and must not pause the worker. |
1298 | 9 | async fn dispatch_declined( |
1299 | 9 | &mut self, |
1300 | 9 | worker_id: &WorkerId, |
1301 | 9 | operation_id: &OperationId, |
1302 | 9 | reason: String, |
1303 | 9 | needs_kb: Option<u64>, |
1304 | 9 | ) -> Result<bool, Error> { |
1305 | 9 | let holds_it = self |
1306 | 9 | .workers |
1307 | 9 | .peek(worker_id) |
1308 | 9 | .is_some_and(|worker| worker.running_action_infos.contains_key(operation_id)); |
1309 | 9 | if !holds_it { |
1310 | 1 | debug!(?worker_id, %operation_id, %reason, "Decline for an operation not on this worker"); |
1311 | 1 | return Ok(false); |
1312 | 8 | } |
1313 | 8 | info!(?worker_id, %operation_id, %reason, ?needs_kb, "Worker declined dispatch"); |
1314 | 8 | self.update_action( |
1315 | 8 | worker_id, |
1316 | 8 | operation_id, |
1317 | 8 | UpdateOperationType::UpdateWithDecline(Decline { reason }), |
1318 | 8 | ) |
1319 | 8 | .await?0 ; |
1320 | 8 | if let Some(worker) = self.workers.get_mut(worker_id) { |
1321 | 8 | if !worker.is_paused { |
1322 | 0 | worker.is_paused = true; |
1323 | 0 | record_worker_state("paused", true); |
1324 | 8 | } |
1325 | | // A worker that admits when idle declined for load because it |
1326 | | // holds other work, whatever this scheduler last saw it hold: it |
1327 | | // waits for room like any busy worker, and if this scheduler sees |
1328 | | // it holding nothing, its pause lifts on the next keepalive. |
1329 | | // One that does not (an older worker, or one with no cgroup |
1330 | | // limit) and declines while holding nothing here may not have |
1331 | | // more room however long it waits, so a pause until a keepalive |
1332 | | // with enough free could hold it for good. Its pause lifts on |
1333 | | // the next keepalive instead, and it is not offered that much |
1334 | | // again until it reports that much free, or the action would |
1335 | | // bounce between it and the queue untried. |
1336 | 8 | if !worker.admits_when_idle && worker.running_action_infos6 .is_empty6 () { |
1337 | 3 | worker.pause_needs_kb = None; |
1338 | 3 | if let Some(needs_kb1 ) = needs_kb { |
1339 | | worker.idle_declined_kb = Some( |
1340 | 1 | worker |
1341 | 1 | .idle_declined_kb |
1342 | 1 | .map_or(needs_kb, |declined_kb| declined_kb0 .min0 (needs_kb0 )), |
1343 | | ); |
1344 | 2 | } |
1345 | 5 | } else { |
1346 | 5 | worker.pause_needs_kb = needs_kb; |
1347 | 5 | } |
1348 | 0 | } |
1349 | 8 | Ok(true) |
1350 | 9 | } |
1351 | | |
1352 | | /// Dispatches this worker has not acknowledged within the timeout, as |
1353 | | /// of its latest keepalive. |
1354 | 8 | fn unacknowledged_dispatches( |
1355 | 8 | &self, |
1356 | 8 | worker_id: &WorkerId, |
1357 | 8 | timestamp: WorkerTimestamp, |
1358 | 8 | timeout_s: u64, |
1359 | 8 | ) -> Vec<OperationId> { |
1360 | 8 | self.workers |
1361 | 8 | .peek(worker_id) |
1362 | 8 | .map(|worker| worker.unacknowledged(timestamp.saturating_sub(timeout_s))) |
1363 | 8 | .unwrap_or_default() |
1364 | 8 | } |
1365 | | |
1366 | 5 | async fn worker_notify_kill_operation( |
1367 | 5 | &mut self, |
1368 | 5 | worker_id: &WorkerId, |
1369 | 5 | operation_id: OperationId, |
1370 | 5 | ) -> Result<(), Error> { |
1371 | 5 | let Some(worker) = self.workers.get_mut(worker_id) else { |
1372 | | // Gone between the snapshot and now; its actions were requeued. |
1373 | 0 | return Ok(()); |
1374 | | }; |
1375 | | // Already told, or finished in the meantime; nothing more to send. |
1376 | 5 | if !worker.running_action_infos.contains_key(&operation_id) |
1377 | 5 | || worker.is_kill_requested(&operation_id) |
1378 | | { |
1379 | 0 | return Ok(()); |
1380 | 5 | } |
1381 | 5 | info!( |
1382 | | ?worker_id, |
1383 | | %operation_id, |
1384 | | "Killing operation the state manager no longer has executing on this worker" |
1385 | | ); |
1386 | 5 | if let Err(err2 ) = worker |
1387 | 5 | .notify_update(WorkerUpdate::KillOperation(operation_id.clone())) |
1388 | 5 | .await |
1389 | | { |
1390 | | // A full channel is a worker that is behind, not one that is |
1391 | | // gone. It keeps its place; the request is forgotten so the |
1392 | | // next sweep sends the kill again. |
1393 | 2 | if err.code == Code::ResourceExhausted { |
1394 | 1 | worker.clear_kill_request(&operation_id); |
1395 | 1 | warn!( |
1396 | | ?worker_id, |
1397 | | %operation_id, |
1398 | | "Worker channel full, kill deferred to the next sweep" |
1399 | | ); |
1400 | 1 | return Ok(()); |
1401 | 1 | } |
1402 | 1 | warn!( |
1403 | | ?worker_id, |
1404 | | %operation_id, |
1405 | | ?err, |
1406 | | "Worker command failed, removing worker" |
1407 | | ); |
1408 | 1 | let err = make_err!( |
1409 | 1 | Code::Internal, |
1410 | | "Worker command failed, removing worker {worker_id} -- {err:?}", |
1411 | | ); |
1412 | 1 | return Result::<(), _>::Err(err.clone()).merge( |
1413 | 1 | self.immediate_evict_worker( |
1414 | 1 | worker_id, |
1415 | 1 | err, |
1416 | 1 | true, |
1417 | 1 | WorkerDisconnectReason::Disconnected, |
1418 | 1 | ) |
1419 | 1 | .await, |
1420 | | ); |
1421 | 3 | } |
1422 | 3 | Ok(()) |
1423 | 5 | } |
1424 | | |
1425 | | /// Evicts the worker from the pool and puts items back into the queue if anything was being executed on it. |
1426 | 23 | async fn immediate_evict_worker( |
1427 | 23 | &mut self, |
1428 | 23 | worker_id: &WorkerId, |
1429 | 23 | err: Error, |
1430 | 23 | is_disconnect: bool, |
1431 | 23 | reason: WorkerDisconnectReason, |
1432 | 23 | ) -> Result<(), Error> { |
1433 | 23 | let mut result = Ok(()); |
1434 | 23 | if let Some(mut worker) = self.remove_worker(worker_id) { |
1435 | | // Log every eviction here rather than in each caller, so a worker |
1436 | | // can never leave the pool unexplained. Without this, a worker that |
1437 | | // vanished mid-build left nothing on the scheduler side to |
1438 | | // attribute it to, and the only visible symptom was the worker |
1439 | | // reconnecting with a fresh id. |
1440 | 23 | info!( |
1441 | | ?worker_id, |
1442 | | is_disconnect, |
1443 | 23 | running_actions = worker.running_action_infos.len(), |
1444 | 23 | reason = %err.message_string(), |
1445 | | "Evicting worker from pool" |
1446 | | ); |
1447 | 23 | record_worker_disconnected(reason, worker.is_draining, worker.is_paused); |
1448 | | // We don't care if we fail to send message to worker, this is only a best attempt. |
1449 | 23 | drop(worker.notify_update(WorkerUpdate::Disconnect).await); |
1450 | | // Whatever took the worker away (its stream ended, it timed |
1451 | | // out, a command to it failed, an operator removed it), the |
1452 | | // action running on it did nothing wrong: it is a worker loss |
1453 | | // with its own budget, not an attempt the action spent. |
1454 | 23 | let update = UpdateOperationType::UpdateWithDisconnect; |
1455 | 23 | for (operation_id19 , _) in worker.running_action_infos.drain() { |
1456 | 19 | result = result.merge( |
1457 | 19 | self.worker_state_manager |
1458 | 19 | .update_operation(&operation_id, worker_id, update.clone()) |
1459 | 19 | .await, |
1460 | | ); |
1461 | | } |
1462 | 0 | } |
1463 | | // Note: Calling this many time is very cheap, it'll only trigger `do_try_match` once. |
1464 | | // TODO(palfrey) This should be moved to inside the Workers struct. |
1465 | 23 | self.capacity_changed(); |
1466 | 23 | result |
1467 | 23 | } |
1468 | | } |
1469 | | |
1470 | | #[derive(Debug, MetricsComponent)] |
1471 | | pub struct ApiWorkerScheduler { |
1472 | | #[metric] |
1473 | | inner: Mutex<ApiWorkerSchedulerImpl>, |
1474 | | /// See `ApiWorkerSchedulerImpl::capacity_generation`; read here without |
1475 | | /// the lock. |
1476 | | capacity_generation: Arc<AtomicU64>, |
1477 | | #[metric(group = "platform_property_manager")] |
1478 | | platform_property_manager: Arc<PlatformPropertyManager>, |
1479 | | |
1480 | | #[metric( |
1481 | | help = "Timeout of how long to evict workers if no response in this given amount of time in seconds." |
1482 | | )] |
1483 | | worker_timeout_s: u64, |
1484 | | #[metric( |
1485 | | help = "How long a sent kill may go unacknowledged before the worker is evicted, in seconds." |
1486 | | )] |
1487 | | unacknowledged_kill_timeout_s: u64, |
1488 | | #[metric( |
1489 | | help = "How long a dispatch may go unacknowledged before it is requeued untried, in seconds; 0 is off." |
1490 | | )] |
1491 | | dispatch_ack_timeout_s: u64, |
1492 | | /// Shared worker registry for checking worker liveness. |
1493 | | worker_registry: SharedWorkerRegistry, |
1494 | | |
1495 | | /// Performance metrics for observability. |
1496 | | metrics: Arc<SchedulerMetrics>, |
1497 | | |
1498 | | /// Channel for publishing origin events such as worker-observed action |
1499 | | /// resource usage. `None` when origin events are disabled. |
1500 | | maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>, |
1501 | | |
1502 | | /// Woken when a worker joins or leaves this scheduler. |
1503 | | local_fleet_change_notify: Arc<Notify>, |
1504 | | } |
1505 | | |
1506 | | impl ApiWorkerScheduler { |
1507 | | #[expect(clippy::too_many_arguments)] |
1508 | 116 | pub fn new( |
1509 | 116 | worker_state_manager: Arc<dyn WorkerStateManager>, |
1510 | 116 | platform_property_manager: Arc<PlatformPropertyManager>, |
1511 | 116 | allocation_strategy: WorkerAllocationStrategy, |
1512 | 116 | live_memory_veto: Option<String>, |
1513 | 116 | memory_escalation: Option<MemoryEscalationSpec>, |
1514 | 116 | worker_change_notify: Arc<Notify>, |
1515 | 116 | worker_timeout_s: u64, |
1516 | 116 | unacknowledged_kill_timeout_s: u64, |
1517 | 116 | dispatch_ack_timeout_s: u64, |
1518 | 116 | worker_registry: SharedWorkerRegistry, |
1519 | 116 | maybe_origin_event_tx: Option<mpsc::Sender<OriginEvent>>, |
1520 | 116 | has_peers: bool, |
1521 | 116 | record_ttl: Duration, |
1522 | 116 | ) -> Arc<Self> { |
1523 | 116 | debug_assert!( |
1524 | 0 | !record_ttl.is_zero(), |
1525 | | "record_ttl must be non-zero; a zero TTL degenerates the census warm-up/staleness checks" |
1526 | | ); |
1527 | 116 | let local_fleet_change_notify = Arc::new(Notify::new()); |
1528 | 116 | let capacity_generation = Arc::new(AtomicU64::new(0)); |
1529 | 116 | Arc::new(Self { |
1530 | 116 | inner: Mutex::new(ApiWorkerSchedulerImpl { |
1531 | 116 | workers: Workers(LruCache::unbounded()), |
1532 | 116 | worker_state_manager, |
1533 | 116 | allocation_strategy, |
1534 | 116 | live_memory_veto, |
1535 | 116 | memory_escalation, |
1536 | 116 | worker_change_notify, |
1537 | 116 | capacity_generation: capacity_generation.clone(), |
1538 | 116 | worker_registry: worker_registry.clone(), |
1539 | 116 | shutting_down: false, |
1540 | 116 | capability_index: WorkerCapabilityIndex::new(), |
1541 | 116 | fleet_generation: 0, |
1542 | 116 | fleet_shape_set: BTreeSet::new(), |
1543 | 116 | // Without peers there is nothing to wait for. |
1544 | 116 | peer_fleet: (!has_peers).then(Vec::new), |
1545 | 116 | shared: has_peers, |
1546 | 116 | record_ttl, |
1547 | 116 | peer_fleet_known_since: None, |
1548 | 116 | peer_fleet_last_refresh: None, |
1549 | 116 | local_fleet_change_notify: local_fleet_change_notify.clone(), |
1550 | 116 | static_verdicts: HashMap::new(), |
1551 | 116 | }), |
1552 | 116 | platform_property_manager, |
1553 | 116 | worker_timeout_s, |
1554 | 116 | unacknowledged_kill_timeout_s, |
1555 | 116 | dispatch_ack_timeout_s, |
1556 | 116 | worker_registry, |
1557 | 116 | metrics: Arc::new(SchedulerMetrics::default()), |
1558 | 116 | maybe_origin_event_tx, |
1559 | 116 | local_fleet_change_notify, |
1560 | 116 | capacity_generation, |
1561 | 116 | }) |
1562 | 116 | } |
1563 | | |
1564 | | /// Whether no worker is connected here and, as far as a trusted census |
1565 | | /// says, to any peer either. False while the census cannot be trusted, |
1566 | | /// so a peer we cannot see is assumed to have workers. |
1567 | 10 | pub async fn no_worker_anywhere(&self, now: SystemTime) -> bool { |
1568 | 10 | let inner = self.inner.lock().await; |
1569 | 10 | inner.workers.is_empty() |
1570 | 7 | && inner.peer_census_trusted(now) |
1571 | 7 | && inner.peer_fleet.as_ref().is_some_and(Vec::is_empty) |
1572 | 10 | } |
1573 | | |
1574 | | /// The capacity generation as of now; see `find_worker_for_action_observed`. |
1575 | 627 | pub fn capacity_generation(&self) -> u64 { |
1576 | 627 | self.capacity_generation.load(Ordering::Relaxed) |
1577 | 627 | } |
1578 | | |
1579 | | /// Returns a reference to the worker registry. |
1580 | 0 | pub const fn worker_registry(&self) -> &SharedWorkerRegistry { |
1581 | 0 | &self.worker_registry |
1582 | 0 | } |
1583 | | |
1584 | | /// `dispatched_at` is the scheduler's clock at the send; the |
1585 | | /// acknowledgement timeout counts from it. |
1586 | 158 | pub async fn worker_notify_run_action( |
1587 | 158 | &self, |
1588 | 158 | worker_id: WorkerId, |
1589 | 158 | operation_id: OperationId, |
1590 | 158 | action_info: ActionInfoWithProps, |
1591 | 158 | dispatched_at: WorkerTimestamp, |
1592 | 158 | ) -> Result<(), Error> { |
1593 | 158 | self.metrics |
1594 | 158 | .actions_dispatched |
1595 | 158 | .fetch_add(1, Ordering::Relaxed); |
1596 | 158 | let mut inner = self.inner.lock().await; |
1597 | 158 | inner |
1598 | 158 | .worker_notify_run_action(worker_id, operation_id, action_info, dispatched_at) |
1599 | 158 | .await |
1600 | 158 | } |
1601 | | |
1602 | | /// A keepalive, or another message standing in for one. Only a |
1603 | | /// keepalive carries a load and lifts a pause; anything else refreshes |
1604 | | /// the timestamp and runs the acknowledgement sweep. |
1605 | 50 | async fn refresh_worker( |
1606 | 50 | &self, |
1607 | 50 | worker_id: &WorkerId, |
1608 | 50 | timestamp: WorkerTimestamp, |
1609 | 50 | load: Option<WorkerLoad>, |
1610 | 50 | keepalive: bool, |
1611 | 50 | ) -> Result<(), Error> { |
1612 | | { |
1613 | 50 | let mut inner = self.inner.lock().await; |
1614 | 50 | inner |
1615 | 50 | .refresh_lifetime(worker_id, timestamp, load, keepalive) |
1616 | 50 | .err_tip(|| "Error refreshing lifetime in worker_keep_alive_received()")?1 ; |
1617 | 49 | if self.dispatch_ack_timeout_s > 0 { |
1618 | 8 | let stale = inner.unacknowledged_dispatches( |
1619 | 8 | worker_id, |
1620 | 8 | timestamp, |
1621 | 8 | self.dispatch_ack_timeout_s, |
1622 | 8 | ); |
1623 | 8 | for operation_id2 in stale { |
1624 | 2 | warn!( |
1625 | | ?worker_id, |
1626 | | %operation_id, |
1627 | | timeout_s = self.dispatch_ack_timeout_s, |
1628 | | "Dispatch never acknowledged, requeuing untried" |
1629 | | ); |
1630 | 2 | match inner |
1631 | 2 | .dispatch_declined( |
1632 | 2 | worker_id, |
1633 | 2 | &operation_id, |
1634 | 2 | "unacknowledged".to_string(), |
1635 | 2 | None, |
1636 | 2 | ) |
1637 | 2 | .await |
1638 | | { |
1639 | 2 | Ok(true) => { |
1640 | 2 | self.metrics |
1641 | 2 | .dispatches_unacknowledged |
1642 | 2 | .fetch_add(1, Ordering::Relaxed); |
1643 | 2 | record_dispatch_requeue("unacknowledged"); |
1644 | 2 | } |
1645 | 0 | Ok(false) => {} |
1646 | 0 | Err(err) => { |
1647 | 0 | warn!(?worker_id, %operation_id, ?err, "Could not requeue unacknowledged dispatch"); |
1648 | | } |
1649 | | } |
1650 | | } |
1651 | 41 | } |
1652 | | } |
1653 | 49 | let now = UNIX_EPOCH + Duration::from_secs(timestamp); |
1654 | 49 | self.worker_registry |
1655 | 49 | .update_worker_heartbeat(worker_id, now) |
1656 | 49 | .await; |
1657 | 49 | Ok(()) |
1658 | 50 | } |
1659 | | |
1660 | 17 | pub async fn running_action_info( |
1661 | 17 | &self, |
1662 | 17 | worker_id: &WorkerId, |
1663 | 17 | operation_id: &OperationId, |
1664 | 17 | ) -> Option<ActionInfoWithProps> { |
1665 | 17 | let inner = self.inner.lock().await; |
1666 | 17 | inner |
1667 | 17 | .workers |
1668 | 17 | .peek(worker_id) |
1669 | 17 | .and_then(|worker| worker.running_action_infos.get(operation_id)) |
1670 | 17 | .map(|pending_action_info| pending_action_info.action_info.clone()) |
1671 | 17 | } |
1672 | | |
1673 | | /// Returns the scheduler metrics for observability. |
1674 | | #[must_use] |
1675 | 0 | pub const fn get_metrics(&self) -> &Arc<SchedulerMetrics> { |
1676 | 0 | &self.metrics |
1677 | 0 | } |
1678 | | |
1679 | | /// A counter that changes every time a worker joins or leaves. |
1680 | 603 | pub async fn fleet_generation(&self) -> u64 { |
1681 | 603 | self.inner.lock().await.fleet_generation |
1682 | 603 | } |
1683 | | |
1684 | | /// The distinct properties this scheduler's workers registered with. |
1685 | 53 | pub async fn fleet_shapes(&self) -> Vec<PlatformProperties> { |
1686 | 53 | self.inner.lock().await.fleet_shapes() |
1687 | 53 | } |
1688 | | |
1689 | | /// Records what the workers connected to peer schedulers can run, or |
1690 | | /// `None` when that is not known. |
1691 | 61 | pub async fn set_peer_fleet( |
1692 | 61 | &self, |
1693 | 61 | peer_fleet: Option<Vec<PlatformProperties>>, |
1694 | 61 | now: SystemTime, |
1695 | 61 | ) { |
1696 | 61 | self.inner.lock().await.set_peer_fleet(peer_fleet, now); |
1697 | 61 | } |
1698 | | |
1699 | | /// Woken when a worker joins or leaves this scheduler. |
1700 | | #[must_use] |
1701 | 100 | pub const fn local_fleet_change_notify(&self) -> &Arc<Notify> { |
1702 | 100 | &self.local_fleet_change_notify |
1703 | 100 | } |
1704 | | |
1705 | | /// Attempts to find a worker that is capable of running this action. |
1706 | | // TODO(palfrey) This algorithm is not very efficient. Simple testing using a tree-like |
1707 | | // structure showed worse performance on a 10_000 worker * 7 properties * 1000 queued tasks |
1708 | | // simulation of worst cases in a single threaded environment. |
1709 | 8 | pub async fn find_worker_for_action( |
1710 | 8 | &self, |
1711 | 8 | platform_properties: &PlatformProperties, |
1712 | 8 | full_worker_logging: bool, |
1713 | 8 | now: SystemTime, |
1714 | 8 | ) -> MatchOutcome { |
1715 | 8 | self.find_worker_for_action_observed(platform_properties, full_worker_logging, now) |
1716 | 8 | .await |
1717 | | .0 |
1718 | 8 | } |
1719 | | |
1720 | | /// `find_worker_for_action`, also returning the capacity generation read |
1721 | | /// under the same lock as the placement. A pass compares it with the one |
1722 | | /// it saw last: a change means a worker gained room since, and an older |
1723 | | /// action that found none earlier in the pass should be offered that room |
1724 | | /// before this one takes it. |
1725 | 360 | pub async fn find_worker_for_action_observed( |
1726 | 360 | &self, |
1727 | 360 | platform_properties: &PlatformProperties, |
1728 | 360 | full_worker_logging: bool, |
1729 | 360 | now: SystemTime, |
1730 | 360 | ) -> (MatchOutcome, u64) { |
1731 | 360 | let start = Instant::now(); |
1732 | 360 | self.metrics |
1733 | 360 | .find_worker_calls |
1734 | 360 | .fetch_add(1, Ordering::Relaxed); |
1735 | | |
1736 | 360 | let mut inner = self.inner.lock().await; |
1737 | 360 | let worker_count = inner.workers.len() as u64; |
1738 | 360 | let result = |
1739 | 360 | inner.inner_find_worker_for_action(platform_properties, full_worker_logging, now); |
1740 | 360 | let generation = inner.capacity_generation.load(Ordering::Relaxed); |
1741 | | |
1742 | | // Track workers iterated (worst case is all workers) |
1743 | 360 | self.metrics |
1744 | 360 | .workers_iterated |
1745 | 360 | .fetch_add(worker_count, Ordering::Relaxed); |
1746 | | |
1747 | 360 | if matches!217 (result, MatchOutcome::Matched(_)) { |
1748 | 143 | self.metrics |
1749 | 143 | .find_worker_hits |
1750 | 143 | .fetch_add(1, Ordering::Relaxed); |
1751 | 217 | } else { |
1752 | 217 | self.metrics |
1753 | 217 | .find_worker_misses |
1754 | 217 | .fetch_add(1, Ordering::Relaxed); |
1755 | 217 | } |
1756 | | |
1757 | | #[allow(clippy::cast_possible_truncation)] |
1758 | 360 | self.metrics |
1759 | 360 | .find_worker_time_ns |
1760 | 360 | .fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed); |
1761 | 360 | (result, generation) |
1762 | 360 | } |
1763 | | |
1764 | | /// Checks to see if the worker exists in the worker pool. Should only be used in unit tests. |
1765 | | #[must_use] |
1766 | 14 | pub async fn contains_worker_for_test(&self, worker_id: &WorkerId) -> bool { |
1767 | 14 | let inner = self.inner.lock().await; |
1768 | 14 | inner.workers.contains(worker_id) |
1769 | 14 | } |
1770 | | |
1771 | | /// A unit test function used to send the keep alive message to the worker from the server. |
1772 | 0 | pub async fn send_keep_alive_to_worker_for_test( |
1773 | 0 | &self, |
1774 | 0 | worker_id: &WorkerId, |
1775 | 1 | ) -> Result<(), Error> { |
1776 | 1 | let mut inner = self.inner.lock().await; |
1777 | 1 | let worker = inner.workers.get_mut(worker_id).ok_or_else(|| {0 |
1778 | 0 | make_input_err!("WorkerId '{}' does not exist in workers map", worker_id) |
1779 | 0 | })?; |
1780 | 1 | worker.keep_alive() |
1781 | 1 | } |
1782 | | } |
1783 | | |
1784 | | #[async_trait] |
1785 | | impl WorkerScheduler for ApiWorkerScheduler { |
1786 | 27 | fn get_platform_property_manager(&self) -> &PlatformPropertyManager { |
1787 | 27 | self.platform_property_manager.as_ref() |
1788 | 27 | } |
1789 | | |
1790 | | async fn record_action_resource_usage( |
1791 | | &self, |
1792 | | worker_id: &WorkerId, |
1793 | | operation_id: &OperationId, |
1794 | | mut resource_usage: ActionResourceUsage, |
1795 | 15 | ) -> Result<(), Error> { |
1796 | | // The worker API talks to this `ApiWorkerScheduler` (it is the |
1797 | | // `WorkerScheduler` returned by `SimpleScheduler::new`), so the |
1798 | | // resource-usage origin event must be published here. Previously the |
1799 | | // only override lived on `SimpleScheduler`, which this path never |
1800 | | // reaches, so the event was silently dropped by the trait's no-op |
1801 | | // default and `observed_worker_peak_memory_mib` was never recorded. |
1802 | | // Sampling is optional, so only record when the worker actually took a |
1803 | | // reading. A zero here means "not sampled", not "used no memory". |
1804 | | { |
1805 | | // The result follows this report; what it says decides whether |
1806 | | // that result is a kill to escalate. |
1807 | | let mut inner = self.inner.lock().await; |
1808 | | if let Some(pending) = inner |
1809 | | .workers |
1810 | | .get_mut(worker_id) |
1811 | 15 | .and_then(|worker| worker.running_action_infos.get_mut(operation_id)) |
1812 | | { |
1813 | | pending.last_usage = Some(resource_usage.clone()); |
1814 | | } |
1815 | | } |
1816 | | let maybe_action_info = self.running_action_info(worker_id, operation_id).await; |
1817 | | let action_mnemonic = maybe_action_info |
1818 | | .as_ref() |
1819 | 15 | .and_then(|action_info| action_info.origin_metadata.bazel_metadata.as_ref()) |
1820 | 1 | .map_or_else(String::new, |bazel_metadata| { |
1821 | 1 | bazel_metadata.action_mnemonic.clone() |
1822 | 1 | }); |
1823 | | |
1824 | | let outcome = |
1825 | | ResourceOutcome::try_from(resource_usage.outcome).unwrap_or(ResourceOutcome::Unknown); |
1826 | | if !matches!( |
1827 | | outcome, |
1828 | | ResourceOutcome::Unknown | ResourceOutcome::Completed |
1829 | | ) { |
1830 | | warn!( |
1831 | | ?worker_id, |
1832 | | ?operation_id, |
1833 | | outcome = outcome.as_str_name(), |
1834 | | enforced = resource_usage.enforced, |
1835 | | action_mnemonic, |
1836 | | peak_memory_kb = resource_usage.peak_memory_kb, |
1837 | | wall_time_ms = resource_usage.wall_time_ms, |
1838 | | reserved = ?resource_usage.reserved, |
1839 | | "Action ended by a kill" |
1840 | | ); |
1841 | | } |
1842 | | |
1843 | | if resource_usage.sampled { |
1844 | | if resource_usage.peak_memory_kb > 0 { |
1845 | | record_execution_peak_memory( |
1846 | | resource_usage.peak_memory_kb, |
1847 | | "", |
1848 | | &action_mnemonic, |
1849 | | outcome.as_str_name(), |
1850 | | ); |
1851 | | } |
1852 | | if resource_usage.cpu_time_ms > 0 { |
1853 | | record_execution_cpu_time( |
1854 | | resource_usage.cpu_time_ms, |
1855 | | "", |
1856 | | &action_mnemonic, |
1857 | | outcome.as_str_name(), |
1858 | | ); |
1859 | | } |
1860 | | } |
1861 | | |
1862 | | let Some(origin_event_tx) = self.maybe_origin_event_tx.as_ref() else { |
1863 | | return Ok(()); |
1864 | | }; |
1865 | | let Some(action_info) = maybe_action_info else { |
1866 | | return Ok(()); |
1867 | | }; |
1868 | | |
1869 | | if resource_usage.operation_id.is_empty() { |
1870 | | resource_usage.operation_id = operation_id.to_string(); |
1871 | | } |
1872 | | if resource_usage.worker_id.is_empty() { |
1873 | | resource_usage.worker_id = worker_id.to_string(); |
1874 | | } |
1875 | | |
1876 | | let event = Event { |
1877 | | event: Some(event::Event::Response(ResponseEvent { |
1878 | | event: Some(response_event::Event::ActionResourceUsage(resource_usage)), |
1879 | | })), |
1880 | | }; |
1881 | | let origin_event = OriginEvent { |
1882 | | version: 0, |
1883 | | event_id: Uuid::now_v6(&get_node_id(Some(&event))) |
1884 | | .hyphenated() |
1885 | | .to_string(), |
1886 | | parent_event_id: action_info |
1887 | | .scheduler_start_execute_event_id |
1888 | | .clone() |
1889 | | .unwrap_or_default(), |
1890 | | bazel_request_metadata: action_info.origin_metadata.bazel_metadata.clone(), |
1891 | | identity: action_info.origin_metadata.identity, |
1892 | | event: Some(event), |
1893 | | }; |
1894 | | // Awaited send (not try_send): apply backpressure when the publisher |
1895 | | // queue is full instead of silently dropping the resource-usage event, |
1896 | | // which is what drives action-level resource sizing in the UI. |
1897 | | if let Err(err) = origin_event_tx.send(origin_event).await { |
1898 | | warn!(?err, "Failed to publish action resource usage origin event"); |
1899 | | } |
1900 | | Ok(()) |
1901 | 15 | } |
1902 | | |
1903 | 136 | async fn add_worker(&self, worker: Worker) -> Result<(), Error> { |
1904 | | let worker_id = worker.id.clone(); |
1905 | | let worker_timestamp = worker.last_update_timestamp; |
1906 | | let mut inner = self.inner.lock().await; |
1907 | | if inner.shutting_down { |
1908 | | warn!("Rejected worker add during shutdown: {}", worker_id); |
1909 | | return Err(make_err!( |
1910 | | Code::Unavailable, |
1911 | | "Received request to add worker while shutting down" |
1912 | | )); |
1913 | | } |
1914 | | let result = inner |
1915 | | .add_worker(worker) |
1916 | | .err_tip(|| "Error while adding worker, removing from pool"); |
1917 | | if let Err(err) = result { |
1918 | | return Result::<(), _>::Err(err.clone()).merge( |
1919 | | inner |
1920 | | .immediate_evict_worker(&worker_id, err, false, WorkerDisconnectReason::Error) |
1921 | | .await, |
1922 | | ); |
1923 | | } |
1924 | | |
1925 | | let now = UNIX_EPOCH + Duration::from_secs(worker_timestamp); |
1926 | | self.worker_registry.register_worker(&worker_id, now).await; |
1927 | | |
1928 | | self.metrics.workers_added.fetch_add(1, Ordering::Relaxed); |
1929 | | Ok(()) |
1930 | 136 | } |
1931 | | |
1932 | | async fn update_action( |
1933 | | &self, |
1934 | | worker_id: &WorkerId, |
1935 | | operation_id: &OperationId, |
1936 | | update: UpdateOperationType, |
1937 | 46 | ) -> Result<(), Error> { |
1938 | | let mut inner = self.inner.lock().await; |
1939 | | let (update, escalated) = inner.maybe_escalate(worker_id, operation_id, update); |
1940 | | if escalated { |
1941 | | record_dispatch_requeue("memory_escalation"); |
1942 | | } |
1943 | | inner.update_action(worker_id, operation_id, update).await |
1944 | 46 | } |
1945 | | |
1946 | | async fn worker_dispatch_accepted( |
1947 | | &self, |
1948 | | worker_id: &WorkerId, |
1949 | | operation_id: &OperationId, |
1950 | 4 | ) -> Result<(), Error> { |
1951 | | let mut inner = self.inner.lock().await; |
1952 | | inner.dispatch_accepted(worker_id, operation_id); |
1953 | | Ok(()) |
1954 | 4 | } |
1955 | | |
1956 | | async fn worker_dispatch_declined( |
1957 | | &self, |
1958 | | worker_id: &WorkerId, |
1959 | | operation_id: &OperationId, |
1960 | | reason: String, |
1961 | | needs_kb: Option<u64>, |
1962 | 7 | ) -> Result<(), Error> { |
1963 | | let mut inner = self.inner.lock().await; |
1964 | | // Counted once the action is back in the queue, not per message. |
1965 | | if inner |
1966 | | .dispatch_declined(worker_id, operation_id, reason, needs_kb) |
1967 | | .await? |
1968 | | { |
1969 | | self.metrics |
1970 | | .dispatches_declined |
1971 | | .fetch_add(1, Ordering::Relaxed); |
1972 | | record_dispatch_requeue("declined"); |
1973 | | } |
1974 | | Ok(()) |
1975 | 7 | } |
1976 | | |
1977 | | async fn worker_keep_alive_received( |
1978 | | &self, |
1979 | | worker_id: &WorkerId, |
1980 | | timestamp: WorkerTimestamp, |
1981 | | load: Option<WorkerLoad>, |
1982 | 41 | ) -> Result<(), Error> { |
1983 | | self.refresh_worker(worker_id, timestamp, load, true).await |
1984 | 41 | } |
1985 | | |
1986 | | async fn worker_liveness_refreshed( |
1987 | | &self, |
1988 | | worker_id: &WorkerId, |
1989 | | timestamp: WorkerTimestamp, |
1990 | 9 | ) -> Result<(), Error> { |
1991 | | self.refresh_worker(worker_id, timestamp, None, false).await |
1992 | 9 | } |
1993 | | |
1994 | 15 | async fn remove_worker(&self, worker_id: &WorkerId) -> Result<(), Error> { |
1995 | | self.worker_registry.remove_worker(worker_id).await; |
1996 | | |
1997 | | let mut inner = self.inner.lock().await; |
1998 | | inner |
1999 | | .immediate_evict_worker( |
2000 | | worker_id, |
2001 | | make_err!(Code::Internal, "Received request to remove worker"), |
2002 | | false, |
2003 | | WorkerDisconnectReason::Removed, |
2004 | | ) |
2005 | | .await |
2006 | 15 | } |
2007 | | |
2008 | 2 | async fn worker_disconnected(&self, worker_id: &WorkerId) -> Result<(), Error> { |
2009 | | self.worker_registry.remove_worker(worker_id).await; |
2010 | | |
2011 | | let mut inner = self.inner.lock().await; |
2012 | | inner |
2013 | | .immediate_evict_worker( |
2014 | | worker_id, |
2015 | | make_err!(Code::Unavailable, "Worker stream ended without going away"), |
2016 | | true, |
2017 | | WorkerDisconnectReason::Disconnected, |
2018 | | ) |
2019 | | .await |
2020 | 2 | } |
2021 | | |
2022 | 0 | async fn shutdown(&self, shutdown_guard: ShutdownGuard) { |
2023 | | let mut inner = self.inner.lock().await; |
2024 | | inner.shutting_down = true; // should reject further worker registration |
2025 | | while let Some(worker_id) = inner |
2026 | | .workers |
2027 | | .peek_lru() |
2028 | 0 | .map(|(worker_id, _worker)| worker_id.clone()) |
2029 | | { |
2030 | | if let Err(err) = inner |
2031 | | .immediate_evict_worker( |
2032 | | &worker_id, |
2033 | | make_err!(Code::Internal, "Scheduler shutdown"), |
2034 | | true, |
2035 | | WorkerDisconnectReason::Shutdown, |
2036 | | ) |
2037 | | .await |
2038 | | { |
2039 | | error!(?err, "Error evicting worker on shutdown."); |
2040 | | } |
2041 | | } |
2042 | | drop(shutdown_guard); |
2043 | 0 | } |
2044 | | |
2045 | 9 | async fn remove_timedout_workers(&self, now_timestamp: WorkerTimestamp) -> Result<(), Error> { |
2046 | | // Check worker liveness using both the local timestamp (from LRU) |
2047 | | // and the worker registry. A worker is alive if either source says it's alive. |
2048 | | let timeout = Duration::from_secs(self.worker_timeout_s); |
2049 | | let now = UNIX_EPOCH + Duration::from_secs(now_timestamp); |
2050 | | let timeout_threshold = now_timestamp.saturating_sub(self.worker_timeout_s); |
2051 | | |
2052 | | let workers_to_check: Vec<(WorkerId, bool, bool)> = { |
2053 | | let inner = self.inner.lock().await; |
2054 | | inner |
2055 | | .workers |
2056 | | .iter() |
2057 | 10 | .map(|(worker_id, worker)| { |
2058 | 10 | let local_alive = worker.last_update_timestamp > timeout_threshold; |
2059 | 10 | let kill_overdue = worker.running_action_infos.values().any(|info| {5 |
2060 | 5 | info.kill_requested_at.is_some_and(|at| {2 |
2061 | 2 | now_timestamp.saturating_sub(at) > self.unacknowledged_kill_timeout_s |
2062 | 2 | }) |
2063 | 5 | }); |
2064 | 10 | (worker_id.clone(), local_alive, kill_overdue) |
2065 | 10 | }) |
2066 | | .collect() |
2067 | | }; |
2068 | | |
2069 | | let mut worker_ids_to_remove = Vec::new(); |
2070 | | for (worker_id, local_alive, kill_overdue) in workers_to_check { |
2071 | | // A healthy worker acknowledges a kill in moments; one that |
2072 | | // cannot is wedged, and its keepalives keep the liveness checks |
2073 | | // below from ever firing while the dead operation holds its |
2074 | | // slot (the nativelink#2672 symptom). Evicting requeues its |
2075 | | // other operations. |
2076 | | if kill_overdue { |
2077 | | warn!( |
2078 | | ?worker_id, |
2079 | | unacknowledged_kill_timeout_s = self.unacknowledged_kill_timeout_s, |
2080 | | "Worker did not acknowledge a kill in time, removing from pool" |
2081 | | ); |
2082 | | worker_ids_to_remove.push((worker_id, true)); |
2083 | | continue; |
2084 | | } |
2085 | | |
2086 | | if local_alive { |
2087 | | continue; |
2088 | | } |
2089 | | |
2090 | | let registry_alive = self |
2091 | | .worker_registry |
2092 | | .is_worker_alive(&worker_id, timeout, now) |
2093 | | .await; |
2094 | | |
2095 | | if !registry_alive { |
2096 | | trace!( |
2097 | | ?worker_id, |
2098 | | local_alive, |
2099 | | registry_alive, |
2100 | | timeout_threshold, |
2101 | | "Worker timed out - neither local nor registry shows alive" |
2102 | | ); |
2103 | | worker_ids_to_remove.push((worker_id, false)); |
2104 | | } |
2105 | | } |
2106 | | |
2107 | | if worker_ids_to_remove.is_empty() { |
2108 | | return Ok(()); |
2109 | | } |
2110 | | |
2111 | | let mut inner = self.inner.lock().await; |
2112 | | let mut result = Ok(()); |
2113 | | |
2114 | | for (worker_id, kill_overdue) in &worker_ids_to_remove { |
2115 | | let err = if *kill_overdue { |
2116 | | make_err!( |
2117 | | Code::Internal, |
2118 | | "Worker {worker_id} did not acknowledge a kill within {}s, removing from pool", |
2119 | | self.unacknowledged_kill_timeout_s |
2120 | | ) |
2121 | | } else { |
2122 | | warn!(?worker_id, "Worker timed out, removing from pool"); |
2123 | | make_err!( |
2124 | | Code::Internal, |
2125 | | "Worker {worker_id} timed out, removing from pool" |
2126 | | ) |
2127 | | }; |
2128 | | let reason = if *kill_overdue { |
2129 | | WorkerDisconnectReason::KillUnacknowledged |
2130 | | } else { |
2131 | | WorkerDisconnectReason::Timeout |
2132 | | }; |
2133 | | result = result.merge( |
2134 | | inner |
2135 | | .immediate_evict_worker(worker_id, err, false, reason) |
2136 | | .await, |
2137 | | ); |
2138 | | } |
2139 | | |
2140 | | result |
2141 | 9 | } |
2142 | | |
2143 | 6 | async fn worker_snapshot(&self) -> Vec<WorkerSummary> { |
2144 | 12 | fn as_strings(properties: &PlatformProperties) -> HashMap<String, String> { |
2145 | 12 | properties |
2146 | 12 | .properties |
2147 | 12 | .iter() |
2148 | 12 | .map(|(name, value)| {4 |
2149 | 4 | let value = match value { |
2150 | 4 | PlatformPropertyValue::Minimum(v) => v.to_string(), |
2151 | 0 | PlatformPropertyValue::Exact(v) |
2152 | 0 | | PlatformPropertyValue::Priority(v) |
2153 | 0 | | PlatformPropertyValue::Ignore(v) |
2154 | 0 | | PlatformPropertyValue::Unknown(v) => v.clone(), |
2155 | | }; |
2156 | 4 | (name.clone(), value) |
2157 | 4 | }) |
2158 | 12 | .collect() |
2159 | 12 | } |
2160 | | let inner = self.inner.lock().await; |
2161 | | inner |
2162 | | .workers |
2163 | | .iter() |
2164 | | .map(|(_, worker)| WorkerSummary { |
2165 | 6 | id: worker.id.to_string(), |
2166 | 6 | running_actions: u32::try_from(worker.running_action_infos.len()) |
2167 | 6 | .unwrap_or(u32::MAX), |
2168 | 6 | max_inflight_tasks: worker.max_inflight_tasks, |
2169 | 6 | is_paused: worker.is_paused, |
2170 | 6 | is_draining: worker.is_draining, |
2171 | 6 | last_update_timestamp: worker.last_update_timestamp, |
2172 | 6 | platform_properties: as_strings(&worker.total_platform_properties), |
2173 | 6 | available_platform_properties: as_strings(&worker.platform_properties), |
2174 | 6 | free_memory_kb: worker.last_load.map(|load| load.free_memory_kb), |
2175 | 6 | }) |
2176 | | .collect() |
2177 | 6 | } |
2178 | | |
2179 | 5 | async fn set_drain_worker(&self, worker_id: &WorkerId, is_draining: bool) -> Result<(), Error> { |
2180 | | let mut inner = self.inner.lock().await; |
2181 | | inner.set_drain_worker(worker_id, is_draining) |
2182 | 5 | } |
2183 | | |
2184 | 9 | async fn kill_revoked_operations(&self) -> Result<(), Error> { |
2185 | | let (worker_state_manager, running) = { |
2186 | | let inner = self.inner.lock().await; |
2187 | | let running: Vec<(WorkerId, OperationId)> = inner |
2188 | | .workers |
2189 | | .iter() |
2190 | 9 | .flat_map(|(worker_id, worker)| { |
2191 | 9 | worker |
2192 | 9 | .running_action_infos |
2193 | 9 | .iter() |
2194 | | // An operation already told to die is never swept |
2195 | | // again; the kill itself is not re-sent. A worker |
2196 | | // that received the kill but never reports back is |
2197 | | // evicted by remove_timedout_workers once the kill |
2198 | | // has gone unacknowledged longer than |
2199 | | // `unacknowledged_kill_timeout_s`; a dead worker by |
2200 | | // the ordinary keepalive timeout. |
2201 | 41 | .filter9 (|(_, pending_action_info)| { |
2202 | 41 | pending_action_info.kill_requested_at.is_none() |
2203 | 41 | }) |
2204 | 40 | .map9 (|(operation_id, _)| (worker_id.clone(), operation_id.clone())) |
2205 | 9 | }) |
2206 | | .collect(); |
2207 | | (inner.worker_state_manager.clone(), running) |
2208 | | }; |
2209 | | |
2210 | | // On store-backed deployments each check is a network round-trip, |
2211 | | // so run them lock-free with bounded concurrency. |
2212 | | let revoked: Vec<(WorkerId, OperationId)> = futures::stream::iter(running) |
2213 | 40 | .map(|(worker_id, operation_id)| { |
2214 | 40 | let worker_state_manager = worker_state_manager.clone(); |
2215 | 40 | async move { |
2216 | 40 | match worker_state_manager |
2217 | 40 | .is_executing_on_worker(&operation_id, &worker_id) |
2218 | 40 | .await |
2219 | | { |
2220 | 35 | Ok(true) => None, |
2221 | 5 | Ok(false) => Some((worker_id, operation_id)), |
2222 | | // Only kill on positive evidence; try again next pass. |
2223 | 0 | Err(err) => { |
2224 | 0 | warn!( |
2225 | | ?worker_id, |
2226 | | %operation_id, |
2227 | | ?err, |
2228 | | "Could not check whether operation is still executing on worker" |
2229 | | ); |
2230 | 0 | None |
2231 | | } |
2232 | | } |
2233 | 40 | } |
2234 | 40 | }) |
2235 | | .buffer_unordered(MAX_CONCURRENT_REVOKED_CHECKS) |
2236 | | .filter_map(future::ready) |
2237 | | .collect() |
2238 | | .await; |
2239 | | |
2240 | | if revoked.is_empty() { |
2241 | | return Ok(()); |
2242 | | } |
2243 | | |
2244 | | // Re-check lock-free so the scheduler mutex is never held across |
2245 | | // store I/O; the checks above may be stale by the time we get here. |
2246 | | // The remaining TOCTOU window is fine: worker_notify_kill_operation |
2247 | | // re-guards with contains_key + is_kill_requested under the lock. |
2248 | | let mut confirmed = Vec::new(); |
2249 | | for (worker_id, operation_id) in revoked { |
2250 | | // A result of Ok(true) or Err(_) means the operation was |
2251 | | // reassigned to this worker after the first check, or the state |
2252 | | // manager went quiet: nothing to kill on this pass. |
2253 | | let revoked = worker_state_manager |
2254 | | .is_executing_on_worker(&operation_id, &worker_id) |
2255 | | .await |
2256 | 5 | .is_ok_and(|executing| !executing); |
2257 | | if revoked { |
2258 | | confirmed.push((worker_id, operation_id)); |
2259 | | } |
2260 | | } |
2261 | | |
2262 | | if confirmed.is_empty() { |
2263 | | return Ok(()); |
2264 | | } |
2265 | | |
2266 | | let mut inner = self.inner.lock().await; |
2267 | | let mut result = Ok(()); |
2268 | | for (worker_id, operation_id) in confirmed { |
2269 | | result = result.merge( |
2270 | | inner |
2271 | | .worker_notify_kill_operation(&worker_id, operation_id) |
2272 | | .await, |
2273 | | ); |
2274 | | } |
2275 | | result |
2276 | 9 | } |
2277 | | } |
2278 | | |
2279 | | impl RootMetricsComponent for ApiWorkerScheduler {} |
2280 | | |
2281 | | #[cfg(test)] |
2282 | | mod peer_census_trusted_tests { |
2283 | | use super::{Duration, SystemTime, UNIX_EPOCH, peer_census_trusted}; |
2284 | | |
2285 | | const TTL: Duration = Duration::from_secs(15); |
2286 | 23 | fn at(secs: u64) -> SystemTime { |
2287 | 23 | UNIX_EPOCH + Duration::from_secs(1000 + secs) |
2288 | 23 | } |
2289 | | |
2290 | | #[test] |
2291 | 1 | fn unknown_census_is_never_trusted() { |
2292 | | // Even a non-shared db, if explicitly unknown, is not trusted. |
2293 | 1 | assert!(!peer_census_trusted(false, false, None, None, TTL, at(0))); |
2294 | 1 | assert!(!peer_census_trusted( |
2295 | 1 | true, |
2296 | 1 | false, |
2297 | 1 | Some(at(0)), |
2298 | 1 | Some(at(0)), |
2299 | 1 | TTL, |
2300 | 1 | at(100) |
2301 | 1 | )); |
2302 | 1 | } |
2303 | | |
2304 | | #[test] |
2305 | 1 | fn non_shared_known_is_trusted_immediately() { |
2306 | | // The whole fleet is local; no warm-up or staleness applies. |
2307 | 1 | assert!(peer_census_trusted(false, true, None, None, TTL, at(0))); |
2308 | 1 | } |
2309 | | |
2310 | | #[test] |
2311 | 1 | fn shared_known_needs_both_timestamps() { |
2312 | 1 | assert!(!peer_census_trusted(true, true, None, None, TTL, at(100))); |
2313 | 1 | assert!(!peer_census_trusted( |
2314 | 1 | true, |
2315 | 1 | true, |
2316 | 1 | Some(at(0)), |
2317 | 1 | None, |
2318 | 1 | TTL, |
2319 | 1 | at(100) |
2320 | 1 | )); |
2321 | 1 | } |
2322 | | |
2323 | | #[test] |
2324 | 1 | fn shared_trusts_only_when_warm_and_fresh() { |
2325 | | // Known at 0, refreshed at 100, evaluated at 100: warm and fresh. |
2326 | 1 | assert!(peer_census_trusted( |
2327 | | true, |
2328 | | true, |
2329 | 1 | Some(at(0)), |
2330 | 1 | Some(at(100)), |
2331 | | TTL, |
2332 | 1 | at(100) |
2333 | | )); |
2334 | | // Exactly one TTL since known counts as warm. |
2335 | 1 | assert!(peer_census_trusted( |
2336 | | true, |
2337 | | true, |
2338 | 1 | Some(at(0)), |
2339 | 1 | Some(at(15)), |
2340 | | TTL, |
2341 | 1 | at(15) |
2342 | | )); |
2343 | 1 | } |
2344 | | |
2345 | | #[test] |
2346 | 1 | fn shared_not_warm_is_not_trusted() { |
2347 | | // Known only half a TTL ago: a peer may not have published yet. |
2348 | 1 | assert!(!peer_census_trusted( |
2349 | 1 | true, |
2350 | 1 | true, |
2351 | 1 | Some(at(0)), |
2352 | 1 | Some(at(7)), |
2353 | 1 | TTL, |
2354 | 1 | at(7) |
2355 | 1 | )); |
2356 | 1 | } |
2357 | | |
2358 | | #[test] |
2359 | 1 | fn shared_stale_refresh_is_not_trusted() { |
2360 | | // Warm, but the last successful exchange was more than a TTL ago (the |
2361 | | // exchange task died/stalled): fail open. |
2362 | 1 | assert!(!peer_census_trusted( |
2363 | 1 | true, |
2364 | 1 | true, |
2365 | 1 | Some(at(0)), |
2366 | 1 | Some(at(50)), |
2367 | 1 | TTL, |
2368 | 1 | at(100) |
2369 | 1 | )); |
2370 | 1 | } |
2371 | | |
2372 | | #[test] |
2373 | 1 | fn future_dated_refresh_counts_as_fresh() { |
2374 | | // Clock skew: a refresh timestamped ahead of `now` is treated as fresh |
2375 | | // (the safe direction). Still requires warm. |
2376 | 1 | assert!(peer_census_trusted( |
2377 | | true, |
2378 | | true, |
2379 | 1 | Some(at(0)), |
2380 | 1 | Some(at(120)), |
2381 | | TTL, |
2382 | 1 | at(100) |
2383 | | )); |
2384 | 1 | } |
2385 | | } |