1use crate::client::{SearchClient, encode};
16use crate::error::{Result, SearchError};
17use rustlavel_core::Json;
18use rustlavel_http::Method;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum WriteResult {
23 Created,
24 Updated,
25 Deleted,
26 NoOp,
30 NotFound,
31 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct IndexedDocument {
64 pub index: String,
65 pub id: String,
67 pub version: i64,
68 pub result: WriteResult,
69 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#[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 pub fn field(&self, path: &str) -> Option<&Json> {
113 self.source.get(path)
114 }
115
116 pub fn string(&self, path: &str) -> Option<&str> {
118 self.source.get(path).and_then(Json::as_str)
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum Action {
125 Index,
127 Create,
129 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#[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 pub fn index(document: Json) -> BulkOperation {
161 BulkOperation { action: Action::Index, index: None, id: None, document }
162 }
163
164 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 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 pub fn id(mut self, id: impl Into<String>) -> BulkOperation {
199 self.id = Some(id.into());
200 self
201 }
202
203 pub fn in_index(mut self, index: impl Into<String>) -> BulkOperation {
205 self.index = Some(index.into());
206 self
207 }
208
209 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 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#[derive(Debug, Clone, PartialEq)]
240pub struct BulkItem {
241 pub action: String,
243 pub index: String,
244 pub id: String,
245 pub status: u16,
248 pub result: Option<WriteResult>,
249 pub error: Option<SearchError>,
252}
253
254impl BulkItem {
255 fn parse(body: &Json) -> BulkItem {
256 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 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#[derive(Debug, Clone, PartialEq)]
300pub struct BulkReport {
301 pub took: u64,
303 pub errors: bool,
305 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 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 pub fn failures(&self) -> Vec<&BulkItem> {
344 self.items.iter().filter(|item| item.is_failure()).collect()
345 }
346
347 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
597fn 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 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 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 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 #[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 .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 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 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 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}