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