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