1use crate::runtime::InFlightMap;
2use awa_model::cron::{atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow};
3use awa_model::{JobRow, PeriodicJob};
4use chrono::Utc;
5use croner::Cron;
6use sqlx::pool::PoolConnection;
7use sqlx::{PgPool, Postgres};
8use std::collections::{HashMap, HashSet};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::task::JoinHandle;
13use tokio_util::sync::CancellationToken;
14use tracing::{debug, error, info, warn};
15
16#[derive(Debug, Clone)]
18pub struct RetentionPolicy {
19 pub completed: Duration,
21 pub failed: Duration,
23}
24
25impl Default for RetentionPolicy {
26 fn default() -> Self {
27 Self {
28 completed: Duration::from_secs(86400), failed: Duration::from_secs(259200), }
31 }
32}
33
34pub struct MaintenanceService {
39 pool: PgPool,
40 metrics: crate::metrics::AwaMetrics,
41 cancel: CancellationToken,
42 leader: Arc<AtomicBool>,
43 alive: Arc<AtomicBool>,
44 periodic_jobs: Arc<Vec<PeriodicJob>>,
45 in_flight: InFlightMap,
48 heartbeat_rescue_interval: Duration,
49 deadline_rescue_interval: Duration,
50 callback_rescue_interval: Duration,
51 promote_interval: Duration,
52 cleanup_interval: Duration,
53 cron_sync_interval: Duration,
54 cron_eval_interval: Duration,
55 leader_check_interval: Duration,
56 leader_election_interval: Duration,
57 heartbeat_staleness: Duration,
58 completed_retention: Duration,
59 failed_retention: Duration,
60 cleanup_batch_size: i64,
61 queue_retention_overrides: HashMap<String, RetentionPolicy>,
62 queue_stats_interval: Duration,
63 dirty_key_recompute_interval: Duration,
64 metadata_reconciliation_interval: Duration,
65 priority_aging_interval: Duration,
68 descriptor_retention: Duration,
72}
73
74const PROMOTE_BATCH_SIZE: i64 = 4_096;
75const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
76
77impl MaintenanceService {
78 pub(crate) fn new(
79 pool: PgPool,
80 metrics: crate::metrics::AwaMetrics,
81 leader: Arc<AtomicBool>,
82 alive: Arc<AtomicBool>,
83 cancel: CancellationToken,
84 periodic_jobs: Arc<Vec<PeriodicJob>>,
85 in_flight: InFlightMap,
86 ) -> Self {
87 Self {
88 pool,
89 metrics,
90 cancel,
91 leader,
92 alive,
93 periodic_jobs,
94 in_flight,
95 heartbeat_rescue_interval: Duration::from_secs(30),
96 deadline_rescue_interval: Duration::from_secs(30),
97 callback_rescue_interval: Duration::from_secs(30),
98 promote_interval: Duration::from_millis(250),
99 cleanup_interval: Duration::from_secs(60),
100 cron_sync_interval: Duration::from_secs(60),
101 cron_eval_interval: Duration::from_secs(1),
102 leader_check_interval: Duration::from_secs(30),
103 leader_election_interval: Duration::from_secs(10),
104 heartbeat_staleness: Duration::from_secs(90),
105 completed_retention: Duration::from_secs(86400), failed_retention: Duration::from_secs(259200), cleanup_batch_size: 1000,
108 queue_retention_overrides: HashMap::new(),
109 queue_stats_interval: Duration::from_secs(30),
110 dirty_key_recompute_interval: Duration::from_secs(2),
111 metadata_reconciliation_interval: Duration::from_secs(60),
112 priority_aging_interval: Duration::from_secs(60),
113 descriptor_retention: Duration::from_secs(30 * 86400), }
115 }
116
117 pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
122 self.priority_aging_interval = interval;
123 self
124 }
125
126 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
135 self.descriptor_retention = retention;
136 self
137 }
138
139 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
144 self.leader_election_interval = interval;
145 self
146 }
147
148 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
150 self.leader_check_interval = interval;
151 self
152 }
153
154 pub fn promote_interval(mut self, interval: Duration) -> Self {
156 self.promote_interval = interval;
157 self
158 }
159
160 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
162 self.heartbeat_rescue_interval = interval;
163 self
164 }
165
166 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
168 self.deadline_rescue_interval = interval;
169 self
170 }
171
172 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
174 self.callback_rescue_interval = interval;
175 self
176 }
177
178 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
184 self.heartbeat_staleness = staleness;
185 self
186 }
187
188 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
190 self.cleanup_interval = interval;
191 self
192 }
193
194 pub fn completed_retention(mut self, retention: Duration) -> Self {
196 self.completed_retention = retention;
197 self
198 }
199
200 pub fn failed_retention(mut self, retention: Duration) -> Self {
202 self.failed_retention = retention;
203 self
204 }
205
206 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
208 self.cleanup_batch_size = batch_size;
209 self
210 }
211
212 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
214 self.queue_stats_interval = interval;
215 self
216 }
217
218 pub fn queue_retention_overrides(
220 mut self,
221 overrides: HashMap<String, RetentionPolicy>,
222 ) -> Self {
223 self.queue_retention_overrides = overrides;
224 self
225 }
226
227 pub async fn run(&self) {
229 info!("Maintenance service starting");
230 self.alive.store(true, Ordering::SeqCst);
231 let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
232 self.leader.store(false, Ordering::SeqCst);
233
234 loop {
235 let mut leader_conn = match self.try_become_leader().await {
238 Ok(Some(conn)) => conn,
239 Ok(None) => {
240 tokio::select! {
242 _ = self.cancel.cancelled() => {
243 debug!("Maintenance service shutting down (not leader)");
244 self.leader.store(false, Ordering::SeqCst);
245 return;
246 }
247 _ = tokio::time::sleep(self.leader_election_interval) => continue,
248 }
249 }
250 Err(err) => {
251 warn!(error = %err, "Failed to check leader status");
252 tokio::select! {
253 _ = self.cancel.cancelled() => {
254 debug!("Maintenance service shutting down (leader check failed)");
255 self.leader.store(false, Ordering::SeqCst);
256 return;
257 }
258 _ = tokio::time::sleep(self.leader_election_interval) => continue,
259 }
260 }
261 };
262
263 debug!("Elected as maintenance leader");
264 self.leader.store(true, Ordering::SeqCst);
265
266 let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
268 let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
269 let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
270 let mut promote_timer = tokio::time::interval(self.promote_interval);
271 let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
272 let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
273 let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
274 let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
275 let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
276 let mut metadata_reconciliation_timer =
277 tokio::time::interval(self.metadata_reconciliation_interval);
278 let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
279
280 heartbeat_rescue_timer.tick().await;
282 deadline_rescue_timer.tick().await;
283 callback_rescue_timer.tick().await;
284 promote_timer.tick().await;
285 cleanup_timer.tick().await;
286 cron_sync_timer.tick().await;
287 leader_check_timer.tick().await;
288 queue_stats_timer.tick().await;
289 dirty_key_timer.tick().await;
290 metadata_reconciliation_timer.tick().await;
291 priority_aging_timer.tick().await;
292
293 self.sync_periodic_jobs_to_db().await;
295 let cron_eval_cancel = self.cancel.child_token();
296 let cron_eval_task = tokio::spawn(Self::run_cron_evaluator(
297 self.pool.clone(),
298 cron_eval_cancel.clone(),
299 self.cron_eval_interval,
300 ));
301
302 loop {
303 tokio::select! {
304 _ = self.cancel.cancelled() => {
305 debug!("Maintenance service shutting down");
306 self.leader.store(false, Ordering::SeqCst);
307 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
308 let _ = Self::release_leader(&mut leader_conn).await;
311 return;
312 }
313 _ = heartbeat_rescue_timer.tick() => {
314 self.rescue_stale_heartbeats().await;
315 }
316 _ = deadline_rescue_timer.tick() => {
317 self.rescue_expired_deadlines().await;
318 }
319 _ = callback_rescue_timer.tick() => {
320 self.rescue_expired_callbacks().await;
321 }
322 _ = promote_timer.tick() => {
323 self.promote_scheduled().await;
324 }
325 _ = cleanup_timer.tick() => {
326 self.cleanup_completed().await;
327 self.cleanup_stale_runtime_snapshots().await;
328 self.cleanup_stale_descriptors().await;
329 }
330 _ = cron_sync_timer.tick() => {
331 self.sync_periodic_jobs_to_db().await;
332 }
333 _ = queue_stats_timer.tick() => {
334 self.publish_queue_health_metrics().await;
335 }
336 _ = dirty_key_timer.tick() => {
337 self.recompute_dirty_admin_metadata().await;
338 }
339 _ = metadata_reconciliation_timer.tick() => {
340 self.refresh_admin_metadata().await;
341 }
342 _ = priority_aging_timer.tick() => {
343 self.age_waiting_priorities().await;
344 }
345 _ = leader_check_timer.tick() => {
346 if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
350 warn!("Leader connection lost, re-entering election loop");
351 self.leader.store(false, Ordering::SeqCst);
352 Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
353 break;
354 }
355 }
356 }
357 }
358 }
359 }
360
361 const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
370 let mut conn = self.pool.acquire().await?;
371 let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
372 .bind(Self::LOCK_KEY)
373 .fetch_one(&mut *conn)
374 .await?;
375 if result.0 {
376 Ok(Some(conn))
377 } else {
378 Ok(None)
379 }
380 }
381
382 async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
387 sqlx::query("SELECT pg_advisory_unlock($1)")
388 .bind(Self::LOCK_KEY)
389 .execute(&mut **conn)
390 .await?;
391 Ok(())
392 }
393
394 async fn run_cron_evaluator(pool: PgPool, cancel: CancellationToken, interval: Duration) {
395 let mut timer = tokio::time::interval(interval);
396 timer.tick().await;
397
398 loop {
399 tokio::select! {
400 _ = cancel.cancelled() => return,
401 _ = timer.tick() => {
402 Self::evaluate_cron_schedules(&pool).await;
403 }
404 }
405 }
406 }
407
408 fn stop_cron_evaluator(cancel: &CancellationToken, task: &JoinHandle<()>) {
409 cancel.cancel();
410 task.abort();
411 }
412
413 #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
417 async fn sync_periodic_jobs_to_db(&self) {
418 if self.periodic_jobs.is_empty() {
419 return;
420 }
421
422 for job in self.periodic_jobs.iter() {
423 if let Err(err) = upsert_cron_job(&self.pool, job).await {
424 error!(name = %job.name, error = %err, "Failed to sync periodic job");
425 }
426 }
427
428 debug!(
429 count = self.periodic_jobs.len(),
430 "Synced periodic jobs to database"
431 );
432 }
433
434 #[tracing::instrument(skip(pool), name = "maintenance.cron_eval")]
440 async fn evaluate_cron_schedules(pool: &PgPool) {
441 let cron_rows = match list_cron_jobs(pool).await {
442 Ok(rows) => rows,
443 Err(err) => {
444 error!(error = %err, "Failed to load cron jobs for evaluation");
445 return;
446 }
447 };
448
449 if cron_rows.is_empty() {
450 return;
451 }
452
453 let now = Utc::now();
454
455 for row in &cron_rows {
456 let fire_time = match compute_fire_time(row, now) {
457 Some(time) => time,
458 None => continue,
459 };
460
461 match atomic_enqueue(pool, &row.name, fire_time, row.last_enqueued_at).await {
462 Ok(Some(job)) => {
463 info!(
464 cron_name = %row.name,
465 job_id = job.id,
466 fire_time = %fire_time,
467 "Enqueued periodic job"
468 );
469 }
470 Ok(None) => {
471 debug!(cron_name = %row.name, "Cron fire already claimed");
473 }
474 Err(err) => {
475 error!(
476 cron_name = %row.name,
477 error = %err,
478 "Failed to enqueue periodic job"
479 );
480 }
481 }
482 }
483 }
484
485 #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
487 async fn rescue_stale_heartbeats(&self) {
488 let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
489 match sqlx::query_as::<_, JobRow>(
490 r#"
491 UPDATE awa.jobs
492 SET state = 'retryable',
493 finalized_at = now(),
494 heartbeat_at = NULL,
495 deadline_at = NULL,
496 callback_id = NULL,
497 callback_timeout_at = NULL,
498 callback_filter = NULL,
499 callback_on_complete = NULL,
500 callback_on_fail = NULL,
501 callback_transform = NULL,
502 errors = errors || jsonb_build_object(
503 'error', 'heartbeat stale: worker presumed dead',
504 'attempt', attempt,
505 'at', now()
506 )::jsonb
507 WHERE id IN (
508 SELECT id FROM awa.jobs_hot
509 WHERE state = 'running'
510 AND heartbeat_at < now() - ($1 * interval '1 millisecond')
511 LIMIT 500
512 FOR UPDATE SKIP LOCKED
513 )
514 RETURNING *
515 "#,
516 )
517 .bind(staleness_ms)
518 .fetch_all(&self.pool)
519 .await
520 {
521 Ok(rescued) if !rescued.is_empty() => {
522 self.metrics.maintenance_rescues.add(
523 rescued.len() as u64,
524 &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
525 );
526 warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
527 self.signal_cancellation(&rescued).await;
529 }
530 Err(err) => {
531 error!(error = %err, "Failed to rescue stale heartbeat jobs");
532 }
533 _ => {}
534 }
535 }
536
537 #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
539 async fn rescue_expired_deadlines(&self) {
540 match sqlx::query_as::<_, JobRow>(
541 r#"
542 UPDATE awa.jobs
543 SET state = 'retryable',
544 finalized_at = now(),
545 heartbeat_at = NULL,
546 deadline_at = NULL,
547 callback_id = NULL,
548 callback_timeout_at = NULL,
549 callback_filter = NULL,
550 callback_on_complete = NULL,
551 callback_on_fail = NULL,
552 callback_transform = NULL,
553 errors = errors || jsonb_build_object(
554 'error', 'hard deadline exceeded',
555 'attempt', attempt,
556 'at', now()
557 )::jsonb
558 WHERE id IN (
559 SELECT id FROM awa.jobs_hot
560 WHERE state = 'running'
561 AND deadline_at IS NOT NULL
562 AND deadline_at < now()
563 LIMIT 500
564 FOR UPDATE SKIP LOCKED
565 )
566 RETURNING *
567 "#,
568 )
569 .fetch_all(&self.pool)
570 .await
571 {
572 Ok(rescued) if !rescued.is_empty() => {
573 self.metrics.maintenance_rescues.add(
574 rescued.len() as u64,
575 &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
576 );
577 warn!(count = rescued.len(), "Rescued deadline-expired jobs");
578 self.signal_cancellation(&rescued).await;
580 }
581 Err(err) => {
582 error!(error = %err, "Failed to rescue deadline-expired jobs");
583 }
584 _ => {}
585 }
586 }
587
588 #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
590 async fn rescue_expired_callbacks(&self) {
591 match sqlx::query_as::<_, JobRow>(
592 r#"
593 UPDATE awa.jobs
594 SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
595 finalized_at = now(),
596 callback_id = NULL,
597 callback_timeout_at = NULL,
598 callback_filter = NULL,
599 callback_on_complete = NULL,
600 callback_on_fail = NULL,
601 callback_transform = NULL,
602 run_at = CASE WHEN attempt >= max_attempts THEN run_at
603 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
604 errors = errors || jsonb_build_object(
605 'error', 'callback timed out',
606 'attempt', attempt,
607 'at', now()
608 )::jsonb
609 WHERE id IN (
610 SELECT id FROM awa.jobs_hot
611 WHERE state = 'waiting_external'
612 AND callback_timeout_at IS NOT NULL
613 AND callback_timeout_at < now()
614 LIMIT 500
615 FOR UPDATE SKIP LOCKED
616 )
617 RETURNING *
618 "#,
619 )
620 .fetch_all(&self.pool)
621 .await
622 {
623 Ok(rescued) if !rescued.is_empty() => {
624 self.metrics.maintenance_rescues.add(
625 rescued.len() as u64,
626 &[opentelemetry::KeyValue::new(
627 "awa.rescue.kind",
628 "callback_timeout",
629 )],
630 );
631 warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
632 }
633 Err(err) => {
634 error!(error = %err, "Failed to rescue callback-timed-out jobs");
635 }
636 _ => {}
637 }
638 }
639
640 #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
647 async fn age_waiting_priorities(&self) {
648 let aging_secs = self.priority_aging_interval.as_secs() as f64;
649 if aging_secs <= 0.0 {
650 return;
651 }
652 match sqlx::query_scalar::<_, i64>(
653 r#"
654 WITH eligible AS (
655 SELECT id FROM awa.jobs_hot
656 WHERE state = 'available'
657 AND priority > 1
658 AND run_at <= now() - make_interval(secs => $1)
659 LIMIT 1000
660 FOR UPDATE SKIP LOCKED
661 )
662 UPDATE awa.jobs_hot
663 SET priority = priority - 1,
664 metadata = CASE
665 WHEN NOT (metadata ? '_awa_original_priority')
666 THEN metadata || jsonb_build_object('_awa_original_priority', priority)
667 ELSE metadata
668 END
669 FROM eligible
670 WHERE awa.jobs_hot.id = eligible.id
671 RETURNING awa.jobs_hot.id
672 "#,
673 )
674 .bind(aging_secs)
675 .fetch_all(&self.pool)
676 .await
677 {
678 Ok(ids) if !ids.is_empty() => {
679 debug!(count = ids.len(), "Aged job priorities");
680 }
681 Err(err) => {
682 error!(error = %err, "Failed to age job priorities");
683 }
684 _ => {}
685 }
686 }
687
688 async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
690 for job in rescued_jobs {
691 if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
692 flag.store(true, Ordering::SeqCst);
693 debug!(job_id = job.id, "Signalled cancellation for rescued job");
694 }
695 }
696 }
697
698 #[tracing::instrument(skip(self), name = "maintenance.promote")]
700 async fn promote_scheduled(&self) {
701 if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
702 error!(error = %err, "Failed to promote scheduled jobs");
703 }
704 if let Err(err) = self
705 .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
706 .await
707 {
708 error!(error = %err, "Failed to promote retryable jobs");
709 }
710 }
711
712 async fn promote_due_state(
713 &self,
714 state: &'static str,
715 label: &'static str,
716 ) -> Result<(), sqlx::Error> {
717 let mut promoted_total = 0usize;
718 let mut notified_queues = HashSet::new();
719
720 for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
721 if self.cancel.is_cancelled() {
722 break;
723 }
724
725 let (promoted, queues) = self.promote_due_batch(state).await?;
726 if promoted == 0 {
727 break;
728 }
729
730 promoted_total += promoted;
731 notified_queues.extend(queues);
732
733 if promoted < PROMOTE_BATCH_SIZE as usize {
734 break;
735 }
736 }
737
738 if promoted_total > 0 {
739 debug!(
740 count = promoted_total,
741 queues = notified_queues.len(),
742 state,
743 "Promoted {label}"
744 );
745 }
746
747 Ok(())
748 }
749
750 fn promote_sql(state: &'static str) -> String {
756 format!(
757 r#"
758 WITH due AS (
759 DELETE FROM awa.scheduled_jobs
760 WHERE id IN (
761 SELECT id
762 FROM awa.scheduled_jobs
763 WHERE state = '{state}'::awa.job_state
764 AND run_at <= now()
765 ORDER BY run_at ASC, id ASC
766 LIMIT $1
767 FOR UPDATE SKIP LOCKED
768 )
769 RETURNING *
770 ),
771 promoted AS (
772 INSERT INTO awa.jobs_hot (
773 id, kind, queue, args, state, priority, attempt, max_attempts,
774 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
775 created_at, errors, metadata, tags, unique_key, unique_states,
776 callback_id, callback_timeout_at, callback_filter, callback_on_complete,
777 callback_on_fail, callback_transform, run_lease, progress
778 )
779 SELECT
780 id,
781 kind,
782 queue,
783 args,
784 'available'::awa.job_state,
785 priority,
786 attempt,
787 max_attempts,
788 now(),
789 NULL,
790 NULL,
791 attempted_at,
792 finalized_at,
793 created_at,
794 errors,
795 metadata,
796 tags,
797 unique_key,
798 unique_states,
799 NULL,
800 NULL,
801 NULL,
802 NULL,
803 NULL,
804 NULL,
805 run_lease,
806 progress
807 FROM due
808 RETURNING queue
809 )
810 SELECT queue FROM promoted
811 "#
812 )
813 }
814
815 async fn promote_due_batch(
816 &self,
817 state: &'static str,
818 ) -> Result<(usize, HashSet<String>), sqlx::Error> {
819 let mut tx = self.pool.begin().await?;
820 let promote_start = std::time::Instant::now();
821 let sql = Self::promote_sql(state);
822 let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
823 .bind(PROMOTE_BATCH_SIZE)
824 .fetch_all(&mut *tx)
825 .await?;
826
827 let promoted = promoted_rows.len();
828 self.metrics
829 .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
830 if promoted == 0 {
831 tx.commit().await?;
832 return Ok((0, HashSet::new()));
833 }
834
835 let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
836
837 tx.commit().await?;
838 Ok((promoted, queues))
839 }
840
841 #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
848 async fn cleanup_completed(&self) {
849 let mut total_deleted: u64 = 0;
850
851 let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
853
854 let completed_retention = format!("{} seconds", self.completed_retention.as_secs());
856 let failed_retention = format!("{} seconds", self.failed_retention.as_secs());
857
858 let global_result = if override_queues.is_empty() {
859 sqlx::query(
860 r#"
861 DELETE FROM awa.jobs_hot
862 WHERE id IN (
863 SELECT id FROM awa.jobs_hot
864 WHERE (state = 'completed' AND finalized_at < now() - $1::interval)
865 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval)
866 LIMIT $3
867 )
868 "#,
869 )
870 .bind(&completed_retention)
871 .bind(&failed_retention)
872 .bind(self.cleanup_batch_size)
873 .execute(&self.pool)
874 .await
875 } else {
876 sqlx::query(
877 r#"
878 DELETE FROM awa.jobs_hot
879 WHERE id IN (
880 SELECT id FROM awa.jobs_hot
881 WHERE ((state = 'completed' AND finalized_at < now() - $1::interval)
882 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval))
883 AND queue != ALL($4::text[])
884 LIMIT $3
885 )
886 "#,
887 )
888 .bind(&completed_retention)
889 .bind(&failed_retention)
890 .bind(self.cleanup_batch_size)
891 .bind(&override_queues)
892 .execute(&self.pool)
893 .await
894 };
895
896 match global_result {
897 Ok(result) if result.rows_affected() > 0 => {
898 total_deleted += result.rows_affected();
899 }
900 Err(err) => {
901 error!(error = %err, "Failed to clean up old jobs (global pass)");
902 }
903 _ => {}
904 }
905
906 for (queue_name, policy) in &self.queue_retention_overrides {
908 let queue_completed = format!("{} seconds", policy.completed.as_secs());
909 let queue_failed = format!("{} seconds", policy.failed.as_secs());
910
911 match sqlx::query(
912 r#"
913 DELETE FROM awa.jobs_hot
914 WHERE id IN (
915 SELECT id FROM awa.jobs_hot
916 WHERE queue = $4
917 AND ((state = 'completed' AND finalized_at < now() - $1::interval)
918 OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval))
919 LIMIT $3
920 )
921 "#,
922 )
923 .bind(&queue_completed)
924 .bind(&queue_failed)
925 .bind(self.cleanup_batch_size)
926 .bind(queue_name)
927 .execute(&self.pool)
928 .await
929 {
930 Ok(result) if result.rows_affected() > 0 => {
931 total_deleted += result.rows_affected();
932 debug!(
933 queue = %queue_name,
934 count = result.rows_affected(),
935 "Cleaned up old jobs (queue override)"
936 );
937 }
938 Err(err) => {
939 error!(
940 queue = %queue_name,
941 error = %err,
942 "Failed to clean up old jobs (queue override)"
943 );
944 }
945 _ => {}
946 }
947 }
948
949 if total_deleted > 0 {
950 info!(count = total_deleted, "Cleaned up old jobs");
951 }
952 }
953}
954
955struct MaintenanceAliveGuard(Arc<AtomicBool>);
956
957impl Drop for MaintenanceAliveGuard {
958 fn drop(&mut self) {
959 self.0.store(false, Ordering::SeqCst);
960 }
961}
962
963fn compute_fire_time(
967 row: &CronJobRow,
968 now: chrono::DateTime<Utc>,
969) -> Option<chrono::DateTime<Utc>> {
970 let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
971 Ok(c) => c,
972 Err(err) => {
973 error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
974 return None;
975 }
976 };
977
978 let tz: chrono_tz::Tz = match row.timezone.parse() {
979 Ok(tz) => tz,
980 Err(err) => {
981 error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
982 return None;
983 }
984 };
985
986 let search_start = match row.last_enqueued_at {
987 Some(last) => last.with_timezone(&tz),
988 None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
993 };
994
995 let mut latest_fire: Option<chrono::DateTime<Utc>> = None;
996
997 for fire_time in cron.iter_from(search_start) {
998 let fire_utc = fire_time.with_timezone(&Utc);
999
1000 if fire_utc > now {
1001 break;
1002 }
1003
1004 if let Some(last) = row.last_enqueued_at {
1005 if fire_utc <= last {
1006 continue;
1007 }
1008 }
1009
1010 latest_fire = Some(fire_utc);
1011 }
1012
1013 latest_fire
1014}
1015
1016impl MaintenanceService {
1017 #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
1020 async fn cleanup_stale_runtime_snapshots(&self) {
1021 if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
1022 &self.pool,
1023 chrono::TimeDelta::try_hours(24).unwrap(),
1024 )
1025 .await
1026 {
1027 tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
1028 }
1029 }
1030
1031 #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
1036 async fn cleanup_stale_descriptors(&self) {
1037 if self.descriptor_retention.is_zero() {
1038 return;
1039 }
1040 let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
1041 .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
1042 for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
1043 match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
1044 Ok(deleted) if deleted > 0 => {
1045 tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
1046 }
1047 Ok(_) => {}
1048 Err(err) => {
1049 tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
1050 }
1051 }
1052 }
1053 }
1054
1055 #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
1059 async fn recompute_dirty_admin_metadata(&self) {
1060 match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
1061 Ok(count) if count > 0 => {
1062 tracing::debug!(count, "Recomputed dirty admin metadata keys");
1063 }
1064 Err(err) => {
1065 tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
1066 }
1067 _ => {}
1068 }
1069 }
1070
1071 #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
1074 async fn refresh_admin_metadata(&self) {
1075 if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
1076 tracing::warn!(error = %err, "Failed to refresh admin metadata");
1077 }
1078 }
1079
1080 #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
1082 async fn publish_queue_health_metrics(&self) {
1083 let stats = match awa_model::admin::queue_overviews(&self.pool).await {
1084 Ok(stats) => stats,
1085 Err(err) => {
1086 tracing::warn!(error = %err, "Failed to query queue stats for metrics");
1087 return;
1088 }
1089 };
1090
1091 for queue_stat in &stats {
1092 let queue = &queue_stat.queue;
1093
1094 self.metrics
1096 .record_queue_depth(queue, "available", queue_stat.available);
1097 self.metrics
1098 .record_queue_depth(queue, "running", queue_stat.running);
1099 self.metrics
1100 .record_queue_depth(queue, "failed", queue_stat.failed);
1101 self.metrics
1102 .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
1103 self.metrics
1104 .record_queue_depth(queue, "retryable", queue_stat.retryable);
1105 self.metrics
1106 .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
1107
1108 if let Some(lag_seconds) = queue_stat.lag_seconds {
1110 self.metrics.record_queue_lag(queue, lag_seconds);
1111 }
1112 }
1113 }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use chrono::TimeZone;
1120
1121 fn cron_row(
1122 cron_expr: &str,
1123 created_at: chrono::DateTime<Utc>,
1124 last_enqueued_at: Option<chrono::DateTime<Utc>>,
1125 ) -> CronJobRow {
1126 CronJobRow {
1127 name: "test_cron".to_string(),
1128 cron_expr: cron_expr.to_string(),
1129 timezone: "UTC".to_string(),
1130 kind: "test_job".to_string(),
1131 queue: "default".to_string(),
1132 args: serde_json::json!({}),
1133 priority: 2,
1134 max_attempts: 25,
1135 tags: Vec::new(),
1136 metadata: serde_json::json!({}),
1137 last_enqueued_at,
1138 created_at,
1139 updated_at: created_at,
1140 }
1141 }
1142
1143 #[test]
1144 fn compute_fire_time_coalesces_missed_existing_fires() {
1145 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
1146 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
1147 let row = cron_row("*/5 * * * * *", last, Some(last));
1148
1149 let fire = compute_fire_time(&row, now);
1150
1151 assert_eq!(
1152 fire,
1153 Some(Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap())
1154 );
1155 }
1156
1157 #[test]
1158 fn compute_fire_time_returns_none_when_no_fire_is_due() {
1159 let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
1160 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 2).unwrap();
1161 let row = cron_row("*/5 * * * * *", last, Some(last));
1162
1163 let fire = compute_fire_time(&row, now);
1164
1165 assert_eq!(fire, None);
1166 }
1167
1168 #[test]
1169 fn compute_fire_time_keeps_first_registration_latest_only() {
1170 let created_at = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
1171 let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap();
1172 let row = cron_row("*/5 * * * * *", created_at, None);
1173
1174 let fire = compute_fire_time(&row, now);
1175
1176 assert_eq!(
1177 fire,
1178 Some(Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap())
1179 );
1180 }
1181}