Skip to main content

rustlavel_search/
document.rs

1//! Writing and reading documents.
2//!
3//! The one thing in this module worth reading before using it is [`bulk`]:
4//! `_bulk` answers **HTTP 200 even when every document in it failed**. The
5//! status code says the request was understood, not that the work was done,
6//! and the outcome of each item is in the body. A caller that checks only the
7//! status silently drops documents — usually the ones that mattered, because
8//! the failures cluster around the unusual records — and the index looks fine
9//! until somebody counts. [`BulkReport`] exists to make that impossible to
10//! miss, and [`BulkReport::ok_or_error`] is one line for callers who would
11//! rather have it as an error.
12//!
13//! [`bulk`]: SearchClient::bulk
14
15use crate::client::{SearchClient, encode};
16use crate::error::{Result, SearchError};
17use rustlavel_core::Json;
18use rustlavel_http::Method;
19
20/// What a write did.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum WriteResult {
23    Created,
24    Updated,
25    Deleted,
26    /// An update whose document was already in the requested state. Nothing
27    /// was written and the version did not move — which matters if you are
28    /// counting writes or watching for a change.
29    NoOp,
30    NotFound,
31    /// A result this client does not know.
32    Other(String),
33}
34
35impl WriteResult {
36    fn parse(value: &str) -> WriteResult {
37        match value {
38            "created" => WriteResult::Created,
39            "updated" => WriteResult::Updated,
40            "deleted" => WriteResult::Deleted,
41            "noop" => WriteResult::NoOp,
42            "not_found" => WriteResult::NotFound,
43            other => WriteResult::Other(other.to_string()),
44        }
45    }
46}
47
48impl std::fmt::Display for WriteResult {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            WriteResult::Created => f.write_str("created"),
52            WriteResult::Updated => f.write_str("updated"),
53            WriteResult::Deleted => f.write_str("deleted"),
54            WriteResult::NoOp => f.write_str("noop"),
55            WriteResult::NotFound => f.write_str("not_found"),
56            WriteResult::Other(other) => f.write_str(other),
57        }
58    }
59}
60
61/// What the cluster said about a document it just wrote.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct IndexedDocument {
64    pub index: String,
65    /// The id, which the cluster generated when none was given.
66    pub id: String,
67    pub version: i64,
68    pub result: WriteResult,
69    /// The two numbers that identify this exact version of the document, for
70    /// [`SearchClient::index_document_if_unchanged`].
71    pub seq_no: i64,
72    pub primary_term: i64,
73}
74
75impl IndexedDocument {
76    fn parse(body: &Json) -> IndexedDocument {
77        IndexedDocument {
78            index: string_at(body, "_index"),
79            id: string_at(body, "_id"),
80            version: int_at(body, "_version"),
81            result: WriteResult::parse(body.get("result").and_then(Json::as_str).unwrap_or("")),
82            seq_no: int_at(body, "_seq_no"),
83            primary_term: int_at(body, "_primary_term"),
84        }
85    }
86}
87
88/// A document read back by id.
89#[derive(Debug, Clone, PartialEq)]
90pub struct Document {
91    pub index: String,
92    pub id: String,
93    pub version: i64,
94    pub seq_no: i64,
95    pub primary_term: i64,
96    pub source: Json,
97}
98
99impl Document {
100    fn parse(body: &Json) -> Document {
101        Document {
102            index: string_at(body, "_index"),
103            id: string_at(body, "_id"),
104            version: int_at(body, "_version"),
105            seq_no: int_at(body, "_seq_no"),
106            primary_term: int_at(body, "_primary_term"),
107            source: body.get("_source").cloned().unwrap_or(Json::Null),
108        }
109    }
110
111    /// A field of the document, by dotted path.
112    pub fn field(&self, path: &str) -> Option<&Json> {
113        self.source.get(path)
114    }
115
116    /// A string field of the document, by dotted path.
117    pub fn string(&self, path: &str) -> Option<&str> {
118        self.source.get(path).and_then(Json::as_str)
119    }
120}
121
122/// What a bulk item does.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum Action {
125    /// Write, replacing anything already at the id.
126    Index,
127    /// Write only if the id is free.
128    Create,
129    /// Merge fields into an existing document.
130    Update,
131    Delete,
132}
133
134impl Action {
135    fn as_str(self) -> &'static str {
136        match self {
137            Action::Index => "index",
138            Action::Create => "create",
139            Action::Update => "update",
140            Action::Delete => "delete",
141        }
142    }
143}
144
145/// One entry in a bulk request.
146#[derive(Debug, Clone, PartialEq)]
147pub struct BulkOperation {
148    action: Action,
149    index: Option<String>,
150    id: Option<String>,
151    document: Json,
152}
153
154impl BulkOperation {
155    /// Write a document, replacing whatever was at the id.
156    ///
157    /// With no [`BulkOperation::id`] the cluster generates one, which is
158    /// faster — it can skip the lookup that a given id forces — and right for
159    /// append-only data like logs.
160    pub fn index(document: Json) -> BulkOperation {
161        BulkOperation { action: Action::Index, index: None, id: None, document }
162    }
163
164    /// Write a document only if nothing is at the id yet.
165    ///
166    /// Fails that item with a version conflict rather than overwriting, which
167    /// makes a bulk load safe to repeat after a partial failure.
168    pub fn create(id: impl Into<String>, document: Json) -> BulkOperation {
169        BulkOperation {
170            action: Action::Create,
171            index: None,
172            id: Some(id.into()),
173            document,
174        }
175    }
176
177    /// Merge these fields into the document, leaving the rest alone.
178    pub fn update(id: impl Into<String>, fields: Json) -> BulkOperation {
179        BulkOperation {
180            action: Action::Update,
181            index: None,
182            id: Some(id.into()),
183            document: Json::object([("doc", fields)]),
184        }
185    }
186
187    pub fn delete(id: impl Into<String>) -> BulkOperation {
188        BulkOperation {
189            action: Action::Delete,
190            index: None,
191            id: Some(id.into()),
192            document: Json::Null,
193        }
194    }
195
196    /// Give this operation an id, for an [`BulkOperation::index`] that should
197    /// overwrite a known document.
198    pub fn id(mut self, id: impl Into<String>) -> BulkOperation {
199        self.id = Some(id.into());
200        self
201    }
202
203    /// Send this one to a different index than the rest of the request.
204    pub fn in_index(mut self, index: impl Into<String>) -> BulkOperation {
205        self.index = Some(index.into());
206        self
207    }
208
209    /// The two lines this operation contributes to the NDJSON body — one, for
210    /// a delete.
211    fn write_into(&self, out: &mut String) {
212        let mut header = Vec::new();
213        if let Some(index) = &self.index {
214            header.push(("_index".to_string(), Json::String(index.clone())));
215        }
216        if let Some(id) = &self.id {
217            header.push(("_id".to_string(), Json::String(id.clone())));
218        }
219
220        let header = Json::object([(
221            self.action.as_str().to_string(),
222            Json::object(header),
223        )]);
224
225        // Compact, because a newline anywhere inside one of these lines ends
226        // the document early and the cluster reports a parse failure that
227        // points at the next line.
228        out.push_str(&header.to_string());
229        out.push('\n');
230
231        if self.action != Action::Delete {
232            out.push_str(&self.document.to_string());
233            out.push('\n');
234        }
235    }
236}
237
238/// One item's outcome in a bulk request.
239#[derive(Debug, Clone, PartialEq)]
240pub struct BulkItem {
241    /// `index`, `create`, `update` or `delete`.
242    pub action: String,
243    pub index: String,
244    pub id: String,
245    /// The per-item HTTP status. This, not the request's, is what says whether
246    /// the document was written.
247    pub status: u16,
248    pub result: Option<WriteResult>,
249    /// Why it failed, classified exactly as a standalone request would be — so
250    /// [`SearchError::is_retryable`] applies per item.
251    pub error: Option<SearchError>,
252}
253
254impl BulkItem {
255    fn parse(body: &Json) -> BulkItem {
256        // Each item is a one-key object naming the action it was.
257        let (action, detail) = body
258            .as_object()
259            .and_then(|map| map.iter().next())
260            .map(|(action, detail)| (action.clone(), detail.clone()))
261            .unwrap_or_else(|| (String::new(), Json::Null));
262
263        let status = int_at(&detail, "status").clamp(0, u16::MAX as i64) as u16;
264        let index = string_at(&detail, "_index");
265        let id = string_at(&detail, "_id");
266
267        let error = detail.get("error").map(|_| {
268            // The per-item error body is the same envelope a standalone
269            // request would have failed with, one level down, so the same
270            // classifier reads it and the same retryability applies.
271            SearchError::from_response(
272                status,
273                &index,
274                &Json::object([("error", detail.get("error").cloned().unwrap_or(Json::Null))])
275                    .to_string(),
276            )
277        });
278
279        BulkItem {
280            action,
281            index,
282            id,
283            status,
284            result: detail.get("result").and_then(Json::as_str).map(WriteResult::parse),
285            error,
286        }
287    }
288
289    pub fn is_failure(&self) -> bool {
290        self.error.is_some() || !(200..300).contains(&self.status)
291    }
292}
293
294/// What a bulk request did, item by item.
295///
296/// The request's HTTP status is 200 whether every item succeeded or none did.
297/// [`BulkReport::failed`] and [`BulkReport::failures`] are the only honest
298/// account of what happened.
299#[derive(Debug, Clone, PartialEq)]
300pub struct BulkReport {
301    /// Milliseconds the cluster spent.
302    pub took: u64,
303    /// The cluster's own summary: true if any item failed.
304    pub errors: bool,
305    /// One entry per operation sent, in the order they were sent.
306    pub items: Vec<BulkItem>,
307}
308
309impl BulkReport {
310    fn parse(body: &Json) -> Result<BulkReport> {
311        let items = body.get("items").and_then(Json::as_array).ok_or_else(|| {
312            SearchError::Malformed {
313                context: "_bulk".to_string(),
314                message: "the reply has no `items`".to_string(),
315            }
316        })?;
317
318        Ok(BulkReport {
319            took: int_at(body, "took").max(0) as u64,
320            errors: body.get("errors").and_then(Json::as_bool).unwrap_or(false),
321            items: items.iter().map(BulkItem::parse).collect(),
322        })
323    }
324
325    fn empty() -> BulkReport {
326        BulkReport { took: 0, errors: false, items: Vec::new() }
327    }
328
329    /// Whether every item was written.
330    pub fn all_succeeded(&self) -> bool {
331        !self.items.iter().any(BulkItem::is_failure)
332    }
333
334    pub fn succeeded(&self) -> usize {
335        self.items.iter().filter(|item| !item.is_failure()).count()
336    }
337
338    pub fn failed(&self) -> usize {
339        self.items.iter().filter(|item| item.is_failure()).count()
340    }
341
342    /// The items that were not written, and why.
343    pub fn failures(&self) -> Vec<&BulkItem> {
344        self.items.iter().filter(|item| item.is_failure()).collect()
345    }
346
347    /// The positions in the operation list that failed.
348    ///
349    /// The cluster answers in the order it was asked, so these index straight
350    /// back into the slice that was sent — which is what makes resubmitting
351    /// only the failures possible without matching on ids, and works for the
352    /// auto-generated ids where there is nothing to match on.
353    pub fn failed_positions(&self) -> Vec<usize> {
354        self.items
355            .iter()
356            .enumerate()
357            .filter(|(_, item)| item.is_failure())
358            .map(|(at, _)| at)
359            .collect()
360    }
361
362    /// The failures that a later attempt could fix — a rejected write under
363    /// load, not a document that does not fit the mapping.
364    ///
365    /// Resubmitting these and only these is the correct response to a bulk
366    /// load that partly failed; resubmitting everything would either duplicate
367    /// documents or re-attempt work that cannot succeed.
368    pub fn retryable_positions(&self) -> Vec<usize> {
369        self.items
370            .iter()
371            .enumerate()
372            .filter(|(_, item)| item.error.as_ref().is_some_and(SearchError::is_retryable))
373            .map(|(at, _)| at)
374            .collect()
375    }
376
377    /// Turn any partial failure into an error.
378    ///
379    /// For the caller who wants `?` to notice rather than inspecting items.
380    /// The report is still there in full on the success path.
381    pub fn ok_or_error(&self) -> Result<()> {
382        if self.all_succeeded() {
383            return Ok(());
384        }
385
386        let first = self
387            .failures()
388            .first()
389            .map(|item| match &item.error {
390                Some(error) => format!("`{}`: {error}", item.id),
391                None => format!("`{}`: status {}", item.id, item.status),
392            })
393            .unwrap_or_default();
394
395        Err(SearchError::BulkPartialFailure {
396            failed: self.failed(),
397            total: self.items.len(),
398            first,
399        })
400    }
401}
402
403impl std::fmt::Display for BulkReport {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        write!(f, "{} of {} written in {}ms", self.succeeded(), self.items.len(), self.took)
406    }
407}
408
409impl SearchClient {
410    /// Write a document at a known id, replacing anything already there.
411    pub async fn index_document(
412        &self,
413        index: &str,
414        id: &str,
415        document: Json,
416    ) -> Result<IndexedDocument> {
417        let path = format!("{}/_doc/{}", encode(index), encode(id));
418        let body = self.json(Method::Put, &path, &context(index, id), Some(document)).await?;
419        Ok(IndexedDocument::parse(&body))
420    }
421
422    /// Write a document and let the cluster choose the id.
423    ///
424    /// Faster than giving one: with no id there is nothing to look up, so the
425    /// write skips the check for an existing version. Right for append-only
426    /// data, wrong for anything that has to be updated later by a key you
427    /// already know.
428    pub async fn index_new_document(
429        &self,
430        index: &str,
431        document: Json,
432    ) -> Result<IndexedDocument> {
433        let path = format!("{}/_doc", encode(index));
434        let body = self.json(Method::Post, &path, index, Some(document)).await?;
435        Ok(IndexedDocument::parse(&body))
436    }
437
438    /// Write a document only if the id is free.
439    ///
440    /// Fails with [`SearchError::VersionConflict`] rather than overwriting,
441    /// which is how an insert is expressed here — Elasticsearch has no other
442    /// way to say "only if it does not exist".
443    pub async fn create_document(
444        &self,
445        index: &str,
446        id: &str,
447        document: Json,
448    ) -> Result<IndexedDocument> {
449        let path = format!("{}/_create/{}", encode(index), encode(id));
450        let body = self.json(Method::Put, &path, &context(index, id), Some(document)).await?;
451        Ok(IndexedDocument::parse(&body))
452    }
453
454    /// Write a document only if it has not changed since it was read.
455    ///
456    /// The two numbers come from the [`Document`] or [`IndexedDocument`] that
457    /// was read; passing them turns read-modify-write into something safe
458    /// against a concurrent writer, which it otherwise is not. If somebody
459    /// else wrote in between, this fails with
460    /// [`SearchError::VersionConflict`] instead of quietly discarding their
461    /// change — which is the entire reason the numbers exist.
462    pub async fn index_document_if_unchanged(
463        &self,
464        index: &str,
465        id: &str,
466        document: Json,
467        seq_no: i64,
468        primary_term: i64,
469    ) -> Result<IndexedDocument> {
470        let path = format!(
471            "{}/_doc/{}?if_seq_no={seq_no}&if_primary_term={primary_term}",
472            encode(index),
473            encode(id)
474        );
475        let body = self.json(Method::Put, &path, &context(index, id), Some(document)).await?;
476        Ok(IndexedDocument::parse(&body))
477    }
478
479    /// Read a document by id.
480    ///
481    /// `None` means the index is there and the document is not, which is
482    /// ordinary. A missing *index* is still an error, because that is a
483    /// deployment problem rather than a missing record, and reporting the two
484    /// the same way is how a typo in an index name survives to production.
485    pub async fn get_document(&self, index: &str, id: &str) -> Result<Option<Document>> {
486        let path = format!("{}/_doc/{}", encode(index), encode(id));
487        let raw = self.send(Method::Get, &path, &context(index, id), None).await?;
488
489        if !raw.is_success() {
490            // A missing document is a 404 whose body says `"found": false`
491            // and carries no `error` at all; a missing index is a 404 with the
492            // usual error envelope. Nothing but the body separates them.
493            let body = Json::parse(&raw.body).unwrap_or(Json::Null);
494            if raw.status == 404 && body.get("found").and_then(Json::as_bool) == Some(false) {
495                return Ok(None);
496            }
497            return Err(raw.error(&context(index, id)));
498        }
499
500        Ok(Some(Document::parse(&raw.into_json(&path)?)))
501    }
502
503    /// Whether a document exists, without transferring it.
504    ///
505    /// `_source=false` leaves the reply at the handful of metadata fields,
506    /// which is as close to a `HEAD` as this can get. It is not one because a
507    /// real cluster answers a `HEAD` with a chunked framing it then never
508    /// terminates, leaving the reader waiting for a body that never comes.
509    pub async fn document_exists(&self, index: &str, id: &str) -> Result<bool> {
510        let path = format!("{}/_doc/{}?_source=false", encode(index), encode(id));
511        self.exists_at(&path, &context(index, id)).await
512    }
513
514    /// Merge fields into a document, leaving the rest of it alone.
515    ///
516    /// Only the named fields change. Note that a partial update is a read,
517    /// a merge and a full rewrite inside the cluster, so it costs more than
518    /// indexing the whole document — it saves bandwidth, not work.
519    pub async fn update_document(
520        &self,
521        index: &str,
522        id: &str,
523        fields: Json,
524    ) -> Result<IndexedDocument> {
525        let path = format!("{}/_update/{}", encode(index), encode(id));
526        let body = self
527            .json(
528                Method::Post,
529                &path,
530                &context(index, id),
531                Some(Json::object([("doc", fields)])),
532            )
533            .await?;
534        Ok(IndexedDocument::parse(&body))
535    }
536
537    /// Delete a document, answering whether there was one.
538    pub async fn delete_document(&self, index: &str, id: &str) -> Result<bool> {
539        let path = format!("{}/_doc/{}", encode(index), encode(id));
540        let raw = self.send(Method::Delete, &path, &context(index, id), None).await?;
541
542        if raw.status == 404 {
543            let body = Json::parse(&raw.body).unwrap_or(Json::Null);
544            if body.get("result").and_then(Json::as_str) == Some("not_found") {
545                return Ok(false);
546            }
547        }
548        if !raw.is_success() {
549            return Err(raw.error(&context(index, id)));
550        }
551
552        Ok(true)
553    }
554
555    /// Write many documents in one request.
556    ///
557    /// **Read the report.** `_bulk` answers 200 as long as the cluster could
558    /// parse the request, whatever happened to the documents in it, so
559    /// `?` on this call is not a check that anything was written.
560    /// [`BulkReport::ok_or_error`] is the one-line version of that check;
561    /// [`BulkReport::failures`] and [`BulkReport::retryable_positions`] are
562    /// the version that lets you resubmit only what is worth resubmitting.
563    ///
564    /// `index` is the default for operations that do not name one of their own.
565    pub async fn bulk(&self, index: &str, operations: &[BulkOperation]) -> Result<BulkReport> {
566        self.send_bulk(&format!("{}/_bulk", encode(index)), index, operations).await
567    }
568
569    /// A bulk request spanning several indices, where every operation names
570    /// its own with [`BulkOperation::in_index`].
571    pub async fn bulk_across_indices(&self, operations: &[BulkOperation]) -> Result<BulkReport> {
572        self.send_bulk("_bulk", "_bulk", operations).await
573    }
574
575    async fn send_bulk(
576        &self,
577        path: &str,
578        context: &str,
579        operations: &[BulkOperation],
580    ) -> Result<BulkReport> {
581        // An empty `_bulk` body is a 400 saying the request body is required,
582        // which is a confusing way to be told that a loop had nothing in it.
583        if operations.is_empty() {
584            return Ok(BulkReport::empty());
585        }
586
587        let mut body = String::new();
588        for operation in operations {
589            operation.write_into(&mut body);
590        }
591
592        let response = self.ndjson(path, context, body).await?;
593        BulkReport::parse(&response)
594    }
595}
596
597/// How a document is named in an error message.
598fn context(index: &str, id: &str) -> String {
599    format!("{index}/{id}")
600}
601
602fn string_at(body: &Json, path: &str) -> String {
603    body.get(path).and_then(Json::as_str).unwrap_or_default().to_string()
604}
605
606fn int_at(body: &Json, path: &str) -> i64 {
607    body.get(path).and_then(Json::as_i64).unwrap_or(0)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use rustlavel_client::{Fake, FakeResponse};
614
615    fn json(body: &str) -> Json {
616        Json::parse(body).expect("valid JSON in a test")
617    }
618
619    fn client(body: &str, status: u16) -> SearchClient {
620        SearchClient::new("http://localhost:9200")
621            .faking(Fake::new().fallback(FakeResponse::text(body).status(status)))
622    }
623
624    #[tokio::test]
625    async fn indexing_a_document_reads_back_what_the_cluster_assigned() {
626        let client = client(
627            r#"{"_index":"posts","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}"#,
628            201,
629        );
630
631        let written = client
632            .index_document("posts", "1", json(r#"{"title":"Rust"}"#))
633            .await
634            .unwrap();
635
636        assert_eq!(written.result, WriteResult::Created);
637        assert_eq!(written.id, "1");
638        assert_eq!(written.seq_no, 0);
639        assert_eq!(written.primary_term, 1);
640
641        let sent = &client.fake().unwrap().recorded()[0];
642        assert_eq!(sent.method, Method::Put);
643        assert_eq!(sent.url, "http://localhost:9200/posts/_doc/1");
644    }
645
646    #[tokio::test]
647    async fn a_document_with_no_id_is_posted_so_the_cluster_generates_one() {
648        let client = client(
649            r#"{"_index":"logs","_id":"kSXqQpEBc1nQ3Z6yTQ8p","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":3,"_primary_term":1}"#,
650            201,
651        );
652
653        let written = client.index_new_document("logs", json(r#"{"level":"warn"}"#)).await.unwrap();
654
655        assert_eq!(written.id, "kSXqQpEBc1nQ3Z6yTQ8p");
656        let sent = &client.fake().unwrap().recorded()[0];
657        assert_eq!(sent.method, Method::Post, "a PUT would need an id in the path");
658        assert_eq!(sent.url, "http://localhost:9200/logs/_doc");
659    }
660
661    #[tokio::test]
662    async fn creating_a_document_that_exists_is_a_version_conflict_not_an_overwrite() {
663        let body = r#"{"error":{"root_cause":[{"type":"version_conflict_engine_exception","reason":"[1]: version conflict, document already exists (current version [1])","index_uuid":"3zLDcCFFTASdSaKQTa93Xg","shard":"0","index":"posts"}],"type":"version_conflict_engine_exception","reason":"[1]: version conflict, document already exists (current version [1])","index_uuid":"3zLDcCFFTASdSaKQTa93Xg","shard":"0","index":"posts"},"status":409}"#;
664        let client = client(body, 409);
665
666        let error =
667            client.create_document("posts", "1", json(r#"{"title":"x"}"#)).await.unwrap_err();
668
669        assert!(error.is_conflict(), "got {error:?}");
670        assert_eq!(
671            client.fake().unwrap().recorded()[0].url,
672            "http://localhost:9200/posts/_create/1"
673        );
674    }
675
676    #[tokio::test]
677    async fn an_optimistic_write_sends_the_two_numbers_that_make_it_safe() {
678        let client = client(
679            r#"{"_index":"posts","_id":"1","_version":2,"result":"updated","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1}"#,
680            200,
681        );
682
683        client
684            .index_document_if_unchanged("posts", "1", json(r#"{"title":"x"}"#), 0, 1)
685            .await
686            .unwrap();
687
688        let url = &client.fake().unwrap().recorded()[0].url;
689        assert!(url.contains("if_seq_no=0"), "got {url}");
690        assert!(url.contains("if_primary_term=1"), "got {url}");
691    }
692
693    #[tokio::test]
694    async fn a_missing_document_is_none_and_a_missing_index_is_still_an_error() {
695        // Both are 404 and only the body tells them apart. Reporting them the
696        // same way is how a typo in an index name reaches production.
697        let missing_document =
698            client(r#"{"_index":"posts","_id":"nope","found":false}"#, 404);
699        assert_eq!(missing_document.get_document("posts", "nope").await.unwrap(), None);
700
701        let missing_index = client(
702            r#"{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index [pots]","resource.type":"index_or_alias","resource.id":"pots","index_uuid":"_na_","index":"pots"}],"type":"index_not_found_exception","reason":"no such index [pots]","resource.type":"index_or_alias","resource.id":"pots","index_uuid":"_na_","index":"pots"},"status":404}"#,
703            404,
704        );
705        assert!(
706            missing_index.get_document("pots", "1").await.unwrap_err().is_index_not_found(),
707            "a missing index must not read as a missing document"
708        );
709    }
710
711    #[tokio::test]
712    async fn reading_a_document_returns_the_source_and_the_concurrency_numbers() {
713        let client = client(
714            r#"{"_index":"posts","_id":"1","_version":1,"_seq_no":0,"_primary_term":1,"found":true,"_source":{"title":"Rust","views":12}}"#,
715            200,
716        );
717
718        let document = client.get_document("posts", "1").await.unwrap().unwrap();
719
720        assert_eq!(document.string("title"), Some("Rust"));
721        assert_eq!(document.field("views").and_then(Json::as_i64), Some(12));
722        assert_eq!((document.seq_no, document.primary_term), (0, 1));
723    }
724
725    #[tokio::test]
726    async fn an_update_that_changed_nothing_reports_a_noop() {
727        // Worth telling apart from an update: the version does not move, so a
728        // caller watching for a change would wait forever.
729        let client = client(
730            r#"{"_index":"posts","_id":"1","_version":2,"result":"noop","_shards":{"total":0,"successful":0,"failed":0},"_seq_no":1,"_primary_term":1}"#,
731            200,
732        );
733
734        let updated = client.update_document("posts", "1", json(r#"{"title":"Rust"}"#)).await.unwrap();
735
736        assert_eq!(updated.result, WriteResult::NoOp);
737        let sent = &client.fake().unwrap().recorded()[0];
738        assert_eq!(sent.url, "http://localhost:9200/posts/_update/1");
739        assert_eq!(sent.body_text(), r#"{"doc":{"title":"Rust"}}"#);
740    }
741
742    #[tokio::test]
743    async fn deleting_something_that_is_not_there_is_false_rather_than_an_error() {
744        let present = client(
745            r#"{"_index":"posts","_id":"1","_version":3,"result":"deleted","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":2,"_primary_term":1}"#,
746            200,
747        );
748        assert!(present.delete_document("posts", "1").await.unwrap());
749
750        let absent = client(
751            r#"{"_index":"posts","_id":"nope","_version":1,"result":"not_found","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":4,"_primary_term":1}"#,
752            404,
753        );
754        assert!(!absent.delete_document("posts", "nope").await.unwrap());
755    }
756
757    #[tokio::test]
758    async fn an_id_that_would_change_the_url_is_encoded() {
759        let client = client(r#"{"_index":"posts","_id":"a/b","result":"created"}"#, 201);
760
761        client.index_document("posts", "a/b", Json::Null).await.unwrap();
762
763        assert_eq!(
764            client.fake().unwrap().recorded()[0].url,
765            "http://localhost:9200/posts/_doc/a%2Fb"
766        );
767    }
768
769    #[test]
770    fn a_bulk_body_is_newline_delimited_with_a_trailing_newline() {
771        // The last newline is not optional: without it the cluster answers
772        // "The bulk request must be terminated by a newline".
773        let mut body = String::new();
774        for operation in [
775            BulkOperation::index(json(r#"{"title":"a"}"#)).id("1"),
776            BulkOperation::create("2", json(r#"{"title":"b"}"#)),
777            BulkOperation::update("1", json(r#"{"views":9}"#)),
778            BulkOperation::delete("3"),
779            BulkOperation::index(json(r#"{"title":"c"}"#)).in_index("other"),
780        ] {
781            operation.write_into(&mut body);
782        }
783
784        assert_eq!(
785            body,
786            concat!(
787                "{\"index\":{\"_id\":\"1\"}}\n{\"title\":\"a\"}\n",
788                "{\"create\":{\"_id\":\"2\"}}\n{\"title\":\"b\"}\n",
789                "{\"update\":{\"_id\":\"1\"}}\n{\"doc\":{\"views\":9}}\n",
790                "{\"delete\":{\"_id\":\"3\"}}\n",
791                "{\"index\":{\"_index\":\"other\"}}\n{\"title\":\"c\"}\n",
792            )
793        );
794        assert!(body.ends_with('\n'));
795    }
796
797    #[tokio::test]
798    async fn a_bulk_request_declares_the_content_type_the_endpoint_is_defined_to_take() {
799        let client = client(r#"{"took":1,"errors":false,"items":[]}"#, 200);
800
801        client.bulk("posts", &[BulkOperation::index(Json::Null)]).await.unwrap();
802
803        let sent = &client.fake().unwrap().recorded()[0];
804        assert_eq!(sent.headers.get("content-type"), Some("application/x-ndjson"));
805        assert_eq!(sent.url, "http://localhost:9200/posts/_bulk");
806    }
807
808    #[tokio::test]
809    async fn an_empty_bulk_sends_nothing_instead_of_asking_for_a_400() {
810        let client = client("", 200);
811
812        let report = client.bulk("posts", &[]).await.unwrap();
813
814        assert_eq!(report.items.len(), 0);
815        assert!(report.all_succeeded());
816        client.fake().unwrap().assert_count(0);
817    }
818
819    /// The trap this module exists for.
820    ///
821    /// `_bulk` answered 200. Two of the four documents were not written, and
822    /// nothing outside the body says so — a caller that checked the status,
823    /// or that used `?` and moved on, has just lost them silently.
824    ///
825    /// The body is copied from a real 8.15 response to a bulk of four
826    /// documents against a strict mapping.
827    #[tokio::test]
828    async fn a_bulk_that_returned_200_can_still_have_lost_documents() {
829        let body = r#"{"took":26,"errors":true,"items":[{"index":{"_index":"posts","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1,"status":201}},{"index":{"_index":"posts","_id":"2","status":400,"error":{"type":"document_parsing_exception","reason":"[1:11] failed to parse field [views] of type [long] in document with id '2'. Preview of field's value: 'many'","caused_by":{"type":"illegal_argument_exception","reason":"For input string: \"many\""}}}},{"create":{"_index":"posts","_id":"3","status":409,"error":{"type":"version_conflict_engine_exception","reason":"[3]: version conflict, document already exists (current version [1])","index_uuid":"3zLDcCFFTASdSaKQTa93Xg","shard":"0","index":"posts"}}},{"index":{"_index":"posts","_id":"4","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1,"status":201}}]}"#;
830        let client = client(body, 200);
831
832        let report = client
833            .bulk(
834                "posts",
835                &[
836                    BulkOperation::index(Json::Null).id("1"),
837                    BulkOperation::index(Json::Null).id("2"),
838                    BulkOperation::create("3", Json::Null),
839                    BulkOperation::index(Json::Null).id("4"),
840                ],
841            )
842            .await
843            // The request itself succeeded. That is the whole problem.
844            .unwrap();
845
846        assert_eq!(report.succeeded(), 2);
847        assert_eq!(report.failed(), 2);
848        assert!(!report.all_succeeded());
849        assert_eq!(report.failed_positions(), vec![1, 2]);
850
851        // Each failure keeps the reason, classified the way a standalone
852        // request would have been.
853        let failures = report.failures();
854        assert!(matches!(failures[0].error, Some(SearchError::MapperParsing { .. })));
855        assert!(matches!(failures[1].error, Some(SearchError::VersionConflict { .. })));
856        assert!(
857            failures[0].error.as_ref().unwrap().to_string().contains("For input string"),
858            "the cause is the half that says what to fix"
859        );
860
861        // Neither of these is worth resubmitting, and saying so is the point:
862        // a blind retry of the whole batch would duplicate the two that worked.
863        assert!(report.retryable_positions().is_empty());
864
865        let error = report.ok_or_error().unwrap_err();
866        assert!(matches!(error, SearchError::BulkPartialFailure { failed: 2, total: 4, .. }));
867        assert!(error.to_string().contains("2 of 4"), "got {error}");
868    }
869
870    #[tokio::test]
871    async fn a_bulk_rejected_for_load_names_the_items_worth_sending_again() {
872        // Under write pressure the cluster rejects individual items with a 429
873        // and accepts the rest. Resubmitting only these is the correct
874        // response; resubmitting the batch would duplicate the successes.
875        let body = r#"{"took":3,"errors":true,"items":[{"index":{"_index":"logs","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1,"status":201}},{"index":{"_index":"logs","_id":"2","status":429,"error":{"type":"es_rejected_execution_exception","reason":"rejected execution of coordinating operation [coordinating_and_primary_bytes=0, replica_bytes=0, all_bytes=0, coordinating_operation_bytes=206, max_coordinating_and_primary_bytes=105630924]"}}}]}"#;
876        let client = client(body, 200);
877
878        let report = client
879            .bulk(
880                "logs",
881                &[
882                    BulkOperation::index(Json::Null).id("1"),
883                    BulkOperation::index(Json::Null).id("2"),
884                ],
885            )
886            .await
887            .unwrap();
888
889        assert_eq!(report.retryable_positions(), vec![1]);
890        assert_eq!(report.failed_positions(), vec![1]);
891        assert_eq!(report.to_string(), "1 of 2 written in 3ms");
892    }
893
894    #[tokio::test]
895    async fn a_bulk_where_everything_worked_needs_no_inspection() {
896        let body = r#"{"took":8,"errors":false,"items":[{"index":{"_index":"posts","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1,"status":201}}]}"#;
897        let client = client(body, 200);
898
899        let report = client.bulk("posts", &[BulkOperation::index(Json::Null).id("1")]).await.unwrap();
900
901        assert!(report.all_succeeded());
902        assert!(report.ok_or_error().is_ok());
903        assert_eq!(report.items[0].result, Some(WriteResult::Created));
904    }
905
906    #[tokio::test]
907    async fn a_bulk_across_indices_goes_to_the_root_endpoint() {
908        let client = client(r#"{"took":1,"errors":false,"items":[]}"#, 200);
909
910        client
911            .bulk_across_indices(&[BulkOperation::index(Json::Null).in_index("posts")])
912            .await
913            .unwrap();
914
915        assert_eq!(client.fake().unwrap().recorded()[0].url, "http://localhost:9200/_bulk");
916    }
917}