Skip to main content

worklane_core/
job.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use async_trait::async_trait;
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9use crate::envelope::JobEnvelope;
10use crate::id::JobId;
11use crate::lane::Lane;
12
13/// A cooperative cancellation flag for a running job.
14///
15/// The worker flips it when it stops maintaining the job's reservation lease —
16/// because the lease was lost (a heartbeat came back stale, so the job will be
17/// redelivered) or the handler timeout elapsed. A long-running, cooperative
18/// handler can poll [`JobContext::is_cancelled`] at safe points and return early
19/// to stop doing work that will be thrown away. It is *advisory*: ignoring it is
20/// safe (delivery is at-least-once regardless), and a handler with no cancellation
21/// checks behaves exactly as before. Cloning shares the underlying flag.
22#[derive(Debug, Clone, Default)]
23pub struct Cancellation(Arc<AtomicBool>);
24
25impl Cancellation {
26    /// A fresh, un-cancelled flag.
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// Signal cancellation. Idempotent.
32    pub fn cancel(&self) {
33        self.0.store(true, Ordering::Release);
34    }
35
36    /// Whether cancellation has been signalled.
37    pub fn is_cancelled(&self) -> bool {
38        self.0.load(Ordering::Acquire)
39    }
40}
41
42/// A boxed error returned by a job handler.
43pub type HandlerError = Box<dyn std::error::Error + Send + Sync>;
44
45/// The result of running a job handler.
46pub type HandlerResult<T> = std::result::Result<T, HandlerError>;
47
48/// Per-run context handed to a job handler.
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub struct JobContext {
52    /// The job id.
53    pub id: JobId,
54    /// The lane the job was reserved from.
55    pub lane: Lane,
56    /// The number of attempts made before this one.
57    pub attempts: u32,
58    /// The maximum number of attempts allowed.
59    pub max_attempts: u32,
60    /// The priority of the job.
61    pub priority: u8,
62    /// The job kind.
63    pub kind: String,
64    /// Optional W3C TraceContext propagation headers carried on the envelope,
65    /// exposed so a handler can read or forward them without re-parsing. `None`
66    /// when the caller injected no trace context.
67    pub trace_context: Option<HashMap<String, String>>,
68    /// Cooperative cancellation for this run (see [`Cancellation`]). Defaults to a
69    /// never-cancelled flag; the worker injects a shared one via
70    /// [`with_cancellation`](JobContext::with_cancellation) and flips it when it
71    /// abandons the lease.
72    cancellation: Cancellation,
73}
74
75impl JobContext {
76    /// Build the per-run context for a dispatched job.
77    pub fn new(
78        id: JobId,
79        lane: Lane,
80        attempts: u32,
81        max_attempts: u32,
82        priority: u8,
83        kind: String,
84        trace_context: Option<HashMap<String, String>>,
85    ) -> Self {
86        JobContext {
87            id,
88            lane,
89            attempts,
90            max_attempts,
91            priority,
92            kind,
93            trace_context,
94            cancellation: Cancellation::new(),
95        }
96    }
97
98    /// Attach a shared [`Cancellation`] (builder style). The worker uses this to
99    /// hand the handler the same flag it flips on lease loss or timeout.
100    #[must_use = "this value must be used"]
101    pub fn with_cancellation(mut self, cancellation: Cancellation) -> Self {
102        self.cancellation = cancellation;
103        self
104    }
105
106    /// Whether the worker has signalled cooperative cancellation for this run —
107    /// the lease was lost (the job will be redelivered) or the handler timed out.
108    /// A cooperative handler can check this at safe points and return early to
109    /// avoid wasting work; ignoring it is safe.
110    pub fn is_cancelled(&self) -> bool {
111        self.cancellation.is_cancelled()
112    }
113}
114
115impl From<&JobEnvelope> for JobContext {
116    /// Project the per-run context from a reserved envelope, keeping the
117    /// envelope-to-context field mapping in one place.
118    fn from(envelope: &JobEnvelope) -> Self {
119        JobContext::new(
120            envelope.id,
121            envelope.lane.clone(),
122            envelope.attempts,
123            envelope.max_attempts,
124            envelope.priority,
125            envelope.kind.clone(),
126            envelope.trace_context.clone(),
127        )
128    }
129}
130
131/// A typed background job.
132///
133/// Implementors declare a serde-serializable [`Payload`](Job::Payload), a unique
134/// [`KIND`](Job::KIND) string used for dispatch, and an async
135/// [`run`](Job::run) method.
136///
137/// **Handlers must be idempotent.** Delivery is at-least-once: a lease that
138/// expires before the job is resolved makes the job visible again, so a handler
139/// can run more than once for the same job. This happens on a worker crash, but
140/// also when the broker's wall clock steps forward (e.g. an NTP jump) past a
141/// reserved job's remaining lease — expiring it while the original handler is
142/// still running. `run` must therefore tolerate re-execution (e.g. guard side
143/// effects with the [`JobContext::id`] or an external dedup key); the broker does
144/// not prevent duplicate execution.
145#[async_trait]
146pub trait Job: Send + Sync + 'static {
147    /// The payload type carried by this job.
148    type Payload: Serialize + DeserializeOwned + Send + Sync + 'static;
149
150    /// The output type returned by this job upon success.
151    type Output: Serialize + DeserializeOwned + Send + Sync + 'static;
152
153    /// The unique kind identifier for this job.
154    const KIND: &'static str;
155
156    /// Execute the job. Returning `Err` causes a retry (until attempts are
157    /// exhausted) or dead-lettering.
158    async fn run(&self, ctx: JobContext, payload: Self::Payload) -> HandlerResult<Self::Output>;
159}