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