Skip to main content

haste_worker/search_indexing/
mod.rs

1use crate::{
2    indexing_lock::{IndexLockProvider, postgres::TenantLockIndex},
3    traits::Worker,
4};
5use haste_fhir_model::r4::generated::resources::ResourceTypeError;
6use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
7use haste_fhir_search::{
8    IndexResource, SearchEngine,
9    elastic_search::{
10        ElasticSearchEngine, create_es_client,
11        search_parameter_resolver::ElasticSearchParameterResolver,
12    },
13};
14use haste_fhirpath::FHIRPathError;
15use haste_jwt::{TenantId, VersionId};
16use haste_repository::{
17    failed_indexing::{FailedIndexRecord, FailedIndexingProvider},
18    fhir::FHIRRepository,
19    pg::PGConnection,
20    sequence::{ResourcePollingValue, ResourceSequential},
21    types::SupportedFHIRVersions,
22};
23use serde::{Deserialize, Serialize};
24use sqlx::{Acquire, query_as, types::time::OffsetDateTime};
25use std::{sync::Arc, time::Instant};
26use tokio::{sync::Mutex, task::JoinHandle};
27
28#[derive(OperationOutcomeError, Debug)]
29pub enum IndexingWorkerError {
30    #[fatal(code = "exception", diagnostic = "Database error: '{arg0}'")]
31    DatabaseConnectionError(#[from] sqlx::Error),
32    #[fatal(code = "exception", diagnostic = "Lock error: '{arg0}'")]
33    OperationError(#[from] OperationOutcomeError),
34    #[fatal(code = "exception", diagnostic = "Elasticsearch error: '{arg0}'")]
35    ElasticsearchError(#[from] elasticsearch::Error),
36    #[fatal(code = "exception", diagnostic = "FHIRPath error: '{arg0}'")]
37    FHIRPathError(#[from] FHIRPathError),
38    #[fatal(
39        code = "exception",
40        diagnostic = "Missing search parameters for resource: '{arg0}'"
41    )]
42    MissingSearchParameters(String),
43    #[fatal(
44        code = "exception",
45        diagnostic = "Fatal error occurred during indexing"
46    )]
47    Fatal,
48    #[fatal(
49        code = "exception",
50        diagnostic = "Artifact error: Invalid resource type '{arg0}'"
51    )]
52    ResourceTypeError(#[from] ResourceTypeError),
53}
54
55struct TenantReturn {
56    id: TenantId,
57    created_at: OffsetDateTime,
58}
59
60async fn get_tenants(
61    repo: &PGConnection,
62    cursor: &OffsetDateTime,
63    count: i64,
64) -> Result<Vec<TenantReturn>, OperationOutcomeError> {
65    match repo {
66        PGConnection::Pool(pool, _) => {
67            let mut connection = pool.acquire().await.map_err(IndexingWorkerError::from)?;
68            let conn = connection
69                .acquire()
70                .await
71                .map_err(IndexingWorkerError::from)?;
72            let result = query_as!(
73                TenantReturn,
74                r#"SELECT id as "id: TenantId", created_at FROM tenants WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2"#,
75                cursor,
76                count
77            )
78            .fetch_all(&mut *conn)
79            .await
80            .map_err(IndexingWorkerError::from)?;
81
82            Ok(result)
83        }
84        PGConnection::Transaction(tx, _, _) => {
85            let mut connection = tx.lock().await;
86            let conn = connection
87                .acquire()
88                .await
89                .map_err(IndexingWorkerError::from)?;
90            let result = query_as!(
91                TenantReturn,
92                r#"SELECT id as "id: TenantId", created_at FROM tenants WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2"#,
93                cursor,
94                count
95            )
96            .fetch_all(&mut *conn)
97            .await
98            .map_err(IndexingWorkerError::from)?;
99
100            Ok(result)
101        }
102    }
103}
104
105/// Records in `FailedIndexingProvider` if any resources failed to index. This is a no-op if the list is empty.
106async fn record_failures(
107    tenant: &TenantId,
108    indexing_error_provider: &impl FailedIndexingProvider,
109    failures: &[FailedIndexRecord],
110) -> Result<(), IndexingWorkerError> {
111    if failures.is_empty() {
112        return Ok(());
113    }
114
115    tracing::warn!(
116        "Parking {} resource(s) that failed indexing for tenant '{}'.",
117        failures.len(),
118        tenant
119    );
120
121    indexing_error_provider.record_failures(failures).await?;
122
123    Ok(())
124}
125
126async fn update_lock_sequence_position<
127    Repo: ResourceSequential + IndexLockProvider<TenantId, TenantLockIndex>,
128>(
129    tenant_id: &TenantId,
130    repo: &Repo,
131    start_sequence: Option<i64>,
132    resources_total: usize,
133    start: Instant,
134    last_polling_value: ResourcePollingValue,
135) -> Result<(), OperationOutcomeError> {
136    let diff = (last_polling_value.sequence + 1) - start_sequence.unwrap_or(0);
137    let total = resources_total;
138
139    if total as u64 != diff.unsigned_abs() {
140        tracing::event!(
141            tracing::Level::WARN,
142            // safe_seq = resource.max_safe_seq.unwrap_or(0),
143            first_seq = start_sequence.unwrap_or(0),
144            last_seq = last_polling_value.sequence,
145            total = resources_total,
146            diff = (last_polling_value.sequence + 1) - start_sequence.unwrap_or(0),
147            "Sequence gap detected while indexing tenant '{}' - resources may have been skipped.",
148            tenant_id
149        );
150    }
151
152    tracing::trace!(
153        "Updating lock for tenant '{}' to sequence position {}.",
154        tenant_id,
155        last_polling_value.sequence
156    );
157
158    repo.update_lock(
159        tenant_id,
160        TenantLockIndex {
161            id: tenant_id.clone(),
162            index_sequence_position: last_polling_value.sequence,
163        },
164    )
165    .await?;
166
167    tracing::trace!(
168        "Indexed {} resources for tenant '{}' in {:.2?} (up to sequence {})",
169        resources_total,
170        tenant_id.as_ref(),
171        start.elapsed(),
172        last_polling_value.sequence
173    );
174
175    Ok(())
176}
177
178static TOTAL_INDEXED: std::sync::LazyLock<Mutex<usize>> =
179    std::sync::LazyLock::new(|| Mutex::new(0));
180
181async fn index_tenant_next_sequence<
182    Repo: ResourceSequential + IndexLockProvider<TenantId, TenantLockIndex> + FailedIndexingProvider,
183    Engine: SearchEngine,
184>(
185    max_concurrent_limit: u64,
186    search_client: Arc<Engine>,
187    repo: &Repo,
188    tenant_id: &TenantId,
189) -> Result<(), IndexingWorkerError> {
190    let start = std::time::Instant::now();
191    let tenant_locks = repo.get_available_locks(vec![tenant_id]).await?;
192
193    if tenant_locks.is_empty() {
194        tracing::info!(
195            "No available locks for tenant '{}', skipping indexing.",
196            tenant_id
197        );
198        return Ok(());
199    }
200
201    tracing::trace!(
202        "Acquired lock for tenant '{}', starting indexing from sequence {}.",
203        tenant_id,
204        tenant_locks[0].index_sequence_position
205    );
206
207    let resources = repo
208        .get_sequence(
209            tenant_id,
210            tenant_locks[0].index_sequence_position.cast_unsigned(),
211            Some(max_concurrent_limit),
212        )
213        .await?;
214
215    let resources_total = resources.len();
216    let start_sequence = resources.first().map(|r| r.sequence);
217    let last_value = resources.last().cloned();
218
219    // Perform indexing if there are resources to index.
220    if !resources.is_empty() {
221        let outcome = search_client
222            .index(
223                SupportedFHIRVersions::R4,
224                resources
225                    .into_iter()
226                    .map(|r| IndexResource {
227                        tenant: r.tenant,
228                        id: r.id,
229                        version_id: VersionId::new(r.version_id),
230                        project: r.project,
231                        fhir_method: r.fhir_method,
232                        resource_type: r.resource_type,
233                        resource: r.resource.0,
234                    })
235                    .collect(),
236            )
237            .await?;
238        let resources_attempted_to_index_count = outcome.succeeded + outcome.failed.len();
239
240        if resources_attempted_to_index_count != resources_total {
241            tracing::error!(
242                "Indexed+failed resource count '{}' does not match retrieved resource count '{}' for tenant '{}'",
243                resources_attempted_to_index_count,
244                resources_total,
245                tenant_id
246            );
247            return Err(IndexingWorkerError::Fatal);
248        }
249
250        let failures = outcome
251            .failed
252            .into_iter()
253            .map(|failure| FailedIndexRecord {
254                tenant: failure.resource.tenant,
255                project: failure.resource.project,
256                version_id: failure.resource.version_id,
257                resource_type: failure.resource.resource_type.as_ref().to_string(),
258                fhir_method: failure.resource.fhir_method,
259                error_message: failure.error.to_string(),
260            })
261            .collect::<Vec<_>>();
262
263        record_failures(tenant_id, repo, &failures).await?;
264
265        if let Some(last_polling_value) = last_value {
266            update_lock_sequence_position(
267                tenant_id,
268                repo,
269                start_sequence,
270                resources_total,
271                start,
272                last_polling_value,
273            )
274            .await?;
275        }
276
277        *(TOTAL_INDEXED.lock().await) += outcome.succeeded;
278    }
279
280    Ok(())
281}
282
283async fn index_for_tenant<
284    Search: SearchEngine,
285    Repository: FHIRRepository
286        + ResourceSequential
287        + IndexLockProvider<TenantId, TenantLockIndex>
288        + FailedIndexingProvider,
289>(
290    max_concurrent_limit: u64,
291    repo: Arc<Repository>,
292    search_client: Arc<Search>,
293    tenant_id: &TenantId,
294) -> Result<(), IndexingWorkerError> {
295    let tx = repo
296        .transaction(false)
297        .await
298        .map_err(IndexingWorkerError::from)?;
299    let res = index_tenant_next_sequence(max_concurrent_limit, search_client, &tx, tenant_id).await;
300
301    match res {
302        Ok(res) => {
303            tx.commit().await?;
304            Ok(res)
305        }
306        Err(e) => {
307            if let Err(rollback_err) = tx.rollback().await {
308                tracing::error!(
309                    "Failed to roll back transaction for tenant '{}' (original error: '{:?}'): '{:?}'",
310                    tenant_id,
311                    e,
312                    rollback_err
313                );
314                return Err(rollback_err.into());
315            }
316            Err(e)
317        }
318    }
319}
320
321pub enum IndexingWorkerEnvironmentVariables {
322    DatabaseURL,
323    ElasticSearchURL,
324    ElasticSearchUsername,
325    ElasticSearchPassword,
326}
327
328impl From<IndexingWorkerEnvironmentVariables> for String {
329    fn from(value: IndexingWorkerEnvironmentVariables) -> Self {
330        match value {
331            IndexingWorkerEnvironmentVariables::DatabaseURL => "DATABASE_URL".to_string(),
332            IndexingWorkerEnvironmentVariables::ElasticSearchURL => "ELASTICSEARCH_URL".to_string(),
333            IndexingWorkerEnvironmentVariables::ElasticSearchUsername => {
334                "ELASTICSEARCH_USERNAME".to_string()
335            }
336            IndexingWorkerEnvironmentVariables::ElasticSearchPassword => {
337                "ELASTICSEARCH_PASSWORD".to_string()
338            }
339        }
340    }
341}
342
343pub struct IndexingWorker {
344    max_concurrent_limit: Option<u64>,
345    running: Arc<tokio::sync::Mutex<bool>>,
346    repo: Arc<PGConnection>,
347    search_engine: Arc<ElasticSearchEngine<ElasticSearchParameterResolver<PGConnection>>>,
348}
349
350#[derive(Clone, Deserialize, Serialize)]
351#[serde(default)]
352pub struct WorkerEnvironment {
353    pub max_concurrent_limit: Option<u64>,
354    pub repo: RepoConfig,
355    pub search: SearchConfig,
356}
357
358// Repo backend where the FHIR server stores its data/resources.
359#[derive(Clone, Deserialize, Serialize)]
360#[serde(tag = "backend", rename_all = "snake_case")]
361pub enum RepoConfig {
362    Postgres(PostgresConfig),
363}
364
365#[derive(Clone, Deserialize, Serialize)]
366pub struct PostgresConfig {
367    pub database_url: String,
368    pub max_connections: u32,
369}
370
371#[derive(Clone, Deserialize, Serialize)]
372pub struct ElasticsearchConfig {
373    pub url: String,
374    pub username: String,
375    pub password: String,
376}
377
378// Search backend where the FHIR server stores its search indices.
379#[derive(Clone, Deserialize, Serialize)]
380#[serde(tag = "backend", rename_all = "snake_case")]
381pub enum SearchConfig {
382    Elasticsearch(ElasticsearchConfig),
383}
384
385impl Default for WorkerEnvironment {
386    fn default() -> Self {
387        Self {
388            max_concurrent_limit: Some(1000),
389            repo: RepoConfig::default(),
390            search: SearchConfig::default(),
391        }
392    }
393}
394
395impl Default for RepoConfig {
396    fn default() -> Self {
397        RepoConfig::Postgres(PostgresConfig::default())
398    }
399}
400impl Default for PostgresConfig {
401    fn default() -> Self {
402        Self {
403            database_url: "postgresql://postgres:postgres@localhost:5432/haste_health".into(),
404            max_connections: 10,
405        }
406    }
407}
408impl Default for SearchConfig {
409    fn default() -> Self {
410        SearchConfig::Elasticsearch(ElasticsearchConfig::default())
411    }
412}
413impl Default for ElasticsearchConfig {
414    fn default() -> Self {
415        Self {
416            url: "http://localhost:9200".into(),
417            username: "elastic".into(),
418            password: "elastic".into(),
419        }
420    }
421}
422
423async fn create_repo(config: &RepoConfig) -> Result<Arc<PGConnection>, OperationOutcomeError> {
424    match config {
425        RepoConfig::Postgres(pg_config) => {
426            let pool = sqlx::PgPool::connect(&pg_config.database_url)
427                .await
428                .map_err(IndexingWorkerError::from)?;
429            Ok(Arc::new(PGConnection::pool(pool)))
430        }
431    }
432}
433
434fn create_search_engine(
435    config: &SearchConfig,
436    repo: &Arc<PGConnection>,
437) -> Result<
438    Arc<ElasticSearchEngine<ElasticSearchParameterResolver<PGConnection>>>,
439    OperationOutcomeError,
440> {
441    match config {
442        SearchConfig::Elasticsearch(elasticsearch_config) => {
443            let es_client = create_es_client(
444                &elasticsearch_config.url,
445                elasticsearch_config.username.clone(),
446                elasticsearch_config.password.clone(),
447            )?;
448            let search_engine = Arc::new(ElasticSearchEngine::new(
449                Arc::new(ElasticSearchParameterResolver::new(
450                    es_client.clone(),
451                    repo.clone(),
452                )),
453                Arc::new(haste_fhirpath::FPEngine::new()),
454                es_client,
455            ));
456
457            Ok(search_engine)
458        }
459    }
460}
461
462impl IndexingWorker {
463    /// Creates and initializes a new worker.
464    ///
465    /// This initializes the repository and search engine using the provided
466    /// configuration, then waits for the search engine to become available.
467    /// The search engine connection is retried up to 5 times, with a 5-second
468    /// delay between attempts.
469    ///
470    /// # Arguments
471    ///
472    /// * `config` - Shared worker configuration containing the repository,
473    ///   search engine, and concurrency settings.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if:
478    /// - creating the repository fails.
479    /// - creating the search engine fails.
480    /// - the search engine remains unavailable after 5 connection attempts.
481    ///
482    /// # Returns
483    ///
484    /// Returns an initialized [`Self`] with the repository and search engine
485    /// ready for use.
486    pub async fn new(config: Arc<WorkerEnvironment>) -> Result<Self, OperationOutcomeError> {
487        let repo = create_repo(&config.repo).await?;
488        let search_engine = create_search_engine(&config.search, &repo)?;
489
490        let mut attempts = 0;
491        while search_engine.is_connected().await.is_err() && attempts < 5 {
492            tracing::error!("Elasticsearch is not connected, retrying in 5 seconds...");
493            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
494            attempts += 1;
495        }
496
497        if search_engine.is_connected().await.is_err() {
498            return Err(OperationOutcomeError::fatal(
499                haste_fhir_model::r4::generated::terminology::IssueType::exception(),
500                "Elasticsearch is not connected after 5 attempts".to_string(),
501            ));
502        }
503
504        Ok(Self {
505            max_concurrent_limit: config.max_concurrent_limit,
506            running: Arc::new(tokio::sync::Mutex::new(true)),
507            repo,
508            search_engine,
509        })
510    }
511}
512
513impl Worker for IndexingWorker {
514    async fn run(&self) -> Result<JoinHandle<()>, OperationOutcomeError> {
515        let mut cursor = OffsetDateTime::UNIX_EPOCH;
516        let tenants_limit: u64 = 100;
517
518        tracing::info!("Starting indexing worker...");
519
520        let mut k = *TOTAL_INDEXED.lock().await;
521
522        let repo = self.repo.clone();
523        let search_engine: Arc<ElasticSearchEngine<ElasticSearchParameterResolver<PGConnection>>> =
524            self.search_engine.clone();
525        let running = self.running.clone();
526        let max_concurrent_limit = self.max_concurrent_limit.unwrap_or(1000);
527
528        let spawned = tokio::spawn(async move {
529            while *running.lock().await {
530                let tenants_to_check =
531                    get_tenants(repo.as_ref(), &cursor, tenants_limit.cast_signed()).await;
532
533                // Nothing to do this iteration (no tenants, or the fetch itself
534                // failed) - back off instead of hammering Postgres in a tight spin.
535                let idle = match tenants_to_check {
536                    Ok(tenants_to_check) => {
537                        let idle = tenants_to_check.is_empty();
538                        if idle || (tenants_to_check.len() as u64) < tenants_limit {
539                            cursor = OffsetDateTime::UNIX_EPOCH; // Reset cursor if no tenants found
540                        } else {
541                            cursor = tenants_to_check[0].created_at;
542                        }
543
544                        for tenant in tenants_to_check {
545                            tracing::trace!("Indexing tenant: '{}'", &tenant.id);
546
547                            let result = index_for_tenant(
548                                max_concurrent_limit,
549                                repo.clone(),
550                                search_engine.clone(),
551                                &tenant.id,
552                            )
553                            .await;
554
555                            if let Err(error) = result {
556                                tracing::error!(
557                                    "Failed to index tenant: '{}' cause: '{:?}'",
558                                    &tenant.id,
559                                    error
560                                );
561                            }
562                        }
563
564                        idle
565                    }
566                    Err(error) => {
567                        tracing::error!("Failed to retrieve tenants: {:?}", error);
568                        true
569                    }
570                };
571
572                if k != *TOTAL_INDEXED.lock().await {
573                    k = *TOTAL_INDEXED.lock().await;
574                    tracing::info!("TOTAL INDEXED SO FAR: {}", k);
575                }
576
577                if idle {
578                    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
579                }
580            }
581        });
582
583        Ok(spawned)
584    }
585
586    async fn stop(&mut self) -> Result<(), OperationOutcomeError> {
587        let mut running = self.running.lock().await;
588        *running = false;
589        Ok(())
590    }
591}