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