/build/source/nativelink-store/src/redis_utils/index_migration.rs
Line | Count | Source |
1 | | // Copyright 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 | | //! Retiring the scheduler indexes an older release created `TEMPORARY`. |
16 | | //! |
17 | | //! On `RediSearch` 2.10 a temporary index is never garbage collected, so every |
18 | | //! rewrite of an indexed hash leaves a dead entry that every later query |
19 | | //! walks. Getting rid of one is the dangerous part: for a temporary index |
20 | | //! both a plain `FT.DROPINDEX` (no `DD`) and the index's own idle expiry |
21 | | //! delete every hash it covers (`DropIndexCommand` deletes when the index is |
22 | | //! temporary; the expiry timer runs `FT.DROPINDEX <name> DD`). Only |
23 | | //! `FT.DROPINDEX <name> _FORCEKEEPDOCS` removes the index alone. |
24 | | //! |
25 | | //! So the old index is never dropped first and never left to expire: |
26 | | //! |
27 | | //! 1. a persistent index is created beside it under [`migrated_name`]; |
28 | | //! 2. reads stay on the old one while the new one backfills, which also |
29 | | //! keeps the old one's idle timer from firing; |
30 | | //! 3. once the new one has every key, reads move to it. "Every key" is |
31 | | //! checked, not assumed from the scan having ended: a scan that runs out |
32 | | //! of memory stops with keys missing, and dropping the old index then |
33 | | //! would leave reads on an index without them; |
34 | | //! 4. the old one is dropped with `_FORCEKEEPDOCS`, by whichever process |
35 | | //! gets there first. |
36 | | //! |
37 | | //! Every step is idempotent: a create that loses a race finds the index |
38 | | //! there, a drop that loses one finds it gone. A process that stops |
39 | | //! anywhere leaves a state the next read carries on from. |
40 | | |
41 | | use redis::aio::ConnectionLike; |
42 | | use redis::{RedisError, Value}; |
43 | | |
44 | | use super::FtInfo; |
45 | | |
46 | | /// The suffix of the persistent index that replaces a temporary one. |
47 | | const MIGRATED_SUFFIX: &str = "_p"; |
48 | | |
49 | | /// The name of the persistent index that replaces `index`. |
50 | 2 | pub(crate) fn migrated_name(index: &str) -> String { |
51 | 2 | format!("{index}{MIGRATED_SUFFIX}") |
52 | 2 | } |
53 | | |
54 | | /// What `FT.DEBUG TTL` says about an index. |
55 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
56 | | pub(crate) enum IndexKind { |
57 | | /// Temporary; the remaining idle time in seconds. |
58 | | Temporary(i64), |
59 | | Persistent, |
60 | | Missing, |
61 | | } |
62 | | |
63 | | /// `FT.DEBUG TTL` answers the remaining time for a temporary index and an |
64 | | /// error otherwise. It reads the timer without resetting it, so asking does |
65 | | /// not keep an index alive. A server that refuses `FT.DEBUG` is an error: |
66 | | /// nothing can then be known, and nothing is changed. |
67 | 0 | pub(crate) async fn index_kind<C>(connection: &mut C, index: &str) -> Result<IndexKind, RedisError> |
68 | 0 | where |
69 | 0 | C: ConnectionLike + Send, |
70 | 0 | { |
71 | 0 | classify( |
72 | 0 | redis::cmd("FT.DEBUG") |
73 | 0 | .arg("TTL") |
74 | 0 | .arg(index) |
75 | 0 | .query_async::<Value>(connection) |
76 | 0 | .await, |
77 | | ) |
78 | 0 | } |
79 | | |
80 | 2 | pub(crate) fn classify(reply: Result<Value, RedisError>) -> Result<IndexKind, RedisError> { |
81 | 2 | match reply { |
82 | 1 | Ok(Value::Int(ttl)) => Ok(IndexKind::Temporary(ttl)), |
83 | 1 | Ok(other) => Err(RedisError::from(( |
84 | 1 | redis::ErrorKind::UnexpectedReturnType, |
85 | 1 | "FT.DEBUG TTL reply", |
86 | 1 | format!("{other:?}"), |
87 | 1 | ))), |
88 | 0 | Err(err) => match classify_error(&error_text(&err)) { |
89 | 0 | Some(kind) => Ok(kind), |
90 | 0 | None => Err(err), |
91 | | }, |
92 | | } |
93 | 2 | } |
94 | | |
95 | | /// An error's text as the server sent it. `RediSearch` errors arrive split |
96 | | /// into a code and a detail (`Unknown` / `index name`), so both are joined |
97 | | /// rather than relying on how the error displays. |
98 | 4 | fn error_text(err: &RedisError) -> String { |
99 | 4 | let mut text = err.to_string(); |
100 | 4 | for part in [err.code(), err.detail()].into_iter().flatten() { |
101 | 4 | text.push(' '); |
102 | 4 | text.push_str(part); |
103 | 4 | } |
104 | 4 | text.to_ascii_lowercase() |
105 | 4 | } |
106 | | |
107 | | /// Redis refused a write because it is at `maxmemory` under `noeviction`. |
108 | | /// Building the replacement is a write, so a full Redis cannot start the |
109 | | /// migration; dropping an index is still allowed there. |
110 | 0 | pub(crate) fn is_out_of_memory(err: &RedisError) -> bool { |
111 | 0 | error_text(err).contains("oom command not allowed") |
112 | 0 | } |
113 | | |
114 | 3 | fn classify_error(text: &str) -> Option<IndexKind> { |
115 | 3 | if text.contains("not temporary") { |
116 | 1 | Some(IndexKind::Persistent) |
117 | 2 | } else if text.contains("unknown index") { |
118 | 1 | Some(IndexKind::Missing) |
119 | | } else { |
120 | 1 | None |
121 | | } |
122 | 3 | } |
123 | | |
124 | | /// Removes `index` and leaves every hash it covered in place, temporary or |
125 | | /// not. Gone already is success. |
126 | 0 | pub(crate) async fn drop_keeping_documents<C>( |
127 | 0 | connection: &mut C, |
128 | 0 | index: &str, |
129 | 0 | ) -> Result<(), RedisError> |
130 | 0 | where |
131 | 0 | C: ConnectionLike + Send, |
132 | 0 | { |
133 | 0 | match redis::cmd("FT.DROPINDEX") |
134 | 0 | .arg(index) |
135 | 0 | .arg("_FORCEKEEPDOCS") |
136 | 0 | .query_async::<Value>(connection) |
137 | 0 | .await |
138 | | { |
139 | 0 | Ok(_) => Ok(()), |
140 | 0 | Err(err) if classify_error(&error_text(&err)) == Some(IndexKind::Missing) => Ok(()), |
141 | 0 | Err(err) => Err(err), |
142 | | } |
143 | 0 | } |
144 | | |
145 | | /// Where the persistent replacement stands. |
146 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
147 | | pub(crate) enum Replacement { |
148 | | /// The background scan is still running. |
149 | | Backfilling, |
150 | | /// The scan finished and the index holds as many keys as the original. |
151 | | Complete, |
152 | | /// The scan finished without every key: some failed to index, or it |
153 | | /// holds fewer keys than the original. |
154 | | Incomplete, |
155 | | } |
156 | | |
157 | | /// Judges a replacement that `FT.INFO` describes as `info` against the |
158 | | /// original's key count (`None` when there is no original to compare with). |
159 | | /// |
160 | | /// The comparison is strict: both indexes cover the same key prefix and both |
161 | | /// take live writes, so at any one instant a complete replacement holds at |
162 | | /// least every key the original does. The caller reads the two counts in one |
163 | | /// `MULTI`/`EXEC` ([`super::ft_info_pair`]) so they are from the same |
164 | | /// instant, which is what makes strictness correct — no tolerance for racing |
165 | | /// deletes, and therefore no tolerance a short scan could hide inside. Any |
166 | | /// shortfall, and any reported indexing failure, keeps the original. |
167 | 8 | pub(crate) const fn replacement_state(info: FtInfo, original_docs: Option<u64>) -> Replacement { |
168 | 8 | if info.indexing { |
169 | 1 | return Replacement::Backfilling; |
170 | 7 | } |
171 | 5 | if matches!1 (info.hash_indexing_failures, Some(failed1 ) if failed != 01 ) { |
172 | 1 | return Replacement::Incomplete; |
173 | 6 | } |
174 | 6 | match original_docs { |
175 | 6 | Some(original3 ) if info.num_docs < original3 => Replacement::Incomplete3 , |
176 | 3 | _ => Replacement::Complete, |
177 | | } |
178 | 8 | } |
179 | | |
180 | | /// The server ended an open cursor because the index under it went away — |
181 | | /// what a reader still paging the original sees when another process |
182 | | /// finishes the migration and retires it (`FT.DROPINDEX` closes the index's |
183 | | /// cursors even with `_FORCEKEEPDOCS`), and what a long-idle cursor sees |
184 | | /// after the server reaps it. Both mean the rows read so far are fine and |
185 | | /// the read can start over; nothing else about the error is known. |
186 | 4 | pub(crate) fn is_cursor_lost(err: &RedisError) -> bool { |
187 | 4 | let text = error_text(err); |
188 | 4 | text.contains("dropped") || text.contains("unknown index")3 || text.contains("cursor")2 |
189 | 4 | } |
190 | | |
191 | | /// What a read should do, from the state of the original index and of its |
192 | | /// persistent replacement (`None` when the replacement does not exist). |
193 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
194 | | pub(crate) enum Step { |
195 | | /// Use the original, which is persistent: nothing to migrate. |
196 | | UseOriginal, |
197 | | /// Create the replacement, then read the original meanwhile. |
198 | | CreateReplacement, |
199 | | /// The replacement is backfilling; read the original meanwhile. |
200 | | WaitOnOriginal, |
201 | | /// The original is gone and the replacement is still backfilling: wait |
202 | | /// for it to finish rather than read a partial index. |
203 | | WaitForReplacement, |
204 | | /// The replacement is complete: read it, retiring the original first |
205 | | /// when it is still there. |
206 | | UseReplacement { retire_original: bool }, |
207 | | /// The replacement finished without every key: keep reading the |
208 | | /// original, which still has them, and leave both in place. Dropping |
209 | | /// the replacement (`_FORCEKEEPDOCS`) makes the next read build it again. |
210 | | KeepOriginal, |
211 | | } |
212 | | |
213 | 12 | pub(crate) const fn plan(original: IndexKind, replacement: Option<Replacement>) -> Step { |
214 | 12 | match (original, replacement) { |
215 | | // Nothing to migrate. A missing original is created persistent by |
216 | | // the ordinary path, under its ordinary name. |
217 | 2 | (IndexKind::Persistent | IndexKind::Missing, None) => Step::UseOriginal, |
218 | 1 | (IndexKind::Temporary(_), None) => Step::CreateReplacement, |
219 | | (IndexKind::Temporary(_) | IndexKind::Persistent, Some(Replacement::Backfilling)) => { |
220 | 2 | Step::WaitOnOriginal |
221 | | } |
222 | | (IndexKind::Temporary(_) | IndexKind::Persistent, Some(Replacement::Incomplete)) => { |
223 | 2 | Step::KeepOriginal |
224 | | } |
225 | 1 | (IndexKind::Missing, Some(Replacement::Backfilling)) => Step::WaitForReplacement, |
226 | | // With no original, the replacement is the only index there is, |
227 | | // whole or not. |
228 | | (IndexKind::Missing, Some(Replacement::Complete | Replacement::Incomplete)) => { |
229 | 2 | Step::UseReplacement { |
230 | 2 | retire_original: false, |
231 | 2 | } |
232 | | } |
233 | | (IndexKind::Temporary(_) | IndexKind::Persistent, Some(Replacement::Complete)) => { |
234 | 2 | Step::UseReplacement { |
235 | 2 | retire_original: true, |
236 | 2 | } |
237 | | } |
238 | | } |
239 | 12 | } |
240 | | |
241 | | #[cfg(test)] |
242 | | mod tests { |
243 | | use super::*; |
244 | | |
245 | | #[test] |
246 | 1 | fn ft_debug_ttl_replies_classify() { |
247 | | // The three replies redis-stack-server 7.4.0-v0 (RediSearch 2.10.5) |
248 | | // gives; the real-Redis test drives them end to end. |
249 | 1 | assert_eq!( |
250 | 1 | classify(Ok(Value::Int(86_399))).unwrap(), |
251 | | IndexKind::Temporary(86_399) |
252 | | ); |
253 | 1 | assert_eq!( |
254 | 1 | classify_error("index is not temporary"), |
255 | | Some(IndexKind::Persistent) |
256 | | ); |
257 | 1 | assert_eq!( |
258 | 1 | classify_error("unknown index name"), |
259 | | Some(IndexKind::Missing) |
260 | | ); |
261 | | // Anything else is not knowledge, and nothing acts on it. |
262 | 1 | assert_eq!(classify_error("err unknown command 'ft.debug'"), None); |
263 | 1 | assert!(classify(Ok(Value::Okay)).is_err()); |
264 | 1 | } |
265 | | |
266 | | #[test] |
267 | 1 | fn a_cursor_ended_under_a_reader_is_recognized() { |
268 | | // The message redis-stack-server 7.4.0-v0 sends when FT.DROPINDEX |
269 | | // closes an index's open cursors, and the two ways a cursor read |
270 | | // fails after the server already forgot index or cursor. |
271 | 3 | for text in [ |
272 | 1 | "The index was dropped while the cursor was idle", |
273 | 1 | "Unknown index name", |
274 | 1 | "Cursor not found", |
275 | 1 | ] { |
276 | | // The kind is irrelevant: the classifier reads only the text. |
277 | 3 | let err = |
278 | 3 | RedisError::from((redis::ErrorKind::UnexpectedReturnType, "", text.to_string())); |
279 | 3 | assert!(is_cursor_lost(&err), "{text}"); |
280 | | } |
281 | 1 | let other = RedisError::from(( |
282 | 1 | redis::ErrorKind::UnexpectedReturnType, |
283 | 1 | "", |
284 | 1 | "OOM command not allowed when used memory > 'maxmemory'".to_string(), |
285 | 1 | )); |
286 | 1 | assert!(!is_cursor_lost(&other)); |
287 | 1 | } |
288 | | |
289 | | #[test] |
290 | 1 | fn a_persistent_or_missing_index_is_left_to_the_ordinary_path() { |
291 | 1 | assert_eq!(plan(IndexKind::Persistent, None), Step::UseOriginal); |
292 | 1 | assert_eq!(plan(IndexKind::Missing, None), Step::UseOriginal); |
293 | 1 | } |
294 | | |
295 | | #[test] |
296 | 1 | fn a_temporary_index_is_read_until_its_replacement_is_complete() { |
297 | 1 | let temporary = IndexKind::Temporary(86_399); |
298 | 1 | assert_eq!(plan(temporary, None), Step::CreateReplacement); |
299 | 1 | assert_eq!( |
300 | 1 | plan(temporary, Some(Replacement::Backfilling)), |
301 | | Step::WaitOnOriginal |
302 | | ); |
303 | 1 | assert_eq!( |
304 | 1 | plan(temporary, Some(Replacement::Complete)), |
305 | | Step::UseReplacement { |
306 | | retire_original: true |
307 | | } |
308 | | ); |
309 | 1 | } |
310 | | |
311 | | #[test] |
312 | 1 | fn an_original_another_release_recreated_is_retired_again() { |
313 | | // A release without the migration recreates the original under its |
314 | | // old name; it is persistent then, and dropping it keeps the hashes. |
315 | 1 | assert_eq!( |
316 | 1 | plan(IndexKind::Persistent, Some(Replacement::Complete)), |
317 | | Step::UseReplacement { |
318 | | retire_original: true |
319 | | } |
320 | | ); |
321 | 1 | assert_eq!( |
322 | 1 | plan(IndexKind::Persistent, Some(Replacement::Backfilling)), |
323 | | Step::WaitOnOriginal |
324 | | ); |
325 | 1 | } |
326 | | |
327 | | #[test] |
328 | 1 | fn without_an_original_the_replacement_is_waited_for() { |
329 | 1 | assert_eq!( |
330 | 1 | plan(IndexKind::Missing, Some(Replacement::Backfilling)), |
331 | | Step::WaitForReplacement |
332 | | ); |
333 | 1 | assert_eq!( |
334 | 1 | plan(IndexKind::Missing, Some(Replacement::Complete)), |
335 | | Step::UseReplacement { |
336 | | retire_original: false |
337 | | } |
338 | | ); |
339 | 1 | } |
340 | | |
341 | | #[test] |
342 | 1 | fn an_incomplete_replacement_never_retires_the_original() { |
343 | 1 | let complete = FtInfo { |
344 | 1 | indexing: false, |
345 | 1 | num_docs: 26_150, |
346 | 1 | hash_indexing_failures: Some(0), |
347 | 1 | }; |
348 | 1 | assert_eq!( |
349 | 1 | replacement_state(complete, Some(26_150)), |
350 | | Replacement::Complete |
351 | | ); |
352 | | // Holding more than the original (keys written since it was judged |
353 | | // last) is complete too. |
354 | 1 | assert_eq!( |
355 | 1 | replacement_state(complete, Some(26_100)), |
356 | | Replacement::Complete |
357 | | ); |
358 | | // The counts come from one MULTI/EXEC, so ANY shortfall is keys the |
359 | | // scan skipped, not a racing delete: one short of 26_150 (well |
360 | | // inside the 1%-plus-two tolerance an earlier version allowed, and |
361 | | // `indexing 0` with zero reported failures) must keep the original. |
362 | 1 | let one_short = FtInfo { |
363 | 1 | num_docs: 26_149, |
364 | 1 | ..complete |
365 | 1 | }; |
366 | 1 | assert_eq!( |
367 | 1 | replacement_state(one_short, Some(26_150)), |
368 | | Replacement::Incomplete |
369 | | ); |
370 | | // The same at a size where the old tolerance was dominated by its |
371 | | // flat "+2": two of four. |
372 | 1 | let two_of_four = FtInfo { |
373 | 1 | num_docs: 2, |
374 | 1 | ..complete |
375 | 1 | }; |
376 | 1 | assert_eq!( |
377 | 1 | replacement_state(two_of_four, Some(4)), |
378 | | Replacement::Incomplete |
379 | | ); |
380 | 1 | let failed = FtInfo { |
381 | 1 | hash_indexing_failures: Some(1), |
382 | 1 | ..complete |
383 | 1 | }; |
384 | 1 | assert_eq!( |
385 | 1 | replacement_state(failed, Some(26_150)), |
386 | | Replacement::Incomplete |
387 | | ); |
388 | | // A server that does not report hash_indexing_failures is judged by |
389 | | // the strict count alone, the same as one that reports zero. |
390 | 1 | let unreported = FtInfo { |
391 | 1 | hash_indexing_failures: None, |
392 | 1 | ..complete |
393 | 1 | }; |
394 | 1 | assert_eq!( |
395 | 1 | replacement_state(unreported, Some(26_150)), |
396 | | Replacement::Complete |
397 | | ); |
398 | 1 | assert_eq!( |
399 | 1 | replacement_state(unreported, Some(26_151)), |
400 | | Replacement::Incomplete |
401 | | ); |
402 | 1 | let scanning = FtInfo { |
403 | 1 | indexing: true, |
404 | 1 | num_docs: 0, |
405 | 1 | ..complete |
406 | 1 | }; |
407 | 1 | assert_eq!( |
408 | 1 | replacement_state(scanning, Some(26_150)), |
409 | | Replacement::Backfilling |
410 | | ); |
411 | | |
412 | 2 | for original in [IndexKind::Temporary(86_399), IndexKind::Persistent]1 { |
413 | 2 | assert_eq!( |
414 | 2 | plan(original, Some(Replacement::Incomplete)), |
415 | | Step::KeepOriginal |
416 | | ); |
417 | | } |
418 | 1 | assert_eq!( |
419 | 1 | plan(IndexKind::Missing, Some(Replacement::Incomplete)), |
420 | | Step::UseReplacement { |
421 | | retire_original: false |
422 | | } |
423 | | ); |
424 | 1 | } |
425 | | |
426 | | #[test] |
427 | 1 | fn the_replacement_sorts_after_the_original() { |
428 | | // Readers that pick the lexicographically last index with a prefix |
429 | | // (the worker provisioner does) move to the replacement on their own. |
430 | 1 | let original = "aa__state_sort_key_3e762c15"; |
431 | 1 | assert!(migrated_name(original).as_str() > original); |
432 | 1 | assert!(migrated_name(original).starts_with(original)); |
433 | 1 | } |
434 | | } |