Skip to main content

runledger_runtime/
worker.rs

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";
31// Kept stable for clients that already match this code; it also covers leases
32// that expired before the worker's lifecycle update reached storage.
33const 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                    // The worker owns this semaphore and never closes it. If
148                    // this defensive branch fires, surface it as an unexpected
149                    // loop completion rather than graceful shutdown.
150                    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(&registry);
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(&registry),
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    // Renew at one-third of the lease TTL so a delayed heartbeat still leaves
640    // time for subsequent renewals before the lease expires.
641    let seconds = (lease_ttl_seconds.max(1) / 3).max(1) as u64;
642    Duration::from_secs(seconds)
643}
644
645#[cfg(test)]
646mod tests;