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/ft_info.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
use redis::aio::ConnectionLike;
16
use redis::{ErrorKind, RedisError, Value};
17
18
/// Whether `RediSearch` is still backfilling `index`: `FT.INFO`'s
19
/// `indexing` field, `1` while the background scan that follows `FT.CREATE`
20
/// is running and `0` once every existing key is in the index. An aggregate
21
/// issued before that reads a partial index, which for the scheduler's queue
22
/// is a queue with actions missing from it.
23
1
pub(crate) async fn ft_info_indexing<C>(
24
1
    connection_manager: C,
25
1
    index: &str,
26
1
) -> Result<bool, RedisError>
27
1
where
28
1
    C: ConnectionLike + Send,
29
1
{
30
1
    Ok(ft_info(connection_manager, index).await
?0
.indexing)
31
1
}
32
33
/// The parts of `FT.INFO` that say whether an index holds every key.
34
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
pub(crate) struct FtInfo {
36
    /// The background scan that follows `FT.CREATE` is still running.
37
    pub(crate) indexing: bool,
38
    /// Keys the index holds.
39
    pub(crate) num_docs: u64,
40
    /// Keys the index failed to take, for instance on running out of
41
    /// memory. A scan can stop with `indexing 0` and keys missing; this and
42
    /// `num_docs` are how that shows. `None` when the server does not report
43
    /// it, which is not the same as zero; `num_docs` is judged the same
44
    /// strict way either way, so the field only ever adds a reason to
45
    /// distrust an index, never one to trust it.
46
    pub(crate) hash_indexing_failures: Option<u64>,
47
}
48
49
1
pub(crate) async fn ft_info<C>(mut connection_manager: C, index: &str) -> Result<FtInfo, RedisError>
50
1
where
51
1
    C: ConnectionLike + Send,
52
1
{
53
1
    let value = redis::cmd("FT.INFO")
54
1
        .arg(index)
55
1
        .query_async::<Value>(&mut connection_manager)
56
1
        .await
?0
;
57
1
    parse_ft_info(&value)
58
1
}
59
60
/// Both indexes' `FT.INFO` in one `MULTI`/`EXEC`, so the two describe the
61
/// same instant: a key written or deleted while checking moves both counts
62
/// or neither. Two separate calls would let a delete land between them and
63
/// leave the second index legitimately short of the first, which could only
64
/// be absorbed by a count tolerance — and a tolerance cannot be told apart
65
/// from an index that quietly skipped that many keys.
66
0
pub(crate) async fn ft_info_pair<C>(
67
0
    mut connection_manager: C,
68
0
    first: &str,
69
0
    second: &str,
70
0
) -> Result<(FtInfo, FtInfo), RedisError>
71
0
where
72
0
    C: ConnectionLike + Send,
73
0
{
74
0
    let mut pipe = redis::pipe();
75
0
    pipe.atomic()
76
0
        .cmd("FT.INFO")
77
0
        .arg(first)
78
0
        .cmd("FT.INFO")
79
0
        .arg(second);
80
0
    let (first, second): (Value, Value) = pipe.query_async(&mut connection_manager).await?;
81
0
    Ok((parse_ft_info(&first)?, parse_ft_info(&second)?))
82
0
}
83
84
/// `FT.INFO` answers a flat key-value array on RESP2 and a map on RESP3.
85
4
fn parse_ft_info(value: &Value) -> Result<FtInfo, RedisError> {
86
4
    let 
indexing3
= number(value, "indexing").ok_or_else(||
{1
87
1
        RedisError::from((ErrorKind::Parse, "FT.INFO reply carries no indexing field"))
88
1
    })?;
89
3
    let num_docs = number(value, "num_docs").ok_or_else(|| 
{0
90
0
        RedisError::from((ErrorKind::Parse, "FT.INFO reply carries no num_docs field"))
91
0
    })?;
92
3
    Ok(FtInfo {
93
3
        indexing: indexing != 0,
94
3
        num_docs,
95
3
        hash_indexing_failures: number(value, "hash_indexing_failures"),
96
3
    })
97
4
}
98
99
/// A top-level numeric field of an `FT.INFO` reply, which `RediSearch` sends
100
/// as an integer, a string or a double depending on the field and protocol.
101
10
fn number(value: &Value, wanted: &str) -> Option<u64> {
102
20
    let 
name10
= |key: &Value| match key {
103
15
        Value::BulkString(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
104
5
        Value::SimpleString(text) => Some(text.clone()),
105
0
        _ => None,
106
20
    };
107
10
    let parse = |value: &Value| match 
value7
{
108
5
        Value::Int(n) => u64::try_from(*n).ok(),
109
1
        Value::BulkString(bytes) => parse_text(&String::from_utf8_lossy(bytes)),
110
0
        Value::SimpleString(text) => parse_text(text),
111
1
        Value::Double(n) => parse_text(&n.to_string()),
112
0
        _ => None,
113
7
    };
114
10
    match value {
115
15
        Value::Array(
items7
) =>
items.chunks(2)7
.
find_map7
(|pair| match pair {
116
15
            [key, value] if name(key).as_deref() == Some(wanted
)5
=>
parse(value)5
,
117
10
            _ => None,
118
15
        }),
119
5
        Value::Map(
entries3
) =>
entries.iter()3
.
find_map3
(|(key, value)| {
120
5
            (name(key).as_deref() == Some(wanted))
121
5
                .then(|| 
parse2
(
value2
))
122
5
                .flatten()
123
5
        }),
124
0
        _ => None,
125
    }
126
10
}
127
128
2
fn parse_text(text: &str) -> Option<u64> {
129
    // Counts only. A count rendered as a float ("12" or "12.0") is a whole
130
    // number; anything with a real fraction is not a count.
131
2
    match text.split_once('.') {
132
2
        None => text.parse().ok(),
133
0
        Some((whole, fraction)) if fraction.bytes().all(|b| b == b'0') => whole.parse().ok(),
134
0
        Some(_) => None,
135
    }
136
2
}
137
138
#[cfg(test)]
139
mod tests {
140
    use super::*;
141
142
8
    fn bulk(text: &str) -> Value {
143
8
        Value::BulkString(text.as_bytes().to_vec())
144
8
    }
145
146
    #[test]
147
1
    fn reads_the_fields_in_both_protocols() {
148
1
        let resp2 = Value::Array(vec![
149
1
            bulk("index_name"),
150
1
            bulk("i"),
151
1
            bulk("num_docs"),
152
1
            bulk("26150"),
153
1
            bulk("hash_indexing_failures"),
154
1
            Value::Int(3),
155
1
            bulk("indexing"),
156
1
            Value::Int(0),
157
1
        ]);
158
1
        assert_eq!(
159
1
            parse_ft_info(&resp2).unwrap(),
160
            FtInfo {
161
                indexing: false,
162
                num_docs: 26_150,
163
                hash_indexing_failures: Some(3)
164
            }
165
        );
166
1
        let resp3 = Value::Map(vec![
167
1
            (Value::SimpleString("num_docs".into()), Value::Double(12.0)),
168
1
            (Value::SimpleString("indexing".into()), Value::Int(1)),
169
1
        ]);
170
1
        assert_eq!(
171
1
            parse_ft_info(&resp3).unwrap(),
172
            FtInfo {
173
                indexing: true,
174
                num_docs: 12,
175
                // Absent is reported as absent, never as zero.
176
                hash_indexing_failures: None
177
            }
178
        );
179
1
        assert!(parse_ft_info(&Value::Array(vec![bulk("num_docs"), bulk("1")])).is_err());
180
1
    }
181
}