1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::cron::{
5 atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow, CronMissedFirePolicy,
6};
7#[cfg(test)]
8use awa_model::SkipReason;
9use awa_model::{
10 JobRow, JobState, PeriodicJob, PruneOutcome, RotateOutcome, TerminalDeltaRollupOutcome,
11};
12use chrono::Utc;
13use croner::Cron;
14use sqlx::pool::PoolConnection;
15use sqlx::{PgPool, Postgres};
16use std::collections::{HashMap, HashSet};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::task::JoinHandle;
21use tokio_util::sync::CancellationToken;
22use tracing::{debug, error, info, warn};
23use uuid::Uuid;
24
25fn is_unique_claim_conflict(err: &awa_model::AwaError) -> bool {
28 match err {
29 awa_model::AwaError::Database(sqlx::Error::Database(db_err)) => {
30 db_err.code().as_deref() == Some("23505")
31 }
32 _ => false,
33 }
34}
35
36const HEARTBEAT_RESCUE_PER_ROW_SQL: &str = r#"
41 UPDATE awa.jobs
42 SET state = 'retryable',
43 finalized_at = now(),
44 heartbeat_at = NULL,
45 deadline_at = NULL,
46 callback_id = NULL,
47 callback_timeout_at = NULL,
48 callback_filter = NULL,
49 callback_on_complete = NULL,
50 callback_on_fail = NULL,
51 callback_transform = NULL,
52 errors = errors || jsonb_build_object(
53 'error', 'heartbeat stale: worker presumed dead',
54 'attempt', attempt,
55 'at', now()
56 )::jsonb
57 WHERE id = $1
58 AND state = 'running'
59 AND heartbeat_at < now() - ($2 * interval '1 millisecond')
60 RETURNING *
61"#;
62
63const DEADLINE_RESCUE_PER_ROW_SQL: &str = r#"
64 UPDATE awa.jobs
65 SET state = 'retryable',
66 finalized_at = now(),
67 heartbeat_at = NULL,
68 deadline_at = NULL,
69 callback_id = NULL,
70 callback_timeout_at = NULL,
71 callback_filter = NULL,
72 callback_on_complete = NULL,
73 callback_on_fail = NULL,
74 callback_transform = NULL,
75 errors = errors || jsonb_build_object(
76 'error', 'hard deadline exceeded',
77 'attempt', attempt,
78 'at', now()
79 )::jsonb
80 WHERE id = $1
81 AND state = 'running'
82 AND deadline_at IS NOT NULL
83 AND deadline_at < now()
84 RETURNING *
85"#;
86
87const CALLBACK_RESCUE_PER_ROW_SQL: &str = r#"
88 UPDATE awa.jobs
89 SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
90 finalized_at = now(),
91 callback_id = NULL,
92 callback_timeout_at = NULL,
93 callback_filter = NULL,
94 callback_on_complete = NULL,
95 callback_on_fail = NULL,
96 callback_transform = NULL,
97 run_at = CASE WHEN attempt >= max_attempts THEN run_at
98 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
99 errors = errors || jsonb_build_object(
100 'error', 'callback timed out',
101 'attempt', attempt,
102 'at', now()
103 )::jsonb
104 WHERE id = $1
105 AND state = 'waiting_external'
106 AND callback_timeout_at IS NOT NULL
107 AND callback_timeout_at < now()
108 RETURNING *
109"#;
110
111#[derive(Debug, Clone)]
113pub struct RetentionPolicy {
114 pub completed: Duration,
116 pub failed: Duration,
118 pub dlq: Option<Duration>,
120}
121
122impl Default for RetentionPolicy {
123 fn default() -> Self {
124 Self {
125 completed: Duration::from_secs(86400), failed: Duration::from_secs(259200), dlq: None,
128 }
129 }
130}
131
132#[derive(Debug, Default)]
141struct MaintenanceBranchState {
142 last_duration: Option<Duration>,
145 is_delayed: bool,
149 consecutive_overrun: u32,
153 consecutive_ontime: u32,
158 cooldown_ticks_remaining: u32,
165}
166
167const OVERRUN_HYSTERESIS_K: u32 = 3;
176
177const OVERRUN_UPPER_NUM: u32 = 3;
191const OVERRUN_UPPER_DEN: u32 = 2;
192const OVERRUN_LOWER_NUM: u32 = 7;
193const OVERRUN_LOWER_DEN: u32 = 10;
194
195const BRANCH_COOLDOWN_TICKS: u32 = 120;
200
201struct BranchTimer<'a> {
206 tracker: &'a MaintenanceBranchTracker,
207 branch: &'static str,
208 metrics: &'a crate::metrics::AwaMetrics,
209 started_at: Instant,
210}
211
212impl<'a> BranchTimer<'a> {
213 fn finish(self) {
219 let duration = self.started_at.elapsed();
220 self.metrics
221 .record_maintenance_branch_duration(self.branch, duration);
222 self.tracker.record_finish(self.branch, duration);
223 }
224}
225
226#[derive(Default)]
233struct MaintenanceBranchTracker {
234 branches: std::sync::Mutex<HashMap<&'static str, MaintenanceBranchState>>,
235}
236
237impl MaintenanceBranchTracker {
238 fn new() -> Self {
239 Self {
240 branches: std::sync::Mutex::new(HashMap::new()),
241 }
242 }
243
244 fn try_begin<'a>(
267 &'a self,
268 branch: &'static str,
269 tick_interval: Duration,
270 metrics: &'a crate::metrics::AwaMetrics,
271 ) -> Option<BranchTimer<'a>> {
272 self.try_begin_with_cooldown(branch, tick_interval, metrics, BRANCH_COOLDOWN_TICKS)
273 }
274
275 fn try_begin_without_cooldown<'a>(
276 &'a self,
277 branch: &'static str,
278 tick_interval: Duration,
279 metrics: &'a crate::metrics::AwaMetrics,
280 ) -> Option<BranchTimer<'a>> {
281 self.try_begin_with_cooldown(branch, tick_interval, metrics, 0)
282 }
283
284 fn try_begin_with_cooldown<'a>(
285 &'a self,
286 branch: &'static str,
287 tick_interval: Duration,
288 metrics: &'a crate::metrics::AwaMetrics,
289 cooldown_ticks: u32,
290 ) -> Option<BranchTimer<'a>> {
291 let mut branches = self
292 .branches
293 .lock()
294 .expect("maintenance branch tracker mutex");
295 let state = branches.entry(branch).or_default();
296
297 if state.cooldown_ticks_remaining > 0 {
300 state.cooldown_ticks_remaining -= 1;
301 return None;
302 }
303
304 if let Some(last_duration) = state.last_duration.take() {
314 let upper_threshold = tick_interval * OVERRUN_UPPER_NUM / OVERRUN_UPPER_DEN;
316 let lower_threshold = tick_interval * OVERRUN_LOWER_NUM / OVERRUN_LOWER_DEN;
317
318 if last_duration > upper_threshold {
319 state.consecutive_overrun = state.consecutive_overrun.saturating_add(1);
320 state.consecutive_ontime = 0;
321 let cross_threshold = state.consecutive_overrun >= OVERRUN_HYSTERESIS_K;
322 if cross_threshold && !state.is_delayed {
323 state.is_delayed = true;
324 state.cooldown_ticks_remaining = cooldown_ticks;
325 warn!(
326 branch,
327 last_duration_ms = last_duration.as_millis() as u64,
328 tick_interval_ms = tick_interval.as_millis() as u64,
329 upper_threshold_ms = upper_threshold.as_millis() as u64,
330 consecutive_overrun = state.consecutive_overrun,
331 cooldown_ticks,
332 "maintenance branch overran tick interval",
333 );
334 metrics.record_maintenance_branch_overrun(branch);
335 if cooldown_ticks > 0 {
336 return None;
337 }
338 } else if cross_threshold && state.is_delayed && cooldown_ticks > 0 {
339 state.cooldown_ticks_remaining = cooldown_ticks;
342 return None;
343 }
344 } else if last_duration < lower_threshold {
345 state.consecutive_ontime = state.consecutive_ontime.saturating_add(1);
346 state.consecutive_overrun = 0;
347 if state.consecutive_ontime >= OVERRUN_HYSTERESIS_K && state.is_delayed {
348 state.is_delayed = false;
349 warn!(
350 branch,
351 last_duration_ms = last_duration.as_millis() as u64,
352 tick_interval_ms = tick_interval.as_millis() as u64,
353 lower_threshold_ms = lower_threshold.as_millis() as u64,
354 consecutive_ontime = state.consecutive_ontime,
355 "maintenance branch recovered to on-time",
356 );
357 }
358 } else {
359 }
362 }
363 drop(branches);
364 Some(BranchTimer {
365 tracker: self,
366 branch,
367 metrics,
368 started_at: Instant::now(),
369 })
370 }
371
372 fn record_finish(&self, branch: &'static str, duration: Duration) {
376 let mut branches = self
377 .branches
378 .lock()
379 .expect("maintenance branch tracker mutex");
380 let state = branches.entry(branch).or_default();
381 state.last_duration = Some(duration);
382 }
383
384 #[cfg(test)]
387 fn snapshot(&self, branch: &'static str) -> Option<(Option<Duration>, bool)> {
388 let branches = self
389 .branches
390 .lock()
391 .expect("maintenance branch tracker mutex");
392 branches
393 .get(branch)
394 .map(|state| (state.last_duration, state.is_delayed))
395 }
396
397 #[cfg(test)]
400 fn cooldown_snapshot(&self, branch: &'static str) -> Option<(u32, u32, u32)> {
401 let branches = self
402 .branches
403 .lock()
404 .expect("maintenance branch tracker mutex");
405 branches.get(branch).map(|state| {
406 (
407 state.cooldown_ticks_remaining,
408 state.consecutive_overrun,
409 state.consecutive_ontime,
410 )
411 })
412 }
413}
414
415#[derive(Default)]
428struct PruneBackoffTracker {
429 branches: std::sync::Mutex<HashMap<&'static str, PruneBackoffState>>,
430}
431
432#[derive(Debug, Default)]
433struct PruneBackoffState {
434 skip_remaining: u32,
438 backoff_level: u8,
441}
442
443const MAX_PRUNE_BACKOFF_LEVEL: u8 = 5;
449
450impl PruneBackoffTracker {
451 fn new() -> Self {
452 Self::default()
453 }
454
455 fn should_skip(&self, branch: &'static str) -> bool {
458 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
459 let state = branches.entry(branch).or_default();
460 if state.skip_remaining > 0 {
461 state.skip_remaining -= 1;
462 true
463 } else {
464 false
465 }
466 }
467
468 fn record_outcome(&self, branch: &'static str, outcome: &PruneOutcome) {
471 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
472 let state = branches.entry(branch).or_default();
473 match outcome {
474 PruneOutcome::Pruned { .. } => {
475 state.skip_remaining = 0;
476 state.backoff_level = 0;
477 }
478 PruneOutcome::SkippedActive { .. } | PruneOutcome::Blocked { .. } => {
479 state.backoff_level = state
480 .backoff_level
481 .saturating_add(1)
482 .min(MAX_PRUNE_BACKOFF_LEVEL);
483 state.skip_remaining = 1u32 << state.backoff_level;
484 }
485 PruneOutcome::Noop => {}
486 }
487 }
488
489 #[cfg(test)]
490 fn snapshot(&self, branch: &'static str) -> Option<(u32, u8)> {
491 let branches = self.branches.lock().expect("prune backoff tracker mutex");
492 branches
493 .get(branch)
494 .map(|state| (state.skip_remaining, state.backoff_level))
495 }
496}
497
498const PRUNE_BRANCH_LEASE: &str = "lease";
501const PRUNE_BRANCH_CLAIM: &str = "claim";
502const PRUNE_BRANCH_QUEUE: &str = "queue";
503
504pub struct MaintenanceService {
509 pool: PgPool,
510 metrics: crate::metrics::AwaMetrics,
511 cancel: CancellationToken,
512 leader: Arc<AtomicBool>,
513 alive: Arc<AtomicBool>,
514 periodic_jobs: Arc<Vec<PeriodicJob>>,
515 enqueue_specs: Arc<
519 HashMap<
520 crate::enqueue_specs::Outcome,
521 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
522 >,
523 >,
524 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
527 in_flight: InFlightMap,
530 storage: RuntimeStorage,
531 heartbeat_rescue_interval: Duration,
532 deadline_rescue_interval: Duration,
533 callback_rescue_interval: Duration,
534 promote_interval: Duration,
535 cleanup_interval: Duration,
536 cron_sync_interval: Duration,
537 cron_eval_interval: Duration,
538 leader_check_interval: Duration,
539 leader_election_interval: Duration,
540 heartbeat_staleness: Duration,
541 completed_retention: Duration,
542 failed_retention: Duration,
543 cleanup_batch_size: i64,
544 queue_retention_overrides: HashMap<String, RetentionPolicy>,
545 queue_stats_interval: Duration,
546 dlq_retention: Duration,
547 dlq_cleanup_batch_size: i64,
548 dlq_policy: DlqPolicy,
549 dirty_key_recompute_interval: Duration,
550 metadata_reconciliation_interval: Duration,
551 priority_aging_interval: Duration,
554 batch_operations_interval: Duration,
555 terminal_count_rollup_interval: Duration,
556 descriptor_retention: Duration,
560}
561
562const PROMOTE_BATCH_SIZE: i64 = 4_096;
563const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
564const CRON_CATCH_UP_LIMIT: usize = 1_000;
565const TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK: usize = 4;
566type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
567
568impl MaintenanceService {
569 #[allow(clippy::too_many_arguments)]
570 pub(crate) fn new(
571 pool: PgPool,
572 metrics: crate::metrics::AwaMetrics,
573 leader: Arc<AtomicBool>,
574 alive: Arc<AtomicBool>,
575 cancel: CancellationToken,
576 periodic_jobs: Arc<Vec<PeriodicJob>>,
577 in_flight: InFlightMap,
578 storage: RuntimeStorage,
579 enqueue_specs: Arc<
580 HashMap<
581 crate::enqueue_specs::Outcome,
582 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
583 >,
584 >,
585 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
586 ) -> Self {
587 Self {
588 pool,
589 metrics,
590 cancel,
591 leader,
592 alive,
593 periodic_jobs,
594 in_flight,
595 storage,
596 enqueue_specs,
597 lifecycle_handlers,
598 heartbeat_rescue_interval: Duration::from_secs(30),
599 deadline_rescue_interval: Duration::from_secs(30),
600 callback_rescue_interval: Duration::from_secs(30),
601 promote_interval: Duration::from_millis(250),
602 cleanup_interval: Duration::from_secs(60),
603 cron_sync_interval: Duration::from_secs(60),
604 cron_eval_interval: Duration::from_secs(1),
605 leader_check_interval: Duration::from_secs(30),
606 leader_election_interval: Duration::from_secs(10),
607 heartbeat_staleness: Duration::from_secs(90),
608 completed_retention: Duration::from_secs(86400), failed_retention: Duration::from_secs(259200), cleanup_batch_size: 1000,
611 queue_retention_overrides: HashMap::new(),
612 queue_stats_interval: Duration::from_secs(30),
613 dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
614 dlq_cleanup_batch_size: 1000,
615 dlq_policy: DlqPolicy::default(),
616 dirty_key_recompute_interval: Duration::from_secs(2),
617 metadata_reconciliation_interval: Duration::from_secs(60),
618 priority_aging_interval: Duration::from_secs(60),
619 batch_operations_interval: Duration::from_secs(1),
620 terminal_count_rollup_interval: Duration::from_secs(30),
621 descriptor_retention: Duration::from_secs(30 * 86400), }
623 }
624
625 pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
630 self.priority_aging_interval = interval;
631 self
632 }
633
634 pub fn batch_operations_interval(mut self, interval: Duration) -> Self {
636 self.batch_operations_interval = interval;
637 self
638 }
639
640 pub fn terminal_count_rollup_interval(mut self, interval: Duration) -> Self {
643 self.terminal_count_rollup_interval = interval;
644 self
645 }
646
647 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
656 self.descriptor_retention = retention;
657 self
658 }
659
660 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
665 self.leader_election_interval = interval;
666 self
667 }
668
669 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
671 self.leader_check_interval = interval;
672 self
673 }
674
675 pub fn promote_interval(mut self, interval: Duration) -> Self {
677 self.promote_interval = interval;
678 self
679 }
680
681 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
683 self.heartbeat_rescue_interval = interval;
684 self
685 }
686
687 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
689 self.deadline_rescue_interval = interval;
690 self
691 }
692
693 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
695 self.callback_rescue_interval = interval;
696 self
697 }
698
699 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
705 self.heartbeat_staleness = staleness;
706 self
707 }
708
709 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
711 self.cleanup_interval = interval;
712 self
713 }
714
715 pub fn completed_retention(mut self, retention: Duration) -> Self {
717 self.completed_retention = retention;
718 self
719 }
720
721 pub fn failed_retention(mut self, retention: Duration) -> Self {
723 self.failed_retention = retention;
724 self
725 }
726
727 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
729 self.cleanup_batch_size = batch_size;
730 self
731 }
732
733 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
735 self.queue_stats_interval = interval;
736 self
737 }
738
739 pub fn dlq_retention(mut self, retention: Duration) -> Self {
741 self.dlq_retention = retention;
742 self
743 }
744
745 pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
747 self.dlq_cleanup_batch_size = batch_size;
748 self
749 }
750
751 pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
753 self.dlq_policy = policy;
754 self
755 }
756
757 pub fn queue_retention_overrides(
759 mut self,
760 overrides: HashMap<String, RetentionPolicy>,
761 ) -> Self {
762 self.queue_retention_overrides = overrides;
763 self
764 }
765
766 pub async fn run(&self) {
768 info!("Maintenance service starting");
769 self.alive.store(true, Ordering::SeqCst);
770 let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
771 self.leader.store(false, Ordering::SeqCst);
772
773 loop {
774 let mut leader_conn = match self.try_become_leader().await {
777 Ok(Some(conn)) => conn,
778 Ok(None) => {
779 tokio::select! {
781 _ = self.cancel.cancelled() => {
782 debug!("Maintenance service shutting down (not leader)");
783 self.leader.store(false, Ordering::SeqCst);
784 return;
785 }
786 _ = tokio::time::sleep(self.leader_election_interval) => continue,
787 }
788 }
789 Err(err) => {
790 warn!(error = %err, "Failed to check leader status");
791 tokio::select! {
792 _ = self.cancel.cancelled() => {
793 debug!("Maintenance service shutting down (leader check failed)");
794 self.leader.store(false, Ordering::SeqCst);
795 return;
796 }
797 _ = tokio::time::sleep(self.leader_election_interval) => continue,
798 }
799 }
800 };
801
802 debug!("Elected as maintenance leader");
803 self.leader.store(true, Ordering::SeqCst);
804
805 let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
807 let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
808 let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
809 let mut promote_timer = tokio::time::interval(self.promote_interval);
810 let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
811 let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
812 let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
813 let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
814 let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
815 let mut metadata_reconciliation_timer =
816 tokio::time::interval(self.metadata_reconciliation_interval);
817 let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
818 let mut batch_operations_timer = tokio::time::interval(self.batch_operations_interval);
819 let mut terminal_count_rollup_timer =
820 tokio::time::interval(self.terminal_count_rollup_interval);
821 let mut vacuum_queue_timer = self
822 .storage
823 .queue_storage()
824 .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
825 let mut vacuum_lease_timer = self
826 .storage
827 .queue_storage()
828 .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
829 let mut vacuum_claim_timer = self
830 .storage
831 .queue_storage()
832 .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
833 let vacuum_queue_interval = self
838 .storage
839 .queue_storage()
840 .map(|runtime| runtime.queue_rotate_interval);
841 let vacuum_lease_interval = self
842 .storage
843 .queue_storage()
844 .map(|runtime| runtime.lease_rotate_interval);
845 let vacuum_claim_interval = self
846 .storage
847 .queue_storage()
848 .map(|runtime| runtime.claim_rotate_interval);
849 let branch_tracker = MaintenanceBranchTracker::new();
853 let prune_tracker = PruneBackoffTracker::new();
859
860 heartbeat_rescue_timer.tick().await;
862 deadline_rescue_timer.tick().await;
863 callback_rescue_timer.tick().await;
864 promote_timer.tick().await;
865 cleanup_timer.tick().await;
866 cron_sync_timer.tick().await;
867 leader_check_timer.tick().await;
868 queue_stats_timer.tick().await;
869 dirty_key_timer.tick().await;
870 metadata_reconciliation_timer.tick().await;
871 priority_aging_timer.tick().await;
872 batch_operations_timer.tick().await;
873 terminal_count_rollup_timer.tick().await;
874 if let Some(timer) = &mut vacuum_queue_timer {
875 timer.tick().await;
876 }
877 if let Some(timer) = &mut vacuum_lease_timer {
878 timer.tick().await;
879 }
880 if let Some(timer) = &mut vacuum_claim_timer {
881 timer.tick().await;
882 }
883
884 self.sync_periodic_jobs_to_db().await;
886 let cron_eval_cancel = self.cancel.child_token();
887 let cron_eval_task = tokio::spawn(Self::run_cron_evaluator(
888 self.pool.clone(),
889 cron_eval_cancel.clone(),
890 self.cron_eval_interval,
891 ));
892
893 loop {
894 tokio::select! {
895 _ = self.cancel.cancelled() => {
896 debug!("Maintenance service shutting down");
897 self.leader.store(false, Ordering::SeqCst);
898 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
899 let _ = Self::release_leader(&mut leader_conn).await;
902 return;
903 }
904 _ = heartbeat_rescue_timer.tick() => {
905 if let Some(timer) = branch_tracker.try_begin("rescue_stale_heartbeats", self.heartbeat_rescue_interval, &self.metrics) {
906 self.rescue_stale_heartbeats().await;
907 timer.finish();
908 }
909 }
910 _ = deadline_rescue_timer.tick() => {
911 if let Some(timer) = branch_tracker.try_begin("rescue_expired_deadlines", self.deadline_rescue_interval, &self.metrics) {
912 self.rescue_expired_deadlines().await;
913 timer.finish();
914 }
915 }
916 _ = callback_rescue_timer.tick() => {
917 if let Some(timer) = branch_tracker.try_begin("rescue_expired_callbacks", self.callback_rescue_interval, &self.metrics) {
918 self.rescue_expired_callbacks().await;
919 timer.finish();
920 }
921 }
922 _ = promote_timer.tick() => {
923 if let Some(timer) = branch_tracker.try_begin("promote_scheduled", self.promote_interval, &self.metrics) {
924 self.promote_scheduled().await;
925 timer.finish();
926 }
927 }
928 _ = cleanup_timer.tick() => {
929 if let Some(timer) = branch_tracker.try_begin("cleanup", self.cleanup_interval, &self.metrics) {
930 self.cleanup_completed().await;
931 self.cleanup_dlq_rows().await;
932 self.cleanup_batch_operations().await;
933 self.cleanup_stale_runtime_snapshots().await;
934 self.cleanup_stale_descriptors().await;
935 timer.finish();
936 }
937 }
938 _ = cron_sync_timer.tick() => {
939 if let Some(timer) = branch_tracker.try_begin("cron_sync", self.cron_sync_interval, &self.metrics) {
940 self.sync_periodic_jobs_to_db().await;
941 timer.finish();
942 }
943 }
944 _ = queue_stats_timer.tick() => {
945 if let Some(timer) = branch_tracker.try_begin("queue_stats", self.queue_stats_interval, &self.metrics) {
946 self.publish_queue_health_metrics().await;
947 timer.finish();
948 }
949 }
950 _ = dirty_key_timer.tick() => {
951 if let Some(timer) = branch_tracker.try_begin("recompute_dirty_admin_metadata", self.dirty_key_recompute_interval, &self.metrics) {
952 self.recompute_dirty_admin_metadata().await;
953 timer.finish();
954 }
955 }
956 _ = metadata_reconciliation_timer.tick() => {
957 if let Some(timer) = branch_tracker.try_begin("refresh_admin_metadata", self.metadata_reconciliation_interval, &self.metrics) {
958 self.refresh_admin_metadata().await;
959 timer.finish();
960 }
961 }
962 _ = priority_aging_timer.tick() => {
963 if let Some(timer) = branch_tracker.try_begin("priority_aging", self.priority_aging_interval, &self.metrics) {
964 self.age_waiting_priorities().await;
965 timer.finish();
966 }
967 }
968 _ = batch_operations_timer.tick() => {
969 if let Some(timer) = branch_tracker.try_begin("batch_operations", self.batch_operations_interval, &self.metrics) {
970 self.process_batch_operation().await;
971 timer.finish();
972 }
973 }
974 _ = terminal_count_rollup_timer.tick() => {
975 if let Some(timer) = branch_tracker.try_begin_without_cooldown("terminal_count_rollup", self.terminal_count_rollup_interval, &self.metrics) {
976 self.rollup_terminal_count_deltas().await;
977 timer.finish();
978 }
979 }
980 _ = async {
981 if let Some(timer) = &mut vacuum_queue_timer {
982 timer.tick().await;
983 } else {
984 std::future::pending::<()>().await;
985 }
986 }, if vacuum_queue_timer.is_some() => {
987 let interval = vacuum_queue_interval
988 .expect("vacuum_queue_interval Some iff vacuum_queue_timer Some");
989 if let Some(timer) = branch_tracker.try_begin("rotate_queue", interval, &self.metrics) {
990 self.rotate_queue_storage_queue(&prune_tracker).await;
991 timer.finish();
992 }
993 }
994 _ = async {
995 if let Some(timer) = &mut vacuum_lease_timer {
996 timer.tick().await;
997 } else {
998 std::future::pending::<()>().await;
999 }
1000 }, if vacuum_lease_timer.is_some() => {
1001 let interval = vacuum_lease_interval
1002 .expect("vacuum_lease_interval Some iff vacuum_lease_timer Some");
1003 if let Some(timer) = branch_tracker.try_begin("rotate_lease", interval, &self.metrics) {
1004 self.rotate_queue_storage_leases(&prune_tracker).await;
1005 timer.finish();
1006 }
1007 }
1008 _ = async {
1009 if let Some(timer) = &mut vacuum_claim_timer {
1010 timer.tick().await;
1011 } else {
1012 std::future::pending::<()>().await;
1013 }
1014 }, if vacuum_claim_timer.is_some() => {
1015 let interval = vacuum_claim_interval
1016 .expect("vacuum_claim_interval Some iff vacuum_claim_timer Some");
1017 if let Some(timer) = branch_tracker.try_begin("rotate_claim", interval, &self.metrics) {
1018 self.rotate_queue_storage_claims(&prune_tracker).await;
1019 timer.finish();
1020 }
1021 }
1022 _ = leader_check_timer.tick() => {
1023 if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
1027 warn!("Leader connection lost, re-entering election loop");
1028 self.leader.store(false, Ordering::SeqCst);
1029 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
1030 break;
1031 }
1032 }
1033 }
1034 }
1035 }
1036 }
1037
1038 const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
1047 let mut conn = self.pool.acquire().await?;
1048 let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
1049 .bind(Self::LOCK_KEY)
1050 .fetch_one(&mut *conn)
1051 .await?;
1052 if result.0 {
1053 Ok(Some(conn))
1054 } else {
1055 Ok(None)
1056 }
1057 }
1058
1059 async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
1064 sqlx::query("SELECT pg_advisory_unlock($1)")
1065 .bind(Self::LOCK_KEY)
1066 .execute(&mut **conn)
1067 .await?;
1068 Ok(())
1069 }
1070
1071 async fn run_cron_evaluator(pool: PgPool, cancel: CancellationToken, interval: Duration) {
1072 let mut timer = tokio::time::interval(interval);
1073 timer.tick().await;
1074
1075 loop {
1076 tokio::select! {
1077 _ = cancel.cancelled() => return,
1078 _ = timer.tick() => {
1079 Self::evaluate_cron_schedules(&pool).await;
1080 }
1081 }
1082 }
1083 }
1084
1085 fn stop_cron_evaluator(cancel: &CancellationToken, task: &JoinHandle<()>) {
1086 cancel.cancel();
1087 task.abort();
1088 }
1089
1090 #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
1094 async fn sync_periodic_jobs_to_db(&self) {
1095 if self.periodic_jobs.is_empty() {
1096 return;
1097 }
1098
1099 for job in self.periodic_jobs.iter() {
1100 if let Err(err) = upsert_cron_job(&self.pool, job).await {
1101 error!(name = %job.name, error = %err, "Failed to sync periodic job");
1102 }
1103 }
1104
1105 debug!(
1106 count = self.periodic_jobs.len(),
1107 "Synced periodic jobs to database"
1108 );
1109 }
1110
1111 async fn process_batch_operation(&self) {
1112 let runner_instance = Uuid::new_v4();
1113 match awa_model::batch_operations::run_one_default_chunk(&self.pool, runner_instance).await
1114 {
1115 Ok(outcome) if outcome.claimed => {
1116 debug!(
1117 processed = outcome.processed,
1118 skipped = outcome.skipped,
1119 errored = outcome.errored,
1120 finalized = outcome.finalized,
1121 "processed batch operation chunk"
1122 );
1123 }
1124 Ok(_) => {}
1125 Err(err) => warn!(error = %err, "failed to process batch operation chunk"),
1126 }
1127 }
1128
1129 async fn cleanup_batch_operations(&self) {
1130 match awa_model::batch_operations::cleanup_expired_batch_operations(&self.pool, 1000).await
1131 {
1132 Ok(deleted) if deleted > 0 => {
1133 debug!(deleted, "cleaned up expired batch operations");
1134 }
1135 Ok(_) => {}
1136 Err(err) => warn!(error = %err, "failed to clean up expired batch operations"),
1137 }
1138 }
1139
1140 async fn rollup_terminal_count_deltas(&self) {
1141 let Some(runtime) = self.storage.queue_storage() else {
1142 return;
1143 };
1144
1145 match runtime
1146 .store
1147 .rollup_terminal_count_deltas(&self.pool, TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK)
1148 .await
1149 {
1150 Ok(TerminalDeltaRollupOutcome {
1151 rolled_slots: 0,
1152 delta_rows: 0,
1153 grouped_keys: 0,
1154 skipped_active_slots: 0,
1155 blocked_slots: 0,
1156 skipped_mvcc_pinned: false,
1157 }) => {}
1158 Ok(outcome) => {
1159 debug!(
1160 rolled_slots = outcome.rolled_slots,
1161 delta_rows = outcome.delta_rows,
1162 grouped_keys = outcome.grouped_keys,
1163 skipped_active_slots = outcome.skipped_active_slots,
1164 blocked_slots = outcome.blocked_slots,
1165 skipped_mvcc_pinned = outcome.skipped_mvcc_pinned,
1166 "rolled up queue-storage terminal count deltas"
1167 );
1168 }
1169 Err(err) => warn!(error = %err, "failed to roll up terminal count deltas"),
1170 }
1171 }
1172
1173 #[tracing::instrument(skip(pool), name = "maintenance.cron_eval")]
1180 async fn evaluate_cron_schedules(pool: &PgPool) {
1181 let cron_rows = match list_cron_jobs(pool).await {
1182 Ok(rows) => rows,
1183 Err(err) => {
1184 error!(error = %err, "Failed to load cron jobs for evaluation");
1185 return;
1186 }
1187 };
1188
1189 if cron_rows.is_empty() {
1190 return;
1191 }
1192
1193 let now = Utc::now();
1194
1195 for row in &cron_rows {
1196 if row.is_paused() {
1197 debug!(cron_name = %row.name, "Skipping paused cron schedule");
1198 continue;
1199 }
1200 let fire_times = compute_fire_times(row, now, CRON_CATCH_UP_LIMIT);
1201 if fire_times.is_empty() {
1202 continue;
1203 }
1204 if fire_times.len() == CRON_CATCH_UP_LIMIT {
1205 warn!(
1206 cron_name = %row.name,
1207 catch_up_limit = CRON_CATCH_UP_LIMIT,
1208 "Cron catch-up limit reached; remaining due fires will be retried on the next evaluation"
1209 );
1210 }
1211
1212 let mut previous_enqueued_at = row.last_enqueued_at;
1213 for fire_time in fire_times {
1214 match atomic_enqueue(pool, &row.name, fire_time, previous_enqueued_at).await {
1215 Ok(Some(job)) => {
1216 previous_enqueued_at = Some(fire_time);
1217 info!(
1218 cron_name = %row.name,
1219 job_id = job.id,
1220 fire_time = %fire_time,
1221 "Enqueued periodic job"
1222 );
1223 }
1224 Ok(None) => {
1225 debug!(cron_name = %row.name, "Cron fire already claimed");
1227 break;
1228 }
1229 Err(err) => {
1230 error!(
1231 cron_name = %row.name,
1232 error = %err,
1233 "Failed to enqueue periodic job"
1234 );
1235 break;
1236 }
1237 }
1238 }
1239 }
1240 }
1241
1242 #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
1244 async fn rescue_stale_heartbeats(&self) {
1245 let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
1246 let outcome = match &self.storage {
1247 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1248 r#"
1249 UPDATE awa.jobs
1250 SET state = 'retryable',
1251 finalized_at = now(),
1252 heartbeat_at = NULL,
1253 deadline_at = NULL,
1254 callback_id = NULL,
1255 callback_timeout_at = NULL,
1256 callback_filter = NULL,
1257 callback_on_complete = NULL,
1258 callback_on_fail = NULL,
1259 callback_transform = NULL,
1260 errors = errors || jsonb_build_object(
1261 'error', 'heartbeat stale: worker presumed dead',
1262 'attempt', attempt,
1263 'at', now()
1264 )::jsonb
1265 WHERE id IN (
1266 SELECT id FROM awa.jobs_hot
1267 WHERE state = 'running'
1268 AND heartbeat_at < now() - ($1 * interval '1 millisecond')
1269 LIMIT 500
1270 FOR UPDATE SKIP LOCKED
1271 )
1272 RETURNING *
1273 "#,
1274 )
1275 .bind(staleness_ms)
1276 .fetch_all(&self.pool)
1277 .await
1278 .map_err(awa_model::AwaError::Database),
1279 RuntimeStorage::QueueStorage(runtime) => {
1280 runtime
1281 .store
1282 .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
1283 .await
1284 }
1285 };
1286 let outcome = match outcome {
1287 Err(err)
1288 if matches!(self.storage, RuntimeStorage::Canonical)
1289 && is_unique_claim_conflict(&err) =>
1290 {
1291 warn!(
1292 error = %err,
1293 "Batched heartbeat rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1294 );
1295 self.rescue_canonical_per_row(
1296 "SELECT id FROM awa.jobs_hot \
1297 WHERE state = 'running' \
1298 AND heartbeat_at < now() - ($1 * interval '1 millisecond') \
1299 LIMIT 500",
1300 HEARTBEAT_RESCUE_PER_ROW_SQL,
1301 Some(staleness_ms),
1302 "running",
1303 "rescued as duplicate: heartbeat stale and unique claim held by a newer job",
1304 "heartbeat",
1305 )
1306 .await
1307 }
1308 other => other,
1309 };
1310 match outcome {
1311 Ok(rescued) if !rescued.is_empty() => {
1312 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1313 .into_iter()
1314 .partition(|job| job.state == JobState::Cancelled);
1315 self.handle_duplicate_cancellations("heartbeat", &cancelled_duplicates)
1316 .await;
1317 if rescued.is_empty() {
1318 return;
1319 }
1320 self.metrics.maintenance_rescues.add(
1321 rescued.len() as u64,
1322 &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
1323 );
1324 warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
1325 self.signal_cancellation(&rescued).await;
1327 for job in &rescued {
1328 self.emit_rescued(job, crate::events::RescueReason::StaleHeartbeat)
1329 .await;
1330 }
1331 }
1332 Err(err) => {
1333 error!(error = %err, "Failed to rescue stale heartbeat jobs");
1334 }
1335 _ => {}
1336 }
1337 }
1338
1339 #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
1341 async fn rescue_expired_deadlines(&self) {
1342 let outcome = match &self.storage {
1343 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1344 r#"
1345 UPDATE awa.jobs
1346 SET state = 'retryable',
1347 finalized_at = now(),
1348 heartbeat_at = NULL,
1349 deadline_at = NULL,
1350 callback_id = NULL,
1351 callback_timeout_at = NULL,
1352 callback_filter = NULL,
1353 callback_on_complete = NULL,
1354 callback_on_fail = NULL,
1355 callback_transform = NULL,
1356 errors = errors || jsonb_build_object(
1357 'error', 'hard deadline exceeded',
1358 'attempt', attempt,
1359 'at', now()
1360 )::jsonb
1361 WHERE id IN (
1362 SELECT id FROM awa.jobs_hot
1363 WHERE state = 'running'
1364 AND deadline_at IS NOT NULL
1365 AND deadline_at < now()
1366 LIMIT 500
1367 FOR UPDATE SKIP LOCKED
1368 )
1369 RETURNING *
1370 "#,
1371 )
1372 .fetch_all(&self.pool)
1373 .await
1374 .map_err(awa_model::AwaError::Database),
1375 RuntimeStorage::QueueStorage(runtime) => {
1376 runtime.store.rescue_expired_deadlines(&self.pool).await
1377 }
1378 };
1379 let outcome = match outcome {
1380 Err(err)
1381 if matches!(self.storage, RuntimeStorage::Canonical)
1382 && is_unique_claim_conflict(&err) =>
1383 {
1384 warn!(
1385 error = %err,
1386 "Batched deadline rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1387 );
1388 self.rescue_canonical_per_row(
1389 "SELECT id FROM awa.jobs_hot \
1390 WHERE state = 'running' \
1391 AND deadline_at IS NOT NULL \
1392 AND deadline_at < now() \
1393 LIMIT 500",
1394 DEADLINE_RESCUE_PER_ROW_SQL,
1395 None,
1396 "running",
1397 "rescued as duplicate: deadline expired and unique claim held by a newer job",
1398 "deadline",
1399 )
1400 .await
1401 }
1402 other => other,
1403 };
1404 match outcome {
1405 Ok(rescued) if !rescued.is_empty() => {
1406 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1407 .into_iter()
1408 .partition(|job| job.state == JobState::Cancelled);
1409 self.handle_duplicate_cancellations("deadline", &cancelled_duplicates)
1410 .await;
1411 if rescued.is_empty() {
1412 return;
1413 }
1414 self.metrics.maintenance_rescues.add(
1415 rescued.len() as u64,
1416 &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
1417 );
1418 warn!(count = rescued.len(), "Rescued deadline-expired jobs");
1419 self.signal_cancellation(&rescued).await;
1421 for job in &rescued {
1422 self.emit_rescued(job, crate::events::RescueReason::DeadlineExceeded)
1423 .await;
1424 }
1425 }
1426 Err(err) => {
1427 error!(error = %err, "Failed to rescue deadline-expired jobs");
1428 }
1429 _ => {}
1430 }
1431 }
1432
1433 #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
1435 async fn rescue_expired_callbacks(&self) {
1436 let outcome = match &self.storage {
1437 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1438 r#"
1439 UPDATE awa.jobs
1440 SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
1441 finalized_at = now(),
1442 callback_id = NULL,
1443 callback_timeout_at = NULL,
1444 callback_filter = NULL,
1445 callback_on_complete = NULL,
1446 callback_on_fail = NULL,
1447 callback_transform = NULL,
1448 run_at = CASE WHEN attempt >= max_attempts THEN run_at
1449 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
1450 errors = errors || jsonb_build_object(
1451 'error', 'callback timed out',
1452 'attempt', attempt,
1453 'at', now()
1454 )::jsonb
1455 WHERE id IN (
1456 SELECT id FROM awa.jobs_hot
1457 WHERE state = 'waiting_external'
1458 AND callback_timeout_at IS NOT NULL
1459 AND callback_timeout_at < now()
1460 LIMIT 500
1461 FOR UPDATE SKIP LOCKED
1462 )
1463 RETURNING *
1464 "#,
1465 )
1466 .fetch_all(&self.pool)
1467 .await
1468 .map_err(awa_model::AwaError::Database),
1469 RuntimeStorage::QueueStorage(runtime) => {
1470 runtime.store.rescue_expired_callbacks(&self.pool).await
1471 }
1472 };
1473 let outcome = match outcome {
1474 Err(err)
1475 if matches!(self.storage, RuntimeStorage::Canonical)
1476 && is_unique_claim_conflict(&err) =>
1477 {
1478 warn!(
1479 error = %err,
1480 "Batched callback-timeout rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1481 );
1482 self.rescue_canonical_per_row(
1483 "SELECT id FROM awa.jobs_hot \
1484 WHERE state = 'waiting_external' \
1485 AND callback_timeout_at IS NOT NULL \
1486 AND callback_timeout_at < now() \
1487 LIMIT 500",
1488 CALLBACK_RESCUE_PER_ROW_SQL,
1489 None,
1490 "waiting_external",
1491 "rescued as duplicate: callback timed out and unique claim held by a newer job",
1492 "callback_timeout",
1493 )
1494 .await
1495 }
1496 other => other,
1497 };
1498 match outcome {
1499 Ok(rescued) if !rescued.is_empty() => {
1500 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1501 .into_iter()
1502 .partition(|job| job.state == JobState::Cancelled);
1503 self.handle_duplicate_cancellations("callback_timeout", &cancelled_duplicates)
1504 .await;
1505 if rescued.is_empty() {
1506 return;
1507 }
1508 self.metrics.maintenance_rescues.add(
1509 rescued.len() as u64,
1510 &[opentelemetry::KeyValue::new(
1511 "awa.rescue.kind",
1512 "callback_timeout",
1513 )],
1514 );
1515 warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
1516 for job in &rescued {
1517 self.emit_rescued(job, crate::events::RescueReason::ExpiredCallback)
1518 .await;
1519 }
1520 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
1521 for job in &rescued {
1522 if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
1523 {
1524 continue;
1525 }
1526 match runtime
1527 .store
1528 .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
1529 .await
1530 {
1531 Ok(Some(_)) => {
1532 self.metrics.record_dlq_moved(
1533 &job.kind,
1534 &job.queue,
1535 "callback_timeout",
1536 );
1537 }
1538 Ok(None) => {}
1539 Err(err) => {
1540 error!(
1541 job_id = job.id,
1542 error = %err,
1543 "Failed to move rescued callback timeout into DLQ"
1544 );
1545 }
1546 }
1547 }
1548 }
1549 }
1550 Err(err) => {
1551 error!(error = %err, "Failed to rescue callback-timed-out jobs");
1552 }
1553 _ => {}
1554 }
1555 }
1556
1557 #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
1564 async fn age_waiting_priorities(&self) {
1565 let aging_secs = self.priority_aging_interval.as_secs_f64();
1566 if aging_secs <= 0.0 {
1567 return;
1568 }
1569 if let Some(runtime) = self.storage.queue_storage() {
1570 debug!(
1571 schema = %runtime.store.schema(),
1572 "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
1573 );
1574 return;
1575 }
1576
1577 match sqlx::query_scalar::<_, i64>(
1578 r#"
1579 WITH eligible AS (
1580 SELECT id FROM awa.jobs_hot
1581 WHERE state = 'available'
1582 AND priority > 1
1583 AND run_at <= now() - make_interval(secs => $1)
1584 LIMIT 1000
1585 FOR UPDATE SKIP LOCKED
1586 )
1587 UPDATE awa.jobs_hot
1588 SET priority = priority - 1,
1589 metadata = CASE
1590 WHEN NOT (metadata ? '_awa_original_priority')
1591 THEN metadata || jsonb_build_object('_awa_original_priority', priority)
1592 ELSE metadata
1593 END
1594 FROM eligible
1595 WHERE awa.jobs_hot.id = eligible.id
1596 RETURNING awa.jobs_hot.id
1597 "#,
1598 )
1599 .bind(aging_secs)
1600 .fetch_all(&self.pool)
1601 .await
1602 {
1603 Ok(ids) if !ids.is_empty() => {
1604 debug!(count = ids.len(), "Aged job priorities");
1605 }
1606 Err(err) => {
1607 error!(error = %err, "Failed to age job priorities");
1608 }
1609 _ => {}
1610 }
1611 }
1612
1613 async fn rescue_canonical_per_row(
1627 &self,
1628 candidates_sql: &str,
1629 per_row_sql: &str,
1630 staleness_ms: Option<i64>,
1631 from_state: &str,
1632 duplicate_error: &str,
1633 rescue_kind: &'static str,
1634 ) -> Result<Vec<JobRow>, awa_model::AwaError> {
1635 let ids: Vec<i64> = {
1636 let query = sqlx::query_scalar(candidates_sql);
1637 let query = match staleness_ms {
1638 Some(ms) => query.bind(ms),
1639 None => query,
1640 };
1641 query
1642 .fetch_all(&self.pool)
1643 .await
1644 .map_err(awa_model::AwaError::Database)?
1645 };
1646
1647 let mut rescued = Vec::new();
1648 for id in ids {
1649 let attempt = {
1650 let query = sqlx::query_as::<_, JobRow>(per_row_sql).bind(id);
1651 let query = match staleness_ms {
1652 Some(ms) => query.bind(ms),
1653 None => query,
1654 };
1655 query
1656 .fetch_optional(&self.pool)
1657 .await
1658 .map_err(awa_model::AwaError::Database)
1659 };
1660 match attempt {
1661 Ok(Some(row)) => rescued.push(row),
1662 Ok(None) => {}
1665 Err(err) if is_unique_claim_conflict(&err) => {
1666 let holder = self.unique_claim_holder(id).await;
1667 warn!(
1668 job_id = id,
1669 claim_holder = ?holder,
1670 rescue_kind,
1671 "Rescue conflicts with a unique claim held by another job; \
1672 cancelling the superseded job (#388)"
1673 );
1674 match self
1675 .cancel_unique_conflicted_job(id, from_state, duplicate_error)
1676 .await
1677 {
1678 Ok(Some(row)) => rescued.push(row),
1679 Ok(None) => {}
1680 Err(err) if is_unique_claim_conflict(&err) => {
1681 error!(
1682 job_id = id,
1683 claim_holder = ?holder,
1684 rescue_kind,
1685 "Cannot rescue or cancel unique-conflicted job: its \
1686 unique_states mask claims 'cancelled' as well; skipping \
1687 so the sweep can proceed — resolve manually (see \
1688 docs/troubleshooting.md, #388)"
1689 );
1690 }
1691 Err(err) => {
1692 error!(job_id = id, error = %err, rescue_kind, "Failed to cancel unique-conflicted job");
1693 }
1694 }
1695 }
1696 Err(err) => {
1697 error!(job_id = id, error = %err, rescue_kind, "Per-row rescue failed");
1698 }
1699 }
1700 }
1701
1702 Ok(rescued)
1703 }
1704
1705 async fn handle_duplicate_cancellations(&self, rescue_kind: &str, cancelled: &[JobRow]) {
1710 if cancelled.is_empty() {
1711 return;
1712 }
1713 self.metrics.maintenance_rescues.add(
1714 cancelled.len() as u64,
1715 &[opentelemetry::KeyValue::new(
1716 "awa.rescue.kind",
1717 format!("{rescue_kind}_duplicate_cancelled"),
1718 )],
1719 );
1720 warn!(
1721 count = cancelled.len(),
1722 rescue_kind, "Cancelled unique-conflicted jobs superseded by a newer duplicate"
1723 );
1724 self.signal_cancellation(cancelled).await;
1725 for job in cancelled {
1726 let handlers = self.lifecycle_handlers.clone();
1727 let kind = job.kind.clone();
1728 let reason = job
1729 .errors
1730 .as_ref()
1731 .and_then(|errors| errors.last())
1732 .and_then(|entry| entry.get("error"))
1733 .and_then(|value| value.as_str())
1734 .unwrap_or("rescued as duplicate: unique claim held by a newer job")
1735 .to_string();
1736 let event = crate::events::UntypedJobEvent::Cancelled {
1737 job: job.clone(),
1738 reason,
1739 };
1740 tokio::spawn(async move {
1741 crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
1742 });
1743 }
1744 }
1745
1746 async fn unique_claim_holder(&self, job_id: i64) -> Option<i64> {
1749 sqlx::query_scalar(
1750 r#"
1751 SELECT c.job_id
1752 FROM awa.jobs AS j
1753 JOIN awa.job_unique_claims AS c ON c.unique_key = j.unique_key
1754 WHERE j.id = $1 AND c.job_id <> j.id
1755 "#,
1756 )
1757 .bind(job_id)
1758 .fetch_optional(&self.pool)
1759 .await
1760 .ok()
1761 .flatten()
1762 }
1763
1764 async fn cancel_unique_conflicted_job(
1770 &self,
1771 job_id: i64,
1772 from_state: &str,
1773 error_message: &str,
1774 ) -> Result<Option<JobRow>, awa_model::AwaError> {
1775 let row = sqlx::query_as::<_, JobRow>(
1776 r#"
1777 UPDATE awa.jobs
1778 SET state = 'cancelled',
1779 finalized_at = now(),
1780 heartbeat_at = NULL,
1781 deadline_at = NULL,
1782 callback_id = NULL,
1783 callback_timeout_at = NULL,
1784 callback_filter = NULL,
1785 callback_on_complete = NULL,
1786 callback_on_fail = NULL,
1787 callback_transform = NULL,
1788 errors = errors || jsonb_build_object(
1789 'error', $3::text,
1790 'attempt', attempt,
1791 'at', now()
1792 )::jsonb
1793 WHERE id = $1 AND state = $2::awa.job_state
1794 RETURNING *
1795 "#,
1796 )
1797 .bind(job_id)
1798 .bind(from_state)
1799 .bind(error_message)
1800 .fetch_optional(&self.pool)
1801 .await?;
1802 Ok(row)
1803 }
1804
1805 async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
1807 for job in rescued_jobs {
1808 if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
1809 flag.store(true, Ordering::SeqCst);
1810 debug!(job_id = job.id, "Signalled cancellation for rescued job");
1811 }
1812 }
1813 }
1814
1815 async fn emit_rescued(&self, job: &JobRow, reason: crate::events::RescueReason) {
1822 self.dispatch_rescued_followups(job, reason).await;
1823 let handlers = self.lifecycle_handlers.clone();
1824 let kind = job.kind.clone();
1825 let event = crate::events::UntypedJobEvent::Rescued {
1826 job: job.clone(),
1827 reason,
1828 };
1829 tokio::spawn(async move {
1830 crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
1831 });
1832 }
1833
1834 async fn dispatch_rescued_followups(&self, job: &JobRow, reason: crate::events::RescueReason) {
1842 let Some(specs) = self
1843 .enqueue_specs
1844 .get(&crate::enqueue_specs::Outcome::Rescued)
1845 .and_then(|by_kind| by_kind.get(&job.kind))
1846 .cloned()
1847 else {
1848 return;
1849 };
1850 if specs.is_empty() {
1851 return;
1852 }
1853 let mut tx = match self.pool.begin().await {
1854 Ok(tx) => tx,
1855 Err(err) => {
1856 error!(
1857 job_id = job.id,
1858 kind = %job.kind,
1859 rescue_reason = reason.as_str(),
1860 error = %err,
1861 "Rescued follow-up dispatch: failed to begin transaction"
1862 );
1863 return;
1864 }
1865 };
1866 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Rescued { reason };
1867 let result =
1868 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, job, &specs, Some(&outcome_ctx))
1869 .await;
1870 match result {
1871 Ok(()) => {
1872 if let Err(err) = tx.commit().await {
1873 error!(
1874 job_id = job.id,
1875 kind = %job.kind,
1876 rescue_reason = reason.as_str(),
1877 error = %err,
1878 "Rescued follow-up dispatch: commit failed"
1879 );
1880 }
1881 }
1882 Err(err) => {
1883 error!(
1884 job_id = job.id,
1885 kind = %job.kind,
1886 rescue_reason = reason.as_str(),
1887 error = %err,
1888 "Rescued follow-up dispatch: spec INSERT failed; rolling back"
1889 );
1890 let _ = tx.rollback().await;
1891 }
1892 }
1893 }
1894
1895 #[tracing::instrument(skip(self), name = "maintenance.promote")]
1897 async fn promote_scheduled(&self) {
1898 if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
1899 error!(error = %err, "Failed to promote scheduled jobs");
1900 }
1901 if let Err(err) = self
1902 .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
1903 .await
1904 {
1905 error!(error = %err, "Failed to promote retryable jobs");
1906 }
1907 }
1908
1909 async fn promote_due_state(
1910 &self,
1911 state: &'static str,
1912 label: &'static str,
1913 ) -> Result<(), awa_model::AwaError> {
1914 let mut promoted_total = 0usize;
1915 let mut notified_queues = HashSet::new();
1916
1917 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
1918 if self.cancel.is_cancelled() {
1919 break;
1920 }
1921
1922 match &self.storage {
1923 RuntimeStorage::Canonical => {
1924 let (promoted, queues) = self
1925 .promote_due_batch(state)
1926 .await
1927 .map_err(awa_model::AwaError::Database)?;
1928 if promoted == 0 {
1929 break;
1930 }
1931
1932 promoted_total += promoted;
1933 notified_queues.extend(queues);
1934
1935 if promoted < PROMOTE_BATCH_SIZE as usize {
1936 break;
1937 }
1938 }
1939 RuntimeStorage::QueueStorage(runtime) => {
1940 let job_state = match state {
1941 "scheduled" => awa_model::JobState::Scheduled,
1942 "retryable" => awa_model::JobState::Retryable,
1943 other => {
1944 return Err(awa_model::AwaError::Validation(format!(
1945 "unsupported queue storage promote state: {other}"
1946 )));
1947 }
1948 };
1949 let promote_start = std::time::Instant::now();
1950 let promoted = runtime
1951 .store
1952 .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
1953 .await?;
1954 self.metrics.record_promotion_batch(
1955 state,
1956 promoted as u64,
1957 promote_start.elapsed(),
1958 );
1959 if promoted == 0 {
1960 break;
1961 }
1962
1963 promoted_total += promoted;
1964
1965 if promoted < PROMOTE_BATCH_SIZE as usize {
1966 break;
1967 }
1968 }
1969 }
1970 }
1971
1972 if promoted_total > 0 {
1973 debug!(
1974 count = promoted_total,
1975 queues = notified_queues.len(),
1976 state,
1977 "Promoted {label}"
1978 );
1979 }
1980
1981 Ok(())
1982 }
1983
1984 fn promote_sql(state: &'static str) -> String {
1990 format!(
1991 r#"
1992 WITH due AS (
1993 DELETE FROM awa.scheduled_jobs
1994 WHERE id IN (
1995 SELECT id
1996 FROM awa.scheduled_jobs
1997 WHERE state = '{state}'::awa.job_state
1998 AND run_at <= now()
1999 ORDER BY run_at ASC, id ASC
2000 LIMIT $1
2001 FOR UPDATE SKIP LOCKED
2002 )
2003 RETURNING *
2004 ),
2005 promoted AS (
2006 INSERT INTO awa.jobs_hot (
2007 id, kind, queue, args, state, priority, attempt, max_attempts,
2008 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
2009 created_at, errors, metadata, tags, unique_key, unique_states,
2010 callback_id, callback_timeout_at, callback_filter, callback_on_complete,
2011 callback_on_fail, callback_transform, run_lease, progress
2012 )
2013 SELECT
2014 id,
2015 kind,
2016 queue,
2017 args,
2018 'available'::awa.job_state,
2019 priority,
2020 attempt,
2021 max_attempts,
2022 now(),
2023 NULL,
2024 NULL,
2025 attempted_at,
2026 finalized_at,
2027 created_at,
2028 errors,
2029 metadata,
2030 tags,
2031 unique_key,
2032 unique_states,
2033 NULL,
2034 NULL,
2035 NULL,
2036 NULL,
2037 NULL,
2038 NULL,
2039 run_lease,
2040 progress
2041 FROM due
2042 RETURNING queue
2043 )
2044 SELECT queue FROM promoted
2045 "#
2046 )
2047 }
2048
2049 async fn promote_due_batch(
2050 &self,
2051 state: &'static str,
2052 ) -> Result<(usize, HashSet<String>), sqlx::Error> {
2053 let mut tx = self.pool.begin().await?;
2054 let promote_start = std::time::Instant::now();
2055 let sql = Self::promote_sql(state);
2056 let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
2057 .bind(PROMOTE_BATCH_SIZE)
2058 .fetch_all(&mut *tx)
2059 .await?;
2060
2061 let promoted = promoted_rows.len();
2062 self.metrics
2063 .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
2064 if promoted == 0 {
2065 tx.commit().await?;
2066 return Ok((0, HashSet::new()));
2067 }
2068
2069 let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
2070
2071 tx.commit().await?;
2072 Ok((promoted, queues))
2073 }
2074
2075 async fn rotate_queue_storage_queue(&self, prune_tracker: &PruneBackoffTracker) {
2076 let Some(runtime) = self.storage.queue_storage() else {
2077 return;
2078 };
2079
2080 match runtime.store.rotate(&self.pool).await {
2081 Ok(outcome) => {
2082 self.metrics.record_rotate_outcome("queue", &outcome);
2083 match outcome {
2084 RotateOutcome::Rotated { slot, generation } => {
2085 debug!(slot, generation, "Rotated queue storage queue segment");
2086 }
2087 RotateOutcome::SkippedBusy { slot, busy } => {
2088 debug!(
2089 slot,
2090 ready_rows = busy.queue_ready,
2091 claim_attempt_batches = busy.queue_claim_attempt_batches,
2092 done_rows = busy.queue_done,
2093 ready_segments = busy.queue_ready_segments,
2094 receipt_completion_batches = busy.queue_receipt_completion_batches,
2095 receipt_completion_tombstones =
2096 busy.queue_receipt_completion_tombstones,
2097 "Skipped busy queue storage queue segment",
2098 );
2099 }
2100 }
2101 }
2102 Err(err) => {
2103 error!(error = %err, "Failed to rotate queue storage queue segments");
2104 return;
2105 }
2106 }
2107
2108 if prune_tracker.should_skip(PRUNE_BRANCH_QUEUE) {
2109 debug!(branch = PRUNE_BRANCH_QUEUE, "Prune backed off this tick");
2110 return;
2111 }
2112
2113 match runtime
2114 .store
2115 .prune_oldest(&self.pool, self.failed_retention)
2116 .await
2117 {
2118 Ok(outcome) => {
2119 self.metrics.record_prune_outcome("queue", &outcome);
2120 prune_tracker.record_outcome(PRUNE_BRANCH_QUEUE, &outcome);
2121 match outcome {
2122 PruneOutcome::Noop => {}
2123 PruneOutcome::Pruned {
2124 slot,
2125 carried_failed_rows,
2126 } => {
2127 debug!(
2128 slot,
2129 carried_failed_rows, "Pruned queue storage queue segment"
2130 );
2131 }
2132 PruneOutcome::Blocked { slot } => {
2133 debug!(slot, "Queue storage queue segment prune blocked");
2134 }
2135 PruneOutcome::SkippedActive {
2136 slot,
2137 reason,
2138 count,
2139 } => {
2140 debug!(
2141 slot,
2142 reason = reason.as_str(),
2143 count,
2144 "Queue storage queue segment still active",
2145 );
2146 }
2147 }
2148 }
2149 Err(err) => {
2150 error!(error = %err, "Failed to prune queue storage queue segments");
2151 }
2152 }
2153 }
2154
2155 async fn rotate_queue_storage_leases(&self, prune_tracker: &PruneBackoffTracker) {
2156 let Some(runtime) = self.storage.queue_storage() else {
2157 return;
2158 };
2159
2160 match runtime.store.rotate_leases(&self.pool).await {
2161 Ok(outcome) => {
2162 self.metrics.record_rotate_outcome("lease", &outcome);
2163 match outcome {
2164 RotateOutcome::Rotated { slot, generation } => {
2165 debug!(slot, generation, "Rotated queue storage lease segment");
2166 }
2167 RotateOutcome::SkippedBusy { slot, busy } => {
2168 debug!(
2169 slot,
2170 lease_rows = busy.leases,
2171 "Skipped busy queue storage lease segment",
2172 );
2173 }
2174 }
2175 }
2176 Err(err) => {
2177 error!(error = %err, "Failed to rotate queue storage lease segments");
2178 return;
2179 }
2180 }
2181
2182 if prune_tracker.should_skip(PRUNE_BRANCH_LEASE) {
2183 debug!(branch = PRUNE_BRANCH_LEASE, "Prune backed off this tick");
2184 return;
2185 }
2186
2187 match runtime.store.prune_oldest_leases(&self.pool).await {
2188 Ok(outcome) => {
2189 self.metrics.record_prune_outcome("lease", &outcome);
2190 prune_tracker.record_outcome(PRUNE_BRANCH_LEASE, &outcome);
2191 match outcome {
2192 PruneOutcome::Noop => {}
2193 PruneOutcome::Pruned { slot, .. } => {
2194 debug!(slot, "Pruned queue storage lease segment");
2195 }
2196 PruneOutcome::Blocked { slot } => {
2197 debug!(slot, "Queue storage lease segment prune blocked");
2198 }
2199 PruneOutcome::SkippedActive {
2200 slot,
2201 reason,
2202 count,
2203 } => {
2204 debug!(
2205 slot,
2206 reason = reason.as_str(),
2207 count,
2208 "Queue storage lease segment still active",
2209 );
2210 }
2211 }
2212 }
2213 Err(err) => {
2214 error!(error = %err, "Failed to prune queue storage lease segments");
2215 }
2216 }
2217 }
2218
2219 async fn rotate_queue_storage_claims(&self, prune_tracker: &PruneBackoffTracker) {
2223 let Some(runtime) = self.storage.queue_storage() else {
2224 return;
2225 };
2226
2227 match runtime.store.rotate_claims(&self.pool).await {
2228 Ok(outcome) => {
2229 self.metrics.record_rotate_outcome("claim", &outcome);
2230 match outcome {
2231 RotateOutcome::Rotated { slot, generation } => {
2232 debug!(slot, generation, "Rotated queue storage claim segment");
2233 }
2234 RotateOutcome::SkippedBusy { slot, busy } => {
2235 debug!(
2236 slot,
2237 claim_rows = busy.claims,
2238 closure_rows = busy.closures,
2239 closure_batch_rows = busy.closure_batches,
2240 "Skipped busy queue storage claim segment",
2241 );
2242 }
2243 }
2244 }
2245 Err(err) => {
2246 error!(error = %err, "Failed to rotate queue storage claim segments");
2247 return;
2248 }
2249 }
2250
2251 if prune_tracker.should_skip(PRUNE_BRANCH_CLAIM) {
2252 debug!(branch = PRUNE_BRANCH_CLAIM, "Prune backed off this tick");
2253 return;
2254 }
2255
2256 match runtime.store.prune_oldest_claims(&self.pool).await {
2257 Ok(outcome) => {
2258 self.metrics.record_prune_outcome("claim", &outcome);
2259 prune_tracker.record_outcome(PRUNE_BRANCH_CLAIM, &outcome);
2260 match outcome {
2261 PruneOutcome::Noop => {}
2262 PruneOutcome::Pruned { slot, .. } => {
2263 debug!(slot, "Pruned queue storage claim segment");
2264 }
2265 PruneOutcome::Blocked { slot } => {
2266 debug!(slot, "Queue storage claim segment prune blocked");
2267 }
2268 PruneOutcome::SkippedActive {
2269 slot,
2270 reason,
2271 count,
2272 } => {
2273 debug!(
2274 slot,
2275 reason = reason.as_str(),
2276 count,
2277 "Queue storage claim segment still active",
2278 );
2279 }
2280 }
2281 }
2282 Err(err) => {
2283 error!(error = %err, "Failed to prune queue storage claim segments");
2284 }
2285 }
2286 }
2287
2288 #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
2295 async fn cleanup_completed(&self) {
2296 if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
2297 return;
2299 }
2300
2301 let mut total_deleted: u64 = 0;
2302
2303 let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
2305
2306 let completed_retention_secs =
2308 i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
2309 let failed_retention_secs =
2310 i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
2311
2312 let global_result = if override_queues.is_empty() {
2313 sqlx::query(
2314 r#"
2315 DELETE FROM awa.jobs_hot
2316 WHERE id IN (
2317 SELECT id FROM awa.jobs_hot
2318 WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2319 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
2320 LIMIT $3
2321 )
2322 "#,
2323 )
2324 .bind(completed_retention_secs)
2325 .bind(failed_retention_secs)
2326 .bind(self.cleanup_batch_size)
2327 .execute(&self.pool)
2328 .await
2329 } else {
2330 sqlx::query(
2331 r#"
2332 DELETE FROM awa.jobs_hot
2333 WHERE id IN (
2334 SELECT id FROM awa.jobs_hot
2335 WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2336 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2337 AND queue != ALL($4::text[])
2338 LIMIT $3
2339 )
2340 "#,
2341 )
2342 .bind(completed_retention_secs)
2343 .bind(failed_retention_secs)
2344 .bind(self.cleanup_batch_size)
2345 .bind(&override_queues)
2346 .execute(&self.pool)
2347 .await
2348 };
2349
2350 match global_result {
2351 Ok(result) if result.rows_affected() > 0 => {
2352 total_deleted += result.rows_affected();
2353 }
2354 Err(err) => {
2355 error!(error = %err, "Failed to clean up old jobs (global pass)");
2356 }
2357 _ => {}
2358 }
2359
2360 for (queue_name, policy) in &self.queue_retention_overrides {
2362 let queue_completed_secs =
2363 i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
2364 let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
2365
2366 match sqlx::query(
2367 r#"
2368 DELETE FROM awa.jobs_hot
2369 WHERE id IN (
2370 SELECT id FROM awa.jobs_hot
2371 WHERE queue = $4
2372 AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2373 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2374 LIMIT $3
2375 )
2376 "#,
2377 )
2378 .bind(queue_completed_secs)
2379 .bind(queue_failed_secs)
2380 .bind(self.cleanup_batch_size)
2381 .bind(queue_name)
2382 .execute(&self.pool)
2383 .await
2384 {
2385 Ok(result) if result.rows_affected() > 0 => {
2386 total_deleted += result.rows_affected();
2387 debug!(
2388 queue = %queue_name,
2389 count = result.rows_affected(),
2390 "Cleaned up old jobs (queue override)"
2391 );
2392 }
2393 Err(err) => {
2394 error!(
2395 queue = %queue_name,
2396 error = %err,
2397 "Failed to clean up old jobs (queue override)"
2398 );
2399 }
2400 _ => {}
2401 }
2402 }
2403
2404 if total_deleted > 0 {
2405 info!(count = total_deleted, "Cleaned up old jobs");
2406 }
2407 }
2408
2409 #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
2410 async fn cleanup_dlq_rows(&self) {
2411 let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
2412 return;
2413 };
2414
2415 let schema = runtime.store.schema();
2416 let override_queues: Vec<&str> = self
2417 .queue_retention_overrides
2418 .iter()
2419 .filter(|(_, policy)| policy.dlq.is_some())
2420 .map(|(queue, _)| queue.as_str())
2421 .collect();
2422 let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
2423
2424 let global_result = if override_queues.is_empty() {
2425 sqlx::query(&format!(
2426 r#"
2427 DELETE FROM {schema}.dlq_entries
2428 WHERE job_id IN (
2429 SELECT job_id FROM {schema}.dlq_entries
2430 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2431 LIMIT $2
2432 )
2433 "#
2434 ))
2435 .bind(retention_secs)
2436 .bind(self.dlq_cleanup_batch_size)
2437 .execute(&self.pool)
2438 .await
2439 } else {
2440 sqlx::query(&format!(
2441 r#"
2442 DELETE FROM {schema}.dlq_entries
2443 WHERE job_id IN (
2444 SELECT job_id FROM {schema}.dlq_entries
2445 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2446 AND queue != ALL($3::text[])
2447 LIMIT $2
2448 )
2449 "#
2450 ))
2451 .bind(retention_secs)
2452 .bind(self.dlq_cleanup_batch_size)
2453 .bind(&override_queues)
2454 .execute(&self.pool)
2455 .await
2456 };
2457
2458 match global_result {
2459 Ok(result) if result.rows_affected() > 0 => {
2460 self.metrics.record_dlq_purged(None, result.rows_affected());
2461 }
2462 Err(err) => {
2463 error!(error = %err, "Failed to clean up DLQ rows (global pass)");
2464 }
2465 _ => {}
2466 }
2467
2468 for (queue, policy) in &self.queue_retention_overrides {
2469 let Some(retention) = policy.dlq else {
2470 continue;
2471 };
2472 let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
2473 match sqlx::query(&format!(
2474 r#"
2475 DELETE FROM {schema}.dlq_entries
2476 WHERE job_id IN (
2477 SELECT job_id FROM {schema}.dlq_entries
2478 WHERE queue = $3
2479 AND dlq_at < now() - make_interval(secs => $1::bigint)
2480 LIMIT $2
2481 )
2482 "#
2483 ))
2484 .bind(retention_secs)
2485 .bind(self.dlq_cleanup_batch_size)
2486 .bind(queue)
2487 .execute(&self.pool)
2488 .await
2489 {
2490 Ok(result) if result.rows_affected() > 0 => {
2491 self.metrics
2492 .record_dlq_purged(Some(queue), result.rows_affected());
2493 }
2494 Err(err) => {
2495 error!(queue, error = %err, "Failed to clean up DLQ rows");
2496 }
2497 _ => {}
2498 }
2499 }
2500 }
2501}
2502
2503struct MaintenanceAliveGuard(Arc<AtomicBool>);
2504
2505impl Drop for MaintenanceAliveGuard {
2506 fn drop(&mut self) {
2507 self.0.store(false, Ordering::SeqCst);
2508 }
2509}
2510
2511fn compute_fire_times(
2517 row: &CronJobRow,
2518 now: chrono::DateTime<Utc>,
2519 limit: usize,
2520) -> Vec<chrono::DateTime<Utc>> {
2521 let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
2522 Ok(c) => c,
2523 Err(err) => {
2524 error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
2525 return Vec::new();
2526 }
2527 };
2528
2529 let tz: chrono_tz::Tz = match row.timezone.parse() {
2530 Ok(tz) => tz,
2531 Err(err) => {
2532 error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
2533 return Vec::new();
2534 }
2535 };
2536
2537 let search_start = match row.last_enqueued_at {
2538 Some(last) => last.with_timezone(&tz),
2539 None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
2544 };
2545
2546 let missed_fire_policy = match CronMissedFirePolicy::parse(&row.missed_fire_policy) {
2547 Ok(policy) => policy,
2548 Err(err) => {
2549 error!(cron_name = %row.name, error = %err, "Invalid cron missed-fire policy in database");
2550 return Vec::new();
2551 }
2552 };
2553 let should_catch_up =
2554 row.last_enqueued_at.is_some() && missed_fire_policy == CronMissedFirePolicy::CatchUp;
2555
2556 if !should_catch_up {
2557 return latest_due_fire(&cron, tz, search_start, row.last_enqueued_at, now)
2558 .into_iter()
2559 .collect();
2560 }
2561
2562 let mut fire_times = Vec::new();
2563 for fire_time in cron.iter_from(search_start) {
2564 let fire_utc = fire_time.with_timezone(&Utc);
2565
2566 if fire_utc > now {
2567 break;
2568 }
2569
2570 if let Some(last) = row.last_enqueued_at {
2571 if fire_utc <= last {
2572 continue;
2573 }
2574 }
2575
2576 fire_times.push(fire_utc);
2577 if fire_times.len() >= limit {
2578 break;
2579 }
2580 }
2581
2582 fire_times
2583}
2584
2585fn latest_due_fire(
2586 cron: &Cron,
2587 tz: chrono_tz::Tz,
2588 search_start: chrono::DateTime<chrono_tz::Tz>,
2589 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2590 now: chrono::DateTime<Utc>,
2591) -> Option<chrono::DateTime<Utc>> {
2592 let first_due = first_due_fire(cron, search_start, last_enqueued_at, now)?;
2593 let total_span_seconds = now.signed_duration_since(first_due).num_seconds().max(1);
2594 let mut lookback_seconds = 1_i64;
2595
2596 loop {
2597 let window_start_utc = (now - chrono::Duration::seconds(lookback_seconds)).max(first_due);
2601 let window_start = window_start_utc.with_timezone(&tz);
2602 let next_in_window = cron
2603 .iter_from(window_start)
2604 .next()
2605 .map(|fire_time| fire_time.with_timezone(&Utc));
2606
2607 if next_in_window.is_some_and(|fire_utc| {
2608 fire_utc <= now && last_enqueued_at.is_none_or(|last| fire_utc > last)
2609 }) {
2610 return latest_due_fire_in_window(cron, window_start, last_enqueued_at, now)
2611 .or(Some(first_due));
2612 }
2613
2614 if lookback_seconds >= total_span_seconds {
2615 return Some(first_due);
2616 }
2617
2618 lookback_seconds = lookback_seconds.saturating_mul(2).min(total_span_seconds);
2619 }
2620}
2621
2622fn first_due_fire(
2623 cron: &Cron,
2624 search_start: chrono::DateTime<chrono_tz::Tz>,
2625 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2626 now: chrono::DateTime<Utc>,
2627) -> Option<chrono::DateTime<Utc>> {
2628 for fire_time in cron.iter_from(search_start) {
2629 let fire_utc = fire_time.with_timezone(&Utc);
2630 if fire_utc > now {
2631 return None;
2632 }
2633 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2634 return Some(fire_utc);
2635 }
2636 }
2637
2638 None
2639}
2640
2641fn latest_due_fire_in_window(
2642 cron: &Cron,
2643 window_start: chrono::DateTime<chrono_tz::Tz>,
2644 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2645 now: chrono::DateTime<Utc>,
2646) -> Option<chrono::DateTime<Utc>> {
2647 let mut latest_fire = None;
2648
2649 for fire_time in cron.iter_from(window_start) {
2650 let fire_utc = fire_time.with_timezone(&Utc);
2651 if fire_utc > now {
2652 break;
2653 }
2654 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2655 latest_fire = Some(fire_utc);
2656 }
2657 }
2658
2659 latest_fire
2660}
2661
2662impl MaintenanceService {
2663 #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
2666 async fn cleanup_stale_runtime_snapshots(&self) {
2667 if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
2668 &self.pool,
2669 chrono::TimeDelta::try_hours(24).unwrap(),
2670 )
2671 .await
2672 {
2673 tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
2674 }
2675 }
2676
2677 #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
2682 async fn cleanup_stale_descriptors(&self) {
2683 if self.descriptor_retention.is_zero() {
2684 return;
2685 }
2686 let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
2687 .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
2688 for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
2689 match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
2690 Ok(deleted) if deleted > 0 => {
2691 tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
2692 }
2693 Ok(_) => {}
2694 Err(err) => {
2695 tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
2696 }
2697 }
2698 }
2699 }
2700
2701 #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
2705 async fn recompute_dirty_admin_metadata(&self) {
2706 if self.storage.queue_storage().is_some() {
2707 return;
2708 }
2709 match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
2710 Ok(count) if count > 0 => {
2711 tracing::debug!(count, "Recomputed dirty admin metadata keys");
2712 }
2713 Err(err) => {
2714 tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
2715 }
2716 _ => {}
2717 }
2718 }
2719
2720 #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
2723 async fn refresh_admin_metadata(&self) {
2724 if self.storage.queue_storage().is_some() {
2725 return;
2726 }
2727 if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
2728 tracing::warn!(error = %err, "Failed to refresh admin metadata");
2729 }
2730 }
2731
2732 #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
2734 async fn publish_queue_health_metrics(&self) {
2735 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
2736 self.publish_queue_storage_health_metrics(runtime).await;
2737 return;
2738 }
2739
2740 let stats = match awa_model::admin::queue_overviews(&self.pool).await {
2741 Ok(stats) => stats,
2742 Err(err) => {
2743 tracing::warn!(error = %err, "Failed to query queue stats for metrics");
2744 return;
2745 }
2746 };
2747
2748 for queue_stat in &stats {
2749 let queue = &queue_stat.queue;
2750
2751 self.metrics
2753 .record_queue_depth(queue, "available", queue_stat.available);
2754 self.metrics
2755 .record_queue_depth(queue, "running", queue_stat.running);
2756 self.metrics
2757 .record_queue_depth(queue, "failed", queue_stat.failed);
2758 self.metrics
2759 .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
2760 self.metrics
2761 .record_queue_depth(queue, "retryable", queue_stat.retryable);
2762 self.metrics
2763 .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
2764
2765 if let Some(lag_seconds) = queue_stat.lag_seconds {
2767 self.metrics.record_queue_lag(queue, lag_seconds);
2768 }
2769 }
2770 }
2771
2772 async fn publish_queue_storage_health_metrics(
2773 &self,
2774 runtime: &crate::storage::QueueStorageRuntime,
2775 ) {
2776 let schema = runtime.store.schema();
2777 let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
2783 r#"
2784 WITH head_signal AS (
2785 SELECT
2786 enqueues.queue,
2787 enqueues.priority,
2788 enqueues.enqueue_shard,
2789 {schema}.sequence_next_value(enqueues.seq_name) AS next_seq,
2790 {schema}.sequence_next_value(claims.seq_name) AS claim_seq
2791 FROM {schema}.queue_enqueue_heads AS enqueues
2792 JOIN {schema}.queue_claim_heads AS claims
2793 ON claims.queue = enqueues.queue
2794 AND claims.priority = enqueues.priority
2795 AND claims.enqueue_shard = enqueues.enqueue_shard
2796 ),
2797 queues AS (
2798 SELECT DISTINCT queue
2799 FROM (
2800 SELECT queue FROM awa.queue_meta
2801 UNION ALL
2802 SELECT queue FROM head_signal
2803 UNION ALL
2804 SELECT queue FROM {schema}.leases
2805 UNION ALL
2806 SELECT queue FROM {schema}.deferred_jobs
2807 UNION ALL
2808 SELECT queue FROM {schema}.queue_terminal_live_counts
2809 UNION ALL
2810 SELECT queue FROM {schema}.queue_terminal_rollups
2811 UNION ALL
2812 SELECT queue FROM {schema}.dlq_entries
2813 ) queues
2814 ),
2815 ready AS (
2816 SELECT
2817 head_signal.queue,
2818 COALESCE(
2819 sum(GREATEST(head_signal.next_seq - head_signal.claim_seq, 0)),
2820 0
2821 )::bigint AS available
2822 FROM head_signal
2823 GROUP BY head_signal.queue
2824 ),
2825 lag AS (
2826 SELECT
2827 head_signal.queue,
2828 EXTRACT(EPOCH FROM clock_timestamp() - min(next_ready.run_at))::double precision
2829 AS lag_seconds
2830 FROM head_signal
2831 JOIN LATERAL (
2832 SELECT ready.run_at
2833 FROM {schema}.ready_entries AS ready
2834 WHERE ready.queue = head_signal.queue
2835 AND ready.priority = head_signal.priority
2836 AND ready.enqueue_shard = head_signal.enqueue_shard
2837 AND ready.lane_seq >= head_signal.claim_seq
2838 AND NOT EXISTS (
2839 SELECT 1
2840 FROM {schema}.ready_tombstones AS tomb
2841 WHERE tomb.ready_slot = ready.ready_slot
2842 AND tomb.ready_generation = ready.ready_generation
2843 AND tomb.queue = ready.queue
2844 AND tomb.priority = ready.priority
2845 AND tomb.enqueue_shard = ready.enqueue_shard
2846 AND tomb.lane_seq = ready.lane_seq
2847 )
2848 ORDER BY ready.lane_seq
2849 LIMIT 1
2850 ) AS next_ready ON TRUE
2851 GROUP BY head_signal.queue
2852 ),
2853 leases AS (
2854 SELECT
2855 queue,
2856 count(*) FILTER (WHERE state = 'running')::bigint AS running,
2857 count(*) FILTER (WHERE state = 'waiting_external')::bigint
2858 AS waiting_external
2859 FROM {schema}.leases
2860 GROUP BY queue
2861 ),
2862 deferred AS (
2863 SELECT
2864 queue,
2865 count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
2866 count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
2867 FROM {schema}.deferred_jobs
2868 GROUP BY queue
2869 ),
2870 terminal AS (
2871 SELECT
2872 queue,
2873 count(*)::bigint AS failed_done
2874 FROM {schema}.done_entries
2875 WHERE state = 'failed'
2876 GROUP BY queue
2877 ),
2878 dlq AS (
2879 SELECT
2880 queue,
2881 count(*)::bigint AS failed_dlq
2882 FROM {schema}.dlq_entries
2883 GROUP BY queue
2884 )
2885 SELECT
2886 queues.queue,
2887 COALESCE(ready.available, 0)::bigint AS available,
2888 COALESCE(leases.running, 0)::bigint AS running,
2889 COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
2890 COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
2891 COALESCE(deferred.retryable, 0)::bigint AS retryable,
2892 COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
2893 COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
2894 lag.lag_seconds
2895 FROM queues
2896 LEFT JOIN ready
2897 ON ready.queue = queues.queue
2898 LEFT JOIN lag
2899 ON lag.queue = queues.queue
2900 LEFT JOIN leases
2901 ON leases.queue = queues.queue
2902 LEFT JOIN deferred
2903 ON deferred.queue = queues.queue
2904 LEFT JOIN terminal
2905 ON terminal.queue = queues.queue
2906 LEFT JOIN dlq
2907 ON dlq.queue = queues.queue
2908 ORDER BY queues.queue
2909 "#
2910 ))
2911 .fetch_all(&self.pool)
2912 .await
2913 {
2914 Ok(rows) => rows,
2915 Err(err) => {
2916 tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
2917 return;
2918 }
2919 };
2920
2921 for (
2922 queue,
2923 available,
2924 running,
2925 waiting_external,
2926 scheduled,
2927 retryable,
2928 failed_done,
2929 failed_dlq,
2930 lag_seconds,
2931 ) in rows
2932 {
2933 self.metrics
2934 .record_queue_depth(&queue, "available", available);
2935 self.metrics.record_queue_depth(&queue, "running", running);
2936 self.metrics
2937 .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
2938 self.metrics
2939 .record_queue_depth(&queue, "scheduled", scheduled);
2940 self.metrics
2941 .record_queue_depth(&queue, "retryable", retryable);
2942 self.metrics
2943 .record_queue_depth(&queue, "waiting_external", waiting_external);
2944 self.metrics.record_dlq_depth(&queue, failed_dlq);
2945
2946 if let Some(lag_seconds) = lag_seconds {
2947 self.metrics.record_queue_lag(&queue, lag_seconds);
2948 }
2949 }
2950 }
2951}
2952
2953#[cfg(test)]
2954mod tests {
2955 use super::*;
2956 use awa_model::{migrations, QueueStorage, QueueStorageConfig};
2957 use chrono::TimeZone;
2958 use sqlx::postgres::PgPoolOptions;
2959 use std::sync::OnceLock;
2960
2961 fn cron_row(
2962 cron_expr: &str,
2963 created_at: chrono::DateTime<Utc>,
2964 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2965 missed_fire_policy: CronMissedFirePolicy,
2966 ) -> CronJobRow {
2967 CronJobRow {
2968 name: "test_cron".to_string(),
2969 cron_expr: cron_expr.to_string(),
2970 timezone: "UTC".to_string(),
2971 kind: "test_job".to_string(),
2972 queue: "default".to_string(),
2973 args: serde_json::json!({}),
2974 priority: 2,
2975 max_attempts: 25,
2976 tags: Vec::new(),
2977 metadata: serde_json::json!({}),
2978 missed_fire_policy: missed_fire_policy.as_str().to_string(),
2979 last_enqueued_at,
2980 created_at,
2981 updated_at: created_at,
2982 paused_at: None,
2983 paused_by: None,
2984 }
2985 }
2986
2987 #[test]
2988 fn compute_fire_times_coalesces_missed_existing_fires_by_default() {
2989 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
2990 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
2991 let row = cron_row(
2992 "*/5 * * * * *",
2993 last,
2994 Some(last),
2995 CronMissedFirePolicy::Coalesce,
2996 );
2997
2998 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
2999
3000 assert_eq!(
3001 fires,
3002 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3003 );
3004 }
3005
3006 #[test]
3007 fn compute_fire_times_coalesces_to_latest_fire_after_long_outage() {
3008 let last = Utc.with_ymd_and_hms(2026, 5, 6, 12, 0, 0).unwrap();
3009 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3010 let row = cron_row(
3011 "*/1 * * * * *",
3012 last,
3013 Some(last),
3014 CronMissedFirePolicy::Coalesce,
3015 );
3016
3017 let fires = compute_fire_times(&row, now, 2);
3018
3019 assert_eq!(
3020 fires,
3021 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3022 );
3023 }
3024
3025 #[test]
3026 fn compute_fire_times_catches_up_when_policy_requests_it() {
3027 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3028 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3029 let row = cron_row(
3030 "*/5 * * * * *",
3031 last,
3032 Some(last),
3033 CronMissedFirePolicy::CatchUp,
3034 );
3035
3036 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3037
3038 assert_eq!(
3039 fires,
3040 vec![
3041 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3042 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3043 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 15).unwrap(),
3044 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap(),
3045 ]
3046 );
3047 }
3048
3049 #[test]
3050 fn compute_fire_times_limits_catch_up_work() {
3051 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3052 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3053 let row = cron_row(
3054 "*/5 * * * * *",
3055 last,
3056 Some(last),
3057 CronMissedFirePolicy::CatchUp,
3058 );
3059
3060 let fires = compute_fire_times(&row, now, 2);
3061
3062 assert_eq!(
3063 fires,
3064 vec![
3065 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3066 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3067 ]
3068 );
3069 }
3070
3071 fn metrics_for_test() -> crate::metrics::AwaMetrics {
3074 crate::metrics::AwaMetrics::from_global()
3075 }
3076
3077 fn database_url() -> String {
3078 std::env::var("DATABASE_URL")
3079 .unwrap_or_else(|_| "postgres://postgres:test@localhost:15432/awa_test".to_string())
3080 }
3081
3082 fn db_test_mutex() -> &'static tokio::sync::Mutex<()> {
3083 static MUTEX: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
3084 MUTEX.get_or_init(|| tokio::sync::Mutex::new(()))
3085 }
3086
3087 async fn ensure_database_exists(url: &str) {
3088 let parts = url
3089 .rsplit_once('/')
3090 .expect("DATABASE_URL must include a database name");
3091 let database_name = parts.1.to_string();
3092 let admin_url = format!("{}/postgres", parts.0);
3093 let admin_pool = PgPoolOptions::new()
3094 .max_connections(1)
3095 .connect(&admin_url)
3096 .await
3097 .expect("Failed to connect to admin database for maintenance tests");
3098 let create_sql = format!("CREATE DATABASE {database_name}");
3099 match sqlx::query(&create_sql).execute(&admin_pool).await {
3100 Ok(_) => {}
3101 Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("42P04") => {}
3102 Err(err) => panic!("Failed to create maintenance test database {database_name}: {err}"),
3103 }
3104 }
3105
3106 async fn setup_pool(max_connections: u32) -> PgPool {
3107 let url = database_url();
3108 ensure_database_exists(&url).await;
3109 PgPoolOptions::new()
3110 .max_connections(max_connections)
3111 .acquire_timeout(Duration::from_secs(5))
3112 .connect(&url)
3113 .await
3114 .expect("Failed to connect to maintenance test database")
3115 }
3116
3117 async fn reset_schema(pool: &PgPool) {
3118 sqlx::raw_sql("DROP SCHEMA IF EXISTS awa CASCADE")
3119 .execute(pool)
3120 .await
3121 .expect("Failed to drop awa schema");
3122 }
3123
3124 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3125 async fn queue_storage_metrics_query_uses_bounded_observability_path() {
3126 let _guard = db_test_mutex().lock().await;
3127 let pool = setup_pool(4).await;
3128 reset_schema(&pool).await;
3129 migrations::run(&pool)
3130 .await
3131 .expect("migrations should succeed");
3132
3133 let store = QueueStorage::new(QueueStorageConfig::default()).expect("queue storage");
3134 store.install(&pool).await.expect("queue storage install");
3135
3136 let runtime = crate::storage::QueueStorageRuntime::new(
3137 QueueStorageConfig::default(),
3138 Duration::from_millis(1000),
3139 Duration::from_millis(250),
3140 )
3141 .expect("queue storage runtime");
3142 let service = MaintenanceService::new(
3143 pool,
3144 metrics_for_test(),
3145 Arc::new(AtomicBool::new(true)),
3146 Arc::new(AtomicBool::new(true)),
3147 CancellationToken::new(),
3148 Arc::new(Vec::new()),
3149 InFlightMap::default(),
3150 RuntimeStorage::QueueStorage(runtime.clone()),
3151 Arc::new(HashMap::new()),
3152 Arc::new(HashMap::new()),
3153 );
3154
3155 let mut receipt_lock_tx = service
3156 .pool
3157 .begin()
3158 .await
3159 .expect("begin receipt lock transaction");
3160 sqlx::query(
3161 "LOCK TABLE awa.lease_claims, awa.lease_claim_closures, awa.lease_claim_closure_batches IN ACCESS EXCLUSIVE MODE",
3162 )
3163 .execute(receipt_lock_tx.as_mut())
3164 .await
3165 .expect("lock receipt tables");
3166
3167 tokio::time::timeout(
3168 Duration::from_secs(3),
3169 service.publish_queue_storage_health_metrics(&runtime),
3170 )
3171 .await
3172 .expect("queue-storage metrics must not wait on receipt tables");
3173
3174 receipt_lock_tx
3175 .rollback()
3176 .await
3177 .expect("release receipt table locks");
3178 }
3179
3180 #[test]
3181 fn branch_tracker_initial_state_has_no_history() {
3182 let tracker = MaintenanceBranchTracker::new();
3183 assert_eq!(tracker.snapshot("promote_scheduled"), None);
3184 }
3185
3186 #[test]
3187 fn branch_tracker_finish_records_last_duration() {
3188 let tracker = MaintenanceBranchTracker::new();
3189 let metrics = metrics_for_test();
3190 let timer = tracker
3191 .try_begin("promote_scheduled", Duration::from_secs(1), &metrics)
3192 .expect("first tick should not be skipped");
3193 timer.finish();
3195 let (last_duration, is_delayed) = tracker
3196 .snapshot("promote_scheduled")
3197 .expect("snapshot should exist after one finish");
3198 assert!(last_duration.is_some());
3199 assert!(
3200 !is_delayed,
3201 "first tick has no prior duration → not delayed"
3202 );
3203 }
3204
3205 fn replay_ticks(
3217 tracker: &MaintenanceBranchTracker,
3218 branch: &'static str,
3219 body_duration: Duration,
3220 tick_interval: Duration,
3221 n: u32,
3222 ) -> Vec<bool> {
3223 let metrics = metrics_for_test();
3224 let mut ran = Vec::with_capacity(n as usize);
3225 for _ in 0..n {
3226 let timer_opt = tracker.try_begin(branch, tick_interval, &metrics);
3227 let did_run = timer_opt.is_some();
3228 ran.push(did_run);
3229 if did_run {
3230 tracker.record_finish(branch, body_duration);
3231 }
3232 }
3233 ran
3234 }
3235
3236 fn seed_last_duration(tracker: &MaintenanceBranchTracker, branch: &'static str, dur: Duration) {
3240 tracker
3241 .branches
3242 .lock()
3243 .unwrap()
3244 .entry(branch)
3245 .or_default()
3246 .last_duration = Some(dur);
3247 }
3248
3249 #[test]
3250 fn branch_tracker_single_overrun_does_not_flip() {
3251 let tracker = MaintenanceBranchTracker::new();
3254 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3255 replay_ticks(
3256 &tracker,
3257 "cleanup",
3258 Duration::from_millis(200), Duration::from_millis(100),
3260 1,
3261 );
3262 assert!(
3263 !tracker.snapshot("cleanup").unwrap().1,
3264 "single overrun must not flip is_delayed (K=3 required)"
3265 );
3266 }
3267
3268 #[test]
3269 fn branch_tracker_deadband_sample_does_not_advance_counters() {
3270 let tracker = MaintenanceBranchTracker::new();
3275 seed_last_duration(&tracker, "cleanup", Duration::from_millis(101));
3276 replay_ticks(
3277 &tracker,
3278 "cleanup",
3279 Duration::from_millis(101),
3280 Duration::from_millis(100),
3281 5,
3282 );
3283 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3284 assert_eq!(cooldown, 0, "deadband samples should not arm cooldown");
3285 assert_eq!(overrun, 0, "deadband sample 101ms must not advance overrun");
3286 assert_eq!(ontime, 0, "deadband sample 101ms must not advance ontime");
3287 }
3288
3289 #[test]
3290 fn branch_tracker_k_consecutive_overruns_flips_and_arms_cooldown() {
3291 let tracker = MaintenanceBranchTracker::new();
3297 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3298 let ran = replay_ticks(
3299 &tracker,
3300 "cleanup",
3301 Duration::from_millis(250),
3302 Duration::from_millis(100),
3303 OVERRUN_HYSTERESIS_K,
3304 );
3305 assert_eq!(ran, vec![true, true, false], "flip-tick skips body");
3306 let (cooldown, overrun, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3307 assert!(tracker.snapshot("cleanup").unwrap().1, "flipped to delayed");
3308 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3309 assert_eq!(
3310 cooldown, BRANCH_COOLDOWN_TICKS,
3311 "cooldown armed to BRANCH_COOLDOWN_TICKS at flip"
3312 );
3313 }
3314
3315 #[test]
3316 fn branch_tracker_without_cooldown_tracks_overrun_but_keeps_running() {
3317 let tracker = MaintenanceBranchTracker::new();
3318 let metrics = metrics_for_test();
3319 seed_last_duration(
3320 &tracker,
3321 "terminal_count_rollup",
3322 Duration::from_millis(250),
3323 );
3324
3325 let mut ran = Vec::new();
3326 for _ in 0..OVERRUN_HYSTERESIS_K {
3327 let timer = tracker.try_begin_without_cooldown(
3328 "terminal_count_rollup",
3329 Duration::from_millis(100),
3330 &metrics,
3331 );
3332 ran.push(timer.is_some());
3333 if timer.is_some() {
3334 tracker.record_finish("terminal_count_rollup", Duration::from_millis(250));
3335 }
3336 }
3337
3338 assert_eq!(
3339 ran,
3340 vec![true, true, true],
3341 "no-cooldown branches should keep running on overrun"
3342 );
3343 let (cooldown, overrun, _) = tracker
3344 .cooldown_snapshot("terminal_count_rollup")
3345 .expect("snapshot");
3346 assert_eq!(cooldown, 0, "no-cooldown branch must not arm cooldown");
3347 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3348 assert!(
3349 tracker.snapshot("terminal_count_rollup").unwrap().1,
3350 "overrun state should still be observable"
3351 );
3352 }
3353
3354 #[test]
3355 fn branch_tracker_cooldown_skips_body() {
3356 let tracker = MaintenanceBranchTracker::new();
3362 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3363 replay_ticks(
3364 &tracker,
3365 "cleanup",
3366 Duration::from_millis(250),
3367 Duration::from_millis(100),
3368 OVERRUN_HYSTERESIS_K,
3369 );
3370 let ran = replay_ticks(
3374 &tracker,
3375 "cleanup",
3376 Duration::from_millis(50),
3377 Duration::from_millis(100),
3378 BRANCH_COOLDOWN_TICKS,
3379 );
3380 assert!(
3381 ran.iter().all(|&r| !r),
3382 "every tick during cooldown must skip body"
3383 );
3384 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3385 assert_eq!(cooldown, 0, "cooldown decrements to zero");
3386 }
3387
3388 #[test]
3389 fn branch_tracker_cooldown_expires_then_body_runs() {
3390 let tracker = MaintenanceBranchTracker::new();
3397 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3398 replay_ticks(
3399 &tracker,
3400 "cleanup",
3401 Duration::from_millis(250),
3402 Duration::from_millis(100),
3403 OVERRUN_HYSTERESIS_K,
3404 );
3405 replay_ticks(
3406 &tracker,
3407 "cleanup",
3408 Duration::from_millis(50),
3409 Duration::from_millis(100),
3410 BRANCH_COOLDOWN_TICKS,
3411 );
3412 let ran = replay_ticks(
3413 &tracker,
3414 "cleanup",
3415 Duration::from_millis(50),
3416 Duration::from_millis(100),
3417 1,
3418 );
3419 assert_eq!(ran, vec![true], "post-cooldown body runs");
3420 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3421 assert_eq!(
3422 cooldown, 0,
3423 "cooldown stays at zero after a single fast body"
3424 );
3425 assert_eq!(
3426 overrun, OVERRUN_HYSTERESIS_K,
3427 "consecutive_overrun preserved across cooldown (no eval on this tick)"
3428 );
3429 assert_eq!(
3430 ontime, 0,
3431 "ontime advances on the next tick — this one had no sample to evaluate"
3432 );
3433 }
3434
3435 #[test]
3436 fn branch_tracker_cooldown_rearms_on_continued_overrun() {
3437 let tracker = MaintenanceBranchTracker::new();
3443 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3444 replay_ticks(
3445 &tracker,
3446 "cleanup",
3447 Duration::from_millis(250),
3448 Duration::from_millis(100),
3449 OVERRUN_HYSTERESIS_K,
3450 );
3451 replay_ticks(
3452 &tracker,
3453 "cleanup",
3454 Duration::from_millis(50),
3455 Duration::from_millis(100),
3456 BRANCH_COOLDOWN_TICKS,
3457 );
3458
3459 let ran = replay_ticks(
3463 &tracker,
3464 "cleanup",
3465 Duration::from_millis(250),
3466 Duration::from_millis(100),
3467 2,
3468 );
3469 assert_eq!(
3470 ran,
3471 vec![true, false],
3472 "first tick runs body; second tick re-arms cooldown"
3473 );
3474 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3475 assert_eq!(cooldown, BRANCH_COOLDOWN_TICKS, "cooldown re-armed");
3476 assert!(
3477 tracker.snapshot("cleanup").unwrap().1,
3478 "still delayed across re-arm"
3479 );
3480 }
3481
3482 #[test]
3483 fn branch_tracker_intermittent_overrun_does_not_flip() {
3484 let tracker = MaintenanceBranchTracker::new();
3488 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3489 for over in [true, false, true, false, true] {
3490 let dur = if over {
3491 Duration::from_millis(200)
3492 } else {
3493 Duration::from_millis(50)
3494 };
3495 replay_ticks(&tracker, "cleanup", dur, Duration::from_millis(100), 1);
3496 }
3497 assert!(
3498 !tracker.snapshot("cleanup").unwrap().1,
3499 "intermittent overruns must not flip"
3500 );
3501 }
3502
3503 #[test]
3504 fn branch_tracker_recovers_only_after_k_ontime_ticks_post_cooldown() {
3505 let tracker = MaintenanceBranchTracker::new();
3511 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3512 replay_ticks(
3513 &tracker,
3514 "cleanup",
3515 Duration::from_millis(250),
3516 Duration::from_millis(100),
3517 OVERRUN_HYSTERESIS_K,
3518 );
3519 replay_ticks(
3520 &tracker,
3521 "cleanup",
3522 Duration::from_millis(50),
3523 Duration::from_millis(100),
3524 BRANCH_COOLDOWN_TICKS,
3525 );
3526
3527 replay_ticks(
3530 &tracker,
3531 "cleanup",
3532 Duration::from_millis(50),
3533 Duration::from_millis(100),
3534 OVERRUN_HYSTERESIS_K,
3535 );
3536 assert!(
3537 tracker.snapshot("cleanup").unwrap().1,
3538 "still delayed after only K-1 evaluations"
3539 );
3540
3541 replay_ticks(
3543 &tracker,
3544 "cleanup",
3545 Duration::from_millis(50),
3546 Duration::from_millis(100),
3547 1,
3548 );
3549 assert!(
3550 !tracker.snapshot("cleanup").unwrap().1,
3551 "recovered after K evaluable on-time samples"
3552 );
3553 }
3554
3555 #[test]
3556 fn branch_tracker_per_branch_state_is_independent() {
3557 let tracker = MaintenanceBranchTracker::new();
3561 seed_last_duration(&tracker, "cleanup", Duration::from_millis(500));
3562 replay_ticks(
3563 &tracker,
3564 "cleanup",
3565 Duration::from_millis(500),
3566 Duration::from_millis(100),
3567 OVERRUN_HYSTERESIS_K,
3568 );
3569 seed_last_duration(&tracker, "promote_scheduled", Duration::from_millis(10));
3570 replay_ticks(
3571 &tracker,
3572 "promote_scheduled",
3573 Duration::from_millis(10),
3574 Duration::from_millis(250),
3575 OVERRUN_HYSTERESIS_K,
3576 );
3577 assert!(tracker.snapshot("cleanup").unwrap().1);
3578 assert!(!tracker.snapshot("promote_scheduled").unwrap().1);
3579 }
3580
3581 fn skip_active(slot: i32) -> PruneOutcome {
3584 PruneOutcome::SkippedActive {
3585 slot,
3586 reason: SkipReason::LeaseActive,
3587 count: 1,
3588 }
3589 }
3590
3591 #[test]
3592 fn prune_backoff_initial_state_does_not_skip() {
3593 let tracker = PruneBackoffTracker::new();
3594 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3595 assert_eq!(
3596 tracker.snapshot(PRUNE_BRANCH_LEASE),
3597 Some((0, 0)),
3598 "polling once must not introduce backoff"
3599 );
3600 }
3601
3602 #[test]
3603 fn prune_backoff_skipped_active_doubles_then_resets_on_pruned() {
3604 let tracker = PruneBackoffTracker::new();
3605
3606 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3608 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3609 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3610 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3611 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3612
3613 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3615 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3616
3617 tracker.record_outcome(
3619 PRUNE_BRANCH_LEASE,
3620 &PruneOutcome::Pruned {
3621 slot: 0,
3622 carried_failed_rows: 0,
3623 },
3624 );
3625 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((0, 0)));
3626 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3627 }
3628
3629 #[test]
3630 fn prune_backoff_blocked_increases_level_same_as_skipped_active() {
3631 let tracker = PruneBackoffTracker::new();
3632 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3633 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3634 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3635 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3636 }
3637
3638 #[test]
3639 fn prune_backoff_noop_is_neutral() {
3640 let tracker = PruneBackoffTracker::new();
3641 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3643 let before = tracker.snapshot(PRUNE_BRANCH_LEASE);
3644 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Noop);
3645 let after = tracker.snapshot(PRUNE_BRANCH_LEASE);
3646 assert_eq!(
3647 before, after,
3648 "Noop must not change backoff state — there was nothing to do, not a failure"
3649 );
3650 }
3651
3652 #[test]
3653 fn prune_backoff_caps_at_max_level() {
3654 let tracker = PruneBackoffTracker::new();
3655 for _ in 0..(MAX_PRUNE_BACKOFF_LEVEL as u32 + 5) {
3657 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3658 }
3659 let (skip_remaining, backoff_level) =
3660 tracker.snapshot(PRUNE_BRANCH_LEASE).expect("snapshot");
3661 assert_eq!(backoff_level, MAX_PRUNE_BACKOFF_LEVEL);
3662 assert_eq!(skip_remaining, 1u32 << MAX_PRUNE_BACKOFF_LEVEL);
3663 }
3664
3665 #[test]
3666 fn prune_backoff_per_branch_state_is_independent() {
3667 let tracker = PruneBackoffTracker::new();
3668 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3669 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3670 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3672 assert_eq!(tracker.snapshot(PRUNE_BRANCH_CLAIM), None);
3673 assert!(!tracker.should_skip(PRUNE_BRANCH_CLAIM));
3674 }
3675
3676 #[test]
3677 fn compute_fire_times_keeps_first_registration_latest_only() {
3678 let created_at = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3679 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap();
3680 let row = cron_row(
3681 "*/5 * * * * *",
3682 created_at,
3683 None,
3684 CronMissedFirePolicy::CatchUp,
3685 );
3686
3687 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3688
3689 assert_eq!(
3690 fires,
3691 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap()]
3692 );
3693 }
3694}