Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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 < origina
l3
=>
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
}