Skip to main content

cja/jobs/
worker.rs

1use std::{
2    any::Any,
3    collections::HashSet,
4    future::Future,
5    num::NonZeroUsize,
6    pin::Pin,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11use futures::FutureExt;
12use thiserror::Error;
13use tokio::sync::{Notify, Semaphore, SemaphorePermit};
14use tokio_util::sync::CancellationToken;
15use tracing::Span;
16
17use crate::app_state::AppState as AS;
18
19use super::registry::JobRegistry;
20
21/// Default maximum number of retry attempts before a job is moved to the dead letter queue.
22///
23/// With exponential backoff (`2^(error_count+1)` seconds), 20 retries means:
24/// - First retry after 2 seconds
25/// - Last retry after ~6 days
26/// - Total retry window of ~12 days
27pub const DEFAULT_MAX_RETRIES: i32 = 20;
28
29/// Default heartbeat interval for an active job.
30pub const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
31/// Default reclaim window after the last successful heartbeat.
32pub const DEFAULT_RECLAIM_WINDOW: Duration = Duration::from_mins(2);
33
34/// Process-local admission for idle job claims. Clone one instance into every
35/// worker that should share the same periodic claim limit. The permits do not
36/// limit running jobs or heartbeats.
37#[derive(Clone, Debug)]
38pub struct IdlePollGate(Arc<IdlePollGateInner>);
39
40#[derive(Debug)]
41struct IdlePollGateInner {
42    periodic_claims: Semaphore,
43    wake: Notify,
44    #[cfg(test)]
45    probe: ClaimProbe,
46}
47
48impl IdlePollGate {
49    /// Create a gate that allows up to `periodic_claimers` concurrent periodic
50    /// idle claims. This does not limit running jobs or their heartbeats.
51    pub fn new(periodic_claimers: NonZeroUsize) -> Self {
52        Self(Arc::new(IdlePollGateInner {
53            periodic_claims: Semaphore::new(periodic_claimers.get()),
54            wake: Notify::new(),
55            #[cfg(test)]
56            probe: ClaimProbe::default(),
57        }))
58    }
59
60    /// Wake one parked worker for one immediate claim. Wakes may coalesce or
61    /// be lost; periodic claims remain necessary for durable discovery.
62    pub fn wake_one(&self) {
63        self.0.wake.notify_one();
64    }
65}
66
67#[derive(Clone, Copy)]
68enum ClaimKind {
69    Periodic,
70    Notified,
71    AfterJob,
72}
73
74#[cfg(test)]
75#[derive(Debug, Default)]
76struct ClaimProbe {
77    attempts: std::sync::atomic::AtomicUsize,
78    empty_periodic: std::sync::atomic::AtomicUsize,
79    in_flight_periodic: std::sync::atomic::AtomicUsize,
80    max_in_flight_periodic: std::sync::atomic::AtomicUsize,
81    notified: std::sync::atomic::AtomicUsize,
82    waiting: std::sync::atomic::AtomicUsize,
83    errors: std::sync::atomic::AtomicUsize,
84}
85
86#[cfg(test)]
87impl ClaimProbe {
88    fn claim(&self, kind: ClaimKind) -> ClaimGuard<'_> {
89        use std::sync::atomic::Ordering::SeqCst;
90        self.attempts.fetch_add(1, SeqCst);
91        if matches!(kind, ClaimKind::Notified) {
92            self.notified.fetch_add(1, SeqCst);
93        }
94        if matches!(kind, ClaimKind::Periodic) {
95            let current = self.in_flight_periodic.fetch_add(1, SeqCst) + 1;
96            self.max_in_flight_periodic.fetch_max(current, SeqCst);
97        }
98        ClaimGuard { probe: self, kind }
99    }
100
101    fn waiting(&self) -> WaitingGuard<'_> {
102        self.waiting
103            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
104        WaitingGuard(self)
105    }
106}
107
108#[cfg(test)]
109struct ClaimGuard<'a> {
110    probe: &'a ClaimProbe,
111    kind: ClaimKind,
112}
113
114#[cfg(test)]
115impl Drop for ClaimGuard<'_> {
116    fn drop(&mut self) {
117        if matches!(self.kind, ClaimKind::Periodic) {
118            self.probe
119                .in_flight_periodic
120                .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
121        }
122    }
123}
124
125#[cfg(test)]
126struct WaitingGuard<'a>(&'a ClaimProbe);
127
128#[cfg(test)]
129impl Drop for WaitingGuard<'_> {
130    fn drop(&mut self) {
131        self.0
132            .waiting
133            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
134    }
135}
136
137/// Settings for the sole job worker. The reclaim window must be at least three
138/// heartbeat intervals; a larger window allows more transient DB failures.
139#[derive(Clone, Debug)]
140pub struct JobWorkerConfig {
141    /// Time between heartbeat attempts while a job runs (30 seconds by default).
142    pub heartbeat_interval: Duration,
143    /// Time since the last successful heartbeat before another worker may
144    /// reclaim and charge the attempt (120 seconds by default).
145    pub reclaim_window: Duration,
146    /// Time allowed for the running job body after cancellation. Defaults to
147    /// `ShutdownBudget::from_env().job_drain` (2 seconds when unset). This should
148    /// match the `job_drain` of the `ShutdownBudget` given to the `Supervisor`;
149    /// apps constructing a custom budget must set this field to the same value.
150    pub shutdown_drain_timeout: Duration,
151    /// Optional shared admission for idle claims. `None` keeps independent polling.
152    pub idle_poll_gate: Option<IdlePollGate>,
153}
154
155impl Default for JobWorkerConfig {
156    fn default() -> Self {
157        Self {
158            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
159            reclaim_window: DEFAULT_RECLAIM_WINDOW,
160            shutdown_drain_timeout: crate::tasks::ShutdownBudget::from_env().job_drain,
161            idle_poll_gate: None,
162        }
163    }
164}
165
166impl JobWorkerConfig {
167    fn validate(&self) -> color_eyre::Result<i64> {
168        use color_eyre::eyre::eyre;
169        if self.heartbeat_interval.is_zero() {
170            return Err(eyre!(
171                "heartbeat interval must be positive: {:?}",
172                self.heartbeat_interval
173            ));
174        }
175        if self
176            .heartbeat_interval
177            .checked_mul(3)
178            .is_none_or(|minimum| self.reclaim_window < minimum)
179        {
180            return Err(eyre!(
181                "reclaim window {:?} must be at least three heartbeat intervals ({:?})",
182                self.reclaim_window,
183                self.heartbeat_interval
184            ));
185        }
186        let heartbeat = i64::try_from(self.heartbeat_interval.as_micros())
187            .map_err(|_| eyre!("heartbeat interval exceeds PostgreSQL microsecond range"))?;
188        let window = i64::try_from(self.reclaim_window.as_micros())
189            .map_err(|_| eyre!("reclaim window exceeds PostgreSQL microsecond range"))?;
190        if heartbeat == 0 || window == 0 || self.heartbeat_interval / 3 == Duration::ZERO {
191            return Err(eyre!(
192                "heartbeat interval must allow a positive query timeout"
193            ));
194        }
195        Ok(window)
196    }
197}
198
199/// Initial backoff duration after a worker tick fails (e.g. the database was
200/// unreachable while trying to claim a job). Doubles on each consecutive
201/// failure, capped at [`TICK_ERROR_BACKOFF_MAX`], and resets after a
202/// successful tick.
203const TICK_ERROR_BACKOFF_BASE: Duration = Duration::from_secs(1);
204
205/// Maximum backoff duration between retries after consecutive tick failures.
206const TICK_ERROR_BACKOFF_MAX: Duration = Duration::from_secs(30);
207
208pub(super) type RunJobResult = Result<RunJobSuccess, JobError>;
209
210#[derive(Debug)]
211pub(super) struct RunJobSuccess(JobFromDB);
212
213enum RunJobOutcome {
214    Completed(RunJobResult),
215    LeaseLost(uuid::Uuid),
216}
217
218struct ClaimedJob {
219    job: JobFromDB,
220    claim_sent_at: tokio::time::Instant,
221    previous_locked_by: Option<String>,
222    previous_error_count: i32,
223}
224
225struct LeaseTracker {
226    last_refresh_sent: tokio::time::Instant,
227    heartbeat_interval: Duration,
228    reclaim_window: Duration,
229}
230
231impl LeaseTracker {
232    fn deadline(&self) -> tokio::time::Instant {
233        self.last_refresh_sent + self.reclaim_window - self.heartbeat_interval / 2
234    }
235}
236
237#[derive(Debug, sqlx::FromRow)]
238pub struct JobFromDB {
239    pub job_id: uuid::Uuid,
240    pub name: String,
241    pub payload: serde_json::Value,
242    pub priority: i32,
243    pub run_at: chrono::DateTime<chrono::Utc>,
244    pub created_at: chrono::DateTime<chrono::Utc>,
245    pub context: String,
246    pub error_count: i32,
247    pub last_error_message: Option<String>,
248    pub last_failed_at: Option<chrono::DateTime<chrono::Utc>>,
249}
250
251#[derive(Debug, Error)]
252#[error("JobError(id:${}) ${1}", self.0.job_id)]
253pub(crate) struct JobError(JobFromDB, color_eyre::Report);
254
255struct Worker<AppState: AS, R: JobRegistry<AppState>> {
256    id: uuid::Uuid,
257    // Cached SQL-bind form of id; jobs.locked_by stores this exact UUID string.
258    owner_token: String,
259    state: AppState,
260    registry: R,
261    sleep_duration: Duration,
262    max_retries: i32,
263    cancellation_token: CancellationToken,
264    config: JobWorkerConfig,
265    reclaim_window_micros: i64,
266    // A watchdog-abandoned row must expire and be charged, even if this worker
267    // subsequently shuts down gracefully.
268    abandoned_jobs: Mutex<HashSet<uuid::Uuid>>,
269    #[cfg(test)]
270    fail_heartbeats: std::sync::atomic::AtomicU32,
271}
272
273impl<AppState: AS, R: JobRegistry<AppState>> Worker<AppState, R> {
274    fn new(
275        state: AppState,
276        registry: R,
277        sleep_duration: Duration,
278        max_retries: i32,
279        cancellation_token: CancellationToken,
280        config: JobWorkerConfig,
281    ) -> color_eyre::Result<Self> {
282        let reclaim_window_micros = config.validate()?;
283        let id = uuid::Uuid::new_v4();
284        Ok(Self {
285            id,
286            owner_token: id.to_string(),
287            state,
288            registry,
289            sleep_duration,
290            max_retries,
291            cancellation_token,
292            config,
293            reclaim_window_micros,
294            abandoned_jobs: Mutex::new(HashSet::new()),
295            #[cfg(test)]
296            fail_heartbeats: std::sync::atomic::AtomicU32::new(0),
297        })
298    }
299
300    // Eyes contract: these fields must exist when the span is created. Eyes
301    // treats a run as cron-triggered when `job.context` starts with `Cron@`
302    // (its `cron_job_failed` monitors) and reads the timing fields for queue
303    // delay. Don't drop or rename them without coordinating Eyes.
304    #[tracing::instrument(
305        name = "worker.run_job",
306        skip(self, job),
307        fields(
308            job.id = %job.job_id,
309            job.name = job.name,
310            job.priority = job.priority,
311            job.run_at = %job.run_at,
312            job.created_at = %job.created_at,
313            job.context = job.context,
314            job.error_count = job.error_count,
315            worker.id = %self.id,
316        ),
317        err,
318    )]
319    async fn run_job(&self, job: &JobFromDB) -> color_eyre::Result<()> {
320        let result = std::panic::AssertUnwindSafe(self.registry.run_job(
321            job,
322            self.state.clone(),
323            self.cancellation_token.clone(),
324        ))
325        .catch_unwind()
326        .await;
327        match result {
328            Ok(result) => result,
329            Err(payload) => Err(color_eyre::eyre::eyre!(
330                "Job panicked: {}",
331                panic_message(payload.as_ref())
332            )),
333        }
334    }
335
336    #[allow(clippy::too_many_lines)] // scoped heartbeat race and ownership-safe finalization belong together
337    async fn run_next_job(&self, claimed: ClaimedJob) -> color_eyre::Result<RunJobOutcome> {
338        let job = claimed.job;
339        let mut tracker = LeaseTracker {
340            last_refresh_sent: claimed.claim_sent_at,
341            heartbeat_interval: self.config.heartbeat_interval,
342            reclaim_window: self.config.reclaim_window,
343        };
344        if tokio::time::Instant::now() >= tracker.deadline() {
345            tracing::warn!(job_id = %job.job_id, "Claim response exceeded lease deadline");
346            self.abandoned_jobs.lock().unwrap().insert(job.job_id);
347            return Ok(RunJobOutcome::LeaseLost(job.job_id));
348        }
349
350        let job_result = {
351            let mut body = std::pin::pin!(self.run_job(&job));
352            let mut next_tick = claimed.claim_sent_at + self.config.heartbeat_interval;
353            type HeartbeatResult = Result<
354                Result<sqlx::postgres::PgQueryResult, sqlx::Error>,
355                tokio::time::error::Elapsed,
356            >;
357            let mut heartbeat: Option<Pin<Box<dyn Future<Output = HeartbeatResult> + Send + '_>>> =
358                None;
359            let mut heartbeat_sent_at = claimed.claim_sent_at;
360            loop {
361                let deadline = tracker.deadline();
362                tokio::select! {
363                    biased;
364                    result = &mut body => break result,
365                    () = tokio::time::sleep_until(deadline) => {
366                        tracing::warn!(job_id = %job.job_id, "Job lease watchdog expired");
367                        self.abandoned_jobs.lock().unwrap().insert(job.job_id);
368                        return Ok(RunJobOutcome::LeaseLost(job.job_id));
369                    }
370                    result = async { heartbeat.as_mut().expect("guarded heartbeat").await }, if heartbeat.is_some() => {
371                        heartbeat = None;
372                        match result {
373                            Ok(Ok(result)) if result.rows_affected() == 1 => {
374                                tracker.last_refresh_sent = heartbeat_sent_at;
375                            }
376                            Ok(Ok(_)) => {
377                                tracing::warn!(job_id = %job.job_id, owner = %self.owner_token, "Job lease ownership lost");
378                                return Ok(RunJobOutcome::LeaseLost(job.job_id));
379                            }
380                            Ok(Err(error)) => tracing::warn!(job_id = %job.job_id, %error, "Job heartbeat failed"),
381                            Err(_) => tracing::warn!(job_id = %job.job_id, "Job heartbeat timed out"),
382                        }
383                    }
384                    () = tokio::time::sleep_until(next_tick), if heartbeat.is_none() => {
385                        let sent_at = tokio::time::Instant::now();
386                        next_tick = sent_at + self.config.heartbeat_interval;
387                        #[cfg(test)]
388                        if self.fail_heartbeats.try_update(
389                            std::sync::atomic::Ordering::SeqCst,
390                            std::sync::atomic::Ordering::SeqCst,
391                            |n| n.checked_sub(1),
392                        ).is_ok() {
393                            tracing::warn!(job_id = %job.job_id, "Synthetic heartbeat failure");
394                            continue;
395                        }
396                        let update = sqlx::query(
397                            "UPDATE jobs SET locked_at = NOW() WHERE job_id = $1 AND locked_by = $2"
398                        )
399                        .bind(job.job_id)
400                        .bind(&self.owner_token)
401                        .execute(self.state.db());
402                        heartbeat_sent_at = sent_at;
403                        heartbeat = Some(Box::pin(tokio::time::timeout(self.config.heartbeat_interval / 3, update)));
404                    }
405                }
406            }
407        };
408        // The body and all heartbeat futures have ended before finalization.
409        if let Err(error) = job_result {
410            let error_message = format!("{error:#}");
411            if job.error_count >= self.max_retries {
412                let count = job
413                    .error_count
414                    .checked_add(1)
415                    .ok_or_else(|| color_eyre::eyre::eyre!("job error_count overflow"))?;
416                if !self.dead_letter_owned(&job, count, &error_message).await? {
417                    return Ok(RunJobOutcome::LeaseLost(job.job_id));
418                }
419                tracing::error!(job_id = %job.job_id, error_count = count, error = %error_message,
420                    "Job permanently failed - moved to dead letter queue");
421            } else {
422                let result = sqlx::query(
423                    "UPDATE jobs SET locked_by = NULL, locked_at = NULL,
424                     error_count = error_count + 1, last_error_message = $3,
425                     last_failed_at = NOW(),
426                     run_at = NOW() + POWER(2, error_count + 1) * interval '1 second'
427                     WHERE job_id = $1 AND locked_by = $2 AND error_count < 2147483647",
428                )
429                .bind(job.job_id)
430                .bind(&self.owner_token)
431                .bind(&error_message)
432                .execute(self.state.db())
433                .await?;
434                if result.rows_affected() != 1 {
435                    tracing::warn!(job_id = %job.job_id, "Job failure finalization lost ownership");
436                    return Ok(RunJobOutcome::LeaseLost(job.job_id));
437                }
438                tracing::warn!(job_id = %job.job_id, retry = job.error_count + 1, error = %error_message,
439                    "Job failed, retry scheduled");
440            }
441            return Ok(RunJobOutcome::Completed(Err(JobError(job, error))));
442        }
443
444        let result = sqlx::query("DELETE FROM jobs WHERE job_id = $1 AND locked_by = $2")
445            .bind(job.job_id)
446            .bind(&self.owner_token)
447            .execute(self.state.db())
448            .await?;
449        if result.rows_affected() != 1 {
450            tracing::warn!(job_id = %job.job_id, "Job completion lost ownership");
451            return Ok(RunJobOutcome::LeaseLost(job.job_id));
452        }
453        Ok(RunJobOutcome::Completed(Ok(RunJobSuccess(job))))
454    }
455
456    async fn dead_letter_owned(
457        &self,
458        job: &JobFromDB,
459        count: i32,
460        message: &str,
461    ) -> color_eyre::Result<bool> {
462        let mut tx = self.state.db().begin().await?;
463        let owned: bool = sqlx::query_scalar(
464            "SELECT EXISTS(SELECT 1 FROM jobs WHERE job_id = $1 AND locked_by = $2 FOR UPDATE)",
465        )
466        .bind(job.job_id)
467        .bind(&self.owner_token)
468        .fetch_one(&mut *tx)
469        .await?;
470        if !owned {
471            tracing::warn!(job_id = %job.job_id, "Dead-letter finalization lost ownership");
472            return Ok(false);
473        }
474        sqlx::query(
475            "INSERT INTO dead_letter_jobs (original_job_id, name, payload, context, priority,
476              error_count, last_error_message, created_at)
477             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
478        )
479        .bind(job.job_id)
480        .bind(&job.name)
481        .bind(&job.payload)
482        .bind(&job.context)
483        .bind(job.priority)
484        .bind(count)
485        .bind(message)
486        .bind(job.created_at)
487        .execute(&mut *tx)
488        .await?;
489        let deleted = sqlx::query("DELETE FROM jobs WHERE job_id = $1 AND locked_by = $2")
490            .bind(job.job_id)
491            .bind(&self.owner_token)
492            .execute(&mut *tx)
493            .await?;
494        if deleted.rows_affected() != 1 {
495            return Err(color_eyre::eyre::eyre!(
496                "owned dead-letter delete affected no rows"
497            ));
498        }
499        tx.commit().await?;
500        Ok(true)
501    }
502
503    #[tracing::instrument(name = "worker.fetch_next_job", level = "trace", skip(self),
504        fields(worker.id = %self.id, job.id = tracing::field::Empty,
505            job.name = tracing::field::Empty, reclaim_window_secs = self.config.reclaim_window.as_secs()), err)]
506    async fn fetch_next_job(&self) -> color_eyre::Result<Option<ClaimedJob>> {
507        use sqlx::Row;
508        let claim_sent_at = tokio::time::Instant::now();
509        let row = sqlx::query(
510            "WITH candidate AS MATERIALIZED (
511                SELECT job_id, locked_by AS previous_locked_by,
512                       error_count AS previous_error_count
513                FROM jobs
514                WHERE run_at <= NOW()
515                  AND (locked_by IS NULL OR locked_at < NOW() - $2::bigint * interval '1 microsecond')
516                  AND (locked_by IS NULL OR error_count < 2147483647)
517                ORDER BY priority DESC, run_at ASC, created_at ASC
518                LIMIT 1 FOR UPDATE SKIP LOCKED
519             ), updated AS (
520                UPDATE jobs AS j
521                SET locked_by = $1, locked_at = NOW(),
522                    error_count = j.error_count + CASE WHEN c.previous_locked_by IS NULL THEN 0 ELSE 1 END,
523                    last_error_message = CASE WHEN c.previous_locked_by IS NULL THEN j.last_error_message
524                      ELSE 'Job lease expired (previous worker: ' || c.previous_locked_by || ')' END,
525                    last_failed_at = CASE WHEN c.previous_locked_by IS NULL THEN j.last_failed_at ELSE NOW() END
526                FROM candidate AS c WHERE j.job_id = c.job_id
527                RETURNING j.job_id, j.name, j.payload, j.priority, j.run_at, j.created_at,
528                          j.context, j.error_count, j.last_error_message, j.last_failed_at
529             )
530             SELECT u.*, c.previous_locked_by, c.previous_error_count FROM updated u
531             JOIN candidate c ON u.job_id = c.job_id"
532        )
533        .bind(&self.owner_token)
534        .bind(self.reclaim_window_micros)
535        .fetch_optional(self.state.db())
536        .await?;
537        let Some(row) = row else { return Ok(None) };
538        let claimed = ClaimedJob {
539            job: JobFromDB {
540                job_id: row.try_get("job_id")?,
541                name: row.try_get("name")?,
542                payload: row.try_get("payload")?,
543                priority: row.try_get("priority")?,
544                run_at: row.try_get("run_at")?,
545                created_at: row.try_get("created_at")?,
546                context: row.try_get("context")?,
547                error_count: row.try_get("error_count")?,
548                last_error_message: row.try_get("last_error_message")?,
549                last_failed_at: row.try_get("last_failed_at")?,
550            },
551            claim_sent_at,
552            previous_locked_by: row.try_get("previous_locked_by")?,
553            previous_error_count: row.try_get("previous_error_count")?,
554        };
555        let span = Span::current();
556        span.record("job.id", claimed.job.job_id.to_string());
557        span.record("job.name", &claimed.job.name);
558        self.abandoned_jobs
559            .lock()
560            .unwrap()
561            .remove(&claimed.job.job_id);
562        if claimed.previous_locked_by.is_some() && claimed.previous_error_count >= self.max_retries
563        {
564            let message = claimed
565                .job
566                .last_error_message
567                .as_deref()
568                .unwrap_or("Job lease expired");
569            if self
570                .dead_letter_owned(&claimed.job, claimed.job.error_count, message)
571                .await?
572            {
573                tracing::error!(job_id = %claimed.job.job_id,
574                    previous_owner = ?claimed.previous_locked_by,
575                    error_count = claimed.job.error_count,
576                    "Job lease expired - moved to dead letter queue");
577            }
578            return Ok(None);
579        }
580        Ok(Some(claimed))
581    }
582}
583
584fn panic_message(payload: &(dyn Any + Send)) -> &str {
585    if let Some(message) = payload.downcast_ref::<String>() {
586        message
587    } else if let Some(message) = payload.downcast_ref::<&str>() {
588        message
589    } else {
590        "non-string payload"
591    }
592}
593
594/// Confirm the database has the schema this worker's SQL relies on.
595///
596/// The worker's queries are not checked at compile time against the app's
597/// database, so an app that is missing cja migrations builds and boots
598/// normally, then fails every `fetch_next_job` forever while cron keeps
599/// enqueueing. Catching that here turns a silently dead worker into a failed
600/// start.
601///
602/// Only a definite schema mismatch is an error. Anything else (database
603/// unreachable, pool timeout) is left to the worker loop, which already
604/// retries transient failures with backoff.
605async fn verify_jobs_schema(db: &sqlx::PgPool) -> color_eyre::Result<()> {
606    const UNDEFINED_COLUMN: &str = "42703";
607    const UNDEFINED_TABLE: &str = "42P01";
608
609    let checks = [
610        "SELECT job_id, name, payload, priority, run_at, created_at, context, locked_by, \
611         locked_at, error_count, last_error_message, last_failed_at FROM jobs LIMIT 0",
612        "SELECT original_job_id, name, payload, context, priority, error_count, \
613         last_error_message, created_at FROM dead_letter_jobs LIMIT 0",
614    ];
615
616    for check in checks {
617        let Err(error) = sqlx::query(check).execute(db).await else {
618            continue;
619        };
620        let schema_mismatch = error
621            .as_database_error()
622            .and_then(sqlx::error::DatabaseError::code)
623            .is_some_and(|code| code == UNDEFINED_COLUMN || code == UNDEFINED_TABLE);
624        if schema_mismatch {
625            return Err(color_eyre::eyre::eyre!(
626                "The jobs schema is missing cja migrations ({error}). The job worker cannot \
627                 claim jobs until they are applied: copy the missing files from cja's \
628                 `migrations/` directory (`cja sync-migrations`) and run them."
629            ));
630        }
631        tracing::warn!(
632            %error,
633            "Could not verify the jobs schema at startup; continuing, the worker loop will retry"
634        );
635        return Ok(());
636    }
637
638    Ok(())
639}
640
641/// Release database locks held by a worker.
642///
643/// This should be called during graceful shutdown to immediately release any job locks
644/// held by this worker, rather than waiting for lease expiry. Watchdog-abandoned
645/// rows stay locked so a later claim records their failed attempt.
646async fn cleanup_worker_locks<AppState: AS, R: JobRegistry<AppState>>(
647    worker: &Worker<AppState, R>,
648) -> color_eyre::Result<()> {
649    tracing::info!(worker_id = %worker.id, "Releasing database locks");
650    let abandoned: Vec<_> = worker
651        .abandoned_jobs
652        .lock()
653        .unwrap()
654        .iter()
655        .copied()
656        .collect();
657
658    let result = sqlx::query(
659        "UPDATE jobs
660         SET locked_by = NULL, locked_at = NULL
661         WHERE locked_by = $1 AND NOT (job_id = ANY($2::uuid[]))",
662    )
663    .bind(&worker.owner_token)
664    .bind(&abandoned)
665    .execute(worker.state.db())
666    .await?;
667
668    tracing::info!(
669        worker_id = %worker.id,
670        locks_released = result.rows_affected(),
671        "Database locks released"
672    );
673
674    Ok(())
675}
676
677/// Start a job worker that processes jobs from the queue.
678///
679/// The worker will continuously poll for jobs and execute them using the provided registry.
680/// Jobs are executed with automatic retry logic on failure.
681///
682/// # Arguments
683///
684/// * `app_state` - The application state containing database connection and configuration
685/// * `registry` - The job registry that maps job names to their implementations
686/// * `sleep_duration` - How long to sleep when no jobs are available. With an
687///   idle poll gate, only permit holders sleep on this interval; permitless
688///   workers park at admission.
689/// * `max_retries` - Maximum number of times to retry a failed job before moving to the dead letter queue (default: 20)
690/// * `shutdown_token` - Cancellation token for graceful shutdown. When cancelled, the worker
691///   will stop accepting new jobs and release database locks before exiting.
692/// * `config` - Heartbeat interval, reclaim window, local shutdown drain budget,
693///   and optional shared idle poll gate
694///
695/// # Retry Behavior
696///
697/// When a job fails:
698/// - The error count is incremented
699/// - The error message and timestamp are recorded
700/// - The job is requeued with exponential backoff: delay = `2^(error_count + 1)` seconds
701///   (first retry: 2s, second: 4s, third: 8s, fourth: 16s, etc.)
702/// - If `error_count` >= `max_retries`, the job is moved to the dead letter queue
703///
704/// # Graceful Shutdown
705///
706/// When the `shutdown_token` is cancelled:
707/// - The worker stops polling for new jobs
708/// - Any currently executing job gets a bounded drain to complete
709/// - Remaining owned locks are released after the drain without charging an attempt
710///
711/// # Transient Error Resilience
712///
713/// Errors from the worker loop itself (e.g. the database being unreachable while
714/// trying to claim the next job, such as a pool acquire timeout during a brief
715/// database stall) are NOT fatal. They are logged at ERROR level and the worker
716/// retries with exponential backoff (starting at 1 second, doubling up to a
717/// 30 second cap, resetting after the next successful tick). This keeps a
718/// transient database hiccup from killing the worker task — and with it, apps
719/// that join on all worker tasks. Job execution failures are unaffected by this:
720/// they continue to use the per-job retry machinery described above.
721///
722/// # Schema Check
723///
724/// Before polling, the worker confirms the `jobs` and `dead_letter_jobs`
725/// tables have the columns its queries use, and returns an error if the
726/// database is missing cja migrations. Without this an out-of-date schema
727/// boots fine and then fails every claim silently. A database that is merely
728/// unreachable at startup is not an error; see transient error resilience
729/// above.
730///
731/// # Lease Timing
732///
733/// If a worker crashes or becomes unresponsive while processing a job, the job will remain
734/// locked in the database. `JobWorkerConfig` defaults to a 30-second heartbeat,
735/// a 120-second reclaim window, and the shutdown budget's job drain (2 seconds
736/// when unset). An expired lock is charged as a failed attempt.
737///
738/// # Example
739///
740/// ```ignore
741/// use std::time::Duration;
742/// use tokio_util::sync::CancellationToken;
743///
744/// let shutdown_token = CancellationToken::new();
745/// let worker_token = shutdown_token.clone();
746///
747/// // Start worker with the default heartbeat, reclaim, and drain settings
748/// tokio::spawn(async move {
749///     cja::jobs::worker::job_worker(
750///         app_state,
751///         registry,
752///         Duration::from_secs(60),      // poll every 60s when idle
753///         20,                            // max 20 retries
754///         worker_token,                  // for graceful shutdown
755///         Default::default(),
756///     ).await.unwrap();
757/// });
758///
759/// // Later, trigger shutdown
760/// shutdown_token.cancel();
761/// ```
762pub async fn job_worker<AppState: AS>(
763    app_state: AppState,
764    registry: impl JobRegistry<AppState>,
765    sleep_duration: Duration,
766    max_retries: i32,
767    shutdown_token: CancellationToken,
768    config: JobWorkerConfig,
769) -> color_eyre::Result<()> {
770    if max_retries < 0 {
771        return Err(color_eyre::eyre::eyre!("max_retries must be nonnegative"));
772    }
773    let worker = Worker::new(
774        app_state,
775        registry,
776        sleep_duration,
777        max_retries,
778        shutdown_token.clone(),
779        config,
780    )?;
781
782    verify_jobs_schema(worker.state.db()).await?;
783
784    let mut tick_error_backoff = TICK_ERROR_BACKOFF_BASE;
785    let gate = worker.config.idle_poll_gate.clone();
786    let mut permit: Option<SemaphorePermit<'_>> = None;
787    let mut after_job = false;
788
789    loop {
790        let claim_kind = if let Some(gate) = &gate {
791            match admit_claim(gate, &mut permit, after_job, &shutdown_token).await {
792                Some(kind) => kind,
793                None => break,
794            }
795        } else {
796            ClaimKind::Periodic
797        };
798        #[cfg(test)]
799        let claim_guard = gate.as_ref().map(|gate| gate.0.probe.claim(claim_kind));
800        #[cfg(not(test))]
801        let _ = claim_kind;
802        let fetched = tokio::select! {
803            result = worker.fetch_next_job() => result,
804            () = shutdown_token.cancelled() => break,
805        };
806        #[cfg(test)]
807        if let Some(gate) = &gate {
808            use std::sync::atomic::Ordering::SeqCst;
809            if matches!(claim_kind, ClaimKind::Periodic) && matches!(fetched.as_ref(), Ok(None)) {
810                gate.0.probe.empty_periodic.fetch_add(1, SeqCst);
811            }
812            if fetched.is_err() {
813                gate.0.probe.errors.fetch_add(1, SeqCst);
814            }
815        }
816        #[cfg(test)]
817        drop(claim_guard);
818        let result = match fetched {
819            Ok(Some(job)) => {
820                drop(permit.take());
821                after_job = gate.is_some();
822                match run_claimed_job(&worker, job, &shutdown_token).await {
823                    Some(result) => result,
824                    None => break,
825                }
826            }
827            Ok(None) => {
828                after_job = false;
829                if gate.is_some() && permit.is_none() {
830                    Ok(())
831                } else {
832                    tokio::select! {
833                        () = tokio::time::sleep(worker.sleep_duration) => Ok(()),
834                        () = shutdown_token.cancelled() => break,
835                    }
836                }
837            }
838            Err(error) => Err(error),
839        };
840        match result {
841            Ok(()) => {
842                tick_error_backoff = TICK_ERROR_BACKOFF_BASE;
843            }
844            Err(error) => {
845                drop(permit.take());
846                after_job = false;
847                // A tick error here is a transient infrastructure failure
848                // (e.g. couldn't reach the database to claim a job), not a
849                // job failure — jobs have their own retry machinery. Log it
850                // and retry with backoff instead of killing the worker,
851                // which would take down apps that join on all worker tasks.
852                tracing::error!(
853                    worker_id = %worker.id,
854                    error = %format!("{error:#}"),
855                    backoff_secs = tick_error_backoff.as_secs(),
856                    "Worker tick failed; backing off before retrying"
857                );
858
859                tokio::select! {
860                    () = tokio::time::sleep(tick_error_backoff) => {}
861                    () = shutdown_token.cancelled() => {
862                        tracing::info!(worker_id = %worker.id, "Job worker shutdown requested");
863                        break;
864                    }
865                }
866
867                tick_error_backoff = (tick_error_backoff * 2).min(TICK_ERROR_BACKOFF_MAX);
868            }
869        }
870    }
871
872    drop(permit);
873    cleanup_worker_locks(&worker).await?;
874    tracing::info!(worker_id = %worker.id, "Job worker shutdown complete");
875    Ok(())
876}
877
878async fn admit_claim<'a>(
879    gate: &'a IdlePollGate,
880    permit: &mut Option<SemaphorePermit<'a>>,
881    after_job: bool,
882    shutdown_token: &CancellationToken,
883) -> Option<ClaimKind> {
884    if after_job {
885        return Some(ClaimKind::AfterJob);
886    }
887    if permit.is_some() {
888        return Some(ClaimKind::Periodic);
889    }
890    #[cfg(test)]
891    let _waiting = gate.0.probe.waiting();
892    tokio::select! {
893        biased;
894        () = shutdown_token.cancelled() => None,
895        acquired = gate.0.periodic_claims.acquire() => {
896            *permit = Some(acquired.expect("idle poll semaphore is never closed"));
897            Some(ClaimKind::Periodic)
898        }
899        () = gate.0.wake.notified() => Some(ClaimKind::Notified),
900    }
901}
902
903async fn run_claimed_job<AppState: AS, R: JobRegistry<AppState>>(
904    worker: &Worker<AppState, R>,
905    job: ClaimedJob,
906    shutdown_token: &CancellationToken,
907) -> Option<color_eyre::Result<()>> {
908    let mut running = std::pin::pin!(worker.run_next_job(job));
909    let run_result = tokio::select! {
910        result = &mut running => result,
911        () = shutdown_token.cancelled() => {
912            match tokio::time::timeout(worker.config.shutdown_drain_timeout, &mut running).await {
913                Ok(result) => result,
914                Err(_) => return None,
915            }
916        }
917    };
918    // Fold the infrastructure error into `result` instead of
919    // `?`-ing out: a transient failure while running/finalizing a
920    // job (e.g. a DB blip during completion bookkeeping) must hit
921    // the same backoff-and-retry arm as a fetch failure — never
922    // kill the worker, which would take down apps that join on
923    // all worker tasks.
924    Some(match run_result {
925        Ok(RunJobOutcome::Completed(Ok(RunJobSuccess(job)))) => {
926            tracing::info!(worker.id = %worker.id, job_id = %job.job_id, "Job Ran");
927            Ok(())
928        }
929        Ok(RunJobOutcome::Completed(Err(job_error))) => {
930            tracing::error!(worker.id = %worker.id, job_id = %job_error.0.job_id, error_count = %job_error.0.error_count, error_msg = %job_error.1, "Job Errored");
931            Ok(())
932        }
933        Ok(RunJobOutcome::LeaseLost(job_id)) => {
934            tracing::warn!(worker.id = %worker.id, %job_id, "Job lease lost; body stopped");
935            Ok(())
936        }
937        Err(error) => Err(error),
938    })
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944    use crate::app_state::AppState;
945    use crate::impl_job_registry;
946    use crate::jobs::Job;
947    use crate::server::cookies::CookieKey;
948
949    #[derive(Clone)]
950    struct TestAppState {
951        db: sqlx::PgPool,
952        cookie_key: CookieKey,
953    }
954
955    impl AppState for TestAppState {
956        fn db(&self) -> &sqlx::PgPool {
957            &self.db
958        }
959
960        fn version(&self) -> &'static str {
961            "test"
962        }
963
964        fn cookie_key(&self) -> &CookieKey {
965            &self.cookie_key
966        }
967    }
968
969    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
970    struct TestJob {
971        id: String,
972    }
973
974    #[async_trait::async_trait]
975    impl Job<TestAppState> for TestJob {
976        const NAME: &'static str = "TestJob";
977
978        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
979            Ok(())
980        }
981    }
982
983    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
984    struct PanicJob;
985
986    #[async_trait::async_trait]
987    impl Job<TestAppState> for PanicJob {
988        const NAME: &'static str = "PanicJob";
989
990        async fn run(&self, app_state: TestAppState) -> color_eyre::Result<()> {
991            sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
992                .bind(Self::NAME)
993                .execute(app_state.db())
994                .await?;
995            panic!("fixture panic payload");
996        }
997    }
998
999    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
1000    struct NormalProbeJob;
1001
1002    #[async_trait::async_trait]
1003    impl Job<TestAppState> for NormalProbeJob {
1004        const NAME: &'static str = "NormalProbeJob";
1005
1006        async fn run(&self, app_state: TestAppState) -> color_eyre::Result<()> {
1007            sqlx::query("INSERT INTO job_probe (marker) VALUES ('ran')")
1008                .execute(app_state.db())
1009                .await?;
1010            Ok(())
1011        }
1012    }
1013
1014    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
1015    struct LongJob;
1016
1017    #[async_trait::async_trait]
1018    impl Job<TestAppState> for LongJob {
1019        const NAME: &'static str = "LongJob";
1020
1021        async fn run(&self, app_state: TestAppState) -> color_eyre::Result<()> {
1022            sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
1023                .bind(Self::NAME)
1024                .execute(app_state.db())
1025                .await?;
1026            tokio::time::sleep(Duration::from_secs(30)).await;
1027            Ok(())
1028        }
1029    }
1030
1031    /// A job that treats shutdown as work to finish: on cancellation it
1032    /// performs a durable database write, THEN returns Ok. Used to prove the
1033    /// bounded drain lets a cooperative job complete its cleanup instead of
1034    /// being dropped mid-flight.
1035    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
1036    struct DrainCooperativeJob;
1037
1038    #[async_trait::async_trait]
1039    impl Job<TestAppState> for DrainCooperativeJob {
1040        const NAME: &'static str = "DrainCooperativeJob";
1041
1042        async fn run(&self, _app_state: TestAppState) -> color_eyre::Result<()> {
1043            Ok(())
1044        }
1045
1046        async fn run_with_cancellation(
1047            &self,
1048            app_state: TestAppState,
1049            cancellation_token: CancellationToken,
1050        ) -> color_eyre::Result<()> {
1051            // Announce that the job body is running. A locked row only proves
1052            // the claim committed, not that the worker has the job yet.
1053            sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
1054                .bind(Self::NAME)
1055                .execute(app_state.db())
1056                .await?;
1057            // Park until shutdown is requested, then do the durable cleanup
1058            // that a dropped future would never get to run.
1059            cancellation_token.cancelled().await;
1060            sqlx::query("UPDATE jobs SET context = 'cooperatively-drained' WHERE name = $1")
1061                .bind(Self::NAME)
1062                .execute(app_state.db())
1063                .await?;
1064            Ok(())
1065        }
1066    }
1067
1068    /// A job that ignores cancellation entirely and sleeps far longer than any
1069    /// drain. Used to prove the bounded drain expires and drops the future.
1070    #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
1071    struct DrainStubbornJob;
1072
1073    #[async_trait::async_trait]
1074    impl Job<TestAppState> for DrainStubbornJob {
1075        const NAME: &'static str = "DrainStubbornJob";
1076
1077        async fn run(&self, app_state: TestAppState) -> color_eyre::Result<()> {
1078            sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
1079                .bind(Self::NAME)
1080                .execute(app_state.db())
1081                .await?;
1082            tokio::time::sleep(Duration::from_secs(30)).await;
1083            Ok(())
1084        }
1085    }
1086
1087    impl_job_registry!(
1088        TestAppState,
1089        TestJob,
1090        PanicJob,
1091        NormalProbeJob,
1092        LongJob,
1093        DrainCooperativeJob,
1094        DrainStubbornJob
1095    );
1096
1097    fn test_state(db: sqlx::PgPool) -> TestAppState {
1098        TestAppState {
1099            db,
1100            cookie_key: CookieKey::generate(),
1101        }
1102    }
1103
1104    #[test]
1105    fn test_worker_config_defaults_and_validation() {
1106        let config = JobWorkerConfig::default();
1107        assert_eq!(config.heartbeat_interval, Duration::from_secs(30));
1108        assert_eq!(config.reclaim_window, Duration::from_mins(2));
1109        assert_eq!(
1110            config.shutdown_drain_timeout,
1111            crate::tasks::ShutdownBudget::from_env().job_drain
1112        );
1113        assert_eq!(config.validate().unwrap(), 120_000_000);
1114
1115        let invalid = JobWorkerConfig {
1116            heartbeat_interval: Duration::ZERO,
1117            ..config.clone()
1118        };
1119        assert!(format!("{:#}", invalid.validate().unwrap_err()).contains("positive"));
1120
1121        let invalid = JobWorkerConfig {
1122            heartbeat_interval: Duration::from_secs(2),
1123            reclaim_window: Duration::from_secs(5),
1124            ..config.clone()
1125        };
1126        let message = format!("{:#}", invalid.validate().unwrap_err());
1127        assert!(
1128            message.contains("2s") && message.contains("5s"),
1129            "{message}"
1130        );
1131
1132        let invalid = JobWorkerConfig {
1133            heartbeat_interval: Duration::MAX,
1134            reclaim_window: Duration::MAX,
1135            ..config.clone()
1136        };
1137        assert!(format!("{:#}", invalid.validate().unwrap_err()).contains("three"));
1138
1139        let beyond_pg = Duration::from_micros(i64::MAX as u64 + 1);
1140        let invalid = JobWorkerConfig {
1141            heartbeat_interval: beyond_pg,
1142            reclaim_window: beyond_pg * 3,
1143            ..config.clone()
1144        };
1145        assert!(
1146            format!("{:#}", invalid.validate().unwrap_err()).contains("heartbeat interval exceeds")
1147        );
1148
1149        let invalid = JobWorkerConfig {
1150            reclaim_window: beyond_pg,
1151            ..config.clone()
1152        };
1153        assert!(
1154            format!("{:#}", invalid.validate().unwrap_err()).contains("reclaim window exceeds")
1155        );
1156
1157        let invalid = JobWorkerConfig {
1158            heartbeat_interval: Duration::from_nanos(2),
1159            reclaim_window: Duration::from_nanos(6),
1160            ..config.clone()
1161        };
1162        assert!(
1163            format!("{:#}", invalid.validate().unwrap_err()).contains("positive query timeout")
1164        );
1165
1166        let tiny_valid = JobWorkerConfig {
1167            heartbeat_interval: Duration::from_micros(3),
1168            reclaim_window: Duration::from_micros(9),
1169            ..config.clone()
1170        };
1171        assert_eq!(tiny_valid.validate().unwrap(), 9);
1172    }
1173
1174    #[sqlx::test]
1175    async fn test_invalid_worker_config_fails_before_schema_access(db: sqlx::PgPool) {
1176        db.close().await;
1177        let error = job_worker(
1178            test_state(db),
1179            Jobs,
1180            Duration::from_millis(20),
1181            20,
1182            CancellationToken::new(),
1183            JobWorkerConfig {
1184                heartbeat_interval: Duration::from_secs(2),
1185                reclaim_window: Duration::from_secs(5),
1186                ..Default::default()
1187            },
1188        )
1189        .await
1190        .unwrap_err();
1191        assert!(format!("{error:#}").contains("reclaim window"));
1192    }
1193
1194    async fn wait_for<T, F, Fut>(mut check: F) -> T
1195    where
1196        F: FnMut() -> Fut,
1197        Fut: std::future::Future<Output = Option<T>>,
1198    {
1199        tokio::time::timeout(Duration::from_secs(10), async {
1200            loop {
1201                if let Some(value) = check().await {
1202                    return value;
1203                }
1204                tokio::time::sleep(Duration::from_millis(25)).await;
1205            }
1206        })
1207        .await
1208        .expect("expected durable job state within 10 seconds")
1209    }
1210
1211    /// Wait until the job body is executing. Cancelling as soon as the row is
1212    /// locked races the worker: the claim can be committed while the worker is
1213    /// still awaiting the response, and a cancel in that window correctly
1214    /// drops the fetch and releases the lock without ever running the job.
1215    async fn wait_until_started(db: &sqlx::PgPool, name: &str) {
1216        for _ in 0..200 {
1217            let context: Option<String> =
1218                sqlx::query_scalar("SELECT context FROM jobs WHERE name = $1")
1219                    .bind(name)
1220                    .fetch_optional(db)
1221                    .await
1222                    .unwrap();
1223            if context.as_deref() == Some("started") {
1224                return;
1225            }
1226            tokio::time::sleep(Duration::from_millis(25)).await;
1227        }
1228        panic!("job {name} never started running");
1229    }
1230
1231    #[sqlx::test]
1232    async fn test_panicking_job_is_charged_and_worker_continues(db: sqlx::PgPool) {
1233        sqlx::query("CREATE TABLE job_probe (marker text NOT NULL)")
1234            .execute(&db)
1235            .await
1236            .unwrap();
1237        let state = test_state(db.clone());
1238        PanicJob
1239            .enqueue(state.clone(), "panic".into(), None)
1240            .await
1241            .unwrap();
1242        let token = CancellationToken::new();
1243        let handle = tokio::spawn(job_worker(
1244            state.clone(),
1245            Jobs,
1246            Duration::from_millis(20),
1247            20,
1248            token.clone(),
1249            JobWorkerConfig {
1250                heartbeat_interval: Duration::from_millis(300),
1251                reclaim_window: Duration::from_millis(1200),
1252                shutdown_drain_timeout: Duration::ZERO,
1253                idle_poll_gate: None,
1254            },
1255        ));
1256        wait_for(|| async {
1257            let row: Option<(i32, Option<String>, Option<String>)> = sqlx::query_as(
1258                "SELECT error_count, locked_by, last_error_message FROM jobs WHERE name = 'PanicJob'"
1259            ).fetch_optional(&db).await.unwrap();
1260            row.filter(|(count, owner, message)| *count == 1 && owner.is_none() && message.as_deref().is_some_and(|m| m.contains("fixture panic payload")))
1261        }).await;
1262        assert!(
1263            !handle.is_finished(),
1264            "handler panic must not stop the worker"
1265        );
1266        NormalProbeJob
1267            .enqueue(state, "normal".into(), None)
1268            .await
1269            .unwrap();
1270        wait_for(|| async {
1271            let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM job_probe")
1272                .fetch_one(&db)
1273                .await
1274                .unwrap();
1275            (count == 1).then_some(())
1276        })
1277        .await;
1278        token.cancel();
1279        handle.await.unwrap().unwrap();
1280    }
1281
1282    #[sqlx::test]
1283    async fn test_panics_dead_letter_with_final_count(db: sqlx::PgPool) {
1284        for max_retries in [0, 1] {
1285            let state = test_state(db.clone());
1286            PanicJob
1287                .enqueue(state.clone(), "terminal panic".into(), None)
1288                .await
1289                .unwrap();
1290            let id: uuid::Uuid =
1291                sqlx::query_scalar("SELECT job_id FROM jobs WHERE name = 'PanicJob'")
1292                    .fetch_one(&db)
1293                    .await
1294                    .unwrap();
1295            let token = CancellationToken::new();
1296            let handle = tokio::spawn(job_worker(
1297                state,
1298                Jobs,
1299                Duration::from_millis(20),
1300                max_retries,
1301                token.clone(),
1302                JobWorkerConfig {
1303                    heartbeat_interval: Duration::from_millis(300),
1304                    reclaim_window: Duration::from_millis(1200),
1305                    shutdown_drain_timeout: Duration::ZERO,
1306                    idle_poll_gate: None,
1307                },
1308            ));
1309            let count: i32 = wait_for(|| async {
1310                sqlx::query_scalar::<_, i32>(
1311                    "SELECT error_count FROM dead_letter_jobs WHERE original_job_id = $1",
1312                )
1313                .bind(id)
1314                .fetch_optional(&db)
1315                .await
1316                .unwrap()
1317            })
1318            .await;
1319            assert_eq!(count, max_retries + 1);
1320            let message: String = sqlx::query_scalar(
1321                "SELECT last_error_message FROM dead_letter_jobs WHERE original_job_id = $1",
1322            )
1323            .bind(id)
1324            .fetch_one(&db)
1325            .await
1326            .unwrap();
1327            assert!(message.contains("fixture panic payload"));
1328            token.cancel();
1329            handle.await.unwrap().unwrap();
1330        }
1331    }
1332
1333    /// A cancellation-aware job must be allowed to finish its durable cleanup
1334    /// inside the bounded drain: the job observes cancellation, writes to the
1335    /// database, returns Ok, and the worker's normal completion bookkeeping
1336    /// (DELETE of the job row) runs — all before the worker exits. Without the
1337    /// drain, the future is dropped at the job's first cancellation checkpoint
1338    /// and the row survives (locked, then merely unlocked by cleanup).
1339    async fn run_worker_briefly(db: sqlx::PgPool) -> color_eyre::Result<()> {
1340        let app_state = TestAppState {
1341            db,
1342            cookie_key: CookieKey::generate(),
1343        };
1344        tokio::time::timeout(
1345            Duration::from_secs(5),
1346            job_worker(
1347                app_state,
1348                Jobs,
1349                Duration::from_millis(50),
1350                DEFAULT_MAX_RETRIES,
1351                CancellationToken::new(),
1352                JobWorkerConfig::default(),
1353            ),
1354        )
1355        .await
1356        .expect("a schema mismatch must end the worker at startup, not leave it polling")
1357    }
1358
1359    /// The eyes incident: `AddJobErrorTracking` was never applied.
1360    #[sqlx::test]
1361    async fn test_worker_refuses_to_start_without_error_tracking_columns(db: sqlx::PgPool) {
1362        sqlx::query("ALTER TABLE jobs DROP COLUMN error_count")
1363            .execute(&db)
1364            .await
1365            .unwrap();
1366
1367        let error = run_worker_briefly(db).await.unwrap_err();
1368
1369        let message = format!("{error:#}");
1370        assert!(message.contains("error_count"), "{message}");
1371        assert!(message.contains("missing cja migrations"), "{message}");
1372    }
1373
1374    #[sqlx::test]
1375    async fn test_worker_refuses_to_start_without_dead_letter_table(db: sqlx::PgPool) {
1376        sqlx::query("DROP TABLE dead_letter_jobs")
1377            .execute(&db)
1378            .await
1379            .unwrap();
1380
1381        let error = run_worker_briefly(db).await.unwrap_err();
1382
1383        let message = format!("{error:#}");
1384        assert!(message.contains("dead_letter_jobs"), "{message}");
1385        assert!(message.contains("missing cja migrations"), "{message}");
1386    }
1387
1388    /// An unreachable database is the worker loop's problem, not a reason to
1389    /// refuse to start.
1390    #[sqlx::test]
1391    async fn test_schema_check_ignores_transient_database_errors(db: sqlx::PgPool) {
1392        db.close().await;
1393
1394        verify_jobs_schema(&db)
1395            .await
1396            .expect("a closed pool is not a schema mismatch");
1397    }
1398
1399    #[sqlx::test]
1400    async fn test_schema_check_passes_on_current_migrations(db: sqlx::PgPool) {
1401        verify_jobs_schema(&db).await.unwrap();
1402    }
1403
1404    #[sqlx::test]
1405    async fn test_shutdown_drain_lets_a_cooperative_job_finish(db: sqlx::PgPool) {
1406        let app_state = TestAppState {
1407            db: db.clone(),
1408            cookie_key: CookieKey::generate(),
1409        };
1410
1411        DrainCooperativeJob
1412            .clone()
1413            .enqueue(app_state.clone(), "drain-cooperative-test".to_owned(), None)
1414            .await
1415            .unwrap();
1416
1417        let shutdown_token = CancellationToken::new();
1418        let worker_token = shutdown_token.clone();
1419        let handle = tokio::spawn(job_worker(
1420            app_state,
1421            Jobs,
1422            Duration::from_millis(10),
1423            20,
1424            worker_token,
1425            JobWorkerConfig {
1426                shutdown_drain_timeout: Duration::from_secs(10),
1427                ..Default::default()
1428            },
1429        ));
1430
1431        wait_until_started(&db, DrainCooperativeJob::NAME).await;
1432        shutdown_token.cancel();
1433
1434        tokio::time::timeout(Duration::from_secs(15), handle)
1435            .await
1436            .expect("worker must exit inside the drain window")
1437            .unwrap()
1438            .expect("worker must not error");
1439
1440        // The job completed INSIDE the drain, so the success path's durable
1441        // bookkeeping (DELETE FROM jobs) ran. A row remaining here means the
1442        // future was dropped before the job could finish.
1443        let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM jobs")
1444            .fetch_one(&db)
1445            .await
1446            .unwrap();
1447        assert_eq!(
1448            remaining, 0,
1449            "cooperative job must complete (row deleted) inside the drain"
1450        );
1451    }
1452
1453    /// A cancellation-ignoring job must be dropped at the drain deadline, not
1454    /// run to completion: the worker returns promptly after the drain expires
1455    /// and `cleanup_worker_locks` has released the row, so another worker (or
1456    /// a post-restart daemon) can take the job over instead of waiting out the
1457    /// full reclaim window.
1458    #[sqlx::test]
1459    async fn test_shutdown_drain_drops_a_non_cooperative_job(db: sqlx::PgPool) {
1460        let app_state = TestAppState {
1461            db: db.clone(),
1462            cookie_key: CookieKey::generate(),
1463        };
1464
1465        DrainStubbornJob
1466            .clone()
1467            .enqueue(app_state.clone(), "drain-stubborn-test".to_owned(), None)
1468            .await
1469            .unwrap();
1470
1471        let shutdown_token = CancellationToken::new();
1472        let worker_token = shutdown_token.clone();
1473        let handle = tokio::spawn(job_worker(
1474            app_state,
1475            Jobs,
1476            Duration::from_millis(10),
1477            20,
1478            worker_token,
1479            JobWorkerConfig {
1480                shutdown_drain_timeout: Duration::from_secs(2),
1481                ..Default::default()
1482            },
1483        ));
1484
1485        wait_until_started(&db, DrainStubbornJob::NAME).await;
1486        let started = std::time::Instant::now();
1487        shutdown_token.cancel();
1488
1489        // The job sleeps for 30 seconds ignoring cancellation; the worker must
1490        // give up after its 2-second drain instead of waiting that out.
1491        tokio::time::timeout(Duration::from_secs(15), handle)
1492            .await
1493            .expect("worker must return after the drain expires, not after the job finishes")
1494            .unwrap()
1495            .expect("worker must not error");
1496        assert!(
1497            started.elapsed() < Duration::from_secs(20),
1498            "worker ran {:?}, longer than its 2s drain — the job future was not dropped",
1499            started.elapsed()
1500        );
1501
1502        // cleanup_worker_locks must have run after the drop: the row stays
1503        // (unlike the cooperative case) but must be unlocked.
1504        let (locked_by,): (Option<String>,) =
1505            sqlx::query_as("SELECT locked_by FROM jobs WHERE name = $1")
1506                .bind(DrainStubbornJob::NAME)
1507                .fetch_one(&db)
1508                .await
1509                .unwrap();
1510        assert!(
1511            locked_by.is_none(),
1512            "dropped job's lock must be released by cleanup_worker_locks"
1513        );
1514        let count: i32 = sqlx::query_scalar("SELECT error_count FROM jobs WHERE name = $1")
1515            .bind(DrainStubbornJob::NAME)
1516            .fetch_one(&db)
1517            .await
1518            .unwrap();
1519        assert_eq!(count, 0, "graceful drain does not charge a failed attempt");
1520        let dead_letters: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM dead_letter_jobs")
1521            .fetch_one(&db)
1522            .await
1523            .unwrap();
1524        assert_eq!(dead_letters, 0);
1525    }
1526
1527    async fn assert_stubborn_drain(db: sqlx::PgPool, config: JobWorkerConfig) -> Duration {
1528        let app_state = TestAppState {
1529            db: db.clone(),
1530            cookie_key: CookieKey::generate(),
1531        };
1532
1533        DrainStubbornJob
1534            .clone()
1535            .enqueue(app_state.clone(), "default-drain-test".to_owned(), None)
1536            .await
1537            .unwrap();
1538
1539        let shutdown_token = CancellationToken::new();
1540        let worker_token = shutdown_token.clone();
1541        let handle = tokio::spawn(job_worker(
1542            app_state,
1543            Jobs,
1544            Duration::from_millis(10),
1545            20,
1546            worker_token,
1547            config,
1548        ));
1549
1550        wait_until_started(&db, DrainStubbornJob::NAME).await;
1551        let started = std::time::Instant::now();
1552        shutdown_token.cancel();
1553
1554        tokio::time::timeout(Duration::from_secs(10), handle)
1555            .await
1556            .expect("drain must be bounded")
1557            .unwrap()
1558            .expect("worker must not error");
1559        let elapsed = started.elapsed();
1560
1561        let (locked_by,): (Option<String>,) =
1562            sqlx::query_as("SELECT locked_by FROM jobs WHERE name = $1")
1563                .bind(DrainStubbornJob::NAME)
1564                .fetch_one(&db)
1565                .await
1566                .unwrap();
1567        assert!(locked_by.is_none(), "lock must be released after drain");
1568        let count: i32 = sqlx::query_scalar("SELECT error_count FROM jobs WHERE name = $1")
1569            .bind(DrainStubbornJob::NAME)
1570            .fetch_one(&db)
1571            .await
1572            .unwrap();
1573        assert_eq!(count, 0);
1574        let dead_letters: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM dead_letter_jobs")
1575            .fetch_one(&db)
1576            .await
1577            .unwrap();
1578        assert_eq!(dead_letters, 0);
1579        elapsed
1580    }
1581
1582    /// Default worker config gives a running body a bounded, nonzero drain.
1583    #[sqlx::test]
1584    async fn test_job_worker_default_drains_for_bounded_time(db: sqlx::PgPool) {
1585        let config = JobWorkerConfig::default();
1586        let drain = config.shutdown_drain_timeout;
1587        let elapsed = assert_stubborn_drain(db, config).await;
1588        assert!(
1589            elapsed >= drain && elapsed < drain + Duration::from_secs(8),
1590            "elapsed {elapsed:?}, configured drain {drain:?}"
1591        );
1592    }
1593
1594    #[sqlx::test]
1595    async fn test_job_worker_zero_drain_releases_uncharged(db: sqlx::PgPool) {
1596        let elapsed = assert_stubborn_drain(
1597            db,
1598            JobWorkerConfig {
1599                shutdown_drain_timeout: Duration::ZERO,
1600                ..Default::default()
1601            },
1602        )
1603        .await;
1604        assert!(elapsed < Duration::from_secs(1), "{elapsed:?}");
1605    }
1606
1607    /// Test that `fetch_next_job` picks up a stale lock with an unprefixed owner.
1608    #[sqlx::test]
1609    async fn test_fetch_next_job_picks_up_stale_locked_job(db: sqlx::PgPool) {
1610        let app_state = TestAppState {
1611            db: db.clone(),
1612            cookie_key: CookieKey::generate(),
1613        };
1614
1615        let job_id = uuid::Uuid::new_v4();
1616        let stale_worker_id = "crashed-worker";
1617
1618        // Insert a job locked 120 seconds ago
1619        sqlx::query(
1620            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count, locked_by, locked_at)
1621             VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, NOW() - interval '120 seconds')",
1622        )
1623        .bind(job_id)
1624        .bind("TestJob")
1625        .bind(serde_json::json!({"id": "stale-lock-test"}))
1626        .bind(0)
1627        .bind("test-stale-lock")
1628        .bind(0)
1629        .bind(stale_worker_id)
1630        .execute(&db)
1631        .await
1632        .unwrap();
1633
1634        // Create a worker with a 60-second reclaim window.
1635        let worker = Worker::new(
1636            app_state,
1637            Jobs,
1638            Duration::from_secs(1),
1639            20,
1640            CancellationToken::new(),
1641            JobWorkerConfig {
1642                heartbeat_interval: Duration::from_secs(15),
1643                reclaim_window: Duration::from_mins(1),
1644                ..Default::default()
1645            },
1646        )
1647        .unwrap();
1648
1649        // fetch_next_job should pick up the stale locked job
1650        let fetched = worker.fetch_next_job().await.unwrap();
1651        assert!(fetched.is_some());
1652        let claimed = fetched.unwrap();
1653        assert_eq!(claimed.job.job_id, job_id);
1654        assert_eq!(claimed.job.error_count, 1);
1655        assert_eq!(claimed.previous_locked_by.as_deref(), Some(stale_worker_id));
1656        let stored_count: i32 =
1657            sqlx::query_scalar("SELECT error_count FROM jobs WHERE job_id = $1")
1658                .bind(job_id)
1659                .fetch_one(&db)
1660                .await
1661                .unwrap();
1662        assert_eq!(stored_count, 1);
1663        assert!(
1664            claimed
1665                .job
1666                .last_error_message
1667                .unwrap()
1668                .contains(stale_worker_id)
1669        );
1670    }
1671
1672    #[sqlx::test]
1673    async fn test_terminal_expired_lease_dead_letters_without_dispatch(db: sqlx::PgPool) {
1674        let job_id = uuid::Uuid::new_v4();
1675        sqlx::query(
1676            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context,
1677               error_count, locked_by, locked_at)
1678             VALUES ($1, 'PanicJob', '{}', 0, NOW(), NOW(), 'never-dispatched', 1,
1679               'dead-worker', NOW() - interval '5 seconds')",
1680        )
1681        .bind(job_id)
1682        .execute(&db)
1683        .await
1684        .unwrap();
1685        let worker = Worker::new(
1686            test_state(db.clone()),
1687            Jobs,
1688            Duration::from_millis(20),
1689            1,
1690            CancellationToken::new(),
1691            JobWorkerConfig {
1692                heartbeat_interval: Duration::from_millis(300),
1693                reclaim_window: Duration::from_millis(1200),
1694                ..Default::default()
1695            },
1696        )
1697        .unwrap();
1698        assert!(worker.fetch_next_job().await.unwrap().is_none());
1699        let row: (i32, String) = sqlx::query_as(
1700            "SELECT error_count, last_error_message FROM dead_letter_jobs WHERE original_job_id = $1"
1701        ).bind(job_id).fetch_one(&db).await.unwrap();
1702        assert_eq!(row.0, 2);
1703        assert!(row.1.contains("dead-worker"));
1704        let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM jobs WHERE job_id = $1")
1705            .bind(job_id)
1706            .fetch_one(&db)
1707            .await
1708            .unwrap();
1709        assert_eq!(remaining, 0);
1710    }
1711
1712    #[sqlx::test]
1713    async fn test_live_job_heartbeat_survives_boundary_failed_ticks(db: sqlx::PgPool) {
1714        use std::sync::{Arc, atomic::Ordering};
1715        let state = test_state(db.clone());
1716        LongJob
1717            .enqueue(state.clone(), "long".into(), None)
1718            .await
1719            .unwrap();
1720        // CI runs ten feature lanes against separate PostgreSQL containers at
1721        // once. Give this sustained-heartbeat test enough scheduling margin.
1722        let lease = JobWorkerConfig {
1723            heartbeat_interval: Duration::from_millis(800),
1724            reclaim_window: Duration::from_secs(4),
1725            ..Default::default()
1726        };
1727        let worker = Arc::new(
1728            Worker::new(
1729                state.clone(),
1730                Jobs,
1731                Duration::from_millis(20),
1732                20,
1733                CancellationToken::new(),
1734                lease.clone(),
1735            )
1736            .unwrap(),
1737        );
1738        let claimed = worker.fetch_next_job().await.unwrap().unwrap();
1739        let initial: chrono::DateTime<chrono::Utc> =
1740            sqlx::query_scalar("SELECT locked_at FROM jobs WHERE job_id = $1")
1741                .bind(claimed.job.job_id)
1742                .fetch_one(&db)
1743                .await
1744                .unwrap();
1745        // 4s / 800ms = 5 intervals: k-2 failures are the tolerated boundary.
1746        worker.fail_heartbeats.store(3, Ordering::SeqCst);
1747        let runner = tokio::spawn({
1748            let worker = Arc::clone(&worker);
1749            async move { worker.run_next_job(claimed).await }
1750        });
1751        wait_until_started(&db, LongJob::NAME).await;
1752        let advanced = wait_for(|| async {
1753            let current: chrono::DateTime<chrono::Utc> =
1754                sqlx::query_scalar("SELECT locked_at FROM jobs WHERE name = 'LongJob'")
1755                    .fetch_one(&db)
1756                    .await
1757                    .unwrap();
1758            (current > initial && worker.fail_heartbeats.load(Ordering::SeqCst) == 0)
1759                .then_some(current)
1760        })
1761        .await;
1762        let competitor = Worker::new(
1763            state,
1764            Jobs,
1765            Duration::from_millis(20),
1766            20,
1767            CancellationToken::new(),
1768            lease,
1769        )
1770        .unwrap();
1771        let deadline = tokio::time::Instant::now() + Duration::from_secs(13);
1772        while tokio::time::Instant::now() < deadline {
1773            assert!(competitor.fetch_next_job().await.unwrap().is_none());
1774            tokio::time::sleep(Duration::from_millis(100)).await;
1775        }
1776        let row: (i32, chrono::DateTime<chrono::Utc>) =
1777            sqlx::query_as("SELECT error_count, locked_at FROM jobs WHERE name = 'LongJob'")
1778                .fetch_one(&db)
1779                .await
1780                .unwrap();
1781        assert_eq!(row.0, 0);
1782        assert!(row.1 > advanced);
1783        assert!(!runner.is_finished());
1784        runner.abort();
1785        let _ = runner.await;
1786        cleanup_worker_locks(&worker).await.unwrap();
1787    }
1788
1789    #[sqlx::test]
1790    async fn test_watchdog_stops_body_before_expiry_and_reclaim_charges_once(db: sqlx::PgPool) {
1791        use std::sync::{Arc, atomic::Ordering};
1792        let state = test_state(db.clone());
1793        LongJob
1794            .enqueue(state.clone(), "watchdog".into(), None)
1795            .await
1796            .unwrap();
1797        let lease = JobWorkerConfig {
1798            heartbeat_interval: Duration::from_millis(300),
1799            reclaim_window: Duration::from_millis(1200),
1800            ..Default::default()
1801        };
1802        let worker = Arc::new(
1803            Worker::new(
1804                state.clone(),
1805                Jobs,
1806                Duration::from_millis(20),
1807                20,
1808                CancellationToken::new(),
1809                lease.clone(),
1810            )
1811            .unwrap(),
1812        );
1813        let claimed = worker.fetch_next_job().await.unwrap().unwrap();
1814        let id = claimed.job.job_id;
1815        worker.fail_heartbeats.store(3, Ordering::SeqCst);
1816        let runner = tokio::spawn({
1817            let worker = Arc::clone(&worker);
1818            async move { worker.run_next_job(claimed).await }
1819        });
1820        wait_until_started(&db, LongJob::NAME).await;
1821        let outcome = tokio::time::timeout(Duration::from_secs(5), runner)
1822            .await
1823            .unwrap()
1824            .unwrap()
1825            .unwrap();
1826        assert!(matches!(outcome, RunJobOutcome::LeaseLost(found) if found == id));
1827        assert_eq!(worker.fail_heartbeats.load(Ordering::SeqCst), 0);
1828        let row: (i32, Option<String>) =
1829            sqlx::query_as("SELECT error_count, locked_by FROM jobs WHERE job_id = $1")
1830                .bind(id)
1831                .fetch_one(&db)
1832                .await
1833                .unwrap();
1834        assert_eq!(row.0, 0);
1835        assert_eq!(row.1.as_deref(), Some(worker.owner_token.as_str()));
1836        cleanup_worker_locks(&worker).await.unwrap();
1837        let still_owned: Option<String> =
1838            sqlx::query_scalar("SELECT locked_by FROM jobs WHERE job_id = $1")
1839                .bind(id)
1840                .fetch_one(&db)
1841                .await
1842                .unwrap();
1843        assert_eq!(still_owned.as_deref(), Some(worker.owner_token.as_str()));
1844        let competitor = Worker::new(
1845            state,
1846            Jobs,
1847            Duration::from_millis(20),
1848            20,
1849            CancellationToken::new(),
1850            lease,
1851        )
1852        .unwrap();
1853        let reclaimed = wait_for(|| async { competitor.fetch_next_job().await.unwrap() }).await;
1854        assert_eq!(reclaimed.job.job_id, id);
1855        assert_eq!(reclaimed.job.error_count, 1);
1856        assert!(
1857            reclaimed
1858                .job
1859                .last_error_message
1860                .unwrap()
1861                .contains(&worker.owner_token)
1862        );
1863    }
1864
1865    #[sqlx::test]
1866    async fn test_lost_ownership_stops_body_without_touching_new_owner(db: sqlx::PgPool) {
1867        let state = test_state(db.clone());
1868        LongJob
1869            .enqueue(state.clone(), "lost".into(), None)
1870            .await
1871            .unwrap();
1872        let lease = JobWorkerConfig {
1873            heartbeat_interval: Duration::from_millis(300),
1874            reclaim_window: Duration::from_millis(1200),
1875            ..Default::default()
1876        };
1877        let worker = Worker::new(
1878            state,
1879            Jobs,
1880            Duration::from_millis(20),
1881            20,
1882            CancellationToken::new(),
1883            lease,
1884        )
1885        .unwrap();
1886        let claimed = worker.fetch_next_job().await.unwrap().unwrap();
1887        let id = claimed.job.job_id;
1888        let runner = tokio::spawn(async move { worker.run_next_job(claimed).await });
1889        wait_until_started(&db, LongJob::NAME).await;
1890        sqlx::query("UPDATE jobs SET locked_by = 'new-owner' WHERE job_id = $1")
1891            .bind(id)
1892            .execute(&db)
1893            .await
1894            .unwrap();
1895        let outcome = tokio::time::timeout(Duration::from_secs(5), runner)
1896            .await
1897            .unwrap()
1898            .unwrap()
1899            .unwrap();
1900        assert!(matches!(outcome, RunJobOutcome::LeaseLost(found) if found == id));
1901        let row: (String, i32) =
1902            sqlx::query_as("SELECT locked_by, error_count FROM jobs WHERE job_id = $1")
1903                .bind(id)
1904                .fetch_one(&db)
1905                .await
1906                .unwrap();
1907        assert_eq!(row, ("new-owner".into(), 0));
1908    }
1909
1910    #[tokio::test(start_paused = true)]
1911    async fn test_lease_tracker_uses_conservative_deadline() {
1912        let sent = tokio::time::Instant::now();
1913        let tracker = LeaseTracker {
1914            last_refresh_sent: sent,
1915            heartbeat_interval: Duration::from_millis(300),
1916            reclaim_window: Duration::from_millis(1200),
1917        };
1918        assert_eq!(tracker.deadline(), sent + Duration::from_millis(1050));
1919        tokio::time::advance(Duration::from_millis(1049)).await;
1920        assert!(tokio::time::Instant::now() < tracker.deadline());
1921        tokio::time::advance(Duration::from_millis(1)).await;
1922        assert!(tokio::time::Instant::now() >= tracker.deadline());
1923    }
1924
1925    /// Test that `fetch_next_job` does NOT pick up a job with a fresh lock
1926    #[sqlx::test]
1927    async fn test_fetch_next_job_skips_recently_locked_job(db: sqlx::PgPool) {
1928        let app_state = TestAppState {
1929            db: db.clone(),
1930            cookie_key: CookieKey::generate(),
1931        };
1932
1933        let job_id = uuid::Uuid::new_v4();
1934        let active_worker_id = "active-worker";
1935
1936        // Insert a job locked only 10 seconds ago
1937        sqlx::query(
1938            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count, locked_by, locked_at)
1939             VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, NOW() - interval '10 seconds')",
1940        )
1941        .bind(job_id)
1942        .bind("TestJob")
1943        .bind(serde_json::json!({"id": "recent-lock-test"}))
1944        .bind(0)
1945        .bind("test-recent-lock")
1946        .bind(0)
1947        .bind(active_worker_id)
1948        .execute(&db)
1949        .await
1950        .unwrap();
1951
1952        // Create a worker with one-hour reclaim window
1953        let worker = Worker::new(
1954            app_state,
1955            Jobs,
1956            Duration::from_secs(1),
1957            20,
1958            CancellationToken::new(),
1959            JobWorkerConfig {
1960                reclaim_window: Duration::from_hours(1),
1961                ..Default::default()
1962            },
1963        )
1964        .unwrap();
1965
1966        // fetch_next_job should NOT pick up the recently locked job
1967        let fetched = worker.fetch_next_job().await.unwrap();
1968        assert!(fetched.is_none());
1969    }
1970
1971    /// Test that unlocked jobs are picked up before stale locked jobs (by priority)
1972    #[sqlx::test]
1973    async fn test_fetch_next_job_prefers_unlocked_by_priority(db: sqlx::PgPool) {
1974        let app_state = TestAppState {
1975            db: db.clone(),
1976            cookie_key: CookieKey::generate(),
1977        };
1978
1979        let unlocked_job_id = uuid::Uuid::new_v4();
1980        let stale_locked_job_id = uuid::Uuid::new_v4();
1981
1982        // Insert unlocked job with higher priority
1983        sqlx::query(
1984            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count)
1985             VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6)",
1986        )
1987        .bind(unlocked_job_id)
1988        .bind("TestJob")
1989        .bind(serde_json::json!({"id": "unlocked"}))
1990        .bind(10) // Higher priority
1991        .bind("test-unlocked")
1992        .bind(0)
1993        .execute(&db)
1994        .await
1995        .unwrap();
1996
1997        // Insert stale locked job with lower priority
1998        sqlx::query(
1999            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count, locked_by, locked_at)
2000             VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, NOW() - interval '120 seconds')",
2001        )
2002        .bind(stale_locked_job_id)
2003        .bind("TestJob")
2004        .bind(serde_json::json!({"id": "stale-locked"}))
2005        .bind(5) // Lower priority
2006        .bind("test-stale")
2007        .bind(0)
2008        .bind("crashed-worker")
2009        .execute(&db)
2010        .await
2011        .unwrap();
2012
2013        // Create a worker with 60-second reclaim window
2014        let worker = Worker::new(
2015            app_state,
2016            Jobs,
2017            Duration::from_secs(1),
2018            20,
2019            CancellationToken::new(),
2020            JobWorkerConfig {
2021                heartbeat_interval: Duration::from_secs(15),
2022                reclaim_window: Duration::from_mins(1),
2023                ..Default::default()
2024            },
2025        )
2026        .unwrap();
2027
2028        // Should pick the higher priority unlocked job first
2029        let fetched = worker.fetch_next_job().await.unwrap();
2030        assert!(fetched.is_some());
2031        let claimed = fetched.unwrap();
2032        assert_eq!(claimed.job.job_id, unlocked_job_id);
2033        assert_eq!(claimed.job.error_count, 0);
2034    }
2035
2036    /// Test that among same-priority jobs, the one that became eligible earliest
2037    /// (smaller `run_at`) is picked first — even when a competing job has an older
2038    /// `created_at`. This guards the readiness-ordering contract: a job whose
2039    /// `run_at` was pushed into the future by retry backoff must not cut in front
2040    /// of a job that has been due longer, just because it was enqueued earlier.
2041    #[sqlx::test]
2042    async fn test_fetch_next_job_orders_by_run_at_over_created_at(db: sqlx::PgPool) {
2043        let app_state = TestAppState {
2044            db: db.clone(),
2045            cookie_key: CookieKey::generate(),
2046        };
2047
2048        let older_created_later_run_id = uuid::Uuid::new_v4();
2049        let newer_created_earlier_run_id = uuid::Uuid::new_v4();
2050
2051        // Job A: created earliest, but only became due 30 seconds ago (e.g. a
2052        // job that failed and had its run_at pushed out by backoff).
2053        sqlx::query(
2054            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count)
2055             VALUES ($1, $2, $3, $4, NOW() - interval '30 seconds', NOW() - interval '300 seconds', $5, $6)",
2056        )
2057        .bind(older_created_later_run_id)
2058        .bind("TestJob")
2059        .bind(serde_json::json!({"id": "older-created-later-run"}))
2060        .bind(0)
2061        .bind("test-older-created")
2062        .bind(0)
2063        .execute(&db)
2064        .await
2065        .unwrap();
2066
2067        // Job B: created more recently, but has been due longer (smaller run_at).
2068        sqlx::query(
2069            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, context, error_count)
2070             VALUES ($1, $2, $3, $4, NOW() - interval '120 seconds', NOW() - interval '60 seconds', $5, $6)",
2071        )
2072        .bind(newer_created_earlier_run_id)
2073        .bind("TestJob")
2074        .bind(serde_json::json!({"id": "newer-created-earlier-run"}))
2075        .bind(0)
2076        .bind("test-newer-created")
2077        .bind(0)
2078        .execute(&db)
2079        .await
2080        .unwrap();
2081
2082        let worker = Worker::new(
2083            app_state,
2084            Jobs,
2085            Duration::from_secs(1),
2086            20,
2087            CancellationToken::new(),
2088            JobWorkerConfig {
2089                heartbeat_interval: Duration::from_secs(15),
2090                reclaim_window: Duration::from_mins(1),
2091                ..Default::default()
2092            },
2093        )
2094        .unwrap();
2095
2096        // Both jobs share a priority; the one due longest (smaller run_at) wins,
2097        // regardless of which was created first.
2098        let fetched = worker.fetch_next_job().await.unwrap();
2099        assert!(fetched.is_some());
2100        assert_eq!(fetched.unwrap().job.job_id, newer_created_earlier_run_id);
2101    }
2102
2103    /// Test that the worker loop survives transient database errors instead of
2104    /// exiting. Before the tick-error backoff was added, a single failed
2105    /// `fetch_next_job` (e.g. a pool acquire timeout during a database stall)
2106    /// propagated out of `job_worker` and killed the worker task — and with it,
2107    /// apps that join on all worker tasks.
2108    #[sqlx::test]
2109    async fn test_job_worker_survives_transient_db_errors(db: sqlx::PgPool) {
2110        let app_state = TestAppState {
2111            db: db.clone(),
2112            cookie_key: CookieKey::generate(),
2113        };
2114
2115        let shutdown_token = CancellationToken::new();
2116        let worker_token = shutdown_token.clone();
2117
2118        // Close the pool so every query the worker makes fails, simulating the
2119        // database being unreachable.
2120        db.close().await;
2121
2122        let handle = tokio::spawn(async move {
2123            job_worker(
2124                app_state,
2125                Jobs,
2126                Duration::from_millis(10),
2127                20,
2128                worker_token,
2129                JobWorkerConfig::default(),
2130            )
2131            .await
2132        });
2133
2134        // Give the worker time to hit at least one failing tick. Before the
2135        // fix, job_worker returned Err almost immediately; with the fix it
2136        // keeps looping (sleeping in error backoff).
2137        tokio::time::sleep(Duration::from_millis(500)).await;
2138        assert!(
2139            !handle.is_finished(),
2140            "worker exited after a transient DB error instead of retrying"
2141        );
2142
2143        // The worker must still respond promptly to shutdown, even while
2144        // sitting in an error-backoff sleep.
2145        shutdown_token.cancel();
2146        let joined = tokio::time::timeout(Duration::from_secs(5), handle).await;
2147        assert!(
2148            joined.is_ok(),
2149            "worker did not shut down promptly after cancellation"
2150        );
2151    }
2152
2153    /// PR review pin (DEV-1537 round 1): the heartbeat must run alongside the
2154    /// job body, not instead of it. A slow heartbeat UPDATE (row lock held,
2155    /// pool contention, DB stall) must not freeze the running job for up to
2156    /// `heartbeat_interval / 3`.
2157    mod heartbeat_does_not_stall_body {
2158        use super::*;
2159        use std::sync::atomic::{AtomicU64, Ordering};
2160
2161        static MAX_GAP_MS: AtomicU64 = AtomicU64::new(0);
2162
2163        #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
2164        struct TickingJob;
2165
2166        #[async_trait::async_trait]
2167        impl Job<TestAppState> for TickingJob {
2168            const NAME: &'static str = "TickingJob";
2169
2170            async fn run(&self, app_state: TestAppState) -> color_eyre::Result<()> {
2171                sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
2172                    .bind(Self::NAME)
2173                    .execute(app_state.db())
2174                    .await?;
2175                let mut last = std::time::Instant::now();
2176                loop {
2177                    tokio::time::sleep(Duration::from_millis(20)).await;
2178                    let now = std::time::Instant::now();
2179                    let gap = u64::try_from((now - last).as_millis()).unwrap();
2180                    MAX_GAP_MS.fetch_max(gap, Ordering::SeqCst);
2181                    last = now;
2182                }
2183            }
2184        }
2185
2186        impl_job_registry!(TestAppState, TickingJob);
2187
2188        #[sqlx::test]
2189        async fn test_slow_heartbeat_does_not_stall_job_body(db: sqlx::PgPool) {
2190            let state = test_state(db.clone());
2191            TickingJob
2192                .enqueue(state.clone(), "ticking".into(), None)
2193                .await
2194                .unwrap();
2195            // Per-attempt heartbeat budget is interval / 3 = 1s.
2196            let lease = JobWorkerConfig {
2197                heartbeat_interval: Duration::from_secs(3),
2198                reclaim_window: Duration::from_secs(12),
2199                ..Default::default()
2200            };
2201            let worker = std::sync::Arc::new(
2202                Worker::new(
2203                    state,
2204                    Jobs,
2205                    Duration::from_millis(20),
2206                    20,
2207                    CancellationToken::new(),
2208                    lease,
2209                )
2210                .unwrap(),
2211            );
2212            let claimed = worker.fetch_next_job().await.unwrap().unwrap();
2213            let id = claimed.job.job_id;
2214            let runner = tokio::spawn({
2215                let worker = std::sync::Arc::clone(&worker);
2216                async move { worker.run_next_job(claimed).await }
2217            });
2218            wait_until_started(&db, TickingJob::NAME).await;
2219            MAX_GAP_MS.store(0, Ordering::SeqCst);
2220
2221            // Hold the row lock across the first heartbeat tick (~3s after
2222            // claim) so that heartbeat UPDATE blocks for its full 1s budget.
2223            let mut tx = db.begin().await.unwrap();
2224            sqlx::query("SELECT 1 FROM jobs WHERE job_id = $1 FOR UPDATE")
2225                .bind(id)
2226                .execute(&mut *tx)
2227                .await
2228                .unwrap();
2229            tokio::time::sleep(Duration::from_secs(5)).await;
2230            tx.rollback().await.unwrap();
2231            tokio::time::sleep(Duration::from_millis(200)).await;
2232
2233            let max_gap = MAX_GAP_MS.load(Ordering::SeqCst);
2234            runner.abort();
2235            let _ = runner.await;
2236            cleanup_worker_locks(&worker).await.unwrap();
2237            assert!(
2238                max_gap < 400,
2239                "job body was not polled for {max_gap}ms while a heartbeat UPDATE was in flight"
2240            );
2241        }
2242    }
2243}
2244
2245#[cfg(test)]
2246mod idle_gate_tests {
2247    use super::*;
2248    use crate::{app_state::AppState, impl_job_registry, jobs::Job, server::cookies::CookieKey};
2249    use serde::{Deserialize, Serialize};
2250    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst};
2251    use tokio::task::JoinHandle;
2252
2253    #[derive(Clone)]
2254    struct State {
2255        db: sqlx::PgPool,
2256        key: CookieKey,
2257        gate: IdlePollGate,
2258        hooks: Arc<AtomicUsize>,
2259        park: Arc<AtomicBool>,
2260        release: Arc<Notify>,
2261        in_body: Arc<AtomicUsize>,
2262        max_in_body: Arc<AtomicUsize>,
2263    }
2264
2265    impl AppState for State {
2266        fn version(&self) -> &'static str {
2267            "gate-test"
2268        }
2269        fn db(&self) -> &sqlx::PgPool {
2270            &self.db
2271        }
2272        fn cookie_key(&self) -> &CookieKey {
2273            &self.key
2274        }
2275        fn job_enqueued(&self) {
2276            self.hooks.fetch_add(1, SeqCst);
2277            self.gate.wake_one();
2278        }
2279    }
2280
2281    #[derive(Clone, Debug, Serialize, Deserialize)]
2282    struct GateJob {
2283        id: String,
2284    }
2285
2286    #[async_trait::async_trait]
2287    impl Job<State> for GateJob {
2288        const NAME: &'static str = "GateJob";
2289        async fn run(&self, state: State) -> color_eyre::Result<()> {
2290            sqlx::query("INSERT INTO gate_job_starts (id) VALUES ($1)")
2291                .bind(&self.id)
2292                .execute(&state.db)
2293                .await?;
2294            let current = state.in_body.fetch_add(1, SeqCst) + 1;
2295            state.max_in_body.fetch_max(current, SeqCst);
2296            if state.park.load(SeqCst) {
2297                state.release.notified().await;
2298            }
2299            state.in_body.fetch_sub(1, SeqCst);
2300            Ok(())
2301        }
2302    }
2303    impl_job_registry!(State, GateJob);
2304
2305    #[derive(Clone, Debug, Deserialize)]
2306    struct BadJob;
2307
2308    impl Serialize for BadJob {
2309        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
2310            Err(serde::ser::Error::custom("fixture serialization failure"))
2311        }
2312    }
2313
2314    #[async_trait::async_trait]
2315    impl Job<State> for BadJob {
2316        const NAME: &'static str = "BadJob";
2317        async fn run(&self, _: State) -> color_eyre::Result<()> {
2318            Ok(())
2319        }
2320    }
2321
2322    fn make_state(db: sqlx::PgPool, gate: IdlePollGate) -> State {
2323        State {
2324            db,
2325            key: CookieKey::generate(),
2326            gate,
2327            hooks: Arc::new(AtomicUsize::new(0)),
2328            park: Arc::new(AtomicBool::new(false)),
2329            release: Arc::new(Notify::new()),
2330            in_body: Arc::new(AtomicUsize::new(0)),
2331            max_in_body: Arc::new(AtomicUsize::new(0)),
2332        }
2333    }
2334
2335    async fn setup(db: &sqlx::PgPool) {
2336        sqlx::query("CREATE TABLE gate_job_starts (id TEXT PRIMARY KEY)")
2337            .execute(db)
2338            .await
2339            .unwrap();
2340    }
2341
2342    struct Workers {
2343        token: CancellationToken,
2344        handles: Vec<JoinHandle<color_eyre::Result<()>>>,
2345    }
2346    impl Workers {
2347        fn start(state: &State, count: usize, interval: Duration, enabled: bool) -> Self {
2348            let token = CancellationToken::new();
2349            let handles = (0..count)
2350                .map(|_| {
2351                    tokio::spawn(job_worker(
2352                        state.clone(),
2353                        Jobs,
2354                        interval,
2355                        20,
2356                        token.clone(),
2357                        JobWorkerConfig {
2358                            idle_poll_gate: enabled.then(|| state.gate.clone()),
2359                            ..Default::default()
2360                        },
2361                    ))
2362                })
2363                .collect();
2364            Self { token, handles }
2365        }
2366        async fn stop(self) {
2367            self.token.cancel();
2368            let mut all_exited_cleanly = true;
2369            for mut handle in self.handles {
2370                match tokio::time::timeout(Duration::from_secs(10), &mut handle).await {
2371                    Ok(Ok(Ok(()))) => {}
2372                    Ok(_) => all_exited_cleanly = false,
2373                    Err(_) => {
2374                        handle.abort();
2375                        let _ = handle.await;
2376                        all_exited_cleanly = false;
2377                    }
2378                }
2379            }
2380            assert!(all_exited_cleanly, "worker shutdown failed");
2381        }
2382    }
2383
2384    async fn with_workers<T>(workers: Workers, body: impl Future<Output = T>) -> T {
2385        let result = std::panic::AssertUnwindSafe(body).catch_unwind().await;
2386        workers.stop().await;
2387        match result {
2388            Ok(value) => value,
2389            Err(payload) => std::panic::resume_unwind(payload),
2390        }
2391    }
2392
2393    async fn until(mut condition: impl FnMut() -> bool) {
2394        tokio::time::timeout(Duration::from_secs(30), async {
2395            while !condition() {
2396                tokio::time::sleep(Duration::from_millis(20)).await;
2397            }
2398        })
2399        .await
2400        .expect("gate condition timeout");
2401    }
2402
2403    async fn started(db: &sqlx::PgPool) -> i64 {
2404        sqlx::query_scalar("SELECT COUNT(*) FROM gate_job_starts")
2405            .fetch_one(db)
2406            .await
2407            .unwrap()
2408    }
2409
2410    async fn insert_sql(
2411        db: &sqlx::PgPool,
2412        id: &str,
2413        delay: &str,
2414        owner: Option<&str>,
2415    ) -> uuid::Uuid {
2416        let job_id = uuid::Uuid::new_v4();
2417        sqlx::query(concat!(
2418            "INSERT INTO jobs (job_id, name, payload, priority, run_at, created_at, ",
2419            "context, locked_by, locked_at) VALUES ($1, $2, $3, 0, ",
2420            "NOW() + $4::interval, NOW(), $5, $6, ",
2421            "CASE WHEN $6::text IS NULL THEN NULL ELSE NOW() END)"
2422        ))
2423        .bind(job_id)
2424        .bind(GateJob::NAME)
2425        .bind(serde_json::json!({"id": id}))
2426        .bind(delay)
2427        .bind(id)
2428        .bind(owner)
2429        .execute(db)
2430        .await
2431        .unwrap();
2432        job_id
2433    }
2434
2435    #[sqlx::test]
2436    async fn quiet_gate_limits_claim_rate_and_in_flight(db: sqlx::PgPool) {
2437        setup(&db).await;
2438        let gate = IdlePollGate::new(NonZeroUsize::new(2).unwrap());
2439        let state = make_state(db, gate.clone());
2440        let workers = Workers::start(&state, 100, Duration::from_millis(200), true);
2441        with_workers(workers, async {
2442            until(|| {
2443                gate.0.probe.waiting.load(SeqCst) == 98
2444                    && gate.0.probe.empty_periodic.load(SeqCst) >= 2
2445            })
2446            .await;
2447            gate.0.probe.attempts.store(0, SeqCst);
2448            gate.0.probe.max_in_flight_periodic.store(0, SeqCst);
2449            tokio::time::sleep(Duration::from_secs(2)).await;
2450            let attempts = gate.0.probe.attempts.load(SeqCst);
2451            let max = gate.0.probe.max_in_flight_periodic.load(SeqCst);
2452            assert!((1..=24).contains(&attempts), "{attempts} attempts");
2453            assert!(max <= 2, "{max} periodic claims in flight");
2454        })
2455        .await;
2456    }
2457
2458    #[sqlx::test]
2459    async fn direct_sql_burst_releases_permits_before_bodies(db: sqlx::PgPool) {
2460        setup(&db).await;
2461        let gate = IdlePollGate::new(NonZeroUsize::new(2).unwrap());
2462        let state = make_state(db.clone(), gate.clone());
2463        state.park.store(true, SeqCst);
2464        // A one-second idle interval keeps the deadline meaningful on slow CI
2465        // runners: immediate permit handoff needs at most one interval plus 16
2466        // serial claims per permit, while waiting an interval per handoff needs
2467        // about 16 seconds and holding permits through bodies never gets past 2.
2468        let workers = Workers::start(&state, 100, Duration::from_secs(1), true);
2469        with_workers(workers, async {
2470            until(|| {
2471                gate.0.probe.waiting.load(SeqCst) == 98
2472                    && gate.0.probe.empty_periodic.load(SeqCst) >= 2
2473            })
2474            .await;
2475            let burst_started = tokio::time::Instant::now();
2476            for id in 0..32 {
2477                insert_sql(&db, &format!("burst-{id}"), "0 seconds", None).await;
2478            }
2479            // Wait on the overlap counter itself. Bodies record their start row
2480            // before counting themselves in, so polling the start rows can see
2481            // 32 starts before the last body is counted as overlapping.
2482            let result = tokio::time::timeout_at(burst_started + Duration::from_secs(8), async {
2483                while state.in_body.load(SeqCst) < 32 {
2484                    tokio::time::sleep(Duration::from_millis(10)).await;
2485                }
2486            })
2487            .await;
2488            let max = state.max_in_body.load(SeqCst);
2489            state.release.notify_waiters();
2490            assert!(
2491                result.is_ok(),
2492                "only {max} of 32 bodies overlapped within eight seconds"
2493            );
2494            assert_eq!(started(&db).await, 32);
2495        })
2496        .await;
2497    }
2498
2499    #[sqlx::test]
2500    async fn enqueue_wakes_parked_worker_and_sql_still_polls(db: sqlx::PgPool) {
2501        setup(&db).await;
2502        let gate = IdlePollGate::new(NonZeroUsize::new(1).unwrap());
2503        let state = make_state(db.clone(), gate.clone());
2504        let workers = Workers::start(&state, 3, Duration::from_secs(5), true);
2505        with_workers(workers, async {
2506            until(|| {
2507                gate.0.probe.waiting.load(SeqCst) == 2
2508                    && gate.0.probe.empty_periodic.load(SeqCst) >= 1
2509            })
2510            .await;
2511            GateJob { id: "wake".into() }
2512                .enqueue(state.clone(), "wake".into(), None)
2513                .await
2514                .unwrap();
2515            let fast = tokio::time::timeout(Duration::from_secs(1), async {
2516                while started(&db).await < 1 {
2517                    tokio::time::sleep(Duration::from_millis(10)).await;
2518                }
2519            })
2520            .await;
2521            let hooks = state.hooks.load(SeqCst);
2522            fast.expect("local wake should start body before periodic tick");
2523            assert_eq!(hooks, 1);
2524        })
2525        .await;
2526    }
2527
2528    #[sqlx::test]
2529    async fn failed_enqueue_does_not_wake_and_wakes_coalesce(db: sqlx::PgPool) {
2530        setup(&db).await;
2531        let gate = IdlePollGate::new(NonZeroUsize::new(1).unwrap());
2532        let state = make_state(db.clone(), gate.clone());
2533        let workers = Workers::start(&state, 3, Duration::from_secs(5), true);
2534        with_workers(workers, async {
2535            until(|| {
2536                gate.0.probe.waiting.load(SeqCst) == 2
2537                    && gate.0.probe.empty_periodic.load(SeqCst) >= 1
2538            })
2539            .await;
2540            let before = gate.0.probe.notified.load(SeqCst);
2541            let error = BadJob
2542                .enqueue(state.clone(), "bad".into(), None)
2543                .await
2544                .unwrap_err();
2545            tokio::time::sleep(Duration::from_millis(100)).await;
2546            let after = gate.0.probe.notified.load(SeqCst);
2547            let hooks = state.hooks.load(SeqCst);
2548            assert!(matches!(
2549                error,
2550                super::super::EnqueueError::SerdeJsonError(_)
2551            ));
2552            assert_eq!(hooks, 0);
2553            assert_eq!(after, before);
2554        })
2555        .await;
2556
2557        for _ in 0..20 {
2558            gate.wake_one();
2559        }
2560        let state = make_state(db.clone(), gate.clone());
2561        let before = gate.0.probe.notified.load(SeqCst);
2562        let workers = Workers::start(&state, 3, Duration::from_millis(100), true);
2563        with_workers(workers, async {
2564            until(|| {
2565                gate.0.probe.waiting.load(SeqCst) == 2
2566                    && gate.0.probe.empty_periodic.load(SeqCst) >= 2
2567            })
2568            .await;
2569            let notified = gate.0.probe.notified.load(SeqCst) - before;
2570            insert_sql(&db, "coalesced", "0 seconds", None).await;
2571            let found = tokio::time::timeout(Duration::from_secs(1), async {
2572                while started(&db).await == 0 {
2573                    tokio::time::sleep(Duration::from_millis(10)).await;
2574                }
2575            })
2576            .await;
2577            assert!(notified <= 1, "{notified} stored notifications consumed");
2578            found.expect("periodic claim discovers SQL job after coalesced wakes");
2579        })
2580        .await;
2581    }
2582
2583    #[sqlx::test]
2584    async fn cancellation_releases_held_and_waiting_permits(db: sqlx::PgPool) {
2585        setup(&db).await;
2586        let gate = IdlePollGate::new(NonZeroUsize::new(1).unwrap());
2587        let state = make_state(db, gate.clone());
2588        let config = JobWorkerConfig {
2589            idle_poll_gate: Some(gate.clone()),
2590            ..Default::default()
2591        };
2592        let first_token = CancellationToken::new();
2593        let mut first = Some(tokio::spawn(job_worker(
2594            state.clone(),
2595            Jobs,
2596            Duration::from_secs(5),
2597            20,
2598            first_token.clone(),
2599            config.clone(),
2600        )));
2601        let second_token = CancellationToken::new();
2602        let mut second = None;
2603        let result = std::panic::AssertUnwindSafe(async {
2604            until(|| gate.0.probe.empty_periodic.load(SeqCst) >= 1).await;
2605            second = Some(tokio::spawn(job_worker(
2606                state,
2607                Jobs,
2608                Duration::from_secs(5),
2609                20,
2610                second_token.clone(),
2611                config,
2612            )));
2613            until(|| gate.0.probe.waiting.load(SeqCst) == 1).await;
2614            second_token.cancel();
2615            tokio::time::timeout(Duration::from_secs(1), second.as_mut().unwrap())
2616                .await
2617                .expect("waiter exits promptly")
2618                .unwrap()
2619                .unwrap();
2620            second.take();
2621            assert_eq!(gate.0.probe.waiting.load(SeqCst), 0);
2622            assert_eq!(gate.0.periodic_claims.available_permits(), 0);
2623            first_token.cancel();
2624            tokio::time::timeout(Duration::from_secs(1), first.as_mut().unwrap())
2625                .await
2626                .expect("holder exits promptly")
2627                .unwrap()
2628                .unwrap();
2629            first.take();
2630            assert_eq!(gate.0.periodic_claims.available_permits(), 1);
2631            assert_eq!(gate.0.probe.in_flight_periodic.load(SeqCst), 0);
2632        })
2633        .catch_unwind()
2634        .await;
2635        first_token.cancel();
2636        second_token.cancel();
2637        Workers {
2638            token: first_token,
2639            handles: first.into_iter().chain(second).collect(),
2640        }
2641        .stop()
2642        .await;
2643        if let Err(payload) = result {
2644            std::panic::resume_unwind(payload);
2645        }
2646    }
2647
2648    #[sqlx::test]
2649    async fn pool_failure_releases_permits_before_backoff(db: sqlx::PgPool) {
2650        use sqlx::postgres::PgPoolOptions;
2651        setup(&db).await;
2652        let pool = PgPoolOptions::new()
2653            .max_connections(2)
2654            .acquire_timeout(Duration::from_millis(100))
2655            .connect_with((*db.connect_options()).clone())
2656            .await
2657            .unwrap();
2658        let gate = IdlePollGate::new(NonZeroUsize::new(2).unwrap());
2659        let state = make_state(pool.clone(), gate.clone());
2660        let workers = Workers::start(&state, 100, Duration::from_millis(200), true);
2661        with_workers(workers, async {
2662            until(|| {
2663                gate.0.probe.waiting.load(SeqCst) == 98
2664                    && gate.0.probe.empty_periodic.load(SeqCst) >= 2
2665            })
2666            .await;
2667            let mut held = Vec::new();
2668            for _ in 0..2 {
2669                held.push(pool.acquire().await.unwrap());
2670            }
2671            until(|| gate.0.probe.errors.load(SeqCst) >= 2).await;
2672            let errors = gate.0.probe.errors.load(SeqCst);
2673            let released = tokio::time::timeout(Duration::from_millis(500), async {
2674                while gate.0.probe.errors.load(SeqCst) <= errors {
2675                    tokio::time::sleep(Duration::from_millis(10)).await;
2676                }
2677            })
2678            .await
2679            .is_ok();
2680            drop(held);
2681            until(|| {
2682                gate.0.probe.waiting.load(SeqCst) == 98
2683                    && gate.0.probe.empty_periodic.load(SeqCst) >= 4
2684            })
2685            .await;
2686            gate.0.probe.attempts.store(0, SeqCst);
2687            gate.0.probe.max_in_flight_periodic.store(0, SeqCst);
2688            tokio::time::sleep(Duration::from_secs(2)).await;
2689            let attempts = gate.0.probe.attempts.load(SeqCst);
2690            let max = gate.0.probe.max_in_flight_periodic.load(SeqCst);
2691            assert!(released, "permits remained held during backoff");
2692            assert!(
2693                (1..=24).contains(&attempts),
2694                "{attempts} claims after recovery"
2695            );
2696            assert!(max <= 2, "{max} periodic claims in flight");
2697        })
2698        .await;
2699    }
2700
2701    #[sqlx::test]
2702    async fn cancelling_blocked_claim_drops_probe_and_permit(db: sqlx::PgPool) {
2703        use sqlx::postgres::PgPoolOptions;
2704        setup(&db).await;
2705        let pool = PgPoolOptions::new()
2706            .max_connections(1)
2707            .acquire_timeout(Duration::from_secs(5))
2708            .connect_with((*db.connect_options()).clone())
2709            .await
2710            .unwrap();
2711        let gate = IdlePollGate::new(NonZeroUsize::new(1).unwrap());
2712        let state = make_state(pool.clone(), gate.clone());
2713        let token = CancellationToken::new();
2714        let worker = tokio::spawn(job_worker(
2715            state,
2716            Jobs,
2717            Duration::from_millis(100),
2718            20,
2719            token.clone(),
2720            JobWorkerConfig {
2721                idle_poll_gate: Some(gate.clone()),
2722                ..Default::default()
2723            },
2724        ));
2725        let workers = Workers {
2726            token: token.clone(),
2727            handles: vec![worker],
2728        };
2729        with_workers(workers, async {
2730            until(|| gate.0.probe.empty_periodic.load(SeqCst) >= 1).await;
2731            let held = pool.acquire().await.unwrap();
2732            until(|| gate.0.probe.in_flight_periodic.load(SeqCst) == 1).await;
2733            token.cancel();
2734            drop(held);
2735            tokio::time::timeout(Duration::from_secs(1), async {
2736                while gate.0.periodic_claims.available_permits() != 1 {
2737                    tokio::time::sleep(Duration::from_millis(10)).await;
2738                }
2739            })
2740            .await
2741            .expect("blocked claim releases permit promptly");
2742            assert_eq!(gate.0.probe.in_flight_periodic.load(SeqCst), 0);
2743            assert_eq!(gate.0.periodic_claims.available_permits(), 1);
2744        })
2745        .await;
2746    }
2747
2748    #[sqlx::test]
2749    async fn periodic_discovers_due_retry_and_expired_lease(db: sqlx::PgPool) {
2750        setup(&db).await;
2751        let gate = IdlePollGate::new(NonZeroUsize::new(1).unwrap());
2752        let state = make_state(db.clone(), gate.clone());
2753        state.park.store(true, SeqCst);
2754        let config = JobWorkerConfig {
2755            heartbeat_interval: Duration::from_millis(300),
2756            reclaim_window: Duration::from_millis(1200),
2757            idle_poll_gate: Some(gate),
2758            ..Default::default()
2759        };
2760        let token = CancellationToken::new();
2761        let first = tokio::spawn(job_worker(
2762            state.clone(),
2763            Jobs,
2764            Duration::from_millis(100),
2765            20,
2766            token.clone(),
2767            config.clone(),
2768        ));
2769        let second = tokio::spawn(job_worker(
2770            state.clone(),
2771            Jobs,
2772            Duration::from_millis(100),
2773            20,
2774            token.clone(),
2775            config,
2776        ));
2777        let workers = Workers {
2778            token,
2779            handles: vec![first, second],
2780        };
2781        with_workers(workers, async {
2782            let retry_id = insert_sql(&db, "retry", "0.5 seconds", None).await;
2783            sqlx::query(concat!(
2784                "UPDATE jobs SET error_count = 1, ",
2785                "last_error_message = 'previous attempt failed' WHERE job_id = $1"
2786            ))
2787            .bind(retry_id)
2788            .execute(&db)
2789            .await
2790            .unwrap();
2791            let lease_id = insert_sql(&db, "lease", "0 seconds", Some("dead-worker")).await;
2792            tokio::time::sleep(Duration::from_millis(200)).await;
2793            let early = started(&db).await;
2794            let result = tokio::time::timeout(Duration::from_secs(2), async {
2795                while started(&db).await < 2 {
2796                    tokio::time::sleep(Duration::from_millis(10)).await;
2797                }
2798            })
2799            .await;
2800            let row: (i32, String) = sqlx::query_as(
2801                "SELECT error_count, last_error_message FROM jobs WHERE job_id = $1",
2802            )
2803            .bind(lease_id)
2804            .fetch_one(&db)
2805            .await
2806            .unwrap();
2807            state.release.notify_waiters();
2808            assert_eq!(early, 0);
2809            result.expect("periodic discovery of retry and lease");
2810            assert_eq!(row.0, 1);
2811            assert!(row.1.starts_with("Job lease expired"));
2812        })
2813        .await;
2814    }
2815
2816    #[sqlx::test]
2817    async fn disabled_gate_keeps_independent_polling(db: sqlx::PgPool) {
2818        setup(&db).await;
2819        let state = make_state(db.clone(), IdlePollGate::new(NonZeroUsize::new(1).unwrap()));
2820        let workers = Workers::start(&state, 1, Duration::from_millis(100), false);
2821        with_workers(workers, async {
2822            insert_sql(&db, "none", "0 seconds", None).await;
2823            let result = tokio::time::timeout(Duration::from_secs(1), async {
2824                while started(&db).await == 0 {
2825                    tokio::time::sleep(Duration::from_millis(10)).await;
2826                }
2827            })
2828            .await;
2829            result.expect("ungated polling should discover direct SQL");
2830        })
2831        .await;
2832    }
2833}