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::{JobCompletion, JobContext, JobFailure};
8use runledger_postgres::QueryErrorKind;
9use runledger_postgres::jobs::{self, JobProgressUpdate};
10use tokio::sync::{Semaphore, watch};
11use tokio::task::JoinSet;
12use tokio::time::{Duration, Instant, MissedTickBehavior, sleep_until};
13use tracing::{Instrument, error, info, info_span, warn};
14
15mod completion;
16mod dead_letter;
17mod observers;
18
19use self::completion::{
20 CompletionObservation, complete_job_after_handler, complete_job_failure_after_handler,
21};
22use self::observers::{JobRunningNotification, TerminalJobObserverEvent, TerminalObserverTasks};
23use crate::RuntimeLoopExit;
24use crate::WorkerError;
25use crate::config::JobsConfig;
26use crate::observer::{JobLeaseLostEvent, JobLifecycleObservers, ObservedJob};
27use crate::registry::JobRegistry;
28use crate::shutdown;
29
30const UNKNOWN_WORKER_ID: &str = "unknown-worker";
31const LEASE_OWNER_MISMATCH_CODE: &str = "job.lease_owner_mismatch";
34const LEASE_MAINTENANCE_FAILED_CODE: &str = "job.lease_maintenance_failed";
35const HANDLER_PANIC_CODE: &str = "job.handler_panic";
36const RUNNING_PROGRESS_PERSIST_FAILED_REASON: &str = "RUNNING_PROGRESS_PERSIST_FAILED";
37const UNSTARTED_CLAIM_RETRY_DELAY_MS: i32 = 1_000;
38
39enum JobExecutionFailure {
40 Handler(JobFailure),
41 LeaseMaintenance(JobFailure),
42}
43
44pub async fn run_worker_loop(
45 pool: runledger_postgres::DbPool,
46 registry: JobRegistry,
47 config: JobsConfig,
48 shutdown: watch::Receiver<bool>,
49) -> RuntimeLoopExit {
50 run_worker_loop_with_observer(
51 pool,
52 registry,
53 config,
54 shutdown,
55 JobLifecycleObservers::empty(),
56 )
57 .await
58}
59
60pub async fn run_worker_loop_with_observer(
61 pool: runledger_postgres::DbPool,
62 registry: JobRegistry,
63 config: JobsConfig,
64 mut shutdown: watch::Receiver<bool>,
65 observers: JobLifecycleObservers,
66) -> RuntimeLoopExit {
67 if let Err(error) = config.validate_worker_loop() {
68 warn!(%error, "invalid jobs config; stopping worker loop");
69 return RuntimeLoopExit::InvalidConfig(error);
70 }
71
72 let registry = Arc::new(registry);
73 let claimable_job_types = registry.registered_types();
74 let semaphore = Arc::new(Semaphore::new(config.max_global_concurrency));
75 let mut join_set: JoinSet<()> = JoinSet::new();
76 let terminal_observer_tasks = TerminalObserverTasks::owned();
77
78 loop {
79 drain_finished_tasks(&mut join_set).await;
80 terminal_observer_tasks.drain_finished().await;
81
82 if shutdown::is_requested_or_closed(&shutdown) {
83 return drain_worker_tasks(
84 join_set,
85 terminal_observer_tasks,
86 RuntimeLoopExit::Shutdown,
87 )
88 .await;
89 }
90
91 if claimable_job_types.is_empty() {
92 if shutdown::wait_for_request_or_timeout(&mut shutdown, config.poll_interval).await {
93 return drain_worker_tasks(
94 join_set,
95 terminal_observer_tasks,
96 RuntimeLoopExit::Shutdown,
97 )
98 .await;
99 }
100 continue;
101 }
102
103 let available = semaphore.available_permits();
104 if available == 0 {
105 if shutdown::wait_for_request_or_timeout(&mut shutdown, config.poll_interval).await {
106 return drain_worker_tasks(
107 join_set,
108 terminal_observer_tasks,
109 RuntimeLoopExit::Shutdown,
110 )
111 .await;
112 }
113 continue;
114 }
115
116 let claim_limit = min(available, config.claim_batch_size as usize);
117 let claimed = match jobs::claim_prestart_jobs_for_types(
118 &pool,
119 &config.worker_id,
120 config.lease_ttl_seconds,
121 claim_limit as i64,
122 &claimable_job_types,
123 )
124 .await
125 {
126 Ok(claimed) => claimed,
127 Err(error) => {
128 let error = WorkerError::ClaimJobs {
129 worker_id: config.worker_id.clone(),
130 source: error,
131 };
132 warn!(%error, "worker claim failed");
133 Vec::new()
134 }
135 };
136
137 if claimed.is_empty() {
138 shutdown::wait_for_request_or_timeout(&mut shutdown, config.poll_interval).await;
139 continue;
140 }
141
142 let claimed_len = claimed.len();
143 for job in claimed {
144 let permit = match Arc::clone(&semaphore).acquire_owned().await {
145 Ok(permit) => permit,
146 Err(_) => {
147 warn!("worker semaphore closed; stopping worker loop");
151 return drain_worker_tasks(
152 join_set,
153 terminal_observer_tasks,
154 RuntimeLoopExit::Completed,
155 )
156 .await;
157 }
158 };
159 let pool_clone = pool.clone();
160 let registry_clone = Arc::clone(®istry);
161 let lease_ttl_seconds = config.lease_ttl_seconds;
162 let observers = observers.clone();
163 let terminal_observer_tasks = terminal_observer_tasks.clone();
164 join_set.spawn(async move {
165 let _permit = permit;
166 process_claimed_job_with_terminal_observers(
167 pool_clone,
168 registry_clone,
169 job,
170 lease_ttl_seconds,
171 observers,
172 terminal_observer_tasks,
173 )
174 .await;
175 });
176 }
177
178 if claimed_len == claim_limit {
179 continue;
180 }
181
182 if shutdown::wait_for_request_or_timeout(&mut shutdown, config.poll_interval).await {
183 return drain_worker_tasks(
184 join_set,
185 terminal_observer_tasks,
186 RuntimeLoopExit::Shutdown,
187 )
188 .await;
189 }
190 }
191}
192
193async fn drain_worker_tasks(
194 mut join_set: JoinSet<()>,
195 terminal_observer_tasks: TerminalObserverTasks,
196 exit: RuntimeLoopExit,
197) -> RuntimeLoopExit {
198 if !join_set.is_empty() {
199 match exit {
200 RuntimeLoopExit::Shutdown => {
201 info!("worker shutdown requested; draining in-flight jobs")
202 }
203 RuntimeLoopExit::InvalidConfig(_) => {
204 warn!("worker loop rejected invalid config; draining in-flight jobs");
205 }
206 RuntimeLoopExit::Completed => {
207 warn!("worker loop completed before shutdown; draining in-flight jobs");
208 }
209 }
210 }
211 while let Some(result) = join_set.join_next().await {
212 if let Err(error) = result {
213 error!(%error, "job task crashed while draining in-flight jobs");
214 }
215 }
216 terminal_observer_tasks.drain_for_shutdown().await;
217 exit
218}
219
220async fn drain_finished_tasks(join_set: &mut JoinSet<()>) {
221 while let Some(result) = join_set.try_join_next() {
222 if let Err(error) = result {
223 error!(%error, "job task crashed");
224 }
225 }
226}
227
228#[cfg(test)]
229async fn process_claimed_job(
230 pool: runledger_postgres::DbPool,
231 registry: Arc<JobRegistry>,
232 job: jobs::JobQueueRecord,
233 lease_ttl_seconds: i32,
234) {
235 process_claimed_job_with_observer(
236 pool,
237 registry,
238 job,
239 lease_ttl_seconds,
240 JobLifecycleObservers::empty(),
241 )
242 .await;
243}
244
245#[cfg(test)]
246async fn process_claimed_job_with_observer(
247 pool: runledger_postgres::DbPool,
248 registry: Arc<JobRegistry>,
249 job: jobs::JobQueueRecord,
250 lease_ttl_seconds: i32,
251 observers: JobLifecycleObservers,
252) {
253 process_claimed_job_with_terminal_observers(
254 pool,
255 registry,
256 job,
257 lease_ttl_seconds,
258 observers,
259 TerminalObserverTasks::detached(),
260 )
261 .await;
262}
263
264async fn process_claimed_job_with_terminal_observers(
265 pool: runledger_postgres::DbPool,
266 registry: Arc<JobRegistry>,
267 job: jobs::JobQueueRecord,
268 lease_ttl_seconds: i32,
269 observers: JobLifecycleObservers,
270 terminal_observer_tasks: TerminalObserverTasks,
271) {
272 let worker_id = job
273 .worker_id
274 .clone()
275 .unwrap_or_else(|| UNKNOWN_WORKER_ID.to_owned());
276
277 let job_span = info_span!(
278 "job",
279 sentry.name = %job.job_type,
280 sentry.op = "runledger.job",
281 job_id = %job.id,
282 job_type = %job.job_type,
283 run_number = job.run_number,
284 attempt = job.attempt,
285 organization_id = ?job.organization_id,
286 worker_id = %worker_id,
287 );
288 async {
289 let start = Instant::now();
290 let context = JobContext {
291 job_id: job.id,
292 run_number: job.run_number,
293 attempt: job.attempt,
294 organization_id: job.organization_id,
295 worker_id: worker_id.clone(),
296 checkpoint: job.checkpoint.clone(),
297 };
298 let observed_job = observed_job(&job, &worker_id);
299
300 if !mark_job_running_or_abort(&pool, &context, &job).await {
301 return;
302 }
303 let mut running_notification =
304 JobRunningNotification::spawn(observers.clone(), observed_job.clone());
305
306 match execute_job_handler_with_heartbeats(
307 pool.clone(),
308 Arc::clone(®istry),
309 &context,
310 &job,
311 lease_ttl_seconds,
312 )
313 .await
314 {
315 Ok(completion) => {
316 complete_job_after_handler(
317 &pool,
318 registry.as_ref(),
319 &context,
320 &job,
321 completion,
322 CompletionObservation::new(
323 &observers,
324 observed_job.clone(),
325 start.elapsed(),
326 &mut running_notification,
327 &terminal_observer_tasks,
328 ),
329 )
330 .await;
331 }
332 Err(JobExecutionFailure::Handler(failure)) => {
333 complete_job_failure_after_handler(
334 &pool,
335 registry.as_ref(),
336 &context,
337 &job,
338 failure,
339 CompletionObservation::new(
340 &observers,
341 observed_job.clone(),
342 start.elapsed(),
343 &mut running_notification,
344 &terminal_observer_tasks,
345 ),
346 )
347 .await;
348 }
349 Err(JobExecutionFailure::LeaseMaintenance(failure)) => {
350 log_lease_maintenance_abort(&job, &failure);
351 running_notification
352 .spawn_terminal_observer(
353 &terminal_observer_tasks,
354 &job,
355 observers.clone(),
356 TerminalJobObserverEvent::LeaseLost(JobLeaseLostEvent {
357 job: observed_job.clone(),
358 duration: start.elapsed(),
359 failure,
360 }),
361 )
362 .await;
363 }
364 }
365
366 info!(
367 job_id = %job.id,
368 attempt = job.attempt,
369 run_number = job.run_number,
370 elapsed_ms = start.elapsed().as_millis(),
371 "job processed"
372 );
373 }
374 .instrument(job_span)
375 .await;
376}
377
378fn observed_job(job: &jobs::JobQueueRecord, worker_id: &str) -> ObservedJob {
379 ObservedJob {
380 job_id: job.id,
381 job_type: job.job_type.clone(),
382 organization_id: job.organization_id,
383 run_number: job.run_number,
384 attempt: job.attempt,
385 max_attempts: job.max_attempts,
386 worker_id: worker_id.to_owned(),
387 }
388}
389
390fn log_lease_maintenance_abort(job: &jobs::JobQueueRecord, failure: &JobFailure) {
391 warn!(
392 job_id = %job.id,
393 attempt = job.attempt,
394 failure_code = failure.code,
395 "job processing aborted because durable lease maintenance was lost"
396 );
397}
398
399async fn mark_job_running_or_abort(
400 pool: &runledger_postgres::DbPool,
401 context: &JobContext,
402 job: &jobs::JobQueueRecord,
403) -> bool {
404 let running_progress = JobProgressUpdate {
405 stage: Some(runledger_core::jobs::JobStage::Running),
406 progress_done: None,
407 progress_total: None,
408 checkpoint: None,
409 };
410
411 let Err(source) = jobs::update_job_progress(
412 pool,
413 job.id,
414 job.run_number,
415 job.attempt,
416 &context.worker_id,
417 &running_progress,
418 )
419 .await
420 else {
421 return true;
422 };
423
424 handle_running_progress_persist_failure(pool, context, job, source).await;
425 false
426}
427
428async fn handle_running_progress_persist_failure(
429 pool: &runledger_postgres::DbPool,
430 context: &JobContext,
431 job: &jobs::JobQueueRecord,
432 source: runledger_postgres::Error,
433) {
434 let lease_owner_mismatch = is_lease_owner_mismatch_error(&source);
435 let error = WorkerError::SetRunningProgress {
436 job_id: job.id,
437 attempt: job.attempt,
438 source,
439 };
440
441 if lease_owner_mismatch {
442 warn!(
443 %error,
444 job_id = %job.id,
445 attempt = job.attempt,
446 "aborting job before execution because lease ownership was already lost"
447 );
448 return;
449 }
450
451 match jobs::release_unstarted_job_claim(
452 pool,
453 job.id,
454 job.run_number,
455 job.attempt,
456 &context.worker_id,
457 RUNNING_PROGRESS_PERSIST_FAILED_REASON,
458 UNSTARTED_CLAIM_RETRY_DELAY_MS,
459 )
460 .await
461 {
462 Ok(()) => {
463 warn!(
464 %error,
465 job_id = %job.id,
466 attempt = job.attempt,
467 "running progress could not be persisted; released unstarted claim back to pending"
468 );
469 }
470 Err(release_error) => {
471 let no_longer_releasable =
472 is_unstarted_claim_release_not_applicable_error(&release_error);
473 let release_error = WorkerError::ReleaseUnstartedClaim {
474 job_id: job.id,
475 attempt: job.attempt,
476 source: release_error,
477 };
478 if no_longer_releasable {
479 warn!(
480 %error,
481 %release_error,
482 job_id = %job.id,
483 attempt = job.attempt,
484 "running progress could not be persisted; unstarted release no longer applies and the job will continue under the current lease owner"
485 );
486 return;
487 }
488
489 warn!(
490 %error,
491 %release_error,
492 job_id = %job.id,
493 attempt = job.attempt,
494 "running progress could not be persisted; leaving claim for reaper recovery"
495 );
496 }
497 }
498}
499
500async fn execute_job_handler(
501 registry: Arc<JobRegistry>,
502 context: &JobContext,
503 job: &jobs::JobQueueRecord,
504) -> Result<JobCompletion, JobFailure> {
505 let Some(handler) = registry.get(job.job_type.as_borrowed()) else {
506 return Err(JobFailure::terminal(
507 "job.handler_not_registered",
508 "No handler is registered for this job type.",
509 ));
510 };
511
512 handler.execute(context.clone(), job.payload.clone()).await
513}
514
515async fn execute_job_handler_with_heartbeats(
516 pool: runledger_postgres::DbPool,
517 registry: Arc<JobRegistry>,
518 context: &JobContext,
519 job: &jobs::JobQueueRecord,
520 lease_ttl_seconds: i32,
521) -> Result<JobCompletion, JobExecutionFailure> {
522 let mut execution =
523 Box::pin(AssertUnwindSafe(execute_job_handler(registry, context, job)).catch_unwind());
524 let timeout_deadline = Instant::now() + Duration::from_secs(job.timeout_seconds.max(1) as u64);
525 let mut timeout = Box::pin(sleep_until(timeout_deadline));
526
527 let mut ticker = tokio::time::interval(heartbeat_interval(lease_ttl_seconds));
528 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
529 ticker.tick().await;
530
531 loop {
532 tokio::select! {
533 result = &mut execution => {
534 return match result {
535 Ok(result) => result.map_err(JobExecutionFailure::Handler),
536 Err(panic_payload) => {
537 Err(JobExecutionFailure::Handler(handler_panic_failure(panic_payload)))
538 }
539 };
540 }
541 _ = &mut timeout => {
542 return Err(JobExecutionFailure::Handler(JobFailure::timeout(
543 "job.timeout_exceeded",
544 "Job exceeded the configured timeout.",
545 )));
546 }
547 _ = ticker.tick() => {
548 if let Err(error) = jobs::heartbeat_job(
549 &pool,
550 job.id,
551 job.run_number,
552 job.attempt,
553 &context.worker_id,
554 lease_ttl_seconds,
555 )
556 .await
557 {
558 let lease_owner_mismatch = is_lease_owner_mismatch_error(&error);
559 let error = WorkerError::Heartbeat {
560 job_id: job.id,
561 attempt: job.attempt,
562 source: error,
563 };
564
565 if lease_owner_mismatch {
566 warn!(%error, job_id = %job.id, "job heartbeat lost lease ownership");
567 return Err(JobExecutionFailure::LeaseMaintenance(
568 lease_owner_mismatch_failure(),
569 ));
570 }
571
572 warn!(
573 %error,
574 job_id = %job.id,
575 "aborting job because lease heartbeat could not be persisted"
576 );
577 return Err(JobExecutionFailure::LeaseMaintenance(
578 lease_maintenance_failure(),
579 ));
580 }
581 }
582 }
583 }
584}
585
586fn lease_owner_mismatch_failure() -> JobFailure {
587 JobFailure::lease_expired(
588 LEASE_OWNER_MISMATCH_CODE,
589 "Job lease ownership was lost during processing.",
590 )
591}
592
593fn lease_maintenance_failure() -> JobFailure {
594 JobFailure::lease_expired(
595 LEASE_MAINTENANCE_FAILED_CODE,
596 "Job lease could not be durably maintained during processing.",
597 )
598}
599
600fn handler_panic_failure(panic_payload: Box<dyn Any + Send>) -> JobFailure {
601 JobFailure::panicked(
602 HANDLER_PANIC_CODE,
603 format!(
604 "Job handler panicked: {}",
605 panic_payload_message(&*panic_payload)
606 ),
607 )
608}
609
610fn panic_payload_message(panic_payload: &(dyn Any + Send)) -> String {
611 if let Some(message) = panic_payload.downcast_ref::<String>() {
612 return message.clone();
613 }
614
615 if let Some(message) = panic_payload.downcast_ref::<&'static str>() {
616 return (*message).to_string();
617 }
618
619 "non-string panic payload".to_string()
620}
621
622fn has_query_error_kind(error: &runledger_postgres::Error, expected_kind: QueryErrorKind) -> bool {
623 matches!(
624 error,
625 runledger_postgres::Error::QueryError(query_error)
626 if query_error.kind() == Some(expected_kind)
627 )
628}
629
630fn is_lease_owner_mismatch_error(error: &runledger_postgres::Error) -> bool {
631 has_query_error_kind(error, QueryErrorKind::JobLeaseOwnerMismatch)
632}
633
634fn is_unstarted_claim_release_not_applicable_error(error: &runledger_postgres::Error) -> bool {
635 has_query_error_kind(error, QueryErrorKind::JobUnstartedClaimReleaseNotApplicable)
636}
637
638fn heartbeat_interval(lease_ttl_seconds: i32) -> Duration {
639 let seconds = (lease_ttl_seconds.max(1) / 3).max(1) as u64;
642 Duration::from_secs(seconds)
643}
644
645#[cfg(test)]
646mod tests;