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