1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::cron::{
5 atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow, CronMissedFirePolicy,
6};
7#[cfg(test)]
8use awa_model::SkipReason;
9use awa_model::{
10 JobRow, JobState, PeriodicJob, PruneOutcome, RotateOutcome, TerminalDeltaRollupOutcome,
11};
12use chrono::Utc;
13use croner::Cron;
14use sqlx::pool::PoolConnection;
15use sqlx::{PgPool, Postgres};
16use std::collections::{HashMap, HashSet};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::task::JoinHandle;
21use tokio_util::sync::CancellationToken;
22use tracing::{debug, error, info, warn};
23use uuid::Uuid;
24
25#[derive(Debug, Clone)]
27pub struct RetentionPolicy {
28 pub completed: Duration,
30 pub failed: Duration,
32 pub dlq: Option<Duration>,
34}
35
36impl Default for RetentionPolicy {
37 fn default() -> Self {
38 Self {
39 completed: Duration::from_secs(86400), failed: Duration::from_secs(259200), dlq: None,
42 }
43 }
44}
45
46#[derive(Debug, Default)]
55struct MaintenanceBranchState {
56 last_duration: Option<Duration>,
59 is_delayed: bool,
63 consecutive_overrun: u32,
67 consecutive_ontime: u32,
72 cooldown_ticks_remaining: u32,
79}
80
81const OVERRUN_HYSTERESIS_K: u32 = 3;
90
91const OVERRUN_UPPER_NUM: u32 = 3;
105const OVERRUN_UPPER_DEN: u32 = 2;
106const OVERRUN_LOWER_NUM: u32 = 7;
107const OVERRUN_LOWER_DEN: u32 = 10;
108
109const BRANCH_COOLDOWN_TICKS: u32 = 120;
114
115struct BranchTimer<'a> {
120 tracker: &'a MaintenanceBranchTracker,
121 branch: &'static str,
122 metrics: &'a crate::metrics::AwaMetrics,
123 started_at: Instant,
124}
125
126impl<'a> BranchTimer<'a> {
127 fn finish(self) {
133 let duration = self.started_at.elapsed();
134 self.metrics
135 .record_maintenance_branch_duration(self.branch, duration);
136 self.tracker.record_finish(self.branch, duration);
137 }
138}
139
140#[derive(Default)]
147struct MaintenanceBranchTracker {
148 branches: std::sync::Mutex<HashMap<&'static str, MaintenanceBranchState>>,
149}
150
151impl MaintenanceBranchTracker {
152 fn new() -> Self {
153 Self {
154 branches: std::sync::Mutex::new(HashMap::new()),
155 }
156 }
157
158 fn try_begin<'a>(
181 &'a self,
182 branch: &'static str,
183 tick_interval: Duration,
184 metrics: &'a crate::metrics::AwaMetrics,
185 ) -> Option<BranchTimer<'a>> {
186 self.try_begin_with_cooldown(branch, tick_interval, metrics, BRANCH_COOLDOWN_TICKS)
187 }
188
189 fn try_begin_without_cooldown<'a>(
190 &'a self,
191 branch: &'static str,
192 tick_interval: Duration,
193 metrics: &'a crate::metrics::AwaMetrics,
194 ) -> Option<BranchTimer<'a>> {
195 self.try_begin_with_cooldown(branch, tick_interval, metrics, 0)
196 }
197
198 fn try_begin_with_cooldown<'a>(
199 &'a self,
200 branch: &'static str,
201 tick_interval: Duration,
202 metrics: &'a crate::metrics::AwaMetrics,
203 cooldown_ticks: u32,
204 ) -> Option<BranchTimer<'a>> {
205 let mut branches = self
206 .branches
207 .lock()
208 .expect("maintenance branch tracker mutex");
209 let state = branches.entry(branch).or_default();
210
211 if state.cooldown_ticks_remaining > 0 {
214 state.cooldown_ticks_remaining -= 1;
215 return None;
216 }
217
218 if let Some(last_duration) = state.last_duration.take() {
228 let upper_threshold = tick_interval * OVERRUN_UPPER_NUM / OVERRUN_UPPER_DEN;
230 let lower_threshold = tick_interval * OVERRUN_LOWER_NUM / OVERRUN_LOWER_DEN;
231
232 if last_duration > upper_threshold {
233 state.consecutive_overrun = state.consecutive_overrun.saturating_add(1);
234 state.consecutive_ontime = 0;
235 let cross_threshold = state.consecutive_overrun >= OVERRUN_HYSTERESIS_K;
236 if cross_threshold && !state.is_delayed {
237 state.is_delayed = true;
238 state.cooldown_ticks_remaining = cooldown_ticks;
239 warn!(
240 branch,
241 last_duration_ms = last_duration.as_millis() as u64,
242 tick_interval_ms = tick_interval.as_millis() as u64,
243 upper_threshold_ms = upper_threshold.as_millis() as u64,
244 consecutive_overrun = state.consecutive_overrun,
245 cooldown_ticks,
246 "maintenance branch overran tick interval",
247 );
248 metrics.record_maintenance_branch_overrun(branch);
249 if cooldown_ticks > 0 {
250 return None;
251 }
252 } else if cross_threshold && state.is_delayed && cooldown_ticks > 0 {
253 state.cooldown_ticks_remaining = cooldown_ticks;
256 return None;
257 }
258 } else if last_duration < lower_threshold {
259 state.consecutive_ontime = state.consecutive_ontime.saturating_add(1);
260 state.consecutive_overrun = 0;
261 if state.consecutive_ontime >= OVERRUN_HYSTERESIS_K && state.is_delayed {
262 state.is_delayed = false;
263 warn!(
264 branch,
265 last_duration_ms = last_duration.as_millis() as u64,
266 tick_interval_ms = tick_interval.as_millis() as u64,
267 lower_threshold_ms = lower_threshold.as_millis() as u64,
268 consecutive_ontime = state.consecutive_ontime,
269 "maintenance branch recovered to on-time",
270 );
271 }
272 } else {
273 }
276 }
277 drop(branches);
278 Some(BranchTimer {
279 tracker: self,
280 branch,
281 metrics,
282 started_at: Instant::now(),
283 })
284 }
285
286 fn record_finish(&self, branch: &'static str, duration: Duration) {
290 let mut branches = self
291 .branches
292 .lock()
293 .expect("maintenance branch tracker mutex");
294 let state = branches.entry(branch).or_default();
295 state.last_duration = Some(duration);
296 }
297
298 #[cfg(test)]
301 fn snapshot(&self, branch: &'static str) -> Option<(Option<Duration>, bool)> {
302 let branches = self
303 .branches
304 .lock()
305 .expect("maintenance branch tracker mutex");
306 branches
307 .get(branch)
308 .map(|state| (state.last_duration, state.is_delayed))
309 }
310
311 #[cfg(test)]
314 fn cooldown_snapshot(&self, branch: &'static str) -> Option<(u32, u32, u32)> {
315 let branches = self
316 .branches
317 .lock()
318 .expect("maintenance branch tracker mutex");
319 branches.get(branch).map(|state| {
320 (
321 state.cooldown_ticks_remaining,
322 state.consecutive_overrun,
323 state.consecutive_ontime,
324 )
325 })
326 }
327}
328
329#[derive(Default)]
342struct PruneBackoffTracker {
343 branches: std::sync::Mutex<HashMap<&'static str, PruneBackoffState>>,
344}
345
346#[derive(Debug, Default)]
347struct PruneBackoffState {
348 skip_remaining: u32,
352 backoff_level: u8,
355}
356
357const MAX_PRUNE_BACKOFF_LEVEL: u8 = 5;
363
364impl PruneBackoffTracker {
365 fn new() -> Self {
366 Self::default()
367 }
368
369 fn should_skip(&self, branch: &'static str) -> bool {
372 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
373 let state = branches.entry(branch).or_default();
374 if state.skip_remaining > 0 {
375 state.skip_remaining -= 1;
376 true
377 } else {
378 false
379 }
380 }
381
382 fn record_outcome(&self, branch: &'static str, outcome: &PruneOutcome) {
385 let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
386 let state = branches.entry(branch).or_default();
387 match outcome {
388 PruneOutcome::Pruned { .. } => {
389 state.skip_remaining = 0;
390 state.backoff_level = 0;
391 }
392 PruneOutcome::SkippedActive { .. } | PruneOutcome::Blocked { .. } => {
393 state.backoff_level = state
394 .backoff_level
395 .saturating_add(1)
396 .min(MAX_PRUNE_BACKOFF_LEVEL);
397 state.skip_remaining = 1u32 << state.backoff_level;
398 }
399 PruneOutcome::Noop => {}
400 }
401 }
402
403 #[cfg(test)]
404 fn snapshot(&self, branch: &'static str) -> Option<(u32, u8)> {
405 let branches = self.branches.lock().expect("prune backoff tracker mutex");
406 branches
407 .get(branch)
408 .map(|state| (state.skip_remaining, state.backoff_level))
409 }
410}
411
412const PRUNE_BRANCH_LEASE: &str = "lease";
415const PRUNE_BRANCH_CLAIM: &str = "claim";
416const PRUNE_BRANCH_QUEUE: &str = "queue";
417
418pub struct MaintenanceService {
423 pool: PgPool,
424 metrics: crate::metrics::AwaMetrics,
425 cancel: CancellationToken,
426 leader: Arc<AtomicBool>,
427 alive: Arc<AtomicBool>,
428 periodic_jobs: Arc<Vec<PeriodicJob>>,
429 enqueue_specs: Arc<
433 HashMap<
434 crate::enqueue_specs::Outcome,
435 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
436 >,
437 >,
438 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
441 in_flight: InFlightMap,
444 storage: RuntimeStorage,
445 heartbeat_rescue_interval: Duration,
446 deadline_rescue_interval: Duration,
447 callback_rescue_interval: Duration,
448 promote_interval: Duration,
449 cleanup_interval: Duration,
450 cron_sync_interval: Duration,
451 cron_eval_interval: Duration,
452 leader_check_interval: Duration,
453 leader_election_interval: Duration,
454 heartbeat_staleness: Duration,
455 completed_retention: Duration,
456 failed_retention: Duration,
457 cleanup_batch_size: i64,
458 queue_retention_overrides: HashMap<String, RetentionPolicy>,
459 queue_stats_interval: Duration,
460 dlq_retention: Duration,
461 dlq_cleanup_batch_size: i64,
462 dlq_policy: DlqPolicy,
463 dirty_key_recompute_interval: Duration,
464 metadata_reconciliation_interval: Duration,
465 priority_aging_interval: Duration,
468 batch_operations_interval: Duration,
469 terminal_count_rollup_interval: Duration,
470 descriptor_retention: Duration,
474}
475
476const PROMOTE_BATCH_SIZE: i64 = 4_096;
477const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
478const CRON_CATCH_UP_LIMIT: usize = 1_000;
479const TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK: usize = 4;
480type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
481
482impl MaintenanceService {
483 #[allow(clippy::too_many_arguments)]
484 pub(crate) fn new(
485 pool: PgPool,
486 metrics: crate::metrics::AwaMetrics,
487 leader: Arc<AtomicBool>,
488 alive: Arc<AtomicBool>,
489 cancel: CancellationToken,
490 periodic_jobs: Arc<Vec<PeriodicJob>>,
491 in_flight: InFlightMap,
492 storage: RuntimeStorage,
493 enqueue_specs: Arc<
494 HashMap<
495 crate::enqueue_specs::Outcome,
496 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
497 >,
498 >,
499 lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
500 ) -> Self {
501 Self {
502 pool,
503 metrics,
504 cancel,
505 leader,
506 alive,
507 periodic_jobs,
508 in_flight,
509 storage,
510 enqueue_specs,
511 lifecycle_handlers,
512 heartbeat_rescue_interval: Duration::from_secs(30),
513 deadline_rescue_interval: Duration::from_secs(30),
514 callback_rescue_interval: Duration::from_secs(30),
515 promote_interval: Duration::from_millis(250),
516 cleanup_interval: Duration::from_secs(60),
517 cron_sync_interval: Duration::from_secs(60),
518 cron_eval_interval: Duration::from_secs(1),
519 leader_check_interval: Duration::from_secs(30),
520 leader_election_interval: Duration::from_secs(10),
521 heartbeat_staleness: Duration::from_secs(90),
522 completed_retention: Duration::from_secs(86400), failed_retention: Duration::from_secs(259200), cleanup_batch_size: 1000,
525 queue_retention_overrides: HashMap::new(),
526 queue_stats_interval: Duration::from_secs(30),
527 dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
528 dlq_cleanup_batch_size: 1000,
529 dlq_policy: DlqPolicy::default(),
530 dirty_key_recompute_interval: Duration::from_secs(2),
531 metadata_reconciliation_interval: Duration::from_secs(60),
532 priority_aging_interval: Duration::from_secs(60),
533 batch_operations_interval: Duration::from_secs(1),
534 terminal_count_rollup_interval: Duration::from_secs(30),
535 descriptor_retention: Duration::from_secs(30 * 86400), }
537 }
538
539 pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
544 self.priority_aging_interval = interval;
545 self
546 }
547
548 pub fn batch_operations_interval(mut self, interval: Duration) -> Self {
550 self.batch_operations_interval = interval;
551 self
552 }
553
554 pub fn terminal_count_rollup_interval(mut self, interval: Duration) -> Self {
557 self.terminal_count_rollup_interval = interval;
558 self
559 }
560
561 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
570 self.descriptor_retention = retention;
571 self
572 }
573
574 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
579 self.leader_election_interval = interval;
580 self
581 }
582
583 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
585 self.leader_check_interval = interval;
586 self
587 }
588
589 pub fn promote_interval(mut self, interval: Duration) -> Self {
591 self.promote_interval = interval;
592 self
593 }
594
595 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
597 self.heartbeat_rescue_interval = interval;
598 self
599 }
600
601 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
603 self.deadline_rescue_interval = interval;
604 self
605 }
606
607 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
609 self.callback_rescue_interval = interval;
610 self
611 }
612
613 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
619 self.heartbeat_staleness = staleness;
620 self
621 }
622
623 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
625 self.cleanup_interval = interval;
626 self
627 }
628
629 pub fn completed_retention(mut self, retention: Duration) -> Self {
631 self.completed_retention = retention;
632 self
633 }
634
635 pub fn failed_retention(mut self, retention: Duration) -> Self {
637 self.failed_retention = retention;
638 self
639 }
640
641 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
643 self.cleanup_batch_size = batch_size;
644 self
645 }
646
647 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
649 self.queue_stats_interval = interval;
650 self
651 }
652
653 pub fn dlq_retention(mut self, retention: Duration) -> Self {
655 self.dlq_retention = retention;
656 self
657 }
658
659 pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
661 self.dlq_cleanup_batch_size = batch_size;
662 self
663 }
664
665 pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
667 self.dlq_policy = policy;
668 self
669 }
670
671 pub fn queue_retention_overrides(
673 mut self,
674 overrides: HashMap<String, RetentionPolicy>,
675 ) -> Self {
676 self.queue_retention_overrides = overrides;
677 self
678 }
679
680 pub async fn run(&self) {
682 info!("Maintenance service starting");
683 self.alive.store(true, Ordering::SeqCst);
684 let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
685 self.leader.store(false, Ordering::SeqCst);
686
687 loop {
688 let mut leader_conn = match self.try_become_leader().await {
691 Ok(Some(conn)) => conn,
692 Ok(None) => {
693 tokio::select! {
695 _ = self.cancel.cancelled() => {
696 debug!("Maintenance service shutting down (not leader)");
697 self.leader.store(false, Ordering::SeqCst);
698 return;
699 }
700 _ = tokio::time::sleep(self.leader_election_interval) => continue,
701 }
702 }
703 Err(err) => {
704 warn!(error = %err, "Failed to check leader status");
705 tokio::select! {
706 _ = self.cancel.cancelled() => {
707 debug!("Maintenance service shutting down (leader check failed)");
708 self.leader.store(false, Ordering::SeqCst);
709 return;
710 }
711 _ = tokio::time::sleep(self.leader_election_interval) => continue,
712 }
713 }
714 };
715
716 debug!("Elected as maintenance leader");
717 self.leader.store(true, Ordering::SeqCst);
718
719 let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
721 let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
722 let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
723 let mut promote_timer = tokio::time::interval(self.promote_interval);
724 let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
725 let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
726 let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
727 let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
728 let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
729 let mut metadata_reconciliation_timer =
730 tokio::time::interval(self.metadata_reconciliation_interval);
731 let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
732 let mut batch_operations_timer = tokio::time::interval(self.batch_operations_interval);
733 let mut terminal_count_rollup_timer =
734 tokio::time::interval(self.terminal_count_rollup_interval);
735 let mut vacuum_queue_timer = self
736 .storage
737 .queue_storage()
738 .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
739 let mut vacuum_lease_timer = self
740 .storage
741 .queue_storage()
742 .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
743 let mut vacuum_claim_timer = self
744 .storage
745 .queue_storage()
746 .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
747 let vacuum_queue_interval = self
752 .storage
753 .queue_storage()
754 .map(|runtime| runtime.queue_rotate_interval);
755 let vacuum_lease_interval = self
756 .storage
757 .queue_storage()
758 .map(|runtime| runtime.lease_rotate_interval);
759 let vacuum_claim_interval = self
760 .storage
761 .queue_storage()
762 .map(|runtime| runtime.claim_rotate_interval);
763 let branch_tracker = MaintenanceBranchTracker::new();
767 let prune_tracker = PruneBackoffTracker::new();
773
774 heartbeat_rescue_timer.tick().await;
776 deadline_rescue_timer.tick().await;
777 callback_rescue_timer.tick().await;
778 promote_timer.tick().await;
779 cleanup_timer.tick().await;
780 cron_sync_timer.tick().await;
781 leader_check_timer.tick().await;
782 queue_stats_timer.tick().await;
783 dirty_key_timer.tick().await;
784 metadata_reconciliation_timer.tick().await;
785 priority_aging_timer.tick().await;
786 batch_operations_timer.tick().await;
787 terminal_count_rollup_timer.tick().await;
788 if let Some(timer) = &mut vacuum_queue_timer {
789 timer.tick().await;
790 }
791 if let Some(timer) = &mut vacuum_lease_timer {
792 timer.tick().await;
793 }
794 if let Some(timer) = &mut vacuum_claim_timer {
795 timer.tick().await;
796 }
797
798 self.sync_periodic_jobs_to_db().await;
800 let cron_eval_cancel = self.cancel.child_token();
801 let cron_eval_task = tokio::spawn(Self::run_cron_evaluator(
802 self.pool.clone(),
803 cron_eval_cancel.clone(),
804 self.cron_eval_interval,
805 ));
806
807 loop {
808 tokio::select! {
809 _ = self.cancel.cancelled() => {
810 debug!("Maintenance service shutting down");
811 self.leader.store(false, Ordering::SeqCst);
812 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
813 let _ = Self::release_leader(&mut leader_conn).await;
816 return;
817 }
818 _ = heartbeat_rescue_timer.tick() => {
819 if let Some(timer) = branch_tracker.try_begin("rescue_stale_heartbeats", self.heartbeat_rescue_interval, &self.metrics) {
820 self.rescue_stale_heartbeats().await;
821 timer.finish();
822 }
823 }
824 _ = deadline_rescue_timer.tick() => {
825 if let Some(timer) = branch_tracker.try_begin("rescue_expired_deadlines", self.deadline_rescue_interval, &self.metrics) {
826 self.rescue_expired_deadlines().await;
827 timer.finish();
828 }
829 }
830 _ = callback_rescue_timer.tick() => {
831 if let Some(timer) = branch_tracker.try_begin("rescue_expired_callbacks", self.callback_rescue_interval, &self.metrics) {
832 self.rescue_expired_callbacks().await;
833 timer.finish();
834 }
835 }
836 _ = promote_timer.tick() => {
837 if let Some(timer) = branch_tracker.try_begin("promote_scheduled", self.promote_interval, &self.metrics) {
838 self.promote_scheduled().await;
839 timer.finish();
840 }
841 }
842 _ = cleanup_timer.tick() => {
843 if let Some(timer) = branch_tracker.try_begin("cleanup", self.cleanup_interval, &self.metrics) {
844 self.cleanup_completed().await;
845 self.cleanup_dlq_rows().await;
846 self.cleanup_batch_operations().await;
847 self.cleanup_stale_runtime_snapshots().await;
848 self.cleanup_stale_descriptors().await;
849 timer.finish();
850 }
851 }
852 _ = cron_sync_timer.tick() => {
853 if let Some(timer) = branch_tracker.try_begin("cron_sync", self.cron_sync_interval, &self.metrics) {
854 self.sync_periodic_jobs_to_db().await;
855 timer.finish();
856 }
857 }
858 _ = queue_stats_timer.tick() => {
859 if let Some(timer) = branch_tracker.try_begin("queue_stats", self.queue_stats_interval, &self.metrics) {
860 self.publish_queue_health_metrics().await;
861 timer.finish();
862 }
863 }
864 _ = dirty_key_timer.tick() => {
865 if let Some(timer) = branch_tracker.try_begin("recompute_dirty_admin_metadata", self.dirty_key_recompute_interval, &self.metrics) {
866 self.recompute_dirty_admin_metadata().await;
867 timer.finish();
868 }
869 }
870 _ = metadata_reconciliation_timer.tick() => {
871 if let Some(timer) = branch_tracker.try_begin("refresh_admin_metadata", self.metadata_reconciliation_interval, &self.metrics) {
872 self.refresh_admin_metadata().await;
873 timer.finish();
874 }
875 }
876 _ = priority_aging_timer.tick() => {
877 if let Some(timer) = branch_tracker.try_begin("priority_aging", self.priority_aging_interval, &self.metrics) {
878 self.age_waiting_priorities().await;
879 timer.finish();
880 }
881 }
882 _ = batch_operations_timer.tick() => {
883 if let Some(timer) = branch_tracker.try_begin("batch_operations", self.batch_operations_interval, &self.metrics) {
884 self.process_batch_operation().await;
885 timer.finish();
886 }
887 }
888 _ = terminal_count_rollup_timer.tick() => {
889 if let Some(timer) = branch_tracker.try_begin_without_cooldown("terminal_count_rollup", self.terminal_count_rollup_interval, &self.metrics) {
890 self.rollup_terminal_count_deltas().await;
891 timer.finish();
892 }
893 }
894 _ = async {
895 if let Some(timer) = &mut vacuum_queue_timer {
896 timer.tick().await;
897 } else {
898 std::future::pending::<()>().await;
899 }
900 }, if vacuum_queue_timer.is_some() => {
901 let interval = vacuum_queue_interval
902 .expect("vacuum_queue_interval Some iff vacuum_queue_timer Some");
903 if let Some(timer) = branch_tracker.try_begin("rotate_queue", interval, &self.metrics) {
904 self.rotate_queue_storage_queue(&prune_tracker).await;
905 timer.finish();
906 }
907 }
908 _ = async {
909 if let Some(timer) = &mut vacuum_lease_timer {
910 timer.tick().await;
911 } else {
912 std::future::pending::<()>().await;
913 }
914 }, if vacuum_lease_timer.is_some() => {
915 let interval = vacuum_lease_interval
916 .expect("vacuum_lease_interval Some iff vacuum_lease_timer Some");
917 if let Some(timer) = branch_tracker.try_begin("rotate_lease", interval, &self.metrics) {
918 self.rotate_queue_storage_leases(&prune_tracker).await;
919 timer.finish();
920 }
921 }
922 _ = async {
923 if let Some(timer) = &mut vacuum_claim_timer {
924 timer.tick().await;
925 } else {
926 std::future::pending::<()>().await;
927 }
928 }, if vacuum_claim_timer.is_some() => {
929 let interval = vacuum_claim_interval
930 .expect("vacuum_claim_interval Some iff vacuum_claim_timer Some");
931 if let Some(timer) = branch_tracker.try_begin("rotate_claim", interval, &self.metrics) {
932 self.rotate_queue_storage_claims(&prune_tracker).await;
933 timer.finish();
934 }
935 }
936 _ = leader_check_timer.tick() => {
937 if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
941 warn!("Leader connection lost, re-entering election loop");
942 self.leader.store(false, Ordering::SeqCst);
943 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
944 break;
945 }
946 }
947 }
948 }
949 }
950 }
951
952 const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
961 let mut conn = self.pool.acquire().await?;
962 let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
963 .bind(Self::LOCK_KEY)
964 .fetch_one(&mut *conn)
965 .await?;
966 if result.0 {
967 Ok(Some(conn))
968 } else {
969 Ok(None)
970 }
971 }
972
973 async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
978 sqlx::query("SELECT pg_advisory_unlock($1)")
979 .bind(Self::LOCK_KEY)
980 .execute(&mut **conn)
981 .await?;
982 Ok(())
983 }
984
985 async fn run_cron_evaluator(pool: PgPool, cancel: CancellationToken, interval: Duration) {
986 let mut timer = tokio::time::interval(interval);
987 timer.tick().await;
988
989 loop {
990 tokio::select! {
991 _ = cancel.cancelled() => return,
992 _ = timer.tick() => {
993 Self::evaluate_cron_schedules(&pool).await;
994 }
995 }
996 }
997 }
998
999 fn stop_cron_evaluator(cancel: &CancellationToken, task: &JoinHandle<()>) {
1000 cancel.cancel();
1001 task.abort();
1002 }
1003
1004 #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
1008 async fn sync_periodic_jobs_to_db(&self) {
1009 if self.periodic_jobs.is_empty() {
1010 return;
1011 }
1012
1013 for job in self.periodic_jobs.iter() {
1014 if let Err(err) = upsert_cron_job(&self.pool, job).await {
1015 error!(name = %job.name, error = %err, "Failed to sync periodic job");
1016 }
1017 }
1018
1019 debug!(
1020 count = self.periodic_jobs.len(),
1021 "Synced periodic jobs to database"
1022 );
1023 }
1024
1025 async fn process_batch_operation(&self) {
1026 let runner_instance = Uuid::new_v4();
1027 match awa_model::batch_operations::run_one_default_chunk(&self.pool, runner_instance).await
1028 {
1029 Ok(outcome) if outcome.claimed => {
1030 debug!(
1031 processed = outcome.processed,
1032 skipped = outcome.skipped,
1033 errored = outcome.errored,
1034 finalized = outcome.finalized,
1035 "processed batch operation chunk"
1036 );
1037 }
1038 Ok(_) => {}
1039 Err(err) => warn!(error = %err, "failed to process batch operation chunk"),
1040 }
1041 }
1042
1043 async fn cleanup_batch_operations(&self) {
1044 match awa_model::batch_operations::cleanup_expired_batch_operations(&self.pool, 1000).await
1045 {
1046 Ok(deleted) if deleted > 0 => {
1047 debug!(deleted, "cleaned up expired batch operations");
1048 }
1049 Ok(_) => {}
1050 Err(err) => warn!(error = %err, "failed to clean up expired batch operations"),
1051 }
1052 }
1053
1054 async fn rollup_terminal_count_deltas(&self) {
1055 let Some(runtime) = self.storage.queue_storage() else {
1056 return;
1057 };
1058
1059 match runtime
1060 .store
1061 .rollup_terminal_count_deltas(&self.pool, TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK)
1062 .await
1063 {
1064 Ok(TerminalDeltaRollupOutcome {
1065 rolled_slots: 0,
1066 delta_rows: 0,
1067 grouped_keys: 0,
1068 skipped_active_slots: 0,
1069 blocked_slots: 0,
1070 skipped_mvcc_pinned: false,
1071 }) => {}
1072 Ok(outcome) => {
1073 debug!(
1074 rolled_slots = outcome.rolled_slots,
1075 delta_rows = outcome.delta_rows,
1076 grouped_keys = outcome.grouped_keys,
1077 skipped_active_slots = outcome.skipped_active_slots,
1078 blocked_slots = outcome.blocked_slots,
1079 skipped_mvcc_pinned = outcome.skipped_mvcc_pinned,
1080 "rolled up queue-storage terminal count deltas"
1081 );
1082 }
1083 Err(err) => warn!(error = %err, "failed to roll up terminal count deltas"),
1084 }
1085 }
1086
1087 #[tracing::instrument(skip(pool), name = "maintenance.cron_eval")]
1094 async fn evaluate_cron_schedules(pool: &PgPool) {
1095 let cron_rows = match list_cron_jobs(pool).await {
1096 Ok(rows) => rows,
1097 Err(err) => {
1098 error!(error = %err, "Failed to load cron jobs for evaluation");
1099 return;
1100 }
1101 };
1102
1103 if cron_rows.is_empty() {
1104 return;
1105 }
1106
1107 let now = Utc::now();
1108
1109 for row in &cron_rows {
1110 if row.is_paused() {
1111 debug!(cron_name = %row.name, "Skipping paused cron schedule");
1112 continue;
1113 }
1114 let fire_times = compute_fire_times(row, now, CRON_CATCH_UP_LIMIT);
1115 if fire_times.is_empty() {
1116 continue;
1117 }
1118 if fire_times.len() == CRON_CATCH_UP_LIMIT {
1119 warn!(
1120 cron_name = %row.name,
1121 catch_up_limit = CRON_CATCH_UP_LIMIT,
1122 "Cron catch-up limit reached; remaining due fires will be retried on the next evaluation"
1123 );
1124 }
1125
1126 let mut previous_enqueued_at = row.last_enqueued_at;
1127 for fire_time in fire_times {
1128 match atomic_enqueue(pool, &row.name, fire_time, previous_enqueued_at).await {
1129 Ok(Some(job)) => {
1130 previous_enqueued_at = Some(fire_time);
1131 info!(
1132 cron_name = %row.name,
1133 job_id = job.id,
1134 fire_time = %fire_time,
1135 "Enqueued periodic job"
1136 );
1137 }
1138 Ok(None) => {
1139 debug!(cron_name = %row.name, "Cron fire already claimed");
1141 break;
1142 }
1143 Err(err) => {
1144 error!(
1145 cron_name = %row.name,
1146 error = %err,
1147 "Failed to enqueue periodic job"
1148 );
1149 break;
1150 }
1151 }
1152 }
1153 }
1154 }
1155
1156 #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
1158 async fn rescue_stale_heartbeats(&self) {
1159 let outcome = match &self.storage {
1160 RuntimeStorage::Canonical => {
1161 let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
1162 sqlx::query_as::<_, JobRow>(
1163 r#"
1164 UPDATE awa.jobs
1165 SET state = 'retryable',
1166 finalized_at = now(),
1167 heartbeat_at = NULL,
1168 deadline_at = NULL,
1169 callback_id = NULL,
1170 callback_timeout_at = NULL,
1171 callback_filter = NULL,
1172 callback_on_complete = NULL,
1173 callback_on_fail = NULL,
1174 callback_transform = NULL,
1175 errors = errors || jsonb_build_object(
1176 'error', 'heartbeat stale: worker presumed dead',
1177 'attempt', attempt,
1178 'at', now()
1179 )::jsonb
1180 WHERE id IN (
1181 SELECT id FROM awa.jobs_hot
1182 WHERE state = 'running'
1183 AND heartbeat_at < now() - ($1 * interval '1 millisecond')
1184 LIMIT 500
1185 FOR UPDATE SKIP LOCKED
1186 )
1187 RETURNING *
1188 "#,
1189 )
1190 .bind(staleness_ms)
1191 .fetch_all(&self.pool)
1192 .await
1193 .map_err(awa_model::AwaError::Database)
1194 }
1195 RuntimeStorage::QueueStorage(runtime) => {
1196 runtime
1197 .store
1198 .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
1199 .await
1200 }
1201 };
1202 match outcome {
1203 Ok(rescued) if !rescued.is_empty() => {
1204 self.metrics.maintenance_rescues.add(
1205 rescued.len() as u64,
1206 &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
1207 );
1208 warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
1209 self.signal_cancellation(&rescued).await;
1211 for job in &rescued {
1212 self.emit_rescued(job, crate::events::RescueReason::StaleHeartbeat)
1213 .await;
1214 }
1215 }
1216 Err(err) => {
1217 error!(error = %err, "Failed to rescue stale heartbeat jobs");
1218 }
1219 _ => {}
1220 }
1221 }
1222
1223 #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
1225 async fn rescue_expired_deadlines(&self) {
1226 let outcome = match &self.storage {
1227 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1228 r#"
1229 UPDATE awa.jobs
1230 SET state = 'retryable',
1231 finalized_at = now(),
1232 heartbeat_at = NULL,
1233 deadline_at = NULL,
1234 callback_id = NULL,
1235 callback_timeout_at = NULL,
1236 callback_filter = NULL,
1237 callback_on_complete = NULL,
1238 callback_on_fail = NULL,
1239 callback_transform = NULL,
1240 errors = errors || jsonb_build_object(
1241 'error', 'hard deadline exceeded',
1242 'attempt', attempt,
1243 'at', now()
1244 )::jsonb
1245 WHERE id IN (
1246 SELECT id FROM awa.jobs_hot
1247 WHERE state = 'running'
1248 AND deadline_at IS NOT NULL
1249 AND deadline_at < now()
1250 LIMIT 500
1251 FOR UPDATE SKIP LOCKED
1252 )
1253 RETURNING *
1254 "#,
1255 )
1256 .fetch_all(&self.pool)
1257 .await
1258 .map_err(awa_model::AwaError::Database),
1259 RuntimeStorage::QueueStorage(runtime) => {
1260 runtime.store.rescue_expired_deadlines(&self.pool).await
1261 }
1262 };
1263 match outcome {
1264 Ok(rescued) if !rescued.is_empty() => {
1265 self.metrics.maintenance_rescues.add(
1266 rescued.len() as u64,
1267 &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
1268 );
1269 warn!(count = rescued.len(), "Rescued deadline-expired jobs");
1270 self.signal_cancellation(&rescued).await;
1272 for job in &rescued {
1273 self.emit_rescued(job, crate::events::RescueReason::DeadlineExceeded)
1274 .await;
1275 }
1276 }
1277 Err(err) => {
1278 error!(error = %err, "Failed to rescue deadline-expired jobs");
1279 }
1280 _ => {}
1281 }
1282 }
1283
1284 #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
1286 async fn rescue_expired_callbacks(&self) {
1287 let outcome = match &self.storage {
1288 RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1289 r#"
1290 UPDATE awa.jobs
1291 SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
1292 finalized_at = now(),
1293 callback_id = NULL,
1294 callback_timeout_at = NULL,
1295 callback_filter = NULL,
1296 callback_on_complete = NULL,
1297 callback_on_fail = NULL,
1298 callback_transform = NULL,
1299 run_at = CASE WHEN attempt >= max_attempts THEN run_at
1300 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
1301 errors = errors || jsonb_build_object(
1302 'error', 'callback timed out',
1303 'attempt', attempt,
1304 'at', now()
1305 )::jsonb
1306 WHERE id IN (
1307 SELECT id FROM awa.jobs_hot
1308 WHERE state = 'waiting_external'
1309 AND callback_timeout_at IS NOT NULL
1310 AND callback_timeout_at < now()
1311 LIMIT 500
1312 FOR UPDATE SKIP LOCKED
1313 )
1314 RETURNING *
1315 "#,
1316 )
1317 .fetch_all(&self.pool)
1318 .await
1319 .map_err(awa_model::AwaError::Database),
1320 RuntimeStorage::QueueStorage(runtime) => {
1321 runtime.store.rescue_expired_callbacks(&self.pool).await
1322 }
1323 };
1324 match outcome {
1325 Ok(rescued) if !rescued.is_empty() => {
1326 self.metrics.maintenance_rescues.add(
1327 rescued.len() as u64,
1328 &[opentelemetry::KeyValue::new(
1329 "awa.rescue.kind",
1330 "callback_timeout",
1331 )],
1332 );
1333 warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
1334 for job in &rescued {
1335 self.emit_rescued(job, crate::events::RescueReason::ExpiredCallback)
1336 .await;
1337 }
1338 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
1339 for job in &rescued {
1340 if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
1341 {
1342 continue;
1343 }
1344 match runtime
1345 .store
1346 .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
1347 .await
1348 {
1349 Ok(Some(_)) => {
1350 self.metrics.record_dlq_moved(
1351 &job.kind,
1352 &job.queue,
1353 "callback_timeout",
1354 );
1355 }
1356 Ok(None) => {}
1357 Err(err) => {
1358 error!(
1359 job_id = job.id,
1360 error = %err,
1361 "Failed to move rescued callback timeout into DLQ"
1362 );
1363 }
1364 }
1365 }
1366 }
1367 }
1368 Err(err) => {
1369 error!(error = %err, "Failed to rescue callback-timed-out jobs");
1370 }
1371 _ => {}
1372 }
1373 }
1374
1375 #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
1382 async fn age_waiting_priorities(&self) {
1383 let aging_secs = self.priority_aging_interval.as_secs_f64();
1384 if aging_secs <= 0.0 {
1385 return;
1386 }
1387 if let Some(runtime) = self.storage.queue_storage() {
1388 debug!(
1389 schema = %runtime.store.schema(),
1390 "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
1391 );
1392 return;
1393 }
1394
1395 match sqlx::query_scalar::<_, i64>(
1396 r#"
1397 WITH eligible AS (
1398 SELECT id FROM awa.jobs_hot
1399 WHERE state = 'available'
1400 AND priority > 1
1401 AND run_at <= now() - make_interval(secs => $1)
1402 LIMIT 1000
1403 FOR UPDATE SKIP LOCKED
1404 )
1405 UPDATE awa.jobs_hot
1406 SET priority = priority - 1,
1407 metadata = CASE
1408 WHEN NOT (metadata ? '_awa_original_priority')
1409 THEN metadata || jsonb_build_object('_awa_original_priority', priority)
1410 ELSE metadata
1411 END
1412 FROM eligible
1413 WHERE awa.jobs_hot.id = eligible.id
1414 RETURNING awa.jobs_hot.id
1415 "#,
1416 )
1417 .bind(aging_secs)
1418 .fetch_all(&self.pool)
1419 .await
1420 {
1421 Ok(ids) if !ids.is_empty() => {
1422 debug!(count = ids.len(), "Aged job priorities");
1423 }
1424 Err(err) => {
1425 error!(error = %err, "Failed to age job priorities");
1426 }
1427 _ => {}
1428 }
1429 }
1430
1431 async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
1433 for job in rescued_jobs {
1434 if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
1435 flag.store(true, Ordering::SeqCst);
1436 debug!(job_id = job.id, "Signalled cancellation for rescued job");
1437 }
1438 }
1439 }
1440
1441 async fn emit_rescued(&self, job: &JobRow, reason: crate::events::RescueReason) {
1448 self.dispatch_rescued_followups(job, reason).await;
1449 let handlers = self.lifecycle_handlers.clone();
1450 let kind = job.kind.clone();
1451 let event = crate::events::UntypedJobEvent::Rescued {
1452 job: job.clone(),
1453 reason,
1454 };
1455 tokio::spawn(async move {
1456 crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
1457 });
1458 }
1459
1460 async fn dispatch_rescued_followups(&self, job: &JobRow, reason: crate::events::RescueReason) {
1468 let Some(specs) = self
1469 .enqueue_specs
1470 .get(&crate::enqueue_specs::Outcome::Rescued)
1471 .and_then(|by_kind| by_kind.get(&job.kind))
1472 .cloned()
1473 else {
1474 return;
1475 };
1476 if specs.is_empty() {
1477 return;
1478 }
1479 let mut tx = match self.pool.begin().await {
1480 Ok(tx) => tx,
1481 Err(err) => {
1482 error!(
1483 job_id = job.id,
1484 kind = %job.kind,
1485 rescue_reason = reason.as_str(),
1486 error = %err,
1487 "Rescued follow-up dispatch: failed to begin transaction"
1488 );
1489 return;
1490 }
1491 };
1492 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Rescued { reason };
1493 let result =
1494 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, job, &specs, Some(&outcome_ctx))
1495 .await;
1496 match result {
1497 Ok(()) => {
1498 if let Err(err) = tx.commit().await {
1499 error!(
1500 job_id = job.id,
1501 kind = %job.kind,
1502 rescue_reason = reason.as_str(),
1503 error = %err,
1504 "Rescued follow-up dispatch: commit failed"
1505 );
1506 }
1507 }
1508 Err(err) => {
1509 error!(
1510 job_id = job.id,
1511 kind = %job.kind,
1512 rescue_reason = reason.as_str(),
1513 error = %err,
1514 "Rescued follow-up dispatch: spec INSERT failed; rolling back"
1515 );
1516 let _ = tx.rollback().await;
1517 }
1518 }
1519 }
1520
1521 #[tracing::instrument(skip(self), name = "maintenance.promote")]
1523 async fn promote_scheduled(&self) {
1524 if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
1525 error!(error = %err, "Failed to promote scheduled jobs");
1526 }
1527 if let Err(err) = self
1528 .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
1529 .await
1530 {
1531 error!(error = %err, "Failed to promote retryable jobs");
1532 }
1533 }
1534
1535 async fn promote_due_state(
1536 &self,
1537 state: &'static str,
1538 label: &'static str,
1539 ) -> Result<(), awa_model::AwaError> {
1540 let mut promoted_total = 0usize;
1541 let mut notified_queues = HashSet::new();
1542
1543 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
1544 if self.cancel.is_cancelled() {
1545 break;
1546 }
1547
1548 match &self.storage {
1549 RuntimeStorage::Canonical => {
1550 let (promoted, queues) = self
1551 .promote_due_batch(state)
1552 .await
1553 .map_err(awa_model::AwaError::Database)?;
1554 if promoted == 0 {
1555 break;
1556 }
1557
1558 promoted_total += promoted;
1559 notified_queues.extend(queues);
1560
1561 if promoted < PROMOTE_BATCH_SIZE as usize {
1562 break;
1563 }
1564 }
1565 RuntimeStorage::QueueStorage(runtime) => {
1566 let job_state = match state {
1567 "scheduled" => awa_model::JobState::Scheduled,
1568 "retryable" => awa_model::JobState::Retryable,
1569 other => {
1570 return Err(awa_model::AwaError::Validation(format!(
1571 "unsupported queue storage promote state: {other}"
1572 )));
1573 }
1574 };
1575 let promote_start = std::time::Instant::now();
1576 let promoted = runtime
1577 .store
1578 .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
1579 .await?;
1580 self.metrics.record_promotion_batch(
1581 state,
1582 promoted as u64,
1583 promote_start.elapsed(),
1584 );
1585 if promoted == 0 {
1586 break;
1587 }
1588
1589 promoted_total += promoted;
1590
1591 if promoted < PROMOTE_BATCH_SIZE as usize {
1592 break;
1593 }
1594 }
1595 }
1596 }
1597
1598 if promoted_total > 0 {
1599 debug!(
1600 count = promoted_total,
1601 queues = notified_queues.len(),
1602 state,
1603 "Promoted {label}"
1604 );
1605 }
1606
1607 Ok(())
1608 }
1609
1610 fn promote_sql(state: &'static str) -> String {
1616 format!(
1617 r#"
1618 WITH due AS (
1619 DELETE FROM awa.scheduled_jobs
1620 WHERE id IN (
1621 SELECT id
1622 FROM awa.scheduled_jobs
1623 WHERE state = '{state}'::awa.job_state
1624 AND run_at <= now()
1625 ORDER BY run_at ASC, id ASC
1626 LIMIT $1
1627 FOR UPDATE SKIP LOCKED
1628 )
1629 RETURNING *
1630 ),
1631 promoted AS (
1632 INSERT INTO awa.jobs_hot (
1633 id, kind, queue, args, state, priority, attempt, max_attempts,
1634 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
1635 created_at, errors, metadata, tags, unique_key, unique_states,
1636 callback_id, callback_timeout_at, callback_filter, callback_on_complete,
1637 callback_on_fail, callback_transform, run_lease, progress
1638 )
1639 SELECT
1640 id,
1641 kind,
1642 queue,
1643 args,
1644 'available'::awa.job_state,
1645 priority,
1646 attempt,
1647 max_attempts,
1648 now(),
1649 NULL,
1650 NULL,
1651 attempted_at,
1652 finalized_at,
1653 created_at,
1654 errors,
1655 metadata,
1656 tags,
1657 unique_key,
1658 unique_states,
1659 NULL,
1660 NULL,
1661 NULL,
1662 NULL,
1663 NULL,
1664 NULL,
1665 run_lease,
1666 progress
1667 FROM due
1668 RETURNING queue
1669 )
1670 SELECT queue FROM promoted
1671 "#
1672 )
1673 }
1674
1675 async fn promote_due_batch(
1676 &self,
1677 state: &'static str,
1678 ) -> Result<(usize, HashSet<String>), sqlx::Error> {
1679 let mut tx = self.pool.begin().await?;
1680 let promote_start = std::time::Instant::now();
1681 let sql = Self::promote_sql(state);
1682 let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
1683 .bind(PROMOTE_BATCH_SIZE)
1684 .fetch_all(&mut *tx)
1685 .await?;
1686
1687 let promoted = promoted_rows.len();
1688 self.metrics
1689 .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
1690 if promoted == 0 {
1691 tx.commit().await?;
1692 return Ok((0, HashSet::new()));
1693 }
1694
1695 let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
1696
1697 tx.commit().await?;
1698 Ok((promoted, queues))
1699 }
1700
1701 async fn rotate_queue_storage_queue(&self, prune_tracker: &PruneBackoffTracker) {
1702 let Some(runtime) = self.storage.queue_storage() else {
1703 return;
1704 };
1705
1706 match runtime.store.rotate(&self.pool).await {
1707 Ok(outcome) => {
1708 self.metrics.record_rotate_outcome("queue", &outcome);
1709 match outcome {
1710 RotateOutcome::Rotated { slot, generation } => {
1711 debug!(slot, generation, "Rotated queue storage queue segment");
1712 }
1713 RotateOutcome::SkippedBusy { slot, busy } => {
1714 debug!(
1715 slot,
1716 ready_rows = busy.queue_ready,
1717 claim_attempt_batches = busy.queue_claim_attempt_batches,
1718 done_rows = busy.queue_done,
1719 ready_segments = busy.queue_ready_segments,
1720 receipt_completion_batches = busy.queue_receipt_completion_batches,
1721 receipt_completion_tombstones =
1722 busy.queue_receipt_completion_tombstones,
1723 "Skipped busy queue storage queue segment",
1724 );
1725 }
1726 }
1727 }
1728 Err(err) => {
1729 error!(error = %err, "Failed to rotate queue storage queue segments");
1730 return;
1731 }
1732 }
1733
1734 if prune_tracker.should_skip(PRUNE_BRANCH_QUEUE) {
1735 debug!(branch = PRUNE_BRANCH_QUEUE, "Prune backed off this tick");
1736 return;
1737 }
1738
1739 match runtime
1740 .store
1741 .prune_oldest(&self.pool, self.failed_retention)
1742 .await
1743 {
1744 Ok(outcome) => {
1745 self.metrics.record_prune_outcome("queue", &outcome);
1746 prune_tracker.record_outcome(PRUNE_BRANCH_QUEUE, &outcome);
1747 match outcome {
1748 PruneOutcome::Noop => {}
1749 PruneOutcome::Pruned {
1750 slot,
1751 carried_failed_rows,
1752 } => {
1753 debug!(
1754 slot,
1755 carried_failed_rows, "Pruned queue storage queue segment"
1756 );
1757 }
1758 PruneOutcome::Blocked { slot } => {
1759 debug!(slot, "Queue storage queue segment prune blocked");
1760 }
1761 PruneOutcome::SkippedActive {
1762 slot,
1763 reason,
1764 count,
1765 } => {
1766 debug!(
1767 slot,
1768 reason = reason.as_str(),
1769 count,
1770 "Queue storage queue segment still active",
1771 );
1772 }
1773 }
1774 }
1775 Err(err) => {
1776 error!(error = %err, "Failed to prune queue storage queue segments");
1777 }
1778 }
1779 }
1780
1781 async fn rotate_queue_storage_leases(&self, prune_tracker: &PruneBackoffTracker) {
1782 let Some(runtime) = self.storage.queue_storage() else {
1783 return;
1784 };
1785
1786 match runtime.store.rotate_leases(&self.pool).await {
1787 Ok(outcome) => {
1788 self.metrics.record_rotate_outcome("lease", &outcome);
1789 match outcome {
1790 RotateOutcome::Rotated { slot, generation } => {
1791 debug!(slot, generation, "Rotated queue storage lease segment");
1792 }
1793 RotateOutcome::SkippedBusy { slot, busy } => {
1794 debug!(
1795 slot,
1796 lease_rows = busy.leases,
1797 "Skipped busy queue storage lease segment",
1798 );
1799 }
1800 }
1801 }
1802 Err(err) => {
1803 error!(error = %err, "Failed to rotate queue storage lease segments");
1804 return;
1805 }
1806 }
1807
1808 if prune_tracker.should_skip(PRUNE_BRANCH_LEASE) {
1809 debug!(branch = PRUNE_BRANCH_LEASE, "Prune backed off this tick");
1810 return;
1811 }
1812
1813 match runtime.store.prune_oldest_leases(&self.pool).await {
1814 Ok(outcome) => {
1815 self.metrics.record_prune_outcome("lease", &outcome);
1816 prune_tracker.record_outcome(PRUNE_BRANCH_LEASE, &outcome);
1817 match outcome {
1818 PruneOutcome::Noop => {}
1819 PruneOutcome::Pruned { slot, .. } => {
1820 debug!(slot, "Pruned queue storage lease segment");
1821 }
1822 PruneOutcome::Blocked { slot } => {
1823 debug!(slot, "Queue storage lease segment prune blocked");
1824 }
1825 PruneOutcome::SkippedActive {
1826 slot,
1827 reason,
1828 count,
1829 } => {
1830 debug!(
1831 slot,
1832 reason = reason.as_str(),
1833 count,
1834 "Queue storage lease segment still active",
1835 );
1836 }
1837 }
1838 }
1839 Err(err) => {
1840 error!(error = %err, "Failed to prune queue storage lease segments");
1841 }
1842 }
1843 }
1844
1845 async fn rotate_queue_storage_claims(&self, prune_tracker: &PruneBackoffTracker) {
1849 let Some(runtime) = self.storage.queue_storage() else {
1850 return;
1851 };
1852
1853 match runtime.store.rotate_claims(&self.pool).await {
1854 Ok(outcome) => {
1855 self.metrics.record_rotate_outcome("claim", &outcome);
1856 match outcome {
1857 RotateOutcome::Rotated { slot, generation } => {
1858 debug!(slot, generation, "Rotated queue storage claim segment");
1859 }
1860 RotateOutcome::SkippedBusy { slot, busy } => {
1861 debug!(
1862 slot,
1863 claim_rows = busy.claims,
1864 closure_rows = busy.closures,
1865 closure_batch_rows = busy.closure_batches,
1866 "Skipped busy queue storage claim segment",
1867 );
1868 }
1869 }
1870 }
1871 Err(err) => {
1872 error!(error = %err, "Failed to rotate queue storage claim segments");
1873 return;
1874 }
1875 }
1876
1877 if prune_tracker.should_skip(PRUNE_BRANCH_CLAIM) {
1878 debug!(branch = PRUNE_BRANCH_CLAIM, "Prune backed off this tick");
1879 return;
1880 }
1881
1882 match runtime.store.prune_oldest_claims(&self.pool).await {
1883 Ok(outcome) => {
1884 self.metrics.record_prune_outcome("claim", &outcome);
1885 prune_tracker.record_outcome(PRUNE_BRANCH_CLAIM, &outcome);
1886 match outcome {
1887 PruneOutcome::Noop => {}
1888 PruneOutcome::Pruned { slot, .. } => {
1889 debug!(slot, "Pruned queue storage claim segment");
1890 }
1891 PruneOutcome::Blocked { slot } => {
1892 debug!(slot, "Queue storage claim segment prune blocked");
1893 }
1894 PruneOutcome::SkippedActive {
1895 slot,
1896 reason,
1897 count,
1898 } => {
1899 debug!(
1900 slot,
1901 reason = reason.as_str(),
1902 count,
1903 "Queue storage claim segment still active",
1904 );
1905 }
1906 }
1907 }
1908 Err(err) => {
1909 error!(error = %err, "Failed to prune queue storage claim segments");
1910 }
1911 }
1912 }
1913
1914 #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
1921 async fn cleanup_completed(&self) {
1922 if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
1923 return;
1925 }
1926
1927 let mut total_deleted: u64 = 0;
1928
1929 let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
1931
1932 let completed_retention_secs =
1934 i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
1935 let failed_retention_secs =
1936 i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
1937
1938 let global_result = if override_queues.is_empty() {
1939 sqlx::query(
1940 r#"
1941 DELETE FROM awa.jobs_hot
1942 WHERE id IN (
1943 SELECT id FROM awa.jobs_hot
1944 WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1945 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
1946 LIMIT $3
1947 )
1948 "#,
1949 )
1950 .bind(completed_retention_secs)
1951 .bind(failed_retention_secs)
1952 .bind(self.cleanup_batch_size)
1953 .execute(&self.pool)
1954 .await
1955 } else {
1956 sqlx::query(
1957 r#"
1958 DELETE FROM awa.jobs_hot
1959 WHERE id IN (
1960 SELECT id FROM awa.jobs_hot
1961 WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1962 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
1963 AND queue != ALL($4::text[])
1964 LIMIT $3
1965 )
1966 "#,
1967 )
1968 .bind(completed_retention_secs)
1969 .bind(failed_retention_secs)
1970 .bind(self.cleanup_batch_size)
1971 .bind(&override_queues)
1972 .execute(&self.pool)
1973 .await
1974 };
1975
1976 match global_result {
1977 Ok(result) if result.rows_affected() > 0 => {
1978 total_deleted += result.rows_affected();
1979 }
1980 Err(err) => {
1981 error!(error = %err, "Failed to clean up old jobs (global pass)");
1982 }
1983 _ => {}
1984 }
1985
1986 for (queue_name, policy) in &self.queue_retention_overrides {
1988 let queue_completed_secs =
1989 i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
1990 let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
1991
1992 match sqlx::query(
1993 r#"
1994 DELETE FROM awa.jobs_hot
1995 WHERE id IN (
1996 SELECT id FROM awa.jobs_hot
1997 WHERE queue = $4
1998 AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1999 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2000 LIMIT $3
2001 )
2002 "#,
2003 )
2004 .bind(queue_completed_secs)
2005 .bind(queue_failed_secs)
2006 .bind(self.cleanup_batch_size)
2007 .bind(queue_name)
2008 .execute(&self.pool)
2009 .await
2010 {
2011 Ok(result) if result.rows_affected() > 0 => {
2012 total_deleted += result.rows_affected();
2013 debug!(
2014 queue = %queue_name,
2015 count = result.rows_affected(),
2016 "Cleaned up old jobs (queue override)"
2017 );
2018 }
2019 Err(err) => {
2020 error!(
2021 queue = %queue_name,
2022 error = %err,
2023 "Failed to clean up old jobs (queue override)"
2024 );
2025 }
2026 _ => {}
2027 }
2028 }
2029
2030 if total_deleted > 0 {
2031 info!(count = total_deleted, "Cleaned up old jobs");
2032 }
2033 }
2034
2035 #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
2036 async fn cleanup_dlq_rows(&self) {
2037 let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
2038 return;
2039 };
2040
2041 let schema = runtime.store.schema();
2042 let override_queues: Vec<&str> = self
2043 .queue_retention_overrides
2044 .iter()
2045 .filter(|(_, policy)| policy.dlq.is_some())
2046 .map(|(queue, _)| queue.as_str())
2047 .collect();
2048 let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
2049
2050 let global_result = if override_queues.is_empty() {
2051 sqlx::query(&format!(
2052 r#"
2053 DELETE FROM {schema}.dlq_entries
2054 WHERE job_id IN (
2055 SELECT job_id FROM {schema}.dlq_entries
2056 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2057 LIMIT $2
2058 )
2059 "#
2060 ))
2061 .bind(retention_secs)
2062 .bind(self.dlq_cleanup_batch_size)
2063 .execute(&self.pool)
2064 .await
2065 } else {
2066 sqlx::query(&format!(
2067 r#"
2068 DELETE FROM {schema}.dlq_entries
2069 WHERE job_id IN (
2070 SELECT job_id FROM {schema}.dlq_entries
2071 WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2072 AND queue != ALL($3::text[])
2073 LIMIT $2
2074 )
2075 "#
2076 ))
2077 .bind(retention_secs)
2078 .bind(self.dlq_cleanup_batch_size)
2079 .bind(&override_queues)
2080 .execute(&self.pool)
2081 .await
2082 };
2083
2084 match global_result {
2085 Ok(result) if result.rows_affected() > 0 => {
2086 self.metrics.record_dlq_purged(None, result.rows_affected());
2087 }
2088 Err(err) => {
2089 error!(error = %err, "Failed to clean up DLQ rows (global pass)");
2090 }
2091 _ => {}
2092 }
2093
2094 for (queue, policy) in &self.queue_retention_overrides {
2095 let Some(retention) = policy.dlq else {
2096 continue;
2097 };
2098 let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
2099 match sqlx::query(&format!(
2100 r#"
2101 DELETE FROM {schema}.dlq_entries
2102 WHERE job_id IN (
2103 SELECT job_id FROM {schema}.dlq_entries
2104 WHERE queue = $3
2105 AND dlq_at < now() - make_interval(secs => $1::bigint)
2106 LIMIT $2
2107 )
2108 "#
2109 ))
2110 .bind(retention_secs)
2111 .bind(self.dlq_cleanup_batch_size)
2112 .bind(queue)
2113 .execute(&self.pool)
2114 .await
2115 {
2116 Ok(result) if result.rows_affected() > 0 => {
2117 self.metrics
2118 .record_dlq_purged(Some(queue), result.rows_affected());
2119 }
2120 Err(err) => {
2121 error!(queue, error = %err, "Failed to clean up DLQ rows");
2122 }
2123 _ => {}
2124 }
2125 }
2126 }
2127}
2128
2129struct MaintenanceAliveGuard(Arc<AtomicBool>);
2130
2131impl Drop for MaintenanceAliveGuard {
2132 fn drop(&mut self) {
2133 self.0.store(false, Ordering::SeqCst);
2134 }
2135}
2136
2137fn compute_fire_times(
2143 row: &CronJobRow,
2144 now: chrono::DateTime<Utc>,
2145 limit: usize,
2146) -> Vec<chrono::DateTime<Utc>> {
2147 let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
2148 Ok(c) => c,
2149 Err(err) => {
2150 error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
2151 return Vec::new();
2152 }
2153 };
2154
2155 let tz: chrono_tz::Tz = match row.timezone.parse() {
2156 Ok(tz) => tz,
2157 Err(err) => {
2158 error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
2159 return Vec::new();
2160 }
2161 };
2162
2163 let search_start = match row.last_enqueued_at {
2164 Some(last) => last.with_timezone(&tz),
2165 None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
2170 };
2171
2172 let missed_fire_policy = match CronMissedFirePolicy::parse(&row.missed_fire_policy) {
2173 Ok(policy) => policy,
2174 Err(err) => {
2175 error!(cron_name = %row.name, error = %err, "Invalid cron missed-fire policy in database");
2176 return Vec::new();
2177 }
2178 };
2179 let should_catch_up =
2180 row.last_enqueued_at.is_some() && missed_fire_policy == CronMissedFirePolicy::CatchUp;
2181
2182 if !should_catch_up {
2183 return latest_due_fire(&cron, tz, search_start, row.last_enqueued_at, now)
2184 .into_iter()
2185 .collect();
2186 }
2187
2188 let mut fire_times = Vec::new();
2189 for fire_time in cron.iter_from(search_start) {
2190 let fire_utc = fire_time.with_timezone(&Utc);
2191
2192 if fire_utc > now {
2193 break;
2194 }
2195
2196 if let Some(last) = row.last_enqueued_at {
2197 if fire_utc <= last {
2198 continue;
2199 }
2200 }
2201
2202 fire_times.push(fire_utc);
2203 if fire_times.len() >= limit {
2204 break;
2205 }
2206 }
2207
2208 fire_times
2209}
2210
2211fn latest_due_fire(
2212 cron: &Cron,
2213 tz: chrono_tz::Tz,
2214 search_start: chrono::DateTime<chrono_tz::Tz>,
2215 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2216 now: chrono::DateTime<Utc>,
2217) -> Option<chrono::DateTime<Utc>> {
2218 let first_due = first_due_fire(cron, search_start, last_enqueued_at, now)?;
2219 let total_span_seconds = now.signed_duration_since(first_due).num_seconds().max(1);
2220 let mut lookback_seconds = 1_i64;
2221
2222 loop {
2223 let window_start_utc = (now - chrono::Duration::seconds(lookback_seconds)).max(first_due);
2227 let window_start = window_start_utc.with_timezone(&tz);
2228 let next_in_window = cron
2229 .iter_from(window_start)
2230 .next()
2231 .map(|fire_time| fire_time.with_timezone(&Utc));
2232
2233 if next_in_window.is_some_and(|fire_utc| {
2234 fire_utc <= now && last_enqueued_at.is_none_or(|last| fire_utc > last)
2235 }) {
2236 return latest_due_fire_in_window(cron, window_start, last_enqueued_at, now)
2237 .or(Some(first_due));
2238 }
2239
2240 if lookback_seconds >= total_span_seconds {
2241 return Some(first_due);
2242 }
2243
2244 lookback_seconds = lookback_seconds.saturating_mul(2).min(total_span_seconds);
2245 }
2246}
2247
2248fn first_due_fire(
2249 cron: &Cron,
2250 search_start: chrono::DateTime<chrono_tz::Tz>,
2251 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2252 now: chrono::DateTime<Utc>,
2253) -> Option<chrono::DateTime<Utc>> {
2254 for fire_time in cron.iter_from(search_start) {
2255 let fire_utc = fire_time.with_timezone(&Utc);
2256 if fire_utc > now {
2257 return None;
2258 }
2259 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2260 return Some(fire_utc);
2261 }
2262 }
2263
2264 None
2265}
2266
2267fn latest_due_fire_in_window(
2268 cron: &Cron,
2269 window_start: chrono::DateTime<chrono_tz::Tz>,
2270 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2271 now: chrono::DateTime<Utc>,
2272) -> Option<chrono::DateTime<Utc>> {
2273 let mut latest_fire = None;
2274
2275 for fire_time in cron.iter_from(window_start) {
2276 let fire_utc = fire_time.with_timezone(&Utc);
2277 if fire_utc > now {
2278 break;
2279 }
2280 if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2281 latest_fire = Some(fire_utc);
2282 }
2283 }
2284
2285 latest_fire
2286}
2287
2288impl MaintenanceService {
2289 #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
2292 async fn cleanup_stale_runtime_snapshots(&self) {
2293 if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
2294 &self.pool,
2295 chrono::TimeDelta::try_hours(24).unwrap(),
2296 )
2297 .await
2298 {
2299 tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
2300 }
2301 }
2302
2303 #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
2308 async fn cleanup_stale_descriptors(&self) {
2309 if self.descriptor_retention.is_zero() {
2310 return;
2311 }
2312 let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
2313 .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
2314 for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
2315 match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
2316 Ok(deleted) if deleted > 0 => {
2317 tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
2318 }
2319 Ok(_) => {}
2320 Err(err) => {
2321 tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
2322 }
2323 }
2324 }
2325 }
2326
2327 #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
2331 async fn recompute_dirty_admin_metadata(&self) {
2332 if self.storage.queue_storage().is_some() {
2333 return;
2334 }
2335 match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
2336 Ok(count) if count > 0 => {
2337 tracing::debug!(count, "Recomputed dirty admin metadata keys");
2338 }
2339 Err(err) => {
2340 tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
2341 }
2342 _ => {}
2343 }
2344 }
2345
2346 #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
2349 async fn refresh_admin_metadata(&self) {
2350 if self.storage.queue_storage().is_some() {
2351 return;
2352 }
2353 if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
2354 tracing::warn!(error = %err, "Failed to refresh admin metadata");
2355 }
2356 }
2357
2358 #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
2360 async fn publish_queue_health_metrics(&self) {
2361 if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
2362 self.publish_queue_storage_health_metrics(runtime).await;
2363 return;
2364 }
2365
2366 let stats = match awa_model::admin::queue_overviews(&self.pool).await {
2367 Ok(stats) => stats,
2368 Err(err) => {
2369 tracing::warn!(error = %err, "Failed to query queue stats for metrics");
2370 return;
2371 }
2372 };
2373
2374 for queue_stat in &stats {
2375 let queue = &queue_stat.queue;
2376
2377 self.metrics
2379 .record_queue_depth(queue, "available", queue_stat.available);
2380 self.metrics
2381 .record_queue_depth(queue, "running", queue_stat.running);
2382 self.metrics
2383 .record_queue_depth(queue, "failed", queue_stat.failed);
2384 self.metrics
2385 .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
2386 self.metrics
2387 .record_queue_depth(queue, "retryable", queue_stat.retryable);
2388 self.metrics
2389 .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
2390
2391 if let Some(lag_seconds) = queue_stat.lag_seconds {
2393 self.metrics.record_queue_lag(queue, lag_seconds);
2394 }
2395 }
2396 }
2397
2398 async fn publish_queue_storage_health_metrics(
2399 &self,
2400 runtime: &crate::storage::QueueStorageRuntime,
2401 ) {
2402 let schema = runtime.store.schema();
2403 let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
2409 r#"
2410 WITH head_signal AS (
2411 SELECT
2412 enqueues.queue,
2413 enqueues.priority,
2414 enqueues.enqueue_shard,
2415 {schema}.sequence_next_value(enqueues.seq_name) AS next_seq,
2416 {schema}.sequence_next_value(claims.seq_name) AS claim_seq
2417 FROM {schema}.queue_enqueue_heads AS enqueues
2418 JOIN {schema}.queue_claim_heads AS claims
2419 ON claims.queue = enqueues.queue
2420 AND claims.priority = enqueues.priority
2421 AND claims.enqueue_shard = enqueues.enqueue_shard
2422 ),
2423 queues AS (
2424 SELECT DISTINCT queue
2425 FROM (
2426 SELECT queue FROM awa.queue_meta
2427 UNION ALL
2428 SELECT queue FROM head_signal
2429 UNION ALL
2430 SELECT queue FROM {schema}.leases
2431 UNION ALL
2432 SELECT queue FROM {schema}.deferred_jobs
2433 UNION ALL
2434 SELECT queue FROM {schema}.queue_terminal_live_counts
2435 UNION ALL
2436 SELECT queue FROM {schema}.queue_terminal_rollups
2437 UNION ALL
2438 SELECT queue FROM {schema}.dlq_entries
2439 ) queues
2440 ),
2441 ready AS (
2442 SELECT
2443 head_signal.queue,
2444 COALESCE(
2445 sum(GREATEST(head_signal.next_seq - head_signal.claim_seq, 0)),
2446 0
2447 )::bigint AS available
2448 FROM head_signal
2449 GROUP BY head_signal.queue
2450 ),
2451 lag AS (
2452 SELECT
2453 head_signal.queue,
2454 EXTRACT(EPOCH FROM clock_timestamp() - min(next_ready.run_at))::double precision
2455 AS lag_seconds
2456 FROM head_signal
2457 JOIN LATERAL (
2458 SELECT ready.run_at
2459 FROM {schema}.ready_entries AS ready
2460 WHERE ready.queue = head_signal.queue
2461 AND ready.priority = head_signal.priority
2462 AND ready.enqueue_shard = head_signal.enqueue_shard
2463 AND ready.lane_seq >= head_signal.claim_seq
2464 AND NOT EXISTS (
2465 SELECT 1
2466 FROM {schema}.ready_tombstones AS tomb
2467 WHERE tomb.ready_slot = ready.ready_slot
2468 AND tomb.ready_generation = ready.ready_generation
2469 AND tomb.queue = ready.queue
2470 AND tomb.priority = ready.priority
2471 AND tomb.enqueue_shard = ready.enqueue_shard
2472 AND tomb.lane_seq = ready.lane_seq
2473 )
2474 ORDER BY ready.lane_seq
2475 LIMIT 1
2476 ) AS next_ready ON TRUE
2477 GROUP BY head_signal.queue
2478 ),
2479 leases AS (
2480 SELECT
2481 queue,
2482 count(*) FILTER (WHERE state = 'running')::bigint AS running,
2483 count(*) FILTER (WHERE state = 'waiting_external')::bigint
2484 AS waiting_external
2485 FROM {schema}.leases
2486 GROUP BY queue
2487 ),
2488 deferred AS (
2489 SELECT
2490 queue,
2491 count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
2492 count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
2493 FROM {schema}.deferred_jobs
2494 GROUP BY queue
2495 ),
2496 terminal AS (
2497 SELECT
2498 queue,
2499 count(*)::bigint AS failed_done
2500 FROM {schema}.done_entries
2501 WHERE state = 'failed'
2502 GROUP BY queue
2503 ),
2504 dlq AS (
2505 SELECT
2506 queue,
2507 count(*)::bigint AS failed_dlq
2508 FROM {schema}.dlq_entries
2509 GROUP BY queue
2510 )
2511 SELECT
2512 queues.queue,
2513 COALESCE(ready.available, 0)::bigint AS available,
2514 COALESCE(leases.running, 0)::bigint AS running,
2515 COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
2516 COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
2517 COALESCE(deferred.retryable, 0)::bigint AS retryable,
2518 COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
2519 COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
2520 lag.lag_seconds
2521 FROM queues
2522 LEFT JOIN ready
2523 ON ready.queue = queues.queue
2524 LEFT JOIN lag
2525 ON lag.queue = queues.queue
2526 LEFT JOIN leases
2527 ON leases.queue = queues.queue
2528 LEFT JOIN deferred
2529 ON deferred.queue = queues.queue
2530 LEFT JOIN terminal
2531 ON terminal.queue = queues.queue
2532 LEFT JOIN dlq
2533 ON dlq.queue = queues.queue
2534 ORDER BY queues.queue
2535 "#
2536 ))
2537 .fetch_all(&self.pool)
2538 .await
2539 {
2540 Ok(rows) => rows,
2541 Err(err) => {
2542 tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
2543 return;
2544 }
2545 };
2546
2547 for (
2548 queue,
2549 available,
2550 running,
2551 waiting_external,
2552 scheduled,
2553 retryable,
2554 failed_done,
2555 failed_dlq,
2556 lag_seconds,
2557 ) in rows
2558 {
2559 self.metrics
2560 .record_queue_depth(&queue, "available", available);
2561 self.metrics.record_queue_depth(&queue, "running", running);
2562 self.metrics
2563 .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
2564 self.metrics
2565 .record_queue_depth(&queue, "scheduled", scheduled);
2566 self.metrics
2567 .record_queue_depth(&queue, "retryable", retryable);
2568 self.metrics
2569 .record_queue_depth(&queue, "waiting_external", waiting_external);
2570 self.metrics.record_dlq_depth(&queue, failed_dlq);
2571
2572 if let Some(lag_seconds) = lag_seconds {
2573 self.metrics.record_queue_lag(&queue, lag_seconds);
2574 }
2575 }
2576 }
2577}
2578
2579#[cfg(test)]
2580mod tests {
2581 use super::*;
2582 use awa_model::{migrations, QueueStorage, QueueStorageConfig};
2583 use chrono::TimeZone;
2584 use sqlx::postgres::PgPoolOptions;
2585 use std::sync::OnceLock;
2586
2587 fn cron_row(
2588 cron_expr: &str,
2589 created_at: chrono::DateTime<Utc>,
2590 last_enqueued_at: Option<chrono::DateTime<Utc>>,
2591 missed_fire_policy: CronMissedFirePolicy,
2592 ) -> CronJobRow {
2593 CronJobRow {
2594 name: "test_cron".to_string(),
2595 cron_expr: cron_expr.to_string(),
2596 timezone: "UTC".to_string(),
2597 kind: "test_job".to_string(),
2598 queue: "default".to_string(),
2599 args: serde_json::json!({}),
2600 priority: 2,
2601 max_attempts: 25,
2602 tags: Vec::new(),
2603 metadata: serde_json::json!({}),
2604 missed_fire_policy: missed_fire_policy.as_str().to_string(),
2605 last_enqueued_at,
2606 created_at,
2607 updated_at: created_at,
2608 paused_at: None,
2609 paused_by: None,
2610 }
2611 }
2612
2613 #[test]
2614 fn compute_fire_times_coalesces_missed_existing_fires_by_default() {
2615 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
2616 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
2617 let row = cron_row(
2618 "*/5 * * * * *",
2619 last,
2620 Some(last),
2621 CronMissedFirePolicy::Coalesce,
2622 );
2623
2624 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
2625
2626 assert_eq!(
2627 fires,
2628 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
2629 );
2630 }
2631
2632 #[test]
2633 fn compute_fire_times_coalesces_to_latest_fire_after_long_outage() {
2634 let last = Utc.with_ymd_and_hms(2026, 5, 6, 12, 0, 0).unwrap();
2635 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
2636 let row = cron_row(
2637 "*/1 * * * * *",
2638 last,
2639 Some(last),
2640 CronMissedFirePolicy::Coalesce,
2641 );
2642
2643 let fires = compute_fire_times(&row, now, 2);
2644
2645 assert_eq!(
2646 fires,
2647 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
2648 );
2649 }
2650
2651 #[test]
2652 fn compute_fire_times_catches_up_when_policy_requests_it() {
2653 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
2654 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
2655 let row = cron_row(
2656 "*/5 * * * * *",
2657 last,
2658 Some(last),
2659 CronMissedFirePolicy::CatchUp,
2660 );
2661
2662 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
2663
2664 assert_eq!(
2665 fires,
2666 vec![
2667 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
2668 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
2669 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 15).unwrap(),
2670 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap(),
2671 ]
2672 );
2673 }
2674
2675 #[test]
2676 fn compute_fire_times_limits_catch_up_work() {
2677 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
2678 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
2679 let row = cron_row(
2680 "*/5 * * * * *",
2681 last,
2682 Some(last),
2683 CronMissedFirePolicy::CatchUp,
2684 );
2685
2686 let fires = compute_fire_times(&row, now, 2);
2687
2688 assert_eq!(
2689 fires,
2690 vec![
2691 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
2692 Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
2693 ]
2694 );
2695 }
2696
2697 fn metrics_for_test() -> crate::metrics::AwaMetrics {
2700 crate::metrics::AwaMetrics::from_global()
2701 }
2702
2703 fn database_url() -> String {
2704 std::env::var("DATABASE_URL")
2705 .unwrap_or_else(|_| "postgres://postgres:test@localhost:15432/awa_test".to_string())
2706 }
2707
2708 fn db_test_mutex() -> &'static tokio::sync::Mutex<()> {
2709 static MUTEX: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
2710 MUTEX.get_or_init(|| tokio::sync::Mutex::new(()))
2711 }
2712
2713 async fn ensure_database_exists(url: &str) {
2714 let parts = url
2715 .rsplit_once('/')
2716 .expect("DATABASE_URL must include a database name");
2717 let database_name = parts.1.to_string();
2718 let admin_url = format!("{}/postgres", parts.0);
2719 let admin_pool = PgPoolOptions::new()
2720 .max_connections(1)
2721 .connect(&admin_url)
2722 .await
2723 .expect("Failed to connect to admin database for maintenance tests");
2724 let create_sql = format!("CREATE DATABASE {database_name}");
2725 match sqlx::query(&create_sql).execute(&admin_pool).await {
2726 Ok(_) => {}
2727 Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("42P04") => {}
2728 Err(err) => panic!("Failed to create maintenance test database {database_name}: {err}"),
2729 }
2730 }
2731
2732 async fn setup_pool(max_connections: u32) -> PgPool {
2733 let url = database_url();
2734 ensure_database_exists(&url).await;
2735 PgPoolOptions::new()
2736 .max_connections(max_connections)
2737 .acquire_timeout(Duration::from_secs(5))
2738 .connect(&url)
2739 .await
2740 .expect("Failed to connect to maintenance test database")
2741 }
2742
2743 async fn reset_schema(pool: &PgPool) {
2744 sqlx::raw_sql("DROP SCHEMA IF EXISTS awa CASCADE")
2745 .execute(pool)
2746 .await
2747 .expect("Failed to drop awa schema");
2748 }
2749
2750 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2751 async fn queue_storage_metrics_query_uses_bounded_observability_path() {
2752 let _guard = db_test_mutex().lock().await;
2753 let pool = setup_pool(4).await;
2754 reset_schema(&pool).await;
2755 migrations::run(&pool)
2756 .await
2757 .expect("migrations should succeed");
2758
2759 let store = QueueStorage::new(QueueStorageConfig::default()).expect("queue storage");
2760 store.install(&pool).await.expect("queue storage install");
2761
2762 let runtime = crate::storage::QueueStorageRuntime::new(
2763 QueueStorageConfig::default(),
2764 Duration::from_millis(1000),
2765 Duration::from_millis(250),
2766 )
2767 .expect("queue storage runtime");
2768 let service = MaintenanceService::new(
2769 pool,
2770 metrics_for_test(),
2771 Arc::new(AtomicBool::new(true)),
2772 Arc::new(AtomicBool::new(true)),
2773 CancellationToken::new(),
2774 Arc::new(Vec::new()),
2775 InFlightMap::default(),
2776 RuntimeStorage::QueueStorage(runtime.clone()),
2777 Arc::new(HashMap::new()),
2778 Arc::new(HashMap::new()),
2779 );
2780
2781 let mut receipt_lock_tx = service
2782 .pool
2783 .begin()
2784 .await
2785 .expect("begin receipt lock transaction");
2786 sqlx::query(
2787 "LOCK TABLE awa.lease_claims, awa.lease_claim_closures, awa.lease_claim_closure_batches IN ACCESS EXCLUSIVE MODE",
2788 )
2789 .execute(receipt_lock_tx.as_mut())
2790 .await
2791 .expect("lock receipt tables");
2792
2793 tokio::time::timeout(
2794 Duration::from_secs(3),
2795 service.publish_queue_storage_health_metrics(&runtime),
2796 )
2797 .await
2798 .expect("queue-storage metrics must not wait on receipt tables");
2799
2800 receipt_lock_tx
2801 .rollback()
2802 .await
2803 .expect("release receipt table locks");
2804 }
2805
2806 #[test]
2807 fn branch_tracker_initial_state_has_no_history() {
2808 let tracker = MaintenanceBranchTracker::new();
2809 assert_eq!(tracker.snapshot("promote_scheduled"), None);
2810 }
2811
2812 #[test]
2813 fn branch_tracker_finish_records_last_duration() {
2814 let tracker = MaintenanceBranchTracker::new();
2815 let metrics = metrics_for_test();
2816 let timer = tracker
2817 .try_begin("promote_scheduled", Duration::from_secs(1), &metrics)
2818 .expect("first tick should not be skipped");
2819 timer.finish();
2821 let (last_duration, is_delayed) = tracker
2822 .snapshot("promote_scheduled")
2823 .expect("snapshot should exist after one finish");
2824 assert!(last_duration.is_some());
2825 assert!(
2826 !is_delayed,
2827 "first tick has no prior duration → not delayed"
2828 );
2829 }
2830
2831 fn replay_ticks(
2843 tracker: &MaintenanceBranchTracker,
2844 branch: &'static str,
2845 body_duration: Duration,
2846 tick_interval: Duration,
2847 n: u32,
2848 ) -> Vec<bool> {
2849 let metrics = metrics_for_test();
2850 let mut ran = Vec::with_capacity(n as usize);
2851 for _ in 0..n {
2852 let timer_opt = tracker.try_begin(branch, tick_interval, &metrics);
2853 let did_run = timer_opt.is_some();
2854 ran.push(did_run);
2855 if did_run {
2856 tracker.record_finish(branch, body_duration);
2857 }
2858 }
2859 ran
2860 }
2861
2862 fn seed_last_duration(tracker: &MaintenanceBranchTracker, branch: &'static str, dur: Duration) {
2866 tracker
2867 .branches
2868 .lock()
2869 .unwrap()
2870 .entry(branch)
2871 .or_default()
2872 .last_duration = Some(dur);
2873 }
2874
2875 #[test]
2876 fn branch_tracker_single_overrun_does_not_flip() {
2877 let tracker = MaintenanceBranchTracker::new();
2880 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
2881 replay_ticks(
2882 &tracker,
2883 "cleanup",
2884 Duration::from_millis(200), Duration::from_millis(100),
2886 1,
2887 );
2888 assert!(
2889 !tracker.snapshot("cleanup").unwrap().1,
2890 "single overrun must not flip is_delayed (K=3 required)"
2891 );
2892 }
2893
2894 #[test]
2895 fn branch_tracker_deadband_sample_does_not_advance_counters() {
2896 let tracker = MaintenanceBranchTracker::new();
2901 seed_last_duration(&tracker, "cleanup", Duration::from_millis(101));
2902 replay_ticks(
2903 &tracker,
2904 "cleanup",
2905 Duration::from_millis(101),
2906 Duration::from_millis(100),
2907 5,
2908 );
2909 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
2910 assert_eq!(cooldown, 0, "deadband samples should not arm cooldown");
2911 assert_eq!(overrun, 0, "deadband sample 101ms must not advance overrun");
2912 assert_eq!(ontime, 0, "deadband sample 101ms must not advance ontime");
2913 }
2914
2915 #[test]
2916 fn branch_tracker_k_consecutive_overruns_flips_and_arms_cooldown() {
2917 let tracker = MaintenanceBranchTracker::new();
2923 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
2924 let ran = replay_ticks(
2925 &tracker,
2926 "cleanup",
2927 Duration::from_millis(250),
2928 Duration::from_millis(100),
2929 OVERRUN_HYSTERESIS_K,
2930 );
2931 assert_eq!(ran, vec![true, true, false], "flip-tick skips body");
2932 let (cooldown, overrun, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
2933 assert!(tracker.snapshot("cleanup").unwrap().1, "flipped to delayed");
2934 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
2935 assert_eq!(
2936 cooldown, BRANCH_COOLDOWN_TICKS,
2937 "cooldown armed to BRANCH_COOLDOWN_TICKS at flip"
2938 );
2939 }
2940
2941 #[test]
2942 fn branch_tracker_without_cooldown_tracks_overrun_but_keeps_running() {
2943 let tracker = MaintenanceBranchTracker::new();
2944 let metrics = metrics_for_test();
2945 seed_last_duration(
2946 &tracker,
2947 "terminal_count_rollup",
2948 Duration::from_millis(250),
2949 );
2950
2951 let mut ran = Vec::new();
2952 for _ in 0..OVERRUN_HYSTERESIS_K {
2953 let timer = tracker.try_begin_without_cooldown(
2954 "terminal_count_rollup",
2955 Duration::from_millis(100),
2956 &metrics,
2957 );
2958 ran.push(timer.is_some());
2959 if timer.is_some() {
2960 tracker.record_finish("terminal_count_rollup", Duration::from_millis(250));
2961 }
2962 }
2963
2964 assert_eq!(
2965 ran,
2966 vec![true, true, true],
2967 "no-cooldown branches should keep running on overrun"
2968 );
2969 let (cooldown, overrun, _) = tracker
2970 .cooldown_snapshot("terminal_count_rollup")
2971 .expect("snapshot");
2972 assert_eq!(cooldown, 0, "no-cooldown branch must not arm cooldown");
2973 assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
2974 assert!(
2975 tracker.snapshot("terminal_count_rollup").unwrap().1,
2976 "overrun state should still be observable"
2977 );
2978 }
2979
2980 #[test]
2981 fn branch_tracker_cooldown_skips_body() {
2982 let tracker = MaintenanceBranchTracker::new();
2988 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
2989 replay_ticks(
2990 &tracker,
2991 "cleanup",
2992 Duration::from_millis(250),
2993 Duration::from_millis(100),
2994 OVERRUN_HYSTERESIS_K,
2995 );
2996 let ran = replay_ticks(
3000 &tracker,
3001 "cleanup",
3002 Duration::from_millis(50),
3003 Duration::from_millis(100),
3004 BRANCH_COOLDOWN_TICKS,
3005 );
3006 assert!(
3007 ran.iter().all(|&r| !r),
3008 "every tick during cooldown must skip body"
3009 );
3010 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3011 assert_eq!(cooldown, 0, "cooldown decrements to zero");
3012 }
3013
3014 #[test]
3015 fn branch_tracker_cooldown_expires_then_body_runs() {
3016 let tracker = MaintenanceBranchTracker::new();
3023 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3024 replay_ticks(
3025 &tracker,
3026 "cleanup",
3027 Duration::from_millis(250),
3028 Duration::from_millis(100),
3029 OVERRUN_HYSTERESIS_K,
3030 );
3031 replay_ticks(
3032 &tracker,
3033 "cleanup",
3034 Duration::from_millis(50),
3035 Duration::from_millis(100),
3036 BRANCH_COOLDOWN_TICKS,
3037 );
3038 let ran = replay_ticks(
3039 &tracker,
3040 "cleanup",
3041 Duration::from_millis(50),
3042 Duration::from_millis(100),
3043 1,
3044 );
3045 assert_eq!(ran, vec![true], "post-cooldown body runs");
3046 let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3047 assert_eq!(
3048 cooldown, 0,
3049 "cooldown stays at zero after a single fast body"
3050 );
3051 assert_eq!(
3052 overrun, OVERRUN_HYSTERESIS_K,
3053 "consecutive_overrun preserved across cooldown (no eval on this tick)"
3054 );
3055 assert_eq!(
3056 ontime, 0,
3057 "ontime advances on the next tick — this one had no sample to evaluate"
3058 );
3059 }
3060
3061 #[test]
3062 fn branch_tracker_cooldown_rearms_on_continued_overrun() {
3063 let tracker = MaintenanceBranchTracker::new();
3069 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3070 replay_ticks(
3071 &tracker,
3072 "cleanup",
3073 Duration::from_millis(250),
3074 Duration::from_millis(100),
3075 OVERRUN_HYSTERESIS_K,
3076 );
3077 replay_ticks(
3078 &tracker,
3079 "cleanup",
3080 Duration::from_millis(50),
3081 Duration::from_millis(100),
3082 BRANCH_COOLDOWN_TICKS,
3083 );
3084
3085 let ran = replay_ticks(
3089 &tracker,
3090 "cleanup",
3091 Duration::from_millis(250),
3092 Duration::from_millis(100),
3093 2,
3094 );
3095 assert_eq!(
3096 ran,
3097 vec![true, false],
3098 "first tick runs body; second tick re-arms cooldown"
3099 );
3100 let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3101 assert_eq!(cooldown, BRANCH_COOLDOWN_TICKS, "cooldown re-armed");
3102 assert!(
3103 tracker.snapshot("cleanup").unwrap().1,
3104 "still delayed across re-arm"
3105 );
3106 }
3107
3108 #[test]
3109 fn branch_tracker_intermittent_overrun_does_not_flip() {
3110 let tracker = MaintenanceBranchTracker::new();
3114 seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3115 for over in [true, false, true, false, true] {
3116 let dur = if over {
3117 Duration::from_millis(200)
3118 } else {
3119 Duration::from_millis(50)
3120 };
3121 replay_ticks(&tracker, "cleanup", dur, Duration::from_millis(100), 1);
3122 }
3123 assert!(
3124 !tracker.snapshot("cleanup").unwrap().1,
3125 "intermittent overruns must not flip"
3126 );
3127 }
3128
3129 #[test]
3130 fn branch_tracker_recovers_only_after_k_ontime_ticks_post_cooldown() {
3131 let tracker = MaintenanceBranchTracker::new();
3137 seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3138 replay_ticks(
3139 &tracker,
3140 "cleanup",
3141 Duration::from_millis(250),
3142 Duration::from_millis(100),
3143 OVERRUN_HYSTERESIS_K,
3144 );
3145 replay_ticks(
3146 &tracker,
3147 "cleanup",
3148 Duration::from_millis(50),
3149 Duration::from_millis(100),
3150 BRANCH_COOLDOWN_TICKS,
3151 );
3152
3153 replay_ticks(
3156 &tracker,
3157 "cleanup",
3158 Duration::from_millis(50),
3159 Duration::from_millis(100),
3160 OVERRUN_HYSTERESIS_K,
3161 );
3162 assert!(
3163 tracker.snapshot("cleanup").unwrap().1,
3164 "still delayed after only K-1 evaluations"
3165 );
3166
3167 replay_ticks(
3169 &tracker,
3170 "cleanup",
3171 Duration::from_millis(50),
3172 Duration::from_millis(100),
3173 1,
3174 );
3175 assert!(
3176 !tracker.snapshot("cleanup").unwrap().1,
3177 "recovered after K evaluable on-time samples"
3178 );
3179 }
3180
3181 #[test]
3182 fn branch_tracker_per_branch_state_is_independent() {
3183 let tracker = MaintenanceBranchTracker::new();
3187 seed_last_duration(&tracker, "cleanup", Duration::from_millis(500));
3188 replay_ticks(
3189 &tracker,
3190 "cleanup",
3191 Duration::from_millis(500),
3192 Duration::from_millis(100),
3193 OVERRUN_HYSTERESIS_K,
3194 );
3195 seed_last_duration(&tracker, "promote_scheduled", Duration::from_millis(10));
3196 replay_ticks(
3197 &tracker,
3198 "promote_scheduled",
3199 Duration::from_millis(10),
3200 Duration::from_millis(250),
3201 OVERRUN_HYSTERESIS_K,
3202 );
3203 assert!(tracker.snapshot("cleanup").unwrap().1);
3204 assert!(!tracker.snapshot("promote_scheduled").unwrap().1);
3205 }
3206
3207 fn skip_active(slot: i32) -> PruneOutcome {
3210 PruneOutcome::SkippedActive {
3211 slot,
3212 reason: SkipReason::LeaseActive,
3213 count: 1,
3214 }
3215 }
3216
3217 #[test]
3218 fn prune_backoff_initial_state_does_not_skip() {
3219 let tracker = PruneBackoffTracker::new();
3220 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3221 assert_eq!(
3222 tracker.snapshot(PRUNE_BRANCH_LEASE),
3223 Some((0, 0)),
3224 "polling once must not introduce backoff"
3225 );
3226 }
3227
3228 #[test]
3229 fn prune_backoff_skipped_active_doubles_then_resets_on_pruned() {
3230 let tracker = PruneBackoffTracker::new();
3231
3232 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3234 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3235 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3236 assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3237 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3238
3239 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3241 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3242
3243 tracker.record_outcome(
3245 PRUNE_BRANCH_LEASE,
3246 &PruneOutcome::Pruned {
3247 slot: 0,
3248 carried_failed_rows: 0,
3249 },
3250 );
3251 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((0, 0)));
3252 assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3253 }
3254
3255 #[test]
3256 fn prune_backoff_blocked_increases_level_same_as_skipped_active() {
3257 let tracker = PruneBackoffTracker::new();
3258 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3259 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3260 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3261 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3262 }
3263
3264 #[test]
3265 fn prune_backoff_noop_is_neutral() {
3266 let tracker = PruneBackoffTracker::new();
3267 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3269 let before = tracker.snapshot(PRUNE_BRANCH_LEASE);
3270 tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Noop);
3271 let after = tracker.snapshot(PRUNE_BRANCH_LEASE);
3272 assert_eq!(
3273 before, after,
3274 "Noop must not change backoff state — there was nothing to do, not a failure"
3275 );
3276 }
3277
3278 #[test]
3279 fn prune_backoff_caps_at_max_level() {
3280 let tracker = PruneBackoffTracker::new();
3281 for _ in 0..(MAX_PRUNE_BACKOFF_LEVEL as u32 + 5) {
3283 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3284 }
3285 let (skip_remaining, backoff_level) =
3286 tracker.snapshot(PRUNE_BRANCH_LEASE).expect("snapshot");
3287 assert_eq!(backoff_level, MAX_PRUNE_BACKOFF_LEVEL);
3288 assert_eq!(skip_remaining, 1u32 << MAX_PRUNE_BACKOFF_LEVEL);
3289 }
3290
3291 #[test]
3292 fn prune_backoff_per_branch_state_is_independent() {
3293 let tracker = PruneBackoffTracker::new();
3294 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3295 tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3296 assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3298 assert_eq!(tracker.snapshot(PRUNE_BRANCH_CLAIM), None);
3299 assert!(!tracker.should_skip(PRUNE_BRANCH_CLAIM));
3300 }
3301
3302 #[test]
3303 fn compute_fire_times_keeps_first_registration_latest_only() {
3304 let created_at = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3305 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap();
3306 let row = cron_row(
3307 "*/5 * * * * *",
3308 created_at,
3309 None,
3310 CronMissedFirePolicy::CatchUp,
3311 );
3312
3313 let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3314
3315 assert_eq!(
3316 fires,
3317 vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap()]
3318 );
3319 }
3320}