Coverage Report

Created: 2026-09-18 20:40

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