Skip to main content

awa_model/
queue_storage.rs

1use crate::admin::{CallbackConfig, CallbackPollResult};
2use crate::dlq::{ListDlqFilter, RetryFromDlqOpts};
3use crate::error::AwaError;
4use crate::insert::prepare_row_raw;
5use crate::{InsertParams, JobRow, JobState};
6use chrono::TimeDelta;
7use chrono::{DateTime, Utc};
8use sqlx::{PgPool, Postgres, QueryBuilder};
9use std::collections::hash_map::DefaultHasher;
10use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
11use std::hash::{Hash, Hasher};
12use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering};
13use std::sync::Mutex;
14use std::time::Duration;
15use uuid::Uuid;
16
17const DEFAULT_SCHEMA: &str = "awa";
18const DEFAULT_QUEUE_SLOT_COUNT: usize = 16;
19const DEFAULT_LEASE_SLOT_COUNT: usize = 8;
20const DEFAULT_CLAIM_SLOT_COUNT: usize = 8;
21const DEFAULT_QUEUE_STRIPE_COUNT: usize = 1;
22const QUEUE_STRIPE_DELIMITER: &str = "#";
23const COPY_NULL_SENTINEL: &str = "__AWA_NULL__";
24const COPY_CHUNK_TARGET_BYTES: usize = 256 * 1024;
25const TERMINAL_COUNTER_BUCKETS: i16 = 256;
26
27/// Deterministically map an ordering key to a shard in `[0, shards)`.
28///
29/// Inputs sharing a key always produce the same shard, which is what
30/// lets producers preserve FIFO within a key when the destination
31/// queue is sharded. `shards <= 1` returns shard 0 unconditionally.
32///
33/// The hash is a portable 64-bit rolling hash over the raw key bytes.
34/// It is intentionally simple enough to implement byte-for-byte in
35/// `awa.insert_job_compat`, so SQL, Rust, and Python producers that
36/// pass the same ordering-key bytes land jobs on the same shard.
37pub fn shard_for_ordering_key(ordering_key: &[u8], shards: i16) -> i16 {
38    if shards <= 1 {
39        return 0;
40    }
41    let mut hash: u128 = 14_695_981_039_346_656_037;
42    const PRIME: u128 = 1_099_511_628_211;
43    const MASK: u128 = u64::MAX as u128;
44    for byte in ordering_key {
45        hash = hash.wrapping_mul(PRIME).wrapping_add(*byte as u128) & MASK;
46    }
47    (hash % (shards as u128)) as i16
48}
49
50fn terminal_counter_bucket(job_id: i64) -> i16 {
51    job_id.rem_euclid(TERMINAL_COUNTER_BUCKETS as i64) as i16
52}
53
54#[derive(Debug, Clone)]
55pub struct QueueStorageConfig {
56    pub schema: String,
57    pub queue_slot_count: usize,
58    pub lease_slot_count: usize,
59    /// Number of child partitions the receipt ring splits
60    /// `lease_claims` / `lease_claim_closures` across (ADR-023).
61    /// Mirrors `lease_slot_count`: a small fixed set of slots
62    /// reclaimed by rotation + TRUNCATE rather than by row-level
63    /// DELETE.
64    pub claim_slot_count: usize,
65    pub queue_stripe_count: usize,
66    /// Use the receipt-plane short path for zero-deadline jobs:
67    /// claim writes a row into `lease_claims` and completion writes
68    /// a closure tombstone into `lease_claim_closures`, both
69    /// reclaimed by claim-ring rotation + TRUNCATE. Default `true`.
70    /// Set to `false` to force every claim through the legacy
71    /// `leases` materialization path.
72    pub lease_claim_receipts: bool,
73}
74
75impl Default for QueueStorageConfig {
76    fn default() -> Self {
77        Self {
78            schema: DEFAULT_SCHEMA.to_string(),
79            queue_slot_count: DEFAULT_QUEUE_SLOT_COUNT,
80            lease_slot_count: DEFAULT_LEASE_SLOT_COUNT,
81            claim_slot_count: DEFAULT_CLAIM_SLOT_COUNT,
82            queue_stripe_count: DEFAULT_QUEUE_STRIPE_COUNT,
83            lease_claim_receipts: true,
84        }
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
89pub struct ClaimedEntry {
90    pub queue: String,
91    pub priority: i16,
92    pub lane_seq: i64,
93    pub ready_slot: i32,
94    pub ready_generation: i64,
95    pub lease_slot: i32,
96    pub lease_generation: i64,
97    /// ADR-023: the `claim_slot` partition this attempt's
98    /// `lease_claims` receipt landed in. The completion path uses this
99    /// to target the matching `lease_claim_closures` partition when
100    /// writing the closure tombstone.
101    pub claim_slot: i32,
102    pub lease_claim_receipt: bool,
103    /// The enqueue shard the row was claimed from. Routes the
104    /// terminal `done_entries` write onto the correct shard's
105    /// `(ready_slot, queue, priority, enqueue_shard, lane_seq)` key
106    /// and is the join predicate for receipt and admin lookups that
107    /// touch `queue_claim_heads` / `ready_entries` / `leases`.
108    pub enqueue_shard: i16,
109}
110
111#[derive(Debug, Clone)]
112pub struct ClaimedRuntimeJob {
113    pub claim: ClaimedEntry,
114    pub job: JobRow,
115    pub unique_states: Option<String>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::FromRow)]
119pub struct QueueClaimerLease {
120    pub claimer_slot: i16,
121    pub lease_epoch: i64,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::FromRow)]
125struct QueueClaimerLeaseRow {
126    claimer_slot: i16,
127    lease_epoch: i64,
128    last_claimed_at: DateTime<Utc>,
129    expires_at: DateTime<Utc>,
130}
131
132impl QueueClaimerLeaseRow {
133    fn lease(self) -> QueueClaimerLease {
134        QueueClaimerLease {
135            claimer_slot: self.claimer_slot,
136            lease_epoch: self.lease_epoch,
137        }
138    }
139
140    fn needs_refresh(
141        self,
142        now: DateTime<Utc>,
143        lease_ttl: Duration,
144        idle_threshold: Duration,
145    ) -> bool {
146        let Ok(idle_refresh_delta) = TimeDelta::from_std(idle_threshold / 2) else {
147            return true;
148        };
149        let Ok(expiry_refresh_delta) = TimeDelta::from_std(lease_ttl / 2) else {
150            return true;
151        };
152
153        self.last_claimed_at <= now - idle_refresh_delta
154            || self.expires_at <= now + expiry_refresh_delta
155    }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::FromRow)]
159pub struct QueueClaimerState {
160    pub target_claimers: i16,
161}
162
163impl ClaimedRuntimeJob {
164    fn into_done_row(self, finalized_at: DateTime<Utc>) -> Result<DoneJobRow, AwaError> {
165        let payload = QueueStorage::payload_from_parts(
166            self.job.metadata,
167            self.job.tags,
168            self.job.errors,
169            None,
170        )?;
171
172        Ok(DoneJobRow {
173            ready_slot: self.claim.ready_slot,
174            ready_generation: self.claim.ready_generation,
175            job_id: self.job.id,
176            kind: self.job.kind,
177            queue: self.job.queue,
178            args: self.job.args,
179            state: JobState::Completed,
180            priority: self.claim.priority,
181            attempt: self.job.attempt,
182            run_lease: self.job.run_lease,
183            max_attempts: self.job.max_attempts,
184            lane_seq: self.claim.lane_seq,
185            enqueue_shard: self.claim.enqueue_shard,
186            run_at: self.job.run_at,
187            attempted_at: self.job.attempted_at,
188            finalized_at,
189            created_at: self.job.created_at,
190            unique_key: self.job.unique_key,
191            unique_states: self.unique_states,
192            payload,
193        })
194    }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct QueueCounts {
199    pub available: i64,
200    pub running: i64,
201    /// Count of rows in *any* terminal state (`completed`, `failed`, or
202    /// `cancelled`) for the queue. The name reflects what the field
203    /// actually counts: it is `count(*) FROM done_entries` semantics, not
204    /// `count(*) WHERE state = 'completed'`. The historical name
205    /// `completed` was a misnomer — `queue_counts_exact` has always
206    /// included failed and cancelled terminals; renamed in #290 along
207    /// with the counter-backed read path.
208    pub terminal: i64,
209}
210
211/// Cheap available-only signal used by the dispatcher's claimer-sizing
212/// control loop. Derives the count from enqueue and claim sequence cursors
213/// summed over the queue's physical stripes — two PK reads per lane, O(few
214/// rows) regardless of backlog size.
215///
216/// This is intentionally a separate type from [`QueueCounts`]: the
217/// dispatcher claim hot path only consumes the available count, and
218/// returning a `QueueCounts` with two perpetually-zero fields would
219/// invite future code to read `.running` or `.terminal` and silently
220/// get wrong answers. Code that legitimately needs the full counts
221/// should call [`QueueStorage::queue_counts`].
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub(crate) struct AvailableSignal {
224    pub available: i64,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum RotateOutcome {
229    Rotated {
230        slot: i32,
231        generation: i64,
232    },
233    /// Target slot has live state; rotation deferred. `busy` carries the
234    /// per-table row counts observed at the gate (only fields relevant to
235    /// the ring being rotated are populated).
236    SkippedBusy {
237        slot: i32,
238        busy: BusyCounts,
239    },
240}
241
242/// Per-table row counts observed at a rotation gate. Each ring populates
243/// only the fields meaningful for it; unused fields stay zero. The
244/// maintenance loop emits one OTel metric label per non-zero field so
245/// dashboards can attribute "rotation pinned" to the responsible side.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub struct BusyCounts {
248    /// Queue ring: rows in the next `ready_entries` child.
249    pub queue_ready: i64,
250    /// Queue ring: rows in the next `done_entries` child.
251    pub queue_done: i64,
252    /// Queue ring: rows in the next `ready_tombstones` child.
253    pub queue_tombstones: i64,
254    /// Queue ring: rows in the next `queue_terminal_count_deltas` child.
255    pub queue_terminal_deltas: i64,
256    /// Lease ring: rows in the next `leases` child.
257    pub leases: i64,
258    /// Claim ring: rows in the next `lease_claims` child.
259    pub claims: i64,
260    /// Claim ring: rows in the next `lease_claim_closures` child.
261    pub closures: i64,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum PruneOutcome {
266    Noop,
267    Pruned {
268        slot: i32,
269    },
270    /// Lock acquisition timed out (held-tx, lock contention).
271    Blocked {
272        slot: i32,
273    },
274    /// Target slot still has live state. `reason` discriminates which gate
275    /// fired and `count` gives its magnitude.
276    SkippedActive {
277        slot: i32,
278        reason: SkipReason,
279        count: i64,
280    },
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
284pub struct TerminalDeltaRollupOutcome {
285    pub rolled_slots: usize,
286    pub delta_rows: i64,
287    pub grouped_keys: i64,
288    pub skipped_active_slots: usize,
289    pub blocked_slots: usize,
290    pub skipped_mvcc_pinned: bool,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294enum TerminalDeltaSlotRollup {
295    Empty,
296    Rolled { delta_rows: i64, grouped_keys: i64 },
297    SkippedActive,
298    SkippedMvccPinned,
299    Blocked,
300}
301
302/// Discriminator for [`PruneOutcome::SkippedActive`].
303///
304/// Multiple gates can fire `SkippedActive` for the same ring (e.g. queue
305/// prune checks both `active_leases` and `pending_ready`). Carrying the
306/// reason separately from `count` lets dashboards split out "ring saturated
307/// because backlog never drained" from "leases lingering on prior
308/// generation" without re-parsing log lines.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum SkipReason {
311    /// Queue prune: leases on the prior generation persist.
312    QueueActiveLeases,
313    /// Queue prune: ready rows without matching done or tombstone evidence.
314    QueuePendingReady,
315    /// Lease prune: target slot equals the current slot (rotator race).
316    LeaseCurrent,
317    /// Lease prune: pending leases on target slot.
318    LeaseActive,
319    /// Claim prune: target slot equals the current slot (rotator race).
320    ClaimCurrent,
321    /// Claim prune: open claims on target slot (no matching closure).
322    ClaimOpen,
323}
324
325impl SkipReason {
326    /// Stable, low-cardinality label suitable for OTel metric attributes.
327    pub fn as_str(self) -> &'static str {
328        match self {
329            Self::QueueActiveLeases => "queue.active_leases",
330            Self::QueuePendingReady => "queue.pending_ready",
331            Self::LeaseCurrent => "lease.current",
332            Self::LeaseActive => "lease.active",
333            Self::ClaimCurrent => "claim.current",
334            Self::ClaimOpen => "claim.open",
335        }
336    }
337}
338
339fn map_sqlx_error(err: sqlx::Error) -> AwaError {
340    if let sqlx::Error::Database(ref db_err) = err {
341        if db_err.code().as_deref() == Some("23505") {
342            return AwaError::UniqueConflict {
343                constraint: db_err.constraint().map(|c| c.to_string()),
344            };
345        }
346    }
347    AwaError::Database(err)
348}
349
350fn is_lock_contention_error(err: &sqlx::Error) -> bool {
351    matches!(
352        err,
353        sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("55P03")
354    )
355}
356
357fn validate_ident(ident: &str) -> Result<(), AwaError> {
358    let mut chars = ident.chars();
359    match chars.next() {
360        Some(first) if first.is_ascii_lowercase() || first == '_' => {}
361        _ => {
362            return Err(AwaError::Validation(format!(
363                "invalid SQL identifier: {ident}"
364            )));
365        }
366    }
367
368    if chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
369        Ok(())
370    } else {
371        Err(AwaError::Validation(format!(
372            "invalid SQL identifier: {ident}"
373        )))
374    }
375}
376
377fn ready_child_name(schema: &str, slot: usize) -> String {
378    format!("{schema}.ready_entries_{slot}")
379}
380
381fn done_child_name(schema: &str, slot: usize) -> String {
382    format!("{schema}.done_entries_{slot}")
383}
384
385fn ready_tombstone_child_name(schema: &str, slot: usize) -> String {
386    format!("{schema}.ready_tombstones_{slot}")
387}
388
389fn terminal_delta_child_name(schema: &str, slot: usize) -> String {
390    format!("{schema}.queue_terminal_count_deltas_{slot}")
391}
392
393fn done_ready_join(schema: &str, done_alias: &str, ready_alias: &str) -> String {
394    format!(
395        r#"
396            LEFT JOIN {schema}.ready_entries AS {ready_alias}
397              ON {ready_alias}.ready_slot = {done_alias}.ready_slot
398             AND {ready_alias}.ready_generation = {done_alias}.ready_generation
399             AND {ready_alias}.queue = {done_alias}.queue
400             AND {ready_alias}.priority = {done_alias}.priority
401             AND {ready_alias}.enqueue_shard = {done_alias}.enqueue_shard
402             AND {ready_alias}.lane_seq = {done_alias}.lane_seq
403        "#
404    )
405}
406
407fn done_row_projection(done_alias: &str, ready_alias: &str) -> String {
408    format!(
409        r#"
410                {done_alias}.ready_slot,
411                {done_alias}.ready_generation,
412                {done_alias}.job_id,
413                {done_alias}.kind,
414                {done_alias}.queue,
415                COALESCE({done_alias}.args, {ready_alias}.args, '{{}}'::jsonb) AS args,
416                {done_alias}.state,
417                {done_alias}.priority,
418                {done_alias}.attempt,
419                {done_alias}.run_lease,
420                COALESCE({done_alias}.max_attempts, {ready_alias}.max_attempts, 25::smallint) AS max_attempts,
421                {done_alias}.lane_seq,
422                {done_alias}.enqueue_shard,
423                COALESCE({done_alias}.run_at, {ready_alias}.run_at, {done_alias}.finalized_at) AS run_at,
424                COALESCE({done_alias}.attempted_at, {ready_alias}.attempted_at) AS attempted_at,
425                {done_alias}.finalized_at,
426                COALESCE({done_alias}.created_at, {ready_alias}.created_at, {done_alias}.finalized_at) AS created_at,
427                COALESCE({done_alias}.unique_key, {ready_alias}.unique_key) AS unique_key,
428                COALESCE({done_alias}.unique_states, {ready_alias}.unique_states) AS unique_states,
429                COALESCE({done_alias}.payload, {ready_alias}.payload, '{{}}'::jsonb) AS payload
430        "#
431    )
432}
433
434fn lease_child_name(schema: &str, slot: usize) -> String {
435    format!("{schema}.leases_{slot}")
436}
437
438fn claim_child_name(schema: &str, slot: usize) -> String {
439    format!("{schema}.lease_claims_{slot}")
440}
441
442fn closure_child_name(schema: &str, slot: usize) -> String {
443    format!("{schema}.lease_claim_closures_{slot}")
444}
445
446fn oldest_initialized_ring_slot(
447    current_slot: i32,
448    generation: i64,
449    slot_count: i32,
450) -> Option<(i32, i64)> {
451    if slot_count <= 1 {
452        return None;
453    }
454
455    let initialized_slots = (generation + 1).min(slot_count as i64) as i32;
456    if initialized_slots <= 1 {
457        return None;
458    }
459
460    let offset = initialized_slots - 1;
461    let oldest_slot = (current_slot - offset).rem_euclid(slot_count);
462    let oldest_generation = generation - offset as i64;
463    if oldest_generation < 0 {
464        return None;
465    }
466
467    Some((oldest_slot, oldest_generation))
468}
469
470#[cfg(test)]
471mod identifier_tests {
472    use super::{validate_ident, QueueStorage, QueueStorageConfig};
473
474    #[test]
475    fn queue_storage_schema_identifiers_are_lowercase_unquoted_names() {
476        for ident in ["awa", "awa_queue_storage", "_awa123"] {
477            validate_ident(ident).expect("identifier should be accepted");
478        }
479
480        for ident in ["Awa", "awa-queue", "123awa", "awa.queue"] {
481            assert!(
482                validate_ident(ident).is_err(),
483                "identifier should be rejected: {ident}"
484            );
485        }
486    }
487
488    #[test]
489    fn default_queue_storage_schema_requires_default_physical_shape() {
490        for config in [
491            QueueStorageConfig {
492                queue_slot_count: 32,
493                ..Default::default()
494            },
495            QueueStorageConfig {
496                lease_slot_count: 4,
497                ..Default::default()
498            },
499            QueueStorageConfig {
500                claim_slot_count: 4,
501                ..Default::default()
502            },
503            QueueStorageConfig {
504                lease_claim_receipts: false,
505                ..Default::default()
506            },
507        ] {
508            let err = QueueStorage::new(config).expect_err("default awa schema shape must reject");
509            assert!(
510                err.to_string()
511                    .contains("default `awa` queue-storage schema"),
512                "unexpected error: {err}"
513            );
514        }
515
516        QueueStorage::new(QueueStorageConfig {
517            schema: "awa_custom".to_string(),
518            queue_slot_count: 4,
519            lease_slot_count: 2,
520            claim_slot_count: 2,
521            lease_claim_receipts: false,
522            ..Default::default()
523        })
524        .expect("custom schema should allow custom physical shape");
525    }
526}
527
528#[cfg(test)]
529mod shard_routing_tests {
530    use super::shard_for_ordering_key;
531    use std::collections::HashSet;
532
533    #[test]
534    fn shards_le_one_collapse_to_zero() {
535        assert_eq!(shard_for_ordering_key(b"customer-42", 1), 0);
536        assert_eq!(shard_for_ordering_key(b"", 1), 0);
537        assert_eq!(shard_for_ordering_key(b"customer-42", 0), 0);
538    }
539
540    #[test]
541    fn same_key_lands_on_same_shard() {
542        let key = b"customer-42";
543        let first = shard_for_ordering_key(key, 8);
544        for _ in 0..100 {
545            assert_eq!(shard_for_ordering_key(key, 8), first);
546        }
547    }
548
549    #[test]
550    fn shard_is_within_range() {
551        for n in 0..256u32 {
552            let key = format!("order-{n}");
553            let shard = shard_for_ordering_key(key.as_bytes(), 8);
554            assert!((0..8).contains(&shard));
555        }
556    }
557
558    #[test]
559    fn distinct_keys_spread_across_shards() {
560        let mut hit: HashSet<i16> = HashSet::new();
561        for n in 0..1024u32 {
562            let key = format!("order-{n}");
563            hit.insert(shard_for_ordering_key(key.as_bytes(), 8));
564        }
565        assert_eq!(hit.len(), 8, "1024 distinct keys should cover all 8 shards");
566    }
567}
568
569#[cfg(test)]
570mod ring_slot_tests {
571    use super::oldest_initialized_ring_slot;
572
573    #[test]
574    fn oldest_initialized_ring_slot_is_none_until_second_slot_exists() {
575        assert_eq!(oldest_initialized_ring_slot(0, 0, 8), None);
576    }
577
578    #[test]
579    fn oldest_initialized_ring_slot_tracks_partial_ring_startup() {
580        assert_eq!(oldest_initialized_ring_slot(1, 1, 8), Some((0, 0)));
581        assert_eq!(oldest_initialized_ring_slot(2, 2, 8), Some((0, 0)));
582        assert_eq!(oldest_initialized_ring_slot(3, 3, 8), Some((0, 0)));
583    }
584
585    #[test]
586    fn oldest_initialized_ring_slot_wraps_after_full_rotation() {
587        assert_eq!(oldest_initialized_ring_slot(7, 7, 8), Some((0, 0)));
588        assert_eq!(oldest_initialized_ring_slot(0, 8, 8), Some((1, 1)));
589        assert_eq!(oldest_initialized_ring_slot(1, 9, 8), Some((2, 2)));
590    }
591}
592
593#[cfg(test)]
594mod claim_cursor_advance_tests {
595    use super::{ClaimCursorAdvance, QueueStorage};
596
597    fn advance(next_seq: i64, only_if_current: Option<i64>) -> ClaimCursorAdvance {
598        ClaimCursorAdvance {
599            queue: "queue".to_string(),
600            priority: 2,
601            enqueue_shard: 0,
602            next_seq,
603            only_if_current,
604        }
605    }
606
607    #[test]
608    fn normalize_claim_cursor_advances_sorts_conditional_lane_updates() {
609        let normalized = QueueStorage::normalize_claim_cursor_advances(&[
610            advance(7, Some(6)),
611            advance(6, Some(5)),
612            advance(8, Some(7)),
613        ]);
614
615        let ordered: Vec<(i64, i64)> = normalized
616            .iter()
617            .map(|advance| (advance.only_if_current.unwrap(), advance.next_seq))
618            .collect();
619        assert_eq!(ordered, vec![(5, 6), (6, 7), (7, 8)]);
620    }
621
622    #[test]
623    fn normalize_claim_cursor_advances_coalesces_unconditional_lane_updates() {
624        let normalized = QueueStorage::normalize_claim_cursor_advances(&[
625            advance(3, None),
626            advance(5, None),
627            advance(4, Some(3)),
628        ]);
629
630        assert_eq!(normalized.len(), 1);
631        assert_eq!(normalized[0].next_seq, 5);
632        assert_eq!(normalized[0].only_if_current, None);
633    }
634}
635
636fn default_payload_metadata() -> serde_json::Value {
637    serde_json::json!({})
638}
639
640fn is_empty_json_object(value: &serde_json::Value) -> bool {
641    value.as_object().is_some_and(serde_json::Map::is_empty)
642}
643
644#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
645struct RuntimePayload {
646    #[serde(
647        default = "default_payload_metadata",
648        skip_serializing_if = "is_empty_json_object"
649    )]
650    metadata: serde_json::Value,
651    #[serde(default, skip_serializing_if = "Vec::is_empty")]
652    tags: Vec<String>,
653    #[serde(default, skip_serializing_if = "Vec::is_empty")]
654    errors: Vec<serde_json::Value>,
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    progress: Option<serde_json::Value>,
657}
658
659impl Default for RuntimePayload {
660    fn default() -> Self {
661        Self {
662            metadata: default_payload_metadata(),
663            tags: Vec::new(),
664            errors: Vec::new(),
665            progress: None,
666        }
667    }
668}
669
670impl RuntimePayload {
671    fn from_json(value: serde_json::Value) -> Result<Self, AwaError> {
672        if value.is_null() {
673            return Ok(Self::default());
674        }
675        let payload: Self = serde_json::from_value(value)?;
676        if !payload.metadata.is_object() {
677            return Err(AwaError::Validation(
678                "queue storage payload metadata must be a JSON object".to_string(),
679            ));
680        }
681        Ok(payload)
682    }
683
684    fn into_json(self) -> serde_json::Value {
685        serde_json::to_value(self).expect("runtime payload serializes")
686    }
687
688    fn errors_option(&self) -> Option<Vec<serde_json::Value>> {
689        (!self.errors.is_empty()).then(|| self.errors.clone())
690    }
691
692    fn push_error(&mut self, error: serde_json::Value) {
693        self.errors.push(error);
694    }
695
696    fn set_progress(&mut self, progress: Option<serde_json::Value>) {
697        self.progress = progress;
698    }
699
700    fn insert_callback_result(&mut self, payload: Option<serde_json::Value>) {
701        let metadata = self
702            .metadata
703            .as_object_mut()
704            .expect("runtime payload metadata object");
705        metadata.insert(
706            "_awa_callback_result".to_string(),
707            payload.unwrap_or(serde_json::Value::Null),
708        );
709    }
710}
711
712#[cfg(test)]
713mod runtime_payload_tests {
714    use super::{storage_payload, terminal_storage_payload, RuntimePayload};
715
716    #[test]
717    fn default_runtime_payload_serializes_compactly() {
718        assert_eq!(
719            RuntimePayload::default().into_json(),
720            serde_json::json!({}),
721            "default payloads should not write empty metadata/tags/errors/progress"
722        );
723        assert_eq!(
724            storage_payload(&RuntimePayload::default().into_json()),
725            None
726        );
727    }
728
729    #[test]
730    fn missing_runtime_payload_fields_round_trip_with_defaults() {
731        let payload = RuntimePayload::from_json(serde_json::json!({})).unwrap();
732
733        assert_eq!(payload.metadata, serde_json::json!({}));
734        assert!(payload.tags.is_empty());
735        assert!(payload.errors.is_empty());
736        assert_eq!(payload.progress, None);
737        assert_eq!(payload.into_json(), serde_json::json!({}));
738    }
739
740    #[test]
741    fn null_runtime_payload_round_trips_with_defaults() {
742        let payload = RuntimePayload::from_json(serde_json::Value::Null).unwrap();
743
744        assert_eq!(payload.metadata, serde_json::json!({}));
745        assert!(payload.tags.is_empty());
746        assert!(payload.errors.is_empty());
747        assert_eq!(payload.progress, None);
748        assert_eq!(storage_payload(&payload.into_json()), None);
749    }
750
751    #[test]
752    fn legacy_expanded_runtime_payload_round_trips_to_compact_form() {
753        let payload = RuntimePayload::from_json(serde_json::json!({
754            "metadata": {},
755            "tags": [],
756            "errors": [],
757            "progress": null
758        }))
759        .unwrap();
760
761        assert_eq!(payload.metadata, serde_json::json!({}));
762        assert!(payload.tags.is_empty());
763        assert!(payload.errors.is_empty());
764        assert_eq!(payload.progress, None);
765        assert_eq!(payload.into_json(), serde_json::json!({}));
766    }
767
768    #[test]
769    fn non_default_runtime_payload_fields_are_preserved() {
770        let payload = RuntimePayload::from_json(serde_json::json!({
771            "metadata": { "source": "test" },
772            "tags": ["fast"],
773            "errors": [{ "message": "boom" }],
774            "progress": { "step": 1 }
775        }))
776        .unwrap();
777
778        assert_eq!(
779            payload.into_json(),
780            serde_json::json!({
781                "metadata": { "source": "test" },
782                "tags": ["fast"],
783                "errors": [{ "message": "boom" }],
784                "progress": { "step": 1 }
785            })
786        );
787    }
788
789    #[test]
790    fn unchanged_terminal_payload_elides_storage_copy() {
791        let payload = serde_json::json!({
792            "metadata": { "source": "test" },
793            "tags": ["fast"]
794        });
795
796        assert_eq!(terminal_storage_payload(&payload, Some(&payload)), None);
797
798        let changed = serde_json::json!({
799            "metadata": { "source": "test" },
800            "tags": ["fast"],
801            "errors": [{ "message": "boom" }]
802        });
803        assert_eq!(
804            terminal_storage_payload(&changed, Some(&payload)),
805            Some(&changed)
806        );
807    }
808}
809
810fn unique_state_claims(unique_states: Option<&str>, state: JobState) -> bool {
811    let Some(bitmask) = unique_states else {
812        return false;
813    };
814    let idx = state.bit_position() as usize;
815    bitmask.as_bytes().get(idx).is_some_and(|bit| *bit == b'1')
816}
817
818fn write_copy_field(buf: &mut Vec<u8>, value: &str) {
819    if value.contains(',')
820        || value.contains('"')
821        || value.contains('\n')
822        || value.contains('\r')
823        || value.contains('\\')
824        || value == COPY_NULL_SENTINEL
825    {
826        buf.push(b'"');
827        for byte in value.bytes() {
828            if byte == b'"' {
829                buf.push(b'"');
830            }
831            buf.push(byte);
832        }
833        buf.push(b'"');
834    } else {
835        buf.extend_from_slice(value.as_bytes());
836    }
837}
838
839fn write_copy_json(buf: &mut Vec<u8>, value: &serde_json::Value) {
840    let json = serde_json::to_string(value).expect("JSON serialization should not fail");
841    write_copy_field(buf, &json);
842}
843
844fn storage_payload(value: &serde_json::Value) -> Option<&serde_json::Value> {
845    (!is_storage_payload_empty(value)).then_some(value)
846}
847
848fn terminal_storage_payload<'a>(
849    value: &'a serde_json::Value,
850    ready_payload: Option<&serde_json::Value>,
851) -> Option<&'a serde_json::Value> {
852    if is_storage_payload_empty(value) || ready_payload.is_some_and(|ready| ready == value) {
853        None
854    } else {
855        Some(value)
856    }
857}
858
859fn is_storage_payload_empty(value: &serde_json::Value) -> bool {
860    value.is_null() || is_empty_json_object(value)
861}
862
863fn write_copy_storage_payload(buf: &mut Vec<u8>, value: &serde_json::Value) {
864    match storage_payload(value) {
865        Some(value) => write_copy_json(buf, value),
866        None => buf.extend_from_slice(COPY_NULL_SENTINEL.as_bytes()),
867    }
868}
869
870fn write_copy_datetime(buf: &mut Vec<u8>, value: DateTime<Utc>) {
871    write_copy_field(buf, &value.to_rfc3339());
872}
873
874fn write_copy_optional_datetime(buf: &mut Vec<u8>, value: Option<DateTime<Utc>>) {
875    match value {
876        Some(value) => write_copy_datetime(buf, value),
877        None => buf.extend_from_slice(COPY_NULL_SENTINEL.as_bytes()),
878    }
879}
880
881fn write_copy_optional_bytes(buf: &mut Vec<u8>, value: &Option<Vec<u8>>) {
882    match value {
883        Some(bytes) => {
884            let bytea_hex = format!("\\x{}", hex::encode(bytes));
885            write_copy_field(buf, &bytea_hex);
886        }
887        None => buf.extend_from_slice(COPY_NULL_SENTINEL.as_bytes()),
888    }
889}
890
891fn write_copy_optional_string(buf: &mut Vec<u8>, value: Option<&str>) {
892    match value {
893        Some(value) => write_copy_field(buf, value),
894        None => buf.extend_from_slice(COPY_NULL_SENTINEL.as_bytes()),
895    }
896}
897
898fn write_ready_copy_row(
899    buf: &mut Vec<u8>,
900    ready_slot: i32,
901    ready_generation: i64,
902    row: &RuntimeReadyInsert,
903) {
904    buf.extend_from_slice(ready_slot.to_string().as_bytes());
905    buf.push(b',');
906    buf.extend_from_slice(ready_generation.to_string().as_bytes());
907    buf.push(b',');
908    buf.extend_from_slice(row.job_id.to_string().as_bytes());
909    buf.push(b',');
910    write_copy_field(buf, &row.kind);
911    buf.push(b',');
912    write_copy_field(buf, &row.queue);
913    buf.push(b',');
914    write_copy_json(buf, &row.args);
915    buf.push(b',');
916    buf.extend_from_slice(row.priority.to_string().as_bytes());
917    buf.push(b',');
918    buf.extend_from_slice(row.attempt.to_string().as_bytes());
919    buf.push(b',');
920    buf.extend_from_slice(row.run_lease.to_string().as_bytes());
921    buf.push(b',');
922    buf.extend_from_slice(row.max_attempts.to_string().as_bytes());
923    buf.push(b',');
924    buf.extend_from_slice(row.lane_seq.to_string().as_bytes());
925    buf.push(b',');
926    buf.extend_from_slice(row.enqueue_shard.to_string().as_bytes());
927    buf.push(b',');
928    write_copy_datetime(buf, row.run_at);
929    buf.push(b',');
930    write_copy_optional_datetime(buf, row.attempted_at);
931    buf.push(b',');
932    write_copy_datetime(buf, row.created_at);
933    buf.push(b',');
934    write_copy_optional_bytes(buf, &row.unique_key);
935    buf.push(b',');
936    write_copy_optional_string(buf, row.unique_states.as_deref());
937    buf.push(b',');
938    write_copy_storage_payload(buf, &row.payload);
939    buf.push(b'\n');
940}
941
942fn write_deferred_copy_row(buf: &mut Vec<u8>, row: &DeferredJobRow) {
943    buf.extend_from_slice(row.job_id.to_string().as_bytes());
944    buf.push(b',');
945    write_copy_field(buf, &row.kind);
946    buf.push(b',');
947    write_copy_field(buf, &row.queue);
948    buf.push(b',');
949    write_copy_json(buf, &row.args);
950    buf.push(b',');
951    write_copy_field(buf, &row.state.to_string());
952    buf.push(b',');
953    buf.extend_from_slice(row.priority.to_string().as_bytes());
954    buf.push(b',');
955    buf.extend_from_slice(row.attempt.to_string().as_bytes());
956    buf.push(b',');
957    buf.extend_from_slice(row.run_lease.to_string().as_bytes());
958    buf.push(b',');
959    buf.extend_from_slice(row.max_attempts.to_string().as_bytes());
960    buf.push(b',');
961    write_copy_datetime(buf, row.run_at);
962    buf.push(b',');
963    write_copy_optional_datetime(buf, row.attempted_at);
964    buf.push(b',');
965    write_copy_optional_datetime(buf, row.finalized_at);
966    buf.push(b',');
967    write_copy_datetime(buf, row.created_at);
968    buf.push(b',');
969    write_copy_optional_bytes(buf, &row.unique_key);
970    buf.push(b',');
971    write_copy_optional_string(buf, row.unique_states.as_deref());
972    buf.push(b',');
973    write_copy_storage_payload(buf, &row.payload);
974    buf.push(b'\n');
975}
976
977fn lifecycle_error(error: impl Into<String>, attempt: i16, terminal: bool) -> serde_json::Value {
978    let mut value = serde_json::json!({
979        "error": error.into(),
980        "attempt": attempt,
981        "at": Utc::now().to_rfc3339(),
982    });
983    if terminal {
984        value["terminal"] = serde_json::Value::Bool(true);
985    }
986    value
987}
988
989fn transition_timestamp(job: &JobRow) -> DateTime<Utc> {
990    job.finalized_at
991        .or(job.heartbeat_at)
992        .or(job.deadline_at)
993        .or(job.attempted_at)
994        .unwrap_or(job.run_at)
995}
996
997fn state_rank(state: JobState) -> u8 {
998    match state {
999        JobState::Running | JobState::WaitingExternal => 4,
1000        JobState::Retryable | JobState::Scheduled => 3,
1001        JobState::Available => 2,
1002        JobState::Completed | JobState::Failed | JobState::Cancelled => 1,
1003    }
1004}
1005
1006#[derive(Debug, Clone, sqlx::FromRow)]
1007struct ReadyJobRow {
1008    job_id: i64,
1009    kind: String,
1010    queue: String,
1011    args: serde_json::Value,
1012    priority: i16,
1013    attempt: i16,
1014    run_lease: i64,
1015    max_attempts: i16,
1016    run_at: DateTime<Utc>,
1017    attempted_at: Option<DateTime<Utc>>,
1018    created_at: DateTime<Utc>,
1019    unique_key: Option<Vec<u8>>,
1020    payload: serde_json::Value,
1021}
1022
1023impl ReadyJobRow {
1024    fn into_job_row(self) -> Result<JobRow, AwaError> {
1025        let payload = RuntimePayload::from_json(self.payload)?;
1026        Ok(JobRow {
1027            id: self.job_id,
1028            kind: self.kind,
1029            queue: self.queue,
1030            args: self.args,
1031            state: JobState::Available,
1032            priority: self.priority,
1033            attempt: self.attempt,
1034            run_lease: self.run_lease,
1035            max_attempts: self.max_attempts,
1036            run_at: self.run_at,
1037            heartbeat_at: None,
1038            deadline_at: None,
1039            attempted_at: self.attempted_at,
1040            finalized_at: None,
1041            created_at: self.created_at,
1042            errors: payload.errors_option(),
1043            metadata: payload.metadata,
1044            tags: payload.tags,
1045            unique_key: self.unique_key,
1046            unique_states: None,
1047            callback_id: None,
1048            callback_timeout_at: None,
1049            callback_filter: None,
1050            callback_on_complete: None,
1051            callback_on_fail: None,
1052            callback_transform: None,
1053            progress: payload.progress,
1054        })
1055    }
1056}
1057
1058#[derive(Debug, Clone, sqlx::FromRow)]
1059struct ReadyTransitionRow {
1060    ready_slot: i32,
1061    ready_generation: i64,
1062    job_id: i64,
1063    kind: String,
1064    queue: String,
1065    args: serde_json::Value,
1066    priority: i16,
1067    attempt: i16,
1068    run_lease: i64,
1069    max_attempts: i16,
1070    lane_seq: i64,
1071    enqueue_shard: i16,
1072    run_at: DateTime<Utc>,
1073    attempted_at: Option<DateTime<Utc>>,
1074    created_at: DateTime<Utc>,
1075    unique_key: Option<Vec<u8>>,
1076    unique_states: Option<String>,
1077    payload: serde_json::Value,
1078}
1079
1080#[derive(Debug, Clone)]
1081struct ClaimCursorAdvance {
1082    queue: String,
1083    priority: i16,
1084    enqueue_shard: i16,
1085    next_seq: i64,
1086    only_if_current: Option<i64>,
1087}
1088
1089type ClaimCursorLaneKey = (String, i16, i16);
1090type ConditionalClaimCursorAdvances = BTreeMap<i64, i64>;
1091type GroupedClaimCursorAdvances =
1092    BTreeMap<ClaimCursorLaneKey, (Option<i64>, ConditionalClaimCursorAdvances)>;
1093type TerminalCounterKey = (i32, i64, String, i16, i16, i16);
1094
1095struct CancelJobTxResult {
1096    row: JobRow,
1097    claim_cursor_advance: Option<ClaimCursorAdvance>,
1098}
1099
1100struct ReadyBatchMoveResult {
1101    moved: bool,
1102}
1103
1104impl ReadyTransitionRow {
1105    fn into_existing_ready_row(
1106        self,
1107        queue: String,
1108        priority: i16,
1109        payload: serde_json::Value,
1110    ) -> ExistingReadyRow {
1111        ExistingReadyRow {
1112            job_id: self.job_id,
1113            kind: self.kind,
1114            queue,
1115            args: self.args,
1116            priority,
1117            attempt: self.attempt,
1118            run_lease: self.run_lease,
1119            max_attempts: self.max_attempts,
1120            run_at: self.run_at,
1121            attempted_at: self.attempted_at,
1122            created_at: self.created_at,
1123            unique_key: self.unique_key,
1124            unique_states: self.unique_states,
1125            payload,
1126        }
1127    }
1128
1129    fn into_done_row(
1130        self,
1131        state: JobState,
1132        finalized_at: DateTime<Utc>,
1133        payload: serde_json::Value,
1134    ) -> DoneJobRow {
1135        DoneJobRow {
1136            ready_slot: self.ready_slot,
1137            ready_generation: self.ready_generation,
1138            job_id: self.job_id,
1139            kind: self.kind,
1140            queue: self.queue,
1141            args: self.args,
1142            state,
1143            priority: self.priority,
1144            attempt: self.attempt,
1145            run_lease: self.run_lease,
1146            max_attempts: self.max_attempts,
1147            lane_seq: self.lane_seq,
1148            enqueue_shard: self.enqueue_shard,
1149            run_at: self.run_at,
1150            attempted_at: self.attempted_at,
1151            finalized_at,
1152            created_at: self.created_at,
1153            unique_key: self.unique_key,
1154            unique_states: self.unique_states,
1155            payload,
1156        }
1157    }
1158}
1159
1160#[derive(Debug, Clone, sqlx::FromRow)]
1161struct ReadyJobLeaseRow {
1162    ready_slot: i32,
1163    ready_generation: i64,
1164    lane_seq: i64,
1165    enqueue_shard: i16,
1166    lease_slot: i32,
1167    lease_generation: i64,
1168    claim_slot: i32,
1169    job_id: i64,
1170    kind: String,
1171    queue: String,
1172    args: serde_json::Value,
1173    lane_priority: i16,
1174    priority: i16,
1175    attempt: i16,
1176    run_lease: i64,
1177    max_attempts: i16,
1178    run_at: DateTime<Utc>,
1179    heartbeat_at: Option<DateTime<Utc>>,
1180    deadline_at: Option<DateTime<Utc>>,
1181    attempted_at: Option<DateTime<Utc>>,
1182    created_at: DateTime<Utc>,
1183    unique_key: Option<Vec<u8>>,
1184    unique_states: Option<String>,
1185    payload: serde_json::Value,
1186}
1187
1188impl ReadyJobLeaseRow {
1189    fn claim_ref(&self, lease_claim_receipt: bool) -> ClaimedEntry {
1190        ClaimedEntry {
1191            queue: self.queue.clone(),
1192            priority: self.lane_priority,
1193            lane_seq: self.lane_seq,
1194            ready_slot: self.ready_slot,
1195            ready_generation: self.ready_generation,
1196            lease_slot: self.lease_slot,
1197            lease_generation: self.lease_generation,
1198            claim_slot: self.claim_slot,
1199            lease_claim_receipt,
1200            enqueue_shard: self.enqueue_shard,
1201        }
1202    }
1203
1204    fn into_job_row(self) -> Result<JobRow, AwaError> {
1205        let mut payload = RuntimePayload::from_json(self.payload)?;
1206        if self.priority < self.lane_priority {
1207            let metadata = payload.metadata.as_object_mut().ok_or_else(|| {
1208                AwaError::Validation(
1209                    "queue storage payload metadata must be a JSON object".to_string(),
1210                )
1211            })?;
1212            metadata
1213                .entry("_awa_original_priority".to_string())
1214                .or_insert_with(|| serde_json::Value::from(i64::from(self.lane_priority)));
1215        }
1216
1217        Ok(JobRow {
1218            id: self.job_id,
1219            kind: self.kind,
1220            queue: self.queue,
1221            args: self.args,
1222            state: JobState::Running,
1223            priority: self.priority,
1224            attempt: self.attempt,
1225            run_lease: self.run_lease,
1226            max_attempts: self.max_attempts,
1227            run_at: self.run_at,
1228            heartbeat_at: self.heartbeat_at,
1229            deadline_at: self.deadline_at,
1230            attempted_at: self.attempted_at,
1231            finalized_at: None,
1232            created_at: self.created_at,
1233            errors: payload.errors_option(),
1234            metadata: payload.metadata,
1235            tags: payload.tags,
1236            unique_key: self.unique_key,
1237            unique_states: None,
1238            callback_id: None,
1239            callback_timeout_at: None,
1240            callback_filter: None,
1241            callback_on_complete: None,
1242            callback_on_fail: None,
1243            callback_transform: None,
1244            progress: payload.progress,
1245        })
1246    }
1247
1248    fn into_claimed_runtime_job(
1249        self,
1250        lease_claim_receipt: bool,
1251    ) -> Result<ClaimedRuntimeJob, AwaError> {
1252        let claim = self.claim_ref(lease_claim_receipt);
1253        let unique_states = self.unique_states.clone();
1254        let job = self.into_job_row()?;
1255        Ok(ClaimedRuntimeJob {
1256            claim,
1257            job,
1258            unique_states,
1259        })
1260    }
1261}
1262
1263#[derive(Debug, Clone)]
1264struct RuntimeReadyRow {
1265    kind: String,
1266    queue: String,
1267    args: serde_json::Value,
1268    priority: i16,
1269    attempt: i16,
1270    run_lease: i64,
1271    max_attempts: i16,
1272    run_at: DateTime<Utc>,
1273    attempted_at: Option<DateTime<Utc>>,
1274    created_at: DateTime<Utc>,
1275    unique_key: Option<Vec<u8>>,
1276    unique_states: Option<String>,
1277    payload: serde_json::Value,
1278    /// Optional caller-supplied key. When the destination queue has
1279    /// `enqueue_shards > 1`, all rows sharing the same key route to
1280    /// the same shard so FIFO within the key is preserved. `None`
1281    /// falls back to the per-store rotor.
1282    ordering_key: Option<Vec<u8>>,
1283}
1284
1285#[derive(Debug, Clone)]
1286struct RuntimeReadyInsert {
1287    job_id: i64,
1288    kind: String,
1289    queue: String,
1290    args: serde_json::Value,
1291    priority: i16,
1292    attempt: i16,
1293    run_lease: i64,
1294    max_attempts: i16,
1295    run_at: DateTime<Utc>,
1296    attempted_at: Option<DateTime<Utc>>,
1297    lane_seq: i64,
1298    /// Enqueue shard the row belongs to. Selected per row by
1299    /// `shard_for_enqueue`; defaults to 0 when the queue's
1300    /// `enqueue_shards` is 1 (the default).
1301    enqueue_shard: i16,
1302    created_at: DateTime<Utc>,
1303    unique_key: Option<Vec<u8>>,
1304    unique_states: Option<String>,
1305    payload: serde_json::Value,
1306}
1307
1308#[derive(Debug, Clone, sqlx::FromRow)]
1309struct DoneJobRow {
1310    ready_slot: i32,
1311    ready_generation: i64,
1312    job_id: i64,
1313    kind: String,
1314    queue: String,
1315    args: serde_json::Value,
1316    state: JobState,
1317    priority: i16,
1318    attempt: i16,
1319    run_lease: i64,
1320    max_attempts: i16,
1321    lane_seq: i64,
1322    /// Enqueue shard the row was claimed from. Part of the
1323    /// `done_entries` primary key so two shards' terminal rows at
1324    /// the same `(ready_slot, queue, priority, lane_seq)` do not
1325    /// collide.
1326    enqueue_shard: i16,
1327    run_at: DateTime<Utc>,
1328    attempted_at: Option<DateTime<Utc>>,
1329    finalized_at: DateTime<Utc>,
1330    created_at: DateTime<Utc>,
1331    unique_key: Option<Vec<u8>>,
1332    unique_states: Option<String>,
1333    payload: serde_json::Value,
1334}
1335
1336impl DoneJobRow {
1337    fn into_job_row(self) -> Result<JobRow, AwaError> {
1338        let payload = RuntimePayload::from_json(self.payload)?;
1339        Ok(JobRow {
1340            id: self.job_id,
1341            kind: self.kind,
1342            queue: self.queue,
1343            args: self.args,
1344            state: self.state,
1345            priority: self.priority,
1346            attempt: self.attempt,
1347            run_lease: self.run_lease,
1348            max_attempts: self.max_attempts,
1349            run_at: self.run_at,
1350            heartbeat_at: None,
1351            deadline_at: None,
1352            attempted_at: self.attempted_at,
1353            finalized_at: Some(self.finalized_at),
1354            created_at: self.created_at,
1355            errors: payload.errors_option(),
1356            metadata: payload.metadata,
1357            tags: payload.tags,
1358            unique_key: self.unique_key,
1359            unique_states: None,
1360            callback_id: None,
1361            callback_timeout_at: None,
1362            callback_filter: None,
1363            callback_on_complete: None,
1364            callback_on_fail: None,
1365            callback_transform: None,
1366            progress: payload.progress,
1367        })
1368    }
1369
1370    fn into_dlq_row(self, dlq_reason: String, dlq_at: DateTime<Utc>) -> DlqJobRow {
1371        DlqJobRow {
1372            job_id: self.job_id,
1373            kind: self.kind,
1374            queue: self.queue,
1375            args: self.args,
1376            state: self.state,
1377            priority: self.priority,
1378            attempt: self.attempt,
1379            run_lease: self.run_lease,
1380            max_attempts: self.max_attempts,
1381            run_at: self.run_at,
1382            attempted_at: self.attempted_at,
1383            finalized_at: self.finalized_at,
1384            created_at: self.created_at,
1385            unique_key: self.unique_key,
1386            unique_states: self.unique_states,
1387            payload: self.payload,
1388            dlq_reason,
1389            dlq_at,
1390            original_run_lease: self.run_lease,
1391        }
1392    }
1393}
1394
1395#[derive(Debug, Clone, sqlx::FromRow)]
1396struct DlqJobRow {
1397    job_id: i64,
1398    kind: String,
1399    queue: String,
1400    args: serde_json::Value,
1401    state: JobState,
1402    priority: i16,
1403    attempt: i16,
1404    run_lease: i64,
1405    max_attempts: i16,
1406    run_at: DateTime<Utc>,
1407    attempted_at: Option<DateTime<Utc>>,
1408    finalized_at: DateTime<Utc>,
1409    created_at: DateTime<Utc>,
1410    unique_key: Option<Vec<u8>>,
1411    unique_states: Option<String>,
1412    payload: serde_json::Value,
1413    dlq_reason: String,
1414    dlq_at: DateTime<Utc>,
1415    original_run_lease: i64,
1416}
1417
1418impl DlqJobRow {
1419    fn into_job_row(self) -> Result<JobRow, AwaError> {
1420        let payload = RuntimePayload::from_json(self.payload)?;
1421        Ok(JobRow {
1422            id: self.job_id,
1423            kind: self.kind,
1424            queue: self.queue,
1425            args: self.args,
1426            state: self.state,
1427            priority: self.priority,
1428            attempt: self.attempt,
1429            run_lease: self.run_lease,
1430            max_attempts: self.max_attempts,
1431            run_at: self.run_at,
1432            heartbeat_at: None,
1433            deadline_at: None,
1434            attempted_at: self.attempted_at,
1435            finalized_at: Some(self.finalized_at),
1436            created_at: self.created_at,
1437            errors: payload.errors_option(),
1438            metadata: payload.metadata,
1439            tags: payload.tags,
1440            unique_key: self.unique_key,
1441            unique_states: None,
1442            callback_id: None,
1443            callback_timeout_at: None,
1444            callback_filter: None,
1445            callback_on_complete: None,
1446            callback_on_fail: None,
1447            callback_transform: None,
1448            progress: payload.progress,
1449        })
1450    }
1451
1452    fn into_retry_ready_row(
1453        self,
1454        queue: String,
1455        priority: i16,
1456        run_at: DateTime<Utc>,
1457        payload: serde_json::Value,
1458    ) -> ExistingReadyRow {
1459        ExistingReadyRow {
1460            job_id: self.job_id,
1461            kind: self.kind,
1462            queue,
1463            args: self.args,
1464            priority,
1465            attempt: 0,
1466            run_lease: self.run_lease,
1467            max_attempts: self.max_attempts,
1468            run_at,
1469            attempted_at: None,
1470            created_at: self.created_at,
1471            unique_key: self.unique_key,
1472            unique_states: self.unique_states,
1473            payload,
1474        }
1475    }
1476
1477    fn into_retry_deferred_row(
1478        self,
1479        queue: String,
1480        priority: i16,
1481        run_at: DateTime<Utc>,
1482        payload: serde_json::Value,
1483    ) -> DeferredJobRow {
1484        DeferredJobRow {
1485            job_id: self.job_id,
1486            kind: self.kind,
1487            queue,
1488            args: self.args,
1489            state: JobState::Scheduled,
1490            priority,
1491            attempt: 0,
1492            run_lease: self.run_lease,
1493            max_attempts: self.max_attempts,
1494            run_at,
1495            attempted_at: None,
1496            finalized_at: None,
1497            created_at: self.created_at,
1498            unique_key: self.unique_key,
1499            unique_states: self.unique_states,
1500            payload,
1501        }
1502    }
1503}
1504
1505#[derive(Debug, Clone)]
1506struct ExistingReadyRow {
1507    job_id: i64,
1508    kind: String,
1509    queue: String,
1510    args: serde_json::Value,
1511    priority: i16,
1512    attempt: i16,
1513    run_lease: i64,
1514    max_attempts: i16,
1515    run_at: DateTime<Utc>,
1516    attempted_at: Option<DateTime<Utc>>,
1517    created_at: DateTime<Utc>,
1518    unique_key: Option<Vec<u8>>,
1519    unique_states: Option<String>,
1520    payload: serde_json::Value,
1521}
1522
1523#[derive(Debug, Clone, sqlx::FromRow)]
1524struct DeletedLeaseRow {
1525    ready_slot: i32,
1526    ready_generation: i64,
1527    job_id: i64,
1528    queue: String,
1529    state: JobState,
1530    priority: i16,
1531    attempt: i16,
1532    run_lease: i64,
1533    max_attempts: i16,
1534    lane_seq: i64,
1535    enqueue_shard: i16,
1536    attempted_at: Option<DateTime<Utc>>,
1537}
1538
1539#[derive(Debug, Clone, sqlx::FromRow)]
1540struct ReadySnapshotRow {
1541    ready_slot: i32,
1542    ready_generation: i64,
1543    kind: String,
1544    queue: String,
1545    args: serde_json::Value,
1546    priority: i16,
1547    lane_seq: i64,
1548    enqueue_shard: i16,
1549    run_at: DateTime<Utc>,
1550    created_at: DateTime<Utc>,
1551    unique_key: Option<Vec<u8>>,
1552    unique_states: Option<String>,
1553    payload: serde_json::Value,
1554}
1555
1556#[derive(Debug, Clone, sqlx::FromRow)]
1557struct AttemptStateRow {
1558    job_id: i64,
1559    run_lease: i64,
1560    progress: Option<serde_json::Value>,
1561    callback_filter: Option<String>,
1562    callback_on_complete: Option<String>,
1563    callback_on_fail: Option<String>,
1564    callback_transform: Option<String>,
1565    callback_result: Option<serde_json::Value>,
1566}
1567
1568#[derive(Debug, Clone)]
1569struct LeaseTransitionRow {
1570    ready_slot: i32,
1571    ready_generation: i64,
1572    job_id: i64,
1573    kind: String,
1574    queue: String,
1575    args: serde_json::Value,
1576    state: JobState,
1577    priority: i16,
1578    attempt: i16,
1579    run_lease: i64,
1580    max_attempts: i16,
1581    lane_seq: i64,
1582    enqueue_shard: i16,
1583    run_at: DateTime<Utc>,
1584    attempted_at: Option<DateTime<Utc>>,
1585    created_at: DateTime<Utc>,
1586    unique_key: Option<Vec<u8>>,
1587    unique_states: Option<String>,
1588    payload: serde_json::Value,
1589    progress: Option<serde_json::Value>,
1590}
1591
1592impl LeaseTransitionRow {
1593    fn into_done_row(
1594        self,
1595        state: JobState,
1596        finalized_at: DateTime<Utc>,
1597        payload: serde_json::Value,
1598    ) -> DoneJobRow {
1599        DoneJobRow {
1600            ready_slot: self.ready_slot,
1601            ready_generation: self.ready_generation,
1602            job_id: self.job_id,
1603            kind: self.kind,
1604            queue: self.queue,
1605            args: self.args,
1606            state,
1607            priority: self.priority,
1608            attempt: self.attempt,
1609            run_lease: self.run_lease,
1610            max_attempts: self.max_attempts,
1611            lane_seq: self.lane_seq,
1612            enqueue_shard: self.enqueue_shard,
1613            run_at: self.run_at,
1614            attempted_at: self.attempted_at,
1615            finalized_at,
1616            created_at: self.created_at,
1617            unique_key: self.unique_key,
1618            unique_states: self.unique_states,
1619            payload,
1620        }
1621    }
1622
1623    fn into_deferred_row(
1624        self,
1625        state: JobState,
1626        run_at: DateTime<Utc>,
1627        finalized_at: Option<DateTime<Utc>>,
1628        payload: serde_json::Value,
1629    ) -> DeferredJobRow {
1630        DeferredJobRow {
1631            job_id: self.job_id,
1632            kind: self.kind,
1633            queue: self.queue,
1634            args: self.args,
1635            state,
1636            priority: self.priority,
1637            attempt: self.attempt,
1638            run_lease: self.run_lease,
1639            max_attempts: self.max_attempts,
1640            run_at,
1641            attempted_at: self.attempted_at,
1642            finalized_at,
1643            created_at: self.created_at,
1644            unique_key: self.unique_key,
1645            unique_states: self.unique_states,
1646            payload,
1647        }
1648    }
1649
1650    fn into_ready_row(self, run_at: DateTime<Utc>, payload: serde_json::Value) -> ExistingReadyRow {
1651        ExistingReadyRow {
1652            job_id: self.job_id,
1653            kind: self.kind,
1654            queue: self.queue,
1655            args: self.args,
1656            priority: self.priority,
1657            attempt: self.attempt,
1658            run_lease: self.run_lease,
1659            max_attempts: self.max_attempts,
1660            run_at,
1661            attempted_at: self.attempted_at,
1662            created_at: self.created_at,
1663            unique_key: self.unique_key,
1664            unique_states: self.unique_states,
1665            payload,
1666        }
1667    }
1668
1669    fn into_dlq_row(
1670        self,
1671        finalized_at: DateTime<Utc>,
1672        payload: serde_json::Value,
1673        dlq_reason: String,
1674        dlq_at: DateTime<Utc>,
1675    ) -> DlqJobRow {
1676        DlqJobRow {
1677            job_id: self.job_id,
1678            kind: self.kind,
1679            queue: self.queue,
1680            args: self.args,
1681            state: JobState::Failed,
1682            priority: self.priority,
1683            attempt: self.attempt,
1684            run_lease: self.run_lease,
1685            max_attempts: self.max_attempts,
1686            run_at: self.run_at,
1687            attempted_at: self.attempted_at,
1688            finalized_at,
1689            created_at: self.created_at,
1690            unique_key: self.unique_key,
1691            unique_states: self.unique_states,
1692            payload,
1693            dlq_reason,
1694            dlq_at,
1695            original_run_lease: self.run_lease,
1696        }
1697    }
1698}
1699
1700#[derive(Debug, Clone, sqlx::FromRow)]
1701struct LeaseJobRow {
1702    job_id: i64,
1703    kind: String,
1704    queue: String,
1705    args: serde_json::Value,
1706    state: JobState,
1707    priority: i16,
1708    attempt: i16,
1709    run_lease: i64,
1710    max_attempts: i16,
1711    run_at: DateTime<Utc>,
1712    heartbeat_at: Option<DateTime<Utc>>,
1713    deadline_at: Option<DateTime<Utc>>,
1714    attempted_at: Option<DateTime<Utc>>,
1715    finalized_at: Option<DateTime<Utc>>,
1716    created_at: DateTime<Utc>,
1717    unique_key: Option<Vec<u8>>,
1718    callback_id: Option<Uuid>,
1719    callback_timeout_at: Option<DateTime<Utc>>,
1720    callback_filter: Option<String>,
1721    callback_on_complete: Option<String>,
1722    callback_on_fail: Option<String>,
1723    callback_transform: Option<String>,
1724    payload: serde_json::Value,
1725    progress: Option<serde_json::Value>,
1726    callback_result: Option<serde_json::Value>,
1727}
1728
1729impl LeaseJobRow {
1730    fn into_job_row(self) -> Result<JobRow, AwaError> {
1731        let payload = QueueStorage::materialize_runtime_payload(
1732            self.payload,
1733            self.progress,
1734            self.callback_result,
1735        )?;
1736        Ok(JobRow {
1737            id: self.job_id,
1738            kind: self.kind,
1739            queue: self.queue,
1740            args: self.args,
1741            state: self.state,
1742            priority: self.priority,
1743            attempt: self.attempt,
1744            run_lease: self.run_lease,
1745            max_attempts: self.max_attempts,
1746            run_at: self.run_at,
1747            heartbeat_at: self.heartbeat_at,
1748            deadline_at: self.deadline_at,
1749            attempted_at: self.attempted_at,
1750            finalized_at: self.finalized_at,
1751            created_at: self.created_at,
1752            errors: payload.errors_option(),
1753            metadata: payload.metadata,
1754            tags: payload.tags,
1755            unique_key: self.unique_key,
1756            unique_states: None,
1757            callback_id: self.callback_id,
1758            callback_timeout_at: self.callback_timeout_at,
1759            callback_filter: self.callback_filter,
1760            callback_on_complete: self.callback_on_complete,
1761            callback_on_fail: self.callback_on_fail,
1762            callback_transform: self.callback_transform,
1763            progress: payload.progress,
1764        })
1765    }
1766}
1767
1768#[derive(Debug, Clone, sqlx::FromRow)]
1769struct DeferredJobRow {
1770    job_id: i64,
1771    kind: String,
1772    queue: String,
1773    args: serde_json::Value,
1774    state: JobState,
1775    priority: i16,
1776    attempt: i16,
1777    run_lease: i64,
1778    max_attempts: i16,
1779    run_at: DateTime<Utc>,
1780    attempted_at: Option<DateTime<Utc>>,
1781    finalized_at: Option<DateTime<Utc>>,
1782    created_at: DateTime<Utc>,
1783    unique_key: Option<Vec<u8>>,
1784    unique_states: Option<String>,
1785    payload: serde_json::Value,
1786}
1787
1788impl DeferredJobRow {
1789    fn into_job_row(self) -> Result<JobRow, AwaError> {
1790        let payload = RuntimePayload::from_json(self.payload)?;
1791        Ok(JobRow {
1792            id: self.job_id,
1793            kind: self.kind,
1794            queue: self.queue,
1795            args: self.args,
1796            state: self.state,
1797            priority: self.priority,
1798            attempt: self.attempt,
1799            run_lease: self.run_lease,
1800            max_attempts: self.max_attempts,
1801            run_at: self.run_at,
1802            heartbeat_at: None,
1803            deadline_at: None,
1804            attempted_at: self.attempted_at,
1805            finalized_at: self.finalized_at,
1806            created_at: self.created_at,
1807            errors: payload.errors_option(),
1808            metadata: payload.metadata,
1809            tags: payload.tags,
1810            unique_key: self.unique_key,
1811            unique_states: None,
1812            callback_id: None,
1813            callback_timeout_at: None,
1814            callback_filter: None,
1815            callback_on_complete: None,
1816            callback_on_fail: None,
1817            callback_transform: None,
1818            progress: payload.progress,
1819        })
1820    }
1821}
1822
1823/// Segmented queue storage backend.
1824///
1825/// Design goals:
1826/// - append-only queue segments in a rotated ring
1827/// - append-only completion segments keyed back to the queue segment
1828/// - a separate, faster rotating lease ring so delete churn is bounded by the
1829///   lease cycle rather than by queue retention
1830/// - hot mutable state restricted to queue cursors, narrow leases, and
1831///   optional per-attempt runtime state only when needed
1832///
1833#[derive(Debug)]
1834pub struct QueueStorage {
1835    config: QueueStorageConfig,
1836    next_stripe_probe: AtomicUsize,
1837    /// Per-store rotor that selects the enqueue shard for the next batch.
1838    /// Rotated once per `shard_for_enqueue` call so producers spread their
1839    /// writes across the per-`(queue, priority)` shard rows.
1840    shard_rotor: AtomicU16,
1841    /// Cache of `awa.queue_meta.enqueue_shards` per queue. Populated lazily
1842    /// on the first `shard_for_enqueue` call for a queue and invalidated by
1843    /// `reset()`. With the default `enqueue_shards = 1`, the cache holds 1
1844    /// and `pick_shard` returns 0 unconditionally.
1845    enqueue_shards_cache: Mutex<HashMap<String, i16>>,
1846    /// Lane-presence cache: `(physical_queue, priority, enqueue_shard)`
1847    /// triples whose three lane rows (queue_lanes, queue_enqueue_heads,
1848    /// queue_claim_heads) we have previously inserted. Skips the three
1849    /// `INSERT … ON CONFLICT DO NOTHING` round-trips on subsequent enqueue
1850    /// batches to a known lane/shard. Cleared on `reset()` because reset
1851    /// TRUNCATEs queue_lanes; `advance_enqueue_head` repairs a stale entry
1852    /// (head row gone after a rolled-back ensure_lane).
1853    ensured_lanes: Mutex<HashSet<(String, i16, i16)>>,
1854}
1855
1856impl QueueStorage {
1857    pub fn new(config: QueueStorageConfig) -> Result<Self, AwaError> {
1858        if config.queue_slot_count < 4 {
1859            return Err(AwaError::Validation(
1860                "queue storage requires at least 4 queue slots".into(),
1861            ));
1862        }
1863        if config.lease_slot_count < 2 {
1864            return Err(AwaError::Validation(
1865                "queue storage requires at least 2 lease slots".into(),
1866            ));
1867        }
1868        if config.claim_slot_count < 2 {
1869            return Err(AwaError::Validation(
1870                "queue storage requires at least 2 claim slots".into(),
1871            ));
1872        }
1873        if config.queue_stripe_count == 0 {
1874            return Err(AwaError::Validation(
1875                "queue storage requires at least 1 queue stripe".into(),
1876            ));
1877        }
1878        if config.schema == DEFAULT_SCHEMA
1879            && (config.queue_slot_count != DEFAULT_QUEUE_SLOT_COUNT
1880                || config.lease_slot_count != DEFAULT_LEASE_SLOT_COUNT
1881                || config.claim_slot_count != DEFAULT_CLAIM_SLOT_COUNT
1882                || !config.lease_claim_receipts)
1883        {
1884            return Err(AwaError::Validation(
1885                "the default `awa` queue-storage schema must use the default slot counts and \
1886                 lease_claim_receipts=true"
1887                    .into(),
1888            ));
1889        }
1890        validate_ident(&config.schema)?;
1891        Ok(Self {
1892            config,
1893            next_stripe_probe: AtomicUsize::new(0),
1894            shard_rotor: AtomicU16::new(0),
1895            enqueue_shards_cache: Mutex::new(HashMap::new()),
1896            ensured_lanes: Mutex::new(HashSet::new()),
1897        })
1898    }
1899
1900    pub fn from_existing_schema(schema: impl Into<String>) -> Result<Self, AwaError> {
1901        Self::new(QueueStorageConfig {
1902            schema: schema.into(),
1903            ..Default::default()
1904        })
1905    }
1906
1907    pub fn schema(&self) -> &str {
1908        &self.config.schema
1909    }
1910
1911    pub fn slot_count(&self) -> usize {
1912        self.queue_slot_count()
1913    }
1914
1915    pub fn queue_slot_count(&self) -> usize {
1916        self.config.queue_slot_count
1917    }
1918
1919    pub fn lease_slot_count(&self) -> usize {
1920        self.config.lease_slot_count
1921    }
1922
1923    pub fn claim_slot_count(&self) -> usize {
1924        self.config.claim_slot_count
1925    }
1926
1927    pub fn queue_stripe_count(&self) -> usize {
1928        self.config.queue_stripe_count
1929    }
1930
1931    pub fn lease_claim_receipts(&self) -> bool {
1932        self.config.lease_claim_receipts
1933    }
1934
1935    fn uses_queue_striping(&self) -> bool {
1936        self.queue_stripe_count() > 1
1937    }
1938
1939    fn is_physical_stripe_queue(&self, queue: &str) -> bool {
1940        self.uses_queue_striping()
1941            && queue
1942                .rsplit_once(QUEUE_STRIPE_DELIMITER)
1943                .is_some_and(|(_, suffix)| suffix.parse::<usize>().is_ok())
1944    }
1945
1946    fn physical_queue_for_stripe(&self, queue: &str, stripe: usize) -> String {
1947        format!("{queue}{QUEUE_STRIPE_DELIMITER}{stripe}")
1948    }
1949
1950    fn physical_queues_for_logical(&self, queue: &str) -> Vec<String> {
1951        if !self.uses_queue_striping() || self.is_physical_stripe_queue(queue) {
1952            return vec![queue.to_string()];
1953        }
1954        (0..self.queue_stripe_count())
1955            .map(|stripe| self.physical_queue_for_stripe(queue, stripe))
1956            .collect()
1957    }
1958
1959    fn stripe_probe_start(&self, stripe_count: usize) -> usize {
1960        if stripe_count <= 1 {
1961            return 0;
1962        }
1963        self.next_stripe_probe.fetch_add(1, Ordering::Relaxed) % stripe_count
1964    }
1965
1966    fn logical_queue_name<'a>(&self, queue: &'a str) -> &'a str {
1967        if !self.uses_queue_striping() {
1968            return queue;
1969        }
1970        queue
1971            .rsplit_once(QUEUE_STRIPE_DELIMITER)
1972            .and_then(|(prefix, suffix)| suffix.parse::<usize>().ok().map(|_| prefix))
1973            .unwrap_or(queue)
1974    }
1975
1976    fn queue_stripe_for_enqueue(
1977        &self,
1978        queue: &str,
1979        unique_key: &Option<Vec<u8>>,
1980        salt: i64,
1981    ) -> String {
1982        if !self.uses_queue_striping() || self.is_physical_stripe_queue(queue) {
1983            return queue.to_string();
1984        }
1985
1986        let stripe = if let Some(key) = unique_key {
1987            let mut hasher = DefaultHasher::new();
1988            key.hash(&mut hasher);
1989            (hasher.finish() as usize) % self.queue_stripe_count()
1990        } else {
1991            salt.rem_euclid(self.queue_stripe_count() as i64) as usize
1992        };
1993        self.physical_queue_for_stripe(queue, stripe)
1994    }
1995
1996    fn use_lease_claim_receipts_for_runtime(&self, _deadline_duration: Duration) -> bool {
1997        // Receipts mode now supports per-claim deadlines via
1998        // `lease_claims.deadline_at` (rescued by
1999        // `rescue_expired_receipt_deadlines_tx`), so receipts is the
2000        // live path whenever the engine is configured for receipts —
2001        // the queue's `deadline_duration` no longer disqualifies it.
2002        self.lease_claim_receipts()
2003    }
2004
2005    pub fn ready_child_relname(&self, slot: usize) -> String {
2006        format!("ready_entries_{slot}")
2007    }
2008
2009    pub fn done_child_relname(&self, slot: usize) -> String {
2010        format!("done_entries_{slot}")
2011    }
2012
2013    pub fn leases_relname(&self) -> &'static str {
2014        "leases"
2015    }
2016
2017    pub fn lease_claims_relname(&self) -> &'static str {
2018        "lease_claims"
2019    }
2020
2021    pub fn lease_claim_closures_relname(&self) -> &'static str {
2022        "lease_claim_closures"
2023    }
2024
2025    pub fn leases_child_relname(&self, slot: usize) -> String {
2026        format!("leases_{slot}")
2027    }
2028
2029    pub fn attempt_state_relname(&self) -> &'static str {
2030        "attempt_state"
2031    }
2032
2033    pub async fn active_schema(pool: &PgPool) -> Result<Option<String>, AwaError> {
2034        sqlx::query_scalar(
2035            "SELECT schema_name FROM awa.runtime_storage_backends WHERE backend = 'queue_storage'",
2036        )
2037        .fetch_optional(pool)
2038        .await
2039        .map_err(map_sqlx_error)
2040    }
2041
2042    /// Transaction-aware variant of [`Self::active_schema`] — read the
2043    /// active queue-storage schema name inside the caller's transaction
2044    /// rather than acquiring a separate pool connection.
2045    pub async fn active_schema_in_tx(
2046        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
2047    ) -> Result<Option<String>, AwaError> {
2048        sqlx::query_scalar(
2049            "SELECT schema_name FROM awa.runtime_storage_backends WHERE backend = 'queue_storage'",
2050        )
2051        .fetch_optional(tx.as_mut())
2052        .await
2053        .map_err(map_sqlx_error)
2054    }
2055
2056    fn materialize_runtime_payload(
2057        payload: serde_json::Value,
2058        progress: Option<serde_json::Value>,
2059        callback_result: Option<serde_json::Value>,
2060    ) -> Result<RuntimePayload, AwaError> {
2061        let mut payload = RuntimePayload::from_json(payload)?;
2062        if let Some(progress) = progress {
2063            payload.set_progress(Some(progress));
2064        }
2065        if let Some(callback_result) = callback_result {
2066            payload.insert_callback_result(Some(callback_result));
2067        }
2068        Ok(payload)
2069    }
2070
2071    fn payload_with_attempt_state(
2072        payload: serde_json::Value,
2073        progress: Option<serde_json::Value>,
2074    ) -> Result<serde_json::Value, AwaError> {
2075        let mut payload = RuntimePayload::from_json(payload)?;
2076        if let Some(progress) = progress {
2077            payload.set_progress(Some(progress));
2078        }
2079        Ok(payload.into_json())
2080    }
2081
2082    fn payload_from_parts(
2083        metadata: serde_json::Value,
2084        tags: Vec<String>,
2085        errors: Option<Vec<serde_json::Value>>,
2086        progress: Option<serde_json::Value>,
2087    ) -> Result<serde_json::Value, AwaError> {
2088        Ok(RuntimePayload {
2089            metadata,
2090            tags,
2091            errors: errors.unwrap_or_default(),
2092            progress,
2093        }
2094        .into_json())
2095    }
2096
2097    async fn sync_unique_claim<'a>(
2098        &self,
2099        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2100        job_id: i64,
2101        unique_key: &Option<Vec<u8>>,
2102        unique_states: Option<&str>,
2103        old_state: Option<JobState>,
2104        new_state: Option<JobState>,
2105    ) -> Result<(), AwaError> {
2106        let old_claim = old_state.is_some_and(|state| unique_state_claims(unique_states, state));
2107        let new_claim = new_state.is_some_and(|state| unique_state_claims(unique_states, state));
2108
2109        if old_claim && !new_claim {
2110            if let Some(key) = unique_key {
2111                sqlx::query(
2112                    "DELETE FROM awa.job_unique_claims WHERE unique_key = $1 AND job_id = $2",
2113                )
2114                .bind(key)
2115                .bind(job_id)
2116                .execute(tx.as_mut())
2117                .await
2118                .map_err(map_sqlx_error)?;
2119            }
2120        }
2121
2122        if new_claim && !old_claim {
2123            if let Some(key) = unique_key {
2124                let result = sqlx::query(
2125                    r#"
2126                    INSERT INTO awa.job_unique_claims (unique_key, job_id)
2127                    VALUES ($1, $2)
2128                    ON CONFLICT (unique_key)
2129                    DO UPDATE SET job_id = EXCLUDED.job_id
2130                    WHERE awa.job_unique_claims.job_id = EXCLUDED.job_id
2131                    "#,
2132                )
2133                .bind(key)
2134                .bind(job_id)
2135                .execute(tx.as_mut())
2136                .await
2137                .map_err(map_sqlx_error)?;
2138
2139                if result.rows_affected() == 0 {
2140                    return Err(AwaError::UniqueConflict {
2141                        constraint: Some("idx_awa_jobs_unique".to_string()),
2142                    });
2143                }
2144            }
2145        }
2146
2147        Ok(())
2148    }
2149
2150    async fn sync_enqueue_unique_claims<'a>(
2151        &self,
2152        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2153        claims: Vec<(Vec<u8>, i64)>,
2154    ) -> Result<(), AwaError> {
2155        if claims.is_empty() {
2156            return Ok(());
2157        }
2158
2159        let mut seen: HashSet<&[u8]> = HashSet::with_capacity(claims.len());
2160        for (key, _) in &claims {
2161            if !seen.insert(key.as_slice()) {
2162                return Err(AwaError::UniqueConflict {
2163                    constraint: Some("idx_awa_jobs_unique".to_string()),
2164                });
2165            }
2166        }
2167
2168        let (keys, job_ids): (Vec<Vec<u8>>, Vec<i64>) = claims.into_iter().unzip();
2169        let (requested, applied): (i64, i64) = sqlx::query_as(
2170            r#"
2171            WITH input(unique_key, job_id) AS (
2172                SELECT * FROM unnest($1::bytea[], $2::bigint[])
2173            ),
2174            inserted AS (
2175                INSERT INTO awa.job_unique_claims (unique_key, job_id)
2176                SELECT unique_key, job_id FROM input
2177                ON CONFLICT (unique_key)
2178                DO UPDATE SET job_id = EXCLUDED.job_id
2179                WHERE awa.job_unique_claims.job_id = EXCLUDED.job_id
2180                RETURNING unique_key
2181            )
2182            SELECT
2183                (SELECT count(*)::bigint FROM input) AS requested,
2184                (SELECT count(*)::bigint FROM inserted) AS applied
2185            "#,
2186        )
2187        .bind(keys)
2188        .bind(job_ids)
2189        .fetch_one(tx.as_mut())
2190        .await
2191        .map_err(map_sqlx_error)?;
2192
2193        if applied != requested {
2194            return Err(AwaError::UniqueConflict {
2195                constraint: Some("idx_awa_jobs_unique".to_string()),
2196            });
2197        }
2198
2199        Ok(())
2200    }
2201
2202    // Enqueue inserts have no prior storage state, so uniqueness only needs to
2203    // add claims for states included in the row's unique-state bitmask. State
2204    // transitions still use `sync_unique_claim`, which can release old claims.
2205    async fn sync_ready_enqueue_unique_claims<'a>(
2206        &self,
2207        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2208        rows: &[RuntimeReadyInsert],
2209    ) -> Result<(), AwaError> {
2210        let claims = rows
2211            .iter()
2212            .filter(|row| unique_state_claims(row.unique_states.as_deref(), JobState::Available))
2213            .filter_map(|row| row.unique_key.as_ref().map(|key| (key.clone(), row.job_id)))
2214            .collect();
2215        self.sync_enqueue_unique_claims(tx, claims).await
2216    }
2217
2218    async fn sync_deferred_enqueue_unique_claims<'a>(
2219        &self,
2220        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2221        rows: &[DeferredJobRow],
2222    ) -> Result<(), AwaError> {
2223        let claims = rows
2224            .iter()
2225            .filter(|row| unique_state_claims(row.unique_states.as_deref(), row.state))
2226            .filter_map(|row| row.unique_key.as_ref().map(|key| (key.clone(), row.job_id)))
2227            .collect();
2228        self.sync_enqueue_unique_claims(tx, claims).await
2229    }
2230
2231    /// Idempotently install the queue-storage substrate (schema, tables,
2232    /// indexes, functions) for this configuration.
2233    ///
2234    /// Runs entirely inside one transaction on a single pooled connection,
2235    /// guarded by `pg_advisory_xact_lock` so concurrent worker startups
2236    /// serialize cleanly rather than fighting over pool slots. The lock
2237    /// auto-releases on COMMIT/ROLLBACK.
2238    ///
2239    /// **Note on index builds:** the `CREATE INDEX IF NOT EXISTS` calls
2240    /// below are not `CONCURRENTLY` (Postgres bans `CONCURRENTLY` inside a
2241    /// transaction). On a fresh install that's a no-op cost. On a redeploy
2242    /// against a database that already has rows in the queue tables, each
2243    /// fresh-index build takes `ShareUpdateExclusiveLock` on its table for
2244    /// the duration of the build, and concurrent writers to that table
2245    /// queue up. For typical operator scenarios this is bounded by the
2246    /// `IF NOT EXISTS` guard — only the *new* indexes added by a runtime
2247    /// upgrade get built; the rest are no-ops.
2248    #[tracing::instrument(skip(self, pool), name = "queue_storage.prepare_schema")]
2249    pub async fn prepare_schema(&self, pool: &PgPool) -> Result<(), AwaError> {
2250        let schema = self.schema();
2251        let install_lock_name = format!("awa.queue_storage.install:{schema}");
2252        let mut install_tx = pool.begin().await.map_err(map_sqlx_error)?;
2253
2254        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
2255            .bind(&install_lock_name)
2256            .execute(install_tx.as_mut())
2257            .await
2258            .map_err(map_sqlx_error)?;
2259
2260        let install_result = async {
2261            // Helper-owned DDL. The helper takes the same per-schema advisory
2262            // xact lock we just acquired (re-entrant on the same session) and
2263            // performs the entire forward-only substrate install: sequences,
2264            // ring-state singletons, partitioned ready/done/lease tables,
2265            // lane indexes, claim_ready_runtime() function, and seed rows.
2266            // Validation inside the helper rejects non-default configuration
2267            // against the default `awa` schema (see migration v023 and #308).
2268            //
2269            // What stays here (constraint 7 of #308 PR 1):
2270            //   * `open_receipt_claims` non-empty rejection + drop (ADR-023).
2271            //   * Legacy rename of `lease_claims` / `lease_claim_closures`
2272            //     before the helper creates the partitioned parents, plus the
2273            //     post-helper copy + drop of the renamed-aside rows.
2274            //   * `queue_count_snapshots` legacy drop.
2275            //
2276            // `awa.runtime_storage_backends` is owned by migrations (v012);
2277            // activation paths only seed/update its row. The helper does NOT
2278            // touch it and does NOT change storage-transition state, so a call
2279            // to `prepare_schema` remains activation-neutral.
2280
2281            sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {schema}"))
2282                .execute(install_tx.as_mut())
2283                .await
2284                .map_err(map_sqlx_error)?;
2285
2286            // The hot path reads "currently open" by anti-joining the
2287            // partitioned `lease_claims` / `lease_claim_closures` pair, so
2288            // `open_receipt_claims` is unused (see ADR-023). Drop it on every
2289            // install. Refuse to drop a non-empty table — non-empty here
2290            // means an operator rolled forward from an older build that
2291            // still wrote rows we don't want to silently delete.
2292            let open_receipt_claims_exists: bool = sqlx::query_scalar(
2293                r#"
2294                SELECT EXISTS (
2295                    SELECT 1 FROM pg_class c
2296                    JOIN pg_namespace n ON n.oid = c.relnamespace
2297                    WHERE n.nspname = $1 AND c.relname = 'open_receipt_claims'
2298                )
2299                "#,
2300            )
2301            .bind(schema)
2302            .fetch_one(install_tx.as_mut())
2303            .await
2304            .map_err(map_sqlx_error)?;
2305            if open_receipt_claims_exists {
2306                let row_count: i64 = sqlx::query_scalar(&format!(
2307                    "SELECT count(*)::bigint FROM {schema}.open_receipt_claims"
2308                ))
2309                .fetch_one(install_tx.as_mut())
2310                .await
2311                .map_err(map_sqlx_error)?;
2312                if row_count > 0 {
2313                    return Err(AwaError::Validation(format!(
2314                        "{schema}.open_receipt_claims has {row_count} rows but the runtime no \
2315                         longer reads or writes this table. Run the ADR-023 reverse migration \
2316                         (recreate from lease_claims minus lease_claim_closures) to drain it, \
2317                         then re-run prepare_schema."
2318                    )));
2319                }
2320                sqlx::query(&format!(
2321                    "DROP TABLE IF EXISTS {schema}.open_receipt_claims CASCADE"
2322                ))
2323                .execute(install_tx.as_mut())
2324                .await
2325                .map_err(map_sqlx_error)?;
2326            }
2327
2328            // Detect the current shape of lease_claims / lease_claim_closures.
2329            // The partitioned parents have relkind 'p'; regular tables have
2330            // 'r' and need to be renamed aside so the helper can create the
2331            // partitioned parents under the canonical name. Copying the data
2332            // back happens after the helper returns, all inside this single
2333            // transaction so a crash leaves the schema in one of exactly two
2334            // states (pre-migration or post-migration).
2335            let lease_claims_relkind: Option<String> = sqlx::query_scalar(
2336                r#"
2337                SELECT c.relkind::text
2338                FROM pg_class c
2339                JOIN pg_namespace n ON n.oid = c.relnamespace
2340                WHERE n.nspname = $1 AND c.relname = 'lease_claims'
2341                "#,
2342            )
2343            .bind(schema)
2344            .fetch_optional(install_tx.as_mut())
2345            .await
2346            .map_err(map_sqlx_error)?;
2347
2348            let closures_relkind: Option<String> = sqlx::query_scalar(
2349                r#"
2350                SELECT c.relkind::text
2351                FROM pg_class c
2352                JOIN pg_namespace n ON n.oid = c.relnamespace
2353                WHERE n.nspname = $1 AND c.relname = 'lease_claim_closures'
2354                "#,
2355            )
2356            .bind(schema)
2357            .fetch_optional(install_tx.as_mut())
2358            .await
2359            .map_err(map_sqlx_error)?;
2360
2361            if lease_claims_relkind.as_deref() == Some("r") {
2362                sqlx::query(&format!(
2363                    "ALTER TABLE {schema}.lease_claims RENAME TO lease_claims_legacy"
2364                ))
2365                .execute(install_tx.as_mut())
2366                .await
2367                .map_err(map_sqlx_error)?;
2368            }
2369            if closures_relkind.as_deref() == Some("r") {
2370                sqlx::query(&format!(
2371                    "ALTER TABLE {schema}.lease_claim_closures RENAME TO lease_claim_closures_legacy"
2372                ))
2373                .execute(install_tx.as_mut())
2374                .await
2375                .map_err(map_sqlx_error)?;
2376            }
2377
2378            // queue_count_snapshots was a staleness-cached counterpart of
2379            // queue_counts_exact. The dispatcher now derives the available
2380            // count directly from the head tables and nothing else needs the
2381            // snapshot. Drop it on every prepare_schema so an upgrade from an
2382            // older install reclaims the storage. Done before the helper
2383            // runs because the helper does not touch this legacy table.
2384            sqlx::query(&format!(
2385                "DROP TABLE IF EXISTS {schema}.queue_count_snapshots"
2386            ))
2387            .execute(install_tx.as_mut())
2388            .await
2389            .map_err(map_sqlx_error)?;
2390
2391            // The single forward-only DDL call. See the migration file
2392            // `awa-model/migrations/v023_install_queue_storage_substrate.sql`
2393            // for the function body. Default values match the constants in
2394            // this file (DEFAULT_QUEUE_SLOT_COUNT etc); the helper validates
2395            // that the default `awa` schema only ever gets default-shaped
2396            // installs.
2397            sqlx::query("SELECT awa.install_queue_storage_substrate($1, $2, $3, $4, $5)")
2398                .bind(schema)
2399                .bind(self.queue_slot_count() as i32)
2400                .bind(self.lease_slot_count() as i32)
2401                .bind(self.claim_slot_count() as i32)
2402                .bind(self.lease_claim_receipts())
2403                .execute(install_tx.as_mut())
2404                .await
2405                .map_err(map_sqlx_error)?;
2406
2407            // #169: apply receipt-plane fillfactor tunings. The install
2408            // helper above creates structure only; tunings live in
2409            // their own SQL function (see v024) and the orchestrator
2410            // here is the canonical place that composes them. This
2411            // means future perf knobs land additively in
2412            // `apply_receipt_plane_fillfactor` (or a sibling helper)
2413            // without touching the bigger install helper, and every
2414            // path that creates substrate — Rust prepare_schema, fresh
2415            // `awa migrate` (via v024's sweep), explicit operator call
2416            // against a custom schema — composes the same two pieces.
2417            sqlx::query("SELECT awa.apply_receipt_plane_fillfactor($1)")
2418                .bind(schema)
2419                .execute(install_tx.as_mut())
2420                .await
2421                .map_err(map_sqlx_error)?;
2422
2423            // Post-helper legacy fixups: copy any renamed-aside rows into the
2424            // newly partitioned parents created by the helper, then drop the
2425            // legacy table. ON CONFLICT DO NOTHING so a re-run after a
2426            // partial copy is idempotent. The outer transaction keeps the
2427            // copy + drop atomic so a crash between them leaves the schema
2428            // in one of exactly two states (pre or post migration); without
2429            // that the next `prepare_schema`'s ON CONFLICT would silently
2430            // mask the inconsistency.
2431            let lease_claims_legacy_exists: bool = sqlx::query_scalar(
2432                r#"
2433                SELECT EXISTS (
2434                    SELECT 1 FROM pg_class c
2435                    JOIN pg_namespace n ON n.oid = c.relnamespace
2436                    WHERE n.nspname = $1 AND c.relname = 'lease_claims_legacy'
2437                )
2438                "#,
2439            )
2440            .bind(schema)
2441            .fetch_one(install_tx.as_mut())
2442            .await
2443            .map_err(map_sqlx_error)?;
2444
2445            let closures_legacy_exists: bool = sqlx::query_scalar(
2446                r#"
2447                SELECT EXISTS (
2448                    SELECT 1 FROM pg_class c
2449                    JOIN pg_namespace n ON n.oid = c.relnamespace
2450                    WHERE n.nspname = $1 AND c.relname = 'lease_claim_closures_legacy'
2451                )
2452                "#,
2453            )
2454            .bind(schema)
2455            .fetch_one(install_tx.as_mut())
2456            .await
2457            .map_err(map_sqlx_error)?;
2458
2459            let legacy_claim_slot: Option<i32> =
2460                if lease_claims_legacy_exists || closures_legacy_exists {
2461                    Some(
2462                        sqlx::query_scalar(&format!(
2463                            "SELECT current_slot FROM {schema}.claim_ring_state WHERE singleton"
2464                        ))
2465                        .fetch_one(install_tx.as_mut())
2466                        .await
2467                        .map_err(map_sqlx_error)?,
2468                    )
2469                } else {
2470                    None
2471                };
2472
2473            if lease_claims_legacy_exists {
2474                sqlx::query(&format!(
2475                    "ALTER TABLE {schema}.lease_claims_legacy ADD COLUMN IF NOT EXISTS enqueue_shard SMALLINT NOT NULL DEFAULT 0"
2476                ))
2477                .execute(install_tx.as_mut())
2478                .await
2479                .map_err(map_sqlx_error)?;
2480                sqlx::query(&format!(
2481                    "ALTER TABLE {schema}.lease_claims_legacy ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ"
2482                ))
2483                .execute(install_tx.as_mut())
2484                .await
2485                .map_err(map_sqlx_error)?;
2486
2487                sqlx::query(&format!(
2488                    r#"
2489                INSERT INTO {schema}.lease_claims (
2490                    claim_slot, job_id, run_lease, ready_slot, ready_generation,
2491                    queue, priority, attempt, max_attempts, lane_seq,
2492                    enqueue_shard, claimed_at, materialized_at, deadline_at
2493                )
2494                SELECT
2495                    $1,
2496                    job_id, run_lease, ready_slot, ready_generation,
2497                    queue, priority, attempt, max_attempts, lane_seq,
2498                    enqueue_shard, claimed_at, materialized_at, deadline_at
2499                FROM {schema}.lease_claims_legacy
2500                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
2501                "#
2502                ))
2503                .bind(legacy_claim_slot.expect("legacy claim slot should be present"))
2504                .execute(install_tx.as_mut())
2505                .await
2506                .map_err(map_sqlx_error)?;
2507
2508                sqlx::query(&format!(
2509                    "DROP TABLE {schema}.lease_claims_legacy"
2510                ))
2511                .execute(install_tx.as_mut())
2512                .await
2513                .map_err(map_sqlx_error)?;
2514            }
2515
2516            if closures_legacy_exists {
2517                sqlx::query(&format!(
2518                    r#"
2519                INSERT INTO {schema}.lease_claim_closures (
2520                    claim_slot, job_id, run_lease, outcome, closed_at
2521                )
2522                SELECT
2523                    $1,
2524                    job_id, run_lease, outcome, closed_at
2525                FROM {schema}.lease_claim_closures_legacy
2526                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
2527                "#
2528                ))
2529                .bind(legacy_claim_slot.expect("legacy claim slot should be present"))
2530                .execute(install_tx.as_mut())
2531                .await
2532                .map_err(map_sqlx_error)?;
2533
2534                sqlx::query(&format!(
2535                    "DROP TABLE {schema}.lease_claim_closures_legacy"
2536                ))
2537                .execute(install_tx.as_mut())
2538                .await
2539                .map_err(map_sqlx_error)?;
2540            }
2541
2542            Ok(())
2543        }
2544        .await;
2545
2546        match install_result {
2547            Ok(()) => install_tx.commit().await.map_err(map_sqlx_error),
2548            Err(err) => {
2549                let _ = install_tx.rollback().await;
2550                Err(err)
2551            }
2552        }
2553    }
2554
2555    #[tracing::instrument(skip(self, pool), name = "queue_storage.activate_backend")]
2556    pub async fn activate_backend(&self, pool: &PgPool) -> Result<(), AwaError> {
2557        let schema = self.schema();
2558        let details = serde_json::json!({ "schema": schema });
2559
2560        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
2561
2562        // Mark queue storage active only after the full schema, partitions,
2563        // indexes, and helper functions are in place. The explicit install
2564        // helper is used by tests and queue-storage-only setups, so it must
2565        // flip both the migration-owned routing registry row and the storage
2566        // transition state.
2567        sqlx::query(
2568            r#"
2569            INSERT INTO awa.runtime_storage_backends (backend, schema_name, updated_at)
2570            VALUES ('queue_storage', $1, now())
2571            ON CONFLICT (backend)
2572            DO UPDATE SET schema_name = EXCLUDED.schema_name, updated_at = EXCLUDED.updated_at
2573            "#,
2574        )
2575        .bind(schema)
2576        .execute(tx.as_mut())
2577        .await
2578        .map_err(map_sqlx_error)?;
2579
2580        let activation_result = sqlx::query(
2581            r#"
2582            UPDATE awa.storage_transition_state AS sts
2583            SET
2584                current_engine = 'queue_storage',
2585                prepared_engine = NULL,
2586                state = 'active',
2587                transition_epoch = CASE
2588                    WHEN sts.current_engine = 'queue_storage'
2589                     AND sts.prepared_engine IS NULL
2590                     AND sts.state = 'active'
2591                     AND sts.details = $1
2592                    THEN sts.transition_epoch
2593                    ELSE sts.transition_epoch + 1
2594                END,
2595                details = $1,
2596                entered_at = CASE
2597                    WHEN sts.current_engine = 'queue_storage'
2598                     AND sts.prepared_engine IS NULL
2599                     AND sts.state = 'active'
2600                     AND sts.details = $1
2601                    THEN sts.entered_at
2602                    ELSE now()
2603                END,
2604                updated_at = now(),
2605                finalized_at = CASE
2606                    WHEN sts.current_engine = 'queue_storage'
2607                     AND sts.prepared_engine IS NULL
2608                     AND sts.state = 'active'
2609                     AND sts.details = $1
2610                    THEN COALESCE(sts.finalized_at, now())
2611                    ELSE now()
2612                END
2613            WHERE sts.singleton
2614            "#,
2615        )
2616        .bind(details)
2617        .execute(tx.as_mut())
2618        .await
2619        .map_err(map_sqlx_error)?;
2620
2621        if activation_result.rows_affected() != 1 {
2622            return Err(AwaError::Validation(
2623                "queue storage activation requires the storage transition state row".into(),
2624            ));
2625        }
2626
2627        tx.commit().await.map_err(map_sqlx_error)?;
2628
2629        Ok(())
2630    }
2631
2632    #[tracing::instrument(skip(self, pool), name = "queue_storage.install")]
2633    pub async fn install(&self, pool: &PgPool) -> Result<(), AwaError> {
2634        self.prepare_schema(pool).await?;
2635        self.activate_backend(pool).await
2636    }
2637
2638    pub async fn reset(&self, pool: &PgPool) -> Result<(), AwaError> {
2639        let schema = self.schema();
2640        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
2641
2642        // Drop any partial-migration leftover tables before the main
2643        // TRUNCATE. If `prepare_schema` crashed mid-migration, the
2644        // schema may contain `lease_claims_legacy` /
2645        // `lease_claim_closures_legacy` alongside the partitioned
2646        // parents. `reset()` must clean these out, otherwise the next
2647        // `prepare_schema()` runs the legacy migration again on top of
2648        // the freshly-emptied parent and silently re-inserts old rows.
2649        sqlx::query(&format!(
2650            "DROP TABLE IF EXISTS {schema}.lease_claims_legacy"
2651        ))
2652        .execute(tx.as_mut())
2653        .await
2654        .map_err(map_sqlx_error)?;
2655
2656        sqlx::query(&format!(
2657            "DROP TABLE IF EXISTS {schema}.lease_claim_closures_legacy"
2658        ))
2659        .execute(tx.as_mut())
2660        .await
2661        .map_err(map_sqlx_error)?;
2662
2663        sqlx::query(&format!(
2664            r#"
2665            TRUNCATE
2666                {schema}.ready_entries,
2667                {schema}.ready_tombstones,
2668                {schema}.done_entries,
2669                {schema}.dlq_entries,
2670                {schema}.leases,
2671                {schema}.lease_claims,
2672                {schema}.lease_claim_closures,
2673                {schema}.attempt_state,
2674                {schema}.deferred_jobs,
2675                {schema}.queue_lanes,
2676                {schema}.queue_terminal_rollups,
2677                {schema}.queue_terminal_live_counts,
2678                {schema}.queue_terminal_count_deltas,
2679                {schema}.queue_claimer_leases,
2680                {schema}.queue_claimer_state,
2681                {schema}.queue_ring_slots,
2682                {schema}.lease_ring_slots,
2683                {schema}.claim_ring_slots
2684            "#
2685        ))
2686        .execute(tx.as_mut())
2687        .await
2688        .map_err(map_sqlx_error)?;
2689
2690        sqlx::query(&format!(
2691            "ALTER SEQUENCE {schema}.job_id_seq RESTART WITH 1"
2692        ))
2693        .execute(tx.as_mut())
2694        .await
2695        .map_err(map_sqlx_error)?;
2696
2697        sqlx::query(&format!(
2698            r#"
2699            UPDATE {schema}.queue_ring_state
2700            SET current_slot = 0,
2701                generation = 0,
2702                slot_count = $1
2703            WHERE singleton = TRUE
2704            "#
2705        ))
2706        .bind(self.queue_slot_count() as i32)
2707        .execute(tx.as_mut())
2708        .await
2709        .map_err(map_sqlx_error)?;
2710
2711        sqlx::query(&format!(
2712            r#"
2713            UPDATE {schema}.lease_ring_state
2714            SET current_slot = 0,
2715                generation = 0,
2716                slot_count = $1
2717            WHERE singleton = TRUE
2718            "#
2719        ))
2720        .bind(self.lease_slot_count() as i32)
2721        .execute(tx.as_mut())
2722        .await
2723        .map_err(map_sqlx_error)?;
2724
2725        sqlx::query(&format!(
2726            r#"
2727            UPDATE {schema}.claim_ring_state
2728            SET current_slot = 0,
2729                generation = 0,
2730                slot_count = $1
2731            WHERE singleton = TRUE
2732            "#
2733        ))
2734        .bind(self.claim_slot_count() as i32)
2735        .execute(tx.as_mut())
2736        .await
2737        .map_err(map_sqlx_error)?;
2738
2739        for slot in 0..self.queue_slot_count() {
2740            sqlx::query(&format!(
2741                r#"
2742                INSERT INTO {schema}.queue_ring_slots (slot, generation)
2743                VALUES ($1, $2)
2744                "#
2745            ))
2746            .bind(slot as i32)
2747            .bind(if slot == 0 { 0_i64 } else { -1_i64 })
2748            .execute(tx.as_mut())
2749            .await
2750            .map_err(map_sqlx_error)?;
2751        }
2752
2753        for slot in 0..self.lease_slot_count() {
2754            sqlx::query(&format!(
2755                r#"
2756                INSERT INTO {schema}.lease_ring_slots (slot, generation)
2757                VALUES ($1, $2)
2758                "#
2759            ))
2760            .bind(slot as i32)
2761            .bind(if slot == 0 { 0_i64 } else { -1_i64 })
2762            .execute(tx.as_mut())
2763            .await
2764            .map_err(map_sqlx_error)?;
2765        }
2766
2767        for slot in 0..self.claim_slot_count() {
2768            sqlx::query(&format!(
2769                r#"
2770                INSERT INTO {schema}.claim_ring_slots (slot, generation)
2771                VALUES ($1, $2)
2772                "#
2773            ))
2774            .bind(slot as i32)
2775            .bind(if slot == 0 { 0_i64 } else { -1_i64 })
2776            .execute(tx.as_mut())
2777            .await
2778            .map_err(map_sqlx_error)?;
2779        }
2780
2781        tx.commit().await.map_err(map_sqlx_error)?;
2782
2783        // queue_lanes was TRUNCATEd above and queue_meta may have a new
2784        // shard configuration for the next round. Clear both caches so
2785        // the next ensure_lane / shard_for_enqueue calls re-observe DB
2786        // state.
2787        self.clear_lane_cache();
2788        self.enqueue_shards_cache
2789            .lock()
2790            .expect("enqueue_shards_cache poisoned")
2791            .clear();
2792        Ok(())
2793    }
2794
2795    async fn ensure_lane<'a>(
2796        &self,
2797        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2798        queue: &str,
2799        priority: i16,
2800        enqueue_shard: i16,
2801    ) -> Result<(), AwaError> {
2802        // Fast path: this store has previously written the three lane
2803        // rows for this `(queue, priority, shard)` triple. The cache is
2804        // optimistic: another transaction may have marked the lane before
2805        // commit, or the marking transaction may later roll back. Verify the
2806        // head row is visible in this transaction before trusting the cache.
2807        if self.lane_is_cached(queue, priority, enqueue_shard) {
2808            let schema = self.schema();
2809            let visible: bool = sqlx::query_scalar(&format!(
2810                r#"
2811                SELECT EXISTS (
2812                    SELECT 1
2813                    FROM {schema}.queue_enqueue_heads
2814                    WHERE queue = $1
2815                      AND priority = $2
2816                      AND enqueue_shard = $3
2817                )
2818                "#
2819            ))
2820            .bind(queue)
2821            .bind(priority)
2822            .bind(enqueue_shard)
2823            .fetch_one(tx.as_mut())
2824            .await
2825            .map_err(map_sqlx_error)?;
2826
2827            if visible {
2828                return Ok(());
2829            }
2830
2831            self.invalidate_cached_lane(queue, priority, enqueue_shard);
2832        }
2833
2834        self.ensure_lane_inserts(tx, queue, priority, enqueue_shard)
2835            .await
2836    }
2837
2838    /// Run the three lane-row inserts unconditionally and mark the
2839    /// `(queue, priority)` pair as cached on success. Skips the cache
2840    /// fast path so callers in the rollback-recovery path can force a
2841    /// re-insert without racing another transaction that has marked
2842    /// the lane but not yet committed its inserts.
2843    async fn ensure_lane_inserts<'a>(
2844        &self,
2845        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
2846        queue: &str,
2847        priority: i16,
2848        enqueue_shard: i16,
2849    ) -> Result<(), AwaError> {
2850        let schema = self.schema();
2851        let lane_lock_key = format!("{schema}:{queue}:{priority}:{enqueue_shard}");
2852        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
2853            .bind(lane_lock_key)
2854            .execute(tx.as_mut())
2855            .await
2856            .map_err(map_sqlx_error)?;
2857
2858        sqlx::query(&format!(
2859            r#"
2860            INSERT INTO {schema}.queue_lanes (queue, priority)
2861            VALUES ($1, $2)
2862            ON CONFLICT (queue, priority) DO NOTHING
2863            "#
2864        ))
2865        .bind(queue)
2866        .bind(priority)
2867        .execute(tx.as_mut())
2868        .await
2869        .map_err(map_sqlx_error)?;
2870
2871        sqlx::query(&format!(
2872            r#"
2873            INSERT INTO {schema}.queue_enqueue_heads (queue, priority, enqueue_shard)
2874            VALUES ($1, $2, $3)
2875            ON CONFLICT (queue, priority, enqueue_shard) DO NOTHING
2876            "#
2877        ))
2878        .bind(queue)
2879        .bind(priority)
2880        .bind(enqueue_shard)
2881        .execute(tx.as_mut())
2882        .await
2883        .map_err(map_sqlx_error)?;
2884
2885        sqlx::query(&format!(
2886            r#"
2887            INSERT INTO {schema}.queue_claim_heads (queue, priority, enqueue_shard)
2888            VALUES ($1, $2, $3)
2889            ON CONFLICT (queue, priority, enqueue_shard) DO NOTHING
2890            "#
2891        ))
2892        .bind(queue)
2893        .bind(priority)
2894        .bind(enqueue_shard)
2895        .execute(tx.as_mut())
2896        .await
2897        .map_err(map_sqlx_error)?;
2898
2899        sqlx::query(&format!(
2900            r#"
2901            SELECT {schema}.ensure_lane_sequences($1, $2, $3)
2902            "#
2903        ))
2904        .bind(queue)
2905        .bind(priority)
2906        .bind(enqueue_shard)
2907        .execute(tx.as_mut())
2908        .await
2909        .map_err(map_sqlx_error)?;
2910
2911        self.mark_lane_ensured(queue, priority, enqueue_shard);
2912        Ok(())
2913    }
2914
2915    /// Pick the enqueue shard for a row on `queue`.
2916    ///
2917    /// Reads `awa.queue_meta.enqueue_shards` (default 1, cached
2918    /// in-process). With `enqueue_shards = 1` the result is always 0.
2919    /// With `enqueue_shards > 1`:
2920    ///
2921    /// - If `ordering_key` is `Some(k)`, the shard is a stable hash
2922    ///   of `k` modulo the shard count. Two rows sharing an
2923    ///   ordering key always land on the same shard, which preserves
2924    ///   FIFO within the key.
2925    /// - If `ordering_key` is `None`, the shard comes from the
2926    ///   per-store rotor, which spreads consecutive picks across
2927    ///   shards.
2928    async fn shard_for_enqueue(
2929        &self,
2930        pool_executor: impl sqlx::PgExecutor<'_>,
2931        queue: &str,
2932        ordering_key: Option<&[u8]>,
2933    ) -> Result<i16, AwaError> {
2934        if let Some(cached) = self
2935            .enqueue_shards_cache
2936            .lock()
2937            .expect("enqueue_shards_cache poisoned")
2938            .get(queue)
2939            .copied()
2940        {
2941            return Ok(self.pick_shard(cached, ordering_key));
2942        }
2943
2944        let shards: i16 = sqlx::query_scalar(
2945            r#"
2946            SELECT COALESCE(MAX(enqueue_shards), 1)::smallint
2947            FROM awa.queue_meta
2948            WHERE queue = $1
2949            "#,
2950        )
2951        .bind(queue)
2952        .fetch_one(pool_executor)
2953        .await
2954        .map_err(map_sqlx_error)?;
2955
2956        let shards = shards.max(1);
2957        self.enqueue_shards_cache
2958            .lock()
2959            .expect("enqueue_shards_cache poisoned")
2960            .insert(queue.to_string(), shards);
2961
2962        Ok(self.pick_shard(shards, ordering_key))
2963    }
2964
2965    /// Map a row (or a no-key sub-batch) to its shard.
2966    ///
2967    /// At `shards <= 1` every row goes to shard 0. Otherwise the
2968    /// caller-supplied `ordering_key` hashes deterministically into
2969    /// `[0, shards)` via [`shard_for_ordering_key`]; an absent key
2970    /// advances the per-store rotor once and returns the next shard.
2971    /// Callers route a whole no-key sub-batch through a single
2972    /// `pick_shard(_, None)` so the rotor amortises across the batch
2973    /// rather than firing per row.
2974    fn pick_shard(&self, shards: i16, ordering_key: Option<&[u8]>) -> i16 {
2975        if shards <= 1 {
2976            return 0;
2977        }
2978        match ordering_key {
2979            Some(key) => shard_for_ordering_key(key, shards),
2980            None => {
2981                let raw = self.shard_rotor.fetch_add(1, Ordering::Relaxed) as i32;
2982                raw.rem_euclid(shards as i32) as i16
2983            }
2984        }
2985    }
2986
2987    fn lane_is_cached(&self, queue: &str, priority: i16, enqueue_shard: i16) -> bool {
2988        let cache = self.ensured_lanes.lock().expect("ensured_lanes mutex");
2989        cache.contains(&(queue.to_string(), priority, enqueue_shard))
2990    }
2991
2992    fn mark_lane_ensured(&self, queue: &str, priority: i16, enqueue_shard: i16) {
2993        self.ensured_lanes
2994            .lock()
2995            .expect("ensured_lanes mutex")
2996            .insert((queue.to_string(), priority, enqueue_shard));
2997    }
2998
2999    fn invalidate_cached_lane(&self, queue: &str, priority: i16, enqueue_shard: i16) {
3000        self.ensured_lanes
3001            .lock()
3002            .expect("ensured_lanes mutex")
3003            .remove(&(queue.to_string(), priority, enqueue_shard));
3004    }
3005
3006    fn clear_lane_cache(&self) {
3007        self.ensured_lanes
3008            .lock()
3009            .expect("ensured_lanes mutex")
3010            .clear();
3011    }
3012
3013    // Reserve lane sequence numbers for a specific
3014    // `(queue, priority, shard)` triple and return the lane sequence at
3015    // which the caller's range starts. If the head row is missing —
3016    // typically because a previous ensure_lane ran inside a transaction
3017    // that ultimately rolled back, leaving a stale cache entry behind —
3018    // the cache entry is invalidated, ensure_lane is re-run, and the reserve
3019    // is retried exactly once. A second miss surfaces as
3020    // RowNotFound.
3021    async fn advance_enqueue_head<'a>(
3022        &self,
3023        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3024        queue: &str,
3025        priority: i16,
3026        enqueue_shard: i16,
3027        count: i64,
3028    ) -> Result<i64, AwaError> {
3029        let schema = self.schema();
3030        let sql = format!(
3031            r#"
3032            SELECT {schema}.reserve_enqueue_seq($1, $2, $3, $4)
3033            FROM {schema}.queue_enqueue_heads
3034            WHERE queue = $1 AND priority = $2 AND enqueue_shard = $3
3035            "#
3036        );
3037
3038        let maybe_start: Option<i64> = sqlx::query_scalar(&sql)
3039            .bind(queue)
3040            .bind(priority)
3041            .bind(enqueue_shard)
3042            .bind(count)
3043            .fetch_optional(tx.as_mut())
3044            .await
3045            .map_err(map_sqlx_error)?;
3046
3047        if let Some(start) = maybe_start {
3048            return Ok(start);
3049        }
3050
3051        // Recovery path: a prior ensure_lane marked the cache and
3052        // then rolled back, leaving the head row missing. Bypass the
3053        // cache fast path here and run the inserts unconditionally —
3054        // calling `ensure_lane` would re-take the fast path if
3055        // another concurrent transaction has re-marked the cache but
3056        // not yet committed its inserts, leaving us in the same
3057        // failure state.
3058        self.invalidate_cached_lane(queue, priority, enqueue_shard);
3059        self.ensure_lane_inserts(tx, queue, priority, enqueue_shard)
3060            .await?;
3061        let start: i64 = sqlx::query_scalar(&sql)
3062            .bind(queue)
3063            .bind(priority)
3064            .bind(enqueue_shard)
3065            .bind(count)
3066            .fetch_one(tx.as_mut())
3067            .await
3068            .map_err(map_sqlx_error)?;
3069        Ok(start)
3070    }
3071
3072    async fn current_queue_ring<'a>(
3073        &self,
3074        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3075    ) -> Result<(i32, i64), AwaError> {
3076        let schema = self.schema();
3077        sqlx::query_as(&format!(
3078            r#"
3079            SELECT current_slot, generation
3080            FROM {schema}.queue_ring_state
3081            WHERE singleton = TRUE
3082            "#
3083        ))
3084        .fetch_one(tx.as_mut())
3085        .await
3086        .map_err(map_sqlx_error)
3087    }
3088
3089    async fn next_job_ids<'a>(
3090        &self,
3091        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3092        count: usize,
3093    ) -> Result<Vec<i64>, AwaError> {
3094        if count == 0 {
3095            return Ok(Vec::new());
3096        }
3097
3098        let query = format!(
3099            "SELECT nextval('{}')::bigint FROM generate_series(1, $1::int)",
3100            self.job_id_sequence()
3101        );
3102
3103        sqlx::query_scalar(&query)
3104            .bind(count as i32)
3105            .fetch_all(tx.as_mut())
3106            .await
3107            .map_err(map_sqlx_error)
3108    }
3109
3110    async fn current_timestamp_tx<'a>(
3111        &self,
3112        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3113    ) -> Result<DateTime<Utc>, AwaError> {
3114        sqlx::query_scalar("SELECT clock_timestamp()")
3115            .fetch_one(tx.as_mut())
3116            .await
3117            .map_err(map_sqlx_error)
3118    }
3119
3120    async fn claim_ready_rows_tx<'a>(
3121        &self,
3122        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3123        queue: &str,
3124        max_batch: i64,
3125        deadline_duration: Duration,
3126        aging_interval: Duration,
3127    ) -> Result<Vec<ReadyJobLeaseRow>, AwaError> {
3128        let schema = self.schema();
3129        sqlx::query_as(&format!(
3130            r#"
3131            SELECT
3132                ready_slot,
3133                ready_generation,
3134                lane_seq,
3135                enqueue_shard,
3136                lease_slot,
3137                lease_generation,
3138                claim_slot,
3139                job_id,
3140                kind,
3141                queue,
3142                args,
3143                lane_priority,
3144                priority,
3145                attempt,
3146                run_lease,
3147                max_attempts,
3148                run_at,
3149                heartbeat_at,
3150                deadline_at,
3151                attempted_at,
3152                created_at,
3153                unique_key,
3154                unique_states,
3155                COALESCE(payload, '{{}}'::jsonb) AS payload
3156            FROM {schema}.claim_ready_runtime($1, $2, $3, $4)
3157            "#
3158        ))
3159        .bind(queue)
3160        .bind(max_batch)
3161        .bind(deadline_duration.as_secs_f64())
3162        .bind(aging_interval.as_secs_f64())
3163        .fetch_all(tx.as_mut())
3164        .await
3165        .map_err(map_sqlx_error)
3166    }
3167
3168    fn claim_cursor_advances(rows: &[ReadyJobLeaseRow]) -> Vec<ClaimCursorAdvance> {
3169        let mut next_by_lane: BTreeMap<ClaimCursorLaneKey, i64> = BTreeMap::new();
3170        for row in rows {
3171            let key = (row.queue.clone(), row.lane_priority, row.enqueue_shard);
3172            let next = row.lane_seq + 1;
3173            next_by_lane
3174                .entry(key)
3175                .and_modify(|current| *current = (*current).max(next))
3176                .or_insert(next);
3177        }
3178
3179        next_by_lane
3180            .into_iter()
3181            .map(
3182                |((queue, priority, enqueue_shard), next_seq)| ClaimCursorAdvance {
3183                    queue,
3184                    priority,
3185                    enqueue_shard,
3186                    next_seq,
3187                    only_if_current: None,
3188                },
3189            )
3190            .collect()
3191    }
3192
3193    fn normalize_claim_cursor_advances(advances: &[ClaimCursorAdvance]) -> Vec<ClaimCursorAdvance> {
3194        let mut grouped: GroupedClaimCursorAdvances = BTreeMap::new();
3195
3196        for advance in advances {
3197            let key = (
3198                advance.queue.clone(),
3199                advance.priority,
3200                advance.enqueue_shard,
3201            );
3202            let (unconditional, conditional) = grouped.entry(key).or_default();
3203            if let Some(only_if_current) = advance.only_if_current {
3204                conditional
3205                    .entry(only_if_current)
3206                    .and_modify(|next| *next = (*next).max(advance.next_seq))
3207                    .or_insert(advance.next_seq);
3208            } else {
3209                *unconditional = Some(
3210                    unconditional
3211                        .map(|next| next.max(advance.next_seq))
3212                        .unwrap_or(advance.next_seq),
3213                );
3214            }
3215        }
3216
3217        let mut normalized = Vec::with_capacity(advances.len());
3218        for ((queue, priority, enqueue_shard), (unconditional, conditional)) in grouped {
3219            if let Some(next_seq) = unconditional {
3220                normalized.push(ClaimCursorAdvance {
3221                    queue,
3222                    priority,
3223                    enqueue_shard,
3224                    next_seq,
3225                    only_if_current: None,
3226                });
3227                continue;
3228            }
3229
3230            for (only_if_current, next_seq) in conditional {
3231                normalized.push(ClaimCursorAdvance {
3232                    queue: queue.clone(),
3233                    priority,
3234                    enqueue_shard,
3235                    next_seq,
3236                    only_if_current: Some(only_if_current),
3237                });
3238            }
3239        }
3240
3241        normalized
3242    }
3243
3244    async fn advance_claim_cursors(&self, pool: &PgPool, advances: &[ClaimCursorAdvance]) {
3245        let advances = Self::normalize_claim_cursor_advances(advances);
3246        // PostgreSQL sequence state is not rolled back with the surrounding
3247        // transaction. Keep claim cursors lagging rather than risking a cursor
3248        // that gets ahead of ready rows whose claim/cancel transaction aborts.
3249        for attempt in 1..=3 {
3250            match self.advance_claim_cursors_strict(pool, &advances).await {
3251                Ok(()) => return,
3252                Err(err) if attempt < 3 => {
3253                    tracing::warn!(
3254                        error = ?err,
3255                        lanes = advances.len(),
3256                        attempt,
3257                        "failed to advance queue-storage claim cursors after committed state change; retrying"
3258                    );
3259                    tokio::time::sleep(Duration::from_millis(25 * attempt as u64)).await;
3260                }
3261                Err(err) => {
3262                    tracing::warn!(
3263                        error = ?err,
3264                        lanes = advances.len(),
3265                        attempts = attempt,
3266                        "failed to advance queue-storage claim cursors after committed state change"
3267                    );
3268                    return;
3269                }
3270            }
3271        }
3272    }
3273
3274    async fn advance_claim_cursors_strict(
3275        &self,
3276        pool: &PgPool,
3277        advances: &[ClaimCursorAdvance],
3278    ) -> Result<(), AwaError> {
3279        if advances.is_empty() {
3280            return Ok(());
3281        }
3282
3283        let schema = self.schema();
3284        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
3285        for advance in advances {
3286            sqlx::query(&format!(
3287                r#"
3288                WITH head AS MATERIALIZED (
3289                    SELECT seq_name
3290                    FROM {schema}.queue_claim_heads
3291                    WHERE queue = $1
3292                      AND priority = $2
3293                      AND enqueue_shard = $3
3294                    FOR UPDATE
3295                )
3296                SELECT {schema}.set_sequence_next(seq_name, $4)
3297                FROM head
3298                WHERE $5::bigint IS NULL
3299                   OR {schema}.sequence_next_value(seq_name) = $5
3300                "#
3301            ))
3302            .bind(&advance.queue)
3303            .bind(advance.priority)
3304            .bind(advance.enqueue_shard)
3305            .bind(advance.next_seq)
3306            .bind(advance.only_if_current)
3307            .execute(tx.as_mut())
3308            .await
3309            .map_err(map_sqlx_error)?;
3310        }
3311
3312        tx.commit().await.map_err(map_sqlx_error)?;
3313        Ok(())
3314    }
3315
3316    async fn execute_ready_inserts_tx<'a>(
3317        &self,
3318        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3319        rows: &[RuntimeReadyInsert],
3320    ) -> Result<usize, AwaError> {
3321        if rows.is_empty() {
3322            return Ok(0);
3323        }
3324
3325        let schema = self.schema();
3326        let ring = self.current_queue_ring(tx).await?;
3327        let mut builder = QueryBuilder::<Postgres>::new(format!(
3328            "INSERT INTO {schema}.ready_entries (ready_slot, ready_generation, job_id, kind, queue, args, priority, attempt, run_lease, max_attempts, lane_seq, enqueue_shard, run_at, attempted_at, created_at, unique_key, unique_states, payload) "
3329        ));
3330        builder.push_values(rows.iter(), |mut b, row| {
3331            b.push_bind(ring.0)
3332                .push_bind(ring.1)
3333                .push_bind(row.job_id)
3334                .push_bind(&row.kind)
3335                .push_bind(&row.queue)
3336                .push_bind(&row.args)
3337                .push_bind(row.priority)
3338                .push_bind(row.attempt)
3339                .push_bind(row.run_lease)
3340                .push_bind(row.max_attempts)
3341                .push_bind(row.lane_seq)
3342                .push_bind(row.enqueue_shard)
3343                .push_bind(row.run_at)
3344                .push_bind(row.attempted_at)
3345                .push_bind(row.created_at)
3346                .push_bind(&row.unique_key)
3347                .push_bind(&row.unique_states)
3348                .push_bind(storage_payload(&row.payload));
3349        });
3350        builder
3351            .build()
3352            .execute(tx.as_mut())
3353            .await
3354            .map_err(map_sqlx_error)?;
3355
3356        Ok(rows.len())
3357    }
3358
3359    async fn execute_ready_copy_tx<'a>(
3360        &self,
3361        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3362        rows: &[RuntimeReadyInsert],
3363    ) -> Result<usize, AwaError> {
3364        if rows.is_empty() {
3365            return Ok(0);
3366        }
3367
3368        let schema = self.schema();
3369        let ring = self.current_queue_ring(tx).await?;
3370        let copy_sql = format!(
3371            "COPY {schema}.ready_entries (ready_slot, ready_generation, job_id, kind, queue, args, priority, attempt, run_lease, max_attempts, lane_seq, enqueue_shard, run_at, attempted_at, created_at, unique_key, unique_states, payload) FROM STDIN WITH (FORMAT csv, NULL '{COPY_NULL_SENTINEL}')"
3372        );
3373        let mut copy_in = tx
3374            .as_mut()
3375            .copy_in_raw(&copy_sql)
3376            .await
3377            .map_err(map_sqlx_error)?;
3378        // 320 bytes/row is only a rough starting point; large JSON payloads
3379        // are bounded by chunked COPY sends below rather than by this reserve.
3380        let mut csv_buf = Vec::with_capacity(rows.len().min(1024) * 320);
3381        for row in rows {
3382            write_ready_copy_row(&mut csv_buf, ring.0, ring.1, row);
3383            if csv_buf.len() >= COPY_CHUNK_TARGET_BYTES {
3384                let chunk =
3385                    std::mem::replace(&mut csv_buf, Vec::with_capacity(COPY_CHUNK_TARGET_BYTES));
3386                copy_in.send(chunk).await.map_err(map_sqlx_error)?;
3387            }
3388        }
3389        if !csv_buf.is_empty() {
3390            copy_in.send(csv_buf).await.map_err(map_sqlx_error)?;
3391        }
3392        copy_in.finish().await.map_err(map_sqlx_error)?;
3393
3394        Ok(rows.len())
3395    }
3396
3397    async fn insert_ready_rows_tx<'a>(
3398        &self,
3399        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3400        rows: Vec<RuntimeReadyRow>,
3401    ) -> Result<usize, AwaError> {
3402        if rows.is_empty() {
3403            return Ok(0);
3404        }
3405
3406        let grouped = self.group_ready_rows_by_shard(tx, rows).await?;
3407        let total_rows: usize = grouped.values().map(Vec::len).sum();
3408        let job_ids = self.next_job_ids(tx, total_rows).await?;
3409        let mut job_id_iter = job_ids.into_iter();
3410
3411        let mut ready_rows = Vec::with_capacity(total_rows);
3412        let mut lane_ranges = Vec::with_capacity(grouped.len());
3413
3414        for ((queue, priority, enqueue_shard), lane_rows) in grouped {
3415            let range_start = ready_rows.len();
3416
3417            for row in lane_rows {
3418                let job_id = job_id_iter.next().ok_or_else(|| {
3419                    AwaError::Validation("queue storage job id allocation underflow".to_string())
3420                })?;
3421                ready_rows.push(RuntimeReadyInsert {
3422                    job_id,
3423                    kind: row.kind,
3424                    queue: row.queue,
3425                    args: row.args,
3426                    priority: row.priority,
3427                    attempt: row.attempt,
3428                    run_lease: row.run_lease,
3429                    max_attempts: row.max_attempts,
3430                    run_at: row.run_at,
3431                    attempted_at: row.attempted_at,
3432                    lane_seq: 0,
3433                    enqueue_shard,
3434                    created_at: row.created_at,
3435                    unique_key: row.unique_key,
3436                    unique_states: row.unique_states,
3437                    payload: row.payload,
3438                });
3439            }
3440            lane_ranges.push((
3441                queue,
3442                priority,
3443                enqueue_shard,
3444                range_start,
3445                ready_rows.len(),
3446            ));
3447        }
3448
3449        self.sync_ready_enqueue_unique_claims(tx, &ready_rows)
3450            .await?;
3451        for (queue, priority, enqueue_shard, range_start, range_end) in lane_ranges {
3452            self.ensure_lane(tx, &queue, priority, enqueue_shard)
3453                .await?;
3454
3455            let count = (range_end - range_start) as i64;
3456            let start_seq = self
3457                .advance_enqueue_head(tx, &queue, priority, enqueue_shard, count)
3458                .await?;
3459
3460            for (offset, row) in ready_rows[range_start..range_end].iter_mut().enumerate() {
3461                row.lane_seq = start_seq + offset as i64;
3462            }
3463        }
3464        self.execute_ready_inserts_tx(tx, &ready_rows).await?;
3465        Ok(total_rows)
3466    }
3467
3468    /// Re-group rows by `(queue, priority, enqueue_shard)` so each
3469    /// resulting bucket targets exactly one head row.
3470    ///
3471    /// The two routing modes are amortised differently:
3472    ///
3473    /// - **Rows with `ordering_key`** are hashed per row via
3474    ///   `shard_for_ordering_key`, so jobs that share a key always
3475    ///   land on the same shard. Each distinct key may produce its
3476    ///   own sub-bucket inside one batch.
3477    /// - **Rows without `ordering_key`** share **one rotor pick per
3478    ///   `(queue, priority)` per call**. A batch of 500 rotor-routed
3479    ///   rows produces one `advance_enqueue_head` UPDATE and one
3480    ///   INSERT, not 500. The rotor still advances per batch, so
3481    ///   successive batches spread across shards; the per-batch
3482    ///   amortisation is what makes `enqueue_shards > 1` net-faster
3483    ///   than `S = 1` at moderate concurrency.
3484    ///
3485    /// Mixing keyed and rotor rows in one call is supported — the
3486    /// keyed rows fan out by key while the rotor rows collapse into
3487    /// a single shard-sub-batch.
3488    async fn group_ready_rows_by_shard<'a>(
3489        &self,
3490        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3491        rows: Vec<RuntimeReadyRow>,
3492    ) -> Result<BTreeMap<(String, i16, i16), Vec<RuntimeReadyRow>>, AwaError> {
3493        // Partition by (queue, priority) first so the rotor pick for
3494        // the no-key sub-batch can amortise over every row that shares
3495        // its destination lane.
3496        let mut by_queue_priority: BTreeMap<(String, i16), Vec<RuntimeReadyRow>> = BTreeMap::new();
3497        for row in rows {
3498            by_queue_priority
3499                .entry((row.queue.clone(), row.priority))
3500                .or_default()
3501                .push(row);
3502        }
3503
3504        let mut grouped: BTreeMap<(String, i16, i16), Vec<RuntimeReadyRow>> = BTreeMap::new();
3505        for ((queue, priority), bucket) in by_queue_priority {
3506            let mut rotor_rows: Vec<RuntimeReadyRow> = Vec::with_capacity(bucket.len());
3507            for row in bucket {
3508                if row.ordering_key.is_some() {
3509                    let shard = self
3510                        .shard_for_enqueue(tx.as_mut(), &queue, row.ordering_key.as_deref())
3511                        .await?;
3512                    grouped
3513                        .entry((queue.clone(), priority, shard))
3514                        .or_default()
3515                        .push(row);
3516                } else {
3517                    rotor_rows.push(row);
3518                }
3519            }
3520            if !rotor_rows.is_empty() {
3521                let shard = self.shard_for_enqueue(tx.as_mut(), &queue, None).await?;
3522                grouped
3523                    .entry((queue.clone(), priority, shard))
3524                    .or_default()
3525                    .extend(rotor_rows);
3526            }
3527        }
3528        Ok(grouped)
3529    }
3530
3531    async fn insert_ready_rows_copy_tx<'a>(
3532        &self,
3533        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3534        rows: Vec<RuntimeReadyRow>,
3535        job_ids: Vec<i64>,
3536    ) -> Result<usize, AwaError> {
3537        if rows.is_empty() {
3538            return Ok(0);
3539        }
3540
3541        let grouped = self.group_ready_rows_by_shard(tx, rows).await?;
3542
3543        let total_rows: usize = grouped.values().map(Vec::len).sum();
3544        if job_ids.len() != total_rows {
3545            return Err(AwaError::Validation(
3546                "queue storage job id allocation count mismatch".to_string(),
3547            ));
3548        }
3549        let mut job_id_iter = job_ids.into_iter();
3550
3551        let mut ready_rows = Vec::with_capacity(total_rows);
3552        let mut lane_ranges = Vec::with_capacity(grouped.len());
3553
3554        for ((queue, priority, enqueue_shard), lane_rows) in grouped {
3555            let range_start = ready_rows.len();
3556
3557            for row in lane_rows {
3558                let job_id = job_id_iter.next().ok_or_else(|| {
3559                    AwaError::Validation("queue storage job id allocation underflow".to_string())
3560                })?;
3561                ready_rows.push(RuntimeReadyInsert {
3562                    job_id,
3563                    kind: row.kind,
3564                    queue: row.queue,
3565                    args: row.args,
3566                    priority: row.priority,
3567                    attempt: row.attempt,
3568                    run_lease: row.run_lease,
3569                    max_attempts: row.max_attempts,
3570                    run_at: row.run_at,
3571                    attempted_at: row.attempted_at,
3572                    lane_seq: 0,
3573                    enqueue_shard,
3574                    created_at: row.created_at,
3575                    unique_key: row.unique_key,
3576                    unique_states: row.unique_states,
3577                    payload: row.payload,
3578                });
3579            }
3580            lane_ranges.push((
3581                queue,
3582                priority,
3583                enqueue_shard,
3584                range_start,
3585                ready_rows.len(),
3586            ));
3587        }
3588
3589        self.sync_ready_enqueue_unique_claims(tx, &ready_rows)
3590            .await?;
3591        for (queue, priority, enqueue_shard, range_start, range_end) in lane_ranges {
3592            self.ensure_lane(tx, &queue, priority, enqueue_shard)
3593                .await?;
3594
3595            let count = (range_end - range_start) as i64;
3596            let start_seq = self
3597                .advance_enqueue_head(tx, &queue, priority, enqueue_shard, count)
3598                .await?;
3599
3600            for (offset, row) in ready_rows[range_start..range_end].iter_mut().enumerate() {
3601                row.lane_seq = start_seq + offset as i64;
3602            }
3603        }
3604        self.execute_ready_copy_tx(tx, &ready_rows).await?;
3605        Ok(total_rows)
3606    }
3607
3608    async fn insert_existing_ready_rows_tx<'a>(
3609        &self,
3610        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3611        rows: Vec<ExistingReadyRow>,
3612        old_state: Option<JobState>,
3613    ) -> Result<usize, AwaError> {
3614        if rows.is_empty() {
3615            return Ok(0);
3616        }
3617
3618        let mut grouped: BTreeMap<(String, i16), Vec<ExistingReadyRow>> = BTreeMap::new();
3619        for row in rows {
3620            grouped
3621                .entry((row.queue.clone(), row.priority))
3622                .or_default()
3623                .push(row);
3624        }
3625
3626        let total_rows: usize = grouped.values().map(Vec::len).sum();
3627        let mut ready_rows = Vec::with_capacity(total_rows);
3628
3629        for ((queue, priority), lane_rows) in grouped {
3630            // Re-enqueue paths (retry-after, age-waiting, DLQ retry,
3631            // callback resume) do not carry the producer's original
3632            // `ordering_key`: the row came back from terminal storage
3633            // or from a deferred row, where the key was not retained.
3634            // Fall back to the rotor so the retry batch picks a shard
3635            // uniformly. Workloads that need ordering preserved across
3636            // retries must re-enqueue with the original key from the
3637            // application layer.
3638            let enqueue_shard = self.shard_for_enqueue(tx.as_mut(), &queue, None).await?;
3639            self.ensure_lane(tx, &queue, priority, enqueue_shard)
3640                .await?;
3641
3642            let count = lane_rows.len() as i64;
3643            let start_seq = self
3644                .advance_enqueue_head(tx, &queue, priority, enqueue_shard, count)
3645                .await?;
3646
3647            for (offset, row) in lane_rows.into_iter().enumerate() {
3648                self.sync_unique_claim(
3649                    tx,
3650                    row.job_id,
3651                    &row.unique_key,
3652                    row.unique_states.as_deref(),
3653                    old_state,
3654                    Some(JobState::Available),
3655                )
3656                .await?;
3657                ready_rows.push(RuntimeReadyInsert {
3658                    job_id: row.job_id,
3659                    kind: row.kind,
3660                    queue: row.queue,
3661                    args: row.args,
3662                    priority: row.priority,
3663                    attempt: row.attempt,
3664                    run_lease: row.run_lease,
3665                    max_attempts: row.max_attempts,
3666                    run_at: row.run_at,
3667                    attempted_at: row.attempted_at,
3668                    lane_seq: start_seq + offset as i64,
3669                    enqueue_shard,
3670                    created_at: row.created_at,
3671                    unique_key: row.unique_key,
3672                    unique_states: row.unique_states,
3673                    payload: row.payload,
3674                });
3675            }
3676        }
3677
3678        self.execute_ready_inserts_tx(tx, &ready_rows).await?;
3679        Ok(total_rows)
3680    }
3681
3682    async fn insert_deferred_rows_tx<'a>(
3683        &self,
3684        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3685        rows: Vec<DeferredJobRow>,
3686        old_state: Option<JobState>,
3687    ) -> Result<usize, AwaError> {
3688        if rows.is_empty() {
3689            return Ok(0);
3690        }
3691
3692        if old_state.is_none() {
3693            self.sync_deferred_enqueue_unique_claims(tx, &rows).await?;
3694        } else {
3695            for row in &rows {
3696                self.sync_unique_claim(
3697                    tx,
3698                    row.job_id,
3699                    &row.unique_key,
3700                    row.unique_states.as_deref(),
3701                    old_state,
3702                    Some(row.state),
3703                )
3704                .await?;
3705            }
3706        }
3707
3708        let schema = self.schema();
3709        let mut builder = QueryBuilder::<Postgres>::new(format!(
3710            "INSERT INTO {schema}.deferred_jobs (job_id, kind, queue, args, state, priority, attempt, run_lease, max_attempts, run_at, attempted_at, finalized_at, created_at, unique_key, unique_states, payload) "
3711        ));
3712        builder.push_values(rows.iter(), |mut b, row| {
3713            b.push_bind(row.job_id)
3714                .push_bind(&row.kind)
3715                .push_bind(&row.queue)
3716                .push_bind(&row.args)
3717                .push_bind(row.state)
3718                .push_bind(row.priority)
3719                .push_bind(row.attempt)
3720                .push_bind(row.run_lease)
3721                .push_bind(row.max_attempts)
3722                .push_bind(row.run_at)
3723                .push_bind(row.attempted_at)
3724                .push_bind(row.finalized_at)
3725                .push_bind(row.created_at)
3726                .push_bind(&row.unique_key)
3727                .push_bind(&row.unique_states)
3728                .push_bind(storage_payload(&row.payload));
3729        });
3730        builder
3731            .build()
3732            .execute(tx.as_mut())
3733            .await
3734            .map_err(map_sqlx_error)?;
3735
3736        Ok(rows.len())
3737    }
3738
3739    async fn insert_deferred_rows_copy_tx<'a>(
3740        &self,
3741        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3742        rows: Vec<DeferredJobRow>,
3743    ) -> Result<usize, AwaError> {
3744        if rows.is_empty() {
3745            return Ok(0);
3746        }
3747
3748        self.sync_deferred_enqueue_unique_claims(tx, &rows).await?;
3749
3750        let schema = self.schema();
3751        let copy_sql = format!(
3752            "COPY {schema}.deferred_jobs (job_id, kind, queue, args, state, priority, attempt, run_lease, max_attempts, run_at, attempted_at, finalized_at, created_at, unique_key, unique_states, payload) FROM STDIN WITH (FORMAT csv, NULL '{COPY_NULL_SENTINEL}')"
3753        );
3754        let mut copy_in = tx
3755            .as_mut()
3756            .copy_in_raw(&copy_sql)
3757            .await
3758            .map_err(map_sqlx_error)?;
3759        // 320 bytes/row is only a rough starting point; large JSON payloads
3760        // are bounded by chunked COPY sends below rather than by this reserve.
3761        let mut csv_buf = Vec::with_capacity(rows.len().min(1024) * 320);
3762        for row in &rows {
3763            write_deferred_copy_row(&mut csv_buf, row);
3764            if csv_buf.len() >= COPY_CHUNK_TARGET_BYTES {
3765                let chunk =
3766                    std::mem::replace(&mut csv_buf, Vec::with_capacity(COPY_CHUNK_TARGET_BYTES));
3767                copy_in.send(chunk).await.map_err(map_sqlx_error)?;
3768            }
3769        }
3770        if !csv_buf.is_empty() {
3771            copy_in.send(csv_buf).await.map_err(map_sqlx_error)?;
3772        }
3773        copy_in.finish().await.map_err(map_sqlx_error)?;
3774
3775        Ok(rows.len())
3776    }
3777
3778    async fn insert_done_rows_tx<'a>(
3779        &self,
3780        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3781        rows: &[DoneJobRow],
3782        old_state: Option<JobState>,
3783    ) -> Result<usize, AwaError> {
3784        if rows.is_empty() {
3785            return Ok(0);
3786        }
3787
3788        for row in rows {
3789            self.sync_unique_claim(
3790                tx,
3791                row.job_id,
3792                &row.unique_key,
3793                row.unique_states.as_deref(),
3794                old_state,
3795                Some(row.state),
3796            )
3797            .await?;
3798        }
3799
3800        let schema = self.schema();
3801        let keep_ready_backing = matches!(
3802            old_state,
3803            Some(JobState::Running | JobState::WaitingExternal)
3804        );
3805        let ready_payloads = if rows
3806            .iter()
3807            .any(|row| !is_storage_payload_empty(&row.payload))
3808        {
3809            self.ready_payloads_for_done_rows_tx(tx, rows).await?
3810        } else {
3811            HashMap::new()
3812        };
3813        let mut ordered_rows: Vec<&DoneJobRow> = rows.iter().collect();
3814        ordered_rows.sort_unstable_by_key(|row| {
3815            (
3816                row.ready_slot,
3817                row.ready_generation,
3818                row.queue.as_str(),
3819                row.priority,
3820                row.enqueue_shard,
3821                row.lane_seq,
3822                row.job_id,
3823            )
3824        });
3825        let (ready_backed, synthetic): (Vec<_>, Vec<_>) = ordered_rows
3826            .into_iter()
3827            .partition(|row| keep_ready_backing && row.lane_seq >= 0);
3828
3829        if !ready_backed.is_empty() {
3830            let mut builder = QueryBuilder::<Postgres>::new(format!(
3831                "INSERT INTO {schema}.done_entries (ready_slot, ready_generation, job_id, kind, queue, state, priority, attempt, run_lease, lane_seq, enqueue_shard, attempted_at, finalized_at, payload) "
3832            ));
3833            builder.push_values(ready_backed, |mut b, row| {
3834                let ready_key = (
3835                    row.ready_slot,
3836                    row.ready_generation,
3837                    row.queue.as_str(),
3838                    row.priority,
3839                    row.enqueue_shard,
3840                    row.lane_seq,
3841                );
3842                let ready_payload = ready_payloads.get(&ready_key);
3843                b.push_bind(row.ready_slot)
3844                    .push_bind(row.ready_generation)
3845                    .push_bind(row.job_id)
3846                    .push_bind(&row.kind)
3847                    .push_bind(&row.queue)
3848                    .push_bind(row.state)
3849                    .push_bind(row.priority)
3850                    .push_bind(row.attempt)
3851                    .push_bind(row.run_lease)
3852                    .push_bind(row.lane_seq)
3853                    .push_bind(row.enqueue_shard)
3854                    .push_bind(row.attempted_at)
3855                    .push_bind(row.finalized_at)
3856                    .push_bind(terminal_storage_payload(&row.payload, ready_payload));
3857            });
3858            builder
3859                .build()
3860                .execute(tx.as_mut())
3861                .await
3862                .map_err(map_sqlx_error)?;
3863        }
3864
3865        if !synthetic.is_empty() {
3866            let mut builder = QueryBuilder::<Postgres>::new(format!(
3867                "INSERT INTO {schema}.done_entries (ready_slot, ready_generation, job_id, kind, queue, args, state, priority, attempt, run_lease, max_attempts, lane_seq, enqueue_shard, run_at, attempted_at, finalized_at, created_at, unique_key, unique_states, payload) "
3868            ));
3869            builder.push_values(synthetic, |mut b, row| {
3870                let ready_key = (
3871                    row.ready_slot,
3872                    row.ready_generation,
3873                    row.queue.as_str(),
3874                    row.priority,
3875                    row.enqueue_shard,
3876                    row.lane_seq,
3877                );
3878                let ready_payload = ready_payloads.get(&ready_key);
3879                b.push_bind(row.ready_slot)
3880                    .push_bind(row.ready_generation)
3881                    .push_bind(row.job_id)
3882                    .push_bind(&row.kind)
3883                    .push_bind(&row.queue)
3884                    .push_bind(&row.args)
3885                    .push_bind(row.state)
3886                    .push_bind(row.priority)
3887                    .push_bind(row.attempt)
3888                    .push_bind(row.run_lease)
3889                    .push_bind(row.max_attempts)
3890                    .push_bind(row.lane_seq)
3891                    .push_bind(row.enqueue_shard)
3892                    .push_bind(row.run_at)
3893                    .push_bind(row.attempted_at)
3894                    .push_bind(row.finalized_at)
3895                    .push_bind(row.created_at)
3896                    .push_bind(&row.unique_key)
3897                    .push_bind(&row.unique_states)
3898                    .push_bind(terminal_storage_payload(&row.payload, ready_payload));
3899            });
3900            builder
3901                .build()
3902                .execute(tx.as_mut())
3903                .await
3904                .map_err(map_sqlx_error)?;
3905        }
3906
3907        // Terminal counts stay exact without mutating the hot live-counter
3908        // rows on every completion. The hot path appends signed deltas; the
3909        // maintenance rollup folds them into queue_terminal_live_counts later.
3910        self.increment_live_terminal_counters_tx(tx, rows).await?;
3911
3912        Ok(rows.len())
3913    }
3914
3915    /// Append positive terminal-count deltas for newly inserted terminal rows.
3916    ///
3917    /// The exact invariant is:
3918    ///
3919    /// `SUM(queue_terminal_live_counts) + SUM(queue_terminal_count_deltas)
3920    /// == count(*) FROM done_entries`.
3921    ///
3922    /// Maintenance asynchronously folds delta rows into the live-counter table
3923    /// and truncates the delta segment. The hot path therefore performs only
3924    /// append-only writes.
3925    async fn increment_live_terminal_counters_tx<'a>(
3926        &self,
3927        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3928        rows: &[DoneJobRow],
3929    ) -> Result<(), AwaError> {
3930        if rows.is_empty() {
3931            return Ok(());
3932        }
3933        let mut by_group: BTreeMap<TerminalCounterKey, i64> = BTreeMap::new();
3934        for row in rows {
3935            let key = (
3936                row.ready_slot,
3937                row.ready_generation,
3938                row.queue.clone(),
3939                row.priority,
3940                row.enqueue_shard,
3941                terminal_counter_bucket(row.job_id),
3942            );
3943            *by_group.entry(key).or_insert(0) += 1;
3944        }
3945        self.append_terminal_count_deltas_tx(tx, by_group).await
3946    }
3947
3948    /// Append negative terminal-count deltas for deleted terminal rows.
3949    ///
3950    /// This is used by retry-from-terminal, DLQ moves, discard paths, and the
3951    /// SQL compatibility delete function. The negative row may cancel a
3952    /// not-yet-rolled positive delta or reduce an already-folded live counter;
3953    /// exact reads sum both sources, so either order is correct.
3954    async fn decrement_live_terminal_counters_tx<'a>(
3955        &self,
3956        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3957        rows: &[TerminalCounterKey],
3958    ) -> Result<(), AwaError> {
3959        if rows.is_empty() {
3960            return Ok(());
3961        }
3962        let mut by_group: BTreeMap<TerminalCounterKey, i64> = BTreeMap::new();
3963        for key in rows {
3964            *by_group.entry(key.clone()).or_insert(0) -= 1;
3965        }
3966        self.append_terminal_count_deltas_tx(tx, by_group).await
3967    }
3968
3969    async fn append_terminal_count_deltas_tx<'a>(
3970        &self,
3971        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
3972        by_group: BTreeMap<TerminalCounterKey, i64>,
3973    ) -> Result<(), AwaError> {
3974        if by_group.is_empty() {
3975            return Ok(());
3976        }
3977
3978        let mut ready_slots: Vec<i32> = Vec::with_capacity(by_group.len());
3979        let mut ready_generations: Vec<i64> = Vec::with_capacity(by_group.len());
3980        let mut queues: Vec<String> = Vec::with_capacity(by_group.len());
3981        let mut priorities: Vec<i16> = Vec::with_capacity(by_group.len());
3982        let mut enqueue_shards: Vec<i16> = Vec::with_capacity(by_group.len());
3983        let mut counter_buckets: Vec<i16> = Vec::with_capacity(by_group.len());
3984        let mut deltas: Vec<i64> = Vec::with_capacity(by_group.len());
3985        for ((slot, generation, queue, prio, shard, bucket), delta) in by_group {
3986            if delta == 0 {
3987                continue;
3988            }
3989            ready_slots.push(slot);
3990            ready_generations.push(generation);
3991            queues.push(queue);
3992            priorities.push(prio);
3993            enqueue_shards.push(shard);
3994            counter_buckets.push(bucket);
3995            deltas.push(delta);
3996        }
3997
3998        if deltas.is_empty() {
3999            return Ok(());
4000        }
4001
4002        let schema = self.schema();
4003        sqlx::query(&format!(
4004            r#"
4005            INSERT INTO {schema}.queue_terminal_count_deltas (
4006                ready_slot,
4007                ready_generation,
4008                queue,
4009                priority,
4010                enqueue_shard,
4011                counter_bucket,
4012                terminal_delta
4013            )
4014            SELECT
4015                ready_slot,
4016                ready_generation,
4017                queue,
4018                priority,
4019                enqueue_shard,
4020                counter_bucket,
4021                terminal_delta
4022            FROM unnest(
4023                $1::int[],
4024                $2::bigint[],
4025                $3::text[],
4026                $4::smallint[],
4027                $5::smallint[],
4028                $6::smallint[],
4029                $7::bigint[]
4030            ) AS d(
4031                ready_slot,
4032                ready_generation,
4033                queue,
4034                priority,
4035                enqueue_shard,
4036                counter_bucket,
4037                terminal_delta
4038            )
4039            ORDER BY
4040                ready_slot,
4041                ready_generation,
4042                queue,
4043                priority,
4044                enqueue_shard,
4045                counter_bucket
4046            "#
4047        ))
4048        .bind(&ready_slots)
4049        .bind(&ready_generations)
4050        .bind(&queues)
4051        .bind(&priorities)
4052        .bind(&enqueue_shards)
4053        .bind(&counter_buckets)
4054        .bind(&deltas)
4055        .execute(tx.as_mut())
4056        .await
4057        .map_err(map_sqlx_error)?;
4058        Ok(())
4059    }
4060
4061    /// Build the row-key vector for `decrement_live_terminal_counters_tx`
4062    /// from a slice of `DoneJobRow` (used by retry-from-terminal,
4063    /// DLQ move, and discard, all of which DELETE FROM done_entries
4064    /// with `RETURNING *` materialised into `DoneJobRow`).
4065    fn done_rows_to_counter_keys(rows: &[DoneJobRow]) -> Vec<TerminalCounterKey> {
4066        rows.iter()
4067            .map(|row| {
4068                (
4069                    row.ready_slot,
4070                    row.ready_generation,
4071                    row.queue.clone(),
4072                    row.priority,
4073                    row.enqueue_shard,
4074                    terminal_counter_bucket(row.job_id),
4075                )
4076            })
4077            .collect()
4078    }
4079
4080    async fn ready_payloads_for_done_rows_tx<'a, 'r>(
4081        &self,
4082        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
4083        rows: &'r [DoneJobRow],
4084    ) -> Result<HashMap<(i32, i64, &'r str, i16, i16, i64), serde_json::Value>, AwaError> {
4085        if rows.is_empty() {
4086            return Ok(HashMap::new());
4087        }
4088
4089        let schema = self.schema();
4090        let ready_slots: Vec<i32> = rows.iter().map(|row| row.ready_slot).collect();
4091        let ready_generations: Vec<i64> = rows.iter().map(|row| row.ready_generation).collect();
4092        let queues: Vec<&str> = rows.iter().map(|row| row.queue.as_str()).collect();
4093        let priorities: Vec<i16> = rows.iter().map(|row| row.priority).collect();
4094        let enqueue_shards: Vec<i16> = rows.iter().map(|row| row.enqueue_shard).collect();
4095        let lane_seqs: Vec<i64> = rows.iter().map(|row| row.lane_seq).collect();
4096
4097        let payload_rows: Vec<(i32, i64, String, i16, i16, i64, serde_json::Value)> =
4098            sqlx::query_as(&format!(
4099                r#"
4100                WITH refs(ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq) AS (
4101                    SELECT * FROM unnest($1::int[], $2::bigint[], $3::text[], $4::smallint[], $5::smallint[], $6::bigint[])
4102                )
4103                SELECT
4104                    ready.ready_slot,
4105                    ready.ready_generation,
4106                    ready.queue,
4107                    ready.priority,
4108                    ready.enqueue_shard,
4109                    ready.lane_seq,
4110                    COALESCE(ready.payload, '{{}}'::jsonb) AS payload
4111                FROM refs
4112                JOIN {schema}.ready_entries AS ready
4113                  ON ready.ready_slot = refs.ready_slot
4114                 AND ready.ready_generation = refs.ready_generation
4115                 AND ready.queue = refs.queue
4116                 AND ready.priority = refs.priority
4117                 AND ready.enqueue_shard = refs.enqueue_shard
4118                 AND ready.lane_seq = refs.lane_seq
4119                "#
4120            ))
4121            .bind(&ready_slots)
4122            .bind(&ready_generations)
4123            .bind(&queues)
4124            .bind(&priorities)
4125            .bind(&enqueue_shards)
4126            .bind(&lane_seqs)
4127            .fetch_all(tx.as_mut())
4128            .await
4129            .map_err(map_sqlx_error)?;
4130
4131        let mut payload_by_owned_key = HashMap::with_capacity(payload_rows.len());
4132        for (ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, payload) in
4133            payload_rows
4134        {
4135            payload_by_owned_key.insert(
4136                (
4137                    ready_slot,
4138                    ready_generation,
4139                    queue,
4140                    priority,
4141                    enqueue_shard,
4142                    lane_seq,
4143                ),
4144                payload,
4145            );
4146        }
4147
4148        let mut payloads = HashMap::with_capacity(payload_by_owned_key.len());
4149        for row in rows {
4150            if let Some(payload) = payload_by_owned_key.remove(&(
4151                row.ready_slot,
4152                row.ready_generation,
4153                row.queue.clone(),
4154                row.priority,
4155                row.enqueue_shard,
4156                row.lane_seq,
4157            )) {
4158                payloads.insert(
4159                    (
4160                        row.ready_slot,
4161                        row.ready_generation,
4162                        row.queue.as_str(),
4163                        row.priority,
4164                        row.enqueue_shard,
4165                        row.lane_seq,
4166                    ),
4167                    payload,
4168                );
4169            }
4170        }
4171        Ok(payloads)
4172    }
4173
4174    async fn insert_dlq_rows_tx<'a>(
4175        &self,
4176        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
4177        rows: &[DlqJobRow],
4178        old_state: Option<JobState>,
4179    ) -> Result<usize, AwaError> {
4180        if rows.is_empty() {
4181            return Ok(0);
4182        }
4183
4184        for row in rows {
4185            self.sync_unique_claim(
4186                tx,
4187                row.job_id,
4188                &row.unique_key,
4189                row.unique_states.as_deref(),
4190                old_state,
4191                Some(JobState::Failed),
4192            )
4193            .await?;
4194        }
4195
4196        let schema = self.schema();
4197        let mut builder = QueryBuilder::<Postgres>::new(format!(
4198            "INSERT INTO {schema}.dlq_entries (job_id, kind, queue, args, state, priority, attempt, run_lease, max_attempts, run_at, attempted_at, finalized_at, created_at, unique_key, unique_states, payload, dlq_reason, dlq_at, original_run_lease) "
4199        ));
4200        builder.push_values(rows.iter(), |mut b, row| {
4201            b.push_bind(row.job_id)
4202                .push_bind(&row.kind)
4203                .push_bind(&row.queue)
4204                .push_bind(&row.args)
4205                .push_bind(row.state)
4206                .push_bind(row.priority)
4207                .push_bind(row.attempt)
4208                .push_bind(row.run_lease)
4209                .push_bind(row.max_attempts)
4210                .push_bind(row.run_at)
4211                .push_bind(row.attempted_at)
4212                .push_bind(row.finalized_at)
4213                .push_bind(row.created_at)
4214                .push_bind(&row.unique_key)
4215                .push_bind(&row.unique_states)
4216                .push_bind(storage_payload(&row.payload))
4217                .push_bind(&row.dlq_reason)
4218                .push_bind(row.dlq_at)
4219                .push_bind(row.original_run_lease);
4220        });
4221        builder
4222            .build()
4223            .execute(tx.as_mut())
4224            .await
4225            .map_err(map_sqlx_error)?;
4226
4227        Ok(rows.len())
4228    }
4229
4230    async fn adjust_terminal_rollups_batch<'a, I>(
4231        &self,
4232        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
4233        deltas: I,
4234    ) -> Result<(), AwaError>
4235    where
4236        I: IntoIterator<Item = (String, i16, i64)>,
4237    {
4238        let mut grouped: BTreeMap<(String, i16), i64> = BTreeMap::new();
4239        for (queue, priority, pruned_completed_delta) in deltas {
4240            if pruned_completed_delta == 0 {
4241                continue;
4242            }
4243            *grouped.entry((queue, priority)).or_insert(0_i64) += pruned_completed_delta;
4244        }
4245
4246        if grouped.is_empty() {
4247            return Ok(());
4248        }
4249
4250        let schema = self.schema();
4251        let mut queues = Vec::with_capacity(grouped.len());
4252        let mut priorities = Vec::with_capacity(grouped.len());
4253        let mut pruned_completed_deltas = Vec::with_capacity(grouped.len());
4254
4255        for ((queue, priority), pruned_completed_delta) in grouped {
4256            queues.push(queue);
4257            priorities.push(priority);
4258            pruned_completed_deltas.push(pruned_completed_delta);
4259        }
4260
4261        sqlx::query(&format!(
4262            r#"
4263            WITH deltas(queue, priority, pruned_completed_delta) AS (
4264                SELECT *
4265                FROM unnest(
4266                    $1::text[],
4267                    $2::smallint[],
4268                    $3::bigint[]
4269                )
4270            )
4271            INSERT INTO {schema}.queue_terminal_rollups AS rollups (
4272                queue,
4273                priority,
4274                pruned_completed_count
4275            )
4276            SELECT
4277                deltas.queue,
4278                deltas.priority,
4279                deltas.pruned_completed_delta
4280            FROM deltas
4281            ON CONFLICT (queue, priority) DO UPDATE
4282            SET pruned_completed_count = GREATEST(
4283                0,
4284                rollups.pruned_completed_count + EXCLUDED.pruned_completed_count
4285            )
4286            "#
4287        ))
4288        .bind(&queues)
4289        .bind(&priorities)
4290        .bind(&pruned_completed_deltas)
4291        .execute(tx.as_mut())
4292        .await
4293        .map_err(map_sqlx_error)?;
4294        Ok(())
4295    }
4296
4297    async fn enqueue_runtime_rows(
4298        &self,
4299        pool: &PgPool,
4300        rows: Vec<RuntimeReadyRow>,
4301    ) -> Result<usize, AwaError> {
4302        if rows.is_empty() {
4303            return Ok(0);
4304        }
4305
4306        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
4307        let total_rows = self.insert_ready_rows_tx(&mut tx, rows.clone()).await?;
4308
4309        let queues_to_notify: Vec<String> = rows.iter().map(|row| row.queue.clone()).collect();
4310        self.notify_queues_tx(&mut tx, queues_to_notify).await?;
4311
4312        tx.commit().await.map_err(map_sqlx_error)?;
4313        Ok(total_rows)
4314    }
4315
4316    pub async fn enqueue_batch(
4317        &self,
4318        pool: &PgPool,
4319        queue: &str,
4320        priority: i16,
4321        count: i64,
4322    ) -> Result<i64, AwaError> {
4323        if count <= 0 {
4324            return Ok(0);
4325        }
4326
4327        let rows: Vec<_> = (0..count)
4328            .map(|seq| RuntimeReadyRow {
4329                kind: "bench_job".to_string(),
4330                queue: if self.uses_queue_striping() && !self.is_physical_stripe_queue(queue) {
4331                    self.physical_queue_for_stripe(
4332                        queue,
4333                        seq.rem_euclid(self.queue_stripe_count() as i64) as usize,
4334                    )
4335                } else {
4336                    queue.to_string()
4337                },
4338                args: serde_json::json!({ "seq": seq }),
4339                priority,
4340                attempt: 0,
4341                run_lease: 0,
4342                max_attempts: 25,
4343                run_at: Utc::now(),
4344                attempted_at: None,
4345                created_at: Utc::now(),
4346                unique_key: None,
4347                unique_states: None,
4348                payload: RuntimePayload::default().into_json(),
4349                ordering_key: None,
4350            })
4351            .collect();
4352        self.enqueue_runtime_rows(pool, rows)
4353            .await
4354            .map(|count| count as i64)
4355    }
4356
4357    pub async fn enqueue_params_batch(
4358        &self,
4359        pool: &PgPool,
4360        jobs: &[InsertParams],
4361    ) -> Result<usize, AwaError> {
4362        if jobs.is_empty() {
4363            return Ok(0);
4364        }
4365
4366        let now = Utc::now();
4367        let mut ready_rows = Vec::new();
4368        let mut deferred_rows = Vec::new();
4369
4370        for (idx, job) in jobs.iter().enumerate() {
4371            let prepared = prepare_row_raw(job.kind.clone(), job.args.clone(), job.opts.clone())?;
4372            let payload = Self::payload_from_parts(prepared.metadata, prepared.tags, None, None)?;
4373            let queue =
4374                self.queue_stripe_for_enqueue(&prepared.queue, &prepared.unique_key, idx as i64);
4375
4376            let ready_row = RuntimeReadyRow {
4377                kind: prepared.kind,
4378                queue: queue.clone(),
4379                args: prepared.args,
4380                priority: prepared.priority,
4381                attempt: 0,
4382                run_lease: 0,
4383                max_attempts: prepared.max_attempts,
4384                run_at: prepared.run_at.unwrap_or(now),
4385                attempted_at: None,
4386                created_at: now,
4387                unique_key: prepared.unique_key,
4388                unique_states: prepared.unique_states,
4389                payload: payload.clone(),
4390                ordering_key: prepared.ordering_key,
4391            };
4392
4393            match prepared.state {
4394                JobState::Available => ready_rows.push(ready_row),
4395                JobState::Scheduled => deferred_rows.push(DeferredJobRow {
4396                    job_id: 0,
4397                    kind: ready_row.kind,
4398                    queue,
4399                    args: ready_row.args,
4400                    state: JobState::Scheduled,
4401                    priority: ready_row.priority,
4402                    attempt: ready_row.attempt,
4403                    run_lease: ready_row.run_lease,
4404                    max_attempts: ready_row.max_attempts,
4405                    run_at: ready_row.run_at,
4406                    attempted_at: ready_row.attempted_at,
4407                    finalized_at: None,
4408                    created_at: ready_row.created_at,
4409                    unique_key: ready_row.unique_key,
4410                    unique_states: ready_row.unique_states,
4411                    payload: payload.clone(),
4412                }),
4413                other => {
4414                    return Err(AwaError::Validation(format!(
4415                        "queue storage does not support initial state {other}"
4416                    )));
4417                }
4418            }
4419        }
4420
4421        let queues_to_notify: Vec<String> =
4422            ready_rows.iter().map(|row| row.queue.clone()).collect();
4423
4424        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
4425        let mut total = 0usize;
4426        if !ready_rows.is_empty() {
4427            total += self
4428                .insert_ready_rows_tx(&mut tx, ready_rows.clone())
4429                .await?;
4430        }
4431        if !deferred_rows.is_empty() {
4432            let ids = self.next_job_ids(&mut tx, deferred_rows.len()).await?;
4433            let deferred_rows: Vec<_> = deferred_rows
4434                .into_iter()
4435                .zip(ids)
4436                .map(|(row, id)| DeferredJobRow { job_id: id, ..row })
4437                .collect();
4438            total += self
4439                .insert_deferred_rows_tx(&mut tx, deferred_rows, None)
4440                .await?;
4441        }
4442
4443        self.notify_queues_tx(&mut tx, queues_to_notify).await?;
4444
4445        tx.commit().await.map_err(map_sqlx_error)?;
4446        Ok(total)
4447    }
4448
4449    /// Enqueue prepared jobs into queue storage using PostgreSQL COPY.
4450    ///
4451    /// This follows the same preparation, queue striping, lane sequencing,
4452    /// uniqueness, and notification semantics as [`Self::enqueue_params_batch`],
4453    /// but streams materialized rows directly into `ready_entries` and
4454    /// `deferred_jobs` instead of building multi-row `INSERT` statements.
4455    #[tracing::instrument(skip(self, pool, jobs), fields(job.count = jobs.len()), name = "queue_storage.enqueue_params_copy")]
4456    pub async fn enqueue_params_copy(
4457        &self,
4458        pool: &PgPool,
4459        jobs: &[InsertParams],
4460    ) -> Result<usize, AwaError> {
4461        if jobs.is_empty() {
4462            return Ok(0);
4463        }
4464
4465        let now = Utc::now();
4466        let mut ready_rows = Vec::new();
4467        let mut deferred_rows = Vec::new();
4468
4469        for (idx, job) in jobs.iter().enumerate() {
4470            let prepared = prepare_row_raw(job.kind.clone(), job.args.clone(), job.opts.clone())?;
4471            let payload = Self::payload_from_parts(prepared.metadata, prepared.tags, None, None)?;
4472            let queue =
4473                self.queue_stripe_for_enqueue(&prepared.queue, &prepared.unique_key, idx as i64);
4474
4475            let ready_row = RuntimeReadyRow {
4476                kind: prepared.kind,
4477                queue: queue.clone(),
4478                args: prepared.args,
4479                priority: prepared.priority,
4480                attempt: 0,
4481                run_lease: 0,
4482                max_attempts: prepared.max_attempts,
4483                run_at: prepared.run_at.unwrap_or(now),
4484                attempted_at: None,
4485                created_at: now,
4486                unique_key: prepared.unique_key,
4487                unique_states: prepared.unique_states,
4488                payload: payload.clone(),
4489                ordering_key: prepared.ordering_key,
4490            };
4491
4492            match prepared.state {
4493                JobState::Available => ready_rows.push(ready_row),
4494                JobState::Scheduled => deferred_rows.push(DeferredJobRow {
4495                    job_id: 0,
4496                    kind: ready_row.kind,
4497                    queue,
4498                    args: ready_row.args,
4499                    state: JobState::Scheduled,
4500                    priority: ready_row.priority,
4501                    attempt: ready_row.attempt,
4502                    run_lease: ready_row.run_lease,
4503                    max_attempts: ready_row.max_attempts,
4504                    run_at: ready_row.run_at,
4505                    attempted_at: ready_row.attempted_at,
4506                    finalized_at: None,
4507                    created_at: ready_row.created_at,
4508                    unique_key: ready_row.unique_key,
4509                    unique_states: ready_row.unique_states,
4510                    payload: payload.clone(),
4511                }),
4512                other => {
4513                    return Err(AwaError::Validation(format!(
4514                        "queue storage does not support initial state {other}"
4515                    )));
4516                }
4517            }
4518        }
4519
4520        let queues_to_notify: Vec<String> =
4521            ready_rows.iter().map(|row| row.queue.clone()).collect();
4522
4523        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
4524        let mut total = 0usize;
4525        let job_ids = self
4526            .next_job_ids(&mut tx, ready_rows.len() + deferred_rows.len())
4527            .await?;
4528        let (ready_job_ids, deferred_job_ids) = job_ids.split_at(ready_rows.len());
4529        if !ready_rows.is_empty() {
4530            total += self
4531                .insert_ready_rows_copy_tx(&mut tx, ready_rows, ready_job_ids.to_vec())
4532                .await?;
4533        }
4534        if !deferred_rows.is_empty() {
4535            let deferred_rows: Vec<_> = deferred_rows
4536                .into_iter()
4537                .zip(deferred_job_ids.iter().copied())
4538                .map(|(row, id)| DeferredJobRow { job_id: id, ..row })
4539                .collect();
4540            total += self
4541                .insert_deferred_rows_copy_tx(&mut tx, deferred_rows)
4542                .await?;
4543        }
4544
4545        self.notify_queues_tx(&mut tx, queues_to_notify).await?;
4546
4547        tx.commit().await.map_err(map_sqlx_error)?;
4548        Ok(total)
4549    }
4550
4551    #[tracing::instrument(skip(self, pool), name = "queue_storage.claim_batch")]
4552    pub async fn claim_batch(
4553        &self,
4554        pool: &PgPool,
4555        queue: &str,
4556        max_batch: i64,
4557    ) -> Result<Vec<ClaimedEntry>, AwaError> {
4558        if max_batch <= 0 {
4559            return Ok(Vec::new());
4560        }
4561
4562        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
4563        let mut claimed_rows = Vec::new();
4564        let stripe_queues = self.physical_queues_for_logical(queue);
4565        let start = self.stripe_probe_start(stripe_queues.len());
4566        for offset in 0..stripe_queues.len() {
4567            if claimed_rows.len() >= max_batch as usize {
4568                break;
4569            }
4570            let stripe_queue = &stripe_queues[(start + offset) % stripe_queues.len()];
4571            let remaining = max_batch - claimed_rows.len() as i64;
4572            claimed_rows.extend(
4573                self.claim_ready_rows_tx(
4574                    &mut tx,
4575                    stripe_queue,
4576                    remaining,
4577                    Duration::ZERO,
4578                    Duration::ZERO,
4579                )
4580                .await?,
4581            );
4582        }
4583        let claim_cursor_advances = Self::claim_cursor_advances(&claimed_rows);
4584        let claimed = claimed_rows
4585            .into_iter()
4586            .map(|row| row.claim_ref(self.lease_claim_receipts()))
4587            .collect();
4588
4589        tx.commit().await.map_err(map_sqlx_error)?;
4590        self.advance_claim_cursors(pool, &claim_cursor_advances)
4591            .await;
4592        Ok(claimed)
4593    }
4594
4595    #[tracing::instrument(skip(self, pool), fields(queue = %queue), name = "queue_storage.claim_runtime_batch")]
4596    pub async fn claim_runtime_batch(
4597        &self,
4598        pool: &PgPool,
4599        queue: &str,
4600        max_batch: i64,
4601        deadline_duration: Duration,
4602    ) -> Result<Vec<ClaimedRuntimeJob>, AwaError> {
4603        self.claim_runtime_batch_with_aging(
4604            pool,
4605            queue,
4606            max_batch,
4607            deadline_duration,
4608            Duration::ZERO,
4609        )
4610        .await
4611    }
4612
4613    #[tracing::instrument(skip(self, pool), fields(queue = %queue), name = "queue_storage.claim_runtime_batch_with_aging")]
4614    pub async fn claim_runtime_batch_with_aging(
4615        &self,
4616        pool: &PgPool,
4617        queue: &str,
4618        max_batch: i64,
4619        deadline_duration: Duration,
4620        aging_interval: Duration,
4621    ) -> Result<Vec<ClaimedRuntimeJob>, AwaError> {
4622        if max_batch <= 0 {
4623            return Ok(Vec::new());
4624        }
4625
4626        let stripe_queues = self.physical_queues_for_logical(queue);
4627        if stripe_queues.len() > 1 {
4628            let mut claimed = Vec::new();
4629            let start = self.stripe_probe_start(stripe_queues.len());
4630            for offset in 0..stripe_queues.len() {
4631                if claimed.len() >= max_batch as usize {
4632                    break;
4633                }
4634                let stripe_queue = &stripe_queues[(start + offset) % stripe_queues.len()];
4635                let remaining = max_batch - claimed.len() as i64;
4636                match self
4637                    .claim_runtime_batch_with_aging_physical(
4638                        pool,
4639                        stripe_queue,
4640                        remaining,
4641                        deadline_duration,
4642                        aging_interval,
4643                    )
4644                    .await
4645                {
4646                    Ok(stripe_claims) => claimed.extend(stripe_claims),
4647                    Err(err) if claimed.is_empty() => return Err(err),
4648                    Err(err) => {
4649                        tracing::warn!(
4650                            queue = %queue,
4651                            stripe_queue = %stripe_queue,
4652                            claimed = claimed.len(),
4653                            error = ?err,
4654                            "returning already-claimed runtime jobs after striped claim error"
4655                        );
4656                        break;
4657                    }
4658                }
4659            }
4660            return Ok(claimed);
4661        }
4662
4663        self.claim_runtime_batch_with_aging_physical(
4664            pool,
4665            &stripe_queues[0],
4666            max_batch,
4667            deadline_duration,
4668            aging_interval,
4669        )
4670        .await
4671    }
4672
4673    async fn claim_runtime_batch_with_aging_physical(
4674        &self,
4675        pool: &PgPool,
4676        queue: &str,
4677        max_batch: i64,
4678        deadline_duration: Duration,
4679        aging_interval: Duration,
4680    ) -> Result<Vec<ClaimedRuntimeJob>, AwaError> {
4681        if max_batch <= 0 {
4682            return Ok(Vec::new());
4683        }
4684
4685        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
4686        let mut claimed = Vec::new();
4687        claimed.extend(
4688            self.claim_ready_rows_tx(&mut tx, queue, max_batch, deadline_duration, aging_interval)
4689                .await?,
4690        );
4691
4692        for row in &claimed {
4693            self.sync_unique_claim(
4694                &mut tx,
4695                row.job_id,
4696                &row.unique_key,
4697                row.unique_states.as_deref(),
4698                Some(JobState::Available),
4699                Some(JobState::Running),
4700            )
4701            .await?;
4702        }
4703
4704        let use_lease_claim_receipts = self.use_lease_claim_receipts_for_runtime(deadline_duration);
4705        if !use_lease_claim_receipts && deadline_duration.is_zero() {
4706            // Legacy zero-deadline claims have no heartbeat/deadline rescue
4707            // timestamp, so a post-commit conversion error would strand the
4708            // materialized lease indefinitely. Keep the old rollback semantics
4709            // for that path.
4710            let converted = claimed
4711                .iter()
4712                .cloned()
4713                .map(|row| row.into_claimed_runtime_job(use_lease_claim_receipts))
4714                .collect::<Result<Vec<_>, _>>()?;
4715            let claim_cursor_advances = Self::claim_cursor_advances(&claimed);
4716            tx.commit().await.map_err(map_sqlx_error)?;
4717            self.advance_claim_cursors(pool, &claim_cursor_advances)
4718                .await;
4719            return Ok(converted);
4720        }
4721
4722        let claim_cursor_advances = Self::claim_cursor_advances(&claimed);
4723
4724        // Release claim locks before doing Rust-side payload conversion; this
4725        // keeps the hot claim transaction focused on database state changes.
4726        tx.commit().await.map_err(map_sqlx_error)?;
4727        self.advance_claim_cursors(pool, &claim_cursor_advances)
4728            .await;
4729
4730        claimed
4731            .into_iter()
4732            .map(|row| row.into_claimed_runtime_job(use_lease_claim_receipts))
4733            .collect()
4734    }
4735
4736    #[tracing::instrument(skip(self, pool), fields(queue = %queue, instance_id = %instance_id), name = "queue_storage.acquire_queue_claimer")]
4737    pub async fn acquire_queue_claimer(
4738        &self,
4739        pool: &PgPool,
4740        queue: &str,
4741        instance_id: Uuid,
4742        max_claimers: i16,
4743        lease_ttl: Duration,
4744        idle_threshold: Duration,
4745    ) -> Result<Option<QueueClaimerLease>, AwaError> {
4746        Ok(self
4747            .acquire_queue_claimer_row(
4748                pool,
4749                queue,
4750                instance_id,
4751                max_claimers,
4752                lease_ttl,
4753                idle_threshold,
4754            )
4755            .await?
4756            .map(QueueClaimerLeaseRow::lease))
4757    }
4758
4759    async fn acquire_queue_claimer_row(
4760        &self,
4761        pool: &PgPool,
4762        queue: &str,
4763        instance_id: Uuid,
4764        max_claimers: i16,
4765        lease_ttl: Duration,
4766        idle_threshold: Duration,
4767    ) -> Result<Option<QueueClaimerLeaseRow>, AwaError> {
4768        if max_claimers <= 0 {
4769            return Ok(None);
4770        }
4771
4772        let schema = self.schema();
4773        let now = Utc::now();
4774        let expires_at = now
4775            + TimeDelta::from_std(lease_ttl)
4776                .map_err(|err| AwaError::Validation(format!("invalid claimer lease ttl: {err}")))?;
4777        let idle_cutoff = now
4778            - TimeDelta::from_std(idle_threshold).map_err(|err| {
4779                AwaError::Validation(format!("invalid claimer idle threshold: {err}"))
4780            })?;
4781        let probe_start = if max_claimers > 1 {
4782            ((instance_id.as_u128() ^ (now.timestamp_millis() as u128)) % (max_claimers as u128))
4783                as i16
4784        } else {
4785            0
4786        };
4787
4788        if let Some(owned) = sqlx::query_as::<_, QueueClaimerLeaseRow>(&format!(
4789            r#"
4790            SELECT claimer_slot, lease_epoch, last_claimed_at, expires_at
4791            FROM {schema}.queue_claimer_leases
4792            WHERE queue = $1
4793              AND owner_instance_id = $2
4794              AND expires_at > $3
4795            ORDER BY claimer_slot
4796            LIMIT 1
4797            "#
4798        ))
4799        .bind(queue)
4800        .bind(instance_id)
4801        .bind(now)
4802        .fetch_optional(pool)
4803        .await
4804        .map_err(map_sqlx_error)?
4805        {
4806            return Ok(Some(owned));
4807        }
4808
4809        for offset in 0..max_claimers {
4810            let slot = (probe_start + offset) % max_claimers;
4811            if let Some(updated) = sqlx::query_as::<_, QueueClaimerLeaseRow>(&format!(
4812                r#"
4813                UPDATE {schema}.queue_claimer_leases
4814                SET owner_instance_id = $3,
4815                    lease_epoch = CASE
4816                        WHEN owner_instance_id = $3 THEN lease_epoch
4817                        ELSE lease_epoch + 1
4818                    END,
4819                    leased_at = $4,
4820                    last_claimed_at = $4,
4821                    expires_at = $5
4822                WHERE queue = $1
4823                  AND claimer_slot = $2
4824                  AND (
4825                        owner_instance_id = $3
4826                     OR expires_at <= $4
4827                     OR last_claimed_at <= $6
4828                  )
4829                RETURNING claimer_slot, lease_epoch, last_claimed_at, expires_at
4830                "#
4831            ))
4832            .bind(queue)
4833            .bind(slot)
4834            .bind(instance_id)
4835            .bind(now)
4836            .bind(expires_at)
4837            .bind(idle_cutoff)
4838            .fetch_optional(pool)
4839            .await
4840            .map_err(map_sqlx_error)?
4841            {
4842                return Ok(Some(updated));
4843            }
4844
4845            if let Some(inserted) = sqlx::query_as::<_, QueueClaimerLeaseRow>(&format!(
4846                r#"
4847                INSERT INTO {schema}.queue_claimer_leases (
4848                    queue,
4849                    claimer_slot,
4850                    owner_instance_id,
4851                    lease_epoch,
4852                    leased_at,
4853                    last_claimed_at,
4854                    expires_at
4855                )
4856                VALUES ($1, $2, $3, 0, $4, $4, $5)
4857                ON CONFLICT (queue, claimer_slot) DO NOTHING
4858                RETURNING claimer_slot, lease_epoch, last_claimed_at, expires_at
4859                "#
4860            ))
4861            .bind(queue)
4862            .bind(slot)
4863            .bind(instance_id)
4864            .bind(now)
4865            .bind(expires_at)
4866            .fetch_optional(pool)
4867            .await
4868            .map_err(map_sqlx_error)?
4869            {
4870                return Ok(Some(inserted));
4871            }
4872        }
4873
4874        Ok(None)
4875    }
4876
4877    #[tracing::instrument(skip(self, pool), fields(queue = %queue, instance_id = %instance_id, claimer_slot = lease.claimer_slot), name = "queue_storage.mark_queue_claimer_active")]
4878    pub async fn mark_queue_claimer_active(
4879        &self,
4880        pool: &PgPool,
4881        queue: &str,
4882        instance_id: Uuid,
4883        lease: QueueClaimerLease,
4884        lease_ttl: Duration,
4885    ) -> Result<bool, AwaError> {
4886        let schema = self.schema();
4887        let now = Utc::now();
4888        let expires_at = now
4889            + TimeDelta::from_std(lease_ttl)
4890                .map_err(|err| AwaError::Validation(format!("invalid claimer lease ttl: {err}")))?;
4891
4892        let result = sqlx::query(&format!(
4893            r#"
4894            UPDATE {schema}.queue_claimer_leases
4895            SET last_claimed_at = $5,
4896                expires_at = $6
4897            WHERE queue = $1
4898              AND claimer_slot = $2
4899              AND owner_instance_id = $3
4900              AND lease_epoch = $4
4901            "#
4902        ))
4903        .bind(queue)
4904        .bind(lease.claimer_slot)
4905        .bind(instance_id)
4906        .bind(lease.lease_epoch)
4907        .bind(now)
4908        .bind(expires_at)
4909        .execute(pool)
4910        .await
4911        .map_err(map_sqlx_error)?;
4912
4913        Ok(result.rows_affected() == 1)
4914    }
4915
4916    fn desired_queue_claimer_target(
4917        &self,
4918        current_target: Option<i16>,
4919        signal: &AvailableSignal,
4920        max_claimers: i16,
4921    ) -> i16 {
4922        // The signal source counts sequence positions reserved for enqueue
4923        // but not yet claimed. It can over-count deleted or uncommitted
4924        // positions, but already-claimed rows are excluded by the claim cursor
4925        // advance.
4926        // `backlog` is retained as a separate name in the threshold
4927        // table to keep room for shape tweaks that diverge from
4928        // `available` later.
4929        let available = signal.available.max(0) as u64;
4930        let backlog = available;
4931        let current = current_target.unwrap_or(1).clamp(1, max_claimers.max(1));
4932        let max_four = 4.min(max_claimers.max(1));
4933        let max_two = 2.min(max_claimers.max(1));
4934
4935        match current {
4936            4.. => {
4937                if available >= 32 || backlog >= 16 {
4938                    max_four
4939                } else if available >= 8 || backlog >= 4 {
4940                    max_two
4941                } else {
4942                    1
4943                }
4944            }
4945            2..=3 => {
4946                if available >= 128 || backlog >= 64 {
4947                    max_four
4948                } else if available >= 4 || backlog >= 2 {
4949                    max_two
4950                } else {
4951                    1
4952                }
4953            }
4954            _ => {
4955                if available >= 64 || backlog >= 32 {
4956                    max_four
4957                } else if available >= 8 || backlog >= 4 {
4958                    max_two
4959                } else {
4960                    1
4961                }
4962            }
4963        }
4964    }
4965
4966    async fn queue_claimer_target(
4967        &self,
4968        pool: &PgPool,
4969        queue: &str,
4970        max_claimers: i16,
4971        control_interval: Duration,
4972    ) -> Result<i16, AwaError> {
4973        let schema = self.schema();
4974        let now = Utc::now();
4975        let stale_cutoff = now
4976            - TimeDelta::from_std(control_interval).map_err(|err| {
4977                AwaError::Validation(format!("invalid claimer control interval: {err}"))
4978            })?;
4979
4980        if let Some(target) = sqlx::query_scalar::<_, i16>(&format!(
4981            r#"
4982            SELECT target_claimers
4983            FROM {schema}.queue_claimer_state
4984            WHERE queue = $1
4985              AND updated_at > $2
4986            "#
4987        ))
4988        .bind(queue)
4989        .bind(stale_cutoff)
4990        .fetch_optional(pool)
4991        .await
4992        .map_err(map_sqlx_error)?
4993        {
4994            return Ok(target.clamp(1, max_claimers.max(1)));
4995        }
4996
4997        let current_target = sqlx::query_scalar::<_, i16>(&format!(
4998            r#"
4999            SELECT target_claimers
5000            FROM {schema}.queue_claimer_state
5001            WHERE queue = $1
5002            "#
5003        ))
5004        .bind(queue)
5005        .fetch_optional(pool)
5006        .await
5007        .map_err(map_sqlx_error)?;
5008
5009        let signal = self.queue_claimer_signal(pool, queue).await?;
5010        let desired = self.desired_queue_claimer_target(current_target, &signal, max_claimers);
5011
5012        if let Some(updated) = sqlx::query_scalar::<_, i16>(&format!(
5013            r#"
5014            INSERT INTO {schema}.queue_claimer_state (queue, target_claimers, updated_at)
5015            VALUES ($1, $2, $3)
5016            ON CONFLICT (queue) DO UPDATE
5017            SET target_claimers = EXCLUDED.target_claimers,
5018                updated_at = EXCLUDED.updated_at
5019            WHERE {schema}.queue_claimer_state.updated_at <= $4
5020            RETURNING target_claimers
5021            "#
5022        ))
5023        .bind(queue)
5024        .bind(desired)
5025        .bind(now)
5026        .bind(stale_cutoff)
5027        .fetch_optional(pool)
5028        .await
5029        .map_err(map_sqlx_error)?
5030        {
5031            return Ok(updated.clamp(1, max_claimers.max(1)));
5032        }
5033
5034        Ok(current_target
5035            .unwrap_or(desired)
5036            .clamp(1, max_claimers.max(1)))
5037    }
5038
5039    /// Cheap, dispatcher-grade available-count signal.
5040    ///
5041    /// Sums `enqueue_cursor - claim_cursor` across the queue's
5042    /// (queue, priority) lanes — one PK read into each of the two head tables per lane
5043    /// (typically ≤ 4 lanes per logical queue). The difference is an
5044    /// upper bound on the count of unclaimed ready rows: admin DELETEs
5045    /// of unclaimed jobs and in-flight enqueue reservations can leave a
5046    /// gap between `claim_cursor` and `enqueue_cursor`. The dispatcher
5047    /// tolerates this drift because the worst case is a wasted claim
5048    /// attempt that finds no committed rows.
5049    ///
5050    /// For an exact count, use [`Self::queue_counts_exact`], which
5051    /// scans `ready_entries`.
5052    async fn queue_claimer_signal(
5053        &self,
5054        pool: &PgPool,
5055        queue: &str,
5056    ) -> Result<AvailableSignal, AwaError> {
5057        let schema = self.schema();
5058        let queues = self.physical_queues_for_logical(queue);
5059        let available: i64 = sqlx::query_scalar(&format!(
5060            r#"
5061            SELECT COALESCE(
5062                sum(GREATEST(
5063                    {schema}.sequence_next_value(qe.seq_name)
5064                        - {schema}.sequence_next_value(qc.seq_name),
5065                    0
5066                )),
5067                0
5068            )::bigint
5069            FROM {schema}.queue_enqueue_heads AS qe
5070            JOIN {schema}.queue_claim_heads AS qc
5071              ON qc.queue = qe.queue
5072             AND qc.priority = qe.priority
5073             AND qc.enqueue_shard = qe.enqueue_shard
5074            WHERE qe.queue = ANY($1)
5075            "#
5076        ))
5077        .bind(&queues)
5078        .fetch_one(pool)
5079        .await
5080        .map_err(map_sqlx_error)?;
5081
5082        Ok(AvailableSignal { available })
5083    }
5084
5085    #[allow(clippy::too_many_arguments)]
5086    #[tracing::instrument(skip(self, pool), fields(queue = %queue, instance_id = %instance_id), name = "queue_storage.claim_runtime_batch_with_aging_for_instance")]
5087    pub async fn claim_runtime_batch_with_aging_for_instance(
5088        &self,
5089        pool: &PgPool,
5090        queue: &str,
5091        max_batch: i64,
5092        deadline_duration: Duration,
5093        aging_interval: Duration,
5094        instance_id: Uuid,
5095        max_claimers: i16,
5096        lease_ttl: Duration,
5097        idle_threshold: Duration,
5098    ) -> Result<Vec<ClaimedRuntimeJob>, AwaError> {
5099        let target_claimers = self
5100            .queue_claimer_target(pool, queue, max_claimers, Duration::from_millis(500))
5101            .await?;
5102
5103        let Some(lease) = self
5104            .acquire_queue_claimer_row(
5105                pool,
5106                queue,
5107                instance_id,
5108                target_claimers,
5109                lease_ttl,
5110                idle_threshold,
5111            )
5112            .await?
5113        else {
5114            return Ok(Vec::new());
5115        };
5116
5117        let claimed = self
5118            .claim_runtime_batch_with_aging(
5119                pool,
5120                queue,
5121                max_batch,
5122                deadline_duration,
5123                aging_interval,
5124            )
5125            .await?;
5126
5127        if !claimed.is_empty() && lease.needs_refresh(Utc::now(), lease_ttl, idle_threshold) {
5128            let _ = self
5129                .mark_queue_claimer_active(pool, queue, instance_id, lease.lease(), lease_ttl)
5130                .await?;
5131        }
5132
5133        Ok(claimed)
5134    }
5135
5136    #[tracing::instrument(skip(self, pool), fields(queue = %queue), name = "queue_storage.claim_job_batch")]
5137    pub async fn claim_job_batch(
5138        &self,
5139        pool: &PgPool,
5140        queue: &str,
5141        max_batch: i64,
5142        deadline_duration: Duration,
5143    ) -> Result<Vec<JobRow>, AwaError> {
5144        self.claim_runtime_batch(pool, queue, max_batch, deadline_duration)
5145            .await
5146            .map(|claimed| claimed.into_iter().map(|row| row.job).collect())
5147    }
5148
5149    #[tracing::instrument(skip(self, pool, claimed), name = "queue_storage.complete_batch")]
5150    pub async fn complete_batch(
5151        &self,
5152        pool: &PgPool,
5153        claimed: &[ClaimedEntry],
5154    ) -> Result<usize, AwaError> {
5155        self.complete_claimed_batch(pool, claimed)
5156            .await
5157            .map(|updated| updated.len())
5158    }
5159
5160    #[tracing::instrument(
5161        skip(self, pool, claimed),
5162        name = "queue_storage.complete_claimed_batch"
5163    )]
5164    pub async fn complete_claimed_batch(
5165        &self,
5166        pool: &PgPool,
5167        claimed: &[ClaimedEntry],
5168    ) -> Result<Vec<(i64, i64)>, AwaError> {
5169        if claimed.is_empty() {
5170            return Ok(Vec::new());
5171        }
5172
5173        let schema = self.schema();
5174        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
5175
5176        let lease_slots: Vec<i32> = claimed.iter().map(|entry| entry.lease_slot).collect();
5177        let queues: Vec<String> = claimed.iter().map(|entry| entry.queue.clone()).collect();
5178        let priorities: Vec<i16> = claimed.iter().map(|entry| entry.priority).collect();
5179        let enqueue_shards: Vec<i16> = claimed.iter().map(|entry| entry.enqueue_shard).collect();
5180        let lane_seqs: Vec<i64> = claimed.iter().map(|entry| entry.lane_seq).collect();
5181
5182        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
5183            r#"
5184            WITH completed(lease_slot, queue, priority, enqueue_shard, lane_seq) AS (
5185                SELECT * FROM unnest($1::int[], $2::text[], $3::smallint[], $4::smallint[], $5::bigint[])
5186            )
5187            DELETE FROM {schema}.leases AS leases
5188            USING completed
5189            WHERE leases.lease_slot = completed.lease_slot
5190              AND leases.queue = completed.queue
5191              AND leases.priority = completed.priority
5192              AND leases.enqueue_shard = completed.enqueue_shard
5193              AND leases.lane_seq = completed.lane_seq
5194            RETURNING
5195                leases.ready_slot,
5196                leases.ready_generation,
5197                leases.job_id,
5198                leases.queue,
5199                leases.state,
5200                leases.priority,
5201                leases.attempt,
5202                leases.run_lease,
5203                leases.max_attempts,
5204                leases.lane_seq,
5205                leases.enqueue_shard,
5206                leases.heartbeat_at,
5207                leases.deadline_at,
5208                leases.attempted_at,
5209                leases.callback_id,
5210                leases.callback_timeout_at
5211            "#
5212        ))
5213        .bind(&lease_slots)
5214        .bind(&queues)
5215        .bind(&priorities)
5216        .bind(&enqueue_shards)
5217        .bind(&lane_seqs)
5218        .fetch_all(tx.as_mut())
5219        .await
5220        .map_err(map_sqlx_error)?;
5221
5222        if deleted.is_empty() {
5223            tx.commit().await.map_err(map_sqlx_error)?;
5224            return Ok(Vec::new());
5225        }
5226
5227        let moved = self.hydrate_deleted_leases_tx(&mut tx, deleted).await?;
5228
5229        let finalized_at = Utc::now();
5230        let mut done_rows = Vec::with_capacity(moved.len());
5231        for entry in moved.iter().cloned() {
5232            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
5233                entry.payload.clone(),
5234                entry.progress.clone(),
5235            )?)?;
5236            payload.set_progress(None);
5237            done_rows.push(entry.into_done_row(
5238                JobState::Completed,
5239                finalized_at,
5240                payload.into_json(),
5241            ));
5242        }
5243
5244        self.insert_done_rows_tx(&mut tx, &done_rows, Some(JobState::Running))
5245            .await?;
5246        tx.commit().await.map_err(map_sqlx_error)?;
5247        Ok(moved
5248            .into_iter()
5249            .map(|entry| (entry.job_id, entry.run_lease))
5250            .collect())
5251    }
5252
5253    fn receipt_fast_complete_candidate(entry: &ClaimedRuntimeJob) -> bool {
5254        entry.claim.lease_claim_receipt
5255            && entry.job.unique_key.is_none()
5256            && is_empty_json_object(&entry.job.metadata)
5257            && entry.job.tags.is_empty()
5258            && entry.job.errors.as_ref().is_none_or(Vec::is_empty)
5259    }
5260
5261    async fn complete_receipt_runtime_batch_fast(
5262        &self,
5263        pool: &PgPool,
5264        claimed: &[ClaimedRuntimeJob],
5265    ) -> Result<Vec<(i64, i64)>, AwaError> {
5266        if claimed.is_empty() {
5267            return Ok(Vec::new());
5268        }
5269
5270        let schema = self.schema();
5271        let finalized_at = Utc::now();
5272        let claim_slots: Vec<i32> = claimed.iter().map(|entry| entry.claim.claim_slot).collect();
5273        let ready_slots: Vec<i32> = claimed.iter().map(|entry| entry.claim.ready_slot).collect();
5274        let ready_generations: Vec<i64> = claimed
5275            .iter()
5276            .map(|entry| entry.claim.ready_generation)
5277            .collect();
5278        let job_ids: Vec<i64> = claimed.iter().map(|entry| entry.job.id).collect();
5279        let kinds: Vec<String> = claimed.iter().map(|entry| entry.job.kind.clone()).collect();
5280        let queues: Vec<String> = claimed
5281            .iter()
5282            .map(|entry| entry.job.queue.clone())
5283            .collect();
5284        let priorities: Vec<i16> = claimed.iter().map(|entry| entry.claim.priority).collect();
5285        let attempts: Vec<i16> = claimed.iter().map(|entry| entry.job.attempt).collect();
5286        let run_leases: Vec<i64> = claimed.iter().map(|entry| entry.job.run_lease).collect();
5287        let lane_seqs: Vec<i64> = claimed.iter().map(|entry| entry.claim.lane_seq).collect();
5288        let enqueue_shards: Vec<i16> = claimed
5289            .iter()
5290            .map(|entry| entry.claim.enqueue_shard)
5291            .collect();
5292        let attempted_ats: Vec<Option<DateTime<Utc>>> =
5293            claimed.iter().map(|entry| entry.job.attempted_at).collect();
5294        let finalized_ats: Vec<DateTime<Utc>> = vec![finalized_at; claimed.len()];
5295        let payloads: Vec<Option<serde_json::Value>> = vec![None; claimed.len()];
5296
5297        sqlx::query_as(&format!(
5298            r#"
5299            WITH completed(
5300                claim_slot,
5301                ready_slot,
5302                ready_generation,
5303                job_id,
5304                kind,
5305                queue,
5306                priority,
5307                attempt,
5308                run_lease,
5309                lane_seq,
5310                enqueue_shard,
5311                attempted_at,
5312                finalized_at,
5313                payload
5314            ) AS (
5315                SELECT *
5316                FROM unnest(
5317                    $1::int[],
5318                    $2::int[],
5319                    $3::bigint[],
5320                    $4::bigint[],
5321                    $5::text[],
5322                    $6::text[],
5323                    $7::smallint[],
5324                    $8::smallint[],
5325                    $9::bigint[],
5326                    $10::bigint[],
5327                    $11::smallint[],
5328                    $12::timestamptz[],
5329                    $13::timestamptz[],
5330                    $14::jsonb[]
5331                )
5332            ),
5333            locked_claims AS (
5334                SELECT claims.claim_slot, claims.job_id, claims.run_lease
5335                FROM {schema}.lease_claims AS claims
5336                JOIN completed
5337                  ON completed.claim_slot = claims.claim_slot
5338                 AND completed.job_id = claims.job_id
5339                 AND completed.run_lease = claims.run_lease
5340                FOR UPDATE OF claims
5341            ),
5342            closed AS (
5343                INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
5344                SELECT locked_claims.claim_slot, locked_claims.job_id, locked_claims.run_lease, 'completed', clock_timestamp()
5345                FROM locked_claims
5346                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
5347                RETURNING job_id, run_lease
5348            ),
5349            deleted_attempts AS (
5350                DELETE FROM {schema}.attempt_state AS attempt
5351                USING closed
5352                WHERE attempt.job_id = closed.job_id
5353                  AND attempt.run_lease = closed.run_lease
5354                RETURNING attempt.job_id
5355            ),
5356            terminal AS (
5357                INSERT INTO {schema}.done_entries (
5358                    ready_slot,
5359                    ready_generation,
5360                    job_id,
5361                    kind,
5362                    queue,
5363                    state,
5364                    priority,
5365                    attempt,
5366                    run_lease,
5367                    lane_seq,
5368                    enqueue_shard,
5369                    attempted_at,
5370                    finalized_at,
5371                    payload
5372                )
5373                SELECT
5374                    completed.ready_slot,
5375                    completed.ready_generation,
5376                    completed.job_id,
5377                    completed.kind,
5378                    completed.queue,
5379                    'completed'::awa.job_state,
5380                    completed.priority,
5381                    completed.attempt,
5382                    completed.run_lease,
5383                    completed.lane_seq,
5384                    completed.enqueue_shard,
5385                    completed.attempted_at,
5386                    completed.finalized_at,
5387                    completed.payload
5388                FROM completed
5389                JOIN closed
5390                  ON closed.job_id = completed.job_id
5391                 AND closed.run_lease = completed.run_lease
5392                RETURNING ready_slot, ready_generation, queue, priority, enqueue_shard, job_id, run_lease
5393            ),
5394            -- Terminal counts use an append-only delta ledger on the hot
5395            -- completion path. Maintenance folds these rows into
5396            -- queue_terminal_live_counts later; exact reads sum both sources.
5397            counter_delta AS (
5398                INSERT INTO {schema}.queue_terminal_count_deltas (
5399                    ready_slot,
5400                    ready_generation,
5401                    queue,
5402                    priority,
5403                    enqueue_shard,
5404                    counter_bucket,
5405                    terminal_delta
5406                )
5407                SELECT
5408                    ready_slot,
5409                    ready_generation,
5410                    queue,
5411                    priority,
5412                    enqueue_shard,
5413                    counter_bucket,
5414                    delta
5415                FROM (
5416                    SELECT
5417                        terminal.ready_slot,
5418                        terminal.ready_generation,
5419                        terminal.queue,
5420                        terminal.priority,
5421                        terminal.enqueue_shard,
5422                        mod(
5423                            mod(terminal.job_id, {TERMINAL_COUNTER_BUCKETS}::bigint)
5424                                + {TERMINAL_COUNTER_BUCKETS}::bigint,
5425                            {TERMINAL_COUNTER_BUCKETS}::bigint
5426                        )::smallint AS counter_bucket,
5427                        count(*)::bigint AS delta
5428                    FROM terminal
5429                    GROUP BY
5430                        terminal.ready_slot,
5431                        terminal.ready_generation,
5432                        terminal.queue,
5433                        terminal.priority,
5434                        terminal.enqueue_shard,
5435                        counter_bucket
5436                ) AS grouped
5437                ORDER BY ready_slot, ready_generation, queue, priority, enqueue_shard, counter_bucket
5438                RETURNING 1
5439            )
5440            SELECT job_id, run_lease
5441            FROM terminal
5442            "#
5443        ))
5444        .bind(&claim_slots)
5445        .bind(&ready_slots)
5446        .bind(&ready_generations)
5447        .bind(&job_ids)
5448        .bind(&kinds)
5449        .bind(&queues)
5450        .bind(&priorities)
5451        .bind(&attempts)
5452        .bind(&run_leases)
5453        .bind(&lane_seqs)
5454        .bind(&enqueue_shards)
5455        .bind(&attempted_ats)
5456        .bind(&finalized_ats)
5457        .bind(&payloads)
5458        .fetch_all(pool)
5459        .await
5460        .map_err(map_sqlx_error)
5461    }
5462
5463    #[tracing::instrument(
5464        skip(self, pool, claimed),
5465        name = "queue_storage.complete_runtime_batch"
5466    )]
5467    pub async fn complete_runtime_batch(
5468        &self,
5469        pool: &PgPool,
5470        claimed: &[ClaimedRuntimeJob],
5471    ) -> Result<Vec<(i64, i64)>, AwaError> {
5472        if claimed.is_empty() {
5473            return Ok(Vec::new());
5474        }
5475
5476        if self.lease_claim_receipts() && claimed.iter().all(Self::receipt_fast_complete_candidate)
5477        {
5478            let mut updated = self
5479                .complete_receipt_runtime_batch_fast(pool, claimed)
5480                .await?;
5481            if updated.len() == claimed.len() {
5482                return Ok(updated);
5483            }
5484
5485            let updated_pairs: BTreeSet<(i64, i64)> = updated.iter().copied().collect();
5486            let missed: Vec<_> = claimed
5487                .iter()
5488                .filter(|entry| !updated_pairs.contains(&(entry.job.id, entry.job.run_lease)))
5489                .cloned()
5490                .collect();
5491            if !missed.is_empty() {
5492                updated.extend(self.complete_runtime_batch_slow(pool, &missed).await?);
5493            }
5494            return Ok(updated);
5495        }
5496
5497        self.complete_runtime_batch_slow(pool, claimed).await
5498    }
5499
5500    async fn complete_runtime_batch_slow(
5501        &self,
5502        pool: &PgPool,
5503        claimed: &[ClaimedRuntimeJob],
5504    ) -> Result<Vec<(i64, i64)>, AwaError> {
5505        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
5506        let result = self
5507            .complete_runtime_batch_slow_in_tx(&mut tx, claimed)
5508            .await?;
5509        tx.commit().await.map_err(map_sqlx_error)?;
5510        Ok(result)
5511    }
5512
5513    /// Same as [`Self::complete_runtime_batch_slow`] but runs on the caller's
5514    /// transaction so additional writes — for example ADR-029 follow-up job
5515    /// inserts — can join the same commit. The caller is responsible for
5516    /// `commit()` / `rollback()`. Handles both receipt-claimed and
5517    /// materialised leases.
5518    pub async fn complete_runtime_batch_slow_in_tx(
5519        &self,
5520        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
5521        claimed: &[ClaimedRuntimeJob],
5522    ) -> Result<Vec<(i64, i64)>, AwaError> {
5523        if claimed.is_empty() {
5524            return Ok(Vec::new());
5525        }
5526
5527        let schema = self.schema();
5528
5529        let claimed_map: BTreeMap<(i64, i64), ClaimedRuntimeJob> = claimed
5530            .iter()
5531            .cloned()
5532            .map(|entry| ((entry.job.id, entry.job.run_lease), entry))
5533            .collect();
5534
5535        if self.lease_claim_receipts() {
5536            let (mut receipt_claimed, mut materialized_claimed): (Vec<_>, Vec<_>) = claimed
5537                .iter()
5538                .cloned()
5539                .partition(|entry| entry.claim.lease_claim_receipt);
5540            let mut updated_all = Vec::new();
5541
5542            if !receipt_claimed.is_empty() {
5543                // claim_slot rides along on `ClaimedEntry`, so the
5544                // closure INSERT can pass (claim_slot, job_id,
5545                // run_lease) triples directly and let Postgres route
5546                // to the correct partition without an extra lookup.
5547                let receipt_claim_slots: Vec<i32> = receipt_claimed
5548                    .iter()
5549                    .map(|entry| entry.claim.claim_slot)
5550                    .collect();
5551                let receipt_job_ids: Vec<i64> =
5552                    receipt_claimed.iter().map(|entry| entry.job.id).collect();
5553                let receipt_run_leases: Vec<i64> = receipt_claimed
5554                    .iter()
5555                    .map(|entry| entry.job.run_lease)
5556                    .collect();
5557                let updated: Vec<(i64, i64)> = sqlx::query_as(&format!(
5558                    r#"
5559                    WITH completed(claim_slot, job_id, run_lease) AS (
5560                        SELECT * FROM unnest($1::int[], $2::bigint[], $3::bigint[])
5561                    ),
5562                    locked_claims AS (
5563                        SELECT claims.claim_slot, claims.job_id, claims.run_lease
5564                        FROM {schema}.lease_claims AS claims
5565                        JOIN completed
5566                          ON completed.claim_slot = claims.claim_slot
5567                         AND completed.job_id = claims.job_id
5568                         AND completed.run_lease = claims.run_lease
5569                        FOR UPDATE OF claims
5570                    ),
5571                    inserted AS (
5572                        INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
5573                        SELECT locked_claims.claim_slot, locked_claims.job_id, locked_claims.run_lease, 'completed', clock_timestamp()
5574                        FROM locked_claims
5575                        ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
5576                        RETURNING job_id, run_lease
5577                    ),
5578                    deleted_attempts AS (
5579                        DELETE FROM {schema}.attempt_state AS attempt
5580                        USING inserted
5581                        WHERE attempt.job_id = inserted.job_id
5582                          AND attempt.run_lease = inserted.run_lease
5583                        RETURNING attempt.job_id
5584                    )
5585                    SELECT job_id, run_lease
5586                    FROM inserted
5587                    "#
5588                ))
5589                .bind(&receipt_claim_slots)
5590                .bind(&receipt_job_ids)
5591                .bind(&receipt_run_leases)
5592                .fetch_all(tx.as_mut())
5593                .await
5594                .map_err(map_sqlx_error)?;
5595
5596                if !updated.is_empty() {
5597                    let finalized_at = Utc::now();
5598                    let mut done_rows = Vec::with_capacity(updated.len());
5599                    for (job_id, run_lease) in &updated {
5600                        if let Some(runtime_job) = claimed_map.get(&(*job_id, *run_lease)).cloned()
5601                        {
5602                            done_rows.push(runtime_job.into_done_row(finalized_at)?);
5603                        }
5604                    }
5605
5606                    self.insert_done_rows_tx(tx, &done_rows, Some(JobState::Running))
5607                        .await?;
5608                    updated_all.extend(updated);
5609                }
5610
5611                let updated_pairs: BTreeSet<(i64, i64)> = updated_all.iter().copied().collect();
5612                let mut escalated_receipts = Vec::new();
5613                for entry in receipt_claimed.drain(..) {
5614                    if !updated_pairs.contains(&(entry.job.id, entry.job.run_lease)) {
5615                        escalated_receipts.push(entry);
5616                    }
5617                }
5618                materialized_claimed.extend(escalated_receipts);
5619            }
5620
5621            if !materialized_claimed.is_empty() {
5622                let lease_slots: Vec<i32> = materialized_claimed
5623                    .iter()
5624                    .map(|entry| entry.claim.lease_slot)
5625                    .collect();
5626                let queues: Vec<String> = materialized_claimed
5627                    .iter()
5628                    .map(|entry| entry.claim.queue.clone())
5629                    .collect();
5630                let priorities: Vec<i16> = materialized_claimed
5631                    .iter()
5632                    .map(|entry| entry.claim.priority)
5633                    .collect();
5634                let enqueue_shards: Vec<i16> = materialized_claimed
5635                    .iter()
5636                    .map(|entry| entry.claim.enqueue_shard)
5637                    .collect();
5638                let lane_seqs: Vec<i64> = materialized_claimed
5639                    .iter()
5640                    .map(|entry| entry.claim.lane_seq)
5641                    .collect();
5642                let run_leases: Vec<i64> = materialized_claimed
5643                    .iter()
5644                    .map(|entry| entry.job.run_lease)
5645                    .collect();
5646
5647                // CTE-as-DML: delete the leases and the matching attempt_state
5648                // rows in one round-trip. See the receipt-disabled branch
5649                // below for the same pattern.
5650                let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
5651                    r#"
5652                    WITH completed(lease_slot, queue, priority, enqueue_shard, lane_seq, run_lease) AS (
5653                        SELECT * FROM unnest($1::int[], $2::text[], $3::smallint[], $4::smallint[], $5::bigint[], $6::bigint[])
5654                    ),
5655                    deleted AS (
5656                        DELETE FROM {schema}.leases AS leases
5657                        USING completed
5658                        WHERE leases.lease_slot = completed.lease_slot
5659                          AND leases.queue = completed.queue
5660                          AND leases.priority = completed.priority
5661                          AND leases.enqueue_shard = completed.enqueue_shard
5662                          AND leases.lane_seq = completed.lane_seq
5663                          AND leases.run_lease = completed.run_lease
5664                        RETURNING
5665                            leases.ready_slot,
5666                            leases.ready_generation,
5667                            leases.job_id,
5668                            leases.queue,
5669                            leases.state,
5670                            leases.priority,
5671                            leases.attempt,
5672                            leases.run_lease,
5673                            leases.max_attempts,
5674                            leases.lane_seq,
5675                            leases.enqueue_shard,
5676                            leases.heartbeat_at,
5677                            leases.deadline_at,
5678                            leases.attempted_at,
5679                            leases.callback_id,
5680                            leases.callback_timeout_at
5681                    ),
5682                    del_attempts AS (
5683                        DELETE FROM {schema}.attempt_state AS attempt
5684                        USING deleted
5685                        WHERE attempt.job_id = deleted.job_id
5686                          AND attempt.run_lease = deleted.run_lease
5687                        RETURNING attempt.job_id
5688                    )
5689                    SELECT
5690                        ready_slot,
5691                        ready_generation,
5692                        job_id,
5693                        queue,
5694                        state,
5695                        priority,
5696                        attempt,
5697                        run_lease,
5698                        max_attempts,
5699                        lane_seq,
5700                        enqueue_shard,
5701                        heartbeat_at,
5702                        deadline_at,
5703                        attempted_at,
5704                        callback_id,
5705                        callback_timeout_at
5706                    FROM deleted
5707                    "#
5708                ))
5709                .bind(&lease_slots)
5710                .bind(&queues)
5711                .bind(&priorities)
5712                .bind(&enqueue_shards)
5713                .bind(&lane_seqs)
5714                .bind(&run_leases)
5715                .fetch_all(tx.as_mut())
5716                .await
5717                .map_err(map_sqlx_error)?;
5718
5719                if !deleted.is_empty() {
5720                    let finalized_at = Utc::now();
5721                    let mut done_rows = Vec::with_capacity(deleted.len());
5722                    for deleted_row in deleted {
5723                        if let Some(runtime_job) = claimed_map
5724                            .get(&(deleted_row.job_id, deleted_row.run_lease))
5725                            .cloned()
5726                        {
5727                            done_rows.push(runtime_job.into_done_row(finalized_at)?);
5728                            updated_all.push((deleted_row.job_id, deleted_row.run_lease));
5729                        }
5730                    }
5731
5732                    self.insert_done_rows_tx(tx, &done_rows, Some(JobState::Running))
5733                        .await?;
5734                }
5735            }
5736
5737            return Ok(updated_all);
5738        }
5739
5740        let lease_slots: Vec<i32> = claimed.iter().map(|entry| entry.claim.lease_slot).collect();
5741        let queues: Vec<String> = claimed
5742            .iter()
5743            .map(|entry| entry.claim.queue.clone())
5744            .collect();
5745        let priorities: Vec<i16> = claimed.iter().map(|entry| entry.claim.priority).collect();
5746        let enqueue_shards: Vec<i16> = claimed
5747            .iter()
5748            .map(|entry| entry.claim.enqueue_shard)
5749            .collect();
5750        let lane_seqs: Vec<i64> = claimed.iter().map(|entry| entry.claim.lane_seq).collect();
5751        let run_leases: Vec<i64> = claimed.iter().map(|entry| entry.job.run_lease).collect();
5752
5753        // Single CTE-as-DML statement: delete the leases and the matching
5754        // attempt_state rows in one round-trip. The `deleted` CTE materialises
5755        // the lease deletion (so its RETURNING is observable to the
5756        // attempt-state delete and to the final SELECT), and `del_attempts`
5757        // hangs off it. Saves one round-trip per completion batch versus
5758        // issuing the attempt-state delete as a separate statement.
5759        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
5760            r#"
5761            WITH completed(lease_slot, queue, priority, enqueue_shard, lane_seq, run_lease) AS (
5762                SELECT * FROM unnest($1::int[], $2::text[], $3::smallint[], $4::smallint[], $5::bigint[], $6::bigint[])
5763            ),
5764            deleted AS (
5765                DELETE FROM {schema}.leases AS leases
5766                USING completed
5767                WHERE leases.lease_slot = completed.lease_slot
5768                  AND leases.queue = completed.queue
5769                  AND leases.priority = completed.priority
5770                  AND leases.enqueue_shard = completed.enqueue_shard
5771                  AND leases.lane_seq = completed.lane_seq
5772                  AND leases.run_lease = completed.run_lease
5773                RETURNING
5774                    leases.ready_slot,
5775                    leases.ready_generation,
5776                    leases.job_id,
5777                    leases.queue,
5778                    leases.state,
5779                    leases.priority,
5780                    leases.attempt,
5781                    leases.run_lease,
5782                    leases.max_attempts,
5783                    leases.lane_seq,
5784                    leases.enqueue_shard,
5785                    leases.heartbeat_at,
5786                    leases.deadline_at,
5787                    leases.attempted_at,
5788                    leases.callback_id,
5789                    leases.callback_timeout_at
5790            ),
5791            del_attempts AS (
5792                DELETE FROM {schema}.attempt_state AS attempt
5793                USING deleted
5794                WHERE attempt.job_id = deleted.job_id
5795                  AND attempt.run_lease = deleted.run_lease
5796                RETURNING attempt.job_id
5797            )
5798            SELECT
5799                ready_slot,
5800                ready_generation,
5801                job_id,
5802                queue,
5803                state,
5804                priority,
5805                attempt,
5806                run_lease,
5807                max_attempts,
5808                lane_seq,
5809                enqueue_shard,
5810                heartbeat_at,
5811                deadline_at,
5812                attempted_at,
5813                callback_id,
5814                callback_timeout_at
5815            FROM deleted
5816            "#
5817        ))
5818        .bind(&lease_slots)
5819        .bind(&queues)
5820        .bind(&priorities)
5821        .bind(&enqueue_shards)
5822        .bind(&lane_seqs)
5823        .bind(&run_leases)
5824        .fetch_all(tx.as_mut())
5825        .await
5826        .map_err(map_sqlx_error)?;
5827
5828        if deleted.is_empty() {
5829            return Ok(Vec::new());
5830        }
5831
5832        let finalized_at = Utc::now();
5833        let mut done_rows = Vec::with_capacity(deleted.len());
5834        let mut updated = Vec::with_capacity(deleted.len());
5835        for deleted_row in deleted {
5836            if let Some(runtime_job) = claimed_map
5837                .get(&(deleted_row.job_id, deleted_row.run_lease))
5838                .cloned()
5839            {
5840                done_rows.push(runtime_job.into_done_row(finalized_at)?);
5841                updated.push((deleted_row.job_id, deleted_row.run_lease));
5842            }
5843        }
5844
5845        self.insert_done_rows_tx(tx, &done_rows, Some(JobState::Running))
5846            .await?;
5847        Ok(updated)
5848    }
5849
5850    #[tracing::instrument(
5851        skip(self, pool, completions),
5852        name = "queue_storage.complete_job_batch_by_id"
5853    )]
5854    pub async fn complete_job_batch_by_id(
5855        &self,
5856        pool: &PgPool,
5857        completions: &[(i64, i64)],
5858    ) -> Result<Vec<(i64, i64)>, AwaError> {
5859        if completions.is_empty() {
5860            return Ok(Vec::new());
5861        }
5862        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
5863        let result = self
5864            .complete_job_batch_by_id_in_tx(&mut tx, completions)
5865            .await?;
5866        tx.commit().await.map_err(map_sqlx_error)?;
5867        Ok(result)
5868    }
5869
5870    /// Same as [`Self::complete_job_batch_by_id`] but runs on the caller's
5871    /// transaction so additional writes — for example ADR-029 follow-up job
5872    /// inserts — can join the same commit. The caller is responsible for
5873    /// `commit()` / `rollback()`.
5874    pub async fn complete_job_batch_by_id_in_tx(
5875        &self,
5876        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
5877        completions: &[(i64, i64)],
5878    ) -> Result<Vec<(i64, i64)>, AwaError> {
5879        if completions.is_empty() {
5880            return Ok(Vec::new());
5881        }
5882
5883        let schema = self.schema();
5884
5885        let job_ids: Vec<i64> = completions.iter().map(|(job_id, _)| *job_id).collect();
5886        let run_leases: Vec<i64> = completions
5887            .iter()
5888            .map(|(_, run_lease)| *run_lease)
5889            .collect();
5890
5891        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
5892            r#"
5893            WITH completed(job_id, run_lease) AS (
5894                SELECT * FROM unnest($1::bigint[], $2::bigint[])
5895            )
5896            DELETE FROM {schema}.leases AS leases
5897            USING completed
5898            WHERE leases.job_id = completed.job_id
5899              AND leases.run_lease = completed.run_lease
5900            RETURNING
5901                leases.ready_slot,
5902                leases.ready_generation,
5903                leases.job_id,
5904                leases.queue,
5905                leases.state,
5906                leases.priority,
5907                leases.attempt,
5908                leases.run_lease,
5909                leases.max_attempts,
5910                leases.lane_seq,
5911                leases.enqueue_shard,
5912                leases.heartbeat_at,
5913                leases.deadline_at,
5914                leases.attempted_at,
5915                leases.callback_id,
5916                leases.callback_timeout_at
5917            "#
5918        ))
5919        .bind(&job_ids)
5920        .bind(&run_leases)
5921        .fetch_all(tx.as_mut())
5922        .await
5923        .map_err(map_sqlx_error)?;
5924
5925        if deleted.is_empty() {
5926            return Ok(Vec::new());
5927        }
5928
5929        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
5930
5931        let finalized_at = Utc::now();
5932        let mut done_rows = Vec::with_capacity(moved.len());
5933        for entry in moved.iter().cloned() {
5934            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
5935                entry.payload.clone(),
5936                entry.progress.clone(),
5937            )?)?;
5938            payload.set_progress(None);
5939            done_rows.push(entry.into_done_row(
5940                JobState::Completed,
5941                finalized_at,
5942                payload.into_json(),
5943            ));
5944        }
5945
5946        self.insert_done_rows_tx(tx, &done_rows, Some(JobState::Running))
5947            .await?;
5948        Ok(moved
5949            .into_iter()
5950            .map(|entry| (entry.job_id, entry.run_lease))
5951            .collect())
5952    }
5953
5954    /// Exact admin/UI-grade queue counts. Scans `ready_entries` rather
5955    /// than reading the head tables — slower, but unaffected by the
5956    /// transient gap between sequence reservations and committed,
5957    /// still-claimable ready rows. Use [`Self::queue_claimer_signal`] for the
5958    /// dispatcher hot path.
5959    ///
5960    /// The live-terminal portion reads from folded
5961    /// `queue_terminal_live_counts` plus unrolled
5962    /// `queue_terminal_count_deltas` when [`Self::terminal_counter_trusted`]
5963    /// returns true, and falls back to a `count(*) FROM done_entries` scan
5964    /// when not. The fallback exists for the rolling-upgrade window: older
5965    /// binaries may have written terminal rows without maintaining the
5966    /// counter/delta contract, so reads stay honest until the operator runs
5967    /// `awa storage rebuild-terminal-counters`.
5968    async fn queue_counts_exact(
5969        &self,
5970        pool: &PgPool,
5971        queue: &str,
5972    ) -> Result<QueueCounts, AwaError> {
5973        let schema = self.schema();
5974        let queues = self.physical_queues_for_logical(queue);
5975        let counter_trusted = self.terminal_counter_trusted(pool).await?;
5976        // The live-terminal CTE swaps between counter-fed and
5977        // scan-fed depending on trust. Build it as a string so the
5978        // outer query plan is otherwise identical between the two
5979        // paths.
5980        let live_terminal_cte = if counter_trusted {
5981            format!(
5982                "live_terminal AS (
5983                    SELECT GREATEST(
5984                        0,
5985                        COALESCE((
5986                            SELECT SUM(live_terminal_count)
5987                            FROM {schema}.queue_terminal_live_counts
5988                            WHERE queue = ANY($1)
5989                        ), 0)
5990                        +
5991                        COALESCE((
5992                            SELECT SUM(terminal_delta)
5993                            FROM {schema}.queue_terminal_count_deltas
5994                            WHERE queue = ANY($1)
5995                        ), 0)
5996                    )::bigint AS terminal
5997                )"
5998            )
5999        } else {
6000            format!(
6001                "live_terminal AS (
6002                    SELECT count(*)::bigint AS terminal
6003                    FROM {schema}.done_entries
6004                    WHERE queue = ANY($1)
6005                )"
6006            )
6007        };
6008        let row: (i64, i64, i64) = sqlx::query_as(&format!(
6009            r#"
6010            WITH lane_counts AS (
6011                -- Exact count: a ready row is available iff its
6012                -- lane_seq has not yet been passed by the lane's
6013                -- claim sequence cursor. Each shard within a (queue,
6014                -- priority) lane carries its own sequence, so the
6015                -- join matches on shard too — otherwise a ready row
6016                -- in shard A could be incorrectly compared against
6017                -- shard B's claim cursor.
6018                SELECT COALESCE(count(*)::bigint, 0) AS available
6019                FROM {schema}.ready_entries AS ready
6020                JOIN {schema}.queue_claim_heads AS claims
6021                  ON claims.queue = ready.queue
6022                 AND claims.priority = ready.priority
6023                 AND claims.enqueue_shard = ready.enqueue_shard
6024                WHERE ready.queue = ANY($1)
6025                  AND ready.lane_seq >= {schema}.sequence_next_value(claims.seq_name)
6026                  AND NOT EXISTS (
6027                      SELECT 1 FROM {schema}.ready_tombstones AS tomb
6028                      WHERE tomb.queue = ready.queue
6029                        AND tomb.priority = ready.priority
6030                        AND tomb.enqueue_shard = ready.enqueue_shard
6031                        AND tomb.lane_seq = ready.lane_seq
6032                        AND tomb.ready_slot = ready.ready_slot
6033                        AND tomb.ready_generation = ready.ready_generation
6034                  )
6035            ),
6036            pruned_terminal AS (
6037                SELECT COALESCE(
6038                    sum(
6039                        GREATEST(
6040                            COALESCE(lanes.pruned_completed_count, 0),
6041                            COALESCE(rollups.pruned_completed_count, 0)
6042                        )
6043                    ),
6044                    0
6045                )::bigint AS completed
6046                FROM (
6047                    SELECT queue, priority, pruned_completed_count
6048                    FROM {schema}.queue_lanes
6049                    WHERE queue = ANY($1)
6050                ) AS lanes
6051                FULL OUTER JOIN (
6052                    SELECT queue, priority, pruned_completed_count
6053                    FROM {schema}.queue_terminal_rollups
6054                    WHERE queue = ANY($1)
6055                ) AS rollups
6056                USING (queue, priority)
6057            ),
6058            live_running AS (
6059                SELECT (
6060                    COALESCE((
6061                        SELECT count(*)::bigint
6062                        FROM {schema}.leases
6063                        WHERE queue = ANY($1)
6064                          AND state = 'running'
6065                    ), 0)
6066                    +
6067                    -- Derive the receipt-backed running count from
6068                    -- lease_claims anti-joined with
6069                    -- lease_claim_closures.
6070                    COALESCE((
6071                        SELECT count(*)::bigint
6072                        FROM {schema}.lease_claims AS claims
6073                        WHERE claims.queue = ANY($1)
6074                          AND NOT EXISTS (
6075                              SELECT 1 FROM {schema}.lease_claim_closures AS closures
6076                              WHERE closures.claim_slot = claims.claim_slot
6077                                  AND closures.job_id = claims.job_id
6078                                  AND closures.run_lease = claims.run_lease
6079                          )
6080                          AND NOT EXISTS (
6081                              SELECT 1
6082                              FROM {schema}.leases AS lease
6083                              WHERE lease.job_id = claims.job_id
6084                                AND lease.run_lease = claims.run_lease
6085                          )
6086                          AND NOT EXISTS (
6087                              SELECT 1
6088                              FROM {schema}.deferred_jobs AS deferred
6089                              WHERE deferred.job_id = claims.job_id
6090                                AND deferred.run_lease = claims.run_lease
6091                          )
6092                          AND NOT EXISTS (
6093                              SELECT 1
6094                              FROM {schema}.done_entries AS done
6095                              WHERE done.job_id = claims.job_id
6096                                AND done.run_lease = claims.run_lease
6097                          )
6098                          AND NOT EXISTS (
6099                              SELECT 1
6100                              FROM {schema}.dlq_entries AS dlq
6101                              WHERE dlq.job_id = claims.job_id
6102                                AND dlq.run_lease = claims.run_lease
6103                          )
6104                    ), 0)
6105                )::bigint AS running
6106            ),
6107            {live_terminal_cte}
6108            SELECT
6109                lane_counts.available,
6110                live_running.running,
6111                pruned_terminal.completed + live_terminal.terminal AS terminal
6112            FROM lane_counts
6113            CROSS JOIN pruned_terminal
6114            CROSS JOIN live_running
6115            CROSS JOIN live_terminal
6116            "#
6117        ))
6118        .bind(&queues)
6119        .fetch_one(pool)
6120        .await
6121        .map_err(map_sqlx_error)?;
6122
6123        let (available, running, terminal) = row;
6124        Ok(QueueCounts {
6125            available,
6126            running,
6127            terminal,
6128        })
6129    }
6130
6131    #[tracing::instrument(skip(self, pool), fields(queue = %queue), name = "queue_storage.queue_counts")]
6132    pub async fn queue_counts(&self, pool: &PgPool, queue: &str) -> Result<QueueCounts, AwaError> {
6133        self.queue_counts_exact(pool, queue).await
6134    }
6135
6136    /// Index-only queue depth probe — for observability / depth-target
6137    /// throttling. Returns the same shape as [`Self::queue_counts`] but
6138    /// skips the table scans that [`Self::queue_counts_exact`] needs for
6139    /// exact terminal accounting:
6140    ///
6141    /// - **available** is the same as the dispatcher's claim signal:
6142    ///   `sum(GREATEST(enqueue_seq - claim_seq, 0))` over the shard
6143    ///   head tables. No scan of `ready_entries`. This is an upper
6144    ///   bound: admin DELETEs, committed gaps, and uncommitted enqueue
6145    ///   reservations can leave the enqueue sequence ahead of the actual
6146    ///   ready row count. Acceptable for depth-target throttling and
6147    ///   dashboards; not suitable for exact billing-style counts.
6148    /// - **running** matches [`Self::queue_counts`]'s strict definition:
6149    ///   `leases.state = 'running'` only. Receipt-plane claims that
6150    ///   have not yet materialised a lease row are omitted (the
6151    ///   exact path catches them via the `lease_claims` anti-join, but
6152    ///   that anti-join is what this fast variant exists to avoid).
6153    ///   `waiting_external` is *not* included — it's reported as part
6154    ///   of admin's parked-callback view, not running.
6155    /// - **terminal** is read from the persisted
6156    ///   `queue_terminal_rollups.pruned_completed_count` denormaliser.
6157    ///   Rows currently in `done_entries` that have not yet rolled up
6158    ///   are not included. Strictly a lower bound; converges to the
6159    ///   exact count when rotation prunes the live `done_entries`
6160    ///   segment. (The name `terminal` is honest — this number counts
6161    ///   `completed`, `failed`, and `cancelled` rows in done_entries,
6162    ///   the same semantics as [`Self::queue_counts_exact`]; renamed
6163    ///   from `completed` in #290.)
6164    ///
6165    /// All three counters are O(num shards) lookups against small head
6166    /// tables and `leases` index probes. Use this for high-cadence
6167    /// pollers (admin dashboards, depth-target producers, soak
6168    /// observability); use [`Self::queue_counts`] for admin tooling
6169    /// that needs the exact terminal count.
6170    #[tracing::instrument(skip(self, pool), fields(queue = %queue), name = "queue_storage.queue_counts_fast")]
6171    pub async fn queue_counts_fast(
6172        &self,
6173        pool: &PgPool,
6174        queue: &str,
6175    ) -> Result<QueueCounts, AwaError> {
6176        let schema = self.schema();
6177        let queues = self.physical_queues_for_logical(queue);
6178        // available: dispatcher signal — already an index-only sum over
6179        // the (queue, priority, enqueue_shard) head tables.
6180        let available = self.queue_claimer_signal(pool, queue).await?.available;
6181        // running: leases.state = 'running' only, matching
6182        // queue_counts_exact's strict definition. Receipt-plane claims
6183        // that haven't materialised a lease row yet are documented as
6184        // omitted in the method-level doc.
6185        let running: i64 = sqlx::query_scalar(&format!(
6186            r#"
6187            SELECT COALESCE(count(*)::bigint, 0)
6188            FROM {schema}.leases
6189            WHERE queue = ANY($1)
6190              AND state = 'running'
6191            "#
6192        ))
6193        .bind(&queues)
6194        .fetch_one(pool)
6195        .await
6196        .map_err(map_sqlx_error)?;
6197        // terminal: denormalised rollup only. Live (un-rolled-up)
6198        // done_entries rows are excluded — see method-level docs.
6199        let terminal: i64 = sqlx::query_scalar(&format!(
6200            r#"
6201            SELECT COALESCE(sum(GREATEST(
6202                COALESCE(lanes.pruned_completed_count, 0),
6203                COALESCE(rollups.pruned_completed_count, 0)
6204            )), 0)::bigint
6205            FROM (
6206                SELECT queue, priority, pruned_completed_count
6207                FROM {schema}.queue_lanes
6208                WHERE queue = ANY($1)
6209            ) AS lanes
6210            FULL OUTER JOIN (
6211                SELECT queue, priority, pruned_completed_count
6212                FROM {schema}.queue_terminal_rollups
6213                WHERE queue = ANY($1)
6214            ) AS rollups
6215            USING (queue, priority)
6216            "#
6217        ))
6218        .bind(&queues)
6219        .fetch_one(pool)
6220        .await
6221        .map_err(map_sqlx_error)?;
6222        Ok(QueueCounts {
6223            available,
6224            running,
6225            terminal,
6226        })
6227    }
6228
6229    async fn retry_job_tx<'a>(
6230        &self,
6231        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6232        job_id: i64,
6233    ) -> Result<Option<JobRow>, AwaError> {
6234        let schema = self.schema();
6235        let deleted_waiting: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
6236            r#"
6237            DELETE FROM {schema}.leases
6238            WHERE job_id = $1
6239              AND state = 'waiting_external'
6240            RETURNING
6241                ready_slot,
6242                ready_generation,
6243                job_id,
6244                queue,
6245                state,
6246                priority,
6247                attempt,
6248                run_lease,
6249                max_attempts,
6250                lane_seq,
6251                enqueue_shard,
6252                heartbeat_at,
6253                deadline_at,
6254                attempted_at,
6255                callback_id,
6256                callback_timeout_at
6257            "#
6258        ))
6259        .bind(job_id)
6260        .fetch_all(tx.as_mut())
6261        .await
6262        .map_err(map_sqlx_error)?;
6263
6264        if !deleted_waiting.is_empty() {
6265            let waiting = self
6266                .hydrate_deleted_leases_tx(tx, deleted_waiting)
6267                .await?
6268                .into_iter()
6269                .next()
6270                .expect("deleted waiting lease");
6271            let ready_payload = Self::payload_with_attempt_state(
6272                waiting.payload.clone(),
6273                waiting.progress.clone(),
6274            )?;
6275            let ready_row = ExistingReadyRow {
6276                attempt: 0,
6277                run_at: Utc::now(),
6278                attempted_at: None,
6279                ..waiting.clone().into_ready_row(Utc::now(), ready_payload)
6280            };
6281            self.insert_existing_ready_rows_tx(tx, vec![ready_row.clone()], Some(waiting.state))
6282                .await?;
6283            self.notify_queues_tx(tx, std::iter::once(waiting.queue.clone()))
6284                .await?;
6285            return Ok(Some(
6286                ReadyJobRow {
6287                    job_id: ready_row.job_id,
6288                    kind: ready_row.kind,
6289                    queue: ready_row.queue,
6290                    args: ready_row.args,
6291                    priority: ready_row.priority,
6292                    attempt: ready_row.attempt,
6293                    run_lease: ready_row.run_lease,
6294                    max_attempts: ready_row.max_attempts,
6295                    run_at: ready_row.run_at,
6296                    attempted_at: ready_row.attempted_at,
6297                    created_at: ready_row.created_at,
6298                    unique_key: ready_row.unique_key,
6299                    payload: ready_row.payload,
6300                }
6301                .into_job_row()?,
6302            ));
6303        }
6304
6305        let done_projection = done_row_projection("done", "ready");
6306        let ready_join = done_ready_join(schema, "done", "ready");
6307        let terminal: Option<DoneJobRow> = sqlx::query_as(&format!(
6308            r#"
6309            WITH deleted AS (
6310                DELETE FROM {schema}.done_entries
6311                WHERE (job_id, finalized_at) IN (
6312                SELECT job_id, finalized_at
6313                FROM {schema}.done_entries
6314                WHERE job_id = $1
6315                  AND state IN ('failed', 'cancelled')
6316                ORDER BY finalized_at DESC
6317                LIMIT 1
6318                FOR UPDATE SKIP LOCKED
6319            )
6320                RETURNING *
6321            )
6322            SELECT {done_projection}
6323            FROM deleted AS done
6324            {ready_join}
6325            "#
6326        ))
6327        .bind(job_id)
6328        .fetch_optional(tx.as_mut())
6329        .await
6330        .map_err(map_sqlx_error)?;
6331
6332        if let Some(terminal) = terminal {
6333            // The DELETE FROM done_entries above removes one terminal row;
6334            // append a negative delta so exact counts stay in lockstep.
6335            self.decrement_live_terminal_counters_tx(
6336                tx,
6337                &Self::done_rows_to_counter_keys(std::slice::from_ref(&terminal)),
6338            )
6339            .await?;
6340            let ready_row = ExistingReadyRow {
6341                job_id: terminal.job_id,
6342                kind: terminal.kind,
6343                queue: terminal.queue.clone(),
6344                args: terminal.args,
6345                priority: terminal.priority,
6346                attempt: 0,
6347                run_lease: terminal.run_lease,
6348                max_attempts: terminal.max_attempts,
6349                run_at: Utc::now(),
6350                attempted_at: None,
6351                created_at: terminal.created_at,
6352                unique_key: terminal.unique_key,
6353                unique_states: terminal.unique_states,
6354                payload: terminal.payload,
6355            };
6356            self.insert_existing_ready_rows_tx(tx, vec![ready_row.clone()], Some(terminal.state))
6357                .await?;
6358            self.notify_queues_tx(tx, std::iter::once(terminal.queue.clone()))
6359                .await?;
6360            return Ok(Some(
6361                ReadyJobRow {
6362                    job_id: ready_row.job_id,
6363                    kind: ready_row.kind,
6364                    queue: ready_row.queue,
6365                    args: ready_row.args,
6366                    priority: ready_row.priority,
6367                    attempt: ready_row.attempt,
6368                    run_lease: ready_row.run_lease,
6369                    max_attempts: ready_row.max_attempts,
6370                    run_at: ready_row.run_at,
6371                    attempted_at: ready_row.attempted_at,
6372                    created_at: ready_row.created_at,
6373                    unique_key: ready_row.unique_key,
6374                    payload: ready_row.payload,
6375                }
6376                .into_job_row()?,
6377            ));
6378        }
6379
6380        Ok(None)
6381    }
6382
6383    pub async fn retry_job(&self, pool: &PgPool, job_id: i64) -> Result<Option<JobRow>, AwaError> {
6384        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6385        let row = self.retry_job_tx(&mut tx, job_id).await?;
6386        tx.commit().await.map_err(map_sqlx_error)?;
6387        Ok(row)
6388    }
6389
6390    pub async fn retry_jobs_by_ids(
6391        &self,
6392        pool: &PgPool,
6393        ids: &[i64],
6394    ) -> Result<Vec<JobRow>, AwaError> {
6395        if ids.is_empty() {
6396            return Ok(Vec::new());
6397        }
6398
6399        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6400        let mut rows = Vec::with_capacity(ids.len());
6401        for job_id in ids {
6402            if let Some(row) = self.retry_job_tx(&mut tx, *job_id).await? {
6403                rows.push(row);
6404            }
6405        }
6406        tx.commit().await.map_err(map_sqlx_error)?;
6407        Ok(rows)
6408    }
6409
6410    /// Write a `<outcome>` closure row for any matching open receipt.
6411    /// Idempotent: no-op if no lease_claims row exists for the
6412    /// `(job_id, run_lease)` pair, or if a closure already exists. Used
6413    /// by the admin cancel path to keep the receipt plane consistent
6414    /// with the job's new terminal state so rescue doesn't revive it.
6415    ///
6416    /// `FOR UPDATE` on the inner SELECT serialises the closure write
6417    /// against `ensure_running_leases_from_receipts_tx`
6418    /// (which also takes `FOR UPDATE OF claims` on the same row) and
6419    /// against concurrent rescue / re-close paths that might race the
6420    /// same `(job_id, run_lease)`. Without it, materialization could
6421    /// see the claim row, decide to materialize, and a concurrent
6422    /// admin cancel could write the closure between materialization's
6423    /// SELECT and the lease INSERT — leaving a `running` lease for a
6424    /// closed claim that admin cancel believes is fully shut down.
6425    async fn close_receipt_tx<'a>(
6426        &self,
6427        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6428        job_id: i64,
6429        run_lease: i64,
6430        outcome: &str,
6431    ) -> Result<(), AwaError> {
6432        let schema = self.schema();
6433        sqlx::query(&format!(
6434            r#"
6435            WITH locked_claim AS (
6436                SELECT claim_slot, job_id, run_lease
6437                FROM {schema}.lease_claims AS claims
6438                WHERE claims.job_id = $1 AND claims.run_lease = $2
6439                FOR UPDATE
6440            )
6441            INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
6442            SELECT claim_slot, job_id, run_lease, $3, clock_timestamp()
6443            FROM locked_claim
6444            ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
6445            "#
6446        ))
6447        .bind(job_id)
6448        .bind(run_lease)
6449        .bind(outcome)
6450        .execute(tx.as_mut())
6451        .await
6452        .map_err(map_sqlx_error)?;
6453        Ok(())
6454    }
6455
6456    /// Emit a `pg_notify('awa:cancel', ...)` inside the cancel
6457    /// transaction so any worker runtime currently executing this
6458    /// `(job_id, run_lease)` learns about the cancellation on commit
6459    /// and fires its in-flight cancel flag. Notifications are
6460    /// automatically discarded on rollback.
6461    async fn notify_cancellation_tx<'a>(
6462        &self,
6463        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6464        job_id: i64,
6465        run_lease: i64,
6466    ) -> Result<(), AwaError> {
6467        let payload = serde_json::json!({ "job_id": job_id, "run_lease": run_lease }).to_string();
6468        sqlx::query("SELECT pg_notify('awa:cancel', $1)")
6469            .bind(payload)
6470            .execute(tx.as_mut())
6471            .await
6472            .map_err(map_sqlx_error)?;
6473        Ok(())
6474    }
6475
6476    async fn cancel_job_tx<'a>(
6477        &self,
6478        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6479        job_id: i64,
6480    ) -> Result<Option<CancelJobTxResult>, AwaError> {
6481        let schema = self.schema();
6482        let ready: Option<ReadyTransitionRow> = sqlx::query_as(&format!(
6483            r#"
6484            WITH target AS (
6485                SELECT ready.*
6486                FROM {schema}.ready_entries AS ready
6487                JOIN {schema}.queue_claim_heads AS claims
6488                  ON claims.queue = ready.queue
6489                 AND claims.priority = ready.priority
6490                 AND claims.enqueue_shard = ready.enqueue_shard
6491                WHERE ready.job_id = $1
6492                  AND ready.lane_seq >= {schema}.sequence_next_value(claims.seq_name)
6493                  AND NOT EXISTS (
6494                      SELECT 1 FROM {schema}.ready_tombstones AS tomb
6495                      WHERE tomb.queue = ready.queue
6496                        AND tomb.priority = ready.priority
6497                        AND tomb.enqueue_shard = ready.enqueue_shard
6498                        AND tomb.lane_seq = ready.lane_seq
6499                        AND tomb.ready_slot = ready.ready_slot
6500                        AND tomb.ready_generation = ready.ready_generation
6501                  )
6502                ORDER BY ready.lane_seq DESC
6503                LIMIT 1
6504                FOR UPDATE OF ready SKIP LOCKED
6505            ),
6506            tombstone AS (
6507                INSERT INTO {schema}.ready_tombstones (
6508                    ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, job_id
6509                )
6510                SELECT ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, job_id
6511                FROM target
6512                ON CONFLICT DO NOTHING
6513            )
6514            SELECT
6515                ready_slot,
6516                ready_generation,
6517                job_id,
6518                kind,
6519                queue,
6520                args,
6521                priority,
6522                attempt,
6523                run_lease,
6524                max_attempts,
6525                lane_seq,
6526                enqueue_shard,
6527                run_at,
6528                attempted_at,
6529                created_at,
6530                unique_key,
6531                unique_states,
6532                COALESCE(payload, '{{}}'::jsonb) AS payload
6533            FROM target
6534            "#
6535        ))
6536        .bind(job_id)
6537        .fetch_optional(tx.as_mut())
6538        .await
6539        .map_err(map_sqlx_error)?;
6540
6541        if let Some(ready) = ready {
6542            let done =
6543                ready
6544                    .clone()
6545                    .into_done_row(JobState::Cancelled, Utc::now(), ready.payload.clone());
6546            self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(JobState::Available))
6547                .await?;
6548            // If the cancelled lane was *exactly* at the claim head,
6549            // advance the head past it so the derived cursor-difference count
6550            // drops by 1 immediately.
6551            // When other unclaimed lanes still sit between claim_seq
6552            // and the cancelled lane_seq, leave claim_seq alone —
6553            // advancing would skip past those still-claimable rows.
6554            // Non-head deletes can leave a bounded dispatcher over-count until
6555            // later committed rows on the lane are claimed.
6556            let claim_cursor_advance = ClaimCursorAdvance {
6557                queue: ready.queue.clone(),
6558                priority: ready.priority,
6559                enqueue_shard: ready.enqueue_shard,
6560                next_seq: ready.lane_seq + 1,
6561                only_if_current: Some(ready.lane_seq),
6562            };
6563            return Ok(Some(CancelJobTxResult {
6564                row: done.into_job_row()?,
6565                claim_cursor_advance: Some(claim_cursor_advance),
6566            }));
6567        }
6568
6569        let deleted_lease: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
6570            r#"
6571            DELETE FROM {schema}.leases
6572            WHERE job_id = $1
6573              AND state IN ('running', 'waiting_external')
6574            RETURNING
6575                ready_slot,
6576                ready_generation,
6577                job_id,
6578                queue,
6579                state,
6580                priority,
6581                attempt,
6582                run_lease,
6583                max_attempts,
6584                lane_seq,
6585                enqueue_shard,
6586                heartbeat_at,
6587                deadline_at,
6588                attempted_at,
6589                callback_id,
6590                callback_timeout_at
6591            "#
6592        ))
6593        .bind(job_id)
6594        .fetch_all(tx.as_mut())
6595        .await
6596        .map_err(map_sqlx_error)?;
6597
6598        if !deleted_lease.is_empty() {
6599            let lease = self
6600                .hydrate_deleted_leases_tx(tx, deleted_lease)
6601                .await?
6602                .into_iter()
6603                .next()
6604                .expect("deleted running lease");
6605            let done_payload =
6606                Self::payload_with_attempt_state(lease.payload.clone(), lease.progress.clone())?;
6607            let done = lease
6608                .clone()
6609                .into_done_row(JobState::Cancelled, Utc::now(), done_payload);
6610            self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(lease.state))
6611                .await?;
6612            // Receipt-plane consistency: close any matching open
6613            // receipt so the ADR-023 anti-join no longer considers this
6614            // attempt live, and rescue doesn't try to revive it.
6615            self.close_receipt_tx(tx, lease.job_id, lease.run_lease, "cancelled")
6616                .await?;
6617            // Wake any worker currently executing this attempt.
6618            self.notify_cancellation_tx(tx, lease.job_id, lease.run_lease)
6619                .await?;
6620            return Ok(Some(CancelJobTxResult {
6621                row: done.into_job_row()?,
6622                claim_cursor_advance: None,
6623            }));
6624        }
6625
6626        // ADR-023 receipt-only cancel: the job may be running on a
6627        // receipt-backed short path that never materialized a `leases`
6628        // row. Find it by anti-joining lease_claims with
6629        // lease_claim_closures, cancel it by writing a closure and a
6630        // done row, and notify listening workers.
6631        if self.lease_claim_receipts() {
6632            type ReceiptCancelRow = (
6633                i32,
6634                i64,
6635                i32,
6636                i64,
6637                String,
6638                i16,
6639                i16,
6640                i16,
6641                i64,
6642                DateTime<Utc>,
6643            );
6644            let receipt: Option<ReceiptCancelRow> = sqlx::query_as(&format!(
6645                r#"
6646                    SELECT
6647                        claims.claim_slot,
6648                        claims.run_lease,
6649                        claims.ready_slot,
6650                        claims.ready_generation,
6651                        claims.queue,
6652                        claims.priority,
6653                        claims.attempt,
6654                        claims.max_attempts,
6655                        claims.lane_seq,
6656                        claims.claimed_at
6657                    FROM {schema}.lease_claims AS claims
6658                    WHERE claims.job_id = $1
6659                      AND NOT EXISTS (
6660                          SELECT 1 FROM {schema}.lease_claim_closures AS closures
6661                          WHERE closures.claim_slot = claims.claim_slot
6662                            AND closures.job_id = claims.job_id
6663                            AND closures.run_lease = claims.run_lease
6664                      )
6665                    ORDER BY claims.run_lease DESC
6666                    LIMIT 1
6667                    FOR UPDATE OF claims SKIP LOCKED
6668                    "#
6669            ))
6670            .bind(job_id)
6671            .fetch_optional(tx.as_mut())
6672            .await
6673            .map_err(map_sqlx_error)?;
6674
6675            if let Some((
6676                claim_slot,
6677                run_lease,
6678                ready_slot,
6679                ready_generation,
6680                queue,
6681                priority,
6682                attempt,
6683                max_attempts,
6684                lane_seq,
6685                claimed_at,
6686            )) = receipt
6687            {
6688                // Hydrate the ready row so we can synthesize the done
6689                // row with the original args/payload.
6690                let ready_match: Option<ReadyTransitionRow> = sqlx::query_as(&format!(
6691                    r#"
6692                    SELECT
6693                        ready_slot,
6694                        ready_generation,
6695                        job_id,
6696                        kind,
6697                        queue,
6698                        args,
6699                        priority,
6700                        attempt,
6701                        run_lease,
6702                        max_attempts,
6703                        lane_seq,
6704                        enqueue_shard,
6705                        run_at,
6706                        attempted_at,
6707                        created_at,
6708                        unique_key,
6709                        unique_states,
6710                        COALESCE(payload, '{{}}'::jsonb) AS payload
6711                    FROM {schema}.ready_entries
6712                    WHERE job_id = $1
6713                      AND ready_slot = $2
6714                      AND ready_generation = $3
6715                      AND queue = $4
6716                      AND priority = $5
6717                      AND lane_seq = $6
6718                    "#
6719                ))
6720                .bind(job_id)
6721                .bind(ready_slot)
6722                .bind(ready_generation)
6723                .bind(&queue)
6724                .bind(priority)
6725                .bind(lane_seq)
6726                .fetch_optional(tx.as_mut())
6727                .await
6728                .map_err(map_sqlx_error)?;
6729
6730                let Some(ready) = ready_match else {
6731                    // Shouldn't happen — the claim references a ready
6732                    // row. Fall through to the deferred / not-found
6733                    // branches.
6734                    return Ok(None);
6735                };
6736
6737                let done = DoneJobRow {
6738                    ready_slot,
6739                    ready_generation,
6740                    job_id,
6741                    kind: ready.kind,
6742                    queue: queue.clone(),
6743                    args: ready.args,
6744                    state: JobState::Cancelled,
6745                    priority,
6746                    attempt,
6747                    run_lease,
6748                    max_attempts,
6749                    lane_seq,
6750                    enqueue_shard: ready.enqueue_shard,
6751                    run_at: ready.run_at,
6752                    attempted_at: Some(claimed_at),
6753                    finalized_at: Utc::now(),
6754                    created_at: ready.created_at,
6755                    unique_key: ready.unique_key,
6756                    unique_states: ready.unique_states,
6757                    payload: ready.payload,
6758                };
6759                self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(JobState::Running))
6760                    .await?;
6761                // Write the closure row into the same claim partition.
6762                sqlx::query(&format!(
6763                    r#"
6764                    INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
6765                    VALUES ($1, $2, $3, 'cancelled', clock_timestamp())
6766                    ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
6767                    "#
6768                ))
6769                .bind(claim_slot)
6770                .bind(job_id)
6771                .bind(run_lease)
6772                .execute(tx.as_mut())
6773                .await
6774                .map_err(map_sqlx_error)?;
6775                // Defensive: between the leases DELETE at the top of
6776                // this function and the FOR UPDATE on claims above, a
6777                // concurrent `ensure_running_leases_from_receipts_tx`
6778                // can have materialized a `leases` row for this
6779                // (job_id, run_lease). Materialize and we both lock the
6780                // same claim row; whichever ran first commits, the
6781                // other replays under the new snapshot. If materialize
6782                // committed first, that lease is now an orphan pointing
6783                // at a job we're about to mark `cancelled`. Sweep it
6784                // defensively. If no race occurred this is a no-op.
6785                sqlx::query(&format!(
6786                    "DELETE FROM {schema}.leases WHERE job_id = $1 AND run_lease = $2"
6787                ))
6788                .bind(job_id)
6789                .bind(run_lease)
6790                .execute(tx.as_mut())
6791                .await
6792                .map_err(map_sqlx_error)?;
6793                self.notify_cancellation_tx(tx, job_id, run_lease).await?;
6794                return Ok(Some(CancelJobTxResult {
6795                    row: done.into_job_row()?,
6796                    claim_cursor_advance: None,
6797                }));
6798            }
6799        }
6800
6801        let deferred: Option<DeferredJobRow> = sqlx::query_as(&format!(
6802            r#"
6803            DELETE FROM {schema}.deferred_jobs
6804            WHERE job_id = $1
6805              AND state IN ('scheduled', 'retryable')
6806            RETURNING
6807                job_id,
6808                kind,
6809                queue,
6810                args,
6811                state,
6812                priority,
6813                attempt,
6814                run_lease,
6815                max_attempts,
6816                run_at,
6817                attempted_at,
6818                finalized_at,
6819                created_at,
6820                unique_key,
6821                unique_states,
6822                COALESCE(payload, '{{}}'::jsonb) AS payload
6823            "#
6824        ))
6825        .bind(job_id)
6826        .fetch_optional(tx.as_mut())
6827        .await
6828        .map_err(map_sqlx_error)?;
6829
6830        if let Some(deferred) = deferred {
6831            let (ready_slot, ready_generation) = self.current_queue_ring(tx).await?;
6832            // A deferred-cancel never observed a claim, so it has no
6833            // shard assignment to inherit. The synthesized terminal row
6834            // is parked on shard 0 with a synthetic negative `lane_seq`
6835            // that keeps the `done_entries` PK
6836            // `(ready_slot, queue, priority, enqueue_shard, lane_seq)`
6837            // unique. `ensure_lane` registers shard 0 for the queue so
6838            // the lane row exists regardless of producer activity.
6839            self.ensure_lane(tx, &deferred.queue, deferred.priority, 0)
6840                .await?;
6841            let done = DoneJobRow {
6842                ready_slot,
6843                ready_generation,
6844                job_id: deferred.job_id,
6845                kind: deferred.kind,
6846                queue: deferred.queue.clone(),
6847                args: deferred.args,
6848                state: JobState::Cancelled,
6849                priority: deferred.priority,
6850                attempt: deferred.attempt,
6851                run_lease: deferred.run_lease,
6852                max_attempts: deferred.max_attempts,
6853                lane_seq: -deferred.job_id,
6854                enqueue_shard: 0,
6855                run_at: deferred.run_at,
6856                attempted_at: deferred.attempted_at,
6857                finalized_at: Utc::now(),
6858                created_at: deferred.created_at,
6859                unique_key: deferred.unique_key,
6860                unique_states: deferred.unique_states,
6861                payload: deferred.payload,
6862            };
6863            self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(deferred.state))
6864                .await?;
6865            return Ok(Some(CancelJobTxResult {
6866                row: done.into_job_row()?,
6867                claim_cursor_advance: None,
6868            }));
6869        }
6870
6871        Ok(None)
6872    }
6873
6874    pub async fn cancel_job(&self, pool: &PgPool, job_id: i64) -> Result<Option<JobRow>, AwaError> {
6875        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6876        let result = self.cancel_job_tx(&mut tx, job_id).await?;
6877        tx.commit().await.map_err(map_sqlx_error)?;
6878        if let Some(result) = result {
6879            if let Some(advance) = result.claim_cursor_advance.as_ref() {
6880                self.advance_claim_cursors(pool, std::slice::from_ref(advance))
6881                    .await;
6882            }
6883            Ok(Some(result.row))
6884        } else {
6885            Ok(None)
6886        }
6887    }
6888
6889    pub async fn cancel_jobs_by_ids(
6890        &self,
6891        pool: &PgPool,
6892        ids: &[i64],
6893    ) -> Result<Vec<JobRow>, AwaError> {
6894        if ids.is_empty() {
6895            return Ok(Vec::new());
6896        }
6897
6898        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6899        let mut rows = Vec::with_capacity(ids.len());
6900        let mut claim_cursor_advances = Vec::new();
6901        for job_id in ids {
6902            if let Some(result) = self.cancel_job_tx(&mut tx, *job_id).await? {
6903                if let Some(advance) = result.claim_cursor_advance {
6904                    claim_cursor_advances.push(advance);
6905                }
6906                rows.push(result.row);
6907            }
6908        }
6909        tx.commit().await.map_err(map_sqlx_error)?;
6910        self.advance_claim_cursors(pool, &claim_cursor_advances)
6911            .await;
6912        Ok(rows)
6913    }
6914
6915    pub async fn set_priority(
6916        &self,
6917        pool: &PgPool,
6918        job_id: i64,
6919        priority: i16,
6920    ) -> Result<bool, AwaError> {
6921        if !(1..=4).contains(&priority) {
6922            return Err(AwaError::Validation(
6923                "priority must be between 1 and 4".to_string(),
6924            ));
6925        }
6926
6927        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6928        let result = self.set_priority_tx(&mut tx, job_id, priority).await?;
6929        tx.commit().await.map_err(map_sqlx_error)?;
6930        Ok(result)
6931    }
6932
6933    pub async fn set_priority_tx<'a>(
6934        &self,
6935        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6936        job_id: i64,
6937        priority: i16,
6938    ) -> Result<bool, AwaError> {
6939        if !(1..=4).contains(&priority) {
6940            return Err(AwaError::Validation(
6941                "priority must be between 1 and 4".to_string(),
6942            ));
6943        }
6944
6945        if self
6946            .update_deferred_batch_fields_tx(tx, job_id, None, Some(priority))
6947            .await?
6948        {
6949            return Ok(true);
6950        }
6951
6952        let result = self
6953            .move_ready_batch_fields_tx(tx, job_id, None, Some(priority))
6954            .await?;
6955        Ok(result.moved)
6956    }
6957
6958    pub async fn move_queue(
6959        &self,
6960        pool: &PgPool,
6961        job_id: i64,
6962        queue: &str,
6963        priority: Option<i16>,
6964    ) -> Result<bool, AwaError> {
6965        if queue.is_empty() || queue.len() > 200 {
6966            return Err(AwaError::Validation(
6967                "destination queue must be 1..=200 characters".to_string(),
6968            ));
6969        }
6970        if let Some(priority) = priority {
6971            if !(1..=4).contains(&priority) {
6972                return Err(AwaError::Validation(
6973                    "priority must be between 1 and 4".to_string(),
6974                ));
6975            }
6976        }
6977
6978        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
6979        let result = self.move_queue_tx(&mut tx, job_id, queue, priority).await?;
6980        tx.commit().await.map_err(map_sqlx_error)?;
6981        Ok(result)
6982    }
6983
6984    pub async fn move_queue_tx<'a>(
6985        &self,
6986        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
6987        job_id: i64,
6988        queue: &str,
6989        priority: Option<i16>,
6990    ) -> Result<bool, AwaError> {
6991        if queue.is_empty() || queue.len() > 200 {
6992            return Err(AwaError::Validation(
6993                "destination queue must be 1..=200 characters".to_string(),
6994            ));
6995        }
6996        if let Some(priority) = priority {
6997            if !(1..=4).contains(&priority) {
6998                return Err(AwaError::Validation(
6999                    "priority must be between 1 and 4".to_string(),
7000                ));
7001            }
7002        }
7003
7004        if self
7005            .update_deferred_batch_fields_tx(tx, job_id, Some(queue), priority)
7006            .await?
7007        {
7008            return Ok(true);
7009        }
7010
7011        let result = self
7012            .move_ready_batch_fields_tx(tx, job_id, Some(queue), priority)
7013            .await?;
7014        Ok(result.moved)
7015    }
7016
7017    async fn update_deferred_batch_fields_tx<'a>(
7018        &self,
7019        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7020        job_id: i64,
7021        queue: Option<&str>,
7022        priority: Option<i16>,
7023    ) -> Result<bool, AwaError> {
7024        let schema = self.schema();
7025        let row: Option<DeferredJobRow> = sqlx::query_as(&format!(
7026            r#"
7027            SELECT
7028                job_id,
7029                kind,
7030                queue,
7031                args,
7032                state,
7033                priority,
7034                attempt,
7035                run_lease,
7036                max_attempts,
7037                run_at,
7038                attempted_at,
7039                finalized_at,
7040                created_at,
7041                unique_key,
7042                unique_states,
7043                COALESCE(payload, '{{}}'::jsonb) AS payload
7044            FROM {schema}.deferred_jobs
7045            WHERE job_id = $1
7046              AND state = 'scheduled'
7047            FOR UPDATE SKIP LOCKED
7048            "#
7049        ))
7050        .bind(job_id)
7051        .fetch_optional(tx.as_mut())
7052        .await
7053        .map_err(map_sqlx_error)?;
7054
7055        let Some(row) = row else {
7056            return Ok(false);
7057        };
7058
7059        let old_queue = row.queue.clone();
7060        let old_priority = row.priority;
7061        let requested_queue = queue.unwrap_or(&old_queue);
7062        let old_logical_queue = self.logical_queue_name(&old_queue).to_string();
7063        let new_queue = if queue.is_some()
7064            && requested_queue != old_queue
7065            && requested_queue != old_logical_queue
7066        {
7067            self.queue_stripe_for_enqueue(requested_queue, &row.unique_key, row.job_id)
7068        } else {
7069            old_queue.clone()
7070        };
7071        let new_priority = priority.unwrap_or(old_priority);
7072        if new_queue == old_queue && new_priority == old_priority {
7073            return Ok(false);
7074        }
7075        let mut payload = RuntimePayload::from_json(row.payload)?;
7076        let metadata = payload.metadata.as_object_mut().ok_or_else(|| {
7077            AwaError::Validation("queue storage payload metadata must be a JSON object".to_string())
7078        })?;
7079        if queue.is_some() {
7080            metadata
7081                .entry("_awa_original_queue".to_string())
7082                .or_insert_with(|| serde_json::Value::from(old_logical_queue));
7083        }
7084        if priority.is_some() {
7085            metadata
7086                .entry("_awa_original_priority".to_string())
7087                .or_insert_with(|| serde_json::Value::from(i64::from(old_priority)));
7088        }
7089
7090        sqlx::query(&format!(
7091            r#"
7092            UPDATE {schema}.deferred_jobs
7093            SET queue = $2,
7094                priority = $3,
7095                payload = $4
7096            WHERE job_id = $1
7097            "#
7098        ))
7099        .bind(job_id)
7100        .bind(new_queue)
7101        .bind(new_priority)
7102        .bind(storage_payload(&payload.into_json()))
7103        .execute(tx.as_mut())
7104        .await
7105        .map_err(map_sqlx_error)?;
7106        Ok(true)
7107    }
7108
7109    async fn move_ready_batch_fields_tx<'a>(
7110        &self,
7111        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7112        job_id: i64,
7113        queue: Option<&str>,
7114        priority: Option<i16>,
7115    ) -> Result<ReadyBatchMoveResult, AwaError> {
7116        let schema = self.schema();
7117        let ready: Option<ReadyTransitionRow> = sqlx::query_as(&format!(
7118            r#"
7119            WITH target AS (
7120                SELECT ready.*
7121                FROM {schema}.ready_entries AS ready
7122                JOIN {schema}.queue_claim_heads AS claims
7123                  ON claims.queue = ready.queue
7124                 AND claims.priority = ready.priority
7125                 AND claims.enqueue_shard = ready.enqueue_shard
7126                WHERE ready.job_id = $1
7127                  AND ready.lane_seq >= {schema}.sequence_next_value(claims.seq_name)
7128                  AND NOT EXISTS (
7129                      SELECT 1 FROM {schema}.ready_tombstones AS tomb
7130                      WHERE tomb.queue = ready.queue
7131                        AND tomb.priority = ready.priority
7132                        AND tomb.enqueue_shard = ready.enqueue_shard
7133                        AND tomb.lane_seq = ready.lane_seq
7134                        AND tomb.ready_slot = ready.ready_slot
7135                        AND tomb.ready_generation = ready.ready_generation
7136                  )
7137                ORDER BY ready.lane_seq DESC
7138                LIMIT 1
7139                FOR UPDATE OF ready SKIP LOCKED
7140            )
7141            SELECT
7142                ready_slot,
7143                ready_generation,
7144                job_id,
7145                kind,
7146                queue,
7147                args,
7148                priority,
7149                attempt,
7150                run_lease,
7151                max_attempts,
7152                lane_seq,
7153                enqueue_shard,
7154                run_at,
7155                attempted_at,
7156                created_at,
7157                unique_key,
7158                unique_states,
7159                COALESCE(payload, '{{}}'::jsonb) AS payload
7160            FROM target
7161            "#
7162        ))
7163        .bind(job_id)
7164        .fetch_optional(tx.as_mut())
7165        .await
7166        .map_err(map_sqlx_error)?;
7167
7168        let Some(ready) = ready else {
7169            return Ok(ReadyBatchMoveResult { moved: false });
7170        };
7171
7172        let old_queue = ready.queue.clone();
7173        let old_priority = ready.priority;
7174        let requested_queue = queue.unwrap_or(&old_queue);
7175        let old_logical_queue = self.logical_queue_name(&old_queue).to_string();
7176        let new_queue = if queue.is_some()
7177            && requested_queue != old_queue
7178            && requested_queue != old_logical_queue
7179        {
7180            self.queue_stripe_for_enqueue(requested_queue, &ready.unique_key, ready.job_id)
7181        } else {
7182            old_queue.clone()
7183        };
7184        let new_priority = priority.unwrap_or(old_priority);
7185        if new_queue == old_queue && new_priority == old_priority {
7186            return Ok(ReadyBatchMoveResult { moved: false });
7187        }
7188        sqlx::query(&format!(
7189            r#"
7190            INSERT INTO {schema}.ready_tombstones (
7191                ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, job_id
7192            )
7193            VALUES ($1, $2, $3, $4, $5, $6, $7)
7194            ON CONFLICT DO NOTHING
7195            "#
7196        ))
7197        .bind(ready.ready_slot)
7198        .bind(ready.ready_generation)
7199        .bind(&ready.queue)
7200        .bind(ready.priority)
7201        .bind(ready.enqueue_shard)
7202        .bind(ready.lane_seq)
7203        .bind(ready.job_id)
7204        .execute(tx.as_mut())
7205        .await
7206        .map_err(map_sqlx_error)?;
7207
7208        let mut payload = RuntimePayload::from_json(ready.payload.clone())?;
7209        let metadata = payload.metadata.as_object_mut().ok_or_else(|| {
7210            AwaError::Validation("queue storage payload metadata must be a JSON object".to_string())
7211        })?;
7212        if queue.is_some() {
7213            metadata
7214                .entry("_awa_original_queue".to_string())
7215                .or_insert_with(|| serde_json::Value::from(old_logical_queue));
7216        }
7217        if priority.is_some() {
7218            metadata
7219                .entry("_awa_original_priority".to_string())
7220                .or_insert_with(|| serde_json::Value::from(i64::from(old_priority)));
7221        }
7222
7223        let notify_queue = new_queue.clone();
7224        let ready_row = ready.into_existing_ready_row(new_queue, new_priority, payload.into_json());
7225        self.insert_existing_ready_rows_tx(tx, vec![ready_row], Some(JobState::Available))
7226            .await?;
7227        self.notify_queues_tx(tx, std::iter::once(notify_queue))
7228            .await?;
7229
7230        Ok(ReadyBatchMoveResult { moved: true })
7231    }
7232
7233    pub async fn age_waiting_priorities(
7234        &self,
7235        pool: &PgPool,
7236        aging_interval: Duration,
7237        limit: i64,
7238    ) -> Result<Vec<i64>, AwaError> {
7239        if limit <= 0 {
7240            return Ok(Vec::new());
7241        }
7242
7243        let cutoff = Utc::now()
7244            - TimeDelta::from_std(aging_interval)
7245                .map_err(|err| AwaError::Validation(format!("invalid aging interval: {err}")))?;
7246        let schema = self.schema();
7247        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
7248
7249        let moved: Vec<ReadyTransitionRow> = sqlx::query_as(&format!(
7250            r#"
7251            WITH target AS (
7252                SELECT ready.*
7253                FROM {schema}.ready_entries AS ready
7254                JOIN {schema}.queue_claim_heads AS claims
7255                  ON claims.queue = ready.queue
7256                 AND claims.priority = ready.priority
7257                 AND claims.enqueue_shard = ready.enqueue_shard
7258                WHERE ready.lane_seq >= {schema}.sequence_next_value(claims.seq_name)
7259                  AND ready.priority > 1
7260                  AND ready.run_at <= $1
7261                  AND NOT EXISTS (
7262                      SELECT 1 FROM {schema}.ready_tombstones AS tomb
7263                      WHERE tomb.queue = ready.queue
7264                        AND tomb.priority = ready.priority
7265                        AND tomb.enqueue_shard = ready.enqueue_shard
7266                        AND tomb.lane_seq = ready.lane_seq
7267                        AND tomb.ready_slot = ready.ready_slot
7268                        AND tomb.ready_generation = ready.ready_generation
7269                  )
7270                ORDER BY ready.run_at ASC, ready.lane_seq ASC
7271                LIMIT $2
7272                FOR UPDATE OF ready SKIP LOCKED
7273            ),
7274            tombstones AS (
7275                INSERT INTO {schema}.ready_tombstones (
7276                    ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, job_id
7277                )
7278                SELECT ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq, job_id
7279                FROM target
7280                ON CONFLICT DO NOTHING
7281            )
7282            SELECT
7283                ready_slot,
7284                ready_generation,
7285                job_id,
7286                kind,
7287                queue,
7288                args,
7289                priority,
7290                attempt,
7291                run_lease,
7292                max_attempts,
7293                lane_seq,
7294                enqueue_shard,
7295                run_at,
7296                attempted_at,
7297                created_at,
7298                unique_key,
7299                unique_states,
7300                COALESCE(payload, '{{}}'::jsonb) AS payload
7301            FROM target
7302            "#
7303        ))
7304        .bind(cutoff)
7305        .bind(limit)
7306        .fetch_all(tx.as_mut())
7307        .await
7308        .map_err(map_sqlx_error)?;
7309
7310        if moved.is_empty() {
7311            tx.commit().await.map_err(map_sqlx_error)?;
7312            return Ok(Vec::new());
7313        }
7314
7315        let mut ids = Vec::with_capacity(moved.len());
7316        let mut queues = BTreeSet::new();
7317        let mut ready_rows = Vec::with_capacity(moved.len());
7318
7319        for row in moved {
7320            ids.push(row.job_id);
7321            queues.insert(row.queue.clone());
7322
7323            let mut payload = RuntimePayload::from_json(row.payload)?;
7324            let metadata = payload.metadata.as_object_mut().ok_or_else(|| {
7325                AwaError::Validation(
7326                    "queue storage payload metadata must be a JSON object".to_string(),
7327                )
7328            })?;
7329            metadata
7330                .entry("_awa_original_priority".to_string())
7331                .or_insert_with(|| serde_json::Value::from(i64::from(row.priority)));
7332
7333            ready_rows.push(ExistingReadyRow {
7334                job_id: row.job_id,
7335                kind: row.kind,
7336                queue: row.queue,
7337                args: row.args,
7338                priority: row.priority - 1,
7339                attempt: row.attempt,
7340                run_lease: row.run_lease,
7341                max_attempts: row.max_attempts,
7342                run_at: row.run_at,
7343                attempted_at: row.attempted_at,
7344                created_at: row.created_at,
7345                unique_key: row.unique_key,
7346                unique_states: row.unique_states,
7347                payload: payload.into_json(),
7348            });
7349        }
7350
7351        // Aging tombstones the source lane without moving its claim cursor,
7352        // then re-inserts on the target lane. The source lane can temporarily
7353        // over-count by `moved`; later claims advance over the tombstones.
7354        // The drift is bounded by aging rate × poll interval.
7355        self.insert_existing_ready_rows_tx(&mut tx, ready_rows, Some(JobState::Available))
7356            .await?;
7357        self.notify_queues_tx(&mut tx, queues).await?;
7358        tx.commit().await.map_err(map_sqlx_error)?;
7359        Ok(ids)
7360    }
7361
7362    fn with_progress(
7363        payload: serde_json::Value,
7364        progress: Option<serde_json::Value>,
7365    ) -> Result<serde_json::Value, AwaError> {
7366        let mut payload = RuntimePayload::from_json(payload)?;
7367        payload.set_progress(progress);
7368        Ok(payload.into_json())
7369    }
7370
7371    async fn take_callback_result(
7372        &self,
7373        pool: &PgPool,
7374        job_id: i64,
7375        run_lease: i64,
7376    ) -> Result<serde_json::Value, AwaError> {
7377        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
7378        let mut row: Option<AttemptStateRow> = sqlx::query_as(&format!(
7379            r#"
7380            SELECT
7381                job_id,
7382                run_lease,
7383                progress,
7384                callback_filter,
7385                callback_on_complete,
7386                callback_on_fail,
7387                callback_transform,
7388                callback_result
7389            FROM {}
7390            WHERE job_id = $1
7391              AND run_lease = $2
7392            FOR UPDATE
7393            "#,
7394            self.attempt_state_table()
7395        ))
7396        .bind(job_id)
7397        .bind(run_lease)
7398        .fetch_optional(tx.as_mut())
7399        .await
7400        .map_err(map_sqlx_error)?;
7401
7402        let Some(mut row) = row.take() else {
7403            tx.commit().await.map_err(map_sqlx_error)?;
7404            return Ok(serde_json::Value::Null);
7405        };
7406
7407        let result = row
7408            .callback_result
7409            .take()
7410            .unwrap_or(serde_json::Value::Null);
7411
7412        if row.progress.is_none()
7413            && row.callback_filter.is_none()
7414            && row.callback_on_complete.is_none()
7415            && row.callback_on_fail.is_none()
7416            && row.callback_transform.is_none()
7417        {
7418            sqlx::query(&format!(
7419                "DELETE FROM {} WHERE job_id = $1 AND run_lease = $2",
7420                self.attempt_state_table()
7421            ))
7422            .bind(job_id)
7423            .bind(run_lease)
7424            .execute(tx.as_mut())
7425            .await
7426            .map_err(map_sqlx_error)?;
7427        } else {
7428            sqlx::query(&format!(
7429                "UPDATE {} SET callback_result = NULL, updated_at = clock_timestamp() WHERE job_id = $1 AND run_lease = $2",
7430                self.attempt_state_table()
7431            ))
7432            .bind(job_id)
7433            .bind(run_lease)
7434            .execute(tx.as_mut())
7435            .await
7436            .map_err(map_sqlx_error)?;
7437        }
7438
7439        tx.commit().await.map_err(map_sqlx_error)?;
7440        Ok(result)
7441    }
7442
7443    async fn backoff_at_tx<'a>(
7444        &self,
7445        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7446        attempt: i16,
7447        max_attempts: i16,
7448    ) -> Result<DateTime<Utc>, AwaError> {
7449        sqlx::query_scalar("SELECT clock_timestamp() + awa.backoff_duration($1, $2)")
7450            .bind(attempt)
7451            .bind(max_attempts)
7452            .fetch_one(tx.as_mut())
7453            .await
7454            .map_err(map_sqlx_error)
7455    }
7456
7457    async fn notify_queues_tx<'a>(
7458        &self,
7459        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7460        queues: impl IntoIterator<Item = String>,
7461    ) -> Result<(), AwaError> {
7462        // BTreeSet dedups so we never emit the same NOTIFY twice per
7463        // transaction. Multi-queue enqueues fold into a single round-trip via
7464        // `unnest($1::text[])` rather than one round-trip per channel.
7465        let channels: Vec<String> = queues
7466            .into_iter()
7467            .map(|queue| format!("awa:{}", self.logical_queue_name(&queue)))
7468            .collect::<BTreeSet<String>>()
7469            .into_iter()
7470            .collect();
7471        if channels.is_empty() {
7472            return Ok(());
7473        }
7474        sqlx::query("SELECT pg_notify(channel, '') FROM unnest($1::text[]) AS channel")
7475            .bind(&channels)
7476            .execute(tx.as_mut())
7477            .await
7478            .map_err(map_sqlx_error)?;
7479        Ok(())
7480    }
7481
7482    async fn ensure_running_leases_from_receipts_tx<'a>(
7483        &self,
7484        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7485        jobs: &[(i64, i64)],
7486    ) -> Result<usize, AwaError> {
7487        if jobs.is_empty() {
7488            return Ok(0);
7489        }
7490
7491        let schema = self.schema();
7492        let job_ids: Vec<i64> = jobs.iter().map(|(job_id, _)| *job_id).collect();
7493        let run_leases: Vec<i64> = jobs.iter().map(|(_, run_lease)| *run_lease).collect();
7494        let inserted: i64 = sqlx::query_scalar(&format!(
7495            r#"
7496            WITH inflight(job_id, run_lease) AS (
7497                SELECT * FROM unnest($1::bigint[], $2::bigint[])
7498            ),
7499            lease_ring AS (
7500                SELECT current_slot AS lease_slot, generation AS lease_generation
7501                FROM {schema}.lease_ring_state
7502                WHERE singleton = TRUE
7503            ),
7504            claim_refs AS (
7505                -- Source claim metadata directly from the partitioned
7506                -- lease_claims table anti-joined against
7507                -- lease_claim_closures.
7508                SELECT
7509                    claims.claim_slot,
7510                    claims.job_id,
7511                    claims.run_lease,
7512                    claims.ready_slot,
7513                    claims.ready_generation,
7514                    claims.queue,
7515                    claims.priority,
7516                    claims.attempt,
7517                    claims.max_attempts,
7518                    claims.lane_seq,
7519                    claims.enqueue_shard,
7520                    claims.claimed_at,
7521                    claims.deadline_at
7522                FROM {schema}.lease_claims AS claims
7523                JOIN inflight
7524                  ON inflight.job_id = claims.job_id
7525                 AND inflight.run_lease = claims.run_lease
7526                WHERE NOT EXISTS (
7527                    SELECT 1 FROM {schema}.lease_claim_closures AS closures
7528                    WHERE closures.claim_slot = claims.claim_slot
7529                      AND closures.job_id = claims.job_id
7530                      AND closures.run_lease = claims.run_lease
7531                )
7532                FOR UPDATE OF claims
7533            ),
7534            already_live AS (
7535                SELECT claim_refs.job_id, claim_refs.run_lease
7536                FROM claim_refs
7537                WHERE EXISTS (
7538                    SELECT 1
7539                    FROM {schema}.leases AS lease
7540                    WHERE lease.job_id = claim_refs.job_id
7541                      AND lease.run_lease = claim_refs.run_lease
7542                )
7543            ),
7544            inserted AS (
7545                INSERT INTO {schema}.leases (
7546                    lease_slot,
7547                    lease_generation,
7548                    ready_slot,
7549                    ready_generation,
7550                    job_id,
7551                    queue,
7552                    state,
7553                    priority,
7554                    attempt,
7555                    run_lease,
7556                    max_attempts,
7557                    lane_seq,
7558                    enqueue_shard,
7559                    heartbeat_at,
7560                    deadline_at,
7561                    attempted_at
7562                )
7563                SELECT
7564                    lease_ring.lease_slot,
7565                    lease_ring.lease_generation,
7566                    claim_refs.ready_slot,
7567                    claim_refs.ready_generation,
7568                    claim_refs.job_id,
7569                    claim_refs.queue,
7570                    'running'::awa.job_state,
7571                    claim_refs.priority,
7572                    claim_refs.attempt,
7573                    claim_refs.run_lease,
7574                    claim_refs.max_attempts,
7575                    claim_refs.lane_seq,
7576                    claim_refs.enqueue_shard,
7577                    clock_timestamp(),
7578                    -- Preserve the per-claim deadline so the lease-side
7579                    -- deadline rescue path picks up materialized claims
7580                    -- without an extra hop. NULL when receipts mode is
7581                    -- on with `deadline_duration = 0` (the short-job
7582                    -- shape that needs no deadline at all).
7583                    claim_refs.deadline_at,
7584                    claim_refs.claimed_at
7585                FROM claim_refs
7586                CROSS JOIN lease_ring
7587                WHERE NOT EXISTS (
7588                    SELECT 1
7589                    FROM {schema}.leases AS lease
7590                    WHERE lease.job_id = claim_refs.job_id
7591                      AND lease.run_lease = claim_refs.run_lease
7592                )
7593                RETURNING job_id, run_lease
7594            ),
7595            marked AS (
7596                UPDATE {schema}.lease_claims AS claims
7597                SET materialized_at = clock_timestamp()
7598                FROM (
7599                    SELECT job_id, run_lease FROM inserted
7600                    UNION
7601                    SELECT job_id, run_lease FROM already_live
7602                ) AS moved
7603                WHERE claims.job_id = moved.job_id
7604                  AND claims.run_lease = moved.run_lease
7605                RETURNING claims.job_id
7606            )
7607            SELECT count(*)::bigint FROM marked
7608            "#
7609        ))
7610        .bind(&job_ids)
7611        .bind(&run_leases)
7612        .fetch_one(tx.as_mut())
7613        .await
7614        .map_err(map_sqlx_error)?;
7615        Ok(inserted as usize)
7616    }
7617
7618    async fn ensure_mutable_running_attempt_tx<'a>(
7619        &self,
7620        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7621        job_id: i64,
7622        run_lease: i64,
7623    ) -> Result<(), AwaError> {
7624        if self.lease_claim_receipts() {
7625            self.ensure_running_leases_from_receipts_tx(tx, &[(job_id, run_lease)])
7626                .await?;
7627        }
7628        Ok(())
7629    }
7630
7631    async fn upsert_attempt_state_from_receipts_tx<'a>(
7632        &self,
7633        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7634        jobs: &[(i64, i64)],
7635    ) -> Result<usize, AwaError> {
7636        if jobs.is_empty() {
7637            return Ok(0);
7638        }
7639
7640        let schema = self.schema();
7641        let job_ids: Vec<i64> = jobs.iter().map(|(job_id, _)| *job_id).collect();
7642        let run_leases: Vec<i64> = jobs.iter().map(|(_, run_lease)| *run_lease).collect();
7643        let updated: i64 = sqlx::query_scalar(&format!(
7644            r#"
7645            WITH inflight(job_id, run_lease) AS (
7646                SELECT * FROM unnest($1::bigint[], $2::bigint[])
7647            ),
7648            claim_refs AS (
7649                -- Source open-claim identity from lease_claims
7650                -- anti-joined against lease_claim_closures.
7651                SELECT claims.job_id, claims.run_lease
7652                FROM {schema}.lease_claims AS claims
7653                JOIN inflight
7654                  ON inflight.job_id = claims.job_id
7655                 AND inflight.run_lease = claims.run_lease
7656                WHERE NOT EXISTS (
7657                    SELECT 1 FROM {schema}.lease_claim_closures AS closures
7658                    WHERE closures.claim_slot = claims.claim_slot
7659                      AND closures.job_id = claims.job_id
7660                      AND closures.run_lease = claims.run_lease
7661                )
7662                FOR UPDATE OF claims
7663            ),
7664            upserted AS (
7665                INSERT INTO {schema}.attempt_state (job_id, run_lease, heartbeat_at, updated_at)
7666                SELECT claim_refs.job_id, claim_refs.run_lease, clock_timestamp(), clock_timestamp()
7667                FROM claim_refs
7668                ON CONFLICT (job_id, run_lease)
7669                DO UPDATE SET
7670                    heartbeat_at = clock_timestamp(),
7671                    updated_at = clock_timestamp()
7672                RETURNING job_id, run_lease
7673            ),
7674            marked AS (
7675                UPDATE {schema}.lease_claims AS claims
7676                SET materialized_at = COALESCE(claims.materialized_at, clock_timestamp())
7677                FROM claim_refs
7678                WHERE claims.job_id = claim_refs.job_id
7679                  AND claims.run_lease = claim_refs.run_lease
7680                RETURNING claims.job_id
7681            )
7682            SELECT count(*)::bigint FROM upserted
7683            "#
7684        ))
7685        .bind(&job_ids)
7686        .bind(&run_leases)
7687        .fetch_one(tx.as_mut())
7688        .await
7689        .map_err(map_sqlx_error)?;
7690        Ok(updated as usize)
7691    }
7692
7693    async fn upsert_attempt_state_progress_from_receipts_tx<'a>(
7694        &self,
7695        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7696        jobs: &[(i64, i64, serde_json::Value)],
7697    ) -> Result<usize, AwaError> {
7698        if jobs.is_empty() {
7699            return Ok(0);
7700        }
7701
7702        let schema = self.schema();
7703        let job_ids: Vec<i64> = jobs.iter().map(|(job_id, _, _)| *job_id).collect();
7704        let run_leases: Vec<i64> = jobs.iter().map(|(_, run_lease, _)| *run_lease).collect();
7705        let progress: Vec<serde_json::Value> = jobs
7706            .iter()
7707            .map(|(_, _, progress)| progress.clone())
7708            .collect();
7709        let updated: i64 = sqlx::query_scalar(&format!(
7710            r#"
7711            WITH inflight(job_id, run_lease, progress) AS (
7712                SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::jsonb[])
7713            ),
7714            claim_refs AS (
7715                -- Same anti-join pattern as the heartbeat-only path
7716                -- above.
7717                SELECT claims.job_id, claims.run_lease, inflight.progress
7718                FROM {schema}.lease_claims AS claims
7719                JOIN inflight
7720                  ON inflight.job_id = claims.job_id
7721                 AND inflight.run_lease = claims.run_lease
7722                WHERE NOT EXISTS (
7723                    SELECT 1 FROM {schema}.lease_claim_closures AS closures
7724                    WHERE closures.claim_slot = claims.claim_slot
7725                      AND closures.job_id = claims.job_id
7726                      AND closures.run_lease = claims.run_lease
7727                )
7728                FOR UPDATE OF claims
7729            ),
7730            upserted AS (
7731                INSERT INTO {schema}.attempt_state (
7732                    job_id,
7733                    run_lease,
7734                    heartbeat_at,
7735                    progress,
7736                    updated_at
7737                )
7738                SELECT
7739                    claim_refs.job_id,
7740                    claim_refs.run_lease,
7741                    clock_timestamp(),
7742                    claim_refs.progress,
7743                    clock_timestamp()
7744                FROM claim_refs
7745                ON CONFLICT (job_id, run_lease)
7746                DO UPDATE SET
7747                    heartbeat_at = clock_timestamp(),
7748                    progress = EXCLUDED.progress,
7749                    updated_at = clock_timestamp()
7750                RETURNING job_id, run_lease
7751            ),
7752            marked AS (
7753                UPDATE {schema}.lease_claims AS claims
7754                SET materialized_at = COALESCE(claims.materialized_at, clock_timestamp())
7755                FROM claim_refs
7756                WHERE claims.job_id = claim_refs.job_id
7757                  AND claims.run_lease = claim_refs.run_lease
7758                RETURNING claims.job_id
7759            )
7760            SELECT count(*)::bigint FROM upserted
7761            "#
7762        ))
7763        .bind(&job_ids)
7764        .bind(&run_leases)
7765        .bind(&progress)
7766        .fetch_one(tx.as_mut())
7767        .await
7768        .map_err(map_sqlx_error)?;
7769        Ok(updated as usize)
7770    }
7771
7772    async fn hydrate_deleted_leases_tx<'a>(
7773        &self,
7774        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7775        deleted: Vec<DeletedLeaseRow>,
7776    ) -> Result<Vec<LeaseTransitionRow>, AwaError> {
7777        if deleted.is_empty() {
7778            return Ok(Vec::new());
7779        }
7780
7781        let schema = self.schema();
7782        let ready_slots: Vec<i32> = deleted.iter().map(|row| row.ready_slot).collect();
7783        let ready_generations: Vec<i64> = deleted.iter().map(|row| row.ready_generation).collect();
7784        let queues: Vec<String> = deleted.iter().map(|row| row.queue.clone()).collect();
7785        let priorities: Vec<i16> = deleted.iter().map(|row| row.priority).collect();
7786        let enqueue_shards: Vec<i16> = deleted.iter().map(|row| row.enqueue_shard).collect();
7787        let lane_seqs: Vec<i64> = deleted.iter().map(|row| row.lane_seq).collect();
7788        let job_ids: Vec<i64> = deleted.iter().map(|row| row.job_id).collect();
7789        let run_leases: Vec<i64> = deleted.iter().map(|row| row.run_lease).collect();
7790
7791        let ready_rows: Vec<ReadySnapshotRow> = sqlx::query_as(&format!(
7792            r#"
7793            WITH refs(ready_slot, ready_generation, queue, priority, enqueue_shard, lane_seq) AS (
7794                SELECT * FROM unnest($1::int[], $2::bigint[], $3::text[], $4::smallint[], $5::smallint[], $6::bigint[])
7795            )
7796            SELECT
7797                ready.ready_slot,
7798                ready.ready_generation,
7799                ready.job_id,
7800                ready.kind,
7801                ready.queue,
7802                ready.args,
7803                ready.priority,
7804                ready.lane_seq,
7805                ready.enqueue_shard,
7806                ready.run_at,
7807                ready.created_at,
7808                ready.unique_key,
7809                ready.unique_states,
7810                COALESCE(ready.payload, '{{}}'::jsonb) AS payload
7811            FROM refs
7812            JOIN {schema}.ready_entries AS ready
7813              ON ready.ready_slot = refs.ready_slot
7814             AND ready.ready_generation = refs.ready_generation
7815             AND ready.queue = refs.queue
7816             AND ready.priority = refs.priority
7817             AND ready.enqueue_shard = refs.enqueue_shard
7818             AND ready.lane_seq = refs.lane_seq
7819            "#
7820        ))
7821        .bind(&ready_slots)
7822        .bind(&ready_generations)
7823        .bind(&queues)
7824        .bind(&priorities)
7825        .bind(&enqueue_shards)
7826        .bind(&lane_seqs)
7827        .fetch_all(tx.as_mut())
7828        .await
7829        .map_err(map_sqlx_error)?;
7830
7831        let attempt_rows: Vec<AttemptStateRow> = sqlx::query_as(&format!(
7832            r#"
7833            WITH refs(job_id, run_lease) AS (
7834                SELECT * FROM unnest($1::bigint[], $2::bigint[])
7835            )
7836            DELETE FROM {schema}.attempt_state AS attempt
7837            USING refs
7838            WHERE attempt.job_id = refs.job_id
7839              AND attempt.run_lease = refs.run_lease
7840            RETURNING
7841                attempt.job_id,
7842                attempt.run_lease,
7843                attempt.progress,
7844                attempt.callback_filter,
7845                attempt.callback_on_complete,
7846                attempt.callback_on_fail,
7847                attempt.callback_transform,
7848                attempt.callback_result
7849            "#
7850        ))
7851        .bind(&job_ids)
7852        .bind(&run_leases)
7853        .fetch_all(tx.as_mut())
7854        .await
7855        .map_err(map_sqlx_error)?;
7856
7857        // Hydrate runs as part of every rescue path that DELETE'd
7858        // a leases row (heartbeat / deadline / callback timeout
7859        // rescue, plus admin cancel of running attempts). For
7860        // receipt-backed attempts those leases came from
7861        // `ensure_running_leases_from_receipts_tx`, which leaves a
7862        // `lease_claims` row behind with `materialized_at` set. The
7863        // rescue itself closes the lease but never wrote a closure
7864        // for the original receipt, so the claim sat "open" until
7865        // partition prune — `load_job` and any
7866        // `lease_claims`-aware count then double-counted the
7867        // attempt as `running` even after it had moved to
7868        // retryable / failed / completed. Write the closure here so
7869        // the receipt plane mirrors the lease plane: when the lease
7870        // is gone, the receipt is gone too.
7871        sqlx::query(&format!(
7872            r#"
7873            WITH refs(job_id, run_lease) AS (
7874                SELECT * FROM unnest($1::bigint[], $2::bigint[])
7875            )
7876            INSERT INTO {schema}.lease_claim_closures
7877                (claim_slot, job_id, run_lease, outcome, closed_at)
7878            SELECT claims.claim_slot, claims.job_id, claims.run_lease,
7879                   'rescue', clock_timestamp()
7880            FROM {schema}.lease_claims AS claims
7881            JOIN refs
7882              ON refs.job_id = claims.job_id
7883             AND refs.run_lease = claims.run_lease
7884            ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
7885            "#
7886        ))
7887        .bind(&job_ids)
7888        .bind(&run_leases)
7889        .execute(tx.as_mut())
7890        .await
7891        .map_err(map_sqlx_error)?;
7892
7893        let ready_map: BTreeMap<(i32, i64, String, i16, i16, i64), ReadySnapshotRow> = ready_rows
7894            .into_iter()
7895            .map(|row| {
7896                (
7897                    (
7898                        row.ready_slot,
7899                        row.ready_generation,
7900                        row.queue.clone(),
7901                        row.priority,
7902                        row.enqueue_shard,
7903                        row.lane_seq,
7904                    ),
7905                    row,
7906                )
7907            })
7908            .collect();
7909
7910        let attempt_map: BTreeMap<(i64, i64), AttemptStateRow> = attempt_rows
7911            .into_iter()
7912            .map(|row| ((row.job_id, row.run_lease), row))
7913            .collect();
7914
7915        let mut hydrated = Vec::with_capacity(deleted.len());
7916        for deleted_row in deleted {
7917            let ready = ready_map
7918                .get(&(
7919                    deleted_row.ready_slot,
7920                    deleted_row.ready_generation,
7921                    deleted_row.queue.clone(),
7922                    deleted_row.priority,
7923                    deleted_row.enqueue_shard,
7924                    deleted_row.lane_seq,
7925                ))
7926                .ok_or_else(|| {
7927                    AwaError::Validation(format!(
7928                        "queue storage ready row missing for deleted lease job {} run_lease {}",
7929                        deleted_row.job_id, deleted_row.run_lease
7930                    ))
7931                })?;
7932            let attempt = attempt_map.get(&(deleted_row.job_id, deleted_row.run_lease));
7933
7934            hydrated.push(LeaseTransitionRow {
7935                ready_slot: deleted_row.ready_slot,
7936                ready_generation: deleted_row.ready_generation,
7937                job_id: deleted_row.job_id,
7938                kind: ready.kind.clone(),
7939                queue: ready.queue.clone(),
7940                args: ready.args.clone(),
7941                state: deleted_row.state,
7942                priority: deleted_row.priority,
7943                attempt: deleted_row.attempt,
7944                run_lease: deleted_row.run_lease,
7945                max_attempts: deleted_row.max_attempts,
7946                lane_seq: deleted_row.lane_seq,
7947                enqueue_shard: deleted_row.enqueue_shard,
7948                run_at: ready.run_at,
7949                attempted_at: deleted_row.attempted_at,
7950                created_at: ready.created_at,
7951                unique_key: ready.unique_key.clone(),
7952                unique_states: ready.unique_states.clone(),
7953                payload: ready.payload.clone(),
7954                progress: attempt.and_then(|row| row.progress.clone()),
7955            });
7956        }
7957
7958        Ok(hydrated)
7959    }
7960
7961    async fn close_open_receipt_claim_tx<'a>(
7962        &self,
7963        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
7964        job_id: i64,
7965        run_lease: i64,
7966        outcome: &str,
7967    ) -> Result<Option<LeaseTransitionRow>, AwaError> {
7968        if !self.lease_claim_receipts() {
7969            return Ok(None);
7970        }
7971
7972        let schema = self.schema();
7973        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
7974            r#"
7975            WITH target AS (
7976                -- Target is the open claim identified from the
7977                -- partitioned lease_claims table anti-joined against
7978                -- lease_claim_closures.
7979                SELECT
7980                    claims.claim_slot,
7981                    claims.ready_slot,
7982                    claims.ready_generation,
7983                    claims.job_id,
7984                    claims.queue,
7985                    'running'::awa.job_state AS state,
7986                    claims.priority,
7987                    claims.attempt,
7988                    claims.run_lease,
7989                    claims.max_attempts,
7990                    claims.lane_seq,
7991                    claims.enqueue_shard,
7992                    claims.claimed_at AS attempted_at
7993                FROM {schema}.lease_claims AS claims
7994                WHERE claims.job_id = $1
7995                  AND claims.run_lease = $2
7996                  AND NOT EXISTS (
7997                      SELECT 1 FROM {schema}.lease_claim_closures AS closures
7998                      WHERE closures.claim_slot = claims.claim_slot
7999                        AND closures.job_id = claims.job_id
8000                        AND closures.run_lease = claims.run_lease
8001                  )
8002                FOR UPDATE OF claims
8003            ),
8004            inserted AS (
8005                INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
8006                SELECT target.claim_slot, target.job_id, target.run_lease, $3, clock_timestamp()
8007                FROM target
8008                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
8009                RETURNING job_id, run_lease
8010            )
8011            SELECT
8012                target.ready_slot,
8013                target.ready_generation,
8014                target.job_id,
8015                target.queue,
8016                target.state,
8017                target.priority,
8018                target.attempt,
8019                target.run_lease,
8020                target.max_attempts,
8021                target.lane_seq,
8022                target.enqueue_shard,
8023                NULL::timestamptz AS heartbeat_at,
8024                NULL::timestamptz AS deadline_at,
8025                target.attempted_at,
8026                NULL::uuid AS callback_id,
8027                NULL::timestamptz AS callback_timeout_at
8028            FROM target
8029            JOIN inserted
8030              ON inserted.job_id = target.job_id
8031             AND inserted.run_lease = target.run_lease
8032            "#
8033        ))
8034        .bind(job_id)
8035        .bind(run_lease)
8036        .bind(outcome)
8037        .fetch_all(tx.as_mut())
8038        .await
8039        .map_err(map_sqlx_error)?;
8040
8041        if deleted.is_empty() {
8042            return Ok(None);
8043        }
8044
8045        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
8046        Ok(moved.into_iter().next())
8047    }
8048
8049    async fn take_running_attempt_tx<'a>(
8050        &self,
8051        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
8052        job_id: i64,
8053        run_lease: i64,
8054        receipt_outcome: &str,
8055    ) -> Result<Option<LeaseTransitionRow>, AwaError> {
8056        if let Some(moved) = self
8057            .close_open_receipt_claim_tx(tx, job_id, run_lease, receipt_outcome)
8058            .await?
8059        {
8060            return Ok(Some(moved));
8061        }
8062
8063        let schema = self.schema();
8064        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
8065            r#"
8066            DELETE FROM {schema}.leases
8067            WHERE job_id = $1
8068              AND run_lease = $2
8069              AND state = 'running'
8070            RETURNING
8071                ready_slot,
8072                ready_generation,
8073                job_id,
8074                queue,
8075                state,
8076                priority,
8077                attempt,
8078                run_lease,
8079                max_attempts,
8080                lane_seq,
8081                enqueue_shard,
8082                heartbeat_at,
8083                deadline_at,
8084                attempted_at,
8085                callback_id,
8086                callback_timeout_at
8087            "#
8088        ))
8089        .bind(job_id)
8090        .bind(run_lease)
8091        .fetch_all(tx.as_mut())
8092        .await
8093        .map_err(map_sqlx_error)?;
8094
8095        if deleted.is_empty() {
8096            return Ok(None);
8097        }
8098
8099        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
8100        Ok(moved.into_iter().next())
8101    }
8102
8103    async fn rescue_stale_receipt_claims_tx<'a>(
8104        &self,
8105        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
8106        cutoff: DateTime<Utc>,
8107    ) -> Result<Vec<DeletedLeaseRow>, AwaError> {
8108        let schema = self.schema();
8109        let rescued: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
8110            r#"
8111            WITH stale_claims AS (
8112                -- Rescue scans partitioned lease_claims anti-joined
8113                -- with lease_claim_closures.
8114                SELECT
8115                    claims.claim_slot,
8116                    claims.ready_slot,
8117                    claims.ready_generation,
8118                    claims.job_id,
8119                    claims.queue,
8120                    'running'::awa.job_state AS state,
8121                    claims.priority,
8122                    claims.attempt,
8123                    claims.run_lease,
8124                    claims.max_attempts,
8125                    claims.lane_seq,
8126                    claims.enqueue_shard,
8127                    claims.claimed_at AS attempted_at
8128                FROM {schema}.lease_claims AS claims
8129                LEFT JOIN {schema}.attempt_state AS attempt
8130                  ON attempt.job_id = claims.job_id
8131                 AND attempt.run_lease = claims.run_lease
8132                WHERE COALESCE(attempt.heartbeat_at, claims.claimed_at) < $1
8133                  AND NOT EXISTS (
8134                      SELECT 1 FROM {schema}.lease_claim_closures AS closures
8135                      WHERE closures.claim_slot = claims.claim_slot
8136                        AND closures.job_id = claims.job_id
8137                        AND closures.run_lease = claims.run_lease
8138                  )
8139                  -- A claim that already materialized into `leases` is
8140                  -- on the lease-side heartbeat-rescue path (see
8141                  -- `rescue_stale_heartbeats`). Rescuing it again here
8142                  -- would write a second closure for an attempt the
8143                  -- runtime is still tracking via its lease row, and on
8144                  -- commit produce a double-failure transition. Mirror
8145                  -- the same anti-join `load_job` uses to disambiguate.
8146                  AND NOT EXISTS (
8147                      SELECT 1 FROM {schema}.leases AS lease
8148                      WHERE lease.job_id = claims.job_id
8149                        AND lease.run_lease = claims.run_lease
8150                  )
8151                ORDER BY COALESCE(attempt.heartbeat_at, claims.claimed_at) ASC
8152                LIMIT 500
8153                FOR UPDATE OF claims SKIP LOCKED
8154            ),
8155            inserted AS (
8156                INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
8157                SELECT stale_claims.claim_slot, stale_claims.job_id, stale_claims.run_lease, 'rescued', clock_timestamp()
8158                FROM stale_claims
8159                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
8160                RETURNING job_id, run_lease
8161            )
8162            SELECT
8163                stale_claims.ready_slot,
8164                stale_claims.ready_generation,
8165                stale_claims.job_id,
8166                stale_claims.queue,
8167                stale_claims.state,
8168                stale_claims.priority,
8169                stale_claims.attempt,
8170                stale_claims.run_lease,
8171                stale_claims.max_attempts,
8172                stale_claims.lane_seq,
8173                stale_claims.enqueue_shard,
8174                stale_claims.attempted_at
8175            FROM stale_claims
8176            JOIN inserted
8177              ON inserted.job_id = stale_claims.job_id
8178             AND inserted.run_lease = stale_claims.run_lease
8179            "#
8180        ))
8181        .bind(cutoff)
8182        .fetch_all(tx.as_mut())
8183        .await
8184        .map_err(map_sqlx_error)?;
8185        Ok(rescued)
8186    }
8187
8188    /// Receipt-side counterpart to `rescue_expired_deadlines`: scans
8189    /// `lease_claims` for rows whose per-claim `deadline_at` has
8190    /// passed but which still don't have a closure or a materialized
8191    /// lease row. Each match gets a `'deadline_expired'` closure
8192    /// written and is returned for the maintenance caller to convert
8193    /// into a deferred / DLQ row, exactly as the lease-side path does.
8194    ///
8195    /// The two anti-joins mirror `rescue_stale_receipt_claims_tx`'s
8196    /// disambiguation: a claim that has already materialized into
8197    /// `leases` is on the lease-side deadline-rescue path, and
8198    /// rescuing it here would double-close it.
8199    async fn rescue_expired_receipt_deadlines_tx<'a>(
8200        &self,
8201        tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
8202    ) -> Result<Vec<DeletedLeaseRow>, AwaError> {
8203        let schema = self.schema();
8204        let rescued: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
8205            r#"
8206            WITH expired_claims AS (
8207                SELECT
8208                    claims.claim_slot,
8209                    claims.ready_slot,
8210                    claims.ready_generation,
8211                    claims.job_id,
8212                    claims.queue,
8213                    'running'::awa.job_state AS state,
8214                    claims.priority,
8215                    claims.attempt,
8216                    claims.run_lease,
8217                    claims.max_attempts,
8218                    claims.lane_seq,
8219                    claims.enqueue_shard,
8220                    claims.claimed_at AS attempted_at
8221                FROM {schema}.lease_claims AS claims
8222                WHERE claims.deadline_at IS NOT NULL
8223                  AND claims.deadline_at < clock_timestamp()
8224                  AND NOT EXISTS (
8225                      SELECT 1 FROM {schema}.lease_claim_closures AS closures
8226                      WHERE closures.claim_slot = claims.claim_slot
8227                        AND closures.job_id = claims.job_id
8228                        AND closures.run_lease = claims.run_lease
8229                  )
8230                  AND NOT EXISTS (
8231                      SELECT 1 FROM {schema}.leases AS lease
8232                      WHERE lease.job_id = claims.job_id
8233                        AND lease.run_lease = claims.run_lease
8234                  )
8235                ORDER BY claims.deadline_at ASC
8236                LIMIT 500
8237                FOR UPDATE OF claims SKIP LOCKED
8238            ),
8239            inserted AS (
8240                INSERT INTO {schema}.lease_claim_closures (claim_slot, job_id, run_lease, outcome, closed_at)
8241                SELECT
8242                    expired_claims.claim_slot,
8243                    expired_claims.job_id,
8244                    expired_claims.run_lease,
8245                    'deadline_expired',
8246                    clock_timestamp()
8247                FROM expired_claims
8248                ON CONFLICT (claim_slot, job_id, run_lease) DO NOTHING
8249                RETURNING job_id, run_lease
8250            )
8251            SELECT
8252                expired_claims.ready_slot,
8253                expired_claims.ready_generation,
8254                expired_claims.job_id,
8255                expired_claims.queue,
8256                expired_claims.state,
8257                expired_claims.priority,
8258                expired_claims.attempt,
8259                expired_claims.run_lease,
8260                expired_claims.max_attempts,
8261                expired_claims.lane_seq,
8262                expired_claims.enqueue_shard,
8263                expired_claims.attempted_at
8264            FROM expired_claims
8265            JOIN inserted
8266              ON inserted.job_id = expired_claims.job_id
8267             AND inserted.run_lease = expired_claims.run_lease
8268            "#
8269        ))
8270        .fetch_all(tx.as_mut())
8271        .await
8272        .map_err(map_sqlx_error)?;
8273        Ok(rescued)
8274    }
8275
8276    pub async fn load_job(&self, pool: &PgPool, job_id: i64) -> Result<Option<JobRow>, AwaError> {
8277        let schema = self.schema();
8278        let mut candidates = Vec::new();
8279
8280        let ready_rows: Vec<ReadyJobRow> = sqlx::query_as(&format!(
8281            r#"
8282            SELECT
8283                job_id,
8284                kind,
8285                queue,
8286                args,
8287                priority,
8288                attempt,
8289                run_lease,
8290                max_attempts,
8291                run_at,
8292                attempted_at,
8293                created_at,
8294                unique_key,
8295                unique_states,
8296                COALESCE(payload, '{{}}'::jsonb) AS payload
8297            FROM {schema}.ready_entries
8298            WHERE job_id = $1
8299            ORDER BY run_lease DESC, attempted_at DESC NULLS LAST, run_at DESC
8300            "#,
8301        ))
8302        .bind(job_id)
8303        .fetch_all(pool)
8304        .await
8305        .map_err(map_sqlx_error)?;
8306        for row in ready_rows {
8307            candidates.push(row.into_job_row()?);
8308        }
8309
8310        let deferred_rows: Vec<DeferredJobRow> = sqlx::query_as(&format!(
8311            r#"
8312            SELECT
8313                job_id,
8314                kind,
8315                queue,
8316                args,
8317                state,
8318                priority,
8319                attempt,
8320                run_lease,
8321                max_attempts,
8322                run_at,
8323                attempted_at,
8324                finalized_at,
8325                created_at,
8326                unique_key,
8327                unique_states,
8328                COALESCE(payload, '{{}}'::jsonb) AS payload
8329            FROM {schema}.deferred_jobs
8330            WHERE job_id = $1
8331            "#,
8332        ))
8333        .bind(job_id)
8334        .fetch_all(pool)
8335        .await
8336        .map_err(map_sqlx_error)?;
8337        for row in deferred_rows {
8338            candidates.push(row.into_job_row()?);
8339        }
8340
8341        let lease_rows: Vec<LeaseJobRow> = sqlx::query_as(&format!(
8342            r#"
8343            SELECT
8344                lease.ready_slot,
8345                lease.ready_generation,
8346                lease.job_id,
8347                ready.kind,
8348                ready.queue,
8349                ready.args,
8350                lease.state,
8351                lease.priority,
8352                lease.attempt,
8353                lease.run_lease,
8354                lease.max_attempts,
8355                lease.lane_seq,
8356                ready.run_at,
8357                COALESCE(attempt.heartbeat_at, lease.heartbeat_at) AS heartbeat_at,
8358                lease.deadline_at,
8359                lease.attempted_at,
8360                NULL::timestamptz AS finalized_at,
8361                ready.created_at,
8362                ready.unique_key,
8363                ready.unique_states,
8364                lease.callback_id,
8365                lease.callback_timeout_at,
8366                attempt.callback_filter,
8367                attempt.callback_on_complete,
8368                attempt.callback_on_fail,
8369                attempt.callback_transform,
8370                COALESCE(ready.payload, '{{}}'::jsonb) AS payload,
8371                attempt.progress,
8372                attempt.callback_result
8373            FROM {schema}.leases AS lease
8374            JOIN {schema}.ready_entries AS ready
8375              ON ready.ready_slot = lease.ready_slot
8376             AND ready.ready_generation = lease.ready_generation
8377             AND ready.queue = lease.queue
8378             AND ready.priority = lease.priority
8379             AND ready.enqueue_shard = lease.enqueue_shard
8380             AND ready.lane_seq = lease.lane_seq
8381            LEFT JOIN {schema}.attempt_state AS attempt
8382              ON attempt.job_id = lease.job_id
8383             AND attempt.run_lease = lease.run_lease
8384            WHERE lease.job_id = $1
8385            ORDER BY lease.run_lease DESC
8386            "#,
8387        ))
8388        .bind(job_id)
8389        .fetch_all(pool)
8390        .await
8391        .map_err(map_sqlx_error)?;
8392        for row in lease_rows {
8393            candidates.push(row.into_job_row()?);
8394        }
8395
8396        // Report receipt-backed attempts as running by anti-joining
8397        // lease_claims against lease_claim_closures.
8398        let lease_claim_rows: Vec<LeaseJobRow> = sqlx::query_as(&format!(
8399            r#"
8400            SELECT
8401                claims.ready_slot,
8402                claims.ready_generation,
8403                claims.job_id,
8404                ready.kind,
8405                ready.queue,
8406                ready.args,
8407                'running'::awa.job_state AS state,
8408                claims.priority,
8409                claims.attempt,
8410                claims.run_lease,
8411                claims.max_attempts,
8412                claims.lane_seq,
8413                ready.run_at,
8414                attempt.heartbeat_at,
8415                claims.deadline_at,
8416                claims.claimed_at AS attempted_at,
8417                NULL::timestamptz AS finalized_at,
8418                ready.created_at,
8419                ready.unique_key,
8420                ready.unique_states,
8421                NULL::uuid AS callback_id,
8422                NULL::timestamptz AS callback_timeout_at,
8423                attempt.callback_filter,
8424                attempt.callback_on_complete,
8425                attempt.callback_on_fail,
8426                attempt.callback_transform,
8427                COALESCE(ready.payload, '{{}}'::jsonb) AS payload,
8428                attempt.progress,
8429                attempt.callback_result
8430            FROM {schema}.lease_claims AS claims
8431            JOIN {schema}.ready_entries AS ready
8432              ON ready.ready_slot = claims.ready_slot
8433             AND ready.ready_generation = claims.ready_generation
8434             AND ready.queue = claims.queue
8435             AND ready.priority = claims.priority
8436             AND ready.enqueue_shard = claims.enqueue_shard
8437             AND ready.lane_seq = claims.lane_seq
8438            LEFT JOIN {schema}.attempt_state AS attempt
8439              ON attempt.job_id = claims.job_id
8440             AND attempt.run_lease = claims.run_lease
8441            WHERE claims.job_id = $1
8442              AND NOT EXISTS (
8443                  SELECT 1 FROM {schema}.lease_claim_closures AS closures
8444                  WHERE closures.claim_slot = claims.claim_slot
8445                    AND closures.job_id = claims.job_id
8446                    AND closures.run_lease = claims.run_lease
8447              )
8448              -- Exclude claims that have already been materialized into
8449              -- leases — the lease-backed branch above already reports
8450              -- those.
8451              AND NOT EXISTS (
8452                  SELECT 1 FROM {schema}.leases AS lease
8453                  WHERE lease.job_id = claims.job_id
8454                    AND lease.run_lease = claims.run_lease
8455              )
8456              -- Exclude claims whose attempt has already been moved to
8457              -- a non-running disposition. Rescue paths (callback
8458              -- timeout, deadline, heartbeat) DELETE the materialised
8459              -- lease and INSERT into `deferred_jobs` / `done_entries`
8460              -- / `dlq_entries`, but they don't always write a
8461              -- closure to `lease_claim_closures` — so the original
8462              -- `lease_claims` row sits "open" until partition prune.
8463              -- Without this guard, `load_job` returns the stale
8464              -- 'running' projection and masks the actual retryable /
8465              -- failed / completed state of the same attempt.
8466              AND NOT EXISTS (
8467                  SELECT 1 FROM {schema}.deferred_jobs AS deferred
8468                  WHERE deferred.job_id = claims.job_id
8469                    AND deferred.run_lease = claims.run_lease
8470              )
8471              AND NOT EXISTS (
8472                  SELECT 1 FROM {schema}.done_entries AS done
8473                  WHERE done.job_id = claims.job_id
8474                    AND done.run_lease = claims.run_lease
8475              )
8476              AND NOT EXISTS (
8477                  SELECT 1 FROM {schema}.dlq_entries AS dlq
8478                  WHERE dlq.job_id = claims.job_id
8479                    AND dlq.run_lease = claims.run_lease
8480              )
8481            ORDER BY claims.run_lease DESC
8482            "#,
8483        ))
8484        .bind(job_id)
8485        .fetch_all(pool)
8486        .await
8487        .map_err(map_sqlx_error)?;
8488        for row in lease_claim_rows {
8489            candidates.push(row.into_job_row()?);
8490        }
8491
8492        let done_rows: Vec<DoneJobRow> = sqlx::query_as(&format!(
8493            r#"
8494            SELECT
8495                ready_slot,
8496                ready_generation,
8497                job_id,
8498                kind,
8499                queue,
8500                args,
8501                state,
8502                priority,
8503                attempt,
8504                run_lease,
8505                max_attempts,
8506                lane_seq,
8507                enqueue_shard,
8508                run_at,
8509                attempted_at,
8510                finalized_at,
8511                created_at,
8512                unique_key,
8513                unique_states,
8514                payload
8515            FROM {schema}.terminal_jobs AS done
8516            WHERE done.job_id = $1
8517            ORDER BY done.run_lease DESC, done.finalized_at DESC
8518            "#,
8519        ))
8520        .bind(job_id)
8521        .fetch_all(pool)
8522        .await
8523        .map_err(map_sqlx_error)?;
8524        for row in done_rows {
8525            candidates.push(row.into_job_row()?);
8526        }
8527
8528        let dlq_rows: Vec<DlqJobRow> = sqlx::query_as(&format!(
8529            r#"
8530            SELECT
8531                job_id,
8532                kind,
8533                queue,
8534                args,
8535                state,
8536                priority,
8537                attempt,
8538                run_lease,
8539                max_attempts,
8540                run_at,
8541                attempted_at,
8542                finalized_at,
8543                created_at,
8544                unique_key,
8545                unique_states,
8546                COALESCE(payload, '{{}}'::jsonb) AS payload,
8547                dlq_reason,
8548                dlq_at,
8549                original_run_lease
8550            FROM {schema}.dlq_entries
8551            WHERE job_id = $1
8552            ORDER BY dlq_at DESC
8553            "#,
8554        ))
8555        .bind(job_id)
8556        .fetch_all(pool)
8557        .await
8558        .map_err(map_sqlx_error)?;
8559        for row in dlq_rows {
8560            candidates.push(row.into_job_row()?);
8561        }
8562
8563        Ok(candidates.into_iter().max_by_key(|job| {
8564            (
8565                job.run_lease,
8566                transition_timestamp(job),
8567                state_rank(job.state),
8568            )
8569        }))
8570    }
8571
8572    pub async fn register_callback(
8573        &self,
8574        pool: &PgPool,
8575        job_id: i64,
8576        run_lease: i64,
8577        timeout: Duration,
8578    ) -> Result<Uuid, AwaError> {
8579        let callback_id = Uuid::new_v4();
8580        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
8581        self.ensure_mutable_running_attempt_tx(&mut tx, job_id, run_lease)
8582            .await?;
8583        let updated = sqlx::query(&format!(
8584            r#"
8585            UPDATE {}
8586            SET callback_id = $2,
8587                callback_timeout_at = clock_timestamp() + make_interval(secs => $3)
8588            WHERE job_id = $1
8589              AND state = 'running'
8590              AND run_lease = $4
8591            "#,
8592            self.leases_table()
8593        ))
8594        .bind(job_id)
8595        .bind(callback_id)
8596        .bind(timeout.as_secs_f64())
8597        .bind(run_lease)
8598        .execute(tx.as_mut())
8599        .await
8600        .map_err(map_sqlx_error)?;
8601
8602        if updated.rows_affected() == 0 {
8603            tx.rollback().await.map_err(map_sqlx_error)?;
8604            return Err(AwaError::Validation("job is not in running state".into()));
8605        }
8606
8607        sqlx::query(&format!(
8608            r#"
8609            UPDATE {}
8610            SET callback_filter = NULL,
8611                callback_on_complete = NULL,
8612                callback_on_fail = NULL,
8613                callback_transform = NULL,
8614                updated_at = clock_timestamp()
8615            WHERE job_id = $1
8616              AND run_lease = $2
8617            "#,
8618            self.attempt_state_table()
8619        ))
8620        .bind(job_id)
8621        .bind(run_lease)
8622        .execute(tx.as_mut())
8623        .await
8624        .map_err(map_sqlx_error)?;
8625
8626        sqlx::query(&format!(
8627            r#"
8628            DELETE FROM {}
8629            WHERE job_id = $1
8630              AND run_lease = $2
8631              AND progress IS NULL
8632              AND callback_result IS NULL
8633              AND callback_filter IS NULL
8634              AND callback_on_complete IS NULL
8635              AND callback_on_fail IS NULL
8636              AND callback_transform IS NULL
8637            "#,
8638            self.attempt_state_table()
8639        ))
8640        .bind(job_id)
8641        .bind(run_lease)
8642        .execute(tx.as_mut())
8643        .await
8644        .map_err(map_sqlx_error)?;
8645
8646        tx.commit().await.map_err(map_sqlx_error)?;
8647        Ok(callback_id)
8648    }
8649
8650    pub async fn register_callback_with_config(
8651        &self,
8652        pool: &PgPool,
8653        job_id: i64,
8654        run_lease: i64,
8655        timeout: Duration,
8656        config: &CallbackConfig,
8657    ) -> Result<Uuid, AwaError> {
8658        if config.is_empty() {
8659            return self
8660                .register_callback(pool, job_id, run_lease, timeout)
8661                .await;
8662        }
8663
8664        #[cfg(feature = "cel")]
8665        {
8666            for (name, expr) in [
8667                ("filter", &config.filter),
8668                ("on_complete", &config.on_complete),
8669                ("on_fail", &config.on_fail),
8670                ("transform", &config.transform),
8671            ] {
8672                if let Some(src) = expr {
8673                    let program = cel::Program::compile(src).map_err(|e| {
8674                        AwaError::Validation(format!("invalid CEL expression for {name}: {e}"))
8675                    })?;
8676                    let references = program.references();
8677                    let bad_vars: Vec<String> = references
8678                        .variables()
8679                        .into_iter()
8680                        .filter(|v| *v != "payload")
8681                        .map(str::to_string)
8682                        .collect();
8683                    if !bad_vars.is_empty() {
8684                        return Err(AwaError::Validation(format!(
8685                            "CEL expression for {name} references undeclared variable(s): {}; only 'payload' is available",
8686                            bad_vars.join(", ")
8687                        )));
8688                    }
8689                }
8690            }
8691        }
8692
8693        #[cfg(not(feature = "cel"))]
8694        {
8695            if !config.is_empty() {
8696                return Err(AwaError::Validation(
8697                    "CEL expressions require the 'cel' feature".into(),
8698                ));
8699            }
8700        }
8701
8702        let callback_id = Uuid::new_v4();
8703        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
8704        self.ensure_mutable_running_attempt_tx(&mut tx, job_id, run_lease)
8705            .await?;
8706        let updated = sqlx::query(&format!(
8707            r#"
8708            UPDATE {}
8709            SET callback_id = $2,
8710                callback_timeout_at = clock_timestamp() + make_interval(secs => $3)
8711            WHERE job_id = $1
8712              AND state = 'running'
8713              AND run_lease = $4
8714            "#,
8715            self.leases_table()
8716        ))
8717        .bind(job_id)
8718        .bind(callback_id)
8719        .bind(timeout.as_secs_f64())
8720        .bind(run_lease)
8721        .execute(tx.as_mut())
8722        .await
8723        .map_err(map_sqlx_error)?;
8724
8725        if updated.rows_affected() == 0 {
8726            tx.rollback().await.map_err(map_sqlx_error)?;
8727            return Err(AwaError::Validation("job is not in running state".into()));
8728        }
8729
8730        sqlx::query(&format!(
8731            r#"
8732            INSERT INTO {} (
8733                job_id,
8734                run_lease,
8735                callback_filter,
8736                callback_on_complete,
8737                callback_on_fail,
8738                callback_transform,
8739                updated_at
8740            )
8741            VALUES ($1, $2, $3, $4, $5, $6, clock_timestamp())
8742            ON CONFLICT (job_id, run_lease)
8743            DO UPDATE SET
8744                callback_filter = EXCLUDED.callback_filter,
8745                callback_on_complete = EXCLUDED.callback_on_complete,
8746                callback_on_fail = EXCLUDED.callback_on_fail,
8747                callback_transform = EXCLUDED.callback_transform,
8748                updated_at = clock_timestamp()
8749            "#,
8750            self.attempt_state_table()
8751        ))
8752        .bind(job_id)
8753        .bind(run_lease)
8754        .bind(&config.filter)
8755        .bind(&config.on_complete)
8756        .bind(&config.on_fail)
8757        .bind(&config.transform)
8758        .execute(tx.as_mut())
8759        .await
8760        .map_err(map_sqlx_error)?;
8761
8762        tx.commit().await.map_err(map_sqlx_error)?;
8763        Ok(callback_id)
8764    }
8765
8766    pub async fn cancel_callback(
8767        &self,
8768        pool: &PgPool,
8769        job_id: i64,
8770        run_lease: i64,
8771    ) -> Result<bool, AwaError> {
8772        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
8773        let result = sqlx::query(&format!(
8774            r#"
8775            UPDATE {}
8776            SET callback_id = NULL,
8777                callback_timeout_at = NULL
8778            WHERE job_id = $1
8779              AND callback_id IS NOT NULL
8780              AND state = 'running'
8781              AND run_lease = $2
8782            "#,
8783            self.leases_table()
8784        ))
8785        .bind(job_id)
8786        .bind(run_lease)
8787        .execute(tx.as_mut())
8788        .await
8789        .map_err(map_sqlx_error)?;
8790        if result.rows_affected() == 0 {
8791            tx.rollback().await.map_err(map_sqlx_error)?;
8792            return Ok(false);
8793        }
8794
8795        sqlx::query(&format!(
8796            r#"
8797            UPDATE {}
8798            SET callback_filter = NULL,
8799                callback_on_complete = NULL,
8800                callback_on_fail = NULL,
8801                callback_transform = NULL,
8802                updated_at = clock_timestamp()
8803            WHERE job_id = $1
8804              AND run_lease = $2
8805            "#,
8806            self.attempt_state_table()
8807        ))
8808        .bind(job_id)
8809        .bind(run_lease)
8810        .execute(tx.as_mut())
8811        .await
8812        .map_err(map_sqlx_error)?;
8813
8814        sqlx::query(&format!(
8815            r#"
8816            DELETE FROM {}
8817            WHERE job_id = $1
8818              AND run_lease = $2
8819              AND progress IS NULL
8820              AND callback_result IS NULL
8821              AND callback_filter IS NULL
8822              AND callback_on_complete IS NULL
8823              AND callback_on_fail IS NULL
8824              AND callback_transform IS NULL
8825            "#,
8826            self.attempt_state_table()
8827        ))
8828        .bind(job_id)
8829        .bind(run_lease)
8830        .execute(tx.as_mut())
8831        .await
8832        .map_err(map_sqlx_error)?;
8833
8834        tx.commit().await.map_err(map_sqlx_error)?;
8835        Ok(true)
8836    }
8837
8838    /// Load the currently-active lease row for `job_id` (running or
8839    /// waiting_external) inside a caller-owned transaction. Used by ADR-029
8840    /// helpers that need the post-park snapshot — including the
8841    /// `callback_id` and `callback_timeout_at` written by
8842    /// `register_callback()` — without leaving the transaction that just
8843    /// performed the parking UPDATE.
8844    pub async fn load_active_lease_in_tx(
8845        &self,
8846        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
8847        job_id: i64,
8848        run_lease: i64,
8849    ) -> Result<Option<JobRow>, AwaError> {
8850        let schema = self.schema();
8851        let row: Option<LeaseJobRow> = sqlx::query_as(&format!(
8852            r#"
8853            SELECT
8854                lease.ready_slot,
8855                lease.ready_generation,
8856                lease.job_id,
8857                ready.kind,
8858                ready.queue,
8859                ready.args,
8860                lease.state,
8861                lease.priority,
8862                lease.attempt,
8863                lease.run_lease,
8864                lease.max_attempts,
8865                lease.lane_seq,
8866                ready.run_at,
8867                COALESCE(attempt.heartbeat_at, lease.heartbeat_at) AS heartbeat_at,
8868                lease.deadline_at,
8869                lease.attempted_at,
8870                NULL::timestamptz AS finalized_at,
8871                ready.created_at,
8872                ready.unique_key,
8873                ready.unique_states,
8874                lease.callback_id,
8875                lease.callback_timeout_at,
8876                attempt.callback_filter,
8877                attempt.callback_on_complete,
8878                attempt.callback_on_fail,
8879                attempt.callback_transform,
8880                COALESCE(ready.payload, '{{}}'::jsonb) AS payload,
8881                attempt.progress,
8882                attempt.callback_result
8883            FROM {schema}.leases AS lease
8884            JOIN {schema}.ready_entries AS ready
8885              ON ready.ready_slot = lease.ready_slot
8886             AND ready.ready_generation = lease.ready_generation
8887             AND ready.queue = lease.queue
8888             AND ready.priority = lease.priority
8889             AND ready.enqueue_shard = lease.enqueue_shard
8890             AND ready.lane_seq = lease.lane_seq
8891            LEFT JOIN {schema}.attempt_state AS attempt
8892              ON attempt.job_id = lease.job_id
8893             AND attempt.run_lease = lease.run_lease
8894            WHERE lease.job_id = $1
8895              AND lease.run_lease = $2
8896            "#,
8897        ))
8898        .bind(job_id)
8899        .bind(run_lease)
8900        .fetch_optional(tx.as_mut())
8901        .await
8902        .map_err(map_sqlx_error)?;
8903        row.map(LeaseJobRow::into_job_row).transpose()
8904    }
8905
8906    pub async fn enter_callback_wait(
8907        &self,
8908        pool: &PgPool,
8909        job_id: i64,
8910        run_lease: i64,
8911        callback_id: Uuid,
8912    ) -> Result<bool, AwaError> {
8913        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
8914        let entered = self
8915            .enter_callback_wait_in_tx(&mut tx, job_id, run_lease, callback_id)
8916            .await?;
8917        tx.commit().await.map_err(map_sqlx_error)?;
8918        Ok(entered)
8919    }
8920
8921    /// Transaction-aware variant of [`Self::enter_callback_wait`] (ADR-029).
8922    /// Returns whether the row transitioned to `waiting_external`.
8923    pub async fn enter_callback_wait_in_tx(
8924        &self,
8925        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
8926        job_id: i64,
8927        run_lease: i64,
8928        callback_id: Uuid,
8929    ) -> Result<bool, AwaError> {
8930        let result = sqlx::query(&format!(
8931            r#"
8932            UPDATE {}
8933            SET state = 'waiting_external',
8934                heartbeat_at = NULL,
8935                deadline_at = NULL
8936            WHERE job_id = $1
8937              AND state = 'running'
8938              AND run_lease = $2
8939              AND callback_id = $3
8940            "#,
8941            self.leases_table()
8942        ))
8943        .bind(job_id)
8944        .bind(run_lease)
8945        .bind(callback_id)
8946        .execute(tx.as_mut())
8947        .await
8948        .map_err(map_sqlx_error)?;
8949        Ok(result.rows_affected() > 0)
8950    }
8951
8952    pub async fn check_callback_state(
8953        &self,
8954        pool: &PgPool,
8955        job_id: i64,
8956        callback_id: Uuid,
8957    ) -> Result<CallbackPollResult, AwaError> {
8958        let row: Option<(JobState, Option<Uuid>, i64, Option<serde_json::Value>)> =
8959            sqlx::query_as(&format!(
8960                r#"
8961                SELECT
8962                    lease.state,
8963                    lease.callback_id,
8964                    lease.run_lease,
8965                    attempt.callback_result
8966                FROM {} AS lease
8967                LEFT JOIN {} AS attempt
8968                  ON attempt.job_id = lease.job_id
8969                 AND attempt.run_lease = lease.run_lease
8970                WHERE lease.job_id = $1
8971                ORDER BY lease.run_lease DESC
8972                LIMIT 1
8973                "#,
8974                self.leases_table(),
8975                self.attempt_state_table()
8976            ))
8977            .bind(job_id)
8978            .fetch_optional(pool)
8979            .await
8980            .map_err(map_sqlx_error)?;
8981
8982        match row {
8983            Some((JobState::Running, None, run_lease, Some(_))) => {
8984                let result = self.take_callback_result(pool, job_id, run_lease).await?;
8985                Ok(CallbackPollResult::Resolved(result))
8986            }
8987            Some((state, Some(current_callback_id), _, _))
8988                if current_callback_id != callback_id =>
8989            {
8990                Ok(CallbackPollResult::Stale {
8991                    token: callback_id,
8992                    current: current_callback_id,
8993                    state,
8994                })
8995            }
8996            Some((JobState::WaitingExternal, Some(current), _, _)) if current == callback_id => {
8997                Ok(CallbackPollResult::Pending)
8998            }
8999            Some((state, _, _, _)) => Ok(CallbackPollResult::UnexpectedState {
9000                token: callback_id,
9001                state,
9002            }),
9003            None => {
9004                if let Some(job) = self.load_job(pool, job_id).await? {
9005                    Ok(CallbackPollResult::UnexpectedState {
9006                        token: callback_id,
9007                        state: job.state,
9008                    })
9009                } else {
9010                    Ok(CallbackPollResult::NotFound)
9011                }
9012            }
9013        }
9014    }
9015
9016    pub async fn callback_job(
9017        &self,
9018        pool: &PgPool,
9019        callback_id: Uuid,
9020        run_lease: Option<i64>,
9021    ) -> Result<Option<JobRow>, AwaError> {
9022        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9023        let result = self
9024            .callback_job_in_tx(&mut tx, callback_id, run_lease, false)
9025            .await?;
9026        tx.commit().await.map_err(map_sqlx_error)?;
9027        Ok(result)
9028    }
9029
9030    /// Transaction-aware variant of [`Self::callback_job`] (ADR-029).
9031    /// When `for_update` is `true` the join's lease row is locked with
9032    /// `FOR UPDATE OF lease`, mirroring the canonical `resolve_callback`
9033    /// lookup that takes a row lock on `awa.jobs_hot` before evaluating
9034    /// the callback policy and committing the resulting transition.
9035    pub async fn callback_job_in_tx(
9036        &self,
9037        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9038        callback_id: Uuid,
9039        run_lease: Option<i64>,
9040        for_update: bool,
9041    ) -> Result<Option<JobRow>, AwaError> {
9042        let lock_clause = if for_update {
9043            "FOR UPDATE OF lease"
9044        } else {
9045            ""
9046        };
9047        let row: Option<LeaseJobRow> = sqlx::query_as(&format!(
9048            r#"
9049            SELECT
9050                lease.ready_slot,
9051                lease.ready_generation,
9052                lease.job_id,
9053                ready.kind,
9054                ready.queue,
9055                ready.args,
9056                lease.state,
9057                lease.priority,
9058                lease.attempt,
9059                lease.run_lease,
9060                lease.max_attempts,
9061                lease.lane_seq,
9062                ready.run_at,
9063                COALESCE(attempt.heartbeat_at, lease.heartbeat_at) AS heartbeat_at,
9064                lease.deadline_at,
9065                lease.attempted_at,
9066                NULL::timestamptz AS finalized_at,
9067                ready.created_at,
9068                ready.unique_key,
9069                ready.unique_states,
9070                lease.callback_id,
9071                lease.callback_timeout_at,
9072                attempt.callback_filter,
9073                attempt.callback_on_complete,
9074                attempt.callback_on_fail,
9075                attempt.callback_transform,
9076                COALESCE(ready.payload, '{{}}'::jsonb) AS payload,
9077                attempt.progress,
9078                attempt.callback_result
9079            FROM {} AS lease
9080            JOIN {schema}.ready_entries AS ready
9081              ON ready.ready_slot = lease.ready_slot
9082             AND ready.ready_generation = lease.ready_generation
9083             AND ready.queue = lease.queue
9084             AND ready.priority = lease.priority
9085             AND ready.enqueue_shard = lease.enqueue_shard
9086             AND ready.lane_seq = lease.lane_seq
9087            LEFT JOIN {schema}.attempt_state AS attempt
9088              ON attempt.job_id = lease.job_id
9089             AND attempt.run_lease = lease.run_lease
9090            WHERE lease.callback_id = $1
9091              AND lease.state IN ('waiting_external', 'running')
9092              AND ($2::bigint IS NULL OR lease.run_lease = $2)
9093            ORDER BY lease.run_lease DESC
9094            LIMIT 1
9095            {lock_clause}
9096            "#,
9097            self.leases_table(),
9098            schema = self.schema(),
9099        ))
9100        .bind(callback_id)
9101        .bind(run_lease)
9102        .fetch_optional(tx.as_mut())
9103        .await
9104        .map_err(map_sqlx_error)?;
9105
9106        row.map(LeaseJobRow::into_job_row).transpose()
9107    }
9108
9109    #[tracing::instrument(skip(self, pool, payload), name = "queue_storage.complete_external")]
9110    pub async fn complete_external(
9111        &self,
9112        pool: &PgPool,
9113        callback_id: Uuid,
9114        payload: Option<serde_json::Value>,
9115        run_lease: Option<i64>,
9116        resume: bool,
9117    ) -> Result<JobRow, AwaError> {
9118        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9119        let result = self
9120            .complete_external_in_tx(&mut tx, callback_id, payload, run_lease, resume)
9121            .await?;
9122        tx.commit().await.map_err(map_sqlx_error)?;
9123        Ok(result)
9124    }
9125
9126    /// Transaction-aware variant of [`Self::complete_external`] (ADR-029).
9127    /// The non-resume path returns the post-completion `JobRow` directly
9128    /// from the `done_row` insert. The resume path returns the parked-row
9129    /// snapshot reloaded inside the same transaction via
9130    /// [`Self::load_active_lease_in_tx`] — i.e. it does not leave the
9131    /// caller's transaction to refresh state.
9132    pub async fn complete_external_in_tx(
9133        &self,
9134        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9135        callback_id: Uuid,
9136        payload: Option<serde_json::Value>,
9137        run_lease: Option<i64>,
9138        resume: bool,
9139    ) -> Result<JobRow, AwaError> {
9140        if resume {
9141            let resumed: Option<(i64, i64)> = sqlx::query_as(&format!(
9142                r#"
9143                UPDATE {}
9144                SET state = 'running',
9145                    callback_id = NULL,
9146                    callback_timeout_at = NULL,
9147                    heartbeat_at = clock_timestamp()
9148                WHERE callback_id = $1
9149                  AND state IN ('waiting_external', 'running')
9150                  AND ($2::bigint IS NULL OR run_lease = $2)
9151                RETURNING job_id, run_lease
9152                "#,
9153                self.leases_table()
9154            ))
9155            .bind(callback_id)
9156            .bind(run_lease)
9157            .fetch_optional(tx.as_mut())
9158            .await
9159            .map_err(map_sqlx_error)?;
9160
9161            let Some((job_id, resumed_run_lease)) = resumed else {
9162                return Err(AwaError::CallbackNotFound {
9163                    callback_id: callback_id.to_string(),
9164                });
9165            };
9166
9167            sqlx::query(&format!(
9168                r#"
9169                INSERT INTO {} (
9170                    job_id,
9171                    run_lease,
9172                    callback_filter,
9173                    callback_on_complete,
9174                    callback_on_fail,
9175                    callback_transform,
9176                    callback_result,
9177                    updated_at
9178                )
9179                VALUES ($1, $2, NULL, NULL, NULL, NULL, $3, clock_timestamp())
9180                ON CONFLICT (job_id, run_lease)
9181                DO UPDATE SET
9182                    callback_filter = NULL,
9183                    callback_on_complete = NULL,
9184                    callback_on_fail = NULL,
9185                    callback_transform = NULL,
9186                    callback_result = EXCLUDED.callback_result,
9187                    updated_at = clock_timestamp()
9188                "#,
9189                self.attempt_state_table()
9190            ))
9191            .bind(job_id)
9192            .bind(resumed_run_lease)
9193            .bind(payload.unwrap_or(serde_json::Value::Null))
9194            .execute(tx.as_mut())
9195            .await
9196            .map_err(map_sqlx_error)?;
9197
9198            return self
9199                .load_active_lease_in_tx(tx, job_id, resumed_run_lease)
9200                .await?
9201                .ok_or(AwaError::CallbackNotFound {
9202                    callback_id: callback_id.to_string(),
9203                });
9204        }
9205
9206        let schema = self.schema();
9207        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
9208            r#"
9209            DELETE FROM {schema}.leases
9210            WHERE callback_id = $1
9211              AND state IN ('waiting_external', 'running')
9212              AND ($2::bigint IS NULL OR run_lease = $2)
9213            RETURNING
9214                ready_slot,
9215                ready_generation,
9216                job_id,
9217                queue,
9218                state,
9219                priority,
9220                attempt,
9221                run_lease,
9222                max_attempts,
9223                lane_seq,
9224                enqueue_shard,
9225                heartbeat_at,
9226                deadline_at,
9227                attempted_at,
9228                callback_id,
9229                callback_timeout_at
9230            "#
9231        ))
9232        .bind(callback_id)
9233        .bind(run_lease)
9234        .fetch_all(tx.as_mut())
9235        .await
9236        .map_err(map_sqlx_error)?;
9237
9238        if deleted.is_empty() {
9239            return Err(AwaError::CallbackNotFound {
9240                callback_id: callback_id.to_string(),
9241            });
9242        }
9243
9244        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
9245        let moved = moved.into_iter().next().expect("deleted callback lease");
9246
9247        let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
9248            moved.payload.clone(),
9249            moved.progress.clone(),
9250        )?)?;
9251        payload.set_progress(None);
9252        let done_row =
9253            moved
9254                .clone()
9255                .into_done_row(JobState::Completed, Utc::now(), payload.into_json());
9256        self.insert_done_rows_tx(tx, std::slice::from_ref(&done_row), Some(moved.state))
9257            .await?;
9258        done_row.into_job_row()
9259    }
9260
9261    pub async fn fail_external(
9262        &self,
9263        pool: &PgPool,
9264        callback_id: Uuid,
9265        error: &str,
9266        run_lease: Option<i64>,
9267    ) -> Result<JobRow, AwaError> {
9268        self.fail_external_with_error_entry(
9269            pool,
9270            callback_id,
9271            serde_json::json!({ "error": error }),
9272            run_lease,
9273        )
9274        .await
9275    }
9276
9277    pub async fn fail_external_with_error_entry(
9278        &self,
9279        pool: &PgPool,
9280        callback_id: Uuid,
9281        error_entry: serde_json::Value,
9282        run_lease: Option<i64>,
9283    ) -> Result<JobRow, AwaError> {
9284        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9285        let result = self
9286            .fail_external_with_error_entry_in_tx(&mut tx, callback_id, error_entry, run_lease)
9287            .await?;
9288        tx.commit().await.map_err(map_sqlx_error)?;
9289        Ok(result)
9290    }
9291
9292    /// Transaction-aware variant of [`Self::fail_external_with_error_entry`]
9293    /// (ADR-029).
9294    pub async fn fail_external_with_error_entry_in_tx(
9295        &self,
9296        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9297        callback_id: Uuid,
9298        error_entry: serde_json::Value,
9299        run_lease: Option<i64>,
9300    ) -> Result<JobRow, AwaError> {
9301        let schema = self.schema();
9302        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
9303            r#"
9304            DELETE FROM {schema}.leases
9305            WHERE callback_id = $1
9306              AND state IN ('waiting_external', 'running')
9307              AND ($2::bigint IS NULL OR run_lease = $2)
9308            RETURNING
9309                ready_slot,
9310                ready_generation,
9311                job_id,
9312                queue,
9313                state,
9314                priority,
9315                attempt,
9316                run_lease,
9317                max_attempts,
9318                lane_seq,
9319                enqueue_shard,
9320                heartbeat_at,
9321                deadline_at,
9322                attempted_at,
9323                callback_id,
9324                callback_timeout_at
9325            "#
9326        ))
9327        .bind(callback_id)
9328        .bind(run_lease)
9329        .fetch_all(tx.as_mut())
9330        .await
9331        .map_err(map_sqlx_error)?;
9332
9333        if deleted.is_empty() {
9334            return Err(AwaError::CallbackNotFound {
9335                callback_id: callback_id.to_string(),
9336            });
9337        }
9338
9339        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
9340        let moved = moved.into_iter().next().expect("deleted callback lease");
9341
9342        let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
9343            moved.payload.clone(),
9344            moved.progress.clone(),
9345        )?)?;
9346        let mut error_entry = match error_entry {
9347            serde_json::Value::Object(map) => serde_json::Value::Object(map),
9348            other => serde_json::json!({ "error": other }),
9349        };
9350        let error_obj = error_entry
9351            .as_object_mut()
9352            .ok_or_else(|| AwaError::Validation("callback error entry must be an object".into()))?;
9353        error_obj
9354            .entry("attempt".to_string())
9355            .or_insert_with(|| serde_json::Value::from(i64::from(moved.attempt)));
9356        error_obj
9357            .entry("at".to_string())
9358            .or_insert_with(|| serde_json::Value::String(Utc::now().to_rfc3339()));
9359        error_obj
9360            .entry("terminal".to_string())
9361            .or_insert(serde_json::Value::Bool(true));
9362        payload.push_error(error_entry);
9363        let done_row =
9364            moved
9365                .clone()
9366                .into_done_row(JobState::Failed, Utc::now(), payload.into_json());
9367        self.insert_done_rows_tx(tx, std::slice::from_ref(&done_row), Some(moved.state))
9368            .await?;
9369        done_row.into_job_row()
9370    }
9371
9372    pub async fn retry_external(
9373        &self,
9374        pool: &PgPool,
9375        callback_id: Uuid,
9376        run_lease: Option<i64>,
9377    ) -> Result<JobRow, AwaError> {
9378        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9379        let result = self
9380            .retry_external_in_tx(&mut tx, callback_id, run_lease)
9381            .await?;
9382        tx.commit().await.map_err(map_sqlx_error)?;
9383        Ok(result)
9384    }
9385
9386    /// Transaction-aware variant of [`Self::retry_external`] (ADR-029).
9387    pub async fn retry_external_in_tx(
9388        &self,
9389        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9390        callback_id: Uuid,
9391        run_lease: Option<i64>,
9392    ) -> Result<JobRow, AwaError> {
9393        let schema = self.schema();
9394        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
9395            r#"
9396            DELETE FROM {schema}.leases
9397            WHERE callback_id = $1
9398              AND state = 'waiting_external'
9399              AND ($2::bigint IS NULL OR run_lease = $2)
9400            RETURNING
9401                ready_slot,
9402                ready_generation,
9403                job_id,
9404                queue,
9405                state,
9406                priority,
9407                attempt,
9408                run_lease,
9409                max_attempts,
9410                lane_seq,
9411                enqueue_shard,
9412                heartbeat_at,
9413                deadline_at,
9414                attempted_at,
9415                callback_id,
9416                callback_timeout_at
9417            "#
9418        ))
9419        .bind(callback_id)
9420        .bind(run_lease)
9421        .fetch_all(tx.as_mut())
9422        .await
9423        .map_err(map_sqlx_error)?;
9424
9425        if deleted.is_empty() {
9426            return Err(AwaError::CallbackNotFound {
9427                callback_id: callback_id.to_string(),
9428            });
9429        }
9430
9431        let moved = self.hydrate_deleted_leases_tx(tx, deleted).await?;
9432        let moved = moved.into_iter().next().expect("deleted callback lease");
9433
9434        let ready_payload =
9435            Self::payload_with_attempt_state(moved.payload.clone(), moved.progress.clone())?;
9436
9437        let ready_row = ExistingReadyRow {
9438            attempt: 0,
9439            run_at: Utc::now(),
9440            ..moved.clone().into_ready_row(Utc::now(), ready_payload)
9441        };
9442        self.insert_existing_ready_rows_tx(tx, vec![ready_row.clone()], Some(moved.state))
9443            .await?;
9444        self.notify_queues_tx(tx, std::iter::once(moved.queue.clone()))
9445            .await?;
9446        ReadyJobRow {
9447            job_id: ready_row.job_id,
9448            kind: ready_row.kind,
9449            queue: ready_row.queue,
9450            args: ready_row.args,
9451            priority: ready_row.priority,
9452            attempt: ready_row.attempt,
9453            run_lease: ready_row.run_lease,
9454            max_attempts: ready_row.max_attempts,
9455            run_at: ready_row.run_at,
9456            attempted_at: ready_row.attempted_at,
9457            created_at: ready_row.created_at,
9458            unique_key: ready_row.unique_key,
9459            payload: ready_row.payload,
9460        }
9461        .into_job_row()
9462    }
9463
9464    pub async fn heartbeat_callback(
9465        &self,
9466        pool: &PgPool,
9467        callback_id: Uuid,
9468        timeout: Duration,
9469    ) -> Result<JobRow, AwaError> {
9470        let updated: Option<(i64, i64)> = sqlx::query_as(&format!(
9471            r#"
9472            UPDATE {}
9473            SET callback_timeout_at = clock_timestamp() + make_interval(secs => $2)
9474            WHERE callback_id = $1
9475              AND state = 'waiting_external'
9476            RETURNING job_id, run_lease
9477            "#,
9478            self.leases_table()
9479        ))
9480        .bind(callback_id)
9481        .bind(timeout.as_secs_f64())
9482        .fetch_optional(pool)
9483        .await
9484        .map_err(map_sqlx_error)?;
9485
9486        let Some((job_id, _run_lease)) = updated else {
9487            return Err(AwaError::CallbackNotFound {
9488                callback_id: callback_id.to_string(),
9489            });
9490        };
9491
9492        self.load_job(pool, job_id)
9493            .await?
9494            .ok_or(AwaError::CallbackNotFound {
9495                callback_id: callback_id.to_string(),
9496            })
9497    }
9498
9499    pub async fn flush_progress(
9500        &self,
9501        pool: &PgPool,
9502        job_id: i64,
9503        run_lease: i64,
9504        progress: serde_json::Value,
9505    ) -> Result<(), AwaError> {
9506        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9507        if self.lease_claim_receipts() {
9508            self.upsert_attempt_state_progress_from_receipts_tx(
9509                &mut tx,
9510                &[(job_id, run_lease, progress.clone())],
9511            )
9512            .await?;
9513        }
9514        sqlx::query(&format!(
9515            r#"
9516            INSERT INTO {} (job_id, run_lease, progress, updated_at)
9517            SELECT lease.job_id, lease.run_lease, $3, clock_timestamp()
9518            FROM {} AS lease
9519            WHERE lease.job_id = $1
9520              AND lease.run_lease = $2
9521              AND lease.state IN ('running', 'waiting_external')
9522            ON CONFLICT (job_id, run_lease)
9523            DO UPDATE SET
9524                progress = EXCLUDED.progress,
9525                updated_at = clock_timestamp()
9526            "#,
9527            self.attempt_state_table(),
9528            self.leases_table()
9529        ))
9530        .bind(job_id)
9531        .bind(run_lease)
9532        .bind(progress)
9533        .execute(tx.as_mut())
9534        .await
9535        .map_err(map_sqlx_error)?;
9536        tx.commit().await.map_err(map_sqlx_error)?;
9537        Ok(())
9538    }
9539
9540    pub async fn heartbeat_batch(
9541        &self,
9542        pool: &PgPool,
9543        jobs: &[(i64, i64)],
9544    ) -> Result<usize, AwaError> {
9545        if jobs.is_empty() {
9546            return Ok(0);
9547        }
9548
9549        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9550        let mut updated = 0_usize;
9551        if self.lease_claim_receipts() {
9552            // #169 B1: in receipts mode, attempt_state is the
9553            // authoritative heartbeat home. Skip the `UPDATE leases SET
9554            // heartbeat_at = ...` entirely — that write was the
9555            // dominant per-heartbeat non-HOT update on the partitioned
9556            // `leases` table (every state-indexed partial index pays 2
9557            // dead entries per write under sustained churn). Compat
9558            // reads in `LeaseJobRow` SELECTs and the `awa.jobs` view
9559            // COALESCE attempt_state.heartbeat_at first.
9560            updated += self
9561                .upsert_attempt_state_from_receipts_tx(&mut tx, jobs)
9562                .await?;
9563        } else {
9564            // Legacy non-receipts mode (custom schemas with
9565            // `lease_claim_receipts=FALSE`): the `leases.heartbeat_at`
9566            // write is still the heartbeat home, since there is no
9567            // upsert_attempt_state path firing.
9568            let job_ids: Vec<i64> = jobs.iter().map(|(job_id, _)| *job_id).collect();
9569            let run_leases: Vec<i64> = jobs.iter().map(|(_, run_lease)| *run_lease).collect();
9570            let result = sqlx::query(&format!(
9571                r#"
9572                WITH inflight AS (
9573                    SELECT * FROM unnest($1::bigint[], $2::bigint[]) AS v(job_id, run_lease)
9574                )
9575                UPDATE {table}
9576                SET heartbeat_at = clock_timestamp()
9577                FROM inflight
9578                WHERE {table}.job_id = inflight.job_id
9579                  AND {table}.run_lease = inflight.run_lease
9580                  AND {table}.state = 'running'
9581                "#,
9582                table = self.leases_table(),
9583            ))
9584            .bind(&job_ids)
9585            .bind(&run_leases)
9586            .execute(tx.as_mut())
9587            .await
9588            .map_err(map_sqlx_error)?;
9589            updated += result.rows_affected() as usize;
9590        }
9591        tx.commit().await.map_err(map_sqlx_error)?;
9592        Ok(updated)
9593    }
9594
9595    pub async fn heartbeat_progress_batch(
9596        &self,
9597        pool: &PgPool,
9598        jobs: &[(i64, i64, serde_json::Value)],
9599    ) -> Result<usize, AwaError> {
9600        if jobs.is_empty() {
9601            return Ok(0);
9602        }
9603
9604        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9605        let updated = if self.lease_claim_receipts() {
9606            // #169 B1: receipts mode is the only supported shape for
9607            // the default `awa` schema. attempt_state already carries
9608            // heartbeat_at + progress, so the `UPDATE leases SET
9609            // heartbeat_at` + nested `INSERT INTO attempt_state` CTE
9610            // is collapsed into the single attempt_state upsert. The
9611            // upsert sources open-claim identity from `lease_claims`
9612            // anti-joined against `lease_claim_closures` so it picks
9613            // up every claim regardless of whether
9614            // `materialize_claims` has fanned it out to `leases` yet.
9615            self.upsert_attempt_state_progress_from_receipts_tx(&mut tx, jobs)
9616                .await?
9617        } else {
9618            // Legacy non-receipts mode keeps the old CTE shape that
9619            // updates leases.heartbeat_at + leases.progress
9620            // (via attempt_state upsert) in a single round-trip.
9621            let schema = self.schema();
9622            let job_ids: Vec<i64> = jobs.iter().map(|(job_id, _, _)| *job_id).collect();
9623            let run_leases: Vec<i64> = jobs.iter().map(|(_, run_lease, _)| *run_lease).collect();
9624            let progress: Vec<serde_json::Value> =
9625                jobs.iter().map(|(_, _, value)| value.clone()).collect();
9626            let lease_updated: i64 = sqlx::query_scalar(&format!(
9627                r#"
9628                WITH inflight AS (
9629                    SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::jsonb[]) AS v(job_id, run_lease, progress)
9630                ),
9631                updated AS (
9632                    UPDATE {table} AS lease
9633                    SET heartbeat_at = clock_timestamp()
9634                    FROM inflight
9635                    WHERE lease.job_id = inflight.job_id
9636                      AND lease.run_lease = inflight.run_lease
9637                      AND lease.state = 'running'
9638                    RETURNING lease.job_id, lease.run_lease, inflight.progress
9639                ),
9640                upsert_attempt AS (
9641                    INSERT INTO {schema}.attempt_state (job_id, run_lease, progress, updated_at)
9642                    SELECT job_id, run_lease, progress, clock_timestamp()
9643                    FROM updated
9644                    ON CONFLICT (job_id, run_lease)
9645                    DO UPDATE SET
9646                        progress = EXCLUDED.progress,
9647                        updated_at = clock_timestamp()
9648                )
9649                SELECT count(*)::bigint FROM updated
9650                "#,
9651                table = self.leases_table(),
9652            ))
9653            .bind(&job_ids)
9654            .bind(&run_leases)
9655            .bind(&progress)
9656            .fetch_one(tx.as_mut())
9657            .await
9658            .map_err(map_sqlx_error)?;
9659            lease_updated as usize
9660        };
9661        tx.commit().await.map_err(map_sqlx_error)?;
9662        Ok(updated)
9663    }
9664
9665    pub async fn retry_after(
9666        &self,
9667        pool: &PgPool,
9668        job_id: i64,
9669        run_lease: i64,
9670        retry_after: Duration,
9671        progress: Option<serde_json::Value>,
9672    ) -> Result<Option<JobRow>, AwaError> {
9673        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9674        let result = self
9675            .retry_after_in_tx(&mut tx, job_id, run_lease, retry_after, progress)
9676            .await?;
9677        tx.commit().await.map_err(map_sqlx_error)?;
9678        Ok(result)
9679    }
9680
9681    /// Transaction-aware variant of [`Self::retry_after`]. Caller owns the
9682    /// transaction lifecycle so the move can commit atomically alongside
9683    /// follow-up `INSERT`s (ADR-029).
9684    pub async fn retry_after_in_tx(
9685        &self,
9686        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9687        job_id: i64,
9688        run_lease: i64,
9689        retry_after: Duration,
9690        progress: Option<serde_json::Value>,
9691    ) -> Result<Option<JobRow>, AwaError> {
9692        let Some(moved) = self
9693            .take_running_attempt_tx(tx, job_id, run_lease, "retryable")
9694            .await?
9695        else {
9696            return Ok(None);
9697        };
9698        let now = self.current_timestamp_tx(tx).await?;
9699
9700        let payload =
9701            Self::with_progress(moved.payload.clone(), progress.or(moved.progress.clone()))?;
9702        let deferred = moved.clone().into_deferred_row(
9703            JobState::Retryable,
9704            now + TimeDelta::from_std(retry_after).map_err(|err| {
9705                AwaError::Validation(format!("invalid retry_after duration: {err}"))
9706            })?,
9707            Some(now),
9708            payload,
9709        );
9710        self.insert_deferred_rows_tx(tx, vec![deferred.clone()], Some(moved.state))
9711            .await?;
9712        Ok(Some(deferred.into_job_row()?))
9713    }
9714
9715    pub async fn snooze(
9716        &self,
9717        pool: &PgPool,
9718        job_id: i64,
9719        run_lease: i64,
9720        snooze_for: Duration,
9721        progress: Option<serde_json::Value>,
9722    ) -> Result<Option<JobRow>, AwaError> {
9723        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9724        let Some(moved) = self
9725            .take_running_attempt_tx(&mut tx, job_id, run_lease, "scheduled")
9726            .await?
9727        else {
9728            tx.commit().await.map_err(map_sqlx_error)?;
9729            return Ok(None);
9730        };
9731        let now = self.current_timestamp_tx(&mut tx).await?;
9732
9733        let payload =
9734            Self::with_progress(moved.payload.clone(), progress.or(moved.progress.clone()))?;
9735        let mut deferred = moved.clone().into_deferred_row(
9736            JobState::Scheduled,
9737            now + TimeDelta::from_std(snooze_for)
9738                .map_err(|err| AwaError::Validation(format!("invalid snooze duration: {err}")))?,
9739            None,
9740            payload,
9741        );
9742        deferred.attempt = deferred.attempt.saturating_sub(1);
9743        self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(moved.state))
9744            .await?;
9745        tx.commit().await.map_err(map_sqlx_error)?;
9746        Ok(Some(deferred.into_job_row()?))
9747    }
9748
9749    pub async fn cancel_running(
9750        &self,
9751        pool: &PgPool,
9752        job_id: i64,
9753        run_lease: i64,
9754        reason: &str,
9755        progress: Option<serde_json::Value>,
9756    ) -> Result<Option<JobRow>, AwaError> {
9757        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9758        let result = self
9759            .cancel_running_in_tx(&mut tx, job_id, run_lease, reason, progress)
9760            .await?;
9761        tx.commit().await.map_err(map_sqlx_error)?;
9762        Ok(result)
9763    }
9764
9765    /// Transaction-aware variant of [`Self::cancel_running`] (ADR-029).
9766    pub async fn cancel_running_in_tx(
9767        &self,
9768        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9769        job_id: i64,
9770        run_lease: i64,
9771        reason: &str,
9772        progress: Option<serde_json::Value>,
9773    ) -> Result<Option<JobRow>, AwaError> {
9774        let Some(moved) = self
9775            .take_running_attempt_tx(tx, job_id, run_lease, "cancelled")
9776            .await?
9777        else {
9778            return Ok(None);
9779        };
9780
9781        let mut payload = RuntimePayload::from_json(Self::with_progress(
9782            moved.payload.clone(),
9783            progress.or(moved.progress.clone()),
9784        )?)?;
9785        payload.push_error(lifecycle_error(
9786            format!("cancelled: {reason}"),
9787            moved.attempt,
9788            false,
9789        ));
9790        let done =
9791            moved
9792                .clone()
9793                .into_done_row(JobState::Cancelled, Utc::now(), payload.into_json());
9794        self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(moved.state))
9795            .await?;
9796        Ok(Some(done.into_job_row()?))
9797    }
9798
9799    pub async fn fail_terminal(
9800        &self,
9801        pool: &PgPool,
9802        job_id: i64,
9803        run_lease: i64,
9804        error: &str,
9805        progress: Option<serde_json::Value>,
9806    ) -> Result<Option<JobRow>, AwaError> {
9807        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9808        let result = self
9809            .fail_terminal_in_tx(&mut tx, job_id, run_lease, error, progress)
9810            .await?;
9811        tx.commit().await.map_err(map_sqlx_error)?;
9812        Ok(result)
9813    }
9814
9815    /// Transaction-aware variant of [`Self::fail_terminal`] (ADR-029).
9816    pub async fn fail_terminal_in_tx(
9817        &self,
9818        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9819        job_id: i64,
9820        run_lease: i64,
9821        error: &str,
9822        progress: Option<serde_json::Value>,
9823    ) -> Result<Option<JobRow>, AwaError> {
9824        let Some(moved) = self
9825            .take_running_attempt_tx(tx, job_id, run_lease, "failed")
9826            .await?
9827        else {
9828            return Ok(None);
9829        };
9830
9831        let mut payload = RuntimePayload::from_json(Self::with_progress(
9832            moved.payload.clone(),
9833            progress.or(moved.progress.clone()),
9834        )?)?;
9835        payload.push_error(lifecycle_error(error, moved.attempt, true));
9836        let done = moved
9837            .clone()
9838            .into_done_row(JobState::Failed, Utc::now(), payload.into_json());
9839        self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(moved.state))
9840            .await?;
9841        Ok(Some(done.into_job_row()?))
9842    }
9843
9844    pub async fn fail_to_dlq(
9845        &self,
9846        pool: &PgPool,
9847        job_id: i64,
9848        run_lease: i64,
9849        dlq_reason: &str,
9850        error: &str,
9851        progress: Option<serde_json::Value>,
9852    ) -> Result<Option<JobRow>, AwaError> {
9853        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9854        let result = self
9855            .fail_to_dlq_in_tx(&mut tx, job_id, run_lease, dlq_reason, error, progress)
9856            .await?;
9857        tx.commit().await.map_err(map_sqlx_error)?;
9858        Ok(result)
9859    }
9860
9861    /// Transaction-aware variant of [`Self::fail_to_dlq`] (ADR-029).
9862    pub async fn fail_to_dlq_in_tx(
9863        &self,
9864        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
9865        job_id: i64,
9866        run_lease: i64,
9867        dlq_reason: &str,
9868        error: &str,
9869        progress: Option<serde_json::Value>,
9870    ) -> Result<Option<JobRow>, AwaError> {
9871        let Some(moved) = self
9872            .take_running_attempt_tx(tx, job_id, run_lease, "dlq")
9873            .await?
9874        else {
9875            return Ok(None);
9876        };
9877
9878        let finalized_at = Utc::now();
9879        let dlq_at = finalized_at;
9880        let mut payload = RuntimePayload::from_json(Self::with_progress(
9881            moved.payload.clone(),
9882            progress.or(moved.progress.clone()),
9883        )?)?;
9884        payload.push_error(lifecycle_error(error, moved.attempt, true));
9885        let dlq_row = moved.clone().into_dlq_row(
9886            finalized_at,
9887            payload.into_json(),
9888            dlq_reason.to_string(),
9889            dlq_at,
9890        );
9891        self.insert_dlq_rows_tx(tx, std::slice::from_ref(&dlq_row), Some(moved.state))
9892            .await?;
9893        Ok(Some(dlq_row.into_job_row()?))
9894    }
9895
9896    pub async fn move_failed_to_dlq(
9897        &self,
9898        pool: &PgPool,
9899        job_id: i64,
9900        dlq_reason: &str,
9901    ) -> Result<Option<JobRow>, AwaError> {
9902        let schema = self.schema();
9903        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9904        let done_projection = done_row_projection("done", "ready");
9905        let ready_join = done_ready_join(schema, "done", "ready");
9906        let moved: Option<DoneJobRow> = sqlx::query_as(&format!(
9907            r#"
9908            WITH deleted AS (
9909                DELETE FROM {schema}.done_entries
9910                WHERE (job_id, finalized_at) IN (
9911                    SELECT job_id, finalized_at
9912                    FROM {schema}.done_entries
9913                    WHERE job_id = $1
9914                      AND state = 'failed'
9915                    ORDER BY finalized_at DESC
9916                    LIMIT 1
9917                    FOR UPDATE SKIP LOCKED
9918                )
9919                RETURNING *
9920            )
9921            SELECT {done_projection}
9922            FROM deleted AS done
9923            {ready_join}
9924            "#
9925        ))
9926        .bind(job_id)
9927        .fetch_optional(tx.as_mut())
9928        .await
9929        .map_err(map_sqlx_error)?;
9930
9931        let Some(moved) = moved else {
9932            tx.commit().await.map_err(map_sqlx_error)?;
9933            return Ok(None);
9934        };
9935
9936        // DLQ inserts are outside done_entries, so the source terminal DELETE
9937        // appends a negative delta.
9938        self.decrement_live_terminal_counters_tx(
9939            &mut tx,
9940            &Self::done_rows_to_counter_keys(std::slice::from_ref(&moved)),
9941        )
9942        .await?;
9943        let dlq_row = moved
9944            .clone()
9945            .into_dlq_row(dlq_reason.to_string(), Utc::now());
9946        self.insert_dlq_rows_tx(&mut tx, std::slice::from_ref(&dlq_row), Some(moved.state))
9947            .await?;
9948        tx.commit().await.map_err(map_sqlx_error)?;
9949        Ok(Some(dlq_row.into_job_row()?))
9950    }
9951
9952    #[tracing::instrument(
9953        skip(self, pool, dlq_reason),
9954        fields(kind = ?kind, queue = ?queue),
9955        name = "queue_storage.bulk_move_failed_to_dlq"
9956    )]
9957    pub async fn bulk_move_failed_to_dlq(
9958        &self,
9959        pool: &PgPool,
9960        kind: Option<&str>,
9961        queue: Option<&str>,
9962        dlq_reason: &str,
9963    ) -> Result<u64, AwaError> {
9964        let schema = self.schema();
9965        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
9966        let done_projection = done_row_projection("done", "ready");
9967        let ready_join = done_ready_join(schema, "done", "ready");
9968        let moved: Vec<DoneJobRow> = sqlx::query_as(&format!(
9969            r#"
9970            WITH deleted AS (
9971                DELETE FROM {schema}.done_entries
9972                WHERE state = 'failed'
9973                  AND ($1::text IS NULL OR kind = $1)
9974                  AND ($2::text IS NULL OR queue = $2)
9975                RETURNING *
9976            )
9977            SELECT {done_projection}
9978            FROM deleted AS done
9979            {ready_join}
9980            "#
9981        ))
9982        .bind(kind)
9983        .bind(queue)
9984        .fetch_all(tx.as_mut())
9985        .await
9986        .map_err(map_sqlx_error)?;
9987
9988        if moved.is_empty() {
9989            tx.commit().await.map_err(map_sqlx_error)?;
9990            return Ok(0);
9991        }
9992
9993        // Bulk DLQ move deletes from done_entries. Append negative deltas by
9994        // the per-group magnitudes of the moved rows.
9995        self.decrement_live_terminal_counters_tx(&mut tx, &Self::done_rows_to_counter_keys(&moved))
9996            .await?;
9997        let dlq_at = Utc::now();
9998        let rows: Vec<DlqJobRow> = moved
9999            .into_iter()
10000            .map(|row| row.into_dlq_row(dlq_reason.to_string(), dlq_at))
10001            .collect();
10002        self.insert_dlq_rows_tx(&mut tx, &rows, Some(JobState::Failed))
10003            .await?;
10004        tx.commit().await.map_err(map_sqlx_error)?;
10005        Ok(rows.len() as u64)
10006    }
10007
10008    pub async fn retry_from_dlq(
10009        &self,
10010        pool: &PgPool,
10011        job_id: i64,
10012        opts: &RetryFromDlqOpts,
10013    ) -> Result<Option<JobRow>, AwaError> {
10014        let schema = self.schema();
10015        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10016        let moved: Option<DlqJobRow> = sqlx::query_as(&format!(
10017            r#"
10018            DELETE FROM {schema}.dlq_entries
10019            WHERE job_id = $1
10020            RETURNING
10021                job_id,
10022                kind,
10023                queue,
10024                args,
10025                state,
10026                priority,
10027                attempt,
10028                run_lease,
10029                max_attempts,
10030                run_at,
10031                attempted_at,
10032                finalized_at,
10033                created_at,
10034                unique_key,
10035                unique_states,
10036                COALESCE(payload, '{{}}'::jsonb) AS payload,
10037                dlq_reason,
10038                dlq_at,
10039                original_run_lease
10040            "#
10041        ))
10042        .bind(job_id)
10043        .fetch_optional(tx.as_mut())
10044        .await
10045        .map_err(map_sqlx_error)?;
10046
10047        let Some(moved) = moved else {
10048            tx.commit().await.map_err(map_sqlx_error)?;
10049            return Ok(None);
10050        };
10051
10052        let queue = opts.queue.clone().unwrap_or_else(|| moved.queue.clone());
10053        let priority = opts.priority.unwrap_or(moved.priority);
10054        let mut payload = RuntimePayload::from_json(moved.payload.clone())?;
10055        payload.set_progress(None);
10056        let payload = payload.into_json();
10057
10058        if let Some(run_at) = opts.run_at.filter(|run_at| *run_at > Utc::now()) {
10059            let deferred = moved.into_retry_deferred_row(queue, priority, run_at, payload);
10060            self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(JobState::Failed))
10061                .await?;
10062            tx.commit().await.map_err(map_sqlx_error)?;
10063            return Ok(Some(deferred.into_job_row()?));
10064        }
10065
10066        let ready = moved.into_retry_ready_row(queue.clone(), priority, Utc::now(), payload);
10067        self.insert_existing_ready_rows_tx(&mut tx, vec![ready.clone()], Some(JobState::Failed))
10068            .await?;
10069        self.notify_queues_tx(&mut tx, std::iter::once(queue))
10070            .await?;
10071        tx.commit().await.map_err(map_sqlx_error)?;
10072        Ok(Some(
10073            ReadyJobRow {
10074                job_id: ready.job_id,
10075                kind: ready.kind,
10076                queue: ready.queue,
10077                args: ready.args,
10078                priority: ready.priority,
10079                attempt: ready.attempt,
10080                run_lease: ready.run_lease,
10081                max_attempts: ready.max_attempts,
10082                run_at: ready.run_at,
10083                attempted_at: ready.attempted_at,
10084                created_at: ready.created_at,
10085                unique_key: ready.unique_key,
10086                payload: ready.payload,
10087            }
10088            .into_job_row()?,
10089        ))
10090    }
10091
10092    #[tracing::instrument(
10093        skip(self, pool, filter),
10094        fields(kind = ?filter.kind, queue = ?filter.queue, tag = ?filter.tag),
10095        name = "queue_storage.bulk_retry_from_dlq"
10096    )]
10097    pub async fn bulk_retry_from_dlq(
10098        &self,
10099        pool: &PgPool,
10100        filter: &ListDlqFilter,
10101    ) -> Result<u64, AwaError> {
10102        let schema = self.schema();
10103        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10104        let moved: Vec<DlqJobRow> = sqlx::query_as(&format!(
10105            r#"
10106            DELETE FROM {schema}.dlq_entries
10107            WHERE ($1::text IS NULL OR kind = $1)
10108              AND ($2::text IS NULL OR queue = $2)
10109              AND ($3::text IS NULL OR payload -> 'tags' ? $3)
10110              AND (
10111                  ($4::bigint IS NULL AND $5::timestamptz IS NULL)
10112                  OR ($4::bigint IS NOT NULL AND $5::timestamptz IS NULL AND job_id < $4)
10113                  OR ($4::bigint IS NULL AND $5::timestamptz IS NOT NULL AND dlq_at < $5)
10114                  OR (
10115                      $4::bigint IS NOT NULL
10116                      AND $5::timestamptz IS NOT NULL
10117                      AND (dlq_at, job_id) < ($5, $4)
10118                  )
10119              )
10120            RETURNING
10121                job_id,
10122                kind,
10123                queue,
10124                args,
10125                state,
10126                priority,
10127                attempt,
10128                run_lease,
10129                max_attempts,
10130                run_at,
10131                attempted_at,
10132                finalized_at,
10133                created_at,
10134                unique_key,
10135                unique_states,
10136                COALESCE(payload, '{{}}'::jsonb) AS payload,
10137                dlq_reason,
10138                dlq_at,
10139                original_run_lease
10140            "#
10141        ))
10142        .bind(&filter.kind)
10143        .bind(&filter.queue)
10144        .bind(&filter.tag)
10145        .bind(filter.before_id)
10146        .bind(filter.before_dlq_at)
10147        .fetch_all(tx.as_mut())
10148        .await
10149        .map_err(map_sqlx_error)?;
10150
10151        if moved.is_empty() {
10152            tx.commit().await.map_err(map_sqlx_error)?;
10153            return Ok(0);
10154        }
10155
10156        let run_at = Utc::now();
10157        let mut queues = BTreeSet::new();
10158        let mut ready_rows = Vec::with_capacity(moved.len());
10159        for moved_row in moved {
10160            let queue = moved_row.queue.clone();
10161            let priority = moved_row.priority;
10162            queues.insert(queue.clone());
10163            let mut payload = RuntimePayload::from_json(moved_row.payload.clone())?;
10164            payload.set_progress(None);
10165            ready_rows.push(moved_row.into_retry_ready_row(
10166                queue,
10167                priority,
10168                run_at,
10169                payload.into_json(),
10170            ));
10171        }
10172
10173        let revived = ready_rows.len() as u64;
10174        self.insert_existing_ready_rows_tx(&mut tx, ready_rows, Some(JobState::Failed))
10175            .await?;
10176        self.notify_queues_tx(&mut tx, queues).await?;
10177        tx.commit().await.map_err(map_sqlx_error)?;
10178        Ok(revived)
10179    }
10180
10181    pub async fn discard_failed_by_kind(&self, pool: &PgPool, kind: &str) -> Result<u64, AwaError> {
10182        let schema = self.schema();
10183        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10184
10185        let done_projection = done_row_projection("done", "ready");
10186        let ready_join = done_ready_join(schema, "done", "ready");
10187        let deleted_done: Vec<DoneJobRow> = sqlx::query_as(&format!(
10188            r#"
10189            WITH deleted AS (
10190                DELETE FROM {schema}.done_entries
10191                WHERE kind = $1
10192                  AND state = 'failed'
10193                RETURNING *
10194            )
10195            SELECT {done_projection}
10196            FROM deleted AS done
10197            {ready_join}
10198            "#
10199        ))
10200        .bind(kind)
10201        .fetch_all(tx.as_mut())
10202        .await
10203        .map_err(map_sqlx_error)?;
10204
10205        let deleted_dlq: Vec<DlqJobRow> = sqlx::query_as(&format!(
10206            r#"
10207            DELETE FROM {schema}.dlq_entries
10208            WHERE kind = $1
10209            RETURNING
10210                job_id,
10211                kind,
10212                queue,
10213                args,
10214                state,
10215                priority,
10216                attempt,
10217                run_lease,
10218                max_attempts,
10219                run_at,
10220                attempted_at,
10221                finalized_at,
10222                created_at,
10223                unique_key,
10224                unique_states,
10225                COALESCE(payload, '{{}}'::jsonb) AS payload,
10226                dlq_reason,
10227                dlq_at,
10228                original_run_lease
10229            "#
10230        ))
10231        .bind(kind)
10232        .fetch_all(tx.as_mut())
10233        .await
10234        .map_err(map_sqlx_error)?;
10235
10236        // Discard removes terminal `failed` rows from done_entries (and
10237        // `failed` DLQ rows from dlq_entries — exact terminal counts are keyed
10238        // on done_entries only). Append negative deltas by the per-group
10239        // magnitudes of `deleted_done`.
10240        self.decrement_live_terminal_counters_tx(
10241            &mut tx,
10242            &Self::done_rows_to_counter_keys(&deleted_done),
10243        )
10244        .await?;
10245
10246        for row in &deleted_done {
10247            self.sync_unique_claim(
10248                &mut tx,
10249                row.job_id,
10250                &row.unique_key,
10251                row.unique_states.as_deref(),
10252                Some(row.state),
10253                None,
10254            )
10255            .await?;
10256        }
10257
10258        for row in &deleted_dlq {
10259            self.sync_unique_claim(
10260                &mut tx,
10261                row.job_id,
10262                &row.unique_key,
10263                row.unique_states.as_deref(),
10264                Some(row.state),
10265                None,
10266            )
10267            .await?;
10268        }
10269
10270        tx.commit().await.map_err(map_sqlx_error)?;
10271        Ok((deleted_done.len() + deleted_dlq.len()) as u64)
10272    }
10273
10274    pub async fn fail_retryable(
10275        &self,
10276        pool: &PgPool,
10277        job_id: i64,
10278        run_lease: i64,
10279        error: &str,
10280        progress: Option<serde_json::Value>,
10281    ) -> Result<Option<JobRow>, AwaError> {
10282        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10283        let result = self
10284            .fail_retryable_in_tx(&mut tx, job_id, run_lease, error, progress)
10285            .await?;
10286        tx.commit().await.map_err(map_sqlx_error)?;
10287        Ok(result)
10288    }
10289
10290    /// Transaction-aware variant of [`Self::fail_retryable`] (ADR-029).
10291    pub async fn fail_retryable_in_tx(
10292        &self,
10293        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
10294        job_id: i64,
10295        run_lease: i64,
10296        error: &str,
10297        progress: Option<serde_json::Value>,
10298    ) -> Result<Option<JobRow>, AwaError> {
10299        let Some(moved) = self
10300            .take_running_attempt_tx(tx, job_id, run_lease, "retryable")
10301            .await?
10302        else {
10303            return Ok(None);
10304        };
10305
10306        let mut payload = RuntimePayload::from_json(Self::with_progress(
10307            moved.payload.clone(),
10308            progress.or(moved.progress.clone()),
10309        )?)?;
10310        let exhausted = moved.attempt >= moved.max_attempts;
10311        payload.push_error(lifecycle_error(error, moved.attempt, exhausted));
10312
10313        if exhausted {
10314            let done =
10315                moved
10316                    .clone()
10317                    .into_done_row(JobState::Failed, Utc::now(), payload.into_json());
10318            self.insert_done_rows_tx(tx, std::slice::from_ref(&done), Some(moved.state))
10319                .await?;
10320            return Ok(Some(done.into_job_row()?));
10321        }
10322
10323        let deferred = moved.clone().into_deferred_row(
10324            JobState::Retryable,
10325            self.backoff_at_tx(tx, moved.attempt, moved.max_attempts)
10326                .await?,
10327            Some(Utc::now()),
10328            payload.into_json(),
10329        );
10330        self.insert_deferred_rows_tx(tx, vec![deferred.clone()], Some(moved.state))
10331            .await?;
10332        Ok(Some(deferred.into_job_row()?))
10333    }
10334
10335    #[tracing::instrument(skip(self, pool), name = "queue_storage.rescue_stale_heartbeats")]
10336    pub async fn rescue_stale_heartbeats(
10337        &self,
10338        pool: &PgPool,
10339        staleness: Duration,
10340    ) -> Result<Vec<JobRow>, AwaError> {
10341        let schema = self.schema();
10342        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10343        let cutoff = Utc::now()
10344            - TimeDelta::from_std(staleness)
10345                .map_err(|err| AwaError::Validation(format!("invalid staleness: {err}")))?;
10346        // #169 B1: the staleness predicate prefers
10347        // `attempt_state.heartbeat_at` (receipts-mode source of truth,
10348        // where heartbeat_batch writes go) and falls back to
10349        // `leases.heartbeat_at` (legacy non-receipts source). Two
10350        // cases this covers:
10351        //
10352        //   * Receipts mode, claim materialized into `leases` via
10353        //     callback registration / progress upsert / equivalent.
10354        //     The leases row was written once at materialize time and
10355        //     its `heartbeat_at` is stale by definition. The fresh
10356        //     value lives on `attempt_state.heartbeat_at`. COALESCE
10357        //     picks `attempt` so a healthy worker isn't falsely
10358        //     rescued — and a dead worker IS rescued, which the
10359        //     receipt-side path can't do (its anti-join below
10360        //     excludes materialized leases to avoid double-closure).
10361        //   * Legacy non-receipts mode: attempt_state.heartbeat_at is
10362        //     never written; COALESCE falls back to
10363        //     leases.heartbeat_at — same shape as pre-B1.
10364        //
10365        // The dropped `idx_state_hb` (v025) doesn't hurt this scan:
10366        // the planner can satisfy the `state='running'` prefix via the
10367        // surviving `(state, deadline_at)` or
10368        // `(state, callback_timeout_at)` indexes, followed by a heap
10369        // recheck of the COALESCE. Bounded by running-lease count and
10370        // called at 30s cadence — cheap.
10371        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
10372            r#"
10373            DELETE FROM {schema}.leases AS target
10374            WHERE (target.job_id, target.run_lease) IN (
10375                SELECT lease.job_id, lease.run_lease
10376                FROM {schema}.leases AS lease
10377                LEFT JOIN {schema}.attempt_state AS attempt
10378                  ON attempt.job_id = lease.job_id
10379                 AND attempt.run_lease = lease.run_lease
10380                WHERE lease.state = 'running'
10381                  AND COALESCE(attempt.heartbeat_at, lease.heartbeat_at) < $1
10382                ORDER BY COALESCE(attempt.heartbeat_at, lease.heartbeat_at) ASC
10383                LIMIT 500
10384                FOR UPDATE OF lease SKIP LOCKED
10385            )
10386            RETURNING
10387                ready_slot,
10388                ready_generation,
10389                job_id,
10390                queue,
10391                state,
10392                priority,
10393                attempt,
10394                run_lease,
10395                max_attempts,
10396                lane_seq,
10397                enqueue_shard,
10398                heartbeat_at,
10399                deadline_at,
10400                attempted_at,
10401                callback_id,
10402                callback_timeout_at
10403            "#
10404        ))
10405        .bind(cutoff)
10406        .fetch_all(tx.as_mut())
10407        .await
10408        .map_err(map_sqlx_error)?;
10409
10410        let rescued_receipts = if self.lease_claim_receipts() {
10411            self.rescue_stale_receipt_claims_tx(&mut tx, cutoff).await?
10412        } else {
10413            Vec::new()
10414        };
10415
10416        if deleted.is_empty() && rescued_receipts.is_empty() {
10417            tx.commit().await.map_err(map_sqlx_error)?;
10418            return Ok(Vec::new());
10419        }
10420
10421        let moved_leases = self.hydrate_deleted_leases_tx(&mut tx, deleted).await?;
10422        let moved_receipts = self
10423            .hydrate_deleted_leases_tx(&mut tx, rescued_receipts)
10424            .await?;
10425
10426        let mut rescued = Vec::with_capacity(moved_leases.len() + moved_receipts.len());
10427        for row in moved_leases {
10428            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
10429                row.payload.clone(),
10430                row.progress.clone(),
10431            )?)?;
10432            payload.push_error(lifecycle_error(
10433                "heartbeat stale: worker presumed dead",
10434                row.attempt,
10435                false,
10436            ));
10437            let deferred = row.clone().into_deferred_row(
10438                JobState::Retryable,
10439                self.backoff_at_tx(&mut tx, row.attempt, row.max_attempts)
10440                    .await?,
10441                Some(Utc::now()),
10442                payload.into_json(),
10443            );
10444            self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(row.state))
10445                .await?;
10446            rescued.push(deferred.into_job_row()?);
10447        }
10448        for row in moved_receipts {
10449            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
10450                row.payload.clone(),
10451                row.progress.clone(),
10452            )?)?;
10453            payload.push_error(lifecycle_error(
10454                "receipt claim stale: worker presumed dead",
10455                row.attempt,
10456                false,
10457            ));
10458            let deferred = row.clone().into_deferred_row(
10459                JobState::Retryable,
10460                self.backoff_at_tx(&mut tx, row.attempt, row.max_attempts)
10461                    .await?,
10462                Some(Utc::now()),
10463                payload.into_json(),
10464            );
10465            self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(row.state))
10466                .await?;
10467            rescued.push(deferred.into_job_row()?);
10468        }
10469        tx.commit().await.map_err(map_sqlx_error)?;
10470        Ok(rescued)
10471    }
10472
10473    #[tracing::instrument(skip(self, pool), name = "queue_storage.rescue_expired_deadlines")]
10474    pub async fn rescue_expired_deadlines(&self, pool: &PgPool) -> Result<Vec<JobRow>, AwaError> {
10475        let schema = self.schema();
10476        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10477        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
10478            r#"
10479            DELETE FROM {schema}.leases
10480            WHERE job_id IN (
10481                SELECT job_id
10482                FROM {schema}.leases
10483                WHERE state = 'running'
10484                  AND deadline_at IS NOT NULL
10485                  AND deadline_at < clock_timestamp()
10486                ORDER BY deadline_at ASC
10487                LIMIT 500
10488                FOR UPDATE SKIP LOCKED
10489            )
10490            RETURNING
10491                ready_slot,
10492                ready_generation,
10493                job_id,
10494                queue,
10495                state,
10496                priority,
10497                attempt,
10498                run_lease,
10499                max_attempts,
10500                lane_seq,
10501                enqueue_shard,
10502                heartbeat_at,
10503                deadline_at,
10504                attempted_at,
10505                callback_id,
10506                callback_timeout_at
10507            "#
10508        ))
10509        .fetch_all(tx.as_mut())
10510        .await
10511        .map_err(map_sqlx_error)?;
10512
10513        // Receipts-mode short-path claims hold their deadline on
10514        // `lease_claims.deadline_at` rather than on a `leases` row, so
10515        // the receipt-plane needs its own scan; merge both populations
10516        // into one `moved` set so the maintenance caller observes a
10517        // single rescue batch per tick.
10518        let receipt_deleted = if self.lease_claim_receipts() {
10519            self.rescue_expired_receipt_deadlines_tx(&mut tx).await?
10520        } else {
10521            Vec::new()
10522        };
10523
10524        if deleted.is_empty() && receipt_deleted.is_empty() {
10525            tx.commit().await.map_err(map_sqlx_error)?;
10526            return Ok(Vec::new());
10527        }
10528
10529        let mut moved = self.hydrate_deleted_leases_tx(&mut tx, deleted).await?;
10530        moved.extend(
10531            self.hydrate_deleted_leases_tx(&mut tx, receipt_deleted)
10532                .await?,
10533        );
10534
10535        let mut rescued = Vec::with_capacity(moved.len());
10536        for row in moved {
10537            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
10538                row.payload.clone(),
10539                row.progress.clone(),
10540            )?)?;
10541            payload.push_error(lifecycle_error(
10542                "hard deadline exceeded",
10543                row.attempt,
10544                false,
10545            ));
10546            let deferred = row.clone().into_deferred_row(
10547                JobState::Retryable,
10548                self.backoff_at_tx(&mut tx, row.attempt, row.max_attempts)
10549                    .await?,
10550                Some(Utc::now()),
10551                payload.into_json(),
10552            );
10553            self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(row.state))
10554                .await?;
10555            rescued.push(deferred.into_job_row()?);
10556        }
10557        tx.commit().await.map_err(map_sqlx_error)?;
10558        Ok(rescued)
10559    }
10560
10561    #[tracing::instrument(skip(self, pool), name = "queue_storage.rescue_expired_callbacks")]
10562    pub async fn rescue_expired_callbacks(&self, pool: &PgPool) -> Result<Vec<JobRow>, AwaError> {
10563        let schema = self.schema();
10564        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10565        let deleted: Vec<DeletedLeaseRow> = sqlx::query_as(&format!(
10566            r#"
10567            DELETE FROM {schema}.leases
10568            WHERE job_id IN (
10569                SELECT job_id
10570                FROM {schema}.leases
10571                WHERE state = 'waiting_external'
10572                  AND callback_timeout_at IS NOT NULL
10573                  AND callback_timeout_at < clock_timestamp()
10574                ORDER BY callback_timeout_at ASC
10575                LIMIT 500
10576                FOR UPDATE SKIP LOCKED
10577            )
10578            RETURNING
10579                ready_slot,
10580                ready_generation,
10581                job_id,
10582                queue,
10583                state,
10584                priority,
10585                attempt,
10586                run_lease,
10587                max_attempts,
10588                lane_seq,
10589                enqueue_shard,
10590                heartbeat_at,
10591                deadline_at,
10592                attempted_at,
10593                callback_id,
10594                callback_timeout_at
10595            "#
10596        ))
10597        .fetch_all(tx.as_mut())
10598        .await
10599        .map_err(map_sqlx_error)?;
10600
10601        if deleted.is_empty() {
10602            tx.commit().await.map_err(map_sqlx_error)?;
10603            return Ok(Vec::new());
10604        }
10605
10606        let moved = self.hydrate_deleted_leases_tx(&mut tx, deleted).await?;
10607
10608        let mut rescued = Vec::with_capacity(moved.len());
10609        for row in moved {
10610            let mut payload = RuntimePayload::from_json(Self::payload_with_attempt_state(
10611                row.payload.clone(),
10612                row.progress.clone(),
10613            )?)?;
10614            let exhausted = row.attempt >= row.max_attempts;
10615            payload.push_error(lifecycle_error(
10616                "callback timed out",
10617                row.attempt,
10618                exhausted,
10619            ));
10620            if exhausted {
10621                let done =
10622                    row.clone()
10623                        .into_done_row(JobState::Failed, Utc::now(), payload.into_json());
10624                self.insert_done_rows_tx(&mut tx, std::slice::from_ref(&done), Some(row.state))
10625                    .await?;
10626                rescued.push(done.into_job_row()?);
10627            } else {
10628                let deferred = row.clone().into_deferred_row(
10629                    JobState::Retryable,
10630                    self.backoff_at_tx(&mut tx, row.attempt, row.max_attempts)
10631                        .await?,
10632                    Some(Utc::now()),
10633                    payload.into_json(),
10634                );
10635                self.insert_deferred_rows_tx(&mut tx, vec![deferred.clone()], Some(row.state))
10636                    .await?;
10637                rescued.push(deferred.into_job_row()?);
10638            }
10639        }
10640        tx.commit().await.map_err(map_sqlx_error)?;
10641        Ok(rescued)
10642    }
10643
10644    pub async fn promote_due(
10645        &self,
10646        pool: &PgPool,
10647        state: JobState,
10648        batch_size: i64,
10649    ) -> Result<usize, AwaError> {
10650        if !matches!(state, JobState::Scheduled | JobState::Retryable) || batch_size <= 0 {
10651            return Ok(0);
10652        }
10653
10654        let schema = self.schema();
10655        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10656        let moved: Vec<DeferredJobRow> = sqlx::query_as(&format!(
10657            r#"
10658            DELETE FROM {schema}.deferred_jobs
10659            WHERE job_id IN (
10660                SELECT job_id
10661                FROM {schema}.deferred_jobs
10662                WHERE state = $1
10663                  AND run_at <= clock_timestamp()
10664                  AND NOT EXISTS (
10665                      SELECT 1 FROM awa.queue_meta
10666                      WHERE queue = {schema}.deferred_jobs.queue AND paused = TRUE
10667                  )
10668                ORDER BY run_at ASC, priority ASC, job_id ASC
10669                LIMIT $2
10670                FOR UPDATE SKIP LOCKED
10671            )
10672            RETURNING
10673                job_id,
10674                kind,
10675                queue,
10676                args,
10677                state,
10678                priority,
10679                attempt,
10680                run_lease,
10681                max_attempts,
10682                run_at,
10683                attempted_at,
10684                finalized_at,
10685                created_at,
10686                unique_key,
10687                unique_states,
10688                COALESCE(payload, '{{}}'::jsonb) AS payload
10689            "#
10690        ))
10691        .bind(state)
10692        .bind(batch_size)
10693        .fetch_all(tx.as_mut())
10694        .await
10695        .map_err(map_sqlx_error)?;
10696
10697        if moved.is_empty() {
10698            tx.commit().await.map_err(map_sqlx_error)?;
10699            return Ok(0);
10700        }
10701
10702        let ready_rows: Vec<ExistingReadyRow> = moved
10703            .iter()
10704            .cloned()
10705            .map(|row| ExistingReadyRow {
10706                job_id: row.job_id,
10707                kind: row.kind,
10708                queue: row.queue,
10709                args: row.args,
10710                priority: row.priority,
10711                attempt: row.attempt,
10712                run_lease: row.run_lease,
10713                max_attempts: row.max_attempts,
10714                run_at: Utc::now(),
10715                attempted_at: row.attempted_at,
10716                created_at: row.created_at,
10717                unique_key: row.unique_key,
10718                unique_states: row.unique_states,
10719                payload: row.payload,
10720            })
10721            .collect();
10722        let queues = ready_rows
10723            .iter()
10724            .map(|row| row.queue.clone())
10725            .collect::<Vec<_>>();
10726        self.insert_existing_ready_rows_tx(&mut tx, ready_rows, Some(state))
10727            .await?;
10728        self.notify_queues_tx(&mut tx, queues).await?;
10729        tx.commit().await.map_err(map_sqlx_error)?;
10730        Ok(moved.len())
10731    }
10732
10733    #[tracing::instrument(skip(self, pool), name = "queue_storage.rotate")]
10734    pub async fn rotate(&self, pool: &PgPool) -> Result<RotateOutcome, AwaError> {
10735        let schema = self.schema();
10736        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10737
10738        let state: (i32, i64, i32) = sqlx::query_as(&format!(
10739            r#"
10740            SELECT current_slot, generation, slot_count
10741            FROM {schema}.queue_ring_state
10742            WHERE singleton = TRUE
10743            FOR UPDATE
10744            "#
10745        ))
10746        .fetch_one(tx.as_mut())
10747        .await
10748        .map_err(map_sqlx_error)?;
10749
10750        let next_slot = (state.0 + 1).rem_euclid(state.2);
10751        let ready_count: i64 = sqlx::query_scalar(&format!(
10752            "SELECT count(*)::bigint FROM {}",
10753            ready_child_name(schema, next_slot as usize)
10754        ))
10755        .fetch_one(tx.as_mut())
10756        .await
10757        .map_err(map_sqlx_error)?;
10758        let done_count: i64 = sqlx::query_scalar(&format!(
10759            "SELECT count(*)::bigint FROM {}",
10760            done_child_name(schema, next_slot as usize)
10761        ))
10762        .fetch_one(tx.as_mut())
10763        .await
10764        .map_err(map_sqlx_error)?;
10765        let tombstone_count: i64 = sqlx::query_scalar(&format!(
10766            "SELECT count(*)::bigint FROM {}",
10767            ready_tombstone_child_name(schema, next_slot as usize)
10768        ))
10769        .fetch_one(tx.as_mut())
10770        .await
10771        .map_err(map_sqlx_error)?;
10772        let terminal_delta_count: i64 = sqlx::query_scalar(&format!(
10773            "SELECT count(*)::bigint FROM {}",
10774            terminal_delta_child_name(schema, next_slot as usize)
10775        ))
10776        .fetch_one(tx.as_mut())
10777        .await
10778        .map_err(map_sqlx_error)?;
10779
10780        if ready_count > 0 || done_count > 0 || tombstone_count > 0 || terminal_delta_count > 0 {
10781            tx.commit().await.map_err(map_sqlx_error)?;
10782            return Ok(RotateOutcome::SkippedBusy {
10783                slot: next_slot,
10784                busy: BusyCounts {
10785                    queue_ready: ready_count,
10786                    queue_done: done_count,
10787                    queue_tombstones: tombstone_count,
10788                    queue_terminal_deltas: terminal_delta_count,
10789                    ..Default::default()
10790                },
10791            });
10792        }
10793
10794        let next_generation = state.1 + 1;
10795
10796        sqlx::query(&format!(
10797            r#"
10798            UPDATE {schema}.queue_ring_state
10799            SET current_slot = $1,
10800                generation = $2
10801            WHERE singleton = TRUE
10802            "#
10803        ))
10804        .bind(next_slot)
10805        .bind(next_generation)
10806        .execute(tx.as_mut())
10807        .await
10808        .map_err(map_sqlx_error)?;
10809
10810        sqlx::query(&format!(
10811            r#"
10812            UPDATE {schema}.queue_ring_slots
10813            SET generation = $2
10814            WHERE slot = $1
10815            "#
10816        ))
10817        .bind(next_slot)
10818        .bind(next_generation)
10819        .execute(tx.as_mut())
10820        .await
10821        .map_err(map_sqlx_error)?;
10822
10823        tx.commit().await.map_err(map_sqlx_error)?;
10824        Ok(RotateOutcome::Rotated {
10825            slot: next_slot,
10826            generation: next_generation,
10827        })
10828    }
10829
10830    #[tracing::instrument(skip(self, pool), name = "queue_storage.rotate_leases")]
10831    pub async fn rotate_leases(&self, pool: &PgPool) -> Result<RotateOutcome, AwaError> {
10832        let schema = self.schema();
10833        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10834
10835        // FOR UPDATE serialises with prune_oldest_leases and parallel
10836        // rotators. Without it two rotators can both pass the busy-check,
10837        // both compute the same next_slot, and the loser's CAS update
10838        // wastes work. `RotateLeasesPlan` in
10839        // `correctness/storage/AwaStorageLockOrder.tla` requires this
10840        // lock as the first acquired resource for the rotation tx.
10841        let state: (i32, i64, i32) = sqlx::query_as(&format!(
10842            r#"
10843            SELECT current_slot, generation, slot_count
10844            FROM {schema}.lease_ring_state
10845            WHERE singleton = TRUE
10846            FOR UPDATE
10847            "#
10848        ))
10849        .fetch_one(tx.as_mut())
10850        .await
10851        .map_err(map_sqlx_error)?;
10852
10853        let next_slot = (state.0 + 1).rem_euclid(state.2);
10854        let lease_count: i64 = sqlx::query_scalar(&format!(
10855            "SELECT count(*)::bigint FROM {}",
10856            lease_child_name(schema, next_slot as usize)
10857        ))
10858        .fetch_one(tx.as_mut())
10859        .await
10860        .map_err(map_sqlx_error)?;
10861
10862        if lease_count > 0 {
10863            tx.commit().await.map_err(map_sqlx_error)?;
10864            return Ok(RotateOutcome::SkippedBusy {
10865                slot: next_slot,
10866                busy: BusyCounts {
10867                    leases: lease_count,
10868                    ..Default::default()
10869                },
10870            });
10871        }
10872
10873        let next_generation = state.1 + 1;
10874
10875        let rotated = sqlx::query(&format!(
10876            r#"
10877            UPDATE {schema}.lease_ring_state
10878            SET current_slot = $1,
10879                generation = $2
10880            WHERE singleton = TRUE
10881              AND current_slot = $3
10882              AND generation = $4
10883            "#
10884        ))
10885        .bind(next_slot)
10886        .bind(next_generation)
10887        .bind(state.0)
10888        .bind(state.1)
10889        .execute(tx.as_mut())
10890        .await
10891        .map_err(map_sqlx_error)?;
10892
10893        if rotated.rows_affected() == 0 {
10894            // Another rotator beat us to the CAS; the row count we sampled
10895            // before is stale. Report the count we did see — it's still the
10896            // best evidence available about what made this attempt give up.
10897            tx.commit().await.map_err(map_sqlx_error)?;
10898            return Ok(RotateOutcome::SkippedBusy {
10899                slot: next_slot,
10900                busy: BusyCounts {
10901                    leases: lease_count,
10902                    ..Default::default()
10903                },
10904            });
10905        }
10906
10907        tx.commit().await.map_err(map_sqlx_error)?;
10908        Ok(RotateOutcome::Rotated {
10909            slot: next_slot,
10910            generation: next_generation,
10911        })
10912    }
10913
10914    /// Rebuild terminal counters from scratch by truncating folded live counts
10915    /// and pending deltas, then re-aggregating `done_entries`. Run this when:
10916    ///
10917    /// - upgrading from a pre-#290 fleet, where in-flight binaries wrote
10918    ///   `done_entries` without maintaining the counter,
10919    /// - after any incident that may have left the counter inconsistent
10920    ///   with `done_entries`, or
10921    /// - as a routine drift-recovery step before relying on counter-fed
10922    ///   reads for billing-grade accuracy.
10923    ///
10924    /// The rebuild is wrapped in an advisory lock so concurrent writers
10925    /// don't interleave new inserts mid-rebuild. The lock key is scoped
10926    /// to the queue-storage schema, so other schemas / other operations
10927    /// are unaffected. Writers that hit the lock will block briefly
10928    /// rather than fail.
10929    ///
10930    /// **Operator note:** this is best run on a quiesced fleet (workers
10931    /// paused or fully drained). Concurrent inserts during the rebuild
10932    /// will block on the lock; long-held locks can stall the fleet. The
10933    /// rebuild itself is O(rows in `done_entries`).
10934    #[tracing::instrument(skip(self, pool), name = "queue_storage.rebuild_terminal_counters")]
10935    pub async fn rebuild_terminal_counters(&self, pool: &PgPool) -> Result<i64, AwaError> {
10936        let schema = self.schema();
10937        let rebuild_lock_name = format!("awa.queue_storage.rebuild_terminal_counters:{schema}");
10938        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
10939
10940        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
10941            .bind(&rebuild_lock_name)
10942            .execute(tx.as_mut())
10943            .await
10944            .map_err(map_sqlx_error)?;
10945
10946        sqlx::query(&format!(
10947            "LOCK TABLE {schema}.done_entries, \
10948             {schema}.queue_terminal_count_deltas, \
10949             {schema}.queue_terminal_live_counts \
10950             IN ACCESS EXCLUSIVE MODE"
10951        ))
10952        .execute(tx.as_mut())
10953        .await
10954        .map_err(map_sqlx_error)?;
10955
10956        sqlx::query(&format!(
10957            "TRUNCATE TABLE {schema}.queue_terminal_live_counts, {schema}.queue_terminal_count_deltas"
10958        ))
10959        .execute(tx.as_mut())
10960        .await
10961        .map_err(map_sqlx_error)?;
10962
10963        let inserted: i64 = sqlx::query_scalar(&format!(
10964            r#"
10965            WITH inserted AS (
10966                INSERT INTO {schema}.queue_terminal_live_counts AS counts (
10967                    ready_slot, queue, priority, enqueue_shard, counter_bucket, live_terminal_count
10968                )
10969                SELECT
10970                    ready_slot,
10971                    queue,
10972                    priority,
10973                    enqueue_shard,
10974                    mod(
10975                        mod(job_id, {TERMINAL_COUNTER_BUCKETS}::bigint)
10976                            + {TERMINAL_COUNTER_BUCKETS}::bigint,
10977                        {TERMINAL_COUNTER_BUCKETS}::bigint
10978                    )::smallint AS counter_bucket,
10979                    count(*)::bigint
10980                FROM {schema}.done_entries
10981                GROUP BY ready_slot, queue, priority, enqueue_shard, counter_bucket
10982                RETURNING 1
10983            )
10984            SELECT COALESCE(count(*), 0)::bigint FROM inserted
10985            "#
10986        ))
10987        .fetch_one(tx.as_mut())
10988        .await
10989        .map_err(map_sqlx_error)?;
10990
10991        // Flip the trust marker. From this point the read path
10992        // (queue_counts_exact) uses the counter directly; before this
10993        // call, it falls back to scanning done_entries.
10994        sqlx::query(&format!(
10995            r#"
10996            UPDATE {schema}.queue_ring_state
10997            SET terminal_counter_trusted_at = now()
10998            WHERE singleton = TRUE
10999            "#
11000        ))
11001        .execute(tx.as_mut())
11002        .await
11003        .map_err(map_sqlx_error)?;
11004
11005        tx.commit().await.map_err(map_sqlx_error)?;
11006        Ok(inserted)
11007    }
11008
11009    /// Check whether the live terminal counter has been marked trusted
11010    /// for exact reads. Returns `true` for fresh installs (the trust
11011    /// marker is auto-set by `prepare_schema` when `done_entries` is
11012    /// empty) and after a successful
11013    /// [`Self::rebuild_terminal_counters`]; returns `false` on an
11014    /// existing install that has not yet been rebuilt under the new
11015    /// counter-aware code path.
11016    ///
11017    /// `queue_counts_exact` consults this to decide between the
11018    /// counter-fed fast path and the scan-based fallback. Single-row
11019    /// PK fetch; negligible cost per call.
11020    pub async fn terminal_counter_trusted(&self, pool: &PgPool) -> Result<bool, AwaError> {
11021        let schema = self.schema();
11022        let trusted: Option<bool> = sqlx::query_scalar(&format!(
11023            "SELECT terminal_counter_trusted_at IS NOT NULL \
11024             FROM {schema}.queue_ring_state WHERE singleton = TRUE"
11025        ))
11026        .fetch_optional(pool)
11027        .await
11028        .map_err(map_sqlx_error)?;
11029        // Missing row = pre-#290 schema that hasn't run the new
11030        // prepare_schema yet. Treat as untrusted; the scan path is
11031        // still correct.
11032        Ok(trusted.unwrap_or(false))
11033    }
11034
11035    /// Fold append-only terminal-count deltas into
11036    /// `queue_terminal_live_counts` for sealed queue slots.
11037    ///
11038    /// Completions and terminal deletes append signed rows into
11039    /// `queue_terminal_count_deltas` instead of updating the live counter on
11040    /// the user-facing hot path. Exact reads sum folded live counts plus
11041    /// pending deltas, so this rollup can run asynchronously. It intentionally
11042    /// skips the current queue slot and any slot with active leases or open
11043    /// receipt claims; that keeps rollup away from the segment receiving hot
11044    /// completions and avoids racing future terminal deltas for the same
11045    /// ready generation. Rollup also stands down while another backend pins the
11046    /// MVCC horizon, leaving the append-only deltas visible to exact reads
11047    /// until the horizon clears.
11048    #[tracing::instrument(skip(self, pool), name = "queue_storage.rollup_terminal_count_deltas")]
11049    pub async fn rollup_terminal_count_deltas(
11050        &self,
11051        pool: &PgPool,
11052        max_slots: usize,
11053    ) -> Result<TerminalDeltaRollupOutcome, AwaError> {
11054        if max_slots == 0 {
11055            return Ok(TerminalDeltaRollupOutcome::default());
11056        }
11057
11058        let schema = self.schema();
11059        let current_slot: i32 = sqlx::query_scalar(&format!(
11060            r#"
11061            SELECT current_slot
11062            FROM {schema}.queue_ring_state
11063            WHERE singleton = TRUE
11064            "#
11065        ))
11066        .fetch_one(pool)
11067        .await
11068        .map_err(map_sqlx_error)?;
11069
11070        let slots = self
11071            .terminal_delta_rollup_candidates(pool, current_slot)
11072            .await?;
11073
11074        let mut outcome = TerminalDeltaRollupOutcome::default();
11075        for (slot, generation) in slots {
11076            if outcome.rolled_slots >= max_slots {
11077                break;
11078            }
11079            match self
11080                .rollup_terminal_count_delta_slot(pool, slot, generation)
11081                .await?
11082            {
11083                TerminalDeltaSlotRollup::Empty => {}
11084                TerminalDeltaSlotRollup::Rolled {
11085                    delta_rows,
11086                    grouped_keys,
11087                } => {
11088                    outcome.rolled_slots += 1;
11089                    outcome.delta_rows += delta_rows;
11090                    outcome.grouped_keys += grouped_keys;
11091                }
11092                TerminalDeltaSlotRollup::SkippedActive => {
11093                    outcome.skipped_active_slots += 1;
11094                }
11095                TerminalDeltaSlotRollup::SkippedMvccPinned => {
11096                    outcome.skipped_mvcc_pinned = true;
11097                    break;
11098                }
11099                TerminalDeltaSlotRollup::Blocked => {
11100                    outcome.blocked_slots += 1;
11101                }
11102            }
11103        }
11104
11105        Ok(outcome)
11106    }
11107
11108    async fn terminal_delta_rollup_mvcc_horizon_pinned_tx(
11109        tx: &mut sqlx::Transaction<'_, Postgres>,
11110    ) -> Result<bool, AwaError> {
11111        let pinned: bool = sqlx::query_scalar(
11112            r#"
11113            SELECT EXISTS (
11114                SELECT 1
11115                FROM pg_stat_activity
11116                WHERE datname = current_database()
11117                  AND pid <> pg_backend_pid()
11118                  AND backend_type = 'client backend'
11119                  AND (
11120                      backend_xmin IS NOT NULL
11121                      OR (
11122                          backend_xid IS NOT NULL
11123                          AND state LIKE 'idle in transaction%'
11124                      )
11125                  )
11126            )
11127            "#,
11128        )
11129        .fetch_one(tx.as_mut())
11130        .await
11131        .map_err(map_sqlx_error)?;
11132        Ok(pinned)
11133    }
11134
11135    async fn terminal_delta_rollup_candidates(
11136        &self,
11137        pool: &PgPool,
11138        current_slot: i32,
11139    ) -> Result<Vec<(i32, i64)>, AwaError> {
11140        let schema = self.schema();
11141        let sealed_slots: Vec<(i32, i64)> = sqlx::query_as(&format!(
11142            r#"
11143            SELECT slot, generation
11144            FROM {schema}.queue_ring_slots
11145            WHERE generation >= 0
11146              AND slot <> $1
11147            ORDER BY generation ASC, slot ASC
11148            "#
11149        ))
11150        .bind(current_slot)
11151        .fetch_all(pool)
11152        .await
11153        .map_err(map_sqlx_error)?;
11154
11155        let mut pending_slots = Vec::new();
11156        for (slot, generation) in sealed_slots {
11157            let Ok(slot_index) = usize::try_from(slot) else {
11158                continue;
11159            };
11160            let delta_child = terminal_delta_child_name(schema, slot_index);
11161            let has_pending: bool = sqlx::query_scalar(&format!(
11162                r#"
11163                SELECT EXISTS (
11164                    SELECT 1
11165                    FROM {delta_child}
11166                    WHERE ready_generation = $1
11167                    LIMIT 1
11168                )
11169                "#
11170            ))
11171            .bind(generation)
11172            .fetch_one(pool)
11173            .await
11174            .map_err(map_sqlx_error)?;
11175            if has_pending {
11176                pending_slots.push((slot, generation));
11177            }
11178        }
11179
11180        Ok(pending_slots)
11181    }
11182
11183    async fn rollup_terminal_count_delta_slot(
11184        &self,
11185        pool: &PgPool,
11186        slot: i32,
11187        generation: i64,
11188    ) -> Result<TerminalDeltaSlotRollup, AwaError> {
11189        let schema = self.schema();
11190        let delta_child = terminal_delta_child_name(schema, slot as usize);
11191        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
11192
11193        let current_slot: i32 = sqlx::query_scalar(&format!(
11194            r#"
11195            SELECT current_slot
11196            FROM {schema}.queue_ring_state
11197            WHERE singleton = TRUE
11198            FOR UPDATE
11199            "#
11200        ))
11201        .fetch_one(tx.as_mut())
11202        .await
11203        .map_err(map_sqlx_error)?;
11204
11205        let slot_generation: Option<i64> = sqlx::query_scalar(&format!(
11206            r#"
11207            SELECT generation
11208            FROM {schema}.queue_ring_slots
11209            WHERE slot = $1
11210            FOR UPDATE
11211            "#
11212        ))
11213        .bind(slot)
11214        .fetch_optional(tx.as_mut())
11215        .await
11216        .map_err(map_sqlx_error)?;
11217
11218        let Some(slot_generation) = slot_generation else {
11219            tx.commit().await.map_err(map_sqlx_error)?;
11220            return Ok(TerminalDeltaSlotRollup::Empty);
11221        };
11222
11223        if current_slot == slot {
11224            tx.commit().await.map_err(map_sqlx_error)?;
11225            return Ok(TerminalDeltaSlotRollup::SkippedActive);
11226        }
11227
11228        if slot_generation != generation {
11229            tx.commit().await.map_err(map_sqlx_error)?;
11230            return Ok(TerminalDeltaSlotRollup::Empty);
11231        }
11232
11233        if Self::terminal_delta_rollup_mvcc_horizon_pinned_tx(&mut tx).await? {
11234            tx.commit().await.map_err(map_sqlx_error)?;
11235            return Ok(TerminalDeltaSlotRollup::SkippedMvccPinned);
11236        }
11237
11238        sqlx::query("SET LOCAL lock_timeout = '50ms'")
11239            .execute(tx.as_mut())
11240            .await
11241            .map_err(map_sqlx_error)?;
11242
11243        let lock_delta = sqlx::query(&format!(
11244            "LOCK TABLE {delta_child} IN ACCESS EXCLUSIVE MODE"
11245        ))
11246        .execute(tx.as_mut())
11247        .await;
11248
11249        match lock_delta {
11250            Ok(_) => {}
11251            Err(err) if is_lock_contention_error(&err) => {
11252                let _ = tx.rollback().await;
11253                return Ok(TerminalDeltaSlotRollup::Blocked);
11254            }
11255            Err(err) => {
11256                let _ = tx.rollback().await;
11257                return Err(map_sqlx_error(err));
11258            }
11259        }
11260
11261        let active_refs: i64 = sqlx::query_scalar(&format!(
11262            r#"
11263            SELECT
11264                COALESCE((
11265                    SELECT count(*)::bigint
11266                    FROM {schema}.leases
11267                    WHERE ready_slot = $1
11268                      AND ready_generation = $2
11269                ), 0)
11270                +
11271                COALESCE((
11272                    SELECT count(*)::bigint
11273                    FROM {schema}.lease_claims AS claims
11274                    WHERE claims.ready_slot = $1
11275                      AND claims.ready_generation = $2
11276                      AND NOT EXISTS (
11277                          SELECT 1
11278                          FROM {schema}.lease_claim_closures AS closures
11279                          WHERE closures.claim_slot = claims.claim_slot
11280                            AND closures.job_id = claims.job_id
11281                            AND closures.run_lease = claims.run_lease
11282                      )
11283                ), 0)
11284            "#
11285        ))
11286        .bind(slot)
11287        .bind(generation)
11288        .fetch_one(tx.as_mut())
11289        .await
11290        .map_err(map_sqlx_error)?;
11291
11292        if active_refs > 0 {
11293            tx.commit().await.map_err(map_sqlx_error)?;
11294            return Ok(TerminalDeltaSlotRollup::SkippedActive);
11295        }
11296
11297        let delta_rows: i64 = sqlx::query_scalar(&format!(
11298            r#"
11299            SELECT count(*)::bigint
11300            FROM {delta_child}
11301            WHERE ready_generation = $1
11302            "#
11303        ))
11304        .bind(generation)
11305        .fetch_one(tx.as_mut())
11306        .await
11307        .map_err(map_sqlx_error)?;
11308
11309        if delta_rows == 0 {
11310            tx.commit().await.map_err(map_sqlx_error)?;
11311            return Ok(TerminalDeltaSlotRollup::Empty);
11312        }
11313
11314        let grouped_keys: i64 = sqlx::query_scalar(&format!(
11315            r#"
11316            WITH grouped AS MATERIALIZED (
11317                SELECT
11318                    ready_slot,
11319                    queue,
11320                    priority,
11321                    enqueue_shard,
11322                    counter_bucket,
11323                    SUM(terminal_delta)::bigint AS delta
11324                FROM {delta_child}
11325                WHERE ready_generation = $1
11326                GROUP BY ready_slot, queue, priority, enqueue_shard, counter_bucket
11327                HAVING SUM(terminal_delta) <> 0
11328            ),
11329            updated AS (
11330                UPDATE {schema}.queue_terminal_live_counts AS counts
11331                SET live_terminal_count = GREATEST(0, counts.live_terminal_count + grouped.delta)
11332                FROM grouped
11333                WHERE counts.ready_slot = grouped.ready_slot
11334                  AND counts.queue = grouped.queue
11335                  AND counts.priority = grouped.priority
11336                  AND counts.enqueue_shard = grouped.enqueue_shard
11337                  AND counts.counter_bucket = grouped.counter_bucket
11338                RETURNING
11339                    counts.ready_slot,
11340                    counts.queue,
11341                    counts.priority,
11342                    counts.enqueue_shard,
11343                    counts.counter_bucket
11344            ),
11345            inserted AS (
11346                INSERT INTO {schema}.queue_terminal_live_counts AS counts (
11347                    ready_slot,
11348                    queue,
11349                    priority,
11350                    enqueue_shard,
11351                    counter_bucket,
11352                    live_terminal_count
11353                )
11354                SELECT
11355                    grouped.ready_slot,
11356                    grouped.queue,
11357                    grouped.priority,
11358                    grouped.enqueue_shard,
11359                    grouped.counter_bucket,
11360                    grouped.delta
11361                FROM grouped
11362                WHERE grouped.delta > 0
11363                  AND NOT EXISTS (
11364                      SELECT 1
11365                      FROM updated
11366                      WHERE updated.ready_slot = grouped.ready_slot
11367                        AND updated.queue = grouped.queue
11368                        AND updated.priority = grouped.priority
11369                        AND updated.enqueue_shard = grouped.enqueue_shard
11370                        AND updated.counter_bucket = grouped.counter_bucket
11371                  )
11372                ORDER BY ready_slot, queue, priority, enqueue_shard, counter_bucket
11373                ON CONFLICT (ready_slot, queue, priority, enqueue_shard, counter_bucket) DO UPDATE
11374                SET live_terminal_count =
11375                    GREATEST(0, counts.live_terminal_count + EXCLUDED.live_terminal_count)
11376                RETURNING 1
11377            )
11378            SELECT count(*)::bigint FROM grouped
11379            "#
11380        ))
11381        .bind(generation)
11382        .fetch_one(tx.as_mut())
11383        .await
11384        .map_err(map_sqlx_error)?;
11385
11386        let truncate_delta = sqlx::query(&format!("TRUNCATE TABLE {delta_child}"))
11387            .execute(tx.as_mut())
11388            .await;
11389
11390        match truncate_delta {
11391            Ok(_) => {
11392                tx.commit().await.map_err(map_sqlx_error)?;
11393                Ok(TerminalDeltaSlotRollup::Rolled {
11394                    delta_rows,
11395                    grouped_keys,
11396                })
11397            }
11398            Err(err) if is_lock_contention_error(&err) => {
11399                let _ = tx.rollback().await;
11400                Ok(TerminalDeltaSlotRollup::Blocked)
11401            }
11402            Err(err) => {
11403                let _ = tx.rollback().await;
11404                Err(map_sqlx_error(err))
11405            }
11406        }
11407    }
11408
11409    #[tracing::instrument(skip(self, pool), name = "queue_storage.prune_oldest")]
11410    pub async fn prune_oldest(&self, pool: &PgPool) -> Result<PruneOutcome, AwaError> {
11411        let schema = self.schema();
11412        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
11413
11414        let state: (i32,) = sqlx::query_as(&format!(
11415            r#"
11416            SELECT current_slot
11417            FROM {schema}.queue_ring_state
11418            WHERE singleton = TRUE
11419            FOR UPDATE
11420            "#
11421        ))
11422        .fetch_one(tx.as_mut())
11423        .await
11424        .map_err(map_sqlx_error)?;
11425
11426        let target: Option<(i32, i64)> = sqlx::query_as(&format!(
11427            r#"
11428            SELECT slot, generation
11429            FROM {schema}.queue_ring_slots
11430            WHERE generation >= 0
11431              AND slot <> $1
11432            ORDER BY generation ASC, slot ASC
11433            LIMIT 1
11434            FOR UPDATE
11435            "#
11436        ))
11437        .bind(state.0)
11438        .fetch_optional(tx.as_mut())
11439        .await
11440        .map_err(map_sqlx_error)?;
11441
11442        let Some((slot, generation)) = target else {
11443            tx.commit().await.map_err(map_sqlx_error)?;
11444            return Ok(PruneOutcome::Noop);
11445        };
11446
11447        let ready_child = ready_child_name(schema, slot as usize);
11448        let done_child = done_child_name(schema, slot as usize);
11449        let tomb_child = ready_tombstone_child_name(schema, slot as usize);
11450        let delta_child = terminal_delta_child_name(schema, slot as usize);
11451
11452        sqlx::query("SET LOCAL lock_timeout = '50ms'")
11453            .execute(tx.as_mut())
11454            .await
11455            .map_err(map_sqlx_error)?;
11456
11457        let lock_tables = sqlx::query(&format!(
11458            "LOCK TABLE {ready_child}, {done_child}, {tomb_child}, {delta_child} IN ACCESS EXCLUSIVE MODE"
11459        ))
11460        .execute(tx.as_mut())
11461        .await;
11462
11463        if lock_tables.is_err() {
11464            let _ = tx.rollback().await;
11465            return Ok(PruneOutcome::Blocked { slot });
11466        }
11467
11468        let active_leases: i64 = sqlx::query_scalar(&format!(
11469            r#"
11470            SELECT count(*)::bigint
11471            FROM {schema}.leases
11472            WHERE ready_slot = $1
11473              AND ready_generation = $2
11474            "#
11475        ))
11476        .bind(slot)
11477        .bind(generation)
11478        .fetch_one(tx.as_mut())
11479        .await
11480        .map_err(map_sqlx_error)?;
11481
11482        if active_leases > 0 {
11483            tx.commit().await.map_err(map_sqlx_error)?;
11484            return Ok(PruneOutcome::SkippedActive {
11485                slot,
11486                reason: SkipReason::QueueActiveLeases,
11487                count: active_leases,
11488            });
11489        }
11490
11491        let pending: i64 = sqlx::query_scalar(&format!(
11492            r#"
11493            SELECT count(*)::bigint
11494            FROM {ready_child} AS ready
11495            LEFT JOIN {done_child} AS done
11496              ON done.ready_generation = ready.ready_generation
11497             AND done.queue = ready.queue
11498             AND done.priority = ready.priority
11499             AND done.enqueue_shard = ready.enqueue_shard
11500             AND done.lane_seq = ready.lane_seq
11501            LEFT JOIN {tomb_child} AS tomb
11502              ON tomb.ready_generation = ready.ready_generation
11503             AND tomb.queue = ready.queue
11504             AND tomb.priority = ready.priority
11505             AND tomb.enqueue_shard = ready.enqueue_shard
11506             AND tomb.lane_seq = ready.lane_seq
11507            WHERE done.lane_seq IS NULL
11508              AND tomb.lane_seq IS NULL
11509            "#
11510        ))
11511        .fetch_one(tx.as_mut())
11512        .await
11513        .map_err(map_sqlx_error)?;
11514
11515        if pending > 0 {
11516            tx.commit().await.map_err(map_sqlx_error)?;
11517            return Ok(PruneOutcome::SkippedActive {
11518                slot,
11519                reason: SkipReason::QueuePendingReady,
11520                count: pending,
11521            });
11522        }
11523
11524        // #290: scan the about-to-be-truncated partition for the rollup
11525        // fold. The rollup column is *permanent* state, so we MUST fold
11526        // from ground truth (the `{done_child}` partition itself), not
11527        // from `queue_terminal_live_counts` — the counter can drift
11528        // briefly during a rolling upgrade from a pre-counter binary
11529        // and folding drift into the rollup would bake it in forever.
11530        // The counter rows for this slot are still cleaned up after the
11531        // truncate; reads from the counter (queue_counts_exact in #305)
11532        // may transiently disagree with the rollup until the operator
11533        // runs `awa storage rebuild-terminal-counters`, but the rollup
11534        // itself stays authoritative. See PR #304 reviewer finding
11535        // "High: A1 can persist stale counter state before the
11536        // read-switch PR" for the trade-off.
11537        let pruned_terminal_counts: Vec<(String, i16, i64)> = sqlx::query_as(&format!(
11538            r#"
11539            SELECT queue, priority, count(*)::bigint AS pruned_count
11540            FROM {done_child}
11541            GROUP BY queue, priority
11542            "#
11543        ))
11544        .fetch_all(tx.as_mut())
11545        .await
11546        .map_err(map_sqlx_error)?;
11547
11548        let truncate = sqlx::query(&format!(
11549            "TRUNCATE TABLE {ready_child}, {done_child}, {tomb_child}, {delta_child}"
11550        ))
11551        .execute(tx.as_mut())
11552        .await;
11553
11554        match truncate {
11555            Ok(_) => {
11556                if !pruned_terminal_counts.is_empty() {
11557                    self.adjust_terminal_rollups_batch(&mut tx, pruned_terminal_counts.into_iter())
11558                        .await?;
11559                }
11560                // #290: the live counter rows for this slot are about to
11561                // be orphans (their underlying done_entries rows just got
11562                // truncated). Delete them in the same transaction. The
11563                // rollup fold above already captured ground-truth from
11564                // the partition scan; this just cleans up the counter
11565                // index entries so a future insert into a re-rotated
11566                // slot starts from zero.
11567                sqlx::query(&format!(
11568                    "DELETE FROM {schema}.queue_terminal_live_counts WHERE ready_slot = $1"
11569                ))
11570                .bind(slot)
11571                .execute(tx.as_mut())
11572                .await
11573                .map_err(map_sqlx_error)?;
11574                tx.commit().await.map_err(map_sqlx_error)?;
11575                Ok(PruneOutcome::Pruned { slot })
11576            }
11577            Err(_) => {
11578                let _ = tx.rollback().await;
11579                Ok(PruneOutcome::Blocked { slot })
11580            }
11581        }
11582    }
11583
11584    #[tracing::instrument(skip(self, pool), name = "queue_storage.prune_oldest_leases")]
11585    pub async fn prune_oldest_leases(&self, pool: &PgPool) -> Result<PruneOutcome, AwaError> {
11586        let schema = self.schema();
11587        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
11588
11589        // `PruneLeasesPlan` in
11590        // `correctness/storage/AwaStorageLockOrder.tla` requires the
11591        // sequence `lease_ring_state FOR UPDATE` →
11592        // `lease_ring_slots[slot] FOR UPDATE` → `ACCESS EXCLUSIVE` on
11593        // the child. Without these locks a concurrent rotator can flip
11594        // the cursor under the prune's liveness check (current_slot
11595        // recheck races a CAS update) and prune what should be the
11596        // active partition.
11597        let state: (i32, i64, i32) = sqlx::query_as(&format!(
11598            r#"
11599            SELECT current_slot, generation, slot_count
11600            FROM {schema}.lease_ring_state
11601            WHERE singleton = TRUE
11602            FOR UPDATE
11603            "#
11604        ))
11605        .fetch_one(tx.as_mut())
11606        .await
11607        .map_err(map_sqlx_error)?;
11608
11609        let Some((slot, _generation)) = oldest_initialized_ring_slot(state.0, state.1, state.2)
11610        else {
11611            tx.commit().await.map_err(map_sqlx_error)?;
11612            return Ok(PruneOutcome::Noop);
11613        };
11614
11615        let slot_locked: Option<i32> = sqlx::query_scalar(&format!(
11616            r#"
11617            SELECT slot FROM {schema}.lease_ring_slots
11618            WHERE slot = $1
11619            FOR UPDATE
11620            "#
11621        ))
11622        .bind(slot)
11623        .fetch_optional(tx.as_mut())
11624        .await
11625        .map_err(map_sqlx_error)?;
11626
11627        if slot_locked.is_none() {
11628            tx.commit().await.map_err(map_sqlx_error)?;
11629            return Ok(PruneOutcome::Noop);
11630        }
11631
11632        let lease_child = lease_child_name(schema, slot as usize);
11633
11634        sqlx::query("SET LOCAL lock_timeout = '50ms'")
11635            .execute(tx.as_mut())
11636            .await
11637            .map_err(map_sqlx_error)?;
11638
11639        let lock_table = sqlx::query(&format!(
11640            "LOCK TABLE {lease_child} IN ACCESS EXCLUSIVE MODE"
11641        ))
11642        .execute(tx.as_mut())
11643        .await;
11644
11645        if lock_table.is_err() {
11646            let _ = tx.rollback().await;
11647            return Ok(PruneOutcome::Blocked { slot });
11648        }
11649
11650        let current_slot: i32 = sqlx::query_scalar(&format!(
11651            r#"
11652            SELECT current_slot
11653            FROM {schema}.lease_ring_state
11654            WHERE singleton = TRUE
11655            "#
11656        ))
11657        .fetch_one(tx.as_mut())
11658        .await
11659        .map_err(map_sqlx_error)?;
11660
11661        if current_slot == slot {
11662            tx.commit().await.map_err(map_sqlx_error)?;
11663            return Ok(PruneOutcome::SkippedActive {
11664                slot,
11665                reason: SkipReason::LeaseCurrent,
11666                count: 0,
11667            });
11668        }
11669
11670        let active_leases: i64 =
11671            sqlx::query_scalar(&format!("SELECT count(*)::bigint FROM {lease_child}"))
11672                .fetch_one(tx.as_mut())
11673                .await
11674                .map_err(map_sqlx_error)?;
11675
11676        if active_leases > 0 {
11677            tx.commit().await.map_err(map_sqlx_error)?;
11678            return Ok(PruneOutcome::SkippedActive {
11679                slot,
11680                reason: SkipReason::LeaseActive,
11681                count: active_leases,
11682            });
11683        }
11684
11685        let truncate = sqlx::query(&format!("TRUNCATE TABLE {lease_child}"))
11686            .execute(tx.as_mut())
11687            .await;
11688
11689        match truncate {
11690            Ok(_) => {
11691                tx.commit().await.map_err(map_sqlx_error)?;
11692                Ok(PruneOutcome::Pruned { slot })
11693            }
11694            Err(_) => {
11695                let _ = tx.rollback().await;
11696                Ok(PruneOutcome::Blocked { slot })
11697            }
11698        }
11699    }
11700
11701    pub async fn vacuum_leases(&self, pool: &PgPool) -> Result<(), AwaError> {
11702        sqlx::query(&format!("VACUUM {}", self.leases_table()))
11703            .execute(pool)
11704            .await
11705            .map_err(map_sqlx_error)?;
11706        Ok(())
11707    }
11708
11709    /// ADR-023 claim-ring rotation. Parallel of `rotate_leases`.
11710    ///
11711    /// Advances `claim_ring_state.current_slot` via compare-and-swap. Before
11712    /// flipping the cursor the target partition must be drained: both the
11713    /// `lease_claims_<next>` and `lease_claim_closures_<next>` child tables
11714    /// must be empty. This is what the `rotate → prune → rotate` ring
11715    /// invariant requires — we only hand out a slot to new claims when a
11716    /// prior prune has truncated it.
11717    #[tracing::instrument(skip(self, pool), name = "queue_storage.rotate_claims")]
11718    pub async fn rotate_claims(&self, pool: &PgPool) -> Result<RotateOutcome, AwaError> {
11719        let schema = self.schema();
11720        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
11721
11722        let state: (i32, i64, i32) = sqlx::query_as(&format!(
11723            r#"
11724            SELECT current_slot, generation, slot_count
11725            FROM {schema}.claim_ring_state
11726            WHERE singleton = TRUE
11727            FOR UPDATE
11728            "#
11729        ))
11730        .fetch_one(tx.as_mut())
11731        .await
11732        .map_err(map_sqlx_error)?;
11733
11734        let next_slot = (state.0 + 1).rem_euclid(state.2);
11735
11736        // Busy check: both children of the incoming slot must be empty.
11737        // A non-empty `lease_claims_<next>` means the previous lap's
11738        // prune hasn't run (or didn't complete); rotating anyway would
11739        // mix fresh claims with legacy rows and defeat the point of
11740        // partitioning. Non-empty `lease_claim_closures_<next>` means
11741        // prune fell behind on closures specifically.
11742        let claim_count: i64 = sqlx::query_scalar(&format!(
11743            "SELECT count(*)::bigint FROM {}",
11744            claim_child_name(schema, next_slot as usize)
11745        ))
11746        .fetch_one(tx.as_mut())
11747        .await
11748        .map_err(map_sqlx_error)?;
11749
11750        let closure_count: i64 = sqlx::query_scalar(&format!(
11751            "SELECT count(*)::bigint FROM {}",
11752            closure_child_name(schema, next_slot as usize)
11753        ))
11754        .fetch_one(tx.as_mut())
11755        .await
11756        .map_err(map_sqlx_error)?;
11757
11758        if claim_count > 0 || closure_count > 0 {
11759            tx.commit().await.map_err(map_sqlx_error)?;
11760            return Ok(RotateOutcome::SkippedBusy {
11761                slot: next_slot,
11762                busy: BusyCounts {
11763                    claims: claim_count,
11764                    closures: closure_count,
11765                    ..Default::default()
11766                },
11767            });
11768        }
11769
11770        let next_generation = state.1 + 1;
11771
11772        let rotated = sqlx::query(&format!(
11773            r#"
11774            UPDATE {schema}.claim_ring_state
11775            SET current_slot = $1,
11776                generation = $2
11777            WHERE singleton = TRUE
11778              AND current_slot = $3
11779              AND generation = $4
11780            "#
11781        ))
11782        .bind(next_slot)
11783        .bind(next_generation)
11784        .bind(state.0)
11785        .bind(state.1)
11786        .execute(tx.as_mut())
11787        .await
11788        .map_err(map_sqlx_error)?;
11789
11790        if rotated.rows_affected() == 0 {
11791            // Lost the CAS race; the row counts we sampled may now be
11792            // stale, but they're the best evidence about why this attempt
11793            // gave up.
11794            tx.commit().await.map_err(map_sqlx_error)?;
11795            return Ok(RotateOutcome::SkippedBusy {
11796                slot: next_slot,
11797                busy: BusyCounts {
11798                    claims: claim_count,
11799                    closures: closure_count,
11800                    ..Default::default()
11801                },
11802            });
11803        }
11804
11805        tx.commit().await.map_err(map_sqlx_error)?;
11806        Ok(RotateOutcome::Rotated {
11807            slot: next_slot,
11808            generation: next_generation,
11809        })
11810    }
11811
11812    /// ADR-023 claim-ring prune. Parallel of `prune_oldest_leases`.
11813    ///
11814    /// Reclaims the oldest initialized (sealed) claim-ring slot by
11815    /// `TRUNCATE`-ing both its `lease_claims_<slot>` and
11816    /// `lease_claim_closures_<slot>` children. Takes the full ADR-023
11817    /// lock sequence:
11818    ///
11819    /// 1. `FOR UPDATE` on `claim_ring_state` (serialises with rotate).
11820    /// 2. `FOR UPDATE` on the target `claim_ring_slots` row.
11821    /// 3. `SET LOCAL lock_timeout = '50ms'` then `LOCK TABLE ACCESS
11822    ///    EXCLUSIVE` on both children (serialises with in-flight
11823    ///    claim/complete/rescue writers; gives up gracefully under
11824    ///    contention).
11825    /// 4. Verifies the slot is not the current one, and that every
11826    ///    claim in the partition has a matching closure row.
11827    /// 5. `TRUNCATE` both children in a single statement.
11828    ///
11829    /// The "every claim has a closure" precondition is what ADR-023
11830    /// calls `PartitionTruncateSafety`. If an open claim remains in the
11831    /// partition, prune returns `SkippedActive` and the claim has to
11832    /// drain by normal completion or be rescued by
11833    /// `rescue_stale_receipt_claims_tx` before prune will try again.
11834    #[tracing::instrument(skip(self, pool), name = "queue_storage.prune_oldest_claims")]
11835    pub async fn prune_oldest_claims(&self, pool: &PgPool) -> Result<PruneOutcome, AwaError> {
11836        let schema = self.schema();
11837        let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
11838
11839        let state: (i32, i64, i32) = sqlx::query_as(&format!(
11840            r#"
11841            SELECT current_slot, generation, slot_count
11842            FROM {schema}.claim_ring_state
11843            WHERE singleton = TRUE
11844            FOR UPDATE
11845            "#
11846        ))
11847        .fetch_one(tx.as_mut())
11848        .await
11849        .map_err(map_sqlx_error)?;
11850
11851        let Some((slot, _generation)) = oldest_initialized_ring_slot(state.0, state.1, state.2)
11852        else {
11853            tx.commit().await.map_err(map_sqlx_error)?;
11854            return Ok(PruneOutcome::Noop);
11855        };
11856
11857        // Lock the slot row so concurrent rotate/prune observe the same
11858        // state machine transition.
11859        let slot_locked: Option<i32> = sqlx::query_scalar(&format!(
11860            r#"
11861            SELECT slot FROM {schema}.claim_ring_slots
11862            WHERE slot = $1
11863            FOR UPDATE
11864            "#
11865        ))
11866        .bind(slot)
11867        .fetch_optional(tx.as_mut())
11868        .await
11869        .map_err(map_sqlx_error)?;
11870
11871        if slot_locked.is_none() {
11872            tx.commit().await.map_err(map_sqlx_error)?;
11873            return Ok(PruneOutcome::Noop);
11874        }
11875
11876        let claim_child = claim_child_name(schema, slot as usize);
11877        let closure_child = closure_child_name(schema, slot as usize);
11878
11879        sqlx::query("SET LOCAL lock_timeout = '50ms'")
11880            .execute(tx.as_mut())
11881            .await
11882            .map_err(map_sqlx_error)?;
11883
11884        let lock_tables = sqlx::query(&format!(
11885            "LOCK TABLE {claim_child}, {closure_child} IN ACCESS EXCLUSIVE MODE"
11886        ))
11887        .execute(tx.as_mut())
11888        .await;
11889
11890        if lock_tables.is_err() {
11891            let _ = tx.rollback().await;
11892            return Ok(PruneOutcome::Blocked { slot });
11893        }
11894
11895        // After taking ACCESS EXCLUSIVE, recheck that the slot is not
11896        // the current one (rotate may have won the ring-state lock
11897        // earlier).
11898        let current_slot: i32 = sqlx::query_scalar(&format!(
11899            r#"
11900            SELECT current_slot FROM {schema}.claim_ring_state WHERE singleton = TRUE
11901            "#
11902        ))
11903        .fetch_one(tx.as_mut())
11904        .await
11905        .map_err(map_sqlx_error)?;
11906
11907        if current_slot == slot {
11908            tx.commit().await.map_err(map_sqlx_error)?;
11909            return Ok(PruneOutcome::SkippedActive {
11910                slot,
11911                reason: SkipReason::ClaimCurrent,
11912                count: 0,
11913            });
11914        }
11915
11916        // `PartitionTruncateSafety`: every claim in this partition must
11917        // have a matching closure. Any open claim means a worker is
11918        // still running (or a rescue hasn't fired yet); we bail and let
11919        // normal lifecycle drain the partition.
11920        let open_claims: i64 = sqlx::query_scalar(&format!(
11921            r#"
11922            SELECT count(*)::bigint
11923            FROM {claim_child} AS claims
11924            WHERE NOT EXISTS (
11925                SELECT 1 FROM {closure_child} AS closures
11926                WHERE closures.claim_slot = claims.claim_slot
11927                  AND closures.job_id = claims.job_id
11928                  AND closures.run_lease = claims.run_lease
11929            )
11930            "#
11931        ))
11932        .fetch_one(tx.as_mut())
11933        .await
11934        .map_err(map_sqlx_error)?;
11935
11936        if open_claims > 0 {
11937            tx.commit().await.map_err(map_sqlx_error)?;
11938            return Ok(PruneOutcome::SkippedActive {
11939                slot,
11940                reason: SkipReason::ClaimOpen,
11941                count: open_claims,
11942            });
11943        }
11944
11945        let truncate = sqlx::query(&format!("TRUNCATE TABLE {claim_child}, {closure_child}"))
11946            .execute(tx.as_mut())
11947            .await;
11948
11949        match truncate {
11950            Ok(_) => {
11951                tx.commit().await.map_err(map_sqlx_error)?;
11952                Ok(PruneOutcome::Pruned { slot })
11953            }
11954            Err(_) => {
11955                let _ = tx.rollback().await;
11956                Ok(PruneOutcome::Blocked { slot })
11957            }
11958        }
11959    }
11960
11961    fn job_id_sequence(&self) -> String {
11962        format!("{}.job_id_seq", self.schema())
11963    }
11964
11965    fn leases_table(&self) -> String {
11966        format!("{}.{}", self.schema(), self.leases_relname())
11967    }
11968
11969    fn attempt_state_table(&self) -> String {
11970        format!("{}.{}", self.schema(), self.attempt_state_relname())
11971    }
11972}