Skip to main content

awa_worker/
maintenance.rs

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