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    dirty_key_recompute_interval: Duration,
63    metadata_reconciliation_interval: Duration,
64    /// Interval for priority aging — jobs waiting longer than this have their
65    /// priority improved by one level per interval elapsed (default: 60s).
66    priority_aging_interval: Duration,
67}
68
69const PROMOTE_BATCH_SIZE: i64 = 4_096;
70const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
71
72impl MaintenanceService {
73    pub(crate) fn new(
74        pool: PgPool,
75        metrics: crate::metrics::AwaMetrics,
76        leader: Arc<AtomicBool>,
77        alive: Arc<AtomicBool>,
78        cancel: CancellationToken,
79        periodic_jobs: Arc<Vec<PeriodicJob>>,
80        in_flight: InFlightMap,
81    ) -> Self {
82        Self {
83            pool,
84            metrics,
85            cancel,
86            leader,
87            alive,
88            periodic_jobs,
89            in_flight,
90            heartbeat_rescue_interval: Duration::from_secs(30),
91            deadline_rescue_interval: Duration::from_secs(30),
92            callback_rescue_interval: Duration::from_secs(30),
93            promote_interval: Duration::from_millis(250),
94            cleanup_interval: Duration::from_secs(60),
95            cron_sync_interval: Duration::from_secs(60),
96            cron_eval_interval: Duration::from_secs(1),
97            leader_check_interval: Duration::from_secs(30),
98            leader_election_interval: Duration::from_secs(10),
99            heartbeat_staleness: Duration::from_secs(90),
100            completed_retention: Duration::from_secs(86400), // 24h
101            failed_retention: Duration::from_secs(259200),   // 72h
102            cleanup_batch_size: 1000,
103            queue_retention_overrides: HashMap::new(),
104            queue_stats_interval: Duration::from_secs(30),
105            dirty_key_recompute_interval: Duration::from_secs(2),
106            metadata_reconciliation_interval: Duration::from_secs(60),
107            priority_aging_interval: Duration::from_secs(60),
108        }
109    }
110
111    /// Set the priority aging interval (default: 60s).
112    ///
113    /// Jobs waiting longer than this per priority level are promoted:
114    /// a priority-4 job waiting 180s is treated as priority-1.
115    pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
116        self.priority_aging_interval = interval;
117        self
118    }
119
120    /// Set the leader election retry interval (default: 10s).
121    ///
122    /// Controls how often a non-leader instance retries acquiring the
123    /// advisory lock. Lower values speed up leader election in tests.
124    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
125        self.leader_election_interval = interval;
126        self
127    }
128
129    /// Set the leader connection health-check interval (default: 30s).
130    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
131        self.leader_check_interval = interval;
132        self
133    }
134
135    /// Set the promotion interval for scheduled/retryable jobs.
136    pub fn promote_interval(mut self, interval: Duration) -> Self {
137        self.promote_interval = interval;
138        self
139    }
140
141    /// Set the stale-heartbeat rescue interval (default: 30s).
142    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
143        self.heartbeat_rescue_interval = interval;
144        self
145    }
146
147    /// Set the deadline rescue interval (default: 30s).
148    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
149        self.deadline_rescue_interval = interval;
150        self
151    }
152
153    /// Set the callback-timeout rescue interval (default: 30s).
154    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
155        self.callback_rescue_interval = interval;
156        self
157    }
158
159    /// Set how long a heartbeat must be stale before the job is rescued (default: 90s).
160    ///
161    /// Should be at least 3× the heartbeat interval to avoid false rescues
162    /// from transient delays. The run-lease guard prevents duplicate completions
163    /// even if a false rescue occurs, but wasted work is still undesirable.
164    pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
165        self.heartbeat_staleness = staleness;
166        self
167    }
168
169    /// Set the cleanup interval (default: 60s).
170    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
171        self.cleanup_interval = interval;
172        self
173    }
174
175    /// Set retention for completed jobs (default: 24h).
176    pub fn completed_retention(mut self, retention: Duration) -> Self {
177        self.completed_retention = retention;
178        self
179    }
180
181    /// Set retention for failed/cancelled jobs (default: 72h).
182    pub fn failed_retention(mut self, retention: Duration) -> Self {
183        self.failed_retention = retention;
184        self
185    }
186
187    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
188    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
189        self.cleanup_batch_size = batch_size;
190        self
191    }
192
193    /// Set the interval for publishing queue depth/lag metrics (default: 30s).
194    pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
195        self.queue_stats_interval = interval;
196        self
197    }
198
199    /// Set per-queue retention overrides.
200    pub fn queue_retention_overrides(
201        mut self,
202        overrides: HashMap<String, RetentionPolicy>,
203    ) -> Self {
204        self.queue_retention_overrides = overrides;
205        self
206    }
207
208    /// Run the maintenance loop. Attempts leader election first.
209    pub async fn run(&self) {
210        info!("Maintenance service starting");
211        self.alive.store(true, Ordering::SeqCst);
212        let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
213        self.leader.store(false, Ordering::SeqCst);
214
215        loop {
216            // Try to acquire advisory lock for leader election.
217            // We get back a dedicated connection that holds the lock.
218            let mut leader_conn = match self.try_become_leader().await {
219                Ok(Some(conn)) => conn,
220                Ok(None) => {
221                    // Not leader — back off and try again
222                    tokio::select! {
223                        _ = self.cancel.cancelled() => {
224                            debug!("Maintenance service shutting down (not leader)");
225                            self.leader.store(false, Ordering::SeqCst);
226                            return;
227                        }
228                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
229                    }
230                }
231                Err(err) => {
232                    warn!(error = %err, "Failed to check leader status");
233                    tokio::select! {
234                        _ = self.cancel.cancelled() => {
235                            debug!("Maintenance service shutting down (leader check failed)");
236                            self.leader.store(false, Ordering::SeqCst);
237                            return;
238                        }
239                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
240                    }
241                }
242            };
243
244            debug!("Elected as maintenance leader");
245            self.leader.store(true, Ordering::SeqCst);
246
247            // Run maintenance tasks as leader
248            let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
249            let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
250            let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
251            let mut promote_timer = tokio::time::interval(self.promote_interval);
252            let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
253            let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
254            let mut cron_eval_timer = tokio::time::interval(self.cron_eval_interval);
255            let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
256            let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
257            let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
258            let mut metadata_reconciliation_timer =
259                tokio::time::interval(self.metadata_reconciliation_interval);
260            let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
261
262            // Skip the first immediate tick
263            heartbeat_rescue_timer.tick().await;
264            deadline_rescue_timer.tick().await;
265            callback_rescue_timer.tick().await;
266            promote_timer.tick().await;
267            cleanup_timer.tick().await;
268            cron_sync_timer.tick().await;
269            cron_eval_timer.tick().await;
270            leader_check_timer.tick().await;
271            queue_stats_timer.tick().await;
272            dirty_key_timer.tick().await;
273            metadata_reconciliation_timer.tick().await;
274            priority_aging_timer.tick().await;
275
276            // Do an initial sync immediately on becoming leader
277            self.sync_periodic_jobs_to_db().await;
278
279            loop {
280                tokio::select! {
281                    _ = self.cancel.cancelled() => {
282                        debug!("Maintenance service shutting down");
283                        self.leader.store(false, Ordering::SeqCst);
284                        // Release leader lock on the same connection that acquired it.
285                        // If this fails, dropping the connection will release the lock anyway.
286                        let _ = Self::release_leader(&mut leader_conn).await;
287                        return;
288                    }
289                    _ = heartbeat_rescue_timer.tick() => {
290                        self.rescue_stale_heartbeats().await;
291                    }
292                    _ = deadline_rescue_timer.tick() => {
293                        self.rescue_expired_deadlines().await;
294                    }
295                    _ = callback_rescue_timer.tick() => {
296                        self.rescue_expired_callbacks().await;
297                    }
298                    _ = promote_timer.tick() => {
299                        self.promote_scheduled().await;
300                    }
301                    _ = cleanup_timer.tick() => {
302                        self.cleanup_completed().await;
303                        self.cleanup_stale_runtime_snapshots().await;
304                    }
305                    _ = cron_sync_timer.tick() => {
306                        self.sync_periodic_jobs_to_db().await;
307                    }
308                    _ = cron_eval_timer.tick() => {
309                        self.evaluate_cron_schedules().await;
310                    }
311                    _ = queue_stats_timer.tick() => {
312                        self.publish_queue_health_metrics().await;
313                    }
314                    _ = dirty_key_timer.tick() => {
315                        self.recompute_dirty_admin_metadata().await;
316                    }
317                    _ = metadata_reconciliation_timer.tick() => {
318                        self.refresh_admin_metadata().await;
319                    }
320                    _ = priority_aging_timer.tick() => {
321                        self.age_waiting_priorities().await;
322                    }
323                    _ = leader_check_timer.tick() => {
324                        // Verify leader connection is still alive.
325                        // The advisory lock is session-scoped: if the connection is alive,
326                        // the lock is held. If the query fails, the connection (and lock) are gone.
327                        if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
328                            warn!("Leader connection lost, re-entering election loop");
329                            self.leader.store(false, Ordering::SeqCst);
330                            break;
331                        }
332                    }
333                }
334            }
335        }
336    }
337
338    /// Advisory lock key for Awa maintenance leader election.
339    const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; // "AWA_MAIN" in hex-ish
340
341    /// Try to acquire the advisory lock for leader election.
342    ///
343    /// Returns a dedicated connection holding the lock on success, or `None` if
344    /// another instance already holds the lock. The lock is session-scoped in
345    /// PostgreSQL, so it stays held as long as this connection is alive.
346    async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
347        let mut conn = self.pool.acquire().await?;
348        let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
349            .bind(Self::LOCK_KEY)
350            .fetch_one(&mut *conn)
351            .await?;
352        if result.0 {
353            Ok(Some(conn))
354        } else {
355            Ok(None)
356        }
357    }
358
359    /// Release the advisory lock on the same connection that acquired it.
360    ///
361    /// Dropping the connection also releases the lock (PG session-scoped behavior),
362    /// so this is a best-effort explicit release.
363    async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
364        sqlx::query("SELECT pg_advisory_unlock($1)")
365            .bind(Self::LOCK_KEY)
366            .execute(&mut **conn)
367            .await?;
368        Ok(())
369    }
370
371    /// Sync all registered periodic job schedules to `awa.cron_jobs` via UPSERT.
372    ///
373    /// Additive only — does NOT delete schedules not in the local set (multi-deployment safe).
374    #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
375    async fn sync_periodic_jobs_to_db(&self) {
376        if self.periodic_jobs.is_empty() {
377            return;
378        }
379
380        for job in self.periodic_jobs.iter() {
381            if let Err(err) = upsert_cron_job(&self.pool, job).await {
382                error!(name = %job.name, error = %err, "Failed to sync periodic job");
383            }
384        }
385
386        debug!(
387            count = self.periodic_jobs.len(),
388            "Synced periodic jobs to database"
389        );
390    }
391
392    /// Evaluate all cron schedules and enqueue any that are due.
393    ///
394    /// For each schedule, computes the latest fire time ≤ now that is after
395    /// `last_enqueued_at`. If a fire is due, executes the atomic CTE to
396    /// mark + insert in one statement.
397    #[tracing::instrument(skip(self), name = "maintenance.cron_eval")]
398    async fn evaluate_cron_schedules(&self) {
399        let cron_rows = match list_cron_jobs(&self.pool).await {
400            Ok(rows) => rows,
401            Err(err) => {
402                error!(error = %err, "Failed to load cron jobs for evaluation");
403                return;
404            }
405        };
406
407        if cron_rows.is_empty() {
408            return;
409        }
410
411        let now = Utc::now();
412
413        for row in &cron_rows {
414            let fire_time = match compute_fire_time(row, now) {
415                Some(time) => time,
416                None => continue,
417            };
418
419            match atomic_enqueue(&self.pool, &row.name, fire_time, row.last_enqueued_at).await {
420                Ok(Some(job)) => {
421                    info!(
422                        cron_name = %row.name,
423                        job_id = job.id,
424                        fire_time = %fire_time,
425                        "Enqueued periodic job"
426                    );
427                }
428                Ok(None) => {
429                    // Another leader already claimed this fire — not an error
430                    debug!(cron_name = %row.name, "Cron fire already claimed");
431                }
432                Err(err) => {
433                    error!(
434                        cron_name = %row.name,
435                        error = %err,
436                        "Failed to enqueue periodic job"
437                    );
438                }
439            }
440        }
441    }
442
443    /// Rescue jobs with stale heartbeats (crash detection).
444    #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
445    async fn rescue_stale_heartbeats(&self) {
446        let staleness_str = format!("{} seconds", self.heartbeat_staleness.as_secs());
447        match sqlx::query_as::<_, JobRow>(
448            r#"
449            UPDATE awa.jobs
450            SET state = 'retryable',
451                finalized_at = now(),
452                heartbeat_at = NULL,
453                deadline_at = NULL,
454                callback_id = NULL,
455                callback_timeout_at = NULL,
456                callback_filter = NULL,
457                callback_on_complete = NULL,
458                callback_on_fail = NULL,
459                callback_transform = NULL,
460                errors = errors || jsonb_build_object(
461                    'error', 'heartbeat stale: worker presumed dead',
462                    'attempt', attempt,
463                    'at', now()
464                )::jsonb
465            WHERE id IN (
466                SELECT id FROM awa.jobs_hot
467                WHERE state = 'running'
468                  AND heartbeat_at < now() - $1::interval
469                LIMIT 500
470                FOR UPDATE SKIP LOCKED
471            )
472            RETURNING *
473            "#,
474        )
475        .bind(&staleness_str)
476        .fetch_all(&self.pool)
477        .await
478        {
479            Ok(rescued) if !rescued.is_empty() => {
480                self.metrics.maintenance_rescues.add(
481                    rescued.len() as u64,
482                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
483                );
484                warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
485                // Signal cancellation to any rescued jobs still running on this instance
486                self.signal_cancellation(&rescued).await;
487            }
488            Err(err) => {
489                error!(error = %err, "Failed to rescue stale heartbeat jobs");
490            }
491            _ => {}
492        }
493    }
494
495    /// Rescue jobs that exceeded their hard deadline.
496    #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
497    async fn rescue_expired_deadlines(&self) {
498        match sqlx::query_as::<_, JobRow>(
499            r#"
500            UPDATE awa.jobs
501            SET state = 'retryable',
502                finalized_at = now(),
503                heartbeat_at = NULL,
504                deadline_at = NULL,
505                callback_id = NULL,
506                callback_timeout_at = NULL,
507                callback_filter = NULL,
508                callback_on_complete = NULL,
509                callback_on_fail = NULL,
510                callback_transform = NULL,
511                errors = errors || jsonb_build_object(
512                    'error', 'hard deadline exceeded',
513                    'attempt', attempt,
514                    'at', now()
515                )::jsonb
516            WHERE id IN (
517                SELECT id FROM awa.jobs_hot
518                WHERE state = 'running'
519                  AND deadline_at IS NOT NULL
520                  AND deadline_at < now()
521                LIMIT 500
522                FOR UPDATE SKIP LOCKED
523            )
524            RETURNING *
525            "#,
526        )
527        .fetch_all(&self.pool)
528        .await
529        {
530            Ok(rescued) if !rescued.is_empty() => {
531                self.metrics.maintenance_rescues.add(
532                    rescued.len() as u64,
533                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
534                );
535                warn!(count = rescued.len(), "Rescued deadline-expired jobs");
536                // Signal cancellation so handlers see ctx.is_cancelled() == true
537                self.signal_cancellation(&rescued).await;
538            }
539            Err(err) => {
540                error!(error = %err, "Failed to rescue deadline-expired jobs");
541            }
542            _ => {}
543        }
544    }
545
546    /// Rescue jobs whose callback timeout has expired.
547    #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
548    async fn rescue_expired_callbacks(&self) {
549        match sqlx::query_as::<_, JobRow>(
550            r#"
551            UPDATE awa.jobs
552            SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
553                finalized_at = now(),
554                callback_id = NULL,
555                callback_timeout_at = NULL,
556                callback_filter = NULL,
557                callback_on_complete = NULL,
558                callback_on_fail = NULL,
559                callback_transform = NULL,
560                run_at = CASE WHEN attempt >= max_attempts THEN run_at
561                         ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
562                errors = errors || jsonb_build_object(
563                    'error', 'callback timed out',
564                    'attempt', attempt,
565                    'at', now()
566                )::jsonb
567            WHERE id IN (
568                SELECT id FROM awa.jobs_hot
569                WHERE state = 'waiting_external'
570                  AND callback_timeout_at IS NOT NULL
571                  AND callback_timeout_at < now()
572                LIMIT 500
573                FOR UPDATE SKIP LOCKED
574            )
575            RETURNING *
576            "#,
577        )
578        .fetch_all(&self.pool)
579        .await
580        {
581            Ok(rescued) if !rescued.is_empty() => {
582                self.metrics.maintenance_rescues.add(
583                    rescued.len() as u64,
584                    &[opentelemetry::KeyValue::new(
585                        "awa.rescue.kind",
586                        "callback_timeout",
587                    )],
588                );
589                warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
590            }
591            Err(err) => {
592                error!(error = %err, "Failed to rescue callback-timed-out jobs");
593            }
594            _ => {}
595        }
596    }
597
598    /// Age priorities for jobs that have been waiting longer than `priority_aging_interval`.
599    ///
600    /// Decrements `priority` by 1 per pass for available jobs waiting longer than
601    /// the aging interval (minimum priority 1). On the first age, stores the
602    /// original priority in `metadata._awa_original_priority` so the API can
603    /// report it accurately.
604    #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
605    async fn age_waiting_priorities(&self) {
606        let aging_secs = self.priority_aging_interval.as_secs() as f64;
607        if aging_secs <= 0.0 {
608            return;
609        }
610        match sqlx::query_scalar::<_, i64>(
611            r#"
612            WITH eligible AS (
613                SELECT id FROM awa.jobs_hot
614                WHERE state = 'available'
615                  AND priority > 1
616                  AND run_at <= now() - make_interval(secs => $1)
617                LIMIT 1000
618                FOR UPDATE SKIP LOCKED
619            )
620            UPDATE awa.jobs_hot
621            SET priority = priority - 1,
622                metadata = CASE
623                    WHEN NOT (metadata ? '_awa_original_priority')
624                    THEN metadata || jsonb_build_object('_awa_original_priority', priority)
625                    ELSE metadata
626                END
627            FROM eligible
628            WHERE awa.jobs_hot.id = eligible.id
629            RETURNING awa.jobs_hot.id
630            "#,
631        )
632        .bind(aging_secs)
633        .fetch_all(&self.pool)
634        .await
635        {
636            Ok(ids) if !ids.is_empty() => {
637                debug!(count = ids.len(), "Aged job priorities");
638            }
639            Err(err) => {
640                error!(error = %err, "Failed to age job priorities");
641            }
642            _ => {}
643        }
644    }
645
646    /// Signal cancellation to any rescued jobs that are still running on this instance.
647    async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
648        for job in rescued_jobs {
649            if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
650                flag.store(true, Ordering::SeqCst);
651                debug!(job_id = job.id, "Signalled cancellation for rescued job");
652            }
653        }
654    }
655
656    /// Promote scheduled jobs that are now due.
657    #[tracing::instrument(skip(self), name = "maintenance.promote")]
658    async fn promote_scheduled(&self) {
659        if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
660            error!(error = %err, "Failed to promote scheduled jobs");
661        }
662        if let Err(err) = self
663            .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
664            .await
665        {
666            error!(error = %err, "Failed to promote retryable jobs");
667        }
668    }
669
670    async fn promote_due_state(
671        &self,
672        state: &'static str,
673        label: &'static str,
674    ) -> Result<(), sqlx::Error> {
675        let mut promoted_total = 0usize;
676        let mut notified_queues = HashSet::new();
677
678        for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
679            if self.cancel.is_cancelled() {
680                break;
681            }
682
683            let (promoted, queues) = self.promote_due_batch(state).await?;
684            if promoted == 0 {
685                break;
686            }
687
688            promoted_total += promoted;
689            notified_queues.extend(queues);
690
691            if promoted < PROMOTE_BATCH_SIZE as usize {
692                break;
693            }
694        }
695
696        if promoted_total > 0 {
697            debug!(
698                count = promoted_total,
699                queues = notified_queues.len(),
700                state,
701                "Promoted {label}"
702            );
703        }
704
705        Ok(())
706    }
707
708    /// SQL template for promotion. The state literal is injected directly
709    /// (not as a parameter) so the planner can match the partial index on
710    /// `(run_at, id) WHERE state = '<state>'`. With a parameter, the planner
711    /// cannot prove the partial index applies and falls back to a full
712    /// bitmap scan on multi-million-row tables.
713    fn promote_sql(state: &'static str) -> String {
714        format!(
715            r#"
716            WITH due AS (
717                DELETE FROM awa.scheduled_jobs
718                WHERE id IN (
719                    SELECT id
720                    FROM awa.scheduled_jobs
721                    WHERE state = '{state}'::awa.job_state
722                      AND run_at <= now()
723                    ORDER BY run_at ASC, id ASC
724                    LIMIT $1
725                    FOR UPDATE SKIP LOCKED
726                )
727                RETURNING *
728            ),
729            promoted AS (
730                INSERT INTO awa.jobs_hot (
731                    id, kind, queue, args, state, priority, attempt, max_attempts,
732                    run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
733                    created_at, errors, metadata, tags, unique_key, unique_states,
734                    callback_id, callback_timeout_at, callback_filter, callback_on_complete,
735                    callback_on_fail, callback_transform, run_lease, progress
736                )
737                SELECT
738                    id,
739                    kind,
740                    queue,
741                    args,
742                    'available'::awa.job_state,
743                    priority,
744                    attempt,
745                    max_attempts,
746                    now(),
747                    NULL,
748                    NULL,
749                    attempted_at,
750                    finalized_at,
751                    created_at,
752                    errors,
753                    metadata,
754                    tags,
755                    unique_key,
756                    unique_states,
757                    NULL,
758                    NULL,
759                    NULL,
760                    NULL,
761                    NULL,
762                    NULL,
763                    run_lease,
764                    progress
765                FROM due
766                RETURNING queue
767            )
768            SELECT queue FROM promoted
769            "#
770        )
771    }
772
773    async fn promote_due_batch(
774        &self,
775        state: &'static str,
776    ) -> Result<(usize, HashSet<String>), sqlx::Error> {
777        let mut tx = self.pool.begin().await?;
778        let promote_start = std::time::Instant::now();
779        let sql = Self::promote_sql(state);
780        let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
781            .bind(PROMOTE_BATCH_SIZE)
782            .fetch_all(&mut *tx)
783            .await?;
784
785        let promoted = promoted_rows.len();
786        self.metrics
787            .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
788        if promoted == 0 {
789            tx.commit().await?;
790            return Ok((0, HashSet::new()));
791        }
792
793        let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
794
795        tx.commit().await?;
796        Ok((promoted, queues))
797    }
798
799    /// Clean up completed/failed/cancelled jobs past retention.
800    ///
801    /// Targets `jobs_hot` directly (bypassing the `awa.jobs` INSTEAD OF trigger)
802    /// since terminal-state jobs always reside in `jobs_hot`.
803    /// Runs a global pass for queues without overrides, then per-queue passes
804    /// for queues with custom retention.
805    #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
806    async fn cleanup_completed(&self) {
807        let mut total_deleted: u64 = 0;
808
809        // Collect override queue names for the exclusion clause
810        let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
811
812        // Global pass: delete jobs in queues that do NOT have overrides
813        let completed_retention = format!("{} seconds", self.completed_retention.as_secs());
814        let failed_retention = format!("{} seconds", self.failed_retention.as_secs());
815
816        let global_result = if override_queues.is_empty() {
817            sqlx::query(
818                r#"
819                DELETE FROM awa.jobs_hot
820                WHERE id IN (
821                    SELECT id FROM awa.jobs_hot
822                    WHERE (state = 'completed' AND finalized_at < now() - $1::interval)
823                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval)
824                    LIMIT $3
825                )
826                "#,
827            )
828            .bind(&completed_retention)
829            .bind(&failed_retention)
830            .bind(self.cleanup_batch_size)
831            .execute(&self.pool)
832            .await
833        } else {
834            sqlx::query(
835                r#"
836                DELETE FROM awa.jobs_hot
837                WHERE id IN (
838                    SELECT id FROM awa.jobs_hot
839                    WHERE ((state = 'completed' AND finalized_at < now() - $1::interval)
840                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval))
841                      AND queue != ALL($4::text[])
842                    LIMIT $3
843                )
844                "#,
845            )
846            .bind(&completed_retention)
847            .bind(&failed_retention)
848            .bind(self.cleanup_batch_size)
849            .bind(&override_queues)
850            .execute(&self.pool)
851            .await
852        };
853
854        match global_result {
855            Ok(result) if result.rows_affected() > 0 => {
856                total_deleted += result.rows_affected();
857            }
858            Err(err) => {
859                error!(error = %err, "Failed to clean up old jobs (global pass)");
860            }
861            _ => {}
862        }
863
864        // Per-queue override passes
865        for (queue_name, policy) in &self.queue_retention_overrides {
866            let queue_completed = format!("{} seconds", policy.completed.as_secs());
867            let queue_failed = format!("{} seconds", policy.failed.as_secs());
868
869            match sqlx::query(
870                r#"
871                DELETE FROM awa.jobs_hot
872                WHERE id IN (
873                    SELECT id FROM awa.jobs_hot
874                    WHERE queue = $4
875                      AND ((state = 'completed' AND finalized_at < now() - $1::interval)
876                        OR (state IN ('failed', 'cancelled') AND finalized_at < now() - $2::interval))
877                    LIMIT $3
878                )
879                "#,
880            )
881            .bind(&queue_completed)
882            .bind(&queue_failed)
883            .bind(self.cleanup_batch_size)
884            .bind(queue_name)
885            .execute(&self.pool)
886            .await
887            {
888                Ok(result) if result.rows_affected() > 0 => {
889                    total_deleted += result.rows_affected();
890                    debug!(
891                        queue = %queue_name,
892                        count = result.rows_affected(),
893                        "Cleaned up old jobs (queue override)"
894                    );
895                }
896                Err(err) => {
897                    error!(
898                        queue = %queue_name,
899                        error = %err,
900                        "Failed to clean up old jobs (queue override)"
901                    );
902                }
903                _ => {}
904            }
905        }
906
907        if total_deleted > 0 {
908            info!(count = total_deleted, "Cleaned up old jobs");
909        }
910    }
911}
912
913struct MaintenanceAliveGuard(Arc<AtomicBool>);
914
915impl Drop for MaintenanceAliveGuard {
916    fn drop(&mut self) {
917        self.0.store(false, Ordering::SeqCst);
918    }
919}
920
921/// Compute the latest fire time for a cron job row, using its expression and timezone.
922///
923/// Returns `None` if no fire is due (next occurrence is in the future).
924fn compute_fire_time(
925    row: &CronJobRow,
926    now: chrono::DateTime<Utc>,
927) -> Option<chrono::DateTime<Utc>> {
928    let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
929        Ok(c) => c,
930        Err(err) => {
931            error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
932            return None;
933        }
934    };
935
936    let tz: chrono_tz::Tz = match row.timezone.parse() {
937        Ok(tz) => tz,
938        Err(err) => {
939            error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
940            return None;
941        }
942    };
943
944    let search_start = match row.last_enqueued_at {
945        Some(last) => last.with_timezone(&tz),
946        // First registration: search from one interval before created_at
947        // so that the current minute's fire is found. Without this,
948        // a schedule created at HH:MM:30 won't find the HH:MM:00 fire
949        // because created_at > fire_time, causing up to 60s delay.
950        None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
951    };
952
953    let mut latest_fire: Option<chrono::DateTime<Utc>> = None;
954
955    for fire_time in cron.iter_from(search_start) {
956        let fire_utc = fire_time.with_timezone(&Utc);
957
958        if fire_utc > now {
959            break;
960        }
961
962        if let Some(last) = row.last_enqueued_at {
963            if fire_utc <= last {
964                continue;
965            }
966        }
967
968        latest_fire = Some(fire_utc);
969    }
970
971    latest_fire
972}
973
974impl MaintenanceService {
975    /// Clean up runtime snapshots older than 24 hours.
976    /// Runs as part of the leader's cleanup cycle (not on every snapshot publish).
977    #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
978    async fn cleanup_stale_runtime_snapshots(&self) {
979        if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
980            &self.pool,
981            chrono::TimeDelta::try_hours(24).unwrap(),
982        )
983        .await
984        {
985            tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
986        }
987    }
988
989    /// Drain dirty keys and recompute exact cached rows for recently-touched
990    /// queues and kinds. This is the primary cache update mechanism — called
991    /// every ~2s to keep dashboard counters fresh.
992    #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
993    async fn recompute_dirty_admin_metadata(&self) {
994        match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
995            Ok(count) if count > 0 => {
996                tracing::debug!(count, "Recomputed dirty admin metadata keys");
997            }
998            Err(err) => {
999                tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
1000            }
1001            _ => {}
1002        }
1003    }
1004
1005    /// Full reconciliation of admin metadata from base tables.
1006    /// Safety net for any drift — runs infrequently (~60s).
1007    #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
1008    async fn refresh_admin_metadata(&self) {
1009        if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
1010            tracing::warn!(error = %err, "Failed to refresh admin metadata");
1011        }
1012    }
1013
1014    /// Publish queue depth and lag as OTel gauge metrics.
1015    #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
1016    async fn publish_queue_health_metrics(&self) {
1017        let stats = match awa_model::admin::queue_stats(&self.pool).await {
1018            Ok(stats) => stats,
1019            Err(err) => {
1020                tracing::warn!(error = %err, "Failed to query queue stats for metrics");
1021                return;
1022            }
1023        };
1024
1025        for queue_stat in &stats {
1026            let queue = &queue_stat.queue;
1027
1028            // Depth per state
1029            self.metrics
1030                .record_queue_depth(queue, "available", queue_stat.available);
1031            self.metrics
1032                .record_queue_depth(queue, "running", queue_stat.running);
1033            self.metrics
1034                .record_queue_depth(queue, "failed", queue_stat.failed);
1035            self.metrics
1036                .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
1037            self.metrics
1038                .record_queue_depth(queue, "retryable", queue_stat.retryable);
1039            self.metrics
1040                .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
1041
1042            // Lag
1043            if let Some(lag_seconds) = queue_stat.lag_seconds {
1044                self.metrics.record_queue_lag(queue, lag_seconds);
1045            }
1046        }
1047    }
1048}