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
108async 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
143struct 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 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 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
390async 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
430async 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 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
487const 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 pub tenant_concurrency: Option<u64>,
510 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 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 pub async fn new(config: Arc<WorkerEnvironment>) -> Result<Self, OperationOutcomeError> {
557 let repo = create_repo(&config.repo).await?;
558
559 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 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 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 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; } 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}