Skip to main content

awa_worker/
maintenance.rs

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