Skip to main content

sepp_rs/
worker.rs

1//! A high-level worker that runs the reserve → process → ack/nack loop.
2//!
3//! [`Worker`] wraps a [`SeppClient`] and drives job consumption for you:
4//! reserve jobs, dispatch each to the handler registered for its `job_type`,
5//! and ack on success or nack on failure. It adds bounded concurrency, optional
6//! lease auto-extension, graceful shutdown via a [`ShutdownHandle`], and (with
7//! the `opentelemetry` feature) metrics and trace linkage.
8//!
9//! Register handlers with [`Worker::handle`], then call [`Worker::run`]:
10//!
11//! ```no_run
12//! use std::time::Duration;
13//! use sepp_rs::client::SeppClient;
14//! use sepp_rs::worker::{HandlerError, Worker};
15//!
16//! # async fn run(client: SeppClient) -> Result<(), Box<dyn std::error::Error>> {
17//! Worker::new(client, ["emails"], Duration::from_secs(30))?
18//!     .with_max_in_flight(32)
19//!     .with_auto_extend()
20//!     .handle("send_welcome", |payload, ctx| async move {
21//!         // ... do the work ...
22//!         Ok(())
23//!     })?
24//!     .handle("send_receipt", |payload, ctx| async move {
25//!         Err(HandlerError::retry("payment service unavailable"))
26//!     })?
27//!     .run()
28//!     .await;
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! A handler's return value decides the job's fate: `Ok(())` acks it, and an
34//! [`Err`] of [`HandlerError`] nacks it with the corresponding
35//! [`RetryDirective`]. A handler that panics is
36//! caught and nacked rather than bringing the worker down.
37
38use std::{collections::HashMap, panic::AssertUnwindSafe, sync::Arc, time::Duration};
39
40use futures::{FutureExt, future::BoxFuture};
41use tokio::{sync::Semaphore, task::AbortHandle};
42use tokio_util::sync::CancellationToken;
43use tracing::{Instrument, debug, error, info, warn};
44
45use crate::{
46    Job, JobCtx, Payload, ReserveOptions, ReserveOptionsError,
47    client::{Lease, LeaseError, RetryDirective, SeppClient},
48    now_millis,
49};
50
51/// An empty reserve that returns sooner than this did not wait out its long
52/// poll (e.g. the server is draining at shutdown); re-polling immediately
53/// would hammer it.
54const EARLY_EMPTY_THRESHOLD: Duration = Duration::from_millis(100);
55/// How long to pause before re-polling after such an early empty reserve.
56const EARLY_EMPTY_BACKOFF: Duration = Duration::from_millis(250);
57
58type Handler = Arc<
59    dyn Fn(Option<Payload>, Arc<JobCtx>) -> BoxFuture<'static, Result<(), HandlerError>>
60        + Send
61        + Sync,
62>;
63
64fn wrap_handler<F, Fut>(h: F) -> Handler
65where
66    F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
67    Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
68{
69    let h = Arc::new(h);
70    Arc::new(move |payload, ctx| Box::pin(h(payload, ctx)))
71}
72
73/// The error a job handler returns to nack its job, choosing how it should be
74/// retried.
75///
76/// Each variant maps to a [`RetryDirective`]:
77/// [`Retry`](Self::Retry) → `Default`, [`RetryAfter`](Self::RetryAfter) →
78/// `After`, [`Permanent`](Self::Permanent) → `DeadLetter`. Use the
79/// [`retry`](Self::retry), [`retry_after`](Self::retry_after), and
80/// [`permanent`](Self::permanent) constructors rather than the variants
81/// directly.
82#[derive(Debug, thiserror::Error)]
83pub enum HandlerError {
84    /// Retry using the queue's default retry policy.
85    #[error("retry: {0}")]
86    Retry(String),
87    /// Retry, but not before the given delay.
88    #[error("retry after {1:?}: {0}")]
89    RetryAfter(String, Duration),
90    /// Do not retry; dead-letter the job immediately.
91    #[error("permanent: {0}")]
92    Permanent(String),
93}
94
95impl HandlerError {
96    /// Nack the job for retry under the queue's default policy.
97    pub fn retry(reason: impl Into<String>) -> Self {
98        Self::Retry(reason.into())
99    }
100    /// Nack the job for retry after at least `delay`.
101    pub fn retry_after(reason: impl Into<String>, delay: Duration) -> Self {
102        Self::RetryAfter(reason.into(), delay)
103    }
104    /// Nack the job as a permanent failure, sending it straight to the
105    /// dead-letter queue.
106    pub fn permanent(reason: impl Into<String>) -> Self {
107        Self::Permanent(reason.into())
108    }
109}
110
111/// Returned by the [`Worker`] builder methods on invalid configuration.
112#[derive(Debug, thiserror::Error)]
113pub enum WorkerBuilderError {
114    /// [`handle`](Worker::handle) was called twice for the same job type. Use
115    /// [`replace_handler`](Worker::replace_handler) to overwrite intentionally.
116    #[error("handler for job_type {0:?} is already registered")]
117    DuplicateHandler(String),
118    /// The underlying [`ReserveOptions`] were invalid (e.g. an empty queue or
119    /// worker id).
120    #[error(transparent)]
121    ReserveOptions(#[from] ReserveOptionsError),
122}
123
124/// A job-processing loop built on a [`SeppClient`].
125///
126/// Configure it fluently — queues and lease duration via [`new`](Self::new),
127/// then `with_*` tuning and one [`handle`](Self::handle) call per job type —
128/// and start it with [`run`](Self::run). `run` consumes the worker and only
129/// returns after a [`ShutdownHandle`] is triggered and in-flight jobs have
130/// drained.
131///
132/// Each reserved job runs on its own task, bounded by
133/// [`with_max_in_flight`](Self::with_max_in_flight). A job whose `job_type` has
134/// no registered handler is nacked for retry with an attempt-based backoff
135/// (`min(2^attempt, 60)` seconds), so a worker that does have the handler —
136/// e.g. one running the next deploy — can pick it up instead of this worker
137/// burning through the job's attempts.
138pub struct Worker {
139    client: SeppClient,
140    opts: ReserveOptions,
141    handlers: HashMap<String, Handler>,
142    catch_all_handler: Option<Handler>,
143    max_in_flight: usize,
144    reserve_error_backoff: Duration,
145    auto_extend: Option<AutoExtend>,
146    shutdown: ShutdownHandle,
147    metrics: Arc<Metrics>,
148}
149
150#[derive(Debug, Clone, Copy)]
151struct AutoExtend {
152    // None = derive the interval from the granted lease each cycle (default);
153    // Some = the caller's explicit interval. The default must track the GRANTED
154    // lease, not the requested one: if the server clamps the lease below the
155    // request, a requested-lease/3 interval fires only after the granted lease
156    // has already expired, so the job is redelivered and runs twice.
157    explicit_interval: Option<Duration>,
158    extend_by: Duration,
159}
160
161#[cfg(feature = "opentelemetry")]
162struct Metrics {
163    jobs_processed: opentelemetry::metrics::Counter<u64>,
164    jobs_nacked: opentelemetry::metrics::Counter<u64>,
165    jobs_in_flight: opentelemetry::metrics::UpDownCounter<i64>,
166    reserves_completed: opentelemetry::metrics::Counter<u64>,
167    reserves_failed: opentelemetry::metrics::Counter<u64>,
168}
169
170#[cfg(not(feature = "opentelemetry"))]
171struct Metrics;
172
173impl Metrics {
174    #[cfg(feature = "opentelemetry")]
175    fn new() -> Self {
176        let meter = opentelemetry::global::meter("sepp-rs");
177        Self {
178            jobs_processed: meter
179                .u64_counter("sepp_rs.jobs.processed")
180                .with_description("Jobs successfully acked.")
181                .build(),
182            jobs_nacked: meter
183                .u64_counter("sepp_rs.jobs.nacked")
184                .with_description("Jobs nacked. Attribute `outcome` is `retry` or `dead_letter`.")
185                .build(),
186            jobs_in_flight: meter
187                .i64_up_down_counter("sepp_rs.jobs.in_flight")
188                .with_description("Jobs currently being processed by handlers.")
189                .build(),
190            reserves_completed: meter
191                .u64_counter("sepp_rs.reserves.completed")
192                .with_description(
193                    "Reserve RPCs that returned. Attribute `jobs` is `some` or `empty`.",
194                )
195                .build(),
196            reserves_failed: meter
197                .u64_counter("sepp_rs.reserves.failed")
198                .with_description("Reserve RPCs that failed.")
199                .build(),
200        }
201    }
202
203    #[cfg(not(feature = "opentelemetry"))]
204    fn new() -> Self {
205        Self
206    }
207
208    fn record_processed(&self) {
209        #[cfg(feature = "opentelemetry")]
210        self.jobs_processed.add(1, &[]);
211    }
212
213    fn record_nacked(&self, dead_lettered: bool) {
214        #[cfg(feature = "opentelemetry")]
215        {
216            let outcome = if dead_lettered {
217                "dead_letter"
218            } else {
219                "retry"
220            };
221            self.jobs_nacked
222                .add(1, &[opentelemetry::KeyValue::new("outcome", outcome)]);
223        }
224        #[cfg(not(feature = "opentelemetry"))]
225        let _ = dead_lettered;
226    }
227
228    fn record_in_flight_delta(&self, delta: i64) {
229        #[cfg(feature = "opentelemetry")]
230        self.jobs_in_flight.add(delta, &[]);
231        #[cfg(not(feature = "opentelemetry"))]
232        let _ = delta;
233    }
234
235    fn record_reserve_ok(&self, empty: bool) {
236        #[cfg(feature = "opentelemetry")]
237        {
238            let jobs = if empty { "empty" } else { "some" };
239            self.reserves_completed
240                .add(1, &[opentelemetry::KeyValue::new("jobs", jobs)]);
241        }
242        #[cfg(not(feature = "opentelemetry"))]
243        let _ = empty;
244    }
245
246    fn record_reserve_failed(&self) {
247        #[cfg(feature = "opentelemetry")]
248        self.reserves_failed.add(1, &[]);
249    }
250}
251
252struct InFlightGuard {
253    metrics: Arc<Metrics>,
254}
255
256impl InFlightGuard {
257    fn new(metrics: Arc<Metrics>) -> Self {
258        metrics.record_in_flight_delta(1);
259        Self { metrics }
260    }
261}
262
263impl Drop for InFlightGuard {
264    fn drop(&mut self) {
265        self.metrics.record_in_flight_delta(-1);
266    }
267}
268
269/// A cloneable handle for triggering a [`Worker`]'s graceful shutdown.
270///
271/// Obtain one from [`Worker::shutdown_handle`] *before* calling
272/// [`Worker::run`] (which consumes the worker). Calling
273/// [`shutdown`](Self::shutdown) stops new reservations; `run` then waits for
274/// in-flight jobs to finish before returning. Clones share the same signal, so
275/// you can hand a handle to a signal-handler task.
276#[derive(Debug, Clone)]
277pub struct ShutdownHandle {
278    token: CancellationToken,
279}
280
281impl ShutdownHandle {
282    fn new() -> Self {
283        Self {
284            token: CancellationToken::new(),
285        }
286    }
287
288    /// Signals the worker to stop reserving new jobs and begin draining.
289    pub fn shutdown(&self) {
290        self.token.cancel();
291    }
292
293    /// Returns whether shutdown has been signalled.
294    pub fn is_shutdown(&self) -> bool {
295        self.token.is_cancelled()
296    }
297}
298
299impl Worker {
300    /// Creates a worker that reserves from `queues` with the given lease
301    /// duration.
302    ///
303    /// Sensible defaults are applied: up to 16 jobs in flight, a 1s backoff
304    /// after a failed reserve, no lease auto-extension, and a generated
305    /// [`worker_id`](Self::with_worker_id) derived from the hostname and PID.
306    /// Register at least one handler with [`handle`](Self::handle) before
307    /// [`run`](Self::run).
308    pub fn new(
309        client: SeppClient,
310        queues: impl IntoIterator<Item = impl Into<String>>,
311        lease_duration: Duration,
312    ) -> Result<Self, WorkerBuilderError> {
313        let mut opts = ReserveOptions::new(queues, lease_duration)?;
314        opts.worker_id = Some(default_worker_id());
315        Ok(Self {
316            client,
317            opts,
318            handlers: HashMap::new(),
319            catch_all_handler: None,
320            max_in_flight: 16,
321            reserve_error_backoff: Duration::from_secs(1),
322            auto_extend: None,
323            shutdown: ShutdownHandle::new(),
324            metrics: Arc::new(Metrics::new()),
325        })
326    }
327
328    /// Sets the long-poll wait timeout for each reserve. See
329    /// [`ReserveOptions::with_wait_timeout`].
330    ///
331    /// # Panics
332    ///
333    /// Panics if `wait` is zero. The server would answer every reserve
334    /// immediately, turning the poll loop into back-to-back RPCs.
335    pub fn with_wait_timeout(mut self, wait: Duration) -> Self {
336        assert!(!wait.is_zero(), "wait_timeout must be non-zero");
337        self.opts.wait_timeout = wait;
338        self
339    }
340
341    /// Caps how many jobs a single reserve may return. The worker already
342    /// limits this to its free in-flight capacity, so set this only to request
343    /// fewer.
344    ///
345    /// # Panics
346    ///
347    /// Panics if `max` is 0. The server requires `max_jobs >= 1` and would
348    /// reject every reserve, hanging the worker.
349    pub fn with_max_jobs(mut self, max: u32) -> Self {
350        assert!(max >= 1, "max_jobs must be at least 1");
351        self.opts.max_jobs = Some(max);
352        self
353    }
354
355    /// Returns a [`ShutdownHandle`] for stopping the worker. Obtain it before
356    /// calling [`run`](Self::run), which consumes `self`.
357    pub fn shutdown_handle(&self) -> ShutdownHandle {
358        self.shutdown.clone()
359    }
360
361    /// Enables automatic lease extension while a handler runs, using a
362    /// heartbeat interval of one third of the lease duration. Use with caution:
363    /// if the handler hangs indefinitely, the lease will be extended forever.
364    ///
365    /// With this on, long-running handlers keep their lease alive without
366    /// calling [`JobCtx::extend`](crate::JobCtx::extend) themselves. If the
367    /// server reassigns the lease anyway, the handler task is aborted to avoid
368    /// double processing.
369    pub fn with_auto_extend(mut self) -> Self {
370        self.auto_extend = Some(AutoExtend {
371            explicit_interval: None,
372            extend_by: self.opts.lease_duration,
373        });
374        self
375    }
376
377    /// Like [`with_auto_extend`](Self::with_auto_extend) but with an explicit
378    /// heartbeat interval (floored at 1ms). The interval should be comfortably
379    /// shorter than the lease duration.
380    pub fn with_auto_extend_interval(mut self, interval: Duration) -> Self {
381        self.auto_extend = Some(AutoExtend {
382            explicit_interval: Some(interval.max(Duration::from_millis(1))),
383            extend_by: self.opts.lease_duration,
384        });
385        self
386    }
387
388    /// Sets the maximum number of jobs processed concurrently (default 16).
389    /// Values below 1 are treated as 1.
390    pub fn with_max_in_flight(mut self, max_in_flight: usize) -> Self {
391        self.max_in_flight = max_in_flight;
392        self
393    }
394
395    /// Sets how long to wait after a failed reserve before retrying (default
396    /// 1s), preventing a hot loop when the server is unreachable.
397    pub fn with_reserve_error_backoff(mut self, backoff: Duration) -> Self {
398        self.reserve_error_backoff = backoff;
399        self
400    }
401
402    /// Overrides the auto-generated worker id. Must be non-empty.
403    pub fn with_worker_id(
404        mut self,
405        worker_id: impl Into<String>,
406    ) -> Result<Self, WorkerBuilderError> {
407        let id = worker_id.into();
408        if id.is_empty() {
409            return Err(ReserveOptionsError::EmptyWorkerId.into());
410        }
411        self.opts.worker_id = Some(id);
412        Ok(self)
413    }
414
415    /// Registers the handler for a `job_type`.
416    ///
417    /// The handler receives the job's optional [`Payload`] and an
418    /// `Arc<JobCtx>`, and returns `Ok(())` to ack or a [`HandlerError`] to
419    /// nack. Returns [`WorkerBuilderError::DuplicateHandler`] if a handler is
420    /// already registered for this type.
421    pub fn handle<F, Fut>(mut self, job_type: &str, h: F) -> Result<Self, WorkerBuilderError>
422    where
423        F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
424        Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
425    {
426        if self.handlers.contains_key(job_type) {
427            return Err(WorkerBuilderError::DuplicateHandler(job_type.to_string()));
428        }
429        self.handlers.insert(job_type.to_string(), wrap_handler(h));
430        Ok(self)
431    }
432
433    /// Registers a catch-all handler for job types without a specific handler.
434    pub fn with_catch_all_handler<F, Fut>(mut self, h: F) -> Self
435    where
436        F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
437        Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
438    {
439        self.catch_all_handler = Some(wrap_handler(h));
440        self
441    }
442
443    /// Registers a handler, overwriting any existing one for the same
444    /// `job_type` instead of erroring.
445    pub fn replace_handler<F, Fut>(mut self, job_type: &str, h: F) -> Self
446    where
447        F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
448        Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
449    {
450        self.handlers.insert(job_type.to_string(), wrap_handler(h));
451        self
452    }
453
454    /// Unregisters the handler for a `job_type`, if any. Jobs of an unhandled
455    /// type are nacked for retry with an attempt-based backoff.
456    pub fn remove_handler(mut self, job_type: &str) -> Self {
457        self.handlers.remove(job_type);
458        self
459    }
460
461    /// Runs the reserve → process → ack/nack loop until shutdown.
462    ///
463    /// Consumes the worker and does not return until a [`ShutdownHandle`] is
464    /// triggered *and* all in-flight jobs have finished draining. Shutdown
465    /// cancels a still-pending reserve promptly, but a reserve that has already
466    /// completed with jobs at the shutdown boundary still has those jobs
467    /// processed as part of the drain — they are leased to this worker either
468    /// way. Reserve errors are logged and retried after
469    /// [`with_reserve_error_backoff`](Self::with_reserve_error_backoff); they do
470    /// not stop the loop. Take a [`shutdown_handle`](Self::shutdown_handle)
471    /// beforehand to be able to stop it.
472    ///
473    /// The drain wait is unbounded: a handler that never returns blocks the
474    /// return of `run` indefinitely (and with
475    /// [`with_auto_extend`](Self::with_auto_extend) its lease is kept alive the
476    /// whole time). If a handler's work can hang, bound it yourself, e.g. with
477    /// [`tokio::time::timeout`].
478    pub async fn run(self) {
479        let max_permits = self.max_in_flight.max(1);
480        let semaphore = Arc::new(Semaphore::new(max_permits));
481        let handlers = Arc::new(self.handlers);
482        let auto_extend = self.auto_extend;
483        let shutdown = self.shutdown.clone();
484        let metrics = Arc::clone(&self.metrics);
485        info!(
486            worker_id = self.opts.worker_id.as_deref().unwrap_or("<none>"),
487            max_in_flight = self.max_in_flight,
488            handlers = handlers.len(),
489            auto_extend = auto_extend.is_some(),
490            "worker started"
491        );
492
493        'outer: loop {
494            let permit = tokio::select! {
495                biased;
496                () = shutdown.token.cancelled() => break 'outer,
497                p = semaphore.clone().acquire_owned() => p.expect("semaphore is never closed"),
498            };
499
500            let mut opts = self.opts.clone();
501            let capacity = (1 + semaphore.available_permits()).min(u32::MAX as usize) as u32;
502            opts.max_jobs = Some(match opts.max_jobs {
503                Some(user_max) => user_max.min(capacity),
504                None => capacity,
505            });
506
507            // Biased with the reserve arm first: when shutdown lands just as a
508            // reserve completes with jobs in hand, those jobs are already
509            // leased to this worker, so process them as part of the drain
510            // instead of leaving them invisible until the lease expires. A
511            // still-pending reserve is dropped (cancelled) promptly.
512            let reserve_started = tokio::time::Instant::now();
513            let jobs = tokio::select! {
514                biased;
515                res = self.client.reserve(&opts) => match res {
516                    Ok(Some(jobs)) => {
517                        metrics.record_reserve_ok(false);
518                        jobs
519                    }
520                    Ok(None) => {
521                        metrics.record_reserve_ok(true);
522                        // Wait window elapsed — normally re-poll immediately.
523                        // But an empty response that arrives much sooner than
524                        // the requested wait (e.g. a server draining at
525                        // shutdown answers at once) would hot-loop, so pause
526                        // briefly first. Capped at half the wait window so a
527                        // deliberately tiny wait_timeout is not misread as
528                        // early.
529                        if reserve_started.elapsed() < EARLY_EMPTY_THRESHOLD.min(opts.wait_timeout / 2) {
530                            drop(permit);
531                            tokio::select! {
532                                biased;
533                                () = shutdown.token.cancelled() => break 'outer,
534                                () = tokio::time::sleep(EARLY_EMPTY_BACKOFF) => {},
535                            }
536                        }
537                        continue;
538                    }
539                    Err(_err) => {
540                        metrics.record_reserve_failed();
541                        warn!(
542                            "reserve error: {_err}; backing off for {:?}",
543                            self.reserve_error_backoff
544                        );
545                        drop(permit);
546                        tokio::select! {
547                            biased;
548                            () = shutdown.token.cancelled() => break 'outer,
549                            () = tokio::time::sleep(self.reserve_error_backoff) => continue,
550                        }
551                    }
552                },
553                () = shutdown.token.cancelled() => {
554                    drop(permit);
555                    break 'outer;
556                }
557            };
558
559            let mut jobs = jobs.into_iter();
560            let Some(first) = jobs.next() else { continue };
561            {
562                let client = self.client.clone();
563                let handlers = Arc::clone(&handlers);
564                let catch_all_handler = self.catch_all_handler.clone();
565                let metrics = Arc::clone(&metrics);
566                let in_flight = InFlightGuard::new(Arc::clone(&metrics));
567                tokio::spawn(async move {
568                    let _permit = permit; // held for the job's lifetime
569                    let _in_flight = in_flight;
570                    process_job(
571                        &client,
572                        &handlers,
573                        &catch_all_handler,
574                        auto_extend,
575                        first,
576                        &metrics,
577                    )
578                    .await;
579                });
580            }
581
582            for job in jobs {
583                let permit = semaphore
584                    .clone()
585                    .acquire_owned()
586                    .await
587                    .expect("semaphore is never closed");
588                let client = self.client.clone();
589                let handlers = Arc::clone(&handlers);
590                let catch_all_handler = self.catch_all_handler.clone();
591                let metrics = Arc::clone(&metrics);
592                let in_flight = InFlightGuard::new(Arc::clone(&metrics));
593                tokio::spawn(async move {
594                    let _permit = permit;
595                    let _in_flight = in_flight;
596                    process_job(
597                        &client,
598                        &handlers,
599                        &catch_all_handler,
600                        auto_extend,
601                        job,
602                        &metrics,
603                    )
604                    .await;
605                });
606            }
607        }
608
609        info!("worker shutting down; waiting for in-flight jobs to finish");
610        let _drain = semaphore
611            .acquire_many(max_permits as u32)
612            .await
613            .expect("semaphore is never closed");
614        info!("worker stopped");
615    }
616}
617
618async fn process_job(
619    client: &SeppClient,
620    handlers: &HashMap<String, Handler>,
621    catch_all_handler: &Option<Handler>,
622    auto_extend: Option<AutoExtend>,
623    job: Job,
624    metrics: &Metrics,
625) {
626    let span = tracing::info_span!(
627        "sepp-rs.process",
628        otel.kind = "consumer",
629        otel.status_code = tracing::field::Empty,
630        job_id = %job.ctx.id,
631        job_type = %job.ctx.job_type,
632        attempt = job.ctx.attempt,
633    );
634
635    #[cfg(feature = "opentelemetry")]
636    if let Some(link) = job
637        .ctx
638        .trace_context
639        .as_ref()
640        .and_then(crate::TraceContext::otel_span_context)
641    {
642        use tracing_opentelemetry::OpenTelemetrySpanExt;
643        span.add_link(link);
644    }
645
646    run_job(
647        client,
648        handlers,
649        catch_all_handler,
650        auto_extend,
651        job,
652        metrics,
653    )
654    .instrument(span)
655    .await
656}
657
658async fn run_job(
659    client: &SeppClient,
660    handlers: &HashMap<String, Handler>,
661    catch_all_handler: &Option<Handler>,
662    auto_extend: Option<AutoExtend>,
663    job: Job,
664    metrics: &Metrics,
665) {
666    let Job { payload, ctx } = job;
667    let lease = ctx.lease.clone();
668    let ctx = Arc::new(ctx);
669
670    let Some(handler) = handlers.get(&ctx.job_type).or(catch_all_handler.as_ref()) else {
671        warn!("no handler registered for job_type `{}`", ctx.job_type);
672
673        // Nack with a backoff rather than the default (immediate) retry:
674        // otherwise this worker re-reserves the job right away and burns
675        // through its attempts in milliseconds.
676        if let Err(err) = client
677            .nack(
678                &ctx,
679                RetryDirective::After(nack_backoff(ctx.attempt)),
680                "no handler registered for job_type",
681            )
682            .await
683        {
684            warn!("failed to nack job with no registered handler: {err}");
685        }
686        return;
687    };
688
689    let fut = handler(payload, Arc::clone(&ctx));
690
691    let disposition = match auto_extend {
692        None => match AssertUnwindSafe(fut).catch_unwind().await {
693            Ok(result) => Disposition::Completed(result),
694            Err(_panic) => Disposition::Panicked,
695        },
696        Some(cfg) => {
697            let handler_task = tokio::spawn(fut);
698            let abort = handler_task.abort_handle();
699            let heartbeat_task = tokio::spawn(heartbeat(lease, cfg, abort));
700
701            let joined = handler_task.await;
702            heartbeat_task.abort(); // handler finished — stop extending
703
704            match joined {
705                Ok(result) => Disposition::Completed(result),
706                Err(err) if err.is_cancelled() => {
707                    error!("lease lost; handler aborted");
708                    return;
709                }
710                Err(_panic) => Disposition::Panicked,
711            }
712        }
713    };
714
715    if let Err(err) = dispose(client, &ctx, disposition, metrics).await {
716        error!(
717            "failed to ack/nack job: {err}; either the lease was lost and the job will be redelivered, or a retried attempt already succeeded and only its response was lost"
718        );
719    }
720}
721
722enum Disposition {
723    Completed(Result<(), HandlerError>),
724    Panicked,
725}
726
727async fn dispose(
728    client: &SeppClient,
729    ctx: &JobCtx,
730    disposition: Disposition,
731    metrics: &Metrics,
732) -> Result<(), LeaseError> {
733    match disposition {
734        Disposition::Completed(Ok(())) => {
735            debug!("job completed; acking");
736            client.ack(ctx).await?;
737            metrics.record_processed();
738            Ok(())
739        }
740        Disposition::Completed(Err(err)) => {
741            tracing::Span::current().record("otel.status_code", "error");
742            warn!("handler returned error; nacking: {err}");
743            let (retry, reason) = match err {
744                HandlerError::Retry(r) => (RetryDirective::Default, r),
745                HandlerError::RetryAfter(r, d) => (RetryDirective::After(d), r),
746                HandlerError::Permanent(r) => (RetryDirective::DeadLetter, r),
747            };
748            let dead_lettered = client.nack(ctx, retry, reason).await?;
749            metrics.record_nacked(dead_lettered);
750            Ok(())
751        }
752        Disposition::Panicked => {
753            tracing::Span::current().record("otel.status_code", "error");
754            error!("handler panicked; nacking");
755            // Backoff instead of an immediate retry, so a deterministically
756            // panicking handler cannot hot-loop through the job's attempts.
757            let dead_lettered = client
758                .nack(
759                    ctx,
760                    RetryDirective::After(nack_backoff(ctx.attempt)),
761                    "handler panicked",
762                )
763                .await?;
764            metrics.record_nacked(dead_lettered);
765            Ok(())
766        }
767    }
768}
769
770/// Exponential backoff for the worker's own nacks (no registered handler,
771/// panicked handler): `min(2^attempt, 60)` seconds, with `attempt` 1-based.
772fn nack_backoff(attempt: u32) -> Duration {
773    Duration::from_secs(2u64.saturating_pow(attempt).min(60))
774}
775
776async fn heartbeat(lease: Lease, cfg: AutoExtend, handler: AbortHandle) {
777    loop {
778        // Derive from the granted lease; see AutoExtend::explicit_interval.
779        let interval = cfg.explicit_interval.unwrap_or_else(|| {
780            let remaining_ms = lease.known_expiry_ms().saturating_sub(now_millis()).max(0) as u64;
781            heartbeat_interval(Duration::from_millis(remaining_ms))
782        });
783        tokio::time::sleep(interval).await;
784
785        match lease.extend(cfg.extend_by).await {
786            Ok(expiry) => debug!(?expiry, "lease extended"),
787            Err(err @ (LeaseError::AttemptMismatch | LeaseError::JobNotFound)) => {
788                error!(
789                    "lease reassigned by server ({err}); aborting handler to avoid double processing"
790                );
791                handler.abort();
792                return;
793            }
794            Err(err) => {
795                if now_millis() >= lease.known_expiry_ms() {
796                    error!("lease lost ({err}); aborting handler to avoid double processing");
797                    handler.abort();
798                    return;
799                }
800                warn!("lease extend failed ({err}); lease still valid, will retry");
801            }
802        }
803    }
804}
805
806fn heartbeat_interval(lease: Duration) -> Duration {
807    (lease / 3).max(Duration::from_millis(1))
808}
809
810fn default_worker_id() -> String {
811    let host = hostname();
812    let rand = uuid::Uuid::new_v4().simple().to_string();
813    format!("{host}-{}-{}", std::process::id(), &rand[..8])
814}
815
816/// Resolves the machine's hostname: the `HOSTNAME` / `COMPUTERNAME` env vars
817/// when set (containers export `HOSTNAME`; most other environments do not),
818/// otherwise the OS hostname.
819fn hostname() -> String {
820    std::env::var("HOSTNAME")
821        .or_else(|_| std::env::var("COMPUTERNAME"))
822        .ok()
823        .filter(|h| !h.is_empty())
824        .unwrap_or_else(|| gethostname::gethostname().to_string_lossy().into_owned())
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn heartbeat_interval_third_of_lease() {
833        assert_eq!(
834            heartbeat_interval(Duration::from_secs(3)),
835            Duration::from_secs(1)
836        );
837    }
838
839    #[test]
840    fn heartbeat_interval_nine_seconds() {
841        assert_eq!(
842            heartbeat_interval(Duration::from_secs(9)),
843            Duration::from_secs(3)
844        );
845    }
846
847    #[test]
848    fn heartbeat_interval_floor_at_one_ms_for_tiny_lease() {
849        assert_eq!(
850            heartbeat_interval(Duration::from_millis(1)),
851            Duration::from_millis(1)
852        );
853    }
854
855    #[test]
856    fn heartbeat_interval_floor_at_one_ms_for_zero_lease() {
857        assert_eq!(heartbeat_interval(Duration::ZERO), Duration::from_millis(1));
858    }
859
860    #[test]
861    fn worker_err_retry() {
862        let e = HandlerError::retry("network");
863        assert!(matches!(e, HandlerError::Retry(s) if s == "network"));
864    }
865
866    #[test]
867    fn worker_err_retry_after() {
868        let e = HandlerError::retry_after("rate limited", Duration::from_secs(5));
869        assert!(matches!(
870            e,
871            HandlerError::RetryAfter(s, d) if s == "rate limited" && d == Duration::from_secs(5)
872        ));
873    }
874
875    #[test]
876    fn worker_err_permanent() {
877        let e = HandlerError::permanent("bad input");
878        assert!(matches!(e, HandlerError::Permanent(s) if s == "bad input"));
879    }
880
881    #[test]
882    fn shutdown_handle_starts_unsignaled() {
883        let h = ShutdownHandle::new();
884        assert!(!h.is_shutdown());
885    }
886
887    #[test]
888    fn shutdown_handle_is_signaled_after_shutdown() {
889        let h = ShutdownHandle::new();
890        h.shutdown();
891        assert!(h.is_shutdown());
892    }
893
894    #[test]
895    fn shutdown_handle_clones_share_state() {
896        let h = ShutdownHandle::new();
897        let h2 = h.clone();
898        h.shutdown();
899        assert!(h2.is_shutdown());
900    }
901
902    #[tokio::test]
903    async fn shutdown_handle_cancelled_resolves_when_already_signaled() {
904        let h = ShutdownHandle::new();
905        h.shutdown();
906        tokio::time::timeout(Duration::from_secs(1), h.token.cancelled())
907            .await
908            .expect("cancelled() should resolve immediately when already signaled");
909    }
910
911    #[tokio::test]
912    async fn shutdown_handle_cancelled_resolves_on_late_signal() {
913        let h = ShutdownHandle::new();
914        let h2 = h.clone();
915        tokio::spawn(async move {
916            tokio::time::sleep(Duration::from_millis(10)).await;
917            h2.shutdown();
918        });
919        tokio::time::timeout(Duration::from_secs(1), h.token.cancelled())
920            .await
921            .expect("cancelled() should be woken by shutdown signal");
922    }
923
924    fn worker_test_client() -> SeppClient {
925        let chan = tonic::transport::Endpoint::from_static("http://[::1]:1").connect_lazy();
926        SeppClient::from_channel(chan)
927    }
928
929    async fn dummy_ok_handler(
930        _payload: Option<Payload>,
931        _ctx: Arc<JobCtx>,
932    ) -> Result<(), HandlerError> {
933        Ok(())
934    }
935
936    #[test]
937    fn default_worker_id_has_expected_format() {
938        // host-pid-rand8, where the hostname itself may contain dashes:
939        // parse from the right.
940        let id = default_worker_id();
941        let parts: Vec<&str> = id.rsplitn(3, '-').collect();
942        assert_eq!(
943            parts.len(),
944            3,
945            "expected host-pid-rand dash-separated parts, got: {id}"
946        );
947        assert_eq!(parts[0].len(), 8, "expected 8-char hex suffix, got: {id}");
948        assert_eq!(
949            parts[1].parse::<u32>().ok(),
950            Some(std::process::id()),
951            "expected the middle part to be the PID, got: {id}"
952        );
953        assert!(!parts[2].is_empty(), "expected a hostname part, got: {id}");
954    }
955
956    #[test]
957    fn hostname_resolves_outside_containers() {
958        // HOSTNAME is a shell-local variable and is typically NOT exported to
959        // this process, so this exercises the OS fallback on most machines.
960        assert!(!hostname().is_empty());
961    }
962
963    #[test]
964    fn nack_backoff_grows_exponentially_and_caps_at_sixty_seconds() {
965        assert_eq!(nack_backoff(1), Duration::from_secs(2));
966        assert_eq!(nack_backoff(2), Duration::from_secs(4));
967        assert_eq!(nack_backoff(5), Duration::from_secs(32));
968        assert_eq!(nack_backoff(6), Duration::from_secs(60));
969        assert_eq!(nack_backoff(100), Duration::from_secs(60));
970        assert_eq!(nack_backoff(u32::MAX), Duration::from_secs(60));
971    }
972
973    #[test]
974    fn handler_error_display_retry() {
975        let e = HandlerError::retry("network timeout");
976        let s = e.to_string();
977        assert!(s.contains("retry"));
978        assert!(s.contains("network timeout"));
979    }
980
981    #[test]
982    fn handler_error_display_retry_after() {
983        let e = HandlerError::retry_after("rate limit", Duration::from_secs(30));
984        let s = e.to_string();
985        assert!(s.contains("retry after"));
986        assert!(s.contains("rate limit"));
987    }
988
989    #[test]
990    fn handler_error_display_permanent() {
991        let e = HandlerError::permanent("bad input");
992        let s = e.to_string();
993        assert!(s.contains("permanent"));
994        assert!(s.contains("bad input"));
995    }
996
997    #[test]
998    fn worker_builder_error_duplicate_handler_display() {
999        let e = WorkerBuilderError::DuplicateHandler("send_email".into());
1000        let s = e.to_string();
1001        assert!(s.contains("send_email"));
1002        assert!(s.contains("already registered"));
1003    }
1004
1005    #[test]
1006    fn worker_builder_error_from_reserve_options() {
1007        let e = WorkerBuilderError::from(ReserveOptionsError::EmptyWorkerId);
1008        let s = e.to_string();
1009        assert!(s.contains("worker_id"));
1010    }
1011
1012    #[tokio::test]
1013    async fn worker_new_rejects_empty_queues() {
1014        let client = worker_test_client();
1015        let result = Worker::new(client, Vec::<String>::new(), Duration::from_secs(1));
1016        assert!(matches!(
1017            result,
1018            Err(WorkerBuilderError::ReserveOptions(
1019                ReserveOptionsError::EmptyQueues
1020            ))
1021        ));
1022    }
1023
1024    #[tokio::test]
1025    async fn worker_new_rejects_zero_lease() {
1026        let client = worker_test_client();
1027        let result = Worker::new(client, ["q"], Duration::ZERO);
1028        assert!(matches!(
1029            result,
1030            Err(WorkerBuilderError::ReserveOptions(
1031                ReserveOptionsError::LeaseDurationTooShort
1032            ))
1033        ));
1034    }
1035
1036    #[tokio::test]
1037    async fn worker_new_succeeds_with_valid_args() {
1038        let client = worker_test_client();
1039        let _w = Worker::new(client, ["q"], Duration::from_secs(1)).unwrap();
1040    }
1041
1042    #[tokio::test]
1043    async fn worker_handle_rejects_duplicate_job_type() {
1044        let client = worker_test_client();
1045        let w = Worker::new(client, ["q"], Duration::from_secs(1))
1046            .unwrap()
1047            .handle("my_job", dummy_ok_handler)
1048            .unwrap();
1049        let result = w.handle("my_job", dummy_ok_handler);
1050        assert!(matches!(
1051            result,
1052            Err(WorkerBuilderError::DuplicateHandler(t)) if t == "my_job"
1053        ));
1054    }
1055
1056    #[tokio::test]
1057    async fn worker_replace_handler_overwrites() {
1058        let client = worker_test_client();
1059        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1060            .unwrap()
1061            .handle("my_job", dummy_ok_handler)
1062            .unwrap()
1063            .replace_handler("my_job", dummy_ok_handler);
1064    }
1065
1066    #[tokio::test]
1067    async fn worker_remove_handler_allows_re_registration() {
1068        let client = worker_test_client();
1069        let w = Worker::new(client, ["q"], Duration::from_secs(1))
1070            .unwrap()
1071            .handle("my_job", dummy_ok_handler)
1072            .unwrap()
1073            .remove_handler("my_job");
1074        let _w = w.handle("my_job", dummy_ok_handler).unwrap();
1075    }
1076
1077    #[tokio::test]
1078    async fn worker_with_catch_all_handler_succeeds() {
1079        let client = worker_test_client();
1080        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1081            .unwrap()
1082            .with_catch_all_handler(dummy_ok_handler);
1083    }
1084
1085    #[tokio::test]
1086    async fn worker_with_worker_id_rejects_empty() {
1087        let client = worker_test_client();
1088        let result = Worker::new(client, ["q"], Duration::from_secs(1))
1089            .unwrap()
1090            .with_worker_id("");
1091        assert!(matches!(
1092            result,
1093            Err(WorkerBuilderError::ReserveOptions(
1094                ReserveOptionsError::EmptyWorkerId
1095            ))
1096        ));
1097    }
1098
1099    #[tokio::test]
1100    #[should_panic(expected = "wait_timeout must be non-zero")]
1101    async fn worker_with_wait_timeout_zero_panics() {
1102        let client = worker_test_client();
1103        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1104            .unwrap()
1105            .with_wait_timeout(Duration::ZERO);
1106    }
1107
1108    #[tokio::test]
1109    async fn worker_with_wait_timeout_accepts_non_zero() {
1110        let client = worker_test_client();
1111        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1112            .unwrap()
1113            .with_wait_timeout(Duration::from_millis(1));
1114    }
1115
1116    #[tokio::test]
1117    #[should_panic(expected = "max_jobs must be at least 1")]
1118    async fn worker_with_max_jobs_zero_panics() {
1119        let client = worker_test_client();
1120        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1121            .unwrap()
1122            .with_max_jobs(0);
1123    }
1124
1125    #[tokio::test]
1126    async fn worker_with_max_jobs_valid() {
1127        let client = worker_test_client();
1128        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1129            .unwrap()
1130            .with_max_jobs(5)
1131            .handle("t", dummy_ok_handler)
1132            .unwrap();
1133    }
1134
1135    #[tokio::test]
1136    async fn worker_with_max_in_flight_lower_bound() {
1137        let client = worker_test_client();
1138        let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1139            .unwrap()
1140            .with_max_in_flight(1)
1141            .handle("t", dummy_ok_handler)
1142            .unwrap();
1143    }
1144
1145    #[tokio::test]
1146    async fn worker_shutdown_handle_returns_untriggered_handle() {
1147        let client = worker_test_client();
1148        let w = Worker::new(client, ["q"], Duration::from_secs(1)).unwrap();
1149        let h = w.shutdown_handle();
1150        assert!(!h.is_shutdown());
1151    }
1152
1153    #[test]
1154    fn in_flight_guard_does_not_panic() {
1155        let metrics = Arc::new(Metrics::new());
1156        let _guard = InFlightGuard::new(metrics);
1157    }
1158}