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
21pub const DEFAULT_MAX_RETRIES: i32 = 20;
28
29pub const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
31pub const DEFAULT_RECLAIM_WINDOW: Duration = Duration::from_mins(2);
33
34#[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 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 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#[derive(Clone, Debug)]
140pub struct JobWorkerConfig {
141 pub heartbeat_interval: Duration,
143 pub reclaim_window: Duration,
146 pub shutdown_drain_timeout: Duration,
151 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
199const TICK_ERROR_BACKOFF_BASE: Duration = Duration::from_secs(1);
204
205const 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 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 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 #[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)] 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 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
594async 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
641async 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
677pub 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 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 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 #[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 sqlx::query("UPDATE jobs SET context = 'started' WHERE name = $1")
1054 .bind(Self::NAME)
1055 .execute(app_state.db())
1056 .await?;
1057 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 #[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 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 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 #[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 #[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 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 #[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 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 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 #[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 #[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 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 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 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 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 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 #[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 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 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 let fetched = worker.fetch_next_job().await.unwrap();
1968 assert!(fetched.is_none());
1969 }
1970
1971 #[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 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) .bind("test-unlocked")
1992 .bind(0)
1993 .execute(&db)
1994 .await
1995 .unwrap();
1996
1997 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) .bind("test-stale")
2007 .bind(0)
2008 .bind("crashed-worker")
2009 .execute(&db)
2010 .await
2011 .unwrap();
2012
2013 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 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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 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}