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