1use crate::{
2 IndexFailure, IndexOutcome, IndexResource, ParameterLevel, ResolvedParameter, SearchEngine,
3 SearchOptions, SearchParameterResolve, SearchReturn,
4 indexing_conversion::{self, DynamicParameterEntry, InsertableIndex},
5};
6use bytes::{Bytes, BytesMut};
7use elasticsearch::{
8 BulkOperation, BulkParts, Elasticsearch,
9 auth::Credentials,
10 cert::CertificateValidation,
11 http::{
12 Url,
13 request::Body,
14 transport::{BuildError, SingleNodeConnectionPool, TransportBuilder},
15 },
16 params::Refresh,
17};
18use haste_fhir_client::request::SearchRequest;
19use haste_fhir_model::r4::generated::{
20 resources::{Resource, ResourceType},
21 terminology::{BoundCode, IssueType, SearchParamType},
22};
23use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
24use haste_fhirpath::FPEngine;
25use haste_jwt::{ProjectId, ResourceId, TenantId, VersionId};
26use haste_repository::types::{FHIRMethod, SupportedFHIRVersions};
27use serde::Deserialize;
28use std::{collections::HashMap, sync::Arc};
29
30mod migration;
31mod search;
32pub mod search_parameter_resolver;
33
34#[derive(Deserialize, Debug)]
35struct SearchEntryPrivate {
36 pub id: Vec<ResourceId>,
37 pub resource_type: Vec<ResourceType>,
38 pub version_id: Vec<VersionId>,
39 pub project: Vec<ProjectId>,
40}
41
42static DYNAMIC_PARAMETER_INDEX_FIELD: &str = "dynamic_parameters";
43
44fn flatten_parameter_field_name(url: &str) -> String {
54 url.replace('.', "_")
55}
56
57#[derive(OperationOutcomeError, Debug)]
58pub enum SearchError {
59 #[fatal(
60 code = "exception",
61 diagnostic = "Failed to evaluate fhirpath expression."
62 )]
63 FHIRPathError(#[from] haste_fhirpath::FHIRPathError),
64 #[fatal(
65 code = "exception",
66 diagnostic = "Search does not support the fhir method: '{arg0:?}'"
67 )]
68 UnsupportedFHIRMethod(FHIRMethod),
69 #[fatal(
70 code = "exception",
71 diagnostic = "Failed to index resources server responded with status code: '{arg0}'"
72 )]
73 Fatal(u16),
74 #[fatal(
75 code = "exception",
76 diagnostic = "Elasticsearch server failed to index: '{arg0}'"
77 )]
78 ElasticsearchError(#[from] elasticsearch::Error),
79 #[fatal(
80 code = "exception",
81 diagnostic = "Elasticsearch server responded with an error: '{arg0}'"
82 )]
83 ElasticSearchResponseError(u16),
84 #[fatal(code = "transient", diagnostic = "Elasticsearch is not reachable.")]
85 NotConnected,
86}
87
88#[derive(OperationOutcomeError, Debug)]
89pub enum SearchConfigError {
90 #[fatal(code = "exception", diagnostic = "Failed to parse URL: '{arg0}'.")]
91 UrlParseError(String),
92 #[fatal(
93 code = "exception",
94 diagnostic = "Elasticsearch client creation failed."
95 )]
96 ElasticSearchConfigError(#[from] BuildError),
97 #[fatal(
98 code = "exception",
99 diagnostic = "Unsupported FHIR version for index: '{arg0}'"
100 )]
101 UnsupportedIndex(SupportedFHIRVersions),
102}
103
104#[derive(Clone)]
105pub struct ElasticSearchEngine<SearchParameterResolver: SearchParameterResolve + 'static> {
106 parameter_resolver: Arc<SearchParameterResolver>,
107 fp_engine: Arc<FPEngine>,
108 client: Arc<Elasticsearch>,
109 prune_removed_search_parameters: bool,
114}
115
116pub fn create_es_client(
125 url: &str,
126 username: String,
127 password: String,
128) -> Result<Arc<Elasticsearch>, SearchConfigError> {
129 let url = Url::parse(url).map_err(|_e| SearchConfigError::UrlParseError(url.to_string()))?;
130 let conn_pool = SingleNodeConnectionPool::new(url);
131 let transport = TransportBuilder::new(conn_pool)
132 .cert_validation(CertificateValidation::None)
133 .auth(Credentials::Basic(username, password))
134 .build()?;
135
136 let elasticsearch_client = Elasticsearch::new(transport);
137
138 Ok(Arc::new(elasticsearch_client))
139}
140
141type Tasks = tokio::task::JoinHandle<(IndexResource, Result<Bytes, OperationOutcomeError>)>;
142
143const TARGET_BULK_BATCH_BYTES: usize = 10 * 1024 * 1024;
146
147fn serialize_bulk_operation(
155 op: &BulkOperation<HashMap<String, InsertableIndex>>,
156) -> Result<Bytes, OperationOutcomeError> {
157 let mut buf = BytesMut::new();
158 op.write(&mut buf).map_err(SearchError::from)?;
159 Ok(buf.freeze())
160}
161
162struct CollectedOperations {
163 bulk_lines: Vec<Bytes>,
168 sent_resources: Vec<IndexResource>,
172 failed: Vec<IndexFailure>,
173}
174
175fn batch_lengths_by_size(sizes: &[usize], target_bytes: usize) -> Vec<usize> {
178 let mut batch_lengths = Vec::new();
179 let mut current_len = 0usize;
180 let mut current_size = 0usize;
181
182 for &size in sizes {
183 if current_len > 0 && current_size + size > target_bytes {
184 batch_lengths.push(current_len);
185 current_len = 0;
186 current_size = 0;
187 }
188
189 current_len += 1;
190 current_size += size;
191 }
192
193 if current_len > 0 {
194 batch_lengths.push(current_len);
195 }
196
197 batch_lengths
198}
199
200fn batch_by_byte_size(
204 bulk_lines: Vec<Bytes>,
205 sent_resources: Vec<IndexResource>,
206 target_bytes: usize,
207) -> Vec<(Vec<Bytes>, Vec<IndexResource>)> {
208 let sizes: Vec<usize> = bulk_lines.iter().map(Bytes::len).collect();
209
210 let mut lines_iter = bulk_lines.into_iter();
211 let mut resources_iter = sent_resources.into_iter();
212
213 batch_lengths_by_size(&sizes, target_bytes)
214 .into_iter()
215 .map(|len| {
216 (
217 (&mut lines_iter).take(len).collect(),
218 (&mut resources_iter).take(len).collect(),
219 )
220 })
221 .collect()
222}
223
224#[derive(Deserialize, Debug)]
226struct BulkResponse {
227 #[allow(dead_code)]
229 took: u64,
230 items: Vec<serde_json::Value>,
232 #[allow(dead_code)]
234 errors: bool,
235}
236
237fn process_bulk_response(
242 bulk_response: &BulkResponse,
243 sent_resources: Vec<IndexResource>,
244) -> Result<IndexOutcome, OperationOutcomeError> {
245 if bulk_response.items.len() != sent_resources.len() {
246 return Err(OperationOutcomeError::fatal(
247 IssueType::exception(),
248 format!(
249 "Elasticsearch bulk response item count '{}' did not match request count '{}'.",
250 bulk_response.items.len(),
251 sent_resources.len()
252 ),
253 ));
254 }
255
256 let mut succeeded = 0;
257 let mut failed = Vec::new();
258
259 for (item, resource) in bulk_response.items.iter().zip(sent_resources) {
263 if let Some(op_result) = item.as_object().and_then(|o| o.values().next()) {
265 match op_result["status"].as_u64() {
266 Some(status) if (200..300).contains(&status) => succeeded += 1,
267 status => {
268 let reason = op_result["error"]["reason"]
269 .as_str()
270 .unwrap_or("unknown error");
271 failed.push(IndexFailure {
272 error: OperationOutcomeError::fatal(
273 IssueType::exception(),
274 format!("Elasticsearch indexing failed (status {status:?}): {reason}"),
275 ),
276 resource,
277 });
278 }
279 }
280 } else {
281 failed.push(IndexFailure {
282 error: OperationOutcomeError::fatal(
283 IssueType::exception(),
284 format!("Unexpected Elasticsearch bulk item shape: '{item}'"),
285 ),
286 resource,
287 });
288 }
289 }
290
291 if !failed.is_empty() {
292 tracing::error!(
293 "Elasticsearch bulk index reported {} failed item(s) out of {}.",
294 failed.len(),
295 bulk_response.items.len()
296 );
297 }
298
299 Ok(IndexOutcome { succeeded, failed })
300}
301
302impl<SearchParameterResolver: SearchParameterResolve + 'static>
303 ElasticSearchEngine<SearchParameterResolver>
304{
305 pub fn new(
306 parameter_resolver: Arc<SearchParameterResolver>,
307 fp_engine: Arc<FPEngine>,
308 es_client: Arc<Elasticsearch>,
309 prune_removed_search_parameters: bool,
310 ) -> Self {
311 ElasticSearchEngine {
312 parameter_resolver,
313 fp_engine,
314 client: es_client,
315 prune_removed_search_parameters,
316 }
317 }
318
319 pub async fn is_connected(&self) -> Result<(), SearchError> {
328 let res = self.client.ping().send().await.map_err(SearchError::from)?;
329
330 if res.status_code().is_success() {
331 Ok(())
332 } else {
333 Err(SearchError::NotConnected)
334 }
335 }
336
337 async fn send_bulk_operations(
346 &self,
347 search_index_name: &'static str,
348 bulk_lines: Vec<Bytes>,
349 sent_resources: Vec<IndexResource>,
350 searchable: bool,
351 ) -> Result<IndexOutcome, OperationOutcomeError> {
352 if bulk_lines.is_empty() {
353 return Ok(IndexOutcome {
354 succeeded: 0,
355 failed: Vec::new(),
356 });
357 }
358
359 let request = self
360 .client
361 .bulk(BulkParts::Index(search_index_name))
362 .body(bulk_lines);
363 let request = if searchable {
364 request.refresh(Refresh::True)
365 } else {
366 request
367 };
368 let res = request.send().await.map_err(SearchError::from)?;
369
370 let response_body = res.json::<BulkResponse>().await.map_err(|_e| {
371 OperationOutcomeError::fatal(
372 IssueType::exception(),
373 "Failed to parse response body.".to_string(),
374 )
375 })?;
376
377 process_bulk_response(&response_body, sent_resources)
378 }
379
380 async fn index_part(
384 &self,
385 resources: Vec<IndexResource>,
386 searchable: bool,
387 ) -> Result<IndexOutcome, OperationOutcomeError> {
388 let resources_total = resources.len();
389 let search_index_name = get_index_name();
390
391 tracing::trace!(
392 "Indexing {} resources into index: '{}'",
393 resources_total,
394 search_index_name
395 );
396
397 let tasks = self.spawn_index_tasks(resources, search_index_name);
398 let CollectedOperations {
399 bulk_lines,
400 sent_resources,
401 mut failed,
402 } = self.collect_bulk_operations(tasks).await?;
403
404 let built_count = bulk_lines.len();
405 let batches = batch_by_byte_size(bulk_lines, sent_resources, TARGET_BULK_BATCH_BYTES);
406
407 tracing::trace!(
408 "Bulk indexing {} resources into index '{}' across {} byte-sized batch(es)",
409 built_count,
410 search_index_name,
411 batches.len()
412 );
413
414 let mut outcome = IndexOutcome {
415 succeeded: 0,
416 failed: Vec::new(),
417 };
418
419 for (batch_lines, batch_resources) in batches {
420 let batch_outcome = self
421 .send_bulk_operations(search_index_name, batch_lines, batch_resources, searchable)
422 .await?;
423 outcome.succeeded += batch_outcome.succeeded;
424 outcome.failed.extend(batch_outcome.failed);
425 }
426
427 outcome.failed.append(&mut failed);
428
429 Ok(outcome)
430 }
431
432 fn spawn_index_tasks(
433 &self,
434 resources: Vec<IndexResource>,
435 search_index_name: &'static str,
436 ) -> Vec<Tasks> {
437 resources
438 .into_iter()
439 .map(|r| {
440 let engine = self.fp_engine.clone();
441 let parameter_resolver = self.parameter_resolver.clone();
442
443 tokio::spawn(async move {
444 Self::build_bulk_operation(engine, parameter_resolver, r, search_index_name)
445 .await
446 })
447 })
448 .collect()
449 }
450
451 async fn collect_bulk_operations(
458 &self,
459 tasks: Vec<Tasks>,
460 ) -> Result<CollectedOperations, OperationOutcomeError> {
461 tracing::trace!("Awaiting {} indexing tasks.", tasks.len());
462
463 let mut bulk_lines = Vec::with_capacity(tasks.len());
464 let mut sent_resources = Vec::with_capacity(tasks.len());
465 let mut failed = Vec::new();
466
467 for task in tasks {
468 let (resource, result) = task
469 .await
470 .map_err(|e| OperationOutcomeError::fatal(IssueType::exception(), e.to_string()))?;
471
472 match result {
473 Ok(bytes) => {
474 sent_resources.push(resource);
475 bulk_lines.push(bytes);
476 }
477 Err(error) => failed.push(IndexFailure { resource, error }),
478 }
479 }
480
481 Ok(CollectedOperations {
482 bulk_lines,
483 sent_resources,
484 failed,
485 })
486 }
487
488 async fn build_bulk_operation<ParameterResolver: SearchParameterResolve>(
489 engine: Arc<FPEngine>,
490 parameter_resolver: Arc<ParameterResolver>,
491 resource: IndexResource,
492 search_index_name: &'static str,
493 ) -> (IndexResource, Result<Bytes, OperationOutcomeError>) {
494 let result = match &resource.fhir_method {
495 FHIRMethod::Create | FHIRMethod::Update => {
496 Self::build_index_operation(
497 engine,
498 parameter_resolver,
499 &resource,
500 search_index_name,
501 )
502 .await
503 }
504
505 FHIRMethod::Delete => {
506 let index_id = unique_index_id(
507 &resource.tenant,
508 &resource.project,
509 &resource.resource_type,
510 &resource.id,
511 );
512 let op: BulkOperation<HashMap<String, InsertableIndex>> =
513 BulkOperation::delete(index_id)
514 .index(search_index_name)
515 .into();
516
517 serialize_bulk_operation(&op)
518 }
519
520 method @ FHIRMethod::Read => Err(OperationOutcomeError::from(
521 SearchError::UnsupportedFHIRMethod((*method).clone()),
522 )),
523 };
524
525 (resource, result)
526 }
527
528 async fn build_index_operation<ParameterResolver: SearchParameterResolve>(
529 engine: Arc<FPEngine>,
530 parameter_resolver: Arc<ParameterResolver>,
531 resource: &IndexResource,
532 search_index_name: &'static str,
533 ) -> Result<Bytes, OperationOutcomeError> {
534 let index_id = unique_index_id(
537 &resource.tenant,
538 &resource.project,
539 &resource.resource_type,
540 &resource.id,
541 );
542
543 let params = parameter_resolver
544 .by_resource_type(&resource.tenant, &resource.project, &resource.resource_type)
545 .await?;
546
547 let mut elastic_index =
548 resource_to_elastic_index(engine, ¶ms, &resource.resource).await?;
549
550 Self::add_index_metadata(&mut elastic_index, resource);
551
552 let op = BulkOperation::index(elastic_index)
553 .id(index_id)
554 .index(search_index_name)
555 .into();
556
557 serialize_bulk_operation(&op)
558 }
559
560 fn add_index_metadata(
561 elastic_index: &mut HashMap<String, InsertableIndex>,
562 resource: &IndexResource,
563 ) {
564 elastic_index.insert(
565 "resource_type".to_string(),
566 InsertableIndex::Meta(resource.resource_type.as_ref().to_string()),
567 );
568
569 elastic_index.insert(
570 "id".to_string(),
571 InsertableIndex::Meta(resource.id.as_ref().to_string()),
572 );
573
574 elastic_index.insert(
575 "version_id".to_string(),
576 InsertableIndex::Meta(resource.version_id.as_ref().to_string()),
577 );
578
579 elastic_index.insert(
580 "project".to_string(),
581 InsertableIndex::Meta(resource.project.as_ref().to_string()),
582 );
583
584 elastic_index.insert(
585 "tenant".to_string(),
586 InsertableIndex::Meta(resource.tenant.as_ref().to_string()),
587 );
588 }
589}
590
591pub(crate) fn is_mapped_search_parameter_type(type_: &BoundCode<SearchParamType>) -> bool {
597 type_ == &SearchParamType::number()
598 || type_ == &SearchParamType::string()
599 || type_ == &SearchParamType::uri()
600 || type_ == &SearchParamType::token()
601 || type_ == &SearchParamType::date()
602 || type_ == &SearchParamType::reference()
603 || type_ == &SearchParamType::quantity()
604}
605
606async fn resource_to_elastic_index(
607 fp_engine: Arc<FPEngine>,
608 parameters: &[ResolvedParameter],
609 resource: &Resource,
610) -> Result<HashMap<String, InsertableIndex>, OperationOutcomeError> {
611 let mut map = HashMap::new();
612 let mut dynamic_parameters = Vec::new();
613 for param in parameters {
614 if let Some(expression) = param
615 .search_parameter
616 .expression
617 .as_ref()
618 .and_then(|e| e.value.as_ref())
619 && let Some(url) = param.search_parameter.url.value.as_ref()
620 {
621 if matches!(param.level, ParameterLevel::System)
626 && !is_mapped_search_parameter_type(¶m.search_parameter.type_)
627 {
628 continue;
629 }
630
631 let result = fp_engine
632 .evaluate(expression, vec![resource])
633 .await
634 .map_err(SearchError::from);
635
636 if let Err(err) = result {
637 tracing::error!(
638 "Failed to evaluate FHIRPath expression: '{}' for resource.",
639 expression,
640 );
641
642 return Err(err.into());
643 }
644
645 let result_vec = indexing_conversion::to_insertable_index(
646 param,
647 &result?.iter().collect::<Vec<_>>(),
648 )?;
649
650 match param.level {
651 ParameterLevel::System => {
652 map.insert(flatten_parameter_field_name(url), result_vec);
653 }
654 ParameterLevel::Project => {
659 let type_ = param.search_parameter.type_.as_str().unwrap_or("string");
660 if let Some(entry) =
661 DynamicParameterEntry::from_leaf(url.clone(), type_, result_vec)
662 {
663 dynamic_parameters.push(entry);
664 }
665 }
666 }
667 }
668 }
669
670 map.insert(
672 DYNAMIC_PARAMETER_INDEX_FIELD.to_string(),
673 InsertableIndex::DynamicParameters(dynamic_parameters),
674 );
675
676 Ok(map)
677}
678
679#[allow(dead_code)]
680static R4_FHIR_INDEX_V1: &str = "r4_search_index";
681static R4_FHIR_INDEX_V2: &str = "r4_search_index_v2";
682
683#[must_use]
684pub const fn get_index_name() -> &'static str {
685 R4_FHIR_INDEX_V2
686}
687
688#[derive(serde::Deserialize, Debug)]
689struct ElasticSearchHitResult {
690 _index: String,
691 _id: String,
692 _score: Option<f64>,
693 fields: SearchEntryPrivate,
694}
695
696#[derive(serde::Deserialize, Debug)]
697struct ElasticSearchHitTotalMeta {
698 value: i64,
699 }
701
702#[derive(serde::Deserialize, Debug)]
703struct ElasticSearchHit {
704 total: Option<ElasticSearchHitTotalMeta>,
705 hits: Vec<ElasticSearchHitResult>,
706}
707
708#[derive(serde::Deserialize, Debug)]
709struct ElasticSearchResponse {
710 hits: ElasticSearchHit,
711}
712
713fn unique_index_id(
714 tenant: &TenantId,
715 project: &ProjectId,
716 resource_type: &ResourceType,
717 id: &ResourceId,
718) -> String {
719 let unique_index_id = format!(
720 "{}/{}/{}/{}",
721 tenant.as_ref(),
722 project.as_ref(),
723 resource_type.as_ref(),
724 id.as_ref()
725 );
726
727 unique_index_id
728}
729
730impl<SearchParameterResolver: SearchParameterResolve> SearchEngine
731 for ElasticSearchEngine<SearchParameterResolver>
732{
733 async fn search(
734 &self,
735 _fhir_version: &SupportedFHIRVersions,
736 tenant: &TenantId,
737 project: &ProjectId,
738 search_request: &SearchRequest,
739 options: Option<SearchOptions>,
740 ) -> Result<SearchReturn, haste_fhir_operation_error::OperationOutcomeError> {
741 search::execute_search(
742 self.client.clone(),
743 self.parameter_resolver.clone(),
744 tenant,
745 project,
746 search_request,
747 options.as_ref(),
748 )
749 .await
750 }
751
752 async fn index(
753 &self,
754 _fhir_version: SupportedFHIRVersions,
755 resources: Vec<IndexResource>,
756 ) -> Result<IndexOutcome, OperationOutcomeError> {
757 crate::project_parameters::index_search_parameters_first(
758 self.parameter_resolver.as_ref(),
759 resources,
760 |resources, searchable| self.index_part(resources, searchable),
761 )
762 .await
763 }
764
765 async fn migrate(
766 &self,
767 _fhir_version: &SupportedFHIRVersions,
768 ) -> Result<(), haste_fhir_operation_error::OperationOutcomeError> {
769 migration::create_mapping(
770 self.parameter_resolver.clone(),
771 &self.client,
772 get_index_name(),
773 self.prune_removed_search_parameters,
774 )
775 .await?;
776 Ok(())
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn flatten_parameter_field_name_strips_dots_only() {
786 assert_eq!(
787 flatten_parameter_field_name("http://hl7.org/fhir/SearchParameter/Patient-name"),
788 "http://hl7_org/fhir/SearchParameter/Patient-name"
789 );
790 }
791
792 #[test]
793 fn flatten_parameter_field_name_handles_multiple_dots() {
794 assert_eq!(
795 flatten_parameter_field_name("https://sub.acme.io/v1.2/x"),
796 "https://sub_acme_io/v1_2/x"
797 );
798 }
799
800 #[test]
801 fn batch_lengths_by_size_empty() {
802 assert_eq!(batch_lengths_by_size(&[], 100), Vec::<usize>::new());
803 }
804
805 #[test]
806 fn batch_lengths_by_size_all_fit_in_one_batch() {
807 assert_eq!(batch_lengths_by_size(&[10, 20, 30], 100), vec![3]);
808 }
809
810 #[test]
811 fn batch_lengths_by_size_splits_when_target_exceeded() {
812 assert_eq!(batch_lengths_by_size(&[40, 40, 40], 100), vec![2, 1]);
814 }
815
816 #[test]
817 fn batch_lengths_by_size_oversized_single_item_gets_its_own_batch() {
818 assert_eq!(batch_lengths_by_size(&[5, 500, 5], 100), vec![1, 1, 1]);
821 }
822
823 #[test]
824 fn batch_lengths_by_size_exact_fit_does_not_split_early() {
825 assert_eq!(batch_lengths_by_size(&[50, 50], 100), vec![2]);
826 }
827
828 #[test]
829 fn serialize_bulk_operation_index_produces_header_and_source_lines() {
830 let mut doc = HashMap::new();
831 doc.insert(
832 "resource_type".to_string(),
833 InsertableIndex::Meta("Patient".to_string()),
834 );
835
836 let op: BulkOperation<HashMap<String, InsertableIndex>> = BulkOperation::index(doc)
837 .id("t/p/Patient/1")
838 .index("r4_search_index_v2")
839 .into();
840
841 let bytes = serialize_bulk_operation(&op).unwrap();
842 let text = std::str::from_utf8(&bytes).unwrap();
843 let lines: Vec<&str> = text.lines().collect();
844
845 assert_eq!(lines.len(), 2);
849 assert!(lines[0].contains(r#""index""#));
850 assert!(lines[0].contains(r#""_id":"t/p/Patient/1""#));
851 assert!(text.ends_with('\n'));
852 }
853
854 #[test]
855 fn serialize_bulk_operation_delete_produces_only_a_header_line() {
856 let op: BulkOperation<HashMap<String, InsertableIndex>> =
857 BulkOperation::delete("t/p/Patient/1")
858 .index("r4_search_index_v2")
859 .into();
860
861 let bytes = serialize_bulk_operation(&op).unwrap();
862 let text = std::str::from_utf8(&bytes).unwrap();
863 let lines: Vec<&str> = text.lines().collect();
864
865 assert_eq!(lines.len(), 1);
867 assert!(lines[0].contains(r#""delete""#));
868 assert!(text.ends_with('\n'));
869 }
870}