/build/source/nativelink-scheduler/src/worker_registry.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::time::Duration; |
16 | | use std::collections::HashMap; |
17 | | use std::sync::Arc; |
18 | | use std::time::SystemTime; |
19 | | |
20 | | use async_lock::RwLock; |
21 | | use nativelink_util::action_messages::WorkerId; |
22 | | use tracing::{debug, trace}; |
23 | | |
24 | | /// Ceiling for an action whose worker no instance here recognises. Long on |
25 | | /// purpose: heartbeats never reach the shared state, so a healthy hour-long |
26 | | /// action and an abandoned one look identical from here, and acting early |
27 | | /// costs live work. |
28 | | pub const ORPHANED_ACTION_TIMEOUT: Duration = Duration::from_hours(1); |
29 | | |
30 | | /// What this scheduler instance knows about a worker's liveness. |
31 | | /// |
32 | | /// A worker only appears in the registry of the instance holding its |
33 | | /// `connect_worker` stream, so `Unknown` means "not mine to judge", not |
34 | | /// "dead". |
35 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
36 | | pub enum WorkerLiveness { |
37 | | /// Registered here and has heartbeat within the timeout. |
38 | | Alive, |
39 | | /// Registered here, but has not been heard from within the timeout. |
40 | | Stale, |
41 | | /// Not registered here at all. Either connected to a different scheduler |
42 | | /// instance, or already evicted (in which case its actions were requeued |
43 | | /// at eviction time and no longer reference it). |
44 | | Unknown, |
45 | | } |
46 | | |
47 | | /// In-memory worker registry that tracks worker liveness. |
48 | | /// |
49 | | /// Per-process: fed by the `connect_worker` streams this instance owns, not a |
50 | | /// view of every worker in the deployment. |
51 | | #[derive(Debug)] |
52 | | pub struct WorkerRegistry { |
53 | | workers: RwLock<HashMap<WorkerId, SystemTime>>, |
54 | | } |
55 | | |
56 | | impl Default for WorkerRegistry { |
57 | 0 | fn default() -> Self { |
58 | 0 | Self::new() |
59 | 0 | } |
60 | | } |
61 | | |
62 | | impl WorkerRegistry { |
63 | | /// Creates a new worker registry. |
64 | 57 | pub fn new() -> Self { |
65 | 57 | Self { |
66 | 57 | workers: RwLock::new(HashMap::new()), |
67 | 57 | } |
68 | 57 | } |
69 | | |
70 | | /// Updates the heartbeat timestamp for a worker. |
71 | 10 | pub async fn update_worker_heartbeat(&self, worker_id: &WorkerId, now: SystemTime) { |
72 | 10 | let mut workers = self.workers.write().await; |
73 | 10 | workers.insert(worker_id.clone(), now); |
74 | 10 | trace!(?worker_id, now = %humantime::format_rfc3339(now), "FLOW: Worker heartbeat updated in registry"); |
75 | 10 | } |
76 | | |
77 | 51 | pub async fn register_worker(&self, worker_id: &WorkerId, now: SystemTime) { |
78 | 51 | let mut workers = self.workers.write().await; |
79 | 51 | workers.insert(worker_id.clone(), now); |
80 | 51 | debug!(?worker_id, "FLOW: Worker registered in registry"); |
81 | 51 | } |
82 | | |
83 | 8 | pub async fn remove_worker(&self, worker_id: &WorkerId) { |
84 | 8 | let mut workers = self.workers.write().await; |
85 | 8 | workers.remove(worker_id); |
86 | 8 | debug!(?worker_id, "FLOW: Worker removed from registry"); |
87 | 8 | } |
88 | | |
89 | | /// Liveness as far as THIS instance can tell. Callers acting on "dead" |
90 | | /// must handle `Unknown` separately. |
91 | 26 | pub async fn check_liveness( |
92 | 26 | &self, |
93 | 26 | worker_id: &WorkerId, |
94 | 26 | timeout: Duration, |
95 | 26 | now: SystemTime, |
96 | 26 | ) -> WorkerLiveness { |
97 | 26 | let workers = self.workers.read().await; |
98 | | |
99 | 26 | let Some(last_seen16 ) = workers.get(worker_id) else { |
100 | 10 | trace!(?worker_id, "FLOW: Worker not registered on this instance"); |
101 | 10 | return WorkerLiveness::Unknown; |
102 | | }; |
103 | | |
104 | | // An overflowing deadline can only be clock skew; treat as alive. |
105 | 16 | let liveness = match last_seen.checked_add(timeout) { |
106 | 16 | Some(deadline8 ) if deadline > now8 => WorkerLiveness::Alive8 , |
107 | 8 | Some(_) => WorkerLiveness::Stale, |
108 | 0 | None => WorkerLiveness::Alive, |
109 | | }; |
110 | 16 | trace!( |
111 | | ?worker_id, |
112 | 16 | last_seen = %humantime::format_rfc3339(*last_seen), |
113 | | ?timeout, |
114 | | ?liveness, |
115 | | "FLOW: Worker liveness check" |
116 | | ); |
117 | 16 | liveness |
118 | 26 | } |
119 | | |
120 | 9 | pub async fn is_worker_alive( |
121 | 9 | &self, |
122 | 9 | worker_id: &WorkerId, |
123 | 9 | timeout: Duration, |
124 | 9 | now: SystemTime, |
125 | 9 | ) -> bool { |
126 | 9 | self.check_liveness(worker_id, timeout, now).await == WorkerLiveness::Alive |
127 | 9 | } |
128 | | |
129 | 0 | pub async fn get_worker_last_seen(&self, worker_id: &WorkerId) -> Option<SystemTime> { |
130 | 0 | let workers = self.workers.read().await; |
131 | 0 | workers.get(worker_id).copied() |
132 | 0 | } |
133 | | } |
134 | | |
135 | | pub type SharedWorkerRegistry = Arc<WorkerRegistry>; |
136 | | |
137 | | #[cfg(test)] |
138 | | mod tests { |
139 | | use nativelink_macro::nativelink_test; |
140 | | |
141 | | use super::*; |
142 | | |
143 | | #[nativelink_test] |
144 | | async fn test_worker_heartbeat() { |
145 | | let registry = WorkerRegistry::new(); |
146 | | let worker_id = WorkerId::from(String::from("test")); |
147 | | let now = SystemTime::now(); |
148 | | |
149 | | // Worker not registered yet |
150 | | assert!( |
151 | | !registry |
152 | | .is_worker_alive(&worker_id, Duration::from_secs(5), now) |
153 | | .await |
154 | | ); |
155 | | |
156 | | // Register worker |
157 | | registry.register_worker(&worker_id, now).await; |
158 | | assert!( |
159 | | registry |
160 | | .is_worker_alive(&worker_id, Duration::from_secs(5), now) |
161 | | .await |
162 | | ); |
163 | | |
164 | | // Check with expired timeout |
165 | | let future = now.checked_add(Duration::from_secs(10)).unwrap(); |
166 | | assert!( |
167 | | !registry |
168 | | .is_worker_alive(&worker_id, Duration::from_secs(5), future) |
169 | | .await |
170 | | ); |
171 | | |
172 | | // Update heartbeat |
173 | | registry.update_worker_heartbeat(&worker_id, future).await; |
174 | | assert!( |
175 | | registry |
176 | | .is_worker_alive(&worker_id, Duration::from_secs(5), future) |
177 | | .await |
178 | | ); |
179 | | } |
180 | | |
181 | | #[nativelink_test] |
182 | | async fn test_check_liveness_distinguishes_stale_from_unknown() { |
183 | | let registry = WorkerRegistry::new(); |
184 | | let mine = WorkerId::from(String::from("mine")); |
185 | | let theirs = WorkerId::from(String::from("belongs-to-another-instance")); |
186 | | let now = SystemTime::now(); |
187 | | let timeout = Duration::from_secs(5); |
188 | | |
189 | | // Never registered here. This is the case that must NOT read as dead: |
190 | | // with several schedulers on shared state it is a peer's worker. |
191 | | assert_eq!( |
192 | | registry.check_liveness(&theirs, timeout, now).await, |
193 | | WorkerLiveness::Unknown |
194 | | ); |
195 | | |
196 | | registry.register_worker(&mine, now).await; |
197 | | assert_eq!( |
198 | | registry.check_liveness(&mine, timeout, now).await, |
199 | | WorkerLiveness::Alive |
200 | | ); |
201 | | |
202 | | // Registered here but gone quiet: genuinely ours and genuinely dead. |
203 | | let later = now.checked_add(Duration::from_secs(10)).unwrap(); |
204 | | assert_eq!( |
205 | | registry.check_liveness(&mine, timeout, later).await, |
206 | | WorkerLiveness::Stale |
207 | | ); |
208 | | |
209 | | // Eviction takes it back to Unknown, not Stale. |
210 | | registry.remove_worker(&mine).await; |
211 | | assert_eq!( |
212 | | registry.check_liveness(&mine, timeout, later).await, |
213 | | WorkerLiveness::Unknown |
214 | | ); |
215 | | } |
216 | | |
217 | | #[nativelink_test] |
218 | | async fn test_remove_worker() { |
219 | | let registry = WorkerRegistry::new(); |
220 | | let worker_id = WorkerId::from(String::from("test-worker")); |
221 | | let now = SystemTime::now(); |
222 | | |
223 | | registry.register_worker(&worker_id, now).await; |
224 | | assert!( |
225 | | registry |
226 | | .is_worker_alive(&worker_id, Duration::from_secs(5), now) |
227 | | .await |
228 | | ); |
229 | | |
230 | | registry.remove_worker(&worker_id).await; |
231 | | assert!( |
232 | | !registry |
233 | | .is_worker_alive(&worker_id, Duration::from_secs(5), now) |
234 | | .await |
235 | | ); |
236 | | } |
237 | | } |