Skip to main content

haste_worker/search_indexing/
mod.rs

1use crate::{
2    indexing_lock::{IndexLockProvider, postgres::ProjectLockIndex},
3    traits::Worker,
4};
5use haste_fhir_model::r4::generated::resources::ResourceTypeError;
6use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
7use haste_fhir_search::config::{SearchConfig, SearchEngineBackend};
8use haste_fhir_search::{IndexResource, SearchEngine};
9use haste_fhirpath::FHIRPathError;
10use haste_jwt::{ProjectId, TenantId, VersionId};
11use haste_repository::config::{RepoConfig, create_repo};
12use haste_repository::{
13    failed_indexing::{FailedIndexRecord, FailedIndexingProvider},
14    fhir::FHIRRepository,
15    pg::PGConnection,
16    sequence::{ResourcePollingValue, ResourceSequential},
17    types::{SearchIndexBackend, SupportedFHIRVersions},
18};
19use serde::{Deserialize, Serialize};
20use sqlx::{Acquire, query_as, types::time::OffsetDateTime};
21use std::{
22    sync::Arc,
23    time::{Duration, Instant},
24};
25use tokio::{
26    sync::{Mutex, Semaphore},
27    task::JoinHandle,
28};
29
30#[derive(OperationOutcomeError, Debug)]
31pub enum IndexingWorkerError {
32    #[fatal(code = "exception", diagnostic = "Database error: '{arg0}'")]
33    DatabaseConnectionError(#[from] sqlx::Error),
34    #[fatal(code = "exception", diagnostic = "Lock error: '{arg0}'")]
35    OperationError(#[from] OperationOutcomeError),
36    #[fatal(code = "exception", diagnostic = "Elasticsearch error: '{arg0}'")]
37    ElasticsearchError(#[from] elasticsearch::Error),
38    #[fatal(code = "exception", diagnostic = "FHIRPath error: '{arg0}'")]
39    FHIRPathError(#[from] FHIRPathError),
40    #[fatal(
41        code = "exception",
42        diagnostic = "Missing search parameters for resource: '{arg0}'"
43    )]
44    MissingSearchParameters(String),
45    #[fatal(
46        code = "exception",
47        diagnostic = "Fatal error occurred during indexing"
48    )]
49    Fatal,
50    #[fatal(
51        code = "exception",
52        diagnostic = "Artifact error: Invalid resource type '{arg0}'"
53    )]
54    ResourceTypeError(#[from] ResourceTypeError),
55}
56
57struct ProjectReturn {
58    tenant: TenantId,
59    project: ProjectId,
60    created_at: OffsetDateTime,
61}
62
63async fn get_projects(
64    repo: &PGConnection,
65    cursor: &OffsetDateTime,
66    count: i64,
67) -> Result<Vec<ProjectReturn>, OperationOutcomeError> {
68    match repo {
69        PGConnection::Pool(pool, _) => {
70            let mut connection = pool.acquire().await.map_err(IndexingWorkerError::from)?;
71            let conn = connection
72                .acquire()
73                .await
74                .map_err(IndexingWorkerError::from)?;
75            let result = query_as!(
76                ProjectReturn,
77                r#"SELECT tenant as "tenant: TenantId", id as "project: ProjectId", created_at FROM projects WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2"#,
78                cursor,
79                count
80            )
81            .fetch_all(&mut *conn)
82            .await
83            .map_err(IndexingWorkerError::from)?;
84
85            Ok(result)
86        }
87        PGConnection::Transaction(tx, _, _) => {
88            let mut connection = tx.lock().await;
89            let conn = connection
90                .acquire()
91                .await
92                .map_err(IndexingWorkerError::from)?;
93            let result = query_as!(
94                ProjectReturn,
95                r#"SELECT tenant as "tenant: TenantId", id as "project: ProjectId", created_at FROM projects WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2"#,
96                cursor,
97                count
98            )
99            .fetch_all(&mut *conn)
100            .await
101            .map_err(IndexingWorkerError::from)?;
102
103            Ok(result)
104        }
105    }
106}
107
108/// Records in `FailedIndexingProvider` if any resources failed to index. This is a no-op if the list is empty.
109async fn record_failures(
110    tenant: &TenantId,
111    project: &ProjectId,
112    indexing_error_provider: &impl FailedIndexingProvider,
113    failures: &[FailedIndexRecord],
114) -> Result<(), IndexingWorkerError> {
115    if failures.is_empty() {
116        return Ok(());
117    }
118
119    tracing::warn!(
120        "Parking {} resource(s) that failed indexing for tenant '{}' project '{}'.",
121        failures.len(),
122        tenant,
123        project.as_ref()
124    );
125
126    for failure in failures {
127        tracing::error!(
128            tenant = %tenant,
129            project = %project.as_ref(),
130            resource_type = %failure.resource_type,
131            version_id = failure.version_id.as_ref(),
132            fhir_method = failure.fhir_method.as_str(),
133            error = %failure.error_message,
134            "Search indexing failed for resource"
135        );
136    }
137
138    indexing_error_provider.record_failures(failures).await?;
139
140    Ok(())
141}
142
143/// The batch of resources indexed in one `index_project_next_sequence` pass,
144/// as measured just before and after the indexing call.
145struct IndexingBatchStats {
146    start_sequence: Option<i64>,
147    resources_total: usize,
148    start: Instant,
149    last_polling_value: ResourcePollingValue,
150}
151
152async fn update_lock_sequence_position<
153    Repo: ResourceSequential + IndexLockProvider<(TenantId, ProjectId), ProjectLockIndex>,
154>(
155    tenant_id: &TenantId,
156    project_id: &ProjectId,
157    backend: SearchIndexBackend,
158    repo: &Repo,
159    batch: IndexingBatchStats,
160) -> Result<(), OperationOutcomeError> {
161    let IndexingBatchStats {
162        start_sequence,
163        resources_total,
164        start,
165        last_polling_value,
166    } = batch;
167    let diff = (last_polling_value.sequence + 1) - start_sequence.unwrap_or(0);
168    let total = resources_total;
169
170    if total as u64 != diff.unsigned_abs() {
171        tracing::event!(
172            tracing::Level::WARN,
173            // safe_seq = resource.max_safe_seq.unwrap_or(0),
174            first_seq = start_sequence.unwrap_or(0),
175            last_seq = last_polling_value.sequence,
176            total = resources_total,
177            diff = (last_polling_value.sequence + 1) - start_sequence.unwrap_or(0),
178            "Sequence gap detected while indexing tenant '{}' project '{}' ({}) - resources may have been skipped.",
179            tenant_id,
180            project_id.as_ref(),
181            backend
182        );
183    }
184
185    tracing::trace!(
186        "Updating {} lock for tenant '{}' project '{}' to sequence position {}.",
187        backend,
188        tenant_id,
189        project_id.as_ref(),
190        last_polling_value.sequence
191    );
192
193    repo.update_lock(
194        backend,
195        &(tenant_id.clone(), project_id.clone()),
196        ProjectLockIndex {
197            tenant: tenant_id.clone(),
198            project: project_id.clone(),
199            index_sequence_position: last_polling_value.sequence,
200        },
201    )
202    .await?;
203
204    tracing::trace!(
205        "Indexed {} resources for tenant '{}' project '{}' in {:.2?} (up to sequence {})",
206        resources_total,
207        tenant_id.as_ref(),
208        project_id.as_ref(),
209        start.elapsed(),
210        last_polling_value.sequence
211    );
212
213    Ok(())
214}
215
216static TOTAL_INDEXED: std::sync::LazyLock<Mutex<usize>> =
217    std::sync::LazyLock::new(|| Mutex::new(0));
218
219async fn index_project_next_sequence<
220    Repo: ResourceSequential
221        + IndexLockProvider<(TenantId, ProjectId), ProjectLockIndex>
222        + FailedIndexingProvider,
223    Engine: SearchEngine,
224>(
225    max_concurrent_limit: u64,
226    backend: SearchIndexBackend,
227    search_client: Arc<Engine>,
228    repo: &Repo,
229    tenant_id: &TenantId,
230    project_id: &ProjectId,
231    safe_sequence: i64,
232) -> Result<usize, IndexingWorkerError> {
233    let start = std::time::Instant::now();
234    let lock_key = (tenant_id.clone(), project_id.clone());
235    let project_locks = repo.get_available_locks(backend, vec![&lock_key]).await?;
236
237    if project_locks.is_empty() {
238        tracing::info!(
239            "No available {} lock for tenant '{}' project '{}', skipping indexing.",
240            backend,
241            tenant_id,
242            project_id.as_ref()
243        );
244        return Ok(0);
245    }
246
247    tracing::trace!(
248        "Acquired {} lock for tenant '{}' project '{}', starting indexing from sequence {}.",
249        backend,
250        tenant_id,
251        project_id.as_ref(),
252        project_locks[0].index_sequence_position
253    );
254
255    let resources = repo
256        .get_sequence(
257            tenant_id,
258            project_id,
259            project_locks[0].index_sequence_position.cast_unsigned(),
260            safe_sequence,
261            Some(max_concurrent_limit),
262        )
263        .await?;
264
265    let resources_total = resources.len();
266    let start_sequence = resources.first().map(|r| r.sequence);
267    let last_value = resources.last().cloned();
268
269    // Perform indexing if there are resources to index.
270    if !resources.is_empty() {
271        let outcome = search_client
272            .index(
273                SupportedFHIRVersions::R4,
274                resources
275                    .into_iter()
276                    .map(|r| IndexResource {
277                        tenant: r.tenant,
278                        id: r.id,
279                        version_id: VersionId::new(r.version_id),
280                        project: r.project,
281                        fhir_method: r.fhir_method,
282                        resource_type: r.resource_type,
283                        resource: r.resource.0,
284                        sequence: r.sequence,
285                    })
286                    .collect(),
287            )
288            .await?;
289        let resources_attempted_to_index_count = outcome.succeeded + outcome.failed.len();
290
291        if resources_attempted_to_index_count != resources_total {
292            tracing::error!(
293                "Indexed+failed resource count '{}' does not match retrieved resource count '{}' for tenant '{}' project '{}'",
294                resources_attempted_to_index_count,
295                resources_total,
296                tenant_id,
297                project_id.as_ref()
298            );
299            return Err(IndexingWorkerError::Fatal);
300        }
301
302        let failures = outcome
303            .failed
304            .into_iter()
305            .map(|failure| FailedIndexRecord {
306                tenant: failure.resource.tenant,
307                project: failure.resource.project,
308                version_id: failure.resource.version_id,
309                resource_type: failure.resource.resource_type.as_ref().to_string(),
310                fhir_method: failure.resource.fhir_method,
311                error_message: failure.error.to_string(),
312            })
313            .collect::<Vec<_>>();
314
315        record_failures(tenant_id, project_id, repo, &failures).await?;
316
317        if let Some(last_polling_value) = last_value {
318            update_lock_sequence_position(
319                tenant_id,
320                project_id,
321                backend,
322                repo,
323                IndexingBatchStats {
324                    start_sequence,
325                    resources_total,
326                    start,
327                    last_polling_value,
328                },
329            )
330            .await?;
331        }
332
333        *(TOTAL_INDEXED.lock().await) += outcome.succeeded;
334    }
335
336    Ok(resources_total)
337}
338
339async fn index_for_project<
340    Search: SearchEngine,
341    Repository: FHIRRepository
342        + ResourceSequential
343        + IndexLockProvider<(TenantId, ProjectId), ProjectLockIndex>
344        + FailedIndexingProvider,
345>(
346    max_concurrent_limit: u64,
347    backend: SearchIndexBackend,
348    repo: Arc<Repository>,
349    search_client: Arc<Search>,
350    tenant_id: &TenantId,
351    project_id: &ProjectId,
352    safe_sequence: i64,
353) -> Result<usize, IndexingWorkerError> {
354    let tx = repo
355        .transaction(false)
356        .await
357        .map_err(IndexingWorkerError::from)?;
358    let res = index_project_next_sequence(
359        max_concurrent_limit,
360        backend,
361        search_client,
362        &tx,
363        tenant_id,
364        project_id,
365        safe_sequence,
366    )
367    .await;
368
369    match res {
370        Ok(res) => {
371            tx.commit().await?;
372            Ok(res)
373        }
374        Err(e) => {
375            if let Err(rollback_err) = tx.rollback().await {
376                tracing::error!(
377                    "Failed to roll back transaction for tenant '{}' project '{}' (original error: '{:?}'): '{:?}'",
378                    tenant_id,
379                    project_id.as_ref(),
380                    e,
381                    rollback_err
382                );
383                return Err(rollback_err.into());
384            }
385            Err(e)
386        }
387    }
388}
389
390/// Indexes every project of one pass concurrently, at most `semaphore`'s
391/// permits at a time, and returns the rows handed to the search engine across
392/// all of them. Zero means every project was already caught up to the
393/// watermark (or locked by another worker).
394async fn index_projects(
395    projects: Vec<ProjectReturn>,
396    repo: &Arc<PGConnection>,
397    search_engine: &Arc<SearchEngineBackend>,
398    semaphore: &Arc<Semaphore>,
399    max_concurrent_limit: u64,
400    backend: SearchIndexBackend,
401    safe_sequence: i64,
402) -> usize {
403    let handles: Vec<_> = projects
404        .into_iter()
405        .map(|project| {
406            tokio::spawn(index_one_project(
407                project,
408                repo.clone(),
409                search_engine.clone(),
410                semaphore.clone(),
411                max_concurrent_limit,
412                backend,
413                safe_sequence,
414            ))
415        })
416        .collect();
417
418    let mut fetched_this_pass = 0usize;
419    for handle in handles {
420        match handle.await {
421            Ok(fetched) => fetched_this_pass += fetched,
422            Err(join_error) => {
423                tracing::error!("Project indexing task panicked: '{:?}'", join_error);
424            }
425        }
426    }
427    fetched_this_pass
428}
429
430/// Indexes one project up to `safe_sequence` once a permit of `semaphore` is
431/// free, and returns the rows handed to the search engine (zero on failure,
432/// which is logged).
433async fn index_one_project(
434    project: ProjectReturn,
435    repo: Arc<PGConnection>,
436    search_engine: Arc<SearchEngineBackend>,
437    semaphore: Arc<Semaphore>,
438    max_concurrent_limit: u64,
439    backend: SearchIndexBackend,
440    safe_sequence: i64,
441) -> usize {
442    let _permit = match semaphore.acquire_owned().await {
443        Ok(permit) => permit,
444        Err(_closed) => {
445            tracing::warn!(
446                "Project semaphore closed; skipping indexing for tenant '{}' project '{}'.",
447                &project.tenant,
448                project.project.as_ref()
449            );
450            return 0;
451        }
452    };
453
454    tracing::trace!(
455        "Indexing tenant: '{}' project: '{}'",
456        &project.tenant,
457        project.project.as_ref()
458    );
459
460    // Boxed: the search engine's indexing future is large, and this one is
461    // spawned per project.
462    let result = Box::pin(index_for_project(
463        max_concurrent_limit,
464        backend,
465        repo,
466        search_engine,
467        &project.tenant,
468        &project.project,
469        safe_sequence,
470    ))
471    .await;
472
473    match result {
474        Ok(fetched) => fetched,
475        Err(error) => {
476            tracing::error!(
477                "Failed to index tenant: '{}' project: '{}' cause: '{:?}'",
478                &project.tenant,
479                project.project.as_ref(),
480                error
481            );
482            0
483        }
484    }
485}
486
487/// Default `poll_interval_ms`: short enough that a write is searchable within
488/// one poll of the `TestScript` runner's 100 ms wait, long enough to cap idle
489/// polling (and with it the watermark scans) at ten passes a second.
490const DEFAULT_POLL_INTERVAL_MS: u64 = 100;
491
492pub struct IndexingWorker {
493    max_concurrent_limit: Option<u64>,
494    tenant_concurrency: Option<u64>,
495    poll_interval: Duration,
496    running: Arc<tokio::sync::Mutex<bool>>,
497    repo: Arc<PGConnection>,
498    search_engine: Arc<SearchEngineBackend>,
499}
500
501#[derive(Clone, Deserialize, Serialize)]
502#[serde(default)]
503pub struct WorkerEnvironment {
504    pub max_concurrent_limit: Option<u64>,
505    /// Maximum number of projects indexed concurrently within one poll.
506    /// Field name kept as `tenant_concurrency` for config compatibility -
507    /// locking moved from tenant- to project-level, but this still bounds
508    /// the same underlying semaphore.
509    pub tenant_concurrency: Option<u64>,
510    /// How long a pass sleeps before polling again when no project had
511    /// anything new to index. Bounds the idle poll rate, and with it the
512    /// `max_safe_seq` watermark scans; passes that found rows run back to
513    /// back.
514    pub poll_interval_ms: Option<u64>,
515    pub repo: RepoConfig,
516    pub search: SearchConfig,
517}
518
519impl Default for WorkerEnvironment {
520    fn default() -> Self {
521        Self {
522            max_concurrent_limit: Some(1000),
523            // Matches `PostgresRepoConfig::default`'s `max_connections`.
524            tenant_concurrency: Some(10),
525            poll_interval_ms: Some(DEFAULT_POLL_INTERVAL_MS),
526            repo: RepoConfig::default(),
527            search: SearchConfig::default(),
528        }
529    }
530}
531
532impl IndexingWorker {
533    /// Creates and initializes a new worker.
534    ///
535    /// This initializes the repository and search engine using the provided
536    /// configuration, then waits for the search engine to become available.
537    /// The search engine connection is retried up to 5 times, with a 5-second
538    /// delay between attempts.
539    ///
540    /// # Arguments
541    ///
542    /// * `config` - Shared worker configuration containing the repository,
543    ///   search engine, and concurrency settings.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if:
548    /// - creating the repository fails.
549    /// - creating the search engine fails.
550    /// - the search engine remains unavailable after 5 connection attempts.
551    ///
552    /// # Returns
553    ///
554    /// Returns an initialized [`Self`] with the repository and search engine
555    /// ready for use.
556    pub async fn new(config: Arc<WorkerEnvironment>) -> Result<Self, OperationOutcomeError> {
557        let repo = create_repo(&config.repo).await?;
558
559        // This worker only writes documents, so it is never allowed to change
560        // the index's shape out from under a running server.
561        let search_engine =
562            haste_fhir_search::config::create_search_engine(&config.search, repo.clone(), false)
563                .await?;
564
565        let mut attempts = 0;
566        while search_engine.is_connected().await.is_err() && attempts < 5 {
567            tracing::error!(
568                "{} is not connected, retrying in 5 seconds...",
569                search_engine.name()
570            );
571            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
572            attempts += 1;
573        }
574
575        search_engine.is_connected().await?;
576
577        Ok(Self {
578            max_concurrent_limit: config.max_concurrent_limit,
579            tenant_concurrency: config.tenant_concurrency,
580            poll_interval: Duration::from_millis(
581                config.poll_interval_ms.unwrap_or(DEFAULT_POLL_INTERVAL_MS),
582            ),
583            running: Arc::new(tokio::sync::Mutex::new(true)),
584            repo,
585            search_engine,
586        })
587    }
588}
589
590impl Worker for IndexingWorker {
591    async fn run(&self) -> Result<JoinHandle<()>, OperationOutcomeError> {
592        let mut cursor = OffsetDateTime::UNIX_EPOCH;
593        let projects_limit: u64 = 100;
594
595        tracing::info!("Starting indexing worker...");
596
597        let mut k = *TOTAL_INDEXED.lock().await;
598
599        let repo = self.repo.clone();
600        let search_engine: Arc<SearchEngineBackend> = self.search_engine.clone();
601        let backend = search_engine.lock_kind();
602        let running = self.running.clone();
603        let max_concurrent_limit = self.max_concurrent_limit.unwrap_or(1000);
604        let poll_interval = self.poll_interval;
605        // With nothing to poll at all (no projects, or the fetch failed) there
606        // is no reason to look again soon.
607        let idle_backoff = Duration::from_secs(1);
608        let project_semaphore = Arc::new(Semaphore::new(
609            usize::try_from(self.tenant_concurrency.unwrap_or(10).max(1)).unwrap_or(usize::MAX),
610        ));
611
612        let spawned = tokio::spawn(async move {
613            while *running.lock().await {
614                // One watermark per pass. It is a property of the sequence, not
615                // of a project, and it is the expensive half of a poll (a scan
616                // of `pg_locks`), so every project in this pass reads up to the
617                // same value.
618                let safe_sequence = match repo.max_safe_sequence().await {
619                    Ok(safe_sequence) => safe_sequence,
620                    Err(error) => {
621                        tracing::error!("Failed to read the safe sequence watermark: {:?}", error);
622                        tokio::time::sleep(idle_backoff).await;
623                        continue;
624                    }
625                };
626
627                let projects_to_check =
628                    get_projects(repo.as_ref(), &cursor, projects_limit.cast_signed()).await;
629
630                // How long to wait before the next pass: `idle_backoff` with
631                // nothing to poll (no projects, or the fetch failed), the poll
632                // interval when the projects had nothing new, and none at all
633                // while rows are flowing.
634                let backoff = match projects_to_check {
635                    Ok(projects_to_check) => {
636                        let idle = projects_to_check.is_empty();
637                        if idle || (projects_to_check.len() as u64) < projects_limit {
638                            cursor = OffsetDateTime::UNIX_EPOCH; // Reset cursor if no projects found
639                        } else {
640                            cursor = projects_to_check[0].created_at;
641                        }
642
643                        let fetched_this_pass = index_projects(
644                            projects_to_check,
645                            &repo,
646                            &search_engine,
647                            &project_semaphore,
648                            max_concurrent_limit,
649                            backend,
650                            safe_sequence,
651                        )
652                        .await;
653                        if idle {
654                            Some(idle_backoff)
655                        } else if fetched_this_pass == 0 {
656                            Some(poll_interval)
657                        } else {
658                            None
659                        }
660                    }
661                    Err(error) => {
662                        tracing::error!("Failed to retrieve projects: {:?}", error);
663                        Some(idle_backoff)
664                    }
665                };
666
667                if k != *TOTAL_INDEXED.lock().await {
668                    k = *TOTAL_INDEXED.lock().await;
669                    tracing::info!("TOTAL INDEXED SO FAR: {}", k);
670                }
671
672                if let Some(backoff) = backoff {
673                    tokio::time::sleep(backoff).await;
674                }
675            }
676        });
677
678        Ok(spawned)
679    }
680
681    async fn stop(&mut self) -> Result<(), OperationOutcomeError> {
682        let mut running = self.running.lock().await;
683        *running = false;
684        Ok(())
685    }
686}