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
27pub 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 pub claim_slot_count: usize,
65 pub queue_stripe_count: usize,
66 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 pub claim_slot: i32,
102 pub lease_claim_receipt: bool,
103 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 pub terminal: i64,
209}
210
211#[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 SkippedBusy {
237 slot: i32,
238 busy: BusyCounts,
239 },
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub struct BusyCounts {
248 pub queue_ready: i64,
250 pub queue_done: i64,
252 pub queue_tombstones: i64,
254 pub queue_terminal_deltas: i64,
256 pub leases: i64,
258 pub claims: i64,
260 pub closures: i64,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum PruneOutcome {
266 Noop,
267 Pruned {
268 slot: i32,
269 },
270 Blocked {
272 slot: i32,
273 },
274 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum SkipReason {
311 QueueActiveLeases,
313 QueuePendingReady,
315 LeaseCurrent,
317 LeaseActive,
319 ClaimCurrent,
321 ClaimOpen,
323}
324
325impl SkipReason {
326 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 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: 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: 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#[derive(Debug)]
1834pub struct QueueStorage {
1835 config: QueueStorageConfig,
1836 next_stripe_probe: AtomicUsize,
1837 shard_rotor: AtomicU16,
1841 enqueue_shards_cache: Mutex<HashMap<String, i16>>,
1846 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(©_sql)
3376 .await
3377 .map_err(map_sqlx_error)?;
3378 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 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 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 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(©_sql)
3757 .await
3758 .map_err(map_sqlx_error)?;
3759 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 self.increment_live_terminal_counters_tx(tx, rows).await?;
3911
3912 Ok(rows.len())
3913 }
3914
3915 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[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 let available = self.queue_claimer_signal(pool, queue).await?.available;
6181 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 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 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 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 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 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 self.close_receipt_tx(tx, lease.job_id, lease.run_lease, "cancelled")
6616 .await?;
6617 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 updated += self
9561 .upsert_attempt_state_from_receipts_tx(&mut tx, jobs)
9562 .await?;
9563 } else {
9564 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 self.upsert_attempt_state_progress_from_receipts_tx(&mut tx, jobs)
9616 .await?
9617 } else {
9618 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 Ok(trusted.unwrap_or(false))
11033 }
11034
11035 #[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 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 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 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 #[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 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 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 #[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 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 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 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}