1use crate::error::AwaError;
2use crate::job::{JobRow, JobState};
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5use sqlx::types::Json;
6use sqlx::PgExecutor;
7use sqlx::PgPool;
8use std::cmp::max;
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct JobTimelineEvent {
16 pub timestamp: DateTime<Utc>,
17 pub label: String,
18 pub state: Option<JobState>,
19 pub detail: Option<String>,
20 pub is_error: bool,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct JobDumpSummary {
25 pub original_priority: i16,
26 pub can_retry: bool,
27 pub can_cancel: bool,
28 pub error_count: usize,
29 pub latest_error: Option<serde_json::Value>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct JobDump {
34 pub job: JobRow,
35 pub summary: JobDumpSummary,
36 pub timeline: Vec<JobTimelineEvent>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "snake_case")]
41pub enum RunDumpSource {
42 CurrentJobRow,
43 ErrorHistory,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct CallbackDump {
48 pub callback_id: Option<Uuid>,
49 pub callback_timeout_at: Option<DateTime<Utc>>,
50 pub callback_filter: Option<String>,
51 pub callback_on_complete: Option<String>,
52 pub callback_on_fail: Option<String>,
53 pub callback_transform: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct RunDump {
58 pub job_id: i64,
59 pub kind: String,
60 pub queue: String,
61 pub selected_attempt: i16,
62 pub current_attempt: i16,
63 pub current_run_lease: i64,
64 pub selected_run_lease: Option<i64>,
65 pub source: RunDumpSource,
66 pub state: JobState,
67 pub started_at: Option<DateTime<Utc>>,
68 pub finished_at: Option<DateTime<Utc>>,
69 pub error: Option<String>,
70 pub terminal: Option<bool>,
71 pub progress: Option<serde_json::Value>,
72 pub metadata: Option<serde_json::Value>,
73 pub callback: Option<CallbackDump>,
74 pub raw_error_entry: Option<serde_json::Value>,
75 pub notes: Vec<String>,
76}
77
78#[derive(Debug, Clone)]
79struct ErrorEntry {
80 attempt: Option<i16>,
81 error: Option<String>,
82 at: Option<DateTime<Utc>>,
83 terminal: bool,
84 raw: serde_json::Value,
85}
86
87fn parse_error_entry(value: &serde_json::Value) -> Option<ErrorEntry> {
88 let obj = value.as_object()?;
89 let attempt = obj
90 .get("attempt")
91 .and_then(|v| v.as_i64())
92 .and_then(|v| i16::try_from(v).ok());
93 let error = obj
94 .get("error")
95 .and_then(|v| v.as_str())
96 .map(ToOwned::to_owned);
97 let at = obj
98 .get("at")
99 .and_then(|v| v.as_str())
100 .and_then(|v| chrono::DateTime::parse_from_rfc3339(v).ok())
101 .map(|v| v.with_timezone(&Utc));
102 let terminal = obj
103 .get("terminal")
104 .and_then(|v| v.as_bool())
105 .unwrap_or(false);
106 Some(ErrorEntry {
107 attempt,
108 error,
109 at,
110 terminal,
111 raw: value.clone(),
112 })
113}
114
115fn original_priority(job: &JobRow) -> i16 {
116 job.metadata
117 .get("_awa_original_priority")
118 .and_then(|v| v.as_i64())
119 .and_then(|v| i16::try_from(v).ok())
120 .unwrap_or(job.priority)
121}
122
123fn build_job_timeline(job: &JobRow) -> Vec<JobTimelineEvent> {
124 let mut events = Vec::new();
125 events.push(JobTimelineEvent {
126 timestamp: job.created_at,
127 label: "Created".to_string(),
128 state: None,
129 detail: None,
130 is_error: false,
131 });
132
133 if let Some(errors) = &job.errors {
134 for entry in errors.iter().filter_map(parse_error_entry) {
135 if let Some(timestamp) = entry.at {
136 let label = match entry.attempt {
137 Some(attempt) => format!("Attempt {attempt} failed"),
138 None => "Error".to_string(),
139 };
140 events.push(JobTimelineEvent {
141 timestamp,
142 label,
143 state: Some(JobState::Failed),
144 detail: entry.error,
145 is_error: true,
146 });
147 }
148 }
149 }
150
151 if let Some(attempted_at) = job.attempted_at {
152 let label = if job.state == JobState::WaitingExternal {
153 format!("Attempt {} — waiting for callback", job.attempt)
154 } else if job.state.is_terminal() {
155 format!("Attempt {} started", job.attempt)
156 } else {
157 format!("Attempt {} running", job.attempt)
158 };
159 let state = if job.state.is_terminal() {
160 Some(JobState::Running)
161 } else {
162 Some(job.state)
163 };
164 events.push(JobTimelineEvent {
165 timestamp: attempted_at,
166 label,
167 state,
168 detail: None,
169 is_error: false,
170 });
171 }
172
173 if let Some(finalized_at) = job.finalized_at {
174 let label = match job.state {
175 JobState::Completed => format!("Completed (attempt {})", job.attempt),
176 JobState::Failed => format!(
177 "Failed after {} attempt{}",
178 job.attempt,
179 if job.attempt == 1 { "" } else { "s" }
180 ),
181 JobState::Cancelled => "Cancelled".to_string(),
182 _ => "Finalized".to_string(),
183 };
184 events.push(JobTimelineEvent {
185 timestamp: finalized_at,
186 label,
187 state: Some(job.state),
188 detail: None,
189 is_error: false,
190 });
191 }
192
193 events.sort_by_key(|event| event.timestamp);
194 events
195}
196
197fn build_job_dump(job: JobRow) -> JobDump {
198 let latest_error = job
199 .errors
200 .as_ref()
201 .and_then(|errors| errors.last())
202 .cloned();
203 let summary = JobDumpSummary {
204 original_priority: original_priority(&job),
205 can_retry: matches!(
206 job.state,
207 JobState::Failed | JobState::Cancelled | JobState::WaitingExternal
208 ),
209 can_cancel: !job.state.is_terminal(),
210 error_count: job.errors.as_ref().map(|errors| errors.len()).unwrap_or(0),
211 latest_error,
212 };
213 let timeline = build_job_timeline(&job);
214 JobDump {
215 job,
216 summary,
217 timeline,
218 }
219}
220
221fn callback_dump(job: &JobRow) -> Option<CallbackDump> {
222 let callback = CallbackDump {
223 callback_id: job.callback_id,
224 callback_timeout_at: job.callback_timeout_at,
225 callback_filter: job.callback_filter.clone(),
226 callback_on_complete: job.callback_on_complete.clone(),
227 callback_on_fail: job.callback_on_fail.clone(),
228 callback_transform: job.callback_transform.clone(),
229 };
230 if callback.callback_id.is_none()
231 && callback.callback_timeout_at.is_none()
232 && callback.callback_filter.is_none()
233 && callback.callback_on_complete.is_none()
234 && callback.callback_on_fail.is_none()
235 && callback.callback_transform.is_none()
236 {
237 None
238 } else {
239 Some(callback)
240 }
241}
242
243pub async fn retry<'e, E>(executor: E, job_id: i64) -> Result<Option<JobRow>, AwaError>
245where
246 E: PgExecutor<'e>,
247{
248 sqlx::query_as::<_, JobRow>(
249 r#"
250 UPDATE awa.jobs
251 SET state = 'available', attempt = 0, run_at = now(),
252 finalized_at = NULL, heartbeat_at = NULL, deadline_at = NULL,
253 callback_id = NULL, callback_timeout_at = NULL,
254 callback_filter = NULL, callback_on_complete = NULL,
255 callback_on_fail = NULL, callback_transform = NULL
256 WHERE id = $1 AND state IN ('failed', 'cancelled', 'waiting_external')
257 RETURNING *
258 "#,
259 )
260 .bind(job_id)
261 .fetch_optional(executor)
262 .await?
263 .ok_or(AwaError::JobNotFound { id: job_id })
264 .map(Some)
265}
266
267pub async fn cancel<'e, E>(executor: E, job_id: i64) -> Result<Option<JobRow>, AwaError>
269where
270 E: PgExecutor<'e>,
271{
272 sqlx::query_as::<_, JobRow>(
273 r#"
274 UPDATE awa.jobs
275 SET state = 'cancelled', finalized_at = now(),
276 callback_id = NULL, callback_timeout_at = NULL,
277 callback_filter = NULL, callback_on_complete = NULL,
278 callback_on_fail = NULL, callback_transform = NULL
279 WHERE id = $1 AND state NOT IN ('completed', 'failed', 'cancelled')
280 RETURNING *
281 "#,
282 )
283 .bind(job_id)
284 .fetch_optional(executor)
285 .await?
286 .ok_or(AwaError::JobNotFound { id: job_id })
287 .map(Some)
288}
289
290pub async fn cancel_by_unique_key<'e, E>(
326 executor: E,
327 kind: &str,
328 queue: Option<&str>,
329 args: Option<&serde_json::Value>,
330 period_bucket: Option<i64>,
331) -> Result<Option<JobRow>, AwaError>
332where
333 E: PgExecutor<'e>,
334{
335 let unique_key = crate::unique::compute_unique_key(kind, queue, args, period_bucket);
336
337 let row = sqlx::query_as::<_, JobRow>(
341 r#"
342 WITH candidates AS (
343 SELECT id FROM awa.jobs_hot
344 WHERE unique_key = $1 AND state NOT IN ('completed', 'failed', 'cancelled')
345 UNION ALL
346 SELECT id FROM awa.scheduled_jobs
347 WHERE unique_key = $1 AND state NOT IN ('completed', 'failed', 'cancelled')
348 ORDER BY id ASC
349 LIMIT 1
350 )
351 UPDATE awa.jobs
352 SET state = 'cancelled', finalized_at = now(),
353 callback_id = NULL, callback_timeout_at = NULL,
354 callback_filter = NULL, callback_on_complete = NULL,
355 callback_on_fail = NULL, callback_transform = NULL
356 FROM candidates
357 WHERE awa.jobs.id = candidates.id
358 RETURNING awa.jobs.*
359 "#,
360 )
361 .bind(&unique_key)
362 .fetch_optional(executor)
363 .await?;
364
365 Ok(row)
366}
367
368pub async fn retry_failed_by_kind<'e, E>(executor: E, kind: &str) -> Result<Vec<JobRow>, AwaError>
370where
371 E: PgExecutor<'e>,
372{
373 let rows = sqlx::query_as::<_, JobRow>(
374 r#"
375 UPDATE awa.jobs
376 SET state = 'available', attempt = 0, run_at = now(),
377 finalized_at = NULL, heartbeat_at = NULL, deadline_at = NULL
378 WHERE kind = $1 AND state = 'failed'
379 RETURNING *
380 "#,
381 )
382 .bind(kind)
383 .fetch_all(executor)
384 .await?;
385
386 Ok(rows)
387}
388
389pub async fn retry_failed_by_queue<'e, E>(executor: E, queue: &str) -> Result<Vec<JobRow>, AwaError>
391where
392 E: PgExecutor<'e>,
393{
394 let rows = sqlx::query_as::<_, JobRow>(
395 r#"
396 UPDATE awa.jobs
397 SET state = 'available', attempt = 0, run_at = now(),
398 finalized_at = NULL, heartbeat_at = NULL, deadline_at = NULL
399 WHERE queue = $1 AND state = 'failed'
400 RETURNING *
401 "#,
402 )
403 .bind(queue)
404 .fetch_all(executor)
405 .await?;
406
407 Ok(rows)
408}
409
410pub async fn discard_failed<'e, E>(executor: E, kind: &str) -> Result<u64, AwaError>
412where
413 E: PgExecutor<'e>,
414{
415 let result = sqlx::query("DELETE FROM awa.jobs WHERE kind = $1 AND state = 'failed'")
416 .bind(kind)
417 .execute(executor)
418 .await?;
419
420 Ok(result.rows_affected())
421}
422
423pub async fn pause_queue<'e, E>(
425 executor: E,
426 queue: &str,
427 paused_by: Option<&str>,
428) -> Result<(), AwaError>
429where
430 E: PgExecutor<'e>,
431{
432 sqlx::query(
433 r#"
434 INSERT INTO awa.queue_meta (queue, paused, paused_at, paused_by)
435 VALUES ($1, TRUE, now(), $2)
436 ON CONFLICT (queue) DO UPDATE SET paused = TRUE, paused_at = now(), paused_by = $2
437 "#,
438 )
439 .bind(queue)
440 .bind(paused_by)
441 .execute(executor)
442 .await?;
443
444 Ok(())
445}
446
447pub async fn resume_queue<'e, E>(executor: E, queue: &str) -> Result<(), AwaError>
449where
450 E: PgExecutor<'e>,
451{
452 sqlx::query("UPDATE awa.queue_meta SET paused = FALSE WHERE queue = $1")
453 .bind(queue)
454 .execute(executor)
455 .await?;
456
457 Ok(())
458}
459
460pub async fn drain_queue<'e, E>(executor: E, queue: &str) -> Result<u64, AwaError>
462where
463 E: PgExecutor<'e>,
464{
465 let result = sqlx::query(
466 r#"
467 UPDATE awa.jobs
468 SET state = 'cancelled', finalized_at = now(),
469 callback_id = NULL, callback_timeout_at = NULL,
470 callback_filter = NULL, callback_on_complete = NULL,
471 callback_on_fail = NULL, callback_transform = NULL
472 WHERE queue = $1 AND state IN ('available', 'scheduled', 'retryable', 'waiting_external')
473 "#,
474 )
475 .bind(queue)
476 .execute(executor)
477 .await?;
478
479 Ok(result.rows_affected())
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
484pub struct QueueDescriptor {
485 pub display_name: Option<String>,
486 pub description: Option<String>,
487 pub owner: Option<String>,
488 pub docs_url: Option<String>,
489 pub tags: Vec<String>,
490 pub extra: serde_json::Value,
491}
492
493impl Default for QueueDescriptor {
494 fn default() -> Self {
495 Self {
496 display_name: None,
497 description: None,
498 owner: None,
499 docs_url: None,
500 tags: Vec::new(),
501 extra: serde_json::json!({}),
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
507pub struct NamedQueueDescriptor {
508 pub queue: String,
509 #[serde(flatten)]
510 pub descriptor: QueueDescriptor,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
514pub struct JobKindDescriptor {
515 pub display_name: Option<String>,
516 pub description: Option<String>,
517 pub owner: Option<String>,
518 pub docs_url: Option<String>,
519 pub tags: Vec<String>,
520 pub extra: serde_json::Value,
521}
522
523impl Default for JobKindDescriptor {
524 fn default() -> Self {
525 Self {
526 display_name: None,
527 description: None,
528 owner: None,
529 docs_url: None,
530 tags: Vec::new(),
531 extra: serde_json::json!({}),
532 }
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
537pub struct NamedJobKindDescriptor {
538 pub kind: String,
539 #[serde(flatten)]
540 pub descriptor: JobKindDescriptor,
541}
542
543const DESCRIPTOR_PARAMS_PER_ROW: u32 = 9;
546
547const DESCRIPTOR_BATCH_SIZE: usize = 5000;
550
551fn build_descriptor_upsert(table: &str, pk: &str, count: usize) -> String {
555 let mut query = format!(
556 "INSERT INTO {table} (\
557 {pk}, display_name, description, owner, docs_url, tags, extra, \
558 descriptor_hash, sync_interval_ms, created_at, updated_at, last_seen_at\
559 ) VALUES "
560 );
561 let mut param_index = 1u32;
562 for i in 0..count {
563 if i > 0 {
564 query.push_str(", ");
565 }
566 query.push_str(&format!(
567 "(${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, ${}, now(), now(), now())",
568 param_index,
569 param_index + 1,
570 param_index + 2,
571 param_index + 3,
572 param_index + 4,
573 param_index + 5,
574 param_index + 6,
575 param_index + 7,
576 param_index + 8,
577 ));
578 param_index += DESCRIPTOR_PARAMS_PER_ROW;
579 }
580 query.push_str(&format!(
584 " ON CONFLICT ({pk}) DO UPDATE SET \
585 display_name = EXCLUDED.display_name, \
586 description = EXCLUDED.description, \
587 owner = EXCLUDED.owner, \
588 docs_url = EXCLUDED.docs_url, \
589 tags = EXCLUDED.tags, \
590 extra = EXCLUDED.extra, \
591 descriptor_hash = EXCLUDED.descriptor_hash, \
592 sync_interval_ms = EXCLUDED.sync_interval_ms, \
593 updated_at = CASE \
594 WHEN {table}.descriptor_hash IS DISTINCT FROM EXCLUDED.descriptor_hash \
595 THEN now() ELSE {table}.updated_at \
596 END, \
597 last_seen_at = now()"
598 ));
599 query
600}
601
602pub async fn sync_queue_descriptors(
612 pool: &PgPool,
613 descriptors: &[NamedQueueDescriptor],
614 sync_interval: std::time::Duration,
615) -> Result<(), AwaError> {
616 if descriptors.is_empty() {
617 return Ok(());
618 }
619 let sync_interval_ms = sync_interval.as_millis() as i64;
620
621 for chunk in descriptors.chunks(DESCRIPTOR_BATCH_SIZE) {
622 let hashes: Vec<String> = chunk
623 .iter()
624 .map(|named| named.descriptor.descriptor_hash())
625 .collect();
626 let sql = build_descriptor_upsert("awa.queue_descriptors", "queue", chunk.len());
627 let mut query = sqlx::query(&sql);
628 for (named, hash) in chunk.iter().zip(hashes.iter()) {
629 query = query
630 .bind(&named.queue)
631 .bind(named.descriptor.display_name.as_deref())
632 .bind(named.descriptor.description.as_deref())
633 .bind(named.descriptor.owner.as_deref())
634 .bind(named.descriptor.docs_url.as_deref())
635 .bind(&named.descriptor.tags)
636 .bind(&named.descriptor.extra)
637 .bind(hash.as_str())
638 .bind(sync_interval_ms);
639 }
640 query.execute(pool).await?;
641 }
642
643 Ok(())
644}
645
646pub async fn sync_job_kind_descriptors(
649 pool: &PgPool,
650 descriptors: &[NamedJobKindDescriptor],
651 sync_interval: std::time::Duration,
652) -> Result<(), AwaError> {
653 if descriptors.is_empty() {
654 return Ok(());
655 }
656 let sync_interval_ms = sync_interval.as_millis() as i64;
657
658 for chunk in descriptors.chunks(DESCRIPTOR_BATCH_SIZE) {
659 let hashes: Vec<String> = chunk
660 .iter()
661 .map(|named| named.descriptor.descriptor_hash())
662 .collect();
663 let sql = build_descriptor_upsert("awa.job_kind_descriptors", "kind", chunk.len());
664 let mut query = sqlx::query(&sql);
665 for (named, hash) in chunk.iter().zip(hashes.iter()) {
666 query = query
667 .bind(&named.kind)
668 .bind(named.descriptor.display_name.as_deref())
669 .bind(named.descriptor.description.as_deref())
670 .bind(named.descriptor.owner.as_deref())
671 .bind(named.descriptor.docs_url.as_deref())
672 .bind(&named.descriptor.tags)
673 .bind(&named.descriptor.extra)
674 .bind(hash.as_str())
675 .bind(sync_interval_ms);
676 }
677 query.execute(pool).await?;
678 }
679
680 Ok(())
681}
682
683impl QueueDescriptor {
684 pub fn new() -> Self {
685 Self::default()
686 }
687
688 pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
689 self.display_name = Some(display_name.into());
690 self
691 }
692
693 pub fn description(mut self, description: impl Into<String>) -> Self {
694 self.description = Some(description.into());
695 self
696 }
697
698 pub fn owner(mut self, owner: impl Into<String>) -> Self {
699 self.owner = Some(owner.into());
700 self
701 }
702
703 pub fn docs_url(mut self, docs_url: impl Into<String>) -> Self {
704 self.docs_url = Some(docs_url.into());
705 self
706 }
707
708 pub fn tags<I, S>(mut self, tags: I) -> Self
709 where
710 I: IntoIterator<Item = S>,
711 S: Into<String>,
712 {
713 self.tags = tags.into_iter().map(Into::into).collect();
714 self
715 }
716
717 pub fn tag(mut self, tag: impl Into<String>) -> Self {
718 self.tags.push(tag.into());
719 self
720 }
721
722 pub fn extra(mut self, extra: serde_json::Value) -> Self {
723 self.extra = extra;
724 self
725 }
726
727 pub fn descriptor_hash(&self) -> String {
728 let payload = serde_json::json!({
729 "display_name": self.display_name,
730 "description": self.description,
731 "owner": self.owner,
732 "docs_url": self.docs_url,
733 "tags": self.tags,
734 "extra": canonicalize_json(&self.extra),
735 });
736 let encoded = serde_json::to_vec(&payload)
737 .expect("queue descriptor JSON serialization should not fail");
738 hex::encode(blake3::hash(&encoded).as_bytes())
739 }
740}
741
742impl JobKindDescriptor {
743 pub fn new() -> Self {
744 Self::default()
745 }
746
747 pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
748 self.display_name = Some(display_name.into());
749 self
750 }
751
752 pub fn description(mut self, description: impl Into<String>) -> Self {
753 self.description = Some(description.into());
754 self
755 }
756
757 pub fn owner(mut self, owner: impl Into<String>) -> Self {
758 self.owner = Some(owner.into());
759 self
760 }
761
762 pub fn docs_url(mut self, docs_url: impl Into<String>) -> Self {
763 self.docs_url = Some(docs_url.into());
764 self
765 }
766
767 pub fn tags<I, S>(mut self, tags: I) -> Self
768 where
769 I: IntoIterator<Item = S>,
770 S: Into<String>,
771 {
772 self.tags = tags.into_iter().map(Into::into).collect();
773 self
774 }
775
776 pub fn tag(mut self, tag: impl Into<String>) -> Self {
777 self.tags.push(tag.into());
778 self
779 }
780
781 pub fn extra(mut self, extra: serde_json::Value) -> Self {
782 self.extra = extra;
783 self
784 }
785
786 pub fn descriptor_hash(&self) -> String {
787 let payload = serde_json::json!({
788 "display_name": self.display_name,
789 "description": self.description,
790 "owner": self.owner,
791 "docs_url": self.docs_url,
792 "tags": self.tags,
793 "extra": canonicalize_json(&self.extra),
794 });
795 let encoded = serde_json::to_vec(&payload)
796 .expect("job kind descriptor JSON serialization should not fail");
797 hex::encode(blake3::hash(&encoded).as_bytes())
798 }
799}
800
801fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
802 match value {
803 serde_json::Value::Object(map) => {
804 let mut keys: Vec<_> = map.keys().cloned().collect();
805 keys.sort();
806 let mut out = serde_json::Map::with_capacity(map.len());
807 for key in keys {
808 if let Some(child) = map.get(&key) {
809 out.insert(key, canonicalize_json(child));
810 }
811 }
812 serde_json::Value::Object(out)
813 }
814 serde_json::Value::Array(values) => {
815 serde_json::Value::Array(values.iter().map(canonicalize_json).collect())
816 }
817 _ => value.clone(),
818 }
819}
820
821#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
822pub struct QueueOverview {
823 pub queue: String,
824 pub display_name: Option<String>,
825 pub description: Option<String>,
826 pub owner: Option<String>,
827 pub docs_url: Option<String>,
828 pub tags: Vec<String>,
829 pub extra: serde_json::Value,
830 pub descriptor_last_seen_at: Option<DateTime<Utc>>,
831 pub descriptor_stale: bool,
832 pub descriptor_mismatch: bool,
833 pub total_queued: i64,
835 pub scheduled: i64,
836 pub available: i64,
837 pub retryable: i64,
838 pub running: i64,
839 pub failed: i64,
840 pub waiting_external: i64,
841 pub completed_last_hour: i64,
842 pub lag_seconds: Option<f64>,
843 pub paused: bool,
844}
845
846#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
847pub struct JobKindOverview {
848 pub kind: String,
849 pub display_name: Option<String>,
850 pub description: Option<String>,
851 pub owner: Option<String>,
852 pub docs_url: Option<String>,
853 pub tags: Vec<String>,
854 pub extra: serde_json::Value,
855 pub descriptor_last_seen_at: Option<DateTime<Utc>>,
856 pub descriptor_stale: bool,
857 pub descriptor_mismatch: bool,
858 pub job_count: i64,
859 pub queue_count: i64,
860 pub completed_last_hour: i64,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
865pub struct RateLimitSnapshot {
866 pub max_rate: f64,
867 pub burst: u32,
868}
869
870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
872#[serde(rename_all = "snake_case")]
873pub enum QueueRuntimeMode {
874 HardReserved,
875 Weighted,
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
880pub struct QueueRuntimeConfigSnapshot {
881 pub mode: QueueRuntimeMode,
882 pub max_workers: Option<u32>,
883 pub min_workers: Option<u32>,
884 pub weight: Option<u32>,
885 pub global_max_workers: Option<u32>,
886 pub poll_interval_ms: u64,
887 pub deadline_duration_secs: u64,
888 pub priority_aging_interval_secs: u64,
889 pub rate_limit: Option<RateLimitSnapshot>,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
894pub struct QueueRuntimeSnapshot {
895 pub queue: String,
896 pub in_flight: u32,
897 pub overflow_held: Option<u32>,
898 pub config: QueueRuntimeConfigSnapshot,
899}
900
901#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
902#[serde(rename_all = "snake_case")]
903pub enum StorageCapability {
904 Canonical,
905 CanonicalDrainOnly,
906 QueueStorage,
907}
908
909impl StorageCapability {
910 pub fn as_str(self) -> &'static str {
911 match self {
912 Self::Canonical => "canonical",
913 Self::CanonicalDrainOnly => "canonical_drain_only",
914 Self::QueueStorage => "queue_storage",
915 }
916 }
917}
918
919impl fmt::Display for StorageCapability {
920 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
921 f.write_str(self.as_str())
922 }
923}
924
925impl FromStr for StorageCapability {
926 type Err = String;
927
928 fn from_str(value: &str) -> Result<Self, Self::Err> {
929 match value {
930 "canonical" => Ok(Self::Canonical),
931 "canonical_drain_only" => Ok(Self::CanonicalDrainOnly),
932 "queue_storage" => Ok(Self::QueueStorage),
933 _ => Err(value.to_string()),
934 }
935 }
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct RuntimeSnapshotInput {
941 pub instance_id: Uuid,
942 pub hostname: Option<String>,
943 pub pid: i32,
944 pub version: String,
945 pub storage_capability: StorageCapability,
946 pub started_at: DateTime<Utc>,
947 pub snapshot_interval_ms: i64,
948 pub healthy: bool,
949 pub postgres_connected: bool,
950 pub poll_loop_alive: bool,
951 pub heartbeat_alive: bool,
952 pub maintenance_alive: bool,
953 pub shutting_down: bool,
954 pub leader: bool,
955 pub global_max_workers: Option<u32>,
956 pub queues: Vec<QueueRuntimeSnapshot>,
957 pub queue_descriptor_hashes: HashMap<String, String>,
958 pub job_kind_descriptor_hashes: HashMap<String, String>,
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct RuntimeInstance {
964 pub instance_id: Uuid,
965 pub hostname: Option<String>,
966 pub pid: i32,
967 pub version: String,
968 pub storage_capability: StorageCapability,
969 pub started_at: DateTime<Utc>,
970 pub last_seen_at: DateTime<Utc>,
971 pub snapshot_interval_ms: i64,
972 pub stale: bool,
973 pub healthy: bool,
974 pub postgres_connected: bool,
975 pub poll_loop_alive: bool,
976 pub heartbeat_alive: bool,
977 pub maintenance_alive: bool,
978 pub shutting_down: bool,
979 pub leader: bool,
980 pub global_max_workers: Option<u32>,
981 pub queues: Vec<QueueRuntimeSnapshot>,
982}
983
984impl RuntimeInstance {
985 fn stale_cutoff(interval_ms: i64) -> Duration {
986 let interval_ms = max(interval_ms, 1_000);
987 Duration::milliseconds(max(interval_ms.saturating_mul(3), 30_000))
988 }
989
990 fn from_db_row(row: RuntimeInstanceRow, now: DateTime<Utc>) -> Result<Self, AwaError> {
991 let stale = row.last_seen_at + Self::stale_cutoff(row.snapshot_interval_ms) < now;
992 let storage_capability =
993 StorageCapability::from_str(&row.storage_capability).map_err(|value| {
994 AwaError::Validation(format!(
995 "invalid storage capability in runtime_instances: {value}"
996 ))
997 })?;
998 Ok(Self {
999 instance_id: row.instance_id,
1000 hostname: row.hostname,
1001 pid: row.pid,
1002 version: row.version,
1003 storage_capability,
1004 started_at: row.started_at,
1005 last_seen_at: row.last_seen_at,
1006 snapshot_interval_ms: row.snapshot_interval_ms,
1007 stale,
1008 healthy: row.healthy,
1009 postgres_connected: row.postgres_connected,
1010 poll_loop_alive: row.poll_loop_alive,
1011 heartbeat_alive: row.heartbeat_alive,
1012 maintenance_alive: row.maintenance_alive,
1013 shutting_down: row.shutting_down,
1014 leader: row.leader,
1015 global_max_workers: row.global_max_workers.map(|v| v as u32),
1016 queues: row.queues.0,
1017 })
1018 }
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1023pub struct RuntimeOverview {
1024 pub total_instances: usize,
1025 pub live_instances: usize,
1026 pub stale_instances: usize,
1027 pub healthy_instances: usize,
1028 pub leader_instances: usize,
1029 pub instances: Vec<RuntimeInstance>,
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1034pub struct QueueRuntimeSummary {
1035 pub queue: String,
1036 pub instance_count: usize,
1037 pub live_instances: usize,
1038 pub stale_instances: usize,
1039 pub healthy_instances: usize,
1040 pub total_in_flight: u64,
1041 pub overflow_held_total: Option<u64>,
1042 pub config_mismatch: bool,
1043 pub config: Option<QueueRuntimeConfigSnapshot>,
1044}
1045
1046#[derive(Debug, sqlx::FromRow)]
1047struct RuntimeInstanceRow {
1048 instance_id: Uuid,
1049 hostname: Option<String>,
1050 pid: i32,
1051 version: String,
1052 storage_capability: String,
1053 started_at: DateTime<Utc>,
1054 last_seen_at: DateTime<Utc>,
1055 snapshot_interval_ms: i64,
1056 healthy: bool,
1057 postgres_connected: bool,
1058 poll_loop_alive: bool,
1059 heartbeat_alive: bool,
1060 maintenance_alive: bool,
1061 shutting_down: bool,
1062 leader: bool,
1063 global_max_workers: Option<i32>,
1064 queues: Json<Vec<QueueRuntimeSnapshot>>,
1065}
1066
1067pub async fn upsert_runtime_snapshot<'e, E>(
1069 executor: E,
1070 snapshot: &RuntimeSnapshotInput,
1071) -> Result<(), AwaError>
1072where
1073 E: PgExecutor<'e>,
1074{
1075 sqlx::query(
1076 r#"
1077 INSERT INTO awa.runtime_instances (
1078 instance_id,
1079 hostname,
1080 pid,
1081 version,
1082 storage_capability,
1083 started_at,
1084 last_seen_at,
1085 snapshot_interval_ms,
1086 healthy,
1087 postgres_connected,
1088 poll_loop_alive,
1089 heartbeat_alive,
1090 maintenance_alive,
1091 shutting_down,
1092 leader,
1093 global_max_workers,
1094 queues,
1095 queue_descriptor_hashes,
1096 job_kind_descriptor_hashes
1097 )
1098 VALUES (
1099 $1, $2, $3, $4, $5, $6, now(), $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
1100 )
1101 ON CONFLICT (instance_id) DO UPDATE SET
1102 hostname = EXCLUDED.hostname,
1103 pid = EXCLUDED.pid,
1104 version = EXCLUDED.version,
1105 storage_capability = EXCLUDED.storage_capability,
1106 started_at = EXCLUDED.started_at,
1107 last_seen_at = now(),
1108 snapshot_interval_ms = EXCLUDED.snapshot_interval_ms,
1109 healthy = EXCLUDED.healthy,
1110 postgres_connected = EXCLUDED.postgres_connected,
1111 poll_loop_alive = EXCLUDED.poll_loop_alive,
1112 heartbeat_alive = EXCLUDED.heartbeat_alive,
1113 maintenance_alive = EXCLUDED.maintenance_alive,
1114 shutting_down = EXCLUDED.shutting_down,
1115 leader = EXCLUDED.leader,
1116 global_max_workers = EXCLUDED.global_max_workers,
1117 queues = EXCLUDED.queues,
1118 queue_descriptor_hashes = EXCLUDED.queue_descriptor_hashes,
1119 job_kind_descriptor_hashes = EXCLUDED.job_kind_descriptor_hashes
1120 "#,
1121 )
1122 .bind(snapshot.instance_id)
1123 .bind(snapshot.hostname.as_deref())
1124 .bind(snapshot.pid)
1125 .bind(&snapshot.version)
1126 .bind(snapshot.storage_capability.as_str())
1127 .bind(snapshot.started_at)
1128 .bind(snapshot.snapshot_interval_ms)
1129 .bind(snapshot.healthy)
1130 .bind(snapshot.postgres_connected)
1131 .bind(snapshot.poll_loop_alive)
1132 .bind(snapshot.heartbeat_alive)
1133 .bind(snapshot.maintenance_alive)
1134 .bind(snapshot.shutting_down)
1135 .bind(snapshot.leader)
1136 .bind(snapshot.global_max_workers.map(|v| v as i32))
1137 .bind(Json(&snapshot.queues))
1138 .bind(Json(&snapshot.queue_descriptor_hashes))
1139 .bind(Json(&snapshot.job_kind_descriptor_hashes))
1140 .execute(executor)
1141 .await?;
1142
1143 Ok(())
1144}
1145
1146pub async fn cleanup_runtime_snapshots<'e, E>(
1148 executor: E,
1149 max_age: Duration,
1150) -> Result<u64, AwaError>
1151where
1152 E: PgExecutor<'e>,
1153{
1154 let seconds = max(max_age.num_seconds(), 1);
1155 let result = sqlx::query(
1156 "DELETE FROM awa.runtime_instances WHERE last_seen_at < now() - make_interval(secs => $1)",
1157 )
1158 .bind(seconds)
1159 .execute(executor)
1160 .await?;
1161
1162 Ok(result.rows_affected())
1163}
1164
1165pub async fn cleanup_stale_descriptors<'e, E>(
1177 executor: E,
1178 table: &str,
1179 max_age: Duration,
1180) -> Result<u64, AwaError>
1181where
1182 E: PgExecutor<'e>,
1183{
1184 if !matches!(table, "awa.queue_descriptors" | "awa.job_kind_descriptors") {
1185 return Err(AwaError::Validation(format!(
1186 "cleanup_stale_descriptors: unknown table {table:?}"
1187 )));
1188 }
1189 let seconds = max(max_age.num_seconds(), 1);
1190 let sql = format!("DELETE FROM {table} WHERE last_seen_at < now() - make_interval(secs => $1)");
1193 let result = sqlx::query(&sql).bind(seconds).execute(executor).await?;
1194 Ok(result.rows_affected())
1195}
1196
1197pub async fn list_runtime_instances<'e, E>(executor: E) -> Result<Vec<RuntimeInstance>, AwaError>
1199where
1200 E: PgExecutor<'e>,
1201{
1202 let rows = sqlx::query_as::<_, RuntimeInstanceRow>(
1203 r#"
1204 SELECT
1205 instance_id,
1206 hostname,
1207 pid,
1208 version,
1209 storage_capability,
1210 started_at,
1211 last_seen_at,
1212 snapshot_interval_ms,
1213 healthy,
1214 postgres_connected,
1215 poll_loop_alive,
1216 heartbeat_alive,
1217 maintenance_alive,
1218 shutting_down,
1219 leader,
1220 global_max_workers,
1221 queues
1222 FROM awa.runtime_instances
1223 ORDER BY leader DESC, last_seen_at DESC, started_at DESC
1224 "#,
1225 )
1226 .fetch_all(executor)
1227 .await?;
1228
1229 let now = Utc::now();
1230 rows.into_iter()
1231 .map(|row| RuntimeInstance::from_db_row(row, now))
1232 .collect()
1233}
1234
1235pub async fn runtime_overview<'e, E>(executor: E) -> Result<RuntimeOverview, AwaError>
1237where
1238 E: PgExecutor<'e>,
1239{
1240 let instances = list_runtime_instances(executor).await?;
1241 let total_instances = instances.len();
1242 let stale_instances = instances.iter().filter(|i| i.stale).count();
1243 let live_instances = total_instances.saturating_sub(stale_instances);
1244 let healthy_instances = instances.iter().filter(|i| !i.stale && i.healthy).count();
1245 let leader_instances = instances.iter().filter(|i| !i.stale && i.leader).count();
1246
1247 Ok(RuntimeOverview {
1248 total_instances,
1249 live_instances,
1250 stale_instances,
1251 healthy_instances,
1252 leader_instances,
1253 instances,
1254 })
1255}
1256
1257pub async fn queue_runtime_summary<'e, E>(executor: E) -> Result<Vec<QueueRuntimeSummary>, AwaError>
1259where
1260 E: PgExecutor<'e>,
1261{
1262 let instances = list_runtime_instances(executor).await?;
1263 let mut by_queue: HashMap<String, Vec<(bool, bool, QueueRuntimeSnapshot)>> = HashMap::new();
1264
1265 for instance in instances {
1266 let is_live = !instance.stale;
1267 let is_healthy = is_live && instance.healthy;
1268 for queue in instance.queues {
1269 by_queue
1270 .entry(queue.queue.clone())
1271 .or_default()
1272 .push((is_live, is_healthy, queue));
1273 }
1274 }
1275
1276 let mut summaries: Vec<_> = by_queue
1277 .into_iter()
1278 .map(|(queue, entries)| {
1279 let instance_count = entries.len();
1280 let live_instances = entries.iter().filter(|(live, _, _)| *live).count();
1281 let stale_instances = instance_count.saturating_sub(live_instances);
1282 let healthy_instances = entries.iter().filter(|(_, healthy, _)| *healthy).count();
1283 let total_in_flight = entries
1284 .iter()
1285 .filter(|(live, _, _)| *live)
1286 .map(|(_, _, queue)| u64::from(queue.in_flight))
1287 .sum();
1288
1289 let overflow_total: u64 = entries
1290 .iter()
1291 .filter(|(live, _, _)| *live)
1292 .filter_map(|(_, _, queue)| queue.overflow_held.map(u64::from))
1293 .sum();
1294
1295 let live_configs: Vec<_> = entries
1296 .iter()
1297 .filter(|(live, _, _)| *live)
1298 .map(|(_, _, queue)| queue.config.clone())
1299 .collect();
1300 let config_candidates = if live_configs.is_empty() {
1301 entries
1302 .iter()
1303 .map(|(_, _, queue)| queue.config.clone())
1304 .collect::<Vec<_>>()
1305 } else {
1306 live_configs
1307 };
1308 let config = config_candidates.first().cloned();
1309 let config_mismatch = config_candidates
1310 .iter()
1311 .skip(1)
1312 .any(|candidate| Some(candidate) != config.as_ref());
1313
1314 QueueRuntimeSummary {
1315 queue,
1316 instance_count,
1317 live_instances,
1318 stale_instances,
1319 healthy_instances,
1320 total_in_flight,
1321 overflow_held_total: config
1322 .as_ref()
1323 .filter(|cfg| cfg.mode == QueueRuntimeMode::Weighted)
1324 .map(|_| overflow_total),
1325 config_mismatch,
1326 config,
1327 }
1328 })
1329 .collect();
1330
1331 summaries.sort_by(|a, b| a.queue.cmp(&b.queue));
1332 Ok(summaries)
1333}
1334
1335pub async fn queue_overviews<'e, E>(executor: E) -> Result<Vec<QueueOverview>, AwaError>
1348where
1349 E: PgExecutor<'e>,
1350{
1351 let rows = sqlx::query_as::<_, QueueOverview>(
1352 r#"
1353 WITH all_queues AS (
1354 -- Union every source of queue-name knowledge so /queues never
1355 -- hides a queue that exists *somewhere* in the system:
1356 -- - queue_state_counts: has observed jobs
1357 -- - queue_descriptors: declared by worker code
1358 -- - queue_meta: pause state recorded by an operator
1359 -- - runtime_instances: registered by a currently-running worker
1360 SELECT queue FROM awa.queue_state_counts
1361 UNION
1362 SELECT queue FROM awa.queue_descriptors
1363 UNION
1364 SELECT queue FROM awa.queue_meta
1365 UNION
1366 SELECT DISTINCT descriptor.key AS queue
1367 FROM awa.runtime_instances runtime
1368 CROSS JOIN LATERAL jsonb_each_text(runtime.queue_descriptor_hashes) AS descriptor(key, value)
1369 ),
1370 live_queue_descriptor_variants AS (
1371 SELECT
1372 descriptor.key AS queue,
1373 count(DISTINCT descriptor.value)::bigint AS descriptor_variant_count
1374 FROM awa.runtime_instances runtime
1375 CROSS JOIN LATERAL jsonb_each_text(runtime.queue_descriptor_hashes) AS descriptor(key, value)
1376 WHERE runtime.last_seen_at + make_interval(
1377 secs => GREATEST(((GREATEST(runtime.snapshot_interval_ms, 1000) / 1000) * 3)::int, 30)
1378 ) >= now()
1379 GROUP BY descriptor.key
1380 ),
1381 available_lag AS (
1382 SELECT
1383 queue,
1384 EXTRACT(EPOCH FROM (now() - min(run_at)))::float8 AS lag_seconds
1385 FROM awa.jobs_hot
1386 WHERE state = 'available'
1387 GROUP BY queue
1388 ),
1389 completed_recent AS (
1390 SELECT
1391 queue,
1392 count(*)::bigint AS completed_last_hour
1393 FROM awa.jobs_hot
1394 WHERE state = 'completed'
1395 AND finalized_at > now() - interval '1 hour'
1396 GROUP BY queue
1397 )
1398 SELECT
1399 q.queue,
1400 qd.display_name,
1401 qd.description,
1402 qd.owner,
1403 qd.docs_url,
1404 COALESCE(qd.tags, ARRAY[]::text[]) AS tags,
1405 COALESCE(qd.extra, '{}'::jsonb) AS extra,
1406 qd.last_seen_at AS descriptor_last_seen_at,
1407 CASE
1408 WHEN qd.last_seen_at IS NULL THEN FALSE
1409 ELSE qd.last_seen_at + make_interval(
1410 secs => GREATEST(((COALESCE(qd.sync_interval_ms, 10000) / 1000) * 3)::int, 30)
1411 ) < now()
1412 END AS descriptor_stale,
1413 COALESCE(qdv.descriptor_variant_count, 0) > 1 AS descriptor_mismatch,
1414 COALESCE(qs.scheduled + qs.available + qs.running + qs.retryable + qs.waiting_external, 0) AS total_queued,
1415 COALESCE(qs.scheduled, 0) AS scheduled,
1416 COALESCE(qs.available, 0) AS available,
1417 COALESCE(qs.retryable, 0) AS retryable,
1418 COALESCE(qs.running, 0) AS running,
1419 COALESCE(qs.failed, 0) AS failed,
1420 COALESCE(qs.waiting_external, 0) AS waiting_external,
1421 COALESCE(cr.completed_last_hour, 0) AS completed_last_hour,
1422 al.lag_seconds,
1423 COALESCE(qm.paused, FALSE) AS paused
1424 FROM all_queues q
1425 LEFT JOIN awa.queue_state_counts qs ON qs.queue = q.queue
1426 LEFT JOIN awa.queue_descriptors qd ON qd.queue = q.queue
1427 LEFT JOIN live_queue_descriptor_variants qdv ON qdv.queue = q.queue
1428 LEFT JOIN available_lag al ON al.queue = q.queue
1429 LEFT JOIN completed_recent cr ON cr.queue = q.queue
1430 LEFT JOIN awa.queue_meta qm ON qm.queue = q.queue
1431 ORDER BY q.queue
1432 "#,
1433 )
1434 .fetch_all(executor)
1435 .await?;
1436
1437 Ok(rows)
1438}
1439
1440pub async fn queue_overview<'e, E>(
1442 executor: E,
1443 queue: &str,
1444) -> Result<Option<QueueOverview>, AwaError>
1445where
1446 E: PgExecutor<'e>,
1447{
1448 let rows = queue_overviews(executor).await?;
1449 Ok(rows.into_iter().find(|row| row.queue == queue))
1450}
1451
1452pub async fn queue_descriptors_for_names<'e, E>(
1453 executor: E,
1454 queues: &[String],
1455) -> Result<HashMap<String, QueueDescriptor>, AwaError>
1456where
1457 E: PgExecutor<'e>,
1458{
1459 if queues.is_empty() {
1460 return Ok(HashMap::new());
1461 }
1462
1463 let rows = sqlx::query_as::<
1464 _,
1465 (
1466 String,
1467 Option<String>,
1468 Option<String>,
1469 Option<String>,
1470 Option<String>,
1471 Vec<String>,
1472 serde_json::Value,
1473 ),
1474 >(
1475 r#"
1476 SELECT
1477 queue,
1478 display_name,
1479 description,
1480 owner,
1481 docs_url,
1482 tags,
1483 extra
1484 FROM awa.queue_descriptors
1485 WHERE queue = ANY($1)
1486 "#,
1487 )
1488 .bind(queues)
1489 .fetch_all(executor)
1490 .await?;
1491
1492 Ok(rows
1493 .into_iter()
1494 .map(
1495 |(queue, display_name, description, owner, docs_url, tags, extra)| {
1496 (
1497 queue,
1498 QueueDescriptor {
1499 display_name,
1500 description,
1501 owner,
1502 docs_url,
1503 tags,
1504 extra,
1505 },
1506 )
1507 },
1508 )
1509 .collect())
1510}
1511
1512pub async fn job_kind_descriptors_for_names<'e, E>(
1513 executor: E,
1514 kinds: &[String],
1515) -> Result<HashMap<String, JobKindDescriptor>, AwaError>
1516where
1517 E: PgExecutor<'e>,
1518{
1519 if kinds.is_empty() {
1520 return Ok(HashMap::new());
1521 }
1522
1523 let rows = sqlx::query_as::<
1524 _,
1525 (
1526 String,
1527 Option<String>,
1528 Option<String>,
1529 Option<String>,
1530 Option<String>,
1531 Vec<String>,
1532 serde_json::Value,
1533 ),
1534 >(
1535 r#"
1536 SELECT
1537 kind,
1538 display_name,
1539 description,
1540 owner,
1541 docs_url,
1542 tags,
1543 extra
1544 FROM awa.job_kind_descriptors
1545 WHERE kind = ANY($1)
1546 "#,
1547 )
1548 .bind(kinds)
1549 .fetch_all(executor)
1550 .await?;
1551
1552 Ok(rows
1553 .into_iter()
1554 .map(
1555 |(kind, display_name, description, owner, docs_url, tags, extra)| {
1556 (
1557 kind,
1558 JobKindDescriptor {
1559 display_name,
1560 description,
1561 owner,
1562 docs_url,
1563 tags,
1564 extra,
1565 },
1566 )
1567 },
1568 )
1569 .collect())
1570}
1571
1572#[derive(Debug, Clone, Default, Serialize)]
1574pub struct ListJobsFilter {
1575 pub state: Option<JobState>,
1576 pub kind: Option<String>,
1577 pub queue: Option<String>,
1578 pub tag: Option<String>,
1579 pub before_id: Option<i64>,
1580 pub limit: Option<i64>,
1581}
1582
1583pub async fn list_jobs<'e, E>(executor: E, filter: &ListJobsFilter) -> Result<Vec<JobRow>, AwaError>
1585where
1586 E: PgExecutor<'e>,
1587{
1588 let limit = filter.limit.unwrap_or(100);
1589
1590 let rows = sqlx::query_as::<_, JobRow>(
1591 r#"
1592 SELECT * FROM awa.jobs
1593 WHERE ($1::awa.job_state IS NULL OR state = $1)
1594 AND ($2::text IS NULL OR kind = $2)
1595 AND ($3::text IS NULL OR queue = $3)
1596 AND ($4::text IS NULL OR tags @> ARRAY[$4]::text[])
1597 AND ($5::bigint IS NULL OR id < $5)
1598 ORDER BY id DESC
1599 LIMIT $6
1600 "#,
1601 )
1602 .bind(filter.state)
1603 .bind(&filter.kind)
1604 .bind(&filter.queue)
1605 .bind(&filter.tag)
1606 .bind(filter.before_id)
1607 .bind(limit)
1608 .fetch_all(executor)
1609 .await?;
1610
1611 Ok(rows)
1612}
1613
1614pub async fn get_job<'e, E>(executor: E, job_id: i64) -> Result<JobRow, AwaError>
1616where
1617 E: PgExecutor<'e>,
1618{
1619 let row = sqlx::query_as::<_, JobRow>("SELECT * FROM awa.jobs WHERE id = $1")
1620 .bind(job_id)
1621 .fetch_optional(executor)
1622 .await?;
1623
1624 row.ok_or(AwaError::JobNotFound { id: job_id })
1625}
1626
1627pub async fn dump_job<'e, E>(executor: E, job_id: i64) -> Result<JobDump, AwaError>
1629where
1630 E: PgExecutor<'e>,
1631{
1632 let job = get_job(executor, job_id).await?;
1633 Ok(build_job_dump(job))
1634}
1635
1636pub async fn dump_run<'e, E>(
1642 executor: E,
1643 job_id: i64,
1644 attempt: Option<i16>,
1645) -> Result<RunDump, AwaError>
1646where
1647 E: PgExecutor<'e>,
1648{
1649 let job = get_job(executor, job_id).await?;
1650 let selected_attempt = attempt.unwrap_or(job.attempt);
1651
1652 if selected_attempt < 0 {
1653 return Err(AwaError::Validation("attempt must be >= 0".to_string()));
1654 }
1655
1656 if job.attempt == 0 && selected_attempt == 0 {
1657 return Ok(RunDump {
1658 job_id: job.id,
1659 kind: job.kind.clone(),
1660 queue: job.queue.clone(),
1661 selected_attempt,
1662 current_attempt: job.attempt,
1663 current_run_lease: job.run_lease,
1664 selected_run_lease: Some(job.run_lease),
1665 source: RunDumpSource::CurrentJobRow,
1666 state: job.state,
1667 started_at: job.attempted_at,
1668 finished_at: job.finalized_at,
1669 error: None,
1670 terminal: None,
1671 progress: job.progress.clone(),
1672 metadata: Some(job.metadata.clone()),
1673 callback: callback_dump(&job),
1674 raw_error_entry: None,
1675 notes: vec!["Job has not been claimed yet; attempt 0 is the pre-run snapshot.".into()],
1676 });
1677 }
1678
1679 if selected_attempt == job.attempt {
1680 let latest_error = job
1681 .errors
1682 .as_ref()
1683 .into_iter()
1684 .flatten()
1685 .filter_map(parse_error_entry)
1686 .find(|entry| entry.attempt == Some(selected_attempt));
1687 return Ok(RunDump {
1688 job_id: job.id,
1689 kind: job.kind.clone(),
1690 queue: job.queue.clone(),
1691 selected_attempt,
1692 current_attempt: job.attempt,
1693 current_run_lease: job.run_lease,
1694 selected_run_lease: Some(job.run_lease),
1695 source: RunDumpSource::CurrentJobRow,
1696 state: job.state,
1697 started_at: job.attempted_at,
1698 finished_at: if job.state.is_terminal() {
1699 job.finalized_at
1700 } else {
1701 None
1702 },
1703 error: latest_error.as_ref().and_then(|entry| entry.error.clone()),
1704 terminal: latest_error.as_ref().map(|entry| entry.terminal),
1705 progress: job.progress.clone(),
1706 metadata: Some(job.metadata.clone()),
1707 callback: callback_dump(&job),
1708 raw_error_entry: latest_error.map(|entry| entry.raw),
1709 notes: vec![],
1710 });
1711 }
1712
1713 if selected_attempt == 0 {
1714 return Err(AwaError::Validation(format!(
1715 "attempt {selected_attempt} is not available for job {job_id}; current attempt is {}",
1716 job.attempt
1717 )));
1718 }
1719
1720 if job.attempt > 0 && selected_attempt > job.attempt {
1721 return Err(AwaError::Validation(format!(
1722 "attempt {selected_attempt} is not available for job {job_id}; current attempt is {}",
1723 job.attempt
1724 )));
1725 }
1726
1727 let historical = job
1728 .errors
1729 .as_ref()
1730 .into_iter()
1731 .flatten()
1732 .filter_map(parse_error_entry)
1733 .find(|entry| entry.attempt == Some(selected_attempt))
1734 .ok_or_else(|| {
1735 AwaError::Validation(format!(
1736 "attempt {selected_attempt} is not present in the recorded error history for job {job_id}"
1737 ))
1738 })?;
1739
1740 Ok(RunDump {
1741 job_id: job.id,
1742 kind: job.kind.clone(),
1743 queue: job.queue.clone(),
1744 selected_attempt,
1745 current_attempt: job.attempt,
1746 current_run_lease: job.run_lease,
1747 selected_run_lease: None,
1748 source: RunDumpSource::ErrorHistory,
1749 state: if historical.terminal {
1750 JobState::Failed
1751 } else {
1752 JobState::Retryable
1753 },
1754 started_at: None,
1755 finished_at: historical.at,
1756 error: historical.error,
1757 terminal: Some(historical.terminal),
1758 progress: None,
1759 metadata: None,
1760 callback: None,
1761 raw_error_entry: Some(historical.raw),
1762 notes: vec![
1763 "Historical attempts are reconstructed from errors[] because Awa does not persist a standalone runs table.".into(),
1764 "Only the current attempt has live progress, callback, and metadata fields.".into(),
1765 ],
1766 })
1767}
1768
1769pub async fn state_counts<'e, E>(executor: E) -> Result<HashMap<JobState, i64>, AwaError>
1773where
1774 E: PgExecutor<'e>,
1775{
1776 let rows = sqlx::query_as::<_, (JobState, i64)>(
1779 r#"
1780 SELECT v.state, v.total FROM (
1781 SELECT
1782 COALESCE(sum(scheduled), 0)::bigint AS scheduled,
1783 COALESCE(sum(available), 0)::bigint AS available,
1784 COALESCE(sum(running), 0)::bigint AS running,
1785 COALESCE(sum(completed), 0)::bigint AS completed,
1786 COALESCE(sum(retryable), 0)::bigint AS retryable,
1787 COALESCE(sum(failed), 0)::bigint AS failed,
1788 COALESCE(sum(cancelled), 0)::bigint AS cancelled,
1789 COALESCE(sum(waiting_external), 0)::bigint AS waiting_external
1790 FROM awa.queue_state_counts
1791 ) s,
1792 LATERAL (VALUES
1793 ('scheduled'::awa.job_state, s.scheduled),
1794 ('available'::awa.job_state, s.available),
1795 ('running'::awa.job_state, s.running),
1796 ('completed'::awa.job_state, s.completed),
1797 ('retryable'::awa.job_state, s.retryable),
1798 ('failed'::awa.job_state, s.failed),
1799 ('cancelled'::awa.job_state, s.cancelled),
1800 ('waiting_external'::awa.job_state, s.waiting_external)
1801 ) AS v(state, total)
1802 "#,
1803 )
1804 .fetch_all(executor)
1805 .await?;
1806
1807 Ok(rows.into_iter().collect())
1808}
1809
1810pub async fn job_kind_overviews<'e, E>(executor: E) -> Result<Vec<JobKindOverview>, AwaError>
1812where
1813 E: PgExecutor<'e>,
1814{
1815 let rows = sqlx::query_as::<_, JobKindOverview>(
1816 r#"
1817 WITH all_kinds AS (
1818 -- Union every source of kind-name knowledge so /kinds never
1819 -- hides a kind that exists *somewhere* in the system:
1820 -- - job_kind_catalog: has observed jobs
1821 -- - job_kind_descriptors: declared by worker code
1822 -- - runtime_instances: reported by a currently-running worker
1823 SELECT kind FROM awa.job_kind_catalog WHERE ref_count > 0
1824 UNION
1825 SELECT kind FROM awa.job_kind_descriptors
1826 UNION
1827 SELECT DISTINCT descriptor.key AS kind
1828 FROM awa.runtime_instances runtime
1829 CROSS JOIN LATERAL jsonb_each_text(runtime.job_kind_descriptor_hashes) AS descriptor(key, value)
1830 ),
1831 live_kind_descriptor_variants AS (
1832 SELECT
1833 descriptor.key AS kind,
1834 count(DISTINCT descriptor.value)::bigint AS descriptor_variant_count
1835 FROM awa.runtime_instances runtime
1836 CROSS JOIN LATERAL jsonb_each_text(runtime.job_kind_descriptor_hashes) AS descriptor(key, value)
1837 WHERE runtime.last_seen_at + make_interval(
1838 secs => GREATEST(((GREATEST(runtime.snapshot_interval_ms, 1000) / 1000) * 3)::int, 30)
1839 ) >= now()
1840 GROUP BY descriptor.key
1841 ),
1842 completed_recent AS (
1843 SELECT
1844 kind,
1845 count(*)::bigint AS completed_last_hour
1846 FROM awa.jobs_hot
1847 WHERE state = 'completed'
1848 AND finalized_at > now() - interval '1 hour'
1849 GROUP BY kind
1850 ),
1851 queue_counts AS (
1852 -- Restrict the per-kind queue fan-out to `jobs_hot` rather than
1853 -- the full `awa.jobs` view. jobs_hot is bounded by retention
1854 -- (default 24h completed / 72h failed) so the scan cost is
1855 -- tied to in-flight volume, not historical volume. The
1856 -- semantic this produces — "queues this kind is currently
1857 -- active on" — is the one admin surfaces actually care about;
1858 -- a kind that hasn't enqueued in 3 months shouldn't be
1859 -- counted as still spanning N queues.
1860 SELECT
1861 kind,
1862 count(DISTINCT queue)::bigint AS queue_count
1863 FROM awa.jobs_hot
1864 GROUP BY kind
1865 )
1866 SELECT
1867 k.kind,
1868 kd.display_name,
1869 kd.description,
1870 kd.owner,
1871 kd.docs_url,
1872 COALESCE(kd.tags, ARRAY[]::text[]) AS tags,
1873 COALESCE(kd.extra, '{}'::jsonb) AS extra,
1874 kd.last_seen_at AS descriptor_last_seen_at,
1875 CASE
1876 WHEN kd.last_seen_at IS NULL THEN FALSE
1877 ELSE kd.last_seen_at + make_interval(
1878 secs => GREATEST(((COALESCE(kd.sync_interval_ms, 10000) / 1000) * 3)::int, 30)
1879 ) < now()
1880 END AS descriptor_stale,
1881 COALESCE(kdv.descriptor_variant_count, 0) > 1 AS descriptor_mismatch,
1882 COALESCE(kc.ref_count, 0) AS job_count,
1883 COALESCE(qc.queue_count, 0) AS queue_count,
1884 COALESCE(cr.completed_last_hour, 0) AS completed_last_hour
1885 FROM all_kinds k
1886 LEFT JOIN awa.job_kind_catalog kc ON kc.kind = k.kind
1887 LEFT JOIN awa.job_kind_descriptors kd ON kd.kind = k.kind
1888 LEFT JOIN live_kind_descriptor_variants kdv ON kdv.kind = k.kind
1889 LEFT JOIN queue_counts qc ON qc.kind = k.kind
1890 LEFT JOIN completed_recent cr ON cr.kind = k.kind
1891 ORDER BY k.kind
1892 "#,
1893 )
1894 .fetch_all(executor)
1895 .await?;
1896
1897 Ok(rows)
1898}
1899
1900pub async fn job_kind_overview<'e, E>(
1901 executor: E,
1902 kind: &str,
1903) -> Result<Option<JobKindOverview>, AwaError>
1904where
1905 E: PgExecutor<'e>,
1906{
1907 let rows = job_kind_overviews(executor).await?;
1908 Ok(rows.into_iter().find(|row| row.kind == kind))
1909}
1910
1911pub async fn distinct_kinds<'e, E>(executor: E) -> Result<Vec<String>, AwaError>
1915where
1916 E: PgExecutor<'e>,
1917{
1918 let rows = sqlx::query_scalar::<_, String>(
1919 "SELECT kind FROM awa.job_kind_catalog WHERE ref_count > 0 ORDER BY kind",
1920 )
1921 .fetch_all(executor)
1922 .await?;
1923
1924 Ok(rows)
1925}
1926
1927pub async fn distinct_queues<'e, E>(executor: E) -> Result<Vec<String>, AwaError>
1931where
1932 E: PgExecutor<'e>,
1933{
1934 let rows = sqlx::query_scalar::<_, String>(
1935 "SELECT queue FROM awa.job_queue_catalog WHERE ref_count > 0 ORDER BY queue",
1936 )
1937 .fetch_all(executor)
1938 .await?;
1939
1940 Ok(rows)
1941}
1942
1943pub async fn recompute_dirty_admin_metadata(pool: &PgPool) -> Result<i32, AwaError> {
1949 let count: i32 = sqlx::query_scalar("SELECT awa.recompute_dirty_admin_metadata(100)")
1950 .fetch_one(pool)
1951 .await?;
1952 Ok(count)
1953}
1954
1955pub async fn flush_dirty_admin_metadata(pool: &PgPool) -> Result<i32, AwaError> {
1962 let mut total = 0i32;
1963 loop {
1964 let count: i32 = sqlx::query_scalar("SELECT awa.recompute_dirty_admin_metadata(100)")
1965 .fetch_one(pool)
1966 .await?;
1967 total += count;
1968 if count == 0 {
1969 break;
1970 }
1971 }
1972 Ok(total)
1973}
1974
1975pub async fn refresh_admin_metadata(pool: &PgPool) -> Result<(), AwaError> {
1981 sqlx::query("SELECT awa.refresh_admin_metadata()")
1982 .execute(pool)
1983 .await?;
1984 Ok(())
1985}
1986
1987pub async fn bulk_retry<'e, E>(executor: E, ids: &[i64]) -> Result<Vec<JobRow>, AwaError>
1989where
1990 E: PgExecutor<'e>,
1991{
1992 let rows = sqlx::query_as::<_, JobRow>(
1993 r#"
1994 UPDATE awa.jobs
1995 SET state = 'available', attempt = 0, run_at = now(),
1996 finalized_at = NULL, heartbeat_at = NULL, deadline_at = NULL,
1997 callback_id = NULL, callback_timeout_at = NULL,
1998 callback_filter = NULL, callback_on_complete = NULL,
1999 callback_on_fail = NULL, callback_transform = NULL
2000 WHERE id = ANY($1) AND state IN ('failed', 'cancelled', 'waiting_external')
2001 RETURNING *
2002 "#,
2003 )
2004 .bind(ids)
2005 .fetch_all(executor)
2006 .await?;
2007
2008 Ok(rows)
2009}
2010
2011pub async fn bulk_cancel<'e, E>(executor: E, ids: &[i64]) -> Result<Vec<JobRow>, AwaError>
2013where
2014 E: PgExecutor<'e>,
2015{
2016 let rows = sqlx::query_as::<_, JobRow>(
2017 r#"
2018 UPDATE awa.jobs
2019 SET state = 'cancelled', finalized_at = now(),
2020 callback_id = NULL, callback_timeout_at = NULL,
2021 callback_filter = NULL, callback_on_complete = NULL,
2022 callback_on_fail = NULL, callback_transform = NULL
2023 WHERE id = ANY($1) AND state NOT IN ('completed', 'failed', 'cancelled')
2024 RETURNING *
2025 "#,
2026 )
2027 .bind(ids)
2028 .fetch_all(executor)
2029 .await?;
2030
2031 Ok(rows)
2032}
2033
2034#[derive(Debug, Clone, Serialize)]
2036pub struct StateTimeseriesBucket {
2037 pub bucket: chrono::DateTime<chrono::Utc>,
2038 pub state: JobState,
2039 pub count: i64,
2040}
2041
2042pub async fn state_timeseries<'e, E>(
2044 executor: E,
2045 minutes: i32,
2046) -> Result<Vec<StateTimeseriesBucket>, AwaError>
2047where
2048 E: PgExecutor<'e>,
2049{
2050 let rows = sqlx::query_as::<_, (chrono::DateTime<chrono::Utc>, JobState, i64)>(
2051 r#"
2052 SELECT
2053 date_trunc('minute', created_at) AS bucket,
2054 state,
2055 count(*) AS count
2056 FROM awa.jobs
2057 WHERE created_at >= now() - make_interval(mins => $1)
2058 GROUP BY bucket, state
2059 ORDER BY bucket
2060 "#,
2061 )
2062 .bind(minutes)
2063 .fetch_all(executor)
2064 .await?;
2065
2066 Ok(rows
2067 .into_iter()
2068 .map(|(bucket, state, count)| StateTimeseriesBucket {
2069 bucket,
2070 state,
2071 count,
2072 })
2073 .collect())
2074}
2075
2076pub async fn register_callback<'e, E>(
2085 executor: E,
2086 job_id: i64,
2087 run_lease: i64,
2088 timeout: std::time::Duration,
2089) -> Result<Uuid, AwaError>
2090where
2091 E: PgExecutor<'e>,
2092{
2093 let callback_id = Uuid::new_v4();
2094 let timeout_secs = timeout.as_secs_f64();
2095 let result = sqlx::query(
2096 r#"UPDATE awa.jobs
2097 SET callback_id = $2,
2098 callback_timeout_at = now() + make_interval(secs => $3),
2099 callback_filter = NULL,
2100 callback_on_complete = NULL,
2101 callback_on_fail = NULL,
2102 callback_transform = NULL
2103 WHERE id = $1 AND state = 'running' AND run_lease = $4"#,
2104 )
2105 .bind(job_id)
2106 .bind(callback_id)
2107 .bind(timeout_secs)
2108 .bind(run_lease)
2109 .execute(executor)
2110 .await?;
2111 if result.rows_affected() == 0 {
2112 return Err(AwaError::Validation("job is not in running state".into()));
2113 }
2114 Ok(callback_id)
2115}
2116
2117pub async fn complete_external<'e, E>(
2128 executor: E,
2129 callback_id: Uuid,
2130 payload: Option<serde_json::Value>,
2131 run_lease: Option<i64>,
2132) -> Result<JobRow, AwaError>
2133where
2134 E: PgExecutor<'e>,
2135{
2136 complete_external_inner(executor, callback_id, payload, run_lease, false).await
2137}
2138
2139pub async fn resume_external<'e, E>(
2145 executor: E,
2146 callback_id: Uuid,
2147 payload: Option<serde_json::Value>,
2148 run_lease: Option<i64>,
2149) -> Result<JobRow, AwaError>
2150where
2151 E: PgExecutor<'e>,
2152{
2153 complete_external_inner(executor, callback_id, payload, run_lease, true).await
2154}
2155
2156async fn complete_external_inner<'e, E>(
2157 executor: E,
2158 callback_id: Uuid,
2159 payload: Option<serde_json::Value>,
2160 run_lease: Option<i64>,
2161 resume: bool,
2162) -> Result<JobRow, AwaError>
2163where
2164 E: PgExecutor<'e>,
2165{
2166 let row = if resume {
2167 let payload_json = payload.unwrap_or(serde_json::Value::Null);
2170 sqlx::query_as::<_, JobRow>(
2171 r#"
2172 UPDATE awa.jobs
2173 SET state = 'running',
2174 callback_id = NULL,
2175 callback_timeout_at = NULL,
2176 callback_filter = NULL,
2177 callback_on_complete = NULL,
2178 callback_on_fail = NULL,
2179 callback_transform = NULL,
2180 heartbeat_at = now(),
2181 metadata = metadata || jsonb_build_object('_awa_callback_result', $3::jsonb)
2182 WHERE callback_id = $1 AND state IN ('waiting_external', 'running')
2183 AND ($2::bigint IS NULL OR run_lease = $2)
2184 RETURNING *
2185 "#,
2186 )
2187 .bind(callback_id)
2188 .bind(run_lease)
2189 .bind(&payload_json)
2190 .fetch_optional(executor)
2191 .await?
2192 } else {
2193 sqlx::query_as::<_, JobRow>(
2195 r#"
2196 UPDATE awa.jobs
2197 SET state = 'completed',
2198 finalized_at = now(),
2199 callback_id = NULL,
2200 callback_timeout_at = NULL,
2201 callback_filter = NULL,
2202 callback_on_complete = NULL,
2203 callback_on_fail = NULL,
2204 callback_transform = NULL,
2205 heartbeat_at = NULL,
2206 deadline_at = NULL,
2207 progress = NULL
2208 WHERE callback_id = $1 AND state IN ('waiting_external', 'running')
2209 AND ($2::bigint IS NULL OR run_lease = $2)
2210 RETURNING *
2211 "#,
2212 )
2213 .bind(callback_id)
2214 .bind(run_lease)
2215 .fetch_optional(executor)
2216 .await?
2217 };
2218
2219 row.ok_or(AwaError::CallbackNotFound {
2220 callback_id: callback_id.to_string(),
2221 })
2222}
2223
2224pub async fn fail_external<'e, E>(
2228 executor: E,
2229 callback_id: Uuid,
2230 error: &str,
2231 run_lease: Option<i64>,
2232) -> Result<JobRow, AwaError>
2233where
2234 E: PgExecutor<'e>,
2235{
2236 let row = sqlx::query_as::<_, JobRow>(
2237 r#"
2238 UPDATE awa.jobs
2239 SET state = 'failed',
2240 finalized_at = now(),
2241 callback_id = NULL,
2242 callback_timeout_at = NULL,
2243 callback_filter = NULL,
2244 callback_on_complete = NULL,
2245 callback_on_fail = NULL,
2246 callback_transform = NULL,
2247 heartbeat_at = NULL,
2248 deadline_at = NULL,
2249 errors = errors || jsonb_build_object(
2250 'error', $2::text,
2251 'attempt', attempt,
2252 'at', now()
2253 )::jsonb
2254 WHERE callback_id = $1 AND state IN ('waiting_external', 'running')
2255 AND ($3::bigint IS NULL OR run_lease = $3)
2256 RETURNING *
2257 "#,
2258 )
2259 .bind(callback_id)
2260 .bind(error)
2261 .bind(run_lease)
2262 .fetch_optional(executor)
2263 .await?;
2264
2265 row.ok_or(AwaError::CallbackNotFound {
2266 callback_id: callback_id.to_string(),
2267 })
2268}
2269
2270pub async fn retry_external<'e, E>(
2280 executor: E,
2281 callback_id: Uuid,
2282 run_lease: Option<i64>,
2283) -> Result<JobRow, AwaError>
2284where
2285 E: PgExecutor<'e>,
2286{
2287 let row = sqlx::query_as::<_, JobRow>(
2288 r#"
2289 UPDATE awa.jobs
2290 SET state = 'available',
2291 attempt = 0,
2292 run_at = now(),
2293 finalized_at = NULL,
2294 callback_id = NULL,
2295 callback_timeout_at = NULL,
2296 callback_filter = NULL,
2297 callback_on_complete = NULL,
2298 callback_on_fail = NULL,
2299 callback_transform = NULL,
2300 heartbeat_at = NULL,
2301 deadline_at = NULL
2302 WHERE callback_id = $1 AND state = 'waiting_external'
2303 AND ($2::bigint IS NULL OR run_lease = $2)
2304 RETURNING *
2305 "#,
2306 )
2307 .bind(callback_id)
2308 .bind(run_lease)
2309 .fetch_optional(executor)
2310 .await?;
2311
2312 row.ok_or(AwaError::CallbackNotFound {
2313 callback_id: callback_id.to_string(),
2314 })
2315}
2316
2317pub async fn heartbeat_callback<'e, E>(
2326 executor: E,
2327 callback_id: Uuid,
2328 timeout: std::time::Duration,
2329) -> Result<JobRow, AwaError>
2330where
2331 E: PgExecutor<'e>,
2332{
2333 let timeout_secs = timeout.as_secs_f64();
2334 let row = sqlx::query_as::<_, JobRow>(
2335 r#"
2336 UPDATE awa.jobs
2337 SET callback_timeout_at = now() + make_interval(secs => $2)
2338 WHERE callback_id = $1 AND state = 'waiting_external'
2339 RETURNING *
2340 "#,
2341 )
2342 .bind(callback_id)
2343 .bind(timeout_secs)
2344 .fetch_optional(executor)
2345 .await?;
2346
2347 row.ok_or(AwaError::CallbackNotFound {
2348 callback_id: callback_id.to_string(),
2349 })
2350}
2351
2352pub async fn cancel_callback<'e, E>(
2358 executor: E,
2359 job_id: i64,
2360 run_lease: i64,
2361) -> Result<bool, AwaError>
2362where
2363 E: PgExecutor<'e>,
2364{
2365 let result = sqlx::query(
2366 r#"
2367 UPDATE awa.jobs
2368 SET callback_id = NULL,
2369 callback_timeout_at = NULL,
2370 callback_filter = NULL,
2371 callback_on_complete = NULL,
2372 callback_on_fail = NULL,
2373 callback_transform = NULL
2374 WHERE id = $1 AND callback_id IS NOT NULL AND state = 'running' AND run_lease = $2
2375 "#,
2376 )
2377 .bind(job_id)
2378 .bind(run_lease)
2379 .execute(executor)
2380 .await?;
2381
2382 Ok(result.rows_affected() > 0)
2383}
2384
2385#[derive(Debug)]
2393pub enum CallbackPollResult {
2394 Resolved(serde_json::Value),
2396 Pending,
2398 Stale {
2400 token: Uuid,
2401 current: Uuid,
2402 state: JobState,
2403 },
2404 UnexpectedState { token: Uuid, state: JobState },
2406 NotFound,
2408}
2409
2410pub async fn enter_callback_wait(
2415 pool: &PgPool,
2416 job_id: i64,
2417 run_lease: i64,
2418 callback_id: Uuid,
2419) -> Result<bool, AwaError> {
2420 let result = sqlx::query(
2421 r#"
2422 UPDATE awa.jobs
2423 SET state = 'waiting_external',
2424 heartbeat_at = NULL,
2425 deadline_at = NULL
2426 WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id = $3
2427 "#,
2428 )
2429 .bind(job_id)
2430 .bind(run_lease)
2431 .bind(callback_id)
2432 .execute(pool)
2433 .await?;
2434
2435 Ok(result.rows_affected() > 0)
2436}
2437
2438pub async fn check_callback_state(
2444 pool: &PgPool,
2445 job_id: i64,
2446 callback_id: Uuid,
2447) -> Result<CallbackPollResult, AwaError> {
2448 let row: Option<(JobState, Option<Uuid>, serde_json::Value)> =
2449 sqlx::query_as("SELECT state, callback_id, metadata FROM awa.jobs WHERE id = $1")
2450 .bind(job_id)
2451 .fetch_optional(pool)
2452 .await?;
2453
2454 match row {
2455 Some((JobState::Running, None, metadata))
2456 if metadata.get("_awa_callback_result").is_some() =>
2457 {
2458 let payload = take_callback_payload(pool, job_id, metadata).await?;
2459 Ok(CallbackPollResult::Resolved(payload))
2460 }
2461 Some((state, Some(current_callback_id), _)) if current_callback_id != callback_id => {
2462 Ok(CallbackPollResult::Stale {
2463 token: callback_id,
2464 current: current_callback_id,
2465 state,
2466 })
2467 }
2468 Some((JobState::WaitingExternal, Some(current), _)) if current == callback_id => {
2469 Ok(CallbackPollResult::Pending)
2470 }
2471 Some((state, _, _)) => Ok(CallbackPollResult::UnexpectedState {
2472 token: callback_id,
2473 state,
2474 }),
2475 None => Ok(CallbackPollResult::NotFound),
2476 }
2477}
2478
2479pub async fn take_callback_payload(
2481 pool: &PgPool,
2482 job_id: i64,
2483 metadata: serde_json::Value,
2484) -> Result<serde_json::Value, AwaError> {
2485 let payload = metadata
2486 .get("_awa_callback_result")
2487 .cloned()
2488 .unwrap_or(serde_json::Value::Null);
2489
2490 sqlx::query("UPDATE awa.jobs SET metadata = metadata - '_awa_callback_result' WHERE id = $1")
2491 .bind(job_id)
2492 .execute(pool)
2493 .await?;
2494
2495 Ok(payload)
2496}
2497
2498#[derive(Debug, Clone, Default)]
2505pub struct CallbackConfig {
2506 pub filter: Option<String>,
2508 pub on_complete: Option<String>,
2510 pub on_fail: Option<String>,
2512 pub transform: Option<String>,
2514}
2515
2516impl CallbackConfig {
2517 pub fn is_empty(&self) -> bool {
2519 self.filter.is_none()
2520 && self.on_complete.is_none()
2521 && self.on_fail.is_none()
2522 && self.transform.is_none()
2523 }
2524}
2525
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2529pub enum DefaultAction {
2530 Complete,
2531 Fail,
2532 Ignore,
2533}
2534
2535#[derive(Debug)]
2537pub enum ResolveOutcome {
2538 Completed {
2539 payload: Option<serde_json::Value>,
2540 job: JobRow,
2541 },
2542 Failed {
2543 job: JobRow,
2544 },
2545 Ignored {
2546 reason: String,
2547 },
2548}
2549
2550impl ResolveOutcome {
2551 pub fn is_completed(&self) -> bool {
2552 matches!(self, ResolveOutcome::Completed { .. })
2553 }
2554 pub fn is_failed(&self) -> bool {
2555 matches!(self, ResolveOutcome::Failed { .. })
2556 }
2557 pub fn is_ignored(&self) -> bool {
2558 matches!(self, ResolveOutcome::Ignored { .. })
2559 }
2560}
2561
2562pub async fn register_callback_with_config<'e, E>(
2571 executor: E,
2572 job_id: i64,
2573 run_lease: i64,
2574 timeout: std::time::Duration,
2575 config: &CallbackConfig,
2576) -> Result<Uuid, AwaError>
2577where
2578 E: PgExecutor<'e>,
2579{
2580 #[cfg(feature = "cel")]
2582 {
2583 for (name, expr) in [
2584 ("filter", &config.filter),
2585 ("on_complete", &config.on_complete),
2586 ("on_fail", &config.on_fail),
2587 ("transform", &config.transform),
2588 ] {
2589 if let Some(src) = expr {
2590 let program = cel::Program::compile(src).map_err(|e| {
2591 AwaError::Validation(format!("invalid CEL expression for {name}: {e}"))
2592 })?;
2593
2594 let refs = program.references();
2598 let bad_vars: Vec<&str> = refs
2599 .variables()
2600 .into_iter()
2601 .filter(|v| *v != "payload")
2602 .collect();
2603 if !bad_vars.is_empty() {
2604 return Err(AwaError::Validation(format!(
2605 "CEL expression for {name} references undeclared variable(s): {}; \
2606 only 'payload' is available",
2607 bad_vars.join(", ")
2608 )));
2609 }
2610 }
2611 }
2612 }
2613
2614 #[cfg(not(feature = "cel"))]
2615 {
2616 if !config.is_empty() {
2617 return Err(AwaError::Validation(
2618 "CEL expressions require the 'cel' feature".into(),
2619 ));
2620 }
2621 }
2622
2623 let callback_id = Uuid::new_v4();
2624 let timeout_secs = timeout.as_secs_f64();
2625
2626 let result = sqlx::query(
2627 r#"UPDATE awa.jobs
2628 SET callback_id = $2,
2629 callback_timeout_at = now() + make_interval(secs => $3),
2630 callback_filter = $4,
2631 callback_on_complete = $5,
2632 callback_on_fail = $6,
2633 callback_transform = $7
2634 WHERE id = $1 AND state = 'running' AND run_lease = $8"#,
2635 )
2636 .bind(job_id)
2637 .bind(callback_id)
2638 .bind(timeout_secs)
2639 .bind(&config.filter)
2640 .bind(&config.on_complete)
2641 .bind(&config.on_fail)
2642 .bind(&config.transform)
2643 .bind(run_lease)
2644 .execute(executor)
2645 .await?;
2646
2647 if result.rows_affected() == 0 {
2648 return Err(AwaError::Validation("job is not in running state".into()));
2649 }
2650 Ok(callback_id)
2651}
2652
2653enum ResolveAction {
2655 Complete(Option<serde_json::Value>),
2656 Fail {
2657 error: String,
2658 expression: Option<String>,
2659 },
2660 Ignore(String),
2661}
2662
2663pub async fn resolve_callback(
2669 pool: &PgPool,
2670 callback_id: Uuid,
2671 payload: Option<serde_json::Value>,
2672 default_action: DefaultAction,
2673 run_lease: Option<i64>,
2674) -> Result<ResolveOutcome, AwaError> {
2675 let mut tx = pool.begin().await?;
2676
2677 let job = sqlx::query_as::<_, JobRow>(
2686 "SELECT * FROM awa.jobs_hot WHERE callback_id = $1
2687 AND state IN ('waiting_external', 'running')
2688 AND ($2::bigint IS NULL OR run_lease = $2)
2689 FOR UPDATE",
2690 )
2691 .bind(callback_id)
2692 .bind(run_lease)
2693 .fetch_optional(&mut *tx)
2694 .await?
2695 .ok_or(AwaError::CallbackNotFound {
2696 callback_id: callback_id.to_string(),
2697 })?;
2698
2699 let action = evaluate_or_default(&job, &payload, default_action)?;
2700
2701 match action {
2702 ResolveAction::Complete(transformed_payload) => {
2703 let completed_job = sqlx::query_as::<_, JobRow>(
2704 r#"
2705 UPDATE awa.jobs
2706 SET state = 'completed',
2707 finalized_at = now(),
2708 callback_id = NULL,
2709 callback_timeout_at = NULL,
2710 callback_filter = NULL,
2711 callback_on_complete = NULL,
2712 callback_on_fail = NULL,
2713 callback_transform = NULL,
2714 heartbeat_at = NULL,
2715 deadline_at = NULL,
2716 progress = NULL
2717 WHERE id = $1
2718 RETURNING *
2719 "#,
2720 )
2721 .bind(job.id)
2722 .fetch_one(&mut *tx)
2723 .await?;
2724
2725 tx.commit().await?;
2726 Ok(ResolveOutcome::Completed {
2727 payload: transformed_payload,
2728 job: completed_job,
2729 })
2730 }
2731 ResolveAction::Fail { error, expression } => {
2732 let mut error_json = serde_json::json!({
2733 "error": error,
2734 "attempt": job.attempt,
2735 "at": chrono::Utc::now().to_rfc3339(),
2736 });
2737 if let Some(expr) = expression {
2738 error_json["expression"] = serde_json::Value::String(expr);
2739 }
2740
2741 let failed_job = sqlx::query_as::<_, JobRow>(
2742 r#"
2743 UPDATE awa.jobs
2744 SET state = 'failed',
2745 finalized_at = now(),
2746 callback_id = NULL,
2747 callback_timeout_at = NULL,
2748 callback_filter = NULL,
2749 callback_on_complete = NULL,
2750 callback_on_fail = NULL,
2751 callback_transform = NULL,
2752 heartbeat_at = NULL,
2753 deadline_at = NULL,
2754 errors = errors || $2::jsonb
2755 WHERE id = $1
2756 RETURNING *
2757 "#,
2758 )
2759 .bind(job.id)
2760 .bind(error_json)
2761 .fetch_one(&mut *tx)
2762 .await?;
2763
2764 tx.commit().await?;
2765 Ok(ResolveOutcome::Failed { job: failed_job })
2766 }
2767 ResolveAction::Ignore(reason) => {
2768 Ok(ResolveOutcome::Ignored { reason })
2770 }
2771 }
2772}
2773
2774fn evaluate_or_default(
2776 job: &JobRow,
2777 payload: &Option<serde_json::Value>,
2778 default_action: DefaultAction,
2779) -> Result<ResolveAction, AwaError> {
2780 let has_expressions = job.callback_filter.is_some()
2781 || job.callback_on_complete.is_some()
2782 || job.callback_on_fail.is_some()
2783 || job.callback_transform.is_some();
2784
2785 if !has_expressions {
2786 return Ok(apply_default(default_action, payload));
2787 }
2788
2789 #[cfg(feature = "cel")]
2790 {
2791 Ok(evaluate_cel(job, payload, default_action))
2792 }
2793
2794 #[cfg(not(feature = "cel"))]
2795 {
2796 let _ = (payload, default_action);
2799 Err(AwaError::Validation(
2800 "CEL expressions present but 'cel' feature is not enabled".into(),
2801 ))
2802 }
2803}
2804
2805fn apply_default(
2806 default_action: DefaultAction,
2807 payload: &Option<serde_json::Value>,
2808) -> ResolveAction {
2809 match default_action {
2810 DefaultAction::Complete => ResolveAction::Complete(payload.clone()),
2811 DefaultAction::Fail => ResolveAction::Fail {
2812 error: "callback failed: default action".to_string(),
2813 expression: None,
2814 },
2815 DefaultAction::Ignore => {
2816 ResolveAction::Ignore("no expressions configured, default is ignore".to_string())
2817 }
2818 }
2819}
2820
2821#[cfg(feature = "cel")]
2822fn evaluate_cel(
2823 job: &JobRow,
2824 payload: &Option<serde_json::Value>,
2825 default_action: DefaultAction,
2826) -> ResolveAction {
2827 let payload_value = payload.as_ref().cloned().unwrap_or(serde_json::Value::Null);
2828
2829 if let Some(filter_expr) = &job.callback_filter {
2831 match eval_bool(filter_expr, &payload_value, job.id, "filter") {
2832 Ok(true) => {} Ok(false) => {
2834 return ResolveAction::Ignore("filter expression returned false".to_string());
2835 }
2836 Err(_) => {
2837 }
2839 }
2840 }
2841
2842 if let Some(on_fail_expr) = &job.callback_on_fail {
2844 match eval_bool(on_fail_expr, &payload_value, job.id, "on_fail") {
2845 Ok(true) => {
2846 return ResolveAction::Fail {
2847 error: "callback failed: on_fail expression matched".to_string(),
2848 expression: Some(on_fail_expr.clone()),
2849 };
2850 }
2851 Ok(false) => {} Err(_) => {
2853 }
2855 }
2856 }
2857
2858 if let Some(on_complete_expr) = &job.callback_on_complete {
2860 match eval_bool(on_complete_expr, &payload_value, job.id, "on_complete") {
2861 Ok(true) => {
2862 let transformed = apply_transform(job, &payload_value);
2864 return ResolveAction::Complete(Some(transformed));
2865 }
2866 Ok(false) => {} Err(_) => {
2868 }
2870 }
2871 }
2872
2873 apply_default(default_action, payload)
2875}
2876
2877#[cfg(feature = "cel")]
2878fn eval_bool(
2879 expression: &str,
2880 payload_value: &serde_json::Value,
2881 job_id: i64,
2882 expression_name: &str,
2883) -> Result<bool, ()> {
2884 let program = match cel::Program::compile(expression) {
2885 Ok(p) => p,
2886 Err(e) => {
2887 tracing::warn!(
2888 job_id,
2889 expression_name,
2890 expression,
2891 error = %e,
2892 "CEL compilation error during evaluation"
2893 );
2894 return Err(());
2895 }
2896 };
2897
2898 let mut context = cel::Context::default();
2899 if let Err(e) = context.add_variable("payload", payload_value.clone()) {
2900 tracing::warn!(
2901 job_id,
2902 expression_name,
2903 error = %e,
2904 "Failed to add payload variable to CEL context"
2905 );
2906 return Err(());
2907 }
2908
2909 match program.execute(&context) {
2910 Ok(cel::Value::Bool(b)) => Ok(b),
2911 Ok(other) => {
2912 tracing::warn!(
2913 job_id,
2914 expression_name,
2915 expression,
2916 result_type = ?other.type_of(),
2917 "CEL expression returned non-bool"
2918 );
2919 Err(())
2920 }
2921 Err(e) => {
2922 tracing::warn!(
2923 job_id,
2924 expression_name,
2925 expression,
2926 error = %e,
2927 "CEL execution error"
2928 );
2929 Err(())
2930 }
2931 }
2932}
2933
2934#[cfg(feature = "cel")]
2935fn apply_transform(job: &JobRow, payload_value: &serde_json::Value) -> serde_json::Value {
2936 let transform_expr = match &job.callback_transform {
2937 Some(expr) => expr,
2938 None => return payload_value.clone(),
2939 };
2940
2941 let program = match cel::Program::compile(transform_expr) {
2942 Ok(p) => p,
2943 Err(e) => {
2944 tracing::warn!(
2945 job_id = job.id,
2946 expression = transform_expr,
2947 error = %e,
2948 "CEL transform compilation error, using original payload"
2949 );
2950 return payload_value.clone();
2951 }
2952 };
2953
2954 let mut context = cel::Context::default();
2955 if let Err(e) = context.add_variable("payload", payload_value.clone()) {
2956 tracing::warn!(
2957 job_id = job.id,
2958 error = %e,
2959 "Failed to add payload variable for transform"
2960 );
2961 return payload_value.clone();
2962 }
2963
2964 match program.execute(&context) {
2965 Ok(value) => match value.json() {
2966 Ok(json) => json,
2967 Err(e) => {
2968 tracing::warn!(
2969 job_id = job.id,
2970 expression = transform_expr,
2971 error = %e,
2972 "CEL transform result could not be converted to JSON, using original payload"
2973 );
2974 payload_value.clone()
2975 }
2976 },
2977 Err(e) => {
2978 tracing::warn!(
2979 job_id = job.id,
2980 expression = transform_expr,
2981 error = %e,
2982 "CEL transform execution error, using original payload"
2983 );
2984 payload_value.clone()
2985 }
2986 }
2987}