/build/source/nativelink-scheduler/src/store_awaited_action_db.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::mem::Discriminant; |
16 | | use core::ops::Bound; |
17 | | use core::sync::atomic::{AtomicU64, Ordering}; |
18 | | use core::time::Duration; |
19 | | use std::borrow::Cow; |
20 | | use std::sync::{Arc, Weak}; |
21 | | use std::time::{SystemTime, UNIX_EPOCH}; |
22 | | |
23 | | use bytes::Bytes; |
24 | | use futures::{Stream, TryStreamExt}; |
25 | | use nativelink_error::{Code, Error, ResultExt, make_err}; |
26 | | use nativelink_metric::MetricsComponent; |
27 | | use nativelink_util::action_messages::{ |
28 | | ActionInfo, ActionStage, ActionUniqueQualifier, OperationId, |
29 | | }; |
30 | | use nativelink_util::instant_wrapper::InstantWrapper; |
31 | | use nativelink_util::metrics::{EXECUTION_METRICS, EXECUTION_STAGE, ExecutionStage}; |
32 | | use nativelink_util::platform_properties::PlatformProperties; |
33 | | use nativelink_util::spawn; |
34 | | use nativelink_util::store_trait::{ |
35 | | FalseValue, SchedulerCurrentVersionProvider, SchedulerIndexProvider, SchedulerStore, |
36 | | SchedulerStoreDataProvider, SchedulerStoreDecodeTo, SchedulerStoreKeyProvider, |
37 | | SchedulerSubscription, SchedulerSubscriptionManager, StoreKey, TrueValue, |
38 | | }; |
39 | | use nativelink_util::task::JoinHandleDropGuard; |
40 | | use opentelemetry::KeyValue; |
41 | | use serde::{Deserialize, Serialize}; |
42 | | use tokio::sync::{Notify, OnceCell}; |
43 | | use tracing::{error, warn}; |
44 | | |
45 | | use crate::awaited_action_db::{ |
46 | | AwaitedAction, AwaitedActionDb, AwaitedActionSubscriber, CLIENT_KEEPALIVE_DURATION, |
47 | | SortedAwaitedAction, SortedAwaitedActionState, |
48 | | }; |
49 | | use crate::worker_registry::{ORPHANED_ACTION_TIMEOUT, SharedWorkerRegistry, WorkerLiveness}; |
50 | | |
51 | | type ClientOperationId = OperationId; |
52 | | |
53 | | /// Maximum number of retries to update client keep alive. |
54 | | const MAX_RETRIES_FOR_CLIENT_KEEPALIVE: u32 = 8; |
55 | | |
56 | | /// Use separate non-versioned Redis key for client keepalives. |
57 | | const USE_SEPARATE_CLIENT_KEEPALIVE_KEY: bool = true; |
58 | | |
59 | | /// How often the actions in each stage are recounted for |
60 | | /// `execution.active.count`. |
61 | | const ACTIVE_COUNT_REFRESH_INTERVAL: Duration = Duration::from_secs(15); |
62 | | |
63 | | /// Every stage the store indexes, with the label it is reported under. |
64 | | const COUNTED_STATES: [(SortedAwaitedActionState, ExecutionStage); 4] = [ |
65 | | ( |
66 | | SortedAwaitedActionState::CacheCheck, |
67 | | ExecutionStage::CacheCheck, |
68 | | ), |
69 | | (SortedAwaitedActionState::Queued, ExecutionStage::Queued), |
70 | | ( |
71 | | SortedAwaitedActionState::Executing, |
72 | | ExecutionStage::Executing, |
73 | | ), |
74 | | ( |
75 | | SortedAwaitedActionState::Completed, |
76 | | ExecutionStage::Completed, |
77 | | ), |
78 | | ]; |
79 | | |
80 | | enum OperationSubscriberState<Sub> { |
81 | | Unsubscribed, |
82 | | Subscribed(Sub), |
83 | | } |
84 | | |
85 | | pub struct OperationSubscriber<S: SchedulerStore, I: InstantWrapper, NowFn: Fn() -> I> { |
86 | | maybe_client_operation_id: Option<ClientOperationId>, |
87 | | subscription_key: OperationIdToAwaitedAction<'static>, |
88 | | weak_store: Weak<S>, |
89 | | state: OperationSubscriberState< |
90 | | <S::SubscriptionManager as SchedulerSubscriptionManager>::Subscription, |
91 | | >, |
92 | | last_known_keepalive_ts: AtomicU64, |
93 | | now_fn: NowFn, |
94 | | /// The record as a listing read it, when this subscriber came from one. |
95 | | /// A listing already loads every record it returns, so `borrow` serves |
96 | | /// this instead of reading the record again; only the client keepalive, |
97 | | /// kept under its own key, is read, once. The matching pass borrows each |
98 | | /// queued action two or three times per pass, so without this a pass |
99 | | /// cost several store round trips per queued action. |
100 | | listed: Option<AwaitedAction>, |
101 | | /// `listed` with the client keepalive merged in, filled on first borrow. |
102 | | listed_with_keepalive: OnceCell<AwaitedAction>, |
103 | | // If the SchedulerSubscriptionManager is not reliable, then this is populated |
104 | | // when the state is set to subscribed. When set it causes the state to be polled |
105 | | // as well as listening for the publishing. |
106 | | maybe_last_stage: Option<Discriminant<ActionStage>>, |
107 | | retain_completed_for: Duration, |
108 | | /// How long a written client keepalive stays meaningful. Past it, an |
109 | | /// absent key and a stale one say the same thing, so the key may as |
110 | | /// well be gone. |
111 | | client_keepalive_ttl: Duration, |
112 | | } |
113 | | |
114 | | impl<S: SchedulerStore, I: InstantWrapper, NowFn: Fn() -> I + core::fmt::Debug> core::fmt::Debug |
115 | | for OperationSubscriber<S, I, NowFn> |
116 | | where |
117 | | OperationSubscriberState< |
118 | | <S::SubscriptionManager as SchedulerSubscriptionManager>::Subscription, |
119 | | >: core::fmt::Debug, |
120 | | { |
121 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
122 | 0 | f.debug_struct("OperationSubscriber") |
123 | 0 | .field("maybe_client_operation_id", &self.maybe_client_operation_id) |
124 | 0 | .field("subscription_key", &self.subscription_key) |
125 | 0 | .field("weak_store", &self.weak_store) |
126 | 0 | .field("state", &self.state) |
127 | 0 | .field("last_known_keepalive_ts", &self.last_known_keepalive_ts) |
128 | 0 | .field("now_fn", &self.now_fn) |
129 | 0 | .finish() |
130 | 0 | } |
131 | | } |
132 | | impl<S, I, NowFn> OperationSubscriber<S, I, NowFn> |
133 | | where |
134 | | S: SchedulerStore, |
135 | | I: InstantWrapper, |
136 | | NowFn: Fn() -> I, |
137 | | { |
138 | 77 | const fn new( |
139 | 77 | maybe_client_operation_id: Option<ClientOperationId>, |
140 | 77 | subscription_key: OperationIdToAwaitedAction<'static>, |
141 | 77 | weak_store: Weak<S>, |
142 | 77 | now_fn: NowFn, |
143 | 77 | retain_completed_for: Duration, |
144 | 77 | client_keepalive_ttl: Duration, |
145 | 77 | ) -> Self { |
146 | 77 | Self { |
147 | 77 | maybe_client_operation_id, |
148 | 77 | subscription_key, |
149 | 77 | weak_store, |
150 | 77 | last_known_keepalive_ts: AtomicU64::new(0), |
151 | 77 | state: OperationSubscriberState::Unsubscribed, |
152 | 77 | now_fn, |
153 | 77 | listed: None, |
154 | 77 | listed_with_keepalive: OnceCell::const_new(), |
155 | 77 | maybe_last_stage: None, |
156 | 77 | retain_completed_for, |
157 | 77 | client_keepalive_ttl, |
158 | 77 | } |
159 | 77 | } |
160 | | |
161 | | /// Serves `borrow` from the record a listing already read. |
162 | 34 | fn listed(mut self, awaited_action: AwaitedAction) -> Self { |
163 | 34 | self.listed = Some(awaited_action); |
164 | 34 | self |
165 | 34 | } |
166 | | |
167 | 47 | async fn inner_get_awaited_action( |
168 | 47 | store: &S, |
169 | 47 | key: OperationIdToAwaitedAction<'_>, |
170 | 47 | maybe_client_operation_id: Option<ClientOperationId>, |
171 | 47 | last_known_keepalive_ts: &AtomicU64, |
172 | 47 | ) -> Result<AwaitedAction, Error> { |
173 | 47 | let mut awaited_action = store |
174 | 47 | .get_and_decode(key.borrow()) |
175 | 47 | .await |
176 | 47 | .err_tip(|| format!0 ("In OperationSubscriber::get_awaited_action {key:?}"))?0 |
177 | 47 | .ok_or_else(|| {0 |
178 | 0 | make_err!( |
179 | 0 | Code::NotFound, |
180 | | "Could not find AwaitedAction for the given operation id {key:?}", |
181 | | ) |
182 | 0 | })?; |
183 | 47 | if let Some(client_operation_id21 ) = maybe_client_operation_id { |
184 | 21 | awaited_action.set_client_operation_id(client_operation_id); |
185 | 26 | } |
186 | 47 | Self::merge_client_keepalive( |
187 | 47 | store, |
188 | 47 | key.0.as_ref(), |
189 | 47 | &mut awaited_action, |
190 | 47 | last_known_keepalive_ts, |
191 | 47 | ) |
192 | 47 | .await; |
193 | 47 | Ok(awaited_action) |
194 | 47 | } |
195 | | |
196 | | /// Folds the client keepalive kept under its own key into the action's |
197 | | /// timestamp and remembers it. |
198 | 81 | async fn merge_client_keepalive( |
199 | 81 | store: &S, |
200 | 81 | operation_id: &OperationId, |
201 | 81 | awaited_action: &mut AwaitedAction, |
202 | 81 | last_known_keepalive_ts: &AtomicU64, |
203 | 81 | ) { |
204 | | // Helper to convert SystemTime to unix timestamp |
205 | 81 | let to_unix_ts = |
206 | 81 | |t: SystemTime| -> u64 { t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) }; |
207 | | |
208 | | // Check the separate keepalive key for the most recent timestamp. |
209 | 81 | let keepalive_ts = if USE_SEPARATE_CLIENT_KEEPALIVE_KEY { |
210 | 81 | match store.get_and_decode(ClientKeepaliveKey(operation_id)).await { |
211 | 0 | Ok(Some(ts)) => { |
212 | 0 | let awaited_ts = to_unix_ts(awaited_action.last_client_keepalive_timestamp()); |
213 | 0 | if ts > awaited_ts { |
214 | 0 | let timestamp = UNIX_EPOCH + Duration::from_secs(ts); |
215 | 0 | awaited_action.update_client_keep_alive(timestamp); |
216 | 0 | ts |
217 | | } else { |
218 | 0 | awaited_ts |
219 | | } |
220 | | } |
221 | 81 | Ok(None) | Err(_) => to_unix_ts(awaited_action.last_client_keepalive_timestamp()), |
222 | | } |
223 | | } else { |
224 | 0 | to_unix_ts(awaited_action.last_client_keepalive_timestamp()) |
225 | | }; |
226 | | |
227 | 81 | last_known_keepalive_ts.store(keepalive_ts, Ordering::Release); |
228 | 81 | } |
229 | | |
230 | | #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this |
231 | 126 | async fn get_awaited_action(&self) -> Result<AwaitedAction, Error> { |
232 | 126 | let store = self |
233 | 126 | .weak_store |
234 | 126 | .upgrade() |
235 | 126 | .err_tip(|| "Store gone in OperationSubscriber::get_awaited_action")?0 ; |
236 | 126 | if let Some(listed81 ) = &self.listed { |
237 | 81 | let merged = self |
238 | 81 | .listed_with_keepalive |
239 | 81 | .get_or_init(|| async {34 |
240 | 34 | let mut awaited_action = listed.clone(); |
241 | 34 | Self::merge_client_keepalive( |
242 | 34 | store.as_ref(), |
243 | 34 | self.subscription_key.0.as_ref(), |
244 | 34 | &mut awaited_action, |
245 | 34 | &self.last_known_keepalive_ts, |
246 | 34 | ) |
247 | 34 | .await; |
248 | 34 | awaited_action |
249 | 68 | }) |
250 | 81 | .await; |
251 | 81 | return Ok(merged.clone()); |
252 | 45 | } |
253 | 45 | Self::inner_get_awaited_action( |
254 | 45 | store.as_ref(), |
255 | 45 | self.subscription_key.borrow(), |
256 | 45 | self.maybe_client_operation_id.clone(), |
257 | 45 | &self.last_known_keepalive_ts, |
258 | 45 | ) |
259 | 45 | .await |
260 | 126 | } |
261 | | } |
262 | | |
263 | | impl<S, I, NowFn> AwaitedActionSubscriber for OperationSubscriber<S, I, NowFn> |
264 | | where |
265 | | S: SchedulerStore, |
266 | | I: InstantWrapper, |
267 | | NowFn: Fn() -> I + Send + Sync + 'static, |
268 | | { |
269 | 2 | async fn changed(&mut self) -> Result<AwaitedAction, Error> { |
270 | 2 | let store = self |
271 | 2 | .weak_store |
272 | 2 | .upgrade() |
273 | 2 | .err_tip(|| "Store gone in OperationSubscriber::get_awaited_action")?0 ; |
274 | 2 | let subscription1 = match &mut self.state { |
275 | 1 | OperationSubscriberState::Subscribed(subscription) => subscription, |
276 | | OperationSubscriberState::Unsubscribed => { |
277 | 1 | let subscription = store |
278 | 1 | .subscription_manager() |
279 | 1 | .await |
280 | 1 | .err_tip(|| "In OperationSubscriber::changed::subscription_manager")?0 |
281 | 1 | .subscribe(self.subscription_key.borrow()) |
282 | 1 | .err_tip(|| "In OperationSubscriber::changed::subscribe")?0 ; |
283 | 1 | self.state = OperationSubscriberState::Subscribed(subscription); |
284 | | // When we've just subscribed, there may have been changes before now. |
285 | 1 | let action = Self::inner_get_awaited_action( |
286 | 1 | store.as_ref(), |
287 | 1 | self.subscription_key.borrow(), |
288 | 1 | self.maybe_client_operation_id.clone(), |
289 | 1 | &self.last_known_keepalive_ts, |
290 | 1 | ) |
291 | 1 | .await |
292 | 1 | .err_tip(|| "In OperationSubscriber::changed")?0 ; |
293 | 1 | if !<S as SchedulerStore>::SubscriptionManager::is_reliable() { |
294 | 1 | self.maybe_last_stage = Some(core::mem::discriminant(&action.state().stage)); |
295 | 1 | }0 |
296 | | // Existing changes are only interesting if the state is past queued. |
297 | 1 | if !matches!(action.state().stage, ActionStage::Queued) { |
298 | 1 | return Ok(action); |
299 | 0 | } |
300 | 0 | let OperationSubscriberState::Subscribed(subscription) = &mut self.state else { |
301 | 0 | unreachable!("Subscription should be in Subscribed state"); |
302 | | }; |
303 | 0 | subscription |
304 | | } |
305 | | }; |
306 | | |
307 | 1 | let changed_fut = subscription.changed(); |
308 | 1 | tokio::pin!(changed_fut); |
309 | | loop { |
310 | | // This is set if the maybe_last_state doesn't match the state in the store. |
311 | 1 | let mut maybe_changed_action = None; |
312 | | |
313 | 1 | let last_known_keepalive_ts = self.last_known_keepalive_ts.load(Ordering::Acquire); |
314 | | // Only a subscriber that stands for a client may say a client is |
315 | | // still there. The matching engine subscribes to every queued |
316 | | // action it considers, and `get_range_of_actions` builds those |
317 | | // subscribers with no client operation id precisely because there |
318 | | // is no client behind them. Letting them write the keepalive |
319 | | // makes the scheduler hold open the actions it is supposed to be |
320 | | // retiring: the keepalive is refreshed, the client timeout in |
321 | | // `apply_filter_predicate` never comes due, and the action is |
322 | | // offered to the matcher again, which refreshes it again. |
323 | | // |
324 | | // The effect is that every queued action carries a keepalive |
325 | | // only seconds old however long its client has been gone, and |
326 | | // none of them are ever retired. |
327 | 1 | if self.maybe_client_operation_id.is_some() |
328 | 1 | && I::from_secs(last_known_keepalive_ts).elapsed() > CLIENT_KEEPALIVE_DURATION |
329 | | { |
330 | 0 | let now = (self.now_fn)().now(); |
331 | 0 | let now_ts = now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); |
332 | | |
333 | 0 | if USE_SEPARATE_CLIENT_KEEPALIVE_KEY { |
334 | 0 | let operation_id = self.subscription_key.0.as_ref(); |
335 | 0 | let update_result = store |
336 | 0 | .update_data( |
337 | 0 | UpdateClientKeepalive { |
338 | 0 | operation_id, |
339 | 0 | timestamp: now_ts, |
340 | 0 | }, |
341 | 0 | // Written with no expiry these outlive every |
342 | 0 | // action that produced them and accumulate |
343 | 0 | // without bound. A keepalive older than the |
344 | 0 | // client timeout cannot change any decision, |
345 | 0 | // because the timeout has already come due and |
346 | 0 | // the stored timestamp is consulted either way. |
347 | 0 | Some(self.client_keepalive_ttl), |
348 | 0 | ) |
349 | 0 | .await; |
350 | | |
351 | 0 | if let Err(e) = update_result { |
352 | 0 | warn!( |
353 | | ?self.subscription_key, |
354 | | ?e, |
355 | | "Failed to update client keepalive (non-versioned)" |
356 | | ); |
357 | 0 | } |
358 | | |
359 | | // Update local timestamp |
360 | 0 | self.last_known_keepalive_ts |
361 | 0 | .store(now_ts, Ordering::Release); |
362 | | |
363 | | // Check if state changed (for unreliable subscription managers) |
364 | 0 | if self.maybe_last_stage.is_some() { |
365 | 0 | let awaited_action = Self::inner_get_awaited_action( |
366 | 0 | store.as_ref(), |
367 | 0 | self.subscription_key.borrow(), |
368 | 0 | self.maybe_client_operation_id.clone(), |
369 | 0 | &self.last_known_keepalive_ts, |
370 | 0 | ) |
371 | 0 | .await |
372 | 0 | .err_tip(|| "In OperationSubscriber::changed")?; |
373 | | |
374 | 0 | if self.maybe_last_stage.as_ref().is_some_and(|last_stage| { |
375 | 0 | *last_stage != core::mem::discriminant(&awaited_action.state().stage) |
376 | 0 | }) { |
377 | 0 | maybe_changed_action = Some(awaited_action); |
378 | 0 | } |
379 | 0 | } |
380 | | } else { |
381 | 0 | for attempt in 1..=MAX_RETRIES_FOR_CLIENT_KEEPALIVE { |
382 | 0 | if attempt > 1 { |
383 | 0 | (self.now_fn)().sleep(Duration::from_millis(100)).await; |
384 | 0 | warn!( |
385 | | ?self.subscription_key, |
386 | | attempt, |
387 | | "Client keepalive retry due to version conflict" |
388 | | ); |
389 | 0 | } |
390 | 0 | let mut awaited_action = Self::inner_get_awaited_action( |
391 | 0 | store.as_ref(), |
392 | 0 | self.subscription_key.borrow(), |
393 | 0 | self.maybe_client_operation_id.clone(), |
394 | 0 | &self.last_known_keepalive_ts, |
395 | 0 | ) |
396 | 0 | .await |
397 | 0 | .err_tip(|| "In OperationSubscriber::changed")?; |
398 | 0 | awaited_action.update_client_keep_alive(now); |
399 | 0 | maybe_changed_action = self |
400 | 0 | .maybe_last_stage |
401 | 0 | .as_ref() |
402 | 0 | .is_some_and(|last_stage| { |
403 | 0 | *last_stage |
404 | 0 | != core::mem::discriminant(&awaited_action.state().stage) |
405 | 0 | }) |
406 | 0 | .then(|| awaited_action.clone()); |
407 | 0 | let expiry = if awaited_action.is_complete() { |
408 | 0 | Some(self.retain_completed_for) |
409 | | } else { |
410 | 0 | None |
411 | | }; |
412 | 0 | match inner_update_awaited_action(store.as_ref(), awaited_action, expiry) |
413 | 0 | .await |
414 | | { |
415 | 0 | Ok(()) => break, |
416 | 0 | err if attempt == MAX_RETRIES_FOR_CLIENT_KEEPALIVE => { |
417 | 0 | err.err_tip_with_code(|_| { |
418 | 0 | (Code::Aborted, "Could not update client keep alive") |
419 | 0 | })?; |
420 | | } |
421 | 0 | _ => (), |
422 | | } |
423 | | } |
424 | | } |
425 | 1 | } |
426 | | |
427 | | // If the polling shows that it's changed state then publish now. |
428 | 1 | if let Some(changed_action0 ) = maybe_changed_action { |
429 | 0 | self.maybe_last_stage = |
430 | 0 | Some(core::mem::discriminant(&changed_action.state().stage)); |
431 | 0 | return Ok(changed_action); |
432 | 1 | } |
433 | | // Determine the sleep time based on the last client keep alive. |
434 | 1 | let sleep_time = CLIENT_KEEPALIVE_DURATION |
435 | 1 | .checked_sub( |
436 | 1 | I::from_secs(self.last_known_keepalive_ts.load(Ordering::Acquire)).elapsed(), |
437 | | ) |
438 | 1 | .unwrap_or(Duration::from_millis(100)); |
439 | 1 | tokio::select! { |
440 | 1 | result = &mut changed_fut => { |
441 | 1 | result?0 ; |
442 | 1 | break; |
443 | | } |
444 | 1 | () = (self.now_fn)().sleep(sleep_time) => { |
445 | 0 | // If we haven't received any updates for a while, we should |
446 | 0 | // let the database know that we are still listening to prevent |
447 | 0 | // the action from being dropped. Also poll for updates if the |
448 | 0 | // subscription manager is unreliable. |
449 | 0 | } |
450 | | } |
451 | | } |
452 | | |
453 | 1 | let awaited_action = Self::inner_get_awaited_action( |
454 | 1 | store.as_ref(), |
455 | 1 | self.subscription_key.borrow(), |
456 | 1 | self.maybe_client_operation_id.clone(), |
457 | 1 | &self.last_known_keepalive_ts, |
458 | 1 | ) |
459 | 1 | .await |
460 | 1 | .err_tip(|| "In OperationSubscriber::changed")?0 ; |
461 | 1 | if self.maybe_last_stage.is_some() { |
462 | 1 | self.maybe_last_stage = Some(core::mem::discriminant(&awaited_action.state().stage)); |
463 | 1 | }0 |
464 | 1 | Ok(awaited_action) |
465 | 2 | } |
466 | | |
467 | 126 | async fn borrow(&self) -> Result<AwaitedAction, Error> { |
468 | 126 | self.get_awaited_action() |
469 | 126 | .await |
470 | 126 | .err_tip(|| "In OperationSubscriber::borrow") |
471 | 126 | } |
472 | | } |
473 | | |
474 | 93 | fn awaited_action_decode(version: i64, data: &Bytes) -> Result<AwaitedAction, Error> { |
475 | 93 | let mut awaited_action: AwaitedAction = serde_json::from_slice(data).map_err(|e| {0 |
476 | 0 | Error::from_std_err(Code::InvalidArgument, &e).append("In AwaitedAction::decode") |
477 | 0 | })?; |
478 | 93 | awaited_action.set_version(version); |
479 | 93 | Ok(awaited_action) |
480 | 93 | } |
481 | | |
482 | | const OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX: &str = "aa_"; |
483 | | const CLIENT_ID_TO_OPERATION_ID_KEY_PREFIX: &str = "cid_"; |
484 | | /// TTL bounding the cid_* mapping's lifetime so it cannot outlive its |
485 | | /// aa_* key and accumulate as a permanent orphan (24h safely exceeds |
486 | | /// any real action lifetime). |
487 | | const CLIENT_ID_MAPPING_TTL: Duration = Duration::from_hours(24); |
488 | | /// Phase 2: Separate key prefix for client keepalives (non-versioned). |
489 | | const CLIENT_KEEPALIVE_KEY_PREFIX: &str = "ck_"; |
490 | | /// One record per scheduler holding what its connected workers can run |
491 | | /// (non-versioned, expires on its own). |
492 | | const FLEET_CAPABILITIES_KEY_PREFIX: &str = "fc_"; |
493 | | /// Index over every fleet capabilities record. The value is the same for |
494 | | /// all of them; the index exists so peers can be listed. |
495 | | const FLEET_CAPABILITIES_INDEX_NAME: &str = "fleet"; |
496 | | |
497 | | #[derive(Debug)] |
498 | | struct OperationIdToAwaitedAction<'a>(Cow<'a, OperationId>); |
499 | | impl OperationIdToAwaitedAction<'_> { |
500 | 95 | fn borrow(&self) -> OperationIdToAwaitedAction<'_> { |
501 | 95 | OperationIdToAwaitedAction(Cow::Borrowed(self.0.as_ref())) |
502 | 95 | } |
503 | | } |
504 | | impl SchedulerStoreKeyProvider for OperationIdToAwaitedAction<'_> { |
505 | | type Versioned = TrueValue; |
506 | 101 | fn get_key(&self) -> StoreKey<'static> { |
507 | 101 | StoreKey::Str(Cow::Owned(format!( |
508 | 101 | "{OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX}{}", |
509 | 101 | self.0 |
510 | 101 | ))) |
511 | 101 | } |
512 | | } |
513 | | impl SchedulerStoreDecodeTo for OperationIdToAwaitedAction<'_> { |
514 | | type DecodeOutput = AwaitedAction; |
515 | 48 | fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
516 | 48 | awaited_action_decode(version, &data) |
517 | 48 | } |
518 | | } |
519 | | |
520 | | struct ClientIdToOperationId<'a>(&'a OperationId); |
521 | | impl SchedulerStoreKeyProvider for ClientIdToOperationId<'_> { |
522 | | type Versioned = FalseValue; |
523 | 18 | fn get_key(&self) -> StoreKey<'static> { |
524 | 18 | StoreKey::Str(Cow::Owned(format!( |
525 | 18 | "{CLIENT_ID_TO_OPERATION_ID_KEY_PREFIX}{}", |
526 | 18 | self.0 |
527 | 18 | ))) |
528 | 18 | } |
529 | | } |
530 | | impl SchedulerStoreDecodeTo for ClientIdToOperationId<'_> { |
531 | | type DecodeOutput = OperationId; |
532 | 2 | fn decode(_version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
533 | 2 | serde_json::from_slice(&data).map_err(|e| {0 |
534 | 0 | Error::from_std_err(Code::InvalidArgument, &e).append(format!( |
535 | | "In ClientIdToOperationId::decode (data: {data:02x?})", |
536 | | )) |
537 | 0 | }) |
538 | 2 | } |
539 | | } |
540 | | |
541 | | struct ClientKeepaliveKey<'a>(&'a OperationId); |
542 | | impl SchedulerStoreKeyProvider for ClientKeepaliveKey<'_> { |
543 | | type Versioned = FalseValue; |
544 | 81 | fn get_key(&self) -> StoreKey<'static> { |
545 | 81 | StoreKey::Str(Cow::Owned(format!( |
546 | 81 | "{CLIENT_KEEPALIVE_KEY_PREFIX}{}", |
547 | 81 | self.0 |
548 | 81 | ))) |
549 | 81 | } |
550 | | } |
551 | | impl SchedulerStoreDecodeTo for ClientKeepaliveKey<'_> { |
552 | | type DecodeOutput = u64; |
553 | 0 | fn decode(_version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
554 | 0 | let s = core::str::from_utf8(&data).map_err(|e| { |
555 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
556 | 0 | .append("In ClientKeepaliveKey::decode utf8") |
557 | 0 | })?; |
558 | 0 | s.parse::<u64>().map_err(|e| { |
559 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
560 | 0 | .append("In ClientKeepaliveKey::decode parse") |
561 | 0 | }) |
562 | 0 | } |
563 | | } |
564 | | |
565 | | struct UpdateClientKeepalive<'a> { |
566 | | operation_id: &'a OperationId, |
567 | | timestamp: u64, |
568 | | } |
569 | | impl SchedulerStoreKeyProvider for UpdateClientKeepalive<'_> { |
570 | | type Versioned = FalseValue; |
571 | 0 | fn get_key(&self) -> StoreKey<'static> { |
572 | 0 | ClientKeepaliveKey(self.operation_id).get_key() |
573 | 0 | } |
574 | | } |
575 | | impl SchedulerStoreDataProvider for UpdateClientKeepalive<'_> { |
576 | 0 | fn try_into_bytes(self) -> Result<Bytes, Error> { |
577 | 0 | Ok(Bytes::from(self.timestamp.to_string())) |
578 | 0 | } |
579 | | } |
580 | | |
581 | | /// What one scheduler's workers can run, as published to its peers. |
582 | | #[derive(Serialize, Deserialize)] |
583 | | struct FleetCapabilities { |
584 | | scheduler_id: String, |
585 | | workers: Vec<PlatformProperties>, |
586 | | } |
587 | | |
588 | | struct UpdateFleetCapabilities(FleetCapabilities); |
589 | | impl SchedulerStoreKeyProvider for UpdateFleetCapabilities { |
590 | | type Versioned = FalseValue; |
591 | 58 | fn get_key(&self) -> StoreKey<'static> { |
592 | 58 | StoreKey::Str(Cow::Owned(format!( |
593 | 58 | "{FLEET_CAPABILITIES_KEY_PREFIX}{}", |
594 | 58 | self.0.scheduler_id |
595 | 58 | ))) |
596 | 58 | } |
597 | | } |
598 | | impl SchedulerStoreDataProvider for UpdateFleetCapabilities { |
599 | 58 | fn try_into_bytes(self) -> Result<Bytes, Error> { |
600 | 58 | serde_json::to_string(&self.0) |
601 | 58 | .map(Bytes::from) |
602 | 58 | .map_err(|e| {0 |
603 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
604 | 0 | .append("Could not convert FleetCapabilities to json") |
605 | 0 | }) |
606 | 58 | } |
607 | 58 | fn get_indexes(&self) -> Result<Vec<(&'static str, Bytes)>, Error> { |
608 | 58 | Ok(vec![( |
609 | 58 | FLEET_CAPABILITIES_INDEX_NAME, |
610 | 58 | Bytes::from_static(b"1"), |
611 | 58 | )]) |
612 | 58 | } |
613 | | } |
614 | | |
615 | | struct SearchFleetCapabilities; |
616 | | impl SchedulerIndexProvider for SearchFleetCapabilities { |
617 | | const KEY_PREFIX: &'static str = FLEET_CAPABILITIES_KEY_PREFIX; |
618 | | const INDEX_NAME: &'static str = FLEET_CAPABILITIES_INDEX_NAME; |
619 | | type Versioned = FalseValue; |
620 | 58 | fn index_value(&self) -> Cow<'_, str> { |
621 | 58 | Cow::Borrowed("1") |
622 | 58 | } |
623 | | } |
624 | | impl SchedulerStoreDecodeTo for SearchFleetCapabilities { |
625 | | type DecodeOutput = FleetCapabilities; |
626 | 100 | fn decode(_version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
627 | 100 | serde_json::from_slice(&data).map_err(|e| {0 |
628 | 0 | Error::from_std_err(Code::Internal, &e).append("In SearchFleetCapabilities::decode") |
629 | 0 | }) |
630 | 100 | } |
631 | | } |
632 | | |
633 | | // TODO(palfrey) We only need operation_id here, it would be nice if we had a way |
634 | | // to tell the decoder we only care about specific fields. |
635 | | struct SearchUniqueQualifierToAwaitedAction<'a>(&'a ActionUniqueQualifier); |
636 | | impl SchedulerIndexProvider for SearchUniqueQualifierToAwaitedAction<'_> { |
637 | | const KEY_PREFIX: &'static str = OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX; |
638 | | const INDEX_NAME: &'static str = "unique_qualifier"; |
639 | | type Versioned = TrueValue; |
640 | 27 | fn index_value(&self) -> Cow<'_, str> { |
641 | 27 | Cow::Owned(format!("{}", self.0)) |
642 | 27 | } |
643 | | } |
644 | | impl SchedulerStoreDecodeTo for SearchUniqueQualifierToAwaitedAction<'_> { |
645 | | type DecodeOutput = AwaitedAction; |
646 | 11 | fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
647 | 11 | awaited_action_decode(version, &data) |
648 | 11 | } |
649 | | } |
650 | | |
651 | | struct SearchStateToAwaitedAction(&'static str); |
652 | | impl SchedulerIndexProvider for SearchStateToAwaitedAction { |
653 | | const KEY_PREFIX: &'static str = OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX; |
654 | | const INDEX_NAME: &'static str = "state"; |
655 | | const MAYBE_SORT_KEY: Option<&'static str> = Some("sort_key"); |
656 | | // The matcher walks the queue highest priority first, oldest first, |
657 | | // which is descending over the packed sort key (see |
658 | | // `AwaitedActionSortKey`). The memory backend does the same with |
659 | | // `.rev()` over its `BTreeSet`. |
660 | | const SORT_DESCENDING: bool = true; |
661 | | type Versioned = TrueValue; |
662 | 41 | fn index_value(&self) -> Cow<'_, str> { |
663 | 41 | Cow::Borrowed(self.0) |
664 | 41 | } |
665 | | } |
666 | | impl SchedulerStoreDecodeTo for SearchStateToAwaitedAction { |
667 | | type DecodeOutput = AwaitedAction; |
668 | 34 | fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> { |
669 | 34 | awaited_action_decode(version, &data) |
670 | 34 | } |
671 | | } |
672 | | |
673 | | /// Counts the actions in a state. Uses the same index as |
674 | | /// [`SearchStateToAwaitedAction`], but only ever as a `count_by_index_prefix` |
675 | | /// argument, so the store returns a total and never reads the actions. |
676 | | struct CountActionsInState(&'static str); |
677 | | impl SchedulerIndexProvider for CountActionsInState { |
678 | | const KEY_PREFIX: &'static str = OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX; |
679 | | const INDEX_NAME: &'static str = "state"; |
680 | | const MAYBE_SORT_KEY: Option<&'static str> = Some("sort_key"); |
681 | | type Versioned = TrueValue; |
682 | 0 | fn index_value(&self) -> Cow<'_, str> { |
683 | 0 | Cow::Borrowed(self.0) |
684 | 0 | } |
685 | | } |
686 | | |
687 | 92 | const fn get_state_prefix(state: SortedAwaitedActionState) -> &'static str { |
688 | 92 | match state { |
689 | 3 | SortedAwaitedActionState::CacheCheck => "cache_check", |
690 | 67 | SortedAwaitedActionState::Queued => "queued", |
691 | 17 | SortedAwaitedActionState::Executing => "executing", |
692 | 5 | SortedAwaitedActionState::Completed => "completed", |
693 | | } |
694 | 92 | } |
695 | | |
696 | | /// Reports how many actions the store holds in each stage as |
697 | | /// `execution.active.count`. |
698 | | /// |
699 | | /// The store is shared, so every scheduler replica reports the same totals: |
700 | | /// aggregate across replicas with `max`, not `sum`. Recording the change since |
701 | | /// the last pass, rather than adding and subtracting per transition, keeps the |
702 | | /// count right across restarts and for transitions another replica made. |
703 | 3 | async fn report_active_counts<S: SchedulerStore>( |
704 | 3 | store: &S, |
705 | 3 | reported: &mut [Option<i64>; COUNTED_STATES.len()], |
706 | 3 | ) { |
707 | 12 | for ((state, stage), last) in COUNTED_STATES3 .iter3 ().zip3 (reported3 .iter_mut3 ()) { |
708 | 12 | let count = store |
709 | 12 | .count_by_index_prefix(CountActionsInState(get_state_prefix(*state))) |
710 | 12 | .await |
711 | 12 | .map(|count| i64::try_from(count).unwrap_or(i64::MAX)); |
712 | 12 | match count { |
713 | | // The first pass records even a zero, so every stage has a series |
714 | | // and an empty queue reads as 0 rather than no data. |
715 | 12 | Ok(count4 ) if *last != Some(count)4 => { |
716 | 4 | EXECUTION_METRICS.execution_active_count.add( |
717 | 4 | count - last.unwrap_or(0), |
718 | 4 | &[KeyValue::new(EXECUTION_STAGE, *stage)], |
719 | 4 | ); |
720 | 4 | *last = Some(count); |
721 | 4 | } |
722 | 8 | Ok(_) => {} |
723 | 0 | Err(err) => warn!( |
724 | | ?err, |
725 | | ?stage, |
726 | | "Failed to count actions for execution.active.count" |
727 | | ), |
728 | | } |
729 | | } |
730 | 3 | } |
731 | | |
732 | | #[derive(Debug)] |
733 | | pub struct UpdateOperationIdToAwaitedAction(AwaitedAction); |
734 | | impl SchedulerCurrentVersionProvider for UpdateOperationIdToAwaitedAction { |
735 | 38 | fn current_version(&self) -> i64 { |
736 | 38 | self.0.version() |
737 | 38 | } |
738 | | } |
739 | | impl SchedulerStoreKeyProvider for UpdateOperationIdToAwaitedAction { |
740 | | type Versioned = TrueValue; |
741 | 38 | fn get_key(&self) -> StoreKey<'static> { |
742 | 38 | OperationIdToAwaitedAction(Cow::Borrowed(self.0.operation_id())).get_key() |
743 | 38 | } |
744 | | } |
745 | | impl SchedulerStoreDataProvider for UpdateOperationIdToAwaitedAction { |
746 | 39 | fn try_into_bytes(self) -> Result<Bytes, Error> { |
747 | 39 | serde_json::to_string(&self.0) |
748 | 39 | .map(Bytes::from) |
749 | 39 | .map_err(|e| {0 |
750 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
751 | 0 | .append("Could not convert AwaitedAction to json") |
752 | 0 | }) |
753 | 39 | } |
754 | 38 | fn get_indexes(&self) -> Result<Vec<(&'static str, Bytes)>, Error> { |
755 | 38 | let unique_qualifier = &self.0.action_info().unique_qualifier; |
756 | 38 | let maybe_unique_qualifier = match &unique_qualifier { |
757 | 38 | ActionUniqueQualifier::Cacheable(_) => Some(unique_qualifier), |
758 | 0 | ActionUniqueQualifier::Uncacheable(_) => None, |
759 | | }; |
760 | 38 | let mut output = Vec::with_capacity(2 + maybe_unique_qualifier.map_or(0, |_| 1)); |
761 | 38 | if maybe_unique_qualifier.is_some() { |
762 | 38 | output.push(( |
763 | 38 | "unique_qualifier", |
764 | 38 | Bytes::from(unique_qualifier.to_string()), |
765 | 38 | )); |
766 | 38 | }0 |
767 | | { |
768 | 38 | let state = SortedAwaitedActionState::try_from(&self.0.state().stage) |
769 | 38 | .err_tip(|| "In UpdateOperationIdToAwaitedAction::get_index")?0 ; |
770 | 38 | output.push(("state", Bytes::from(get_state_prefix(state)))); |
771 | 38 | let sorted_awaited_action = SortedAwaitedAction::from(&self.0); |
772 | 38 | output.push(( |
773 | 38 | "sort_key", |
774 | 38 | // We encode to fixed-width hex to ensure that the sort key is |
775 | 38 | // lexicographically sorted. |
776 | 38 | Bytes::from(sorted_awaited_action.sort_key.index_field()), |
777 | 38 | )); |
778 | | } |
779 | 38 | Ok(output) |
780 | 38 | } |
781 | | } |
782 | | |
783 | | struct UpdateClientIdToOperationId { |
784 | | client_operation_id: ClientOperationId, |
785 | | operation_id: OperationId, |
786 | | } |
787 | | impl SchedulerStoreKeyProvider for UpdateClientIdToOperationId { |
788 | | type Versioned = FalseValue; |
789 | 16 | fn get_key(&self) -> StoreKey<'static> { |
790 | 16 | ClientIdToOperationId(&self.client_operation_id).get_key() |
791 | 16 | } |
792 | | } |
793 | | impl SchedulerStoreDataProvider for UpdateClientIdToOperationId { |
794 | 16 | fn try_into_bytes(self) -> Result<Bytes, Error> { |
795 | 16 | serde_json::to_string(&self.operation_id) |
796 | 16 | .map(Bytes::from) |
797 | 16 | .map_err(|e| {0 |
798 | 0 | Error::from_std_err(Code::InvalidArgument, &e) |
799 | 0 | .append("Could not convert OperationId to json") |
800 | 0 | }) |
801 | 16 | } |
802 | | } |
803 | | |
804 | 23 | pub async fn inner_update_awaited_action( |
805 | 23 | store: &impl SchedulerStore, |
806 | 23 | mut new_awaited_action: AwaitedAction, |
807 | 23 | expiry: Option<Duration>, |
808 | 23 | ) -> Result<(), Error> { |
809 | 23 | let operation_id = new_awaited_action.operation_id().clone(); |
810 | 23 | if new_awaited_action.state().client_operation_id != operation_id { |
811 | 0 | new_awaited_action.set_client_operation_id(operation_id.clone()); |
812 | 23 | } |
813 | | |
814 | 23 | let _is_finished = new_awaited_action.state().stage.is_finished(); |
815 | | |
816 | 23 | let maybe_version = store |
817 | 23 | .update_data(UpdateOperationIdToAwaitedAction(new_awaited_action), expiry) |
818 | 23 | .await |
819 | 23 | .err_tip(|| "In RedisAwaitedActionDb::update_awaited_action")?0 ; |
820 | | |
821 | 23 | if maybe_version.is_none() { |
822 | 5 | warn!( |
823 | | %operation_id, |
824 | | "Could not update AwaitedAction because the version did not match" |
825 | | ); |
826 | 5 | return Err(make_err!( |
827 | 5 | Code::Aborted, |
828 | 5 | "Could not update AwaitedAction because the version did not match for {operation_id}", |
829 | 5 | )); |
830 | 18 | } |
831 | | |
832 | 18 | Ok(()) |
833 | 23 | } |
834 | | |
835 | | #[derive(Debug, MetricsComponent)] |
836 | | pub struct StoreAwaitedActionDb<S, F, I, NowFn> |
837 | | where |
838 | | S: SchedulerStore, |
839 | | F: Fn() -> OperationId, |
840 | | I: InstantWrapper, |
841 | | NowFn: Fn() -> I, |
842 | | { |
843 | | store: Arc<S>, |
844 | | now_fn: NowFn, |
845 | | operation_id_creator: F, |
846 | | _pull_task_change_subscriber_spawn: JoinHandleDropGuard<()>, |
847 | | _active_count_spawn: Option<JoinHandleDropGuard<()>>, |
848 | | retain_completed_for: Duration, |
849 | | client_keepalive_ttl: Duration, |
850 | | worker_registry: Option<SharedWorkerRegistry>, |
851 | | } |
852 | | |
853 | | impl<S, F, I, NowFn> StoreAwaitedActionDb<S, F, I, NowFn> |
854 | | where |
855 | | S: SchedulerStore, |
856 | | F: Fn() -> OperationId, |
857 | | I: InstantWrapper, |
858 | | NowFn: Fn() -> I + Send + Sync + Clone + 'static, |
859 | | { |
860 | 24 | pub async fn new( |
861 | 24 | store: Arc<S>, |
862 | 24 | task_change_publisher: Arc<Notify>, |
863 | 24 | now_fn: NowFn, |
864 | 24 | operation_id_creator: F, |
865 | 24 | retain_completed_for_s: u32, |
866 | 24 | client_action_timeout_s: u64, |
867 | 24 | enable_active_action_count_metric: bool, |
868 | 24 | ) -> Result<Self, Error> { |
869 | 24 | let mut subscription = store |
870 | 24 | .subscription_manager() |
871 | 24 | .await |
872 | 24 | .err_tip(|| "In RedisAwaitedActionDb::new")?0 |
873 | 24 | .subscribe(OperationIdToAwaitedAction(Cow::Owned(OperationId::String( |
874 | 24 | String::new(), |
875 | 24 | )))) |
876 | 24 | .err_tip(|| "In RedisAwaitedActionDb::new")?0 ; |
877 | 24 | let pull_task_change_subscriber = spawn!( |
878 | | "redis_awaited_action_db_pull_task_change_subscriber", |
879 | 17 | async move { |
880 | | loop { |
881 | 61 | let changed_res44 = subscription |
882 | 61 | .changed() |
883 | 61 | .await |
884 | 44 | .err_tip(|| "In RedisAwaitedActionDb::new"); |
885 | 44 | if let Err(err0 ) = changed_res { |
886 | 0 | error!( |
887 | | "Error waiting for pull task change subscriber in RedisAwaitedActionDb::new - {err:?}" |
888 | | ); |
889 | | // Sleep for a second to avoid a busy loop, then trigger the notify |
890 | | // so if a reconnect happens we let local resources know that things |
891 | | // might have changed. |
892 | 0 | tokio::time::sleep(Duration::from_secs(1)).await; |
893 | 44 | } |
894 | 44 | task_change_publisher.as_ref().notify_one(); |
895 | | } |
896 | | } |
897 | | ); |
898 | | // Off by default: counting queries the same store that serves action |
899 | | // scheduling, once per interval per replica, so it is opt-in rather |
900 | | // than a cost every deployment pays for a metric it may not read. |
901 | 24 | let active_count_spawn = enable_active_action_count_metric.then(|| {1 |
902 | 1 | let weak_store = Arc::downgrade(&store); |
903 | 1 | spawn!("store_awaited_action_db_active_count", async move { |
904 | 1 | let mut reported = [None; COUNTED_STATES.len()]; |
905 | | loop { |
906 | | // Wait first, so constructing the db never queries the store. |
907 | 4 | tokio::time::sleep(ACTIVE_COUNT_REFRESH_INTERVAL).await; |
908 | 3 | let Some(store) = weak_store.upgrade() else { |
909 | 0 | return; |
910 | | }; |
911 | 3 | report_active_counts(store.as_ref(), &mut reported).await; |
912 | | } |
913 | 0 | }) |
914 | 1 | }); |
915 | 24 | Ok(Self { |
916 | 24 | store, |
917 | 24 | now_fn, |
918 | 24 | operation_id_creator, |
919 | 24 | _pull_task_change_subscriber_spawn: pull_task_change_subscriber, |
920 | 24 | _active_count_spawn: active_count_spawn, |
921 | 24 | retain_completed_for: Duration::from_secs(retain_completed_for_s.into()), |
922 | 24 | client_keepalive_ttl: Duration::from_secs(client_action_timeout_s), |
923 | 24 | worker_registry: None, |
924 | 24 | }) |
925 | 24 | } |
926 | | |
927 | | /// Whether an executing action looks abandoned and should be recreated |
928 | | /// rather than joined. |
929 | | /// |
930 | | /// `last_worker_updated_timestamp` lives in the shared store and |
931 | | /// heartbeats never refresh it, so on its own it goes stale on any action |
932 | | /// outliving `worker_timeout_s` and a healthy worker looks abandoned. |
933 | | /// Consult the registry first and only fall back to the timestamp for |
934 | | /// workers this instance owns. |
935 | | #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this |
936 | 11 | async fn executing_action_is_abandoned( |
937 | 11 | &self, |
938 | 11 | awaited_action: &AwaitedAction, |
939 | 11 | no_event_action_timeout: Duration, |
940 | 11 | now: SystemTime, |
941 | 11 | ) -> bool { |
942 | 11 | if awaited_action.state().stage != ActionStage::Executing { |
943 | 3 | return false; |
944 | 8 | } |
945 | | |
946 | 8 | let liveness = match (&self.worker_registry, awaited_action.worker_id()) { |
947 | 7 | (Some(worker_registry), Some(worker_id)) => { |
948 | 7 | worker_registry |
949 | 7 | .check_liveness(worker_id, no_event_action_timeout, now) |
950 | 7 | .await |
951 | | } |
952 | | // No registry, or not assigned yet: timestamp only, as before. |
953 | 1 | _ => WorkerLiveness::Stale, |
954 | | }; |
955 | | |
956 | 8 | let ceiling4 = match liveness { |
957 | | // Ours and heartbeating. Never recreate: that forks a second |
958 | | // execution of work already running. |
959 | 4 | WorkerLiveness::Alive => return false, |
960 | | // Ours and gone quiet. |
961 | 2 | WorkerLiveness::Stale => no_event_action_timeout, |
962 | | // A peer's worker, or an orphan nobody owns. |
963 | 2 | WorkerLiveness::Unknown => ORPHANED_ACTION_TIMEOUT, |
964 | | }; |
965 | | |
966 | 4 | awaited_action |
967 | 4 | .last_worker_updated_timestamp() |
968 | 4 | .checked_add(ceiling) |
969 | 4 | .is_some_and(|deadline| deadline < now) |
970 | 11 | } |
971 | | |
972 | | // `pub` so integration tests in `tests/` can drive this directly; |
973 | | // matches the precedent of `inner_update_awaited_action` below. |
974 | | #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this |
975 | 24 | pub async fn try_subscribe( |
976 | 24 | &self, |
977 | 24 | client_operation_id: &ClientOperationId, |
978 | 24 | unique_qualifier: &ActionUniqueQualifier, |
979 | 24 | no_event_action_timeout: Duration, |
980 | 24 | // TODO(palfrey) To simplify the scheduler 2024 refactor, we |
981 | 24 | // removed the ability to upgrade priorities of actions. |
982 | 24 | // we should add priority upgrades back in. |
983 | 24 | _priority: i32, |
984 | 24 | ) -> Result<Option<AwaitedAction>, Error> { |
985 | | // Retry once on miss: closes the RediSearch index-visibility |
986 | | // window where two concurrent `add_action` calls can both see |
987 | | // empty and create duplicate scheduler operations. |
988 | | const SUBSCRIBE_RACE_RETRY_DELAY: Duration = Duration::from_millis(20); |
989 | 24 | match unique_qualifier { |
990 | 23 | ActionUniqueQualifier::Cacheable(_) => {} |
991 | 1 | ActionUniqueQualifier::Uncacheable(_) => return Ok(None), |
992 | | } |
993 | 23 | let mut maybe_awaited_action: Option<AwaitedAction> = None; |
994 | 36 | for attempt in 0..2_u3223 { |
995 | 36 | if attempt > 0 { |
996 | 13 | tokio::time::sleep(SUBSCRIBE_RACE_RETRY_DELAY).await; |
997 | 23 | } |
998 | 36 | let stream = self |
999 | 36 | .store |
1000 | 36 | .search_by_index_prefix(SearchUniqueQualifierToAwaitedAction(unique_qualifier)) |
1001 | 36 | .await |
1002 | 36 | .err_tip(|| "In RedisAwaitedActionDb::try_subscribe")?0 ; |
1003 | 36 | tokio::pin!(stream); |
1004 | | // This index search is by prefix. In particular, an unscoped key |
1005 | | // is a prefix of its scoped variants and must not join those jobs. |
1006 | 36 | while let Some(candidate11 ) = stream |
1007 | 36 | .try_next() |
1008 | 36 | .await |
1009 | 36 | .err_tip(|| "In RedisAwaitedActionDb::try_subscribe")?0 |
1010 | | { |
1011 | 11 | if &candidate.action_info().unique_qualifier == unique_qualifier { |
1012 | 11 | maybe_awaited_action = Some(candidate); |
1013 | 11 | break; |
1014 | 0 | } |
1015 | | } |
1016 | 36 | if maybe_awaited_action.is_some() { |
1017 | 11 | break; |
1018 | 25 | } |
1019 | | } |
1020 | 23 | match maybe_awaited_action { |
1021 | 11 | Some(awaited_action) => { |
1022 | | // TODO(palfrey) We don't support joining completed jobs because we |
1023 | | // need to also check that all the data is still in the cache. |
1024 | | // If the existing job failed then we need to set back to queued or we get |
1025 | | // a version mismatch. Equally we need to check the timeout as the job |
1026 | | // may be abandoned in the store. |
1027 | 11 | let abandoned = self |
1028 | 11 | .executing_action_is_abandoned( |
1029 | 11 | &awaited_action, |
1030 | 11 | no_event_action_timeout, |
1031 | 11 | (self.now_fn)().now(), |
1032 | 11 | ) |
1033 | 11 | .await; |
1034 | 11 | let awaited_action = if awaited_action.state().stage.is_finished() || abandoned { |
1035 | 3 | tracing::debug!( |
1036 | | "Recreating action {:?} for operation {client_operation_id}", |
1037 | 3 | awaited_action.action_info().digest() |
1038 | | ); |
1039 | | // The version is reset because we have a new operation ID. |
1040 | 3 | AwaitedAction::new( |
1041 | 3 | (self.operation_id_creator)(), |
1042 | 3 | awaited_action.action_info().clone(), |
1043 | 3 | (self.now_fn)().now(), |
1044 | | ) |
1045 | | } else { |
1046 | 8 | tracing::debug!( |
1047 | | "Subscribing to existing action {:?} for operation {client_operation_id}", |
1048 | 8 | awaited_action.action_info().digest() |
1049 | | ); |
1050 | 8 | awaited_action |
1051 | | }; |
1052 | 11 | Ok(Some(awaited_action)) |
1053 | | } |
1054 | 12 | None => Ok(None), |
1055 | | } |
1056 | 24 | } |
1057 | | |
1058 | | #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this |
1059 | 2 | async fn inner_get_awaited_action_by_id( |
1060 | 2 | &self, |
1061 | 2 | client_operation_id: &ClientOperationId, |
1062 | 2 | ) -> Result<Option<OperationSubscriber<S, I, NowFn>>, Error> { |
1063 | 2 | let maybe_operation_id = self |
1064 | 2 | .store |
1065 | 2 | .get_and_decode(ClientIdToOperationId(client_operation_id)) |
1066 | 2 | .await |
1067 | 2 | .err_tip(|| "In RedisAwaitedActionDb::get_awaited_action_by_id")?0 ; |
1068 | 2 | let Some(operation_id) = maybe_operation_id else { |
1069 | 0 | return Ok(None); |
1070 | | }; |
1071 | | |
1072 | | // Validate that the internal operation actually exists. |
1073 | | // If it doesn't, this is an orphaned client operation mapping that should be cleaned up. |
1074 | | // This can happen when an operation is deleted (completed/timed out) but the |
1075 | | // client_id -> operation_id mapping persists in the store. |
1076 | 2 | let maybe_awaited_action = match self |
1077 | 2 | .store |
1078 | 2 | .get_and_decode(OperationIdToAwaitedAction(Cow::Borrowed(&operation_id))) |
1079 | 2 | .await |
1080 | | { |
1081 | 2 | Ok(maybe_action) => maybe_action, |
1082 | 0 | Err(err) if err.code == Code::NotFound => { |
1083 | 0 | tracing::warn!( |
1084 | | "Orphaned client operation mapping detected: client_id={} maps to operation_id={}, \ |
1085 | | but the operation does not exist in the store (NotFound). This typically happens when \ |
1086 | | an operation completes or times out but the client mapping persists.", |
1087 | | client_operation_id, |
1088 | | operation_id |
1089 | | ); |
1090 | 0 | None |
1091 | | } |
1092 | 0 | Err(err) => { |
1093 | | // Some other error occurred |
1094 | 0 | return Err(err).err_tip( |
1095 | | || "In RedisAwaitedActionDb::get_awaited_action_by_id::validate_operation", |
1096 | | ); |
1097 | | } |
1098 | | }; |
1099 | | |
1100 | 2 | if maybe_awaited_action.is_none() { |
1101 | 1 | tracing::warn!( |
1102 | | "Found orphaned client operation mapping: client_id={} -> operation_id={}, \ |
1103 | | but operation no longer exists. Returning None to prevent client from polling \ |
1104 | | a non-existent operation.", |
1105 | | client_operation_id, |
1106 | | operation_id |
1107 | | ); |
1108 | 1 | return Ok(None); |
1109 | 1 | } |
1110 | | |
1111 | 1 | Ok(Some(OperationSubscriber::new( |
1112 | 1 | Some(client_operation_id.clone()), |
1113 | 1 | OperationIdToAwaitedAction(Cow::Owned(operation_id)), |
1114 | 1 | Arc::downgrade(&self.store), |
1115 | 1 | self.now_fn.clone(), |
1116 | 1 | self.retain_completed_for, |
1117 | 1 | self.client_keepalive_ttl, |
1118 | 1 | ))) |
1119 | 2 | } |
1120 | | } |
1121 | | |
1122 | | impl<S, F, I, NowFn> AwaitedActionDb for StoreAwaitedActionDb<S, F, I, NowFn> |
1123 | | where |
1124 | | S: SchedulerStore, |
1125 | | F: Fn() -> OperationId + Send + Sync + Unpin + 'static, |
1126 | | I: InstantWrapper, |
1127 | | NowFn: Fn() -> I + Send + Sync + Unpin + Clone + 'static, |
1128 | | { |
1129 | | type Subscriber = OperationSubscriber<S, I, NowFn>; |
1130 | | |
1131 | 2 | async fn get_awaited_action_by_id( |
1132 | 2 | &self, |
1133 | 2 | client_operation_id: &ClientOperationId, |
1134 | 2 | ) -> Result<Option<Self::Subscriber>, Error> { |
1135 | 2 | self.inner_get_awaited_action_by_id(client_operation_id) |
1136 | 2 | .await |
1137 | 2 | } |
1138 | | |
1139 | 26 | fn get_by_operation_id( |
1140 | 26 | &self, |
1141 | 26 | operation_id: &OperationId, |
1142 | 26 | ) -> impl Future<Output = Result<Option<Self::Subscriber>, Error>> { |
1143 | 26 | std::future::ready(Ok(Some(OperationSubscriber::new( |
1144 | 26 | None, |
1145 | 26 | OperationIdToAwaitedAction(Cow::Owned(operation_id.clone())), |
1146 | 26 | Arc::downgrade(&self.store), |
1147 | 26 | self.now_fn.clone(), |
1148 | 26 | self.retain_completed_for, |
1149 | 26 | self.client_keepalive_ttl, |
1150 | 26 | )))) |
1151 | 26 | } |
1152 | | |
1153 | 22 | async fn update_awaited_action(&self, new_awaited_action: AwaitedAction) -> Result<(), Error> { |
1154 | 22 | let expiry = if new_awaited_action.is_complete() { |
1155 | 2 | Some(self.retain_completed_for) |
1156 | | } else { |
1157 | 20 | None |
1158 | | }; |
1159 | 22 | inner_update_awaited_action(self.store.as_ref(), new_awaited_action, expiry).await |
1160 | 22 | } |
1161 | | |
1162 | 16 | async fn add_action( |
1163 | 16 | &self, |
1164 | 16 | client_operation_id: ClientOperationId, |
1165 | 16 | action_info: Arc<ActionInfo>, |
1166 | 16 | no_event_action_timeout: Duration, |
1167 | 16 | ) -> Result<Self::Subscriber, Error> { |
1168 | | loop { |
1169 | | // Check to see if the action is already known and subscribe if it is. |
1170 | 16 | let mut awaited_action = self |
1171 | 16 | .try_subscribe( |
1172 | 16 | &client_operation_id, |
1173 | 16 | &action_info.unique_qualifier, |
1174 | 16 | no_event_action_timeout, |
1175 | 16 | action_info.priority, |
1176 | 16 | ) |
1177 | 16 | .await |
1178 | 16 | .err_tip(|| "In RedisAwaitedActionDb::add_action")?0 |
1179 | 16 | .unwrap_or_else(|| {11 |
1180 | 11 | tracing::debug!( |
1181 | | "Creating new action {:?} for operation {client_operation_id}", |
1182 | 11 | action_info.digest() |
1183 | | ); |
1184 | 11 | AwaitedAction::new( |
1185 | 11 | (self.operation_id_creator)(), |
1186 | 11 | action_info.clone(), |
1187 | 11 | (self.now_fn)().now(), |
1188 | | ) |
1189 | 11 | }); |
1190 | | |
1191 | 16 | debug_assert!( |
1192 | 0 | !awaited_action.state().stage.is_finished(), |
1193 | | "Expected a queued or executing action; completed actions must be recreated" |
1194 | | ); |
1195 | | |
1196 | 16 | let operation_id = awaited_action.operation_id().clone(); |
1197 | 16 | if awaited_action.state().client_operation_id != operation_id { |
1198 | 0 | // Just in case the client_operation_id was set to something else |
1199 | 0 | // we put it back to the underlying operation_id. |
1200 | 0 | awaited_action.set_client_operation_id(operation_id.clone()); |
1201 | 16 | } |
1202 | 16 | awaited_action.update_client_keep_alive((self.now_fn)().now()); |
1203 | | |
1204 | 16 | let version = awaited_action.version(); |
1205 | 16 | let expiry = if awaited_action.is_complete() { |
1206 | 0 | Some(self.retain_completed_for) |
1207 | | } else { |
1208 | 16 | None |
1209 | | }; |
1210 | 16 | if self |
1211 | 16 | .store |
1212 | 16 | .update_data(UpdateOperationIdToAwaitedAction(awaited_action), expiry) |
1213 | 16 | .await |
1214 | 16 | .err_tip(|| "In RedisAwaitedActionDb::add_action")?0 |
1215 | 16 | .is_none() |
1216 | | { |
1217 | | // The version was out of date, try again. |
1218 | 0 | tracing::info!( |
1219 | | "Version out of date for {:?} {operation_id} {version}, retrying.", |
1220 | 0 | action_info.digest() |
1221 | | ); |
1222 | 0 | continue; |
1223 | 16 | } |
1224 | | |
1225 | | // Bound the cid_* mapping's lifetime (see CLIENT_ID_MAPPING_TTL). |
1226 | 16 | self.store |
1227 | 16 | .update_data( |
1228 | 16 | UpdateClientIdToOperationId { |
1229 | 16 | client_operation_id: client_operation_id.clone(), |
1230 | 16 | operation_id: operation_id.clone(), |
1231 | 16 | }, |
1232 | 16 | Some(CLIENT_ID_MAPPING_TTL), |
1233 | 16 | ) |
1234 | 16 | .await |
1235 | 16 | .err_tip(|| "In RedisAwaitedActionDb::add_action while adding client mapping")?0 ; |
1236 | | |
1237 | 16 | return Ok(OperationSubscriber::new( |
1238 | 16 | Some(client_operation_id), |
1239 | 16 | OperationIdToAwaitedAction(Cow::Owned(operation_id)), |
1240 | 16 | Arc::downgrade(&self.store), |
1241 | 16 | self.now_fn.clone(), |
1242 | 16 | self.retain_completed_for, |
1243 | 16 | self.client_keepalive_ttl, |
1244 | 16 | )); |
1245 | | } |
1246 | 16 | } |
1247 | | |
1248 | 8 | fn set_worker_registry(&mut self, worker_registry: SharedWorkerRegistry) { |
1249 | 8 | self.worker_registry = Some(worker_registry); |
1250 | 8 | } |
1251 | | |
1252 | 58 | async fn exchange_fleet_capabilities( |
1253 | 58 | &self, |
1254 | 58 | scheduler_id: &str, |
1255 | 58 | local: Vec<PlatformProperties>, |
1256 | 58 | ttl: Duration, |
1257 | 58 | ) -> Result<Vec<PlatformProperties>, Error> { |
1258 | 58 | self.store |
1259 | 58 | .update_data( |
1260 | 58 | UpdateFleetCapabilities(FleetCapabilities { |
1261 | 58 | scheduler_id: scheduler_id.to_string(), |
1262 | 58 | workers: local, |
1263 | 58 | }), |
1264 | 58 | Some(ttl), |
1265 | 58 | ) |
1266 | 58 | .await |
1267 | 58 | .err_tip(|| "In StoreAwaitedActionDb::exchange_fleet_capabilities publish")?0 ; |
1268 | | |
1269 | 58 | let stream = self |
1270 | 58 | .store |
1271 | 58 | .search_by_index_prefix(SearchFleetCapabilities) |
1272 | 58 | .await |
1273 | 58 | .err_tip(|| "In StoreAwaitedActionDb::exchange_fleet_capabilities search")?0 ; |
1274 | | // Records that have expired on peers that stopped publishing are |
1275 | | // gone from the search already, so everything found here is live. |
1276 | 58 | stream |
1277 | 100 | .try_filter58 (|record| futures::future::ready(record.scheduler_id != scheduler_id)) |
1278 | 58 | .try_fold(Vec::new(), |mut peers, record| async move {42 |
1279 | 42 | peers.extend(record.workers); |
1280 | 42 | Ok(peers) |
1281 | 84 | }) |
1282 | 58 | .await |
1283 | 58 | .err_tip(|| "In StoreAwaitedActionDb::exchange_fleet_capabilities collect") |
1284 | 58 | } |
1285 | | |
1286 | 4 | fn shares_state(&self) -> bool { |
1287 | 4 | true |
1288 | 4 | } |
1289 | | |
1290 | 42 | async fn get_range_of_actions( |
1291 | 42 | &self, |
1292 | 42 | state: SortedAwaitedActionState, |
1293 | 42 | start: Bound<SortedAwaitedAction>, |
1294 | 42 | end: Bound<SortedAwaitedAction>, |
1295 | 42 | desc: bool, |
1296 | 42 | ) -> Result<impl Stream<Item = Result<Self::Subscriber, Error>> + Send, Error> { |
1297 | 42 | if !matches!0 (start, Bound::Unbounded) { |
1298 | 0 | return Err(make_err!( |
1299 | 0 | Code::Unimplemented, |
1300 | 0 | "Start bound is not supported in RedisAwaitedActionDb::get_range_of_actions", |
1301 | 0 | )); |
1302 | 42 | } |
1303 | 42 | if !matches!0 (end, Bound::Unbounded) { |
1304 | 0 | return Err(make_err!( |
1305 | 0 | Code::Unimplemented, |
1306 | 0 | "End bound is not supported in RedisAwaitedActionDb::get_range_of_actions", |
1307 | 0 | )); |
1308 | 42 | } |
1309 | | // The index is read in one direction, fixed by |
1310 | | // `SearchStateToAwaitedAction::SORT_DESCENDING`, and every caller |
1311 | | // asks for that direction. Ascending has no caller, so it is not |
1312 | | // implemented rather than silently served backwards. |
1313 | 42 | if !desc { |
1314 | 0 | return Err(make_err!( |
1315 | 0 | Code::Unimplemented, |
1316 | 0 | "Ascending order is not supported in RedisAwaitedActionDb::get_range_of_actions", |
1317 | 0 | )); |
1318 | 42 | } |
1319 | 42 | Ok(self |
1320 | 42 | .store |
1321 | 42 | .search_by_index_prefix(SearchStateToAwaitedAction(get_state_prefix(state))) |
1322 | 42 | .await |
1323 | 42 | .err_tip(|| "In RedisAwaitedActionDb::get_range_of_actions")?0 |
1324 | 42 | .map_ok(move |awaited_action| {34 |
1325 | 34 | OperationSubscriber::new( |
1326 | 34 | None, |
1327 | 34 | OperationIdToAwaitedAction(Cow::Owned(awaited_action.operation_id().clone())), |
1328 | 34 | Arc::downgrade(&self.store), |
1329 | 34 | self.now_fn.clone(), |
1330 | 34 | self.retain_completed_for, |
1331 | 34 | self.client_keepalive_ttl, |
1332 | | ) |
1333 | 34 | .listed(awaited_action) |
1334 | 34 | })) |
1335 | 42 | } |
1336 | | |
1337 | 0 | async fn get_all_awaited_actions( |
1338 | 0 | &self, |
1339 | 0 | ) -> Result<impl Stream<Item = Result<Self::Subscriber, Error>>, Error> { |
1340 | 0 | Ok(self |
1341 | 0 | .store |
1342 | 0 | .search_by_index_prefix(SearchStateToAwaitedAction("")) |
1343 | 0 | .await |
1344 | 0 | .err_tip(|| "In RedisAwaitedActionDb::get_range_of_actions")? |
1345 | 0 | .map_ok(move |awaited_action| { |
1346 | 0 | OperationSubscriber::new( |
1347 | 0 | None, |
1348 | 0 | OperationIdToAwaitedAction(Cow::Owned(awaited_action.operation_id().clone())), |
1349 | 0 | Arc::downgrade(&self.store), |
1350 | 0 | self.now_fn.clone(), |
1351 | 0 | self.retain_completed_for, |
1352 | 0 | self.client_keepalive_ttl, |
1353 | | ) |
1354 | 0 | .listed(awaited_action) |
1355 | 0 | })) |
1356 | 0 | } |
1357 | | } |