Skip to main content

awa_worker/
maintenance.rs

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/// Per-queue or global retention policy for completed and failed/cancelled jobs.
16#[derive(Debug, Clone)]
17pub struct RetentionPolicy {
18    /// How long to keep completed jobs before cleanup.
19    pub completed: Duration,
20    /// How long to keep failed/cancelled jobs before cleanup.
21    pub failed: Duration,
22}
23
24impl Default for RetentionPolicy {
25    fn default() -> Self {
26        Self {
27            completed: Duration::from_secs(86400), // 24h
28            failed: Duration::from_secs(259200),   // 72h
29        }
30    }
31}
32
33/// Maintenance service: runs leader-elected background tasks.
34///
35/// Tasks: heartbeat rescue, deadline rescue, scheduled promotion, cleanup,
36/// periodic job sync and evaluation.
37pub 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 job cancellation flags — used to signal deadline/heartbeat rescue
45    /// to running handlers on this worker instance.
46    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), // 24h
95            failed_retention: Duration::from_secs(259200),   // 72h
96            cleanup_batch_size: 1000,
97            queue_retention_overrides: HashMap::new(),
98        }
99    }
100
101    /// Set the leader election retry interval (default: 10s).
102    ///
103    /// Controls how often a non-leader instance retries acquiring the
104    /// advisory lock. Lower values speed up leader election in tests.
105    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
106        self.leader_election_interval = interval;
107        self
108    }
109
110    /// Set the leader connection health-check interval (default: 30s).
111    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
112        self.leader_check_interval = interval;
113        self
114    }
115
116    /// Set the promotion interval for scheduled/retryable jobs.
117    pub fn promote_interval(mut self, interval: Duration) -> Self {
118        self.promote_interval = interval;
119        self
120    }
121
122    /// Set the stale-heartbeat rescue interval (default: 30s).
123    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
124        self.heartbeat_rescue_interval = interval;
125        self
126    }
127
128    /// Set the deadline rescue interval (default: 30s).
129    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
130        self.deadline_rescue_interval = interval;
131        self
132    }
133
134    /// Set the callback-timeout rescue interval (default: 30s).
135    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
136        self.callback_rescue_interval = interval;
137        self
138    }
139
140    /// Set the cleanup interval (default: 60s).
141    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
142        self.cleanup_interval = interval;
143        self
144    }
145
146    /// Set retention for completed jobs (default: 24h).
147    pub fn completed_retention(mut self, retention: Duration) -> Self {
148        self.completed_retention = retention;
149        self
150    }
151
152    /// Set retention for failed/cancelled jobs (default: 72h).
153    pub fn failed_retention(mut self, retention: Duration) -> Self {
154        self.failed_retention = retention;
155        self
156    }
157
158    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
159    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
160        self.cleanup_batch_size = batch_size;
161        self
162    }
163
164    /// Set per-queue retention overrides.
165    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    /// Run the maintenance loop. Attempts leader election first.
174    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            // Try to acquire advisory lock for leader election.
182            // We get back a dedicated connection that holds the lock.
183            let mut leader_conn = match self.try_become_leader().await {
184                Ok(Some(conn)) => conn,
185                Ok(None) => {
186                    // Not leader — back off and try again
187                    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            // Run maintenance tasks as leader
213            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            // Skip the first immediate tick
223            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            // Do an initial sync immediately on becoming leader
233            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                        // Release leader lock on the same connection that acquired it.
241                        // If this fails, dropping the connection will release the lock anyway.
242                        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                        // Verify leader connection is still alive.
269                        // The advisory lock is session-scoped: if the connection is alive,
270                        // the lock is held. If the query fails, the connection (and lock) are gone.
271                        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    /// Advisory lock key for Awa maintenance leader election.
283    const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; // "AWA_MAIN" in hex-ish
284
285    /// Try to acquire the advisory lock for leader election.
286    ///
287    /// Returns a dedicated connection holding the lock on success, or `None` if
288    /// another instance already holds the lock. The lock is session-scoped in
289    /// PostgreSQL, so it stays held as long as this connection is alive.
290    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    /// Release the advisory lock on the same connection that acquired it.
304    ///
305    /// Dropping the connection also releases the lock (PG session-scoped behavior),
306    /// so this is a best-effort explicit release.
307    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    /// Sync all registered periodic job schedules to `awa.cron_jobs` via UPSERT.
316    ///
317    /// Additive only — does NOT delete schedules not in the local set (multi-deployment safe).
318    #[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    /// Evaluate all cron schedules and enqueue any that are due.
337    ///
338    /// For each schedule, computes the latest fire time ≤ now that is after
339    /// `last_enqueued_at`. If a fire is due, executes the atomic CTE to
340    /// mark + insert in one statement.
341    #[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                    // Another leader already claimed this fire — not an error
374                    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    /// Rescue jobs with stale heartbeats (crash detection).
388    #[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                // Signal cancellation to any rescued jobs still running on this instance
430                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    /// Rescue jobs that exceeded their hard deadline.
440    #[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                // Signal cancellation so handlers see ctx.is_cancelled() == true
481                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    /// Rescue jobs whose callback timeout has expired.
491    #[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    /// Signal cancellation to any rescued jobs that are still running on this instance.
543    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    /// Promote scheduled jobs that are now due.
553    #[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    /// Clean up completed/failed/cancelled jobs past retention.
687    ///
688    /// Targets `jobs_hot` directly (bypassing the `awa.jobs` INSTEAD OF trigger)
689    /// since terminal-state jobs always reside in `jobs_hot`.
690    /// Runs a global pass for queues without overrides, then per-queue passes
691    /// for queues with custom retention.
692    #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
693    async fn cleanup_completed(&self) {
694        let mut total_deleted: u64 = 0;
695
696        // Collect override queue names for the exclusion clause
697        let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
698
699        // Global pass: delete jobs in queues that do NOT have overrides
700        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        // Per-queue override passes
752        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
808/// Compute the latest fire time for a cron job row, using its expression and timezone.
809///
810/// Returns `None` if no fire is due (next occurrence is in the future).
811fn 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        // First registration: search from one interval before created_at
834        // so that the current minute's fire is found. Without this,
835        // a schedule created at HH:MM:30 won't find the HH:MM:00 fire
836        // because created_at > fire_time, causing up to 60s delay.
837        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    /// Clean up runtime snapshots older than 24 hours.
863    /// Runs as part of the leader's cleanup cycle (not on every snapshot publish).
864    #[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}