/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 | | } |