1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::cron::{atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow};
5use awa_model::{JobRow, JobState, PeriodicJob, PruneOutcome, RotateOutcome};
6use chrono::Utc;
7use croner::Cron;
8use sqlx::pool::PoolConnection;
9use sqlx::{PgPool, Postgres};
10use std::collections::{HashMap, HashSet};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio_util::sync::CancellationToken;
15use tracing::{debug, error, info, warn};
16
17#[derive(Debug, Clone)]
19pub struct RetentionPolicy {
20 pub completed: Duration,
22 pub failed: Duration,
24 pub dlq: Option<Duration>,
26}
27
28impl Default for RetentionPolicy {
29 fn default() -> Self {
30 Self {
31 completed: Duration::from_secs(86400), failed: Duration::from_secs(259200), dlq: None,
34 }
35 }
36}
37
38pub struct MaintenanceService {
43 pool: PgPool,
44 metrics: crate::metrics::AwaMetrics,
45 cancel: CancellationToken,
46 leader: Arc<AtomicBool>,
47 alive: Arc<AtomicBool>,
48 periodic_jobs: Arc<Vec<PeriodicJob>>,
49 in_flight: InFlightMap,
52 storage: RuntimeStorage,
53 heartbeat_rescue_interval: Duration,
54 deadline_rescue_interval: Duration,
55 callback_rescue_interval: Duration,
56 promote_interval: Duration,
57 cleanup_interval: Duration,
58 cron_sync_interval: Duration,
59 cron_eval_interval: Duration,
60 leader_check_interval: Duration,
61 leader_election_interval: Duration,
62 heartbeat_staleness: Duration,
63 completed_retention: Duration,
64 failed_retention: Duration,
65 cleanup_batch_size: i64,
66 queue_retention_overrides: HashMap<String, RetentionPolicy>,
67 queue_stats_interval: Duration,
68 dlq_retention: Duration,
69 dlq_cleanup_batch_size: i64,
70 dlq_policy: DlqPolicy,
71 dirty_key_recompute_interval: Duration,
72 metadata_reconciliation_interval: Duration,
73 priority_aging_interval: Duration,
76 descriptor_retention: Duration,
80}
81
82const PROMOTE_BATCH_SIZE: i64 = 4_096;
83const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
84type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
85
86impl MaintenanceService {
87 #[allow(clippy::too_many_arguments)]
88 pub(crate) fn new(
89 pool: PgPool,
90 metrics: crate::metrics::AwaMetrics,
91 leader: Arc<AtomicBool>,
92 alive: Arc<AtomicBool>,
93 cancel: CancellationToken,
94 periodic_jobs: Arc<Vec<PeriodicJob>>,
95 in_flight: InFlightMap,
96 storage: RuntimeStorage,
97 ) -> Self {
98 Self {
99 pool,
100 metrics,
101 cancel,
102 leader,
103 alive,
104 periodic_jobs,
105 in_flight,
106 storage,
107 heartbeat_rescue_interval: Duration::from_secs(30),
108 deadline_rescue_interval: Duration::from_secs(30),
109 callback_rescue_interval: Duration::from_secs(30),
110 promote_interval: Duration::from_millis(250),
111 cleanup_interval: Duration::from_secs(60),
112 cron_sync_interval: Duration::from_secs(60),
113 cron_eval_interval: Duration::from_secs(1),
114 leader_check_interval: Duration::from_secs(30),
115 leader_election_interval: Duration::from_secs(10),
116 heartbeat_staleness: Duration::from_secs(90),
117 completed_retention: Duration::from_secs(86400), failed_retention: Duration::from_secs(259200), cleanup_batch_size: 1000,
120 queue_retention_overrides: HashMap::new(),
121 queue_stats_interval: Duration::from_secs(30),
122 dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
123 dlq_cleanup_batch_size: 1000,
124 dlq_policy: DlqPolicy::default(),
125 dirty_key_recompute_interval: Duration::from_secs(2),
126 metadata_reconciliation_interval: Duration::from_secs(60),
127 priority_aging_interval: Duration::from_secs(60),
128 descriptor_retention: Duration::from_secs(30 * 86400), }
130 }
131
132 pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
137 self.priority_aging_interval = interval;
138 self
139 }
140
141 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
150 self.descriptor_retention = retention;
151 self
152 }
153
154 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
159 self.leader_election_interval = interval;
160 self
161 }
162
163 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
165 self.leader_check_interval = interval;
166 self
167 }
168
169 pub fn promote_interval(mut self, interval: Duration) -> Self {
171 self.promote_interval = interval;
172 self
173 }
174
175 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
177 self.heartbeat_rescue_interval = interval;
178 self
179 }
180
181 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
183 self.deadline_rescue_interval = interval;
184 self
185 }
186
187 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
189 self.callback_rescue_interval = interval;
190 self
191 }
192
193 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
199 self.heartbeat_staleness = staleness;
200 self
201 }
202
203 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
205 self.cleanup_interval = interval;
206 self
207 }
208
209 pub fn completed_retention(mut self, retention: Duration) -> Self {
211 self.completed_retention = retention;
212 self
213 }
214
215 pub fn failed_retention(mut self, retention: Duration) -> Self {
217 self.failed_retention = retention;
218 self
219 }
220
221 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
223 self.cleanup_batch_size = batch_size;
224 self
225 }
226
227 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
229 self.queue_stats_interval = interval;
230 self
231 }
232
233 pub fn dlq_retention(mut self, retention: Duration) -> Self {
235 self.dlq_retention = retention;
236 self
237 }
238
239 pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
241 self.dlq_cleanup_batch_size = batch_size;
242 self
243 }
244
245 pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
247 self.dlq_policy = policy;
248 self
249 }
250
251 pub fn queue_retention_overrides(
253 mut self,
254 overrides: HashMap<String, RetentionPolicy>,
255 ) -> Self {
256 self.queue_retention_overrides = overrides;
257 self
258 }
259
260 pub async fn run(&self) {
262 info!("Maintenance service starting");
263 self.alive.store(true, Ordering::SeqCst);
264 let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
265 self.leader.store(false, Ordering::SeqCst);
266
267 loop {
268 let mut leader_conn = match self.try_become_leader().await {
271 Ok(Some(conn)) => conn,
272 Ok(None) => {
273 tokio::select! {
275 _ = self.cancel.cancelled() => {
276 debug!("Maintenance service shutting down (not leader)");
277 self.leader.store(false, Ordering::SeqCst);
278 return;
279 }
280 _ = tokio::time::sleep(self.leader_election_interval) => continue,
281 }
282 }
283 Err(err) => {
284 warn!(error = %err, "Failed to check leader status");
285 tokio::select! {
286 _ = self.cancel.cancelled() => {
287 debug!("Maintenance service shutting down (leader check failed)");
288 self.leader.store(false, Ordering::SeqCst);
289 return;
290 }
291 _ = tokio::time::sleep(self.leader_election_interval) => continue,
292 }
293 }
294 };
295
296 debug!("Elected as maintenance leader");
297 self.leader.store(true, Ordering::SeqCst);
298
299 let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
301 let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
302 let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
303 let mut promote_timer = tokio::time::interval(self.promote_interval);
304 let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
305 let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
306 let mut cron_eval_timer = tokio::time::interval(self.cron_eval_interval);
307 let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
308 let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
309 let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
310 let mut metadata_reconciliation_timer =
311 tokio::time::interval(self.metadata_reconciliation_interval);
312 let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
313 let mut vacuum_queue_timer = self
314 .storage
315 .queue_storage()
316 .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
317 let mut vacuum_lease_timer = self
318 .storage
319 .queue_storage()
320 .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
321 let mut vacuum_claim_timer = self
322 .storage
323 .queue_storage()
324 .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
325
326 heartbeat_rescue_timer.tick().await;
328 deadline_rescue_timer.tick().await;
329 callback_rescue_timer.tick().await;
330 promote_timer.tick().await;
331 cleanup_timer.tick().await;
332 cron_sync_timer.tick().await;
333 cron_eval_timer.tick().await;
334 leader_check_timer.tick().await;
335 queue_stats_timer.tick().await;
336 dirty_key_timer.tick().await;
337 metadata_reconciliation_timer.tick().await;
338 priority_aging_timer.tick().await;
339 if let Some(timer) = &mut vacuum_queue_timer {
340 timer.tick().await;
341 }
342 if let Some(timer) = &mut vacuum_lease_timer {
343 timer.tick().await;
344 }
345 if let Some(timer) = &mut vacuum_claim_timer {
346 timer.tick().await;
347 }
348
349 self.sync_periodic_jobs_to_db().await;
351
352 loop {
353 tokio::select! {
354 _ = self.cancel.cancelled() => {
355 debug!("Maintenance service shutting down");
356 self.leader.store(false, Ordering::SeqCst);
357 let _ = Self::release_leader(&mut leader_conn).await;
360 return;
361 }
362 _ = heartbeat_rescue_timer.tick() => {
363 self.rescue_stale_heartbeats().await;
364 }
365 _ = deadline_rescue_timer.tick() => {
366 self.rescue_expired_deadlines().await;
367 }
368 _ = callback_rescue_timer.tick() => {
369 self.rescue_expired_callbacks().await;
370 }
371 _ = promote_timer.tick() => {
372 self.promote_scheduled().await;
373 }
374 _ = cleanup_timer.tick() => {
375 self.cleanup_completed().await;
376 self.cleanup_dlq_rows().await;
377 self.cleanup_stale_runtime_snapshots().await;
378 self.cleanup_stale_descriptors().await;
379 }
380 _ = cron_sync_timer.tick() => {
381 self.sync_periodic_jobs_to_db().await;
382 }
383 _ = cron_eval_timer.tick() => {
384 self.evaluate_cron_schedules().await;
385 }
386 _ = queue_stats_timer.tick() => {
387 self.publish_queue_health_metrics().await;
388 }
389 _ = dirty_key_timer.tick() => {
390 self.recompute_dirty_admin_metadata().await;
391 }
392 _ = metadata_reconciliation_timer.tick() => {
393 self.refresh_admin_metadata().await;
394 }
395 _ = priority_aging_timer.tick() => {
396 self.age_waiting_priorities().await;
397 }
398 _ = async {
399 if let Some(timer) = &mut vacuum_queue_timer {
400 timer.tick().await;
401 } else {
402 std::future::pending::<()>().await;
403 }
404 }, if vacuum_queue_timer.is_some() => {
405 self.rotate_queue_storage_queue().await;
406 }
407 _ = async {
408 if let Some(timer) = &mut vacuum_lease_timer {
409 timer.tick().await;
410 } else {
411 std::future::pending::<()>().await;
412 }
413 }, if vacuum_lease_timer.is_some() => {
414 self.rotate_queue_storage_leases().await;
415 }
416 _ = async {
417 if let Some(timer) = &mut vacuum_claim_timer {
418 timer.tick().await;
419 } else {
420 std::future::pending::<()>().await;
421 }
422 }, if vacuum_claim_timer.is_some() => {
423 self.rotate_queue_storage_claims().await;
424 }
425 _ = leader_check_timer.tick() => {
426 if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
430 warn!("Leader connection lost, re-entering election loop");
431 self.leader.store(false, Ordering::SeqCst);
432 break;
433 }
434 }
435 }
436 }
437 }
438 }
439
440 const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
449 let mut conn = self.pool.acquire().await?;
450 let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
451 .bind(Self::LOCK_KEY)
452 .fetch_one(&mut *conn)
453 .await?;
454 if result.0 {
455 Ok(Some(conn))
456 } else {
457 Ok(None)
458 }
459 }
460
461 async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
466 sqlx::query("SELECT pg_advisory_unlock($1)")
467 .bind(Self::LOCK_KEY)
468 .execute(&mut **conn)
469 .await?;
470 Ok(())
471 }
472
473 #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
477 async fn sync_periodic_jobs_to_db(&self) {
478 if self.periodic_jobs.is_empty() {
479 return;
480 }
481
482 for job in self.periodic_jobs.iter() {
483 if let Err(err) = upsert_cron_job(&self.pool, job).await {
484 error!(name = %job.name, error = %err, "Failed to sync periodic job");
485 }
486 }
487
488 debug!(
489 count = self.periodic_jobs.len(),
490 "Synced periodic jobs to database"
491 );
492 }
493
494 #[tracing::instrument(skip(self), name = "maintenance.cron_eval")]
500 async fn evaluate_cron_schedules(&self) {
501 let cron_rows = match list_cron_jobs(&self.pool).await {
502 Ok(rows) => rows,
503 Err(err) => {
504 error!(error = %err, "Failed to load cron jobs for evaluation");
505 return;
506 }
507 };
508
509 if cron_rows.is_empty() {
510 return;
511 }
512
513 let now = Utc::now();
514
515 for row in &cron_rows {
516 let fire_time = match compute_fire_time(row, now) {
517 Some(time) => time,
518 None => continue,
519 };
520
521 match atomic_enqueue(&self.pool, &row.name, fire_time, row.last_enqueued_at).await {
522 Ok(Some(job)) => {
523 info!(
524 cron_name = %row.name,
525 job_id = job.id,
526 fire_time = %fire_time,
527 "Enqueued periodic job"
528 );
529 }
530 Ok(None) => {
531 debug!(cron_name = %row.name, "Cron fire already claimed");
533 }
534 Err(err) => {
535 error!(
536 cron_name = %row.name,
537 error = %err,
538 "Failed to enqueue periodic job"
539 );
540 }
541 }
542 }
543 }
544
545 #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
547 async fn rescue_stale_heartbeats(&self) {
548 let outcome = match &self.storage {
549 RuntimeStorage::Canonical => {
550 let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
551 sqlx::query_as::<_, JobRow>(
552 r#"
553 UPDATE awa.jobs
554 SET state = 'retryable',
555 finalized_at = now(),
556 heartbeat_at = NULL,
557 deadline_at = NULL,
558 callback_id = NULL,
559 callback_timeout_at = NULL,
560 callback_filter = NULL,
561 callback_on_complete = NULL,
562 callback_on_fail = NULL,
563 callback_transform = NULL,
564 errors = errors || jsonb_build_object(
565 'error', 'heartbeat stale: worker presumed dead',
566 'attempt', attempt,
567 'at', now()
568 )::jsonb
569 WHERE id IN (
570 SELECT id FROM awa.jobs_hot
571 WHERE state = 'running'
572 AND heartbeat_at < now() - ($1 * interval '1 millisecond')
573 LIMIT 500
574 FOR UPDATE SKIP LOCKED
575 )
576 RETURNING *
577 "#,
578 )
579 .bind(staleness_ms)
580 .fetch_all(&self.pool)
581 .await
582 .map_err(awa_model::AwaError::Database)
583 }
584 RuntimeStorage::QueueStorage(runtime) => {
585 runtime
586 .store
587 .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
588 .await
589 }
590 };
591 match outcome {
592 Ok(rescued) if !rescued.is_empty() => {
593 self.metrics.maintenance_rescues.add(
594 rescued.len() as u64,
595 &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
596 );
597 warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
598 self.signal_cancellation(&rescued).await;
600 }
601 Err(err) => {
602 error!(error = %err, "Failed to rescue stale heartbeat jobs");
603 }
604 _ => {}
605 }
606 }
607
608 #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
610 async fn rescue_expired_deadlines(&self) {
611 let outcome = match &self.storage {
612 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
613 r#"
614 UPDATE awa.jobs
615 SET state = 'retryable',
616 finalized_at = now(),
617 heartbeat_at = NULL,
618 deadline_at = NULL,
619 callback_id = NULL,
620 callback_timeout_at = NULL,
621 callback_filter = NULL,
622 callback_on_complete = NULL,
623 callback_on_fail = NULL,
624 callback_transform = NULL,
625 errors = errors || jsonb_build_object(
626 'error', 'hard deadline exceeded',
627 'attempt', attempt,
628 'at', now()
629 )::jsonb
630 WHERE id IN (
631 SELECT id FROM awa.jobs_hot
632 WHERE state = 'running'
633 AND deadline_at IS NOT NULL
634 AND deadline_at < now()
635 LIMIT 500
636 FOR UPDATE SKIP LOCKED
637 )
638 RETURNING *
639 "#,
640 )
641 .fetch_all(&self.pool)
642 .await
643 .map_err(awa_model::AwaError::Database),
644 RuntimeStorage::QueueStorage(runtime) => {
645 runtime.store.rescue_expired_deadlines(&self.pool).await
646 }
647 };
648 match outcome {
649 Ok(rescued) if !rescued.is_empty() => {
650 self.metrics.maintenance_rescues.add(
651 rescued.len() as u64,
652 &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
653 );
654 warn!(count = rescued.len(), "Rescued deadline-expired jobs");
655 self.signal_cancellation(&rescued).await;
657 }
658 Err(err) => {
659 error!(error = %err, "Failed to rescue deadline-expired jobs");
660 }
661 _ => {}
662 }
663 }
664
665 #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
667 async fn rescue_expired_callbacks(&self) {
668 let outcome = match &self.storage {
669 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
670 r#"
671 UPDATE awa.jobs
672 SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
673 finalized_at = now(),
674 callback_id = NULL,
675 callback_timeout_at = NULL,
676 callback_filter = NULL,
677 callback_on_complete = NULL,
678 callback_on_fail = NULL,
679 callback_transform = NULL,
680 run_at = CASE WHEN attempt >= max_attempts THEN run_at
681 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
682 errors = errors || jsonb_build_object(
683 'error', 'callback timed out',
684 'attempt', attempt,
685 'at', now()
686 )::jsonb
687 WHERE id IN (
688 SELECT id FROM awa.jobs_hot
689 WHERE state = 'waiting_external'
690 AND callback_timeout_at IS NOT NULL
691 AND callback_timeout_at < now()
692 LIMIT 500
693 FOR UPDATE SKIP LOCKED
694 )
695 RETURNING *
696 "#,
697 )
698 .fetch_all(&self.pool)
699 .await
700 .map_err(awa_model::AwaError::Database),
701 RuntimeStorage::QueueStorage(runtime) => {
702 runtime.store.rescue_expired_callbacks(&self.pool).await
703 }
704 };
705 match outcome {
706 Ok(rescued) if !rescued.is_empty() => {
707 self.metrics.maintenance_rescues.add(
708 rescued.len() as u64,
709 &[opentelemetry::KeyValue::new(
710 "awa.rescue.kind",
711 "callback_timeout",
712 )],
713 );
714 warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
715 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
716 for job in &rescued {
717 if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
718 {
719 continue;
720 }
721 match runtime
722 .store
723 .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
724 .await
725 {
726 Ok(Some(_)) => {
727 self.metrics.record_dlq_moved(
728 &job.kind,
729 &job.queue,
730 "callback_timeout",
731 );
732 }
733 Ok(None) => {}
734 Err(err) => {
735 error!(
736 job_id = job.id,
737 error = %err,
738 "Failed to move rescued callback timeout into DLQ"
739 );
740 }
741 }
742 }
743 }
744 }
745 Err(err) => {
746 error!(error = %err, "Failed to rescue callback-timed-out jobs");
747 }
748 _ => {}
749 }
750 }
751
752 #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
759 async fn age_waiting_priorities(&self) {
760 let aging_secs = self.priority_aging_interval.as_secs_f64();
761 if aging_secs <= 0.0 {
762 return;
763 }
764 if let Some(runtime) = self.storage.queue_storage() {
765 debug!(
766 schema = %runtime.store.schema(),
767 "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
768 );
769 return;
770 }
771
772 match sqlx::query_scalar::<_, i64>(
773 r#"
774 WITH eligible AS (
775 SELECT id FROM awa.jobs_hot
776 WHERE state = 'available'
777 AND priority > 1
778 AND run_at <= now() - make_interval(secs => $1)
779 LIMIT 1000
780 FOR UPDATE SKIP LOCKED
781 )
782 UPDATE awa.jobs_hot
783 SET priority = priority - 1,
784 metadata = CASE
785 WHEN NOT (metadata ? '_awa_original_priority')
786 THEN metadata || jsonb_build_object('_awa_original_priority', priority)
787 ELSE metadata
788 END
789 FROM eligible
790 WHERE awa.jobs_hot.id = eligible.id
791 RETURNING awa.jobs_hot.id
792 "#,
793 )
794 .bind(aging_secs)
795 .fetch_all(&self.pool)
796 .await
797 {
798 Ok(ids) if !ids.is_empty() => {
799 debug!(count = ids.len(), "Aged job priorities");
800 }
801 Err(err) => {
802 error!(error = %err, "Failed to age job priorities");
803 }
804 _ => {}
805 }
806 }
807
808 async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
810 for job in rescued_jobs {
811 if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
812 flag.store(true, Ordering::SeqCst);
813 debug!(job_id = job.id, "Signalled cancellation for rescued job");
814 }
815 }
816 }
817
818 #[tracing::instrument(skip(self), name = "maintenance.promote")]
820 async fn promote_scheduled(&self) {
821 if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
822 error!(error = %err, "Failed to promote scheduled jobs");
823 }
824 if let Err(err) = self
825 .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
826 .await
827 {
828 error!(error = %err, "Failed to promote retryable jobs");
829 }
830 }
831
832 async fn promote_due_state(
833 &self,
834 state: &'static str,
835 label: &'static str,
836 ) -> Result<(), awa_model::AwaError> {
837 let mut promoted_total = 0usize;
838 let mut notified_queues = HashSet::new();
839
840 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
841 if self.cancel.is_cancelled() {
842 break;
843 }
844
845 match &self.storage {
846 RuntimeStorage::Canonical => {
847 let (promoted, queues) = self
848 .promote_due_batch(state)
849 .await
850 .map_err(awa_model::AwaError::Database)?;
851 if promoted == 0 {
852 break;
853 }
854
855 promoted_total += promoted;
856 notified_queues.extend(queues);
857
858 if promoted < PROMOTE_BATCH_SIZE as usize {
859 break;
860 }
861 }
862 RuntimeStorage::QueueStorage(runtime) => {
863 let job_state = match state {
864 "scheduled" => awa_model::JobState::Scheduled,
865 "retryable" => awa_model::JobState::Retryable,
866 other => {
867 return Err(awa_model::AwaError::Validation(format!(
868 "unsupported queue storage promote state: {other}"
869 )));
870 }
871 };
872 let promote_start = std::time::Instant::now();
873 let promoted = runtime
874 .store
875 .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
876 .await?;
877 self.metrics.record_promotion_batch(
878 state,
879 promoted as u64,
880 promote_start.elapsed(),
881 );
882 if promoted == 0 {
883 break;
884 }
885
886 promoted_total += promoted;
887
888 if promoted < PROMOTE_BATCH_SIZE as usize {
889 break;
890 }
891 }
892 }
893 }
894
895 if promoted_total > 0 {
896 debug!(
897 count = promoted_total,
898 queues = notified_queues.len(),
899 state,
900 "Promoted {label}"
901 );
902 }
903
904 Ok(())
905 }
906
907 fn promote_sql(state: &'static str) -> String {
913 format!(
914 r#"
915 WITH due AS (
916 DELETE FROM awa.scheduled_jobs
917 WHERE id IN (
918 SELECT id
919 FROM awa.scheduled_jobs
920 WHERE state = '{state}'::awa.job_state
921 AND run_at <= now()
922 ORDER BY run_at ASC, id ASC
923 LIMIT $1
924 FOR UPDATE SKIP LOCKED
925 )
926 RETURNING *
927 ),
928 promoted AS (
929 INSERT INTO awa.jobs_hot (
930 id, kind, queue, args, state, priority, attempt, max_attempts,
931 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
932 created_at, errors, metadata, tags, unique_key, unique_states,
933 callback_id, callback_timeout_at, callback_filter, callback_on_complete,
934 callback_on_fail, callback_transform, run_lease, progress
935 )
936 SELECT
937 id,
938 kind,
939 queue,
940 args,
941 'available'::awa.job_state,
942 priority,
943 attempt,
944 max_attempts,
945 now(),
946 NULL,
947 NULL,
948 attempted_at,
949 finalized_at,
950 created_at,
951 errors,
952 metadata,
953 tags,
954 unique_key,
955 unique_states,
956 NULL,
957 NULL,
958 NULL,
959 NULL,
960 NULL,
961 NULL,
962 run_lease,
963 progress
964 FROM due
965 RETURNING queue
966 )
967 SELECT queue FROM promoted
968 "#
969 )
970 }
971
972 async fn promote_due_batch(
973 &self,
974 state: &'static str,
975 ) -> Result<(usize, HashSet<String>), sqlx::Error> {
976 let mut tx = self.pool.begin().await?;
977 let promote_start = std::time::Instant::now();
978 let sql = Self::promote_sql(state);
979 let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
980 .bind(PROMOTE_BATCH_SIZE)
981 .fetch_all(&mut *tx)
982 .await?;
983
984 let promoted = promoted_rows.len();
985 self.metrics
986 .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
987 if promoted == 0 {
988 tx.commit().await?;
989 return Ok((0, HashSet::new()));
990 }
991
992 let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
993
994 tx.commit().await?;
995 Ok((promoted, queues))
996 }
997
998 async fn rotate_queue_storage_queue(&self) {
999 let Some(runtime) = self.storage.queue_storage() else {
1000 return;
1001 };
1002
1003 match runtime.store.rotate(&self.pool).await {
1004 Ok(outcome) => {
1005 self.metrics.record_rotate_outcome("queue", &outcome);
1006 match outcome {
1007 RotateOutcome::Rotated { slot, generation } => {
1008 debug!(slot, generation, "Rotated queue storage queue segment");
1009 }
1010 RotateOutcome::SkippedBusy { slot, busy } => {
1011 debug!(
1012 slot,
1013 ready_rows = busy.queue_ready,
1014 done_rows = busy.queue_done,
1015 "Skipped busy queue storage queue segment",
1016 );
1017 }
1018 }
1019 }
1020 Err(err) => {
1021 error!(error = %err, "Failed to rotate queue storage queue segments");
1022 return;
1023 }
1024 }
1025
1026 match runtime.store.prune_oldest(&self.pool).await {
1027 Ok(outcome) => {
1028 self.metrics.record_prune_outcome("queue", &outcome);
1029 match outcome {
1030 PruneOutcome::Noop => {}
1031 PruneOutcome::Pruned { slot } => {
1032 debug!(slot, "Pruned queue storage queue segment");
1033 }
1034 PruneOutcome::Blocked { slot } => {
1035 debug!(slot, "Queue storage queue segment prune blocked");
1036 }
1037 PruneOutcome::SkippedActive {
1038 slot,
1039 reason,
1040 count,
1041 } => {
1042 debug!(
1043 slot,
1044 reason = reason.as_str(),
1045 count,
1046 "Queue storage queue segment still active",
1047 );
1048 }
1049 }
1050 }
1051 Err(err) => {
1052 error!(error = %err, "Failed to prune queue storage queue segments");
1053 }
1054 }
1055 }
1056
1057 async fn rotate_queue_storage_leases(&self) {
1058 let Some(runtime) = self.storage.queue_storage() else {
1059 return;
1060 };
1061
1062 match runtime.store.rotate_leases(&self.pool).await {
1063 Ok(outcome) => {
1064 self.metrics.record_rotate_outcome("lease", &outcome);
1065 match outcome {
1066 RotateOutcome::Rotated { slot, generation } => {
1067 debug!(slot, generation, "Rotated queue storage lease segment");
1068 }
1069 RotateOutcome::SkippedBusy { slot, busy } => {
1070 debug!(
1071 slot,
1072 lease_rows = busy.leases,
1073 "Skipped busy queue storage lease segment",
1074 );
1075 }
1076 }
1077 }
1078 Err(err) => {
1079 error!(error = %err, "Failed to rotate queue storage lease segments");
1080 return;
1081 }
1082 }
1083
1084 match runtime.store.prune_oldest_leases(&self.pool).await {
1085 Ok(outcome) => {
1086 self.metrics.record_prune_outcome("lease", &outcome);
1087 match outcome {
1088 PruneOutcome::Noop => {}
1089 PruneOutcome::Pruned { slot } => {
1090 debug!(slot, "Pruned queue storage lease segment");
1091 }
1092 PruneOutcome::Blocked { slot } => {
1093 debug!(slot, "Queue storage lease segment prune blocked");
1094 }
1095 PruneOutcome::SkippedActive {
1096 slot,
1097 reason,
1098 count,
1099 } => {
1100 debug!(
1101 slot,
1102 reason = reason.as_str(),
1103 count,
1104 "Queue storage lease segment still active",
1105 );
1106 }
1107 }
1108 }
1109 Err(err) => {
1110 error!(error = %err, "Failed to prune queue storage lease segments");
1111 }
1112 }
1113 }
1114
1115 async fn rotate_queue_storage_claims(&self) {
1119 let Some(runtime) = self.storage.queue_storage() else {
1120 return;
1121 };
1122
1123 match runtime.store.rotate_claims(&self.pool).await {
1124 Ok(outcome) => {
1125 self.metrics.record_rotate_outcome("claim", &outcome);
1126 match outcome {
1127 RotateOutcome::Rotated { slot, generation } => {
1128 debug!(slot, generation, "Rotated queue storage claim segment");
1129 }
1130 RotateOutcome::SkippedBusy { slot, busy } => {
1131 debug!(
1132 slot,
1133 claim_rows = busy.claims,
1134 closure_rows = busy.closures,
1135 "Skipped busy queue storage claim segment",
1136 );
1137 }
1138 }
1139 }
1140 Err(err) => {
1141 error!(error = %err, "Failed to rotate queue storage claim segments");
1142 return;
1143 }
1144 }
1145
1146 match runtime.store.prune_oldest_claims(&self.pool).await {
1147 Ok(outcome) => {
1148 self.metrics.record_prune_outcome("claim", &outcome);
1149 match outcome {
1150 PruneOutcome::Noop => {}
1151 PruneOutcome::Pruned { slot } => {
1152 debug!(slot, "Pruned queue storage claim segment");
1153 }
1154 PruneOutcome::Blocked { slot } => {
1155 debug!(slot, "Queue storage claim segment prune blocked");
1156 }
1157 PruneOutcome::SkippedActive {
1158 slot,
1159 reason,
1160 count,
1161 } => {
1162 debug!(
1163 slot,
1164 reason = reason.as_str(),
1165 count,
1166 "Queue storage claim segment still active",
1167 );
1168 }
1169 }
1170 }
1171 Err(err) => {
1172 error!(error = %err, "Failed to prune queue storage claim segments");
1173 }
1174 }
1175 }
1176
1177 #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
1184 async fn cleanup_completed(&self) {
1185 if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
1186 return;
1188 }
1189
1190 let mut total_deleted: u64 = 0;
1191
1192 let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
1194
1195 let completed_retention_secs =
1197 i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
1198 let failed_retention_secs =
1199 i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
1200
1201 let global_result = if override_queues.is_empty() {
1202 sqlx::query(
1203 r#"
1204 DELETE FROM awa.jobs_hot
1205 WHERE id IN (
1206 SELECT id FROM awa.jobs_hot
1207 WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1208 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
1209 LIMIT $3
1210 )
1211 "#,
1212 )
1213 .bind(completed_retention_secs)
1214 .bind(failed_retention_secs)
1215 .bind(self.cleanup_batch_size)
1216 .execute(&self.pool)
1217 .await
1218 } else {
1219 sqlx::query(
1220 r#"
1221 DELETE FROM awa.jobs_hot
1222 WHERE id IN (
1223 SELECT id FROM awa.jobs_hot
1224 WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1225 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
1226 AND queue != ALL($4::text[])
1227 LIMIT $3
1228 )
1229 "#,
1230 )
1231 .bind(completed_retention_secs)
1232 .bind(failed_retention_secs)
1233 .bind(self.cleanup_batch_size)
1234 .bind(&override_queues)
1235 .execute(&self.pool)
1236 .await
1237 };
1238
1239 match global_result {
1240 Ok(result) if result.rows_affected() > 0 => {
1241 total_deleted += result.rows_affected();
1242 }
1243 Err(err) => {
1244 error!(error = %err, "Failed to clean up old jobs (global pass)");
1245 }
1246 _ => {}
1247 }
1248
1249 for (queue_name, policy) in &self.queue_retention_overrides {
1251 let queue_completed_secs =
1252 i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
1253 let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
1254
1255 match sqlx::query(
1256 r#"
1257 DELETE FROM awa.jobs_hot
1258 WHERE id IN (
1259 SELECT id FROM awa.jobs_hot
1260 WHERE queue = $4
1261 AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1262 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
1263 LIMIT $3
1264 )
1265 "#,
1266 )
1267 .bind(queue_completed_secs)
1268 .bind(queue_failed_secs)
1269 .bind(self.cleanup_batch_size)
1270 .bind(queue_name)
1271 .execute(&self.pool)
1272 .await
1273 {
1274 Ok(result) if result.rows_affected() > 0 => {
1275 total_deleted += result.rows_affected();
1276 debug!(
1277 queue = %queue_name,
1278 count = result.rows_affected(),
1279 "Cleaned up old jobs (queue override)"
1280 );
1281 }
1282 Err(err) => {
1283 error!(
1284 queue = %queue_name,
1285 error = %err,
1286 "Failed to clean up old jobs (queue override)"
1287 );
1288 }
1289 _ => {}
1290 }
1291 }
1292
1293 if total_deleted > 0 {
1294 info!(count = total_deleted, "Cleaned up old jobs");
1295 }
1296 }
1297
1298 #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
1299 async fn cleanup_dlq_rows(&self) {
1300 let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
1301 return;
1302 };
1303
1304 let schema = runtime.store.schema();
1305 let override_queues: Vec<&str> = self
1306 .queue_retention_overrides
1307 .iter()
1308 .filter(|(_, policy)| policy.dlq.is_some())
1309 .map(|(queue, _)| queue.as_str())
1310 .collect();
1311 let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
1312
1313 let global_result = if override_queues.is_empty() {
1314 sqlx::query(&format!(
1315 r#"
1316 DELETE FROM {schema}.dlq_entries
1317 WHERE job_id IN (
1318 SELECT job_id FROM {schema}.dlq_entries
1319 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
1320 LIMIT $2
1321 )
1322 "#
1323 ))
1324 .bind(retention_secs)
1325 .bind(self.dlq_cleanup_batch_size)
1326 .execute(&self.pool)
1327 .await
1328 } else {
1329 sqlx::query(&format!(
1330 r#"
1331 DELETE FROM {schema}.dlq_entries
1332 WHERE job_id IN (
1333 SELECT job_id FROM {schema}.dlq_entries
1334 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
1335 AND queue != ALL($3::text[])
1336 LIMIT $2
1337 )
1338 "#
1339 ))
1340 .bind(retention_secs)
1341 .bind(self.dlq_cleanup_batch_size)
1342 .bind(&override_queues)
1343 .execute(&self.pool)
1344 .await
1345 };
1346
1347 match global_result {
1348 Ok(result) if result.rows_affected() > 0 => {
1349 self.metrics.record_dlq_purged(None, result.rows_affected());
1350 }
1351 Err(err) => {
1352 error!(error = %err, "Failed to clean up DLQ rows (global pass)");
1353 }
1354 _ => {}
1355 }
1356
1357 for (queue, policy) in &self.queue_retention_overrides {
1358 let Some(retention) = policy.dlq else {
1359 continue;
1360 };
1361 let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
1362 match sqlx::query(&format!(
1363 r#"
1364 DELETE FROM {schema}.dlq_entries
1365 WHERE job_id IN (
1366 SELECT job_id FROM {schema}.dlq_entries
1367 WHERE queue = $3
1368 AND dlq_at < now() - make_interval(secs => $1::bigint)
1369 LIMIT $2
1370 )
1371 "#
1372 ))
1373 .bind(retention_secs)
1374 .bind(self.dlq_cleanup_batch_size)
1375 .bind(queue)
1376 .execute(&self.pool)
1377 .await
1378 {
1379 Ok(result) if result.rows_affected() > 0 => {
1380 self.metrics
1381 .record_dlq_purged(Some(queue), result.rows_affected());
1382 }
1383 Err(err) => {
1384 error!(queue, error = %err, "Failed to clean up DLQ rows");
1385 }
1386 _ => {}
1387 }
1388 }
1389 }
1390}
1391
1392struct MaintenanceAliveGuard(Arc<AtomicBool>);
1393
1394impl Drop for MaintenanceAliveGuard {
1395 fn drop(&mut self) {
1396 self.0.store(false, Ordering::SeqCst);
1397 }
1398}
1399
1400fn compute_fire_time(
1404 row: &CronJobRow,
1405 now: chrono::DateTime<Utc>,
1406) -> Option<chrono::DateTime<Utc>> {
1407 let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
1408 Ok(c) => c,
1409 Err(err) => {
1410 error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
1411 return None;
1412 }
1413 };
1414
1415 let tz: chrono_tz::Tz = match row.timezone.parse() {
1416 Ok(tz) => tz,
1417 Err(err) => {
1418 error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
1419 return None;
1420 }
1421 };
1422
1423 let search_start = match row.last_enqueued_at {
1424 Some(last) => last.with_timezone(&tz),
1425 None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
1430 };
1431
1432 let mut latest_fire: Option<chrono::DateTime<Utc>> = None;
1433
1434 for fire_time in cron.iter_from(search_start) {
1435 let fire_utc = fire_time.with_timezone(&Utc);
1436
1437 if fire_utc > now {
1438 break;
1439 }
1440
1441 if let Some(last) = row.last_enqueued_at {
1442 if fire_utc <= last {
1443 continue;
1444 }
1445 }
1446
1447 latest_fire = Some(fire_utc);
1448 }
1449
1450 latest_fire
1451}
1452
1453impl MaintenanceService {
1454 #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
1457 async fn cleanup_stale_runtime_snapshots(&self) {
1458 if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
1459 &self.pool,
1460 chrono::TimeDelta::try_hours(24).unwrap(),
1461 )
1462 .await
1463 {
1464 tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
1465 }
1466 }
1467
1468 #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
1473 async fn cleanup_stale_descriptors(&self) {
1474 if self.descriptor_retention.is_zero() {
1475 return;
1476 }
1477 let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
1478 .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
1479 for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
1480 match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
1481 Ok(deleted) if deleted > 0 => {
1482 tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
1483 }
1484 Ok(_) => {}
1485 Err(err) => {
1486 tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
1487 }
1488 }
1489 }
1490 }
1491
1492 #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
1496 async fn recompute_dirty_admin_metadata(&self) {
1497 if self.storage.queue_storage().is_some() {
1498 return;
1499 }
1500 match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
1501 Ok(count) if count > 0 => {
1502 tracing::debug!(count, "Recomputed dirty admin metadata keys");
1503 }
1504 Err(err) => {
1505 tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
1506 }
1507 _ => {}
1508 }
1509 }
1510
1511 #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
1514 async fn refresh_admin_metadata(&self) {
1515 if self.storage.queue_storage().is_some() {
1516 return;
1517 }
1518 if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
1519 tracing::warn!(error = %err, "Failed to refresh admin metadata");
1520 }
1521 }
1522
1523 #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
1525 async fn publish_queue_health_metrics(&self) {
1526 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
1527 self.publish_queue_storage_health_metrics(runtime).await;
1528 return;
1529 }
1530
1531 let stats = match awa_model::admin::queue_overviews(&self.pool).await {
1532 Ok(stats) => stats,
1533 Err(err) => {
1534 tracing::warn!(error = %err, "Failed to query queue stats for metrics");
1535 return;
1536 }
1537 };
1538
1539 for queue_stat in &stats {
1540 let queue = &queue_stat.queue;
1541
1542 self.metrics
1544 .record_queue_depth(queue, "available", queue_stat.available);
1545 self.metrics
1546 .record_queue_depth(queue, "running", queue_stat.running);
1547 self.metrics
1548 .record_queue_depth(queue, "failed", queue_stat.failed);
1549 self.metrics
1550 .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
1551 self.metrics
1552 .record_queue_depth(queue, "retryable", queue_stat.retryable);
1553 self.metrics
1554 .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
1555
1556 if let Some(lag_seconds) = queue_stat.lag_seconds {
1558 self.metrics.record_queue_lag(queue, lag_seconds);
1559 }
1560 }
1561 }
1562
1563 async fn publish_queue_storage_health_metrics(
1564 &self,
1565 runtime: &crate::storage::QueueStorageRuntime,
1566 ) {
1567 let schema = runtime.store.schema();
1568 let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
1569 r#"
1570 WITH queues AS (
1571 SELECT DISTINCT queue
1572 FROM (
1573 SELECT queue FROM awa.queue_meta
1574 UNION ALL
1575 SELECT queue FROM {schema}.ready_entries
1576 UNION ALL
1577 SELECT queue FROM {schema}.leases
1578 UNION ALL
1579 SELECT queue FROM {schema}.deferred_jobs
1580 UNION ALL
1581 SELECT queue FROM {schema}.done_entries
1582 UNION ALL
1583 SELECT queue FROM {schema}.dlq_entries
1584 ) queues
1585 ),
1586 ready AS (
1587 SELECT
1588 queue,
1589 count(*)::bigint AS available,
1590 EXTRACT(EPOCH FROM clock_timestamp() - min(run_at))::double precision
1591 AS lag_seconds
1592 FROM {schema}.ready_entries
1593 GROUP BY queue
1594 ),
1595 leases AS (
1596 SELECT
1597 queue,
1598 count(*) FILTER (WHERE state = 'running')::bigint AS running,
1599 count(*) FILTER (WHERE state = 'waiting_external')::bigint
1600 AS waiting_external
1601 FROM {schema}.leases
1602 GROUP BY queue
1603 ),
1604 deferred AS (
1605 SELECT
1606 queue,
1607 count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
1608 count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
1609 FROM {schema}.deferred_jobs
1610 GROUP BY queue
1611 ),
1612 terminal AS (
1613 SELECT
1614 queue,
1615 count(*) FILTER (WHERE state = 'failed')::bigint AS failed_done
1616 FROM {schema}.done_entries
1617 GROUP BY queue
1618 ),
1619 dlq AS (
1620 SELECT
1621 queue,
1622 count(*)::bigint AS failed_dlq
1623 FROM {schema}.dlq_entries
1624 GROUP BY queue
1625 )
1626 SELECT
1627 queues.queue,
1628 COALESCE(ready.available, 0)::bigint AS available,
1629 COALESCE(leases.running, 0)::bigint AS running,
1630 COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
1631 COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
1632 COALESCE(deferred.retryable, 0)::bigint AS retryable,
1633 COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
1634 COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
1635 ready.lag_seconds
1636 FROM queues
1637 LEFT JOIN ready
1638 ON ready.queue = queues.queue
1639 LEFT JOIN leases
1640 ON leases.queue = queues.queue
1641 LEFT JOIN deferred
1642 ON deferred.queue = queues.queue
1643 LEFT JOIN terminal
1644 ON terminal.queue = queues.queue
1645 LEFT JOIN dlq
1646 ON dlq.queue = queues.queue
1647 ORDER BY queues.queue
1648 "#
1649 ))
1650 .fetch_all(&self.pool)
1651 .await
1652 {
1653 Ok(rows) => rows,
1654 Err(err) => {
1655 tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
1656 return;
1657 }
1658 };
1659
1660 for (
1661 queue,
1662 available,
1663 running,
1664 waiting_external,
1665 scheduled,
1666 retryable,
1667 failed_done,
1668 failed_dlq,
1669 lag_seconds,
1670 ) in rows
1671 {
1672 self.metrics
1673 .record_queue_depth(&queue, "available", available);
1674 self.metrics.record_queue_depth(&queue, "running", running);
1675 self.metrics
1676 .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
1677 self.metrics
1678 .record_queue_depth(&queue, "scheduled", scheduled);
1679 self.metrics
1680 .record_queue_depth(&queue, "retryable", retryable);
1681 self.metrics
1682 .record_queue_depth(&queue, "waiting_external", waiting_external);
1683 self.metrics.record_dlq_depth(&queue, failed_dlq);
1684
1685 if let Some(lag_seconds) = lag_seconds {
1686 self.metrics.record_queue_lag(&queue, lag_seconds);
1687 }
1688 }
1689 }
1690}