Skip to main content

awa_worker/
maintenance.rs

1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::cron::{atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow};
5use awa_model::{JobRow, JobState, PeriodicJob, PruneOutcome, RotateOutcome};
6use chrono::Utc;
7use croner::Cron;
8use sqlx::pool::PoolConnection;
9use sqlx::{PgPool, Postgres};
10use std::collections::{HashMap, HashSet};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio_util::sync::CancellationToken;
15use tracing::{debug, error, info, warn};
16
17/// Per-queue or global retention policy for completed and failed/cancelled jobs.
18#[derive(Debug, Clone)]
19pub struct RetentionPolicy {
20    /// How long to keep completed jobs before cleanup.
21    pub completed: Duration,
22    /// How long to keep failed/cancelled jobs before cleanup.
23    pub failed: Duration,
24    /// How long to keep DLQ rows before cleanup.
25    pub dlq: Option<Duration>,
26}
27
28impl Default for RetentionPolicy {
29    fn default() -> Self {
30        Self {
31            completed: Duration::from_secs(86400), // 24h
32            failed: Duration::from_secs(259200),   // 72h
33            dlq: None,
34        }
35    }
36}
37
38/// Maintenance service: runs leader-elected background tasks.
39///
40/// Tasks: heartbeat rescue, deadline rescue, scheduled promotion, cleanup,
41/// periodic job sync and evaluation.
42pub struct MaintenanceService {
43    pool: PgPool,
44    metrics: crate::metrics::AwaMetrics,
45    cancel: CancellationToken,
46    leader: Arc<AtomicBool>,
47    alive: Arc<AtomicBool>,
48    periodic_jobs: Arc<Vec<PeriodicJob>>,
49    /// In-flight job cancellation flags — used to signal deadline/heartbeat rescue
50    /// to running handlers on this worker instance.
51    in_flight: InFlightMap,
52    storage: RuntimeStorage,
53    heartbeat_rescue_interval: Duration,
54    deadline_rescue_interval: Duration,
55    callback_rescue_interval: Duration,
56    promote_interval: Duration,
57    cleanup_interval: Duration,
58    cron_sync_interval: Duration,
59    cron_eval_interval: Duration,
60    leader_check_interval: Duration,
61    leader_election_interval: Duration,
62    heartbeat_staleness: Duration,
63    completed_retention: Duration,
64    failed_retention: Duration,
65    cleanup_batch_size: i64,
66    queue_retention_overrides: HashMap<String, RetentionPolicy>,
67    queue_stats_interval: Duration,
68    dlq_retention: Duration,
69    dlq_cleanup_batch_size: i64,
70    dlq_policy: DlqPolicy,
71    dirty_key_recompute_interval: Duration,
72    metadata_reconciliation_interval: Duration,
73    /// Interval for priority aging — jobs waiting longer than this have their
74    /// priority improved by one level per interval elapsed (default: 60s).
75    priority_aging_interval: Duration,
76    /// How long a descriptor catalog row can sit without being refreshed
77    /// before the maintenance leader deletes it. Zero disables cleanup.
78    /// Default: 30 days.
79    descriptor_retention: Duration,
80}
81
82const PROMOTE_BATCH_SIZE: i64 = 4_096;
83const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
84type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
85
86impl MaintenanceService {
87    #[allow(clippy::too_many_arguments)]
88    pub(crate) fn new(
89        pool: PgPool,
90        metrics: crate::metrics::AwaMetrics,
91        leader: Arc<AtomicBool>,
92        alive: Arc<AtomicBool>,
93        cancel: CancellationToken,
94        periodic_jobs: Arc<Vec<PeriodicJob>>,
95        in_flight: InFlightMap,
96        storage: RuntimeStorage,
97    ) -> Self {
98        Self {
99            pool,
100            metrics,
101            cancel,
102            leader,
103            alive,
104            periodic_jobs,
105            in_flight,
106            storage,
107            heartbeat_rescue_interval: Duration::from_secs(30),
108            deadline_rescue_interval: Duration::from_secs(30),
109            callback_rescue_interval: Duration::from_secs(30),
110            promote_interval: Duration::from_millis(250),
111            cleanup_interval: Duration::from_secs(60),
112            cron_sync_interval: Duration::from_secs(60),
113            cron_eval_interval: Duration::from_secs(1),
114            leader_check_interval: Duration::from_secs(30),
115            leader_election_interval: Duration::from_secs(10),
116            heartbeat_staleness: Duration::from_secs(90),
117            completed_retention: Duration::from_secs(86400), // 24h
118            failed_retention: Duration::from_secs(259200),   // 72h
119            cleanup_batch_size: 1000,
120            queue_retention_overrides: HashMap::new(),
121            queue_stats_interval: Duration::from_secs(30),
122            dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
123            dlq_cleanup_batch_size: 1000,
124            dlq_policy: DlqPolicy::default(),
125            dirty_key_recompute_interval: Duration::from_secs(2),
126            metadata_reconciliation_interval: Duration::from_secs(60),
127            priority_aging_interval: Duration::from_secs(60),
128            descriptor_retention: Duration::from_secs(30 * 86400), // 30d
129        }
130    }
131
132    /// Set the priority aging interval (default: 60s).
133    ///
134    /// Jobs waiting longer than this per priority level are promoted:
135    /// a priority-4 job waiting 180s is treated as priority-1.
136    pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
137        self.priority_aging_interval = interval;
138        self
139    }
140
141    /// How long a descriptor catalog row can go without being re-synced
142    /// before the maintenance leader deletes it (default: 30 days). Set
143    /// to `Duration::ZERO` to disable — useful if you maintain the catalog
144    /// externally or want to keep historical descriptors forever.
145    ///
146    /// Descriptors carry no FK from jobs, so deletion is safe: a later
147    /// worker restart that re-declares the same queue or kind will
148    /// recreate the row from its declaration on the next snapshot tick.
149    pub fn descriptor_retention(mut self, retention: Duration) -> Self {
150        self.descriptor_retention = retention;
151        self
152    }
153
154    /// Set the leader election retry interval (default: 10s).
155    ///
156    /// Controls how often a non-leader instance retries acquiring the
157    /// advisory lock. Lower values speed up leader election in tests.
158    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
159        self.leader_election_interval = interval;
160        self
161    }
162
163    /// Set the leader connection health-check interval (default: 30s).
164    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
165        self.leader_check_interval = interval;
166        self
167    }
168
169    /// Set the promotion interval for scheduled/retryable jobs.
170    pub fn promote_interval(mut self, interval: Duration) -> Self {
171        self.promote_interval = interval;
172        self
173    }
174
175    /// Set the stale-heartbeat rescue interval (default: 30s).
176    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
177        self.heartbeat_rescue_interval = interval;
178        self
179    }
180
181    /// Set the deadline rescue interval (default: 30s).
182    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
183        self.deadline_rescue_interval = interval;
184        self
185    }
186
187    /// Set the callback-timeout rescue interval (default: 30s).
188    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
189        self.callback_rescue_interval = interval;
190        self
191    }
192
193    /// Set how long a heartbeat must be stale before the job is rescued (default: 90s).
194    ///
195    /// Should be at least 3× the heartbeat interval to avoid false rescues
196    /// from transient delays. The run-lease guard prevents duplicate completions
197    /// even if a false rescue occurs, but wasted work is still undesirable.
198    pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
199        self.heartbeat_staleness = staleness;
200        self
201    }
202
203    /// Set the cleanup interval (default: 60s).
204    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
205        self.cleanup_interval = interval;
206        self
207    }
208
209    /// Set retention for completed jobs (default: 24h).
210    pub fn completed_retention(mut self, retention: Duration) -> Self {
211        self.completed_retention = retention;
212        self
213    }
214
215    /// Set retention for failed/cancelled jobs (default: 72h).
216    pub fn failed_retention(mut self, retention: Duration) -> Self {
217        self.failed_retention = retention;
218        self
219    }
220
221    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
222    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
223        self.cleanup_batch_size = batch_size;
224        self
225    }
226
227    /// Set the interval for publishing queue depth/lag metrics (default: 30s).
228    pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
229        self.queue_stats_interval = interval;
230        self
231    }
232
233    /// Set retention for DLQ rows (default: 30 days).
234    pub fn dlq_retention(mut self, retention: Duration) -> Self {
235        self.dlq_retention = retention;
236        self
237    }
238
239    /// Set the maximum number of DLQ rows deleted per cleanup pass (default: 1000).
240    pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
241        self.dlq_cleanup_batch_size = batch_size;
242        self
243    }
244
245    /// Set the per-queue DLQ policy.
246    pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
247        self.dlq_policy = policy;
248        self
249    }
250
251    /// Set per-queue retention overrides.
252    pub fn queue_retention_overrides(
253        mut self,
254        overrides: HashMap<String, RetentionPolicy>,
255    ) -> Self {
256        self.queue_retention_overrides = overrides;
257        self
258    }
259
260    /// Run the maintenance loop. Attempts leader election first.
261    pub async fn run(&self) {
262        info!("Maintenance service starting");
263        self.alive.store(true, Ordering::SeqCst);
264        let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
265        self.leader.store(false, Ordering::SeqCst);
266
267        loop {
268            // Try to acquire advisory lock for leader election.
269            // We get back a dedicated connection that holds the lock.
270            let mut leader_conn = match self.try_become_leader().await {
271                Ok(Some(conn)) => conn,
272                Ok(None) => {
273                    // Not leader — back off and try again
274                    tokio::select! {
275                        _ = self.cancel.cancelled() => {
276                            debug!("Maintenance service shutting down (not leader)");
277                            self.leader.store(false, Ordering::SeqCst);
278                            return;
279                        }
280                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
281                    }
282                }
283                Err(err) => {
284                    warn!(error = %err, "Failed to check leader status");
285                    tokio::select! {
286                        _ = self.cancel.cancelled() => {
287                            debug!("Maintenance service shutting down (leader check failed)");
288                            self.leader.store(false, Ordering::SeqCst);
289                            return;
290                        }
291                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
292                    }
293                }
294            };
295
296            debug!("Elected as maintenance leader");
297            self.leader.store(true, Ordering::SeqCst);
298
299            // Run maintenance tasks as leader
300            let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
301            let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
302            let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
303            let mut promote_timer = tokio::time::interval(self.promote_interval);
304            let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
305            let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
306            let mut cron_eval_timer = tokio::time::interval(self.cron_eval_interval);
307            let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
308            let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
309            let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
310            let mut metadata_reconciliation_timer =
311                tokio::time::interval(self.metadata_reconciliation_interval);
312            let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
313            let mut vacuum_queue_timer = self
314                .storage
315                .queue_storage()
316                .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
317            let mut vacuum_lease_timer = self
318                .storage
319                .queue_storage()
320                .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
321            let mut vacuum_claim_timer = self
322                .storage
323                .queue_storage()
324                .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
325
326            // Skip the first immediate tick
327            heartbeat_rescue_timer.tick().await;
328            deadline_rescue_timer.tick().await;
329            callback_rescue_timer.tick().await;
330            promote_timer.tick().await;
331            cleanup_timer.tick().await;
332            cron_sync_timer.tick().await;
333            cron_eval_timer.tick().await;
334            leader_check_timer.tick().await;
335            queue_stats_timer.tick().await;
336            dirty_key_timer.tick().await;
337            metadata_reconciliation_timer.tick().await;
338            priority_aging_timer.tick().await;
339            if let Some(timer) = &mut vacuum_queue_timer {
340                timer.tick().await;
341            }
342            if let Some(timer) = &mut vacuum_lease_timer {
343                timer.tick().await;
344            }
345            if let Some(timer) = &mut vacuum_claim_timer {
346                timer.tick().await;
347            }
348
349            // Do an initial sync immediately on becoming leader
350            self.sync_periodic_jobs_to_db().await;
351
352            loop {
353                tokio::select! {
354                    _ = self.cancel.cancelled() => {
355                        debug!("Maintenance service shutting down");
356                        self.leader.store(false, Ordering::SeqCst);
357                        // Release leader lock on the same connection that acquired it.
358                        // If this fails, dropping the connection will release the lock anyway.
359                        let _ = Self::release_leader(&mut leader_conn).await;
360                        return;
361                    }
362                    _ = heartbeat_rescue_timer.tick() => {
363                        self.rescue_stale_heartbeats().await;
364                    }
365                    _ = deadline_rescue_timer.tick() => {
366                        self.rescue_expired_deadlines().await;
367                    }
368                    _ = callback_rescue_timer.tick() => {
369                        self.rescue_expired_callbacks().await;
370                    }
371                    _ = promote_timer.tick() => {
372                        self.promote_scheduled().await;
373                    }
374                    _ = cleanup_timer.tick() => {
375                        self.cleanup_completed().await;
376                        self.cleanup_dlq_rows().await;
377                        self.cleanup_stale_runtime_snapshots().await;
378                        self.cleanup_stale_descriptors().await;
379                    }
380                    _ = cron_sync_timer.tick() => {
381                        self.sync_periodic_jobs_to_db().await;
382                    }
383                    _ = cron_eval_timer.tick() => {
384                        self.evaluate_cron_schedules().await;
385                    }
386                    _ = queue_stats_timer.tick() => {
387                        self.publish_queue_health_metrics().await;
388                    }
389                    _ = dirty_key_timer.tick() => {
390                        self.recompute_dirty_admin_metadata().await;
391                    }
392                    _ = metadata_reconciliation_timer.tick() => {
393                        self.refresh_admin_metadata().await;
394                    }
395                    _ = priority_aging_timer.tick() => {
396                        self.age_waiting_priorities().await;
397                    }
398                    _ = async {
399                        if let Some(timer) = &mut vacuum_queue_timer {
400                            timer.tick().await;
401                        } else {
402                            std::future::pending::<()>().await;
403                        }
404                    }, if vacuum_queue_timer.is_some() => {
405                        self.rotate_queue_storage_queue().await;
406                    }
407                    _ = async {
408                        if let Some(timer) = &mut vacuum_lease_timer {
409                            timer.tick().await;
410                        } else {
411                            std::future::pending::<()>().await;
412                        }
413                    }, if vacuum_lease_timer.is_some() => {
414                        self.rotate_queue_storage_leases().await;
415                    }
416                    _ = async {
417                        if let Some(timer) = &mut vacuum_claim_timer {
418                            timer.tick().await;
419                        } else {
420                            std::future::pending::<()>().await;
421                        }
422                    }, if vacuum_claim_timer.is_some() => {
423                        self.rotate_queue_storage_claims().await;
424                    }
425                    _ = leader_check_timer.tick() => {
426                        // Verify leader connection is still alive.
427                        // The advisory lock is session-scoped: if the connection is alive,
428                        // the lock is held. If the query fails, the connection (and lock) are gone.
429                        if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
430                            warn!("Leader connection lost, re-entering election loop");
431                            self.leader.store(false, Ordering::SeqCst);
432                            break;
433                        }
434                    }
435                }
436            }
437        }
438    }
439
440    /// Advisory lock key for Awa maintenance leader election.
441    const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; // "AWA_MAIN" in hex-ish
442
443    /// Try to acquire the advisory lock for leader election.
444    ///
445    /// Returns a dedicated connection holding the lock on success, or `None` if
446    /// another instance already holds the lock. The lock is session-scoped in
447    /// PostgreSQL, so it stays held as long as this connection is alive.
448    async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
449        let mut conn = self.pool.acquire().await?;
450        let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
451            .bind(Self::LOCK_KEY)
452            .fetch_one(&mut *conn)
453            .await?;
454        if result.0 {
455            Ok(Some(conn))
456        } else {
457            Ok(None)
458        }
459    }
460
461    /// Release the advisory lock on the same connection that acquired it.
462    ///
463    /// Dropping the connection also releases the lock (PG session-scoped behavior),
464    /// so this is a best-effort explicit release.
465    async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
466        sqlx::query("SELECT pg_advisory_unlock($1)")
467            .bind(Self::LOCK_KEY)
468            .execute(&mut **conn)
469            .await?;
470        Ok(())
471    }
472
473    /// Sync all registered periodic job schedules to `awa.cron_jobs` via UPSERT.
474    ///
475    /// Additive only — does NOT delete schedules not in the local set (multi-deployment safe).
476    #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
477    async fn sync_periodic_jobs_to_db(&self) {
478        if self.periodic_jobs.is_empty() {
479            return;
480        }
481
482        for job in self.periodic_jobs.iter() {
483            if let Err(err) = upsert_cron_job(&self.pool, job).await {
484                error!(name = %job.name, error = %err, "Failed to sync periodic job");
485            }
486        }
487
488        debug!(
489            count = self.periodic_jobs.len(),
490            "Synced periodic jobs to database"
491        );
492    }
493
494    /// Evaluate all cron schedules and enqueue any that are due.
495    ///
496    /// For each schedule, computes the latest fire time ≤ now that is after
497    /// `last_enqueued_at`. If a fire is due, executes the atomic CTE to
498    /// mark + insert in one statement.
499    #[tracing::instrument(skip(self), name = "maintenance.cron_eval")]
500    async fn evaluate_cron_schedules(&self) {
501        let cron_rows = match list_cron_jobs(&self.pool).await {
502            Ok(rows) => rows,
503            Err(err) => {
504                error!(error = %err, "Failed to load cron jobs for evaluation");
505                return;
506            }
507        };
508
509        if cron_rows.is_empty() {
510            return;
511        }
512
513        let now = Utc::now();
514
515        for row in &cron_rows {
516            let fire_time = match compute_fire_time(row, now) {
517                Some(time) => time,
518                None => continue,
519            };
520
521            match atomic_enqueue(&self.pool, &row.name, fire_time, row.last_enqueued_at).await {
522                Ok(Some(job)) => {
523                    info!(
524                        cron_name = %row.name,
525                        job_id = job.id,
526                        fire_time = %fire_time,
527                        "Enqueued periodic job"
528                    );
529                }
530                Ok(None) => {
531                    // Another leader already claimed this fire — not an error
532                    debug!(cron_name = %row.name, "Cron fire already claimed");
533                }
534                Err(err) => {
535                    error!(
536                        cron_name = %row.name,
537                        error = %err,
538                        "Failed to enqueue periodic job"
539                    );
540                }
541            }
542        }
543    }
544
545    /// Rescue jobs with stale heartbeats (crash detection).
546    #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
547    async fn rescue_stale_heartbeats(&self) {
548        let outcome = match &self.storage {
549            RuntimeStorage::Canonical => {
550                let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
551                sqlx::query_as::<_, JobRow>(
552                    r#"
553                    UPDATE awa.jobs
554                    SET state = 'retryable',
555                        finalized_at = now(),
556                        heartbeat_at = NULL,
557                        deadline_at = NULL,
558                        callback_id = NULL,
559                        callback_timeout_at = NULL,
560                        callback_filter = NULL,
561                        callback_on_complete = NULL,
562                        callback_on_fail = NULL,
563                        callback_transform = NULL,
564                        errors = errors || jsonb_build_object(
565                            'error', 'heartbeat stale: worker presumed dead',
566                            'attempt', attempt,
567                            'at', now()
568                        )::jsonb
569                    WHERE id IN (
570                        SELECT id FROM awa.jobs_hot
571                        WHERE state = 'running'
572                          AND heartbeat_at < now() - ($1 * interval '1 millisecond')
573                        LIMIT 500
574                        FOR UPDATE SKIP LOCKED
575                    )
576                    RETURNING *
577                    "#,
578                )
579                .bind(staleness_ms)
580                .fetch_all(&self.pool)
581                .await
582                .map_err(awa_model::AwaError::Database)
583            }
584            RuntimeStorage::QueueStorage(runtime) => {
585                runtime
586                    .store
587                    .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
588                    .await
589            }
590        };
591        match outcome {
592            Ok(rescued) if !rescued.is_empty() => {
593                self.metrics.maintenance_rescues.add(
594                    rescued.len() as u64,
595                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
596                );
597                warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
598                // Signal cancellation to any rescued jobs still running on this instance
599                self.signal_cancellation(&rescued).await;
600            }
601            Err(err) => {
602                error!(error = %err, "Failed to rescue stale heartbeat jobs");
603            }
604            _ => {}
605        }
606    }
607
608    /// Rescue jobs that exceeded their hard deadline.
609    #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
610    async fn rescue_expired_deadlines(&self) {
611        let outcome = match &self.storage {
612            RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
613                r#"
614                UPDATE awa.jobs
615                SET state = 'retryable',
616                    finalized_at = now(),
617                    heartbeat_at = NULL,
618                    deadline_at = NULL,
619                    callback_id = NULL,
620                    callback_timeout_at = NULL,
621                    callback_filter = NULL,
622                    callback_on_complete = NULL,
623                    callback_on_fail = NULL,
624                    callback_transform = NULL,
625                    errors = errors || jsonb_build_object(
626                        'error', 'hard deadline exceeded',
627                        'attempt', attempt,
628                        'at', now()
629                    )::jsonb
630                WHERE id IN (
631                    SELECT id FROM awa.jobs_hot
632                    WHERE state = 'running'
633                      AND deadline_at IS NOT NULL
634                      AND deadline_at < now()
635                    LIMIT 500
636                    FOR UPDATE SKIP LOCKED
637                )
638                RETURNING *
639                "#,
640            )
641            .fetch_all(&self.pool)
642            .await
643            .map_err(awa_model::AwaError::Database),
644            RuntimeStorage::QueueStorage(runtime) => {
645                runtime.store.rescue_expired_deadlines(&self.pool).await
646            }
647        };
648        match outcome {
649            Ok(rescued) if !rescued.is_empty() => {
650                self.metrics.maintenance_rescues.add(
651                    rescued.len() as u64,
652                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
653                );
654                warn!(count = rescued.len(), "Rescued deadline-expired jobs");
655                // Signal cancellation so handlers see ctx.is_cancelled() == true
656                self.signal_cancellation(&rescued).await;
657            }
658            Err(err) => {
659                error!(error = %err, "Failed to rescue deadline-expired jobs");
660            }
661            _ => {}
662        }
663    }
664
665    /// Rescue jobs whose callback timeout has expired.
666    #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
667    async fn rescue_expired_callbacks(&self) {
668        let outcome = match &self.storage {
669            RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
670                r#"
671                UPDATE awa.jobs
672                SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
673                    finalized_at = now(),
674                    callback_id = NULL,
675                    callback_timeout_at = NULL,
676                    callback_filter = NULL,
677                    callback_on_complete = NULL,
678                    callback_on_fail = NULL,
679                    callback_transform = NULL,
680                    run_at = CASE WHEN attempt >= max_attempts THEN run_at
681                             ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
682                    errors = errors || jsonb_build_object(
683                        'error', 'callback timed out',
684                        'attempt', attempt,
685                        'at', now()
686                    )::jsonb
687                WHERE id IN (
688                    SELECT id FROM awa.jobs_hot
689                    WHERE state = 'waiting_external'
690                      AND callback_timeout_at IS NOT NULL
691                      AND callback_timeout_at < now()
692                    LIMIT 500
693                    FOR UPDATE SKIP LOCKED
694                )
695                RETURNING *
696                "#,
697            )
698            .fetch_all(&self.pool)
699            .await
700            .map_err(awa_model::AwaError::Database),
701            RuntimeStorage::QueueStorage(runtime) => {
702                runtime.store.rescue_expired_callbacks(&self.pool).await
703            }
704        };
705        match outcome {
706            Ok(rescued) if !rescued.is_empty() => {
707                self.metrics.maintenance_rescues.add(
708                    rescued.len() as u64,
709                    &[opentelemetry::KeyValue::new(
710                        "awa.rescue.kind",
711                        "callback_timeout",
712                    )],
713                );
714                warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
715                if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
716                    for job in &rescued {
717                        if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
718                        {
719                            continue;
720                        }
721                        match runtime
722                            .store
723                            .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
724                            .await
725                        {
726                            Ok(Some(_)) => {
727                                self.metrics.record_dlq_moved(
728                                    &job.kind,
729                                    &job.queue,
730                                    "callback_timeout",
731                                );
732                            }
733                            Ok(None) => {}
734                            Err(err) => {
735                                error!(
736                                    job_id = job.id,
737                                    error = %err,
738                                    "Failed to move rescued callback timeout into DLQ"
739                                );
740                            }
741                        }
742                    }
743                }
744            }
745            Err(err) => {
746                error!(error = %err, "Failed to rescue callback-timed-out jobs");
747            }
748            _ => {}
749        }
750    }
751
752    /// Age priorities for jobs that have been waiting longer than `priority_aging_interval`.
753    ///
754    /// Decrements `priority` by 1 per pass for available jobs waiting longer than
755    /// the aging interval (minimum priority 1). On the first age, stores the
756    /// original priority in `metadata._awa_original_priority` so the API can
757    /// report it accurately.
758    #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
759    async fn age_waiting_priorities(&self) {
760        let aging_secs = self.priority_aging_interval.as_secs_f64();
761        if aging_secs <= 0.0 {
762            return;
763        }
764        if let Some(runtime) = self.storage.queue_storage() {
765            debug!(
766                schema = %runtime.store.schema(),
767                "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
768            );
769            return;
770        }
771
772        match sqlx::query_scalar::<_, i64>(
773            r#"
774            WITH eligible AS (
775                SELECT id FROM awa.jobs_hot
776                WHERE state = 'available'
777                  AND priority > 1
778                  AND run_at <= now() - make_interval(secs => $1)
779                LIMIT 1000
780                FOR UPDATE SKIP LOCKED
781            )
782            UPDATE awa.jobs_hot
783            SET priority = priority - 1,
784                metadata = CASE
785                    WHEN NOT (metadata ? '_awa_original_priority')
786                    THEN metadata || jsonb_build_object('_awa_original_priority', priority)
787                    ELSE metadata
788                END
789            FROM eligible
790            WHERE awa.jobs_hot.id = eligible.id
791            RETURNING awa.jobs_hot.id
792            "#,
793        )
794        .bind(aging_secs)
795        .fetch_all(&self.pool)
796        .await
797        {
798            Ok(ids) if !ids.is_empty() => {
799                debug!(count = ids.len(), "Aged job priorities");
800            }
801            Err(err) => {
802                error!(error = %err, "Failed to age job priorities");
803            }
804            _ => {}
805        }
806    }
807
808    /// Signal cancellation to any rescued jobs that are still running on this instance.
809    async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
810        for job in rescued_jobs {
811            if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
812                flag.store(true, Ordering::SeqCst);
813                debug!(job_id = job.id, "Signalled cancellation for rescued job");
814            }
815        }
816    }
817
818    /// Promote scheduled jobs that are now due.
819    #[tracing::instrument(skip(self), name = "maintenance.promote")]
820    async fn promote_scheduled(&self) {
821        if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
822            error!(error = %err, "Failed to promote scheduled jobs");
823        }
824        if let Err(err) = self
825            .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
826            .await
827        {
828            error!(error = %err, "Failed to promote retryable jobs");
829        }
830    }
831
832    async fn promote_due_state(
833        &self,
834        state: &'static str,
835        label: &'static str,
836    ) -> Result<(), awa_model::AwaError> {
837        let mut promoted_total = 0usize;
838        let mut notified_queues = HashSet::new();
839
840        for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
841            if self.cancel.is_cancelled() {
842                break;
843            }
844
845            match &self.storage {
846                RuntimeStorage::Canonical => {
847                    let (promoted, queues) = self
848                        .promote_due_batch(state)
849                        .await
850                        .map_err(awa_model::AwaError::Database)?;
851                    if promoted == 0 {
852                        break;
853                    }
854
855                    promoted_total += promoted;
856                    notified_queues.extend(queues);
857
858                    if promoted < PROMOTE_BATCH_SIZE as usize {
859                        break;
860                    }
861                }
862                RuntimeStorage::QueueStorage(runtime) => {
863                    let job_state = match state {
864                        "scheduled" => awa_model::JobState::Scheduled,
865                        "retryable" => awa_model::JobState::Retryable,
866                        other => {
867                            return Err(awa_model::AwaError::Validation(format!(
868                                "unsupported queue storage promote state: {other}"
869                            )));
870                        }
871                    };
872                    let promote_start = std::time::Instant::now();
873                    let promoted = runtime
874                        .store
875                        .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
876                        .await?;
877                    self.metrics.record_promotion_batch(
878                        state,
879                        promoted as u64,
880                        promote_start.elapsed(),
881                    );
882                    if promoted == 0 {
883                        break;
884                    }
885
886                    promoted_total += promoted;
887
888                    if promoted < PROMOTE_BATCH_SIZE as usize {
889                        break;
890                    }
891                }
892            }
893        }
894
895        if promoted_total > 0 {
896            debug!(
897                count = promoted_total,
898                queues = notified_queues.len(),
899                state,
900                "Promoted {label}"
901            );
902        }
903
904        Ok(())
905    }
906
907    /// SQL template for promotion. The state literal is injected directly
908    /// (not as a parameter) so the planner can match the partial index on
909    /// `(run_at, id) WHERE state = '<state>'`. With a parameter, the planner
910    /// cannot prove the partial index applies and falls back to a full
911    /// bitmap scan on multi-million-row tables.
912    fn promote_sql(state: &'static str) -> String {
913        format!(
914            r#"
915            WITH due AS (
916                DELETE FROM awa.scheduled_jobs
917                WHERE id IN (
918                    SELECT id
919                    FROM awa.scheduled_jobs
920                    WHERE state = '{state}'::awa.job_state
921                      AND run_at <= now()
922                    ORDER BY run_at ASC, id ASC
923                    LIMIT $1
924                    FOR UPDATE SKIP LOCKED
925                )
926                RETURNING *
927            ),
928            promoted AS (
929                INSERT INTO awa.jobs_hot (
930                    id, kind, queue, args, state, priority, attempt, max_attempts,
931                    run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
932                    created_at, errors, metadata, tags, unique_key, unique_states,
933                    callback_id, callback_timeout_at, callback_filter, callback_on_complete,
934                    callback_on_fail, callback_transform, run_lease, progress
935                )
936                SELECT
937                    id,
938                    kind,
939                    queue,
940                    args,
941                    'available'::awa.job_state,
942                    priority,
943                    attempt,
944                    max_attempts,
945                    now(),
946                    NULL,
947                    NULL,
948                    attempted_at,
949                    finalized_at,
950                    created_at,
951                    errors,
952                    metadata,
953                    tags,
954                    unique_key,
955                    unique_states,
956                    NULL,
957                    NULL,
958                    NULL,
959                    NULL,
960                    NULL,
961                    NULL,
962                    run_lease,
963                    progress
964                FROM due
965                RETURNING queue
966            )
967            SELECT queue FROM promoted
968            "#
969        )
970    }
971
972    async fn promote_due_batch(
973        &self,
974        state: &'static str,
975    ) -> Result<(usize, HashSet<String>), sqlx::Error> {
976        let mut tx = self.pool.begin().await?;
977        let promote_start = std::time::Instant::now();
978        let sql = Self::promote_sql(state);
979        let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
980            .bind(PROMOTE_BATCH_SIZE)
981            .fetch_all(&mut *tx)
982            .await?;
983
984        let promoted = promoted_rows.len();
985        self.metrics
986            .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
987        if promoted == 0 {
988            tx.commit().await?;
989            return Ok((0, HashSet::new()));
990        }
991
992        let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
993
994        tx.commit().await?;
995        Ok((promoted, queues))
996    }
997
998    async fn rotate_queue_storage_queue(&self) {
999        let Some(runtime) = self.storage.queue_storage() else {
1000            return;
1001        };
1002
1003        match runtime.store.rotate(&self.pool).await {
1004            Ok(outcome) => {
1005                self.metrics.record_rotate_outcome("queue", &outcome);
1006                match outcome {
1007                    RotateOutcome::Rotated { slot, generation } => {
1008                        debug!(slot, generation, "Rotated queue storage queue segment");
1009                    }
1010                    RotateOutcome::SkippedBusy { slot, busy } => {
1011                        debug!(
1012                            slot,
1013                            ready_rows = busy.queue_ready,
1014                            done_rows = busy.queue_done,
1015                            "Skipped busy queue storage queue segment",
1016                        );
1017                    }
1018                }
1019            }
1020            Err(err) => {
1021                error!(error = %err, "Failed to rotate queue storage queue segments");
1022                return;
1023            }
1024        }
1025
1026        match runtime.store.prune_oldest(&self.pool).await {
1027            Ok(outcome) => {
1028                self.metrics.record_prune_outcome("queue", &outcome);
1029                match outcome {
1030                    PruneOutcome::Noop => {}
1031                    PruneOutcome::Pruned { slot } => {
1032                        debug!(slot, "Pruned queue storage queue segment");
1033                    }
1034                    PruneOutcome::Blocked { slot } => {
1035                        debug!(slot, "Queue storage queue segment prune blocked");
1036                    }
1037                    PruneOutcome::SkippedActive {
1038                        slot,
1039                        reason,
1040                        count,
1041                    } => {
1042                        debug!(
1043                            slot,
1044                            reason = reason.as_str(),
1045                            count,
1046                            "Queue storage queue segment still active",
1047                        );
1048                    }
1049                }
1050            }
1051            Err(err) => {
1052                error!(error = %err, "Failed to prune queue storage queue segments");
1053            }
1054        }
1055    }
1056
1057    async fn rotate_queue_storage_leases(&self) {
1058        let Some(runtime) = self.storage.queue_storage() else {
1059            return;
1060        };
1061
1062        match runtime.store.rotate_leases(&self.pool).await {
1063            Ok(outcome) => {
1064                self.metrics.record_rotate_outcome("lease", &outcome);
1065                match outcome {
1066                    RotateOutcome::Rotated { slot, generation } => {
1067                        debug!(slot, generation, "Rotated queue storage lease segment");
1068                    }
1069                    RotateOutcome::SkippedBusy { slot, busy } => {
1070                        debug!(
1071                            slot,
1072                            lease_rows = busy.leases,
1073                            "Skipped busy queue storage lease segment",
1074                        );
1075                    }
1076                }
1077            }
1078            Err(err) => {
1079                error!(error = %err, "Failed to rotate queue storage lease segments");
1080                return;
1081            }
1082        }
1083
1084        match runtime.store.prune_oldest_leases(&self.pool).await {
1085            Ok(outcome) => {
1086                self.metrics.record_prune_outcome("lease", &outcome);
1087                match outcome {
1088                    PruneOutcome::Noop => {}
1089                    PruneOutcome::Pruned { slot } => {
1090                        debug!(slot, "Pruned queue storage lease segment");
1091                    }
1092                    PruneOutcome::Blocked { slot } => {
1093                        debug!(slot, "Queue storage lease segment prune blocked");
1094                    }
1095                    PruneOutcome::SkippedActive {
1096                        slot,
1097                        reason,
1098                        count,
1099                    } => {
1100                        debug!(
1101                            slot,
1102                            reason = reason.as_str(),
1103                            count,
1104                            "Queue storage lease segment still active",
1105                        );
1106                    }
1107                }
1108            }
1109            Err(err) => {
1110                error!(error = %err, "Failed to prune queue storage lease segments");
1111            }
1112        }
1113    }
1114
1115    /// Claim-ring maintenance tick (see ADR-023). Rotates the claim-ring
1116    /// cursor and prunes the oldest fully-closed partition, mirroring the
1117    /// lease-ring rotate/prune pair above.
1118    async fn rotate_queue_storage_claims(&self) {
1119        let Some(runtime) = self.storage.queue_storage() else {
1120            return;
1121        };
1122
1123        match runtime.store.rotate_claims(&self.pool).await {
1124            Ok(outcome) => {
1125                self.metrics.record_rotate_outcome("claim", &outcome);
1126                match outcome {
1127                    RotateOutcome::Rotated { slot, generation } => {
1128                        debug!(slot, generation, "Rotated queue storage claim segment");
1129                    }
1130                    RotateOutcome::SkippedBusy { slot, busy } => {
1131                        debug!(
1132                            slot,
1133                            claim_rows = busy.claims,
1134                            closure_rows = busy.closures,
1135                            "Skipped busy queue storage claim segment",
1136                        );
1137                    }
1138                }
1139            }
1140            Err(err) => {
1141                error!(error = %err, "Failed to rotate queue storage claim segments");
1142                return;
1143            }
1144        }
1145
1146        match runtime.store.prune_oldest_claims(&self.pool).await {
1147            Ok(outcome) => {
1148                self.metrics.record_prune_outcome("claim", &outcome);
1149                match outcome {
1150                    PruneOutcome::Noop => {}
1151                    PruneOutcome::Pruned { slot } => {
1152                        debug!(slot, "Pruned queue storage claim segment");
1153                    }
1154                    PruneOutcome::Blocked { slot } => {
1155                        debug!(slot, "Queue storage claim segment prune blocked");
1156                    }
1157                    PruneOutcome::SkippedActive {
1158                        slot,
1159                        reason,
1160                        count,
1161                    } => {
1162                        debug!(
1163                            slot,
1164                            reason = reason.as_str(),
1165                            count,
1166                            "Queue storage claim segment still active",
1167                        );
1168                    }
1169                }
1170            }
1171            Err(err) => {
1172                error!(error = %err, "Failed to prune queue storage claim segments");
1173            }
1174        }
1175    }
1176
1177    /// Clean up completed/failed/cancelled jobs past retention.
1178    ///
1179    /// Targets `jobs_hot` directly (bypassing the `awa.jobs` INSTEAD OF trigger)
1180    /// since terminal-state jobs always reside in `jobs_hot`.
1181    /// Runs a global pass for queues without overrides, then per-queue passes
1182    /// for queues with custom retention.
1183    #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
1184    async fn cleanup_completed(&self) {
1185        if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
1186            // Queue storage uses rotation/prune rather than row-by-row cleanup.
1187            return;
1188        }
1189
1190        let mut total_deleted: u64 = 0;
1191
1192        // Collect override queue names for the exclusion clause
1193        let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
1194
1195        // Global pass: delete jobs in queues that do NOT have overrides
1196        let completed_retention_secs =
1197            i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
1198        let failed_retention_secs =
1199            i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
1200
1201        let global_result = if override_queues.is_empty() {
1202            sqlx::query(
1203                r#"
1204                DELETE FROM awa.jobs_hot
1205                WHERE id IN (
1206                    SELECT id FROM awa.jobs_hot
1207                    WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1208                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
1209                    LIMIT $3
1210                )
1211                "#,
1212            )
1213            .bind(completed_retention_secs)
1214            .bind(failed_retention_secs)
1215            .bind(self.cleanup_batch_size)
1216            .execute(&self.pool)
1217            .await
1218        } else {
1219            sqlx::query(
1220                r#"
1221                DELETE FROM awa.jobs_hot
1222                WHERE id IN (
1223                    SELECT id FROM awa.jobs_hot
1224                    WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1225                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
1226                      AND queue != ALL($4::text[])
1227                    LIMIT $3
1228                )
1229                "#,
1230            )
1231            .bind(completed_retention_secs)
1232            .bind(failed_retention_secs)
1233            .bind(self.cleanup_batch_size)
1234            .bind(&override_queues)
1235            .execute(&self.pool)
1236            .await
1237        };
1238
1239        match global_result {
1240            Ok(result) if result.rows_affected() > 0 => {
1241                total_deleted += result.rows_affected();
1242            }
1243            Err(err) => {
1244                error!(error = %err, "Failed to clean up old jobs (global pass)");
1245            }
1246            _ => {}
1247        }
1248
1249        // Per-queue override passes
1250        for (queue_name, policy) in &self.queue_retention_overrides {
1251            let queue_completed_secs =
1252                i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
1253            let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
1254
1255            match sqlx::query(
1256                r#"
1257                DELETE FROM awa.jobs_hot
1258                WHERE id IN (
1259                    SELECT id FROM awa.jobs_hot
1260                    WHERE queue = $4
1261                      AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
1262                        OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
1263                    LIMIT $3
1264                )
1265                "#,
1266            )
1267            .bind(queue_completed_secs)
1268            .bind(queue_failed_secs)
1269            .bind(self.cleanup_batch_size)
1270            .bind(queue_name)
1271            .execute(&self.pool)
1272            .await
1273            {
1274                Ok(result) if result.rows_affected() > 0 => {
1275                    total_deleted += result.rows_affected();
1276                    debug!(
1277                        queue = %queue_name,
1278                        count = result.rows_affected(),
1279                        "Cleaned up old jobs (queue override)"
1280                    );
1281                }
1282                Err(err) => {
1283                    error!(
1284                        queue = %queue_name,
1285                        error = %err,
1286                        "Failed to clean up old jobs (queue override)"
1287                    );
1288                }
1289                _ => {}
1290            }
1291        }
1292
1293        if total_deleted > 0 {
1294            info!(count = total_deleted, "Cleaned up old jobs");
1295        }
1296    }
1297
1298    #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
1299    async fn cleanup_dlq_rows(&self) {
1300        let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
1301            return;
1302        };
1303
1304        let schema = runtime.store.schema();
1305        let override_queues: Vec<&str> = self
1306            .queue_retention_overrides
1307            .iter()
1308            .filter(|(_, policy)| policy.dlq.is_some())
1309            .map(|(queue, _)| queue.as_str())
1310            .collect();
1311        let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
1312
1313        let global_result = if override_queues.is_empty() {
1314            sqlx::query(&format!(
1315                r#"
1316                DELETE FROM {schema}.dlq_entries
1317                WHERE job_id IN (
1318                    SELECT job_id FROM {schema}.dlq_entries
1319                    WHERE dlq_at < now() - make_interval(secs => $1::bigint)
1320                    LIMIT $2
1321                )
1322                "#
1323            ))
1324            .bind(retention_secs)
1325            .bind(self.dlq_cleanup_batch_size)
1326            .execute(&self.pool)
1327            .await
1328        } else {
1329            sqlx::query(&format!(
1330                r#"
1331                DELETE FROM {schema}.dlq_entries
1332                WHERE job_id IN (
1333                    SELECT job_id FROM {schema}.dlq_entries
1334                    WHERE dlq_at < now() - make_interval(secs => $1::bigint)
1335                      AND queue != ALL($3::text[])
1336                    LIMIT $2
1337                )
1338                "#
1339            ))
1340            .bind(retention_secs)
1341            .bind(self.dlq_cleanup_batch_size)
1342            .bind(&override_queues)
1343            .execute(&self.pool)
1344            .await
1345        };
1346
1347        match global_result {
1348            Ok(result) if result.rows_affected() > 0 => {
1349                self.metrics.record_dlq_purged(None, result.rows_affected());
1350            }
1351            Err(err) => {
1352                error!(error = %err, "Failed to clean up DLQ rows (global pass)");
1353            }
1354            _ => {}
1355        }
1356
1357        for (queue, policy) in &self.queue_retention_overrides {
1358            let Some(retention) = policy.dlq else {
1359                continue;
1360            };
1361            let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
1362            match sqlx::query(&format!(
1363                r#"
1364                DELETE FROM {schema}.dlq_entries
1365                WHERE job_id IN (
1366                    SELECT job_id FROM {schema}.dlq_entries
1367                    WHERE queue = $3
1368                      AND dlq_at < now() - make_interval(secs => $1::bigint)
1369                    LIMIT $2
1370                )
1371                "#
1372            ))
1373            .bind(retention_secs)
1374            .bind(self.dlq_cleanup_batch_size)
1375            .bind(queue)
1376            .execute(&self.pool)
1377            .await
1378            {
1379                Ok(result) if result.rows_affected() > 0 => {
1380                    self.metrics
1381                        .record_dlq_purged(Some(queue), result.rows_affected());
1382                }
1383                Err(err) => {
1384                    error!(queue, error = %err, "Failed to clean up DLQ rows");
1385                }
1386                _ => {}
1387            }
1388        }
1389    }
1390}
1391
1392struct MaintenanceAliveGuard(Arc<AtomicBool>);
1393
1394impl Drop for MaintenanceAliveGuard {
1395    fn drop(&mut self) {
1396        self.0.store(false, Ordering::SeqCst);
1397    }
1398}
1399
1400/// Compute the latest fire time for a cron job row, using its expression and timezone.
1401///
1402/// Returns `None` if no fire is due (next occurrence is in the future).
1403fn compute_fire_time(
1404    row: &CronJobRow,
1405    now: chrono::DateTime<Utc>,
1406) -> Option<chrono::DateTime<Utc>> {
1407    let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
1408        Ok(c) => c,
1409        Err(err) => {
1410            error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
1411            return None;
1412        }
1413    };
1414
1415    let tz: chrono_tz::Tz = match row.timezone.parse() {
1416        Ok(tz) => tz,
1417        Err(err) => {
1418            error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
1419            return None;
1420        }
1421    };
1422
1423    let search_start = match row.last_enqueued_at {
1424        Some(last) => last.with_timezone(&tz),
1425        // First registration: search from one interval before created_at
1426        // so that the current minute's fire is found. Without this,
1427        // a schedule created at HH:MM:30 won't find the HH:MM:00 fire
1428        // because created_at > fire_time, causing up to 60s delay.
1429        None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
1430    };
1431
1432    let mut latest_fire: Option<chrono::DateTime<Utc>> = None;
1433
1434    for fire_time in cron.iter_from(search_start) {
1435        let fire_utc = fire_time.with_timezone(&Utc);
1436
1437        if fire_utc > now {
1438            break;
1439        }
1440
1441        if let Some(last) = row.last_enqueued_at {
1442            if fire_utc <= last {
1443                continue;
1444            }
1445        }
1446
1447        latest_fire = Some(fire_utc);
1448    }
1449
1450    latest_fire
1451}
1452
1453impl MaintenanceService {
1454    /// Clean up runtime snapshots older than 24 hours.
1455    /// Runs as part of the leader's cleanup cycle (not on every snapshot publish).
1456    #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
1457    async fn cleanup_stale_runtime_snapshots(&self) {
1458        if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
1459            &self.pool,
1460            chrono::TimeDelta::try_hours(24).unwrap(),
1461        )
1462        .await
1463        {
1464            tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
1465        }
1466    }
1467
1468    /// Delete catalog rows whose last_seen_at is older than
1469    /// `descriptor_retention`. Runs alongside the existing cleanup cycle.
1470    /// When retention is zero this is a no-op, so this stays cheap for
1471    /// operators who don't want descriptor GC.
1472    #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
1473    async fn cleanup_stale_descriptors(&self) {
1474        if self.descriptor_retention.is_zero() {
1475            return;
1476        }
1477        let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
1478            .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
1479        for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
1480            match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
1481                Ok(deleted) if deleted > 0 => {
1482                    tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
1483                }
1484                Ok(_) => {}
1485                Err(err) => {
1486                    tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
1487                }
1488            }
1489        }
1490    }
1491
1492    /// Drain dirty keys and recompute exact cached rows for recently-touched
1493    /// queues and kinds. This is the primary cache update mechanism — called
1494    /// every ~2s to keep dashboard counters fresh.
1495    #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
1496    async fn recompute_dirty_admin_metadata(&self) {
1497        if self.storage.queue_storage().is_some() {
1498            return;
1499        }
1500        match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
1501            Ok(count) if count > 0 => {
1502                tracing::debug!(count, "Recomputed dirty admin metadata keys");
1503            }
1504            Err(err) => {
1505                tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
1506            }
1507            _ => {}
1508        }
1509    }
1510
1511    /// Full reconciliation of admin metadata from base tables.
1512    /// Safety net for any drift — runs infrequently (~60s).
1513    #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
1514    async fn refresh_admin_metadata(&self) {
1515        if self.storage.queue_storage().is_some() {
1516            return;
1517        }
1518        if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
1519            tracing::warn!(error = %err, "Failed to refresh admin metadata");
1520        }
1521    }
1522
1523    /// Publish queue depth and lag as OTel gauge metrics.
1524    #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
1525    async fn publish_queue_health_metrics(&self) {
1526        if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
1527            self.publish_queue_storage_health_metrics(runtime).await;
1528            return;
1529        }
1530
1531        let stats = match awa_model::admin::queue_overviews(&self.pool).await {
1532            Ok(stats) => stats,
1533            Err(err) => {
1534                tracing::warn!(error = %err, "Failed to query queue stats for metrics");
1535                return;
1536            }
1537        };
1538
1539        for queue_stat in &stats {
1540            let queue = &queue_stat.queue;
1541
1542            // Depth per state
1543            self.metrics
1544                .record_queue_depth(queue, "available", queue_stat.available);
1545            self.metrics
1546                .record_queue_depth(queue, "running", queue_stat.running);
1547            self.metrics
1548                .record_queue_depth(queue, "failed", queue_stat.failed);
1549            self.metrics
1550                .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
1551            self.metrics
1552                .record_queue_depth(queue, "retryable", queue_stat.retryable);
1553            self.metrics
1554                .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
1555
1556            // Lag
1557            if let Some(lag_seconds) = queue_stat.lag_seconds {
1558                self.metrics.record_queue_lag(queue, lag_seconds);
1559            }
1560        }
1561    }
1562
1563    async fn publish_queue_storage_health_metrics(
1564        &self,
1565        runtime: &crate::storage::QueueStorageRuntime,
1566    ) {
1567        let schema = runtime.store.schema();
1568        let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
1569            r#"
1570            WITH queues AS (
1571                SELECT DISTINCT queue
1572                FROM (
1573                    SELECT queue FROM awa.queue_meta
1574                    UNION ALL
1575                    SELECT queue FROM {schema}.ready_entries
1576                    UNION ALL
1577                    SELECT queue FROM {schema}.leases
1578                    UNION ALL
1579                    SELECT queue FROM {schema}.deferred_jobs
1580                    UNION ALL
1581                    SELECT queue FROM {schema}.done_entries
1582                    UNION ALL
1583                    SELECT queue FROM {schema}.dlq_entries
1584                ) queues
1585            ),
1586            ready AS (
1587                SELECT
1588                    queue,
1589                    count(*)::bigint AS available,
1590                    EXTRACT(EPOCH FROM clock_timestamp() - min(run_at))::double precision
1591                        AS lag_seconds
1592                FROM {schema}.ready_entries
1593                GROUP BY queue
1594            ),
1595            leases AS (
1596                SELECT
1597                    queue,
1598                    count(*) FILTER (WHERE state = 'running')::bigint AS running,
1599                    count(*) FILTER (WHERE state = 'waiting_external')::bigint
1600                        AS waiting_external
1601                FROM {schema}.leases
1602                GROUP BY queue
1603            ),
1604            deferred AS (
1605                SELECT
1606                    queue,
1607                    count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
1608                    count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
1609                FROM {schema}.deferred_jobs
1610                GROUP BY queue
1611            ),
1612            terminal AS (
1613                SELECT
1614                    queue,
1615                    count(*) FILTER (WHERE state = 'failed')::bigint AS failed_done
1616                FROM {schema}.done_entries
1617                GROUP BY queue
1618            ),
1619            dlq AS (
1620                SELECT
1621                    queue,
1622                    count(*)::bigint AS failed_dlq
1623                FROM {schema}.dlq_entries
1624                GROUP BY queue
1625            )
1626            SELECT
1627                queues.queue,
1628                COALESCE(ready.available, 0)::bigint AS available,
1629                COALESCE(leases.running, 0)::bigint AS running,
1630                COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
1631                COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
1632                COALESCE(deferred.retryable, 0)::bigint AS retryable,
1633                COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
1634                COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
1635                ready.lag_seconds
1636            FROM queues
1637            LEFT JOIN ready
1638              ON ready.queue = queues.queue
1639            LEFT JOIN leases
1640              ON leases.queue = queues.queue
1641            LEFT JOIN deferred
1642              ON deferred.queue = queues.queue
1643            LEFT JOIN terminal
1644              ON terminal.queue = queues.queue
1645            LEFT JOIN dlq
1646              ON dlq.queue = queues.queue
1647            ORDER BY queues.queue
1648            "#
1649        ))
1650        .fetch_all(&self.pool)
1651        .await
1652        {
1653            Ok(rows) => rows,
1654            Err(err) => {
1655                tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
1656                return;
1657            }
1658        };
1659
1660        for (
1661            queue,
1662            available,
1663            running,
1664            waiting_external,
1665            scheduled,
1666            retryable,
1667            failed_done,
1668            failed_dlq,
1669            lag_seconds,
1670        ) in rows
1671        {
1672            self.metrics
1673                .record_queue_depth(&queue, "available", available);
1674            self.metrics.record_queue_depth(&queue, "running", running);
1675            self.metrics
1676                .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
1677            self.metrics
1678                .record_queue_depth(&queue, "scheduled", scheduled);
1679            self.metrics
1680                .record_queue_depth(&queue, "retryable", retryable);
1681            self.metrics
1682                .record_queue_depth(&queue, "waiting_external", waiting_external);
1683            self.metrics.record_dlq_depth(&queue, failed_dlq);
1684
1685            if let Some(lag_seconds) = lag_seconds {
1686                self.metrics.record_queue_lag(&queue, lag_seconds);
1687            }
1688        }
1689    }
1690}