/build/source/nativelink-store/src/redis_utils/ft_search_count.rs
Line | Count | Source |
1 | | // Copyright 2024-2025 The NativeLink Authors. 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 | | use tracing::error; |
18 | | |
19 | | /// Per-query `FT.SEARCH` timeout in milliseconds. Matches the `FT.AGGREGATE` |
20 | | /// timeout: the index is the same size, so the scan can take just as long. |
21 | | const FT_SEARCH_TIMEOUT_MS: u64 = 10_000; |
22 | | |
23 | | /// Counts the documents matching a query without fetching any of them. |
24 | | /// |
25 | | /// `LIMIT 0 0` asks `RediSearch` for the total only, so the reply is a single |
26 | | /// integer however many documents match. This is the cheap counterpart to |
27 | | /// [`ft_aggregate`](super::ft_aggregate::ft_aggregate), which has to `LOAD` a |
28 | | /// field from every matching document and page through them with a cursor. |
29 | 2 | pub(crate) async fn ft_search_count<C>( |
30 | 2 | mut connection_manager: C, |
31 | 2 | index: String, |
32 | 2 | query: String, |
33 | 2 | ) -> Result<u64, RedisError> |
34 | 2 | where |
35 | 2 | C: ConnectionLike + Send, |
36 | 2 | { |
37 | 2 | let res = redis::cmd("FT.SEARCH") |
38 | 2 | .arg(&index) |
39 | 2 | .arg(&query) |
40 | 2 | .arg("LIMIT") |
41 | 2 | .arg(0) |
42 | 2 | .arg(0) |
43 | 2 | .arg("TIMEOUT") |
44 | 2 | .arg(FT_SEARCH_TIMEOUT_MS) |
45 | 2 | .query_async::<Value>(&mut connection_manager) |
46 | 2 | .await; |
47 | 2 | let value = match res { |
48 | 2 | Ok(value) => value, |
49 | 0 | Err(e) => { |
50 | 0 | error!(?e, index, ?query, "Error calling ft.search"); |
51 | 0 | return Err(e); |
52 | | } |
53 | | }; |
54 | 2 | parse_total(&value) |
55 | 2 | } |
56 | | |
57 | | /// Reads the match total out of an `FT.SEARCH` reply. |
58 | | /// |
59 | | /// RESP2 answers with an array whose first element is the total; RESP3 answers |
60 | | /// with a map holding `total_results`. |
61 | 6 | fn parse_total(value: &Value) -> Result<u64, RedisError> { |
62 | 6 | let total5 = match value { |
63 | 3 | Value::Array(items) => match items.first() { |
64 | 3 | Some(Value::Int(total)) => *total, |
65 | 0 | other => { |
66 | 0 | error!(?other, "Non-int for first value in ft.search"); |
67 | 0 | return Err(RedisError::from(( |
68 | 0 | ErrorKind::Parse, |
69 | 0 | "Non int for search total", |
70 | 0 | format!("{other:?}"), |
71 | 0 | ))); |
72 | | } |
73 | | }, |
74 | 3 | Value::Map(entries) => { |
75 | 5 | let total3 = entries.iter()3 .find_map3 (|(key, value)| match (key, value) { |
76 | 2 | (Value::SimpleString(key), Value::Int(total)) if key == "total_results" => { |
77 | 2 | Some(*total) |
78 | | } |
79 | 3 | _ => None, |
80 | 5 | }); |
81 | 3 | let Some(total2 ) = total else { |
82 | 1 | error!(?entries, "No total_results in ft.search reply"); |
83 | 1 | return Err(RedisError::from(( |
84 | 1 | ErrorKind::Parse, |
85 | 1 | "No total_results in ft.search reply", |
86 | 1 | format!("{entries:?}"), |
87 | 1 | ))); |
88 | | }; |
89 | 2 | total |
90 | | } |
91 | 0 | other => { |
92 | 0 | error!(?other, "Unexpected top-level value in ft.search reply"); |
93 | 0 | return Err(RedisError::from(( |
94 | 0 | ErrorKind::Parse, |
95 | 0 | "Expected array or map", |
96 | 0 | format!("{other:?}"), |
97 | 0 | ))); |
98 | | } |
99 | | }; |
100 | 5 | u64::try_from(total).map_err(|_| {1 |
101 | 1 | RedisError::from(( |
102 | 1 | ErrorKind::Parse, |
103 | 1 | "Negative total in ft.search reply", |
104 | 1 | format!("{total}"), |
105 | 1 | )) |
106 | 1 | }) |
107 | 6 | } |
108 | | |
109 | | #[cfg(test)] |
110 | | mod tests { |
111 | | use redis::Value; |
112 | | |
113 | | use super::parse_total; |
114 | | |
115 | | #[test] |
116 | 1 | fn parses_resp2_total() { |
117 | 1 | let value = Value::Array(vec![Value::Int(7)]); |
118 | 1 | assert_eq!(parse_total(&value).unwrap(), 7); |
119 | 1 | } |
120 | | |
121 | | #[test] |
122 | 1 | fn parses_resp3_total() { |
123 | 1 | let value = Value::Map(vec![ |
124 | 1 | ( |
125 | 1 | Value::SimpleString("attributes".to_string()), |
126 | 1 | Value::Array(vec![]), |
127 | 1 | ), |
128 | 1 | ( |
129 | 1 | Value::SimpleString("total_results".to_string()), |
130 | 1 | Value::Int(3), |
131 | 1 | ), |
132 | 1 | ]); |
133 | 1 | assert_eq!(parse_total(&value).unwrap(), 3); |
134 | 1 | } |
135 | | |
136 | | #[test] |
137 | 1 | fn rejects_missing_total() { |
138 | 1 | let value = Value::Map(vec![( |
139 | 1 | Value::SimpleString("attributes".to_string()), |
140 | 1 | Value::Array(vec![]), |
141 | 1 | )]); |
142 | 1 | assert!(parse_total(&value).is_err()); |
143 | 1 | } |
144 | | |
145 | | #[test] |
146 | 1 | fn rejects_negative_total() { |
147 | 1 | let value = Value::Array(vec![Value::Int(-1)]); |
148 | 1 | assert!(parse_total(&value).is_err()); |
149 | 1 | } |
150 | | } |