Skip to main content

rustlavel_search/
response.rs

1//! Reading a search response.
2//!
3//! Two things in this shape are routinely misread, and both lose information
4//! silently rather than loudly:
5//!
6//! - **The total is a relation, not a number.** Since Elasticsearch 7 the
7//!   counting stops at 10,000 by default and the response says "at least
8//!   10,000". A caller that reads `value` alone will print "10,000 results"
9//!   above a list of millions. [`TotalHits`] refuses to be an integer for that
10//!   reason.
11//! - **A search can succeed on some shards and fail on others.** The status is
12//!   200, the hits are there, and a slice of the index was never consulted.
13//!   [`SearchResults::is_partial`] is the only thing that says so.
14
15use crate::error::{Result, SearchError};
16use rustlavel_core::Json;
17use std::collections::BTreeMap;
18
19/// Everything a search came back with.
20#[derive(Debug, Clone, PartialEq)]
21pub struct SearchResults {
22    /// Milliseconds the cluster spent, excluding the network.
23    pub took: u64,
24    /// The search hit its time limit and returned what it had.
25    pub timed_out: bool,
26    pub shards: Shards,
27    pub total: TotalHits,
28    /// The best score, or `None` when the search was sorted by a field — in
29    /// that case nothing was scored at all.
30    pub max_score: Option<f64>,
31    pub hits: Vec<Hit>,
32    pub aggregations: Aggregations,
33}
34
35impl SearchResults {
36    pub fn parse(body: &Json) -> Result<SearchResults> {
37        let hits = body.get("hits").ok_or_else(|| SearchError::Malformed {
38            context: "_search".to_string(),
39            message: "the reply has no `hits`".to_string(),
40        })?;
41
42        Ok(SearchResults {
43            took: u64_at(body, "took"),
44            timed_out: body.get("timed_out").and_then(Json::as_bool).unwrap_or(false),
45            shards: Shards::parse(body.get("_shards")),
46            total: TotalHits::parse(hits.get("total")),
47            max_score: hits.get("max_score").and_then(Json::as_f64),
48            hits: hits
49                .get("hits")
50                .and_then(Json::as_array)
51                .map(|items| items.iter().map(Hit::parse).collect())
52                .unwrap_or_default(),
53            aggregations: Aggregations::parse(body.get("aggregations")),
54        })
55    }
56
57    /// How many documents came back on this page — not how many matched.
58    pub fn len(&self) -> usize {
59        self.hits.len()
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.hits.is_empty()
64    }
65
66    /// Whether part of the index was not consulted.
67    ///
68    /// A search over a cluster with a failing shard answers 200 with fewer
69    /// results, and nothing in the hits distinguishes that from a query that
70    /// genuinely matched less. Anything acting on absence — "no orders, so
71    /// close the account" — has to check this first.
72    pub fn is_partial(&self) -> bool {
73        self.timed_out || self.shards.failed > 0
74    }
75
76    /// The `_source` of every hit, in order.
77    pub fn sources(&self) -> impl Iterator<Item = &Json> {
78        self.hits.iter().map(|hit| &hit.source)
79    }
80
81    /// The `_id` of every hit, in order.
82    pub fn ids(&self) -> Vec<&str> {
83        self.hits.iter().map(|hit| hit.id.as_str()).collect()
84    }
85
86    /// The first hit, for a search that expects at most one.
87    pub fn first(&self) -> Option<&Hit> {
88        self.hits.first()
89    }
90}
91
92/// How many shards answered.
93#[derive(Debug, Clone, Default, PartialEq)]
94pub struct Shards {
95    pub total: u32,
96    pub successful: u32,
97    pub skipped: u32,
98    pub failed: u32,
99    /// Why the failed ones failed, where the cluster said.
100    pub failures: Vec<String>,
101}
102
103impl Shards {
104    fn parse(body: Option<&Json>) -> Shards {
105        let Some(body) = body else {
106            return Shards::default();
107        };
108
109        Shards {
110            total: u32_at(body, "total"),
111            successful: u32_at(body, "successful"),
112            skipped: u32_at(body, "skipped"),
113            failed: u32_at(body, "failed"),
114            failures: body
115                .get("failures")
116                .and_then(Json::as_array)
117                .map(|items| {
118                    items
119                        .iter()
120                        .map(|failure| {
121                            failure
122                                .get("reason.reason")
123                                .or_else(|| failure.get("reason"))
124                                .and_then(Json::as_str)
125                                .unwrap_or("no reason given")
126                                .to_string()
127                        })
128                        .collect()
129                })
130                .unwrap_or_default(),
131        }
132    }
133}
134
135/// How the total relates to the real number of matches.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TotalRelation {
138    /// The number is the answer.
139    Exact,
140    /// Counting stopped early; the real number is this or larger.
141    AtLeast,
142}
143
144/// How many documents matched.
145///
146/// Not a `u64`, deliberately. Elasticsearch stops counting at
147/// `track_total_hits` — 10,000 unless asked otherwise — and reports the cutoff
148/// as the value with a `gte` relation. Anything that renders a count, decides
149/// how many pages there are, or compares against an expected number has to
150/// know which of the two it is holding.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct TotalHits {
153    pub value: u64,
154    pub relation: TotalRelation,
155}
156
157impl TotalHits {
158    fn parse(body: Option<&Json>) -> TotalHits {
159        let Some(body) = body else {
160            return TotalHits { value: 0, relation: TotalRelation::Exact };
161        };
162
163        // Elasticsearch 6, and OpenSearch asked for `rest_total_hits_as_int`,
164        // send a bare number here. It has no cutoff, so it is always exact.
165        if let Some(value) = body.as_i64() {
166            return TotalHits { value: value.max(0) as u64, relation: TotalRelation::Exact };
167        }
168
169        TotalHits {
170            value: body.get("value").and_then(Json::as_i64).unwrap_or(0).max(0) as u64,
171            relation: match body.get("relation").and_then(Json::as_str) {
172                Some("gte") => TotalRelation::AtLeast,
173                _ => TotalRelation::Exact,
174            },
175        }
176    }
177
178    /// Whether the number can be trusted as the whole answer.
179    pub fn is_exact(&self) -> bool {
180        self.relation == TotalRelation::Exact
181    }
182
183    /// The count, but only when it is really the count.
184    ///
185    /// Use this where being wrong matters — a "1 of N" label, a page count.
186    /// [`TotalHits::value`] is there for a progress bar, where a floor is fine.
187    pub fn exact(&self) -> Option<u64> {
188        self.is_exact().then_some(self.value)
189    }
190}
191
192impl std::fmt::Display for TotalHits {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        match self.relation {
195            TotalRelation::Exact => write!(f, "{}", self.value),
196            TotalRelation::AtLeast => write!(f, "at least {}", self.value),
197        }
198    }
199}
200
201/// One matching document.
202#[derive(Debug, Clone, PartialEq)]
203pub struct Hit {
204    pub index: String,
205    pub id: String,
206    /// `None` when the search was sorted by a field rather than by relevance.
207    pub score: Option<f64>,
208    /// The document, or the part of it `_source` filtering asked for.
209    pub source: Json,
210    /// The values this hit sorted on, for paging with `search_after`.
211    pub sort: Vec<Json>,
212}
213
214impl Hit {
215    fn parse(body: &Json) -> Hit {
216        Hit {
217            index: string_at(body, "_index"),
218            id: string_at(body, "_id"),
219            score: body.get("_score").and_then(Json::as_f64),
220            source: body.get("_source").cloned().unwrap_or(Json::Null),
221            sort: body.get("sort").and_then(Json::as_array).map(<[Json]>::to_vec).unwrap_or_default(),
222        }
223    }
224
225    /// A field of the document, by dotted path.
226    pub fn field(&self, path: &str) -> Option<&Json> {
227        self.source.get(path)
228    }
229
230    /// A string field of the document, by dotted path.
231    pub fn string(&self, path: &str) -> Option<&str> {
232        self.source.get(path).and_then(Json::as_str)
233    }
234}
235
236/// The aggregation results, keyed by the names the search gave them.
237#[derive(Debug, Clone, Default, PartialEq)]
238pub struct Aggregations(BTreeMap<String, Json>);
239
240impl Aggregations {
241    fn parse(body: Option<&Json>) -> Aggregations {
242        Aggregations(
243            body.and_then(Json::as_object).cloned().unwrap_or_default(),
244        )
245    }
246
247    pub fn is_empty(&self) -> bool {
248        self.0.is_empty()
249    }
250
251    pub fn names(&self) -> Vec<&str> {
252        self.0.keys().map(String::as_str).collect()
253    }
254
255    /// One result as the cluster sent it, for an aggregation this module does
256    /// not model.
257    pub fn raw(&self, name: &str) -> Option<&Json> {
258        self.0.get(name)
259    }
260
261    /// A `terms` result.
262    pub fn terms(&self, name: &str) -> Option<TermsResult> {
263        let body = self.0.get(name)?;
264        let buckets = body.get("buckets")?.as_array()?;
265
266        Some(TermsResult {
267            doc_count_error_upper_bound: u64_at(body, "doc_count_error_upper_bound"),
268            sum_other_doc_count: u64_at(body, "sum_other_doc_count"),
269            buckets: buckets.iter().map(Bucket::parse).collect(),
270        })
271    }
272
273    /// A `stats` result.
274    pub fn stats(&self, name: &str) -> Option<StatsResult> {
275        let body = self.0.get(name)?;
276
277        Some(StatsResult {
278            count: u64_at(body, "count"),
279            // Null on every one of these when the aggregation saw no document
280            // — there is no minimum of nothing, and reporting 0 would be a
281            // number somebody charts.
282            min: body.get("min").and_then(Json::as_f64),
283            max: body.get("max").and_then(Json::as_f64),
284            avg: body.get("avg").and_then(Json::as_f64),
285            sum: body.get("sum").and_then(Json::as_f64).unwrap_or(0.0),
286        })
287    }
288
289    /// The number from a single-value metric — `min`, `max`, `avg`, `sum`,
290    /// `cardinality`, `value_count`.
291    pub fn value(&self, name: &str) -> Option<f64> {
292        self.0.get(name)?.get("value")?.as_f64()
293    }
294}
295
296/// What a `terms` aggregation returned.
297#[derive(Debug, Clone, PartialEq)]
298pub struct TermsResult {
299    pub buckets: Vec<Bucket>,
300    /// The largest number of documents any *returned* bucket may be missing.
301    pub doc_count_error_upper_bound: u64,
302    /// Documents in the values that did not make the list.
303    pub sum_other_doc_count: u64,
304}
305
306impl TermsResult {
307    /// Whether values are missing from this answer.
308    ///
309    /// A `terms` aggregation returns the top few — ten by default — and says
310    /// nothing else about the rest. Charting the buckets as "the tags" when
311    /// this is true draws a picture that is quietly wrong, and raising `size`
312    /// is the fix.
313    pub fn is_approximate(&self) -> bool {
314        self.sum_other_doc_count > 0 || self.doc_count_error_upper_bound > 0
315    }
316
317    /// A bucket by its key, rendered as a string.
318    pub fn get(&self, key: &str) -> Option<&Bucket> {
319        self.buckets.iter().find(|bucket| bucket.key_string() == key)
320    }
321
322    /// The count for one key, or zero if it is not in the answer.
323    ///
324    /// Zero is honest only when [`TermsResult::is_approximate`] is false.
325    pub fn count(&self, key: &str) -> u64 {
326        self.get(key).map(|bucket| bucket.doc_count).unwrap_or(0)
327    }
328}
329
330/// One bucket of an aggregation.
331#[derive(Debug, Clone, PartialEq)]
332pub struct Bucket {
333    /// The value, which is a string for a `keyword` field and a number for a
334    /// numeric or date one.
335    pub key: Json,
336    pub doc_count: u64,
337    /// Whatever was nested inside this bucket.
338    pub aggregations: Aggregations,
339}
340
341impl Bucket {
342    fn parse(body: &Json) -> Bucket {
343        Bucket {
344            key: body.get("key").cloned().unwrap_or(Json::Null),
345            doc_count: u64_at(body, "doc_count"),
346            // The bucket object holds its sub-aggregations alongside `key` and
347            // `doc_count`; those two extra names cost nothing and keeping the
348            // whole object means a sub-aggregation is read exactly the way a
349            // top-level one is.
350            aggregations: Aggregations::parse(Some(body)),
351        }
352    }
353
354    /// The key as text.
355    ///
356    /// Prefers `key_as_string`, which a date histogram sends alongside the raw
357    /// epoch milliseconds and which is the only readable form of it.
358    pub fn key_string(&self) -> String {
359        if let Some(Json::String(text)) = self.aggregations.raw("key_as_string") {
360            return text.clone();
361        }
362        match &self.key {
363            Json::String(text) => text.clone(),
364            other => other.to_string(),
365        }
366    }
367}
368
369/// What a `stats` aggregation returned.
370#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct StatsResult {
372    pub count: u64,
373    /// `None` when nothing was aggregated — not zero.
374    pub min: Option<f64>,
375    pub max: Option<f64>,
376    pub avg: Option<f64>,
377    pub sum: f64,
378}
379
380fn string_at(body: &Json, path: &str) -> String {
381    body.get(path).and_then(Json::as_str).unwrap_or_default().to_string()
382}
383
384fn u64_at(body: &Json, path: &str) -> u64 {
385    body.get(path).and_then(Json::as_i64).unwrap_or(0).max(0) as u64
386}
387
388fn u32_at(body: &Json, path: &str) -> u32 {
389    u64_at(body, path) as u32
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    /// Copied from a running Elasticsearch 8.15, not written from memory.
397    fn parse(body: &str) -> SearchResults {
398        SearchResults::parse(&Json::parse(body).expect("valid JSON in a test")).unwrap()
399    }
400
401    #[test]
402    fn reads_the_hits_a_real_search_returns() {
403        let results = parse(
404            r#"{"took":4,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":0.60706794,"hits":[{"_index":"posts","_id":"1","_score":0.60706794,"_source":{"title":"Rust web frameworks","tags":["rust","web"],"views":120}},{"_index":"posts","_id":"2","_score":0.35667494,"_source":{"title":"Rust and Tokio","tags":["rust"],"views":45}}]}}"#,
405        );
406
407        assert_eq!(results.took, 4);
408        assert_eq!(results.total.value, 2);
409        assert!(results.total.is_exact());
410        assert_eq!(results.max_score, Some(0.60706794));
411        assert_eq!(results.ids(), vec!["1", "2"]);
412        assert_eq!(results.hits[0].string("title"), Some("Rust web frameworks"));
413        assert_eq!(results.hits[0].field("views").and_then(Json::as_i64), Some(120));
414        assert!(!results.is_partial());
415    }
416
417    #[test]
418    fn a_capped_total_is_never_reported_as_the_answer() {
419        // The trap: `value` is 10000 and the real number is far larger. This is
420        // the default for every index, so it is the common case rather than an
421        // edge one.
422        let results = parse(
423            r#"{"took":12,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":10000,"relation":"gte"},"max_score":1.0,"hits":[]}}"#,
424        );
425
426        assert_eq!(results.total.relation, TotalRelation::AtLeast);
427        assert!(!results.total.is_exact());
428        assert_eq!(results.total.exact(), None, "a floor must not be handed out as a count");
429        assert_eq!(results.total.to_string(), "at least 10000");
430    }
431
432    #[test]
433    fn an_exact_total_reads_as_a_plain_number() {
434        let results = parse(
435            r#"{"took":1,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":7,"relation":"eq"},"max_score":null,"hits":[]}}"#,
436        );
437
438        assert_eq!(results.total.exact(), Some(7));
439        assert_eq!(results.total.to_string(), "7");
440        assert_eq!(results.max_score, None, "a sorted search scores nothing");
441    }
442
443    #[test]
444    fn an_old_style_integer_total_is_still_read() {
445        // OpenSearch with `rest_total_hits_as_int`, and Elasticsearch 6.
446        let results = parse(
447            r#"{"took":1,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":42,"max_score":1.0,"hits":[]}}"#,
448        );
449
450        assert_eq!(results.total.exact(), Some(42));
451    }
452
453    #[test]
454    fn a_search_that_succeeded_on_only_some_shards_says_so() {
455        // Status 200, hits present, and part of the index was never read. A
456        // caller acting on absence has to know.
457        let results = parse(
458            r#"{"took":8,"timed_out":false,"_shards":{"total":2,"successful":1,"skipped":0,"failed":1,"failures":[{"shard":0,"index":"posts","node":"abc","reason":{"type":"query_shard_exception","reason":"failed to create query"}}]},"hits":{"total":{"value":1,"relation":"eq"},"max_score":1.0,"hits":[{"_index":"posts","_id":"1","_score":1.0,"_source":{}}]}}"#,
459        );
460
461        assert!(results.is_partial(), "one shard failed and the results are incomplete");
462        assert_eq!(results.shards.failed, 1);
463        assert_eq!(results.shards.failures, vec!["failed to create query".to_string()]);
464    }
465
466    #[test]
467    fn a_timed_out_search_is_partial_too() {
468        let results = parse(
469            r#"{"took":500,"timed_out":true,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":1.0,"hits":[]}}"#,
470        );
471
472        assert!(results.is_partial());
473    }
474
475    #[test]
476    fn reads_a_terms_aggregation_and_the_stats_nested_inside_it() {
477        let results = parse(
478            r#"{"took":6,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"by_tag":{"doc_count_error_upper_bound":0,"sum_other_doc_count":0,"buckets":[{"key":"rust","doc_count":2,"views":{"count":2,"min":45.0,"max":120.0,"avg":82.5,"sum":165.0}},{"key":"web","doc_count":1,"views":{"count":1,"min":120.0,"max":120.0,"avg":120.0,"sum":120.0}}]}}}"#,
479        );
480
481        let tags = results.aggregations.terms("by_tag").unwrap();
482        assert_eq!(tags.buckets.len(), 2);
483        assert_eq!(tags.buckets[0].key_string(), "rust");
484        assert_eq!(tags.count("rust"), 2);
485        assert_eq!(tags.count("nothing"), 0);
486        assert!(!tags.is_approximate());
487
488        let views = tags.buckets[0].aggregations.stats("views").unwrap();
489        assert_eq!(views.count, 2);
490        assert_eq!(views.avg, Some(82.5));
491        assert_eq!(views.sum, 165.0);
492    }
493
494    #[test]
495    fn a_truncated_terms_aggregation_admits_that_it_is_truncated() {
496        // Ten buckets and 4,000 documents in the ones that did not fit. A chart
497        // drawn from this without checking is wrong and looks fine.
498        let results = parse(
499            r#"{"took":9,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":5000,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"by_tag":{"doc_count_error_upper_bound":12,"sum_other_doc_count":4000,"buckets":[{"key":"rust","doc_count":1000}]}}}"#,
500        );
501
502        let tags = results.aggregations.terms("by_tag").unwrap();
503        assert!(tags.is_approximate());
504        assert_eq!(tags.sum_other_doc_count, 4000);
505    }
506
507    #[test]
508    fn stats_over_nothing_are_null_rather_than_zero() {
509        let results = parse(
510            r#"{"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":0,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"price":{"count":0,"min":null,"max":null,"avg":null,"sum":0.0}}}"#,
511        );
512
513        let price = results.aggregations.stats("price").unwrap();
514        assert_eq!(price.count, 0);
515        assert_eq!(price.min, None, "there is no minimum of nothing");
516        assert_eq!(price.avg, None);
517        assert_eq!(price.sum, 0.0);
518    }
519
520    #[test]
521    fn a_single_value_metric_is_read_by_name() {
522        let results = parse(
523            r#"{"took":3,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"authors":{"value":2},"total_views":{"value":285.0}}}"#,
524        );
525
526        assert_eq!(results.aggregations.value("authors"), Some(2.0));
527        assert_eq!(results.aggregations.value("total_views"), Some(285.0));
528        assert_eq!(results.aggregations.value("missing"), None);
529        assert_eq!(results.aggregations.names(), vec!["authors", "total_views"]);
530    }
531
532    #[test]
533    fn a_date_histogram_bucket_reads_as_its_date_and_not_as_epoch_millis() {
534        let results = parse(
535            r#"{"took":5,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"per_day":{"buckets":[{"key_as_string":"2024-05-01T00:00:00.000Z","key":1714521600000,"doc_count":2}]}}}"#,
536        );
537
538        let days = results.aggregations.terms("per_day").unwrap();
539        assert_eq!(days.buckets[0].key_string(), "2024-05-01T00:00:00.000Z");
540        assert_eq!(days.buckets[0].doc_count, 2);
541    }
542
543    #[test]
544    fn sort_values_come_back_for_paging_past_the_result_window() {
545        let results = parse(
546            r#"{"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":null,"hits":[{"_index":"posts","_id":"2","_score":null,"_source":{"title":"b"},"sort":[1714521600000,"2"]}]}}"#,
547        );
548
549        assert_eq!(results.hits[0].sort.len(), 2);
550        assert_eq!(results.hits[0].sort[1].as_str(), Some("2"));
551    }
552
553    #[test]
554    fn a_reply_with_no_hits_object_is_a_shape_we_do_not_understand() {
555        let error = SearchResults::parse(&Json::parse(r#"{"took":1}"#).unwrap()).unwrap_err();
556        assert!(matches!(error, SearchError::Malformed { .. }), "got {error:?}");
557    }
558}