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