1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::{QueueStorageRuntime, 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#"
50 WITH deleted AS (
51 DELETE FROM awa.jobs_hot
52 WHERE id = $1
53 AND state = 'running'
54 AND heartbeat_at < now() - ($2 * interval '1 millisecond')
55 RETURNING *
56 )
57 INSERT INTO awa.scheduled_jobs (
58 id, kind, queue, args, state, priority, attempt, max_attempts,
59 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
60 created_at, errors, metadata, tags, unique_key, unique_states,
61 callback_id, callback_timeout_at, callback_filter,
62 callback_on_complete, callback_on_fail, callback_transform,
63 run_lease, progress
64 )
65 SELECT
66 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
67 run_at, NULL, NULL, attempted_at, now(),
68 created_at,
69 errors || jsonb_build_object(
70 'error', 'heartbeat stale: worker presumed dead',
71 'attempt', attempt,
72 'at', now()
73 )::jsonb,
74 metadata, tags, unique_key, unique_states,
75 NULL, NULL, NULL, NULL, NULL, NULL,
76 run_lease, progress
77 FROM deleted
78 RETURNING *
79"#;
80
81const DEADLINE_RESCUE_PER_ROW_SQL: &str = r#"
82 WITH deleted AS (
83 DELETE FROM awa.jobs_hot
84 WHERE id = $1
85 AND state = 'running'
86 AND deadline_at IS NOT NULL
87 AND deadline_at < now()
88 RETURNING *
89 )
90 INSERT INTO awa.scheduled_jobs (
91 id, kind, queue, args, state, priority, attempt, max_attempts,
92 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
93 created_at, errors, metadata, tags, unique_key, unique_states,
94 callback_id, callback_timeout_at, callback_filter,
95 callback_on_complete, callback_on_fail, callback_transform,
96 run_lease, progress
97 )
98 SELECT
99 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
100 run_at, NULL, NULL, attempted_at, now(),
101 created_at,
102 errors || jsonb_build_object(
103 'error', 'hard deadline exceeded',
104 'attempt', attempt,
105 'at', now()
106 )::jsonb,
107 metadata, tags, unique_key, unique_states,
108 NULL, NULL, NULL, NULL, NULL, NULL,
109 run_lease, progress
110 FROM deleted
111 RETURNING *
112"#;
113
114const CALLBACK_RESCUE_PER_ROW_SQL: &str = r#"
115 WITH candidate AS (
116 SELECT id, attempt, max_attempts FROM awa.jobs_hot
117 WHERE id = $1
118 AND state = 'waiting_external'
119 AND callback_timeout_at IS NOT NULL
120 AND callback_timeout_at < now()
121 FOR UPDATE
122 ),
123 failed AS (
124 UPDATE awa.jobs_hot
125 SET state = 'failed',
126 finalized_at = now(),
127 callback_id = NULL,
128 callback_timeout_at = NULL,
129 callback_filter = NULL,
130 callback_on_complete = NULL,
131 callback_on_fail = NULL,
132 callback_transform = NULL,
133 errors = errors || jsonb_build_object(
134 'error', 'callback timed out',
135 'attempt', attempt,
136 'at', now()
137 )::jsonb
138 WHERE id IN (SELECT id FROM candidate WHERE attempt >= max_attempts)
139 RETURNING *
140 ),
141 deleted AS (
142 DELETE FROM awa.jobs_hot
143 WHERE id IN (SELECT id FROM candidate WHERE attempt < max_attempts)
144 RETURNING *
145 ),
146 moved AS (
147 INSERT INTO awa.scheduled_jobs (
148 id, kind, queue, args, state, priority, attempt, max_attempts,
149 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
150 created_at, errors, metadata, tags, unique_key, unique_states,
151 callback_id, callback_timeout_at, callback_filter,
152 callback_on_complete, callback_on_fail, callback_transform,
153 run_lease, progress
154 )
155 SELECT
156 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
157 now() + awa.backoff_duration(attempt, max_attempts),
158 heartbeat_at, deadline_at, attempted_at, now(),
159 created_at,
160 errors || jsonb_build_object(
161 'error', 'callback timed out',
162 'attempt', attempt,
163 'at', now()
164 )::jsonb,
165 metadata, tags, unique_key, unique_states,
166 NULL, NULL, NULL, NULL, NULL, NULL,
167 run_lease, progress
168 FROM deleted
169 RETURNING *
170 )
171 SELECT * FROM failed
172 UNION ALL
173 SELECT * FROM moved
174"#;
175
176#[derive(Debug, Clone)]
178pub struct RetentionPolicy {
179 pub completed: Duration,
181 pub failed: Duration,
183 pub dlq: Option<Duration>,
185}
186
187impl Default for RetentionPolicy {
188 fn default() -> Self {
189 Self {
190 completed: Duration::from_secs(86400), failed: Duration::from_secs(259200), dlq: None,
193 }
194 }
195}
196
197#[derive(Debug, Default)]
206struct MaintenanceBranchState {
207 last_duration: Option<Duration>,
210 is_delayed: bool,
214 consecutive_overrun: u32,
218 consecutive_ontime: u32,
223 cooldown_ticks_remaining: u32,
230}
231
232const OVERRUN_HYSTERESIS_K: u32 = 3;
241
242const OVERRUN_UPPER_NUM: u32 = 3;
256const OVERRUN_UPPER_DEN: u32 = 2;
257const OVERRUN_LOWER_NUM: u32 = 7;
258const OVERRUN_LOWER_DEN: u32 = 10;
259
260const BRANCH_COOLDOWN_TICKS: u32 = 120;
265
266struct BranchTimer<'a> {
271 tracker: &'a MaintenanceBranchTracker,
272 branch: &'static str,
273 metrics: &'a crate::metrics::AwaMetrics,
274 started_at: Instant,
275}
276
277impl<'a> BranchTimer<'a> {
278 fn finish(self) {
284 let duration = self.started_at.elapsed();
285 self.metrics
286 .record_maintenance_branch_duration(self.branch, duration);
287 self.tracker.record_finish(self.branch, duration);
288 }
289}
290
291#[derive(Default)]
298struct MaintenanceBranchTracker {
299 branches: std::sync::Mutex<HashMap<&'static str, MaintenanceBranchState>>,
300}
301
302impl MaintenanceBranchTracker {
303 fn new() -> Self {
304 Self {
305 branches: std::sync::Mutex::new(HashMap::new()),
306 }
307 }
308
309 fn try_begin<'a>(
332 &'a self,
333 branch: &'static str,
334 tick_interval: Duration,
335 metrics: &'a crate::metrics::AwaMetrics,
336 ) -> Option<BranchTimer<'a>> {
337 self.try_begin_with_cooldown(branch, tick_interval, metrics, BRANCH_COOLDOWN_TICKS)
338 }
339
340 fn try_begin_without_cooldown<'a>(
341 &'a self,
342 branch: &'static str,
343 tick_interval: Duration,
344 metrics: &'a crate::metrics::AwaMetrics,
345 ) -> Option<BranchTimer<'a>> {
346 self.try_begin_with_cooldown(branch, tick_interval, metrics, 0)
347 }
348
349 fn try_begin_with_cooldown<'a>(
350 &'a self,
351 branch: &'static str,
352 tick_interval: Duration,
353 metrics: &'a crate::metrics::AwaMetrics,
354 cooldown_ticks: u32,
355 ) -> Option<BranchTimer<'a>> {
356 let mut branches = self
357 .branches
358 .lock()
359 .expect("maintenance branch tracker mutex");
360 let state = branches.entry(branch).or_default();
361
362 if state.cooldown_ticks_remaining > 0 {
365 state.cooldown_ticks_remaining -= 1;
366 return None;
367 }
368
369 if let Some(last_duration) = state.last_duration.take() {
379 let upper_threshold = tick_interval * OVERRUN_UPPER_NUM / OVERRUN_UPPER_DEN;
381 let lower_threshold = tick_interval * OVERRUN_LOWER_NUM / OVERRUN_LOWER_DEN;
382
383 if last_duration > upper_threshold {
384 state.consecutive_overrun = state.consecutive_overrun.saturating_add(1);
385 state.consecutive_ontime = 0;
386 let cross_threshold = state.consecutive_overrun >= OVERRUN_HYSTERESIS_K;
387 if cross_threshold && !state.is_delayed {
388 state.is_delayed = true;
389 state.cooldown_ticks_remaining = cooldown_ticks;
390 warn!(
391 branch,
392 last_duration_ms = last_duration.as_millis() as u64,
393 tick_interval_ms = tick_interval.as_millis() as u64,
394 upper_threshold_ms = upper_threshold.as_millis() as u64,
395 consecutive_overrun = state.consecutive_overrun,
396 cooldown_ticks,
397 "maintenance branch overran tick interval",
398 );
399 metrics.record_maintenance_branch_overrun(branch);
400 if cooldown_ticks > 0 {
401 return None;
402 }
403 } else if cross_threshold && state.is_delayed && cooldown_ticks > 0 {
404 state.cooldown_ticks_remaining = cooldown_ticks;
407 return None;
408 }
409 } else if last_duration < lower_threshold {
410 state.consecutive_ontime = state.consecutive_ontime.saturating_add(1);
411 state.consecutive_overrun = 0;
412 if state.consecutive_ontime >= OVERRUN_HYSTERESIS_K && state.is_delayed {
413 state.is_delayed = false;
414 warn!(
415 branch,
416 last_duration_ms = last_duration.as_millis() as u64,
417 tick_interval_ms = tick_interval.as_millis() as u64,
418 lower_threshold_ms = lower_threshold.as_millis() as u64,
419 consecutive_ontime = state.consecutive_ontime,
420 "maintenance branch recovered to on-time",
421 );
422 }
423 } else {
424 }
427 }
428 drop(branches);
429 Some(BranchTimer {
430 tracker: self,
431 branch,
432 metrics,
433 started_at: Instant::now(),
434 })
435 }
436
437 fn record_finish(&self, branch: &'static str, duration: Duration) {
441 let mut branches = self
442 .branches
443 .lock()
444 .expect("maintenance branch tracker mutex");
445 let state = branches.entry(branch).or_default();
446 state.last_duration = Some(duration);
447 }
448
449 #[cfg(test)]
452 fn snapshot(&self, branch: &'static str) -> Option<(Option<Duration>, bool)> {
453 let branches = self
454 .branches
455 .lock()
456 .expect("maintenance branch tracker mutex");
457 branches
458 .get(branch)
459 .map(|state| (state.last_duration, state.is_delayed))
460 }
461
462 #[cfg(test)]
465 fn cooldown_snapshot(&self, branch: &'static str) -> Option<(u32, u32, u32)> {
466 let branches = self
467 .branches
468 .lock()
469 .expect("maintenance branch tracker mutex");
470 branches.get(branch).map(|state| {
471 (
472 state.cooldown_ticks_remaining,
473 state.consecutive_overrun,
474 state.consecutive_ontime,
475 )
476 })
477 }
478}
479
480#[derive(Default)]
493struct PruneBackoffTracker {
494 branches: std::sync::Mutex<HashMap<&'static str, PruneBackoffState>>,
495}
496
497#[derive(Debug, Default)]
498struct PruneBackoffState {
499 skip_remaining: u32,
503 backoff_level: u8,
506}
507
508const MAX_PRUNE_BACKOFF_LEVEL: u8 = 5;
514
515impl PruneBackoffTracker {
516 fn new() -> Self {
517 Self::default()
518 }
519
520 fn should_skip(&self, branch: &'static str) -> bool {
523 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
524 let state = branches.entry(branch).or_default();
525 if state.skip_remaining > 0 {
526 state.skip_remaining -= 1;
527 true
528 } else {
529 false
530 }
531 }
532
533 fn record_outcome(&self, branch: &'static str, outcome: &PruneOutcome) {
536 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
537 let state = branches.entry(branch).or_default();
538 match outcome {
539 PruneOutcome::Pruned { .. } => {
540 state.skip_remaining = 0;
541 state.backoff_level = 0;
542 }
543 PruneOutcome::SkippedActive { .. } | PruneOutcome::Blocked { .. } => {
544 state.backoff_level = state
545 .backoff_level
546 .saturating_add(1)
547 .min(MAX_PRUNE_BACKOFF_LEVEL);
548 state.skip_remaining = 1u32 << state.backoff_level;
549 }
550 PruneOutcome::Noop => {}
551 }
552 }
553
554 #[cfg(test)]
555 fn snapshot(&self, branch: &'static str) -> Option<(u32, u8)> {
556 let branches = self.branches.lock().expect("prune backoff tracker mutex");
557 branches
558 .get(branch)
559 .map(|state| (state.skip_remaining, state.backoff_level))
560 }
561}
562
563const PRUNE_BRANCH_LEASE: &str = "lease";
566const PRUNE_BRANCH_CLAIM: &str = "claim";
567const PRUNE_BRANCH_QUEUE: &str = "queue";
568
569pub struct MaintenanceService {
574 pool: PgPool,
575 metrics: crate::metrics::AwaMetrics,
576 cancel: CancellationToken,
577 leader: Arc<AtomicBool>,
578 alive: Arc<AtomicBool>,
579 periodic_jobs: Arc<Vec<PeriodicJob>>,
580 enqueue_specs: Arc<
584 HashMap<
585 crate::enqueue_specs::Outcome,
586 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
587 >,
588 >,
589 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
592 in_flight: InFlightMap,
595 storage: RuntimeStorage,
596 standby_queue_storage: Option<QueueStorageRuntime>,
601 heartbeat_rescue_interval: Duration,
602 deadline_rescue_interval: Duration,
603 callback_rescue_interval: Duration,
604 promote_interval: Duration,
605 cleanup_interval: Duration,
606 cron_sync_interval: Duration,
607 cron_eval_interval: Duration,
608 leader_check_interval: Duration,
609 leader_election_interval: Duration,
610 heartbeat_staleness: Duration,
611 completed_retention: Duration,
612 failed_retention: Duration,
613 cleanup_batch_size: i64,
614 queue_retention_overrides: HashMap<String, RetentionPolicy>,
615 queue_stats_interval: Duration,
616 dlq_retention: Duration,
617 dlq_cleanup_batch_size: i64,
618 dlq_policy: DlqPolicy,
619 dirty_key_recompute_interval: Duration,
620 metadata_reconciliation_interval: Duration,
621 priority_aging_interval: Duration,
624 batch_operations_interval: Duration,
625 terminal_count_rollup_interval: Duration,
626 descriptor_retention: Duration,
630}
631
632const PROMOTE_BATCH_SIZE: i64 = 4_096;
633const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
634const CRON_CATCH_UP_LIMIT: usize = 1_000;
635const TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK: usize = 4;
636type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
637
638impl MaintenanceService {
639 #[allow(clippy::too_many_arguments)]
640 pub(crate) fn new(
641 pool: PgPool,
642 metrics: crate::metrics::AwaMetrics,
643 leader: Arc<AtomicBool>,
644 alive: Arc<AtomicBool>,
645 cancel: CancellationToken,
646 periodic_jobs: Arc<Vec<PeriodicJob>>,
647 in_flight: InFlightMap,
648 storage: RuntimeStorage,
649 enqueue_specs: Arc<
650 HashMap<
651 crate::enqueue_specs::Outcome,
652 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
653 >,
654 >,
655 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
656 ) -> Self {
657 Self {
658 pool,
659 metrics,
660 cancel,
661 leader,
662 alive,
663 periodic_jobs,
664 in_flight,
665 storage,
666 standby_queue_storage: None,
667 enqueue_specs,
668 lifecycle_handlers,
669 heartbeat_rescue_interval: Duration::from_secs(30),
670 deadline_rescue_interval: Duration::from_secs(30),
671 callback_rescue_interval: Duration::from_secs(30),
672 promote_interval: Duration::from_millis(250),
673 cleanup_interval: Duration::from_secs(60),
674 cron_sync_interval: Duration::from_secs(60),
675 cron_eval_interval: Duration::from_secs(1),
676 leader_check_interval: Duration::from_secs(30),
677 leader_election_interval: Duration::from_secs(10),
678 heartbeat_staleness: Duration::from_secs(90),
679 completed_retention: Duration::from_secs(86400), failed_retention: Duration::from_secs(259200), cleanup_batch_size: 1000,
682 queue_retention_overrides: HashMap::new(),
683 queue_stats_interval: Duration::from_secs(30),
684 dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
685 dlq_cleanup_batch_size: 1000,
686 dlq_policy: DlqPolicy::default(),
687 dirty_key_recompute_interval: Duration::from_secs(2),
688 metadata_reconciliation_interval: Duration::from_secs(60),
689 priority_aging_interval: Duration::from_secs(60),
690 batch_operations_interval: Duration::from_secs(1),
691 terminal_count_rollup_interval: Duration::from_secs(30),
692 descriptor_retention: Duration::from_secs(30 * 86400), }
694 }
695
696 pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
701 self.priority_aging_interval = interval;
702 self
703 }
704
705 pub fn batch_operations_interval(mut self, interval: Duration) -> Self {
707 self.batch_operations_interval = interval;
708 self
709 }
710
711 pub fn terminal_count_rollup_interval(mut self, interval: Duration) -> Self {
714 self.terminal_count_rollup_interval = interval;
715 self
716 }
717
718 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
727 self.descriptor_retention = retention;
728 self
729 }
730
731 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
736 self.leader_election_interval = interval;
737 self
738 }
739
740 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
742 self.leader_check_interval = interval;
743 self
744 }
745
746 pub(crate) fn standby_queue_storage(mut self, runtime: Option<QueueStorageRuntime>) -> Self {
750 self.standby_queue_storage = runtime;
751 self
752 }
753
754 pub fn promote_interval(mut self, interval: Duration) -> Self {
756 self.promote_interval = interval;
757 self
758 }
759
760 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
762 self.heartbeat_rescue_interval = interval;
763 self
764 }
765
766 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
768 self.deadline_rescue_interval = interval;
769 self
770 }
771
772 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
774 self.callback_rescue_interval = interval;
775 self
776 }
777
778 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
784 self.heartbeat_staleness = staleness;
785 self
786 }
787
788 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
790 self.cleanup_interval = interval;
791 self
792 }
793
794 pub fn completed_retention(mut self, retention: Duration) -> Self {
796 self.completed_retention = retention;
797 self
798 }
799
800 pub fn failed_retention(mut self, retention: Duration) -> Self {
802 self.failed_retention = retention;
803 self
804 }
805
806 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
808 self.cleanup_batch_size = batch_size;
809 self
810 }
811
812 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
814 self.queue_stats_interval = interval;
815 self
816 }
817
818 pub fn dlq_retention(mut self, retention: Duration) -> Self {
820 self.dlq_retention = retention;
821 self
822 }
823
824 pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
826 self.dlq_cleanup_batch_size = batch_size;
827 self
828 }
829
830 pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
832 self.dlq_policy = policy;
833 self
834 }
835
836 pub fn queue_retention_overrides(
838 mut self,
839 overrides: HashMap<String, RetentionPolicy>,
840 ) -> Self {
841 self.queue_retention_overrides = overrides;
842 self
843 }
844
845 pub async fn run(&self) {
847 info!("Maintenance service starting");
848 self.alive.store(true, Ordering::SeqCst);
849 let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
850 self.leader.store(false, Ordering::SeqCst);
851
852 loop {
853 let mut leader_conn = match self.try_become_leader().await {
856 Ok(Some(conn)) => conn,
857 Ok(None) => {
858 tokio::select! {
860 _ = self.cancel.cancelled() => {
861 debug!("Maintenance service shutting down (not leader)");
862 self.leader.store(false, Ordering::SeqCst);
863 return;
864 }
865 _ = tokio::time::sleep(self.leader_election_interval) => continue,
866 }
867 }
868 Err(err) => {
869 warn!(error = %err, "Failed to check leader status");
870 tokio::select! {
871 _ = self.cancel.cancelled() => {
872 debug!("Maintenance service shutting down (leader check failed)");
873 self.leader.store(false, Ordering::SeqCst);
874 return;
875 }
876 _ = tokio::time::sleep(self.leader_election_interval) => continue,
877 }
878 }
879 };
880
881 debug!("Elected as maintenance leader");
882 self.leader.store(true, Ordering::SeqCst);
883
884 let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
886 let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
887 let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
888 let mut promote_timer = tokio::time::interval(self.promote_interval);
889 let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
890 let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
891 let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
892 let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
893 let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
894 let mut metadata_reconciliation_timer =
895 tokio::time::interval(self.metadata_reconciliation_interval);
896 let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
897 let mut batch_operations_timer = tokio::time::interval(self.batch_operations_interval);
898 let mut terminal_count_rollup_timer =
899 tokio::time::interval(self.terminal_count_rollup_interval);
900 let mut vacuum_queue_timer = self
901 .storage
902 .queue_storage()
903 .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
904 let mut vacuum_lease_timer = self
905 .storage
906 .queue_storage()
907 .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
908 let mut vacuum_claim_timer = self
909 .storage
910 .queue_storage()
911 .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
912 let vacuum_queue_interval = self
917 .storage
918 .queue_storage()
919 .map(|runtime| runtime.queue_rotate_interval);
920 let vacuum_lease_interval = self
921 .storage
922 .queue_storage()
923 .map(|runtime| runtime.lease_rotate_interval);
924 let vacuum_claim_interval = self
925 .storage
926 .queue_storage()
927 .map(|runtime| runtime.claim_rotate_interval);
928 let branch_tracker = MaintenanceBranchTracker::new();
932 let prune_tracker = PruneBackoffTracker::new();
938
939 heartbeat_rescue_timer.tick().await;
941 deadline_rescue_timer.tick().await;
942 callback_rescue_timer.tick().await;
943 promote_timer.tick().await;
944 cleanup_timer.tick().await;
945 cron_sync_timer.tick().await;
946 leader_check_timer.tick().await;
947 queue_stats_timer.tick().await;
948 dirty_key_timer.tick().await;
949 metadata_reconciliation_timer.tick().await;
950 priority_aging_timer.tick().await;
951 batch_operations_timer.tick().await;
952 terminal_count_rollup_timer.tick().await;
953 if let Some(timer) = &mut vacuum_queue_timer {
954 timer.tick().await;
955 }
956 if let Some(timer) = &mut vacuum_lease_timer {
957 timer.tick().await;
958 }
959 if let Some(timer) = &mut vacuum_claim_timer {
960 timer.tick().await;
961 }
962
963 self.sync_periodic_jobs_to_db().await;
965 let cron_eval_cancel = self.cancel.child_token();
966 let cron_eval_task = tokio::spawn(Self::run_cron_evaluator(
967 self.pool.clone(),
968 cron_eval_cancel.clone(),
969 self.cron_eval_interval,
970 ));
971
972 loop {
973 tokio::select! {
974 _ = self.cancel.cancelled() => {
975 debug!("Maintenance service shutting down");
976 self.leader.store(false, Ordering::SeqCst);
977 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
978 let _ = Self::release_leader(&mut leader_conn).await;
981 return;
982 }
983 _ = heartbeat_rescue_timer.tick() => {
984 if let Some(timer) = branch_tracker.try_begin("rescue_stale_heartbeats", self.heartbeat_rescue_interval, &self.metrics) {
985 self.rescue_stale_heartbeats().await;
986 timer.finish();
987 }
988 }
989 _ = deadline_rescue_timer.tick() => {
990 if let Some(timer) = branch_tracker.try_begin("rescue_expired_deadlines", self.deadline_rescue_interval, &self.metrics) {
991 self.rescue_expired_deadlines().await;
992 timer.finish();
993 }
994 }
995 _ = callback_rescue_timer.tick() => {
996 if let Some(timer) = branch_tracker.try_begin("rescue_expired_callbacks", self.callback_rescue_interval, &self.metrics) {
997 self.rescue_expired_callbacks().await;
998 timer.finish();
999 }
1000 }
1001 _ = promote_timer.tick() => {
1002 if let Some(timer) = branch_tracker.try_begin("promote_scheduled", self.promote_interval, &self.metrics) {
1003 self.promote_scheduled().await;
1004 timer.finish();
1005 }
1006 }
1007 _ = cleanup_timer.tick() => {
1008 if let Some(timer) = branch_tracker.try_begin("cleanup", self.cleanup_interval, &self.metrics) {
1009 self.cleanup_completed().await;
1010 self.cleanup_dlq_rows().await;
1011 self.cleanup_batch_operations().await;
1012 self.cleanup_stale_runtime_snapshots().await;
1013 self.cleanup_stale_descriptors().await;
1014 timer.finish();
1015 }
1016 }
1017 _ = cron_sync_timer.tick() => {
1018 if let Some(timer) = branch_tracker.try_begin("cron_sync", self.cron_sync_interval, &self.metrics) {
1019 self.sync_periodic_jobs_to_db().await;
1020 timer.finish();
1021 }
1022 }
1023 _ = queue_stats_timer.tick() => {
1024 if let Some(timer) = branch_tracker.try_begin("queue_stats", self.queue_stats_interval, &self.metrics) {
1025 self.publish_queue_health_metrics().await;
1026 timer.finish();
1027 }
1028 }
1029 _ = dirty_key_timer.tick() => {
1030 if let Some(timer) = branch_tracker.try_begin("recompute_dirty_admin_metadata", self.dirty_key_recompute_interval, &self.metrics) {
1031 self.recompute_dirty_admin_metadata().await;
1032 timer.finish();
1033 }
1034 }
1035 _ = metadata_reconciliation_timer.tick() => {
1036 if let Some(timer) = branch_tracker.try_begin("refresh_admin_metadata", self.metadata_reconciliation_interval, &self.metrics) {
1037 self.refresh_admin_metadata().await;
1038 timer.finish();
1039 }
1040 }
1041 _ = priority_aging_timer.tick() => {
1042 if let Some(timer) = branch_tracker.try_begin("priority_aging", self.priority_aging_interval, &self.metrics) {
1043 self.age_waiting_priorities().await;
1044 timer.finish();
1045 }
1046 }
1047 _ = batch_operations_timer.tick() => {
1048 if let Some(timer) = branch_tracker.try_begin("batch_operations", self.batch_operations_interval, &self.metrics) {
1049 self.process_batch_operation().await;
1050 timer.finish();
1051 }
1052 }
1053 _ = terminal_count_rollup_timer.tick() => {
1054 if let Some(timer) = branch_tracker.try_begin_without_cooldown("terminal_count_rollup", self.terminal_count_rollup_interval, &self.metrics) {
1055 self.rollup_terminal_count_deltas().await;
1056 timer.finish();
1057 }
1058 }
1059 _ = async {
1060 if let Some(timer) = &mut vacuum_queue_timer {
1061 timer.tick().await;
1062 } else {
1063 std::future::pending::<()>().await;
1064 }
1065 }, if vacuum_queue_timer.is_some() => {
1066 let interval = vacuum_queue_interval
1067 .expect("vacuum_queue_interval Some iff vacuum_queue_timer Some");
1068 if let Some(timer) = branch_tracker.try_begin("rotate_queue", interval, &self.metrics) {
1069 self.rotate_queue_storage_queue(&prune_tracker).await;
1070 timer.finish();
1071 }
1072 }
1073 _ = async {
1074 if let Some(timer) = &mut vacuum_lease_timer {
1075 timer.tick().await;
1076 } else {
1077 std::future::pending::<()>().await;
1078 }
1079 }, if vacuum_lease_timer.is_some() => {
1080 let interval = vacuum_lease_interval
1081 .expect("vacuum_lease_interval Some iff vacuum_lease_timer Some");
1082 if let Some(timer) = branch_tracker.try_begin("rotate_lease", interval, &self.metrics) {
1083 self.rotate_queue_storage_leases(&prune_tracker).await;
1084 timer.finish();
1085 }
1086 }
1087 _ = async {
1088 if let Some(timer) = &mut vacuum_claim_timer {
1089 timer.tick().await;
1090 } else {
1091 std::future::pending::<()>().await;
1092 }
1093 }, if vacuum_claim_timer.is_some() => {
1094 let interval = vacuum_claim_interval
1095 .expect("vacuum_claim_interval Some iff vacuum_claim_timer Some");
1096 if let Some(timer) = branch_tracker.try_begin("rotate_claim", interval, &self.metrics) {
1097 self.rotate_queue_storage_claims(&prune_tracker).await;
1098 timer.finish();
1099 }
1100 }
1101 _ = leader_check_timer.tick() => {
1102 if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
1106 warn!("Leader connection lost, re-entering election loop");
1107 self.leader.store(false, Ordering::SeqCst);
1108 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
1109 break;
1110 }
1111 }
1112 }
1113 }
1114 }
1115 }
1116
1117 const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
1126 let mut conn = self.pool.acquire().await?;
1127 let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
1128 .bind(Self::LOCK_KEY)
1129 .fetch_one(&mut *conn)
1130 .await?;
1131 if result.0 {
1132 Ok(Some(conn))
1133 } else {
1134 Ok(None)
1135 }
1136 }
1137
1138 async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
1143 sqlx::query("SELECT pg_advisory_unlock($1)")
1144 .bind(Self::LOCK_KEY)
1145 .execute(&mut **conn)
1146 .await?;
1147 Ok(())
1148 }
1149
1150 async fn run_cron_evaluator(pool: PgPool, cancel: CancellationToken, interval: Duration) {
1151 let mut timer = tokio::time::interval(interval);
1152 timer.tick().await;
1153
1154 loop {
1155 tokio::select! {
1156 _ = cancel.cancelled() => return,
1157 _ = timer.tick() => {
1158 Self::evaluate_cron_schedules(&pool).await;
1159 }
1160 }
1161 }
1162 }
1163
1164 fn stop_cron_evaluator(cancel: &CancellationToken, task: &JoinHandle<()>) {
1165 cancel.cancel();
1166 task.abort();
1167 }
1168
1169 #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
1173 async fn sync_periodic_jobs_to_db(&self) {
1174 if self.periodic_jobs.is_empty() {
1175 return;
1176 }
1177
1178 for job in self.periodic_jobs.iter() {
1179 if let Err(err) = upsert_cron_job(&self.pool, job).await {
1180 error!(name = %job.name, error = %err, "Failed to sync periodic job");
1181 }
1182 }
1183
1184 debug!(
1185 count = self.periodic_jobs.len(),
1186 "Synced periodic jobs to database"
1187 );
1188 }
1189
1190 async fn process_batch_operation(&self) {
1191 let runner_instance = Uuid::new_v4();
1192 match awa_model::batch_operations::run_one_default_chunk(&self.pool, runner_instance).await
1193 {
1194 Ok(outcome) if outcome.claimed => {
1195 debug!(
1196 processed = outcome.processed,
1197 skipped = outcome.skipped,
1198 errored = outcome.errored,
1199 finalized = outcome.finalized,
1200 "processed batch operation chunk"
1201 );
1202 }
1203 Ok(_) => {}
1204 Err(err) => warn!(error = %err, "failed to process batch operation chunk"),
1205 }
1206 }
1207
1208 async fn cleanup_batch_operations(&self) {
1209 match awa_model::batch_operations::cleanup_expired_batch_operations(&self.pool, 1000).await
1210 {
1211 Ok(deleted) if deleted > 0 => {
1212 debug!(deleted, "cleaned up expired batch operations");
1213 }
1214 Ok(_) => {}
1215 Err(err) => warn!(error = %err, "failed to clean up expired batch operations"),
1216 }
1217 }
1218
1219 async fn rollup_terminal_count_deltas(&self) {
1220 let Some(runtime) = self.storage.queue_storage() else {
1221 return;
1222 };
1223
1224 match runtime
1225 .store
1226 .rollup_terminal_count_deltas(&self.pool, TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK)
1227 .await
1228 {
1229 Ok(TerminalDeltaRollupOutcome {
1230 rolled_slots: 0,
1231 delta_rows: 0,
1232 grouped_keys: 0,
1233 skipped_active_slots: 0,
1234 blocked_slots: 0,
1235 skipped_mvcc_pinned: false,
1236 }) => {}
1237 Ok(outcome) => {
1238 debug!(
1239 rolled_slots = outcome.rolled_slots,
1240 delta_rows = outcome.delta_rows,
1241 grouped_keys = outcome.grouped_keys,
1242 skipped_active_slots = outcome.skipped_active_slots,
1243 blocked_slots = outcome.blocked_slots,
1244 skipped_mvcc_pinned = outcome.skipped_mvcc_pinned,
1245 "rolled up queue-storage terminal count deltas"
1246 );
1247 }
1248 Err(err) => warn!(error = %err, "failed to roll up terminal count deltas"),
1249 }
1250 }
1251
1252 #[tracing::instrument(skip(pool), name = "maintenance.cron_eval")]
1259 async fn evaluate_cron_schedules(pool: &PgPool) {
1260 let cron_rows = match list_cron_jobs(pool).await {
1261 Ok(rows) => rows,
1262 Err(err) => {
1263 error!(error = %err, "Failed to load cron jobs for evaluation");
1264 return;
1265 }
1266 };
1267
1268 if cron_rows.is_empty() {
1269 return;
1270 }
1271
1272 let now = Utc::now();
1273
1274 for row in &cron_rows {
1275 if row.is_paused() {
1276 debug!(cron_name = %row.name, "Skipping paused cron schedule");
1277 continue;
1278 }
1279 let fire_times = compute_fire_times(row, now, CRON_CATCH_UP_LIMIT);
1280 if fire_times.is_empty() {
1281 continue;
1282 }
1283 if fire_times.len() == CRON_CATCH_UP_LIMIT {
1284 warn!(
1285 cron_name = %row.name,
1286 catch_up_limit = CRON_CATCH_UP_LIMIT,
1287 "Cron catch-up limit reached; remaining due fires will be retried on the next evaluation"
1288 );
1289 }
1290
1291 let mut previous_enqueued_at = row.last_enqueued_at;
1292 for fire_time in fire_times {
1293 match atomic_enqueue(pool, &row.name, fire_time, previous_enqueued_at).await {
1294 Ok(Some(job)) => {
1295 previous_enqueued_at = Some(fire_time);
1296 info!(
1297 cron_name = %row.name,
1298 job_id = job.id,
1299 fire_time = %fire_time,
1300 "Enqueued periodic job"
1301 );
1302 }
1303 Ok(None) => {
1304 debug!(cron_name = %row.name, "Cron fire already claimed");
1306 break;
1307 }
1308 Err(err) => {
1309 error!(
1310 cron_name = %row.name,
1311 error = %err,
1312 "Failed to enqueue periodic job"
1313 );
1314 break;
1315 }
1316 }
1317 }
1318 }
1319 }
1320
1321 #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
1329 async fn rescue_stale_heartbeats(&self) {
1330 match &self.storage {
1331 RuntimeStorage::Canonical => {
1332 let outcome = self.rescue_canonical_stale_heartbeats().await;
1333 self.process_heartbeat_rescues(outcome, true).await;
1334 if let Some(runtime) = self.active_standby_queue_storage().await {
1335 let outcome = runtime
1336 .store
1337 .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
1338 .await;
1339 self.process_heartbeat_rescues(outcome, false).await;
1340 }
1341 }
1342 RuntimeStorage::QueueStorage(runtime) => {
1343 let outcome = runtime
1344 .store
1345 .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
1346 .await;
1347 self.process_heartbeat_rescues(outcome, true).await;
1348 let outcome = self.rescue_canonical_stale_heartbeats().await;
1349 self.process_heartbeat_rescues(outcome, false).await;
1350 }
1351 }
1352 }
1353
1354 async fn rescue_canonical_stale_heartbeats(&self) -> Result<Vec<JobRow>, awa_model::AwaError> {
1357 let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
1358 let outcome = sqlx::query_as::<_, JobRow>(
1359 r#"
1360 WITH deleted AS (
1361 DELETE FROM awa.jobs_hot
1362 WHERE id IN (
1363 SELECT id FROM awa.jobs_hot
1364 WHERE state = 'running'
1365 AND heartbeat_at < now() - ($1 * interval '1 millisecond')
1366 LIMIT 500
1367 FOR UPDATE SKIP LOCKED
1368 )
1369 RETURNING *
1370 )
1371 INSERT INTO awa.scheduled_jobs (
1372 id, kind, queue, args, state, priority, attempt, max_attempts,
1373 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
1374 created_at, errors, metadata, tags, unique_key, unique_states,
1375 callback_id, callback_timeout_at, callback_filter,
1376 callback_on_complete, callback_on_fail, callback_transform,
1377 run_lease, progress
1378 )
1379 SELECT
1380 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
1381 run_at, NULL, NULL, attempted_at, now(),
1382 created_at,
1383 errors || jsonb_build_object(
1384 'error', 'heartbeat stale: worker presumed dead',
1385 'attempt', attempt,
1386 'at', now()
1387 )::jsonb,
1388 metadata, tags, unique_key, unique_states,
1389 NULL, NULL, NULL, NULL, NULL, NULL,
1390 run_lease, progress
1391 FROM deleted
1392 RETURNING *
1393 "#,
1394 )
1395 .bind(staleness_ms)
1396 .fetch_all(&self.pool)
1397 .await
1398 .map_err(awa_model::AwaError::Database);
1399 match outcome {
1400 Err(err) if is_unique_claim_conflict(&err) => {
1401 warn!(
1402 error = %err,
1403 "Batched heartbeat rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1404 );
1405 self.rescue_canonical_per_row(
1406 "SELECT id FROM awa.jobs_hot \
1407 WHERE state = 'running' \
1408 AND heartbeat_at < now() - ($1 * interval '1 millisecond') \
1409 LIMIT 500",
1410 HEARTBEAT_RESCUE_PER_ROW_SQL,
1411 Some(staleness_ms),
1412 "running",
1413 "rescued as duplicate: heartbeat stale and unique claim held by a newer job",
1414 "heartbeat",
1415 )
1416 .await
1417 }
1418 other => other,
1419 }
1420 }
1421
1422 async fn process_heartbeat_rescues(
1428 &self,
1429 outcome: Result<Vec<JobRow>, awa_model::AwaError>,
1430 signal_local: bool,
1431 ) {
1432 match outcome {
1433 Ok(rescued) if !rescued.is_empty() => {
1434 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1435 .into_iter()
1436 .partition(|job| job.state == JobState::Cancelled);
1437 self.handle_duplicate_cancellations(
1438 "heartbeat",
1439 &cancelled_duplicates,
1440 signal_local,
1441 )
1442 .await;
1443 if rescued.is_empty() {
1444 return;
1445 }
1446 self.metrics.maintenance_rescues.add(
1447 rescued.len() as u64,
1448 &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
1449 );
1450 warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
1451 if signal_local {
1453 self.signal_cancellation(&rescued).await;
1454 }
1455 for job in &rescued {
1456 self.emit_rescued(job, crate::events::RescueReason::StaleHeartbeat)
1457 .await;
1458 }
1459 }
1460 Err(err) => {
1461 error!(error = %err, "Failed to rescue stale heartbeat jobs");
1462 }
1463 _ => {}
1464 }
1465 }
1466
1467 async fn active_standby_queue_storage(&self) -> Option<&QueueStorageRuntime> {
1471 let runtime = self.standby_queue_storage.as_ref()?;
1472 match sqlx::query_scalar::<_, Option<String>>("SELECT awa.active_queue_storage_schema()")
1473 .fetch_one(&self.pool)
1474 .await
1475 {
1476 Ok(active_schema) if active_schema.as_deref() == Some(runtime.store.schema()) => {
1477 Some(runtime)
1478 }
1479 Ok(_) => None,
1480 Err(err) => {
1481 error!(error = %err, "Failed to resolve the active queue-storage schema");
1482 None
1483 }
1484 }
1485 }
1486
1487 #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
1492 async fn rescue_expired_deadlines(&self) {
1493 match &self.storage {
1494 RuntimeStorage::Canonical => {
1495 let outcome = self.rescue_canonical_expired_deadlines().await;
1496 self.process_deadline_rescues(outcome, true).await;
1497 if let Some(runtime) = self.active_standby_queue_storage().await {
1498 let outcome = runtime.store.rescue_expired_deadlines(&self.pool).await;
1499 self.process_deadline_rescues(outcome, false).await;
1500 }
1501 }
1502 RuntimeStorage::QueueStorage(runtime) => {
1503 let outcome = runtime.store.rescue_expired_deadlines(&self.pool).await;
1504 self.process_deadline_rescues(outcome, true).await;
1505 let outcome = self.rescue_canonical_expired_deadlines().await;
1506 self.process_deadline_rescues(outcome, false).await;
1507 }
1508 }
1509 }
1510
1511 async fn rescue_canonical_expired_deadlines(&self) -> Result<Vec<JobRow>, awa_model::AwaError> {
1514 let outcome = sqlx::query_as::<_, JobRow>(
1515 r#"
1516 WITH deleted AS (
1517 DELETE FROM awa.jobs_hot
1518 WHERE id IN (
1519 SELECT id FROM awa.jobs_hot
1520 WHERE state = 'running'
1521 AND deadline_at IS NOT NULL
1522 AND deadline_at < now()
1523 LIMIT 500
1524 FOR UPDATE SKIP LOCKED
1525 )
1526 RETURNING *
1527 )
1528 INSERT INTO awa.scheduled_jobs (
1529 id, kind, queue, args, state, priority, attempt, max_attempts,
1530 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
1531 created_at, errors, metadata, tags, unique_key, unique_states,
1532 callback_id, callback_timeout_at, callback_filter,
1533 callback_on_complete, callback_on_fail, callback_transform,
1534 run_lease, progress
1535 )
1536 SELECT
1537 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
1538 run_at, NULL, NULL, attempted_at, now(),
1539 created_at,
1540 errors || jsonb_build_object(
1541 'error', 'hard deadline exceeded',
1542 'attempt', attempt,
1543 'at', now()
1544 )::jsonb,
1545 metadata, tags, unique_key, unique_states,
1546 NULL, NULL, NULL, NULL, NULL, NULL,
1547 run_lease, progress
1548 FROM deleted
1549 RETURNING *
1550 "#,
1551 )
1552 .fetch_all(&self.pool)
1553 .await
1554 .map_err(awa_model::AwaError::Database);
1555 match outcome {
1556 Err(err) if is_unique_claim_conflict(&err) => {
1557 warn!(
1558 error = %err,
1559 "Batched deadline rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1560 );
1561 self.rescue_canonical_per_row(
1562 "SELECT id FROM awa.jobs_hot \
1563 WHERE state = 'running' \
1564 AND deadline_at IS NOT NULL \
1565 AND deadline_at < now() \
1566 LIMIT 500",
1567 DEADLINE_RESCUE_PER_ROW_SQL,
1568 None,
1569 "running",
1570 "rescued as duplicate: deadline expired and unique claim held by a newer job",
1571 "deadline",
1572 )
1573 .await
1574 }
1575 other => other,
1576 }
1577 }
1578
1579 async fn process_deadline_rescues(
1582 &self,
1583 outcome: Result<Vec<JobRow>, awa_model::AwaError>,
1584 signal_local: bool,
1585 ) {
1586 match outcome {
1587 Ok(rescued) if !rescued.is_empty() => {
1588 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1589 .into_iter()
1590 .partition(|job| job.state == JobState::Cancelled);
1591 self.handle_duplicate_cancellations(
1592 "deadline",
1593 &cancelled_duplicates,
1594 signal_local,
1595 )
1596 .await;
1597 if rescued.is_empty() {
1598 return;
1599 }
1600 self.metrics.maintenance_rescues.add(
1601 rescued.len() as u64,
1602 &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
1603 );
1604 warn!(count = rescued.len(), "Rescued deadline-expired jobs");
1605 if signal_local {
1607 self.signal_cancellation(&rescued).await;
1608 }
1609 for job in &rescued {
1610 self.emit_rescued(job, crate::events::RescueReason::DeadlineExceeded)
1611 .await;
1612 }
1613 }
1614 Err(err) => {
1615 error!(error = %err, "Failed to rescue deadline-expired jobs");
1616 }
1617 _ => {}
1618 }
1619 }
1620
1621 #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
1628 async fn rescue_expired_callbacks(&self) {
1629 match &self.storage {
1630 RuntimeStorage::Canonical => {
1631 let outcome = self.rescue_canonical_expired_callbacks().await;
1632 self.process_callback_rescues(outcome, true, None).await;
1633 if let Some(runtime) = self.active_standby_queue_storage().await {
1634 let outcome = runtime.store.rescue_expired_callbacks(&self.pool).await;
1635 self.process_callback_rescues(outcome, false, Some(runtime))
1636 .await;
1637 }
1638 }
1639 RuntimeStorage::QueueStorage(runtime) => {
1640 let outcome = runtime.store.rescue_expired_callbacks(&self.pool).await;
1641 self.process_callback_rescues(outcome, true, Some(runtime))
1642 .await;
1643 let outcome = self.rescue_canonical_expired_callbacks().await;
1644 self.process_callback_rescues(outcome, false, None).await;
1645 }
1646 }
1647 }
1648
1649 async fn rescue_canonical_expired_callbacks(&self) -> Result<Vec<JobRow>, awa_model::AwaError> {
1655 let outcome = sqlx::query_as::<_, JobRow>(
1656 r#"
1657 WITH candidates AS (
1658 SELECT id, attempt, max_attempts FROM awa.jobs_hot
1659 WHERE state = 'waiting_external'
1660 AND callback_timeout_at IS NOT NULL
1661 AND callback_timeout_at < now()
1662 LIMIT 500
1663 FOR UPDATE SKIP LOCKED
1664 ),
1665 failed AS (
1666 UPDATE awa.jobs_hot
1667 SET state = 'failed',
1668 finalized_at = now(),
1669 callback_id = NULL,
1670 callback_timeout_at = NULL,
1671 callback_filter = NULL,
1672 callback_on_complete = NULL,
1673 callback_on_fail = NULL,
1674 callback_transform = NULL,
1675 errors = errors || jsonb_build_object(
1676 'error', 'callback timed out',
1677 'attempt', attempt,
1678 'at', now()
1679 )::jsonb
1680 WHERE id IN (SELECT id FROM candidates WHERE attempt >= max_attempts)
1681 RETURNING *
1682 ),
1683 deleted AS (
1684 DELETE FROM awa.jobs_hot
1685 WHERE id IN (SELECT id FROM candidates WHERE attempt < max_attempts)
1686 RETURNING *
1687 ),
1688 moved AS (
1689 INSERT INTO awa.scheduled_jobs (
1690 id, kind, queue, args, state, priority, attempt, max_attempts,
1691 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
1692 created_at, errors, metadata, tags, unique_key, unique_states,
1693 callback_id, callback_timeout_at, callback_filter,
1694 callback_on_complete, callback_on_fail, callback_transform,
1695 run_lease, progress
1696 )
1697 SELECT
1698 id, kind, queue, args, 'retryable', priority, attempt, max_attempts,
1699 now() + awa.backoff_duration(attempt, max_attempts),
1700 heartbeat_at, deadline_at, attempted_at, now(),
1701 created_at,
1702 errors || jsonb_build_object(
1703 'error', 'callback timed out',
1704 'attempt', attempt,
1705 'at', now()
1706 )::jsonb,
1707 metadata, tags, unique_key, unique_states,
1708 NULL, NULL, NULL, NULL, NULL, NULL,
1709 run_lease, progress
1710 FROM deleted
1711 RETURNING *
1712 )
1713 SELECT * FROM failed
1714 UNION ALL
1715 SELECT * FROM moved
1716 "#,
1717 )
1718 .fetch_all(&self.pool)
1719 .await
1720 .map_err(awa_model::AwaError::Database);
1721 match outcome {
1722 Err(err) if is_unique_claim_conflict(&err) => {
1723 warn!(
1724 error = %err,
1725 "Batched callback-timeout rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1726 );
1727 self.rescue_canonical_per_row(
1728 "SELECT id FROM awa.jobs_hot \
1729 WHERE state = 'waiting_external' \
1730 AND callback_timeout_at IS NOT NULL \
1731 AND callback_timeout_at < now() \
1732 LIMIT 500",
1733 CALLBACK_RESCUE_PER_ROW_SQL,
1734 None,
1735 "waiting_external",
1736 "rescued as duplicate: callback timed out and unique claim held by a newer job",
1737 "callback_timeout",
1738 )
1739 .await
1740 }
1741 other => other,
1742 }
1743 }
1744
1745 async fn process_callback_rescues(
1752 &self,
1753 outcome: Result<Vec<JobRow>, awa_model::AwaError>,
1754 signal_local: bool,
1755 dlq_runtime: Option<&QueueStorageRuntime>,
1756 ) {
1757 match outcome {
1758 Ok(rescued) if !rescued.is_empty() => {
1759 let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1760 .into_iter()
1761 .partition(|job| job.state == JobState::Cancelled);
1762 self.handle_duplicate_cancellations(
1763 "callback_timeout",
1764 &cancelled_duplicates,
1765 signal_local,
1766 )
1767 .await;
1768 if rescued.is_empty() {
1769 return;
1770 }
1771 self.metrics.maintenance_rescues.add(
1772 rescued.len() as u64,
1773 &[opentelemetry::KeyValue::new(
1774 "awa.rescue.kind",
1775 "callback_timeout",
1776 )],
1777 );
1778 warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
1779 for job in &rescued {
1780 self.emit_rescued(job, crate::events::RescueReason::ExpiredCallback)
1781 .await;
1782 }
1783 if let Some(runtime) = dlq_runtime {
1784 for job in &rescued {
1785 if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
1786 {
1787 continue;
1788 }
1789 match runtime
1790 .store
1791 .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
1792 .await
1793 {
1794 Ok(Some(_)) => {
1795 self.metrics.record_dlq_moved(
1796 &job.kind,
1797 &job.queue,
1798 "callback_timeout",
1799 );
1800 }
1801 Ok(None) => {}
1802 Err(err) => {
1803 error!(
1804 job_id = job.id,
1805 error = %err,
1806 "Failed to move rescued callback timeout into DLQ"
1807 );
1808 }
1809 }
1810 }
1811 }
1812 }
1813 Err(err) => {
1814 error!(error = %err, "Failed to rescue callback-timed-out jobs");
1815 }
1816 _ => {}
1817 }
1818 }
1819
1820 #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
1827 async fn age_waiting_priorities(&self) {
1828 let aging_secs = self.priority_aging_interval.as_secs_f64();
1829 if aging_secs <= 0.0 {
1830 return;
1831 }
1832 if let Some(runtime) = self.storage.queue_storage() {
1833 debug!(
1834 schema = %runtime.store.schema(),
1835 "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
1836 );
1837 return;
1838 }
1839
1840 match sqlx::query_scalar::<_, i64>(
1841 r#"
1842 WITH eligible AS (
1843 SELECT id FROM awa.jobs_hot
1844 WHERE state = 'available'
1845 AND priority > 1
1846 AND run_at <= now() - make_interval(secs => $1)
1847 LIMIT 1000
1848 FOR UPDATE SKIP LOCKED
1849 )
1850 UPDATE awa.jobs_hot
1851 SET priority = priority - 1,
1852 metadata = CASE
1853 WHEN NOT (metadata ? '_awa_original_priority')
1854 THEN metadata || jsonb_build_object('_awa_original_priority', priority)
1855 ELSE metadata
1856 END
1857 FROM eligible
1858 WHERE awa.jobs_hot.id = eligible.id
1859 RETURNING awa.jobs_hot.id
1860 "#,
1861 )
1862 .bind(aging_secs)
1863 .fetch_all(&self.pool)
1864 .await
1865 {
1866 Ok(ids) if !ids.is_empty() => {
1867 debug!(count = ids.len(), "Aged job priorities");
1868 }
1869 Err(err) => {
1870 error!(error = %err, "Failed to age job priorities");
1871 }
1872 _ => {}
1873 }
1874 }
1875
1876 async fn rescue_canonical_per_row(
1890 &self,
1891 candidates_sql: &str,
1892 per_row_sql: &str,
1893 staleness_ms: Option<i64>,
1894 from_state: &str,
1895 duplicate_error: &str,
1896 rescue_kind: &'static str,
1897 ) -> Result<Vec<JobRow>, awa_model::AwaError> {
1898 let ids: Vec<i64> = {
1899 let query = sqlx::query_scalar(candidates_sql);
1900 let query = match staleness_ms {
1901 Some(ms) => query.bind(ms),
1902 None => query,
1903 };
1904 query
1905 .fetch_all(&self.pool)
1906 .await
1907 .map_err(awa_model::AwaError::Database)?
1908 };
1909
1910 let mut rescued = Vec::new();
1911 for id in ids {
1912 let attempt = {
1913 let query = sqlx::query_as::<_, JobRow>(per_row_sql).bind(id);
1914 let query = match staleness_ms {
1915 Some(ms) => query.bind(ms),
1916 None => query,
1917 };
1918 query
1919 .fetch_optional(&self.pool)
1920 .await
1921 .map_err(awa_model::AwaError::Database)
1922 };
1923 match attempt {
1924 Ok(Some(row)) => rescued.push(row),
1925 Ok(None) => {}
1928 Err(err) if is_unique_claim_conflict(&err) => {
1929 let holder = self.unique_claim_holder(id).await;
1930 warn!(
1931 job_id = id,
1932 claim_holder = ?holder,
1933 rescue_kind,
1934 "Rescue conflicts with a unique claim held by another job; \
1935 cancelling the superseded job (#388)"
1936 );
1937 match self
1938 .cancel_unique_conflicted_job(id, from_state, duplicate_error)
1939 .await
1940 {
1941 Ok(Some(row)) => rescued.push(row),
1942 Ok(None) => {}
1943 Err(err) if is_unique_claim_conflict(&err) => {
1944 error!(
1945 job_id = id,
1946 claim_holder = ?holder,
1947 rescue_kind,
1948 "Cannot rescue or cancel unique-conflicted job: its \
1949 unique_states mask claims 'cancelled' as well; skipping \
1950 so the sweep can proceed — resolve manually (see \
1951 docs/troubleshooting.md, #388)"
1952 );
1953 }
1954 Err(err) => {
1955 error!(job_id = id, error = %err, rescue_kind, "Failed to cancel unique-conflicted job");
1956 }
1957 }
1958 }
1959 Err(err) => {
1960 error!(job_id = id, error = %err, rescue_kind, "Per-row rescue failed");
1961 }
1962 }
1963 }
1964
1965 Ok(rescued)
1966 }
1967
1968 async fn handle_duplicate_cancellations(
1974 &self,
1975 rescue_kind: &str,
1976 cancelled: &[JobRow],
1977 signal_local: bool,
1978 ) {
1979 if cancelled.is_empty() {
1980 return;
1981 }
1982 self.metrics.maintenance_rescues.add(
1983 cancelled.len() as u64,
1984 &[opentelemetry::KeyValue::new(
1985 "awa.rescue.kind",
1986 format!("{rescue_kind}_duplicate_cancelled"),
1987 )],
1988 );
1989 warn!(
1990 count = cancelled.len(),
1991 rescue_kind, "Cancelled unique-conflicted jobs superseded by a newer duplicate"
1992 );
1993 if signal_local {
1994 self.signal_cancellation(cancelled).await;
1995 }
1996 for job in cancelled {
1997 let handlers = self.lifecycle_handlers.clone();
1998 let kind = job.kind.clone();
1999 let reason = job
2000 .errors
2001 .as_ref()
2002 .and_then(|errors| errors.last())
2003 .and_then(|entry| entry.get("error"))
2004 .and_then(|value| value.as_str())
2005 .unwrap_or("rescued as duplicate: unique claim held by a newer job")
2006 .to_string();
2007 let event = crate::events::UntypedJobEvent::Cancelled {
2008 job: job.clone(),
2009 reason,
2010 };
2011 tokio::spawn(async move {
2012 crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
2013 });
2014 }
2015 }
2016
2017 async fn unique_claim_holder(&self, job_id: i64) -> Option<i64> {
2020 sqlx::query_scalar(
2021 r#"
2022 SELECT c.job_id
2023 FROM awa.jobs_hot AS j
2024 JOIN awa.job_unique_claims AS c ON c.unique_key = j.unique_key
2025 WHERE j.id = $1 AND c.job_id <> j.id
2026 "#,
2027 )
2028 .bind(job_id)
2029 .fetch_optional(&self.pool)
2030 .await
2031 .ok()
2032 .flatten()
2033 }
2034
2035 async fn cancel_unique_conflicted_job(
2041 &self,
2042 job_id: i64,
2043 from_state: &str,
2044 error_message: &str,
2045 ) -> Result<Option<JobRow>, awa_model::AwaError> {
2046 let row = sqlx::query_as::<_, JobRow>(
2047 r#"
2048 UPDATE awa.jobs_hot
2049 SET state = 'cancelled',
2050 finalized_at = now(),
2051 heartbeat_at = NULL,
2052 deadline_at = NULL,
2053 callback_id = NULL,
2054 callback_timeout_at = NULL,
2055 callback_filter = NULL,
2056 callback_on_complete = NULL,
2057 callback_on_fail = NULL,
2058 callback_transform = NULL,
2059 errors = errors || jsonb_build_object(
2060 'error', $3::text,
2061 'attempt', attempt,
2062 'at', now()
2063 )::jsonb
2064 WHERE id = $1 AND state = $2::awa.job_state
2065 RETURNING *
2066 "#,
2067 )
2068 .bind(job_id)
2069 .bind(from_state)
2070 .bind(error_message)
2071 .fetch_optional(&self.pool)
2072 .await?;
2073 Ok(row)
2074 }
2075
2076 async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
2078 for job in rescued_jobs {
2079 if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
2080 flag.store(true, Ordering::SeqCst);
2081 debug!(job_id = job.id, "Signalled cancellation for rescued job");
2082 }
2083 }
2084 }
2085
2086 async fn emit_rescued(&self, job: &JobRow, reason: crate::events::RescueReason) {
2093 self.dispatch_rescued_followups(job, reason).await;
2094 let handlers = self.lifecycle_handlers.clone();
2095 let kind = job.kind.clone();
2096 let event = crate::events::UntypedJobEvent::Rescued {
2097 job: job.clone(),
2098 reason,
2099 };
2100 tokio::spawn(async move {
2101 crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
2102 });
2103 }
2104
2105 async fn dispatch_rescued_followups(&self, job: &JobRow, reason: crate::events::RescueReason) {
2113 let Some(specs) = self
2114 .enqueue_specs
2115 .get(&crate::enqueue_specs::Outcome::Rescued)
2116 .and_then(|by_kind| by_kind.get(&job.kind))
2117 .cloned()
2118 else {
2119 return;
2120 };
2121 if specs.is_empty() {
2122 return;
2123 }
2124 let mut tx = match self.pool.begin().await {
2125 Ok(tx) => tx,
2126 Err(err) => {
2127 error!(
2128 job_id = job.id,
2129 kind = %job.kind,
2130 rescue_reason = reason.as_str(),
2131 error = %err,
2132 "Rescued follow-up dispatch: failed to begin transaction"
2133 );
2134 return;
2135 }
2136 };
2137 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Rescued { reason };
2138 let result =
2139 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, job, &specs, Some(&outcome_ctx))
2140 .await;
2141 match result {
2142 Ok(()) => {
2143 if let Err(err) = tx.commit().await {
2144 error!(
2145 job_id = job.id,
2146 kind = %job.kind,
2147 rescue_reason = reason.as_str(),
2148 error = %err,
2149 "Rescued follow-up dispatch: commit failed"
2150 );
2151 }
2152 }
2153 Err(err) => {
2154 error!(
2155 job_id = job.id,
2156 kind = %job.kind,
2157 rescue_reason = reason.as_str(),
2158 error = %err,
2159 "Rescued follow-up dispatch: spec INSERT failed; rolling back"
2160 );
2161 let _ = tx.rollback().await;
2162 }
2163 }
2164 }
2165
2166 #[tracing::instrument(skip(self), name = "maintenance.promote")]
2168 async fn promote_scheduled(&self) {
2169 if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
2170 error!(error = %err, "Failed to promote scheduled jobs");
2171 }
2172 if let Err(err) = self
2173 .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
2174 .await
2175 {
2176 error!(error = %err, "Failed to promote retryable jobs");
2177 }
2178 }
2179
2180 async fn promote_due_state(
2181 &self,
2182 state: &'static str,
2183 label: &'static str,
2184 ) -> Result<(), awa_model::AwaError> {
2185 let mut promoted_total = 0usize;
2186 let mut notified_queues = HashSet::new();
2187
2188 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
2189 if self.cancel.is_cancelled() {
2190 break;
2191 }
2192
2193 match &self.storage {
2194 RuntimeStorage::Canonical => {
2195 let (promoted, queues) = self
2196 .promote_due_batch(state)
2197 .await
2198 .map_err(awa_model::AwaError::Database)?;
2199 if promoted == 0 {
2200 break;
2201 }
2202
2203 promoted_total += promoted;
2204 notified_queues.extend(queues);
2205
2206 if promoted < PROMOTE_BATCH_SIZE as usize {
2207 break;
2208 }
2209 }
2210 RuntimeStorage::QueueStorage(runtime) => {
2211 let job_state = match state {
2212 "scheduled" => awa_model::JobState::Scheduled,
2213 "retryable" => awa_model::JobState::Retryable,
2214 other => {
2215 return Err(awa_model::AwaError::Validation(format!(
2216 "unsupported queue storage promote state: {other}"
2217 )));
2218 }
2219 };
2220 let promote_start = std::time::Instant::now();
2221 let promoted = runtime
2222 .store
2223 .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
2224 .await?;
2225 self.metrics.record_promotion_batch(
2226 state,
2227 promoted as u64,
2228 promote_start.elapsed(),
2229 );
2230 if promoted == 0 {
2231 break;
2232 }
2233
2234 promoted_total += promoted;
2235
2236 if promoted < PROMOTE_BATCH_SIZE as usize {
2237 break;
2238 }
2239 }
2240 }
2241 }
2242
2243 if matches!(&self.storage, RuntimeStorage::QueueStorage(_)) {
2250 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
2251 if self.cancel.is_cancelled() {
2252 break;
2253 }
2254 let (promoted, queues) = self
2255 .promote_due_batch(state)
2256 .await
2257 .map_err(awa_model::AwaError::Database)?;
2258 if promoted == 0 {
2259 break;
2260 }
2261 promoted_total += promoted;
2262 notified_queues.extend(queues);
2263 if promoted < PROMOTE_BATCH_SIZE as usize {
2264 break;
2265 }
2266 }
2267 }
2268
2269 if matches!(&self.storage, RuntimeStorage::Canonical) {
2275 if let Some(runtime) = &self.standby_queue_storage {
2276 let active_schema: Option<String> =
2277 sqlx::query_scalar("SELECT awa.active_queue_storage_schema()")
2278 .fetch_one(&self.pool)
2279 .await
2280 .map_err(awa_model::AwaError::Database)?;
2281 if active_schema.as_deref() == Some(runtime.store.schema()) {
2282 let job_state = match state {
2283 "scheduled" => awa_model::JobState::Scheduled,
2284 "retryable" => awa_model::JobState::Retryable,
2285 other => {
2286 return Err(awa_model::AwaError::Validation(format!(
2287 "unsupported queue storage promote state: {other}"
2288 )));
2289 }
2290 };
2291 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
2292 if self.cancel.is_cancelled() {
2293 break;
2294 }
2295 let promote_start = std::time::Instant::now();
2296 let promoted = runtime
2297 .store
2298 .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
2299 .await?;
2300 self.metrics.record_promotion_batch(
2301 state,
2302 promoted as u64,
2303 promote_start.elapsed(),
2304 );
2305 if promoted == 0 {
2306 break;
2307 }
2308 promoted_total += promoted;
2309 if promoted < PROMOTE_BATCH_SIZE as usize {
2310 break;
2311 }
2312 }
2313 }
2314 }
2315 }
2316
2317 if promoted_total > 0 {
2318 debug!(
2319 count = promoted_total,
2320 queues = notified_queues.len(),
2321 state,
2322 "Promoted {label}"
2323 );
2324 }
2325
2326 Ok(())
2327 }
2328
2329 fn promote_sql(state: &'static str) -> String {
2335 format!(
2336 r#"
2337 WITH due AS (
2338 DELETE FROM awa.scheduled_jobs
2339 WHERE id IN (
2340 SELECT id
2341 FROM awa.scheduled_jobs
2342 WHERE state = '{state}'::awa.job_state
2343 AND run_at <= now()
2344 ORDER BY run_at ASC, id ASC
2345 LIMIT $1
2346 FOR UPDATE SKIP LOCKED
2347 )
2348 RETURNING *
2349 ),
2350 promoted AS (
2351 INSERT INTO awa.jobs_hot (
2352 id, kind, queue, args, state, priority, attempt, max_attempts,
2353 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
2354 created_at, errors, metadata, tags, unique_key, unique_states,
2355 callback_id, callback_timeout_at, callback_filter, callback_on_complete,
2356 callback_on_fail, callback_transform, run_lease, progress
2357 )
2358 SELECT
2359 id,
2360 kind,
2361 queue,
2362 args,
2363 'available'::awa.job_state,
2364 priority,
2365 attempt,
2366 max_attempts,
2367 now(),
2368 NULL,
2369 NULL,
2370 attempted_at,
2371 finalized_at,
2372 created_at,
2373 errors,
2374 metadata,
2375 tags,
2376 unique_key,
2377 unique_states,
2378 NULL,
2379 NULL,
2380 NULL,
2381 NULL,
2382 NULL,
2383 NULL,
2384 run_lease,
2385 progress
2386 FROM due
2387 RETURNING queue
2388 )
2389 SELECT queue FROM promoted
2390 "#
2391 )
2392 }
2393
2394 async fn promote_due_batch(
2395 &self,
2396 state: &'static str,
2397 ) -> Result<(usize, HashSet<String>), sqlx::Error> {
2398 let mut tx = self.pool.begin().await?;
2399 let promote_start = std::time::Instant::now();
2400 let sql = Self::promote_sql(state);
2401 let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
2402 .bind(PROMOTE_BATCH_SIZE)
2403 .fetch_all(&mut *tx)
2404 .await?;
2405
2406 let promoted = promoted_rows.len();
2407 self.metrics
2408 .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
2409 if promoted == 0 {
2410 tx.commit().await?;
2411 return Ok((0, HashSet::new()));
2412 }
2413
2414 let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
2415
2416 tx.commit().await?;
2417 Ok((promoted, queues))
2418 }
2419
2420 async fn rotate_queue_storage_queue(&self, prune_tracker: &PruneBackoffTracker) {
2421 let Some(runtime) = self.storage.queue_storage() else {
2422 return;
2423 };
2424
2425 match runtime.store.rotate(&self.pool).await {
2426 Ok(outcome) => {
2427 self.metrics.record_rotate_outcome("queue", &outcome);
2428 match outcome {
2429 RotateOutcome::Rotated { slot, generation } => {
2430 debug!(slot, generation, "Rotated queue storage queue segment");
2431 }
2432 RotateOutcome::SkippedBusy { slot, busy } => {
2433 debug!(
2434 slot,
2435 ready_rows = busy.queue_ready,
2436 claim_attempt_batches = busy.queue_claim_attempt_batches,
2437 done_rows = busy.queue_done,
2438 ready_segments = busy.queue_ready_segments,
2439 receipt_completion_batches = busy.queue_receipt_completion_batches,
2440 receipt_completion_tombstones =
2441 busy.queue_receipt_completion_tombstones,
2442 "Skipped busy queue storage queue segment",
2443 );
2444 }
2445 }
2446 }
2447 Err(err) => {
2448 error!(error = %err, "Failed to rotate queue storage queue segments");
2449 return;
2450 }
2451 }
2452
2453 if prune_tracker.should_skip(PRUNE_BRANCH_QUEUE) {
2454 debug!(branch = PRUNE_BRANCH_QUEUE, "Prune backed off this tick");
2455 return;
2456 }
2457
2458 match runtime
2459 .store
2460 .prune_oldest(&self.pool, self.failed_retention)
2461 .await
2462 {
2463 Ok(outcome) => {
2464 self.metrics.record_prune_outcome("queue", &outcome);
2465 prune_tracker.record_outcome(PRUNE_BRANCH_QUEUE, &outcome);
2466 match outcome {
2467 PruneOutcome::Noop => {}
2468 PruneOutcome::Pruned {
2469 slot,
2470 carried_failed_rows,
2471 } => {
2472 debug!(
2473 slot,
2474 carried_failed_rows, "Pruned queue storage queue segment"
2475 );
2476 }
2477 PruneOutcome::Blocked { slot } => {
2478 debug!(slot, "Queue storage queue segment prune blocked");
2479 }
2480 PruneOutcome::SkippedActive {
2481 slot,
2482 reason,
2483 count,
2484 } => {
2485 debug!(
2486 slot,
2487 reason = reason.as_str(),
2488 count,
2489 "Queue storage queue segment still active",
2490 );
2491 }
2492 }
2493 }
2494 Err(err) => {
2495 error!(error = %err, "Failed to prune queue storage queue segments");
2496 }
2497 }
2498 }
2499
2500 async fn rotate_queue_storage_leases(&self, prune_tracker: &PruneBackoffTracker) {
2501 let Some(runtime) = self.storage.queue_storage() else {
2502 return;
2503 };
2504
2505 match runtime.store.rotate_leases(&self.pool).await {
2506 Ok(outcome) => {
2507 self.metrics.record_rotate_outcome("lease", &outcome);
2508 match outcome {
2509 RotateOutcome::Rotated { slot, generation } => {
2510 debug!(slot, generation, "Rotated queue storage lease segment");
2511 }
2512 RotateOutcome::SkippedBusy { slot, busy } => {
2513 debug!(
2514 slot,
2515 lease_rows = busy.leases,
2516 "Skipped busy queue storage lease segment",
2517 );
2518 }
2519 }
2520 }
2521 Err(err) => {
2522 error!(error = %err, "Failed to rotate queue storage lease segments");
2523 return;
2524 }
2525 }
2526
2527 if prune_tracker.should_skip(PRUNE_BRANCH_LEASE) {
2528 debug!(branch = PRUNE_BRANCH_LEASE, "Prune backed off this tick");
2529 return;
2530 }
2531
2532 match runtime.store.prune_oldest_leases(&self.pool).await {
2533 Ok(outcome) => {
2534 self.metrics.record_prune_outcome("lease", &outcome);
2535 prune_tracker.record_outcome(PRUNE_BRANCH_LEASE, &outcome);
2536 match outcome {
2537 PruneOutcome::Noop => {}
2538 PruneOutcome::Pruned { slot, .. } => {
2539 debug!(slot, "Pruned queue storage lease segment");
2540 }
2541 PruneOutcome::Blocked { slot } => {
2542 debug!(slot, "Queue storage lease segment prune blocked");
2543 }
2544 PruneOutcome::SkippedActive {
2545 slot,
2546 reason,
2547 count,
2548 } => {
2549 debug!(
2550 slot,
2551 reason = reason.as_str(),
2552 count,
2553 "Queue storage lease segment still active",
2554 );
2555 }
2556 }
2557 }
2558 Err(err) => {
2559 error!(error = %err, "Failed to prune queue storage lease segments");
2560 }
2561 }
2562 }
2563
2564 async fn rotate_queue_storage_claims(&self, prune_tracker: &PruneBackoffTracker) {
2568 let Some(runtime) = self.storage.queue_storage() else {
2569 return;
2570 };
2571
2572 match runtime.store.rotate_claims(&self.pool).await {
2573 Ok(outcome) => {
2574 self.metrics.record_rotate_outcome("claim", &outcome);
2575 match outcome {
2576 RotateOutcome::Rotated { slot, generation } => {
2577 debug!(slot, generation, "Rotated queue storage claim segment");
2578 }
2579 RotateOutcome::SkippedBusy { slot, busy } => {
2580 debug!(
2581 slot,
2582 claim_rows = busy.claims,
2583 closure_rows = busy.closures,
2584 closure_batch_rows = busy.closure_batches,
2585 "Skipped busy queue storage claim segment",
2586 );
2587 }
2588 }
2589 }
2590 Err(err) => {
2591 error!(error = %err, "Failed to rotate queue storage claim segments");
2592 return;
2593 }
2594 }
2595
2596 if prune_tracker.should_skip(PRUNE_BRANCH_CLAIM) {
2597 debug!(branch = PRUNE_BRANCH_CLAIM, "Prune backed off this tick");
2598 return;
2599 }
2600
2601 match runtime.store.prune_oldest_claims(&self.pool).await {
2602 Ok(outcome) => {
2603 self.metrics.record_prune_outcome("claim", &outcome);
2604 prune_tracker.record_outcome(PRUNE_BRANCH_CLAIM, &outcome);
2605 match outcome {
2606 PruneOutcome::Noop => {}
2607 PruneOutcome::Pruned { slot, .. } => {
2608 debug!(slot, "Pruned queue storage claim segment");
2609 }
2610 PruneOutcome::Blocked { slot } => {
2611 debug!(slot, "Queue storage claim segment prune blocked");
2612 }
2613 PruneOutcome::SkippedActive {
2614 slot,
2615 reason,
2616 count,
2617 } => {
2618 debug!(
2619 slot,
2620 reason = reason.as_str(),
2621 count,
2622 "Queue storage claim segment still active",
2623 );
2624 }
2625 }
2626 }
2627 Err(err) => {
2628 error!(error = %err, "Failed to prune queue storage claim segments");
2629 }
2630 }
2631 }
2632
2633 #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
2640 async fn cleanup_completed(&self) {
2641 if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
2642 return;
2644 }
2645
2646 let mut total_deleted: u64 = 0;
2647
2648 let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
2650
2651 let completed_retention_secs =
2653 i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
2654 let failed_retention_secs =
2655 i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
2656
2657 let global_result = if override_queues.is_empty() {
2658 sqlx::query(
2659 r#"
2660 DELETE FROM awa.jobs_hot
2661 WHERE id IN (
2662 SELECT id FROM awa.jobs_hot
2663 WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2664 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
2665 LIMIT $3
2666 )
2667 "#,
2668 )
2669 .bind(completed_retention_secs)
2670 .bind(failed_retention_secs)
2671 .bind(self.cleanup_batch_size)
2672 .execute(&self.pool)
2673 .await
2674 } else {
2675 sqlx::query(
2676 r#"
2677 DELETE FROM awa.jobs_hot
2678 WHERE id IN (
2679 SELECT id FROM awa.jobs_hot
2680 WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2681 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2682 AND queue != ALL($4::text[])
2683 LIMIT $3
2684 )
2685 "#,
2686 )
2687 .bind(completed_retention_secs)
2688 .bind(failed_retention_secs)
2689 .bind(self.cleanup_batch_size)
2690 .bind(&override_queues)
2691 .execute(&self.pool)
2692 .await
2693 };
2694
2695 match global_result {
2696 Ok(result) if result.rows_affected() > 0 => {
2697 total_deleted += result.rows_affected();
2698 }
2699 Err(err) => {
2700 error!(error = %err, "Failed to clean up old jobs (global pass)");
2701 }
2702 _ => {}
2703 }
2704
2705 for (queue_name, policy) in &self.queue_retention_overrides {
2707 let queue_completed_secs =
2708 i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
2709 let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
2710
2711 match sqlx::query(
2712 r#"
2713 DELETE FROM awa.jobs_hot
2714 WHERE id IN (
2715 SELECT id FROM awa.jobs_hot
2716 WHERE queue = $4
2717 AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2718 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2719 LIMIT $3
2720 )
2721 "#,
2722 )
2723 .bind(queue_completed_secs)
2724 .bind(queue_failed_secs)
2725 .bind(self.cleanup_batch_size)
2726 .bind(queue_name)
2727 .execute(&self.pool)
2728 .await
2729 {
2730 Ok(result) if result.rows_affected() > 0 => {
2731 total_deleted += result.rows_affected();
2732 debug!(
2733 queue = %queue_name,
2734 count = result.rows_affected(),
2735 "Cleaned up old jobs (queue override)"
2736 );
2737 }
2738 Err(err) => {
2739 error!(
2740 queue = %queue_name,
2741 error = %err,
2742 "Failed to clean up old jobs (queue override)"
2743 );
2744 }
2745 _ => {}
2746 }
2747 }
2748
2749 if total_deleted > 0 {
2750 info!(count = total_deleted, "Cleaned up old jobs");
2751 }
2752 }
2753
2754 #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
2755 async fn cleanup_dlq_rows(&self) {
2756 let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
2757 return;
2758 };
2759
2760 let schema = runtime.store.schema();
2761 let override_queues: Vec<&str> = self
2762 .queue_retention_overrides
2763 .iter()
2764 .filter(|(_, policy)| policy.dlq.is_some())
2765 .map(|(queue, _)| queue.as_str())
2766 .collect();
2767 let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
2768
2769 let global_result = if override_queues.is_empty() {
2770 sqlx::query(&format!(
2771 r#"
2772 DELETE FROM {schema}.dlq_entries
2773 WHERE job_id IN (
2774 SELECT job_id FROM {schema}.dlq_entries
2775 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2776 LIMIT $2
2777 )
2778 "#
2779 ))
2780 .bind(retention_secs)
2781 .bind(self.dlq_cleanup_batch_size)
2782 .execute(&self.pool)
2783 .await
2784 } else {
2785 sqlx::query(&format!(
2786 r#"
2787 DELETE FROM {schema}.dlq_entries
2788 WHERE job_id IN (
2789 SELECT job_id FROM {schema}.dlq_entries
2790 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2791 AND queue != ALL($3::text[])
2792 LIMIT $2
2793 )
2794 "#
2795 ))
2796 .bind(retention_secs)
2797 .bind(self.dlq_cleanup_batch_size)
2798 .bind(&override_queues)
2799 .execute(&self.pool)
2800 .await
2801 };
2802
2803 match global_result {
2804 Ok(result) if result.rows_affected() > 0 => {
2805 self.metrics.record_dlq_purged(None, result.rows_affected());
2806 }
2807 Err(err) => {
2808 error!(error = %err, "Failed to clean up DLQ rows (global pass)");
2809 }
2810 _ => {}
2811 }
2812
2813 for (queue, policy) in &self.queue_retention_overrides {
2814 let Some(retention) = policy.dlq else {
2815 continue;
2816 };
2817 let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
2818 match sqlx::query(&format!(
2819 r#"
2820 DELETE FROM {schema}.dlq_entries
2821 WHERE job_id IN (
2822 SELECT job_id FROM {schema}.dlq_entries
2823 WHERE queue = $3
2824 AND dlq_at < now() - make_interval(secs => $1::bigint)
2825 LIMIT $2
2826 )
2827 "#
2828 ))
2829 .bind(retention_secs)
2830 .bind(self.dlq_cleanup_batch_size)
2831 .bind(queue)
2832 .execute(&self.pool)
2833 .await
2834 {
2835 Ok(result) if result.rows_affected() > 0 => {
2836 self.metrics
2837 .record_dlq_purged(Some(queue), result.rows_affected());
2838 }
2839 Err(err) => {
2840 error!(queue, error = %err, "Failed to clean up DLQ rows");
2841 }
2842 _ => {}
2843 }
2844 }
2845 }
2846}
2847
2848struct MaintenanceAliveGuard(Arc<AtomicBool>);
2849
2850impl Drop for MaintenanceAliveGuard {
2851 fn drop(&mut self) {
2852 self.0.store(false, Ordering::SeqCst);
2853 }
2854}
2855
2856fn compute_fire_times(
2862 row: &CronJobRow,
2863 now: chrono::DateTime<Utc>,
2864 limit: usize,
2865) -> Vec<chrono::DateTime<Utc>> {
2866 let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
2867 Ok(c) => c,
2868 Err(err) => {
2869 error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
2870 return Vec::new();
2871 }
2872 };
2873
2874 let tz: chrono_tz::Tz = match row.timezone.parse() {
2875 Ok(tz) => tz,
2876 Err(err) => {
2877 error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
2878 return Vec::new();
2879 }
2880 };
2881
2882 let search_start = match row.last_enqueued_at {
2883 Some(last) => last.with_timezone(&tz),
2884 None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
2889 };
2890
2891 let missed_fire_policy = match CronMissedFirePolicy::parse(&row.missed_fire_policy) {
2892 Ok(policy) => policy,
2893 Err(err) => {
2894 error!(cron_name = %row.name, error = %err, "Invalid cron missed-fire policy in database");
2895 return Vec::new();
2896 }
2897 };
2898 let should_catch_up =
2899 row.last_enqueued_at.is_some() && missed_fire_policy == CronMissedFirePolicy::CatchUp;
2900
2901 if !should_catch_up {
2902 return latest_due_fire(&cron, tz, search_start, row.last_enqueued_at, now)
2903 .into_iter()
2904 .collect();
2905 }
2906
2907 let mut fire_times = Vec::new();
2908 for fire_time in cron.iter_from(search_start) {
2909 let fire_utc = fire_time.with_timezone(&Utc);
2910
2911 if fire_utc > now {
2912 break;
2913 }
2914
2915 if let Some(last) = row.last_enqueued_at {
2916 if fire_utc <= last {
2917 continue;
2918 }
2919 }
2920
2921 fire_times.push(fire_utc);
2922 if fire_times.len() >= limit {
2923 break;
2924 }
2925 }
2926
2927 fire_times
2928}
2929
2930fn latest_due_fire(
2931 cron: &Cron,
2932 tz: chrono_tz::Tz,
2933 search_start: chrono::DateTime<chrono_tz::Tz>,
2934 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2935 now: chrono::DateTime<Utc>,
2936) -> Option<chrono::DateTime<Utc>> {
2937 let first_due = first_due_fire(cron, search_start, last_enqueued_at, now)?;
2938 let total_span_seconds = now.signed_duration_since(first_due).num_seconds().max(1);
2939 let mut lookback_seconds = 1_i64;
2940
2941 loop {
2942 let window_start_utc = (now - chrono::Duration::seconds(lookback_seconds)).max(first_due);
2946 let window_start = window_start_utc.with_timezone(&tz);
2947 let next_in_window = cron
2948 .iter_from(window_start)
2949 .next()
2950 .map(|fire_time| fire_time.with_timezone(&Utc));
2951
2952 if next_in_window.is_some_and(|fire_utc| {
2953 fire_utc <= now && last_enqueued_at.is_none_or(|last| fire_utc > last)
2954 }) {
2955 return latest_due_fire_in_window(cron, window_start, last_enqueued_at, now)
2956 .or(Some(first_due));
2957 }
2958
2959 if lookback_seconds >= total_span_seconds {
2960 return Some(first_due);
2961 }
2962
2963 lookback_seconds = lookback_seconds.saturating_mul(2).min(total_span_seconds);
2964 }
2965}
2966
2967fn first_due_fire(
2968 cron: &Cron,
2969 search_start: chrono::DateTime<chrono_tz::Tz>,
2970 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2971 now: chrono::DateTime<Utc>,
2972) -> Option<chrono::DateTime<Utc>> {
2973 for fire_time in cron.iter_from(search_start) {
2974 let fire_utc = fire_time.with_timezone(&Utc);
2975 if fire_utc > now {
2976 return None;
2977 }
2978 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2979 return Some(fire_utc);
2980 }
2981 }
2982
2983 None
2984}
2985
2986fn latest_due_fire_in_window(
2987 cron: &Cron,
2988 window_start: chrono::DateTime<chrono_tz::Tz>,
2989 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2990 now: chrono::DateTime<Utc>,
2991) -> Option<chrono::DateTime<Utc>> {
2992 let mut latest_fire = None;
2993
2994 for fire_time in cron.iter_from(window_start) {
2995 let fire_utc = fire_time.with_timezone(&Utc);
2996 if fire_utc > now {
2997 break;
2998 }
2999 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
3000 latest_fire = Some(fire_utc);
3001 }
3002 }
3003
3004 latest_fire
3005}
3006
3007impl MaintenanceService {
3008 #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
3011 async fn cleanup_stale_runtime_snapshots(&self) {
3012 if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
3013 &self.pool,
3014 chrono::TimeDelta::try_hours(24).unwrap(),
3015 )
3016 .await
3017 {
3018 tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
3019 }
3020 }
3021
3022 #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
3027 async fn cleanup_stale_descriptors(&self) {
3028 if self.descriptor_retention.is_zero() {
3029 return;
3030 }
3031 let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
3032 .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
3033 for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
3034 match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
3035 Ok(deleted) if deleted > 0 => {
3036 tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
3037 }
3038 Ok(_) => {}
3039 Err(err) => {
3040 tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
3041 }
3042 }
3043 }
3044 }
3045
3046 #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
3050 async fn recompute_dirty_admin_metadata(&self) {
3051 if self.storage.queue_storage().is_some() {
3052 return;
3053 }
3054 match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
3055 Ok(count) if count > 0 => {
3056 tracing::debug!(count, "Recomputed dirty admin metadata keys");
3057 }
3058 Err(err) => {
3059 tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
3060 }
3061 _ => {}
3062 }
3063 }
3064
3065 #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
3068 async fn refresh_admin_metadata(&self) {
3069 if self.storage.queue_storage().is_some() {
3070 return;
3071 }
3072 if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
3073 tracing::warn!(error = %err, "Failed to refresh admin metadata");
3074 }
3075 }
3076
3077 #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
3079 async fn publish_queue_health_metrics(&self) {
3080 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
3081 self.publish_queue_storage_health_metrics(runtime).await;
3082 return;
3083 }
3084
3085 let stats = match awa_model::admin::queue_overviews(&self.pool).await {
3086 Ok(stats) => stats,
3087 Err(err) => {
3088 tracing::warn!(error = %err, "Failed to query queue stats for metrics");
3089 return;
3090 }
3091 };
3092
3093 for queue_stat in &stats {
3094 let queue = &queue_stat.queue;
3095
3096 self.metrics
3098 .record_queue_depth(queue, "available", queue_stat.available);
3099 self.metrics
3100 .record_queue_depth(queue, "running", queue_stat.running);
3101 self.metrics
3102 .record_queue_depth(queue, "failed", queue_stat.failed);
3103 self.metrics
3104 .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
3105 self.metrics
3106 .record_queue_depth(queue, "retryable", queue_stat.retryable);
3107 self.metrics
3108 .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
3109
3110 if let Some(lag_seconds) = queue_stat.lag_seconds {
3112 self.metrics.record_queue_lag(queue, lag_seconds);
3113 }
3114 }
3115 }
3116
3117 async fn publish_queue_storage_health_metrics(
3118 &self,
3119 runtime: &crate::storage::QueueStorageRuntime,
3120 ) {
3121 let schema = runtime.store.schema();
3122 let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
3128 r#"
3129 WITH head_signal AS (
3130 SELECT
3131 enqueues.queue,
3132 enqueues.priority,
3133 enqueues.enqueue_shard,
3134 {schema}.sequence_next_value(enqueues.seq_name) AS next_seq,
3135 {schema}.sequence_next_value(claims.seq_name) AS claim_seq
3136 FROM {schema}.queue_enqueue_heads AS enqueues
3137 JOIN {schema}.queue_claim_heads AS claims
3138 ON claims.queue = enqueues.queue
3139 AND claims.priority = enqueues.priority
3140 AND claims.enqueue_shard = enqueues.enqueue_shard
3141 ),
3142 queues AS (
3143 SELECT DISTINCT queue
3144 FROM (
3145 SELECT queue FROM awa.queue_meta
3146 UNION ALL
3147 SELECT queue FROM head_signal
3148 UNION ALL
3149 SELECT queue FROM {schema}.leases
3150 UNION ALL
3151 SELECT queue FROM {schema}.deferred_jobs
3152 UNION ALL
3153 SELECT queue FROM {schema}.queue_terminal_live_counts
3154 UNION ALL
3155 SELECT queue FROM {schema}.queue_terminal_rollups
3156 UNION ALL
3157 SELECT queue FROM {schema}.dlq_entries
3158 ) queues
3159 ),
3160 ready AS (
3161 SELECT
3162 head_signal.queue,
3163 COALESCE(
3164 sum(GREATEST(head_signal.next_seq - head_signal.claim_seq, 0)),
3165 0
3166 )::bigint AS available
3167 FROM head_signal
3168 GROUP BY head_signal.queue
3169 ),
3170 lag AS (
3171 SELECT
3172 head_signal.queue,
3173 EXTRACT(EPOCH FROM clock_timestamp() - min(next_ready.run_at))::double precision
3174 AS lag_seconds
3175 FROM head_signal
3176 JOIN LATERAL (
3177 SELECT ready.run_at
3178 FROM {schema}.ready_entries AS ready
3179 WHERE ready.queue = head_signal.queue
3180 AND ready.priority = head_signal.priority
3181 AND ready.enqueue_shard = head_signal.enqueue_shard
3182 AND ready.lane_seq >= head_signal.claim_seq
3183 AND NOT EXISTS (
3184 SELECT 1
3185 FROM {schema}.ready_tombstones AS tomb
3186 WHERE tomb.ready_slot = ready.ready_slot
3187 AND tomb.ready_generation = ready.ready_generation
3188 AND tomb.queue = ready.queue
3189 AND tomb.priority = ready.priority
3190 AND tomb.enqueue_shard = ready.enqueue_shard
3191 AND tomb.lane_seq = ready.lane_seq
3192 )
3193 ORDER BY ready.lane_seq
3194 LIMIT 1
3195 ) AS next_ready ON TRUE
3196 GROUP BY head_signal.queue
3197 ),
3198 leases AS (
3199 SELECT
3200 queue,
3201 count(*) FILTER (WHERE state = 'running')::bigint AS running,
3202 count(*) FILTER (WHERE state = 'waiting_external')::bigint
3203 AS waiting_external
3204 FROM {schema}.leases
3205 GROUP BY queue
3206 ),
3207 deferred AS (
3208 SELECT
3209 queue,
3210 count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
3211 count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
3212 FROM {schema}.deferred_jobs
3213 GROUP BY queue
3214 ),
3215 terminal AS (
3216 SELECT
3217 queue,
3218 count(*)::bigint AS failed_done
3219 FROM {schema}.done_entries
3220 WHERE state = 'failed'
3221 GROUP BY queue
3222 ),
3223 dlq AS (
3224 SELECT
3225 queue,
3226 count(*)::bigint AS failed_dlq
3227 FROM {schema}.dlq_entries
3228 GROUP BY queue
3229 )
3230 SELECT
3231 queues.queue,
3232 COALESCE(ready.available, 0)::bigint AS available,
3233 COALESCE(leases.running, 0)::bigint AS running,
3234 COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
3235 COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
3236 COALESCE(deferred.retryable, 0)::bigint AS retryable,
3237 COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
3238 COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
3239 lag.lag_seconds
3240 FROM queues
3241 LEFT JOIN ready
3242 ON ready.queue = queues.queue
3243 LEFT JOIN lag
3244 ON lag.queue = queues.queue
3245 LEFT JOIN leases
3246 ON leases.queue = queues.queue
3247 LEFT JOIN deferred
3248 ON deferred.queue = queues.queue
3249 LEFT JOIN terminal
3250 ON terminal.queue = queues.queue
3251 LEFT JOIN dlq
3252 ON dlq.queue = queues.queue
3253 ORDER BY queues.queue
3254 "#
3255 ))
3256 .fetch_all(&self.pool)
3257 .await
3258 {
3259 Ok(rows) => rows,
3260 Err(err) => {
3261 tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
3262 return;
3263 }
3264 };
3265
3266 for (
3267 queue,
3268 available,
3269 running,
3270 waiting_external,
3271 scheduled,
3272 retryable,
3273 failed_done,
3274 failed_dlq,
3275 lag_seconds,
3276 ) in rows
3277 {
3278 self.metrics
3279 .record_queue_depth(&queue, "available", available);
3280 self.metrics.record_queue_depth(&queue, "running", running);
3281 self.metrics
3282 .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
3283 self.metrics
3284 .record_queue_depth(&queue, "scheduled", scheduled);
3285 self.metrics
3286 .record_queue_depth(&queue, "retryable", retryable);
3287 self.metrics
3288 .record_queue_depth(&queue, "waiting_external", waiting_external);
3289 self.metrics.record_dlq_depth(&queue, failed_dlq);
3290
3291 if let Some(lag_seconds) = lag_seconds {
3292 self.metrics.record_queue_lag(&queue, lag_seconds);
3293 }
3294 }
3295 }
3296}
3297
3298#[cfg(test)]
3299mod tests {
3300 use super::*;
3301 use awa_model::{migrations, QueueStorage, QueueStorageConfig};
3302 use chrono::TimeZone;
3303 use sqlx::postgres::PgPoolOptions;
3304 use std::sync::OnceLock;
3305
3306 fn cron_row(
3307 cron_expr: &str,
3308 created_at: chrono::DateTime<Utc>,
3309 last_enqueued_at: Option<chrono::DateTime<Utc>>,
3310 missed_fire_policy: CronMissedFirePolicy,
3311 ) -> CronJobRow {
3312 CronJobRow {
3313 name: "test_cron".to_string(),
3314 cron_expr: cron_expr.to_string(),
3315 timezone: "UTC".to_string(),
3316 kind: "test_job".to_string(),
3317 queue: "default".to_string(),
3318 args: serde_json::json!({}),
3319 priority: 2,
3320 max_attempts: 25,
3321 tags: Vec::new(),
3322 metadata: serde_json::json!({}),
3323 missed_fire_policy: missed_fire_policy.as_str().to_string(),
3324 last_enqueued_at,
3325 created_at,
3326 updated_at: created_at,
3327 paused_at: None,
3328 paused_by: None,
3329 }
3330 }
3331
3332 #[test]
3333 fn compute_fire_times_coalesces_missed_existing_fires_by_default() {
3334 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3335 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3336 let row = cron_row(
3337 "*/5 * * * * *",
3338 last,
3339 Some(last),
3340 CronMissedFirePolicy::Coalesce,
3341 );
3342
3343 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3344
3345 assert_eq!(
3346 fires,
3347 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3348 );
3349 }
3350
3351 #[test]
3352 fn compute_fire_times_coalesces_to_latest_fire_after_long_outage() {
3353 let last = Utc.with_ymd_and_hms(2026, 5, 6, 12, 0, 0).unwrap();
3354 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3355 let row = cron_row(
3356 "*/1 * * * * *",
3357 last,
3358 Some(last),
3359 CronMissedFirePolicy::Coalesce,
3360 );
3361
3362 let fires = compute_fire_times(&row, now, 2);
3363
3364 assert_eq!(
3365 fires,
3366 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3367 );
3368 }
3369
3370 #[test]
3371 fn compute_fire_times_catches_up_when_policy_requests_it() {
3372 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3373 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3374 let row = cron_row(
3375 "*/5 * * * * *",
3376 last,
3377 Some(last),
3378 CronMissedFirePolicy::CatchUp,
3379 );
3380
3381 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3382
3383 assert_eq!(
3384 fires,
3385 vec![
3386 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3387 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3388 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 15).unwrap(),
3389 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap(),
3390 ]
3391 );
3392 }
3393
3394 #[test]
3395 fn compute_fire_times_limits_catch_up_work() {
3396 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3397 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3398 let row = cron_row(
3399 "*/5 * * * * *",
3400 last,
3401 Some(last),
3402 CronMissedFirePolicy::CatchUp,
3403 );
3404
3405 let fires = compute_fire_times(&row, now, 2);
3406
3407 assert_eq!(
3408 fires,
3409 vec![
3410 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3411 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3412 ]
3413 );
3414 }
3415
3416 fn metrics_for_test() -> crate::metrics::AwaMetrics {
3419 crate::metrics::AwaMetrics::from_global()
3420 }
3421
3422 fn database_url() -> String {
3423 std::env::var("DATABASE_URL")
3424 .unwrap_or_else(|_| "postgres://postgres:test@localhost:15432/awa_test".to_string())
3425 }
3426
3427 fn db_test_mutex() -> &'static tokio::sync::Mutex<()> {
3428 static MUTEX: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
3429 MUTEX.get_or_init(|| tokio::sync::Mutex::new(()))
3430 }
3431
3432 async fn ensure_database_exists(url: &str) {
3433 let parts = url
3434 .rsplit_once('/')
3435 .expect("DATABASE_URL must include a database name");
3436 let database_name = parts.1.to_string();
3437 let admin_url = format!("{}/postgres", parts.0);
3438 let admin_pool = PgPoolOptions::new()
3439 .max_connections(1)
3440 .connect(&admin_url)
3441 .await
3442 .expect("Failed to connect to admin database for maintenance tests");
3443 let create_sql = format!("CREATE DATABASE {database_name}");
3444 match sqlx::query(&create_sql).execute(&admin_pool).await {
3445 Ok(_) => {}
3446 Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("42P04") => {}
3447 Err(err) => panic!("Failed to create maintenance test database {database_name}: {err}"),
3448 }
3449 }
3450
3451 async fn setup_pool(max_connections: u32) -> PgPool {
3452 let url = database_url();
3453 ensure_database_exists(&url).await;
3454 PgPoolOptions::new()
3455 .max_connections(max_connections)
3456 .acquire_timeout(Duration::from_secs(5))
3457 .connect(&url)
3458 .await
3459 .expect("Failed to connect to maintenance test database")
3460 }
3461
3462 async fn reset_schema(pool: &PgPool) {
3463 sqlx::raw_sql("DROP SCHEMA IF EXISTS awa CASCADE")
3464 .execute(pool)
3465 .await
3466 .expect("Failed to drop awa schema");
3467 }
3468
3469 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3470 async fn queue_storage_metrics_query_uses_bounded_observability_path() {
3471 let _guard = db_test_mutex().lock().await;
3472 let pool = setup_pool(4).await;
3473 reset_schema(&pool).await;
3474 migrations::run(&pool)
3475 .await
3476 .expect("migrations should succeed");
3477
3478 let store = QueueStorage::new(QueueStorageConfig::default()).expect("queue storage");
3479 store.install(&pool).await.expect("queue storage install");
3480
3481 let runtime = crate::storage::QueueStorageRuntime::new(
3482 QueueStorageConfig::default(),
3483 Duration::from_millis(1000),
3484 Duration::from_millis(250),
3485 )
3486 .expect("queue storage runtime");
3487 let service = MaintenanceService::new(
3488 pool,
3489 metrics_for_test(),
3490 Arc::new(AtomicBool::new(true)),
3491 Arc::new(AtomicBool::new(true)),
3492 CancellationToken::new(),
3493 Arc::new(Vec::new()),
3494 InFlightMap::default(),
3495 RuntimeStorage::QueueStorage(runtime.clone()),
3496 Arc::new(HashMap::new()),
3497 Arc::new(HashMap::new()),
3498 );
3499
3500 let mut receipt_lock_tx = service
3501 .pool
3502 .begin()
3503 .await
3504 .expect("begin receipt lock transaction");
3505 sqlx::query(
3506 "LOCK TABLE awa.lease_claims, awa.lease_claim_closures, awa.lease_claim_closure_batches IN ACCESS EXCLUSIVE MODE",
3507 )
3508 .execute(receipt_lock_tx.as_mut())
3509 .await
3510 .expect("lock receipt tables");
3511
3512 tokio::time::timeout(
3513 Duration::from_secs(3),
3514 service.publish_queue_storage_health_metrics(&runtime),
3515 )
3516 .await
3517 .expect("queue-storage metrics must not wait on receipt tables");
3518
3519 receipt_lock_tx
3520 .rollback()
3521 .await
3522 .expect("release receipt table locks");
3523 }
3524
3525 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3534 async fn queue_storage_leader_rescues_canonical_plane() {
3535 let base_url = database_url();
3536 let url = format!("{}_rescue_mirror", base_url.trim_end_matches('/'));
3537 ensure_database_exists(&url).await;
3538 let pool = PgPoolOptions::new()
3539 .max_connections(4)
3540 .acquire_timeout(Duration::from_secs(5))
3541 .connect(&url)
3542 .await
3543 .expect("Failed to connect to rescue-mirror test database");
3544 reset_schema(&pool).await;
3545 migrations::run(&pool)
3546 .await
3547 .expect("migrations should succeed");
3548
3549 let store = QueueStorage::new(QueueStorageConfig::default()).expect("queue storage");
3550 store.install(&pool).await.expect("queue storage install");
3551
3552 sqlx::query(
3558 r#"
3559 UPDATE awa.storage_transition_state
3560 SET current_engine = 'canonical',
3561 prepared_engine = NULL,
3562 state = 'canonical',
3563 transition_epoch = transition_epoch + 1,
3564 details = '{}'::jsonb,
3565 updated_at = now(),
3566 finalized_at = NULL
3567 WHERE singleton
3568 "#,
3569 )
3570 .execute(&pool)
3571 .await
3572 .expect("rewind transition state to canonical");
3573
3574 let stale_running: i64 = sqlx::query_scalar(
3576 r#"
3577 INSERT INTO awa.jobs_hot (kind, queue, state, attempt, heartbeat_at, attempted_at)
3578 VALUES ('rescue_mirror', 'rescue_mirror_q', 'running', 1, now() - interval '1 hour', now())
3579 RETURNING id
3580 "#,
3581 )
3582 .fetch_one(&pool)
3583 .await
3584 .expect("seed stale canonical running job");
3585
3586 let expired_callback: i64 = sqlx::query_scalar(
3591 r#"
3592 INSERT INTO awa.jobs_hot (
3593 kind, queue, state, attempt, attempted_at,
3594 callback_id, callback_timeout_at
3595 )
3596 VALUES (
3597 'rescue_mirror', 'rescue_mirror_q', 'waiting_external', 1, now(),
3598 gen_random_uuid(), now() - interval '1 hour'
3599 )
3600 RETURNING id
3601 "#,
3602 )
3603 .fetch_one(&pool)
3604 .await
3605 .expect("seed expired-callback canonical job");
3606
3607 sqlx::query(
3608 r#"
3609 INSERT INTO awa.runtime_instances (
3610 instance_id, hostname, pid, version, storage_capability,
3611 transition_role, started_at, last_seen_at, snapshot_interval_ms,
3612 healthy, postgres_connected, poll_loop_alive, heartbeat_alive,
3613 maintenance_alive, shutting_down, leader, global_max_workers,
3614 queues, queue_descriptor_hashes, job_kind_descriptor_hashes
3615 )
3616 VALUES (
3617 gen_random_uuid(), 'rescue-mirror-test', 0, '0.0.0-test',
3618 'queue_storage', 'queue_storage_target',
3619 now(), now(), 5000, TRUE, TRUE, TRUE, TRUE, TRUE,
3620 FALSE, FALSE, 1,
3621 '[]'::jsonb, '[]'::jsonb, '[]'::jsonb
3622 )
3623 "#,
3624 )
3625 .execute(&pool)
3626 .await
3627 .expect("stamp queue_storage_target runtime");
3628 sqlx::query("SELECT awa.storage_prepare('queue_storage', '{\"schema\": \"awa\"}'::jsonb)")
3629 .execute(&pool)
3630 .await
3631 .expect("storage_prepare");
3632 sqlx::query("SELECT awa.storage_enter_mixed_transition()")
3633 .execute(&pool)
3634 .await
3635 .expect("storage_enter_mixed_transition");
3636
3637 let runtime = crate::storage::QueueStorageRuntime::new(
3638 QueueStorageConfig::default(),
3639 Duration::from_millis(1000),
3640 Duration::from_millis(250),
3641 )
3642 .expect("queue storage runtime");
3643 let service = MaintenanceService::new(
3644 pool.clone(),
3645 metrics_for_test(),
3646 Arc::new(AtomicBool::new(true)),
3647 Arc::new(AtomicBool::new(true)),
3648 CancellationToken::new(),
3649 Arc::new(Vec::new()),
3650 InFlightMap::default(),
3651 RuntimeStorage::QueueStorage(runtime),
3652 Arc::new(HashMap::new()),
3653 Arc::new(HashMap::new()),
3654 );
3655
3656 service.rescue_stale_heartbeats().await;
3657 service.rescue_expired_callbacks().await;
3658
3659 let state: String =
3663 sqlx::query_scalar("SELECT state::text FROM awa.scheduled_jobs WHERE id = $1")
3664 .bind(stale_running)
3665 .fetch_one(&pool)
3666 .await
3667 .expect("read rescued stale-heartbeat job");
3668 assert_eq!(state, "retryable");
3669
3670 let (state, callback_id): (String, Option<uuid::Uuid>) =
3671 sqlx::query_as("SELECT state::text, callback_id FROM awa.scheduled_jobs WHERE id = $1")
3672 .bind(expired_callback)
3673 .fetch_one(&pool)
3674 .await
3675 .expect("read rescued callback job");
3676 assert_eq!(state, "retryable");
3677 assert_eq!(callback_id, None, "rescue must clear the callback park");
3678
3679 let leftover: i64 =
3680 sqlx::query_scalar("SELECT count(*) FROM awa.jobs_hot WHERE kind = 'rescue_mirror'")
3681 .fetch_one(&pool)
3682 .await
3683 .expect("count leftover canonical hot rows");
3684 assert_eq!(leftover, 0, "no stuck canonical rows may remain");
3685 }
3686
3687 #[test]
3688 fn branch_tracker_initial_state_has_no_history() {
3689 let tracker = MaintenanceBranchTracker::new();
3690 assert_eq!(tracker.snapshot("promote_scheduled"), None);
3691 }
3692
3693 #[test]
3694 fn branch_tracker_finish_records_last_duration() {
3695 let tracker = MaintenanceBranchTracker::new();
3696 let metrics = metrics_for_test();
3697 let timer = tracker
3698 .try_begin("promote_scheduled", Duration::from_secs(1), &metrics)
3699 .expect("first tick should not be skipped");
3700 timer.finish();
3702 let (last_duration, is_delayed) = tracker
3703 .snapshot("promote_scheduled")
3704 .expect("snapshot should exist after one finish");
3705 assert!(last_duration.is_some());
3706 assert!(
3707 !is_delayed,
3708 "first tick has no prior duration → not delayed"
3709 );
3710 }
3711
3712 fn replay_ticks(
3724 tracker: &MaintenanceBranchTracker,
3725 branch: &'static str,
3726 body_duration: Duration,
3727 tick_interval: Duration,
3728 n: u32,
3729 ) -> Vec<bool> {
3730 let metrics = metrics_for_test();
3731 let mut ran = Vec::with_capacity(n as usize);
3732 for _ in 0..n {
3733 let timer_opt = tracker.try_begin(branch, tick_interval, &metrics);
3734 let did_run = timer_opt.is_some();
3735 ran.push(did_run);
3736 if did_run {
3737 tracker.record_finish(branch, body_duration);
3738 }
3739 }
3740 ran
3741 }
3742
3743 fn seed_last_duration(tracker: &MaintenanceBranchTracker, branch: &'static str, dur: Duration) {
3747 tracker
3748 .branches
3749 .lock()
3750 .unwrap()
3751 .entry(branch)
3752 .or_default()
3753 .last_duration = Some(dur);
3754 }
3755
3756 #[test]
3757 fn branch_tracker_single_overrun_does_not_flip() {
3758 let tracker = MaintenanceBranchTracker::new();
3761 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3762 replay_ticks(
3763 &tracker,
3764 "cleanup",
3765 Duration::from_millis(200), Duration::from_millis(100),
3767 1,
3768 );
3769 assert!(
3770 !tracker.snapshot("cleanup").unwrap().1,
3771 "single overrun must not flip is_delayed (K=3 required)"
3772 );
3773 }
3774
3775 #[test]
3776 fn branch_tracker_deadband_sample_does_not_advance_counters() {
3777 let tracker = MaintenanceBranchTracker::new();
3782 seed_last_duration(&tracker, "cleanup", Duration::from_millis(101));
3783 replay_ticks(
3784 &tracker,
3785 "cleanup",
3786 Duration::from_millis(101),
3787 Duration::from_millis(100),
3788 5,
3789 );
3790 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3791 assert_eq!(cooldown, 0, "deadband samples should not arm cooldown");
3792 assert_eq!(overrun, 0, "deadband sample 101ms must not advance overrun");
3793 assert_eq!(ontime, 0, "deadband sample 101ms must not advance ontime");
3794 }
3795
3796 #[test]
3797 fn branch_tracker_k_consecutive_overruns_flips_and_arms_cooldown() {
3798 let tracker = MaintenanceBranchTracker::new();
3804 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3805 let ran = replay_ticks(
3806 &tracker,
3807 "cleanup",
3808 Duration::from_millis(250),
3809 Duration::from_millis(100),
3810 OVERRUN_HYSTERESIS_K,
3811 );
3812 assert_eq!(ran, vec![true, true, false], "flip-tick skips body");
3813 let (cooldown, overrun, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3814 assert!(tracker.snapshot("cleanup").unwrap().1, "flipped to delayed");
3815 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3816 assert_eq!(
3817 cooldown, BRANCH_COOLDOWN_TICKS,
3818 "cooldown armed to BRANCH_COOLDOWN_TICKS at flip"
3819 );
3820 }
3821
3822 #[test]
3823 fn branch_tracker_without_cooldown_tracks_overrun_but_keeps_running() {
3824 let tracker = MaintenanceBranchTracker::new();
3825 let metrics = metrics_for_test();
3826 seed_last_duration(
3827 &tracker,
3828 "terminal_count_rollup",
3829 Duration::from_millis(250),
3830 );
3831
3832 let mut ran = Vec::new();
3833 for _ in 0..OVERRUN_HYSTERESIS_K {
3834 let timer = tracker.try_begin_without_cooldown(
3835 "terminal_count_rollup",
3836 Duration::from_millis(100),
3837 &metrics,
3838 );
3839 ran.push(timer.is_some());
3840 if timer.is_some() {
3841 tracker.record_finish("terminal_count_rollup", Duration::from_millis(250));
3842 }
3843 }
3844
3845 assert_eq!(
3846 ran,
3847 vec![true, true, true],
3848 "no-cooldown branches should keep running on overrun"
3849 );
3850 let (cooldown, overrun, _) = tracker
3851 .cooldown_snapshot("terminal_count_rollup")
3852 .expect("snapshot");
3853 assert_eq!(cooldown, 0, "no-cooldown branch must not arm cooldown");
3854 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3855 assert!(
3856 tracker.snapshot("terminal_count_rollup").unwrap().1,
3857 "overrun state should still be observable"
3858 );
3859 }
3860
3861 #[test]
3862 fn branch_tracker_cooldown_skips_body() {
3863 let tracker = MaintenanceBranchTracker::new();
3869 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3870 replay_ticks(
3871 &tracker,
3872 "cleanup",
3873 Duration::from_millis(250),
3874 Duration::from_millis(100),
3875 OVERRUN_HYSTERESIS_K,
3876 );
3877 let ran = replay_ticks(
3881 &tracker,
3882 "cleanup",
3883 Duration::from_millis(50),
3884 Duration::from_millis(100),
3885 BRANCH_COOLDOWN_TICKS,
3886 );
3887 assert!(
3888 ran.iter().all(|&r| !r),
3889 "every tick during cooldown must skip body"
3890 );
3891 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3892 assert_eq!(cooldown, 0, "cooldown decrements to zero");
3893 }
3894
3895 #[test]
3896 fn branch_tracker_cooldown_expires_then_body_runs() {
3897 let tracker = MaintenanceBranchTracker::new();
3904 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3905 replay_ticks(
3906 &tracker,
3907 "cleanup",
3908 Duration::from_millis(250),
3909 Duration::from_millis(100),
3910 OVERRUN_HYSTERESIS_K,
3911 );
3912 replay_ticks(
3913 &tracker,
3914 "cleanup",
3915 Duration::from_millis(50),
3916 Duration::from_millis(100),
3917 BRANCH_COOLDOWN_TICKS,
3918 );
3919 let ran = replay_ticks(
3920 &tracker,
3921 "cleanup",
3922 Duration::from_millis(50),
3923 Duration::from_millis(100),
3924 1,
3925 );
3926 assert_eq!(ran, vec![true], "post-cooldown body runs");
3927 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3928 assert_eq!(
3929 cooldown, 0,
3930 "cooldown stays at zero after a single fast body"
3931 );
3932 assert_eq!(
3933 overrun, OVERRUN_HYSTERESIS_K,
3934 "consecutive_overrun preserved across cooldown (no eval on this tick)"
3935 );
3936 assert_eq!(
3937 ontime, 0,
3938 "ontime advances on the next tick — this one had no sample to evaluate"
3939 );
3940 }
3941
3942 #[test]
3943 fn branch_tracker_cooldown_rearms_on_continued_overrun() {
3944 let tracker = MaintenanceBranchTracker::new();
3950 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3951 replay_ticks(
3952 &tracker,
3953 "cleanup",
3954 Duration::from_millis(250),
3955 Duration::from_millis(100),
3956 OVERRUN_HYSTERESIS_K,
3957 );
3958 replay_ticks(
3959 &tracker,
3960 "cleanup",
3961 Duration::from_millis(50),
3962 Duration::from_millis(100),
3963 BRANCH_COOLDOWN_TICKS,
3964 );
3965
3966 let ran = replay_ticks(
3970 &tracker,
3971 "cleanup",
3972 Duration::from_millis(250),
3973 Duration::from_millis(100),
3974 2,
3975 );
3976 assert_eq!(
3977 ran,
3978 vec![true, false],
3979 "first tick runs body; second tick re-arms cooldown"
3980 );
3981 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3982 assert_eq!(cooldown, BRANCH_COOLDOWN_TICKS, "cooldown re-armed");
3983 assert!(
3984 tracker.snapshot("cleanup").unwrap().1,
3985 "still delayed across re-arm"
3986 );
3987 }
3988
3989 #[test]
3990 fn branch_tracker_intermittent_overrun_does_not_flip() {
3991 let tracker = MaintenanceBranchTracker::new();
3995 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3996 for over in [true, false, true, false, true] {
3997 let dur = if over {
3998 Duration::from_millis(200)
3999 } else {
4000 Duration::from_millis(50)
4001 };
4002 replay_ticks(&tracker, "cleanup", dur, Duration::from_millis(100), 1);
4003 }
4004 assert!(
4005 !tracker.snapshot("cleanup").unwrap().1,
4006 "intermittent overruns must not flip"
4007 );
4008 }
4009
4010 #[test]
4011 fn branch_tracker_recovers_only_after_k_ontime_ticks_post_cooldown() {
4012 let tracker = MaintenanceBranchTracker::new();
4018 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
4019 replay_ticks(
4020 &tracker,
4021 "cleanup",
4022 Duration::from_millis(250),
4023 Duration::from_millis(100),
4024 OVERRUN_HYSTERESIS_K,
4025 );
4026 replay_ticks(
4027 &tracker,
4028 "cleanup",
4029 Duration::from_millis(50),
4030 Duration::from_millis(100),
4031 BRANCH_COOLDOWN_TICKS,
4032 );
4033
4034 replay_ticks(
4037 &tracker,
4038 "cleanup",
4039 Duration::from_millis(50),
4040 Duration::from_millis(100),
4041 OVERRUN_HYSTERESIS_K,
4042 );
4043 assert!(
4044 tracker.snapshot("cleanup").unwrap().1,
4045 "still delayed after only K-1 evaluations"
4046 );
4047
4048 replay_ticks(
4050 &tracker,
4051 "cleanup",
4052 Duration::from_millis(50),
4053 Duration::from_millis(100),
4054 1,
4055 );
4056 assert!(
4057 !tracker.snapshot("cleanup").unwrap().1,
4058 "recovered after K evaluable on-time samples"
4059 );
4060 }
4061
4062 #[test]
4063 fn branch_tracker_per_branch_state_is_independent() {
4064 let tracker = MaintenanceBranchTracker::new();
4068 seed_last_duration(&tracker, "cleanup", Duration::from_millis(500));
4069 replay_ticks(
4070 &tracker,
4071 "cleanup",
4072 Duration::from_millis(500),
4073 Duration::from_millis(100),
4074 OVERRUN_HYSTERESIS_K,
4075 );
4076 seed_last_duration(&tracker, "promote_scheduled", Duration::from_millis(10));
4077 replay_ticks(
4078 &tracker,
4079 "promote_scheduled",
4080 Duration::from_millis(10),
4081 Duration::from_millis(250),
4082 OVERRUN_HYSTERESIS_K,
4083 );
4084 assert!(tracker.snapshot("cleanup").unwrap().1);
4085 assert!(!tracker.snapshot("promote_scheduled").unwrap().1);
4086 }
4087
4088 fn skip_active(slot: i32) -> PruneOutcome {
4091 PruneOutcome::SkippedActive {
4092 slot,
4093 reason: SkipReason::LeaseActive,
4094 count: 1,
4095 }
4096 }
4097
4098 #[test]
4099 fn prune_backoff_initial_state_does_not_skip() {
4100 let tracker = PruneBackoffTracker::new();
4101 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
4102 assert_eq!(
4103 tracker.snapshot(PRUNE_BRANCH_LEASE),
4104 Some((0, 0)),
4105 "polling once must not introduce backoff"
4106 );
4107 }
4108
4109 #[test]
4110 fn prune_backoff_skipped_active_doubles_then_resets_on_pruned() {
4111 let tracker = PruneBackoffTracker::new();
4112
4113 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4115 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
4116 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
4117 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
4118 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
4119
4120 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4122 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
4123
4124 tracker.record_outcome(
4126 PRUNE_BRANCH_LEASE,
4127 &PruneOutcome::Pruned {
4128 slot: 0,
4129 carried_failed_rows: 0,
4130 },
4131 );
4132 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((0, 0)));
4133 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
4134 }
4135
4136 #[test]
4137 fn prune_backoff_blocked_increases_level_same_as_skipped_active() {
4138 let tracker = PruneBackoffTracker::new();
4139 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
4140 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
4141 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
4142 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
4143 }
4144
4145 #[test]
4146 fn prune_backoff_noop_is_neutral() {
4147 let tracker = PruneBackoffTracker::new();
4148 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4150 let before = tracker.snapshot(PRUNE_BRANCH_LEASE);
4151 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Noop);
4152 let after = tracker.snapshot(PRUNE_BRANCH_LEASE);
4153 assert_eq!(
4154 before, after,
4155 "Noop must not change backoff state — there was nothing to do, not a failure"
4156 );
4157 }
4158
4159 #[test]
4160 fn prune_backoff_caps_at_max_level() {
4161 let tracker = PruneBackoffTracker::new();
4162 for _ in 0..(MAX_PRUNE_BACKOFF_LEVEL as u32 + 5) {
4164 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4165 }
4166 let (skip_remaining, backoff_level) =
4167 tracker.snapshot(PRUNE_BRANCH_LEASE).expect("snapshot");
4168 assert_eq!(backoff_level, MAX_PRUNE_BACKOFF_LEVEL);
4169 assert_eq!(skip_remaining, 1u32 << MAX_PRUNE_BACKOFF_LEVEL);
4170 }
4171
4172 #[test]
4173 fn prune_backoff_per_branch_state_is_independent() {
4174 let tracker = PruneBackoffTracker::new();
4175 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4176 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
4177 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
4179 assert_eq!(tracker.snapshot(PRUNE_BRANCH_CLAIM), None);
4180 assert!(!tracker.should_skip(PRUNE_BRANCH_CLAIM));
4181 }
4182
4183 #[test]
4184 fn compute_fire_times_keeps_first_registration_latest_only() {
4185 let created_at = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
4186 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap();
4187 let row = cron_row(
4188 "*/5 * * * * *",
4189 created_at,
4190 None,
4191 CronMissedFirePolicy::CatchUp,
4192 );
4193
4194 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
4195
4196 assert_eq!(
4197 fires,
4198 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap()]
4199 );
4200 }
4201}