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;