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    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), // 24h
96            failed_retention: Duration::from_secs(259200),   // 72h
97            cleanup_batch_size: 1000,
98            queue_retention_overrides: HashMap::new(),
99            queue_stats_interval: Duration::from_secs(30),
100        }
101    }
102
103    /// Set the leader election retry interval (default: 10s).
104    ///
105    /// Controls how often a non-leader instance retries acquiring the
106    /// advisory lock. Lower values speed up leader election in tests.
107    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
108        self.leader_election_interval = interval;
109        self
110    }
111
112    /// Set the leader connection health-check interval (default: 30s).
113    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
114        self.leader_check_interval = interval;
115        self
116    }
117
118    /// Set the promotion interval for scheduled/retryable jobs.
119    pub fn promote_interval(mut self, interval: Duration) -> Self {
120        self.promote_interval = interval;
121        self
122    }
123
124    /// Set the stale-heartbeat rescue interval (default: 30s).
125    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
126        self.heartbeat_rescue_interval = interval;
127        self
128    }
129
130    /// Set the deadline rescue interval (default: 30s).
131    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
132        self.deadline_rescue_interval = interval;
133        self
134    }
135
136    /// Set the callback-timeout rescue interval (default: 30s).
137    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
138        self.callback_rescue_interval = interval;
139        self
140    }
141
142    /// Set how long a heartbeat must be stale before the job is rescued (default: 90s).
143    ///
144    /// Should be at least 3× the heartbeat interval to avoid false rescues
145    /// from transient delays. The run-lease guard prevents duplicate completions
146    /// even if a false rescue occurs, but wasted work is still undesirable.
147    pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
148        self.heartbeat_staleness = staleness;
149        self
150    }
151
152    /// Set the cleanup interval (default: 60s).
153    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
154        self.cleanup_interval = interval;
155        self
156    }
157
158    /// Set retention for completed jobs (default: 24h).
159    pub fn completed_retention(mut self, retention: Duration) -> Self {
160        self.completed_retention = retention;
161        self
162    }
163
164    /// Set retention for failed/cancelled jobs (default: 72h).
165    pub fn failed_retention(mut self, retention: Duration) -> Self {
166        self.failed_retention = retention;
167        self
168    }
169
170    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
171    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
172        self.cleanup_batch_size = batch_size;
173        self
174    }
175
176    /// Set the interval for publishing queue depth/lag metrics (default: 30s).
177    pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
178        self.queue_stats_interval = interval;
179        self
180    }
181
182    /// Set per-queue retention overrides.
183    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    /// Run the maintenance loop. Attempts leader election first.
192    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            // Try to acquire advisory lock for leader election.
200            // We get back a dedicated connection that holds the lock.
201            let mut leader_conn = match self.try_become_leader().await {
202                Ok(Some(conn)) => conn,
203                Ok(None) => {
204                    // Not leader — back off and try again
205                    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            // Run maintenance tasks as leader
231            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            // Skip the first immediate tick
242            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            // Do an initial sync immediately on becoming leader
253            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                        // Release leader lock on the same connection that acquired it.
261                        // If this fails, dropping the connection will release the lock anyway.
262                        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                        // Verify leader connection is still alive.
292                        // The advisory lock is session-scoped: if the connection is alive,
293                        // the lock is held. If the query fails, the connection (and lock) are gone.
294                        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    /// Advisory lock key for Awa maintenance leader election.
306    const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; // "AWA_MAIN" in hex-ish
307
308    /// Try to acquire the advisory lock for leader election.
309    ///
310    /// Returns a dedicated connection holding the lock on success, or `None` if
311    /// another instance already holds the lock. The lock is session-scoped in
312    /// PostgreSQL, so it stays held as long as this connection is alive.
313    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    /// Release the advisory lock on the same connection that acquired it.
327    ///
328    /// Dropping the connection also releases the lock (PG session-scoped behavior),
329    /// so this is a best-effort explicit release.
330    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    /// Sync all registered periodic job schedules to `awa.cron_jobs` via UPSERT.
339    ///
340    /// Additive only — does NOT delete schedules not in the local set (multi-deployment safe).
341    #[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    /// Evaluate all cron schedules and enqueue any that are due.
360    ///
361    /// For each schedule, computes the latest fire time ≤ now that is after
362    /// `last_enqueued_at`. If a fire is due, executes the atomic CTE to
363    /// mark + insert in one statement.
364    #[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                    // Another leader already claimed this fire — not an error
397                    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    /// Rescue jobs with stale heartbeats (crash detection).
411    #[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                // Signal cancellation to any rescued jobs still running on this instance
453                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    /// Rescue jobs that exceeded their hard deadline.
463    #[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                // Signal cancellation so handlers see ctx.is_cancelled() == true
504                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    /// Rescue jobs whose callback timeout has expired.
514    #[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    /// Signal cancellation to any rescued jobs that are still running on this instance.
566    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    /// Promote scheduled jobs that are now due.
576    #[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    /// SQL template for promotion. The state literal is injected directly
628    /// (not as a parameter) so the planner can match the partial index on
629    /// `(run_at, id) WHERE state = '<state>'`. With a parameter, the planner
630    /// cannot prove the partial index applies and falls back to a full
631    /// bitmap scan on multi-million-row tables.
632    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    /// Clean up completed/failed/cancelled jobs past retention.
719    ///
720    /// Targets `jobs_hot` directly (bypassing the `awa.jobs` INSTEAD OF trigger)
721    /// since terminal-state jobs always reside in `jobs_hot`.
722    /// Runs a global pass for queues without overrides, then per-queue passes
723    /// for queues with custom retention.
724    #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
725    async fn cleanup_completed(&self) {
726        let mut total_deleted: u64 = 0;
727
728        // Collect override queue names for the exclusion clause
729        let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
730
731        // Global pass: delete jobs in queues that do NOT have overrides
732        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        // Per-queue override passes
784        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
840/// Compute the latest fire time for a cron job row, using its expression and timezone.
841///
842/// Returns `None` if no fire is due (next occurrence is in the future).
843fn 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        // First registration: search from one interval before created_at
866        // so that the current minute's fire is found. Without this,
867        // a schedule created at HH:MM:30 won't find the HH:MM:00 fire
868        // because created_at > fire_time, causing up to 60s delay.
869        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    /// Clean up runtime snapshots older than 24 hours.
895    /// Runs as part of the leader's cleanup cycle (not on every snapshot publish).
896    #[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    /// Publish queue depth and lag as OTel gauge metrics.
909    #[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            // Depth per state
923            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            // Lag
937            if let Some(lag_seconds) = queue_stat.lag_seconds {
938                self.metrics.record_queue_lag(queue, lag_seconds);
939            }
940        }
941    }
942}