/build/source/nativelink-store/src/redis_store.rs
Line | Count | Source |
1 | | // Copyright 2024-2026 Trace Machina, Inc. 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::{HashMap, 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, RedisError, ScanOptions, |
56 | | Script, SetExpiry, SetOptions, Value, 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::{Mutex as AsyncMutex, 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::index_migration::{ |
71 | | self, IndexKind, Replacement, Step, drop_keeping_documents, index_kind, is_cursor_lost, |
72 | | is_out_of_memory, migrated_name, |
73 | | }; |
74 | | use crate::redis_utils::{ |
75 | | FtAggregateCursor, FtAggregateOptions, FtCreateOptions, SearchSchema, ft_aggregate, ft_create, |
76 | | ft_info, ft_info_indexing, ft_info_pair, ft_search_count, |
77 | | }; |
78 | | |
79 | | /// The default size of the read chunk when reading data from Redis. |
80 | | /// Note: If this changes it should be updated in the config documentation. |
81 | | const DEFAULT_READ_CHUNK_SIZE: usize = 64 * 1024; |
82 | | |
83 | | /// The default size of the connection pool if not specified. |
84 | | /// Note: If this changes it should be updated in the config documentation. |
85 | | const DEFAULT_CONNECTION_POOL_SIZE: usize = 3; |
86 | | |
87 | | /// The default delay between retries if not specified. |
88 | | /// Note: If this changes it should be updated in the config documentation. |
89 | | const DEFAULT_RETRY_DELAY: f32 = 0.1; |
90 | | |
91 | | /// Maximum attempts (re-resolving the master each time) for an operation to |
92 | | /// ride out a transient Redis topology change — a failover, a dropped |
93 | | /// connection, or brief replica lag. Used by `update`'s verify and by the read |
94 | | /// paths so a transient error doesn't fail an otherwise healthy request. |
95 | | const MAX_REDIS_RETRY_ATTEMPTS: u32 = 5; |
96 | | |
97 | | /// Whether a Redis error is worth re-resolving the master and retrying: a |
98 | | /// dropped/refused connection, an IO error, or a write that hit a freshly |
99 | | /// demoted replica. Anything else (e.g. a real protocol/logic error) is |
100 | | /// returned as-is. |
101 | 272 | fn is_retryable_redis_error(err: &RedisError) -> bool { |
102 | 272 | err.is_connection_dropped() |
103 | 33 | || err.is_connection_refusal() |
104 | 33 | || err.is_io_error() |
105 | 31 | || err.kind() == redis::ErrorKind::Server(redis::ServerErrorKind::ReadOnly) |
106 | 272 | } |
107 | | |
108 | | /// The default connection timeout in milliseconds if not specified. |
109 | | /// Note: If this changes it should be updated in the config documentation. |
110 | | const DEFAULT_CONNECTION_TIMEOUT_MS: u64 = 3000; |
111 | | |
112 | | /// The default command timeout in milliseconds if not specified. |
113 | | /// Note: If this changes it should be updated in the config documentation. |
114 | | const DEFAULT_COMMAND_TIMEOUT_MS: u64 = 10_000; |
115 | | |
116 | | /// The default `check_health` PING ceiling in milliseconds if not specified. |
117 | | /// Note: If this changes it should be updated in the config documentation. |
118 | | const DEFAULT_HEALTH_CHECK_TIMEOUT_MS: u64 = 4000; |
119 | | |
120 | | /// The default maximum number of chunk uploads per update. |
121 | | /// Note: If this changes it should be updated in the config documentation. |
122 | | pub const DEFAULT_MAX_CHUNK_UPLOADS_PER_UPDATE: usize = 10; |
123 | | |
124 | | /// The default COUNT value passed when scanning keys in Redis. |
125 | | /// Note: If this changes it should be updated in the config documentation. |
126 | | const DEFAULT_SCAN_COUNT: usize = 10_000; |
127 | | |
128 | | /// The default COUNT value passed when scanning search indexes |
129 | | /// Note: If this changes it should be updated in the config documentation. |
130 | | pub const DEFAULT_MAX_COUNT_PER_CURSOR: u64 = 1_500; |
131 | | |
132 | | const DEFAULT_CLIENT_PERMITS: usize = 500; |
133 | | |
134 | | /// Converts a TTL to the seconds `EXPIRE` takes. |
135 | | /// |
136 | | /// Clamped to at least one second. Redis treats `EXPIRE` with a value of zero |
137 | | /// or less as "delete now", so a sub-second duration reaching here would |
138 | | /// destroy the value it was meant to protect. Config already rejects zero, so |
139 | | /// this only guards a future caller. |
140 | 30 | fn ttl_seconds(key_ttl: Duration) -> i64 { |
141 | 30 | i64::try_from(key_ttl.as_secs()).unwrap_or(i64::MAX).max(1) |
142 | 30 | } |
143 | | |
144 | | /// Lifetime of an upload's temp key on a store without `key_ttl_s`. Every |
145 | | /// chunk arms it again, so it bounds the gap between two chunks, not the |
146 | | /// upload. |
147 | | /// |
148 | | /// Longer than any pause a client is allowed between chunks (Bazel's |
149 | | /// `--remote_timeout` is 60 s by default, and a resumed upload waits at most |
150 | | /// the `ByteStream` server's disconnect timeout), and short enough that the |
151 | | /// uploads in flight when a CAS dies are gone within the hour. An upload that |
152 | | /// goes quiet for longer fails rather than publishing a key that was |
153 | | /// recreated without its first chunks. |
154 | | const TEMP_KEY_TTL: Duration = Duration::from_hours(1); |
155 | | |
156 | | /// `RENAME` of a key that is not there. On a retried publish that means |
157 | | /// the first attempt applied before its reply was lost, or the failover |
158 | | /// lost the temp key; the caller tells the two apart by the final key. |
159 | 2 | fn is_no_such_key(err: &RedisError) -> bool { |
160 | 2 | err.kind() == redis::ErrorKind::Server(redis::ServerErrorKind::ResponseError) |
161 | 2 | && err |
162 | 2 | .detail() |
163 | 2 | .is_some_and(|detail| detail.contains("no such key")) |
164 | 2 | } |
165 | | |
166 | | /// A wrapper around Redis to allow it to be reconnected. |
167 | | pub trait RedisManager<C> |
168 | | where |
169 | | C: ConnectionLike + Clone, |
170 | | { |
171 | | /// Get a connection manager and a unique identifier for this connection |
172 | | /// which may be used to issue a reconnect later. |
173 | | fn get_connection(&self) -> impl Future<Output = Result<(C, Uuid), Error>> + Send; |
174 | | |
175 | | /// Reconnect if the uuid matches the uuid returned from `get_connection()`. |
176 | | fn reconnect(&self, uuid: Uuid) -> impl Future<Output = Result<(C, Uuid), Error>> + Send; |
177 | | |
178 | | /// Get an invocation of the update version script for a given `key`. |
179 | | fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_>; |
180 | | |
181 | | /// Configure the connection to have a psubscribe on it and perform the |
182 | | /// subscription on reconnect. |
183 | | fn psubscribe(&self, pattern: &str) -> impl Future<Output = Result<(), Error>> + Send; |
184 | | } |
185 | | |
186 | | #[derive(Debug)] |
187 | | pub struct ClusterRedisManager<C> |
188 | | where |
189 | | C: ConnectionLike + Clone, |
190 | | { |
191 | | /// A constant Uuid, we never reconnect. |
192 | | uuid: Uuid, |
193 | | |
194 | | /// Redis script used to update a value in redis if the version matches. |
195 | | /// This is done by incrementing the version number and then setting the new |
196 | | /// data only if the version number matches the existing version number. |
197 | | update_if_version_matches_script: Script, |
198 | | |
199 | | /// The client pool connecting to the backing Redis instance(s). |
200 | | connection_manager: C, |
201 | | } |
202 | | |
203 | | impl<C> ClusterRedisManager<C> |
204 | | where |
205 | | C: ConnectionLike + Clone, |
206 | | { |
207 | 50 | pub async fn new(mut connection_manager: C) -> Result<Self, Error> { |
208 | 50 | let update_if_version_matches_script = Script::new(LUA_VERSION_SET_SCRIPT); |
209 | 50 | update_if_version_matches_script |
210 | 50 | .load_async(&mut connection_manager) |
211 | 50 | .await?0 ; |
212 | 50 | Ok(Self { |
213 | 50 | uuid: Uuid::new_v4(), |
214 | 50 | update_if_version_matches_script, |
215 | 50 | connection_manager, |
216 | 50 | }) |
217 | 50 | } |
218 | | } |
219 | | |
220 | | impl<C> RedisManager<C> for ClusterRedisManager<C> |
221 | | where |
222 | | C: ConnectionLike + Clone + Send + Sync, |
223 | | { |
224 | 147 | fn get_connection(&self) -> impl Future<Output = Result<(C, Uuid), Error>> + Send { |
225 | 147 | future::ready(Ok((self.connection_manager.clone(), self.uuid))) |
226 | 147 | } |
227 | | |
228 | 13 | fn reconnect(&self, _uuid: Uuid) -> impl Future<Output = Result<(C, Uuid), Error>> + Send { |
229 | 13 | self.get_connection() |
230 | 13 | } |
231 | | |
232 | 0 | fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_> { |
233 | 0 | self.update_if_version_matches_script.key(key) |
234 | 0 | } |
235 | | |
236 | 4 | fn psubscribe(&self, _pattern: &str) -> impl Future<Output = Result<(), Error>> + Send { |
237 | | // This is a no-op for cluster connections. |
238 | 4 | future::ready(Ok(())) |
239 | 4 | } |
240 | | } |
241 | | |
242 | | type RedisConnectFuture<C> = dyn Future<Output = Result<C, Error>> + Send; |
243 | | type RedisConnectFn<C> = dyn Fn() -> Pin<Box<RedisConnectFuture<C>>> + Send + Sync; |
244 | | |
245 | | pub struct StandardRedisManager<C> |
246 | | where |
247 | | C: ConnectionLike + Clone, |
248 | | { |
249 | | /// Function used to re-connect to Redis. |
250 | | connect_func: Box<RedisConnectFn<C>>, |
251 | | |
252 | | /// Redis script used to update a value in redis if the version matches. |
253 | | /// This is done by incrementing the version number and then setting the new |
254 | | /// data only if the version number matches the existing version number. |
255 | | update_if_version_matches_script: Script, |
256 | | |
257 | | /// The client pool connecting to the backing Redis instance(s) and a Uuid |
258 | | /// for this connection in order to avoid multiple reconnection attempts. |
259 | | connection_manager: tokio::sync::RwLock<(C, Uuid)>, |
260 | | |
261 | | /// Serializes reconnect attempts so a Sentinel master failover triggers a |
262 | | /// single re-resolution instead of a thundering herd. Kept separate from |
263 | | /// `connection_manager` on purpose: the (potentially multi-second) connect |
264 | | /// runs while holding only this lock, so in-flight `get_connection` readers |
265 | | /// keep using the existing handle and are never frozen behind a slow |
266 | | /// reconnect. See [`Self::reconnect`]. |
267 | | reconnect_lock: tokio::sync::Mutex<()>, |
268 | | |
269 | | /// A list of subscription that should be performed on reconnect. |
270 | | subscriptions: Mutex<HashSet<String>>, |
271 | | } |
272 | | |
273 | | impl<C> Debug for StandardRedisManager<C> |
274 | | where |
275 | | C: ConnectionLike + Clone, |
276 | | { |
277 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
278 | 0 | f.debug_struct("StandardRedisManager") |
279 | 0 | .field( |
280 | 0 | "update_if_version_matches_script", |
281 | 0 | &self.update_if_version_matches_script, |
282 | 0 | ) |
283 | 0 | .field("subscriptions", &self.subscriptions) |
284 | 0 | .finish() |
285 | 0 | } |
286 | | } |
287 | | |
288 | | impl<C> StandardRedisManager<C> |
289 | | where |
290 | | C: ConnectionLike + Clone + Send + Sync, |
291 | | { |
292 | 131 | async fn configure(&self, connection_manager: &mut C) -> Result<(), Error> { |
293 | 131 | self.update_if_version_matches_script |
294 | 131 | .load_async(connection_manager) |
295 | 131 | .await?0 ; |
296 | 131 | Ok(()) |
297 | 131 | } |
298 | | |
299 | 116 | async fn new(connect_func: Box<RedisConnectFn<C>>) -> Result<Self, Error> { |
300 | 116 | let connection_manager113 = connect_func().await?3 ; |
301 | 113 | let update_if_version_matches_script = Script::new(LUA_VERSION_SET_SCRIPT); |
302 | 113 | let connection = Self { |
303 | 113 | connect_func, |
304 | 113 | update_if_version_matches_script, |
305 | 113 | connection_manager: tokio::sync::RwLock::new((connection_manager, Uuid::new_v4())), |
306 | 113 | reconnect_lock: tokio::sync::Mutex::new(()), |
307 | 113 | subscriptions: Mutex::new(HashSet::new()), |
308 | 113 | }; |
309 | | { |
310 | 113 | let mut connection_manager = connection.connection_manager.write().await; |
311 | 113 | connection.configure(&mut connection_manager.0).await?0 ; |
312 | | } |
313 | 113 | Ok(connection) |
314 | 116 | } |
315 | | } |
316 | | |
317 | | impl RedisManager<ConnectionManager> for StandardRedisManager<ConnectionManager> { |
318 | 691k | async fn get_connection(&self) -> Result<(ConnectionManager, Uuid), Error> { |
319 | 691k | Ok(self.connection_manager.read().await.clone()) |
320 | 691k | } |
321 | | |
322 | 249 | async fn reconnect(&self, uuid: Uuid) -> Result<(ConnectionManager, Uuid), Error> { |
323 | | // Fast path: another caller already reconnected past this generation, |
324 | | // so the handle is fresh — hand it back without re-resolving. |
325 | | { |
326 | 249 | let guard = self.connection_manager.read().await; |
327 | 249 | if guard.1 != uuid { |
328 | 0 | return Ok(guard.clone()); |
329 | 249 | } |
330 | | } |
331 | | // Serialize reconnect attempts on a dedicated lock so a failover storm |
332 | | // resolves the new master once. Critically this is NOT the |
333 | | // `connection_manager` lock, so the slow connect below does not block |
334 | | // the `get_connection` readers that every Redis op needs — they keep |
335 | | // using the old (now-failing) handle and fail fast on their own |
336 | | // command timeout instead of all freezing behind one reconnect. |
337 | 249 | let _reconnect_guard = self.reconnect_lock.lock().await; |
338 | | // Re-check: a reconnect may have completed while we waited for the lock. |
339 | | { |
340 | 249 | let guard = self.connection_manager.read().await; |
341 | 249 | if guard.1 != uuid { |
342 | 2 | return Ok(guard.clone()); |
343 | 247 | } |
344 | | } |
345 | 247 | record_connection_reconnect("redis"); |
346 | 247 | let mut connection_manager18 = (self.connect_func)().await?229 ; |
347 | 18 | let new_uuid = Uuid::new_v4(); |
348 | 18 | self.configure(&mut connection_manager).await?0 ; |
349 | 18 | let subscriptions = { |
350 | 18 | let guard = self.subscriptions.lock(); |
351 | 18 | guard.iter().map(Clone::clone).collect::<Vec<_>>() |
352 | | }; |
353 | 18 | for subscription15 in &subscriptions { |
354 | 15 | connection_manager.psubscribe(subscription).await?0 ; |
355 | | } |
356 | 18 | let mut replayed = subscriptions.into_iter().collect::<HashSet<_>>(); |
357 | | // Publish the new handle under a brief exclusive lock. Before |
358 | | // publishing, replay any patterns registered since the snapshot above: |
359 | | // `psubscribe` records its pattern in `self.subscriptions` *before* it |
360 | | // fetches a connection, so a pattern missing from this final delta was |
361 | | // inserted while we hold the write lock, and its `get_connection` will |
362 | | // block here and observe the new handle — it subscribes on the live |
363 | | // connection itself. Either way there is no window where a pattern is |
364 | | // recorded as active but subscribed only on the old connection. |
365 | | { |
366 | 18 | let mut guard = self.connection_manager.write().await; |
367 | 18 | let missing = { |
368 | 18 | let subs = self.subscriptions.lock(); |
369 | 18 | subs.iter() |
370 | 18 | .filter(|s| !replayed16 .contains16 (*s16 )) |
371 | 18 | .cloned() |
372 | 18 | .collect::<Vec<_>>() |
373 | | }; |
374 | 18 | for subscription1 in missing { |
375 | 1 | connection_manager.psubscribe(&subscription).await?0 ; |
376 | 1 | replayed.insert(subscription); |
377 | | } |
378 | 18 | *guard = (connection_manager.clone(), new_uuid); |
379 | | } |
380 | 18 | info!(old = %uuid, new = %new_uuid, "StandardRedisManager re-resolved the Redis master"); |
381 | 18 | Ok((connection_manager, new_uuid)) |
382 | 249 | } |
383 | | |
384 | 7.91k | fn update_script(&self, key: &str) -> redis::ScriptInvocation<'_> { |
385 | 7.91k | self.update_if_version_matches_script.key(key) |
386 | 7.91k | } |
387 | | |
388 | 98 | async fn psubscribe(&self, pattern: &str) -> Result<(), Error> { |
389 | 98 | debug!(pattern, "new psubscribe"); |
390 | | // Record the pattern BEFORE fetching a connection. A concurrent |
391 | | // `reconnect` either sees the pattern in the snapshot it replays under |
392 | | // its pre-publish write lock (subscribing it on the new connection), |
393 | | // or it published first — in which case `get_connection` below returns |
394 | | // the new live connection and we subscribe on it ourselves. Inserting |
395 | | // after fetching the connection (the previous order) left a window |
396 | | // where the pattern was recorded as active but PSUBSCRIBEd only on the |
397 | | // old, about-to-be-replaced connection. |
398 | 98 | let new_subscription = self.subscriptions.lock().insert(String::from(pattern)); |
399 | 98 | if new_subscription { |
400 | 98 | let result = async { |
401 | 98 | let mut connection = self.get_connection().await?0 .0; |
402 | 98 | connection.psubscribe(pattern).await.map_err(Error::from) |
403 | 98 | } |
404 | 98 | .await; |
405 | 98 | if result.is_err() { |
406 | 0 | self.subscriptions.lock().remove(pattern); |
407 | 98 | } |
408 | 98 | result?0 ; |
409 | 0 | } |
410 | 98 | debug!(pattern, new_subscription, "new psubscribe complete"); |
411 | 98 | Ok(()) |
412 | 98 | } |
413 | | } |
414 | | |
415 | | /// A [`StoreDriver`] implementation that uses Redis as a backing store. |
416 | | #[derive(MetricsComponent)] |
417 | | pub struct RedisStore<C, M> |
418 | | where |
419 | | C: ConnectionLike + Clone, |
420 | | M: RedisManager<C>, |
421 | | { |
422 | | /// The client pool connecting to the backing Redis instance(s). |
423 | | connection_manager: M, |
424 | | |
425 | | /// The underlying connection type in the connection manager. |
426 | | _connection_type: PhantomData<C>, |
427 | | |
428 | | /// A channel to publish updates to when a key is added, removed, or modified. |
429 | | #[metric( |
430 | | help = "The pubsub channel to publish updates to when a key is added, removed, or modified" |
431 | | )] |
432 | | pub_sub_channel: Option<String>, |
433 | | |
434 | | /// A function used to generate names for temporary keys. |
435 | | temp_name_generator_fn: fn() -> String, |
436 | | |
437 | | /// A common prefix to append to all keys before they are sent to Redis. |
438 | | /// |
439 | | /// See [`RedisStore::key_prefix`](`nativelink_config::stores::RedisStore::key_prefix`). |
440 | | #[metric(help = "Prefix to append to all keys before sending to Redis")] |
441 | | key_prefix: String, |
442 | | |
443 | | /// The amount of data to read from Redis at a time. |
444 | | #[metric(help = "The amount of data to read from Redis at a time")] |
445 | | read_chunk_size: usize, |
446 | | |
447 | | /// The maximum number of chunk uploads per update. |
448 | | /// This is used to limit the number of chunk uploads per update to prevent |
449 | | /// overloading when uploading large blocks of data |
450 | | #[metric(help = "The maximum number of chunk uploads per update")] |
451 | | max_chunk_uploads_per_update: usize, |
452 | | |
453 | | /// The COUNT value passed when scanning keys in Redis. |
454 | | /// This is used to hint the amount of work that should be done per response. |
455 | | #[metric(help = "The COUNT value passed when scanning keys in Redis")] |
456 | | scan_count: usize, |
457 | | |
458 | | /// The COUNT value used with search indexes |
459 | | #[metric(help = "The maximum number of results to return per cursor")] |
460 | | max_count_per_cursor: u64, |
461 | | |
462 | | /// See [`RedisSpec::migrate_temporary_indexes`]. |
463 | | migrate_temporary_indexes: bool, |
464 | | |
465 | | /// For each scheduler index, the name reads use and until when that |
466 | | /// answer holds. A settled answer is rechecked after |
467 | | /// `INDEX_MIGRATION_RECHECK`, so an original that a process without the |
468 | | /// migration recreates is retired again. |
469 | | index_names: Mutex<HashMap<String, (String, Instant)>>, |
470 | | |
471 | | /// Lets one read at a time run [`Self::migration_step`]. Concurrent |
472 | | /// reads whose cached name has expired would otherwise each repeat the |
473 | | /// `FT.DEBUG`/`FT.INFO` checks — and, while a replacement backfills with |
474 | | /// no original left, each sit in its own `FT.INFO` poll — against a |
475 | | /// Redis that is already memory-stressed. Waiters re-read the cache the |
476 | | /// winner refreshed instead. |
477 | | index_migration_gate: AsyncMutex<()>, |
478 | | |
479 | | /// A manager for subscriptions to keys in Redis. |
480 | | subscription_manager: Arc<RedisSubscriptionManager>, |
481 | | |
482 | | /// Permits to limit inflight Redis requests. Technically only |
483 | | /// limits the calls to `get_client()`, but the requests per client |
484 | | /// are small enough that it works well enough. |
485 | | client_permits: Arc<Semaphore>, |
486 | | |
487 | | /// Per-call ceiling for `check_health` PING. |
488 | | health_check_timeout: Duration, |
489 | | |
490 | | /// Expire keys this store writes after this long. `None` keeps them |
491 | | /// forever, which is the default and what every store other than a BEP |
492 | | /// store wants. |
493 | | key_ttl: Option<Duration>, |
494 | | |
495 | | /// Have we done a subscribe for messages for `remove_callback` subscribes? |
496 | | has_remove_callback_subscribe: OnceCell<()>, |
497 | | |
498 | | remove_callbacks: Arc<async_lock::Mutex<Vec<RemoveCallback>>>, |
499 | | } |
500 | | |
501 | | impl<C, M> Debug for RedisStore<C, M> |
502 | | where |
503 | | C: ConnectionLike + Clone, |
504 | | M: RedisManager<C>, |
505 | | { |
506 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
507 | 0 | f.debug_struct("RedisStore") |
508 | 0 | .field("temp_name_generator_fn", &self.temp_name_generator_fn) |
509 | 0 | .field("key_prefix", &self.key_prefix) |
510 | 0 | .field("read_chunk_size", &self.read_chunk_size) |
511 | 0 | .field( |
512 | 0 | "max_chunk_uploads_per_update", |
513 | 0 | &self.max_chunk_uploads_per_update, |
514 | 0 | ) |
515 | 0 | .field("scan_count", &self.scan_count) |
516 | 0 | .field("subscription_manager", &self.subscription_manager) |
517 | 0 | .field("client_permits", &self.client_permits) |
518 | 0 | .finish() |
519 | 0 | } |
520 | | } |
521 | | |
522 | | struct ClientWithPermit<C: ConnectionLike> { |
523 | | connection_manager: C, |
524 | | uuid: Uuid, |
525 | | |
526 | | // here so it sticks around with the client and doesn't get dropped until that does |
527 | | #[allow(dead_code)] |
528 | | semaphore_permit: OwnedSemaphorePermit, |
529 | | } |
530 | | |
531 | | impl<C: ConnectionLike + Clone> ClientWithPermit<C> { |
532 | 241 | async fn reconnect<M: RedisManager<C> + Sync>(&mut self, manager: &M) -> Result<(), Error> { |
533 | 241 | (self.connection_manager17 , self.uuid17 ) = manager.reconnect(self.uuid).await?224 ; |
534 | 17 | Ok(()) |
535 | 241 | } |
536 | | } |
537 | | |
538 | | impl<C: ConnectionLike> Drop for ClientWithPermit<C> { |
539 | 674k | fn drop(&mut self) { |
540 | 674k | trace!( |
541 | 2.64k | remaining = self.semaphore_permit.semaphore().available_permits(), |
542 | | "Dropping a client permit" |
543 | | ); |
544 | 674k | } |
545 | | } |
546 | | |
547 | | /// Decode a `StoreKey` coming from Redis |
548 | 35 | pub fn decode_key<'a>( |
549 | 35 | key_prefix: &String, |
550 | 35 | encoded_key: Cow<'a, str>, |
551 | 35 | ) -> Result<StoreKey<'a>, Error> { |
552 | 35 | let key_no_prefix = if key_prefix.is_empty() { |
553 | 30 | encoded_key |
554 | | } else { |
555 | 5 | match encoded_key.strip_prefix(key_prefix) { |
556 | 5 | Some(r) => Cow::from(r.to_string()), |
557 | | None => { |
558 | 0 | return Err(make_err!( |
559 | 0 | Code::InvalidArgument, |
560 | 0 | "Redis key ({}) is missing prefix ({})", |
561 | 0 | encoded_key, |
562 | 0 | key_prefix |
563 | 0 | )); |
564 | | } |
565 | | } |
566 | | }; |
567 | 35 | let maybe_digest_info: Result<_, serde::de::value::Error> = |
568 | 35 | DigestInfo::deserialize(key_no_prefix.clone().into_deserializer()); |
569 | 35 | if let Ok(digest_info8 ) = maybe_digest_info { |
570 | 8 | Ok(StoreKey::Digest(digest_info)) |
571 | | } else { |
572 | 27 | Ok(StoreKey::Str(key_no_prefix)) |
573 | | } |
574 | 35 | } |
575 | | |
576 | | impl<C, M> RedisStore<C, M> |
577 | | where |
578 | | C: ConnectionLike + Clone + Sync, |
579 | | M: RedisManager<C> + Sync, |
580 | | { |
581 | | /// Used for testing when determinism is required. |
582 | | #[expect(clippy::too_many_arguments)] |
583 | 162 | pub async fn new_from_builder_and_parts( |
584 | 162 | pub_sub_channel: Option<String>, |
585 | 162 | temp_name_generator_fn: fn() -> String, |
586 | 162 | key_prefix: String, |
587 | 162 | read_chunk_size: usize, |
588 | 162 | max_chunk_uploads_per_update: usize, |
589 | 162 | scan_count: usize, |
590 | 162 | max_client_permits: usize, |
591 | 162 | max_count_per_cursor: u64, |
592 | 162 | health_check_timeout: Duration, |
593 | 162 | key_ttl: Option<Duration>, |
594 | 162 | subscriber_channel: UnboundedReceiver<PushInfo>, |
595 | 162 | connection_manager: M, |
596 | 162 | ) -> Result<Self, Error> { |
597 | 162 | info!("Redis index fingerprint: {FINGERPRINT_CREATE_INDEX_HEX}"); |
598 | 162 | let remove_callbacks = Arc::new(async_lock::Mutex::new(Vec::new())); |
599 | 162 | let subscription_manager = Arc::new(RedisSubscriptionManager::new( |
600 | 162 | subscriber_channel, |
601 | 162 | remove_callbacks.clone(), |
602 | 162 | key_prefix.clone(), |
603 | | )); |
604 | 162 | if let Some(channel91 ) = &pub_sub_channel { |
605 | 91 | connection_manager.psubscribe(channel).await?0 ; |
606 | 71 | } |
607 | | |
608 | 162 | Ok(Self { |
609 | 162 | connection_manager, |
610 | 162 | _connection_type: PhantomData, |
611 | 162 | pub_sub_channel, |
612 | 162 | temp_name_generator_fn, |
613 | 162 | key_prefix, |
614 | 162 | read_chunk_size, |
615 | 162 | max_chunk_uploads_per_update, |
616 | 162 | scan_count, |
617 | 162 | subscription_manager, |
618 | 162 | client_permits: Arc::new(Semaphore::new(max_client_permits)), |
619 | 162 | max_count_per_cursor, |
620 | 162 | migrate_temporary_indexes: false, |
621 | 162 | index_names: Mutex::new(HashMap::new()), |
622 | 162 | index_migration_gate: AsyncMutex::new(()), |
623 | 162 | health_check_timeout, |
624 | 162 | key_ttl, |
625 | 162 | has_remove_callback_subscribe: OnceCell::const_new(), |
626 | 162 | remove_callbacks, |
627 | 162 | }) |
628 | 162 | } |
629 | | |
630 | | /// Turns on [`RedisSpec::migrate_temporary_indexes`]. |
631 | | #[must_use] |
632 | 0 | pub fn with_temporary_index_migration(mut self) -> Self { |
633 | 0 | info!("Redis temporary-index migration enabled"); |
634 | 0 | self.migrate_temporary_indexes = true; |
635 | 0 | self |
636 | 0 | } |
637 | | |
638 | | /// `STRLEN` of a key, retried on a transient error like the reads are. |
639 | 2 | async fn strlen( |
640 | 2 | &self, |
641 | 2 | client: &mut ClientWithPermit<C>, |
642 | 2 | encoded_key: &str, |
643 | 2 | ) -> Result<u64, Error> { |
644 | 2 | let mut attempt: u32 = 0; |
645 | | loop { |
646 | 2 | attempt += 1; |
647 | 2 | match redis::cmd("STRLEN") |
648 | 2 | .arg(encoded_key) |
649 | 2 | .query_async::<u64>(&mut client.connection_manager) |
650 | 2 | .await |
651 | | { |
652 | 2 | Ok(v) => return Ok(v), |
653 | 0 | Err(err) |
654 | 0 | if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) => |
655 | | { |
656 | 0 | client.reconnect(&self.connection_manager).await?; |
657 | 0 | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
658 | | } |
659 | 0 | Err(err) => { |
660 | 0 | return Err(Error::from(err).append("In RedisStore::get_part::strlen")); |
661 | | } |
662 | | } |
663 | | } |
664 | 2 | } |
665 | | |
666 | 689k | async fn get_client(&self) -> Result<ClientWithPermit<C>, Error> { |
667 | 689k | let local_client_permits = self.client_permits.clone(); |
668 | 689k | let remaining = local_client_permits.available_permits(); |
669 | | // Zero here means every client is busy and this call is about to wait, |
670 | | // which is the saturation signal worth alerting on. |
671 | 689k | record_connection_acquired("redis", Some(remaining), remaining == 0); |
672 | 689k | let semaphore_permit674k = local_client_permits.acquire_owned().await?0 ; |
673 | 674k | trace!(remaining, "Got a client permit"); |
674 | 674k | let (connection_manager, uuid) = self.connection_manager.get_connection().await?0 ; |
675 | 674k | Ok(ClientWithPermit { |
676 | 674k | connection_manager, |
677 | 674k | uuid, |
678 | 674k | semaphore_permit, |
679 | 674k | }) |
680 | 674k | } |
681 | | |
682 | | /// Encode a `StoreKey` so it can be sent to Redis. |
683 | 689k | pub fn encode_key<'a>(&self, key: &'a StoreKey<'a>) -> Cow<'a, str> { |
684 | 689k | let key_body = key.as_str(); |
685 | 689k | if self.key_prefix.is_empty() { |
686 | 689k | key_body |
687 | | } else { |
688 | | // This is in the hot path for all redis operations, so we try to reuse the allocation |
689 | | // from `key.as_str()` if possible. |
690 | 9 | match key_body { |
691 | 8 | Cow::Owned(mut encoded_key) => { |
692 | 8 | encoded_key.insert_str(0, &self.key_prefix); |
693 | 8 | Cow::Owned(encoded_key) |
694 | | } |
695 | 1 | Cow::Borrowed(body) => { |
696 | 1 | let mut encoded_key = String::with_capacity(self.key_prefix.len() + body.len()); |
697 | 1 | encoded_key.push_str(&self.key_prefix); |
698 | 1 | encoded_key.push_str(body); |
699 | 1 | Cow::Owned(encoded_key) |
700 | | } |
701 | | } |
702 | | } |
703 | 689k | } |
704 | | |
705 | 115 | fn set_spec_defaults(spec: &mut RedisSpec) -> Result<(), Error> { |
706 | 115 | if spec.addresses.is_empty() { |
707 | 0 | return Err(make_err!( |
708 | 0 | Code::InvalidArgument, |
709 | 0 | "No addresses were specified in redis store configuration." |
710 | 0 | )); |
711 | 115 | } |
712 | | |
713 | 115 | if spec.broadcast_channel_capacity != 0 { |
714 | 1 | warn!("broadcast_channel_capacity in Redis spec is deprecated and ignored"); |
715 | 114 | } |
716 | 115 | if spec.response_timeout_s != 0 { |
717 | 0 | warn!( |
718 | | "response_timeout_s in Redis spec is deprecated and ignored, use command_timeout_ms" |
719 | | ); |
720 | 115 | } |
721 | 115 | if spec.connection_timeout_s != 0 { |
722 | 0 | if spec.connection_timeout_ms != 0 { |
723 | 0 | return Err(make_err!( |
724 | 0 | Code::InvalidArgument, |
725 | 0 | "Both connection_timeout_s and connection_timeout_ms were set, can only have one!" |
726 | 0 | )); |
727 | 0 | } |
728 | 0 | warn!("connection_timeout_s in Redis spec is deprecated, use connection_timeout_ms"); |
729 | 0 | spec.connection_timeout_ms = spec.connection_timeout_s * 1000; |
730 | 115 | } |
731 | 115 | if spec.connection_timeout_ms == 0 { |
732 | 110 | spec.connection_timeout_ms = DEFAULT_CONNECTION_TIMEOUT_MS; |
733 | 110 | }5 |
734 | 115 | if spec.command_timeout_ms == 0 { |
735 | 109 | spec.command_timeout_ms = DEFAULT_COMMAND_TIMEOUT_MS; |
736 | 109 | }6 |
737 | 115 | if spec.health_check_timeout_ms == 0 { |
738 | 115 | spec.health_check_timeout_ms = DEFAULT_HEALTH_CHECK_TIMEOUT_MS; |
739 | 115 | }0 |
740 | 115 | if spec.connection_pool_size == 0 { |
741 | 115 | spec.connection_pool_size = DEFAULT_CONNECTION_POOL_SIZE; |
742 | 115 | }0 |
743 | 115 | if spec.read_chunk_size == 0 { |
744 | 115 | spec.read_chunk_size = DEFAULT_READ_CHUNK_SIZE; |
745 | 115 | }0 |
746 | 115 | if spec.max_count_per_cursor == 0 { |
747 | 115 | spec.max_count_per_cursor = DEFAULT_MAX_COUNT_PER_CURSOR; |
748 | 115 | }0 |
749 | 115 | if spec.max_chunk_uploads_per_update == 0 { |
750 | 115 | spec.max_chunk_uploads_per_update = DEFAULT_MAX_CHUNK_UPLOADS_PER_UPDATE; |
751 | 115 | }0 |
752 | 115 | if spec.scan_count == 0 { |
753 | 115 | spec.scan_count = DEFAULT_SCAN_COUNT; |
754 | 115 | }0 |
755 | 115 | if spec.max_client_permits == 0 { |
756 | 115 | spec.max_client_permits = DEFAULT_CLIENT_PERMITS; |
757 | 115 | }0 |
758 | 115 | if spec.retry.delay == 0.0 { |
759 | 115 | spec.retry.delay = DEFAULT_RETRY_DELAY; |
760 | 115 | }0 |
761 | 115 | if spec.retry.max_retries == 0 { |
762 | 115 | spec.retry.max_retries = 1; |
763 | 115 | }0 |
764 | 115 | trace!(?spec, "redis spec is after setting defaults"); |
765 | 115 | Ok(()) |
766 | 115 | } |
767 | | |
768 | | // Only used by tests, because we need to make a real redis connection, then fix this to get fixed values |
769 | 11 | pub fn replace_temp_name_generator(&mut self, replacement: fn() -> String) { |
770 | 11 | self.temp_name_generator_fn = replacement; |
771 | 11 | } |
772 | | } |
773 | | |
774 | | impl RedisStore<ClusterConnection, ClusterRedisManager<ClusterConnection>> { |
775 | 0 | pub async fn new_cluster(mut spec: RedisSpec) -> Result<Arc<Self>, Error> { |
776 | 0 | let migrate_temporary_indexes = spec.migrate_temporary_indexes; |
777 | 0 | if spec.mode != RedisMode::Cluster { |
778 | 0 | return Err(Error::new( |
779 | 0 | Code::InvalidArgument, |
780 | 0 | "new_cluster only works for Cluster mode".to_string(), |
781 | 0 | )); |
782 | 0 | } |
783 | 0 | Self::set_spec_defaults(&mut spec)?; |
784 | | |
785 | 0 | let parsed_addrs: Vec<_> = spec |
786 | 0 | .addresses |
787 | 0 | .iter_mut() |
788 | 0 | .map(|addr| { |
789 | 0 | addr.clone().into_connection_info().map(|connection_info| { |
790 | 0 | let redis_settings = connection_info |
791 | 0 | .redis_settings() |
792 | 0 | .clone() |
793 | | // We need RESP3 here because the cluster mode doesn't support RESP2 pubsub |
794 | | // See also https://docs.rs/redis/latest/redis/cluster_async/index.html#pubsub |
795 | 0 | .set_protocol(redis::ProtocolVersion::RESP3); |
796 | 0 | connection_info.set_redis_settings(redis_settings) |
797 | 0 | }) |
798 | 0 | }) |
799 | 0 | .collect::<Result<Vec<_>, _>>()?; |
800 | | |
801 | 0 | let connection_timeout = Duration::from_millis(spec.connection_timeout_ms); |
802 | 0 | let command_timeout = Duration::from_millis(spec.command_timeout_ms); |
803 | 0 | let (tx, subscriber_channel) = unbounded_channel(); |
804 | | |
805 | 0 | let builder = ClusterClient::builder(parsed_addrs) |
806 | 0 | .connection_timeout(connection_timeout) |
807 | 0 | .response_timeout(command_timeout) |
808 | 0 | .push_sender(tx) |
809 | 0 | .retries(u32::try_from(spec.retry.max_retries)?); |
810 | | |
811 | 0 | let client = builder.build()?; |
812 | | |
813 | 0 | Self::new_from_builder_and_parts( |
814 | 0 | spec.experimental_pub_sub_channel, |
815 | 0 | || Uuid::new_v4().to_string(), |
816 | 0 | spec.key_prefix.clone(), |
817 | 0 | spec.read_chunk_size, |
818 | 0 | spec.max_chunk_uploads_per_update, |
819 | 0 | spec.scan_count, |
820 | 0 | spec.max_client_permits, |
821 | 0 | spec.max_count_per_cursor, |
822 | 0 | Duration::from_millis(spec.health_check_timeout_ms), |
823 | 0 | (spec.key_ttl_s > 0).then(|| Duration::from_secs(spec.key_ttl_s)), |
824 | 0 | subscriber_channel, |
825 | 0 | ClusterRedisManager::new(client.get_async_connection().await?).await?, |
826 | | ) |
827 | 0 | .await |
828 | 0 | .map(|store| { |
829 | 0 | if migrate_temporary_indexes { |
830 | 0 | store.with_temporary_index_migration() |
831 | | } else { |
832 | 0 | store |
833 | | } |
834 | 0 | }) |
835 | 0 | .map(Arc::new) |
836 | 0 | } |
837 | | } |
838 | | |
839 | | impl RedisStore<ConnectionManager, StandardRedisManager<ConnectionManager>> { |
840 | 361 | async fn connect( |
841 | 361 | spec: RedisSpec, |
842 | 361 | tx: UnboundedSender<PushInfo>, |
843 | 361 | ) -> Result<ConnectionManager, Error> { |
844 | 361 | let connection_timeout = Duration::from_millis(spec.connection_timeout_ms); |
845 | 361 | let command_timeout = Duration::from_millis(spec.command_timeout_ms); |
846 | | |
847 | 361 | let addr = &spec.addresses[0]; |
848 | 361 | let local_addr = addr.clone(); |
849 | 361 | let mut parsed_addr = local_addr |
850 | 361 | .replace("redis+sentinel://", "redis://") |
851 | 361 | .into_connection_info()?0 ; |
852 | | |
853 | 361 | let redis_settings = parsed_addr |
854 | 361 | .redis_settings() |
855 | 361 | .clone() |
856 | | // We need RESP3 here because we want to do set_push_sender |
857 | 361 | .set_protocol(redis::ProtocolVersion::RESP3); |
858 | 361 | parsed_addr = parsed_addr.set_redis_settings(redis_settings); |
859 | 361 | debug!(?parsed_addr, "Parsed redis addr"); |
860 | | |
861 | 361 | let client360 = timeout( |
862 | 361 | connection_timeout, |
863 | 361 | spawn!("connect", async move { |
864 | 361 | match spec.mode { |
865 | 352 | RedisMode::Standard => Client::open(parsed_addr).map_err(Into::<Error>::into), |
866 | | RedisMode::Cluster => { |
867 | 0 | return Err(Error::new( |
868 | 0 | Code::Internal, |
869 | 0 | "Use RedisStore::new_cluster for cluster connections".to_owned(), |
870 | 0 | )); |
871 | | } |
872 | 9 | RedisMode::Sentinel => async { |
873 | 9 | let url_parsing = Url::parse(&local_addr)?0 ; |
874 | 9 | let master_name = url_parsing |
875 | 9 | .query_pairs() |
876 | 9 | .find(|(key, _)| key1 == "sentinelServiceName"1 ) |
877 | 9 | .map_or_else(|| "master"8 .into8 (), |(_, value)| value1 .to_string1 ()); |
878 | | |
879 | 9 | let redis_connection_info = parsed_addr.redis_settings().clone(); |
880 | 9 | let sentinel_connection_info = SentinelNodeConnectionInfo::default() |
881 | 9 | .set_redis_connection_info(redis_connection_info); |
882 | | |
883 | | // We fish this out because sentinels don't support db, we need to set it |
884 | | // on the client only. See also https://github.com/redis-rs/redis-rs/issues/1950 |
885 | 9 | let original_db = parsed_addr.redis_settings().db(); |
886 | 9 | if original_db != 0 { |
887 | 1 | // sentinel_connection_info has the actual DB set |
888 | 1 | let revised_settings = parsed_addr.redis_settings().clone().set_db(0); |
889 | 1 | parsed_addr = parsed_addr.set_redis_settings(revised_settings); |
890 | 8 | } |
891 | | |
892 | 9 | SentinelClient::build( |
893 | 9 | vec![parsed_addr], |
894 | 9 | master_name, |
895 | 9 | Some(sentinel_connection_info), |
896 | 9 | SentinelServerType::Master, |
897 | | ) |
898 | 9 | .map_err(Into::<Error>::into) |
899 | 9 | } |
900 | 18 | .and_then9 (|mut s| async move {9 Ok(s9 .async_get_client().await) }) |
901 | 9 | .await?0 |
902 | 9 | .map_err(Into::<Error>::into), |
903 | | } |
904 | | // Keep the underlying error's code rather than forcing |
905 | | // InvalidArgument. Not every connect failure is a bad URL: a |
906 | | // sentinel failover in progress reports the master as missing, |
907 | | // which is transient and retryable, and overriding it to a |
908 | | // permanent status made clients give up on a recoverable blip. |
909 | 361 | .err_tip(|| format!1 ("While connecting to redis with url: {local_addr}")) |
910 | 361 | }), |
911 | | ) |
912 | 361 | .await |
913 | 361 | .err_tip(|| format!0 ("Timeout while connecting to redis with url: {addr}"))?0 ?0 ?1 ; |
914 | | |
915 | 360 | let connection_manager_config = { |
916 | 360 | ConnectionManagerConfig::new() |
917 | 360 | .set_number_of_retries(spec.retry.max_retries) |
918 | 360 | .set_connection_timeout(Some(connection_timeout)) |
919 | 360 | .set_response_timeout(Some(command_timeout)) |
920 | 360 | .set_push_sender(tx) |
921 | | }; |
922 | | |
923 | 129 | let mut connection_manager = |
924 | 360 | ConnectionManager::new_with_config(client, connection_manager_config) |
925 | 360 | .await |
926 | 360 | .err_tip(|| format!231 ("While connecting to redis with url: {addr}"))?231 ; |
927 | | |
928 | 129 | if let Some(pub_sub_channel105 ) = spec.experimental_pub_sub_channel { |
929 | 105 | connection_manager.psubscribe(pub_sub_channel).await?0 ; |
930 | 24 | } |
931 | | |
932 | 129 | Ok(connection_manager) |
933 | 361 | } |
934 | | |
935 | | /// Create a new `RedisStore` from the given configuration. |
936 | 115 | pub async fn new_standard(mut spec: RedisSpec) -> Result<Arc<Self>, Error> { |
937 | 115 | Self::set_spec_defaults(&mut spec)?0 ; |
938 | | |
939 | 115 | if spec.addresses.len() != 1 { |
940 | 0 | return Err(make_err!( |
941 | 0 | Code::Unimplemented, |
942 | 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." |
943 | 0 | )); |
944 | 115 | } |
945 | | |
946 | 115 | let (tx, subscriber_channel) = unbounded_channel(); |
947 | 115 | let migrate_temporary_indexes = spec.migrate_temporary_indexes; |
948 | | |
949 | 112 | Self::new_from_builder_and_parts( |
950 | 115 | spec.experimental_pub_sub_channel.clone(), |
951 | 1 | || Uuid::new_v4().to_string(), |
952 | 115 | spec.key_prefix.clone(), |
953 | 115 | spec.read_chunk_size, |
954 | 115 | spec.max_chunk_uploads_per_update, |
955 | 115 | spec.scan_count, |
956 | 115 | spec.max_client_permits, |
957 | 115 | spec.max_count_per_cursor, |
958 | 115 | Duration::from_millis(spec.health_check_timeout_ms), |
959 | 115 | (spec.key_ttl_s > 0).then(|| Duration::from_secs0 (spec.key_ttl_s0 )), |
960 | 115 | subscriber_channel, |
961 | 361 | StandardRedisManager::new115 (Box::new115 (move || { |
962 | 361 | Box::pin(Self::connect(spec.clone(), tx.clone())) |
963 | 361 | })) |
964 | 115 | .await?3 , |
965 | | ) |
966 | 112 | .await |
967 | 112 | .map(|store| { |
968 | 112 | if migrate_temporary_indexes { |
969 | 0 | store.with_temporary_index_migration() |
970 | | } else { |
971 | 112 | store |
972 | | } |
973 | 112 | }) |
974 | 112 | .map(Arc::new) |
975 | 115 | } |
976 | | } |
977 | | |
978 | | /// Deletes the temp key of an upload that did not publish, however it |
979 | | /// ended. `delete` is for an error the store sees. `Drop` is for the update |
980 | | /// future being dropped mid-stream, which is how the `ByteStream` server ends |
981 | | /// an upload whose client went away or closed the stream early: no error |
982 | | /// reaches the store, so without this the key stayed until its TTL. A chunk |
983 | | /// still in flight on this connection was queued before the DEL, so Redis |
984 | | /// applies the DEL after it; a chunk retried on another connection has no |
985 | | /// such order and can recreate the key after the DEL, which is why every |
986 | | /// chunk write arms the lifetime again and deletes a key it finds recreated |
987 | | /// (see `update_through_temp_key`). What this cannot delete, the lifetime |
988 | | /// removes. |
989 | | struct TempKeyGuard<C> |
990 | | where |
991 | | C: ConnectionLike + Clone + Send + Sync + 'static, |
992 | | { |
993 | | connection: C, |
994 | | temp_key: String, |
995 | | armed: bool, |
996 | | } |
997 | | |
998 | | impl<C> TempKeyGuard<C> |
999 | | where |
1000 | | C: ConnectionLike + Clone + Send + Sync + 'static, |
1001 | | { |
1002 | | /// The upload published; the key is gone under its final name. |
1003 | 16 | fn disarm(mut self) { |
1004 | 16 | self.armed = false; |
1005 | 16 | } |
1006 | | |
1007 | 0 | async fn delete(self) { |
1008 | 0 | let connection = self.connection.clone(); |
1009 | 0 | self.delete_with(connection).await; |
1010 | 0 | } |
1011 | | |
1012 | | /// `delete` on `connection` rather than the one the guard was given: |
1013 | | /// after a failover the upload ended on a newer one. |
1014 | 11 | async fn delete_with(mut self, mut connection: C) { |
1015 | 11 | self.armed = false; |
1016 | 11 | if let Err(err2 ) = connection.del::<_, ()>(&self.temp_key).await { |
1017 | 2 | debug!( |
1018 | | ?err, |
1019 | | temp_key = %self.temp_key, |
1020 | | "Could not delete the temp key of a failed upload; its TTL removes it" |
1021 | | ); |
1022 | 9 | } |
1023 | 11 | } |
1024 | | } |
1025 | | |
1026 | | impl<C> Drop for TempKeyGuard<C> |
1027 | | where |
1028 | | C: ConnectionLike + Clone + Send + Sync + 'static, |
1029 | | { |
1030 | 28 | fn drop(&mut self) { |
1031 | 28 | if !self.armed { |
1032 | 27 | return; |
1033 | 1 | } |
1034 | | // Dropped mid-stream. Nothing can be awaited here, so the delete runs |
1035 | | // on its own task; without a runtime (shutdown) the TTL does it. |
1036 | 1 | let Ok(handle) = tokio::runtime::Handle::try_current() else { |
1037 | 0 | return; |
1038 | | }; |
1039 | 1 | let temp_key = core::mem::take(&mut self.temp_key); |
1040 | 1 | let mut connection = self.connection.clone(); |
1041 | 1 | handle.spawn(async move { |
1042 | 1 | if let Err(err0 ) = connection.del::<_, ()>(&temp_key).await { |
1043 | 0 | debug!( |
1044 | | ?err, |
1045 | | temp_key, |
1046 | | "Could not delete the temp key of a dropped upload; its TTL removes it" |
1047 | | ); |
1048 | 1 | } |
1049 | 1 | }); |
1050 | 28 | } |
1051 | | } |
1052 | | |
1053 | | impl<C, M> RedisStore<C, M> |
1054 | | where |
1055 | | C: ConnectionLike + Clone + Send + Sync + Unpin + 'static, |
1056 | | M: RedisManager<C> + Unpin + Send + Sync + 'static, |
1057 | | { |
1058 | | /// Stages an upload under `temp_key` and publishes it as `final_key`. |
1059 | | /// |
1060 | | /// The temp key is created with a lifetime before any byte lands, and |
1061 | | /// every chunk arms it again, so an upload that stops anywhere (the |
1062 | | /// client went away, this process did) leaves a key Redis removes on its |
1063 | | /// own. Nothing else cleans temp keys up: on a store without `key_ttl_s` |
1064 | | /// they lived forever, and a CAS that failed or restarted under load left |
1065 | | /// them in numbers. The lifetime is `key_ttl_s` when set, else |
1066 | | /// [`TEMP_KEY_TTL`]; a key that expired or was lost in a failover and was |
1067 | | /// recreated by a later chunk is refused rather than published, see the |
1068 | | /// TTL checks on each chunk and after them. The caller's [`TempKeyGuard`] |
1069 | | /// deletes the key when this does not publish. |
1070 | 28 | async fn update_through_temp_key( |
1071 | 28 | &self, |
1072 | 28 | key: &StoreKey<'_>, |
1073 | 28 | final_key: &str, |
1074 | 28 | temp_key: &str, |
1075 | 28 | reader: DropCloserReadHalf, |
1076 | 28 | upload_size: UploadSizeInfo, |
1077 | 28 | ) -> Result<u64, Error> { |
1078 | 28 | let mut client = self.get_client().await?0 ; |
1079 | | |
1080 | 28 | let temp_ttl_secs = self.key_ttl.unwrap_or(TEMP_KEY_TTL).as_secs().max(1); |
1081 | 28 | let temp_ttl = ttl_seconds(self.key_ttl.unwrap_or(TEMP_KEY_TTL)); |
1082 | 28 | let staged = SetOptions::default().with_expiration(SetExpiry::EX(temp_ttl_secs)); |
1083 | 28 | match client |
1084 | 28 | .connection_manager |
1085 | 28 | .set_options::<_, _, ()>(temp_key, "", staged.clone()) |
1086 | 28 | .await |
1087 | | { |
1088 | 25 | Ok(()) => {} |
1089 | 3 | Err(err2 ) if is_retryable_redis_error(&err)2 => { |
1090 | 2 | let (connection_manager, uuid) = |
1091 | 2 | self.connection_manager.reconnect(client.uuid).await?0 ; |
1092 | 2 | client.connection_manager = connection_manager; |
1093 | 2 | client.uuid = uuid; |
1094 | 2 | client |
1095 | 2 | .connection_manager |
1096 | 2 | .set_options::<_, _, ()>(temp_key, "", staged) |
1097 | 2 | .await |
1098 | 2 | .err_tip(|| {1 |
1099 | 1 | format!( |
1100 | | "(after reconnect) while creating temp key ({temp_key}) in RedisStore::update" |
1101 | | ) |
1102 | 1 | })?; |
1103 | | } |
1104 | 1 | Err(err) => { |
1105 | 1 | return Err(Error::from(err).append(format!( |
1106 | 1 | "While creating temp key ({temp_key}) in RedisStore::update" |
1107 | 1 | ))); |
1108 | | } |
1109 | | } |
1110 | | |
1111 | 26 | let mut read_stream = reader |
1112 | 26 | .scan(0u32, |bytes_read, chunk_res| { |
1113 | 26 | future::ready(Some( |
1114 | 26 | chunk_res |
1115 | 26 | .err_tip(|| "Failed to read chunk in update in redis store") |
1116 | 26 | .and_then(|chunk| { |
1117 | 26 | let offset = isize::try_from(*bytes_read).err_tip(|| "Could not convert offset to isize in RedisStore::update")?0 ; |
1118 | 26 | let chunk_len = u32::try_from(chunk.len()).err_tip( |
1119 | | || "Could not convert chunk length to u32 in RedisStore::update", |
1120 | 0 | )?; |
1121 | 26 | let new_bytes_read = bytes_read |
1122 | 26 | .checked_add(chunk_len) |
1123 | 26 | .err_tip(|| "Overflow protection in RedisStore::update")?0 ; |
1124 | 26 | *bytes_read = new_bytes_read; |
1125 | 26 | Ok::<_, Error>((offset, *bytes_read, chunk)) |
1126 | 26 | }), |
1127 | | )) |
1128 | 26 | }) |
1129 | 26 | .map(|res| { |
1130 | 26 | let (offset, end_pos, chunk) = res?0 ; |
1131 | 26 | Ok(async move { |
1132 | 26 | let (mut connection_manager, connect_id) = self.connection_manager.get_connection().await?0 ; |
1133 | | // The chunk, the key's TTL as it stood before the chunk, |
1134 | | // and the lifetime armed again, in one round trip. A key |
1135 | | // that expired or was lost in a failover is recreated by |
1136 | | // the SETRANGE, zero-filled and with no TTL, and only the |
1137 | | // TTL tells. The EXPIRE makes the lifetime a bound on the |
1138 | | // gap between chunks rather than on the upload, and gives |
1139 | | // even a recreated key a lifetime, so a chunk that lands |
1140 | | // after the upload's DEL (one retried on another |
1141 | | // connection, say) leaves nothing behind for good. |
1142 | 26 | let mut write = pipe(); |
1143 | 26 | write |
1144 | 26 | .setrange(temp_key, offset, chunk.to_vec()) |
1145 | 26 | .ttl(temp_key) |
1146 | 26 | .expire(temp_key, temp_ttl); |
1147 | 26 | let (_, ttl_before25 , _): (usize, i64, i64)25 = match write |
1148 | 26 | .query_async(&mut connection_manager) |
1149 | 26 | .await |
1150 | | { |
1151 | 24 | Ok(written) => written, |
1152 | 2 | Err(err1 ) if is_retryable_redis_error(&err)1 => { |
1153 | 1 | let (reconnected, _connect_id) = |
1154 | 1 | self.connection_manager.reconnect(connect_id).await?0 ; |
1155 | 1 | connection_manager = reconnected; |
1156 | 1 | write |
1157 | 1 | .query_async(&mut connection_manager) |
1158 | 1 | .await |
1159 | 1 | .err_tip(|| {0 |
1160 | 0 | format!("(after reconnect) while appending to temp key ({temp_key}) in RedisStore::update. offset = {offset}. end_pos = {end_pos}") |
1161 | 0 | })? |
1162 | | } |
1163 | 1 | Err(err) => { |
1164 | 1 | let mut error: Error = err.into(); |
1165 | 1 | error |
1166 | 1 | .messages |
1167 | 1 | .push(format!("While appending to temp key ({temp_key}) in RedisStore::update. offset = {offset}. end_pos = {end_pos}")); |
1168 | 1 | return Err(error); |
1169 | | } |
1170 | | }; |
1171 | 25 | if ttl_before < 0 { |
1172 | | // Recreated by this chunk: gone now rather than at |
1173 | | // the end of the lifetime the EXPIRE just gave it. |
1174 | 1 | if let Err(err0 ) = connection_manager.del::<_, ()>(temp_key).await { |
1175 | 0 | debug!( |
1176 | | ?err, |
1177 | | temp_key, |
1178 | | "Could not delete a recreated temp key; its TTL removes it" |
1179 | | ); |
1180 | 1 | } |
1181 | 1 | return Err(make_err!( |
1182 | 1 | Code::Aborted, |
1183 | 1 | "Chunk at offset {offset} of {}({temp_key}) found the temp key gone (expired after {temp_ttl}s without a chunk, or lost in a failover) and recreated it; not published, send the upload again", |
1184 | 1 | key.as_str(), |
1185 | 1 | )); |
1186 | 24 | } |
1187 | 24 | Ok::<u32, Error>(end_pos) |
1188 | 26 | }) |
1189 | 26 | }) |
1190 | 26 | .try_buffer_unordered(self.max_chunk_uploads_per_update); |
1191 | | |
1192 | 26 | let mut total_len: u32 = 0; |
1193 | 50 | while let Some(last_pos24 ) = read_stream.try_next().await?2 { |
1194 | 24 | if last_pos > total_len { |
1195 | 24 | total_len = last_pos; |
1196 | 24 | }0 |
1197 | | } |
1198 | | |
1199 | 23 | let expected_len = usize::try_from(total_len).unwrap_or(usize::MAX); |
1200 | 23 | upload_size.validate_size(u64::from(total_len))?3 ; |
1201 | | |
1202 | | // The chunk writes above reconnect on any transient failover error, so on a mid-write Redis |
1203 | | // failover the data lands on the *current* master. The length check and |
1204 | | // rename below must run against that same master: the connection |
1205 | | // captured before the writes may now point at a demoted replica, where |
1206 | | // strlen reads 0 and would fail an otherwise healthy write. Re-resolve |
1207 | | // the master and retry so a transient topology change (failover or brief |
1208 | | // replica lag) doesn't drop the value. |
1209 | 20 | let mut attempt: u32 = 0; |
1210 | 18 | let blob_len = loop { |
1211 | 26 | attempt += 1; |
1212 | | // The master can go away between the chunk writes and this check |
1213 | | // too, and the connection then waits out the command timeout on a |
1214 | | // dead peer. Re-resolve the master and ask it, like every other |
1215 | | // step here does, instead of failing the upload. The error can |
1216 | | // also be immediate (connection refused while the old master is |
1217 | | // gone and Sentinel has not promoted a replica yet), so back off |
1218 | | // before each retry, doubling, so the attempts together span a |
1219 | | // typical failover of a few seconds rather than a few milliseconds. |
1220 | | // |
1221 | | // The key's remaining TTL comes back in the same round trip. |
1222 | 26 | let (blob_len25 , remaining_ttl25 ): (usize, i64)25 = match pipe() |
1223 | 26 | .strlen(temp_key) |
1224 | 26 | .ttl(temp_key) |
1225 | 26 | .query_async(&mut client.connection_manager) |
1226 | 26 | .await |
1227 | | { |
1228 | 25 | Ok(checked) => checked, |
1229 | 1 | Err(err) |
1230 | 1 | if is_retryable_redis_error(&err) && attempt < MAX_REDIS_RETRY_ATTEMPTS => |
1231 | | { |
1232 | 1 | let (connection_manager, uuid) = |
1233 | 1 | self.connection_manager.reconnect(client.uuid).await?0 ; |
1234 | 1 | client.connection_manager = connection_manager; |
1235 | 1 | client.uuid = uuid; |
1236 | 1 | let backoff = |
1237 | 1 | DEFAULT_RETRY_DELAY * 2f32.powi(i32::try_from(attempt).unwrap_or(i32::MAX)); |
1238 | 1 | sleep(Duration::from_secs_f32(backoff)).await; |
1239 | 1 | continue; |
1240 | | } |
1241 | 0 | Err(err) => { |
1242 | 0 | return Err(Error::from(err) |
1243 | 0 | .append(format!("In RedisStore::update strlen check for {temp_key}"))); |
1244 | | } |
1245 | | }; |
1246 | | // Safety check: reject if a retried append double-wrote the data. |
1247 | 25 | if blob_len > expected_len { |
1248 | 0 | return Err(make_input_err!( |
1249 | 0 | "Data length mismatch in RedisStore::update for {}({}) - expected {} bytes, got {} bytes", |
1250 | 0 | key.as_str(), |
1251 | 0 | temp_key, |
1252 | 0 | total_len, |
1253 | 0 | blob_len, |
1254 | 0 | )); |
1255 | 25 | } |
1256 | 25 | if blob_len == expected_len { |
1257 | | // SETRANGE on a key that has expired or was lost creates it |
1258 | | // again, zero-filled up to the offset and with no TTL, and |
1259 | | // the length still matches. Each chunk checks this on its |
1260 | | // own write; this is the check for whatever happened between |
1261 | | // the last chunk's EXPIRE and here. Publishing would store |
1262 | | // zeros under a digest they do not hash to. |
1263 | 19 | if remaining_ttl < 0 { |
1264 | 1 | return Err(make_err!( |
1265 | 1 | Code::Aborted, |
1266 | 1 | "Upload of {}({}) lost its temp key (expired after {}s without a chunk, or lost in a failover) and a later chunk recreated it; not published, send it again", |
1267 | 1 | key.as_str(), |
1268 | 1 | temp_key, |
1269 | 1 | temp_ttl_secs, |
1270 | 1 | )); |
1271 | 18 | } |
1272 | 18 | break blob_len; |
1273 | 6 | } |
1274 | | // blob_len < expected (typically 0): the temp key isn't visible on |
1275 | | // this connection yet; re-resolve the master and retry. |
1276 | 6 | if attempt >= MAX_REDIS_RETRY_ATTEMPTS { |
1277 | 1 | return Err(make_input_err!( |
1278 | 1 | "Data length mismatch in RedisStore::update for {}({}) - expected {} bytes, got {} bytes after {} attempts", |
1279 | 1 | key.as_str(), |
1280 | 1 | temp_key, |
1281 | 1 | total_len, |
1282 | 1 | blob_len, |
1283 | 1 | attempt, |
1284 | 1 | )); |
1285 | 5 | } |
1286 | 5 | let (connection_manager, uuid) = self.connection_manager.reconnect(client.uuid).await?0 ; |
1287 | 5 | client.connection_manager = connection_manager; |
1288 | 5 | client.uuid = uuid; |
1289 | 5 | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1290 | | }; |
1291 | | |
1292 | | // Rename the temp key so that the data appears under the real key. Any data already present in the real key is lost. |
1293 | | // The rename carries the temp key's TTL across, so the real key gets |
1294 | | // its own lifetime in the same transaction: `key_ttl_s` counted from |
1295 | | // completion rather than from the first byte, or none at all. One |
1296 | | // transaction, so nothing can be published with the temp key's |
1297 | | // lifetime still on it, and so a transaction that applied on the |
1298 | | // old master before its reply was lost in a failover left the final |
1299 | | // key complete, which the retry below recognizes. |
1300 | | // Reconnect once on a transient failover error in case the master moved between the verify and here. |
1301 | 18 | let mut publish = pipe(); |
1302 | 18 | publish.atomic().rename(temp_key, final_key); |
1303 | 18 | match self.key_ttl { |
1304 | 2 | Some(key_ttl) => publish.expire(final_key, ttl_seconds(key_ttl)), |
1305 | 16 | None => publish.persist(final_key), |
1306 | | }; |
1307 | 18 | let published: Option<((), bool)> = |
1308 | 18 | match publish.query_async(&mut client.connection_manager).await { |
1309 | 15 | Ok(published) => Some(published), |
1310 | 3 | Err(err) if is_retryable_redis_error(&err) => { |
1311 | 3 | let (connection_manager, uuid) = |
1312 | 3 | self.connection_manager.reconnect(client.uuid).await?0 ; |
1313 | 3 | client.connection_manager = connection_manager; |
1314 | 3 | client.uuid = uuid; |
1315 | 3 | match publish.query_async(&mut client.connection_manager).await { |
1316 | 1 | Ok(published) => Some(published), |
1317 | 2 | Err(err) if is_no_such_key(&err) => None, |
1318 | 0 | Err(err) => { |
1319 | 0 | return Err(Error::from(err).append(format!( |
1320 | 0 | "While publishing {final_key} (after reconnect) in RedisStore::update()" |
1321 | 0 | ))); |
1322 | | } |
1323 | | } |
1324 | | } |
1325 | 0 | Err(err) => { |
1326 | 0 | return Err(Error::from(err).append(format!( |
1327 | 0 | "While publishing {final_key} in RedisStore::update()" |
1328 | 0 | ))); |
1329 | | } |
1330 | | }; |
1331 | 18 | let kept17 = if let Some(((), kept16 )) = published { |
1332 | 16 | kept |
1333 | | } else { |
1334 | | // The temp key is gone on the master the retry reached. |
1335 | | // Either the first attempt applied and the final key is this |
1336 | | // upload, or the failover lost the temp key and the final key |
1337 | | // is whatever was there before. For a digest key the two are |
1338 | | // the same bytes; for a string key this accepts the value |
1339 | | // already there, as an upload that lost a race does. |
1340 | 2 | let len: usize = client |
1341 | 2 | .connection_manager |
1342 | 2 | .strlen(final_key) |
1343 | 2 | .await |
1344 | 2 | .err_tip(|| {0 |
1345 | 0 | format!( |
1346 | | "While checking {final_key} after a publish whose reply was lost in RedisStore::update()" |
1347 | | ) |
1348 | 0 | })?; |
1349 | 2 | if len != expected_len { |
1350 | 1 | return Err(make_err!( |
1351 | 1 | Code::Aborted, |
1352 | 1 | "Upload of {}({}) lost its temp key in a failover and {} is not it ({} bytes, expected {}); send it again", |
1353 | 1 | key.as_str(), |
1354 | 1 | temp_key, |
1355 | 1 | final_key, |
1356 | 1 | len, |
1357 | 1 | expected_len, |
1358 | 1 | )); |
1359 | 1 | } |
1360 | 1 | true |
1361 | | }; |
1362 | | // EXPIRE and PERSIST answer 0 only when the key is gone, which inside |
1363 | | // the transaction means it expired as the RENAME ran. The client has |
1364 | | // to send it again. |
1365 | 17 | if !kept { |
1366 | 1 | return Err(make_err!( |
1367 | 1 | Code::Aborted, |
1368 | 1 | "Upload of {}({}) expired as it was published; not stored, send it again", |
1369 | 1 | key.as_str(), |
1370 | 1 | final_key, |
1371 | 1 | )); |
1372 | 16 | } |
1373 | | |
1374 | | // If we have a publish channel configured, send a notice that the key has been set. |
1375 | 16 | if let Some(pub_sub_channel0 ) = &self.pub_sub_channel { |
1376 | 0 | client |
1377 | 0 | .connection_manager |
1378 | 0 | .publish::<_, _, ()>(pub_sub_channel, final_key) |
1379 | 0 | .await?; |
1380 | 16 | } |
1381 | | |
1382 | 16 | Ok(blob_len.try_into().unwrap_or(0)) |
1383 | 27 | } |
1384 | | } |
1385 | | |
1386 | | #[async_trait] |
1387 | | impl<C, M> StoreDriver for RedisStore<C, M> |
1388 | | where |
1389 | | C: ConnectionLike + Clone + Send + Sync + Unpin + 'static, |
1390 | | M: RedisManager<C> + Unpin + Send + Sync + 'static, |
1391 | | { |
1392 | 0 | async fn post_init(self: Arc<Self>) -> Result<(), Error> { |
1393 | | Ok(()) |
1394 | 0 | } |
1395 | | |
1396 | | async fn has_with_results( |
1397 | | self: Pin<&Self>, |
1398 | | keys: &[StoreKey<'_>], |
1399 | | results: &mut [Option<u64>], |
1400 | 25 | ) -> Result<(), Error> { |
1401 | | // TODO(palfrey) We could use pipeline here, but it makes retry more |
1402 | | // difficult and it doesn't work very well in cluster mode. |
1403 | | // If we wanted to optimize this with pipeline be careful to |
1404 | | // implement retry and to support cluster mode. |
1405 | | |
1406 | | izip!(keys.iter(), results.iter_mut(),) |
1407 | 25 | .map(|(key, result)| async move { |
1408 | | // We need to do a special pass to ensure our zero key exist. |
1409 | 25 | if is_zero_digest(key.borrow()) { |
1410 | 2 | *result = Some(0); |
1411 | 2 | return Ok::<_, Error>(()); |
1412 | 23 | } |
1413 | 23 | let encoded_key = self.encode_key(key); |
1414 | | |
1415 | 23 | let mut client = self.get_client().await?0 ; |
1416 | | |
1417 | | // Redis returns 0 when the key doesn't exist |
1418 | | // AND when the key exists with value of length 0. |
1419 | | // Therefore, we need to check both length and existence |
1420 | | // and do it in a pipeline for efficiency |
1421 | | // Re-resolve the master and retry on a transient failover so a |
1422 | | // topology change doesn't fail the existence check. |
1423 | 23 | let (blob_len, exists) = { |
1424 | 23 | let mut attempt: u32 = 0; |
1425 | | loop { |
1426 | 24 | attempt += 1; |
1427 | 24 | match pipe() |
1428 | 24 | .strlen(encoded_key.as_ref()) |
1429 | 24 | .exists(encoded_key.as_ref()) |
1430 | 24 | .query_async::<(u64, bool)>(&mut client.connection_manager) |
1431 | 24 | .await |
1432 | | { |
1433 | 23 | Ok(v) => break v, |
1434 | 1 | Err(err) |
1435 | 1 | if attempt < MAX_REDIS_RETRY_ATTEMPTS |
1436 | 1 | && is_retryable_redis_error(&err) => |
1437 | | { |
1438 | 1 | client.reconnect(&self.connection_manager).await?0 ; |
1439 | 1 | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1440 | | } |
1441 | 0 | Err(err) => { |
1442 | 0 | return Err( |
1443 | 0 | Error::from(err).append("In RedisStore::has_with_results::all") |
1444 | 0 | ); |
1445 | | } |
1446 | | } |
1447 | | } |
1448 | | }; |
1449 | | |
1450 | | // An empty value cannot be a blob whose digest names bytes: a |
1451 | | // failed upload left it. Reported present it is read as |
1452 | | // DataLoss forever and never uploaded again. |
1453 | 23 | *result = match (exists, blob_len) { |
1454 | 6 | (false, _) => None, |
1455 | 1 | (true, 0) if matches!(key.borrow(), StoreKey::Digest(digest) if digest.size_bytes() > 0) => { |
1456 | 1 | warn!( |
1457 | | ?key, |
1458 | | "Redis holds an empty value for a digest that names bytes; reporting it missing" |
1459 | | ); |
1460 | 1 | None |
1461 | | } |
1462 | 16 | (true, len) => Some(len), |
1463 | | }; |
1464 | | |
1465 | 23 | Ok::<_, Error>(()) |
1466 | 50 | }) |
1467 | | .collect::<FuturesUnordered<_>>() |
1468 | | .try_collect() |
1469 | | .await |
1470 | 25 | } |
1471 | | |
1472 | | async fn list( |
1473 | | self: Pin<&Self>, |
1474 | | range: (Bound<StoreKey<'_>>, Bound<StoreKey<'_>>), |
1475 | | handler: &mut (dyn for<'a> FnMut(&'a StoreKey) -> bool + Send + Sync + '_), |
1476 | 8 | ) -> Result<u64, Error> { |
1477 | | let range = ( |
1478 | | range.0.map(StoreKey::into_owned), |
1479 | | range.1.map(StoreKey::into_owned), |
1480 | | ); |
1481 | | let pattern = match range.0 { |
1482 | | Bound::Included(ref start) | Bound::Excluded(ref start) => match range.1 { |
1483 | | Bound::Included(ref end) | Bound::Excluded(ref end) => { |
1484 | | let start = start.as_str(); |
1485 | | let end = end.as_str(); |
1486 | | let max_length = start.len().min(end.len()); |
1487 | | let length = start |
1488 | | .chars() |
1489 | | .zip(end.chars()) |
1490 | 20 | .position(|(a, b)| a != b) |
1491 | | .unwrap_or(max_length); |
1492 | | format!("{}{}*", self.key_prefix, &start[..length]) |
1493 | | } |
1494 | | Bound::Unbounded => format!("{}*", self.key_prefix), |
1495 | | }, |
1496 | | Bound::Unbounded => format!("{}*", self.key_prefix), |
1497 | | }; |
1498 | | let mut client = self.get_client().await?; |
1499 | | trace!(%pattern, count=self.scan_count, "Running SCAN"); |
1500 | | // Restart the scan on a transient failover. Redis SCAN may re-emit keys |
1501 | | // even without failures, so callers already tolerate duplicates; |
1502 | | // re-resolving the master and rescanning is within that contract and |
1503 | | // avoids failing on a topology change mid-iteration. |
1504 | | let mut attempt: u32 = 0; |
1505 | | loop { |
1506 | | attempt += 1; |
1507 | | let opts = ScanOptions::default() |
1508 | | .with_pattern(pattern.clone()) |
1509 | | .with_count(self.scan_count); |
1510 | | // Scan via a cloned connection handle so the iterator doesn't hold a |
1511 | | // borrow on `client` across a reconnect. |
1512 | | let mut conn = client.connection_manager.clone(); |
1513 | | let mut scan_stream: AsyncIter<Value> = match conn.scan_options(opts).await { |
1514 | | Ok(s) => s, |
1515 | | Err(err) |
1516 | | if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) => |
1517 | | { |
1518 | | client.reconnect(&self.connection_manager).await?; |
1519 | | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1520 | | continue; |
1521 | | } |
1522 | | Err(err) => return Err(Error::from(err).append("During scan_options")), |
1523 | | }; |
1524 | | let mut iterations = 0; |
1525 | | let mut errors = vec![]; |
1526 | | let mut transient_err = false; |
1527 | | while let Some(key) = scan_stream.next_item().await { |
1528 | | match key { |
1529 | | Ok(Value::BulkString(raw_key)) => { |
1530 | | let Ok(str_key) = str::from_utf8(&raw_key) else { |
1531 | | error!(?raw_key, "Non-utf8 key"); |
1532 | | errors.push(format!("Non-utf8 key {raw_key:?}")); |
1533 | | continue; |
1534 | | }; |
1535 | | match decode_key(&self.key_prefix, Cow::from(str_key)) { |
1536 | | Ok(key) => { |
1537 | | if range.contains(&key) { |
1538 | | iterations += 1; |
1539 | | if !handler(&key) { |
1540 | | error!("Issue in handler"); |
1541 | | errors.push("Issue in handler".to_string()); |
1542 | | } |
1543 | | } else { |
1544 | | trace!(%key, ?range, "Key not in range"); |
1545 | | } |
1546 | | } |
1547 | | Err(e) => { |
1548 | | errors.push(e.to_string()); |
1549 | | } |
1550 | | } |
1551 | | } |
1552 | | Err(err) |
1553 | | if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) => |
1554 | | { |
1555 | | // Connection dropped mid-scan; restart from scratch. |
1556 | | transient_err = true; |
1557 | | break; |
1558 | | } |
1559 | | other => { |
1560 | | error!(?other, "Non-string in key"); |
1561 | | errors.push("Non-string in key".to_string()); |
1562 | | } |
1563 | | } |
1564 | | } |
1565 | | if transient_err { |
1566 | | drop(scan_stream); |
1567 | | client.reconnect(&self.connection_manager).await?; |
1568 | | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1569 | | continue; |
1570 | | } |
1571 | | return if errors.is_empty() { |
1572 | | Ok(iterations) |
1573 | | } else { |
1574 | | error!(?errors, "Errors in scan stream"); |
1575 | | Err(Error::new(Code::Internal, format!("Errors: {errors:?}"))) |
1576 | | }; |
1577 | | } |
1578 | 8 | } |
1579 | | |
1580 | | async fn update( |
1581 | | self: Pin<&Self>, |
1582 | | key: StoreKey<'_>, |
1583 | | mut reader: DropCloserReadHalf, |
1584 | | upload_size: UploadSizeInfo, |
1585 | 30 | ) -> Result<u64, Error> { |
1586 | | let final_key = self.encode_key(&key); |
1587 | | |
1588 | | // While the name generation function can be supplied by the user, we need to have the curly |
1589 | | // braces in place in order to manage redis' hashing behavior and make sure that the temporary |
1590 | | // key name and the final key name are directed to the same cluster node. See |
1591 | | // https://redis.io/blog/redis-clustering-best-practices-with-keys/ |
1592 | | // |
1593 | | // The TL;DR is that if we're in cluster mode and the names hash differently, we can't use request |
1594 | | // pipelining. By using these braces, we tell redis to only hash the part of the temporary key that's |
1595 | | // identical to the final key -- so they will always hash to the same node. |
1596 | | let temp_key = format!("temp-{}-{{{final_key}}}", (self.temp_name_generator_fn)()); |
1597 | | |
1598 | | if is_zero_digest(key.borrow()) { |
1599 | | let chunk = reader |
1600 | | .peek() |
1601 | | .await |
1602 | | .err_tip(|| "Failed to peek in RedisStore::update")?; |
1603 | | if chunk.is_empty() { |
1604 | | reader |
1605 | | .drain() |
1606 | | .await |
1607 | | .err_tip(|| "Failed to drain in RedisStore::update")?; |
1608 | | // Zero-digest keys are special -- we don't need to do anything with it. |
1609 | | return Ok(0); |
1610 | | } |
1611 | | } |
1612 | | |
1613 | | // The temp key expires on its own. Deleting it as soon as the upload |
1614 | | // cannot publish frees the memory now rather than at the end of its |
1615 | | // lifetime, which is the difference between a bounded and a full |
1616 | | // Redis when uploads fail or are abandoned in numbers. |
1617 | | let (connection, _uuid) = self.connection_manager.get_connection().await?; |
1618 | | let guard = TempKeyGuard { |
1619 | | connection, |
1620 | | temp_key: temp_key.clone(), |
1621 | | armed: true, |
1622 | | }; |
1623 | | match self |
1624 | | .update_through_temp_key(&key, &final_key, &temp_key, reader, upload_size) |
1625 | | .await |
1626 | | { |
1627 | | Ok(len) => { |
1628 | | guard.disarm(); |
1629 | | Ok(len) |
1630 | | } |
1631 | | Err(err) => { |
1632 | | // On the connection the upload ended on, which after a |
1633 | | // failover is a newer one than the guard was given. |
1634 | | match self.connection_manager.get_connection().await { |
1635 | | Ok((connection, _uuid)) => guard.delete_with(connection).await, |
1636 | | Err(_) => guard.delete().await, |
1637 | | } |
1638 | | Err(err) |
1639 | | } |
1640 | | } |
1641 | 30 | } |
1642 | | |
1643 | | async fn get_part( |
1644 | | self: Pin<&Self>, |
1645 | | key: StoreKey<'_>, |
1646 | | writer: &mut DropCloserWriteHalf, |
1647 | | offset: u64, |
1648 | | length: Option<u64>, |
1649 | 16 | ) -> Result<(), Error> { |
1650 | | let offset = isize::try_from(offset).err_tip(|| "Could not convert offset to isize")?; |
1651 | | let length = length |
1652 | 4 | .map(|v| usize::try_from(v).err_tip(|| "Could not convert length to usize")) |
1653 | | .transpose()?; |
1654 | | |
1655 | | // To follow RBE spec we need to consider any digest's with |
1656 | | // zero size to be existing. |
1657 | | if is_zero_digest(key.borrow()) { |
1658 | | return writer |
1659 | | .send_eof() |
1660 | | .err_tip(|| "Failed to send zero EOF in redis store get_part"); |
1661 | | } |
1662 | | |
1663 | | let encoded_key = self.encode_key(&key); |
1664 | | let encoded_key = encoded_key.as_ref(); |
1665 | | |
1666 | | // N.B. the `-1`'s you see here are because redis GETRANGE is inclusive at both the start and end, so when we |
1667 | | // do math with indices we change them to be exclusive at the end. |
1668 | | |
1669 | | // We want to read the data at the key from `offset` to `offset + length`. |
1670 | | let data_start = offset; |
1671 | | let data_end = data_start |
1672 | 4 | .saturating_add(length.map_or(isize::MAX, |l| isize::try_from(l).unwrap_or(isize::MAX))) |
1673 | | .saturating_sub(1); |
1674 | | |
1675 | | // And we don't ever want to read more than `read_chunk_size` bytes at a time, so we'll need to iterate. |
1676 | | let mut chunk_start = data_start; |
1677 | | let mut chunk_end = cmp::min( |
1678 | | data_start.saturating_add(self.read_chunk_size.try_into().unwrap_or(isize::MAX)) - 1, |
1679 | | data_end, |
1680 | | ); |
1681 | | |
1682 | | let mut client = self.get_client().await?; |
1683 | | let mut bytes_read: u64 = 0; |
1684 | | loop { |
1685 | | // getrange is position-based and idempotent, so re-resolve the |
1686 | | // master and retry on a transient failover without re-sending |
1687 | | // already-written chunks. |
1688 | | let chunk: Bytes = { |
1689 | | let mut attempt: u32 = 0; |
1690 | | loop { |
1691 | | attempt += 1; |
1692 | | match client |
1693 | | .connection_manager |
1694 | | .getrange(encoded_key, chunk_start, chunk_end) |
1695 | | .await |
1696 | | { |
1697 | | Ok(v) => break v, |
1698 | | Err(err) |
1699 | | if attempt < MAX_REDIS_RETRY_ATTEMPTS |
1700 | | && is_retryable_redis_error(&err) => |
1701 | | { |
1702 | | client.reconnect(&self.connection_manager).await?; |
1703 | | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1704 | | } |
1705 | | Err(err) => { |
1706 | | return Err( |
1707 | | Error::from(err).append("In RedisStore::get_part::getrange") |
1708 | | ); |
1709 | | } |
1710 | | } |
1711 | | } |
1712 | | }; |
1713 | | |
1714 | | let didnt_receive_full_chunk = chunk.len() < self.read_chunk_size; |
1715 | | let reached_end_of_data = chunk_end == data_end; |
1716 | | |
1717 | | if didnt_receive_full_chunk || reached_end_of_data { |
1718 | | if !chunk.is_empty() { |
1719 | | writer |
1720 | | .send(chunk) |
1721 | | .await |
1722 | | .err_tip(|| "Failed to write data in RedisStore::get_part")?; |
1723 | | } else if bytes_read > 0 { |
1724 | | // GETRANGE answers the same for a value that ends on a |
1725 | | // chunk boundary and for a key removed between two |
1726 | | // chunks (eviction under allkeys-lru). Handing the second |
1727 | | // on as EOF is a truncated blob that every store above |
1728 | | // caches as complete, so ask how long the value is now |
1729 | | // and refuse anything but the end we stopped at. |
1730 | | let value_len = self.strlen(&mut client, encoded_key).await?; |
1731 | | let read_end = u64::try_from(data_start) |
1732 | | .unwrap_or(0) |
1733 | | .saturating_add(bytes_read); |
1734 | | if value_len != read_end { |
1735 | | return Err(make_err!( |
1736 | | Code::NotFound, |
1737 | | "Data for {key:?} changed under the read in the Redis store: read {bytes_read} bytes from offset {data_start}, the key now holds {value_len}" |
1738 | | )); |
1739 | | } |
1740 | | } |
1741 | | |
1742 | | break; // No more data to read. |
1743 | | } |
1744 | | |
1745 | | // We received a full chunk's worth of data, so write it... |
1746 | | bytes_read += chunk.len() as u64; |
1747 | | writer |
1748 | | .send(chunk) |
1749 | | .await |
1750 | | .err_tip(|| "Failed to write data in RedisStore::get_part")?; |
1751 | | |
1752 | | // ...and go grab the next chunk. |
1753 | | chunk_start = chunk_end + 1; |
1754 | | chunk_end = cmp::min( |
1755 | | chunk_start.saturating_add(self.read_chunk_size.try_into().unwrap_or(isize::MAX)) |
1756 | | - 1, |
1757 | | data_end, |
1758 | | ); |
1759 | | } |
1760 | | |
1761 | | // If we didn't write any data, check if the key exists, if not return a NotFound error. |
1762 | | // This is required by spec. |
1763 | | if writer.get_bytes_written() == 0 { |
1764 | | // We're supposed to read 0 bytes, so just check if the key exists. |
1765 | | let exists: bool = { |
1766 | | let mut attempt: u32 = 0; |
1767 | | loop { |
1768 | | attempt += 1; |
1769 | | match client.connection_manager.exists(encoded_key).await { |
1770 | | Ok(v) => break v, |
1771 | | Err(err) |
1772 | | if attempt < MAX_REDIS_RETRY_ATTEMPTS |
1773 | | && is_retryable_redis_error(&err) => |
1774 | | { |
1775 | | client.reconnect(&self.connection_manager).await?; |
1776 | | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
1777 | | } |
1778 | | Err(err) => { |
1779 | | return Err( |
1780 | | Error::from(err).append("In RedisStore::get_part::zero_exists") |
1781 | | ); |
1782 | | } |
1783 | | } |
1784 | | } |
1785 | | }; |
1786 | | |
1787 | | if !exists { |
1788 | | return Err(make_err!( |
1789 | | Code::NotFound, |
1790 | | "Data not found in Redis store for digest: {key:?}" |
1791 | | )); |
1792 | | } |
1793 | | // The key exists and holds nothing, yet the digest names bytes |
1794 | | // from this offset on: that is the empty value a failed upload |
1795 | | // left, not the blob. NotFound sends the client back to upload it. |
1796 | | if let StoreKey::Digest(digest) = key.borrow() |
1797 | | && digest.size_bytes() > u64::try_from(data_start).unwrap_or(u64::MAX) |
1798 | 0 | && length.is_none_or(|len| len > 0) |
1799 | | { |
1800 | | return Err(make_err!( |
1801 | | Code::NotFound, |
1802 | | "Redis holds an empty value for {key:?}, which names {} bytes; treating it as missing so the client uploads it again", |
1803 | | digest.size_bytes() |
1804 | | )); |
1805 | | } |
1806 | | } |
1807 | | |
1808 | | writer |
1809 | | .send_eof() |
1810 | | .err_tip(|| "Failed to write EOF in redis store get_part") |
1811 | 16 | } |
1812 | | |
1813 | 16 | fn inner_store(&self, _digest: Option<StoreKey>) -> &dyn StoreDriver { |
1814 | 16 | self |
1815 | 16 | } |
1816 | | |
1817 | 0 | fn as_any(&self) -> &(dyn core::any::Any + Sync + Send) { |
1818 | 0 | self |
1819 | 0 | } |
1820 | | |
1821 | 0 | fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send> { |
1822 | 0 | self |
1823 | 0 | } |
1824 | | |
1825 | 0 | fn register_health(self: Arc<Self>, registry: &mut HealthRegistryBuilder) { |
1826 | 0 | registry.register_indicator(self); |
1827 | 0 | } |
1828 | | |
1829 | 9 | fn register_remove_callback(self: Arc<Self>, callback: RemoveCallback) -> Result<(), Error> { |
1830 | 9 | debug!(?callback, "New callback"); |
1831 | 9 | background_spawn!("remove_callback_subscribe", async move { |
1832 | 9 | self.remove_callbacks.lock().await.push(callback); |
1833 | | // The subscription init is retried until it succeeds: this |
1834 | | // function already told its caller Ok(()) (e.g. |
1835 | | // ExistenceCacheStore, which relies on eviction callbacks for |
1836 | | // invalidation), so a transient Redis error here (e.g. -LOADING |
1837 | | // during a restart/failover) must not permanently disable remove |
1838 | | // callbacks. Previously a one-shot `get_or_try_init` failure was |
1839 | | // only logged, the OnceCell stayed empty, and nothing ever |
1840 | | // retried, so the `__key*__:*` subscription was silently never |
1841 | | // established. |
1842 | 9 | let mut retry_delay = Duration::from_millis(50); |
1843 | | loop { |
1844 | 9 | let local_self = self.clone(); |
1845 | 9 | let init_result = self |
1846 | 9 | .has_remove_callback_subscribe |
1847 | 9 | .get_or_try_init(|| async move { |
1848 | | // The notify-keyspace-events check is advisory: we log |
1849 | | // misconfiguration loudly, but a failure to *read* the |
1850 | | // config must not block the subscription itself. |
1851 | 9 | let cfg_result = async { |
1852 | 9 | let mut client = local_self.get_client().await?0 ; |
1853 | 9 | 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::from1 (e1 ).append1 ("Parsing notify-keyspace-events")) |
1854 | 9 | }.await; |
1855 | 9 | match &cfg_result { |
1856 | 8 | Ok(cfg) => { |
1857 | 8 | if cfg.len() != 1 { |
1858 | 0 | warn!(?cfg, "Got multiple items for CONFIG GET, expected one"); |
1859 | 8 | } else if let Some((_, events_cfg)) = cfg.first() { |
1860 | 8 | if events_cfg.is_empty() { |
1861 | 1 | error!("notify-keyspace-events not enabled for Redis, will fail to get remove callbacks"); |
1862 | 7 | } else if !events_cfg.contains('K') { |
1863 | 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"); |
1864 | 6 | } else if !events_cfg.contains('A') { |
1865 | 1 | error!(notify_keyspace_events=events_cfg, "notify-keyspace-events does not contain 'A' so we won't get eviction events"); |
1866 | 5 | } |
1867 | | // FIXME: Redis events spec appears unreliable, so we subscribe anyways |
1868 | | // It should just need Ke as per https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/ |
1869 | | // but I'm yet to get reliable eviction events out of that |
1870 | 8 | info!(notify_keyspace_events=events_cfg, "Attempting to subscribe to eviction events"); |
1871 | 0 | } |
1872 | | } |
1873 | 1 | Err(err) => { |
1874 | 1 | error!(?err, "Error while trying to initialise remove_callback_subscribe: could not verify notify-keyspace-events; subscribing anyway"); |
1875 | | } |
1876 | | } |
1877 | 9 | local_self.connection_manager.psubscribe("__key*__:*").await?0 ; |
1878 | 9 | Ok::<(), Error>(()) |
1879 | 18 | }) |
1880 | 9 | .await; |
1881 | 9 | match init_result { |
1882 | 9 | Ok(()) => break, |
1883 | 0 | Err(err) => { |
1884 | 0 | error!( |
1885 | | ?err, |
1886 | | "Error while trying to initialise remove_callback_subscribe; will retry" |
1887 | | ); |
1888 | 0 | sleep(retry_delay).await; |
1889 | 0 | retry_delay = (retry_delay * 2).min(Duration::from_secs(5)); |
1890 | | } |
1891 | | } |
1892 | | } |
1893 | 9 | }); |
1894 | 9 | Ok(()) |
1895 | 9 | } |
1896 | | } |
1897 | | |
1898 | | #[async_trait] |
1899 | | impl<C, M> HealthStatusIndicator for RedisStore<C, M> |
1900 | | where |
1901 | | C: ConnectionLike + Clone + Send + Sync + Unpin + 'static, |
1902 | | M: RedisManager<C> + Send + Sync + Unpin + 'static, |
1903 | | { |
1904 | 1 | fn get_name(&self) -> &'static str { |
1905 | 1 | "RedisStore" |
1906 | 1 | } |
1907 | | |
1908 | | /// Lightweight health check: just `PING` the master, bounded by a |
1909 | | /// short physical timeout. The default `StoreDriver::check_health` |
1910 | | /// performs a full `update_oneshot` + `has` + `get_part_unchunked` |
1911 | | /// roundtrip, which queues behind real production traffic on the |
1912 | | /// same connection-permit semaphore and Redis master. When the |
1913 | | /// store is even moderately loaded that easily exceeds the |
1914 | | /// `HealthServer` per-indicator budget (default 5 s), each |
1915 | | /// RedisStore-backed indicator (AC, small-blob CAS, scheduler) |
1916 | | /// reports `HealthStatus::Timeout`, and `/status` returns 503 — |
1917 | | /// surfaced as a readiness-probe failure that sheds traffic from |
1918 | | /// an otherwise-functional pod. A `PING` proves the connection |
1919 | | /// is reachable and the master is accepting commands; that is |
1920 | | /// the only invariant a kubelet probe needs. |
1921 | 1 | async fn check_health(&self, _namespace: Cow<'static, str>) -> HealthStatus { |
1922 | | let mut client = match self.get_client().await { |
1923 | | Ok(c) => c, |
1924 | | Err(e) => { |
1925 | | return HealthStatus::new_failed( |
1926 | | self, |
1927 | | format!("RedisStore::check_health: failed to acquire connection: {e}").into(), |
1928 | | ); |
1929 | | } |
1930 | | }; |
1931 | | |
1932 | | // Hold the `ClientWithPermit` for the duration of the call so |
1933 | | // its `Drop` releases the semaphore permit on exit. We just |
1934 | | // need a `&mut` to the connection manager underneath. |
1935 | 1 | let ping = async { |
1936 | 1 | redis::cmd("PING") |
1937 | 1 | .query_async::<()>(&mut client.connection_manager) |
1938 | 1 | .await |
1939 | 1 | }; |
1940 | | let retry_reason = match timeout(self.health_check_timeout, ping).await { |
1941 | | Ok(Ok(())) => { |
1942 | | return HealthStatus::new_ok(self, "RedisStore::check_health: PING ok".into()); |
1943 | | } |
1944 | | // A PING that errors connection-wise or times out means the handle |
1945 | | // points at a master that went away (a Sentinel failover). Without |
1946 | | // re-resolving here, a store with no other traffic stays wedged on |
1947 | | // the dead handle and reports unhealthy forever — shedding readiness |
1948 | | // traffic from an otherwise-recovered pod until it is restarted. |
1949 | | Ok(Err(e)) if is_retryable_redis_error(&e) => format!("PING errored: {e}"), |
1950 | | Ok(Err(e)) => { |
1951 | | return HealthStatus::new_failed( |
1952 | | self, |
1953 | | format!("RedisStore::check_health: PING errored: {e}").into(), |
1954 | | ); |
1955 | | } |
1956 | | Err(_) => format!( |
1957 | | "PING exceeded {}ms timeout", |
1958 | | self.health_check_timeout.as_millis() |
1959 | | ), |
1960 | | }; |
1961 | | |
1962 | | // The reconnect (re-resolving the master via Sentinel) is logged by |
1963 | | // `StandardRedisManager::reconnect`; `retry_reason` is surfaced in the |
1964 | | // failure message below if the second PING still doesn't come back. |
1965 | | if let Err(e) = client.reconnect(&self.connection_manager).await { |
1966 | | return HealthStatus::new_failed( |
1967 | | self, |
1968 | | format!("RedisStore::check_health: {retry_reason}; reconnect failed: {e}").into(), |
1969 | | ); |
1970 | | } |
1971 | 1 | let ping = async { |
1972 | 1 | redis::cmd("PING") |
1973 | 1 | .query_async::<()>(&mut client.connection_manager) |
1974 | 1 | .await |
1975 | 1 | }; |
1976 | | match timeout(self.health_check_timeout, ping).await { |
1977 | | Ok(Ok(())) => HealthStatus::new_ok( |
1978 | | self, |
1979 | | "RedisStore::check_health: PING ok after re-resolving master".into(), |
1980 | | ), |
1981 | | Ok(Err(e)) => HealthStatus::new_failed( |
1982 | | self, |
1983 | | format!("RedisStore::check_health: PING still errored after reconnect: {e}").into(), |
1984 | | ), |
1985 | | Err(_) => HealthStatus::new_failed( |
1986 | | self, |
1987 | | format!( |
1988 | | "RedisStore::check_health: PING still exceeded {}ms timeout after reconnect", |
1989 | | self.health_check_timeout.as_millis() |
1990 | | ) |
1991 | | .into(), |
1992 | | ), |
1993 | | } |
1994 | 1 | } |
1995 | | } |
1996 | | |
1997 | | // ------------------------------------------------------------------- |
1998 | | // Below this line are specific to the redis scheduler implementation. |
1999 | | // ------------------------------------------------------------------- |
2000 | | |
2001 | | /// The time in milliseconds that a redis cursor can be idle before it is closed. |
2002 | | const CURSOR_IDLE_MS: u64 = 30_000; |
2003 | | /// The name of the field in the Redis hash that stores the data. |
2004 | | const DATA_FIELD_NAME: &str = "data"; |
2005 | | /// The name of the field in the Redis hash that stores the version. |
2006 | | const VERSION_FIELD_NAME: &str = "version"; |
2007 | | |
2008 | | #[allow(rustdoc::broken_intra_doc_links)] |
2009 | | /// Lua script to set a key if the version matches. |
2010 | | /// Args: |
2011 | | /// KEYS[1]: The key where the version is stored. |
2012 | | /// ARGV[1]: The expected version. |
2013 | | /// ARGV[2]: TTL in seconds, or 0 for forever |
2014 | | /// ARGV[3]: The new data. |
2015 | | /// ARGV[4*]: Key-value pairs of additional data to include. |
2016 | | /// Returns: |
2017 | | /// The new version if the version matches. nil is returned if the |
2018 | | /// value was not set. |
2019 | | pub const LUA_VERSION_SET_SCRIPT: &str = formatcp!( |
2020 | | r" |
2021 | | local key = KEYS[1] |
2022 | | local expected_version = tonumber(ARGV[1]) |
2023 | | local ttl = tonumber(ARGV[2]) |
2024 | | local new_data = ARGV[3] |
2025 | | local new_version = redis.call('HINCRBY', key, '{VERSION_FIELD_NAME}', 1) |
2026 | | local i |
2027 | | local indexes = {{}} |
2028 | | |
2029 | | if new_version-1 ~= expected_version then |
2030 | | local reverted = redis.call('HINCRBY', key, '{VERSION_FIELD_NAME}', -1) |
2031 | | -- HINCRBY creates the key before the version can be checked, so a |
2032 | | -- caller holding a stale version for a key that has since gone leaves |
2033 | | -- behind a hash with nothing in it but a zeroed version. It has no |
2034 | | -- data, no expiry and never gains either, yet it still matches the |
2035 | | -- index prefix, so it sits in the index as an empty document and |
2036 | | -- crowds every search that reads it. Only ever removes a key this |
2037 | | -- call brought into existence: a real record always carries data. |
2038 | | if reverted == 0 and redis.call('HEXISTS', key, '{DATA_FIELD_NAME}') == 0 then |
2039 | | redis.call('DEL', key) |
2040 | | end |
2041 | | return {{ 0, new_version-1 }} |
2042 | | end |
2043 | | -- Skip first 3 argvs, as they are known inputs. |
2044 | | -- Remember: Lua is 1-indexed. |
2045 | | for i=4, #ARGV do |
2046 | | indexes[i-3] = ARGV[i] |
2047 | | end |
2048 | | |
2049 | | -- In testing we witnessed redis sometimes not update our FT indexes |
2050 | | -- resulting in stale data. It appears if we delete our keys then insert |
2051 | | -- them again it works and reduces risk significantly. |
2052 | | redis.call('DEL', key) |
2053 | | redis.call('HSET', key, '{DATA_FIELD_NAME}', new_data, '{VERSION_FIELD_NAME}', new_version, unpack(indexes)) |
2054 | | |
2055 | | if ttl ~= 0 then |
2056 | | redis.call('EXPIRE', key, ttl) |
2057 | | end |
2058 | | return {{ 1, new_version }} |
2059 | | " |
2060 | | ); |
2061 | | |
2062 | | /// How long a freshly created index is given to finish its background |
2063 | | /// scan before a read goes ahead regardless, and how often it is asked. |
2064 | | const INDEX_READY_TIMEOUT: Duration = Duration::from_secs(5); |
2065 | | const INDEX_READY_POLL: Duration = Duration::from_millis(50); |
2066 | | |
2067 | | /// `FT.CREATE` of an index that exists: `RediSearch` answers an `Index` |
2068 | | /// extension error saying so, which a create that races another node gets. |
2069 | 3 | fn is_index_already_exists(err: &RedisError) -> bool { |
2070 | 3 | err.kind() == redis::ErrorKind::Extension |
2071 | 2 | && err.code() == Some("Index") |
2072 | 1 | && err |
2073 | 1 | .detail() |
2074 | 1 | .is_some_and(|d| d.to_ascii_lowercase().contains("already exists")) |
2075 | 3 | } |
2076 | | |
2077 | | /// Polls `FT.INFO` until the index reports `indexing 0`, the timeout |
2078 | | /// passes, or the call fails; none of those stops the caller, which reads |
2079 | | /// the index either way. |
2080 | 1 | async fn wait_for_index_ready<C>(connection_manager: C, index: &str) |
2081 | 1 | where |
2082 | 1 | C: ConnectionLike + Send + Clone, |
2083 | 1 | { |
2084 | 1 | wait_for_index_ready_within(connection_manager, index, INDEX_READY_TIMEOUT).await; |
2085 | 1 | } |
2086 | | |
2087 | 1 | async fn wait_for_index_ready_within<C>(connection_manager: C, index: &str, timeout: Duration) |
2088 | 1 | where |
2089 | 1 | C: ConnectionLike + Send + Clone, |
2090 | 1 | { |
2091 | 1 | let started = Instant::now(); |
2092 | | loop { |
2093 | 1 | match ft_info_indexing(connection_manager.clone(), index).await { |
2094 | 1 | Ok(false) => return, |
2095 | 0 | Ok(true) => {} |
2096 | 0 | Err(err) => { |
2097 | 0 | warn!( |
2098 | | ?err, |
2099 | | index, "Could not read FT.INFO after creating the index; reading it now" |
2100 | | ); |
2101 | 0 | return; |
2102 | | } |
2103 | | } |
2104 | 0 | if started.elapsed() >= timeout { |
2105 | 0 | warn!( |
2106 | | index, |
2107 | | "Index still backfilling after {timeout:?}; reading it now" |
2108 | | ); |
2109 | 0 | return; |
2110 | 0 | } |
2111 | 0 | sleep(INDEX_READY_POLL).await; |
2112 | | } |
2113 | 1 | } |
2114 | | |
2115 | | /// This is the output of the calculations below hardcoded into the executable. |
2116 | | const FINGERPRINT_CREATE_INDEX_HEX: &str = "3e762c15"; |
2117 | | |
2118 | | #[cfg(test)] |
2119 | | mod test { |
2120 | | use super::FINGERPRINT_CREATE_INDEX_HEX; |
2121 | | |
2122 | | /// String of the `FT.CREATE` command used to create the index template. |
2123 | | const CREATE_INDEX_TEMPLATE: &str = "FT.CREATE {} ON HASH PREFIX 1 {} NOOFFSETS NOHL NOFIELDS NOFREQS SCHEMA {} TAG CASESENSITIVE SORTABLE"; |
2124 | | |
2125 | | /// Compile-time fingerprint of the `FT.CREATE` command used to create the |
2126 | | /// index template. This is a simple CRC32 checksum of the command string. |
2127 | | /// We don't care about it actually being a valid CRC32 checksum, just that |
2128 | | /// it's a unique identifier with a low chance of collision. |
2129 | 1 | const fn fingerprint_create_index_template() -> u32 { |
2130 | | const POLY: u32 = 0xEDB8_8320; |
2131 | | const DATA: &[u8] = CREATE_INDEX_TEMPLATE.as_bytes(); |
2132 | 1 | let mut crc = 0xFFFF_FFFF; |
2133 | 1 | let mut i = 0; |
2134 | 102 | while i < DATA.len() { |
2135 | 101 | let byte = DATA[i]; |
2136 | 101 | crc ^= byte as u32; |
2137 | | |
2138 | 101 | let mut j = 0; |
2139 | 909 | while j < 8 { |
2140 | 808 | crc = if crc & 1 != 0 { |
2141 | 386 | (crc >> 1) ^ POLY |
2142 | | } else { |
2143 | 422 | crc >> 1 |
2144 | | }; |
2145 | 808 | j += 1; |
2146 | | } |
2147 | 101 | i += 1; |
2148 | | } |
2149 | 1 | crc |
2150 | 1 | } |
2151 | | |
2152 | | /// Verify that our calculation always evaluates to this fixed value. |
2153 | | #[test] |
2154 | 1 | fn test_fingerprint_value() { |
2155 | 1 | assert_eq!( |
2156 | 1 | format!("{:08x}", &fingerprint_create_index_template()), |
2157 | | FINGERPRINT_CREATE_INDEX_HEX, |
2158 | | ); |
2159 | 1 | } |
2160 | | } |
2161 | | |
2162 | | /// Get the name of the index to create for the given field. |
2163 | | /// This will add some prefix data to the name to try and ensure |
2164 | | /// if the index definition changes, the name will get a new name. |
2165 | | macro_rules! get_index_name { |
2166 | | ($prefix:expr, $field:expr, $maybe_sort:expr) => { |
2167 | | format_args!( |
2168 | | "{}_{}_{}_{}", |
2169 | | $prefix, |
2170 | | $field, |
2171 | | $maybe_sort.unwrap_or(""), |
2172 | | FINGERPRINT_CREATE_INDEX_HEX |
2173 | | ) |
2174 | | }; |
2175 | | } |
2176 | | |
2177 | | /// Try to sanitize a string to be used as a Redis key. |
2178 | | /// We don't actually modify the string, just check if it's valid. |
2179 | 16.9k | const fn try_sanitize(s: &str) -> bool { |
2180 | | // Note: We cannot use for loops or iterators here because they are not const. |
2181 | | // Allowing us to use a const function here gives the compiler the ability to |
2182 | | // optimize this function away entirely in the case where the input is constant. |
2183 | 16.9k | let chars = s.as_bytes(); |
2184 | 16.9k | let mut i: usize = 0; |
2185 | 16.9k | let len = s.len(); |
2186 | | loop { |
2187 | 856k | if i >= len { |
2188 | 16.9k | break; |
2189 | 839k | } |
2190 | 839k | let c = chars[i]; |
2191 | 839k | if !c.is_ascii_alphanumeric() && c != b'_'39.6k { |
2192 | 0 | return false; |
2193 | 839k | } |
2194 | 839k | i += 1; |
2195 | | } |
2196 | 16.9k | true |
2197 | 16.9k | } |
2198 | | |
2199 | | /// An individual subscription to a key in Redis. |
2200 | | #[derive(Debug)] |
2201 | | pub struct RedisSubscription { |
2202 | | receiver: Option<tokio::sync::watch::Receiver<String>>, |
2203 | | weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<RedisSubscriptionPublisher>>>, |
2204 | | } |
2205 | | |
2206 | | impl SchedulerSubscription for RedisSubscription { |
2207 | | /// Wait for the subscription key to change. |
2208 | 52.9k | async fn changed(&mut self) -> Result<(), Error> {20.9k |
2209 | 20.9k | let receiver = self |
2210 | 20.9k | .receiver |
2211 | 20.9k | .as_mut() |
2212 | 20.9k | .ok_or_else(|| make_err!0 (Code::Internal0 , "In RedisSubscription::changed::as_mut"))?0 ; |
2213 | 20.9k | receiver.changed().await.map_err16.8k (|err| {0 |
2214 | 0 | Error::from_std_err(Code::Internal, &err) |
2215 | 0 | .append("In RedisSubscription::changed::changed") |
2216 | 0 | }) |
2217 | 16.8k | } |
2218 | | } |
2219 | | |
2220 | | // If the subscription is dropped, we need to possibly remove the key from the |
2221 | | // subscribed keys map. |
2222 | | impl Drop for RedisSubscription { |
2223 | 5.70k | fn drop(&mut self) { |
2224 | 5.70k | let Some(receiver) = self.receiver.take() else { |
2225 | 0 | warn!("RedisSubscription has already been dropped, nothing to do."); |
2226 | 0 | return; // Already dropped, nothing to do. |
2227 | | }; |
2228 | 5.70k | let key = receiver.borrow().clone(); |
2229 | 5.70k | let Some(subscribed_keys5.68k ) = self.weak_subscribed_keys.upgrade() else { |
2230 | 22 | return; // Parent dropped — nothing to do. |
2231 | | }; |
2232 | 5.68k | let mut subscribed_keys = subscribed_keys.write(); |
2233 | 5.68k | let Some(publisher) = subscribed_keys.get(&key) else { |
2234 | 0 | warn!( |
2235 | | %key, |
2236 | | "RedisSubscription::drop: key absent from subscribed_keys under write lock — \ |
2237 | | indicates an unexpected removal path", |
2238 | | ); |
2239 | 0 | return; |
2240 | | }; |
2241 | | // Count includes our own (still-alive) receiver. If we are the |
2242 | | // sole subscriber, remove the publisher entry. |
2243 | 5.68k | if publisher.receiver_count() == 1 { |
2244 | 5.47k | subscribed_keys.remove(&key); |
2245 | 5.47k | }203 |
2246 | 5.68k | drop(receiver); |
2247 | 5.70k | } |
2248 | | } |
2249 | | |
2250 | | /// A publisher for a key in Redis. |
2251 | | #[derive(Debug)] |
2252 | | struct RedisSubscriptionPublisher { |
2253 | | sender: Mutex<tokio::sync::watch::Sender<String>>, |
2254 | | } |
2255 | | |
2256 | | impl RedisSubscriptionPublisher { |
2257 | 5.50k | fn new( |
2258 | 5.50k | key: String, |
2259 | 5.50k | weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<Self>>>, |
2260 | 5.50k | ) -> (Self, RedisSubscription) { |
2261 | 5.50k | let (sender, receiver) = tokio::sync::watch::channel(key); |
2262 | 5.50k | let publisher = Self { |
2263 | 5.50k | sender: Mutex::new(sender), |
2264 | 5.50k | }; |
2265 | 5.50k | let subscription = RedisSubscription { |
2266 | 5.50k | receiver: Some(receiver), |
2267 | 5.50k | weak_subscribed_keys, |
2268 | 5.50k | }; |
2269 | 5.50k | (publisher, subscription) |
2270 | 5.50k | } |
2271 | | |
2272 | 203 | fn subscribe( |
2273 | 203 | &self, |
2274 | 203 | weak_subscribed_keys: Weak<RwLock<StringPatriciaMap<Self>>>, |
2275 | 203 | ) -> RedisSubscription { |
2276 | 203 | let receiver = self.sender.lock().subscribe(); |
2277 | 203 | RedisSubscription { |
2278 | 203 | receiver: Some(receiver), |
2279 | 203 | weak_subscribed_keys, |
2280 | 203 | } |
2281 | 203 | } |
2282 | | |
2283 | 5.68k | fn receiver_count(&self) -> usize { |
2284 | 5.68k | self.sender.lock().receiver_count() |
2285 | 5.68k | } |
2286 | | |
2287 | 20.2k | fn notify(&self) { |
2288 | | // TODO(https://github.com/sile/patricia_tree/issues/40) When this is addressed |
2289 | | // we can remove the `Mutex` and use the mutable iterator directly. |
2290 | 20.2k | self.sender.lock().send_modify(|_| {}); |
2291 | 20.2k | } |
2292 | | } |
2293 | | |
2294 | | #[derive(Debug, Clone)] |
2295 | | pub struct RedisSubscriptionManager { |
2296 | | subscribed_keys: Arc<RwLock<StringPatriciaMap<RedisSubscriptionPublisher>>>, |
2297 | | tx_for_test: UnboundedSender<String>, |
2298 | | _subscription_spawn: Arc<Mutex<JoinHandleDropGuard<()>>>, |
2299 | | } |
2300 | | |
2301 | | impl RedisSubscriptionManager { |
2302 | 169 | pub fn new( |
2303 | 169 | subscriber_channel: UnboundedReceiver<PushInfo>, |
2304 | 169 | remove_callbacks: Arc<async_lock::Mutex<Vec<RemoveCallback>>>, |
2305 | 169 | key_prefix: String, |
2306 | 169 | ) -> Self { |
2307 | 169 | let subscribed_keys = Arc::new(RwLock::new(StringPatriciaMap::new())); |
2308 | 169 | let subscribed_keys_weak = Arc::downgrade(&subscribed_keys); |
2309 | 169 | let (tx_for_test, mut rx_for_test) = unbounded_channel(); |
2310 | 169 | let mut local_subscriber_channel = UnboundedReceiverStream::new(subscriber_channel); |
2311 | | Self { |
2312 | 169 | subscribed_keys, |
2313 | 169 | tx_for_test, |
2314 | 169 | _subscription_spawn: Arc::new(Mutex::new(spawn!( |
2315 | | "redis_subscribe_spawn", |
2316 | 132 | async move { |
2317 | 132 | debug!("running subscribe loop"); |
2318 | | loop { |
2319 | | loop { |
2320 | 46.7k | let key46.6k = select! { |
2321 | 46.7k | value46.6k = rx_for_test.recv() => { |
2322 | 46.6k | let Some(value) = value else { |
2323 | 0 | unreachable!("Channel should never close"); |
2324 | | }; |
2325 | 46.6k | value |
2326 | | }, |
2327 | 46.7k | maybe_push_info32 = local_subscriber_channel.next() => { |
2328 | 32 | if let Some(push_info13 ) = maybe_push_info { |
2329 | 13 | match push_info.kind { |
2330 | 6 | redis::PushKind::PMessage => {}, |
2331 | | redis::PushKind::PSubscribe => { |
2332 | 1 | trace!(?push_info, "PSubscribe, ignore"); |
2333 | 1 | continue; |
2334 | | } |
2335 | | _ => { |
2336 | 6 | warn!(?push_info, "Other push_info message, discarded"); |
2337 | 6 | continue; |
2338 | | }, |
2339 | | } |
2340 | 6 | if push_info.data.len() != 3 { |
2341 | 0 | error!(?push_info, "Expected exactly 3 values on subscriber channel (pattern, channel, value)"); |
2342 | 0 | continue; |
2343 | 6 | } |
2344 | 6 | let value = match push_info.data.last().unwrap() { |
2345 | 0 | Value::SimpleString(s) => { |
2346 | 0 | s.clone() |
2347 | | } |
2348 | 6 | Value::BulkString(v) => { |
2349 | 6 | String::from_utf8(v.clone()).expect("String message") |
2350 | | } |
2351 | 0 | other => { |
2352 | 0 | error!(?other, "Received non-string message in RedisSubscriptionManager"); |
2353 | 0 | continue; |
2354 | | } |
2355 | | }; |
2356 | | // Redis reports maxmemory eviction as |
2357 | | // "evicted" and TTL expiry as "expired". |
2358 | | // Both mean the key is gone, so both have |
2359 | | // to invalidate anything caching its |
2360 | | // existence; treating only one as a removal |
2361 | | // leaves an ExistenceCacheStore claiming to |
2362 | | // hold a key that Redis has already dropped. |
2363 | 6 | if value == "evicted" || value == "expired"3 { |
2364 | 5 | trace!(?push_info, %value, "Key removal event"); |
2365 | 5 | let removed_key = if let Some(key) = push_info.data.get(1) { |
2366 | 5 | if let Value::BulkString(s) = key { |
2367 | 5 | String::from_utf8(s.clone()).expect("String message") |
2368 | | } else { |
2369 | 0 | error!(?push_info, "Removed key wasn't bulk-string"); |
2370 | 0 | continue; |
2371 | | } |
2372 | | } else { |
2373 | 0 | error!(?push_info, "No key in removal event"); |
2374 | 0 | continue; |
2375 | | }; |
2376 | 5 | trace!(?removed_key, "Removed key"); |
2377 | 5 | let Some((_prefix, internal_key)) = removed_key.split_once(':') else { |
2378 | 0 | error!(?removed_key, "Removed key doesn't contain a colon"); |
2379 | 0 | continue; |
2380 | | }; |
2381 | | |
2382 | 5 | let store_key = match decode_key(&key_prefix, Cow::from(internal_key)) { |
2383 | 5 | Ok(k) => k.into_owned(), |
2384 | 0 | Err(err) => { |
2385 | 0 | error!(%err, internal_key, "Bad redis key"); |
2386 | 0 | continue; |
2387 | | } |
2388 | | }; |
2389 | 5 | let locked_remove_callbacks = remove_callbacks.lock().await; |
2390 | 5 | let mut callbacks: FuturesUnordered<_> = |
2391 | 5 | locked_remove_callbacks.iter() |
2392 | 5 | .map(|callback| callback.callback(store_key.borrow())) |
2393 | 5 | .collect(); |
2394 | 10 | while callbacks.next().await.is_some() {}5 |
2395 | 5 | continue |
2396 | 1 | } |
2397 | 1 | value |
2398 | | } else { |
2399 | 19 | error!("Error receiving message in RedisSubscriptionManager from subscriber_channel"); |
2400 | 19 | break; |
2401 | | } |
2402 | | } |
2403 | | }; |
2404 | 46.6k | trace!(key, "New subscription manager key"); |
2405 | 46.6k | let Some(subscribed_keys) = subscribed_keys_weak.upgrade() else { |
2406 | 0 | warn!( |
2407 | | "It appears our parent has been dropped, exiting RedisSubscriptionManager spawn" |
2408 | | ); |
2409 | 0 | return; |
2410 | | }; |
2411 | 46.6k | let subscribed_keys_mux = subscribed_keys.read(); |
2412 | 46.6k | subscribed_keys_mux |
2413 | 46.6k | .common_prefix_values(&*key) |
2414 | 46.6k | .for_each(RedisSubscriptionPublisher::notify); |
2415 | | } |
2416 | | // Sleep for a small amount of time to ensure we don't reconnect too quickly. |
2417 | 19 | sleep(Duration::from_secs(1)).await; |
2418 | | // If we reconnect or lag behind we might have had dirty keys, so we need to |
2419 | | // flag all of them as changed. |
2420 | 2 | let Some(subscribed_keys) = subscribed_keys_weak.upgrade() else { |
2421 | 0 | warn!( |
2422 | | "It appears our parent has been dropped, exiting RedisSubscriptionManager spawn" |
2423 | | ); |
2424 | 0 | return; |
2425 | | }; |
2426 | 2 | let subscribed_keys_mux = subscribed_keys.read(); |
2427 | | // Just in case also get a new receiver. |
2428 | 2 | for publisher0 in subscribed_keys_mux.values() { |
2429 | 0 | publisher.notify(); |
2430 | 0 | } |
2431 | | } |
2432 | 0 | } |
2433 | | ))), |
2434 | | } |
2435 | 169 | } |
2436 | | } |
2437 | | |
2438 | | impl SubscriptionManagerNotify for RedisSubscriptionManager { |
2439 | 46.6k | fn notify_for_test(&self, value: String) { |
2440 | 46.6k | self.tx_for_test.send(value).unwrap(); |
2441 | 46.6k | } |
2442 | | } |
2443 | | |
2444 | | impl SchedulerSubscriptionManager for RedisSubscriptionManager { |
2445 | | type Subscription = RedisSubscription; |
2446 | | |
2447 | 5.70k | fn subscribe<K>(&self, key: K) -> Result<Self::Subscription, Error> |
2448 | 5.70k | where |
2449 | 5.70k | K: SchedulerStoreKeyProvider, |
2450 | | { |
2451 | 5.70k | let weak_subscribed_keys = Arc::downgrade(&self.subscribed_keys); |
2452 | 5.70k | let mut subscribed_keys = self.subscribed_keys.write(); |
2453 | 5.70k | let key = key.get_key(); |
2454 | 5.70k | let key_str = key.as_str(); |
2455 | 5.70k | let mut subscription = if let Some(publisher203 ) = subscribed_keys.get(&key_str) { |
2456 | 203 | publisher.subscribe(weak_subscribed_keys) |
2457 | | } else { |
2458 | 5.50k | let (publisher, subscription) = |
2459 | 5.50k | RedisSubscriptionPublisher::new(key_str.to_string(), weak_subscribed_keys); |
2460 | 5.50k | subscribed_keys.insert(key_str, publisher); |
2461 | 5.50k | subscription |
2462 | | }; |
2463 | 5.70k | subscription |
2464 | 5.70k | .receiver |
2465 | 5.70k | .as_mut() |
2466 | 5.70k | .ok_or_else(|| {0 |
2467 | 0 | make_err!( |
2468 | 0 | Code::Internal, |
2469 | | "Receiver should be set in RedisSubscriptionManager::subscribe" |
2470 | | ) |
2471 | 0 | })? |
2472 | 5.70k | .mark_changed(); |
2473 | | |
2474 | 5.70k | Ok(subscription) |
2475 | 5.70k | } |
2476 | | |
2477 | 5.20k | fn is_reliable() -> bool { |
2478 | 5.20k | false |
2479 | 5.20k | } |
2480 | | } |
2481 | | |
2482 | | /// How the scheduler's index for `K` is created: never `TEMPORARY`, since |
2483 | | /// `RediSearch` 2.10 never collects a temporary index and every update of an |
2484 | | /// indexed hash leaves a dead entry behind for good. A persistent index is |
2485 | | /// collected. An index an older release created temporary must not be |
2486 | | /// dropped the ordinary way, which deletes its hashes: see |
2487 | | /// `redis_utils::index_migration`. |
2488 | 3 | fn index_definition<K: SchedulerIndexProvider>() -> (FtCreateOptions, Vec<SearchSchema>) { |
2489 | 3 | let mut schema = vec![SearchSchema { |
2490 | 3 | field_name: K::INDEX_NAME.into(), |
2491 | 3 | sortable: false, |
2492 | 3 | }]; |
2493 | 3 | if let Some(sort_key) = K::MAYBE_SORT_KEY { |
2494 | 3 | schema.push(SearchSchema { |
2495 | 3 | field_name: sort_key.into(), |
2496 | 3 | sortable: true, |
2497 | 3 | }); |
2498 | 3 | }0 |
2499 | 3 | let create_options = FtCreateOptions { |
2500 | 3 | prefixes: vec![K::KEY_PREFIX.into()], |
2501 | 3 | nohl: true, |
2502 | 3 | nofields: true, |
2503 | 3 | nofreqs: true, |
2504 | 3 | nooffsets: true, |
2505 | 3 | temporary: None, |
2506 | 3 | }; |
2507 | 3 | (create_options, schema) |
2508 | 3 | } |
2509 | | |
2510 | | /// How long a settled index name is trusted before it is checked again. A |
2511 | | /// failed check is trusted as long, so a server that refuses the check |
2512 | | /// (some managed Redis refuse `FT.DEBUG`) is asked once a minute, not on |
2513 | | /// every read. |
2514 | | const INDEX_MIGRATION_RECHECK: Duration = Duration::from_mins(1); |
2515 | | |
2516 | | /// How long an answer that holds only while a replacement backfills is |
2517 | | /// trusted, so concurrent reads do not each repeat the check. |
2518 | | const INDEX_MIGRATION_POLL: Duration = Duration::from_secs(1); |
2519 | | |
2520 | | /// How long a read waits for a replacement index to finish backfilling when |
2521 | | /// there is no original left to read meanwhile. Reads normally stay on the |
2522 | | /// original while the replacement fills, so this is only hit if the |
2523 | | /// original was removed some other way. |
2524 | | const MIGRATION_READY_TIMEOUT: Duration = Duration::from_mins(10); |
2525 | | |
2526 | | impl<C, M> RedisStore<C, M> |
2527 | | where |
2528 | | C: Clone + ConnectionLike + Sync + Send + 'static, |
2529 | | M: RedisManager<C> + Sync + Send + 'static, |
2530 | | { |
2531 | 16.9k | fn original_index_name<K: SchedulerIndexProvider>() -> String { |
2532 | 16.9k | format!( |
2533 | | "{}", |
2534 | 16.9k | get_index_name!(K::KEY_PREFIX, K::INDEX_NAME, K::MAYBE_SORT_KEY) |
2535 | | ) |
2536 | 16.9k | } |
2537 | | |
2538 | | /// The index a read of `K` uses, advancing the migration of an inherited |
2539 | | /// temporary index by one step when `migrate_temporary_indexes` is set. |
2540 | | /// Any failure reads the original, which is what reads did before. |
2541 | 16.9k | async fn read_index_name<K: SchedulerIndexProvider>(&self) -> String { |
2542 | 16.9k | let original = Self::original_index_name::<K>(); |
2543 | 16.9k | if !self.migrate_temporary_indexes { |
2544 | 16.9k | return original; |
2545 | 0 | } |
2546 | 0 | if let Some((name, until)) = self.index_names.lock().get(&original) |
2547 | 0 | && Instant::now() < *until |
2548 | | { |
2549 | 0 | return name.clone(); |
2550 | 0 | } |
2551 | | // One reader checks; the rest wait here and take the answer it |
2552 | | // cached, instead of a herd of FT.DEBUG/FT.INFO calls (and, in |
2553 | | // Step::WaitForReplacement, a herd of ready polls). |
2554 | 0 | let _gate = self.index_migration_gate.lock().await; |
2555 | 0 | if let Some((name, until)) = self.index_names.lock().get(&original) |
2556 | 0 | && Instant::now() < *until |
2557 | | { |
2558 | 0 | return name.clone(); |
2559 | 0 | } |
2560 | 0 | let (name, trust) = match self.migration_step::<K>(&original).await { |
2561 | 0 | Ok(decided) => decided, |
2562 | 0 | Err(err) => { |
2563 | 0 | warn!(index = %original, ?err, "Could not check the index for migration; reading it"); |
2564 | 0 | (original.clone(), INDEX_MIGRATION_RECHECK) |
2565 | | } |
2566 | | }; |
2567 | 0 | self.index_names |
2568 | 0 | .lock() |
2569 | 0 | .insert(original, (name.clone(), Instant::now() + trust)); |
2570 | 0 | name |
2571 | 16.9k | } |
2572 | | |
2573 | | /// Drops the decided name for `K`, so the next read checks again. Used |
2574 | | /// when the index a read was given has gone, which happens to a read |
2575 | | /// that was handed the original just before another process retired it. |
2576 | 0 | fn forget_index_name<K: SchedulerIndexProvider>(&self) { |
2577 | 0 | self.index_names |
2578 | 0 | .lock() |
2579 | 0 | .remove(&Self::original_index_name::<K>()); |
2580 | 0 | } |
2581 | | |
2582 | | /// The index a count of `K` uses. Counting never migrates: it follows |
2583 | | /// what reads decided. |
2584 | 2 | fn count_index_name<K: SchedulerIndexProvider>(&self) -> String { |
2585 | 2 | let original = Self::original_index_name::<K>(); |
2586 | 2 | self.index_names |
2587 | 2 | .lock() |
2588 | 2 | .get(&original) |
2589 | 2 | .map_or(original, |(name, _)| name0 .clone0 ()) |
2590 | 2 | } |
2591 | | |
2592 | | /// One step of `redis_utils::index_migration`. Returns the index to read |
2593 | | /// and how long that answer holds. |
2594 | 0 | async fn migration_step<K: SchedulerIndexProvider>( |
2595 | 0 | &self, |
2596 | 0 | original: &str, |
2597 | 0 | ) -> Result<(String, Duration), Error> { |
2598 | 0 | let replacement = migrated_name(original); |
2599 | 0 | let (mut connection, _) = self.connection_manager.get_connection().await?; |
2600 | 0 | let original_kind = index_kind(&mut connection, original).await?; |
2601 | 0 | let replacement_state = match index_kind(&mut connection, &replacement).await? { |
2602 | 0 | IndexKind::Missing => None, |
2603 | | IndexKind::Persistent => { |
2604 | | // Both counts in one MULTI/EXEC: a key written or deleted |
2605 | | // while checking moves both or neither, so replacement_state |
2606 | | // compares them strictly, with no tolerance a short scan |
2607 | | // could hide inside. |
2608 | 0 | let (original_docs, info) = if original_kind == IndexKind::Missing { |
2609 | 0 | (None, ft_info(connection.clone(), &replacement).await?) |
2610 | | } else { |
2611 | 0 | let (original_info, replacement_info) = |
2612 | 0 | ft_info_pair(connection.clone(), original, &replacement).await?; |
2613 | 0 | (Some(original_info.num_docs), replacement_info) |
2614 | | }; |
2615 | 0 | let state = index_migration::replacement_state(info, original_docs); |
2616 | 0 | if state == Replacement::Incomplete { |
2617 | 0 | error!( |
2618 | | %original, |
2619 | | %replacement, |
2620 | | replacement_docs = info.num_docs, |
2621 | | ?original_docs, |
2622 | | hash_indexing_failures = ?info.hash_indexing_failures, |
2623 | | "The replacement index does not hold every key; reads stay on the \ |
2624 | | original. Check Redis memory, then drop the replacement with \ |
2625 | | `FT.DROPINDEX {replacement} _FORCEKEEPDOCS` to build it again" |
2626 | | ); |
2627 | 0 | } |
2628 | 0 | Some(state) |
2629 | | } |
2630 | | IndexKind::Temporary(_) => { |
2631 | 0 | return Err(make_err!( |
2632 | 0 | Code::Internal, |
2633 | 0 | "Replacement index {replacement} is temporary; it was not created by this code" |
2634 | 0 | )); |
2635 | | } |
2636 | | }; |
2637 | 0 | match index_migration::plan(original_kind, replacement_state) { |
2638 | | Step::UseOriginal => Ok(( |
2639 | 0 | original.to_string(), |
2640 | 0 | if original_kind == IndexKind::Persistent { |
2641 | 0 | INDEX_MIGRATION_RECHECK |
2642 | | } else { |
2643 | 0 | INDEX_MIGRATION_POLL |
2644 | | }, |
2645 | | )), |
2646 | | Step::CreateReplacement => { |
2647 | 0 | let (create_options, schema) = index_definition::<K>(); |
2648 | 0 | match ft_create( |
2649 | 0 | connection.clone(), |
2650 | 0 | replacement.clone(), |
2651 | 0 | create_options, |
2652 | 0 | schema, |
2653 | | ) |
2654 | 0 | .await |
2655 | | { |
2656 | | Ok(()) => { |
2657 | 0 | info!(%original, %replacement, "Building a persistent index to replace a temporary one"); |
2658 | | } |
2659 | 0 | Err(err) if is_index_already_exists(&err) => {} |
2660 | 0 | Err(err) if is_out_of_memory(&err) => { |
2661 | 0 | error!( |
2662 | | %original, |
2663 | | %replacement, |
2664 | | "Redis is at maxmemory, so the persistent index that replaces the \ |
2665 | | temporary one cannot be built; reads stay on the temporary index. \ |
2666 | | Raise maxmemory, or restart Redis: a restart rebuilds the temporary \ |
2667 | | index without its dead entries, which frees most of the memory" |
2668 | | ); |
2669 | 0 | return Ok((original.to_string(), INDEX_MIGRATION_RECHECK)); |
2670 | | } |
2671 | 0 | Err(err) => return Err(err.into()), |
2672 | | } |
2673 | 0 | Ok((original.to_string(), INDEX_MIGRATION_POLL)) |
2674 | | } |
2675 | 0 | Step::WaitOnOriginal => Ok((original.to_string(), INDEX_MIGRATION_POLL)), |
2676 | 0 | Step::KeepOriginal => Ok((original.to_string(), INDEX_MIGRATION_RECHECK)), |
2677 | | Step::WaitForReplacement => { |
2678 | 0 | wait_for_index_ready_within(connection, &replacement, MIGRATION_READY_TIMEOUT) |
2679 | 0 | .await; |
2680 | 0 | Ok((replacement, INDEX_MIGRATION_POLL)) |
2681 | | } |
2682 | 0 | Step::UseReplacement { retire_original } => { |
2683 | 0 | if retire_original { |
2684 | 0 | drop_keeping_documents(&mut connection, original).await?; |
2685 | 0 | info!(%original, %replacement, "Retired the temporary index, keeping its keys"); |
2686 | 0 | } |
2687 | 0 | Ok((replacement, INDEX_MIGRATION_RECHECK)) |
2688 | | } |
2689 | | } |
2690 | 0 | } |
2691 | | |
2692 | | /// Opens one aggregate read over `K`'s index for |
2693 | | /// `search_by_index_prefix`: resolves which index to read, runs |
2694 | | /// `FT.AGGREGATE` on it, and recovers a stale resolved name, a moved |
2695 | | /// master and a missing index. The returned stream pages the cursor; an |
2696 | | /// error from a later page ends it. |
2697 | 16.9k | async fn open_index_rows<K: SchedulerIndexProvider + Send>( |
2698 | 16.9k | &self, |
2699 | 16.9k | index_value: &str, |
2700 | 16.9k | ) -> Result<Pin<Box<dyn Stream<Item = Result<Value, RedisError>> + Send>>, Error> { |
2701 | 16.9k | let index_name = self.read_index_name::<K>().await; |
2702 | 16.9k | let run_ft_aggregate16.9k = |connection_manager: C, index_name: String| async { |
2703 | 16.9k | ft_aggregate( |
2704 | 16.9k | connection_manager, |
2705 | 16.9k | index_name, |
2706 | 16.9k | if index_value.is_empty() { |
2707 | 2 | "*".to_string() |
2708 | | } else { |
2709 | 16.9k | format!("@{}:{{ {} }}", K::INDEX_NAME, index_value) |
2710 | | }, |
2711 | | FtAggregateOptions { |
2712 | 16.9k | load: vec![DATA_FIELD_NAME.into(), VERSION_FIELD_NAME.into()], |
2713 | 16.9k | cursor: FtAggregateCursor { |
2714 | 16.9k | count: self.max_count_per_cursor, |
2715 | 16.9k | max_idle: CURSOR_IDLE_MS, |
2716 | 16.9k | }, |
2717 | 16.9k | sort_by: K::MAYBE_SORT_KEY.map_or_else(Vec::new, |v| vec!5.73k [format!5.73k ("@{v}")]), |
2718 | | sort_desc: K::SORT_DESCENDING, |
2719 | | }, |
2720 | | ) |
2721 | 16.9k | .await |
2722 | 33.8k | }; |
2723 | 16.9k | let run_ft_create = |connection_manager: C, index_name: String| async {3 |
2724 | 3 | let (create_options, schema) = index_definition::<K>(); |
2725 | 3 | ft_create(connection_manager, index_name, create_options, schema).await |
2726 | 6 | }; |
2727 | | |
2728 | 16.9k | let (connection_manager, connect_id) = self.connection_manager.get_connection().await?0 ; |
2729 | 16.9k | let stream16.9k = match run_ft_aggregate(connection_manager.clone(), index_name.clone()).await { |
2730 | | // A demoted master answers READONLY and a dead/old master drops the |
2731 | | // connection or times the command out. Both mean the master moved |
2732 | | // (Sentinel failover) — re-resolve it and retry rather than letting |
2733 | | // the scheduler's matching loop spin on a stale handle. (A missing |
2734 | | // index is not retryable here; it falls through to the create path |
2735 | | // below, which creates the index and retries the aggregate inline.) |
2736 | 11 | Err(err8 ) if is_retryable_redis_error(&err)8 => { |
2737 | 3 | let (connection_manager, _connect_id) = |
2738 | 8 | self.connection_manager.reconnect(connect_id).await?5 ; |
2739 | 3 | run_ft_aggregate(connection_manager, index_name.clone()) |
2740 | 3 | .await |
2741 | 3 | .err_tip(|| {0 |
2742 | 0 | format!( |
2743 | | "Error with reconnected ft_aggregate in RedisStore::search_by_index_prefix({index_name})" |
2744 | | ) |
2745 | 0 | }) |
2746 | | } |
2747 | | Err(_) => { |
2748 | | // The index this read was given may have just been retired |
2749 | | // by another process finishing the migration. Creating it |
2750 | | // again would bring back the index the migration removed and |
2751 | | // read it before it is full; ask again which index to read. |
2752 | 3 | let moved = if self.migrate_temporary_indexes { |
2753 | 0 | self.forget_index_name::<K>(); |
2754 | 0 | let current = self.read_index_name::<K>().await; |
2755 | 0 | (current != index_name).then_some(current) |
2756 | | } else { |
2757 | 3 | None |
2758 | | }; |
2759 | | // A failure on the re-resolved name falls through to the |
2760 | | // create path below only when that name is the original, |
2761 | | // which the ordinary path owns and may recreate — the |
2762 | | // behavior every failure had before the migration existed. |
2763 | | // A replacement is never created here: it would come back |
2764 | | // empty and be read before it is full. `index_name` is |
2765 | | // rebound so the create path works on whatever index the |
2766 | | // migration settled on. |
2767 | 3 | let (index_name, resolved) = match moved { |
2768 | 0 | Some(current) => { |
2769 | 0 | let result = run_ft_aggregate(connection_manager.clone(), current.clone()) |
2770 | 0 | .await |
2771 | 0 | .err_tip(|| { |
2772 | 0 | format!( |
2773 | | "Error with re-resolved ft_aggregate in RedisStore::search_by_index_prefix({current})" |
2774 | | ) |
2775 | 0 | }); |
2776 | 0 | let recreate = |
2777 | 0 | result.is_err() && current == Self::original_index_name::<K>(); |
2778 | 0 | (current, (!recreate).then_some(result)) |
2779 | | } |
2780 | 3 | None => (index_name, None), |
2781 | | }; |
2782 | 3 | if let Some(result0 ) = resolved { |
2783 | 0 | result |
2784 | | } else { |
2785 | 3 | let (connection_manager, result) = |
2786 | 3 | match run_ft_create(connection_manager.clone(), index_name.clone()).await { |
2787 | 3 | Err(err0 ) if is_retryable_redis_error(&err)0 => { |
2788 | 0 | let (connection_manager, _connect_id) = |
2789 | 0 | self.connection_manager.reconnect(connect_id).await?; |
2790 | | ( |
2791 | 0 | connection_manager.clone(), |
2792 | 0 | run_ft_create(connection_manager, index_name.clone()).await, |
2793 | | ) |
2794 | | } |
2795 | 3 | result => (connection_manager, result), |
2796 | | }; |
2797 | | |
2798 | | // RediSearch returns ErrorKind::Extension with code "Index" |
2799 | | // and detail along the lines of "Index already exists" when |
2800 | | // FT.CREATE races with another node. |
2801 | 3 | let create_result = result.or_else(|e| { |
2802 | 3 | if is_index_already_exists(&e) { |
2803 | 1 | Ok(()) |
2804 | | } else { |
2805 | 2 | Err(e).err_tip(|| { |
2806 | 2 | format!( |
2807 | | "Error with ft_create in RedisStore::search_by_index_prefix({index_name})" |
2808 | | ) |
2809 | 2 | }) |
2810 | | } |
2811 | 3 | }); |
2812 | | |
2813 | | // FT.CREATE returns before the index holds the keys that |
2814 | | // already exist; RediSearch scans them in the background. |
2815 | | // An aggregate in that window reads a partial index, and |
2816 | | // for the scheduler's queue that is a queue with actions |
2817 | | // missing, so wait for the scan to finish first. The wait |
2818 | | // is bounded; past it the aggregate runs anyway and the next |
2819 | | // pass re-reads. |
2820 | 3 | if create_result.is_ok() { |
2821 | 1 | wait_for_index_ready(connection_manager.clone(), &index_name).await; |
2822 | 2 | } |
2823 | | |
2824 | 3 | let run_result = run_ft_aggregate(connection_manager, index_name.clone()) |
2825 | 3 | .await |
2826 | 3 | .err_tip(|| { |
2827 | 3 | format!( |
2828 | | "Error with second ft_aggregate in RedisStore::search_by_index_prefix({index_name})" |
2829 | | ) |
2830 | 3 | }); |
2831 | | |
2832 | | // Creating the index will race which is ok. If it fails to create, we only |
2833 | | // error if the second ft_aggregate call fails and fails to create. |
2834 | 3 | run_result.or_else(move |e| create_result.merge(Err(e))) |
2835 | | } |
2836 | | } |
2837 | 16.9k | Ok(stream) => Ok(stream), |
2838 | 3 | }?; |
2839 | 16.9k | Ok(Box::pin(stream)) |
2840 | 16.9k | } |
2841 | | } |
2842 | | |
2843 | | impl<C, M> SchedulerStore for RedisStore<C, M> |
2844 | | where |
2845 | | C: Clone + ConnectionLike + Sync + Send + 'static, |
2846 | | M: RedisManager<C> + Sync + Send + 'static, |
2847 | | { |
2848 | | type SubscriptionManager = RedisSubscriptionManager; |
2849 | | |
2850 | 5.38k | fn subscription_manager( |
2851 | 5.38k | &self, |
2852 | 5.38k | ) -> impl Future<Output = Result<Arc<RedisSubscriptionManager>, Error>> { |
2853 | 5.38k | std::future::ready(if self.pub_sub_channel.is_none() { |
2854 | 0 | Err(make_input_err!( |
2855 | 0 | "RedisStore must have a pubsub for Redis Scheduler if using subscriptions" |
2856 | 0 | )) |
2857 | | } else { |
2858 | 5.38k | Ok(self.subscription_manager.clone()) |
2859 | | }) |
2860 | 5.38k | } |
2861 | | |
2862 | 48.7k | async fn update_data<T>(&self, data: T, expiry: Option<Duration>) -> Result<Option<i64>, Error> |
2863 | 48.7k | where |
2864 | 48.7k | T: SchedulerStoreDataProvider |
2865 | 48.7k | + SchedulerStoreKeyProvider |
2866 | 48.7k | + SchedulerCurrentVersionProvider |
2867 | 48.7k | + Send, |
2868 | 48.7k | { |
2869 | 48.7k | let key = data.get_key(); |
2870 | 48.7k | let redis_key = self.encode_key(&key); |
2871 | 48.7k | let mut client46.7k = self.get_client().await?0 ; |
2872 | 46.7k | let maybe_index = data.get_indexes().err_tip(|| {0 |
2873 | 0 | format!("Err getting index in RedisStore::update_data::versioned for {redis_key}") |
2874 | 0 | })?; |
2875 | 46.7k | if <T as SchedulerStoreKeyProvider>::Versioned::VALUE { |
2876 | 7.91k | let current_version = data.current_version(); |
2877 | 7.91k | let data = data.try_into_bytes().err_tip(|| {0 |
2878 | 0 | format!("Could not convert value to bytes in RedisStore::update_data::versioned for {redis_key}") |
2879 | 0 | })?; |
2880 | 7.91k | let mut script = self.connection_manager.update_script(redis_key.as_ref()); |
2881 | 7.91k | let mut script_invocation = script |
2882 | 7.91k | .arg(format!("{current_version}")) |
2883 | 7.91k | .arg(expiry.unwrap_or(Duration::ZERO).as_secs()) |
2884 | 7.91k | .arg(data.to_vec()); |
2885 | 23.4k | for (name, value) in maybe_index7.91k { |
2886 | 23.4k | script_invocation = script_invocation.arg(name).arg(value.to_vec()); |
2887 | 23.4k | } |
2888 | 7.91k | let start = Instant::now(); |
2889 | 7.91k | let (success7.89k , new_version7.89k ): (bool, i64)7.89k = match script_invocation |
2890 | 7.91k | .invoke_async(&mut client.connection_manager) |
2891 | 7.91k | .await |
2892 | | { |
2893 | 7.89k | Ok(v) => v, |
2894 | 16 | Err(err8 ) if is_retryable_redis_error(&err)8 => { |
2895 | 8 | client.reconnect(&self.connection_manager).await?1 ; |
2896 | 7 | script_invocation |
2897 | 7 | .invoke_async(&mut client.connection_manager) |
2898 | 7 | .await |
2899 | 7 | .err_tip(|| format!5 ("(after reconnect) In RedisStore::update_data::versioned for {key:?}"))?5 |
2900 | | } |
2901 | 8 | Err(err) => { |
2902 | 8 | let mut error: Error = err.into(); |
2903 | 8 | error |
2904 | 8 | .messages |
2905 | 8 | .push(format!("In RedisStore::update_data::versioned for {key:?}")); |
2906 | 8 | return Err(error); |
2907 | | } |
2908 | | }; |
2909 | | |
2910 | 7.89k | let elapsed = start.elapsed(); |
2911 | | |
2912 | 7.89k | if elapsed > Duration::from_millis(100) { |
2913 | 628 | warn!( |
2914 | | %redis_key, |
2915 | | ?elapsed, |
2916 | | "Slow Redis version-set operation" |
2917 | | ); |
2918 | 7.26k | } |
2919 | 7.89k | if !success { |
2920 | 10 | warn!( |
2921 | | %redis_key, |
2922 | | %key, |
2923 | | %current_version, |
2924 | | %new_version, |
2925 | 5 | caller = core::any::type_name::<T>(), |
2926 | | "Redis version conflict - optimistic lock failed" |
2927 | | ); |
2928 | 10 | return Ok(None); |
2929 | 7.88k | } |
2930 | 7.88k | trace!( |
2931 | | %redis_key, |
2932 | | %key, |
2933 | | old_version = %current_version, |
2934 | | %new_version, |
2935 | | "Updated redis key to new version" |
2936 | | ); |
2937 | | // If we have a publish channel configured, send a notice that the key has been set. |
2938 | 7.88k | if let Some(pub_sub_channel7.88k ) = &self.pub_sub_channel { |
2939 | 7.88k | return Ok(client |
2940 | 7.88k | .connection_manager |
2941 | 7.88k | .publish(pub_sub_channel, redis_key.as_ref()) |
2942 | 7.88k | .await?3 ); |
2943 | 2 | } |
2944 | 2 | Ok(Some(new_version)) |
2945 | | } else { |
2946 | 38.8k | let data = data.try_into_bytes().err_tip(|| {0 |
2947 | 0 | format!("Could not convert value to bytes in RedisStore::update_data::noversion for {redis_key}") |
2948 | 0 | })?; |
2949 | 38.8k | let mut fields: Vec<(String, _)> = vec![]; |
2950 | 38.8k | fields.push((DATA_FIELD_NAME.into(), data.to_vec())); |
2951 | 38.8k | for (name1.31k , value1.31k ) in maybe_index { |
2952 | 1.31k | fields.push((name.into(), value.to_vec())); |
2953 | 1.31k | } |
2954 | 38.8k | match client |
2955 | 38.8k | .connection_manager |
2956 | 38.8k | .hset_multiple::<_, _, _, ()>(redis_key.as_ref(), &fields) |
2957 | 38.8k | .await |
2958 | | { |
2959 | 38.8k | Ok(_v) => { |
2960 | 38.8k | if let Some(expiry_v) = expiry { |
2961 | 38.8k | let seconds = |
2962 | 38.8k | TryInto::<i64>::try_into(expiry_v.as_secs()).err_tip(|| {0 |
2963 | 0 | format!("Expiry seconds doesn't map to i64: {expiry_v:#?}") |
2964 | 0 | })?; |
2965 | 38.8k | let expiry_result38.7k : u838.7k = client |
2966 | 38.8k | .connection_manager |
2967 | 38.8k | .expire(redis_key.as_ref(), seconds) |
2968 | 38.8k | .await |
2969 | 38.7k | .err_tip(|| {1 |
2970 | 1 | format!( |
2971 | | "In RedisStore::update_data::noversion (expiry) for {redis_key}" |
2972 | | ) |
2973 | 1 | })?; |
2974 | 38.7k | if expiry_result != 1 { |
2975 | 1 | warn!(%redis_key, seconds, "Wasn't able to set expiry for Redis key"); |
2976 | 38.7k | } |
2977 | 0 | } |
2978 | | } |
2979 | 19 | Err(err) if is_retryable_redis_error(&err) => { |
2980 | 19 | client.reconnect(&self.connection_manager).await?12 ; |
2981 | 7 | client |
2982 | 7 | .connection_manager |
2983 | 7 | .hset_multiple::<_, _, _, ()>(redis_key.as_ref(), &fields) |
2984 | 7 | .await |
2985 | 7 | .err_tip(|| format!4 ("(after reconnect) In RedisStore::update_data::noversion (hset) for {redis_key}"))?4 ; |
2986 | 3 | if let Some(expiry_v) = expiry { |
2987 | 3 | let seconds = |
2988 | 3 | TryInto::<i64>::try_into(expiry_v.as_secs()).err_tip(|| {0 |
2989 | 0 | format!("Expiry seconds doesn't map to i64: {expiry_v:#?}") |
2990 | 0 | })?; |
2991 | 3 | let expiry_result1 : u81 = client.connection_manager.expire(redis_key.as_ref(), seconds).await |
2992 | 3 | .err_tip(|| format!2 ("(after reconnect) In RedisStore::update_data::noversion (expiry) for {redis_key}"))?2 ; |
2993 | 1 | if expiry_result != 1 { |
2994 | 0 | warn!(%redis_key, seconds, "Wasn't able to set expiry for Redis key"); |
2995 | 1 | } |
2996 | 0 | } |
2997 | | } |
2998 | 0 | Err(err) => { |
2999 | 0 | let mut error: Error = err.into(); |
3000 | 0 | error.messages.push(format!( |
3001 | | "In RedisStore::update_data::noversion for {redis_key}" |
3002 | | )); |
3003 | 0 | return Err(error); |
3004 | | } |
3005 | | } |
3006 | | // If we have a publish channel configured, send a notice that the key has been set. |
3007 | 38.7k | if let Some(pub_sub_channel38.7k ) = &self.pub_sub_channel { |
3008 | 38.7k | return Ok(client |
3009 | 38.7k | .connection_manager |
3010 | 38.7k | .publish(pub_sub_channel, redis_key.as_ref()) |
3011 | 38.7k | .await?0 ); |
3012 | 3 | } |
3013 | 3 | Ok(Some(0)) // Always use "0" version since this is not a versioned request. |
3014 | | } |
3015 | 46.6k | } |
3016 | | |
3017 | 16.9k | async fn search_by_index_prefix<K>( |
3018 | 16.9k | &self, |
3019 | 16.9k | index: K, |
3020 | 16.9k | ) -> Result< |
3021 | 16.9k | impl Stream<Item = Result<<K as SchedulerStoreDecodeTo>::DecodeOutput, Error>> + Send, |
3022 | 16.9k | Error, |
3023 | 16.9k | > |
3024 | 16.9k | where |
3025 | 16.9k | K: SchedulerIndexProvider + SchedulerStoreDecodeTo + Send, |
3026 | 16.9k | { |
3027 | 16.9k | let index_value = index.index_value().into_owned(); |
3028 | 16.9k | try_sanitize(index_value.as_str()) |
3029 | 16.9k | .then_some(()) |
3030 | 16.9k | .err_tip(|| {0 |
3031 | 0 | format!("In RedisStore::search_by_index_prefix::try_sanitize - {index_value:?}") |
3032 | 0 | })?; |
3033 | 16.9k | let rows16.9k = self.open_index_rows::<K>(&index_value).await?8 ; |
3034 | | // Retiring a migrated index closes the cursors other processes still |
3035 | | // hold on it, and that failure arrives on whatever page this reader |
3036 | | // asks for next — past open_index_rows' initial-aggregate recovery. |
3037 | | // Start the read over on whichever index the migration settled on, |
3038 | | // once: rows already seen come again, which readers of the queue |
3039 | | // tolerate (every row names an action whose state is checked on |
3040 | | // use), where erroring out here fails the caller's whole pass. |
3041 | 16.9k | let stream = futures::stream::unfold( |
3042 | 16.9k | (Some(rows), index_value, true, self), |
3043 | 498k | move |(mut rows, index_value, mut may_restart, store)| async move { |
3044 | | loop { |
3045 | 498k | match rows.as_mut()?0 .next().await { |
3046 | 481k | Some(Ok(row)) => { |
3047 | 481k | return Some((Ok(row), (rows, index_value, may_restart, store))); |
3048 | | } |
3049 | 0 | Some(Err(err)) |
3050 | 0 | if may_restart |
3051 | 0 | && store.migrate_temporary_indexes |
3052 | 0 | && is_cursor_lost(&err) => |
3053 | | { |
3054 | 0 | may_restart = false; |
3055 | 0 | info!( |
3056 | | %index_value, |
3057 | | ?err, |
3058 | | "A page read lost its index or cursor (an index another \ |
3059 | | process retired loses both); starting the read over" |
3060 | | ); |
3061 | 0 | store.forget_index_name::<K>(); |
3062 | 0 | match store.open_index_rows::<K>(&index_value).await { |
3063 | 0 | Ok(reopened) => rows = Some(reopened), |
3064 | 0 | Err(reopen_err) => { |
3065 | 0 | return Some(( |
3066 | 0 | Err(reopen_err.merge(err)), |
3067 | 0 | (None, index_value, may_restart, store), |
3068 | 0 | )); |
3069 | | } |
3070 | | } |
3071 | | } |
3072 | 0 | Some(Err(err)) => { |
3073 | | return Some(( |
3074 | 0 | Err(Error::from(err)).err_tip( |
3075 | | || "Error in stream of in RedisStore::search_by_index_prefix", |
3076 | | ), |
3077 | 0 | (None, index_value, may_restart, store), |
3078 | | )); |
3079 | | } |
3080 | 16.9k | None => return None, |
3081 | | } |
3082 | | } |
3083 | 997k | }, |
3084 | | ); |
3085 | | |
3086 | 481k | Ok(stream16.9k .filter_map16.9k (|result| async move { |
3087 | 481k | let raw_redis_map = match result { |
3088 | 481k | Ok(v) => v, |
3089 | 0 | Err(e) => return Some(Err(e)), |
3090 | | }; |
3091 | | |
3092 | 481k | if matches!(raw_redis_map, Value::Int(_)) { |
3093 | 0 | return None; |
3094 | 481k | } |
3095 | | |
3096 | 481k | let Some(redis_map) = raw_redis_map.as_sequence() else { |
3097 | 0 | return Some(Err(Error::new( |
3098 | 0 | Code::Internal, |
3099 | 0 | format!("Non-array from ft_aggregate: {raw_redis_map:?}"), |
3100 | 0 | ))); |
3101 | | }; |
3102 | 481k | let mut redis_map_iter = redis_map.iter(); |
3103 | 481k | let mut bytes_data: Option<Bytes> = None; |
3104 | 481k | let mut version: Option<i64> = None; |
3105 | 1.44M | while let Some(key962k ) = redis_map_iter.next() { |
3106 | 962k | let value = redis_map_iter.next().unwrap(); |
3107 | 962k | let Value::BulkString(k) = key else { |
3108 | 0 | return Some(Err(Error::new( |
3109 | 0 | Code::Internal, |
3110 | 0 | format!("Non-BulkString key from ft_aggregate: {key:?}"), |
3111 | 0 | ))); |
3112 | | }; |
3113 | 962k | let Ok(str_key) = str::from_utf8(k) else { |
3114 | 0 | return Some(Err(Error::new( |
3115 | 0 | Code::Internal, |
3116 | 0 | format!("Non-utf8 key from ft_aggregate: {key:?}"), |
3117 | 0 | ))); |
3118 | | }; |
3119 | 962k | let Value::BulkString(v) = value else { |
3120 | 0 | return Some(Err(Error::new( |
3121 | 0 | Code::Internal, |
3122 | 0 | format!("Non-BulkString value from ft_aggregate: {key:?}"), |
3123 | 0 | ))); |
3124 | | }; |
3125 | 962k | match str_key { |
3126 | 962k | DATA_FIELD_NAME => { |
3127 | 481k | bytes_data = Some(v.clone().into()); |
3128 | 481k | } |
3129 | 480k | VERSION_FIELD_NAME => { |
3130 | 480k | let Ok(str_v) = str::from_utf8(v) else { |
3131 | 0 | return Some(Err(Error::new( |
3132 | 0 | Code::Internal, |
3133 | 0 | format!("Non-utf8 version value from ft_aggregate: {v:?}"), |
3134 | 0 | ))); |
3135 | | }; |
3136 | 480k | let Ok(raw_version) = str_v.parse::<i64>() else { |
3137 | 0 | return Some(Err(Error::new( |
3138 | 0 | Code::Internal, |
3139 | 0 | format!("Non-integer version value from ft_aggregate: {str_v:?}"), |
3140 | 0 | ))); |
3141 | | }; |
3142 | 480k | version = Some(raw_version); |
3143 | | } |
3144 | 1 | other => { |
3145 | 1 | if K::MAYBE_SORT_KEY == Some(other) { |
3146 | 1 | // ignore sort keys |
3147 | 1 | } else { |
3148 | 0 | return Some(Err(Error::new( |
3149 | 0 | Code::Internal, |
3150 | 0 | format!("Extra keys from ft_aggregate: {other}"), |
3151 | 0 | ))); |
3152 | | } |
3153 | | } |
3154 | | } |
3155 | | } |
3156 | 481k | let Some(found_bytes_data) = bytes_data else { |
3157 | 0 | return Some(Err(Error::new( |
3158 | 0 | Code::Internal, |
3159 | 0 | format!("Missing '{DATA_FIELD_NAME}' in ft_aggregate, got: {raw_redis_map:?}"), |
3160 | 0 | ))); |
3161 | | }; |
3162 | | Some( |
3163 | 481k | K::decode(version.unwrap_or(0), found_bytes_data) |
3164 | 481k | .err_tip(|| "In RedisStore::search_by_index_prefix::decode"), |
3165 | | ) |
3166 | 963k | })) |
3167 | 16.9k | } |
3168 | | |
3169 | 2 | async fn count_by_index_prefix<K>(&self, index: K) -> Result<u64, Error> |
3170 | 2 | where |
3171 | 2 | K: SchedulerIndexProvider + Send, |
3172 | 2 | { |
3173 | 2 | let index_value = index.index_value(); |
3174 | 2 | try_sanitize(index_value.as_ref()) |
3175 | 2 | .then_some(()) |
3176 | 2 | .err_tip(|| {0 |
3177 | 0 | format!("In RedisStore::count_by_index_prefix::try_sanitize - {index_value:?}") |
3178 | 0 | })?; |
3179 | 2 | let index_name = self.count_index_name::<K>(); |
3180 | 2 | let query = if index_value.is_empty() { |
3181 | 0 | "*".to_string() |
3182 | | } else { |
3183 | 2 | format!("@{}:{{ {} }}", K::INDEX_NAME, index_value) |
3184 | | }; |
3185 | | |
3186 | 2 | let (connection_manager, connect_id) = self.connection_manager.get_connection().await?0 ; |
3187 | | // Same failover handling as search_by_index_prefix: a demoted master |
3188 | | // answers READONLY and a dead one drops the connection, both meaning the |
3189 | | // master moved. Unlike that path this never creates the index; counting |
3190 | | // is a read-only observer and the matching loop owns index creation. |
3191 | 2 | match ft_search_count(connection_manager, index_name.clone(), query.clone()).await { |
3192 | 0 | Err(err) if is_retryable_redis_error(&err) => { |
3193 | 0 | let (connection_manager, _connect_id) = |
3194 | 0 | self.connection_manager.reconnect(connect_id).await?; |
3195 | 0 | ft_search_count(connection_manager, index_name.clone(), query) |
3196 | 0 | .await |
3197 | 0 | .map_err(Error::from) |
3198 | 0 | .err_tip(|| { |
3199 | 0 | format!( |
3200 | | "Error with reconnected ft_search_count in RedisStore::count_by_index_prefix({index_name})" |
3201 | | ) |
3202 | 0 | }) |
3203 | | } |
3204 | 2 | result => result.map_err(Error::from).err_tip(|| {0 |
3205 | 0 | format!( |
3206 | | "Error with ft_search_count in RedisStore::count_by_index_prefix({index_name})" |
3207 | | ) |
3208 | 0 | }), |
3209 | | } |
3210 | 2 | } |
3211 | | |
3212 | 640k | async fn get_and_decode<K>( |
3213 | 640k | &self, |
3214 | 640k | key: K, |
3215 | 640k | ) -> Result<Option<<K as SchedulerStoreDecodeTo>::DecodeOutput>, Error> |
3216 | 640k | where |
3217 | 640k | K: SchedulerStoreKeyProvider + SchedulerStoreDecodeTo + Send, |
3218 | 640k | { |
3219 | 640k | let key = key.get_key(); |
3220 | 640k | let key = self.encode_key(&key); |
3221 | 640k | let mut client627k = self.get_client().await?0 ; |
3222 | | // hmget is idempotent, so re-resolve the master and retry on a transient |
3223 | | // failover (matching the read paths in get_part/list) instead of failing |
3224 | | // a scheduler-state read while the master is moving. |
3225 | 609k | let results: Vec<Value> = { |
3226 | 627k | let mut attempt: u32 = 0; |
3227 | | loop { |
3228 | 627k | attempt += 1; |
3229 | 627k | match client |
3230 | 627k | .connection_manager |
3231 | 627k | .hmget::<_, Vec<String>, Vec<Value>>( |
3232 | 627k | key.as_ref(), |
3233 | 627k | vec![VERSION_FIELD_NAME.into(), DATA_FIELD_NAME.into()], |
3234 | | ) |
3235 | 627k | .await |
3236 | | { |
3237 | 609k | Ok(v) => break v, |
3238 | 212 | Err(err) |
3239 | 212 | if attempt < MAX_REDIS_RETRY_ATTEMPTS && is_retryable_redis_error(&err) => |
3240 | | { |
3241 | 212 | client.reconnect(&self.connection_manager).await?211 ; |
3242 | 1 | sleep(Duration::from_secs_f32(DEFAULT_RETRY_DELAY)).await; |
3243 | | } |
3244 | 0 | Err(err) => { |
3245 | 0 | return Err(Error::from(err).append(format!( |
3246 | 0 | "In RedisStore::get_without_version::notversioned {key}" |
3247 | 0 | ))); |
3248 | | } |
3249 | | } |
3250 | | } |
3251 | | }; |
3252 | 609k | let Some(Value::BulkString(data106k )) = results.get(1) else { |
3253 | 503k | return Ok(None); |
3254 | | }; |
3255 | | #[allow(clippy::get_first)] |
3256 | 106k | let version = if let Some(raw_v) = results.get(0) { |
3257 | 106k | match raw_v { |
3258 | 0 | Value::Int(v) => *v, |
3259 | 68.6k | Value::BulkString(v) => i64::from_str(str::from_utf8(v).expect("utf-8 bulkstring")) |
3260 | 68.6k | .expect("integer bulkstring"), |
3261 | 38.0k | Value::Nil => 0, |
3262 | | _ => { |
3263 | 0 | warn!(?raw_v, "Non-integer version!"); |
3264 | 0 | 0 |
3265 | | } |
3266 | | } |
3267 | | } else { |
3268 | 0 | 0 |
3269 | | }; |
3270 | | Ok(Some( |
3271 | 106k | K::decode(version, Bytes::from(data.clone())).err_tip(|| {0 |
3272 | 0 | format!("In RedisStore::get_with_version::notversioned::decode {key}") |
3273 | 0 | })?, |
3274 | | )) |
3275 | 610k | } |
3276 | | } |
3277 | | |
3278 | | #[cfg(test)] |
3279 | | // This module hand-rolls a minimal RESP3 server plus a dedicated |
3280 | | // current-thread runtime so the psubscribe/reconnect interleaving is exact and |
3281 | | // timing-independent; that legitimately needs the otherwise-disallowed tokio |
3282 | | // runtime/spawn primitives. |
3283 | | #[expect( |
3284 | | clippy::disallowed_methods, |
3285 | | reason = "deterministic in-test RESP server needs a hand-built runtime and raw spawns" |
3286 | | )] |
3287 | | #[expect( |
3288 | | clippy::doc_markdown, |
3289 | | reason = "choreography docs reference bare Redis/RESP identifiers (PSUBSCRIBE, Sha1) by name" |
3290 | | )] |
3291 | | #[expect( |
3292 | | trivial_casts, |
3293 | | reason = "explicit Pin<Box<dyn Future>> coercion for the connect closure's return type" |
3294 | | )] |
3295 | | mod psubscribe_reconnect_race_tests { |
3296 | | use core::time::Duration; |
3297 | | use std::collections::HashSet; |
3298 | | use std::sync::Arc; |
3299 | | |
3300 | | use redis::aio::{ConnectionManager, ConnectionManagerConfig}; |
3301 | | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
3302 | | use tokio::net::{TcpListener, TcpStream}; |
3303 | | use tokio::sync::Notify; |
3304 | | |
3305 | | use super::{RedisManager, StandardRedisManager}; |
3306 | | |
3307 | | /// Reads one CRLF-terminated line from the stream. |
3308 | 48 | async fn read_line(stream: &mut TcpStream) -> Option<String> { |
3309 | 48 | let mut line = Vec::new(); |
3310 | 48 | let mut byte = [0u8; 1]; |
3311 | | loop { |
3312 | 196 | if stream.read_exact(&mut byte).await.is_err194 () { |
3313 | 0 | return None; |
3314 | 194 | } |
3315 | 194 | if byte[0] == b'\n' { |
3316 | 46 | break; |
3317 | 148 | } |
3318 | 148 | if byte[0] != b'\r' { |
3319 | 102 | line.push(byte[0]); |
3320 | 102 | }46 |
3321 | | } |
3322 | 46 | Some(String::from_utf8_lossy(&line).into_owned()) |
3323 | 46 | } |
3324 | | |
3325 | | /// Parses one RESP command (array of bulk strings) from the client. |
3326 | 14 | async fn parse_command(stream: &mut TcpStream) -> Option<Vec<String>> { |
3327 | 14 | let header12 = read_line(stream).await?0 ; |
3328 | 12 | if !header.starts_with('*') { |
3329 | 0 | return Some(vec![header]); |
3330 | 12 | } |
3331 | 12 | let count: usize = header[1..].parse().ok()?0 ; |
3332 | 12 | let mut parts = Vec::with_capacity(count); |
3333 | 12 | for _ in 0..count { |
3334 | 34 | let len_line = read_line(stream).await?0 ; |
3335 | 34 | let len: usize = len_line.strip_prefix('$')?0 .parse().ok()?0 ; |
3336 | 34 | let mut data = vec![0u8; len + 2]; // include trailing CRLF |
3337 | 34 | stream.read_exact(&mut data).await.ok()?0 ; |
3338 | 34 | parts.push(String::from_utf8_lossy(&data[..len]).into_owned()); |
3339 | | } |
3340 | 12 | Some(parts) |
3341 | 12 | } |
3342 | | |
3343 | | struct FakeServerState { |
3344 | | /// Sha1 to return for SCRIPT LOAD (must match the client's own hash). |
3345 | | script_hash: String, |
3346 | | /// Patterns each connection (by accept order) was PSUBSCRIBEd to. |
3347 | | patterns_per_conn: parking_lot::Mutex<Vec<Vec<String>>>, |
3348 | | /// Signalled when conn #1 (the reconnect connection) receives its |
3349 | | /// first PSUBSCRIBE (the replay of the pre-existing pattern). |
3350 | | replay_psubscribe_arrived: Notify, |
3351 | | /// The server holds conn #1's first PSUBSCRIBE reply until this is |
3352 | | /// notified, keeping reconnect() parked inside its replay loop. |
3353 | | release_replay_reply: Notify, |
3354 | | } |
3355 | | |
3356 | 2 | async fn handle_conn(mut stream: TcpStream, conn_idx: usize, state: Arc<FakeServerState>) { |
3357 | 2 | let mut first_psubscribe_on_this_conn = true; |
3358 | 14 | while let Some(cmd12 ) = parse_command(&mut stream).await { |
3359 | 12 | let name = cmd |
3360 | 12 | .first() |
3361 | 12 | .map(|c| c.to_ascii_uppercase()) |
3362 | 12 | .unwrap_or_default(); |
3363 | 12 | match name.as_str() { |
3364 | 12 | "HELLO" => { |
3365 | 2 | stream |
3366 | 2 | .write_all( |
3367 | 2 | b"%3\r\n$6\r\nserver\r\n$5\r\nredis\r\n$7\r\nversion\r\n$5\r\n7.4.0\r\n$5\r\nproto\r\n:3\r\n", |
3368 | 2 | ) |
3369 | 2 | .await |
3370 | 2 | .unwrap(); |
3371 | | } |
3372 | 10 | "SCRIPT" => { |
3373 | 2 | let reply = |
3374 | 2 | format!("${}\r\n{}\r\n", state.script_hash.len(), state.script_hash); |
3375 | 2 | stream.write_all(reply.as_bytes()).await.unwrap(); |
3376 | | } |
3377 | 8 | "PSUBSCRIBE" => { |
3378 | 4 | let pattern = cmd.get(1).cloned().unwrap_or_default(); |
3379 | 4 | state.patterns_per_conn.lock()[conn_idx].push(pattern.clone()); |
3380 | 4 | if conn_idx == 1 && first_psubscribe_on_this_conn2 { |
3381 | 1 | first_psubscribe_on_this_conn = false; |
3382 | | // Tell the test that reconnect() is now parked awaiting |
3383 | | // this reply inside its replay loop, then hold the |
3384 | | // reply until the test releases it. |
3385 | 1 | state.replay_psubscribe_arrived.notify_one(); |
3386 | 1 | state.release_replay_reply.notified().await; |
3387 | 3 | } |
3388 | 4 | let reply = format!( |
3389 | | ">3\r\n$10\r\npsubscribe\r\n${}\r\n{}\r\n:1\r\n", |
3390 | 4 | pattern.len(), |
3391 | | pattern |
3392 | | ); |
3393 | 4 | stream.write_all(reply.as_bytes()).await.unwrap(); |
3394 | | } |
3395 | | _ => { |
3396 | 4 | stream.write_all(b"+OK\r\n").await.unwrap(); |
3397 | | } |
3398 | | } |
3399 | | } |
3400 | 0 | } |
3401 | | |
3402 | | /// Deterministic repro: a `psubscribe` that interleaves with a `reconnect` |
3403 | | /// whose subscription snapshot was taken before the insert lands only on |
3404 | | /// the old (about-to-be-replaced) connection. The new live connection is |
3405 | | /// never subscribed, yet the pattern is recorded as active in |
3406 | | /// `self.subscriptions`, so notifications are silently lost until an |
3407 | | /// unrelated future reconnect replays the set. |
3408 | | /// |
3409 | | /// Choreography (all real await points, no timing races): |
3410 | | /// 1. Manager connects (conn #0) and psubscribes "pre". |
3411 | | /// 2. Task B calls reconnect(): connects conn #1, snapshots |
3412 | | /// subscriptions = {"pre"}, and parks in the replay loop because the |
3413 | | /// fake server withholds conn #1's PSUBSCRIBE "pre" reply. |
3414 | | /// 3. The test (task A) calls psubscribe("pat"): get_connection still |
3415 | | /// returns conn #0 (reconnect has not published yet), the pattern is |
3416 | | /// inserted into `subscriptions`, and PSUBSCRIBE succeeds on conn #0. |
3417 | | /// 4. The server releases the reply; reconnect publishes conn #1. |
3418 | | /// 5. Conn #1 — now the live connection — was never subscribed to "pat". |
3419 | | #[test] |
3420 | 1 | fn psubscribe_lost_across_reconnect() { |
3421 | 1 | let runtime = tokio::runtime::Builder::new_current_thread() |
3422 | 1 | .enable_all() |
3423 | 1 | .build() |
3424 | 1 | .unwrap(); |
3425 | 1 | runtime.block_on(async { |
3426 | 1 | let state = Arc::new(FakeServerState { |
3427 | 1 | script_hash: redis::Script::new(super::LUA_VERSION_SET_SCRIPT) |
3428 | 1 | .get_hash() |
3429 | 1 | .to_string(), |
3430 | 1 | patterns_per_conn: parking_lot::Mutex::new(Vec::new()), |
3431 | 1 | replay_psubscribe_arrived: Notify::new(), |
3432 | 1 | release_replay_reply: Notify::new(), |
3433 | 1 | }); |
3434 | | |
3435 | 1 | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); |
3436 | 1 | let port = listener.local_addr().unwrap().port(); |
3437 | | { |
3438 | 1 | let state = state.clone(); |
3439 | 1 | tokio::spawn(async move { |
3440 | 1 | let mut conn_idx = 0usize; |
3441 | | loop { |
3442 | 3 | let (stream2 , _) = listener.accept().await.unwrap2 (); |
3443 | 2 | state.patterns_per_conn.lock().push(Vec::new()); |
3444 | 2 | tokio::spawn(handle_conn(stream, conn_idx, state.clone())); |
3445 | 2 | conn_idx += 1; |
3446 | | } |
3447 | | }); |
3448 | | } |
3449 | | |
3450 | | // Keep the push receiver alive for the whole test. |
3451 | 1 | let (push_tx, _push_rx) = tokio::sync::mpsc::unbounded_channel(); |
3452 | 1 | let url = format!("redis://127.0.0.1:{port}/?protocol=resp3"); |
3453 | 2 | let connect_func1 = Box::new1 (move || { |
3454 | 2 | let url = url.clone(); |
3455 | 2 | let push_tx = push_tx.clone(); |
3456 | 2 | Box::pin(async move { |
3457 | 2 | let client = redis::Client::open(url.as_str()).unwrap(); |
3458 | 2 | let config = ConnectionManagerConfig::new() |
3459 | 2 | .set_number_of_retries(0) |
3460 | 2 | .set_connection_timeout(Some(Duration::from_secs(5))) |
3461 | 2 | .set_response_timeout(Some(Duration::from_secs(5))) |
3462 | 2 | .set_push_sender(push_tx); |
3463 | 2 | ConnectionManager::new_with_config(client, config) |
3464 | 2 | .await |
3465 | 2 | .map_err(Into::into) |
3466 | 2 | }) as core::pin::Pin<Box<_>> |
3467 | 2 | }); |
3468 | | |
3469 | 1 | let manager = Arc::new(StandardRedisManager::new(connect_func).await.unwrap()); |
3470 | | |
3471 | | // Step 1: a pre-existing subscription so reconnect's replay loop |
3472 | | // has an await point we can park it on. |
3473 | 1 | manager.psubscribe("pre").await.unwrap(); |
3474 | 1 | let (_conn, uuid0) = manager.get_connection().await.unwrap(); |
3475 | | |
3476 | | // Step 2: start reconnect; it will connect conn #1, snapshot |
3477 | | // subscriptions (= {"pre"}), and park replaying "pre". |
3478 | 1 | let reconnect_task = { |
3479 | 1 | let manager = manager.clone(); |
3480 | 1 | tokio::spawn(async move { manager.reconnect(uuid0).await }) |
3481 | | }; |
3482 | 1 | tokio::time::timeout( |
3483 | 1 | Duration::from_secs(5), |
3484 | 1 | state.replay_psubscribe_arrived.notified(), |
3485 | 1 | ) |
3486 | 1 | .await |
3487 | 1 | .expect("reconnect never reached its subscription replay loop"); |
3488 | | |
3489 | | // Step 3: concurrent psubscribe while reconnect is parked |
3490 | | // post-snapshot, pre-publish. It succeeds — on the old connection. |
3491 | 1 | manager.psubscribe("pat").await.unwrap(); |
3492 | | |
3493 | | // Step 4: let reconnect finish and publish conn #1. |
3494 | 1 | state.release_replay_reply.notify_one(); |
3495 | 1 | let (_new_conn, new_uuid) = reconnect_task.await.unwrap().unwrap(); |
3496 | 1 | assert_ne!( |
3497 | | uuid0, new_uuid, |
3498 | | "reconnect must have published a new connection" |
3499 | | ); |
3500 | | |
3501 | | // The manager believes "pat" is an active subscription... |
3502 | 1 | let recorded: HashSet<String> = manager.subscriptions.lock().clone(); |
3503 | 1 | assert!( |
3504 | 1 | recorded.contains("pat"), |
3505 | | "sanity: pattern recorded as active" |
3506 | | ); |
3507 | | |
3508 | | // ...but the live connection (conn #1) was never subscribed to it. |
3509 | 1 | let conn1_patterns = state.patterns_per_conn.lock()[1].clone(); |
3510 | 1 | assert!( |
3511 | 1 | conn1_patterns.contains(&"pat".to_string()), |
3512 | | "RACE REPRODUCED: pattern \"pat\" is recorded as an active \ |
3513 | | subscription but the live post-reconnect connection was only \ |
3514 | | subscribed to {conn1_patterns:?}; keyspace notifications for \ |
3515 | | \"pat\" are silently lost until the next reconnect", |
3516 | | ); |
3517 | 1 | }); |
3518 | 1 | } |
3519 | | } |