Skip to main content

awa_worker/
maintenance.rs

1use crate::executor::DlqPolicy;
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::cron::{
5    atomic_enqueue, list_cron_jobs, upsert_cron_job, CronJobRow, CronMissedFirePolicy,
6};
7#[cfg(test)]
8use awa_model::SkipReason;
9use awa_model::{
10    JobRow, JobState, PeriodicJob, PruneOutcome, RotateOutcome, TerminalDeltaRollupOutcome,
11};
12use chrono::Utc;
13use croner::Cron;
14use sqlx::pool::PoolConnection;
15use sqlx::{PgPool, Postgres};
16use std::collections::{HashMap, HashSet};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::task::JoinHandle;
21use tokio_util::sync::CancellationToken;
22use tracing::{debug, error, info, warn};
23use uuid::Uuid;
24
25/// The canonical unique-claim conflict raised by `awa.sync_job_unique_claims`
26/// (SQLSTATE 23505, constraint reported as `idx_awa_jobs_unique`). See #388.
27fn is_unique_claim_conflict(err: &awa_model::AwaError) -> bool {
28    match err {
29        awa_model::AwaError::Database(sqlx::Error::Database(db_err)) => {
30            db_err.code().as_deref() == Some("23505")
31        }
32        _ => false,
33    }
34}
35
36/// Single-row variants of the batched canonical rescue UPDATEs, used by the
37/// #388 fallback. Bodies mirror the batch statements exactly; only the WHERE
38/// clause narrows to one id (re-checking the staleness predicate so a row
39/// that recovered between selection and update is left alone).
40const HEARTBEAT_RESCUE_PER_ROW_SQL: &str = r#"
41    UPDATE awa.jobs
42    SET state = 'retryable',
43        finalized_at = now(),
44        heartbeat_at = NULL,
45        deadline_at = NULL,
46        callback_id = NULL,
47        callback_timeout_at = NULL,
48        callback_filter = NULL,
49        callback_on_complete = NULL,
50        callback_on_fail = NULL,
51        callback_transform = NULL,
52        errors = errors || jsonb_build_object(
53            'error', 'heartbeat stale: worker presumed dead',
54            'attempt', attempt,
55            'at', now()
56        )::jsonb
57    WHERE id = $1
58      AND state = 'running'
59      AND heartbeat_at < now() - ($2 * interval '1 millisecond')
60    RETURNING *
61"#;
62
63const DEADLINE_RESCUE_PER_ROW_SQL: &str = r#"
64    UPDATE awa.jobs
65    SET state = 'retryable',
66        finalized_at = now(),
67        heartbeat_at = NULL,
68        deadline_at = NULL,
69        callback_id = NULL,
70        callback_timeout_at = NULL,
71        callback_filter = NULL,
72        callback_on_complete = NULL,
73        callback_on_fail = NULL,
74        callback_transform = NULL,
75        errors = errors || jsonb_build_object(
76            'error', 'hard deadline exceeded',
77            'attempt', attempt,
78            'at', now()
79        )::jsonb
80    WHERE id = $1
81      AND state = 'running'
82      AND deadline_at IS NOT NULL
83      AND deadline_at < now()
84    RETURNING *
85"#;
86
87const CALLBACK_RESCUE_PER_ROW_SQL: &str = r#"
88    UPDATE awa.jobs
89    SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
90        finalized_at = now(),
91        callback_id = NULL,
92        callback_timeout_at = NULL,
93        callback_filter = NULL,
94        callback_on_complete = NULL,
95        callback_on_fail = NULL,
96        callback_transform = NULL,
97        run_at = CASE WHEN attempt >= max_attempts THEN run_at
98                 ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
99        errors = errors || jsonb_build_object(
100            'error', 'callback timed out',
101            'attempt', attempt,
102            'at', now()
103        )::jsonb
104    WHERE id = $1
105      AND state = 'waiting_external'
106      AND callback_timeout_at IS NOT NULL
107      AND callback_timeout_at < now()
108    RETURNING *
109"#;
110
111/// Per-queue or global retention policy for completed and failed/cancelled jobs.
112#[derive(Debug, Clone)]
113pub struct RetentionPolicy {
114    /// How long to keep completed jobs before cleanup.
115    pub completed: Duration,
116    /// How long to keep failed/cancelled jobs before cleanup.
117    pub failed: Duration,
118    /// How long to keep DLQ rows before cleanup.
119    pub dlq: Option<Duration>,
120}
121
122impl Default for RetentionPolicy {
123    fn default() -> Self {
124        Self {
125            completed: Duration::from_secs(86400), // 24h
126            failed: Duration::from_secs(259200),   // 72h
127            dlq: None,
128        }
129    }
130}
131
132/// Per-branch observability state for the maintenance leader's main
133/// `tokio::select!` loop. Tracks the most recent body duration so the
134/// next fire of the same branch can decide whether it was already
135/// overdue, and an `is_delayed` flag so we only log/count one event per
136/// "overrun episode" rather than once per tick.
137///
138/// Wired through `AwaMetrics` — see [`crate::metrics::AwaMetrics`] for the
139/// instrument definitions. Issue #242.
140#[derive(Debug, Default)]
141struct MaintenanceBranchState {
142    /// Body duration of the previous run of this branch. `None` until the
143    /// branch has run at least once.
144    last_duration: Option<Duration>,
145    /// True when we've emitted the on-time -> delayed warning/counter
146    /// for the current overrun episode. Cleared on the recovery
147    /// transition.
148    is_delayed: bool,
149    /// Number of consecutive overrun observations (last_duration above
150    /// the upper threshold). Samples inside the deadband neither
151    /// advance this counter nor reset it.
152    consecutive_overrun: u32,
153    /// Mirror of `consecutive_overrun` for clearly-on-time samples
154    /// (last_duration below the lower threshold). Used to gate
155    /// delayed -> on-time recovery with the same K-consecutive
156    /// requirement.
157    consecutive_ontime: u32,
158    /// Tick counter that suppresses the branch body while the branch
159    /// is unhealthy. Decremented every tick; while non-zero the
160    /// caller's body is skipped. Re-armed on every flip-to-delayed
161    /// AND every overrun observation while already delayed, so a
162    /// branch that keeps failing keeps quietly waiting instead of
163    /// hammering the database.
164    cooldown_ticks_remaining: u32,
165}
166
167/// How many consecutive observations the branch tracker requires before
168/// flipping `is_delayed` either direction. Combined with the
169/// duration-margin thresholds below: a sample inside the deadband
170/// (between `LOWER_FACTOR_*` and `UPPER_FACTOR_*` of `tick_interval`)
171/// doesn't advance either counter, so the boundary flap from #316's
172/// post-mortem stops at the source. A sample outside the deadband
173/// still needs `OVERRUN_HYSTERESIS_K` peers on the same side before
174/// the state flips.
175const OVERRUN_HYSTERESIS_K: u32 = 3;
176
177/// Duration-margin thresholds. Crossing
178/// `last_duration > tick_interval * UPPER` counts as an overrun
179/// sample; `last_duration < tick_interval * LOWER` counts as an
180/// on-time sample. Everything in between is a deadband that leaves
181/// both counters untouched.
182///
183/// `UPPER = 3/2` and `LOWER = 7/10` give a generous deadband — the
184/// branch has to be at 70% of its tick or below to count as recovered,
185/// and 50% above its tick to count as overrunning. This prevents
186/// 51ms-vs-49ms jitter at the 50ms boundary from advancing either
187/// counter, which is what bench evidence on #316 showed was still
188/// happening with K-only hysteresis. Integer ratios avoid f64 in the
189/// hot path.
190const OVERRUN_UPPER_NUM: u32 = 3;
191const OVERRUN_UPPER_DEN: u32 = 2;
192const OVERRUN_LOWER_NUM: u32 = 7;
193const OVERRUN_LOWER_DEN: u32 = 10;
194
195/// Number of ticks to suppress a delayed branch's body. At the default
196/// `lease_rotate_interval = 1000ms`, 120 ticks = 120s wall time of quiet
197/// before the branch tries again. Re-armed on every overrun
198/// observation while still delayed.
199const BRANCH_COOLDOWN_TICKS: u32 = 120;
200
201/// Records the start of one branch body and emits observability when the
202/// body returns. Constructed by [`MaintenanceBranchTracker::try_begin`], which
203/// also applies the previous-run overrun check before the body starts so
204/// the warning/counter line up with the fire moment described in #242.
205struct BranchTimer<'a> {
206    tracker: &'a MaintenanceBranchTracker,
207    branch: &'static str,
208    metrics: &'a crate::metrics::AwaMetrics,
209    started_at: Instant,
210}
211
212impl<'a> BranchTimer<'a> {
213    /// Stop the timer, record the duration into both the per-branch
214    /// histogram and the tracker's state. Must be called once after the
215    /// body returns — there is no `Drop` impl so a panic-mid-body would
216    /// leave the histogram un-recorded, which is fine: a panicking
217    /// maintenance branch is a bigger story than the missing sample.
218    fn finish(self) {
219        let duration = self.started_at.elapsed();
220        self.metrics
221            .record_maintenance_branch_duration(self.branch, duration);
222        self.tracker.record_finish(self.branch, duration);
223    }
224}
225
226/// Owns the per-branch overrun state for the maintenance leader loop.
227/// All access is logically single-threaded — the maintenance loop is the
228/// only caller — but the loop runs inside a `tokio::spawn`-ed task that
229/// requires `Send`, so we use `std::sync::Mutex` rather than `RefCell`.
230/// The mutex is uncontended in practice (one acquirer); cost is one
231/// uncontended lock per branch tick.
232#[derive(Default)]
233struct MaintenanceBranchTracker {
234    branches: std::sync::Mutex<HashMap<&'static str, MaintenanceBranchState>>,
235}
236
237impl MaintenanceBranchTracker {
238    fn new() -> Self {
239        Self {
240            branches: std::sync::Mutex::new(HashMap::new()),
241        }
242    }
243
244    /// Call at the moment a branch arm fires, before running its body.
245    /// Applies cooldown + duration-margin hysteresis, then either:
246    ///   * returns `Some(BranchTimer)` so the caller runs the body
247    ///     and calls `.finish()`, or
248    ///   * returns `None` to signal "skip this tick" — the body
249    ///     must not run, and no duration sample is recorded for this
250    ///     tick (so the K-on-time counter doesn't advance for skipped
251    ///     work).
252    ///
253    /// Two gating phases:
254    ///
255    /// 1. **Cooldown.** If `cooldown_ticks_remaining > 0`, decrement
256    ///    and return `None`. While cooldown is non-zero the branch is
257    ///    quiet; this is set on flip-to-delayed and re-armed on
258    ///    every subsequent overrun observation, so an unhealthy
259    ///    branch backs off instead of beating on the database every
260    ///    tick.
261    /// 2. **Duration-margin hysteresis.** Compare `last_duration` to
262    ///    `tick_interval * UPPER/LOWER`. Outside the deadband, advance
263    ///    the matching K-counter; inside, leave both alone. Crossing
264    ///    K-consecutive on either side flips `is_delayed` and may
265    ///    arm cooldown.
266    fn try_begin<'a>(
267        &'a self,
268        branch: &'static str,
269        tick_interval: Duration,
270        metrics: &'a crate::metrics::AwaMetrics,
271    ) -> Option<BranchTimer<'a>> {
272        self.try_begin_with_cooldown(branch, tick_interval, metrics, BRANCH_COOLDOWN_TICKS)
273    }
274
275    fn try_begin_without_cooldown<'a>(
276        &'a self,
277        branch: &'static str,
278        tick_interval: Duration,
279        metrics: &'a crate::metrics::AwaMetrics,
280    ) -> Option<BranchTimer<'a>> {
281        self.try_begin_with_cooldown(branch, tick_interval, metrics, 0)
282    }
283
284    fn try_begin_with_cooldown<'a>(
285        &'a self,
286        branch: &'static str,
287        tick_interval: Duration,
288        metrics: &'a crate::metrics::AwaMetrics,
289        cooldown_ticks: u32,
290    ) -> Option<BranchTimer<'a>> {
291        let mut branches = self
292            .branches
293            .lock()
294            .expect("maintenance branch tracker mutex");
295        let state = branches.entry(branch).or_default();
296
297        // Phase 1: cooldown gate. Counts down regardless of any
298        // other signal; the branch stays quiet while non-zero.
299        if state.cooldown_ticks_remaining > 0 {
300            state.cooldown_ticks_remaining -= 1;
301            return None;
302        }
303
304        // Phase 2: duration-margin + K-consecutive hysteresis.
305        // `take()` consumes the sample so we evaluate each body's
306        // duration exactly once. Without this, when cooldown returns
307        // None (no body runs → no new sample), the next post-cooldown
308        // `try_begin` would re-read the same pre-flip overrun sample,
309        // see `consecutive_overrun >= K` already, and re-arm cooldown
310        // forever. The branch would never run its body again until
311        // the worker restarts. Codex + CodeRabbit both flagged this
312        // on PR #318.
313        if let Some(last_duration) = state.last_duration.take() {
314            // Integer-ratio thresholds — avoid f64 in the hot path.
315            let upper_threshold = tick_interval * OVERRUN_UPPER_NUM / OVERRUN_UPPER_DEN;
316            let lower_threshold = tick_interval * OVERRUN_LOWER_NUM / OVERRUN_LOWER_DEN;
317
318            if last_duration > upper_threshold {
319                state.consecutive_overrun = state.consecutive_overrun.saturating_add(1);
320                state.consecutive_ontime = 0;
321                let cross_threshold = state.consecutive_overrun >= OVERRUN_HYSTERESIS_K;
322                if cross_threshold && !state.is_delayed {
323                    state.is_delayed = true;
324                    state.cooldown_ticks_remaining = cooldown_ticks;
325                    warn!(
326                        branch,
327                        last_duration_ms = last_duration.as_millis() as u64,
328                        tick_interval_ms = tick_interval.as_millis() as u64,
329                        upper_threshold_ms = upper_threshold.as_millis() as u64,
330                        consecutive_overrun = state.consecutive_overrun,
331                        cooldown_ticks,
332                        "maintenance branch overran tick interval",
333                    );
334                    metrics.record_maintenance_branch_overrun(branch);
335                    if cooldown_ticks > 0 {
336                        return None;
337                    }
338                } else if cross_threshold && state.is_delayed && cooldown_ticks > 0 {
339                    // Already delayed and another overrun arrived —
340                    // re-arm cooldown but stay quiet about it.
341                    state.cooldown_ticks_remaining = cooldown_ticks;
342                    return None;
343                }
344            } else if last_duration < lower_threshold {
345                state.consecutive_ontime = state.consecutive_ontime.saturating_add(1);
346                state.consecutive_overrun = 0;
347                if state.consecutive_ontime >= OVERRUN_HYSTERESIS_K && state.is_delayed {
348                    state.is_delayed = false;
349                    warn!(
350                        branch,
351                        last_duration_ms = last_duration.as_millis() as u64,
352                        tick_interval_ms = tick_interval.as_millis() as u64,
353                        lower_threshold_ms = lower_threshold.as_millis() as u64,
354                        consecutive_ontime = state.consecutive_ontime,
355                        "maintenance branch recovered to on-time",
356                    );
357                }
358            } else {
359                // Deadband — neither counter advances. This is the
360                // explicit no-op that kills the 49ms-vs-51ms flap.
361            }
362        }
363        drop(branches);
364        Some(BranchTimer {
365            tracker: self,
366            branch,
367            metrics,
368            started_at: Instant::now(),
369        })
370    }
371
372    /// Internal — called by [`BranchTimer::finish`] to stash the body's
373    /// wall-clock duration so the next fire of the same branch can apply
374    /// the overrun check.
375    fn record_finish(&self, branch: &'static str, duration: Duration) {
376        let mut branches = self
377            .branches
378            .lock()
379            .expect("maintenance branch tracker mutex");
380        let state = branches.entry(branch).or_default();
381        state.last_duration = Some(duration);
382    }
383
384    /// Test helper — read the current state for one branch. Not used in
385    /// production code paths.
386    #[cfg(test)]
387    fn snapshot(&self, branch: &'static str) -> Option<(Option<Duration>, bool)> {
388        let branches = self
389            .branches
390            .lock()
391            .expect("maintenance branch tracker mutex");
392        branches
393            .get(branch)
394            .map(|state| (state.last_duration, state.is_delayed))
395    }
396
397    /// Test-only — read the per-branch cooldown counter and consecutive
398    /// counters so tests can assert on the full state machine.
399    #[cfg(test)]
400    fn cooldown_snapshot(&self, branch: &'static str) -> Option<(u32, u32, u32)> {
401        let branches = self
402            .branches
403            .lock()
404            .expect("maintenance branch tracker mutex");
405        branches.get(branch).map(|state| {
406            (
407                state.cooldown_ticks_remaining,
408                state.consecutive_overrun,
409                state.consecutive_ontime,
410            )
411        })
412    }
413}
414
415/// Per-segment exponential backoff for the prune step of the queue /
416/// lease / claim ring-rotation branches. The rotate step is always
417/// cheap (cursor advance under an advisory lock); the prune step is
418/// what hurts under pinned MVCC: every tick attempts a best-effort child
419/// `ACCESS EXCLUSIVE NOWAIT` lock followed by a bounded liveness check.
420/// Under a pinned snapshot the child can't be reclaimed, so prune
421/// returns `SkippedActive` or `Blocked` repeatedly. This tracker skips
422/// the next 2^level ticks (capped at 32) after a `SkippedActive` or
423/// `Blocked` outcome, doubling on each repeat. A successful `Pruned`
424/// resets to no backoff. `Noop` (ring empty / nothing to consider)
425/// leaves state unchanged — backoff is for "I tried and couldn't",
426/// not "there's nothing to do."
427#[derive(Default)]
428struct PruneBackoffTracker {
429    branches: std::sync::Mutex<HashMap<&'static str, PruneBackoffState>>,
430}
431
432#[derive(Debug, Default)]
433struct PruneBackoffState {
434    /// Number of upcoming ticks to skip the prune call entirely.
435    /// Decremented on every `should_skip` poll; the tick where it
436    /// reaches 0 actually runs prune again.
437    skip_remaining: u32,
438    /// Last backoff exponent applied. Used to set `skip_remaining` to
439    /// `1 << level` on the next failure. Reset to 0 on `Pruned`.
440    backoff_level: u8,
441}
442
443/// Cap on the backoff exponent. `2^5 = 32` ticks; at the 1000ms default
444/// `lease_rotate_interval` that is ~32s between prune attempts under
445/// sustained pin pressure. Long enough to cut the per-tick scan cost
446/// dramatically; short enough that prune resumes promptly once the
447/// snapshot is released.
448const MAX_PRUNE_BACKOFF_LEVEL: u8 = 5;
449
450impl PruneBackoffTracker {
451    fn new() -> Self {
452        Self::default()
453    }
454
455    /// Returns true when the caller should skip this tick's prune.
456    /// Side effect: decrements `skip_remaining` when non-zero.
457    fn should_skip(&self, branch: &'static str) -> bool {
458        let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
459        let state = branches.entry(branch).or_default();
460        if state.skip_remaining > 0 {
461            state.skip_remaining -= 1;
462            true
463        } else {
464            false
465        }
466    }
467
468    /// Update backoff state for a completed prune call. `Pruned` resets;
469    /// `SkippedActive` / `Blocked` doubles; `Noop` is neutral.
470    fn record_outcome(&self, branch: &'static str, outcome: &PruneOutcome) {
471        let mut branches = self.branches.lock().expect("prune backoff tracker mutex");
472        let state = branches.entry(branch).or_default();
473        match outcome {
474            PruneOutcome::Pruned { .. } => {
475                state.skip_remaining = 0;
476                state.backoff_level = 0;
477            }
478            PruneOutcome::SkippedActive { .. } | PruneOutcome::Blocked { .. } => {
479                state.backoff_level = state
480                    .backoff_level
481                    .saturating_add(1)
482                    .min(MAX_PRUNE_BACKOFF_LEVEL);
483                state.skip_remaining = 1u32 << state.backoff_level;
484            }
485            PruneOutcome::Noop => {}
486        }
487    }
488
489    #[cfg(test)]
490    fn snapshot(&self, branch: &'static str) -> Option<(u32, u8)> {
491        let branches = self.branches.lock().expect("prune backoff tracker mutex");
492        branches
493            .get(branch)
494            .map(|state| (state.skip_remaining, state.backoff_level))
495    }
496}
497
498/// Branch keys used by the prune backoff tracker. Kept as `&'static
499/// str` so the tracker's HashMap doesn't have to allocate per call.
500const PRUNE_BRANCH_LEASE: &str = "lease";
501const PRUNE_BRANCH_CLAIM: &str = "claim";
502const PRUNE_BRANCH_QUEUE: &str = "queue";
503
504/// Maintenance service: runs leader-elected background tasks.
505///
506/// Tasks: heartbeat rescue, deadline rescue, scheduled promotion, cleanup,
507/// periodic job sync and evaluation.
508pub struct MaintenanceService {
509    pool: PgPool,
510    metrics: crate::metrics::AwaMetrics,
511    cancel: CancellationToken,
512    leader: Arc<AtomicBool>,
513    alive: Arc<AtomicBool>,
514    periodic_jobs: Arc<Vec<PeriodicJob>>,
515    /// ADR-029 follow-up specs registry — used to dispatch `Rescued`
516    /// follow-ups (best-effort, separate tx) when this service rescues
517    /// jobs from stale heartbeat / expired callback / exceeded deadline.
518    enqueue_specs: Arc<
519        HashMap<
520            crate::enqueue_specs::Outcome,
521            HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
522        >,
523    >,
524    /// In-process `Rescued` lifecycle hooks (ADR-015), shared with the
525    /// executor. Best-effort fire-and-forget per rescued job.
526    lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
527    /// In-flight job cancellation flags — used to signal deadline/heartbeat rescue
528    /// to running handlers on this worker instance.
529    in_flight: InFlightMap,
530    storage: RuntimeStorage,
531    heartbeat_rescue_interval: Duration,
532    deadline_rescue_interval: Duration,
533    callback_rescue_interval: Duration,
534    promote_interval: Duration,
535    cleanup_interval: Duration,
536    cron_sync_interval: Duration,
537    cron_eval_interval: Duration,
538    leader_check_interval: Duration,
539    leader_election_interval: Duration,
540    heartbeat_staleness: Duration,
541    completed_retention: Duration,
542    failed_retention: Duration,
543    cleanup_batch_size: i64,
544    queue_retention_overrides: HashMap<String, RetentionPolicy>,
545    queue_stats_interval: Duration,
546    dlq_retention: Duration,
547    dlq_cleanup_batch_size: i64,
548    dlq_policy: DlqPolicy,
549    dirty_key_recompute_interval: Duration,
550    metadata_reconciliation_interval: Duration,
551    /// Interval for priority aging — jobs waiting longer than this have their
552    /// priority improved by one level per interval elapsed (default: 60s).
553    priority_aging_interval: Duration,
554    batch_operations_interval: Duration,
555    terminal_count_rollup_interval: Duration,
556    /// How long a descriptor catalog row can sit without being refreshed
557    /// before the maintenance leader deletes it. Zero disables cleanup.
558    /// Default: 30 days.
559    descriptor_retention: Duration,
560}
561
562const PROMOTE_BATCH_SIZE: i64 = 4_096;
563const PROMOTE_MAX_BATCHES_PER_TICK: usize = 32;
564const CRON_CATCH_UP_LIMIT: usize = 1_000;
565const TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK: usize = 4;
566type QueueStorageMetricRow = (String, i64, i64, i64, i64, i64, i64, i64, Option<f64>);
567
568impl MaintenanceService {
569    #[allow(clippy::too_many_arguments)]
570    pub(crate) fn new(
571        pool: PgPool,
572        metrics: crate::metrics::AwaMetrics,
573        leader: Arc<AtomicBool>,
574        alive: Arc<AtomicBool>,
575        cancel: CancellationToken,
576        periodic_jobs: Arc<Vec<PeriodicJob>>,
577        in_flight: InFlightMap,
578        storage: RuntimeStorage,
579        enqueue_specs: Arc<
580            HashMap<
581                crate::enqueue_specs::Outcome,
582                HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
583            >,
584        >,
585        lifecycle_handlers: Arc<HashMap<String, Vec<crate::events::BoxedUntypedEventHandler>>>,
586    ) -> Self {
587        Self {
588            pool,
589            metrics,
590            cancel,
591            leader,
592            alive,
593            periodic_jobs,
594            in_flight,
595            storage,
596            enqueue_specs,
597            lifecycle_handlers,
598            heartbeat_rescue_interval: Duration::from_secs(30),
599            deadline_rescue_interval: Duration::from_secs(30),
600            callback_rescue_interval: Duration::from_secs(30),
601            promote_interval: Duration::from_millis(250),
602            cleanup_interval: Duration::from_secs(60),
603            cron_sync_interval: Duration::from_secs(60),
604            cron_eval_interval: Duration::from_secs(1),
605            leader_check_interval: Duration::from_secs(30),
606            leader_election_interval: Duration::from_secs(10),
607            heartbeat_staleness: Duration::from_secs(90),
608            completed_retention: Duration::from_secs(86400), // 24h
609            failed_retention: Duration::from_secs(259200),   // 72h
610            cleanup_batch_size: 1000,
611            queue_retention_overrides: HashMap::new(),
612            queue_stats_interval: Duration::from_secs(30),
613            dlq_retention: Duration::from_secs(60 * 60 * 24 * 30),
614            dlq_cleanup_batch_size: 1000,
615            dlq_policy: DlqPolicy::default(),
616            dirty_key_recompute_interval: Duration::from_secs(2),
617            metadata_reconciliation_interval: Duration::from_secs(60),
618            priority_aging_interval: Duration::from_secs(60),
619            batch_operations_interval: Duration::from_secs(1),
620            terminal_count_rollup_interval: Duration::from_secs(30),
621            descriptor_retention: Duration::from_secs(30 * 86400), // 30d
622        }
623    }
624
625    /// Set the priority aging interval (default: 60s).
626    ///
627    /// Jobs waiting longer than this per priority level are promoted:
628    /// a priority-4 job waiting 180s is treated as priority-1.
629    pub fn priority_aging_interval(mut self, interval: Duration) -> Self {
630        self.priority_aging_interval = interval;
631        self
632    }
633
634    /// Set how often the maintenance leader processes batch-operation chunks.
635    pub fn batch_operations_interval(mut self, interval: Duration) -> Self {
636        self.batch_operations_interval = interval;
637        self
638    }
639
640    /// Set how often terminal-count delta rows are folded into the live
641    /// counter table for sealed queue segments (default: 30s).
642    pub fn terminal_count_rollup_interval(mut self, interval: Duration) -> Self {
643        self.terminal_count_rollup_interval = interval;
644        self
645    }
646
647    /// How long a descriptor catalog row can go without being re-synced
648    /// before the maintenance leader deletes it (default: 30 days). Set
649    /// to `Duration::ZERO` to disable — useful if you maintain the catalog
650    /// externally or want to keep historical descriptors forever.
651    ///
652    /// Descriptors carry no FK from jobs, so deletion is safe: a later
653    /// worker restart that re-declares the same queue or kind will
654    /// recreate the row from its declaration on the next snapshot tick.
655    pub fn descriptor_retention(mut self, retention: Duration) -> Self {
656        self.descriptor_retention = retention;
657        self
658    }
659
660    /// Set the leader election retry interval (default: 10s).
661    ///
662    /// Controls how often a non-leader instance retries acquiring the
663    /// advisory lock. Lower values speed up leader election in tests.
664    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
665        self.leader_election_interval = interval;
666        self
667    }
668
669    /// Set the leader connection health-check interval (default: 30s).
670    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
671        self.leader_check_interval = interval;
672        self
673    }
674
675    /// Set the promotion interval for scheduled/retryable jobs.
676    pub fn promote_interval(mut self, interval: Duration) -> Self {
677        self.promote_interval = interval;
678        self
679    }
680
681    /// Set the stale-heartbeat rescue interval (default: 30s).
682    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
683        self.heartbeat_rescue_interval = interval;
684        self
685    }
686
687    /// Set the deadline rescue interval (default: 30s).
688    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
689        self.deadline_rescue_interval = interval;
690        self
691    }
692
693    /// Set the callback-timeout rescue interval (default: 30s).
694    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
695        self.callback_rescue_interval = interval;
696        self
697    }
698
699    /// Set how long a heartbeat must be stale before the job is rescued (default: 90s).
700    ///
701    /// Should be at least 3× the heartbeat interval to avoid false rescues
702    /// from transient delays. The run-lease guard prevents duplicate completions
703    /// even if a false rescue occurs, but wasted work is still undesirable.
704    pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
705        self.heartbeat_staleness = staleness;
706        self
707    }
708
709    /// Set the cleanup interval (default: 60s).
710    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
711        self.cleanup_interval = interval;
712        self
713    }
714
715    /// Set retention for completed jobs (default: 24h).
716    pub fn completed_retention(mut self, retention: Duration) -> Self {
717        self.completed_retention = retention;
718        self
719    }
720
721    /// Set retention for failed/cancelled jobs (default: 72h).
722    pub fn failed_retention(mut self, retention: Duration) -> Self {
723        self.failed_retention = retention;
724        self
725    }
726
727    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
728    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
729        self.cleanup_batch_size = batch_size;
730        self
731    }
732
733    /// Set the interval for publishing queue depth/lag metrics (default: 30s).
734    pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
735        self.queue_stats_interval = interval;
736        self
737    }
738
739    /// Set retention for DLQ rows (default: 30 days).
740    pub fn dlq_retention(mut self, retention: Duration) -> Self {
741        self.dlq_retention = retention;
742        self
743    }
744
745    /// Set the maximum number of DLQ rows deleted per cleanup pass (default: 1000).
746    pub fn dlq_cleanup_batch_size(mut self, batch_size: i64) -> Self {
747        self.dlq_cleanup_batch_size = batch_size;
748        self
749    }
750
751    /// Set the per-queue DLQ policy.
752    pub(crate) fn dlq_policy(mut self, policy: DlqPolicy) -> Self {
753        self.dlq_policy = policy;
754        self
755    }
756
757    /// Set per-queue retention overrides.
758    pub fn queue_retention_overrides(
759        mut self,
760        overrides: HashMap<String, RetentionPolicy>,
761    ) -> Self {
762        self.queue_retention_overrides = overrides;
763        self
764    }
765
766    /// Run the maintenance loop. Attempts leader election first.
767    pub async fn run(&self) {
768        info!("Maintenance service starting");
769        self.alive.store(true, Ordering::SeqCst);
770        let _alive_guard = MaintenanceAliveGuard(self.alive.clone());
771        self.leader.store(false, Ordering::SeqCst);
772
773        loop {
774            // Try to acquire advisory lock for leader election.
775            // We get back a dedicated connection that holds the lock.
776            let mut leader_conn = match self.try_become_leader().await {
777                Ok(Some(conn)) => conn,
778                Ok(None) => {
779                    // Not leader — back off and try again
780                    tokio::select! {
781                        _ = self.cancel.cancelled() => {
782                            debug!("Maintenance service shutting down (not leader)");
783                            self.leader.store(false, Ordering::SeqCst);
784                            return;
785                        }
786                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
787                    }
788                }
789                Err(err) => {
790                    warn!(error = %err, "Failed to check leader status");
791                    tokio::select! {
792                        _ = self.cancel.cancelled() => {
793                            debug!("Maintenance service shutting down (leader check failed)");
794                            self.leader.store(false, Ordering::SeqCst);
795                            return;
796                        }
797                        _ = tokio::time::sleep(self.leader_election_interval) => continue,
798                    }
799                }
800            };
801
802            debug!("Elected as maintenance leader");
803            self.leader.store(true, Ordering::SeqCst);
804
805            // Run maintenance tasks as leader
806            let mut heartbeat_rescue_timer = tokio::time::interval(self.heartbeat_rescue_interval);
807            let mut deadline_rescue_timer = tokio::time::interval(self.deadline_rescue_interval);
808            let mut callback_rescue_timer = tokio::time::interval(self.callback_rescue_interval);
809            let mut promote_timer = tokio::time::interval(self.promote_interval);
810            let mut cleanup_timer = tokio::time::interval(self.cleanup_interval);
811            let mut cron_sync_timer = tokio::time::interval(self.cron_sync_interval);
812            let mut leader_check_timer = tokio::time::interval(self.leader_check_interval);
813            let mut queue_stats_timer = tokio::time::interval(self.queue_stats_interval);
814            let mut dirty_key_timer = tokio::time::interval(self.dirty_key_recompute_interval);
815            let mut metadata_reconciliation_timer =
816                tokio::time::interval(self.metadata_reconciliation_interval);
817            let mut priority_aging_timer = tokio::time::interval(self.priority_aging_interval);
818            let mut batch_operations_timer = tokio::time::interval(self.batch_operations_interval);
819            let mut terminal_count_rollup_timer =
820                tokio::time::interval(self.terminal_count_rollup_interval);
821            let mut vacuum_queue_timer = self
822                .storage
823                .queue_storage()
824                .map(|runtime| tokio::time::interval(runtime.queue_rotate_interval));
825            let mut vacuum_lease_timer = self
826                .storage
827                .queue_storage()
828                .map(|runtime| tokio::time::interval(runtime.lease_rotate_interval));
829            let mut vacuum_claim_timer = self
830                .storage
831                .queue_storage()
832                .map(|runtime| tokio::time::interval(runtime.claim_rotate_interval));
833            // Cache rotate intervals alongside the timers so the per-branch
834            // observability calls can recover the configured period. Both
835            // derive from the same `queue_storage()` source so they are
836            // Some/None in lockstep with their corresponding timers.
837            let vacuum_queue_interval = self
838                .storage
839                .queue_storage()
840                .map(|runtime| runtime.queue_rotate_interval);
841            let vacuum_lease_interval = self
842                .storage
843                .queue_storage()
844                .map(|runtime| runtime.lease_rotate_interval);
845            let vacuum_claim_interval = self
846                .storage
847                .queue_storage()
848                .map(|runtime| runtime.claim_rotate_interval);
849            // Per-branch overrun tracker. Issue #242 — observability only;
850            // the architectural split of this `tokio::select!` is deferred
851            // to v0.7 conditional on the overrun counter showing fleet hits.
852            let branch_tracker = MaintenanceBranchTracker::new();
853            // Per-segment prune backoff (#169). Gates the prune step of
854            // each rotate branch so a pinned MVCC snapshot doesn't make
855            // us repeat the ACCESS-EXCLUSIVE + count(*) attempt every
856            // tick. Local to the leader loop so a re-election resets
857            // backoff to zero.
858            let prune_tracker = PruneBackoffTracker::new();
859
860            // Skip the first immediate tick
861            heartbeat_rescue_timer.tick().await;
862            deadline_rescue_timer.tick().await;
863            callback_rescue_timer.tick().await;
864            promote_timer.tick().await;
865            cleanup_timer.tick().await;
866            cron_sync_timer.tick().await;
867            leader_check_timer.tick().await;
868            queue_stats_timer.tick().await;
869            dirty_key_timer.tick().await;
870            metadata_reconciliation_timer.tick().await;
871            priority_aging_timer.tick().await;
872            batch_operations_timer.tick().await;
873            terminal_count_rollup_timer.tick().await;
874            if let Some(timer) = &mut vacuum_queue_timer {
875                timer.tick().await;
876            }
877            if let Some(timer) = &mut vacuum_lease_timer {
878                timer.tick().await;
879            }
880            if let Some(timer) = &mut vacuum_claim_timer {
881                timer.tick().await;
882            }
883
884            // Do an initial sync immediately on becoming leader
885            self.sync_periodic_jobs_to_db().await;
886            let cron_eval_cancel = self.cancel.child_token();
887            let cron_eval_task = tokio::spawn(Self::run_cron_evaluator(
888                self.pool.clone(),
889                cron_eval_cancel.clone(),
890                self.cron_eval_interval,
891            ));
892
893            loop {
894                tokio::select! {
895                    _ = self.cancel.cancelled() => {
896                        debug!("Maintenance service shutting down");
897                        self.leader.store(false, Ordering::SeqCst);
898                        Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
899                        // Release leader lock on the same connection that acquired it.
900                        // If this fails, dropping the connection will release the lock anyway.
901                        let _ = Self::release_leader(&mut leader_conn).await;
902                        return;
903                    }
904                    _ = heartbeat_rescue_timer.tick() => {
905                        if let Some(timer) = branch_tracker.try_begin("rescue_stale_heartbeats", self.heartbeat_rescue_interval, &self.metrics) {
906                            self.rescue_stale_heartbeats().await;
907                            timer.finish();
908                        }
909                    }
910                    _ = deadline_rescue_timer.tick() => {
911                        if let Some(timer) = branch_tracker.try_begin("rescue_expired_deadlines", self.deadline_rescue_interval, &self.metrics) {
912                            self.rescue_expired_deadlines().await;
913                            timer.finish();
914                        }
915                    }
916                    _ = callback_rescue_timer.tick() => {
917                        if let Some(timer) = branch_tracker.try_begin("rescue_expired_callbacks", self.callback_rescue_interval, &self.metrics) {
918                            self.rescue_expired_callbacks().await;
919                            timer.finish();
920                        }
921                    }
922                    _ = promote_timer.tick() => {
923                        if let Some(timer) = branch_tracker.try_begin("promote_scheduled", self.promote_interval, &self.metrics) {
924                            self.promote_scheduled().await;
925                            timer.finish();
926                        }
927                    }
928                    _ = cleanup_timer.tick() => {
929                        if let Some(timer) = branch_tracker.try_begin("cleanup", self.cleanup_interval, &self.metrics) {
930                            self.cleanup_completed().await;
931                            self.cleanup_dlq_rows().await;
932                            self.cleanup_batch_operations().await;
933                            self.cleanup_stale_runtime_snapshots().await;
934                            self.cleanup_stale_descriptors().await;
935                            timer.finish();
936                        }
937                    }
938                    _ = cron_sync_timer.tick() => {
939                        if let Some(timer) = branch_tracker.try_begin("cron_sync", self.cron_sync_interval, &self.metrics) {
940                            self.sync_periodic_jobs_to_db().await;
941                            timer.finish();
942                        }
943                    }
944                    _ = queue_stats_timer.tick() => {
945                        if let Some(timer) = branch_tracker.try_begin("queue_stats", self.queue_stats_interval, &self.metrics) {
946                            self.publish_queue_health_metrics().await;
947                            timer.finish();
948                        }
949                    }
950                    _ = dirty_key_timer.tick() => {
951                        if let Some(timer) = branch_tracker.try_begin("recompute_dirty_admin_metadata", self.dirty_key_recompute_interval, &self.metrics) {
952                            self.recompute_dirty_admin_metadata().await;
953                            timer.finish();
954                        }
955                    }
956                    _ = metadata_reconciliation_timer.tick() => {
957                        if let Some(timer) = branch_tracker.try_begin("refresh_admin_metadata", self.metadata_reconciliation_interval, &self.metrics) {
958                            self.refresh_admin_metadata().await;
959                            timer.finish();
960                        }
961                    }
962                    _ = priority_aging_timer.tick() => {
963                        if let Some(timer) = branch_tracker.try_begin("priority_aging", self.priority_aging_interval, &self.metrics) {
964                            self.age_waiting_priorities().await;
965                            timer.finish();
966                        }
967                    }
968                    _ = batch_operations_timer.tick() => {
969                        if let Some(timer) = branch_tracker.try_begin("batch_operations", self.batch_operations_interval, &self.metrics) {
970                            self.process_batch_operation().await;
971                            timer.finish();
972                        }
973                    }
974                    _ = terminal_count_rollup_timer.tick() => {
975                        if let Some(timer) = branch_tracker.try_begin_without_cooldown("terminal_count_rollup", self.terminal_count_rollup_interval, &self.metrics) {
976                            self.rollup_terminal_count_deltas().await;
977                            timer.finish();
978                        }
979                    }
980                    _ = async {
981                        if let Some(timer) = &mut vacuum_queue_timer {
982                            timer.tick().await;
983                        } else {
984                            std::future::pending::<()>().await;
985                        }
986                    }, if vacuum_queue_timer.is_some() => {
987                        let interval = vacuum_queue_interval
988                            .expect("vacuum_queue_interval Some iff vacuum_queue_timer Some");
989                        if let Some(timer) = branch_tracker.try_begin("rotate_queue", interval, &self.metrics) {
990                            self.rotate_queue_storage_queue(&prune_tracker).await;
991                            timer.finish();
992                        }
993                    }
994                    _ = async {
995                        if let Some(timer) = &mut vacuum_lease_timer {
996                            timer.tick().await;
997                        } else {
998                            std::future::pending::<()>().await;
999                        }
1000                    }, if vacuum_lease_timer.is_some() => {
1001                        let interval = vacuum_lease_interval
1002                            .expect("vacuum_lease_interval Some iff vacuum_lease_timer Some");
1003                        if let Some(timer) = branch_tracker.try_begin("rotate_lease", interval, &self.metrics) {
1004                            self.rotate_queue_storage_leases(&prune_tracker).await;
1005                            timer.finish();
1006                        }
1007                    }
1008                    _ = async {
1009                        if let Some(timer) = &mut vacuum_claim_timer {
1010                            timer.tick().await;
1011                        } else {
1012                            std::future::pending::<()>().await;
1013                        }
1014                    }, if vacuum_claim_timer.is_some() => {
1015                        let interval = vacuum_claim_interval
1016                            .expect("vacuum_claim_interval Some iff vacuum_claim_timer Some");
1017                        if let Some(timer) = branch_tracker.try_begin("rotate_claim", interval, &self.metrics) {
1018                            self.rotate_queue_storage_claims(&prune_tracker).await;
1019                            timer.finish();
1020                        }
1021                    }
1022                    _ = leader_check_timer.tick() => {
1023                        // Verify leader connection is still alive.
1024                        // The advisory lock is session-scoped: if the connection is alive,
1025                        // the lock is held. If the query fails, the connection (and lock) are gone.
1026                        if sqlx::query("SELECT 1").execute(&mut *leader_conn).await.is_err() {
1027                            warn!("Leader connection lost, re-entering election loop");
1028                            self.leader.store(false, Ordering::SeqCst);
1029                            Self::stop_cron_evaluator(&cron_eval_cancel, &cron_eval_task);
1030                            break;
1031                        }
1032                    }
1033                }
1034            }
1035        }
1036    }
1037
1038    /// Advisory lock key for Awa maintenance leader election.
1039    const LOCK_KEY: i64 = 0x_4157_415f_4d41_494e; // "AWA_MAIN" in hex-ish
1040
1041    /// Try to acquire the advisory lock for leader election.
1042    ///
1043    /// Returns a dedicated connection holding the lock on success, or `None` if
1044    /// another instance already holds the lock. The lock is session-scoped in
1045    /// PostgreSQL, so it stays held as long as this connection is alive.
1046    async fn try_become_leader(&self) -> Result<Option<PoolConnection<Postgres>>, sqlx::Error> {
1047        let mut conn = self.pool.acquire().await?;
1048        let result: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock($1)")
1049            .bind(Self::LOCK_KEY)
1050            .fetch_one(&mut *conn)
1051            .await?;
1052        if result.0 {
1053            Ok(Some(conn))
1054        } else {
1055            Ok(None)
1056        }
1057    }
1058
1059    /// Release the advisory lock on the same connection that acquired it.
1060    ///
1061    /// Dropping the connection also releases the lock (PG session-scoped behavior),
1062    /// so this is a best-effort explicit release.
1063    async fn release_leader(conn: &mut PoolConnection<Postgres>) -> Result<(), sqlx::Error> {
1064        sqlx::query("SELECT pg_advisory_unlock($1)")
1065            .bind(Self::LOCK_KEY)
1066            .execute(&mut **conn)
1067            .await?;
1068        Ok(())
1069    }
1070
1071    async fn run_cron_evaluator(pool: PgPool, cancel: CancellationToken, interval: Duration) {
1072        let mut timer = tokio::time::interval(interval);
1073        timer.tick().await;
1074
1075        loop {
1076            tokio::select! {
1077                _ = cancel.cancelled() => return,
1078                _ = timer.tick() => {
1079                    Self::evaluate_cron_schedules(&pool).await;
1080                }
1081            }
1082        }
1083    }
1084
1085    fn stop_cron_evaluator(cancel: &CancellationToken, task: &JoinHandle<()>) {
1086        cancel.cancel();
1087        task.abort();
1088    }
1089
1090    /// Sync all registered periodic job schedules to `awa.cron_jobs` via UPSERT.
1091    ///
1092    /// Additive only — does NOT delete schedules not in the local set (multi-deployment safe).
1093    #[tracing::instrument(skip(self), name = "maintenance.cron_sync")]
1094    async fn sync_periodic_jobs_to_db(&self) {
1095        if self.periodic_jobs.is_empty() {
1096            return;
1097        }
1098
1099        for job in self.periodic_jobs.iter() {
1100            if let Err(err) = upsert_cron_job(&self.pool, job).await {
1101                error!(name = %job.name, error = %err, "Failed to sync periodic job");
1102            }
1103        }
1104
1105        debug!(
1106            count = self.periodic_jobs.len(),
1107            "Synced periodic jobs to database"
1108        );
1109    }
1110
1111    async fn process_batch_operation(&self) {
1112        let runner_instance = Uuid::new_v4();
1113        match awa_model::batch_operations::run_one_default_chunk(&self.pool, runner_instance).await
1114        {
1115            Ok(outcome) if outcome.claimed => {
1116                debug!(
1117                    processed = outcome.processed,
1118                    skipped = outcome.skipped,
1119                    errored = outcome.errored,
1120                    finalized = outcome.finalized,
1121                    "processed batch operation chunk"
1122                );
1123            }
1124            Ok(_) => {}
1125            Err(err) => warn!(error = %err, "failed to process batch operation chunk"),
1126        }
1127    }
1128
1129    async fn cleanup_batch_operations(&self) {
1130        match awa_model::batch_operations::cleanup_expired_batch_operations(&self.pool, 1000).await
1131        {
1132            Ok(deleted) if deleted > 0 => {
1133                debug!(deleted, "cleaned up expired batch operations");
1134            }
1135            Ok(_) => {}
1136            Err(err) => warn!(error = %err, "failed to clean up expired batch operations"),
1137        }
1138    }
1139
1140    async fn rollup_terminal_count_deltas(&self) {
1141        let Some(runtime) = self.storage.queue_storage() else {
1142            return;
1143        };
1144
1145        match runtime
1146            .store
1147            .rollup_terminal_count_deltas(&self.pool, TERMINAL_COUNT_ROLLUP_MAX_SLOTS_PER_TICK)
1148            .await
1149        {
1150            Ok(TerminalDeltaRollupOutcome {
1151                rolled_slots: 0,
1152                delta_rows: 0,
1153                grouped_keys: 0,
1154                skipped_active_slots: 0,
1155                blocked_slots: 0,
1156                skipped_mvcc_pinned: false,
1157            }) => {}
1158            Ok(outcome) => {
1159                debug!(
1160                    rolled_slots = outcome.rolled_slots,
1161                    delta_rows = outcome.delta_rows,
1162                    grouped_keys = outcome.grouped_keys,
1163                    skipped_active_slots = outcome.skipped_active_slots,
1164                    blocked_slots = outcome.blocked_slots,
1165                    skipped_mvcc_pinned = outcome.skipped_mvcc_pinned,
1166                    "rolled up queue-storage terminal count deltas"
1167                );
1168            }
1169            Err(err) => warn!(error = %err, "failed to roll up terminal count deltas"),
1170        }
1171    }
1172
1173    /// Evaluate all cron schedules and enqueue any that are due.
1174    ///
1175    /// For each schedule, computes due fire times ≤ now that are after
1176    /// `last_enqueued_at`. If fires are due, executes the atomic CTE for each
1177    /// fire in order so delayed evaluation catches up instead of collapsing
1178    /// intermediate fires.
1179    #[tracing::instrument(skip(pool), name = "maintenance.cron_eval")]
1180    async fn evaluate_cron_schedules(pool: &PgPool) {
1181        let cron_rows = match list_cron_jobs(pool).await {
1182            Ok(rows) => rows,
1183            Err(err) => {
1184                error!(error = %err, "Failed to load cron jobs for evaluation");
1185                return;
1186            }
1187        };
1188
1189        if cron_rows.is_empty() {
1190            return;
1191        }
1192
1193        let now = Utc::now();
1194
1195        for row in &cron_rows {
1196            if row.is_paused() {
1197                debug!(cron_name = %row.name, "Skipping paused cron schedule");
1198                continue;
1199            }
1200            let fire_times = compute_fire_times(row, now, CRON_CATCH_UP_LIMIT);
1201            if fire_times.is_empty() {
1202                continue;
1203            }
1204            if fire_times.len() == CRON_CATCH_UP_LIMIT {
1205                warn!(
1206                    cron_name = %row.name,
1207                    catch_up_limit = CRON_CATCH_UP_LIMIT,
1208                    "Cron catch-up limit reached; remaining due fires will be retried on the next evaluation"
1209                );
1210            }
1211
1212            let mut previous_enqueued_at = row.last_enqueued_at;
1213            for fire_time in fire_times {
1214                match atomic_enqueue(pool, &row.name, fire_time, previous_enqueued_at).await {
1215                    Ok(Some(job)) => {
1216                        previous_enqueued_at = Some(fire_time);
1217                        info!(
1218                            cron_name = %row.name,
1219                            job_id = job.id,
1220                            fire_time = %fire_time,
1221                            "Enqueued periodic job"
1222                        );
1223                    }
1224                    Ok(None) => {
1225                        // Another leader already claimed this fire — not an error
1226                        debug!(cron_name = %row.name, "Cron fire already claimed");
1227                        break;
1228                    }
1229                    Err(err) => {
1230                        error!(
1231                            cron_name = %row.name,
1232                            error = %err,
1233                            "Failed to enqueue periodic job"
1234                        );
1235                        break;
1236                    }
1237                }
1238            }
1239        }
1240    }
1241
1242    /// Rescue jobs with stale heartbeats (crash detection).
1243    #[tracing::instrument(skip(self), name = "maintenance.rescue_stale")]
1244    async fn rescue_stale_heartbeats(&self) {
1245        let staleness_ms = self.heartbeat_staleness.as_millis() as i64;
1246        let outcome = match &self.storage {
1247            RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1248                r#"
1249                    UPDATE awa.jobs
1250                    SET state = 'retryable',
1251                        finalized_at = now(),
1252                        heartbeat_at = NULL,
1253                        deadline_at = NULL,
1254                        callback_id = NULL,
1255                        callback_timeout_at = NULL,
1256                        callback_filter = NULL,
1257                        callback_on_complete = NULL,
1258                        callback_on_fail = NULL,
1259                        callback_transform = NULL,
1260                        errors = errors || jsonb_build_object(
1261                            'error', 'heartbeat stale: worker presumed dead',
1262                            'attempt', attempt,
1263                            'at', now()
1264                        )::jsonb
1265                    WHERE id IN (
1266                        SELECT id FROM awa.jobs_hot
1267                        WHERE state = 'running'
1268                          AND heartbeat_at < now() - ($1 * interval '1 millisecond')
1269                        LIMIT 500
1270                        FOR UPDATE SKIP LOCKED
1271                    )
1272                    RETURNING *
1273                    "#,
1274            )
1275            .bind(staleness_ms)
1276            .fetch_all(&self.pool)
1277            .await
1278            .map_err(awa_model::AwaError::Database),
1279            RuntimeStorage::QueueStorage(runtime) => {
1280                runtime
1281                    .store
1282                    .rescue_stale_heartbeats(&self.pool, self.heartbeat_staleness)
1283                    .await
1284            }
1285        };
1286        let outcome = match outcome {
1287            Err(err)
1288                if matches!(self.storage, RuntimeStorage::Canonical)
1289                    && is_unique_claim_conflict(&err) =>
1290            {
1291                warn!(
1292                    error = %err,
1293                    "Batched heartbeat rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1294                );
1295                self.rescue_canonical_per_row(
1296                    "SELECT id FROM awa.jobs_hot \
1297                     WHERE state = 'running' \
1298                       AND heartbeat_at < now() - ($1 * interval '1 millisecond') \
1299                     LIMIT 500",
1300                    HEARTBEAT_RESCUE_PER_ROW_SQL,
1301                    Some(staleness_ms),
1302                    "running",
1303                    "rescued as duplicate: heartbeat stale and unique claim held by a newer job",
1304                    "heartbeat",
1305                )
1306                .await
1307            }
1308            other => other,
1309        };
1310        match outcome {
1311            Ok(rescued) if !rescued.is_empty() => {
1312                let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1313                    .into_iter()
1314                    .partition(|job| job.state == JobState::Cancelled);
1315                self.handle_duplicate_cancellations("heartbeat", &cancelled_duplicates)
1316                    .await;
1317                if rescued.is_empty() {
1318                    return;
1319                }
1320                self.metrics.maintenance_rescues.add(
1321                    rescued.len() as u64,
1322                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "heartbeat")],
1323                );
1324                warn!(count = rescued.len(), "Rescued stale heartbeat jobs");
1325                // Signal cancellation to any rescued jobs still running on this instance
1326                self.signal_cancellation(&rescued).await;
1327                for job in &rescued {
1328                    self.emit_rescued(job, crate::events::RescueReason::StaleHeartbeat)
1329                        .await;
1330                }
1331            }
1332            Err(err) => {
1333                error!(error = %err, "Failed to rescue stale heartbeat jobs");
1334            }
1335            _ => {}
1336        }
1337    }
1338
1339    /// Rescue jobs that exceeded their hard deadline.
1340    #[tracing::instrument(skip(self), name = "maintenance.rescue_deadline")]
1341    async fn rescue_expired_deadlines(&self) {
1342        let outcome = match &self.storage {
1343            RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1344                r#"
1345                UPDATE awa.jobs
1346                SET state = 'retryable',
1347                    finalized_at = now(),
1348                    heartbeat_at = NULL,
1349                    deadline_at = NULL,
1350                    callback_id = NULL,
1351                    callback_timeout_at = NULL,
1352                    callback_filter = NULL,
1353                    callback_on_complete = NULL,
1354                    callback_on_fail = NULL,
1355                    callback_transform = NULL,
1356                    errors = errors || jsonb_build_object(
1357                        'error', 'hard deadline exceeded',
1358                        'attempt', attempt,
1359                        'at', now()
1360                    )::jsonb
1361                WHERE id IN (
1362                    SELECT id FROM awa.jobs_hot
1363                    WHERE state = 'running'
1364                      AND deadline_at IS NOT NULL
1365                      AND deadline_at < now()
1366                    LIMIT 500
1367                    FOR UPDATE SKIP LOCKED
1368                )
1369                RETURNING *
1370                "#,
1371            )
1372            .fetch_all(&self.pool)
1373            .await
1374            .map_err(awa_model::AwaError::Database),
1375            RuntimeStorage::QueueStorage(runtime) => {
1376                runtime.store.rescue_expired_deadlines(&self.pool).await
1377            }
1378        };
1379        let outcome = match outcome {
1380            Err(err)
1381                if matches!(self.storage, RuntimeStorage::Canonical)
1382                    && is_unique_claim_conflict(&err) =>
1383            {
1384                warn!(
1385                    error = %err,
1386                    "Batched deadline rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1387                );
1388                self.rescue_canonical_per_row(
1389                    "SELECT id FROM awa.jobs_hot \
1390                     WHERE state = 'running' \
1391                       AND deadline_at IS NOT NULL \
1392                       AND deadline_at < now() \
1393                     LIMIT 500",
1394                    DEADLINE_RESCUE_PER_ROW_SQL,
1395                    None,
1396                    "running",
1397                    "rescued as duplicate: deadline expired and unique claim held by a newer job",
1398                    "deadline",
1399                )
1400                .await
1401            }
1402            other => other,
1403        };
1404        match outcome {
1405            Ok(rescued) if !rescued.is_empty() => {
1406                let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1407                    .into_iter()
1408                    .partition(|job| job.state == JobState::Cancelled);
1409                self.handle_duplicate_cancellations("deadline", &cancelled_duplicates)
1410                    .await;
1411                if rescued.is_empty() {
1412                    return;
1413                }
1414                self.metrics.maintenance_rescues.add(
1415                    rescued.len() as u64,
1416                    &[opentelemetry::KeyValue::new("awa.rescue.kind", "deadline")],
1417                );
1418                warn!(count = rescued.len(), "Rescued deadline-expired jobs");
1419                // Signal cancellation so handlers see ctx.is_cancelled() == true
1420                self.signal_cancellation(&rescued).await;
1421                for job in &rescued {
1422                    self.emit_rescued(job, crate::events::RescueReason::DeadlineExceeded)
1423                        .await;
1424                }
1425            }
1426            Err(err) => {
1427                error!(error = %err, "Failed to rescue deadline-expired jobs");
1428            }
1429            _ => {}
1430        }
1431    }
1432
1433    /// Rescue jobs whose callback timeout has expired.
1434    #[tracing::instrument(skip(self), name = "maintenance.rescue_callback_timeout")]
1435    async fn rescue_expired_callbacks(&self) {
1436        let outcome = match &self.storage {
1437            RuntimeStorage::Canonical => sqlx::query_as::<_, JobRow>(
1438                r#"
1439                UPDATE awa.jobs
1440                SET state = CASE WHEN attempt >= max_attempts THEN 'failed'::awa.job_state ELSE 'retryable'::awa.job_state END,
1441                    finalized_at = now(),
1442                    callback_id = NULL,
1443                    callback_timeout_at = NULL,
1444                    callback_filter = NULL,
1445                    callback_on_complete = NULL,
1446                    callback_on_fail = NULL,
1447                    callback_transform = NULL,
1448                    run_at = CASE WHEN attempt >= max_attempts THEN run_at
1449                             ELSE now() + awa.backoff_duration(attempt, max_attempts) END,
1450                    errors = errors || jsonb_build_object(
1451                        'error', 'callback timed out',
1452                        'attempt', attempt,
1453                        'at', now()
1454                    )::jsonb
1455                WHERE id IN (
1456                    SELECT id FROM awa.jobs_hot
1457                    WHERE state = 'waiting_external'
1458                      AND callback_timeout_at IS NOT NULL
1459                      AND callback_timeout_at < now()
1460                    LIMIT 500
1461                    FOR UPDATE SKIP LOCKED
1462                )
1463                RETURNING *
1464                "#,
1465            )
1466            .fetch_all(&self.pool)
1467            .await
1468            .map_err(awa_model::AwaError::Database),
1469            RuntimeStorage::QueueStorage(runtime) => {
1470                runtime.store.rescue_expired_callbacks(&self.pool).await
1471            }
1472        };
1473        let outcome = match outcome {
1474            Err(err)
1475                if matches!(self.storage, RuntimeStorage::Canonical)
1476                    && is_unique_claim_conflict(&err) =>
1477            {
1478                warn!(
1479                    error = %err,
1480                    "Batched callback-timeout rescue hit a unique-claim conflict; retrying row-at-a-time (#388)"
1481                );
1482                self.rescue_canonical_per_row(
1483                    "SELECT id FROM awa.jobs_hot \
1484                     WHERE state = 'waiting_external' \
1485                       AND callback_timeout_at IS NOT NULL \
1486                       AND callback_timeout_at < now() \
1487                     LIMIT 500",
1488                    CALLBACK_RESCUE_PER_ROW_SQL,
1489                    None,
1490                    "waiting_external",
1491                    "rescued as duplicate: callback timed out and unique claim held by a newer job",
1492                    "callback_timeout",
1493                )
1494                .await
1495            }
1496            other => other,
1497        };
1498        match outcome {
1499            Ok(rescued) if !rescued.is_empty() => {
1500                let (cancelled_duplicates, rescued): (Vec<_>, Vec<_>) = rescued
1501                    .into_iter()
1502                    .partition(|job| job.state == JobState::Cancelled);
1503                self.handle_duplicate_cancellations("callback_timeout", &cancelled_duplicates)
1504                    .await;
1505                if rescued.is_empty() {
1506                    return;
1507                }
1508                self.metrics.maintenance_rescues.add(
1509                    rescued.len() as u64,
1510                    &[opentelemetry::KeyValue::new(
1511                        "awa.rescue.kind",
1512                        "callback_timeout",
1513                    )],
1514                );
1515                warn!(count = rescued.len(), "Rescued callback-timed-out jobs");
1516                for job in &rescued {
1517                    self.emit_rescued(job, crate::events::RescueReason::ExpiredCallback)
1518                        .await;
1519                }
1520                if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
1521                    for job in &rescued {
1522                        if job.state != JobState::Failed || !self.dlq_policy.enabled_for(&job.queue)
1523                        {
1524                            continue;
1525                        }
1526                        match runtime
1527                            .store
1528                            .move_failed_to_dlq(&self.pool, job.id, "callback_timeout")
1529                            .await
1530                        {
1531                            Ok(Some(_)) => {
1532                                self.metrics.record_dlq_moved(
1533                                    &job.kind,
1534                                    &job.queue,
1535                                    "callback_timeout",
1536                                );
1537                            }
1538                            Ok(None) => {}
1539                            Err(err) => {
1540                                error!(
1541                                    job_id = job.id,
1542                                    error = %err,
1543                                    "Failed to move rescued callback timeout into DLQ"
1544                                );
1545                            }
1546                        }
1547                    }
1548                }
1549            }
1550            Err(err) => {
1551                error!(error = %err, "Failed to rescue callback-timed-out jobs");
1552            }
1553            _ => {}
1554        }
1555    }
1556
1557    /// Age priorities for jobs that have been waiting longer than `priority_aging_interval`.
1558    ///
1559    /// Decrements `priority` by 1 per pass for available jobs waiting longer than
1560    /// the aging interval (minimum priority 1). On the first age, stores the
1561    /// original priority in `metadata._awa_original_priority` so the API can
1562    /// report it accurately.
1563    #[tracing::instrument(skip(self), name = "maintenance.priority_aging")]
1564    async fn age_waiting_priorities(&self) {
1565        let aging_secs = self.priority_aging_interval.as_secs_f64();
1566        if aging_secs <= 0.0 {
1567            return;
1568        }
1569        if let Some(runtime) = self.storage.queue_storage() {
1570            debug!(
1571                schema = %runtime.store.schema(),
1572                "Queue storage uses claim-time priority aging; skipping physical reprioritization pass"
1573            );
1574            return;
1575        }
1576
1577        match sqlx::query_scalar::<_, i64>(
1578            r#"
1579            WITH eligible AS (
1580                SELECT id FROM awa.jobs_hot
1581                WHERE state = 'available'
1582                  AND priority > 1
1583                  AND run_at <= now() - make_interval(secs => $1)
1584                LIMIT 1000
1585                FOR UPDATE SKIP LOCKED
1586            )
1587            UPDATE awa.jobs_hot
1588            SET priority = priority - 1,
1589                metadata = CASE
1590                    WHEN NOT (metadata ? '_awa_original_priority')
1591                    THEN metadata || jsonb_build_object('_awa_original_priority', priority)
1592                    ELSE metadata
1593                END
1594            FROM eligible
1595            WHERE awa.jobs_hot.id = eligible.id
1596            RETURNING awa.jobs_hot.id
1597            "#,
1598        )
1599        .bind(aging_secs)
1600        .fetch_all(&self.pool)
1601        .await
1602        {
1603            Ok(ids) if !ids.is_empty() => {
1604                debug!(count = ids.len(), "Aged job priorities");
1605            }
1606            Err(err) => {
1607                error!(error = %err, "Failed to age job priorities");
1608            }
1609            _ => {}
1610        }
1611    }
1612
1613    /// Row-at-a-time fallback for the canonical rescue sweeps (#388).
1614    ///
1615    /// The batched rescue UPDATEs abort wholesale when any single row's
1616    /// `running -> retryable` (or `waiting_external -> retryable`) transition
1617    /// re-enters its unique-claim state set while another job holds the
1618    /// claim — `awa.sync_job_unique_claims` raises `idx_awa_jobs_unique`.
1619    /// One poisoned row must not starve rescue for the whole cluster, so on
1620    /// that specific failure the sweep retries per row: rows that rescue
1621    /// cleanly are returned as usual; a row whose rescue conflicts is
1622    /// cancelled instead (the claim holder — the job that superseded it —
1623    /// wins), and a row that can't even be cancelled (its mask claims
1624    /// `cancelled` too) is skipped with a loud log so it never blocks the
1625    /// rest of the batch.
1626    async fn rescue_canonical_per_row(
1627        &self,
1628        candidates_sql: &str,
1629        per_row_sql: &str,
1630        staleness_ms: Option<i64>,
1631        from_state: &str,
1632        duplicate_error: &str,
1633        rescue_kind: &'static str,
1634    ) -> Result<Vec<JobRow>, awa_model::AwaError> {
1635        let ids: Vec<i64> = {
1636            let query = sqlx::query_scalar(candidates_sql);
1637            let query = match staleness_ms {
1638                Some(ms) => query.bind(ms),
1639                None => query,
1640            };
1641            query
1642                .fetch_all(&self.pool)
1643                .await
1644                .map_err(awa_model::AwaError::Database)?
1645        };
1646
1647        let mut rescued = Vec::new();
1648        for id in ids {
1649            let attempt = {
1650                let query = sqlx::query_as::<_, JobRow>(per_row_sql).bind(id);
1651                let query = match staleness_ms {
1652                    Some(ms) => query.bind(ms),
1653                    None => query,
1654                };
1655                query
1656                    .fetch_optional(&self.pool)
1657                    .await
1658                    .map_err(awa_model::AwaError::Database)
1659            };
1660            match attempt {
1661                Ok(Some(row)) => rescued.push(row),
1662                // The row was completed/claimed/rescued by someone else
1663                // between candidate selection and the update.
1664                Ok(None) => {}
1665                Err(err) if is_unique_claim_conflict(&err) => {
1666                    let holder = self.unique_claim_holder(id).await;
1667                    warn!(
1668                        job_id = id,
1669                        claim_holder = ?holder,
1670                        rescue_kind,
1671                        "Rescue conflicts with a unique claim held by another job; \
1672                         cancelling the superseded job (#388)"
1673                    );
1674                    match self
1675                        .cancel_unique_conflicted_job(id, from_state, duplicate_error)
1676                        .await
1677                    {
1678                        Ok(Some(row)) => rescued.push(row),
1679                        Ok(None) => {}
1680                        Err(err) if is_unique_claim_conflict(&err) => {
1681                            error!(
1682                                job_id = id,
1683                                claim_holder = ?holder,
1684                                rescue_kind,
1685                                "Cannot rescue or cancel unique-conflicted job: its \
1686                                 unique_states mask claims 'cancelled' as well; skipping \
1687                                 so the sweep can proceed — resolve manually (see \
1688                                 docs/troubleshooting.md, #388)"
1689                            );
1690                        }
1691                        Err(err) => {
1692                            error!(job_id = id, error = %err, rescue_kind, "Failed to cancel unique-conflicted job");
1693                        }
1694                    }
1695                }
1696                Err(err) => {
1697                    error!(job_id = id, error = %err, rescue_kind, "Per-row rescue failed");
1698                }
1699            }
1700        }
1701
1702        Ok(rescued)
1703    }
1704
1705    /// Shared handling for rescue rows that were cancelled because a newer
1706    /// duplicate held their unique claim (either engine): metrics, local
1707    /// cancellation signal, and the same terminal lifecycle event a normal
1708    /// cancellation dispatches.
1709    async fn handle_duplicate_cancellations(&self, rescue_kind: &str, cancelled: &[JobRow]) {
1710        if cancelled.is_empty() {
1711            return;
1712        }
1713        self.metrics.maintenance_rescues.add(
1714            cancelled.len() as u64,
1715            &[opentelemetry::KeyValue::new(
1716                "awa.rescue.kind",
1717                format!("{rescue_kind}_duplicate_cancelled"),
1718            )],
1719        );
1720        warn!(
1721            count = cancelled.len(),
1722            rescue_kind, "Cancelled unique-conflicted jobs superseded by a newer duplicate"
1723        );
1724        self.signal_cancellation(cancelled).await;
1725        for job in cancelled {
1726            let handlers = self.lifecycle_handlers.clone();
1727            let kind = job.kind.clone();
1728            let reason = job
1729                .errors
1730                .as_ref()
1731                .and_then(|errors| errors.last())
1732                .and_then(|entry| entry.get("error"))
1733                .and_then(|value| value.as_str())
1734                .unwrap_or("rescued as duplicate: unique claim held by a newer job")
1735                .to_string();
1736            let event = crate::events::UntypedJobEvent::Cancelled {
1737                job: job.clone(),
1738                reason,
1739            };
1740            tokio::spawn(async move {
1741                crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
1742            });
1743        }
1744    }
1745
1746    /// The job id currently holding the unique claim for `job_id`'s key,
1747    /// for operator-legible conflict logs.
1748    async fn unique_claim_holder(&self, job_id: i64) -> Option<i64> {
1749        sqlx::query_scalar(
1750            r#"
1751            SELECT c.job_id
1752            FROM awa.jobs AS j
1753            JOIN awa.job_unique_claims AS c ON c.unique_key = j.unique_key
1754            WHERE j.id = $1 AND c.job_id <> j.id
1755            "#,
1756        )
1757        .bind(job_id)
1758        .fetch_optional(&self.pool)
1759        .await
1760        .ok()
1761        .flatten()
1762    }
1763
1764    /// Terminal fallback for a job whose rescue transition conflicts with a
1765    /// unique claim: the newer claim holder wins, this job is cancelled with
1766    /// an error entry naming why. `cancelled` sits outside the default
1767    /// unique-states mask, so this transition normally releases nothing and
1768    /// claims nothing.
1769    async fn cancel_unique_conflicted_job(
1770        &self,
1771        job_id: i64,
1772        from_state: &str,
1773        error_message: &str,
1774    ) -> Result<Option<JobRow>, awa_model::AwaError> {
1775        let row = sqlx::query_as::<_, JobRow>(
1776            r#"
1777            UPDATE awa.jobs
1778            SET state = 'cancelled',
1779                finalized_at = now(),
1780                heartbeat_at = NULL,
1781                deadline_at = NULL,
1782                callback_id = NULL,
1783                callback_timeout_at = NULL,
1784                callback_filter = NULL,
1785                callback_on_complete = NULL,
1786                callback_on_fail = NULL,
1787                callback_transform = NULL,
1788                errors = errors || jsonb_build_object(
1789                    'error', $3::text,
1790                    'attempt', attempt,
1791                    'at', now()
1792                )::jsonb
1793            WHERE id = $1 AND state = $2::awa.job_state
1794            RETURNING *
1795            "#,
1796        )
1797        .bind(job_id)
1798        .bind(from_state)
1799        .bind(error_message)
1800        .fetch_optional(&self.pool)
1801        .await?;
1802        Ok(row)
1803    }
1804
1805    /// Signal cancellation to any rescued jobs that are still running on this instance.
1806    async fn signal_cancellation(&self, rescued_jobs: &[JobRow]) {
1807        for job in rescued_jobs {
1808            if let Some(flag) = self.in_flight.get_cancel((job.id, job.run_lease)) {
1809                flag.store(true, Ordering::SeqCst);
1810                debug!(job_id = job.id, "Signalled cancellation for rescued job");
1811            }
1812        }
1813    }
1814
1815    /// Emit a Rescued notification: dispatch the durable follow-up specs
1816    /// (best-effort, separate tx — see [`Self::dispatch_rescued_followups`])
1817    /// then fire the in-process hook detached so a slow observer does not
1818    /// gate the side-effect path or any work the caller does after this
1819    /// (e.g. the callback-rescue DLQ move in
1820    /// [`Self::rescue_expired_callbacks`]).
1821    async fn emit_rescued(&self, job: &JobRow, reason: crate::events::RescueReason) {
1822        self.dispatch_rescued_followups(job, reason).await;
1823        let handlers = self.lifecycle_handlers.clone();
1824        let kind = job.kind.clone();
1825        let event = crate::events::UntypedJobEvent::Rescued {
1826            job: job.clone(),
1827            reason,
1828        };
1829        tokio::spawn(async move {
1830            crate::executor::dispatch_lifecycle_event(&handlers, &kind, event).await;
1831        });
1832    }
1833
1834    /// ADR-029 best-effort dispatch of `Rescued` follow-up specs.
1835    ///
1836    /// Like callback-resolution follow-ups, this runs in a *separate*
1837    /// transaction from the rescue UPDATE — the rescue UPDATE has already
1838    /// committed by the time we're here, so we can't be atomic without
1839    /// teaching every rescue path to take a `&mut tx`. If a spec INSERT
1840    /// fails it's logged; the rescue itself remains valid.
1841    async fn dispatch_rescued_followups(&self, job: &JobRow, reason: crate::events::RescueReason) {
1842        let Some(specs) = self
1843            .enqueue_specs
1844            .get(&crate::enqueue_specs::Outcome::Rescued)
1845            .and_then(|by_kind| by_kind.get(&job.kind))
1846            .cloned()
1847        else {
1848            return;
1849        };
1850        if specs.is_empty() {
1851            return;
1852        }
1853        let mut tx = match self.pool.begin().await {
1854            Ok(tx) => tx,
1855            Err(err) => {
1856                error!(
1857                    job_id = job.id,
1858                    kind = %job.kind,
1859                    rescue_reason = reason.as_str(),
1860                    error = %err,
1861                    "Rescued follow-up dispatch: failed to begin transaction"
1862                );
1863                return;
1864            }
1865        };
1866        let outcome_ctx = crate::enqueue_specs::OutcomeContext::Rescued { reason };
1867        let result =
1868            crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, job, &specs, Some(&outcome_ctx))
1869                .await;
1870        match result {
1871            Ok(()) => {
1872                if let Err(err) = tx.commit().await {
1873                    error!(
1874                        job_id = job.id,
1875                        kind = %job.kind,
1876                        rescue_reason = reason.as_str(),
1877                        error = %err,
1878                        "Rescued follow-up dispatch: commit failed"
1879                    );
1880                }
1881            }
1882            Err(err) => {
1883                error!(
1884                    job_id = job.id,
1885                    kind = %job.kind,
1886                    rescue_reason = reason.as_str(),
1887                    error = %err,
1888                    "Rescued follow-up dispatch: spec INSERT failed; rolling back"
1889                );
1890                let _ = tx.rollback().await;
1891            }
1892        }
1893    }
1894
1895    /// Promote scheduled jobs that are now due.
1896    #[tracing::instrument(skip(self), name = "maintenance.promote")]
1897    async fn promote_scheduled(&self) {
1898        if let Err(err) = self.promote_due_state("scheduled", "scheduled jobs").await {
1899            error!(error = %err, "Failed to promote scheduled jobs");
1900        }
1901        if let Err(err) = self
1902            .promote_due_state("retryable", "retryable jobs (backoff elapsed)")
1903            .await
1904        {
1905            error!(error = %err, "Failed to promote retryable jobs");
1906        }
1907    }
1908
1909    async fn promote_due_state(
1910        &self,
1911        state: &'static str,
1912        label: &'static str,
1913    ) -> Result<(), awa_model::AwaError> {
1914        let mut promoted_total = 0usize;
1915        let mut notified_queues = HashSet::new();
1916
1917        for _ in 0..PROMOTE_MAX_BATCHES_PER_TICK {
1918            if self.cancel.is_cancelled() {
1919                break;
1920            }
1921
1922            match &self.storage {
1923                RuntimeStorage::Canonical => {
1924                    let (promoted, queues) = self
1925                        .promote_due_batch(state)
1926                        .await
1927                        .map_err(awa_model::AwaError::Database)?;
1928                    if promoted == 0 {
1929                        break;
1930                    }
1931
1932                    promoted_total += promoted;
1933                    notified_queues.extend(queues);
1934
1935                    if promoted < PROMOTE_BATCH_SIZE as usize {
1936                        break;
1937                    }
1938                }
1939                RuntimeStorage::QueueStorage(runtime) => {
1940                    let job_state = match state {
1941                        "scheduled" => awa_model::JobState::Scheduled,
1942                        "retryable" => awa_model::JobState::Retryable,
1943                        other => {
1944                            return Err(awa_model::AwaError::Validation(format!(
1945                                "unsupported queue storage promote state: {other}"
1946                            )));
1947                        }
1948                    };
1949                    let promote_start = std::time::Instant::now();
1950                    let promoted = runtime
1951                        .store
1952                        .promote_due(&self.pool, job_state, PROMOTE_BATCH_SIZE)
1953                        .await?;
1954                    self.metrics.record_promotion_batch(
1955                        state,
1956                        promoted as u64,
1957                        promote_start.elapsed(),
1958                    );
1959                    if promoted == 0 {
1960                        break;
1961                    }
1962
1963                    promoted_total += promoted;
1964
1965                    if promoted < PROMOTE_BATCH_SIZE as usize {
1966                        break;
1967                    }
1968                }
1969            }
1970        }
1971
1972        if promoted_total > 0 {
1973            debug!(
1974                count = promoted_total,
1975                queues = notified_queues.len(),
1976                state,
1977                "Promoted {label}"
1978            );
1979        }
1980
1981        Ok(())
1982    }
1983
1984    /// SQL template for promotion. The state literal is injected directly
1985    /// (not as a parameter) so the planner can match the partial index on
1986    /// `(run_at, id) WHERE state = '<state>'`. With a parameter, the planner
1987    /// cannot prove the partial index applies and falls back to a full
1988    /// bitmap scan on multi-million-row tables.
1989    fn promote_sql(state: &'static str) -> String {
1990        format!(
1991            r#"
1992            WITH due AS (
1993                DELETE FROM awa.scheduled_jobs
1994                WHERE id IN (
1995                    SELECT id
1996                    FROM awa.scheduled_jobs
1997                    WHERE state = '{state}'::awa.job_state
1998                      AND run_at <= now()
1999                    ORDER BY run_at ASC, id ASC
2000                    LIMIT $1
2001                    FOR UPDATE SKIP LOCKED
2002                )
2003                RETURNING *
2004            ),
2005            promoted AS (
2006                INSERT INTO awa.jobs_hot (
2007                    id, kind, queue, args, state, priority, attempt, max_attempts,
2008                    run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
2009                    created_at, errors, metadata, tags, unique_key, unique_states,
2010                    callback_id, callback_timeout_at, callback_filter, callback_on_complete,
2011                    callback_on_fail, callback_transform, run_lease, progress
2012                )
2013                SELECT
2014                    id,
2015                    kind,
2016                    queue,
2017                    args,
2018                    'available'::awa.job_state,
2019                    priority,
2020                    attempt,
2021                    max_attempts,
2022                    now(),
2023                    NULL,
2024                    NULL,
2025                    attempted_at,
2026                    finalized_at,
2027                    created_at,
2028                    errors,
2029                    metadata,
2030                    tags,
2031                    unique_key,
2032                    unique_states,
2033                    NULL,
2034                    NULL,
2035                    NULL,
2036                    NULL,
2037                    NULL,
2038                    NULL,
2039                    run_lease,
2040                    progress
2041                FROM due
2042                RETURNING queue
2043            )
2044            SELECT queue FROM promoted
2045            "#
2046        )
2047    }
2048
2049    async fn promote_due_batch(
2050        &self,
2051        state: &'static str,
2052    ) -> Result<(usize, HashSet<String>), sqlx::Error> {
2053        let mut tx = self.pool.begin().await?;
2054        let promote_start = std::time::Instant::now();
2055        let sql = Self::promote_sql(state);
2056        let promoted_rows: Vec<(String,)> = sqlx::query_as(&sql)
2057            .bind(PROMOTE_BATCH_SIZE)
2058            .fetch_all(&mut *tx)
2059            .await?;
2060
2061        let promoted = promoted_rows.len();
2062        self.metrics
2063            .record_promotion_batch(state, promoted as u64, promote_start.elapsed());
2064        if promoted == 0 {
2065            tx.commit().await?;
2066            return Ok((0, HashSet::new()));
2067        }
2068
2069        let queues: HashSet<String> = promoted_rows.into_iter().map(|(queue,)| queue).collect();
2070
2071        tx.commit().await?;
2072        Ok((promoted, queues))
2073    }
2074
2075    async fn rotate_queue_storage_queue(&self, prune_tracker: &PruneBackoffTracker) {
2076        let Some(runtime) = self.storage.queue_storage() else {
2077            return;
2078        };
2079
2080        match runtime.store.rotate(&self.pool).await {
2081            Ok(outcome) => {
2082                self.metrics.record_rotate_outcome("queue", &outcome);
2083                match outcome {
2084                    RotateOutcome::Rotated { slot, generation } => {
2085                        debug!(slot, generation, "Rotated queue storage queue segment");
2086                    }
2087                    RotateOutcome::SkippedBusy { slot, busy } => {
2088                        debug!(
2089                            slot,
2090                            ready_rows = busy.queue_ready,
2091                            claim_attempt_batches = busy.queue_claim_attempt_batches,
2092                            done_rows = busy.queue_done,
2093                            ready_segments = busy.queue_ready_segments,
2094                            receipt_completion_batches = busy.queue_receipt_completion_batches,
2095                            receipt_completion_tombstones =
2096                                busy.queue_receipt_completion_tombstones,
2097                            "Skipped busy queue storage queue segment",
2098                        );
2099                    }
2100                }
2101            }
2102            Err(err) => {
2103                error!(error = %err, "Failed to rotate queue storage queue segments");
2104                return;
2105            }
2106        }
2107
2108        if prune_tracker.should_skip(PRUNE_BRANCH_QUEUE) {
2109            debug!(branch = PRUNE_BRANCH_QUEUE, "Prune backed off this tick");
2110            return;
2111        }
2112
2113        match runtime
2114            .store
2115            .prune_oldest(&self.pool, self.failed_retention)
2116            .await
2117        {
2118            Ok(outcome) => {
2119                self.metrics.record_prune_outcome("queue", &outcome);
2120                prune_tracker.record_outcome(PRUNE_BRANCH_QUEUE, &outcome);
2121                match outcome {
2122                    PruneOutcome::Noop => {}
2123                    PruneOutcome::Pruned {
2124                        slot,
2125                        carried_failed_rows,
2126                    } => {
2127                        debug!(
2128                            slot,
2129                            carried_failed_rows, "Pruned queue storage queue segment"
2130                        );
2131                    }
2132                    PruneOutcome::Blocked { slot } => {
2133                        debug!(slot, "Queue storage queue segment prune blocked");
2134                    }
2135                    PruneOutcome::SkippedActive {
2136                        slot,
2137                        reason,
2138                        count,
2139                    } => {
2140                        debug!(
2141                            slot,
2142                            reason = reason.as_str(),
2143                            count,
2144                            "Queue storage queue segment still active",
2145                        );
2146                    }
2147                }
2148            }
2149            Err(err) => {
2150                error!(error = %err, "Failed to prune queue storage queue segments");
2151            }
2152        }
2153    }
2154
2155    async fn rotate_queue_storage_leases(&self, prune_tracker: &PruneBackoffTracker) {
2156        let Some(runtime) = self.storage.queue_storage() else {
2157            return;
2158        };
2159
2160        match runtime.store.rotate_leases(&self.pool).await {
2161            Ok(outcome) => {
2162                self.metrics.record_rotate_outcome("lease", &outcome);
2163                match outcome {
2164                    RotateOutcome::Rotated { slot, generation } => {
2165                        debug!(slot, generation, "Rotated queue storage lease segment");
2166                    }
2167                    RotateOutcome::SkippedBusy { slot, busy } => {
2168                        debug!(
2169                            slot,
2170                            lease_rows = busy.leases,
2171                            "Skipped busy queue storage lease segment",
2172                        );
2173                    }
2174                }
2175            }
2176            Err(err) => {
2177                error!(error = %err, "Failed to rotate queue storage lease segments");
2178                return;
2179            }
2180        }
2181
2182        if prune_tracker.should_skip(PRUNE_BRANCH_LEASE) {
2183            debug!(branch = PRUNE_BRANCH_LEASE, "Prune backed off this tick");
2184            return;
2185        }
2186
2187        match runtime.store.prune_oldest_leases(&self.pool).await {
2188            Ok(outcome) => {
2189                self.metrics.record_prune_outcome("lease", &outcome);
2190                prune_tracker.record_outcome(PRUNE_BRANCH_LEASE, &outcome);
2191                match outcome {
2192                    PruneOutcome::Noop => {}
2193                    PruneOutcome::Pruned { slot, .. } => {
2194                        debug!(slot, "Pruned queue storage lease segment");
2195                    }
2196                    PruneOutcome::Blocked { slot } => {
2197                        debug!(slot, "Queue storage lease segment prune blocked");
2198                    }
2199                    PruneOutcome::SkippedActive {
2200                        slot,
2201                        reason,
2202                        count,
2203                    } => {
2204                        debug!(
2205                            slot,
2206                            reason = reason.as_str(),
2207                            count,
2208                            "Queue storage lease segment still active",
2209                        );
2210                    }
2211                }
2212            }
2213            Err(err) => {
2214                error!(error = %err, "Failed to prune queue storage lease segments");
2215            }
2216        }
2217    }
2218
2219    /// Claim-ring maintenance tick (see ADR-023). Rotates the claim-ring
2220    /// cursor and prunes the oldest fully-closed partition, mirroring the
2221    /// lease-ring rotate/prune pair above.
2222    async fn rotate_queue_storage_claims(&self, prune_tracker: &PruneBackoffTracker) {
2223        let Some(runtime) = self.storage.queue_storage() else {
2224            return;
2225        };
2226
2227        match runtime.store.rotate_claims(&self.pool).await {
2228            Ok(outcome) => {
2229                self.metrics.record_rotate_outcome("claim", &outcome);
2230                match outcome {
2231                    RotateOutcome::Rotated { slot, generation } => {
2232                        debug!(slot, generation, "Rotated queue storage claim segment");
2233                    }
2234                    RotateOutcome::SkippedBusy { slot, busy } => {
2235                        debug!(
2236                            slot,
2237                            claim_rows = busy.claims,
2238                            closure_rows = busy.closures,
2239                            closure_batch_rows = busy.closure_batches,
2240                            "Skipped busy queue storage claim segment",
2241                        );
2242                    }
2243                }
2244            }
2245            Err(err) => {
2246                error!(error = %err, "Failed to rotate queue storage claim segments");
2247                return;
2248            }
2249        }
2250
2251        if prune_tracker.should_skip(PRUNE_BRANCH_CLAIM) {
2252            debug!(branch = PRUNE_BRANCH_CLAIM, "Prune backed off this tick");
2253            return;
2254        }
2255
2256        match runtime.store.prune_oldest_claims(&self.pool).await {
2257            Ok(outcome) => {
2258                self.metrics.record_prune_outcome("claim", &outcome);
2259                prune_tracker.record_outcome(PRUNE_BRANCH_CLAIM, &outcome);
2260                match outcome {
2261                    PruneOutcome::Noop => {}
2262                    PruneOutcome::Pruned { slot, .. } => {
2263                        debug!(slot, "Pruned queue storage claim segment");
2264                    }
2265                    PruneOutcome::Blocked { slot } => {
2266                        debug!(slot, "Queue storage claim segment prune blocked");
2267                    }
2268                    PruneOutcome::SkippedActive {
2269                        slot,
2270                        reason,
2271                        count,
2272                    } => {
2273                        debug!(
2274                            slot,
2275                            reason = reason.as_str(),
2276                            count,
2277                            "Queue storage claim segment still active",
2278                        );
2279                    }
2280                }
2281            }
2282            Err(err) => {
2283                error!(error = %err, "Failed to prune queue storage claim segments");
2284            }
2285        }
2286    }
2287
2288    /// Clean up completed/failed/cancelled jobs past retention.
2289    ///
2290    /// Targets `jobs_hot` directly (bypassing the `awa.jobs` INSTEAD OF trigger)
2291    /// since terminal-state jobs always reside in `jobs_hot`.
2292    /// Runs a global pass for queues without overrides, then per-queue passes
2293    /// for queues with custom retention.
2294    #[tracing::instrument(skip(self), name = "maintenance.cleanup")]
2295    async fn cleanup_completed(&self) {
2296        if matches!(self.storage, RuntimeStorage::QueueStorage(_)) {
2297            // Queue storage uses rotation/prune rather than row-by-row cleanup.
2298            return;
2299        }
2300
2301        let mut total_deleted: u64 = 0;
2302
2303        // Collect override queue names for the exclusion clause
2304        let override_queues: Vec<String> = self.queue_retention_overrides.keys().cloned().collect();
2305
2306        // Global pass: delete jobs in queues that do NOT have overrides
2307        let completed_retention_secs =
2308            i64::try_from(self.completed_retention.as_secs()).unwrap_or(i64::MAX);
2309        let failed_retention_secs =
2310            i64::try_from(self.failed_retention.as_secs()).unwrap_or(i64::MAX);
2311
2312        let global_result = if override_queues.is_empty() {
2313            sqlx::query(
2314                r#"
2315                DELETE FROM awa.jobs_hot
2316                WHERE id IN (
2317                    SELECT id FROM awa.jobs_hot
2318                    WHERE (state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2319                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint))
2320                    LIMIT $3
2321                )
2322                "#,
2323            )
2324            .bind(completed_retention_secs)
2325            .bind(failed_retention_secs)
2326            .bind(self.cleanup_batch_size)
2327            .execute(&self.pool)
2328            .await
2329        } else {
2330            sqlx::query(
2331                r#"
2332                DELETE FROM awa.jobs_hot
2333                WHERE id IN (
2334                    SELECT id FROM awa.jobs_hot
2335                    WHERE ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2336                       OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2337                      AND queue != ALL($4::text[])
2338                    LIMIT $3
2339                )
2340                "#,
2341            )
2342            .bind(completed_retention_secs)
2343            .bind(failed_retention_secs)
2344            .bind(self.cleanup_batch_size)
2345            .bind(&override_queues)
2346            .execute(&self.pool)
2347            .await
2348        };
2349
2350        match global_result {
2351            Ok(result) if result.rows_affected() > 0 => {
2352                total_deleted += result.rows_affected();
2353            }
2354            Err(err) => {
2355                error!(error = %err, "Failed to clean up old jobs (global pass)");
2356            }
2357            _ => {}
2358        }
2359
2360        // Per-queue override passes
2361        for (queue_name, policy) in &self.queue_retention_overrides {
2362            let queue_completed_secs =
2363                i64::try_from(policy.completed.as_secs()).unwrap_or(i64::MAX);
2364            let queue_failed_secs = i64::try_from(policy.failed.as_secs()).unwrap_or(i64::MAX);
2365
2366            match sqlx::query(
2367                r#"
2368                DELETE FROM awa.jobs_hot
2369                WHERE id IN (
2370                    SELECT id FROM awa.jobs_hot
2371                    WHERE queue = $4
2372                      AND ((state = 'completed' AND finalized_at < now() - make_interval(secs => $1::bigint))
2373                        OR (state IN ('failed', 'cancelled') AND finalized_at < now() - make_interval(secs => $2::bigint)))
2374                    LIMIT $3
2375                )
2376                "#,
2377            )
2378            .bind(queue_completed_secs)
2379            .bind(queue_failed_secs)
2380            .bind(self.cleanup_batch_size)
2381            .bind(queue_name)
2382            .execute(&self.pool)
2383            .await
2384            {
2385                Ok(result) if result.rows_affected() > 0 => {
2386                    total_deleted += result.rows_affected();
2387                    debug!(
2388                        queue = %queue_name,
2389                        count = result.rows_affected(),
2390                        "Cleaned up old jobs (queue override)"
2391                    );
2392                }
2393                Err(err) => {
2394                    error!(
2395                        queue = %queue_name,
2396                        error = %err,
2397                        "Failed to clean up old jobs (queue override)"
2398                    );
2399                }
2400                _ => {}
2401            }
2402        }
2403
2404        if total_deleted > 0 {
2405            info!(count = total_deleted, "Cleaned up old jobs");
2406        }
2407    }
2408
2409    #[tracing::instrument(skip(self), name = "maintenance.cleanup_dlq")]
2410    async fn cleanup_dlq_rows(&self) {
2411        let RuntimeStorage::QueueStorage(runtime) = &self.storage else {
2412            return;
2413        };
2414
2415        let schema = runtime.store.schema();
2416        let override_queues: Vec<&str> = self
2417            .queue_retention_overrides
2418            .iter()
2419            .filter(|(_, policy)| policy.dlq.is_some())
2420            .map(|(queue, _)| queue.as_str())
2421            .collect();
2422        let retention_secs = i64::try_from(self.dlq_retention.as_secs()).unwrap_or(i64::MAX);
2423
2424        let global_result = if override_queues.is_empty() {
2425            sqlx::query(&format!(
2426                r#"
2427                DELETE FROM {schema}.dlq_entries
2428                WHERE job_id IN (
2429                    SELECT job_id FROM {schema}.dlq_entries
2430                    WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2431                    LIMIT $2
2432                )
2433                "#
2434            ))
2435            .bind(retention_secs)
2436            .bind(self.dlq_cleanup_batch_size)
2437            .execute(&self.pool)
2438            .await
2439        } else {
2440            sqlx::query(&format!(
2441                r#"
2442                DELETE FROM {schema}.dlq_entries
2443                WHERE job_id IN (
2444                    SELECT job_id FROM {schema}.dlq_entries
2445                    WHERE dlq_at < now() - make_interval(secs => $1::bigint)
2446                      AND queue != ALL($3::text[])
2447                    LIMIT $2
2448                )
2449                "#
2450            ))
2451            .bind(retention_secs)
2452            .bind(self.dlq_cleanup_batch_size)
2453            .bind(&override_queues)
2454            .execute(&self.pool)
2455            .await
2456        };
2457
2458        match global_result {
2459            Ok(result) if result.rows_affected() > 0 => {
2460                self.metrics.record_dlq_purged(None, result.rows_affected());
2461            }
2462            Err(err) => {
2463                error!(error = %err, "Failed to clean up DLQ rows (global pass)");
2464            }
2465            _ => {}
2466        }
2467
2468        for (queue, policy) in &self.queue_retention_overrides {
2469            let Some(retention) = policy.dlq else {
2470                continue;
2471            };
2472            let retention_secs = i64::try_from(retention.as_secs()).unwrap_or(i64::MAX);
2473            match sqlx::query(&format!(
2474                r#"
2475                DELETE FROM {schema}.dlq_entries
2476                WHERE job_id IN (
2477                    SELECT job_id FROM {schema}.dlq_entries
2478                    WHERE queue = $3
2479                      AND dlq_at < now() - make_interval(secs => $1::bigint)
2480                    LIMIT $2
2481                )
2482                "#
2483            ))
2484            .bind(retention_secs)
2485            .bind(self.dlq_cleanup_batch_size)
2486            .bind(queue)
2487            .execute(&self.pool)
2488            .await
2489            {
2490                Ok(result) if result.rows_affected() > 0 => {
2491                    self.metrics
2492                        .record_dlq_purged(Some(queue), result.rows_affected());
2493                }
2494                Err(err) => {
2495                    error!(queue, error = %err, "Failed to clean up DLQ rows");
2496                }
2497                _ => {}
2498            }
2499        }
2500    }
2501}
2502
2503struct MaintenanceAliveGuard(Arc<AtomicBool>);
2504
2505impl Drop for MaintenanceAliveGuard {
2506    fn drop(&mut self) {
2507        self.0.store(false, Ordering::SeqCst);
2508    }
2509}
2510
2511/// Compute due fire times for a cron job row, using its expression and timezone.
2512///
2513/// Existing schedules can catch up missed fires up to `limit` when configured.
2514/// First registration always enqueues only the latest due fire to avoid
2515/// backfilling before the schedule was known to AWA.
2516fn compute_fire_times(
2517    row: &CronJobRow,
2518    now: chrono::DateTime<Utc>,
2519    limit: usize,
2520) -> Vec<chrono::DateTime<Utc>> {
2521    let cron = match Cron::new(&row.cron_expr).with_seconds_optional().parse() {
2522        Ok(c) => c,
2523        Err(err) => {
2524            error!(cron_name = %row.name, error = %err, "Invalid cron expression in database");
2525            return Vec::new();
2526        }
2527    };
2528
2529    let tz: chrono_tz::Tz = match row.timezone.parse() {
2530        Ok(tz) => tz,
2531        Err(err) => {
2532            error!(cron_name = %row.name, error = %err, "Invalid timezone in database");
2533            return Vec::new();
2534        }
2535    };
2536
2537    let search_start = match row.last_enqueued_at {
2538        Some(last) => last.with_timezone(&tz),
2539        // First registration: search from one interval before created_at
2540        // so that the current minute's fire is found. Without this,
2541        // a schedule created at HH:MM:30 won't find the HH:MM:00 fire
2542        // because created_at > fire_time, causing up to 60s delay.
2543        None => (row.created_at - chrono::Duration::minutes(1)).with_timezone(&tz),
2544    };
2545
2546    let missed_fire_policy = match CronMissedFirePolicy::parse(&row.missed_fire_policy) {
2547        Ok(policy) => policy,
2548        Err(err) => {
2549            error!(cron_name = %row.name, error = %err, "Invalid cron missed-fire policy in database");
2550            return Vec::new();
2551        }
2552    };
2553    let should_catch_up =
2554        row.last_enqueued_at.is_some() && missed_fire_policy == CronMissedFirePolicy::CatchUp;
2555
2556    if !should_catch_up {
2557        return latest_due_fire(&cron, tz, search_start, row.last_enqueued_at, now)
2558            .into_iter()
2559            .collect();
2560    }
2561
2562    let mut fire_times = Vec::new();
2563    for fire_time in cron.iter_from(search_start) {
2564        let fire_utc = fire_time.with_timezone(&Utc);
2565
2566        if fire_utc > now {
2567            break;
2568        }
2569
2570        if let Some(last) = row.last_enqueued_at {
2571            if fire_utc <= last {
2572                continue;
2573            }
2574        }
2575
2576        fire_times.push(fire_utc);
2577        if fire_times.len() >= limit {
2578            break;
2579        }
2580    }
2581
2582    fire_times
2583}
2584
2585fn latest_due_fire(
2586    cron: &Cron,
2587    tz: chrono_tz::Tz,
2588    search_start: chrono::DateTime<chrono_tz::Tz>,
2589    last_enqueued_at: Option<chrono::DateTime<Utc>>,
2590    now: chrono::DateTime<Utc>,
2591) -> Option<chrono::DateTime<Utc>> {
2592    let first_due = first_due_fire(cron, search_start, last_enqueued_at, now)?;
2593    let total_span_seconds = now.signed_duration_since(first_due).num_seconds().max(1);
2594    let mut lookback_seconds = 1_i64;
2595
2596    loop {
2597        // Croner has no previous-occurrence iterator. Search backward from
2598        // `now` with an exponentially growing window, then scan only that
2599        // small window to preserve coalesced/latest-only semantics.
2600        let window_start_utc = (now - chrono::Duration::seconds(lookback_seconds)).max(first_due);
2601        let window_start = window_start_utc.with_timezone(&tz);
2602        let next_in_window = cron
2603            .iter_from(window_start)
2604            .next()
2605            .map(|fire_time| fire_time.with_timezone(&Utc));
2606
2607        if next_in_window.is_some_and(|fire_utc| {
2608            fire_utc <= now && last_enqueued_at.is_none_or(|last| fire_utc > last)
2609        }) {
2610            return latest_due_fire_in_window(cron, window_start, last_enqueued_at, now)
2611                .or(Some(first_due));
2612        }
2613
2614        if lookback_seconds >= total_span_seconds {
2615            return Some(first_due);
2616        }
2617
2618        lookback_seconds = lookback_seconds.saturating_mul(2).min(total_span_seconds);
2619    }
2620}
2621
2622fn first_due_fire(
2623    cron: &Cron,
2624    search_start: chrono::DateTime<chrono_tz::Tz>,
2625    last_enqueued_at: Option<chrono::DateTime<Utc>>,
2626    now: chrono::DateTime<Utc>,
2627) -> Option<chrono::DateTime<Utc>> {
2628    for fire_time in cron.iter_from(search_start) {
2629        let fire_utc = fire_time.with_timezone(&Utc);
2630        if fire_utc > now {
2631            return None;
2632        }
2633        if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2634            return Some(fire_utc);
2635        }
2636    }
2637
2638    None
2639}
2640
2641fn latest_due_fire_in_window(
2642    cron: &Cron,
2643    window_start: chrono::DateTime<chrono_tz::Tz>,
2644    last_enqueued_at: Option<chrono::DateTime<Utc>>,
2645    now: chrono::DateTime<Utc>,
2646) -> Option<chrono::DateTime<Utc>> {
2647    let mut latest_fire = None;
2648
2649    for fire_time in cron.iter_from(window_start) {
2650        let fire_utc = fire_time.with_timezone(&Utc);
2651        if fire_utc > now {
2652            break;
2653        }
2654        if last_enqueued_at.is_none_or(|last| fire_utc > last) {
2655            latest_fire = Some(fire_utc);
2656        }
2657    }
2658
2659    latest_fire
2660}
2661
2662impl MaintenanceService {
2663    /// Clean up runtime snapshots older than 24 hours.
2664    /// Runs as part of the leader's cleanup cycle (not on every snapshot publish).
2665    #[tracing::instrument(skip(self), name = "maintenance.cleanup_runtime_snapshots")]
2666    async fn cleanup_stale_runtime_snapshots(&self) {
2667        if let Err(err) = awa_model::admin::cleanup_runtime_snapshots(
2668            &self.pool,
2669            chrono::TimeDelta::try_hours(24).unwrap(),
2670        )
2671        .await
2672        {
2673            tracing::warn!(error = %err, "Failed to clean up stale runtime snapshots");
2674        }
2675    }
2676
2677    /// Delete catalog rows whose last_seen_at is older than
2678    /// `descriptor_retention`. Runs alongside the existing cleanup cycle.
2679    /// When retention is zero this is a no-op, so this stays cheap for
2680    /// operators who don't want descriptor GC.
2681    #[tracing::instrument(skip(self), name = "maintenance.cleanup_stale_descriptors")]
2682    async fn cleanup_stale_descriptors(&self) {
2683        if self.descriptor_retention.is_zero() {
2684            return;
2685        }
2686        let max_age = chrono::TimeDelta::from_std(self.descriptor_retention)
2687            .unwrap_or_else(|_| chrono::TimeDelta::try_days(30).unwrap());
2688        for table in ["awa.queue_descriptors", "awa.job_kind_descriptors"] {
2689            match awa_model::admin::cleanup_stale_descriptors(&self.pool, table, max_age).await {
2690                Ok(deleted) if deleted > 0 => {
2691                    tracing::info!(table, deleted, "Cleaned up stale descriptor rows");
2692                }
2693                Ok(_) => {}
2694                Err(err) => {
2695                    tracing::warn!(table, error = %err, "Failed to clean up stale descriptors");
2696                }
2697            }
2698        }
2699    }
2700
2701    /// Drain dirty keys and recompute exact cached rows for recently-touched
2702    /// queues and kinds. This is the primary cache update mechanism — called
2703    /// every ~2s to keep dashboard counters fresh.
2704    #[tracing::instrument(skip(self), name = "maintenance.recompute_dirty_metadata")]
2705    async fn recompute_dirty_admin_metadata(&self) {
2706        if self.storage.queue_storage().is_some() {
2707            return;
2708        }
2709        match awa_model::admin::recompute_dirty_admin_metadata(&self.pool).await {
2710            Ok(count) if count > 0 => {
2711                tracing::debug!(count, "Recomputed dirty admin metadata keys");
2712            }
2713            Err(err) => {
2714                tracing::warn!(error = %err, "Failed to recompute dirty admin metadata");
2715            }
2716            _ => {}
2717        }
2718    }
2719
2720    /// Full reconciliation of admin metadata from base tables.
2721    /// Safety net for any drift — runs infrequently (~60s).
2722    #[tracing::instrument(skip(self), name = "maintenance.refresh_admin_metadata")]
2723    async fn refresh_admin_metadata(&self) {
2724        if self.storage.queue_storage().is_some() {
2725            return;
2726        }
2727        if let Err(err) = awa_model::admin::refresh_admin_metadata(&self.pool).await {
2728            tracing::warn!(error = %err, "Failed to refresh admin metadata");
2729        }
2730    }
2731
2732    /// Publish queue depth and lag as OTel gauge metrics.
2733    #[tracing::instrument(skip(self), name = "maintenance.queue_stats")]
2734    async fn publish_queue_health_metrics(&self) {
2735        if let RuntimeStorage::QueueStorage(runtime) = &self.storage {
2736            self.publish_queue_storage_health_metrics(runtime).await;
2737            return;
2738        }
2739
2740        let stats = match awa_model::admin::queue_overviews(&self.pool).await {
2741            Ok(stats) => stats,
2742            Err(err) => {
2743                tracing::warn!(error = %err, "Failed to query queue stats for metrics");
2744                return;
2745            }
2746        };
2747
2748        for queue_stat in &stats {
2749            let queue = &queue_stat.queue;
2750
2751            // Depth per state
2752            self.metrics
2753                .record_queue_depth(queue, "available", queue_stat.available);
2754            self.metrics
2755                .record_queue_depth(queue, "running", queue_stat.running);
2756            self.metrics
2757                .record_queue_depth(queue, "failed", queue_stat.failed);
2758            self.metrics
2759                .record_queue_depth(queue, "scheduled", queue_stat.scheduled);
2760            self.metrics
2761                .record_queue_depth(queue, "retryable", queue_stat.retryable);
2762            self.metrics
2763                .record_queue_depth(queue, "waiting_external", queue_stat.waiting_external);
2764
2765            // Lag
2766            if let Some(lag_seconds) = queue_stat.lag_seconds {
2767                self.metrics.record_queue_lag(queue, lag_seconds);
2768            }
2769        }
2770    }
2771
2772    async fn publish_queue_storage_health_metrics(
2773        &self,
2774        runtime: &crate::storage::QueueStorageRuntime,
2775    ) {
2776        let schema = runtime.store.schema();
2777        // This is a high-cadence metrics path, not an admin exact-count
2778        // endpoint. Keep it on queue-storage control tables and bounded
2779        // lane-head probes so long retained ready/done/receipt segments do
2780        // not compete with worker traffic. Admin surfaces that need exact
2781        // counts still go through QueueStorage::queue_counts().
2782        let rows: Vec<QueueStorageMetricRow> = match sqlx::query_as(&format!(
2783            r#"
2784            WITH head_signal AS (
2785                SELECT
2786                    enqueues.queue,
2787                    enqueues.priority,
2788                    enqueues.enqueue_shard,
2789                    {schema}.sequence_next_value(enqueues.seq_name) AS next_seq,
2790                    {schema}.sequence_next_value(claims.seq_name) AS claim_seq
2791                FROM {schema}.queue_enqueue_heads AS enqueues
2792                JOIN {schema}.queue_claim_heads AS claims
2793                  ON claims.queue = enqueues.queue
2794                 AND claims.priority = enqueues.priority
2795                 AND claims.enqueue_shard = enqueues.enqueue_shard
2796            ),
2797            queues AS (
2798                SELECT DISTINCT queue
2799                FROM (
2800                    SELECT queue FROM awa.queue_meta
2801                    UNION ALL
2802                    SELECT queue FROM head_signal
2803                    UNION ALL
2804                    SELECT queue FROM {schema}.leases
2805                    UNION ALL
2806                    SELECT queue FROM {schema}.deferred_jobs
2807                    UNION ALL
2808                    SELECT queue FROM {schema}.queue_terminal_live_counts
2809                    UNION ALL
2810                    SELECT queue FROM {schema}.queue_terminal_rollups
2811                    UNION ALL
2812                    SELECT queue FROM {schema}.dlq_entries
2813                ) queues
2814            ),
2815            ready AS (
2816                SELECT
2817                    head_signal.queue,
2818                    COALESCE(
2819                        sum(GREATEST(head_signal.next_seq - head_signal.claim_seq, 0)),
2820                        0
2821                    )::bigint AS available
2822                FROM head_signal
2823                GROUP BY head_signal.queue
2824            ),
2825            lag AS (
2826                SELECT
2827                    head_signal.queue,
2828                    EXTRACT(EPOCH FROM clock_timestamp() - min(next_ready.run_at))::double precision
2829                        AS lag_seconds
2830                FROM head_signal
2831                JOIN LATERAL (
2832                    SELECT ready.run_at
2833                    FROM {schema}.ready_entries AS ready
2834                    WHERE ready.queue = head_signal.queue
2835                      AND ready.priority = head_signal.priority
2836                      AND ready.enqueue_shard = head_signal.enqueue_shard
2837                      AND ready.lane_seq >= head_signal.claim_seq
2838                      AND NOT EXISTS (
2839                          SELECT 1
2840                          FROM {schema}.ready_tombstones AS tomb
2841                          WHERE tomb.ready_slot = ready.ready_slot
2842                            AND tomb.ready_generation = ready.ready_generation
2843                            AND tomb.queue = ready.queue
2844                            AND tomb.priority = ready.priority
2845                            AND tomb.enqueue_shard = ready.enqueue_shard
2846                            AND tomb.lane_seq = ready.lane_seq
2847                      )
2848                    ORDER BY ready.lane_seq
2849                    LIMIT 1
2850                ) AS next_ready ON TRUE
2851                GROUP BY head_signal.queue
2852            ),
2853            leases AS (
2854                SELECT
2855                    queue,
2856                    count(*) FILTER (WHERE state = 'running')::bigint AS running,
2857                    count(*) FILTER (WHERE state = 'waiting_external')::bigint
2858                        AS waiting_external
2859                FROM {schema}.leases
2860                GROUP BY queue
2861            ),
2862            deferred AS (
2863                SELECT
2864                    queue,
2865                    count(*) FILTER (WHERE state = 'scheduled')::bigint AS scheduled,
2866                    count(*) FILTER (WHERE state = 'retryable')::bigint AS retryable
2867                FROM {schema}.deferred_jobs
2868                GROUP BY queue
2869            ),
2870            terminal AS (
2871                SELECT
2872                    queue,
2873                    count(*)::bigint AS failed_done
2874                FROM {schema}.done_entries
2875                WHERE state = 'failed'
2876                GROUP BY queue
2877            ),
2878            dlq AS (
2879                SELECT
2880                    queue,
2881                    count(*)::bigint AS failed_dlq
2882                FROM {schema}.dlq_entries
2883                GROUP BY queue
2884            )
2885            SELECT
2886                queues.queue,
2887                COALESCE(ready.available, 0)::bigint AS available,
2888                COALESCE(leases.running, 0)::bigint AS running,
2889                COALESCE(leases.waiting_external, 0)::bigint AS waiting_external,
2890                COALESCE(deferred.scheduled, 0)::bigint AS scheduled,
2891                COALESCE(deferred.retryable, 0)::bigint AS retryable,
2892                COALESCE(terminal.failed_done, 0)::bigint AS failed_done,
2893                COALESCE(dlq.failed_dlq, 0)::bigint AS failed_dlq,
2894                lag.lag_seconds
2895            FROM queues
2896            LEFT JOIN ready
2897              ON ready.queue = queues.queue
2898            LEFT JOIN lag
2899              ON lag.queue = queues.queue
2900            LEFT JOIN leases
2901              ON leases.queue = queues.queue
2902            LEFT JOIN deferred
2903              ON deferred.queue = queues.queue
2904            LEFT JOIN terminal
2905              ON terminal.queue = queues.queue
2906            LEFT JOIN dlq
2907              ON dlq.queue = queues.queue
2908            ORDER BY queues.queue
2909            "#
2910        ))
2911        .fetch_all(&self.pool)
2912        .await
2913        {
2914            Ok(rows) => rows,
2915            Err(err) => {
2916                tracing::warn!(error = %err, "Failed to query queue storage stats for metrics");
2917                return;
2918            }
2919        };
2920
2921        for (
2922            queue,
2923            available,
2924            running,
2925            waiting_external,
2926            scheduled,
2927            retryable,
2928            failed_done,
2929            failed_dlq,
2930            lag_seconds,
2931        ) in rows
2932        {
2933            self.metrics
2934                .record_queue_depth(&queue, "available", available);
2935            self.metrics.record_queue_depth(&queue, "running", running);
2936            self.metrics
2937                .record_queue_depth(&queue, "failed", failed_done + failed_dlq);
2938            self.metrics
2939                .record_queue_depth(&queue, "scheduled", scheduled);
2940            self.metrics
2941                .record_queue_depth(&queue, "retryable", retryable);
2942            self.metrics
2943                .record_queue_depth(&queue, "waiting_external", waiting_external);
2944            self.metrics.record_dlq_depth(&queue, failed_dlq);
2945
2946            if let Some(lag_seconds) = lag_seconds {
2947                self.metrics.record_queue_lag(&queue, lag_seconds);
2948            }
2949        }
2950    }
2951}
2952
2953#[cfg(test)]
2954mod tests {
2955    use super::*;
2956    use awa_model::{migrations, QueueStorage, QueueStorageConfig};
2957    use chrono::TimeZone;
2958    use sqlx::postgres::PgPoolOptions;
2959    use std::sync::OnceLock;
2960
2961    fn cron_row(
2962        cron_expr: &str,
2963        created_at: chrono::DateTime<Utc>,
2964        last_enqueued_at: Option<chrono::DateTime<Utc>>,
2965        missed_fire_policy: CronMissedFirePolicy,
2966    ) -> CronJobRow {
2967        CronJobRow {
2968            name: "test_cron".to_string(),
2969            cron_expr: cron_expr.to_string(),
2970            timezone: "UTC".to_string(),
2971            kind: "test_job".to_string(),
2972            queue: "default".to_string(),
2973            args: serde_json::json!({}),
2974            priority: 2,
2975            max_attempts: 25,
2976            tags: Vec::new(),
2977            metadata: serde_json::json!({}),
2978            missed_fire_policy: missed_fire_policy.as_str().to_string(),
2979            last_enqueued_at,
2980            created_at,
2981            updated_at: created_at,
2982            paused_at: None,
2983            paused_by: None,
2984        }
2985    }
2986
2987    #[test]
2988    fn compute_fire_times_coalesces_missed_existing_fires_by_default() {
2989        let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
2990        let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
2991        let row = cron_row(
2992            "*/5 * * * * *",
2993            last,
2994            Some(last),
2995            CronMissedFirePolicy::Coalesce,
2996        );
2997
2998        let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
2999
3000        assert_eq!(
3001            fires,
3002            vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3003        );
3004    }
3005
3006    #[test]
3007    fn compute_fire_times_coalesces_to_latest_fire_after_long_outage() {
3008        let last = Utc.with_ymd_and_hms(2026, 5, 6, 12, 0, 0).unwrap();
3009        let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3010        let row = cron_row(
3011            "*/1 * * * * *",
3012            last,
3013            Some(last),
3014            CronMissedFirePolicy::Coalesce,
3015        );
3016
3017        let fires = compute_fire_times(&row, now, 2);
3018
3019        assert_eq!(
3020            fires,
3021            vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap()]
3022        );
3023    }
3024
3025    #[test]
3026    fn compute_fire_times_catches_up_when_policy_requests_it() {
3027        let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3028        let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap();
3029        let row = cron_row(
3030            "*/5 * * * * *",
3031            last,
3032            Some(last),
3033            CronMissedFirePolicy::CatchUp,
3034        );
3035
3036        let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3037
3038        assert_eq!(
3039            fires,
3040            vec![
3041                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3042                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3043                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 15).unwrap(),
3044                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 20).unwrap(),
3045            ]
3046        );
3047    }
3048
3049    #[test]
3050    fn compute_fire_times_limits_catch_up_work() {
3051        let last = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 0).unwrap();
3052        let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3053        let row = cron_row(
3054            "*/5 * * * * *",
3055            last,
3056            Some(last),
3057            CronMissedFirePolicy::CatchUp,
3058        );
3059
3060        let fires = compute_fire_times(&row, now, 2);
3061
3062        assert_eq!(
3063            fires,
3064            vec![
3065                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 5).unwrap(),
3066                Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 10).unwrap(),
3067            ]
3068        );
3069    }
3070
3071    // ── MaintenanceBranchTracker (#242) ──────────────────────────────
3072
3073    fn metrics_for_test() -> crate::metrics::AwaMetrics {
3074        crate::metrics::AwaMetrics::from_global()
3075    }
3076
3077    fn database_url() -> String {
3078        std::env::var("DATABASE_URL")
3079            .unwrap_or_else(|_| "postgres://postgres:test@localhost:15432/awa_test".to_string())
3080    }
3081
3082    fn db_test_mutex() -> &'static tokio::sync::Mutex<()> {
3083        static MUTEX: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
3084        MUTEX.get_or_init(|| tokio::sync::Mutex::new(()))
3085    }
3086
3087    async fn ensure_database_exists(url: &str) {
3088        let parts = url
3089            .rsplit_once('/')
3090            .expect("DATABASE_URL must include a database name");
3091        let database_name = parts.1.to_string();
3092        let admin_url = format!("{}/postgres", parts.0);
3093        let admin_pool = PgPoolOptions::new()
3094            .max_connections(1)
3095            .connect(&admin_url)
3096            .await
3097            .expect("Failed to connect to admin database for maintenance tests");
3098        let create_sql = format!("CREATE DATABASE {database_name}");
3099        match sqlx::query(&create_sql).execute(&admin_pool).await {
3100            Ok(_) => {}
3101            Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("42P04") => {}
3102            Err(err) => panic!("Failed to create maintenance test database {database_name}: {err}"),
3103        }
3104    }
3105
3106    async fn setup_pool(max_connections: u32) -> PgPool {
3107        let url = database_url();
3108        ensure_database_exists(&url).await;
3109        PgPoolOptions::new()
3110            .max_connections(max_connections)
3111            .acquire_timeout(Duration::from_secs(5))
3112            .connect(&url)
3113            .await
3114            .expect("Failed to connect to maintenance test database")
3115    }
3116
3117    async fn reset_schema(pool: &PgPool) {
3118        sqlx::raw_sql("DROP SCHEMA IF EXISTS awa CASCADE")
3119            .execute(pool)
3120            .await
3121            .expect("Failed to drop awa schema");
3122    }
3123
3124    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3125    async fn queue_storage_metrics_query_uses_bounded_observability_path() {
3126        let _guard = db_test_mutex().lock().await;
3127        let pool = setup_pool(4).await;
3128        reset_schema(&pool).await;
3129        migrations::run(&pool)
3130            .await
3131            .expect("migrations should succeed");
3132
3133        let store = QueueStorage::new(QueueStorageConfig::default()).expect("queue storage");
3134        store.install(&pool).await.expect("queue storage install");
3135
3136        let runtime = crate::storage::QueueStorageRuntime::new(
3137            QueueStorageConfig::default(),
3138            Duration::from_millis(1000),
3139            Duration::from_millis(250),
3140        )
3141        .expect("queue storage runtime");
3142        let service = MaintenanceService::new(
3143            pool,
3144            metrics_for_test(),
3145            Arc::new(AtomicBool::new(true)),
3146            Arc::new(AtomicBool::new(true)),
3147            CancellationToken::new(),
3148            Arc::new(Vec::new()),
3149            InFlightMap::default(),
3150            RuntimeStorage::QueueStorage(runtime.clone()),
3151            Arc::new(HashMap::new()),
3152            Arc::new(HashMap::new()),
3153        );
3154
3155        let mut receipt_lock_tx = service
3156            .pool
3157            .begin()
3158            .await
3159            .expect("begin receipt lock transaction");
3160        sqlx::query(
3161            "LOCK TABLE awa.lease_claims, awa.lease_claim_closures, awa.lease_claim_closure_batches IN ACCESS EXCLUSIVE MODE",
3162        )
3163        .execute(receipt_lock_tx.as_mut())
3164        .await
3165        .expect("lock receipt tables");
3166
3167        tokio::time::timeout(
3168            Duration::from_secs(3),
3169            service.publish_queue_storage_health_metrics(&runtime),
3170        )
3171        .await
3172        .expect("queue-storage metrics must not wait on receipt tables");
3173
3174        receipt_lock_tx
3175            .rollback()
3176            .await
3177            .expect("release receipt table locks");
3178    }
3179
3180    #[test]
3181    fn branch_tracker_initial_state_has_no_history() {
3182        let tracker = MaintenanceBranchTracker::new();
3183        assert_eq!(tracker.snapshot("promote_scheduled"), None);
3184    }
3185
3186    #[test]
3187    fn branch_tracker_finish_records_last_duration() {
3188        let tracker = MaintenanceBranchTracker::new();
3189        let metrics = metrics_for_test();
3190        let timer = tracker
3191            .try_begin("promote_scheduled", Duration::from_secs(1), &metrics)
3192            .expect("first tick should not be skipped");
3193        // Body would run here in production; for the test we just finish.
3194        timer.finish();
3195        let (last_duration, is_delayed) = tracker
3196            .snapshot("promote_scheduled")
3197            .expect("snapshot should exist after one finish");
3198        assert!(last_duration.is_some());
3199        assert!(
3200            !is_delayed,
3201            "first tick has no prior duration → not delayed"
3202        );
3203    }
3204
3205    /// Drive `n` consecutive `try_begin` ticks. When the body would
3206    /// have run (try_begin returns Some), call `record_finish` with
3207    /// `body_duration` to simulate the body completing in that time.
3208    /// When the body is skipped (cooldown returns None), `last_duration`
3209    /// is intentionally NOT updated — matching production where a
3210    /// skipped tick doesn't produce a new sample. Returns one bool per
3211    /// tick: true if the body ran, false if it was skipped.
3212    ///
3213    /// This helper does NOT seed `last_duration` itself. Tests that need
3214    /// an initial sample (e.g., to drive the very first overrun
3215    /// observation) must call `seed_last_duration` first.
3216    fn replay_ticks(
3217        tracker: &MaintenanceBranchTracker,
3218        branch: &'static str,
3219        body_duration: Duration,
3220        tick_interval: Duration,
3221        n: u32,
3222    ) -> Vec<bool> {
3223        let metrics = metrics_for_test();
3224        let mut ran = Vec::with_capacity(n as usize);
3225        for _ in 0..n {
3226            let timer_opt = tracker.try_begin(branch, tick_interval, &metrics);
3227            let did_run = timer_opt.is_some();
3228            ran.push(did_run);
3229            if did_run {
3230                tracker.record_finish(branch, body_duration);
3231            }
3232        }
3233        ran
3234    }
3235
3236    /// Explicitly seed `last_duration` as if a prior body completed in
3237    /// `dur`. Use to set up the initial state for tests that need
3238    /// the first `try_begin` to see a sample.
3239    fn seed_last_duration(tracker: &MaintenanceBranchTracker, branch: &'static str, dur: Duration) {
3240        tracker
3241            .branches
3242            .lock()
3243            .unwrap()
3244            .entry(branch)
3245            .or_default()
3246            .last_duration = Some(dur);
3247    }
3248
3249    #[test]
3250    fn branch_tracker_single_overrun_does_not_flip() {
3251        // A single overrun sample (clearly above the upper threshold)
3252        // shouldn't flip is_delayed — K-consecutive is required.
3253        let tracker = MaintenanceBranchTracker::new();
3254        seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3255        replay_ticks(
3256            &tracker,
3257            "cleanup",
3258            Duration::from_millis(200), // upper threshold for 100ms tick is 150
3259            Duration::from_millis(100),
3260            1,
3261        );
3262        assert!(
3263            !tracker.snapshot("cleanup").unwrap().1,
3264            "single overrun must not flip is_delayed (K=3 required)"
3265        );
3266    }
3267
3268    #[test]
3269    fn branch_tracker_deadband_sample_does_not_advance_counters() {
3270        // Samples between LOWER (70ms) and UPPER (150ms) of a 100ms
3271        // tick — e.g., 101ms — must NOT advance either counter. This
3272        // is the fix the bench post-mortem prescribed: 49ms-vs-51ms
3273        // flap at the boundary no longer accumulates toward a flip.
3274        let tracker = MaintenanceBranchTracker::new();
3275        seed_last_duration(&tracker, "cleanup", Duration::from_millis(101));
3276        replay_ticks(
3277            &tracker,
3278            "cleanup",
3279            Duration::from_millis(101),
3280            Duration::from_millis(100),
3281            5,
3282        );
3283        let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3284        assert_eq!(cooldown, 0, "deadband samples should not arm cooldown");
3285        assert_eq!(overrun, 0, "deadband sample 101ms must not advance overrun");
3286        assert_eq!(ontime, 0, "deadband sample 101ms must not advance ontime");
3287    }
3288
3289    #[test]
3290    fn branch_tracker_k_consecutive_overruns_flips_and_arms_cooldown() {
3291        // After K=3 consecutive samples clearly above UPPER, flip to
3292        // delayed AND arm cooldown. The third try_begin consumes the
3293        // K-th sample, crosses the threshold, and returns None (the
3294        // flip-tick skips the body so we don't immediately do more
3295        // expensive work).
3296        let tracker = MaintenanceBranchTracker::new();
3297        seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3298        let ran = replay_ticks(
3299            &tracker,
3300            "cleanup",
3301            Duration::from_millis(250),
3302            Duration::from_millis(100),
3303            OVERRUN_HYSTERESIS_K,
3304        );
3305        assert_eq!(ran, vec![true, true, false], "flip-tick skips body");
3306        let (cooldown, overrun, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3307        assert!(tracker.snapshot("cleanup").unwrap().1, "flipped to delayed");
3308        assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3309        assert_eq!(
3310            cooldown, BRANCH_COOLDOWN_TICKS,
3311            "cooldown armed to BRANCH_COOLDOWN_TICKS at flip"
3312        );
3313    }
3314
3315    #[test]
3316    fn branch_tracker_without_cooldown_tracks_overrun_but_keeps_running() {
3317        let tracker = MaintenanceBranchTracker::new();
3318        let metrics = metrics_for_test();
3319        seed_last_duration(
3320            &tracker,
3321            "terminal_count_rollup",
3322            Duration::from_millis(250),
3323        );
3324
3325        let mut ran = Vec::new();
3326        for _ in 0..OVERRUN_HYSTERESIS_K {
3327            let timer = tracker.try_begin_without_cooldown(
3328                "terminal_count_rollup",
3329                Duration::from_millis(100),
3330                &metrics,
3331            );
3332            ran.push(timer.is_some());
3333            if timer.is_some() {
3334                tracker.record_finish("terminal_count_rollup", Duration::from_millis(250));
3335            }
3336        }
3337
3338        assert_eq!(
3339            ran,
3340            vec![true, true, true],
3341            "no-cooldown branches should keep running on overrun"
3342        );
3343        let (cooldown, overrun, _) = tracker
3344            .cooldown_snapshot("terminal_count_rollup")
3345            .expect("snapshot");
3346        assert_eq!(cooldown, 0, "no-cooldown branch must not arm cooldown");
3347        assert_eq!(overrun, OVERRUN_HYSTERESIS_K);
3348        assert!(
3349            tracker.snapshot("terminal_count_rollup").unwrap().1,
3350            "overrun state should still be observable"
3351        );
3352    }
3353
3354    #[test]
3355    fn branch_tracker_cooldown_skips_body() {
3356        // Drive past the flip, then assert subsequent ticks return
3357        // None until cooldown decrements to zero. After the flip,
3358        // `last_duration` was consumed by `take()`, so the cooldown
3359        // gate is the only thing returning None here — exactly the
3360        // production shape we want.
3361        let tracker = MaintenanceBranchTracker::new();
3362        seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3363        replay_ticks(
3364            &tracker,
3365            "cleanup",
3366            Duration::from_millis(250),
3367            Duration::from_millis(100),
3368            OVERRUN_HYSTERESIS_K,
3369        );
3370        // Subsequent ticks return None until cooldown drains. We pass
3371        // 50ms as the body_duration but it's unused — `record_finish`
3372        // is only called when a body actually runs.
3373        let ran = replay_ticks(
3374            &tracker,
3375            "cleanup",
3376            Duration::from_millis(50),
3377            Duration::from_millis(100),
3378            BRANCH_COOLDOWN_TICKS,
3379        );
3380        assert!(
3381            ran.iter().all(|&r| !r),
3382            "every tick during cooldown must skip body"
3383        );
3384        let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3385        assert_eq!(cooldown, 0, "cooldown decrements to zero");
3386    }
3387
3388    #[test]
3389    fn branch_tracker_cooldown_expires_then_body_runs() {
3390        // After cooldown drains, the first post-cooldown try_begin
3391        // sees `last_duration = None` (consumed at the flip, no body
3392        // ran during cooldown), so no hysteresis check fires; the
3393        // body simply runs. The counters do NOT advance on this
3394        // first post-cooldown tick — the sample this body produces
3395        // is evaluated on the *next* try_begin.
3396        let tracker = MaintenanceBranchTracker::new();
3397        seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3398        replay_ticks(
3399            &tracker,
3400            "cleanup",
3401            Duration::from_millis(250),
3402            Duration::from_millis(100),
3403            OVERRUN_HYSTERESIS_K,
3404        );
3405        replay_ticks(
3406            &tracker,
3407            "cleanup",
3408            Duration::from_millis(50),
3409            Duration::from_millis(100),
3410            BRANCH_COOLDOWN_TICKS,
3411        );
3412        let ran = replay_ticks(
3413            &tracker,
3414            "cleanup",
3415            Duration::from_millis(50),
3416            Duration::from_millis(100),
3417            1,
3418        );
3419        assert_eq!(ran, vec![true], "post-cooldown body runs");
3420        let (cooldown, overrun, ontime) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3421        assert_eq!(
3422            cooldown, 0,
3423            "cooldown stays at zero after a single fast body"
3424        );
3425        assert_eq!(
3426            overrun, OVERRUN_HYSTERESIS_K,
3427            "consecutive_overrun preserved across cooldown (no eval on this tick)"
3428        );
3429        assert_eq!(
3430            ontime, 0,
3431            "ontime advances on the next tick — this one had no sample to evaluate"
3432        );
3433    }
3434
3435    #[test]
3436    fn branch_tracker_cooldown_rearms_on_continued_overrun() {
3437        // After cooldown drains, the first post-cooldown body runs
3438        // and is slow. That slow body's sample is evaluated on the
3439        // *second* post-cooldown try_begin, where it re-arms cooldown
3440        // because consecutive_overrun is already at K (preserved
3441        // across the cooldown).
3442        let tracker = MaintenanceBranchTracker::new();
3443        seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3444        replay_ticks(
3445            &tracker,
3446            "cleanup",
3447            Duration::from_millis(250),
3448            Duration::from_millis(100),
3449            OVERRUN_HYSTERESIS_K,
3450        );
3451        replay_ticks(
3452            &tracker,
3453            "cleanup",
3454            Duration::from_millis(50),
3455            Duration::from_millis(100),
3456            BRANCH_COOLDOWN_TICKS,
3457        );
3458
3459        // Tick 1 post-cooldown: take None, no eval, body runs slow.
3460        // Tick 2: take Some(250), consecutive_overrun saturates past
3461        // K, is_delayed && cross → re-arm cooldown.
3462        let ran = replay_ticks(
3463            &tracker,
3464            "cleanup",
3465            Duration::from_millis(250),
3466            Duration::from_millis(100),
3467            2,
3468        );
3469        assert_eq!(
3470            ran,
3471            vec![true, false],
3472            "first tick runs body; second tick re-arms cooldown"
3473        );
3474        let (cooldown, _, _) = tracker.cooldown_snapshot("cleanup").expect("snapshot");
3475        assert_eq!(cooldown, BRANCH_COOLDOWN_TICKS, "cooldown re-armed");
3476        assert!(
3477            tracker.snapshot("cleanup").unwrap().1,
3478            "still delayed across re-arm"
3479        );
3480    }
3481
3482    #[test]
3483    fn branch_tracker_intermittent_overrun_does_not_flip() {
3484        // Pattern: 3 clearly-overrun samples interleaved with
3485        // clearly-on-time samples. Non-consecutive overruns never
3486        // accumulate K=3 because each on-time tick resets the counter.
3487        let tracker = MaintenanceBranchTracker::new();
3488        seed_last_duration(&tracker, "cleanup", Duration::from_millis(200));
3489        for over in [true, false, true, false, true] {
3490            let dur = if over {
3491                Duration::from_millis(200)
3492            } else {
3493                Duration::from_millis(50)
3494            };
3495            replay_ticks(&tracker, "cleanup", dur, Duration::from_millis(100), 1);
3496        }
3497        assert!(
3498            !tracker.snapshot("cleanup").unwrap().1,
3499            "intermittent overruns must not flip"
3500        );
3501    }
3502
3503    #[test]
3504    fn branch_tracker_recovers_only_after_k_ontime_ticks_post_cooldown() {
3505        // After cooldown drains, K consecutive on-time samples must be
3506        // *evaluated* before is_delayed clears. The very first
3507        // post-cooldown body has no sample to evaluate (last_duration
3508        // was consumed at the flip), so K evaluable samples require
3509        // K+1 post-cooldown ticks: one to seed, K to advance ontime.
3510        let tracker = MaintenanceBranchTracker::new();
3511        seed_last_duration(&tracker, "cleanup", Duration::from_millis(250));
3512        replay_ticks(
3513            &tracker,
3514            "cleanup",
3515            Duration::from_millis(250),
3516            Duration::from_millis(100),
3517            OVERRUN_HYSTERESIS_K,
3518        );
3519        replay_ticks(
3520            &tracker,
3521            "cleanup",
3522            Duration::from_millis(50),
3523            Duration::from_millis(100),
3524            BRANCH_COOLDOWN_TICKS,
3525        );
3526
3527        // K post-cooldown ticks: tick 1 seeds, ticks 2..K evaluate K-1
3528        // on-time samples. Still delayed.
3529        replay_ticks(
3530            &tracker,
3531            "cleanup",
3532            Duration::from_millis(50),
3533            Duration::from_millis(100),
3534            OVERRUN_HYSTERESIS_K,
3535        );
3536        assert!(
3537            tracker.snapshot("cleanup").unwrap().1,
3538            "still delayed after only K-1 evaluations"
3539        );
3540
3541        // (K+1)-th tick evaluates the K-th on-time sample → recovery.
3542        replay_ticks(
3543            &tracker,
3544            "cleanup",
3545            Duration::from_millis(50),
3546            Duration::from_millis(100),
3547            1,
3548        );
3549        assert!(
3550            !tracker.snapshot("cleanup").unwrap().1,
3551            "recovered after K evaluable on-time samples"
3552        );
3553    }
3554
3555    #[test]
3556    fn branch_tracker_per_branch_state_is_independent() {
3557        // Drive "cleanup" past the K=3 overrun threshold while
3558        // "promote_scheduled" stays on-time the whole time. Branches
3559        // must not share state.
3560        let tracker = MaintenanceBranchTracker::new();
3561        seed_last_duration(&tracker, "cleanup", Duration::from_millis(500));
3562        replay_ticks(
3563            &tracker,
3564            "cleanup",
3565            Duration::from_millis(500),
3566            Duration::from_millis(100),
3567            OVERRUN_HYSTERESIS_K,
3568        );
3569        seed_last_duration(&tracker, "promote_scheduled", Duration::from_millis(10));
3570        replay_ticks(
3571            &tracker,
3572            "promote_scheduled",
3573            Duration::from_millis(10),
3574            Duration::from_millis(250),
3575            OVERRUN_HYSTERESIS_K,
3576        );
3577        assert!(tracker.snapshot("cleanup").unwrap().1);
3578        assert!(!tracker.snapshot("promote_scheduled").unwrap().1);
3579    }
3580
3581    // ── PruneBackoffTracker (#169) ────────────────────────────────────
3582
3583    fn skip_active(slot: i32) -> PruneOutcome {
3584        PruneOutcome::SkippedActive {
3585            slot,
3586            reason: SkipReason::LeaseActive,
3587            count: 1,
3588        }
3589    }
3590
3591    #[test]
3592    fn prune_backoff_initial_state_does_not_skip() {
3593        let tracker = PruneBackoffTracker::new();
3594        assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3595        assert_eq!(
3596            tracker.snapshot(PRUNE_BRANCH_LEASE),
3597            Some((0, 0)),
3598            "polling once must not introduce backoff"
3599        );
3600    }
3601
3602    #[test]
3603    fn prune_backoff_skipped_active_doubles_then_resets_on_pruned() {
3604        let tracker = PruneBackoffTracker::new();
3605
3606        // First failure: backoff_level=1, skip the next 2 ticks.
3607        tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3608        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3609        assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3610        assert!(tracker.should_skip(PRUNE_BRANCH_LEASE));
3611        assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3612
3613        // Second failure: backoff_level=2, skip the next 4.
3614        tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3615        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3616
3617        // Recovery: Pruned clears everything.
3618        tracker.record_outcome(
3619            PRUNE_BRANCH_LEASE,
3620            &PruneOutcome::Pruned {
3621                slot: 0,
3622                carried_failed_rows: 0,
3623            },
3624        );
3625        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((0, 0)));
3626        assert!(!tracker.should_skip(PRUNE_BRANCH_LEASE));
3627    }
3628
3629    #[test]
3630    fn prune_backoff_blocked_increases_level_same_as_skipped_active() {
3631        let tracker = PruneBackoffTracker::new();
3632        tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3633        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((2, 1)));
3634        tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Blocked { slot: 0 });
3635        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3636    }
3637
3638    #[test]
3639    fn prune_backoff_noop_is_neutral() {
3640        let tracker = PruneBackoffTracker::new();
3641        // Build up some backoff first so we have observable state.
3642        tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3643        let before = tracker.snapshot(PRUNE_BRANCH_LEASE);
3644        tracker.record_outcome(PRUNE_BRANCH_LEASE, &PruneOutcome::Noop);
3645        let after = tracker.snapshot(PRUNE_BRANCH_LEASE);
3646        assert_eq!(
3647            before, after,
3648            "Noop must not change backoff state — there was nothing to do, not a failure"
3649        );
3650    }
3651
3652    #[test]
3653    fn prune_backoff_caps_at_max_level() {
3654        let tracker = PruneBackoffTracker::new();
3655        // Drive past the cap to make sure backoff_level saturates.
3656        for _ in 0..(MAX_PRUNE_BACKOFF_LEVEL as u32 + 5) {
3657            tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3658        }
3659        let (skip_remaining, backoff_level) =
3660            tracker.snapshot(PRUNE_BRANCH_LEASE).expect("snapshot");
3661        assert_eq!(backoff_level, MAX_PRUNE_BACKOFF_LEVEL);
3662        assert_eq!(skip_remaining, 1u32 << MAX_PRUNE_BACKOFF_LEVEL);
3663    }
3664
3665    #[test]
3666    fn prune_backoff_per_branch_state_is_independent() {
3667        let tracker = PruneBackoffTracker::new();
3668        tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3669        tracker.record_outcome(PRUNE_BRANCH_LEASE, &skip_active(0));
3670        // Claim branch is untouched.
3671        assert_eq!(tracker.snapshot(PRUNE_BRANCH_LEASE), Some((4, 2)));
3672        assert_eq!(tracker.snapshot(PRUNE_BRANCH_CLAIM), None);
3673        assert!(!tracker.should_skip(PRUNE_BRANCH_CLAIM));
3674    }
3675
3676    #[test]
3677    fn compute_fire_times_keeps_first_registration_latest_only() {
3678        let created_at = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 30).unwrap();
3679        let now = Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap();
3680        let row = cron_row(
3681            "*/5 * * * * *",
3682            created_at,
3683            None,
3684            CronMissedFirePolicy::CatchUp,
3685        );
3686
3687        let fires = compute_fire_times(&row, now, CRON_CATCH_UP_LIMIT);
3688
3689        assert_eq!(
3690            fires,
3691            vec![Utc.with_ymd_and_hms(2026, 5, 7, 12, 0, 55).unwrap()]
3692        );
3693    }
3694}