Skip to main content

cja/jobs/
mod.rs

1//! Background job processing with `PostgreSQL` persistence.
2//!
3//! The job system provides a reliable, database-backed work queue with automatic retries,
4//! exponential backoff, priority scheduling, and a dead letter queue for permanently
5//! failed jobs.
6//!
7//! # Defining a Job
8//!
9//! Implement the [`Job`] trait for your job type:
10//!
11//! ```rust,ignore
12//! use cja::jobs::Job;
13//! use serde::{Serialize, Deserialize};
14//!
15//! #[derive(Debug, Serialize, Deserialize, Clone)]
16//! struct SendEmailJob {
17//!     to: String,
18//!     subject: String,
19//! }
20//!
21//! #[async_trait::async_trait]
22//! impl Job<MyAppState> for SendEmailJob {
23//!     const NAME: &'static str = "SendEmailJob";
24//!
25//!     async fn run(&self, app_state: MyAppState) -> cja::Result<()> {
26//!         // Send email using app_state.db() for templates, etc.
27//!         Ok(())
28//!     }
29//! }
30//! ```
31//!
32//! # Registering Jobs
33//!
34//! All job types must be registered via the [`impl_job_registry!`](crate::impl_job_registry) macro:
35//!
36//! ```rust,ignore
37//! cja::impl_job_registry!(MyAppState, SendEmailJob, CleanupJob, ReportJob);
38//! ```
39//!
40//! This generates a `Jobs` struct that routes jobs by their `NAME` constant.
41//! Forgetting to register a job type means it won't be processed.
42//!
43//! # Enqueuing Jobs
44//!
45//! ```rust,ignore
46//! let job = SendEmailJob {
47//!     to: "user@example.com".into(),
48//!     subject: "Welcome!".into(),
49//! };
50//!
51//! // Default priority (0)
52//! job.clone().enqueue(app_state.clone(), "user-signup".into(), None).await?;
53//!
54//! // High priority — higher values run first (ORDER BY priority DESC)
55//! job.enqueue(app_state, "urgent".into(), Some(10)).await?;
56//! ```
57//!
58//! **Priority**: Higher values run first. Priority 10 runs before 0, and -10
59//! runs last. This is per-enqueue, not per-job-type.
60//!
61//! # Retry Behavior
62//!
63//! Failed jobs are automatically retried with exponential backoff
64//! (delay = `2^(error_count + 1)` seconds):
65//!
66//! | Retry # | Delay |
67//! |---------|-------|
68//! | 1 | 2 seconds |
69//! | 2 | 4 seconds |
70//! | 5 | 32 seconds |
71//! | 10 | ~17 minutes |
72//! | 20 | ~12 days |
73//!
74//! With `max_retries = N`, a job can execute N+1 times. Returned errors,
75//! caught handler panics, and expired leases each count as a failed attempt.
76//! The final incremented count is stored in `dead_letter_jobs`.
77//!
78//! # Idempotency
79//!
80//! **Jobs must be idempotent.** The system retries on timeouts, worker crashes,
81//! and network failures. Guard against double-application with:
82//!
83//! - `ON CONFLICT DO NOTHING` for inserts
84//! - Early-exit checks at job start
85//! - Idempotency keys in your domain logic
86//!
87//! # Concurrent Workers
88//!
89//! Multiple [`worker::job_worker`] instances can run safely — the worker SQL
90//! uses `FOR UPDATE SKIP LOCKED` to prevent duplicate processing at the
91//! database level.
92//!
93//! # Cancellation Support
94//!
95//! Long-running jobs can override [`Job::run_with_cancellation`] to exit
96//! gracefully during shutdown by checking the cancellation token periodically.
97//!
98//! # Database Schema
99//!
100//! ## `jobs` table
101//!
102//! | Column | Type | Description |
103//! |--------|------|-------------|
104//! | `job_id` | UUID | Primary key |
105//! | `name` | TEXT | Job type name (matches `Job::NAME`) |
106//! | `payload` | JSONB | Serialized job data |
107//! | `priority` | INT | Higher = runs first (`ORDER BY priority DESC`) |
108//! | `run_at` | TIMESTAMPTZ | When job can next be executed |
109//! | `created_at` | TIMESTAMPTZ | When job was enqueued |
110//! | `locked_at` | TIMESTAMPTZ | Last successful heartbeat (claim time initially) |
111//! | `locked_by` | TEXT | Worker UUID string used as the exact ownership key |
112//! | `context` | TEXT | Debug info (e.g., "user-signup") |
113//! | `error_count` | INT | Number of failures |
114//! | `last_error_message` | TEXT | Most recent error |
115//! | `last_failed_at` | TIMESTAMPTZ | Timestamp of last failure |
116//!
117//! ## `dead_letter_jobs` table
118//!
119//! Jobs that exceeded max retries are moved here for manual investigation.
120//!
121//! | Column | Type | Description |
122//! |--------|------|-------------|
123//! | `id` | UUID | Primary key |
124//! | `original_job_id` | UUID | Original `job_id` |
125//! | `name` | TEXT | Job type name |
126//! | `payload` | JSONB | Serialized job data |
127//! | `context` | TEXT | Debug info |
128//! | `priority` | INT | Original priority |
129//! | `error_count` | INT | Total failure count |
130//! | `last_error_message` | TEXT | Final error |
131//! | `created_at` | TIMESTAMPTZ | Original creation time |
132//! | `failed_at` | TIMESTAMPTZ | When moved to dead letter |
133
134use crate::app_state::AppState as AS;
135use serde::{Serialize, de::DeserializeOwned};
136use thiserror::Error;
137use tracing::{Instrument, instrument};
138
139pub(crate) fn enqueue_span<AppState: AS, J: Job<AppState>>(
140    job: &J,
141    context: &str,
142    priority: Option<i32>,
143    created_at: chrono::DateTime<chrono::Utc>,
144    job_id: uuid::Uuid,
145) -> tracing::Span {
146    tracing::info_span!(
147        "jobs.enqueue",
148        job.id = %job_id,
149        job.name = J::NAME,
150        job.context = %context,
151        job.priority = priority.unwrap_or(0),
152        job.created_at = %created_at,
153        job.run_at = %created_at,
154        "self" = ?job,
155        context = %context,
156        priority = ?priority,
157    )
158}
159
160pub(crate) async fn enqueue_on_connection<AppState: AS, J: Job<AppState>>(
161    conn: &mut sqlx::PgConnection,
162    job: J,
163    context: &str,
164    priority: Option<i32>,
165    created_at: chrono::DateTime<chrono::Utc>,
166    job_id: uuid::Uuid,
167) -> Result<uuid::Uuid, EnqueueError> {
168    sqlx::query(
169        "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context)
170         VALUES ($1, $2, $3, $4, $5, $6, $7)",
171    )
172    .bind(job_id)
173    .bind(J::NAME)
174    .bind(serde_json::to_value(job)?)
175    .bind(priority.unwrap_or(0))
176    .bind(created_at)
177    .bind(created_at)
178    .bind(context)
179    .execute(conn)
180    .await?;
181    Ok(job_id)
182}
183
184pub(crate) fn enqueue_receipt<JName: AsRef<str>>(job_id: uuid::Uuid, name: JName) {
185    tracing::info!(event_type = "job_enqueued", job_id = %job_id, job.name = name.as_ref(), "Job enqueued");
186}
187
188pub(crate) fn enqueue_failure(job_id: uuid::Uuid, error: &EnqueueError) {
189    tracing::error!(job_id = %job_id, %error, "Job enqueue failed");
190}
191
192pub mod registry;
193
194pub use tokio_util::sync::CancellationToken;
195pub use worker::{
196    DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_MAX_RETRIES, DEFAULT_RECLAIM_WINDOW, IdlePollGate,
197    JobWorkerConfig,
198};
199
200#[derive(Debug, Error)]
201pub enum EnqueueError {
202    #[error("SqlxError: {0}")]
203    SqlxError(#[from] sqlx::Error),
204    #[error("SerdeJsonError: {0}")]
205    SerdeJsonError(#[from] serde_json::Error),
206}
207
208/// A trait for defining background jobs that can be enqueued and processed asynchronously.
209///
210/// Jobs must be serializable and provide a unique name identifier. The job system
211/// handles persistence, retries, and concurrent execution automatically.
212///
213/// # Automatic Retry Behavior
214///
215/// All jobs are automatically retried on failure with exponential backoff:
216/// - Failed jobs are requeued with increasing delays: 2, 4, 8, 16, 32... seconds
217/// - Error messages and failure timestamps are tracked in the database
218/// - Jobs are moved to a dead letter queue after exceeding the configured max retries (default: 20)
219/// - No manual intervention required for transient failures
220///
221/// # Lock Timeout (Abandoned Job Recovery)
222///
223/// Jobs are locked while being processed to prevent multiple workers from running the same
224/// job. If a worker crashes or becomes unresponsive, the lock remains but the job is never
225/// completed. The lease reclaim window handles this:
226/// - New workers heartbeat active jobs every 30 seconds by default
227/// - A missing heartbeat makes the job claimable after 120 seconds by default
228/// - Every expired lease counts as a failed attempt
229/// - This ensures jobs are eventually processed even after worker failures
230///
231/// # Example
232///
233/// ```rust
234/// use cja::jobs::Job;
235/// use serde::{Serialize, Deserialize};
236///
237/// # #[derive(Debug, Clone)]
238/// # struct MyAppState {
239/// #     db: sqlx::PgPool,
240/// # }
241/// # impl cja::app_state::AppState for MyAppState {
242/// #     fn db(&self) -> &sqlx::PgPool { &self.db }
243/// #     fn version(&self) -> &str { "1.0.0" }
244/// #     fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
245/// # }
246///
247/// #[derive(Debug, Serialize, Deserialize, Clone)]
248/// struct EmailJob {
249///     to: String,
250///     subject: String,
251///     body: String,
252/// }
253///
254/// #[async_trait::async_trait]
255/// impl Job<MyAppState> for EmailJob {
256///     const NAME: &'static str = "EmailJob";
257///
258///     async fn run(&self, app_state: MyAppState) -> color_eyre::Result<()> {
259///         // Send email logic here
260///         println!("Sending email to {} with subject: {}", self.to, self.subject);
261///         // You can access the database through app_state.db()
262///         Ok(())
263///     }
264/// }
265/// ```
266///
267/// # Enqueuing Jobs
268///
269/// ```rust
270/// # use cja::jobs::Job;
271/// # use serde::{Serialize, Deserialize};
272/// # #[derive(Debug, Serialize, Deserialize, Clone)]
273/// # struct EmailJob { to: String, subject: String, body: String }
274/// # #[derive(Debug, Clone)]
275/// # struct MyAppState { db: sqlx::PgPool }
276/// # impl cja::app_state::AppState for MyAppState {
277/// #     fn db(&self) -> &sqlx::PgPool { &self.db }
278/// #     fn version(&self) -> &str { "1.0.0" }
279/// #     fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
280/// # }
281/// # #[async_trait::async_trait]
282/// # impl Job<MyAppState> for EmailJob {
283/// #     const NAME: &'static str = "EmailJob";
284/// #     async fn run(&self, _: MyAppState) -> color_eyre::Result<()> { Ok(()) }
285/// # }
286/// # async fn example(app_state: MyAppState) -> Result<(), cja::jobs::EnqueueError> {
287/// let job = EmailJob {
288///     to: "user@example.com".to_string(),
289///     subject: "Welcome!".to_string(),
290///     body: "Thank you for signing up!".to_string(),
291/// };
292///
293/// // Enqueue the job with a context string for debugging
294/// job.enqueue(app_state, "user-signup".to_string(), None).await?;
295/// # Ok(())
296/// # }
297/// ```
298///
299/// # Job with Database Access
300///
301/// ```rust
302/// use cja::jobs::Job;
303/// use serde::{Serialize, Deserialize};
304///
305/// # #[derive(Debug, Clone)]
306/// # struct MyAppState {
307/// #     db: sqlx::PgPool,
308/// # }
309/// # impl cja::app_state::AppState for MyAppState {
310/// #     fn db(&self) -> &sqlx::PgPool { &self.db }
311/// #     fn version(&self) -> &str { "1.0.0" }
312/// #     fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
313/// # }
314///
315/// #[derive(Debug, Serialize, Deserialize, Clone)]
316/// struct ProcessPaymentJob {
317///     user_id: i32,
318///     amount_cents: i64,
319/// }
320///
321/// #[async_trait::async_trait]
322/// impl Job<MyAppState> for ProcessPaymentJob {
323///     const NAME: &'static str = "ProcessPaymentJob";
324///
325///     async fn run(&self, app_state: MyAppState) -> color_eyre::Result<()> {
326///         use crate::cja::app_state::AppState;
327///         use sqlx::Row;
328///
329///         // Access the database through app_state
330///         let user = sqlx::query("SELECT name FROM users WHERE id = $1")
331///             .bind(self.user_id)
332///             .fetch_one(app_state.db())
333///             .await?;
334///
335///         println!("Processing payment of {} cents for user {} #{}",
336///                  self.amount_cents, user.get::<String, _>("name"), self.user_id);
337///
338///         sqlx::query("INSERT INTO payments (user_id, amount_cents) VALUES ($1, $2)")
339///             .bind(self.user_id)
340///             .bind(self.amount_cents)
341///             .execute(app_state.db())
342///             .await?;
343///
344///         Ok(())
345///     }
346/// }
347/// ```
348#[async_trait::async_trait]
349pub trait Job<AppState: AS>:
350    Serialize + DeserializeOwned + Send + Sync + std::fmt::Debug + Clone + 'static
351{
352    /// The unique name identifier for this job type.
353    /// This is used for routing jobs to their handlers.
354    const NAME: &'static str;
355
356    /// Execute the job logic.
357    ///
358    /// This method has access to the full application state,
359    /// including the database connection pool.
360    ///
361    /// If this method returns an error, the job will be automatically retried
362    /// with exponential backoff until it succeeds or exceeds max retries.
363    async fn run(&self, app_state: AppState) -> color_eyre::Result<()>;
364
365    /// Execute the job logic with an optional cancellation token for graceful shutdown.
366    ///
367    /// Long-running jobs can override this method to check the cancellation token
368    /// periodically and exit early during shutdown. The default implementation
369    /// ignores the token and calls `run()`, so existing jobs continue to work
370    /// without changes.
371    ///
372    /// # Example
373    ///
374    /// ```rust
375    /// # use cja::jobs::{Job, CancellationToken};
376    /// # use serde::{Serialize, Deserialize};
377    /// # #[derive(Debug, Serialize, Deserialize, Clone)]
378    /// # struct LongJob { iterations: u32 }
379    /// # #[derive(Debug, Clone)]
380    /// # struct MyAppState { db: sqlx::PgPool }
381    /// # impl cja::app_state::AppState for MyAppState {
382    /// #     fn db(&self) -> &sqlx::PgPool { &self.db }
383    /// #     fn version(&self) -> &str { "1.0.0" }
384    /// #     fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
385    /// # }
386    /// #[async_trait::async_trait]
387    /// impl Job<MyAppState> for LongJob {
388    ///     const NAME: &'static str = "LongJob";
389    ///
390    ///     async fn run(&self, _app_state: MyAppState) -> color_eyre::Result<()> {
391    ///         // Simple implementation without cancellation support
392    ///         Ok(())
393    ///     }
394    ///
395    ///     async fn run_with_cancellation(
396    ///         &self,
397    ///         app_state: MyAppState,
398    ///         cancellation_token: CancellationToken,
399    ///     ) -> color_eyre::Result<()> {
400    ///         for i in 0..self.iterations {
401    ///             // Check if shutdown was requested
402    ///             if cancellation_token.is_cancelled() {
403    ///                 tracing::info!("Job cancelled after {} iterations", i);
404    ///                 return Err(color_eyre::eyre::eyre!("Job cancelled during shutdown"));
405    ///             }
406    ///
407    ///             // Do some work
408    ///             process_iteration(i, app_state.clone()).await?;
409    ///         }
410    ///         Ok(())
411    ///     }
412    /// }
413    /// # async fn process_iteration(_i: u32, _app_state: MyAppState) -> color_eyre::Result<()> { Ok(()) }
414    /// ```
415    async fn run_with_cancellation(
416        &self,
417        app_state: AppState,
418        _cancellation_token: CancellationToken,
419    ) -> color_eyre::Result<()> {
420        // Default implementation ignores cancellation token
421        self.run(app_state).await
422    }
423
424    /// Internal method used by the job system to deserialize and run jobs.
425    ///
426    /// You typically won't call this directly - it's used by the job worker.
427    #[instrument(name = "jobs.run_from_value", skip(app_state, cancellation_token), fields(job.name = Self::NAME), err)]
428    async fn run_from_value(
429        value: serde_json::Value,
430        app_state: AppState,
431        cancellation_token: CancellationToken,
432    ) -> color_eyre::Result<()> {
433        let job: Self = serde_json::from_value(value)?;
434
435        job.run_with_cancellation(app_state, cancellation_token)
436            .await
437    }
438
439    /// Enqueue this job for asynchronous execution.
440    ///
441    /// The job will be persisted to the database and picked up by a worker process.
442    /// Jobs are executed with at-least-once semantics and automatic retries on failure.
443    ///
444    /// # Arguments
445    /// * `app_state` - The application state containing the database connection
446    /// * `context` - A string describing why this job was enqueued (useful for debugging)
447    /// * `priority` - Optional priority for this job instance. Higher values run first.
448    ///   Defaults to 0 if `None`. Use negative values for lower-priority background work.
449    ///
450    /// # Example
451    ///
452    /// ```rust
453    /// # use cja::jobs::Job;
454    /// # use serde::{Serialize, Deserialize};
455    /// # #[derive(Debug, Serialize, Deserialize, Clone)]
456    /// # struct MyJob { data: String }
457    /// # #[derive(Debug, Clone)]
458    /// # struct MyAppState { db: sqlx::PgPool }
459    /// # impl cja::app_state::AppState for MyAppState {
460    /// #     fn db(&self) -> &sqlx::PgPool { &self.db }
461    /// #     fn version(&self) -> &str { "1.0.0" }
462    /// #     fn cookie_key(&self) -> &cja::server::cookies::CookieKey { todo!() }
463    /// # }
464    /// # #[async_trait::async_trait]
465    /// # impl Job<MyAppState> for MyJob {
466    /// #     const NAME: &'static str = "MyJob";
467    /// #     async fn run(&self, _: MyAppState) -> color_eyre::Result<()> { Ok(()) }
468    /// # }
469    /// # async fn example(app_state: MyAppState) -> Result<(), cja::jobs::EnqueueError> {
470    /// let job = MyJob { data: "important work".to_string() };
471    /// // Default priority
472    /// job.clone().enqueue(app_state.clone(), "user-requested".to_string(), None).await?;
473    /// // Low priority background work
474    /// job.enqueue(app_state, "background-cleanup".to_string(), Some(-10)).await?;
475    /// # Ok(())
476    /// # }
477    /// ```
478    async fn enqueue(
479        self,
480        app_state: AppState,
481        context: String,
482        priority: Option<i32>,
483    ) -> Result<(), EnqueueError> {
484        let job_id = uuid::Uuid::new_v4();
485        let created_at = chrono::Utc::now();
486        // Eyes captures fields at span creation. Recording the id after the
487        // insert would leave the enqueue disconnected from worker attempts.
488        let span = enqueue_span::<AppState, Self>(&self, &context, priority, created_at, job_id);
489        async move {
490            let result = async {
491                let mut conn = app_state.db().acquire().await?;
492                enqueue_on_connection::<AppState, Self>(
493                    &mut conn, self, &context, priority, created_at, job_id,
494                )
495                .await?;
496                Ok::<_, EnqueueError>(())
497            }
498            .await;
499            match &result {
500                Ok(()) => {
501                    enqueue_receipt(job_id, Self::NAME);
502                    app_state.job_enqueued();
503                }
504                Err(error) => enqueue_failure(job_id, error),
505            }
506            result
507        }
508        .instrument(span)
509        .await
510    }
511}
512
513pub mod worker;