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::{
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";
22// Kept stable for clients that already match this code; it also covers leases
23// that expired before the worker's lifecycle update reached storage.
24const 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                    // The worker owns this semaphore and never closes it. If
108                    // this defensive branch fires, surface it as an unexpected
109                    // loop completion rather than graceful shutdown.
110                    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(&registry);
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(&registry),
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    // Renew at one-third of the lease TTL so a delayed heartbeat still leaves
643    // time for subsequent renewals before the lease expires.
644    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;