Skip to main content

awa_model/
admin.rs

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
243/// Retry a single failed, cancelled, or waiting_external job.
244pub 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
267/// Cancel a single non-terminal job.
268pub 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
290/// Cancel a job by its unique key components.
291///
292/// Reconstructs the BLAKE3 unique key from the same inputs used at insert time
293/// (kind, optional queue, optional args, optional period bucket), then cancels
294/// the single oldest matching non-terminal job. Returns `None` if no matching
295/// job was found (already completed, already cancelled, or never existed).
296///
297/// The parameters must match what was used at insert time: pass `queue` only if
298/// the original `UniqueOpts` had `by_queue: true`, `args` only if `by_args: true`,
299/// and `period_bucket` only if `by_period` was set. Mismatched components produce
300/// a different hash and the job won't be found.
301///
302/// Only one job is cancelled per call (the oldest by `id`). This is intentional:
303/// unique key enforcement uses a state bitmask, so multiple rows with the same
304/// key can legally coexist (e.g., one `waiting_external` + one `available`).
305/// Cancelling all of them in one shot would be surprising.
306///
307/// This is useful when the caller knows the job kind and args but not the job ID —
308/// e.g., cancelling a scheduled reminder when the triggering condition is resolved.
309///
310/// # Implementation notes
311///
312/// Queries `jobs_hot` and `scheduled_jobs` directly rather than the `awa.jobs`
313/// UNION ALL view, because PostgreSQL does not support `FOR UPDATE` on UNION
314/// views. The CTE selects candidate IDs without row locks; blocking on a
315/// concurrently-locked row (e.g., one being processed by a worker) happens
316/// implicitly during the UPDATE phase via the writable view trigger. If the
317/// worker completes the job before the UPDATE acquires the lock, the state
318/// check (`NOT IN ('completed', 'failed', 'cancelled')`) causes the cancel
319/// to no-op and return `None`.
320///
321/// The lookup scans `unique_key` on both physical tables without a dedicated
322/// index. This is acceptable for low-volume use cases. For high-volume tables,
323/// consider adding a partial index on `unique_key WHERE unique_key IS NOT NULL`
324/// or routing through `job_unique_claims` (which is already indexed).
325pub 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    // Find the oldest matching job across both physical tables. CTE selects
338    // candidate IDs without row locks; blocking on concurrently-locked rows
339    // happens implicitly during the UPDATE via the writable view trigger.
340    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
368/// Retry all failed jobs of a given kind.
369pub 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
389/// Retry all failed jobs in a given queue.
390pub 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
410/// Discard (delete) all failed jobs of a given kind.
411pub 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
423/// Pause a queue. Affects all workers immediately.
424pub 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
447/// Resume a paused queue.
448pub 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
460/// Drain a queue: cancel all non-running, non-terminal jobs.
461pub 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/// Code-declared, operator-facing descriptor for a queue.
483#[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
543/// Columns bound per descriptor row. Must match the order used in
544/// [`build_descriptor_upsert`] and the bind loop below.
545const DESCRIPTOR_PARAMS_PER_ROW: u32 = 9;
546
547/// Postgres caps bound parameters at 65535 per statement. 9 params × 7000 rows
548/// = 63k, so chunk below that with a comfortable margin.
549const DESCRIPTOR_BATCH_SIZE: usize = 5000;
550
551/// Build a batched INSERT ... VALUES (...), (...), ... ON CONFLICT upsert for
552/// a descriptor catalog. `table` is `awa.queue_descriptors` or
553/// `awa.job_kind_descriptors`; `pk` is `queue` or `kind`.
554fn 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    // updated_at only advances when the hash changes — so an untouched
581    // descriptor keeps its original `updated_at` timestamp across ticks
582    // but still gets a fresh `last_seen_at` for liveness.
583    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
602/// Upsert queue descriptors declared by the worker runtime.
603///
604/// Descriptor rows are part of the control-plane catalog and intentionally
605/// separate from mutable queue runtime state in `awa.queue_meta`.
606///
607/// Descriptors are upserted in batched multi-row statements — one round-trip
608/// per [`DESCRIPTOR_BATCH_SIZE`] descriptors rather than one per descriptor.
609/// For typical fleets (≤100 queues / ≤500 kinds) that collapses to a single
610/// round-trip per sync call.
611pub 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
646/// Upsert job-kind descriptors declared by the worker runtime. Batched the
647/// same way as [`sync_queue_descriptors`].
648pub 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    /// All non-terminal jobs for the queue, including running and waiting_external.
834    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/// Snapshot of a per-queue rate limit configuration.
864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
865pub struct RateLimitSnapshot {
866    pub max_rate: f64,
867    pub burst: u32,
868}
869
870/// Runtime concurrency mode for a queue.
871#[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/// Per-queue configuration published by a worker runtime instance.
879#[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/// Runtime state for one queue on one worker instance.
893#[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/// Data written by a worker runtime into the observability snapshot table.
939#[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/// A worker runtime instance as exposed through the admin API.
962#[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/// Cluster-wide runtime overview.
1022#[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/// Queue-centric runtime/config summary aggregated across worker instances.
1033#[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
1067/// Upsert a runtime observability snapshot for one worker instance.
1068pub 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
1146/// Opportunistically delete long-stale runtime snapshot rows.
1147pub 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
1165/// Delete descriptor rows whose `last_seen_at` is older than `max_age`.
1166///
1167/// Intended to run on the maintenance leader's cleanup cycle — a descriptor
1168/// whose declaring code has been retired would otherwise linger in the
1169/// catalog forever, showing as permanently stale. Descriptors are
1170/// best-effort (no FK from `awa.jobs*`), so deletion is safe: if a worker
1171/// later re-declares the same queue / kind, the next sync recreates the
1172/// row from the declaration.
1173///
1174/// `table` must be `awa.queue_descriptors` or `awa.job_kind_descriptors`;
1175/// the caller is expected to dispatch both in turn.
1176pub 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    // Table name is an authenticated literal from the match above — safe
1191    // to interpolate into the statement.
1192    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
1197/// List all runtime instances ordered with leader/live instances first.
1198pub 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
1235/// Cluster runtime overview with instance list.
1236pub 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
1257/// Queue runtime/config summary aggregated across worker snapshots.
1258pub 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
1335/// Get queue overviews for all known queues.
1336///
1337/// Hybrid read: per-state counts come from the `queue_state_counts`
1338/// cache table (eventually consistent, ~2s lag), while `lag_seconds`
1339/// and `completed_last_hour` are computed live from `jobs_hot`.
1340///
1341/// The cache is kept fresh by the maintenance leader's dirty-key
1342/// recompute (~2s) and full reconciliation (~60s). Also warmed during
1343/// `migrate()`.
1344///
1345/// For exact cached counts in tests without a running maintenance
1346/// leader, call `flush_dirty_admin_metadata()` first.
1347pub 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
1440/// Get one queue overview by name.
1441pub 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/// List jobs with optional filters.
1573#[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
1583/// List jobs matching the given filter.
1584pub 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
1614/// Get a single job by ID.
1615pub 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
1627/// Build a read-only inspection snapshot for one job.
1628pub 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
1636/// Build a read-only inspection snapshot for one attempt.
1637///
1638/// Awa does not currently persist a standalone runs table. The current attempt
1639/// is inspected from the live job row. Historical attempts are reconstructed
1640/// from the structured `errors[]` history.
1641pub 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
1769/// Count jobs grouped by state.
1770///
1771/// Reads from the `queue_state_counts` cache table.
1772pub async fn state_counts<'e, E>(executor: E) -> Result<HashMap<JobState, i64>, AwaError>
1773where
1774    E: PgExecutor<'e>,
1775{
1776    // Single scan of queue_state_counts — sums all columns in one pass
1777    // then unpivots via VALUES join.
1778    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
1810/// Get job-kind overviews for all known kinds.
1811pub 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
1911/// Return all distinct job kinds.
1912///
1913/// Reads from the `job_kind_catalog` cache table.
1914pub 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
1927/// Return all distinct queue names.
1928///
1929/// Reads from the `job_queue_catalog` cache table.
1930pub 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
1943/// Drain one batch of dirty keys and recompute exact cached rows.
1944/// Returns the number of dirty keys processed in this batch.
1945///
1946/// Called frequently by the maintenance leader (~2s). Uses per-queue
1947/// indexes for targeted recompute rather than full table scans.
1948pub 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
1955/// Drain ALL dirty keys until the backlog is empty.
1956///
1957/// Use in tests or admin tooling where you need the cache to be fully
1958/// consistent before reading. Each call to the underlying SQL function
1959/// acquires a blocking advisory lock, so concurrent callers serialize
1960/// rather than skip.
1961pub 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
1975/// Full reconciliation of admin metadata counters from base tables.
1976///
1977/// Called infrequently by the maintenance leader (~60s) as a safety net
1978/// to correct any drift from skipped dirty keys. Also called during
1979/// migrate() to warm the cache.
1980pub 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
1987/// Retry multiple jobs by ID. Only retries failed, cancelled, or waiting_external jobs.
1988pub 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
2011/// Cancel multiple jobs by ID. Only cancels non-terminal jobs.
2012pub 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/// A bucketed count of jobs by state over time.
2035#[derive(Debug, Clone, Serialize)]
2036pub struct StateTimeseriesBucket {
2037    pub bucket: chrono::DateTime<chrono::Utc>,
2038    pub state: JobState,
2039    pub count: i64,
2040}
2041
2042/// Return time-bucketed state counts over the last N minutes.
2043pub 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
2076/// Register a callback for a running job, writing the callback_id and timeout
2077/// to the database immediately.
2078///
2079/// Call this BEFORE sending the callback_id to the external system to avoid
2080/// the race condition where the external system fires before the DB knows
2081/// about the callback.
2082///
2083/// Returns the generated callback UUID on success.
2084pub 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
2117/// Complete a waiting job via external callback.
2118///
2119/// Accepts jobs in `waiting_external` or `running` state (race handling: the
2120/// external system may fire before the executor transitions to `waiting_external`).
2121///
2122/// When `resume` is `false` (default), the job transitions to `completed`.
2123/// When `resume` is `true`, the job transitions back to `running` with the
2124/// callback payload stored in metadata under `_awa_callback_result`. The
2125/// handler can then read the result and continue processing (sequential
2126/// callback pattern from ADR-016).
2127pub 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
2139/// Complete a waiting job and resume the handler with the callback payload.
2140///
2141/// Like `complete_external`, but the job transitions to `running` instead of
2142/// `completed`, allowing the handler to continue with sequential callbacks.
2143/// The payload is stored in `metadata._awa_callback_result`.
2144pub 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        // Resume: transition to running, store payload, refresh heartbeat.
2168        // The handler is still alive and polling — it will detect the state change.
2169        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        // Complete: terminal state, clear everything.
2194        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
2224/// Fail a waiting job via external callback.
2225///
2226/// Records the error and transitions to `failed`.
2227pub 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
2270/// Retry a waiting job via external callback.
2271///
2272/// Resets to `available` with attempt = 0. The handler must be idempotent
2273/// with respect to the external call — a retry re-executes from scratch.
2274///
2275/// Only accepts `waiting_external` state — unlike complete/fail which are
2276/// terminal transitions, retry puts the job back to `available`. Allowing
2277/// retry from `running` would risk concurrent dispatch if the original
2278/// handler hasn't finished yet.
2279pub 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
2317/// Reset the callback timeout for a long-running external operation.
2318///
2319/// External systems call this periodically to signal "still working" without
2320/// completing the job. Resets `callback_timeout_at` to `now() + timeout`.
2321/// The job stays in `waiting_external`.
2322///
2323/// Returns the updated job row, or `CallbackNotFound` if the callback ID
2324/// doesn't match a waiting job.
2325pub 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
2352/// Cancel (clear) a registered callback for a running job.
2353///
2354/// Best-effort cleanup: returns `Ok(true)` if a row was updated,
2355/// `Ok(false)` if no match (already resolved, rescued, or wrong lease).
2356/// Callers should not treat `false` as an error.
2357pub 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// ── Sequential callback wait helpers ─────────────────────────────────
2386//
2387// These functions extract the DB-interaction logic for `wait_for_callback`
2388// so that both the Rust `JobContext` and the Python bridge call the same
2389// code paths.
2390
2391/// Result of a single poll iteration inside `wait_for_callback`.
2392#[derive(Debug)]
2393pub enum CallbackPollResult {
2394    /// The callback was resolved and the payload is ready.
2395    Resolved(serde_json::Value),
2396    /// Still waiting — caller should sleep and poll again.
2397    Pending,
2398    /// The callback token is stale (a different callback is current).
2399    Stale {
2400        token: Uuid,
2401        current: Uuid,
2402        state: JobState,
2403    },
2404    /// The job left the wait unexpectedly (rescued, cancelled, etc.).
2405    UnexpectedState { token: Uuid, state: JobState },
2406    /// The job was not found.
2407    NotFound,
2408}
2409
2410/// Transition a running job to `waiting_external` for the given callback.
2411///
2412/// Returns `Ok(true)` if the transition succeeded, `Ok(false)` if the row
2413/// did not match (the caller should check for an early-resume race).
2414pub 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
2438/// Check the current state of a job during callback wait.
2439///
2440/// Handles the early-resume race: if `resume_external` won the race before
2441/// the handler transitioned to `waiting_external`, the callback result is
2442/// already in metadata and this returns `Resolved`.
2443pub 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
2479/// Extract the `_awa_callback_result` key from metadata and clean it up.
2480pub 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// ── CEL callback expressions ──────────────────────────────────────────
2499
2500/// Configuration for CEL callback expressions.
2501///
2502/// All fields are optional. When all are `None`, behaviour is identical to
2503/// the original `register_callback` (no expression evaluation).
2504#[derive(Debug, Clone, Default)]
2505pub struct CallbackConfig {
2506    /// Gate: should this payload be processed at all? Returns bool.
2507    pub filter: Option<String>,
2508    /// Does this payload indicate success? Returns bool.
2509    pub on_complete: Option<String>,
2510    /// Does this payload indicate failure? Returns bool. Evaluated before on_complete.
2511    pub on_fail: Option<String>,
2512    /// Reshape payload before returning to caller. Returns any Value.
2513    pub transform: Option<String>,
2514}
2515
2516impl CallbackConfig {
2517    /// Returns true if no expressions are configured.
2518    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/// What `resolve_callback` should do if no CEL conditions match or no
2527/// expressions are configured.
2528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2529pub enum DefaultAction {
2530    Complete,
2531    Fail,
2532    Ignore,
2533}
2534
2535/// Outcome of `resolve_callback`.
2536#[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
2562/// Register a callback with optional CEL expressions.
2563///
2564/// When expressions are provided and the `cel` feature is enabled, each
2565/// expression is trial-compiled at registration time so syntax errors are
2566/// caught early.
2567///
2568/// When the `cel` feature is disabled and any expression is non-None,
2569/// returns `AwaError::Validation`.
2570pub 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    // Validate CEL expressions at registration time: compile + check references
2581    #[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                // Reject undeclared variables — CEL only reports these at execution
2595                // time, so an expression like `missing == 1` would parse fine but
2596                // silently fall into the fail-open path at resolve time.
2597                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
2653/// Internal action decided by CEL evaluation or default.
2654enum ResolveAction {
2655    Complete(Option<serde_json::Value>),
2656    Fail {
2657        error: String,
2658        expression: Option<String>,
2659    },
2660    Ignore(String),
2661}
2662
2663/// Resolve a callback by evaluating CEL expressions against the payload.
2664///
2665/// Uses a transaction with `SELECT ... FOR UPDATE` for atomicity.
2666/// The `default_action` determines behaviour when no CEL conditions match
2667/// or no expressions are configured.
2668pub 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    // Query jobs_hot directly (not the awa.jobs UNION ALL view) because
2678    // FOR UPDATE is not reliably supported on UNION views. Waiting_external
2679    // and running jobs are always in jobs_hot (the check constraint on
2680    // scheduled_jobs only allows scheduled/retryable).
2681    //
2682    // Accepts both 'waiting_external' and 'running' to handle the race where
2683    // a fast callback arrives before the executor transitions running ->
2684    // waiting_external (matching complete_external/fail_external behavior).
2685    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            // No state change — dropping tx releases FOR UPDATE lock
2769            Ok(ResolveOutcome::Ignored { reason })
2770        }
2771    }
2772}
2773
2774/// Evaluate CEL expressions or fall through to default_action.
2775fn 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        // Expressions are present but CEL feature is not enabled.
2797        // Return an error without mutating the job — it stays in waiting_external.
2798        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    // 1. Evaluate filter
2830    if let Some(filter_expr) = &job.callback_filter {
2831        match eval_bool(filter_expr, &payload_value, job.id, "filter") {
2832            Ok(true) => {} // pass through
2833            Ok(false) => {
2834                return ResolveAction::Ignore("filter expression returned false".to_string());
2835            }
2836            Err(_) => {
2837                // Fail-open: treat filter error as true (pass through)
2838            }
2839        }
2840    }
2841
2842    // 2. Evaluate on_fail (before on_complete — fail takes precedence)
2843    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) => {} // don't fail
2852            Err(_) => {
2853                // Fail-open: treat on_fail error as false (don't fail)
2854            }
2855        }
2856    }
2857
2858    // 3. Evaluate on_complete
2859    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                // Complete with optional transform
2863                let transformed = apply_transform(job, &payload_value);
2864                return ResolveAction::Complete(Some(transformed));
2865            }
2866            Ok(false) => {} // don't complete
2867            Err(_) => {
2868                // Fail-open: treat on_complete error as false (don't complete)
2869            }
2870        }
2871    }
2872
2873    // 4. Neither condition matched → apply default_action
2874    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}