Coverage Report

Created: 2026-07-21 15:28

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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::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::spawn;
32
use nativelink_util::store_trait::{
33
    FalseValue, SchedulerCurrentVersionProvider, SchedulerIndexProvider, SchedulerStore,
34
    SchedulerStoreDataProvider, SchedulerStoreDecodeTo, SchedulerStoreKeyProvider,
35
    SchedulerSubscription, SchedulerSubscriptionManager, StoreKey, TrueValue,
36
};
37
use nativelink_util::task::JoinHandleDropGuard;
38
use tokio::sync::Notify;
39
use tracing::{error, warn};
40
41
use crate::awaited_action_db::{
42
    AwaitedAction, AwaitedActionDb, AwaitedActionSubscriber, CLIENT_KEEPALIVE_DURATION,
43
    SortedAwaitedAction, SortedAwaitedActionState,
44
};
45
46
type ClientOperationId = OperationId;
47
48
/// Maximum number of retries to update client keep alive.
49
const MAX_RETRIES_FOR_CLIENT_KEEPALIVE: u32 = 8;
50
51
/// Use separate non-versioned Redis key for client keepalives.
52
const USE_SEPARATE_CLIENT_KEEPALIVE_KEY: bool = true;
53
54
enum OperationSubscriberState<Sub> {
55
    Unsubscribed,
56
    Subscribed(Sub),
57
}
58
59
pub struct OperationSubscriber<S: SchedulerStore, I: InstantWrapper, NowFn: Fn() -> I> {
60
    maybe_client_operation_id: Option<ClientOperationId>,
61
    subscription_key: OperationIdToAwaitedAction<'static>,
62
    weak_store: Weak<S>,
63
    state: OperationSubscriberState<
64
        <S::SubscriptionManager as SchedulerSubscriptionManager>::Subscription,
65
    >,
66
    last_known_keepalive_ts: AtomicU64,
67
    now_fn: NowFn,
68
    // If the SchedulerSubscriptionManager is not reliable, then this is populated
69
    // when the state is set to subscribed.  When set it causes the state to be polled
70
    // as well as listening for the publishing.
71
    maybe_last_stage: Option<Discriminant<ActionStage>>,
72
    retain_completed_for: Duration,
73
}
74
75
impl<S: SchedulerStore, I: InstantWrapper, NowFn: Fn() -> I + core::fmt::Debug> core::fmt::Debug
76
    for OperationSubscriber<S, I, NowFn>
77
where
78
    OperationSubscriberState<
79
        <S::SubscriptionManager as SchedulerSubscriptionManager>::Subscription,
80
    >: core::fmt::Debug,
81
{
82
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83
0
        f.debug_struct("OperationSubscriber")
84
0
            .field("maybe_client_operation_id", &self.maybe_client_operation_id)
85
0
            .field("subscription_key", &self.subscription_key)
86
0
            .field("weak_store", &self.weak_store)
87
0
            .field("state", &self.state)
88
0
            .field("last_known_keepalive_ts", &self.last_known_keepalive_ts)
89
0
            .field("now_fn", &self.now_fn)
90
0
            .finish()
91
0
    }
92
}
93
impl<S, I, NowFn> OperationSubscriber<S, I, NowFn>
94
where
95
    S: SchedulerStore,
96
    I: InstantWrapper,
97
    NowFn: Fn() -> I,
98
{
99
28
    const fn new(
100
28
        maybe_client_operation_id: Option<ClientOperationId>,
101
28
        subscription_key: OperationIdToAwaitedAction<'static>,
102
28
        weak_store: Weak<S>,
103
28
        now_fn: NowFn,
104
28
        retain_completed_for: Duration,
105
28
    ) -> Self {
106
28
        Self {
107
28
            maybe_client_operation_id,
108
28
            subscription_key,
109
28
            weak_store,
110
28
            last_known_keepalive_ts: AtomicU64::new(0),
111
28
            state: OperationSubscriberState::Unsubscribed,
112
28
            now_fn,
113
28
            maybe_last_stage: None,
114
28
            retain_completed_for,
115
28
        }
116
28
    }
117
118
45
    async fn inner_get_awaited_action(
119
45
        store: &S,
120
45
        key: OperationIdToAwaitedAction<'_>,
121
45
        maybe_client_operation_id: Option<ClientOperationId>,
122
45
        last_known_keepalive_ts: &AtomicU64,
123
45
    ) -> Result<AwaitedAction, Error> {
124
45
        let mut awaited_action = store
125
45
            .get_and_decode(key.borrow())
126
45
            .await
127
45
            .err_tip(|| 
format!0
("In OperationSubscriber::get_awaited_action {key:?}"))
?0
128
45
            .ok_or_else(|| 
{0
129
0
                make_err!(
130
0
                    Code::NotFound,
131
                    "Could not find AwaitedAction for the given operation id {key:?}",
132
                )
133
0
            })?;
134
45
        if let Some(
client_operation_id6
) = maybe_client_operation_id {
135
6
            awaited_action.set_client_operation_id(client_operation_id);
136
39
        }
137
138
        // Helper to convert SystemTime to unix timestamp
139
45
        let to_unix_ts = |t: std::time::SystemTime| -> u64 {
140
45
            t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
141
45
        };
142
143
        // Check the separate keepalive key for the most recent timestamp.
144
45
        let keepalive_ts = if USE_SEPARATE_CLIENT_KEEPALIVE_KEY {
145
45
            let operation_id = key.0.as_ref();
146
45
            match store.get_and_decode(ClientKeepaliveKey(operation_id)).await {
147
0
                Ok(Some(ts)) => {
148
0
                    let awaited_ts = to_unix_ts(awaited_action.last_client_keepalive_timestamp());
149
0
                    if ts > awaited_ts {
150
0
                        let timestamp = UNIX_EPOCH + Duration::from_secs(ts);
151
0
                        awaited_action.update_client_keep_alive(timestamp);
152
0
                        ts
153
                    } else {
154
0
                        awaited_ts
155
                    }
156
                }
157
45
                Ok(None) | Err(_) => to_unix_ts(awaited_action.last_client_keepalive_timestamp()),
158
            }
159
        } else {
160
0
            to_unix_ts(awaited_action.last_client_keepalive_timestamp())
161
        };
162
163
45
        last_known_keepalive_ts.store(keepalive_ts, Ordering::Release);
164
45
        Ok(awaited_action)
165
45
    }
166
167
    #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this
168
43
    async fn get_awaited_action(&self) -> Result<AwaitedAction, Error> {
169
43
        let store = self
170
43
            .weak_store
171
43
            .upgrade()
172
43
            .err_tip(|| "Store gone in OperationSubscriber::get_awaited_action")
?0
;
173
43
        Self::inner_get_awaited_action(
174
43
            store.as_ref(),
175
43
            self.subscription_key.borrow(),
176
43
            self.maybe_client_operation_id.clone(),
177
43
            &self.last_known_keepalive_ts,
178
43
        )
179
43
        .await
180
43
    }
181
}
182
183
impl<S, I, NowFn> AwaitedActionSubscriber for OperationSubscriber<S, I, NowFn>
184
where
185
    S: SchedulerStore,
186
    I: InstantWrapper,
187
    NowFn: Fn() -> I + Send + Sync + 'static,
188
{
189
2
    async fn changed(&mut self) -> Result<AwaitedAction, Error> {
190
2
        let store = self
191
2
            .weak_store
192
2
            .upgrade()
193
2
            .err_tip(|| "Store gone in OperationSubscriber::get_awaited_action")
?0
;
194
2
        let 
subscription1
= match &mut self.state {
195
1
            OperationSubscriberState::Subscribed(subscription) => subscription,
196
            OperationSubscriberState::Unsubscribed => {
197
1
                let subscription = store
198
1
                    .subscription_manager()
199
1
                    .await
200
1
                    .err_tip(|| "In OperationSubscriber::changed::subscription_manager")
?0
201
1
                    .subscribe(self.subscription_key.borrow())
202
1
                    .err_tip(|| "In OperationSubscriber::changed::subscribe")
?0
;
203
1
                self.state = OperationSubscriberState::Subscribed(subscription);
204
                // When we've just subscribed, there may have been changes before now.
205
1
                let action = Self::inner_get_awaited_action(
206
1
                    store.as_ref(),
207
1
                    self.subscription_key.borrow(),
208
1
                    self.maybe_client_operation_id.clone(),
209
1
                    &self.last_known_keepalive_ts,
210
1
                )
211
1
                .await
212
1
                .err_tip(|| "In OperationSubscriber::changed")
?0
;
213
1
                if !<S as SchedulerStore>::SubscriptionManager::is_reliable() {
214
1
                    self.maybe_last_stage = Some(core::mem::discriminant(&action.state().stage));
215
1
                
}0
216
                // Existing changes are only interesting if the state is past queued.
217
1
                if !matches!(action.state().stage, ActionStage::Queued) {
218
1
                    return Ok(action);
219
0
                }
220
0
                let OperationSubscriberState::Subscribed(subscription) = &mut self.state else {
221
0
                    unreachable!("Subscription should be in Subscribed state");
222
                };
223
0
                subscription
224
            }
225
        };
226
227
1
        let changed_fut = subscription.changed();
228
1
        tokio::pin!(changed_fut);
229
        loop {
230
            // This is set if the maybe_last_state doesn't match the state in the store.
231
1
            let mut maybe_changed_action = None;
232
233
1
            let last_known_keepalive_ts = self.last_known_keepalive_ts.load(Ordering::Acquire);
234
1
            if I::from_secs(last_known_keepalive_ts).elapsed() > CLIENT_KEEPALIVE_DURATION {
235
0
                let now = (self.now_fn)().now();
236
0
                let now_ts = now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
237
238
0
                if USE_SEPARATE_CLIENT_KEEPALIVE_KEY {
239
0
                    let operation_id = self.subscription_key.0.as_ref();
240
0
                    let update_result = store
241
0
                        .update_data(
242
0
                            UpdateClientKeepalive {
243
0
                                operation_id,
244
0
                                timestamp: now_ts,
245
0
                            },
246
0
                            None,
247
0
                        )
248
0
                        .await;
249
250
0
                    if let Err(e) = update_result {
251
0
                        warn!(
252
                            ?self.subscription_key,
253
                            ?e,
254
                            "Failed to update client keepalive (non-versioned)"
255
                        );
256
0
                    }
257
258
                    // Update local timestamp
259
0
                    self.last_known_keepalive_ts
260
0
                        .store(now_ts, Ordering::Release);
261
262
                    // Check if state changed (for unreliable subscription managers)
263
0
                    if self.maybe_last_stage.is_some() {
264
0
                        let awaited_action = Self::inner_get_awaited_action(
265
0
                            store.as_ref(),
266
0
                            self.subscription_key.borrow(),
267
0
                            self.maybe_client_operation_id.clone(),
268
0
                            &self.last_known_keepalive_ts,
269
0
                        )
270
0
                        .await
271
0
                        .err_tip(|| "In OperationSubscriber::changed")?;
272
273
0
                        if self.maybe_last_stage.as_ref().is_some_and(|last_stage| {
274
0
                            *last_stage != core::mem::discriminant(&awaited_action.state().stage)
275
0
                        }) {
276
0
                            maybe_changed_action = Some(awaited_action);
277
0
                        }
278
0
                    }
279
                } else {
280
0
                    for attempt in 1..=MAX_RETRIES_FOR_CLIENT_KEEPALIVE {
281
0
                        if attempt > 1 {
282
0
                            (self.now_fn)().sleep(Duration::from_millis(100)).await;
283
0
                            warn!(
284
                                ?self.subscription_key,
285
                                attempt,
286
                                "Client keepalive retry due to version conflict"
287
                            );
288
0
                        }
289
0
                        let mut awaited_action = Self::inner_get_awaited_action(
290
0
                            store.as_ref(),
291
0
                            self.subscription_key.borrow(),
292
0
                            self.maybe_client_operation_id.clone(),
293
0
                            &self.last_known_keepalive_ts,
294
0
                        )
295
0
                        .await
296
0
                        .err_tip(|| "In OperationSubscriber::changed")?;
297
0
                        awaited_action.update_client_keep_alive(now);
298
0
                        maybe_changed_action = self
299
0
                            .maybe_last_stage
300
0
                            .as_ref()
301
0
                            .is_some_and(|last_stage| {
302
0
                                *last_stage
303
0
                                    != core::mem::discriminant(&awaited_action.state().stage)
304
0
                            })
305
0
                            .then(|| awaited_action.clone());
306
0
                        let expiry = if awaited_action.is_complete() {
307
0
                            Some(self.retain_completed_for)
308
                        } else {
309
0
                            None
310
                        };
311
0
                        match inner_update_awaited_action(store.as_ref(), awaited_action, expiry)
312
0
                            .await
313
                        {
314
0
                            Ok(()) => break,
315
0
                            err if attempt == MAX_RETRIES_FOR_CLIENT_KEEPALIVE => {
316
0
                                err.err_tip_with_code(|_| {
317
0
                                    (Code::Aborted, "Could not update client keep alive")
318
0
                                })?;
319
                            }
320
0
                            _ => (),
321
                        }
322
                    }
323
                }
324
1
            }
325
326
            // If the polling shows that it's changed state then publish now.
327
1
            if let Some(
changed_action0
) = maybe_changed_action {
328
0
                self.maybe_last_stage =
329
0
                    Some(core::mem::discriminant(&changed_action.state().stage));
330
0
                return Ok(changed_action);
331
1
            }
332
            // Determine the sleep time based on the last client keep alive.
333
1
            let sleep_time = CLIENT_KEEPALIVE_DURATION
334
1
                .checked_sub(
335
1
                    I::from_secs(self.last_known_keepalive_ts.load(Ordering::Acquire)).elapsed(),
336
                )
337
1
                .unwrap_or(Duration::from_millis(100));
338
1
            tokio::select! {
339
1
                result = &mut changed_fut => {
340
1
                    result
?0
;
341
1
                    break;
342
                }
343
1
                () = (self.now_fn)().sleep(sleep_time) => {
344
0
                    // If we haven't received any updates for a while, we should
345
0
                    // let the database know that we are still listening to prevent
346
0
                    // the action from being dropped.  Also poll for updates if the
347
0
                    // subscription manager is unreliable.
348
0
                }
349
            }
350
        }
351
352
1
        let awaited_action = Self::inner_get_awaited_action(
353
1
            store.as_ref(),
354
1
            self.subscription_key.borrow(),
355
1
            self.maybe_client_operation_id.clone(),
356
1
            &self.last_known_keepalive_ts,
357
1
        )
358
1
        .await
359
1
        .err_tip(|| "In OperationSubscriber::changed")
?0
;
360
1
        if self.maybe_last_stage.is_some() {
361
1
            self.maybe_last_stage = Some(core::mem::discriminant(&awaited_action.state().stage));
362
1
        
}0
363
1
        Ok(awaited_action)
364
2
    }
365
366
43
    async fn borrow(&self) -> Result<AwaitedAction, Error> {
367
43
        self.get_awaited_action()
368
43
            .await
369
43
            .err_tip(|| "In OperationSubscriber::borrow")
370
43
    }
371
}
372
373
57
fn awaited_action_decode(version: i64, data: &Bytes) -> Result<AwaitedAction, Error> {
374
57
    let mut awaited_action: AwaitedAction = serde_json::from_slice(data).map_err(|e| 
{0
375
0
        Error::from_std_err(Code::InvalidArgument, &e).append("In AwaitedAction::decode")
376
0
    })?;
377
57
    awaited_action.set_version(version);
378
57
    Ok(awaited_action)
379
57
}
380
381
const OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX: &str = "aa_";
382
const CLIENT_ID_TO_OPERATION_ID_KEY_PREFIX: &str = "cid_";
383
/// TTL bounding the cid_* mapping's lifetime so it cannot outlive its
384
/// aa_* key and accumulate as a permanent orphan (24h safely exceeds
385
/// any real action lifetime).
386
const CLIENT_ID_MAPPING_TTL: Duration = Duration::from_hours(24);
387
/// Phase 2: Separate key prefix for client keepalives (non-versioned).
388
const CLIENT_KEEPALIVE_KEY_PREFIX: &str = "ck_";
389
390
#[derive(Debug)]
391
struct OperationIdToAwaitedAction<'a>(Cow<'a, OperationId>);
392
impl OperationIdToAwaitedAction<'_> {
393
91
    fn borrow(&self) -> OperationIdToAwaitedAction<'_> {
394
91
        OperationIdToAwaitedAction(Cow::Borrowed(self.0.as_ref()))
395
91
    }
396
}
397
impl SchedulerStoreKeyProvider for OperationIdToAwaitedAction<'_> {
398
    type Versioned = TrueValue;
399
69
    fn get_key(&self) -> StoreKey<'static> {
400
69
        StoreKey::Str(Cow::Owned(format!(
401
69
            "{OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX}{}",
402
69
            self.0
403
69
        )))
404
69
    }
405
}
406
impl SchedulerStoreDecodeTo for OperationIdToAwaitedAction<'_> {
407
    type DecodeOutput = AwaitedAction;
408
46
    fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> {
409
46
        awaited_action_decode(version, &data)
410
46
    }
411
}
412
413
struct ClientIdToOperationId<'a>(&'a OperationId);
414
impl SchedulerStoreKeyProvider for ClientIdToOperationId<'_> {
415
    type Versioned = FalseValue;
416
6
    fn get_key(&self) -> StoreKey<'static> {
417
6
        StoreKey::Str(Cow::Owned(format!(
418
6
            "{CLIENT_ID_TO_OPERATION_ID_KEY_PREFIX}{}",
419
6
            self.0
420
6
        )))
421
6
    }
422
}
423
impl SchedulerStoreDecodeTo for ClientIdToOperationId<'_> {
424
    type DecodeOutput = OperationId;
425
2
    fn decode(_version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> {
426
2
        serde_json::from_slice(&data).map_err(|e| 
{0
427
0
            Error::from_std_err(Code::InvalidArgument, &e).append(format!(
428
                "In ClientIdToOperationId::decode (data: {data:02x?})",
429
            ))
430
0
        })
431
2
    }
432
}
433
434
struct ClientKeepaliveKey<'a>(&'a OperationId);
435
impl SchedulerStoreKeyProvider for ClientKeepaliveKey<'_> {
436
    type Versioned = FalseValue;
437
45
    fn get_key(&self) -> StoreKey<'static> {
438
45
        StoreKey::Str(Cow::Owned(format!(
439
45
            "{CLIENT_KEEPALIVE_KEY_PREFIX}{}",
440
45
            self.0
441
45
        )))
442
45
    }
443
}
444
impl SchedulerStoreDecodeTo for ClientKeepaliveKey<'_> {
445
    type DecodeOutput = u64;
446
0
    fn decode(_version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> {
447
0
        let s = core::str::from_utf8(&data).map_err(|e| {
448
0
            Error::from_std_err(Code::InvalidArgument, &e)
449
0
                .append("In ClientKeepaliveKey::decode utf8")
450
0
        })?;
451
0
        s.parse::<u64>().map_err(|e| {
452
0
            Error::from_std_err(Code::InvalidArgument, &e)
453
0
                .append("In ClientKeepaliveKey::decode parse")
454
0
        })
455
0
    }
456
}
457
458
struct UpdateClientKeepalive<'a> {
459
    operation_id: &'a OperationId,
460
    timestamp: u64,
461
}
462
impl SchedulerStoreKeyProvider for UpdateClientKeepalive<'_> {
463
    type Versioned = FalseValue;
464
0
    fn get_key(&self) -> StoreKey<'static> {
465
0
        ClientKeepaliveKey(self.operation_id).get_key()
466
0
    }
467
}
468
impl SchedulerStoreDataProvider for UpdateClientKeepalive<'_> {
469
0
    fn try_into_bytes(self) -> Result<Bytes, Error> {
470
0
        Ok(Bytes::from(self.timestamp.to_string()))
471
0
    }
472
}
473
474
// TODO(palfrey) We only need operation_id here, it would be nice if we had a way
475
// to tell the decoder we only care about specific fields.
476
struct SearchUniqueQualifierToAwaitedAction<'a>(&'a ActionUniqueQualifier);
477
impl SchedulerIndexProvider for SearchUniqueQualifierToAwaitedAction<'_> {
478
    const KEY_PREFIX: &'static str = OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX;
479
    const INDEX_NAME: &'static str = "unique_qualifier";
480
    type Versioned = TrueValue;
481
6
    fn index_value(&self) -> Cow<'_, str> {
482
6
        Cow::Owned(format!("{}", self.0))
483
6
    }
484
}
485
impl SchedulerStoreDecodeTo for SearchUniqueQualifierToAwaitedAction<'_> {
486
    type DecodeOutput = AwaitedAction;
487
3
    fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> {
488
3
        awaited_action_decode(version, &data)
489
3
    }
490
}
491
492
struct SearchStateToAwaitedAction(&'static str);
493
impl SchedulerIndexProvider for SearchStateToAwaitedAction {
494
    const KEY_PREFIX: &'static str = OPERATION_ID_TO_AWAITED_ACTION_KEY_PREFIX;
495
    const INDEX_NAME: &'static str = "state";
496
    const MAYBE_SORT_KEY: Option<&'static str> = Some("sort_key");
497
    type Versioned = TrueValue;
498
17
    fn index_value(&self) -> Cow<'_, str> {
499
17
        Cow::Borrowed(self.0)
500
17
    }
501
}
502
impl SchedulerStoreDecodeTo for SearchStateToAwaitedAction {
503
    type DecodeOutput = AwaitedAction;
504
8
    fn decode(version: i64, data: Bytes) -> Result<Self::DecodeOutput, Error> {
505
8
        awaited_action_decode(version, &data)
506
8
    }
507
}
508
509
34
const fn get_state_prefix(state: SortedAwaitedActionState) -> &'static str {
510
34
    match state {
511
0
        SortedAwaitedActionState::CacheCheck => "cache_check",
512
26
        SortedAwaitedActionState::Queued => "queued",
513
7
        SortedAwaitedActionState::Executing => "executing",
514
1
        SortedAwaitedActionState::Completed => "completed",
515
    }
516
34
}
517
518
#[derive(Debug)]
519
pub struct UpdateOperationIdToAwaitedAction(AwaitedAction);
520
impl SchedulerCurrentVersionProvider for UpdateOperationIdToAwaitedAction {
521
17
    fn current_version(&self) -> i64 {
522
17
        self.0.version()
523
17
    }
524
}
525
impl SchedulerStoreKeyProvider for UpdateOperationIdToAwaitedAction {
526
    type Versioned = TrueValue;
527
17
    fn get_key(&self) -> StoreKey<'static> {
528
17
        OperationIdToAwaitedAction(Cow::Borrowed(self.0.operation_id())).get_key()
529
17
    }
530
}
531
impl SchedulerStoreDataProvider for UpdateOperationIdToAwaitedAction {
532
18
    fn try_into_bytes(self) -> Result<Bytes, Error> {
533
18
        serde_json::to_string(&self.0)
534
18
            .map(Bytes::from)
535
18
            .map_err(|e| 
{0
536
0
                Error::from_std_err(Code::InvalidArgument, &e)
537
0
                    .append("Could not convert AwaitedAction to json")
538
0
            })
539
18
    }
540
17
    fn get_indexes(&self) -> Result<Vec<(&'static str, Bytes)>, Error> {
541
17
        let unique_qualifier = &self.0.action_info().unique_qualifier;
542
17
        let maybe_unique_qualifier = match &unique_qualifier {
543
17
            ActionUniqueQualifier::Cacheable(_) => Some(unique_qualifier),
544
0
            ActionUniqueQualifier::Uncacheable(_) => None,
545
        };
546
17
        let mut output = Vec::with_capacity(2 + maybe_unique_qualifier.map_or(0, |_| 1));
547
17
        if maybe_unique_qualifier.is_some() {
548
17
            output.push((
549
17
                "unique_qualifier",
550
17
                Bytes::from(unique_qualifier.to_string()),
551
17
            ));
552
17
        
}0
553
        {
554
17
            let state = SortedAwaitedActionState::try_from(&self.0.state().stage)
555
17
                .err_tip(|| "In UpdateOperationIdToAwaitedAction::get_index")
?0
;
556
17
            output.push(("state", Bytes::from(get_state_prefix(state))));
557
17
            let sorted_awaited_action = SortedAwaitedAction::from(&self.0);
558
17
            output.push((
559
17
                "sort_key",
560
17
                // We encode to hex to ensure that the sort key is lexicographically sorted.
561
17
                Bytes::from(format!("{:016x}", sorted_awaited_action.sort_key.as_u64())),
562
17
            ));
563
        }
564
17
        Ok(output)
565
17
    }
566
}
567
568
struct UpdateClientIdToOperationId {
569
    client_operation_id: ClientOperationId,
570
    operation_id: OperationId,
571
}
572
impl SchedulerStoreKeyProvider for UpdateClientIdToOperationId {
573
    type Versioned = FalseValue;
574
4
    fn get_key(&self) -> StoreKey<'static> {
575
4
        ClientIdToOperationId(&self.client_operation_id).get_key()
576
4
    }
577
}
578
impl SchedulerStoreDataProvider for UpdateClientIdToOperationId {
579
4
    fn try_into_bytes(self) -> Result<Bytes, Error> {
580
4
        serde_json::to_string(&self.operation_id)
581
4
            .map(Bytes::from)
582
4
            .map_err(|e| 
{0
583
0
                Error::from_std_err(Code::InvalidArgument, &e)
584
0
                    .append("Could not convert OperationId to json")
585
0
            })
586
4
    }
587
}
588
589
14
pub async fn inner_update_awaited_action(
590
14
    store: &impl SchedulerStore,
591
14
    mut new_awaited_action: AwaitedAction,
592
14
    expiry: Option<Duration>,
593
14
) -> Result<(), Error> {
594
14
    let operation_id = new_awaited_action.operation_id().clone();
595
14
    if new_awaited_action.state().client_operation_id != operation_id {
596
0
        new_awaited_action.set_client_operation_id(operation_id.clone());
597
14
    }
598
599
14
    let _is_finished = new_awaited_action.state().stage.is_finished();
600
601
14
    let maybe_version = store
602
14
        .update_data(UpdateOperationIdToAwaitedAction(new_awaited_action), expiry)
603
14
        .await
604
14
        .err_tip(|| "In RedisAwaitedActionDb::update_awaited_action")
?0
;
605
606
14
    if maybe_version.is_none() {
607
4
        warn!(
608
            %operation_id,
609
            "Could not update AwaitedAction because the version did not match"
610
        );
611
4
        return Err(make_err!(
612
4
            Code::Aborted,
613
4
            "Could not update AwaitedAction because the version did not match for {operation_id}",
614
4
        ));
615
10
    }
616
617
10
    Ok(())
618
14
}
619
620
#[derive(Debug, MetricsComponent)]
621
pub struct StoreAwaitedActionDb<S, F, I, NowFn>
622
where
623
    S: SchedulerStore,
624
    F: Fn() -> OperationId,
625
    I: InstantWrapper,
626
    NowFn: Fn() -> I,
627
{
628
    store: Arc<S>,
629
    now_fn: NowFn,
630
    operation_id_creator: F,
631
    _pull_task_change_subscriber_spawn: JoinHandleDropGuard<()>,
632
    retain_completed_for: Duration,
633
}
634
635
impl<S, F, I, NowFn> StoreAwaitedActionDb<S, F, I, NowFn>
636
where
637
    S: SchedulerStore,
638
    F: Fn() -> OperationId,
639
    I: InstantWrapper,
640
    NowFn: Fn() -> I + Send + Sync + Clone + 'static,
641
{
642
7
    pub async fn new(
643
7
        store: Arc<S>,
644
7
        task_change_publisher: Arc<Notify>,
645
7
        now_fn: NowFn,
646
7
        operation_id_creator: F,
647
7
        retain_completed_for_s: u32,
648
7
    ) -> Result<Self, Error> {
649
7
        let mut subscription = store
650
7
            .subscription_manager()
651
7
            .await
652
7
            .err_tip(|| "In RedisAwaitedActionDb::new")
?0
653
7
            .subscribe(OperationIdToAwaitedAction(Cow::Owned(OperationId::String(
654
7
                String::new(),
655
7
            ))))
656
7
            .err_tip(|| "In RedisAwaitedActionDb::new")
?0
;
657
7
        let pull_task_change_subscriber = spawn!(
658
            "redis_awaited_action_db_pull_task_change_subscriber",
659
6
            async move {
660
                loop {
661
22
                    let 
changed_res16
= subscription
662
22
                        .changed()
663
22
                        .await
664
16
                        .err_tip(|| "In RedisAwaitedActionDb::new");
665
16
                    if let Err(
err0
) = changed_res {
666
0
                        error!(
667
                            "Error waiting for pull task change subscriber in RedisAwaitedActionDb::new  - {err:?}"
668
                        );
669
                        // Sleep for a second to avoid a busy loop, then trigger the notify
670
                        // so if a reconnect happens we let local resources know that things
671
                        // might have changed.
672
0
                        tokio::time::sleep(Duration::from_secs(1)).await;
673
16
                    }
674
16
                    task_change_publisher.as_ref().notify_one();
675
                }
676
            }
677
        );
678
7
        Ok(Self {
679
7
            store,
680
7
            now_fn,
681
7
            operation_id_creator,
682
7
            _pull_task_change_subscriber_spawn: pull_task_change_subscriber,
683
7
            retain_completed_for: Duration::from_secs(retain_completed_for_s.into()),
684
7
        })
685
7
    }
686
687
    // `pub` so integration tests in `tests/` can drive this directly;
688
    // matches the precedent of `inner_update_awaited_action` below.
689
    #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this
690
7
    pub async fn try_subscribe(
691
7
        &self,
692
7
        client_operation_id: &ClientOperationId,
693
7
        unique_qualifier: &ActionUniqueQualifier,
694
7
        no_event_action_timeout: Duration,
695
7
        // TODO(palfrey) To simplify the scheduler 2024 refactor, we
696
7
        // removed the ability to upgrade priorities of actions.
697
7
        // we should add priority upgrades back in.
698
7
        _priority: i32,
699
7
    ) -> Result<Option<AwaitedAction>, Error> {
700
        // Retry once on miss: closes the RediSearch index-visibility
701
        // window where two concurrent `add_action` calls can both see
702
        // empty and create duplicate scheduler operations.
703
        const SUBSCRIBE_RACE_RETRY_DELAY: Duration = Duration::from_millis(20);
704
7
        match unique_qualifier {
705
6
            ActionUniqueQualifier::Cacheable(_) => {}
706
1
            ActionUniqueQualifier::Uncacheable(_) => return Ok(None),
707
        }
708
6
        let mut maybe_awaited_action: Option<AwaitedAction> = None;
709
10
        for attempt in 
0..2_u326
{
710
10
            if attempt > 0 {
711
4
                tokio::time::sleep(SUBSCRIBE_RACE_RETRY_DELAY).await;
712
6
            }
713
10
            let stream = self
714
10
                .store
715
10
                .search_by_index_prefix(SearchUniqueQualifierToAwaitedAction(unique_qualifier))
716
10
                .await
717
10
                .err_tip(|| "In RedisAwaitedActionDb::try_subscribe")
?0
;
718
10
            tokio::pin!(stream);
719
10
            maybe_awaited_action = stream
720
10
                .try_next()
721
10
                .await
722
10
                .err_tip(|| "In RedisAwaitedActionDb::try_subscribe")
?0
;
723
10
            if maybe_awaited_action.is_some() {
724
3
                break;
725
7
            }
726
        }
727
6
        match maybe_awaited_action {
728
3
            Some(awaited_action) => {
729
                // TODO(palfrey) We don't support joining completed jobs because we
730
                // need to also check that all the data is still in the cache.
731
                // If the existing job failed then we need to set back to queued or we get
732
                // a version mismatch.  Equally we need to check the timeout as the job
733
                // may be abandoned in the store.
734
3
                let worker_should_update_before = (awaited_action.state().stage
735
3
                    == ActionStage::Executing)
736
3
                    .then_some(())
737
3
                    .map(|()| 
awaited_action0
.
last_worker_updated_timestamp0
())
738
3
                    .and_then(|last_worker_updated| 
{0
739
0
                        last_worker_updated.checked_add(no_event_action_timeout)
740
0
                    });
741
3
                let awaited_action = if awaited_action.state().stage.is_finished()
742
2
                    || worker_should_update_before
743
2
                        .is_some_and(|timestamp| 
timestamp0
<
(self.now_fn)().now()0
)
744
                {
745
1
                    tracing::debug!(
746
                        "Recreating action {:?} for operation {client_operation_id}",
747
1
                        awaited_action.action_info().digest()
748
                    );
749
                    // The version is reset because we have a new operation ID.
750
1
                    AwaitedAction::new(
751
1
                        (self.operation_id_creator)(),
752
1
                        awaited_action.action_info().clone(),
753
1
                        (self.now_fn)().now(),
754
                    )
755
                } else {
756
2
                    tracing::debug!(
757
                        "Subscribing to existing action {:?} for operation {client_operation_id}",
758
2
                        awaited_action.action_info().digest()
759
                    );
760
2
                    awaited_action
761
                };
762
3
                Ok(Some(awaited_action))
763
            }
764
3
            None => Ok(None),
765
        }
766
7
    }
767
768
    #[expect(clippy::future_not_send)] // TODO(jhpratt) remove this
769
2
    async fn inner_get_awaited_action_by_id(
770
2
        &self,
771
2
        client_operation_id: &ClientOperationId,
772
2
    ) -> Result<Option<OperationSubscriber<S, I, NowFn>>, Error> {
773
2
        let maybe_operation_id = self
774
2
            .store
775
2
            .get_and_decode(ClientIdToOperationId(client_operation_id))
776
2
            .await
777
2
            .err_tip(|| "In RedisAwaitedActionDb::get_awaited_action_by_id")
?0
;
778
2
        let Some(operation_id) = maybe_operation_id else {
779
0
            return Ok(None);
780
        };
781
782
        // Validate that the internal operation actually exists.
783
        // If it doesn't, this is an orphaned client operation mapping that should be cleaned up.
784
        // This can happen when an operation is deleted (completed/timed out) but the
785
        // client_id -> operation_id mapping persists in the store.
786
2
        let maybe_awaited_action = match self
787
2
            .store
788
2
            .get_and_decode(OperationIdToAwaitedAction(Cow::Borrowed(&operation_id)))
789
2
            .await
790
        {
791
2
            Ok(maybe_action) => maybe_action,
792
0
            Err(err) if err.code == Code::NotFound => {
793
0
                tracing::warn!(
794
                    "Orphaned client operation mapping detected: client_id={} maps to operation_id={}, \
795
                    but the operation does not exist in the store (NotFound). This typically happens when \
796
                    an operation completes or times out but the client mapping persists.",
797
                    client_operation_id,
798
                    operation_id
799
                );
800
0
                None
801
            }
802
0
            Err(err) => {
803
                // Some other error occurred
804
0
                return Err(err).err_tip(
805
                    || "In RedisAwaitedActionDb::get_awaited_action_by_id::validate_operation",
806
                );
807
            }
808
        };
809
810
2
        if maybe_awaited_action.is_none() {
811
1
            tracing::warn!(
812
                "Found orphaned client operation mapping: client_id={} -> operation_id={}, \
813
                but operation no longer exists. Returning None to prevent client from polling \
814
                a non-existent operation.",
815
                client_operation_id,
816
                operation_id
817
            );
818
1
            return Ok(None);
819
1
        }
820
821
1
        Ok(Some(OperationSubscriber::new(
822
1
            Some(client_operation_id.clone()),
823
1
            OperationIdToAwaitedAction(Cow::Owned(operation_id)),
824
1
            Arc::downgrade(&self.store),
825
1
            self.now_fn.clone(),
826
1
            self.retain_completed_for,
827
1
        )))
828
2
    }
829
}
830
831
impl<S, F, I, NowFn> AwaitedActionDb for StoreAwaitedActionDb<S, F, I, NowFn>
832
where
833
    S: SchedulerStore,
834
    F: Fn() -> OperationId + Send + Sync + Unpin + 'static,
835
    I: InstantWrapper,
836
    NowFn: Fn() -> I + Send + Sync + Unpin + Clone + 'static,
837
{
838
    type Subscriber = OperationSubscriber<S, I, NowFn>;
839
840
2
    async fn get_awaited_action_by_id(
841
2
        &self,
842
2
        client_operation_id: &ClientOperationId,
843
2
    ) -> Result<Option<Self::Subscriber>, Error> {
844
2
        self.inner_get_awaited_action_by_id(client_operation_id)
845
2
            .await
846
2
    }
847
848
15
    async fn get_by_operation_id(
849
15
        &self,
850
15
        operation_id: &OperationId,
851
15
    ) -> Result<Option<Self::Subscriber>, Error> {
852
15
        Ok(Some(OperationSubscriber::new(
853
15
            None,
854
15
            OperationIdToAwaitedAction(Cow::Owned(operation_id.clone())),
855
15
            Arc::downgrade(&self.store),
856
15
            self.now_fn.clone(),
857
15
            self.retain_completed_for,
858
15
        )))
859
15
    }
860
861
13
    async fn update_awaited_action(&self, new_awaited_action: AwaitedAction) -> Result<(), Error> {
862
13
        let expiry = if new_awaited_action.is_complete() {
863
1
            Some(self.retain_completed_for)
864
        } else {
865
12
            None
866
        };
867
13
        inner_update_awaited_action(self.store.as_ref(), new_awaited_action, expiry).await
868
13
    }
869
870
4
    async fn add_action(
871
4
        &self,
872
4
        client_operation_id: ClientOperationId,
873
4
        action_info: Arc<ActionInfo>,
874
4
        no_event_action_timeout: Duration,
875
4
    ) -> Result<Self::Subscriber, Error> {
876
        loop {
877
            // Check to see if the action is already known and subscribe if it is.
878
4
            let mut awaited_action = self
879
4
                .try_subscribe(
880
4
                    &client_operation_id,
881
4
                    &action_info.unique_qualifier,
882
4
                    no_event_action_timeout,
883
4
                    action_info.priority,
884
4
                )
885
4
                .await
886
4
                .err_tip(|| "In RedisAwaitedActionDb::add_action")
?0
887
4
                .unwrap_or_else(|| 
{2
888
2
                    tracing::debug!(
889
                        "Creating new action {:?} for operation {client_operation_id}",
890
2
                        action_info.digest()
891
                    );
892
2
                    AwaitedAction::new(
893
2
                        (self.operation_id_creator)(),
894
2
                        action_info.clone(),
895
2
                        (self.now_fn)().now(),
896
                    )
897
2
                });
898
899
4
            debug_assert!(
900
0
                ActionStage::Queued == awaited_action.state().stage,
901
                "Expected action to be queued"
902
            );
903
904
4
            let operation_id = awaited_action.operation_id().clone();
905
4
            if awaited_action.state().client_operation_id != operation_id {
906
0
                // Just in case the client_operation_id was set to something else
907
0
                // we put it back to the underlying operation_id.
908
0
                awaited_action.set_client_operation_id(operation_id.clone());
909
4
            }
910
4
            awaited_action.update_client_keep_alive((self.now_fn)().now());
911
912
4
            let version = awaited_action.version();
913
4
            let expiry = if awaited_action.is_complete() {
914
0
                Some(self.retain_completed_for)
915
            } else {
916
4
                None
917
            };
918
4
            if self
919
4
                .store
920
4
                .update_data(UpdateOperationIdToAwaitedAction(awaited_action), expiry)
921
4
                .await
922
4
                .err_tip(|| "In RedisAwaitedActionDb::add_action")
?0
923
4
                .is_none()
924
            {
925
                // The version was out of date, try again.
926
0
                tracing::info!(
927
                    "Version out of date for {:?} {operation_id} {version}, retrying.",
928
0
                    action_info.digest()
929
                );
930
0
                continue;
931
4
            }
932
933
            // Bound the cid_* mapping's lifetime (see CLIENT_ID_MAPPING_TTL).
934
4
            self.store
935
4
                .update_data(
936
4
                    UpdateClientIdToOperationId {
937
4
                        client_operation_id: client_operation_id.clone(),
938
4
                        operation_id: operation_id.clone(),
939
4
                    },
940
4
                    Some(CLIENT_ID_MAPPING_TTL),
941
4
                )
942
4
                .await
943
4
                .err_tip(|| "In RedisAwaitedActionDb::add_action while adding client mapping")
?0
;
944
945
4
            return Ok(OperationSubscriber::new(
946
4
                Some(client_operation_id),
947
4
                OperationIdToAwaitedAction(Cow::Owned(operation_id)),
948
4
                Arc::downgrade(&self.store),
949
4
                self.now_fn.clone(),
950
4
                self.retain_completed_for,
951
4
            ));
952
        }
953
4
    }
954
955
17
    async fn get_range_of_actions(
956
17
        &self,
957
17
        state: SortedAwaitedActionState,
958
17
        start: Bound<SortedAwaitedAction>,
959
17
        end: Bound<SortedAwaitedAction>,
960
17
        desc: bool,
961
17
    ) -> Result<impl Stream<Item = Result<Self::Subscriber, Error>> + Send, Error> {
962
17
        if !
matches!0
(start, Bound::Unbounded) {
963
0
            return Err(make_err!(
964
0
                Code::Unimplemented,
965
0
                "Start bound is not supported in RedisAwaitedActionDb::get_range_of_actions",
966
0
            ));
967
17
        }
968
17
        if !
matches!0
(end, Bound::Unbounded) {
969
0
            return Err(make_err!(
970
0
                Code::Unimplemented,
971
0
                "Start bound is not supported in RedisAwaitedActionDb::get_range_of_actions",
972
0
            ));
973
17
        }
974
        // TODO(palfrey) This API is not difficult to implement, but there is no code path
975
        // that uses it, so no reason to implement it yet.
976
17
        if !desc {
977
0
            return Err(make_err!(
978
0
                Code::Unimplemented,
979
0
                "Descending order is not supported in RedisAwaitedActionDb::get_range_of_actions",
980
0
            ));
981
17
        }
982
17
        Ok(self
983
17
            .store
984
17
            .search_by_index_prefix(SearchStateToAwaitedAction(get_state_prefix(state)))
985
17
            .await
986
17
            .err_tip(|| "In RedisAwaitedActionDb::get_range_of_actions")
?0
987
17
            .map_ok(move |awaited_action| 
{8
988
8
                OperationSubscriber::new(
989
8
                    None,
990
8
                    OperationIdToAwaitedAction(Cow::Owned(awaited_action.operation_id().clone())),
991
8
                    Arc::downgrade(&self.store),
992
8
                    self.now_fn.clone(),
993
8
                    self.retain_completed_for,
994
                )
995
8
            }))
996
17
    }
997
998
0
    async fn get_all_awaited_actions(
999
0
        &self,
1000
0
    ) -> Result<impl Stream<Item = Result<Self::Subscriber, Error>>, Error> {
1001
0
        Ok(self
1002
0
            .store
1003
0
            .search_by_index_prefix(SearchStateToAwaitedAction(""))
1004
0
            .await
1005
0
            .err_tip(|| "In RedisAwaitedActionDb::get_range_of_actions")?
1006
0
            .map_ok(move |awaited_action| {
1007
0
                OperationSubscriber::new(
1008
0
                    None,
1009
0
                    OperationIdToAwaitedAction(Cow::Owned(awaited_action.operation_id().clone())),
1010
0
                    Arc::downgrade(&self.store),
1011
0
                    self.now_fn.clone(),
1012
0
                    self.retain_completed_for,
1013
                )
1014
0
            }))
1015
0
    }
1016
}