1use std::any::Any;
2use std::cmp::min;
3use std::panic::AssertUnwindSafe;
4use std::sync::Arc;
5
6use futures_util::FutureExt;
7use runledger_core::jobs::{
8 JobCompletion, JobContext, JobDeadLetterInfo, JobDeadLetterReason, JobFailure, JobFailureKind,
9};
10use runledger_postgres::jobs::{self, JobCompletionUpdate, JobFailureUpdate, JobProgressUpdate};
11use tokio::sync::{Semaphore, watch};
12use tokio::task::JoinSet;
13use tokio::time::{Duration, Instant, MissedTickBehavior, sleep, sleep_until};
14use tracing::{Instrument, error, info, info_span, warn};
15
16use crate::RuntimeLoopExit;
17use crate::WorkerError;
18use crate::config::JobsConfig;
19use crate::registry::JobRegistry;
20
21const UNKNOWN_WORKER_ID: &str = "unknown-worker";
22const LEASE_OWNER_MISMATCH_CODE: &str = "job.lease_owner_mismatch";
25const LEASE_MAINTENANCE_FAILED_CODE: &str = "job.lease_maintenance_failed";
26const WORKFLOW_RELEASE_CONFLICT_CODE: &str = "workflow.release_conflict";
27const HANDLER_PANIC_CODE: &str = "job.handler_panic";
28const INVALID_COMPLETION_PROGRESS_CODE: &str = "job.invalid_completion_progress";
29const RUNNING_PROGRESS_PERSIST_FAILED_REASON: &str = "RUNNING_PROGRESS_PERSIST_FAILED";
30const UNSTARTED_CLAIM_RELEASE_NOT_APPLICABLE_CODE: &str =
31 "job.unstarted_claim_release_not_applicable";
32const UNSTARTED_CLAIM_RETRY_DELAY_MS: i32 = 1_000;
33#[cfg(test)]
34const TERMINAL_HOOK_TIMEOUT: Duration = Duration::from_millis(100);
35#[cfg(not(test))]
36const TERMINAL_HOOK_TIMEOUT: Duration = Duration::from_secs(10);
37
38pub async fn run_worker_loop(
39 pool: runledger_postgres::DbPool,
40 registry: JobRegistry,
41 config: JobsConfig,
42 mut shutdown: watch::Receiver<bool>,
43) -> RuntimeLoopExit {
44 if let Err(error) = config.validate_worker_loop() {
45 warn!(%error, "invalid jobs config; stopping worker loop");
46 return RuntimeLoopExit::InvalidConfig(error);
47 }
48
49 let registry = Arc::new(registry);
50 let claimable_job_types = registry.registered_types();
51 let semaphore = Arc::new(Semaphore::new(config.max_global_concurrency));
52 let mut join_set: JoinSet<()> = JoinSet::new();
53
54 loop {
55 drain_finished_tasks(&mut join_set).await;
56
57 if shutdown_requested_or_closed(&shutdown) {
58 return drain_in_flight_jobs(join_set, RuntimeLoopExit::Shutdown).await;
59 }
60
61 if claimable_job_types.is_empty() {
62 if wait_for_shutdown_or_poll(&mut shutdown, config.poll_interval).await {
63 return drain_in_flight_jobs(join_set, RuntimeLoopExit::Shutdown).await;
64 }
65 continue;
66 }
67
68 let available = semaphore.available_permits();
69 if available == 0 {
70 if wait_for_shutdown_or_poll(&mut shutdown, config.poll_interval).await {
71 return drain_in_flight_jobs(join_set, RuntimeLoopExit::Shutdown).await;
72 }
73 continue;
74 }
75
76 let claim_limit = min(available, config.claim_batch_size as usize);
77 let claimed = match jobs::claim_prestart_jobs_for_types(
78 &pool,
79 &config.worker_id,
80 config.lease_ttl_seconds,
81 claim_limit as i64,
82 &claimable_job_types,
83 )
84 .await
85 {
86 Ok(claimed) => claimed,
87 Err(error) => {
88 let error = WorkerError::ClaimJobs {
89 worker_id: config.worker_id.clone(),
90 source: error,
91 };
92 warn!(%error, "worker claim failed");
93 Vec::new()
94 }
95 };
96
97 if claimed.is_empty() {
98 wait_for_shutdown_or_poll(&mut shutdown, config.poll_interval).await;
99 continue;
100 }
101
102 let claimed_len = claimed.len();
103 for job in claimed {
104 let permit = match Arc::clone(&semaphore).acquire_owned().await {
105 Ok(permit) => permit,
106 Err(_) => {
107 warn!("worker semaphore closed; stopping worker loop");
111 return drain_in_flight_jobs(join_set, RuntimeLoopExit::Completed).await;
112 }
113 };
114 let pool_clone = pool.clone();
115 let registry_clone = Arc::clone(®istry);
116 let lease_ttl_seconds = config.lease_ttl_seconds;
117 join_set.spawn(async move {
118 let _permit = permit;
119 process_claimed_job(pool_clone, registry_clone, job, lease_ttl_seconds).await;
120 });
121 }
122
123 if claimed_len == claim_limit {
124 continue;
125 }
126
127 if wait_for_shutdown_or_poll(&mut shutdown, config.poll_interval).await {
128 return drain_in_flight_jobs(join_set, RuntimeLoopExit::Shutdown).await;
129 }
130 }
131}
132
133async fn drain_in_flight_jobs(mut join_set: JoinSet<()>, exit: RuntimeLoopExit) -> RuntimeLoopExit {
134 if !join_set.is_empty() {
135 match exit {
136 RuntimeLoopExit::Shutdown => {
137 info!("worker shutdown requested; draining in-flight jobs")
138 }
139 RuntimeLoopExit::InvalidConfig(_) => {
140 warn!("worker loop rejected invalid config; draining in-flight jobs");
141 }
142 RuntimeLoopExit::Completed => {
143 warn!("worker loop completed before shutdown; draining in-flight jobs");
144 }
145 }
146 }
147 while join_set.join_next().await.is_some() {}
148 exit
149}
150
151async fn drain_finished_tasks(join_set: &mut JoinSet<()>) {
152 while let Some(result) = join_set.try_join_next() {
153 if let Err(error) = result {
154 error!(%error, "job task crashed");
155 }
156 }
157}
158
159fn shutdown_requested_or_closed(shutdown: &watch::Receiver<bool>) -> bool {
160 *shutdown.borrow() || shutdown.has_changed().is_err()
161}
162
163async fn wait_for_shutdown_or_poll(
164 shutdown: &mut watch::Receiver<bool>,
165 poll_interval: Duration,
166) -> bool {
167 tokio::select! {
168 changed = shutdown.changed() => changed.is_err() || *shutdown.borrow(),
169 _ = sleep(poll_interval) => false,
170 }
171}
172
173async fn process_claimed_job(
174 pool: runledger_postgres::DbPool,
175 registry: Arc<JobRegistry>,
176 job: jobs::JobQueueRecord,
177 lease_ttl_seconds: i32,
178) {
179 let worker_id = job
180 .worker_id
181 .clone()
182 .unwrap_or_else(|| UNKNOWN_WORKER_ID.to_owned());
183
184 let job_span = info_span!(
185 "job",
186 sentry.name = %job.job_type,
187 sentry.op = "runledger.job",
188 job_id = %job.id,
189 job_type = %job.job_type,
190 run_number = job.run_number,
191 attempt = job.attempt,
192 organization_id = ?job.organization_id,
193 worker_id = %worker_id,
194 );
195 async {
196 let start = Instant::now();
197 let context = JobContext {
198 job_id: job.id,
199 run_number: job.run_number,
200 attempt: job.attempt,
201 organization_id: job.organization_id,
202 worker_id: worker_id.clone(),
203 };
204
205 if !mark_job_running_or_abort(&pool, &context, &job).await {
206 return;
207 }
208
209 match execute_job_handler_with_heartbeats(
210 pool.clone(),
211 Arc::clone(®istry),
212 &context,
213 &job,
214 lease_ttl_seconds,
215 )
216 .await
217 {
218 Ok(completion) => {
219 if let Some(failure) = invalid_completion_progress_failure(&completion) {
220 warn!(
221 job_id = %job.id,
222 attempt = job.attempt,
223 failure_code = failure.code,
224 failure_message = %failure.message,
225 "handler returned invalid success completion; marking job terminal"
226 );
227 complete_job_failure_after_handler(
228 &pool,
229 registry.as_ref(),
230 &context,
231 &job,
232 failure,
233 )
234 .await;
235 } else {
236 let completion_update = JobCompletionUpdate {
237 progress_done: completion.progress_done,
238 progress_total: completion.progress_total,
239 checkpoint: completion.checkpoint.as_ref(),
240 output: completion.output.as_ref(),
241 };
242 if let Err(error) = jobs::complete_job_success(
243 &pool,
244 job.id,
245 job.run_number,
246 job.attempt,
247 &context.worker_id,
248 Some(&completion_update),
249 )
250 .await
251 {
252 let release_conflict = is_workflow_release_conflict_error(&error);
253 let error = WorkerError::CompleteSuccess {
254 job_id: job.id,
255 attempt: job.attempt,
256 source: error,
257 };
258 if release_conflict {
259 warn!(
260 %error,
261 job_id = %job.id,
262 "job success completion conflicted with workflow cancellation; leaving lease for reaper recovery"
263 );
264 } else {
265 error!(%error, job_id = %job.id, "failed to mark job success");
266 }
267 }
268 }
269 }
270 Err(failure) => {
271 complete_job_failure_after_handler(&pool, registry.as_ref(), &context, &job, failure)
272 .await;
273 }
274 }
275
276 info!(
277 job_id = %job.id,
278 attempt = job.attempt,
279 run_number = job.run_number,
280 elapsed_ms = start.elapsed().as_millis(),
281 "job processed"
282 );
283 }
284 .instrument(job_span)
285 .await;
286}
287
288async fn complete_job_failure_after_handler(
289 pool: &runledger_postgres::DbPool,
290 registry: &JobRegistry,
291 context: &JobContext,
292 job: &jobs::JobQueueRecord,
293 failure: JobFailure,
294) {
295 if is_lease_maintenance_failure(&failure) {
296 warn!(
297 job_id = %job.id,
298 attempt = job.attempt,
299 failure_code = failure.code,
300 "job processing aborted because durable lease maintenance was lost"
301 );
302 return;
303 }
304
305 let retry_delay_ms = if is_non_retryable_failure_kind(failure.kind) {
306 None
307 } else {
308 Some(retry_delay_ms_for_failure(registry, job, &failure))
309 };
310 let failure_payload = JobFailureUpdate {
311 kind: failure.kind,
312 code: failure.code,
313 message: failure.message.as_ref(),
314 retry_delay_ms,
315 };
316 let dead_letter = dead_letter_info(job, &failure);
317 if let Err(error) = jobs::complete_job_failure(
318 pool,
319 job.id,
320 job.run_number,
321 job.attempt,
322 &context.worker_id,
323 &failure_payload,
324 )
325 .await
326 {
327 let release_conflict = is_workflow_release_conflict_error(&error);
328 let error = WorkerError::CompleteFailure {
329 job_id: job.id,
330 attempt: job.attempt,
331 source: error,
332 };
333 if release_conflict {
334 warn!(
335 %error,
336 job_id = %job.id,
337 "job failure completion conflicted with workflow cancellation; leaving lease for reaper recovery"
338 );
339 } else {
340 error!(%error, job_id = %job.id, "failed to mark job failure");
341 }
342 } else if let Some(dead_letter) = dead_letter {
343 warn!(
344 job_id = %job.id,
345 job_type = %job.job_type,
346 run_number = job.run_number,
347 attempt = job.attempt,
348 max_attempts = job.max_attempts,
349 organization_id = ?job.organization_id,
350 worker_id = %context.worker_id,
351 dead_letter_reason = ?dead_letter.reason,
352 failure_kind = ?dead_letter.failure.kind,
353 failure_code = dead_letter.failure.code,
354 failure_message = %dead_letter.failure.message,
355 "job dead lettered after handler failure"
356 );
357 notify_handler_of_dead_letter(registry, context, job, dead_letter).await;
358 }
359}
360
361async fn mark_job_running_or_abort(
362 pool: &runledger_postgres::DbPool,
363 context: &JobContext,
364 job: &jobs::JobQueueRecord,
365) -> bool {
366 let running_progress = JobProgressUpdate {
367 stage: Some(runledger_core::jobs::JobStage::Running),
368 progress_done: None,
369 progress_total: None,
370 checkpoint: None,
371 };
372
373 let Err(source) = jobs::update_job_progress(
374 pool,
375 job.id,
376 job.run_number,
377 job.attempt,
378 &context.worker_id,
379 &running_progress,
380 )
381 .await
382 else {
383 return true;
384 };
385
386 handle_running_progress_persist_failure(pool, context, job, source).await;
387 false
388}
389
390async fn handle_running_progress_persist_failure(
391 pool: &runledger_postgres::DbPool,
392 context: &JobContext,
393 job: &jobs::JobQueueRecord,
394 source: runledger_postgres::Error,
395) {
396 let lease_owner_mismatch = is_lease_owner_mismatch_error(&source);
397 let error = WorkerError::SetRunningProgress {
398 job_id: job.id,
399 attempt: job.attempt,
400 source,
401 };
402
403 if lease_owner_mismatch {
404 warn!(
405 %error,
406 job_id = %job.id,
407 attempt = job.attempt,
408 "aborting job before execution because lease ownership was already lost"
409 );
410 return;
411 }
412
413 match jobs::release_unstarted_job_claim(
414 pool,
415 job.id,
416 job.run_number,
417 job.attempt,
418 &context.worker_id,
419 RUNNING_PROGRESS_PERSIST_FAILED_REASON,
420 UNSTARTED_CLAIM_RETRY_DELAY_MS,
421 )
422 .await
423 {
424 Ok(()) => {
425 warn!(
426 %error,
427 job_id = %job.id,
428 attempt = job.attempt,
429 "running progress could not be persisted; released unstarted claim back to pending"
430 );
431 }
432 Err(release_error) => {
433 let no_longer_releasable =
434 is_unstarted_claim_release_not_applicable_error(&release_error);
435 let release_error = WorkerError::ReleaseUnstartedClaim {
436 job_id: job.id,
437 attempt: job.attempt,
438 source: release_error,
439 };
440 if no_longer_releasable {
441 warn!(
442 %error,
443 %release_error,
444 job_id = %job.id,
445 attempt = job.attempt,
446 "running progress could not be persisted; unstarted release no longer applies and the job will continue under the current lease owner"
447 );
448 return;
449 }
450
451 warn!(
452 %error,
453 %release_error,
454 job_id = %job.id,
455 attempt = job.attempt,
456 "running progress could not be persisted; leaving claim for reaper recovery"
457 );
458 }
459 }
460}
461
462async fn execute_job_handler(
463 registry: Arc<JobRegistry>,
464 context: &JobContext,
465 job: &jobs::JobQueueRecord,
466) -> Result<JobCompletion, JobFailure> {
467 let Some(handler) = registry.get(job.job_type.as_borrowed()) else {
468 return Err(JobFailure::terminal(
469 "job.handler_not_registered",
470 "No handler is registered for this job type.",
471 ));
472 };
473
474 handler.execute(context.clone(), job.payload.clone()).await
475}
476
477async fn execute_job_handler_with_heartbeats(
478 pool: runledger_postgres::DbPool,
479 registry: Arc<JobRegistry>,
480 context: &JobContext,
481 job: &jobs::JobQueueRecord,
482 lease_ttl_seconds: i32,
483) -> Result<JobCompletion, JobFailure> {
484 let mut execution =
485 Box::pin(AssertUnwindSafe(execute_job_handler(registry, context, job)).catch_unwind());
486 let timeout_deadline = Instant::now() + Duration::from_secs(job.timeout_seconds.max(1) as u64);
487 let mut timeout = Box::pin(sleep_until(timeout_deadline));
488
489 let mut ticker = tokio::time::interval(heartbeat_interval(lease_ttl_seconds));
490 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
491 ticker.tick().await;
492
493 loop {
494 tokio::select! {
495 result = &mut execution => {
496 return match result {
497 Ok(result) => result,
498 Err(panic_payload) => Err(handler_panic_failure(panic_payload)),
499 };
500 }
501 _ = &mut timeout => {
502 return Err(JobFailure::timeout(
503 "job.timeout_exceeded",
504 "Job exceeded the configured timeout.",
505 ));
506 }
507 _ = ticker.tick() => {
508 if let Err(error) = jobs::heartbeat_job(
509 &pool,
510 job.id,
511 job.run_number,
512 job.attempt,
513 &context.worker_id,
514 lease_ttl_seconds,
515 )
516 .await
517 {
518 let lease_owner_mismatch = is_lease_owner_mismatch_error(&error);
519 let error = WorkerError::Heartbeat {
520 job_id: job.id,
521 attempt: job.attempt,
522 source: error,
523 };
524
525 if lease_owner_mismatch {
526 warn!(%error, job_id = %job.id, "job heartbeat lost lease ownership");
527 return Err(lease_owner_mismatch_failure());
528 }
529
530 warn!(
531 %error,
532 job_id = %job.id,
533 "aborting job because lease heartbeat could not be persisted"
534 );
535 return Err(lease_maintenance_failure());
536 }
537 }
538 }
539 }
540}
541
542fn lease_owner_mismatch_failure() -> JobFailure {
543 JobFailure::lease_expired(
544 LEASE_OWNER_MISMATCH_CODE,
545 "Job lease ownership was lost during processing.",
546 )
547}
548
549fn lease_maintenance_failure() -> JobFailure {
550 JobFailure::lease_expired(
551 LEASE_MAINTENANCE_FAILED_CODE,
552 "Job lease could not be durably maintained during processing.",
553 )
554}
555
556fn handler_panic_failure(panic_payload: Box<dyn Any + Send>) -> JobFailure {
557 JobFailure::panicked(
558 HANDLER_PANIC_CODE,
559 format!(
560 "Job handler panicked: {}",
561 panic_payload_message(&*panic_payload)
562 ),
563 )
564}
565
566fn panic_payload_message(panic_payload: &(dyn Any + Send)) -> String {
567 if let Some(message) = panic_payload.downcast_ref::<String>() {
568 return message.clone();
569 }
570
571 if let Some(message) = panic_payload.downcast_ref::<&'static str>() {
572 return (*message).to_string();
573 }
574
575 "non-string panic payload".to_string()
576}
577
578fn invalid_completion_progress_failure(completion: &JobCompletion) -> Option<JobFailure> {
579 invalid_completion_progress_detail(completion.progress_done, completion.progress_total)
580 .map(|detail| JobFailure::terminal(INVALID_COMPLETION_PROGRESS_CODE, detail))
581}
582
583fn invalid_completion_progress_detail(
584 progress_done: Option<i64>,
585 progress_total: Option<i64>,
586) -> Option<String> {
587 if let Some(progress_done) = progress_done
588 && progress_done < 0
589 {
590 return Some(format!(
591 "Handler returned invalid success progress: progress_done must be greater than or equal to zero, got {progress_done}."
592 ));
593 }
594
595 if let Some(progress_total) = progress_total
596 && progress_total < 0
597 {
598 return Some(format!(
599 "Handler returned invalid success progress: progress_total must be greater than or equal to zero, got {progress_total}."
600 ));
601 }
602
603 if let (Some(progress_done), Some(progress_total)) = (progress_done, progress_total)
604 && progress_done > progress_total
605 {
606 return Some(format!(
607 "Handler returned invalid success progress: progress_done must not exceed progress_total, got progress_done={progress_done}, progress_total={progress_total}."
608 ));
609 }
610
611 None
612}
613
614fn has_query_error_code(error: &runledger_postgres::Error, expected_code: &str) -> bool {
615 matches!(
616 error,
617 runledger_postgres::Error::QueryError(query_error)
618 if query_error.code() == expected_code
619 )
620}
621
622fn is_lease_owner_mismatch_error(error: &runledger_postgres::Error) -> bool {
623 has_query_error_code(error, LEASE_OWNER_MISMATCH_CODE)
624}
625
626fn is_unstarted_claim_release_not_applicable_error(error: &runledger_postgres::Error) -> bool {
627 has_query_error_code(error, UNSTARTED_CLAIM_RELEASE_NOT_APPLICABLE_CODE)
628}
629
630fn is_workflow_release_conflict_error(error: &runledger_postgres::Error) -> bool {
631 has_query_error_code(error, WORKFLOW_RELEASE_CONFLICT_CODE)
632}
633
634fn is_lease_maintenance_failure(failure: &JobFailure) -> bool {
635 matches!(
636 failure.code,
637 LEASE_OWNER_MISMATCH_CODE | LEASE_MAINTENANCE_FAILED_CODE
638 )
639}
640
641fn heartbeat_interval(lease_ttl_seconds: i32) -> Duration {
642 let seconds = (lease_ttl_seconds.max(1) / 3).max(1) as u64;
645 Duration::from_secs(seconds)
646}
647
648fn is_non_retryable_failure_kind(kind: JobFailureKind) -> bool {
649 matches!(kind, JobFailureKind::Terminal | JobFailureKind::Panicked)
650}
651
652fn retry_delay_ms_for_failure(
653 registry: &JobRegistry,
654 job: &jobs::JobQueueRecord,
655 failure: &JobFailure,
656) -> i32 {
657 registry
658 .retry_delay_override(job.job_type.as_borrowed(), failure.code)
659 .unwrap_or_else(|| compute_retry_delay_ms(job.attempt, job.id))
660}
661
662fn dead_letter_info(job: &jobs::JobQueueRecord, failure: &JobFailure) -> Option<JobDeadLetterInfo> {
663 let reason = if is_non_retryable_failure_kind(failure.kind) {
664 Some(JobDeadLetterReason::FailureKindNonRetryable)
665 } else if job.attempt >= job.max_attempts {
666 Some(JobDeadLetterReason::AttemptsExhausted)
667 } else {
668 None
669 }?;
670
671 Some(JobDeadLetterInfo::new(
672 failure.clone(),
673 reason,
674 Some(job.max_attempts),
675 ))
676}
677
678async fn notify_handler_of_dead_letter(
679 registry: &JobRegistry,
680 context: &JobContext,
681 job: &jobs::JobQueueRecord,
682 dead_letter: JobDeadLetterInfo,
683) {
684 let Some(handler) = registry.get(job.job_type.as_borrowed()) else {
685 return;
686 };
687 let context = context.clone();
688 let payload = job.payload.clone();
689
690 let hook_task = tokio::spawn(async move {
691 tokio::time::timeout(
692 TERMINAL_HOOK_TIMEOUT,
693 handler.on_dead_letter(context, payload, dead_letter),
694 )
695 .await
696 .is_ok()
697 });
698 match hook_task.await {
699 Ok(true) => {}
700 Ok(false) => {
701 warn!(
702 job_id = %job.id,
703 job_type = %job.job_type,
704 run_number = job.run_number,
705 attempt = job.attempt,
706 timeout_ms = TERMINAL_HOOK_TIMEOUT.as_millis(),
707 "dead-letter hook timed out; continuing worker job task"
708 );
709 }
710 Err(error) => log_dead_letter_hook_join_error(job, error),
711 }
712}
713
714fn log_dead_letter_hook_join_error(job: &jobs::JobQueueRecord, error: tokio::task::JoinError) {
715 if error.is_panic() {
716 warn!(
717 job_id = %job.id,
718 job_type = %job.job_type,
719 run_number = job.run_number,
720 attempt = job.attempt,
721 error = %error,
722 "dead-letter hook panicked; continuing worker job task"
723 );
724 } else if error.is_cancelled() {
725 warn!(
726 job_id = %job.id,
727 job_type = %job.job_type,
728 run_number = job.run_number,
729 attempt = job.attempt,
730 error = %error,
731 "dead-letter hook was cancelled; continuing worker job task"
732 );
733 } else {
734 warn!(
735 job_id = %job.id,
736 job_type = %job.job_type,
737 run_number = job.run_number,
738 attempt = job.attempt,
739 error = %error,
740 "dead-letter hook join failed; continuing worker job task"
741 );
742 }
743}
744
745fn compute_retry_delay_ms(attempt: i32, job_id: uuid::Uuid) -> i32 {
746 let exp = attempt.clamp(1, 10) as u32;
747 let base_ms: i64 = 5_000;
748 let raw = base_ms * (1_i64 << exp);
749 let capped = raw.min(300_000);
750 let jitter = (job_id.as_u128() % 1_000) as i64 - 500;
751 (capped + jitter).max(1_000) as i32
752}
753
754#[cfg(test)]
755mod tests;