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