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
105async 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 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 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#[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#[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 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 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; } 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}