Coverage Report

Created: 2026-09-18 20:40

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-store/src/redis_store.rs
Line
Count
Source
1
// Copyright 2024-2026 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::cmp;
16
use core::fmt::Debug;
17
use core::marker::PhantomData;
18
use core::ops::{Bound, RangeBounds};
19
use core::pin::Pin;
20
use core::str::FromStr;
21
use core::time::Duration;
22
use std::borrow::Cow;
23
use std::collections::HashSet;
24
use std::sync::{Arc, Weak};
25
use std::time::Instant;
26
27
use async_trait::async_trait;
28
use bytes::Bytes;
29
use const_format::formatcp;
30
use futures::stream::FuturesUnordered;
31
use futures::{Stream, StreamExt, TryFutureExt, TryStreamExt, future};
32
use itertools::izip;
33
use nativelink_config::stores::{RedisMode, RedisSpec};
34
use nativelink_error::{Code, Error, ResultExt, make_err, make_input_err};
35
use nativelink_metric::MetricsComponent;
36
use nativelink_redis_tester::SubscriptionManagerNotify;
37
use nativelink_util::buf_channel::{DropCloserReadHalf, DropCloserWriteHalf};
38
use nativelink_util::common::DigestInfo;
39
use nativelink_util::health_utils::{HealthRegistryBuilder, HealthStatus, HealthStatusIndicator};
40
use nativelink_util::metrics::{record_connection_acquired, record_connection_reconnect};
41
use nativelink_util::store_trait::{
42
    BoolValue, RemoveCallback, SchedulerCurrentVersionProvider, SchedulerIndexProvider,
43
    SchedulerStore, SchedulerStoreDataProvider, SchedulerStoreDecodeTo, SchedulerStoreKeyProvider,
44
    SchedulerSubscription, SchedulerSubscriptionManager, StoreDriver, StoreKey, UploadSizeInfo,
45
};
46
use nativelink_util::task::JoinHandleDropGuard;
47
use nativelink_util::{background_spawn, spawn};
48
use parking_lot::{Mutex, RwLock};
49
use patricia_tree::StringPatriciaMap;
50
use redis::aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig};
51
use redis::cluster::ClusterClient;
52
use redis::cluster_async::ClusterConnection;
53
use redis::sentinel::{SentinelClient, SentinelNodeConnectionInfo, SentinelServerType};
54
use redis::{
55
    AsyncCommands, AsyncIter, Client, IntoConnectionInfo, PushInfo, ScanOptions, Script, Value,
56
    pipe,
57
};
58
use serde::Deserialize;
59
use serde::de::IntoDeserializer;
60
use tokio::select;
61
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
62
use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore};
63
use tokio::time::{sleep, timeout};
64
use tokio_stream::wrappers::UnboundedReceiverStream;
65
use tracing::{debug, error, info, trace, warn};
66
use url::Url;
67
use uuid::Uuid;
68
69
use crate::cas_utils::is_zero_digest;
70
use crate::redis_utils::{
71
    FtAggregateCursor, FtAggregateOptions, FtCreateOptions, SearchSchema, ft_aggregate, ft_create,
72
    ft_search_count,
73
};
74
75
/// The default size of the read chunk when reading data from Redis.
76
/// Note: If this changes it should be updated in the config documentation.
77
const DEFAULT_READ_CHUNK_SIZE: usize = 64 * 1024;
78
79
/// The default size of the connection pool if not specified.
80
/// Note: If this changes it should be updated in the config documentation.
81
const DEFAULT_CONNECTION_POOL_SIZE: usize = 3;
82
83
/// The default delay between retries if not specified.
84
/// Note: If this changes it should be updated in the config documentation.
85
const DEFAULT_RETRY_DELAY: f32 = 0.1;
86
87
/// Maximum attempts (re-resolving the master each time) for an operation to
88
/// ride out a transient Redis topology change — a failover, a dropped
89
/// connection, or brief replica lag. Used by `update`'s verify and by the read
90
/// paths so a transient error doesn't fail an otherwise healthy request.
91
const MAX_REDIS_RETRY_ATTEMPTS: u32 = 5;
92
93
/// Whether a Redis error is worth re-resolving the master and retrying: a
94
/// dropped/refused connection, an IO error, or a write that hit a freshly
95
/// demoted replica. Anything else (e.g. a real protocol/logic error) is
96
/// returned as-is.
97
14
fn is_retryable_redis_error(err: &redis::RedisError) -> bool {
98
14
    err.is_connection_dropped()
99
9
        || err.is_connection_refusal()
100
9
        || err.is_io_error()
101
8
        || err.kind() == redis::ErrorKind::Server(redis::ServerErrorKind::ReadOnly)
102
14
}
103
104
/// The default connection timeout in milliseconds if not specified.
105
/// Note: If this changes it should be updated in the config documentation.
106
const DEFAULT_CONNECTION_TIMEOUT_MS: u64 = 3000;
107
108
/// The default command timeout in milliseconds if not specified.
109
/// Note: If this changes it should be updated in the config documentation.
110
const DEFAULT_COMMAND_TIMEOUT_MS: u64 = 10_000;
111
112
/// The default `check_health` PING ceiling in milliseconds if not specified.
113
/// Note: If this changes it should be updated in the config documentation.
114
const DEFAULT_HEALTH_CHECK_TIMEOUT_MS: u64 = 4000;
115
116
/// The default maximum number of chunk uploads per update.
117
/// Note: If this changes it should be updated in the config documentation.
118
pub const DEFAULT_MAX_CHUNK_UPLOADS_PER_UPDATE: usize = 10;
119
120
/// The default COUNT value passed when scanning keys in Redis.
121
/// Note: If this changes it should be updated in the config documentation.
122
const DEFAULT_SCAN_COUNT: usize = 10_000;
123
124
/// The default COUNT value passed when scanning search indexes
125
/// Note: If this changes it should be updated in the config documentation.
126
pub const DEFAULT_MAX_COUNT_PER_CURSOR: u64 = 1_500;
127
128
const DEFAULT_CLIENT_PERMITS: usize = 500;
129
130
/// Converts a TTL to the seconds `EXPIRE` takes.
131
///
132
/// Clamped to at least one second. Redis treats `EXPIRE` with a value of zero
133
/// or less as "delete now", so a sub-second duration reaching here would
134
/// destroy the value it was meant to protect. Config already rejects zero, so
135
/// this only guards a future caller.
136
4
fn ttl_seconds(key_ttl: Duration) -> i64 {
137
4
    i64::try_from(key_ttl.as_secs()).unwrap_or(i64::MAX).max(1)
138
4
}
139
140
/// A wrapper around Redis to allow it to be reconnected.
141
pub trait RedisManager<C>
142
where
143
    C: ConnectionLike + Clone,
144
{
145
    /// Get a connection manager and a unique identifier for this connection
146
    /// which may be used to issue a reconnect later.
147
    fn get_connection(&self) -> impl Future<Output = Result<(C, Uuid), Error>> + Send;
148
149
    /// Reconnect if the uuid matches the uuid returned from `get_connection()`.
150
    fn reconnect(&self, uuid: Uuid) -> impl Future<Output = Result<(C, Uuid), Error>> + Send;
151
152
    /// Get an invocation of the update version script for a given `key`.
153
    fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_>;
154
155
    /// Configure the connection to have a psubscribe on it and perform the
156
    /// subscription on reconnect.
157
    fn psubscribe(&self, pattern: &str) -> impl Future<Output = Result<(), Error>> + Send;
158
}
159
160
#[derive(Debug)]
161
pub struct ClusterRedisManager<C>
162
where
163
    C: ConnectionLike + Clone,
164
{
165
    /// A constant Uuid, we never reconnect.
166
    uuid: Uuid,
167
168
    /// Redis script used to update a value in redis if the version matches.
169
    /// This is done by incrementing the version number and then setting the new
170
    /// data only if the version number matches the existing version number.
171
    update_if_version_matches_script: Script,
172
173
    /// The client pool connecting to the backing Redis instance(s).
174
    connection_manager: C,
175
}
176
177
impl<C> ClusterRedisManager<C>
178
where
179
    C: ConnectionLike + Clone,
180
{
181
34
    pub async fn new(mut connection_manager: C) -> Result<Self, Error> {
182
34
        let update_if_version_matches_script = Script::new(LUA_VERSION_SET_SCRIPT);
183
34
        update_if_version_matches_script
184
34
            .load_async(&mut connection_manager)
185
34
            .await
?0
;
186
34
        Ok(Self {
187
34
            uuid: Uuid::new_v4(),
188
34
            update_if_version_matches_script,
189
34
            connection_manager,
190
34
        })
191
34
    }
192
}
193
194
impl<C> RedisManager<C> for ClusterRedisManager<C>
195
where
196
    C: ConnectionLike + Clone + Send + Sync,
197
{
198
71
    fn get_connection(&self) -> impl Future<Output = Result<(C, Uuid), Error>> + Send {
199
71
        future::ready(Ok((self.connection_manager.clone(), self.uuid)))
200
71
    }
201
202
10
    fn reconnect(&self, _uuid: Uuid) -> impl Future<Output = Result<(C, Uuid), Error>> + Send {
203
10
        self.get_connection()
204
10
    }
205
206
0
    fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_> {
207
0
        self.update_if_version_matches_script.key(key)
208
0
    }
209
210
4
    fn psubscribe(&self, _pattern: &str) -> impl Future<Output = Result<(), Error>> + Send {
211
        // This is a no-op for cluster connections.
212
4
        future::ready(Ok(()))
213
4
    }
214
}
215
216
type RedisConnectFuture<C> = dyn Future<Output = Result<C, Error>> + Send;
217
type RedisConnectFn<C> = dyn Fn() -> Pin<Box<RedisConnectFuture<C>>> + Send + Sync;
218
219
pub struct StandardRedisManager<C>
220
where
221
    C: ConnectionLike + Clone,
222
{
223
    /// Function used to re-connect to Redis.
224
    connect_func: Box<RedisConnectFn<C>>,
225
226
    /// Redis script used to update a value in redis if the version matches.
227
    /// This is done by incrementing the version number and then setting the new
228
    /// data only if the version number matches the existing version number.
229
    update_if_version_matches_script: Script,
230
231
    /// The client pool connecting to the backing Redis instance(s) and a Uuid
232
    /// for this connection in order to avoid multiple reconnection attempts.
233
    connection_manager: tokio::sync::RwLock<(C, Uuid)>,
234
235
    /// Serializes reconnect attempts so a Sentinel master failover triggers a
236
    /// single re-resolution instead of a thundering herd. Kept separate from
237
    /// `connection_manager` on purpose: the (potentially multi-second) connect
238
    /// runs while holding only this lock, so in-flight `get_connection` readers
239
    /// keep using the existing handle and are never frozen behind a slow
240
    /// reconnect. See [`Self::reconnect`].
241
    reconnect_lock: tokio::sync::Mutex<()>,
242
243
    /// A list of subscription that should be performed on reconnect.
244
    subscriptions: Mutex<HashSet<String>>,
245
}
246
247
impl<C> Debug for StandardRedisManager<C>
248
where
249
    C: ConnectionLike + Clone,
250
{
251
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
252
0
        f.debug_struct("StandardRedisManager")
253
0
            .field(
254
0
                "update_if_version_matches_script",
255
0
                &self.update_if_version_matches_script,
256
0
            )
257
0
            .field("subscriptions", &self.subscriptions)
258
0
            .finish()
259
0
    }
260
}
261
262
impl<C> StandardRedisManager<C>
263
where
264
    C: ConnectionLike + Clone + Send + Sync,
265
{
266
24
    async fn configure(&self, connection_manager: &mut C) -> Result<(), Error> {
267
24
        self.update_if_version_matches_script
268
24
            .load_async(connection_manager)
269
24
            .await
?0
;
270
24
        Ok(())
271
24
    }
272
273
24
    async fn new(connect_func: Box<RedisConnectFn<C>>) -> Result<Self, Error> {
274
24
        let 
connection_manager21
= connect_func().await
?3
;
275
21
        let update_if_version_matches_script = Script::new(LUA_VERSION_SET_SCRIPT);
276
21
        let connection = Self {
277
21
            connect_func,
278
21
            update_if_version_matches_script,
279
21
            connection_manager: tokio::sync::RwLock::new((connection_manager, Uuid::new_v4())),
280
21
            reconnect_lock: tokio::sync::Mutex::new(()),
281
21
            subscriptions: Mutex::new(HashSet::new()),
282
21
        };
283
        {
284
21
            let mut connection_manager = connection.connection_manager.write().await;
285
21
            connection.configure(&mut connection_manager.0).await
?0
;
286
        }
287
21
        Ok(connection)
288
24
    }
289
}
290
291
impl RedisManager<ConnectionManager> for StandardRedisManager<ConnectionManager> {
292
164
    async fn get_connection(&self) -> Result<(ConnectionManager, Uuid), Error> {
293
164
        Ok(self.connection_manager.read().await.clone())
294
164
    }
295
296
3
    async fn reconnect(&self, uuid: Uuid) -> Result<(ConnectionManager, Uuid), Error> {
297
        // Fast path: another caller already reconnected past this generation,
298
        // so the handle is fresh — hand it back without re-resolving.
299
        {
300
3
            let guard = self.connection_manager.read().await;
301
3
            if guard.1 != uuid {
302
0
                return Ok(guard.clone());
303
3
            }
304
        }
305
        // Serialize reconnect attempts on a dedicated lock so a failover storm
306
        // resolves the new master once. Critically this is NOT the
307
        // `connection_manager` lock, so the slow connect below does not block
308
        // the `get_connection` readers that every Redis op needs — they keep
309
        // using the old (now-failing) handle and fail fast on their own
310
        // command timeout instead of all freezing behind one reconnect.
311
3
        let _reconnect_guard = self.reconnect_lock.lock().await;
312
        // Re-check: a reconnect may have completed while we waited for the lock.
313
        {
314
3
            let guard = self.connection_manager.read().await;
315
3
            if guard.1 != uuid {
316
0
                return Ok(guard.clone());
317
3
            }
318
        }
319
3
        record_connection_reconnect("redis");
320
3
        let mut connection_manager = (self.connect_func)().await
?0
;
321
3
        let new_uuid = Uuid::new_v4();
322
3
        self.configure(&mut connection_manager).await
?0
;
323
3
        let subscriptions = {
324
3
            let guard = self.subscriptions.lock();
325
3
            guard.iter().map(Clone::clone).collect::<Vec<_>>()
326
        };
327
3
        for 
subscription0
in subscriptions {
328
0
            connection_manager.psubscribe(&subscription).await?;
329
        }
330
        // Publish the new handle under a brief exclusive lock.
331
        {
332
3
            let mut guard = self.connection_manager.write().await;
333
3
            *guard = (connection_manager.clone(), new_uuid);
334
        }
335
3
        info!(old = %uuid, new = %new_uuid, "StandardRedisManager re-resolved the Redis master");
336
3
        Ok((connection_manager, new_uuid))
337
3
    }
338
339
21
    fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_> {
340
21
        self.update_if_version_matches_script.key(key)
341
21
    }
342
343
9
    async fn psubscribe(&self, pattern: &str) -> Result<(), Error> {
344
9
        debug!(pattern, "new psubscribe");
345
9
        let mut connection = self.get_connection().await
?0
.0;
346
9
        let new_subscription = self.subscriptions.lock().insert(String::from(pattern));
347
9
        if new_subscription {
348
9
            let result = connection.psubscribe(pattern).await;
349
9
            if result.is_err() {
350
0
                self.subscriptions.lock().remove(pattern);
351
9
            }
352
9
            result
?0
;
353
0
        }
354
9
        debug!(pattern, new_subscription, "new psubscribe complete");
355
9
        Ok(())
356
9
    }
357
}
358
359
/// A [`StoreDriver`] implementation that uses Redis as a backing store.
360
#[derive(MetricsComponent)]
361
pub struct RedisStore<C, M>
362
where
363
    C: ConnectionLike + Clone,
364
    M: RedisManager<C>,
365
{
366
    /// The client pool connecting to the backing Redis instance(s).
367
    connection_manager: M,
368
369
    /// The underlying connection type in the connection manager.
370
    _connection_type: PhantomData<C>,
371
372
    /// A channel to publish updates to when a key is added, removed, or modified.
373
    #[metric(
374
        help = "The pubsub channel to publish updates to when a key is added, removed, or modified"
375
    )]
376
    pub_sub_channel: Option<String>,
377
378
    /// A function used to generate names for temporary keys.
379
    temp_name_generator_fn: fn() -> String,
380
381
    /// A common prefix to append to all keys before they are sent to Redis.
382
    ///
383
    /// See [`RedisStore::key_prefix`](`nativelink_config::stores::RedisStore::key_prefix`).
384
    #[metric(help = "Prefix to append to all keys before sending to Redis")]
385
    key_prefix: String,
386
387
    /// The amount of data to read from Redis at a time.
388
    #[metric(help = "The amount of data to read from Redis at a time")]
389
    read_chunk_size: usize,
390
391
    /// The maximum number of chunk uploads per update.
392
    /// This is used to limit the number of chunk uploads per update to prevent
393
    /// overloading when uploading large blocks of data
394
    #[metric(help = "The maximum number of chunk uploads per update")]
395
    max_chunk_uploads_per_update: usize,
396
397
    /// The COUNT value passed when scanning keys in Redis.
398
    /// This is used to hint the amount of work that should be done per response.
399
    #[metric(help = "The COUNT value passed when scanning keys in Redis")]
400
    scan_count: usize,
401
402
    /// The COUNT value used with search indexes
403
    #[metric(help = "The maximum number of results to return per cursor")]
404
    max_count_per_cursor: u64,
405
406
    /// A manager for subscriptions to keys in Redis.
407
    subscription_manager: Arc<RedisSubscriptionManager>,
408
409
    /// Permits to limit inflight Redis requests. Technically only
410
    /// limits the calls to `get_client()`, but the requests per client
411
    /// are small enough that it works well enough.
412
    client_permits: Arc<Semaphore>,
413
414
    /// Per-call ceiling for `check_health` PING.
415
    health_check_timeout: Duration,
416
417
    /// Expire keys this store writes after this long. `None` keeps them
418
    /// forever, which is the default and what every store other than a BEP
419
    /// store wants.
420
    key_ttl: Option<Duration>,
421
422
    /// Have we done a subscribe for messages for `remove_callback` subscribes?
423
    has_remove_callback_subscribe: OnceCell<()>,
424
425
    remove_callbacks: Arc<async_lock::Mutex<Vec<RemoveCallback>>>,
426
}
427
428
impl<C, M> Debug for RedisStore<C, M>
429
where
430
    C: ConnectionLike + Clone,
431
    M: RedisManager<C>,
432
{
433
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
434
0
        f.debug_struct("RedisStore")
435
0
            .field("temp_name_generator_fn", &self.temp_name_generator_fn)
436
0
            .field("key_prefix", &self.key_prefix)
437
0
            .field("read_chunk_size", &self.read_chunk_size)
438
0
            .field(
439
0
                "max_chunk_uploads_per_update",
440
0
                &self.max_chunk_uploads_per_update,
441
0
            )
442
0
            .field("scan_count", &self.scan_count)
443
0
            .field("subscription_manager", &self.subscription_manager)
444
0
            .field("client_permits", &self.client_permits)
445
0
            .finish()
446
0
    }
447
}
448
449
struct ClientWithPermit<C: ConnectionLike> {
450
    connection_manager: C,
451
    uuid: Uuid,
452
453
    // here so it sticks around with the client and doesn't get dropped until that does
454
    #[allow(dead_code)]
455
    semaphore_permit: OwnedSemaphorePermit,
456
}
457
458
impl<C: ConnectionLike + Clone> ClientWithPermit<C> {
459
3
    async fn reconnect<M: RedisManager<C> + Sync>(&mut self, manager: &M) -> Result<(), Error> {
460
3
        (self.connection_manager, self.uuid) = manager.reconnect(self.uuid).await
?0
;
461
3
        Ok(())
462
3
    }
463
}
464
465
impl<C: ConnectionLike> Drop for ClientWithPermit<C> {
466
169
    fn drop(&mut self) {
467
169
        trace!(
468
169
            remaining = self.semaphore_permit.semaphore().available_permits(),
469
            "Dropping a client permit"
470
        );
471
169
    }
472
}
473
474
/// Decode a `StoreKey` coming from Redis
475
35
pub fn decode_key<'a>(
476
35
    key_prefix: &String,
477
35
    encoded_key: Cow<'a, str>,
478
35
) -> Result<StoreKey<'a>, Error> {
479
35
    let key_no_prefix = if key_prefix.is_empty() {
480
30
        encoded_key
481
    } else {
482
5
        match encoded_key.strip_prefix(key_prefix) {
483
5
            Some(r) => Cow::from(r.to_string()),
484
            None => {
485
0
                return Err(make_err!(
486
0
                    Code::InvalidArgument,
487
0
                    "Redis key ({}) is missing prefix ({})",
488
0
                    encoded_key,
489
0
                    key_prefix
490
0
                ));
491
            }
492
        }
493
    };
494
35
    let maybe_digest_info: Result<_, serde::de::value::Error> =
495
35
        DigestInfo::deserialize(key_no_prefix.clone().into_deserializer());
496
35
    if let Ok(
digest_info8
) = maybe_digest_info {
497
8
        Ok(StoreKey::Digest(digest_info))
498
    } else {
499
27
        Ok(StoreKey::Str(key_no_prefix))
500
    }
501
35
}
502
503
impl<C, M> RedisStore<C, M>
504
where
505
    C: ConnectionLike + Clone + Sync,
506
    M: RedisManager<C> + Sync,
507
{
508
    /// Used for testing when determinism is required.
509
    #[expect(clippy::too_many_arguments)]
510
55
    pub async fn new_from_builder_and_parts(
511
55
        pub_sub_channel: Option<String>,
512
55
        temp_name_generator_fn: fn() -> String,
513
55
        key_prefix: String,
514
55
        read_chunk_size: usize,
515
55
        max_chunk_uploads_per_update: usize,
516
55
        scan_count: usize,
517
55
        max_client_permits: usize,
518
55
        max_count_per_cursor: u64,
519
55
        health_check_timeout: Duration,
520
55
        key_ttl: Option<Duration>,
521
55
        subscriber_channel: UnboundedReceiver<PushInfo>,
522
55
        connection_manager: M,
523
55
    ) -> Result<Self, Error> {
524
55
        info!("Redis index fingerprint: {FINGERPRINT_CREATE_INDEX_HEX}");
525
55
        let remove_callbacks = Arc::new(async_lock::Mutex::new(Vec::new()));
526
55
        let subscription_manager = Arc::new(RedisSubscriptionManager::new(
527
55
            subscriber_channel,
528
55
            remove_callbacks.clone(),
529
55
            key_prefix.clone(),
530
        ));
531
55
        if let Some(
channel5
) = &pub_sub_channel {
532
5
            connection_manager.psubscribe(channel).await
?0
;
533
50
        }
534
535
55
        Ok(Self {
536
55
            connection_manager,
537
55
            _connection_type: PhantomData,
538
55
            pub_sub_channel,
539
55
            temp_name_generator_fn,
540
55
            key_prefix,
541
55
            read_chunk_size,
542
55
            max_chunk_uploads_per_update,
543
55
            scan_count,
544
55
            subscription_manager,
545
55
            client_permits: Arc::new(Semaphore::new(max_client_permits)),
546
55
            max_count_per_cursor,
547
55
            health_check_timeout,
548
55
            key_ttl,
549
55
            has_remove_callback_subscribe: OnceCell::const_new(),
550
55
            remove_callbacks,
551
55
        })
552
55
    }
553
554
169
    async fn get_client(&self) -> Result<ClientWithPermit<C>, Error> {
555
169
        let local_client_permits = self.client_permits.clone();
556
169
        let remaining = local_client_permits.available_permits();
557
        // Zero here means every client is busy and this call is about to wait,
558
        // which is the saturation signal worth alerting on.
559
169
        record_connection_acquired("redis", Some(remaining), remaining == 0);
560
169
        let semaphore_permit = local_client_permits.acquire_owned().await
?0
;
561
169
        trace!(remaining, "Got a client permit");
562
169
        let (connection_manager, uuid) = self.connection_manager.get_connection().await
?0
;
563
169
        Ok(ClientWithPermit {
564
169
            connection_manager,
565
169
            uuid,
566
169
            semaphore_permit,
567
169
        })
568
169
    }
569
570
    /// Encode a `StoreKey` so it can be sent to Redis.
571
160
    pub fn encode_key<'a>(&self, key: &'a StoreKey<'a>) -> Cow<'a, str> {
572
160
        let key_body = key.as_str();
573
160
        if self.key_prefix.is_empty() {
574
151
            key_body
575
        } else {
576
            // This is in the hot path for all redis operations, so we try to reuse the allocation
577
            // from `key.as_str()` if possible.
578
9
            match key_body {
579
8
                Cow::Owned(mut encoded_key) => {
580
8
                    encoded_key.insert_str(0, &self.key_prefix);
581
8
                    Cow::Owned(encoded_key)
582
                }
583
1
                Cow::Borrowed(body) => {
584
1
                    let mut encoded_key = String::with_capacity(self.key_prefix.len() + body.len());
585
1
                    encoded_key.push_str(&self.key_prefix);
586
1
                    encoded_key.push_str(body);
587
1
                    Cow::Owned(encoded_key)
588
                }
589
            }
590
        }
591
160
    }
592
593
24
    fn set_spec_defaults(spec: &mut RedisSpec) -> Result<(), Error> {
594
24
        if spec.addresses.is_empty() {
595
0
            return Err(make_err!(
596
0
                Code::InvalidArgument,
597
0
                "No addresses were specified in redis store configuration."
598
0
            ));
599
24
        }
600
601
24
        if spec.broadcast_channel_capacity != 0 {
602
1
            warn!("broadcast_channel_capacity in Redis spec is deprecated and ignored");
603
23
        }
604
24
        if spec.response_timeout_s != 0 {
605
0
            warn!(
606
                "response_timeout_s in Redis spec is deprecated and ignored, use command_timeout_ms"
607
            );
608
24
        }
609
24
        if spec.connection_timeout_s != 0 {
610
0
            if spec.connection_timeout_ms != 0 {
611
0
                return Err(make_err!(
612
0
                    Code::InvalidArgument,
613
0
                    "Both connection_timeout_s and connection_timeout_ms were set, can only have one!"
614
0
                ));
615
0
            }
616
0
            warn!("connection_timeout_s in Redis spec is deprecated, use connection_timeout_ms");
617
0
            spec.connection_timeout_ms = spec.connection_timeout_s * 1000;
618
24
        }
619
24
        if spec.connection_timeout_ms == 0 {
620
19
            spec.connection_timeout_ms = DEFAULT_CONNECTION_TIMEOUT_MS;
621
19
        
}5
622
24
        if spec.command_timeout_ms == 0 {
623
22
            spec.command_timeout_ms = DEFAULT_COMMAND_TIMEOUT_MS;
624
22
        
}2
625
24
        if spec.health_check_timeout_ms == 0 {
626
24
            spec.health_check_timeout_ms = DEFAULT_HEALTH_CHECK_TIMEOUT_MS;
627
24
        
}0
628
24
        if spec.connection_pool_size == 0 {
629
24
            spec.connection_pool_size = DEFAULT_CONNECTION_POOL_SIZE;
630
24
        
}0
631
24
        if spec.read_chunk_size == 0 {
632
24
            spec.read_chunk_size = DEFAULT_READ_CHUNK_SIZE;
633
24
        
}0
634
24
        if spec.max_count_per_cursor == 0 {
635
24
            spec.max_count_per_cursor = DEFAULT_MAX_COUNT_PER_CURSOR;
636
24
        
}0
637
24
        if spec.max_chunk_uploads_per_update == 0 {
638
24
            spec.max_chunk_uploads_per_update = DEFAULT_MAX_CHUNK_UPLOADS_PER_UPDATE;
639
24
        
}0
640
24
        if spec.scan_count == 0 {
641
24
            spec.scan_count = DEFAULT_SCAN_COUNT;
642
24
        
}0
643
24
        if spec.max_client_permits == 0 {
644
24
            spec.max_client_permits = DEFAULT_CLIENT_PERMITS;
645
24
        
}0
646
24
        if spec.retry.delay == 0.0 {
647
24
            spec.retry.delay = DEFAULT_RETRY_DELAY;
648
24
        
}0
649
24
        if spec.retry.max_retries == 0 {
650
24
            spec.retry.max_retries = 1;
651
24
        
}0
652
24
        trace!(?spec, "redis spec is after setting defaults");
653
24
        Ok(())
654
24
    }
655
656
    // Only used by tests, because we need to make a real redis connection, then fix this to get fixed values
657
10
    pub fn replace_temp_name_generator(&mut self, replacement: fn() -> String) {
658
10
        self.temp_name_generator_fn = replacement;
659
10
    }
660
}
661
662
impl RedisStore<ClusterConnection, ClusterRedisManager<ClusterConnection>> {
663
0
    pub async fn new_cluster(mut spec: RedisSpec) -> Result<Arc<Self>, Error> {
664
0
        if spec.mode != RedisMode::Cluster {
665
0
            return Err(Error::new(
666
0
                Code::InvalidArgument,
667
0
                "new_cluster only works for Cluster mode".to_string(),
668
0
            ));
669
0
        }
670
0
        Self::set_spec_defaults(&mut spec)?;
671
672
0
        let parsed_addrs: Vec<_> = spec
673
0
            .addresses
674
0
            .iter_mut()
675
0
            .map(|addr| {
676
0
                addr.clone().into_connection_info().map(|connection_info| {
677
0
                    let redis_settings = connection_info
678
0
                        .redis_settings()
679
0
                        .clone()
680
                        // We need RESP3 here because the cluster mode doesn't support RESP2 pubsub
681
                        // See also https://docs.rs/redis/latest/redis/cluster_async/index.html#pubsub
682
0
                        .set_protocol(redis::ProtocolVersion::RESP3);
683
0
                    connection_info.set_redis_settings(redis_settings)
684
0
                })
685
0
            })
686
0
            .collect::<Result<Vec<_>, _>>()?;
687
688
0
        let connection_timeout = Duration::from_millis(spec.connection_timeout_ms);
689
0
        let command_timeout = Duration::from_millis(spec.command_timeout_ms);
690
0
        let (tx, subscriber_channel) = unbounded_channel();
691
692
0
        let builder = ClusterClient::builder(parsed_addrs)
693
0
            .connection_timeout(connection_timeout)
694
0
            .response_timeout(command_timeout)
695
0
            .push_sender(tx)
696
0
            .retries(u32::try_from(spec.retry.max_retries)?);
697
698
0
        let client = builder.build()?;
699
700
0
        Self::new_from_builder_and_parts(
701
0
            spec.experimental_pub_sub_channel,
702
0
            || Uuid::new_v4().to_string(),
703
0
            spec.key_prefix.clone(),
704
0
            spec.read_chunk_size,
705
0
            spec.max_chunk_uploads_per_update,
706
0
            spec.scan_count,
707
0
            spec.max_client_permits,
708
0
            spec.max_count_per_cursor,
709
0
            Duration::from_millis(spec.health_check_timeout_ms),
710
0
            (spec.key_ttl_s > 0).then(|| Duration::from_secs(spec.key_ttl_s)),
711
0
            subscriber_channel,
712
0
            ClusterRedisManager::new(client.get_async_connection().await?).await?,
713
        )
714
0
        .await
715
0
        .map(Arc::new)
716
0
    }
717
}
718
719
impl RedisStore<ConnectionManager, StandardRedisManager<ConnectionManager>> {
720
27
    async fn connect(
721
27
        spec: RedisSpec,
722
27
        tx: UnboundedSender<PushInfo>,
723
27
    ) -> Result<ConnectionManager, Error> {
724
27
        let connection_timeout = Duration::from_millis(spec.connection_timeout_ms);
725
27
        let command_timeout = Duration::from_millis(spec.command_timeout_ms);
726
727
27
        let addr = &spec.addresses[0];
728
27
        let local_addr = addr.clone();
729
27
        let mut parsed_addr = local_addr
730
27
            .replace("redis+sentinel://", "redis://")
731
27
            .into_connection_info()
?0
;
732
733
27
        let redis_settings = parsed_addr
734
27
            .redis_settings()
735
27
            .clone()
736
            // We need RESP3 here because we want to do set_push_sender
737
27
            .set_protocol(redis::ProtocolVersion::RESP3);
738
27
        parsed_addr = parsed_addr.set_redis_settings(redis_settings);
739
27
        debug!(?parsed_addr, "Parsed redis addr");
740
741
27
        let 
client26
= timeout(
742
27
            connection_timeout,
743
27
            spawn!("connect", async move {
744
27
                match spec.mode {
745
18
                    RedisMode::Standard => Client::open(parsed_addr).map_err(Into::<Error>::into),
746
                    RedisMode::Cluster => {
747
0
                        return Err(Error::new(
748
0
                            Code::Internal,
749
0
                            "Use RedisStore::new_cluster for cluster connections".to_owned(),
750
0
                        ));
751
                    }
752
9
                    RedisMode::Sentinel => async {
753
9
                        let url_parsing = Url::parse(&local_addr)
?0
;
754
9
                        let master_name = url_parsing
755
9
                            .query_pairs()
756
9
                            .find(|(key, _)| 
key1
==
"sentinelServiceName"1
)
757
9
                            .map_or_else(|| 
"master"8
.
into8
(), |(_, value)|
value1
.
to_string1
());
758
759
9
                        let redis_connection_info = parsed_addr.redis_settings().clone();
760
9
                        let sentinel_connection_info = SentinelNodeConnectionInfo::default()
761
9
                            .set_redis_connection_info(redis_connection_info);
762
763
                        // We fish this out because sentinels don't support db, we need to set it
764
                        // on the client only. See also https://github.com/redis-rs/redis-rs/issues/1950
765
9
                        let original_db = parsed_addr.redis_settings().db();
766
9
                        if original_db != 0 {
767
1
                            // sentinel_connection_info has the actual DB set
768
1
                            let revised_settings = parsed_addr.redis_settings().clone().set_db(0);
769
1
                            parsed_addr = parsed_addr.set_redis_settings(revised_settings);
770
8
                        }
771
772
9
                        SentinelClient::build(
773
9
                            vec![parsed_addr],
774
9
                            master_name,
775
9
                            Some(sentinel_connection_info),
776
9
                            SentinelServerType::Master,
777
                        )
778
9
                        .map_err(Into::<Error>::into)
779
9
                    }
780
18
                    .
and_then9
(|mut s| async move
{9
Ok(
s9
.async_get_client().await) })
781
9
                    .await
?0
782
9
                    .map_err(Into::<Error>::into),
783
                }
784
                // Keep the underlying error's code rather than forcing
785
                // InvalidArgument. Not every connect failure is a bad URL: a
786
                // sentinel failover in progress reports the master as missing,
787
                // which is transient and retryable, and overriding it to a
788
                // permanent status made clients give up on a recoverable blip.
789
27
                .err_tip(|| 
format!1
("While connecting to redis with url: {local_addr}"))
790
27
            }),
791
        )
792
27
        .await
793
27
        .err_tip(|| 
format!0
("Timeout while connecting to redis with url: {addr}"))
?0
?0
?1
;
794
795
26
        let connection_manager_config = {
796
26
            ConnectionManagerConfig::new()
797
26
                .set_number_of_retries(spec.retry.max_retries)
798
26
                .set_connection_timeout(Some(connection_timeout))
799
26
                .set_response_timeout(Some(command_timeout))
800
26
                .set_push_sender(tx)
801
        };
802
803
24
        let mut connection_manager =
804
26
            ConnectionManager::new_with_config(client, connection_manager_config)
805
26
                .await
806
26
                .err_tip(|| 
format!2
("While connecting to redis with url: {addr}"))
?2
;
807
808
24
        if let Some(
pub_sub_channel5
) = spec.experimental_pub_sub_channel {
809
5
            connection_manager.psubscribe(pub_sub_channel).await
?0
;
810
19
        }
811
812
24
        Ok(connection_manager)
813
27
    }
814
815
    /// Create a new `RedisStore` from the given configuration.
816
24
    pub async fn new_standard(mut spec: RedisSpec) -> Result<Arc<Self>, Error> {
817
24
        Self::set_spec_defaults(&mut spec)
?0
;
818
819
24
        if spec.addresses.len() != 1 {
820
0
            return Err(make_err!(
821
0
                Code::Unimplemented,
822
0
                "Connecting directly to multiple redis nodes in a cluster is currently unsupported. Please specify a single URL to a single node, and nativelink will use cluster discover to find the other nodes."
823
0
            ));
824
24
        }
825
826
24
        let (tx, subscriber_channel) = unbounded_channel();
827
828
21
        Self::new_from_builder_and_parts(
829
24
            spec.experimental_pub_sub_channel.clone(),
830
1
            || Uuid::new_v4().to_string(),
831
24
            spec.key_prefix.clone(),
832
24
            spec.read_chunk_size,
833
24
            spec.max_chunk_uploads_per_update,
834
24
            spec.scan_count,
835
24
            spec.max_client_permits,
836
24
            spec.max_count_per_cursor,
837
24
            Duration::from_millis(spec.health_check_timeout_ms),
838
24
            (spec.key_ttl_s > 0).then(|| 
Duration::from_secs0
(
spec.key_ttl_s0
)),
839
24
            subscriber_channel,
840
27
            
StandardRedisManager::new24
(
Box::new24
(move || {
841
27
                Box::pin(Self::connect(spec.clone(), tx.clone()))
842
27
            }))
843
24
            .await
?3
,
844
        )
845
21
        .await
846
21
        .map(Arc::new)
847
24
    }
848
}
849
850
#[async_trait]
851
impl<C, M> StoreDriver for RedisStore<C, M>
852
where
853
    C: ConnectionLike + Clone + Send + Sync + Unpin + 'static,
854
    M: RedisManager<C> + Unpin + Send + Sync + 'static,
855
{
856
0
    async fn post_init(self: Arc<Self>) -> Result<(), Error> {
857
        Ok(())
858
0
    }
859
860
    async fn has_with_results(
861
        self: Pin<&Self>,
862
        keys: &[StoreKey<'_>],
863
        results: &mut [Option<u64>],
864
12
    ) -> Result<(), Error> {
865
        // TODO(palfrey) We could use pipeline here, but it makes retry more
866
        // difficult and it doesn't work very well in cluster mode.
867
        // If we wanted to optimize this with pipeline be careful to
868
        // implement retry and to support cluster mode.
869
870
        izip!(keys.iter(), results.iter_mut(),)
871
12
            .map(|(key, result)| async move {
872
                // We need to do a special pass to ensure our zero key exist.
873
12
                if is_zero_digest(key.borrow()) {
874
2
                    *result = Some(0);
875
2
                    return Ok::<_, Error>(());
876
10
                }
877
10
                let encoded_key = self.encode_key(key);
878
879
10
                let mut client = self.get_client().await
?0
;
880
881
                // Redis returns 0 when the key doesn't exist
882
                // AND when the key exists with value of length 0.
883
                // Therefore, we need to check both length and existence
884
                // and do it in a pipeline for efficiency
885
                // Re-resolve the master and retry on a transient failover so a
886
                // topology change doesn't fail the existence check.
887
10
                let (blob_len, exists) = {
888
10
                    let mut attempt: u32 = 0;
889
                    loop {
890
11
                        attempt += 1;
891
11
                        match pipe()
892
11
                            .strlen(encoded_key.as_ref())
893
11
                            .exists(encoded_key.as_ref())
894
11
                            .query_async::<(u64, bool)>(&mut client.connection_manager)
895
11
                            .await
896
                        {
897
10
                            Ok(v) => break v,
898
1
                            Err(err)
899
1
                                if attempt < MAX_REDIS_RETRY_ATTEMPTS
900
1
                                    && is_retryable_redis_error(&err) =>
901
                            {
902
1
                                client.reconnect(&self.connection_manager).await
?0
;
903
1
                                sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
904
                            }
905
0
                            Err(err) => {
906
0
                                return Err(
907
0
                                    Error::from(err).append("In RedisStore::has_with_results::all")
908
0
                                );
909
                            }
910
                        }
911
                    }
912
                };
913
914
10
                *result = if exists { Some(blob_len) } else { 
None0
};
915
916
10
                Ok::<_, Error>(())
917
24
            })
918
            .collect::<FuturesUnordered<_>>()
919
            .try_collect()
920
            .await
921
12
    }
922
923
    async fn list(
924
        self: Pin<&Self>,
925
        range: (Bound<StoreKey<'_>>, Bound<StoreKey<'_>>),
926
        handler: &mut (dyn for<'a> FnMut(&'a StoreKey) -> bool + Send + Sync + '_),
927
8
    ) -> Result<u64, Error> {
928
        let range = (
929
            range.0.map(StoreKey::into_owned),
930
            range.1.map(StoreKey::into_owned),
931
        );
932
        let pattern = match range.0 {
933
            Bound::Included(ref start) | Bound::Excluded(ref start) => match range.1 {
934
                Bound::Included(ref end) | Bound::Excluded(ref end) => {
935
                    let start = start.as_str();
936
                    let end = end.as_str();
937
                    let max_length = start.len().min(end.len());
938
                    let length = start
939
                        .chars()
940
                        .zip(end.chars())
941
20
                        .position(|(a, b)| a != b)
942
                        .unwrap_or(max_length);
943
                    format!("{}{}*", self.key_prefix, &start[..length])
944
                }
945
                Bound::Unbounded => format!("{}*", self.key_prefix),
946
            },
947
            Bound::Unbounded => format!("{}*", self.key_prefix),
948
        };
949
        let mut client = self.get_client().await?;
950
        trace!(%pattern, count=self.scan_count, "Running SCAN");
951
        // Restart the scan on a transient failover. Redis SCAN may re-emit keys
952
        // even without failures, so callers already tolerate duplicates;
953
        // re-resolving the master and rescanning is within that contract and
954
        // avoids failing on a topology change mid-iteration.
955
        let mut attempt: u32 = 0;
956
        loop {
957
            attempt += 1;
958
            let opts = ScanOptions::default()
959
                .with_pattern(pattern.clone())
960
                .with_count(self.scan_count);
961
            // Scan via a cloned connection handle so the iterator doesn't hold a
962
            // borrow on `client` across a reconnect.
963
            let mut conn = client.connection_manager.clone();
964
            let mut scan_stream: AsyncIter<Value> = match conn.scan_options(opts).await {
965
                Ok(s) => s,
966
                Err(err)
967
                    if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) =>
968
                {
969
                    client.reconnect(&self.connection_manager).await?;
970
                    sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
971
                    continue;
972
                }
973
                Err(err) => return Err(Error::from(err).append("During scan_options")),
974
            };
975
            let mut iterations = 0;
976
            let mut errors = vec![];
977
            let mut transient_err = false;
978
            while let Some(key) = scan_stream.next_item().await {
979
                match key {
980
                    Ok(Value::BulkString(raw_key)) => {
981
                        let Ok(str_key) = str::from_utf8(&raw_key) else {
982
                            error!(?raw_key, "Non-utf8 key");
983
                            errors.push(format!("Non-utf8 key {raw_key:?}"));
984
                            continue;
985
                        };
986
                        match decode_key(&self.key_prefix, Cow::from(str_key)) {
987
                            Ok(key) => {
988
                                if range.contains(&key) {
989
                                    iterations += 1;
990
                                    if !handler(&key) {
991
                                        error!("Issue in handler");
992
                                        errors.push("Issue in handler".to_string());
993
                                    }
994
                                } else {
995
                                    trace!(%key, ?range, "Key not in range");
996
                                }
997
                            }
998
                            Err(e) => {
999
                                errors.push(e.to_string());
1000
                            }
1001
                        }
1002
                    }
1003
                    Err(err)
1004
                        if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) =>
1005
                    {
1006
                        // Connection dropped mid-scan; restart from scratch.
1007
                        transient_err = true;
1008
                        break;
1009
                    }
1010
                    other => {
1011
                        error!(?other, "Non-string in key");
1012
                        errors.push("Non-string in key".to_string());
1013
                    }
1014
                }
1015
            }
1016
            if transient_err {
1017
                drop(scan_stream);
1018
                client.reconnect(&self.connection_manager).await?;
1019
                sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
1020
                continue;
1021
            }
1022
            return if errors.is_empty() {
1023
                Ok(iterations)
1024
            } else {
1025
                error!(?errors, "Errors in scan stream");
1026
                Err(Error::new(Code::Internal, format!("Errors: {errors:?}")))
1027
            };
1028
        }
1029
8
    }
1030
1031
    async fn update(
1032
        self: Pin<&Self>,
1033
        key: StoreKey<'_>,
1034
        mut reader: DropCloserReadHalf,
1035
        _upload_size: UploadSizeInfo,
1036
15
    ) -> Result<u64, Error> {
1037
        let final_key = self.encode_key(&key);
1038
1039
        // While the name generation function can be supplied by the user, we need to have the curly
1040
        // braces in place in order to manage redis' hashing behavior and make sure that the temporary
1041
        // key name and the final key name are directed to the same cluster node. See
1042
        // https://redis.io/blog/redis-clustering-best-practices-with-keys/
1043
        //
1044
        // The TL;DR is that if we're in cluster mode and the names hash differently, we can't use request
1045
        // pipelining. By using these braces, we tell redis to only hash the part of the temporary key that's
1046
        // identical to the final key -- so they will always hash to the same node.
1047
        let temp_key = format!("temp-{}-{{{final_key}}}", (self.temp_name_generator_fn)());
1048
1049
        if is_zero_digest(key.borrow()) {
1050
            let chunk = reader
1051
                .peek()
1052
                .await
1053
                .err_tip(|| "Failed to peek in RedisStore::update")?;
1054
            if chunk.is_empty() {
1055
                reader
1056
                    .drain()
1057
                    .await
1058
                    .err_tip(|| "Failed to drain in RedisStore::update")?;
1059
                // Zero-digest keys are special -- we don't need to do anything with it.
1060
                return Ok(0);
1061
            }
1062
        }
1063
1064
        let mut client = self.get_client().await?;
1065
1066
        let mut read_stream = reader
1067
14
            .scan(0u32, |bytes_read, chunk_res| {
1068
14
                future::ready(Some(
1069
14
                    chunk_res
1070
14
                        .err_tip(|| "Failed to read chunk in update in redis store")
1071
14
                        .and_then(|chunk| 
{13
1072
13
                            let offset = isize::try_from(*bytes_read).err_tip(|| "Could not convert offset to isize in RedisStore::update")
?0
;
1073
13
                            let chunk_len = u32::try_from(chunk.len()).err_tip(
1074
                                || "Could not convert chunk length to u32 in RedisStore::update",
1075
0
                            )?;
1076
13
                            let new_bytes_read = bytes_read
1077
13
                                .checked_add(chunk_len)
1078
13
                                .err_tip(|| "Overflow protection in RedisStore::update")
?0
;
1079
13
                            *bytes_read = new_bytes_read;
1080
13
                            Ok::<_, Error>((offset, *bytes_read, chunk))
1081
13
                        }),
1082
                ))
1083
14
            })
1084
14
            .map(|res| {
1085
14
                let (
offset13
,
end_pos13
,
chunk13
) = res
?1
;
1086
13
                let temp_key_ref = &temp_key;
1087
13
                Ok(async move {
1088
13
                    let (mut connection_manager, connect_id) = self.connection_manager.get_connection().await
?0
;
1089
13
                    match connection_manager
1090
13
                        .setrange::<_, _, usize>(temp_key_ref, offset, chunk.to_vec())
1091
13
                        .await {
1092
10
                        Ok(_) => {},
1093
3
                        Err(err)
1094
3
                            if is_retryable_redis_error(&err) =>
1095
                        {
1096
3
                            let (mut connection_manager, _connect_id) = self.connection_manager.reconnect(connect_id).await
?0
;
1097
3
                            connection_manager
1098
3
                                .setrange::<_, _, usize>(temp_key_ref, offset, chunk.to_vec())
1099
3
                                .await
1100
3
                                .err_tip(
1101
1
                                    || format!("(after reconnect) while appending to temp key ({temp_key_ref}) in RedisStore::update. offset = {offset}. end_pos = {end_pos}"),
1102
1
                                )?;
1103
                        }
1104
0
                        Err(err) => {
1105
0
                            let mut error: Error = err.into();
1106
0
                            error
1107
0
                                .messages
1108
0
                                .push(format!("While appending to temp key ({temp_key_ref}) in RedisStore::update. offset = {offset}. end_pos = {end_pos}"));
1109
0
                            return Err(error);
1110
                        }
1111
                    }
1112
                    // Guard the temp key as soon as it exists. An update that
1113
                    // dies anywhere after this — mid-upload, during the length
1114
                    // check, during the rename — would otherwise leave the
1115
                    // temp key behind forever, and nothing cleans those up.
1116
                    // EXPIRE on a key that does not exist yet is a no-op, so
1117
                    // this has to happen after the first chunk lands rather
1118
                    // than before the loop.
1119
12
                    if offset == 0 && let Some(
key_ttl2
) =
self.key_ttl11
{
1120
2
                        let ttl_secs = ttl_seconds(key_ttl);
1121
2
                        match connection_manager.expire::<_, ()>(temp_key_ref, ttl_secs).await {
1122
2
                            Ok(()) => {}
1123
0
                            Err(err) if is_retryable_redis_error(&err) => {
1124
0
                                let (mut connection_manager, _connect_id) = self.connection_manager.reconnect(connect_id).await?;
1125
0
                                connection_manager
1126
0
                                    .expire::<_, ()>(temp_key_ref, ttl_secs)
1127
0
                                    .await
1128
0
                                    .err_tip(|| format!("(after reconnect) while setting TTL on temp key ({temp_key_ref}) in RedisStore::update"))?;
1129
                            }
1130
0
                            Err(err) => {
1131
0
                                return Err(Error::from(err).append(format!("While setting TTL on temp key ({temp_key_ref}) in RedisStore::update")));
1132
                            }
1133
                        }
1134
10
                    }
1135
12
                    Ok::<u32, Error>(end_pos)
1136
13
                })
1137
14
            })
1138
            .try_buffer_unordered(self.max_chunk_uploads_per_update);
1139
1140
        let mut total_len: u32 = 0;
1141
        while let Some(last_pos) = read_stream.try_next().await? {
1142
            if last_pos > total_len {
1143
                total_len = last_pos;
1144
            }
1145
        }
1146
1147
        let expected_len = usize::try_from(total_len).unwrap_or(usize::MAX);
1148
1149
        // The chunk writes above reconnect on any transient failover error, so on a mid-write Redis
1150
        // failover the data lands on the *current* master. The length check and
1151
        // rename below must run against that same master: the connection
1152
        // captured before the writes may now point at a demoted replica, where
1153
        // strlen reads 0 and would fail an otherwise healthy write. Re-resolve
1154
        // the master and retry so a transient topology change (failover or brief
1155
        // replica lag) doesn't drop the value.
1156
        let mut attempt: u32 = 0;
1157
        let blob_len = loop {
1158
            attempt += 1;
1159
            let blob_len: usize = client
1160
                .connection_manager
1161
                .strlen(&temp_key)
1162
                .await
1163
0
                .err_tip(|| format!("In RedisStore::update strlen check for {temp_key}"))?;
1164
            // Safety check: reject if a retried append double-wrote the data.
1165
            if blob_len > expected_len {
1166
                return Err(make_input_err!(
1167
                    "Data length mismatch in RedisStore::update for {}({}) - expected {} bytes, got {} bytes",
1168
                    key.borrow().as_str(),
1169
                    temp_key,
1170
                    total_len,
1171
                    blob_len,
1172
                ));
1173
            }
1174
            if blob_len == expected_len {
1175
                break blob_len;
1176
            }
1177
            // blob_len < expected (typically 0): the temp key isn't visible on
1178
            // this connection yet — re-resolve the master and retry.
1179
            if attempt >= MAX_REDIS_RETRY_ATTEMPTS {
1180
                return Err(make_input_err!(
1181
                    "Data length mismatch in RedisStore::update for {}({}) - expected {} bytes, got {} bytes after {} attempts",
1182
                    key.borrow().as_str(),
1183
                    temp_key,
1184
                    total_len,
1185
                    blob_len,
1186
                    attempt,
1187
                ));
1188
            }
1189
            let (connection_manager, uuid) = self.connection_manager.reconnect(client.uuid).await?;
1190
            client.connection_manager = connection_manager;
1191
            client.uuid = uuid;
1192
            sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
1193
        };
1194
1195
        // Rename the temp key so that the data appears under the real key. Any data already present in the real key is lost.
1196
        // Reconnect once on a transient failover error in case the master moved between the verify and here.
1197
        match client
1198
            .connection_manager
1199
            .rename::<_, _, ()>(&temp_key, final_key.as_ref())
1200
            .await
1201
        {
1202
            Ok(()) => {}
1203
            Err(err) if is_retryable_redis_error(&err) => {
1204
                let (connection_manager, uuid) =
1205
                    self.connection_manager.reconnect(client.uuid).await?;
1206
                client.connection_manager = connection_manager;
1207
                client.uuid = uuid;
1208
                client
1209
                    .connection_manager
1210
                    .rename::<_, _, ()>(&temp_key, final_key.as_ref())
1211
                    .await
1212
                    .err_tip(
1213
                        || "While queueing key rename (after reconnect) in RedisStore::update()",
1214
                    )?;
1215
            }
1216
            Err(err) => {
1217
                return Err(
1218
                    Error::from(err).append("While queueing key rename in RedisStore::update()")
1219
                );
1220
            }
1221
        }
1222
1223
        // The rename carries the temp key's TTL across, so the key is never
1224
        // unprotected. Re-setting it here restarts the window at completion
1225
        // rather than at the first byte, which matters for a large blob whose
1226
        // upload takes a noticeable slice of the TTL. Reconnect once on a
1227
        // transient failover error, the same as the rename above: a key that
1228
        // silently never expires is the bug this option exists to prevent.
1229
        if let Some(key_ttl) = self.key_ttl {
1230
            let ttl_secs = ttl_seconds(key_ttl);
1231
            match client
1232
                .connection_manager
1233
                .expire::<_, ()>(final_key.as_ref(), ttl_secs)
1234
                .await
1235
            {
1236
                Ok(()) => {}
1237
                Err(err) if is_retryable_redis_error(&err) => {
1238
                    let (connection_manager, uuid) =
1239
                        self.connection_manager.reconnect(client.uuid).await?;
1240
                    client.connection_manager = connection_manager;
1241
                    client.uuid = uuid;
1242
                    client
1243
                        .connection_manager
1244
                        .expire::<_, ()>(final_key.as_ref(), ttl_secs)
1245
                        .await
1246
0
                        .err_tip(|| {
1247
0
                            format!(
1248
                                "While setting TTL on {final_key} (after reconnect) in RedisStore::update()"
1249
                            )
1250
0
                        })?;
1251
                }
1252
                Err(err) => {
1253
                    return Err(Error::from(err).append(format!(
1254
                        "While setting TTL on {final_key} in RedisStore::update()"
1255
                    )));
1256
                }
1257
            }
1258
        }
1259
1260
        // If we have a publish channel configured, send a notice that the key has been set.
1261
        if let Some(pub_sub_channel) = &self.pub_sub_channel {
1262
            client
1263
                .connection_manager
1264
                .publish::<_, _, ()>(pub_sub_channel, final_key.as_ref())
1265
                .await?;
1266
        }
1267
1268
        Ok(blob_len.try_into().unwrap_or(0))
1269
15
    }
1270
1271
    async fn get_part(
1272
        self: Pin<&Self>,
1273
        key: StoreKey<'_>,
1274
        writer: &mut DropCloserWriteHalf,
1275
        offset: u64,
1276
        length: Option<u64>,
1277
5
    ) -> Result<(), Error> {
1278
        let offset = isize::try_from(offset).err_tip(|| "Could not convert offset to isize")?;
1279
        let length = length
1280
4
            .map(|v| usize::try_from(v).err_tip(|| "Could not convert length to usize"))
1281
            .transpose()?;
1282
1283
        // To follow RBE spec we need to consider any digest's with
1284
        // zero size to be existing.
1285
        if is_zero_digest(key.borrow()) {
1286
            return writer
1287
                .send_eof()
1288
                .err_tip(|| "Failed to send zero EOF in redis store get_part");
1289
        }
1290
1291
        let encoded_key = self.encode_key(&key);
1292
        let encoded_key = encoded_key.as_ref();
1293
1294
        // N.B. the `-1`'s you see here are because redis GETRANGE is inclusive at both the start and end, so when we
1295
        // do math with indices we change them to be exclusive at the end.
1296
1297
        // We want to read the data at the key from `offset` to `offset + length`.
1298
        let data_start = offset;
1299
        let data_end = data_start
1300
4
            .saturating_add(length.map_or(isize::MAX, |l| isize::try_from(l).unwrap_or(isize::MAX)))
1301
            .saturating_sub(1);
1302
1303
        // And we don't ever want to read more than `read_chunk_size` bytes at a time, so we'll need to iterate.
1304
        let mut chunk_start = data_start;
1305
        let mut chunk_end = cmp::min(
1306
            data_start.saturating_add(self.read_chunk_size.try_into().unwrap_or(isize::MAX)) - 1,
1307
            data_end,
1308
        );
1309
1310
        let mut client = self.get_client().await?;
1311
        loop {
1312
            // getrange is position-based and idempotent, so re-resolve the
1313
            // master and retry on a transient failover without re-sending
1314
            // already-written chunks.
1315
            let chunk: Bytes = {
1316
                let mut attempt: u32 = 0;
1317
                loop {
1318
                    attempt += 1;
1319
                    match client
1320
                        .connection_manager
1321
                        .getrange(encoded_key, chunk_start, chunk_end)
1322
                        .await
1323
                    {
1324
                        Ok(v) => break v,
1325
                        Err(err)
1326
                            if attempt < MAX_REDIS_RETRY_ATTEMPTS
1327
                                && is_retryable_redis_error(&err) =>
1328
                        {
1329
                            client.reconnect(&self.connection_manager).await?;
1330
                            sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
1331
                        }
1332
                        Err(err) => {
1333
                            return Err(
1334
                                Error::from(err).append("In RedisStore::get_part::getrange")
1335
                            );
1336
                        }
1337
                    }
1338
                }
1339
            };
1340
1341
            let didnt_receive_full_chunk = chunk.len() < self.read_chunk_size;
1342
            let reached_end_of_data = chunk_end == data_end;
1343
1344
            if didnt_receive_full_chunk || reached_end_of_data {
1345
                if !chunk.is_empty() {
1346
                    writer
1347
                        .send(chunk)
1348
                        .await
1349
                        .err_tip(|| "Failed to write data in RedisStore::get_part")?;
1350
                }
1351
1352
                break; // No more data to read.
1353
            }
1354
1355
            // We received a full chunk's worth of data, so write it...
1356
            writer
1357
                .send(chunk)
1358
                .await
1359
                .err_tip(|| "Failed to write data in RedisStore::get_part")?;
1360
1361
            // ...and go grab the next chunk.
1362
            chunk_start = chunk_end + 1;
1363
            chunk_end = cmp::min(
1364
                chunk_start.saturating_add(self.read_chunk_size.try_into().unwrap_or(isize::MAX))
1365
                    - 1,
1366
                data_end,
1367
            );
1368
        }
1369
1370
        // If we didn't write any data, check if the key exists, if not return a NotFound error.
1371
        // This is required by spec.
1372
        if writer.get_bytes_written() == 0 {
1373
            // We're supposed to read 0 bytes, so just check if the key exists.
1374
            let exists: bool = {
1375
                let mut attempt: u32 = 0;
1376
                loop {
1377
                    attempt += 1;
1378
                    match client.connection_manager.exists(encoded_key).await {
1379
                        Ok(v) => break v,
1380
                        Err(err)
1381
                            if attempt < MAX_REDIS_RETRY_ATTEMPTS
1382
                                && is_retryable_redis_error(&err) =>
1383
                        {
1384
                            client.reconnect(&self.connection_manager).await?;
1385
                            sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
1386
                        }
1387
                        Err(err) => {
1388
                            return Err(
1389
                                Error::from(err).append("In RedisStore::get_part::zero_exists")
1390
                            );
1391
                        }
1392
                    }
1393
                }
1394
            };
1395
1396
            if !exists {
1397
                return Err(make_err!(
1398
                    Code::NotFound,
1399
                    "Data not found in Redis store for digest: {key:?}"
1400
                ));
1401
            }
1402
        }
1403
1404
        writer
1405
            .send_eof()
1406
            .err_tip(|| "Failed to write EOF in redis store get_part")
1407
5
    }
1408
1409
0
    fn inner_store(&self, _digest: Option<StoreKey>) -> &dyn StoreDriver {
1410
0
        self
1411
0
    }
1412
1413
0
    fn as_any(&self) -> &(dyn core::any::Any + Sync + Send) {
1414
0
        self
1415
0
    }
1416
1417
0
    fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send> {
1418
0
        self
1419
0
    }
1420
1421
0
    fn register_health(self: Arc<Self>, registry: &mut HealthRegistryBuilder) {
1422
0
        registry.register_indicator(self);
1423
0
    }
1424
1425
8
    fn register_remove_callback(self: Arc<Self>, callback: RemoveCallback) -> Result<(), Error> {
1426
8
        debug!(?callback, "New callback");
1427
8
        let local_self = self.clone();
1428
8
        background_spawn!("remove_callback_subscribe", async move {
1429
8
            self.remove_callbacks.lock().await.push(callback);
1430
8
            if let Err(
err0
) = local_self.clone().has_remove_callback_subscribe
1431
8
                .get_or_try_init(|| async move {
1432
8
                    let mut client = local_self.get_client().await
?0
;
1433
8
                    let cfg = redis::cmd("CONFIG").arg("GET").arg("notify-keyspace-events").to_owned().query_async::<Vec<(String,String)>>(&mut client.connection_manager).await.map_err(|e| 
Error::from0
(
e0
).
append0
("Parsing notify-keyspace-events"))
?0
;
1434
8
                    if cfg.len() != 1 {
1435
0
                        warn!(?cfg, "Got multiple items for CONFIG GET, expected one");
1436
0
                        return Err(make_input_err!("Got multiple items for CONFIG GET, expected one"));
1437
8
                    }
1438
8
                    let events_cfg = &cfg.first().ok_or_else(|| 
make_err!0
(
Code::InvalidArgument0
, "Only one item"))
?0
.1;
1439
8
                    if events_cfg.is_empty() {
1440
1
                        error!("notify-keyspace-events not enabled for Redis, will fail to get remove callbacks");
1441
7
                    } else if !events_cfg.contains('K') {
1442
1
                        error!(notify_keyspace_events=events_cfg, "notify-keyspace-events does not contain 'K' so won't get keyspace events we need for eviction events");
1443
6
                    } else if !events_cfg.contains('A') {
1444
1
                        error!(notify_keyspace_events=events_cfg, "notify-keyspace-events does not contain 'A' so we won't get eviction events");
1445
5
                    }
1446
                    // FIXME: Redis events spec appears unreliable, so we subscribe anyways
1447
                    // It should just need Ke as per https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/
1448
                    // but I'm yet to get reliable eviction events out of that
1449
8
                    info!(notify_keyspace_events=events_cfg, "Attempting to subscribe to eviction events");
1450
8
                    self.connection_manager.psubscribe("__key*__:*").await
?0
;
1451
8
                    Ok::<(), Error>(())
1452
16
                 })
1453
8
                .await {
1454
0
                    error!(?err, "Error while trying to initialise remove_callback_subscribe");
1455
8
                }
1456
8
        });
1457
8
        Ok(())
1458
8
    }
1459
}
1460
1461
#[async_trait]
1462
impl<C, M> HealthStatusIndicator for RedisStore<C, M>
1463
where
1464
    C: ConnectionLike + Clone + Send + Sync + Unpin + 'static,
1465
    M: RedisManager<C> + Send + Sync + Unpin + 'static,
1466
{
1467
1
    fn get_name(&self) -> &'static str {
1468
1
        "RedisStore"
1469
1
    }
1470
1471
    /// Lightweight health check: just `PING` the master, bounded by a
1472
    /// short physical timeout. The default `StoreDriver::check_health`
1473
    /// performs a full `update_oneshot` + `has` + `get_part_unchunked`
1474
    /// roundtrip, which queues behind real production traffic on the
1475
    /// same connection-permit semaphore and Redis master. When the
1476
    /// store is even moderately loaded that easily exceeds the
1477
    /// `HealthServer` per-indicator budget (default 5 s), each
1478
    /// RedisStore-backed indicator (AC, small-blob CAS, scheduler)
1479
    /// reports `HealthStatus::Timeout`, and `/status` returns 503 —
1480
    /// surfaced as a readiness-probe failure that sheds traffic from
1481
    /// an otherwise-functional pod. A `PING` proves the connection
1482
    /// is reachable and the master is accepting commands; that is
1483
    /// the only invariant a kubelet probe needs.
1484
1
    async fn check_health(&self, _namespace: Cow<'static, str>) -> HealthStatus {
1485
        let mut client = match self.get_client().await {
1486
            Ok(c) => c,
1487
            Err(e) => {
1488
                return HealthStatus::new_failed(
1489
                    self,
1490
                    format!("RedisStore::check_health: failed to acquire connection: {e}").into(),
1491
                );
1492
            }
1493
        };
1494
1495
        // Hold the `ClientWithPermit` for the duration of the call so
1496
        // its `Drop` releases the semaphore permit on exit. We just
1497
        // need a `&mut` to the connection manager underneath.
1498
1
        let ping = async {
1499
1
            redis::cmd("PING")
1500
1
                .query_async::<()>(&mut client.connection_manager)
1501
1
                .await
1502
1
        };
1503
        let retry_reason = match timeout(self.health_check_timeout, ping).await {
1504
            Ok(Ok(())) => {
1505
                return HealthStatus::new_ok(self, "RedisStore::check_health: PING ok".into());
1506
            }
1507
            // A PING that errors connection-wise or times out means the handle
1508
            // points at a master that went away (a Sentinel failover). Without
1509
            // re-resolving here, a store with no other traffic stays wedged on
1510
            // the dead handle and reports unhealthy forever — shedding readiness
1511
            // traffic from an otherwise-recovered pod until it is restarted.
1512
            Ok(Err(e)) if is_retryable_redis_error(&e) => format!("PING errored: {e}"),
1513
            Ok(Err(e)) => {
1514
                return HealthStatus::new_failed(
1515
                    self,
1516
                    format!("RedisStore::check_health: PING errored: {e}").into(),
1517
                );
1518
            }
1519
            Err(_) => format!(
1520
                "PING exceeded {}ms timeout",
1521
                self.health_check_timeout.as_millis()
1522
            ),
1523
        };
1524
1525
        // The reconnect (re-resolving the master via Sentinel) is logged by
1526
        // `StandardRedisManager::reconnect`; `retry_reason` is surfaced in the
1527
        // failure message below if the second PING still doesn't come back.
1528
        if let Err(e) = client.reconnect(&self.connection_manager).await {
1529
            return HealthStatus::new_failed(
1530
                self,
1531
                format!("RedisStore::check_health: {retry_reason}; reconnect failed: {e}").into(),
1532
            );
1533
        }
1534
1
        let ping = async {
1535
1
            redis::cmd("PING")
1536
1
                .query_async::<()>(&mut client.connection_manager)
1537
1
                .await
1538
1
        };
1539
        match timeout(self.health_check_timeout, ping).await {
1540
            Ok(Ok(())) => HealthStatus::new_ok(
1541
                self,
1542
                "RedisStore::check_health: PING ok after re-resolving master".into(),
1543
            ),
1544
            Ok(Err(e)) => HealthStatus::new_failed(
1545
                self,
1546
                format!("RedisStore::check_health: PING still errored after reconnect: {e}").into(),
1547
            ),
1548
            Err(_) => HealthStatus::new_failed(
1549
                self,
1550
                format!(
1551
                    "RedisStore::check_health: PING still exceeded {}ms timeout after reconnect",
1552
                    self.health_check_timeout.as_millis()
1553
                )
1554
                .into(),
1555
            ),
1556
        }
1557
1
    }
1558
}
1559
1560
// -------------------------------------------------------------------
1561
// Below this line are specific to the redis scheduler implementation.
1562
// -------------------------------------------------------------------
1563
1564
/// The time in milliseconds that a redis cursor can be idle before it is closed.
1565
const CURSOR_IDLE_MS: u64 = 30_000;
1566
/// The name of the field in the Redis hash that stores the data.
1567
const DATA_FIELD_NAME: &str = "data";
1568
/// The name of the field in the Redis hash that stores the version.
1569
const VERSION_FIELD_NAME: &str = "version";
1570
/// The time to live of indexes in seconds. After this time redis may delete the index.
1571
const INDEX_TTL_S: u64 = 60 * 60 * 24; // 24 hours.
1572
1573
#[allow(rustdoc::broken_intra_doc_links)]
1574
/// Lua script to set a key if the version matches.
1575
/// Args:
1576
///   KEYS[1]: The key where the version is stored.
1577
///   ARGV[1]: The expected version.
1578
///   ARGV[2]: TTL in seconds, or 0 for forever
1579
///   ARGV[3]: The new data.
1580
///   ARGV[4*]: Key-value pairs of additional data to include.
1581
/// Returns:
1582
///   The new version if the version matches. nil is returned if the
1583
///   value was not set.
1584
pub const LUA_VERSION_SET_SCRIPT: &str = formatcp!(
1585
    r"
1586
local key = KEYS[1]
1587
local expected_version = tonumber(ARGV[1])
1588
local ttl = tonumber(ARGV[2])
1589
local new_data = ARGV[3]
1590
local new_version = redis.call('HINCRBY', key, '{VERSION_FIELD_NAME}', 1)
1591
local i
1592
local indexes = {{}}
1593
1594
if new_version-1 ~= expected_version then
1595
    local reverted = redis.call('HINCRBY', key, '{VERSION_FIELD_NAME}', -1)
1596
    -- HINCRBY creates the key before the version can be checked, so a
1597
    -- caller holding a stale version for a key that has since gone leaves
1598
    -- behind a hash with nothing in it but a zeroed version. It has no
1599
    -- data, no expiry and never gains either, yet it still matches the
1600
    -- index prefix, so it sits in the index as an empty document and
1601
    -- crowds every search that reads it. Only ever removes a key this
1602
    -- call brought into existence: a real record always carries data.
1603
    if reverted == 0 and redis.call('HEXISTS', key, '{DATA_FIELD_NAME}') == 0 then
1604
        redis.call('DEL', key)
1605
    end
1606
    return {{ 0, new_version-1 }}
1607
end
1608
-- Skip first 3 argvs, as they are known inputs.
1609
-- Remember: Lua is 1-indexed.
1610
for i=4, #ARGV do
1611
    indexes[i-3] = ARGV[i]
1612
end
1613
1614
-- In testing we witnessed redis sometimes not update our FT indexes
1615
-- resulting in stale data. It appears if we delete our keys then insert
1616
-- them again it works and reduces risk significantly.
1617
redis.call('DEL', key)
1618
redis.call('HSET', key, '{DATA_FIELD_NAME}', new_data, '{VERSION_FIELD_NAME}', new_version, unpack(indexes))
1619
1620
if ttl ~= 0 then
1621
    redis.call('EXPIRE', key, ttl)
1622
end
1623
return {{ 1, new_version }}
1624
"
1625
);
1626
1627
/// This is the output of the calculations below hardcoded into the executable.
1628
const FINGERPRINT_CREATE_INDEX_HEX: &str = "3e762c15";
1629
1630
#[cfg(test)]
1631
mod test {
1632
    use super::FINGERPRINT_CREATE_INDEX_HEX;
1633
1634
    /// String of the `FT.CREATE` command used to create the index template.
1635
    const CREATE_INDEX_TEMPLATE: &str = "FT.CREATE {} ON HASH PREFIX 1 {} NOOFFSETS NOHL NOFIELDS NOFREQS SCHEMA {} TAG CASESENSITIVE SORTABLE";
1636
1637
    /// Compile-time fingerprint of the `FT.CREATE` command used to create the
1638
    /// index template. This is a simple CRC32 checksum of the command string.
1639
    /// We don't care about it actually being a valid CRC32 checksum, just that
1640
    /// it's a unique identifier with a low chance of collision.
1641
1
    const fn fingerprint_create_index_template() -> u32 {
1642
        const POLY: u32 = 0xEDB8_8320;
1643
        const DATA: &[u8] = CREATE_INDEX_TEMPLATE.as_bytes();
1644
1
        let mut crc = 0xFFFF_FFFF;
1645
1
        let mut i = 0;
1646
102
        while i < DATA.len() {
1647
101
            let byte = DATA[i];
1648
101
            crc ^= byte as u32;
1649
1650
101
            let mut j = 0;
1651
909
            while j < 8 {
1652
808
                crc = if crc & 1 != 0 {
1653
386
                    (crc >> 1) ^ POLY
1654
                } else {
1655
422
                    crc >> 1
1656
                };
1657
808
                j += 1;
1658
            }
1659
101
            i += 1;
1660
        }
1661
1
        crc
1662
1
    }
1663
1664
    /// Verify that our calculation always evaluates to this fixed value.
1665
    #[test]
1666
1
    fn test_fingerprint_value() {
1667
1
        assert_eq!(
1668
1
            format!("{:08x}", &fingerprint_create_index_template()),
1669
            FINGERPRINT_CREATE_INDEX_HEX,
1670
        );
1671
1
    }
1672
}
1673
1674
/// Get the name of the index to create for the given field.
1675
/// This will add some prefix data to the name to try and ensure
1676
/// if the index definition changes, the name will get a new name.
1677
macro_rules! get_index_name {
1678
    ($prefix:expr, $field:expr, $maybe_sort:expr) => {
1679
        format_args!(
1680
            "{}_{}_{}_{}",
1681
            $prefix,
1682
            $field,
1683
            $maybe_sort.unwrap_or(""),
1684
            FINGERPRINT_CREATE_INDEX_HEX
1685
        )
1686
    };
1687
}
1688
1689
/// Try to sanitize a string to be used as a Redis key.
1690
/// We don't actually modify the string, just check if it's valid.
1691
34
const fn try_sanitize(s: &str) -> bool {
1692
    // Note: We cannot use for loops or iterators here because they are not const.
1693
    // Allowing us to use a const function here gives the compiler the ability to
1694
    // optimize this function away entirely in the case where the input is constant.
1695
34
    let chars = s.as_bytes();
1696
34
    let mut i: usize = 0;
1697
34
    let len = s.len();
1698
    loop {
1699
770
        if i >= len {
1700
34
            break;
1701
736
        }
1702
736
        let c = chars[i];
1703
736
        if !c.is_ascii_alphanumeric() && 
c != b'_'30
{
1704
0
            return false;
1705
736
        }
1706
736
        i += 1;
1707
    }
1708
34
    true
1709
34
}
1710
1711
/// An individual subscription to a key in Redis.
1712
#[derive(Debug)]
1713
pub struct RedisSubscription {
1714
    receiver: Option<tokio::sync::watch::Receiver<String>>,
1715
    weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<RedisSubscriptionPublisher>>>,
1716
}
1717
1718
impl SchedulerSubscription for RedisSubscription {
1719
    /// Wait for the subscription key to change.
1720
25
    async fn changed(&mut self) -> Result<(), Error> {
1721
25
        let receiver = self
1722
25
            .receiver
1723
25
            .as_mut()
1724
25
            .ok_or_else(|| 
make_err!0
(
Code::Internal0
, "In RedisSubscription::changed::as_mut"))
?0
;
1725
25
        receiver.changed().await.
map_err20
(|err|
{0
1726
0
            Error::from_std_err(Code::Internal, &err)
1727
0
                .append("In RedisSubscription::changed::changed")
1728
0
        })
1729
20
    }
1730
}
1731
1732
// If the subscription is dropped, we need to possibly remove the key from the
1733
// subscribed keys map.
1734
impl Drop for RedisSubscription {
1735
412
    fn drop(&mut self) {
1736
412
        let Some(receiver) = self.receiver.take() else {
1737
0
            warn!("RedisSubscription has already been dropped, nothing to do.");
1738
0
            return; // Already dropped, nothing to do.
1739
        };
1740
412
        let key = receiver.borrow().clone();
1741
412
        let Some(
subscribed_keys409
) = self.weak_subscribed_keys.upgrade() else {
1742
3
            return; // Parent dropped — nothing to do.
1743
        };
1744
409
        let mut subscribed_keys = subscribed_keys.write();
1745
409
        let Some(publisher) = subscribed_keys.get(&key) else {
1746
0
            warn!(
1747
                %key,
1748
                "RedisSubscription::drop: key absent from subscribed_keys under write lock — \
1749
                 indicates an unexpected removal path",
1750
            );
1751
0
            return;
1752
        };
1753
        // Count includes our own (still-alive) receiver. If we are the
1754
        // sole subscriber, remove the publisher entry.
1755
409
        if publisher.receiver_count() == 1 {
1756
207
            subscribed_keys.remove(&key);
1757
207
        
}202
1758
409
        drop(receiver);
1759
412
    }
1760
}
1761
1762
/// A publisher for a key in Redis.
1763
#[derive(Debug)]
1764
struct RedisSubscriptionPublisher {
1765
    sender: Mutex<tokio::sync::watch::Sender<String>>,
1766
}
1767
1768
impl RedisSubscriptionPublisher {
1769
210
    fn new(
1770
210
        key: String,
1771
210
        weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<Self>>>,
1772
210
    ) -> (Self, RedisSubscription) {
1773
210
        let (sender, receiver) = tokio::sync::watch::channel(key);
1774
210
        let publisher = Self {
1775
210
            sender: Mutex::new(sender),
1776
210
        };
1777
210
        let subscription = RedisSubscription {
1778
210
            receiver: Some(receiver),
1779
210
            weak_subscribed_keys,
1780
210
        };
1781
210
        (publisher, subscription)
1782
210
    }
1783
1784
202
    fn subscribe(
1785
202
        &self,
1786
202
        weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<Self>>>,
1787
202
    ) -> RedisSubscription {
1788
202
        let receiver = self.sender.lock().subscribe();
1789
202
        RedisSubscription {
1790
202
            receiver: Some(receiver),
1791
202
            weak_subscribed_keys,
1792
202
        }
1793
202
    }
1794
1795
409
    fn receiver_count(&self) -> usize {
1796
409
        self.sender.lock().receiver_count()
1797
409
    }
1798
1799
19
    fn notify(&self) {
1800
        // TODO(https://github.com/sile/patricia_tree/issues/40) When this is addressed
1801
        // we can remove the `Mutex` and use the mutable iterator directly.
1802
19
        self.sender.lock().send_modify(|_| {});
1803
19
    }
1804
}
1805
1806
#[derive(Debug, Clone)]
1807
pub struct RedisSubscriptionManager {
1808
    subscribed_keys: Arc<RwLock<StringPatriciaMap<RedisSubscriptionPublisher>>>,
1809
    tx_for_test: UnboundedSender<String>,
1810
    _subscription_spawn: Arc<Mutex<JoinHandleDropGuard<()>>>,
1811
}
1812
1813
impl RedisSubscriptionManager {
1814
62
    pub fn new(
1815
62
        subscriber_channel: UnboundedReceiver<PushInfo>,
1816
62
        remove_callbacks: Arc<async_lock::Mutex<Vec<RemoveCallback>>>,
1817
62
        key_prefix: String,
1818
62
    ) -> Self {
1819
62
        let subscribed_keys = Arc::new(RwLock::new(StringPatriciaMap::new()));
1820
62
        let subscribed_keys_weak = Arc::downgrade(&subscribed_keys);
1821
62
        let (tx_for_test, mut rx_for_test) = unbounded_channel();
1822
62
        let mut local_subscriber_channel = UnboundedReceiverStream::new(subscriber_channel);
1823
        Self {
1824
62
            subscribed_keys,
1825
62
            tx_for_test,
1826
62
            _subscription_spawn: Arc::new(Mutex::new(spawn!(
1827
                "redis_subscribe_spawn",
1828
34
                async move {
1829
34
                    debug!("running subscribe loop");
1830
                    loop {
1831
                        loop {
1832
57
                            let 
key17
= select! {
1833
57
                                
value16
= rx_for_test.recv() => {
1834
16
                                    let Some(value) = value else {
1835
0
                                        unreachable!("Channel should never close");
1836
                                    };
1837
16
                                    value
1838
                                },
1839
57
                                
maybe_push_info16
= local_subscriber_channel.next() => {
1840
16
                                    if let Some(
push_info7
) = maybe_push_info {
1841
7
                                        match push_info.kind {
1842
6
                                            redis::PushKind::PMessage => {},
1843
                                            redis::PushKind::PSubscribe => {
1844
1
                                                trace!(?push_info, "PSubscribe, ignore");
1845
1
                                                continue;
1846
                                            }
1847
                                            _ => {
1848
0
                                                warn!(?push_info, "Other push_info message, discarded");
1849
0
                                                continue;
1850
                                            },
1851
                                        }
1852
6
                                        if push_info.data.len() != 3 {
1853
0
                                            error!(?push_info, "Expected exactly 3 values on subscriber channel (pattern, channel, value)");
1854
0
                                            continue;
1855
6
                                        }
1856
6
                                        let value = match push_info.data.last().unwrap() {
1857
0
                                            Value::SimpleString(s) => {
1858
0
                                                s.clone()
1859
                                            }
1860
6
                                            Value::BulkString(v) => {
1861
6
                                                String::from_utf8(v.clone()).expect("String message")
1862
                                            }
1863
0
                                            other => {
1864
0
                                                error!(?other, "Received non-string message in RedisSubscriptionManager");
1865
0
                                                continue;
1866
                                            }
1867
                                        };
1868
                                        // Redis reports maxmemory eviction as
1869
                                        // "evicted" and TTL expiry as "expired".
1870
                                        // Both mean the key is gone, so both have
1871
                                        // to invalidate anything caching its
1872
                                        // existence; treating only one as a removal
1873
                                        // leaves an ExistenceCacheStore claiming to
1874
                                        // hold a key that Redis has already dropped.
1875
6
                                        if value == "evicted" || 
value == "expired"3
{
1876
5
                                            trace!(?push_info, %value, "Key removal event");
1877
5
                                            let removed_key = if let Some(key) = push_info.data.get(1) {
1878
5
                                                if let Value::BulkString(s) = key {
1879
5
                                                    String::from_utf8(s.clone()).expect("String message")
1880
                                                } else {
1881
0
                                                    error!(?push_info, "Removed key wasn't bulk-string");
1882
0
                                                    continue;
1883
                                                }
1884
                                            } else {
1885
0
                                                error!(?push_info, "No key in removal event");
1886
0
                                                continue;
1887
                                            };
1888
5
                                            trace!(?removed_key, "Removed key");
1889
5
                                            let Some((_prefix, internal_key)) = removed_key.split_once(':') else {
1890
0
                                                error!(?removed_key, "Removed key doesn't contain a colon");
1891
0
                                                continue;
1892
                                            };
1893
1894
5
                                            let store_key = match decode_key(&key_prefix, Cow::from(internal_key)) {
1895
5
                                                Ok(k) => k.into_owned(),
1896
0
                                                Err(err) => {
1897
0
                                                    error!(%err, internal_key, "Bad redis key");
1898
0
                                                    continue;
1899
                                                }
1900
                                            };
1901
5
                                            let locked_remove_callbacks = remove_callbacks.lock().await;
1902
5
                                            let mut callbacks: FuturesUnordered<_> =
1903
5
                                                locked_remove_callbacks.iter()
1904
5
                                                .map(|callback| callback.callback(store_key.borrow()))
1905
5
                                                .collect();
1906
10
                                            while callbacks.next().await.is_some() 
{}5
1907
5
                                            continue
1908
1
                                        }
1909
1
                                        value
1910
                                    } else {
1911
9
                                        error!("Error receiving message in RedisSubscriptionManager from subscriber_channel");
1912
9
                                        break;
1913
                                    }
1914
                                }
1915
                            };
1916
17
                            trace!(key, "New subscription manager key");
1917
17
                            let Some(subscribed_keys) = subscribed_keys_weak.upgrade() else {
1918
0
                                warn!(
1919
                                    "It appears our parent has been dropped, exiting RedisSubscriptionManager spawn"
1920
                                );
1921
0
                                return;
1922
                            };
1923
17
                            let subscribed_keys_mux = subscribed_keys.read();
1924
17
                            subscribed_keys_mux
1925
17
                                .common_prefix_values(&*key)
1926
17
                                .for_each(RedisSubscriptionPublisher::notify);
1927
                        }
1928
                        // Sleep for a small amount of time to ensure we don't reconnect too quickly.
1929
9
                        sleep(Duration::from_secs(1)).await;
1930
                        // If we reconnect or lag behind we might have had dirty keys, so we need to
1931
                        // flag all of them as changed.
1932
0
                        let Some(subscribed_keys) = subscribed_keys_weak.upgrade() else {
1933
0
                            warn!(
1934
                                "It appears our parent has been dropped, exiting RedisSubscriptionManager spawn"
1935
                            );
1936
0
                            return;
1937
                        };
1938
0
                        let subscribed_keys_mux = subscribed_keys.read();
1939
                        // Just in case also get a new receiver.
1940
0
                        for publisher in subscribed_keys_mux.values() {
1941
0
                            publisher.notify();
1942
0
                        }
1943
                    }
1944
0
                }
1945
            ))),
1946
        }
1947
62
    }
1948
}
1949
1950
impl SubscriptionManagerNotify for RedisSubscriptionManager {
1951
18
    fn notify_for_test(&self, value: String) {
1952
18
        self.tx_for_test.send(value).unwrap();
1953
18
    }
1954
}
1955
1956
impl SchedulerSubscriptionManager for RedisSubscriptionManager {
1957
    type Subscription = RedisSubscription;
1958
1959
412
    fn subscribe<K>(&self, key: K) -> Result<Self::Subscription, Error>
1960
412
    where
1961
412
        K: SchedulerStoreKeyProvider,
1962
    {
1963
412
        let weak_subscribed_keys = Arc::downgrade(&self.subscribed_keys);
1964
412
        let mut subscribed_keys = self.subscribed_keys.write();
1965
412
        let key = key.get_key();
1966
412
        let key_str = key.as_str();
1967
412
        let mut subscription = if let Some(
publisher202
) = subscribed_keys.get(&key_str) {
1968
202
            publisher.subscribe(weak_subscribed_keys)
1969
        } else {
1970
210
            let (publisher, subscription) =
1971
210
                RedisSubscriptionPublisher::new(key_str.to_string(), weak_subscribed_keys);
1972
210
            subscribed_keys.insert(key_str, publisher);
1973
210
            subscription
1974
        };
1975
412
        subscription
1976
412
            .receiver
1977
412
            .as_mut()
1978
412
            .ok_or_else(|| 
{0
1979
0
                make_err!(
1980
0
                    Code::Internal,
1981
                    "Receiver should be set in RedisSubscriptionManager::subscribe"
1982
                )
1983
0
            })?
1984
412
            .mark_changed();
1985
1986
412
        Ok(subscription)
1987
412
    }
1988
1989
1
    fn is_reliable() -> bool {
1990
1
        false
1991
1
    }
1992
}
1993
1994
impl<C, M> SchedulerStore for RedisStore<C, M>
1995
where
1996
    C: Clone + ConnectionLike + Sync + Send + 'static,
1997
    M: RedisManager<C> + Sync + Send + 'static,
1998
{
1999
    type SubscriptionManager = RedisSubscriptionManager;
2000
2001
9
    fn subscription_manager(
2002
9
        &self,
2003
9
    ) -> impl Future<Output = Result<Arc<RedisSubscriptionManager>, Error>> {
2004
9
        std::future::ready(if self.pub_sub_channel.is_none() {
2005
0
            Err(make_input_err!(
2006
0
                "RedisStore must have a pubsub for Redis Scheduler if using subscriptions"
2007
0
            ))
2008
        } else {
2009
9
            Ok(self.subscription_manager.clone())
2010
        })
2011
9
    }
2012
2013
28
    async fn update_data<T>(&self, data: T, expiry: Option<Duration>) -> Result<Option<i64>, Error>
2014
28
    where
2015
28
        T: SchedulerStoreDataProvider
2016
28
            + SchedulerStoreKeyProvider
2017
28
            + SchedulerCurrentVersionProvider
2018
28
            + Send,
2019
28
    {
2020
28
        let key = data.get_key();
2021
28
        let redis_key = self.encode_key(&key);
2022
28
        let mut client = self.get_client().await
?0
;
2023
28
        let maybe_index = data.get_indexes().err_tip(|| 
{0
2024
0
            format!("Err getting index in RedisStore::update_data::versioned for {redis_key}")
2025
0
        })?;
2026
28
        if <T as SchedulerStoreKeyProvider>::Versioned::VALUE {
2027
21
            let current_version = data.current_version();
2028
21
            let data = data.try_into_bytes().err_tip(|| 
{0
2029
0
                format!("Could not convert value to bytes in RedisStore::update_data::versioned for {redis_key}")
2030
0
            })?;
2031
21
            let mut script = self.connection_manager.update_script(redis_key.as_ref());
2032
21
            let mut script_invocation = script
2033
21
                .arg(format!("{current_version}"))
2034
21
                .arg(expiry.unwrap_or(Duration::ZERO).as_secs())
2035
21
                .arg(data.to_vec());
2036
61
            for (name, value) in 
maybe_index21
{
2037
61
                script_invocation = script_invocation.arg(name).arg(value.to_vec());
2038
61
            }
2039
21
            let start = Instant::now();
2040
21
            let (success, new_version): (bool, i64) = match script_invocation
2041
21
                .invoke_async(&mut client.connection_manager)
2042
21
                .await
2043
            {
2044
21
                Ok(v) => v,
2045
0
                Err(err) if is_retryable_redis_error(&err) => {
2046
0
                    client.reconnect(&self.connection_manager).await?;
2047
0
                    script_invocation
2048
0
                        .invoke_async(&mut client.connection_manager)
2049
0
                        .await
2050
0
                        .err_tip(|| format!("(after reconnect) In RedisStore::update_data::versioned for {key:?}"))?
2051
                }
2052
0
                Err(err) => {
2053
0
                    let mut error: Error = err.into();
2054
0
                    error
2055
0
                        .messages
2056
0
                        .push(format!("In RedisStore::update_data::versioned for {key:?}"));
2057
0
                    return Err(error);
2058
                }
2059
            };
2060
2061
21
            let elapsed = start.elapsed();
2062
2063
21
            if elapsed > Duration::from_millis(100) {
2064
0
                warn!(
2065
                    %redis_key,
2066
                    ?elapsed,
2067
                    "Slow Redis version-set operation"
2068
                );
2069
21
            }
2070
21
            if !success {
2071
5
                warn!(
2072
                    %redis_key,
2073
                    %key,
2074
                    %current_version,
2075
                    %new_version,
2076
5
                    caller = core::any::type_name::<T>(),
2077
                    "Redis version conflict - optimistic lock failed"
2078
                );
2079
5
                return Ok(None);
2080
16
            }
2081
16
            trace!(
2082
                %redis_key,
2083
                %key,
2084
                old_version = %current_version,
2085
                %new_version,
2086
                "Updated redis key to new version"
2087
            );
2088
            // If we have a publish channel configured, send a notice that the key has been set.
2089
16
            if let Some(
pub_sub_channel14
) = &self.pub_sub_channel {
2090
14
                return Ok(client
2091
14
                    .connection_manager
2092
14
                    .publish(pub_sub_channel, redis_key.as_ref())
2093
14
                    .await
?0
);
2094
2
            }
2095
2
            Ok(Some(new_version))
2096
        } else {
2097
7
            let data = data.try_into_bytes().err_tip(|| 
{0
2098
0
                format!("Could not convert value to bytes in RedisStore::update_data::noversion for {redis_key}")
2099
0
            })?;
2100
7
            let mut fields: Vec<(String, _)> = vec![];
2101
7
            fields.push((DATA_FIELD_NAME.into(), data.to_vec()));
2102
7
            for (
name6
,
value6
) in maybe_index {
2103
6
                fields.push((name.into(), value.to_vec()));
2104
6
            }
2105
7
            match client
2106
7
                .connection_manager
2107
7
                .hset_multiple::<_, _, _, ()>(redis_key.as_ref(), &fields)
2108
7
                .await
2109
            {
2110
6
                Ok(_v) => {
2111
6
                    if let Some(expiry_v) = expiry {
2112
6
                        let seconds =
2113
6
                            TryInto::<i64>::try_into(expiry_v.as_secs()).err_tip(|| 
{0
2114
0
                                format!("Expiry seconds doesn't map to i64: {expiry_v:#?}")
2115
0
                            })?;
2116
6
                        let expiry_result: u8 = client
2117
6
                            .connection_manager
2118
6
                            .expire(redis_key.as_ref(), seconds)
2119
6
                            .await
2120
6
                            .err_tip(|| 
{0
2121
0
                                format!(
2122
                                    "In RedisStore::update_data::noversion (expiry) for {redis_key}"
2123
                                )
2124
0
                            })?;
2125
6
                        if expiry_result != 1 {
2126
1
                            warn!(%redis_key, seconds, "Wasn't able to set expiry for Redis key");
2127
5
                        }
2128
0
                    }
2129
                }
2130
1
                Err(err) if is_retryable_redis_error(&err) => {
2131
1
                    client.reconnect(&self.connection_manager).await
?0
;
2132
1
                    client
2133
1
                        .connection_manager
2134
1
                        .hset_multiple::<_, _, _, ()>(redis_key.as_ref(), &fields)
2135
1
                        .await
2136
1
                        .err_tip(|| 
format!0
("(after reconnect) In RedisStore::update_data::noversion (hset) for {redis_key}"))
?0
;
2137
1
                    if let Some(expiry_v) = expiry {
2138
1
                        let seconds =
2139
1
                            TryInto::<i64>::try_into(expiry_v.as_secs()).err_tip(|| 
{0
2140
0
                                format!("Expiry seconds doesn't map to i64: {expiry_v:#?}")
2141
0
                            })?;
2142
1
                        let expiry_result: u8 = client.connection_manager.expire(redis_key.as_ref(), seconds).await
2143
1
                        .err_tip(|| 
format!0
("(after reconnect) In RedisStore::update_data::noversion (expiry) for {redis_key}"))
?0
;
2144
1
                        if expiry_result != 1 {
2145
0
                            warn!(%redis_key, seconds, "Wasn't able to set expiry for Redis key");
2146
1
                        }
2147
0
                    }
2148
                }
2149
0
                Err(err) => {
2150
0
                    let mut error: Error = err.into();
2151
0
                    error.messages.push(format!(
2152
                        "In RedisStore::update_data::noversion for {redis_key}"
2153
                    ));
2154
0
                    return Err(error);
2155
                }
2156
            }
2157
            // If we have a publish channel configured, send a notice that the key has been set.
2158
7
            if let Some(
pub_sub_channel4
) = &self.pub_sub_channel {
2159
4
                return Ok(client
2160
4
                    .connection_manager
2161
4
                    .publish(pub_sub_channel, redis_key.as_ref())
2162
4
                    .await
?0
);
2163
3
            }
2164
3
            Ok(Some(0)) // Always use "0" version since this is not a versioned request.
2165
        }
2166
28
    }
2167
2168
32
    async fn search_by_index_prefix<K>(
2169
32
        &self,
2170
32
        index: K,
2171
32
    ) -> Result<
2172
32
        impl Stream<Item = Result<<K as SchedulerStoreDecodeTo>::DecodeOutput, Error>> + Send,
2173
32
        Error,
2174
32
    >
2175
32
    where
2176
32
        K: SchedulerIndexProvider + SchedulerStoreDecodeTo + Send,
2177
32
    {
2178
32
        let index_value = index.index_value();
2179
32
        try_sanitize(index_value.as_ref())
2180
32
            .then_some(())
2181
32
            .err_tip(|| 
{0
2182
0
                format!("In RedisStore::search_by_index_prefix::try_sanitize - {index_value:?}")
2183
0
            })?;
2184
36
        let 
run_ft_aggregate32
= |connection_manager: C| async {
2185
36
            ft_aggregate(
2186
36
                connection_manager,
2187
36
                format!(
2188
                    "{}",
2189
36
                    get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY)
2190
                ),
2191
36
                if index_value.is_empty() {
2192
2
                    "*".to_string()
2193
                } else {
2194
34
                    format!("@{}:{{ {} }}", K::INDEX_NAME, index_value)
2195
                },
2196
                FtAggregateOptions {
2197
36
                    load: vec![DATA_FIELD_NAME.into(), VERSION_FIELD_NAME.into()],
2198
36
                    cursor: FtAggregateCursor {
2199
36
                        count: self.max_count_per_cursor,
2200
36
                        max_idle: CURSOR_IDLE_MS,
2201
36
                    },
2202
36
                    sort_by: K::MAYBE_SORT_KEY.map_or_else(Vec::new, |v| 
vec!30
[
format!30
("@{v}")]),
2203
                },
2204
            )
2205
36
            .await
2206
72
        };
2207
32
        let run_ft_create = |connection_manager: C| async 
{3
2208
3
            let mut schema = vec![SearchSchema {
2209
3
                field_name: K::INDEX_NAME.into(),
2210
3
                sortable: false,
2211
3
            }];
2212
3
            if let Some(sort_key) = K::MAYBE_SORT_KEY {
2213
3
                schema.push(SearchSchema {
2214
3
                    field_name: sort_key.into(),
2215
3
                    sortable: true,
2216
3
                });
2217
3
            
}0
2218
3
            let create_options = FtCreateOptions {
2219
3
                prefixes: vec![K::KEY_PREFIX.into()],
2220
3
                nohl: true,
2221
3
                nofields: true,
2222
3
                nofreqs: true,
2223
3
                nooffsets: true,
2224
3
                temporary: Some(INDEX_TTL_S),
2225
3
            };
2226
3
            let index = format!(
2227
                "{}",
2228
3
                get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY)
2229
            );
2230
3
            ft_create(connection_manager, index, create_options, schema).await
2231
6
        };
2232
2233
32
        let (connection_manager, connect_id) = self.connection_manager.get_connection().await
?0
;
2234
32
        let 
stream29
= match run_ft_aggregate(connection_manager.clone()).await {
2235
            // A demoted master answers READONLY and a dead/old master drops the
2236
            // connection or times the command out. Both mean the master moved
2237
            // (Sentinel failover) — re-resolve it and retry rather than letting
2238
            // the scheduler's matching loop spin on a stale handle. (A missing
2239
            // index is not retryable here; it falls through to the create path
2240
            // below, which re-runs on the next matching cycle if needed.)
2241
4
            Err(
err1
) if is_retryable_redis_error(&err
)1
=> {
2242
1
                let (connection_manager, _connect_id) =
2243
1
                    self.connection_manager.reconnect(connect_id).await
?0
;
2244
1
                run_ft_aggregate(connection_manager).await.err_tip(|| 
{0
2245
0
                    format!(
2246
                        "Error with reconnected ft_aggregate in RedisStore::search_by_index_prefix({})",
2247
0
                        get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY),
2248
                    )
2249
0
                })
2250
            }
2251
            Err(_) => {
2252
3
                let (connection_manager, result) =
2253
3
                    match run_ft_create(connection_manager.clone()).await {
2254
3
                        Err(
err0
) if is_retryable_redis_error(&err
)0
=> {
2255
0
                            let (connection_manager, _connect_id) =
2256
0
                                self.connection_manager.reconnect(connect_id).await?;
2257
                            (
2258
0
                                connection_manager.clone(),
2259
0
                                run_ft_create(connection_manager).await,
2260
                            )
2261
                        }
2262
3
                        result => (connection_manager, result),
2263
                    };
2264
2265
                // RediSearch returns ErrorKind::Extension with code "Index"
2266
                // and detail along the lines of "Index already exists" when
2267
                // FT.CREATE races with another node.
2268
3
                let create_result = result.or_else(|e| {
2269
3
                    let is_already_exists = e.kind() == redis::ErrorKind::Extension
2270
2
                        && e.code() == Some("Index")
2271
1
                        && e.detail()
2272
1
                            .is_some_and(|d| d.to_ascii_lowercase().contains("already exists"));
2273
3
                    if is_already_exists {
2274
1
                        Ok(())
2275
                    } else {
2276
2
                        Err(e).err_tip(|| {
2277
2
                            format!(
2278
                                "Error with ft_create in RedisStore::search_by_index_prefix({})",
2279
2
                                get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY),
2280
                            )
2281
2
                        })
2282
                    }
2283
3
                });
2284
2285
3
                let run_result = run_ft_aggregate(connection_manager).await.err_tip(|| {
2286
3
                    format!(
2287
                        "Error with second ft_aggregate in RedisStore::search_by_index_prefix({})",
2288
3
                        get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY),
2289
                    )
2290
3
                });
2291
2292
                // Creating the index will race which is ok. If it fails to create, we only
2293
                // error if the second ft_aggregate call fails and fails to create.
2294
3
                run_result.or_else(move |e| create_result.merge(Err(e)))
2295
            }
2296
28
            Ok(stream) => Ok(stream),
2297
3
        }?;
2298
2299
29
        Ok(stream.filter_map(|result| async move 
{19
2300
19
            let raw_redis_map = match result {
2301
19
                Ok(v) => v,
2302
0
                Err(e) => {
2303
                    return Some(
2304
0
                        Err(Error::from(e))
2305
0
                            .err_tip(|| "Error in stream of in RedisStore::search_by_index_prefix"),
2306
                    );
2307
                }
2308
            };
2309
2310
19
            if 
matches!18
(raw_redis_map, Value::Int(_)) {
2311
1
                return None;
2312
18
            }
2313
2314
18
            let Some(redis_map) = raw_redis_map.as_sequence() else {
2315
0
                return Some(Err(Error::new(
2316
0
                    Code::Internal,
2317
0
                    format!("Non-array from ft_aggregate: {raw_redis_map:?}"),
2318
0
                )));
2319
            };
2320
18
            let mut redis_map_iter = redis_map.iter();
2321
18
            let mut bytes_data: Option<Bytes> = None;
2322
18
            let mut version: Option<i64> = None;
2323
55
            while let Some(
key37
) = redis_map_iter.next() {
2324
37
                let value = redis_map_iter.next().unwrap();
2325
37
                let Value::BulkString(k) = key else {
2326
0
                    return Some(Err(Error::new(
2327
0
                        Code::Internal,
2328
0
                        format!("Non-BulkString key from ft_aggregate: {key:?}"),
2329
0
                    )));
2330
                };
2331
37
                let Ok(str_key) = str::from_utf8(k) else {
2332
0
                    return Some(Err(Error::new(
2333
0
                        Code::Internal,
2334
0
                        format!("Non-utf8 key from ft_aggregate: {key:?}"),
2335
0
                    )));
2336
                };
2337
37
                let Value::BulkString(v) = value else {
2338
0
                    return Some(Err(Error::new(
2339
0
                        Code::Internal,
2340
0
                        format!("Non-BulkString value from ft_aggregate: {key:?}"),
2341
0
                    )));
2342
                };
2343
37
                match str_key {
2344
37
                    DATA_FIELD_NAME => {
2345
18
                        bytes_data = Some(v.clone().into());
2346
18
                    }
2347
19
                    VERSION_FIELD_NAME => {
2348
18
                        let Ok(str_v) = str::from_utf8(v) else {
2349
0
                            return Some(Err(Error::new(
2350
0
                                Code::Internal,
2351
0
                                format!("Non-utf8 version value from ft_aggregate: {v:?}"),
2352
0
                            )));
2353
                        };
2354
18
                        let Ok(raw_version) = str_v.parse::<i64>() else {
2355
0
                            return Some(Err(Error::new(
2356
0
                                Code::Internal,
2357
0
                                format!("Non-integer version value from ft_aggregate: {str_v:?}"),
2358
0
                            )));
2359
                        };
2360
18
                        version = Some(raw_version);
2361
                    }
2362
1
                    other => {
2363
1
                        if K::MAYBE_SORT_KEY == Some(other) {
2364
1
                            // ignore sort keys
2365
1
                        } else {
2366
0
                            return Some(Err(Error::new(
2367
0
                                Code::Internal,
2368
0
                                format!("Extra keys from ft_aggregate: {other}"),
2369
0
                            )));
2370
                        }
2371
                    }
2372
                }
2373
            }
2374
18
            let Some(found_bytes_data) = bytes_data else {
2375
0
                return Some(Err(Error::new(
2376
0
                    Code::Internal,
2377
0
                    format!("Missing '{DATA_FIELD_NAME}' in ft_aggregate, got: {raw_redis_map:?}"),
2378
0
                )));
2379
            };
2380
            Some(
2381
18
                K::decode(version.unwrap_or(0), found_bytes_data)
2382
18
                    .err_tip(|| "In RedisStore::search_by_index_prefix::decode"),
2383
            )
2384
38
        }))
2385
32
    }
2386
2387
2
    async fn count_by_index_prefix<K>(&self, index: K) -> Result<u64, Error>
2388
2
    where
2389
2
        K: SchedulerIndexProvider + Send,
2390
2
    {
2391
2
        let index_value = index.index_value();
2392
2
        try_sanitize(index_value.as_ref())
2393
2
            .then_some(())
2394
2
            .err_tip(|| 
{0
2395
0
                format!("In RedisStore::count_by_index_prefix::try_sanitize - {index_value:?}")
2396
0
            })?;
2397
2
        let index_name = format!(
2398
            "{}",
2399
2
            get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY)
2400
        );
2401
2
        let query = if index_value.is_empty() {
2402
0
            "*".to_string()
2403
        } else {
2404
2
            format!("@{}:{{ {} }}", K::INDEX_NAME, index_value)
2405
        };
2406
2407
2
        let (connection_manager, connect_id) = self.connection_manager.get_connection().await
?0
;
2408
        // Same failover handling as search_by_index_prefix: a demoted master
2409
        // answers READONLY and a dead one drops the connection, both meaning the
2410
        // master moved. Unlike that path this never creates the index; counting
2411
        // is a read-only observer and the matching loop owns index creation.
2412
2
        match ft_search_count(connection_manager, index_name.clone(), query.clone()).await {
2413
0
            Err(err) if is_retryable_redis_error(&err) => {
2414
0
                let (connection_manager, _connect_id) =
2415
0
                    self.connection_manager.reconnect(connect_id).await?;
2416
0
                ft_search_count(connection_manager, index_name.clone(), query)
2417
0
                    .await
2418
0
                    .map_err(Error::from)
2419
0
                    .err_tip(|| {
2420
0
                        format!(
2421
                            "Error with reconnected ft_search_count in RedisStore::count_by_index_prefix({index_name})"
2422
                        )
2423
0
                    })
2424
            }
2425
2
            result => result.map_err(Error::from).err_tip(|| 
{0
2426
0
                format!(
2427
                    "Error with ft_search_count in RedisStore::count_by_index_prefix({index_name})"
2428
                )
2429
0
            }),
2430
        }
2431
2
    }
2432
2433
96
    async fn get_and_decode<K>(
2434
96
        &self,
2435
96
        key: K,
2436
96
    ) -> Result<Option<<K as SchedulerStoreDecodeTo>::DecodeOutput>, Error>
2437
96
    where
2438
96
        K: SchedulerStoreKeyProvider + SchedulerStoreDecodeTo + Send,
2439
96
    {
2440
96
        let key = key.get_key();
2441
96
        let key = self.encode_key(&key);
2442
96
        let mut client = self.get_client().await
?0
;
2443
        // hmget is idempotent, so re-resolve the master and retry on a transient
2444
        // failover (matching the read paths in get_part/list) instead of failing
2445
        // a scheduler-state read while the master is moving.
2446
96
        let results: Vec<Value> = {
2447
96
            let mut attempt: u32 = 0;
2448
            loop {
2449
96
                attempt += 1;
2450
96
                match client
2451
96
                    .connection_manager
2452
96
                    .hmget::<_, Vec<String>, Vec<Value>>(
2453
96
                        key.as_ref(),
2454
96
                        vec![VERSION_FIELD_NAME.into(), DATA_FIELD_NAME.into()],
2455
                    )
2456
96
                    .await
2457
                {
2458
96
                    Ok(v) => break v,
2459
0
                    Err(err)
2460
0
                        if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) =>
2461
                    {
2462
0
                        client.reconnect(&self.connection_manager).await?;
2463
0
                        sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await;
2464
                    }
2465
0
                    Err(err) => {
2466
0
                        return Err(Error::from(err).append(format!(
2467
0
                            "In RedisStore::get_without_version::notversioned {key}"
2468
0
                        )));
2469
                    }
2470
                }
2471
            }
2472
        };
2473
96
        let Some(Value::BulkString(
data49
)) = results.get(1) else {
2474
47
            return Ok(None);
2475
        };
2476
        #[allow(clippy::get_first)]
2477
49
        let version = if let Some(raw_v) = results.get(0) {
2478
49
            match raw_v {
2479
0
                Value::Int(v) => *v,
2480
47
                Value::BulkString(v) => i64::from_str(str::from_utf8(v).expect("utf-8 bulkstring"))
2481
47
                    .expect("integer bulkstring"),
2482
2
                Value::Nil => 0,
2483
                _ => {
2484
0
                    warn!(?raw_v, "Non-integer version!");
2485
0
                    0
2486
                }
2487
            }
2488
        } else {
2489
0
            0
2490
        };
2491
        Ok(Some(
2492
49
            K::decode(version, Bytes::from(data.clone())).err_tip(|| 
{0
2493
0
                format!("In RedisStore::get_with_version::notversioned::decode {key}")
2494
0
            })?,
2495
        ))
2496
96
    }
2497
}