Skip to main content

runledger_postgres/jobs/queue/
intents.rs

1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4use runledger_core::jobs::{JobStage, JobType, JobTypeName};
5use serde_json::Value;
6use sqlx::types::Uuid;
7
8use crate::error::SanitizedQueryErrorDiagnostics;
9use crate::{DbPool, DbTx, Error, QueryError, QueryErrorCategory, Result};
10
11use super::super::errors::{validate_page_limit, validate_pagination};
12use super::super::row_decode::{parse_job_stage, parse_job_type_name};
13use super::super::rows::{
14    JobEnqueueIntentOutcomeRow, JobEnqueueIntentRecordRow, SupportedJobEnqueueIntentPromotionRow,
15};
16use super::super::transaction_isolation::{
17    ReadCommittedTx, begin_owned_read_committed_tx, ensure_read_committed_tx,
18    finish_owned_transaction,
19};
20use super::super::transaction_settings::{
21    PostgresTimeout, cap_local_lock_timeout_duration_tx, cap_local_statement_timeout_duration_tx,
22    cap_local_transaction_timeout_duration_tx, set_local_lock_timeout_tx,
23    set_local_statement_timeout_tx,
24};
25use super::super::types::{
26    JobEnqueue, JobEnqueueDisposition, JobEnqueueIntent, JobEnqueueIntentDisposition,
27    JobEnqueueIntentListFilter, JobEnqueueIntentMetricsFilter, JobEnqueueIntentMetricsRecord,
28    JobEnqueueIntentOutcome, JobEnqueueIntentOutcomeState, JobEnqueueIntentPromotionReport,
29    JobEnqueueIntentRecord, JobEnqueueIntentStatus,
30};
31use super::enqueue::{
32    IntentEnqueueResolution, JOB_ENQUEUE_REQUEST_VERSION, canonical_job_enqueue_request_v1,
33    enqueue_job_from_intent_tx, validate_execution_resource_key,
34};
35
36const RECORD_OPERATION: &str = "record job enqueue intent";
37const PROMOTE_OPERATION: &str = "promote job enqueue intents";
38// The two-int advisory key space is distinct from the bigint key space used by
39// workflow release locks. Advisory locks remain database-global, so a rare key
40// collision with embedding-application locks only over-serializes work.
41const RUNLEDGER_ADVISORY_LOCK_NAMESPACE: i32 = 0x7275_6e6c;
42const JOB_ENQUEUE_INTENT_RETENTION_LOCK: i32 = 0x696e_7465;
43const JOB_ENQUEUE_INTENT_PROMOTION_LOCK_TIMEOUT: PostgresTimeout =
44    PostgresTimeout::new(Duration::from_secs(5));
45const JOB_ENQUEUE_INTENT_PROMOTION_TRANSACTION_TIMEOUT: PostgresTimeout =
46    PostgresTimeout::new(Duration::from_secs(25));
47const JOB_ENQUEUE_INTENT_RETENTION_FENCE_LOCK_TIMEOUT: PostgresTimeout =
48    PostgresTimeout::new(Duration::from_secs(30));
49const JOB_ENQUEUE_INTENT_RETENTION_LOCK_TIMEOUT: PostgresTimeout =
50    PostgresTimeout::new(Duration::from_secs(5));
51const JOB_ENQUEUE_INTENT_RETENTION_STATEMENT_TIMEOUT: PostgresTimeout =
52    PostgresTimeout::new(Duration::from_secs(35));
53// These ordering constraints are the liveness contract between the shared and
54// exclusive sides of the fence. Keep them compile-time checked when tuning.
55const _: () = assert!(
56    JOB_ENQUEUE_INTENT_PROMOTION_TRANSACTION_TIMEOUT.milliseconds()
57        > JOB_ENQUEUE_INTENT_PROMOTION_LOCK_TIMEOUT.milliseconds()
58);
59const _: () = assert!(
60    JOB_ENQUEUE_INTENT_RETENTION_FENCE_LOCK_TIMEOUT.milliseconds()
61        > JOB_ENQUEUE_INTENT_PROMOTION_TRANSACTION_TIMEOUT.milliseconds()
62);
63const _: () = assert!(
64    JOB_ENQUEUE_INTENT_RETENTION_STATEMENT_TIMEOUT.milliseconds()
65        > JOB_ENQUEUE_INTENT_RETENTION_FENCE_LOCK_TIMEOUT.milliseconds()
66);
67const JOB_ENQUEUE_INTENT_RETENTION_BATCH_LIMIT_MAX: usize = 1_000;
68// A failed row assigns two subtransaction IDs (the row savepoint and the
69// rollback's replacement subtransaction). Capping at 24 leaves 16 IDs of
70// headroom below PostgreSQL's 64 cached-subtransaction threshold. The enqueue
71// path must not add nested savepoints without revisiting this bound. Larger
72// batches also retain more intent, definition, and existing-job locks until
73// the outer transaction commits.
74const PROMOTION_BATCH_LIMIT_MAX: i64 = 24;
75
76struct PreparedIntent<'a> {
77    enqueue: JobEnqueue<'a>,
78    execution_resource_key: Option<&'a str>,
79    stage: &'static str,
80    enqueue_request: Value,
81}
82
83struct IntentPromotionRequest {
84    job_type: JobTypeName,
85    organization_id: Option<Uuid>,
86    payload: Value,
87    priority: Option<i32>,
88    max_attempts: Option<i32>,
89    timeout_seconds: Option<i32>,
90    next_run_at: Option<DateTime<Utc>>,
91    idempotency_key: String,
92    stage: JobStage,
93    execution_resource_key: Option<String>,
94}
95
96impl IntentPromotionRequest {
97    fn as_job_enqueue(&self) -> JobEnqueue<'_> {
98        JobEnqueue {
99            job_type: self.job_type.as_borrowed(),
100            organization_id: self.organization_id,
101            payload: &self.payload,
102            priority: self.priority,
103            max_attempts: self.max_attempts,
104            timeout_seconds: self.timeout_seconds,
105            next_run_at: self.next_run_at,
106            idempotency_key: Some(&self.idempotency_key),
107            stage: Some(self.stage),
108        }
109    }
110}
111
112struct PreparedIntentPromotion {
113    id: Uuid,
114    request: IntentPromotionRequest,
115    current_enqueue_request: Value,
116}
117
118impl PreparedIntentPromotion {
119    fn try_from_row(row: SupportedJobEnqueueIntentPromotionRow) -> Result<Self> {
120        let request = IntentPromotionRequest {
121            job_type: parse_job_type_name(row.job_type)?,
122            organization_id: row.organization_id,
123            payload: row.payload,
124            priority: row.priority,
125            max_attempts: row.max_attempts,
126            timeout_seconds: row.timeout_seconds,
127            next_run_at: row.next_run_at,
128            idempotency_key: row.idempotency_key,
129            stage: parse_job_stage(row.stage)?,
130            execution_resource_key: row.execution_resource_key,
131        };
132        validate_execution_resource_key_if_present(request.execution_resource_key.as_deref())?;
133
134        let enqueue = request.as_job_enqueue();
135        let current_enqueue_request = canonical_job_enqueue_request_v1(
136            &enqueue,
137            request.stage.as_db_value(),
138            request.execution_resource_key.as_deref(),
139        )?;
140
141        Ok(Self {
142            id: row.id,
143            request,
144            current_enqueue_request,
145        })
146    }
147}
148
149enum IntentPromotionCandidate {
150    Ready(PreparedIntentPromotion),
151    Invalid { id: Uuid, error: Error },
152}
153
154impl IntentPromotionCandidate {
155    fn from_row(row: SupportedJobEnqueueIntentPromotionRow) -> Self {
156        let id = row.id;
157        match PreparedIntentPromotion::try_from_row(row) {
158            Ok(prepared) => Self::Ready(prepared),
159            Err(error) => Self::Invalid { id, error },
160        }
161    }
162
163    fn id(&self) -> Uuid {
164        match self {
165            Self::Ready(prepared) => prepared.id,
166            Self::Invalid { id, .. } => *id,
167        }
168    }
169}
170
171struct JobEnqueueIntentMetricsRow {
172    job_type: String,
173    pending_count: i64,
174    retrying_count: i64,
175    max_promotion_attempts: i32,
176    conflicted_24h: i64,
177    promoted_24h: i64,
178    oldest_pending_at: Option<DateTime<Utc>>,
179}
180
181enum IntentPromotionDisposition {
182    Inserted,
183    Existing,
184    Conflicted,
185    DefinitionBecameUnavailable,
186    RetryDeferred,
187}
188
189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190enum IntentPromotionFailureAction {
191    Conflict {
192        code: &'static str,
193        client_message: &'static str,
194    },
195    RetryDeferred {
196        code: &'static str,
197        client_message: &'static str,
198    },
199    Propagate,
200}
201
202impl JobEnqueueIntentPromotionReport {
203    fn record(&mut self, disposition: IntentPromotionDisposition) {
204        match disposition {
205            IntentPromotionDisposition::Inserted => {
206                self.inserted_jobs += 1;
207                self.total_promoted += 1;
208            }
209            IntentPromotionDisposition::Existing => {
210                self.existing_jobs += 1;
211                self.total_promoted += 1;
212            }
213            IntentPromotionDisposition::Conflicted => self.conflicted += 1,
214            IntentPromotionDisposition::DefinitionBecameUnavailable => {
215                self.definition_became_unavailable += 1;
216            }
217            IntentPromotionDisposition::RetryDeferred => self.retry_deferred += 1,
218        }
219    }
220}
221
222/// Records a durable enqueue intent in a caller-owned transaction.
223///
224/// This operation never reads or locks `job_definitions`. It requires
225/// PostgreSQL `READ COMMITTED` isolation because an idempotency conflict is
226/// resolved by reading the row that won the unique-key race. Concurrent calls
227/// for the same `(job_type, organization_id, idempotency_key)` may wait for the
228/// transaction that first claimed that unique key to commit or roll back. The
229/// caller must include that wait in its transaction lock ordering and timeout
230/// budget.
231///
232/// Call this before any operation in the same transaction that can lock a
233/// `job_queue` row. In particular, do not enqueue, recover, or explicitly lock
234/// a job first and then record an intent. Queue retention locks intent rows
235/// before job rows; recording first preserves that canonical order and avoids
236/// an inverse lock cycle with retention.
237///
238/// The returned lifecycle status is a point-in-time observation. A shared key
239/// lock protects an existing intent from deletion for the rest of the caller
240/// transaction without blocking promotion's non-key status update, so the
241/// status may change before the transaction ends. Use intent reads and metrics
242/// to observe subsequent promotion or conflict.
243pub async fn record_job_enqueue_intent_tx(
244    tx: &mut DbTx<'_>,
245    intent: &JobEnqueueIntent<'_>,
246) -> Result<JobEnqueueIntentOutcome> {
247    let prepared = prepare_intent(intent)?;
248    let mut tx = ensure_read_committed_tx(
249        tx,
250        RECORD_OPERATION,
251        "job.intent_idempotency_unsupported_isolation",
252        "Job enqueue intent recording requires READ COMMITTED transaction isolation.",
253    )
254    .await?;
255    record_job_enqueue_intent_read_committed_tx(&mut tx, &prepared).await
256}
257
258/// Records a durable enqueue intent in an owned transaction.
259///
260/// The returned lifecycle status is a point-in-time observation; promotion can
261/// change it after this call returns.
262pub async fn record_job_enqueue_intent(
263    pool: &DbPool,
264    intent: &JobEnqueueIntent<'_>,
265) -> Result<JobEnqueueIntentOutcome> {
266    let prepared = prepare_intent(intent)?;
267    let mut tx = begin_owned_read_committed_tx(pool, RECORD_OPERATION).await?;
268    let operation_result = {
269        let mut read_committed_tx = tx.as_read_committed_tx();
270        record_job_enqueue_intent_read_committed_tx(&mut read_committed_tx, &prepared).await
271    };
272    finish_owned_transaction(tx, RECORD_OPERATION, operation_result).await
273}
274
275async fn record_job_enqueue_intent_read_committed_tx(
276    tx: &mut ReadCommittedTx<'_, '_>,
277    prepared: &PreparedIntent<'_>,
278) -> Result<JobEnqueueIntentOutcome> {
279    let enqueue = &prepared.enqueue;
280    let idempotency_key = enqueue
281        .idempotency_key
282        .ok_or_else(intent_idempotency_key_error)?;
283    for resolution_attempt in 0..2 {
284        let row = if let Some(organization_id) = enqueue.organization_id {
285            sqlx::query_as!(
286                JobEnqueueIntentOutcomeRow,
287                "INSERT INTO job_enqueue_intents (
288                job_type,
289                organization_id,
290                payload,
291                priority,
292                max_attempts,
293                timeout_seconds,
294                next_run_at,
295                idempotency_key,
296                stage,
297                enqueue_request_version,
298                enqueue_request,
299                execution_resource_key
300             )
301             VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12)
302             ON CONFLICT (job_type, organization_id, idempotency_key)
303             WHERE organization_id IS NOT NULL
304             DO NOTHING
305             RETURNING
306                id,
307                status,
308                promoted_job_id,
309                TRUE AS \"enqueue_request_matches!\"",
310                enqueue.job_type as _,
311                organization_id,
312                enqueue.payload,
313                enqueue.priority,
314                enqueue.max_attempts,
315                enqueue.timeout_seconds,
316                enqueue.next_run_at,
317                idempotency_key,
318                prepared.stage,
319                JOB_ENQUEUE_REQUEST_VERSION,
320                &prepared.enqueue_request,
321                prepared.execution_resource_key,
322            )
323            .fetch_optional(&mut **tx.as_tx())
324            .await
325        } else {
326            sqlx::query_as!(
327                JobEnqueueIntentOutcomeRow,
328                "INSERT INTO job_enqueue_intents (
329                job_type,
330                organization_id,
331                payload,
332                priority,
333                max_attempts,
334                timeout_seconds,
335                next_run_at,
336                idempotency_key,
337                stage,
338                enqueue_request_version,
339                enqueue_request,
340                execution_resource_key
341             )
342             VALUES ($1, NULL, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
343             ON CONFLICT (job_type, idempotency_key)
344             WHERE organization_id IS NULL
345             DO NOTHING
346             RETURNING
347                id,
348                status,
349                promoted_job_id,
350                TRUE AS \"enqueue_request_matches!\"",
351                enqueue.job_type as _,
352                enqueue.payload,
353                enqueue.priority,
354                enqueue.max_attempts,
355                enqueue.timeout_seconds,
356                enqueue.next_run_at,
357                idempotency_key,
358                prepared.stage,
359                JOB_ENQUEUE_REQUEST_VERSION,
360                &prepared.enqueue_request,
361                prepared.execution_resource_key,
362            )
363            .fetch_optional(&mut **tx.as_tx())
364            .await
365        }
366        .map_err(|error| Error::from_query_sqlx_with_context(RECORD_OPERATION, error))?;
367
368        if let Some(row) = row {
369            return intent_outcome(&row, JobEnqueueIntentDisposition::Inserted);
370        }
371
372        let existing = load_existing_intent_with_key_share(tx, prepared).await?;
373        let Some(existing) = existing else {
374            if resolution_attempt == 0 {
375                continue;
376            }
377            return Err(intent_conflict_missing_existing_error(
378                enqueue.job_type.as_str(),
379            ));
380        };
381
382        if !existing.enqueue_request_matches {
383            return Err(intent_idempotency_conflict_error(enqueue.job_type.as_str()));
384        }
385
386        return intent_outcome(&existing, JobEnqueueIntentDisposition::Existing);
387    }
388
389    // The two bounded attempts above either return an outcome or a classified
390    // error. Keep this defensive fallback in case that control flow changes.
391    Err(intent_conflict_missing_existing_error(
392        enqueue.job_type.as_str(),
393    ))
394}
395
396async fn load_existing_intent_with_key_share(
397    tx: &mut ReadCommittedTx<'_, '_>,
398    prepared: &PreparedIntent<'_>,
399) -> Result<Option<JobEnqueueIntentOutcomeRow>> {
400    let enqueue = &prepared.enqueue;
401    let Some(idempotency_key) = enqueue.idempotency_key else {
402        return Err(intent_idempotency_key_error());
403    };
404
405    let result = if let Some(organization_id) = enqueue.organization_id {
406        sqlx::query_as!(
407            JobEnqueueIntentOutcomeRow,
408            "SELECT
409                id,
410                status,
411                promoted_job_id,
412                enqueue_request = $4::jsonb AS \"enqueue_request_matches!\"
413             FROM job_enqueue_intents
414             WHERE job_type = $1
415               AND organization_id = $2
416               AND idempotency_key = $3
417             LIMIT 1
418             FOR KEY SHARE",
419            enqueue.job_type as _,
420            organization_id,
421            idempotency_key,
422            &prepared.enqueue_request,
423        )
424        .fetch_optional(&mut **tx.as_tx())
425        .await
426    } else {
427        sqlx::query_as!(
428            JobEnqueueIntentOutcomeRow,
429            "SELECT
430                id,
431                status,
432                promoted_job_id,
433                enqueue_request = $3::jsonb AS \"enqueue_request_matches!\"
434             FROM job_enqueue_intents
435             WHERE job_type = $1
436               AND organization_id IS NULL
437               AND idempotency_key = $2
438             LIMIT 1
439             FOR KEY SHARE",
440            enqueue.job_type as _,
441            idempotency_key,
442            &prepared.enqueue_request,
443        )
444        .fetch_optional(&mut **tx.as_tx())
445        .await
446    };
447
448    result.map_err(|error| {
449        Error::from_query_sqlx_with_context("load existing job enqueue intent", error)
450    })
451}
452
453/// Loads one durable enqueue intent by ID.
454///
455/// Passing an organization ID filters to that tenant. Passing `None` performs
456/// an administrator-wide lookup; authentication remains the caller's concern.
457pub async fn get_job_enqueue_intent_by_id(
458    pool: &DbPool,
459    organization_id: Option<Uuid>,
460    intent_id: Uuid,
461) -> Result<Option<JobEnqueueIntentRecord>> {
462    let row = sqlx::query_as!(
463        JobEnqueueIntentRecordRow,
464        "SELECT
465            id,
466            job_type,
467            organization_id,
468            payload,
469            priority,
470            max_attempts,
471            timeout_seconds,
472            next_run_at,
473            idempotency_key,
474            stage,
475            enqueue_request_version,
476            execution_resource_key,
477            promotion_attempts,
478            next_promotion_at,
479            last_attempted_at,
480            status,
481            promoted_job_id,
482            promoted_at,
483            conflicted_at,
484            last_error_code,
485            last_error_message,
486            created_at,
487            updated_at
488         FROM job_enqueue_intents
489         WHERE id = $1
490           AND ($2::uuid IS NULL OR organization_id = $2)
491         LIMIT 1",
492        intent_id,
493        organization_id,
494    )
495    .fetch_optional(pool)
496    .await
497    .map_err(|error| Error::from_query_sqlx_with_context("get job enqueue intent by id", error))?;
498
499    row.map(JobEnqueueIntentRecordRow::into_record).transpose()
500}
501
502/// Lists durable enqueue intents with bounded pagination.
503pub async fn list_job_enqueue_intents(
504    pool: &DbPool,
505    filter: &JobEnqueueIntentListFilter<'_>,
506) -> Result<Vec<JobEnqueueIntentRecord>> {
507    validate_pagination(filter.limit, filter.offset)?;
508    let status = filter.status.map(JobEnqueueIntentStatus::as_db_value);
509
510    let rows = sqlx::query_as!(
511        JobEnqueueIntentRecordRow,
512        "SELECT
513            id,
514            job_type,
515            organization_id,
516            payload,
517            priority,
518            max_attempts,
519            timeout_seconds,
520            next_run_at,
521            idempotency_key,
522            stage,
523            enqueue_request_version,
524            execution_resource_key,
525            promotion_attempts,
526            next_promotion_at,
527            last_attempted_at,
528            status,
529            promoted_job_id,
530            promoted_at,
531            conflicted_at,
532            last_error_code,
533            last_error_message,
534            created_at,
535            updated_at
536         FROM job_enqueue_intents
537         WHERE ($1::uuid IS NULL OR organization_id = $1)
538           AND ($2::text IS NULL OR status = $2)
539           AND ($3::text IS NULL OR job_type ILIKE '%' || $3 || '%')
540         ORDER BY created_at DESC, id DESC
541         LIMIT $4
542         OFFSET $5",
543        filter.organization_id,
544        status,
545        filter.job_type_query,
546        filter.limit,
547        filter.offset,
548    )
549    .fetch_all(pool)
550    .await
551    .map_err(|error| Error::from_query_sqlx_with_context("list job enqueue intents", error))?;
552
553    rows.into_iter()
554        .map(JobEnqueueIntentRecordRow::into_record)
555        .collect()
556}
557
558/// Returns durable intent backlog and promotion signals grouped by job type.
559///
560/// Each lifecycle population is aggregated behind its own selective predicate.
561/// `pending_count`, `retrying_count`, `max_promotion_attempts`, and
562/// `oldest_pending_at` describe only intents that are currently pending;
563/// attempts made by terminal promoted or conflicted intents are not backlog.
564/// `conflicted_24h` and `promoted_24h` are rolling operational signals; full
565/// terminal evidence remains available through the intent read/list APIs. Job
566/// types represented only by terminal intents older than 24 hours are omitted
567/// because every returned signal for them would be zero. Results use stable
568/// job-type ordering and the requested page must be in the shared `1..=1000`
569/// limit range with a non-negative offset.
570pub async fn get_job_enqueue_intent_metrics(
571    pool: &DbPool,
572    filter: &JobEnqueueIntentMetricsFilter<'_>,
573) -> Result<Vec<JobEnqueueIntentMetricsRecord>> {
574    validate_pagination(filter.limit, filter.offset)?;
575    let job_type = filter.job_type.map(|job_type| job_type.as_str());
576    let rows = sqlx::query_as!(
577        JobEnqueueIntentMetricsRow,
578        "WITH status_metrics AS (
579            SELECT
580                job_type,
581                COUNT(*)::bigint AS pending_count,
582                COUNT(*) FILTER (WHERE promotion_attempts > 0)::bigint AS retrying_count,
583                MAX(promotion_attempts)::integer AS max_promotion_attempts,
584                0::bigint AS conflicted_24h,
585                0::bigint AS promoted_24h,
586                MIN(created_at) AS oldest_pending_at
587            FROM job_enqueue_intents
588            WHERE status = 'PENDING'
589              AND ($1::uuid IS NULL OR organization_id = $1)
590              AND ($2::text IS NULL OR job_type = $2)
591            GROUP BY job_type
592
593            UNION ALL
594
595            SELECT
596                job_type,
597                0::bigint AS pending_count,
598                0::bigint AS retrying_count,
599                0::integer AS max_promotion_attempts,
600                COUNT(*)::bigint AS conflicted_24h,
601                0::bigint AS promoted_24h,
602                NULL::timestamptz AS oldest_pending_at
603            FROM job_enqueue_intents
604            WHERE status = 'CONFLICTED'
605              AND conflicted_at >= now() - interval '24 hours'
606              AND ($1::uuid IS NULL OR organization_id = $1)
607              AND ($2::text IS NULL OR job_type = $2)
608            GROUP BY job_type
609
610            UNION ALL
611
612            SELECT
613                job_type,
614                0::bigint AS pending_count,
615                0::bigint AS retrying_count,
616                0::integer AS max_promotion_attempts,
617                0::bigint AS conflicted_24h,
618                COUNT(*)::bigint AS promoted_24h,
619                NULL::timestamptz AS oldest_pending_at
620            FROM job_enqueue_intents
621            WHERE status = 'PROMOTED'
622              AND promoted_at >= now() - interval '24 hours'
623              AND ($1::uuid IS NULL OR organization_id = $1)
624              AND ($2::text IS NULL OR job_type = $2)
625            GROUP BY job_type
626         )
627         SELECT
628            job_type AS \"job_type!\",
629            MAX(pending_count)::bigint AS \"pending_count!\",
630            MAX(retrying_count)::bigint AS \"retrying_count!\",
631            MAX(max_promotion_attempts)::integer AS \"max_promotion_attempts!\",
632            MAX(conflicted_24h)::bigint AS \"conflicted_24h!\",
633            MAX(promoted_24h)::bigint AS \"promoted_24h!\",
634            MIN(oldest_pending_at) AS oldest_pending_at
635         FROM status_metrics
636         GROUP BY job_type
637         ORDER BY job_type
638         LIMIT $3
639         OFFSET $4",
640        filter.organization_id,
641        job_type,
642        filter.limit,
643        filter.offset,
644    )
645    .fetch_all(pool)
646    .await
647    .map_err(|error| {
648        Error::from_query_sqlx_with_context("get job enqueue intent metrics", error)
649    })?;
650
651    rows.into_iter()
652        .map(|row| {
653            Ok(JobEnqueueIntentMetricsRecord {
654                job_type: parse_job_type_name(row.job_type)?,
655                pending_count: row.pending_count,
656                retrying_count: row.retrying_count,
657                max_promotion_attempts: row.max_promotion_attempts,
658                conflicted_24h: row.conflicted_24h,
659                promoted_24h: row.promoted_24h,
660                oldest_pending_at: row.oldest_pending_at,
661            })
662        })
663        .collect()
664}
665
666/// Promotes a bounded batch of pending intents for enabled, allowed job types.
667///
668/// The requested limit must be in `1..=1000` and is then capped at 24 so one
669/// transaction cannot create enough savepoint subtransactions to push
670/// PostgreSQL into subtransaction overflow. Promotion waits at most five
671/// seconds for every lock wait and at most twenty-five seconds for the complete
672/// owned transaction while preserving any stricter session settings. The total
673/// cap prevents repeated lock waits or non-locking statements from holding the
674/// shared retention fence indefinitely. PostgreSQL terminates the promotion
675/// session if that total cap expires; SQLx discards the disconnected pooled
676/// session and the error is returned for retry. A fence-acquisition timeout
677/// likewise aborts the owned transaction; row-level promotion failures are
678/// deferred through the normal retry path. An idle pass performs one
679/// non-locking eligibility query and returns without opening a transaction or
680/// acquiring the retention fence. Work committed after that query is picked up
681/// on the next configured pass.
682pub async fn promote_job_enqueue_intents_for_types(
683    pool: &DbPool,
684    allowed_job_types: &[JobType<'_>],
685    limit: i64,
686) -> Result<JobEnqueueIntentPromotionReport> {
687    validate_page_limit(limit)?;
688    if allowed_job_types.is_empty() {
689        return Ok(JobEnqueueIntentPromotionReport::default());
690    }
691    let limit = limit.min(PROMOTION_BATCH_LIMIT_MAX);
692
693    let allowed_job_types = allowed_job_types
694        .iter()
695        .map(|job_type| job_type.as_str().to_owned())
696        .collect::<Vec<_>>();
697    if !has_eligible_job_enqueue_intents(pool, &allowed_job_types).await? {
698        return Ok(JobEnqueueIntentPromotionReport::default());
699    }
700
701    let mut tx = begin_owned_read_committed_tx(pool, PROMOTE_OPERATION).await?;
702    let operation_result = {
703        let mut read_committed_tx = tx.as_read_committed_tx();
704        promote_job_enqueue_intents_read_committed_tx(
705            &mut read_committed_tx,
706            &allowed_job_types,
707            limit,
708        )
709        .await
710    };
711    finish_owned_transaction(tx, PROMOTE_OPERATION, operation_result).await
712}
713
714async fn has_eligible_job_enqueue_intents(
715    pool: &DbPool,
716    allowed_job_types: &[String],
717) -> Result<bool> {
718    // Keep this non-locking eligibility predicate aligned with the claiming
719    // query below. A false negative delays work until the next pass; a false
720    // positive only takes the transactional path unnecessarily.
721    sqlx::query_scalar::<_, bool>(
722        "SELECT EXISTS (
723            SELECT 1
724            FROM job_enqueue_intents intent
725            INNER JOIN job_definitions definition
726               ON definition.job_type = intent.job_type
727              AND definition.is_enabled = true
728            WHERE intent.status = 'PENDING'
729              AND intent.enqueue_request_version = $2
730              AND intent.next_promotion_at <= now()
731              AND intent.job_type = ANY($1::text[])
732         )",
733    )
734    .bind(allowed_job_types)
735    .bind(JOB_ENQUEUE_REQUEST_VERSION)
736    .fetch_one(pool)
737    .await
738    .map_err(|error| {
739        Error::from_query_sqlx_with_context("check eligible job enqueue intents", error)
740    })
741}
742
743async fn promote_job_enqueue_intents_read_committed_tx(
744    tx: &mut ReadCommittedTx<'_, '_>,
745    allowed_job_types: &[String],
746    limit: i64,
747) -> Result<JobEnqueueIntentPromotionReport> {
748    prepare_job_enqueue_intent_promotion_critical_section_tx(tx).await?;
749
750    let rows = sqlx::query_as!(
751        SupportedJobEnqueueIntentPromotionRow,
752        "SELECT
753            intent.id,
754            intent.job_type,
755            intent.organization_id,
756            intent.payload,
757            intent.priority,
758            intent.max_attempts,
759            intent.timeout_seconds,
760            intent.next_run_at,
761            intent.idempotency_key,
762            intent.stage,
763            intent.execution_resource_key
764         FROM job_enqueue_intents intent
765         INNER JOIN job_definitions definition
766            ON definition.job_type = intent.job_type
767           AND definition.is_enabled = true
768         WHERE intent.status = 'PENDING'
769           AND intent.enqueue_request_version = $3
770           AND intent.next_promotion_at <= now()
771           AND intent.job_type = ANY($1::text[])
772         ORDER BY intent.next_promotion_at, intent.created_at, intent.id
773         LIMIT $2
774         FOR NO KEY UPDATE OF intent SKIP LOCKED",
775        &allowed_job_types,
776        limit,
777        JOB_ENQUEUE_REQUEST_VERSION,
778    )
779    .fetch_all(&mut **tx.as_tx())
780    .await
781    .map_err(|error| Error::from_query_sqlx_with_context("claim job enqueue intents", error))?;
782
783    let candidates = rows
784        .into_iter()
785        .map(IntentPromotionCandidate::from_row)
786        .collect::<Vec<_>>();
787
788    let mut report = JobEnqueueIntentPromotionReport::default();
789    report.mark_batch_size(candidates.len(), limit);
790    for candidate in candidates {
791        let intent_id = candidate.id();
792        // Keep these control statements dynamic: compiling three fixed
793        // SAVEPOINT statements would create SQLx cache entries without adding
794        // result-shape safety.
795        sqlx::query("SAVEPOINT promote_intent_row")
796            .execute(&mut **tx.as_tx())
797            .await
798            .map_err(|error| {
799                Error::from_query_sqlx_with_context("create intent promotion savepoint", error)
800            })?;
801
802        let promotion_result = promote_intent_candidate_tx(tx, candidate).await;
803
804        let disposition = match promotion_result {
805            Ok(disposition) => disposition,
806            Err(error) => {
807                rollback_intent_promotion_savepoint(tx).await?;
808                match classify_intent_promotion_failure(&error) {
809                    IntentPromotionFailureAction::Conflict {
810                        code,
811                        client_message,
812                    } => {
813                        mark_intent_conflicted_tx(tx, intent_id, code, client_message).await?;
814                        log_query_intent_promotion_failure(intent_id, &error, "conflicted");
815                        IntentPromotionDisposition::Conflicted
816                    }
817                    IntentPromotionFailureAction::RetryDeferred {
818                        code,
819                        client_message,
820                    } => {
821                        mark_intent_retry_deferred_tx(tx, intent_id, code, client_message).await?;
822                        log_query_intent_promotion_failure(intent_id, &error, "retry_deferred");
823                        IntentPromotionDisposition::RetryDeferred
824                    }
825                    IntentPromotionFailureAction::Propagate => return Err(error),
826                }
827            }
828        };
829
830        sqlx::query("RELEASE SAVEPOINT promote_intent_row")
831            .execute(&mut **tx.as_tx())
832            .await
833            .map_err(|error| {
834                Error::from_query_sqlx_with_context("release intent promotion savepoint", error)
835            })?;
836        report.record(disposition);
837    }
838
839    if report.total_promoted > 0
840        || report.conflicted > 0
841        || report.definition_became_unavailable > 0
842        || report.retry_deferred > 0
843    {
844        tracing::info!(
845            inserted_jobs = report.inserted_jobs,
846            existing_jobs = report.existing_jobs,
847            conflicted = report.conflicted,
848            definition_became_unavailable = report.definition_became_unavailable,
849            retry_deferred = report.retry_deferred,
850            "processed durable job enqueue intents"
851        );
852    }
853
854    Ok(report)
855}
856
857async fn promote_intent_candidate_tx(
858    tx: &mut ReadCommittedTx<'_, '_>,
859    candidate: IntentPromotionCandidate,
860) -> Result<IntentPromotionDisposition> {
861    match candidate {
862        IntentPromotionCandidate::Ready(prepared) => {
863            promote_prepared_intent_tx(tx, &prepared).await
864        }
865        IntentPromotionCandidate::Invalid { error, .. } => Err(error),
866    }
867}
868
869async fn ensure_intent_snapshot_matches_tx(
870    tx: &mut ReadCommittedTx<'_, '_>,
871    prepared: &PreparedIntentPromotion,
872) -> Result<()> {
873    // Compare against the already-locked source rows so the persisted snapshots
874    // never leave PostgreSQL. Rust `Value` equality does not model PostgreSQL
875    // numeric normalization.
876    let matches = sqlx::query_scalar::<_, bool>(
877        "SELECT enqueue_request = $2::jsonb
878         FROM job_enqueue_intents
879         WHERE id = $1",
880    )
881    .bind(prepared.id)
882    .bind(&prepared.current_enqueue_request)
883    .fetch_one(&mut **tx.as_tx())
884    .await
885    .map_err(|error| {
886        Error::from_query_sqlx_with_context("compare job enqueue intent snapshot", error)
887    })?;
888
889    if matches {
890        Ok(())
891    } else {
892        Err(intent_snapshot_mismatch_error(prepared.id))
893    }
894}
895
896async fn promote_prepared_intent_tx(
897    tx: &mut ReadCommittedTx<'_, '_>,
898    prepared: &PreparedIntentPromotion,
899) -> Result<IntentPromotionDisposition> {
900    ensure_intent_snapshot_matches_tx(tx, prepared).await?;
901    let enqueue = prepared.request.as_job_enqueue();
902
903    match enqueue_job_from_intent_tx(
904        tx,
905        &enqueue,
906        prepared.request.idempotency_key.as_str(),
907        prepared.request.execution_resource_key.as_deref(),
908    )
909    .await?
910    {
911        IntentEnqueueResolution::Enqueued(outcome) => {
912            mark_intent_promoted_tx(tx, prepared.id, outcome.job_id).await?;
913            Ok(match outcome.disposition {
914                JobEnqueueDisposition::Inserted => IntentPromotionDisposition::Inserted,
915                JobEnqueueDisposition::Existing => IntentPromotionDisposition::Existing,
916            })
917        }
918        IntentEnqueueResolution::DefinitionUnavailable { code } => {
919            let diagnostics = SanitizedQueryErrorDiagnostics::from_code(code);
920            log_intent_promotion_failure(prepared.id, diagnostics, "definition_became_unavailable");
921            Ok(IntentPromotionDisposition::DefinitionBecameUnavailable)
922        }
923        IntentEnqueueResolution::Conflict {
924            code,
925            client_message,
926        } => {
927            mark_intent_conflicted_tx(tx, prepared.id, code, client_message).await?;
928            let diagnostics = SanitizedQueryErrorDiagnostics::from_code(code);
929            log_intent_promotion_failure(prepared.id, diagnostics, "conflicted");
930            Ok(IntentPromotionDisposition::Conflicted)
931        }
932    }
933}
934
935fn log_query_intent_promotion_failure(
936    intent_id: Uuid,
937    error: &Error,
938    promotion_outcome: &'static str,
939) {
940    let Error::QueryError(error) = error else {
941        return;
942    };
943    log_intent_promotion_failure(intent_id, error.sanitized_diagnostics(), promotion_outcome);
944}
945
946fn log_intent_promotion_failure(
947    intent_id: Uuid,
948    diagnostics: SanitizedQueryErrorDiagnostics<'_>,
949    promotion_outcome: &'static str,
950) {
951    tracing::warn!(
952        intent_id = %intent_id,
953        error_code = diagnostics.code(),
954        error_sqlstate = diagnostics.sqlstate().unwrap_or("none"),
955        error_constraint = diagnostics.constraint().unwrap_or("none"),
956        promotion_outcome,
957        "durable job enqueue intent promotion did not complete"
958    );
959}
960
961async fn rollback_intent_promotion_savepoint(tx: &mut ReadCommittedTx<'_, '_>) -> Result<()> {
962    sqlx::query("ROLLBACK TO SAVEPOINT promote_intent_row")
963        .execute(&mut **tx.as_tx())
964        .await
965        .map_err(|error| {
966            Error::from_query_sqlx_with_context("rollback intent promotion savepoint", error)
967        })
968        .map(|_| ())
969}
970
971fn classify_intent_promotion_failure(error: &Error) -> IntentPromotionFailureAction {
972    let error = match error {
973        Error::QueryError(error) => error,
974        Error::ConfigError(_) | Error::ConnectionError(_) | Error::MigrationError(_) => {
975            return IntentPromotionFailureAction::Propagate;
976        }
977    };
978    // Maintenance invariant: only intrinsically invalid persisted requests are
979    // terminal. Drift between redundant columns and the canonical snapshot is
980    // repairable, so it follows the deferred path. Unknown query failures
981    // also remain retryable so an outage or repairable application policy
982    // cannot discard work.
983    match error.code() {
984        "job.intent_invalid_persisted_row"
985        | "job.invalid_job_type"
986        | "job.invalid_execution_resource_key"
987        | "job.invalid_stage" => IntentPromotionFailureAction::Conflict {
988            code: error.code(),
989            client_message: error.client_message(),
990        },
991        _ => IntentPromotionFailureAction::RetryDeferred {
992            code: error.code(),
993            client_message: error.client_message(),
994        },
995    }
996}
997
998async fn mark_intent_promoted_tx(
999    tx: &mut ReadCommittedTx<'_, '_>,
1000    intent_id: Uuid,
1001    job_id: Uuid,
1002) -> Result<()> {
1003    let result = sqlx::query!(
1004        "UPDATE job_enqueue_intents
1005         SET status = 'PROMOTED',
1006             promotion_attempts = promotion_attempts + 1,
1007             last_attempted_at = now(),
1008             promoted_job_id = $2,
1009             promoted_at = now(),
1010             conflicted_at = NULL,
1011             last_error_code = NULL,
1012             last_error_message = NULL
1013         WHERE id = $1
1014           AND status = 'PENDING'",
1015        intent_id,
1016        job_id,
1017    )
1018    .execute(&mut **tx.as_tx())
1019    .await
1020    .map_err(|error| {
1021        Error::from_query_sqlx_with_context("mark job enqueue intent promoted", error)
1022    })?;
1023    ensure_one_intent_updated(result.rows_affected(), intent_id, "promote")
1024}
1025
1026async fn mark_intent_conflicted_tx(
1027    tx: &mut ReadCommittedTx<'_, '_>,
1028    intent_id: Uuid,
1029    error_code: &str,
1030    error_message: &str,
1031) -> Result<()> {
1032    let result = sqlx::query!(
1033        "UPDATE job_enqueue_intents
1034         SET status = 'CONFLICTED',
1035             promotion_attempts = promotion_attempts + 1,
1036             last_attempted_at = now(),
1037             promoted_job_id = NULL,
1038             promoted_at = NULL,
1039             conflicted_at = now(),
1040             last_error_code = $2,
1041             last_error_message = $3
1042         WHERE id = $1
1043           AND status = 'PENDING'",
1044        intent_id,
1045        error_code,
1046        error_message,
1047    )
1048    .execute(&mut **tx.as_tx())
1049    .await
1050    .map_err(|error| {
1051        Error::from_query_sqlx_with_context("mark job enqueue intent conflicted", error)
1052    })?;
1053    ensure_one_intent_updated(result.rows_affected(), intent_id, "conflict")
1054}
1055
1056async fn mark_intent_retry_deferred_tx(
1057    tx: &mut ReadCommittedTx<'_, '_>,
1058    intent_id: Uuid,
1059    error_code: &str,
1060    error_message: &str,
1061) -> Result<()> {
1062    let result = sqlx::query!(
1063        "UPDATE job_enqueue_intents
1064         SET promotion_attempts = promotion_attempts + 1,
1065             last_attempted_at = now(),
1066             next_promotion_at = now()
1067                 + LEAST(
1068                     interval '4 minutes',
1069                     interval '1 second'
1070                         * power(2.0, LEAST(promotion_attempts, 9)::double precision)
1071                 )
1072                 + random() * LEAST(
1073                     interval '1 minute',
1074                     interval '0.25 seconds'
1075                         * power(2.0, LEAST(promotion_attempts, 9)::double precision)
1076                 ),
1077             last_error_code = $2,
1078             last_error_message = $3
1079         WHERE id = $1
1080           AND status = 'PENDING'",
1081        intent_id,
1082        error_code,
1083        error_message,
1084    )
1085    .execute(&mut **tx.as_tx())
1086    .await
1087    .map_err(|error| {
1088        Error::from_query_sqlx_with_context("defer failed job enqueue intent promotion", error)
1089    })?;
1090    ensure_one_intent_updated(result.rows_affected(), intent_id, "defer")
1091}
1092
1093/// Deletes a bounded batch of old promoted intents.
1094///
1095/// Pending and conflicted rows are never selected. Runledger does not schedule
1096/// this cleanup automatically; the embedding application owns its retention
1097/// window. This cutoff cleanup removes the retention fence from every linked
1098/// job whose intent it deletes, but it is not coordinated with job retention.
1099/// Queue retention that needs atomic fence removal must instead call
1100/// [`delete_promoted_job_enqueue_intents_for_jobs_tx`] for its exact selected job
1101/// IDs before deleting those jobs in the same transaction.
1102pub async fn delete_promoted_job_enqueue_intents_before(
1103    pool: &DbPool,
1104    cutoff: DateTime<Utc>,
1105    limit: i64,
1106) -> Result<u64> {
1107    validate_page_limit(limit)?;
1108    let result = sqlx::query!(
1109        "WITH selected AS (
1110            SELECT id
1111            FROM job_enqueue_intents
1112            WHERE status = 'PROMOTED'
1113              AND promoted_at < $1
1114            ORDER BY promoted_at, id
1115            LIMIT $2
1116            FOR UPDATE SKIP LOCKED
1117         )
1118         DELETE FROM job_enqueue_intents intent
1119         USING selected
1120         WHERE intent.id = selected.id",
1121        cutoff,
1122        limit,
1123    )
1124    .execute(pool)
1125    .await
1126    .map_err(|error| {
1127        Error::from_query_sqlx_with_context("delete promoted job enqueue intents", error)
1128    })?;
1129    Ok(result.rows_affected())
1130}
1131
1132/// Deletes promoted intents linked to an exact set of jobs in a caller-owned
1133/// retention transaction.
1134///
1135/// This operation requires PostgreSQL `READ COMMITTED` isolation so intent
1136/// deletion observes promotions that commit before the exclusive retention
1137/// fence is acquired. Stronger isolation levels retain an older transaction
1138/// snapshot and are rejected before any retention lock is taken.
1139///
1140/// After selecting candidate IDs without row locks, call this as the retention
1141/// transaction's first lock-taking operation, before locking or deleting the
1142/// same `job_queue` rows. The helper takes the exclusive side of the
1143/// enqueue-intent promotion fence, deletes existing promoted intent links, then
1144/// locks existing queue rows in UUID order. Concurrent promotions finish before
1145/// those intent or job row locks are taken, and new promotions wait until the
1146/// retention transaction ends. Taking intent locks before job locks matches the
1147/// duplicate-recorder order and prevents a recorder/retention lock-order cycle.
1148/// The fence prevents a pending intent from becoming linked between the intent
1149/// cleanup and the caller's queue delete.
1150/// Exact IDs are required because an intent may have converged on an older
1151/// existing job, so matching retention cutoffs alone cannot reliably order the
1152/// two deletes. Pending and conflicted intents are never deleted. At most 1,000
1153/// job IDs may be supplied per call; an empty slice is a no-op. Keep the
1154/// transaction short and commit promptly to release the fence. Because exact
1155/// cleanup cannot skip requested IDs, the helper may wait for a concurrent
1156/// promotion, a matching job-row lock, or an intent-row lock held by a duplicate
1157/// recorder. Runledger keeps its timeout caps active from fence acquisition
1158/// through intent deletion. The exclusive fence may wait up to thirty seconds
1159/// for a promotion transaction, whose total lifetime is capped at twenty-five
1160/// seconds. After acquiring the fence, each job- or intent-row lock wait is
1161/// capped at five seconds. Each statement is capped at thirty-five seconds.
1162/// Stricter caller settings are preserved. A timeout or deadlock aborts the
1163/// transaction; callers must roll it back and may retry the complete retention
1164/// transaction.
1165pub async fn delete_promoted_job_enqueue_intents_for_jobs_tx(
1166    tx: &mut DbTx<'_>,
1167    job_ids: &[Uuid],
1168) -> Result<u64> {
1169    if job_ids.is_empty() {
1170        return Ok(0);
1171    }
1172    validate_job_enqueue_intent_retention_batch_size(job_ids.len())?;
1173    let mut tx = ensure_read_committed_tx(
1174        tx,
1175        "job enqueue intent retention",
1176        "job.intent_retention_unsupported_isolation",
1177        "Job enqueue intent retention requires READ COMMITTED transaction isolation.",
1178    )
1179    .await?;
1180    delete_promoted_job_enqueue_intents_in_retention_critical_section_tx(&mut tx, job_ids).await
1181}
1182
1183async fn delete_promoted_job_enqueue_intents_in_retention_critical_section_tx(
1184    tx: &mut ReadCommittedTx<'_, '_>,
1185    job_ids: &[Uuid],
1186) -> Result<u64> {
1187    let previous_statement_timeout = cap_local_statement_timeout_duration_tx(
1188        tx.as_tx(),
1189        JOB_ENQUEUE_INTENT_RETENTION_STATEMENT_TIMEOUT,
1190        "cap statement timeout for job enqueue intent retention",
1191    )
1192    .await?;
1193    let previous_lock_timeout =
1194        cap_job_enqueue_intent_retention_fence_lock_timeout_tx(tx.as_tx()).await?;
1195
1196    lock_job_enqueue_intent_retention_exclusive_tx(tx.as_tx()).await?;
1197    // Once the exclusive fence is held, no promotion can extend this section.
1198    // Restore the shorter per-lock budget before touching intent or job rows.
1199    cap_job_enqueue_intent_retention_lock_timeout_tx(tx.as_tx()).await?;
1200
1201    let result = sqlx::query!(
1202        "DELETE FROM job_enqueue_intents
1203         WHERE status = 'PROMOTED'
1204           AND promoted_job_id = ANY($1::uuid[])",
1205        job_ids,
1206    )
1207    .execute(&mut **tx.as_tx())
1208    .await
1209    .map_err(|error| {
1210        Error::from_query_sqlx_with_context(
1211            "delete promoted job enqueue intents for retained jobs",
1212            error,
1213        )
1214    })?;
1215
1216    // Intent rows precede job rows in every critical section that can hold both.
1217    // Keeping that order here lets a duplicate recorder lock its promoted job
1218    // without cycling against retention.
1219    lock_retained_jobs_tx(tx.as_tx(), job_ids).await?;
1220
1221    restore_job_enqueue_intent_lock_timeout_tx(tx.as_tx(), &previous_lock_timeout).await?;
1222    set_local_statement_timeout_tx(
1223        tx.as_tx(),
1224        &previous_statement_timeout,
1225        "restore statement timeout after job enqueue intent retention",
1226    )
1227    .await?;
1228
1229    Ok(result.rows_affected())
1230}
1231
1232fn validate_job_enqueue_intent_retention_batch_size(batch_size: usize) -> Result<()> {
1233    if batch_size <= JOB_ENQUEUE_INTENT_RETENTION_BATCH_LIMIT_MAX {
1234        return Ok(());
1235    }
1236
1237    Err(Error::QueryError(QueryError::from_classified(
1238        QueryErrorCategory::Validation,
1239        "job.intent_retention_batch_too_large",
1240        "Job enqueue intent retention batch must contain at most 1,000 job IDs.",
1241        format!(
1242            "job enqueue intent retention batch must contain at most \
1243             {JOB_ENQUEUE_INTENT_RETENTION_BATCH_LIMIT_MAX} job IDs, got {batch_size}"
1244        ),
1245    )))
1246}
1247
1248async fn lock_retained_jobs_tx(tx: &mut DbTx<'_>, job_ids: &[Uuid]) -> Result<()> {
1249    sqlx::query(
1250        "SELECT id
1251         FROM job_queue
1252         WHERE id = ANY($1::uuid[])
1253         ORDER BY id
1254         FOR UPDATE",
1255    )
1256    .bind(job_ids)
1257    .fetch_all(&mut **tx)
1258    .await
1259    .map_err(|error| {
1260        Error::from_query_sqlx_with_context(
1261            "lock retained jobs before promoted intent cleanup",
1262            error,
1263        )
1264    })?;
1265    Ok(())
1266}
1267
1268async fn cap_job_enqueue_intent_promotion_lock_timeout_tx(tx: &mut DbTx<'_>) -> Result<String> {
1269    cap_local_lock_timeout_duration_tx(
1270        tx,
1271        JOB_ENQUEUE_INTENT_PROMOTION_LOCK_TIMEOUT,
1272        "cap lock timeout for job enqueue intent promotion",
1273    )
1274    .await
1275}
1276
1277async fn cap_job_enqueue_intent_retention_fence_lock_timeout_tx(
1278    tx: &mut DbTx<'_>,
1279) -> Result<String> {
1280    cap_local_lock_timeout_duration_tx(
1281        tx,
1282        JOB_ENQUEUE_INTENT_RETENTION_FENCE_LOCK_TIMEOUT,
1283        "cap lock timeout for job enqueue intent retention fence",
1284    )
1285    .await
1286}
1287
1288async fn cap_job_enqueue_intent_retention_lock_timeout_tx(tx: &mut DbTx<'_>) -> Result<String> {
1289    cap_local_lock_timeout_duration_tx(
1290        tx,
1291        JOB_ENQUEUE_INTENT_RETENTION_LOCK_TIMEOUT,
1292        "cap lock timeout for job enqueue intent retention critical section",
1293    )
1294    .await
1295}
1296
1297async fn restore_job_enqueue_intent_lock_timeout_tx(
1298    tx: &mut DbTx<'_>,
1299    previous_lock_timeout: &str,
1300) -> Result<()> {
1301    set_local_lock_timeout_tx(
1302        tx,
1303        previous_lock_timeout,
1304        "restore lock timeout after job enqueue intent retention critical section",
1305    )
1306    .await
1307}
1308
1309async fn prepare_job_enqueue_intent_promotion_critical_section_tx(
1310    tx: &mut ReadCommittedTx<'_, '_>,
1311) -> Result<()> {
1312    // Promotion owns this transaction, so leave both caps active until commit
1313    // or rollback. The per-lock cap bounds one acquisition; the transaction cap
1314    // bounds all work performed while the shared retention fence is held.
1315    cap_local_transaction_timeout_duration_tx(
1316        tx.as_tx(),
1317        JOB_ENQUEUE_INTENT_PROMOTION_TRANSACTION_TIMEOUT,
1318        "cap transaction timeout for job enqueue intent promotion",
1319    )
1320    .await?;
1321    cap_job_enqueue_intent_promotion_lock_timeout_tx(tx.as_tx()).await?;
1322    sqlx::query(
1323        "SELECT pg_advisory_xact_lock_shared($1, $2)
1324         /* runledger:lock_job_enqueue_intent_promotion */",
1325    )
1326    .bind(RUNLEDGER_ADVISORY_LOCK_NAMESPACE)
1327    .bind(JOB_ENQUEUE_INTENT_RETENTION_LOCK)
1328    .execute(&mut **tx.as_tx())
1329    .await
1330    .map_err(|error| {
1331        Error::from_query_sqlx_with_context("lock job enqueue intent promotion", error)
1332    })?;
1333    Ok(())
1334}
1335
1336async fn lock_job_enqueue_intent_retention_exclusive_tx(tx: &mut DbTx<'_>) -> Result<()> {
1337    sqlx::query(
1338        "SELECT pg_advisory_xact_lock($1, $2)
1339         /* runledger:lock_job_enqueue_intent_retention */",
1340    )
1341    .bind(RUNLEDGER_ADVISORY_LOCK_NAMESPACE)
1342    .bind(JOB_ENQUEUE_INTENT_RETENTION_LOCK)
1343    .execute(&mut **tx)
1344    .await
1345    .map_err(|error| {
1346        Error::from_query_sqlx_with_context("lock job enqueue intent retention", error)
1347    })?;
1348    Ok(())
1349}
1350
1351fn prepare_intent<'a>(intent: &JobEnqueueIntent<'a>) -> Result<PreparedIntent<'a>> {
1352    let enqueue = intent.as_job_enqueue();
1353    JobType::try_new(enqueue.job_type.as_str()).map_err(|_| invalid_intent_job_type_error())?;
1354    let Some(idempotency_key) = enqueue.idempotency_key else {
1355        return Err(intent_idempotency_key_error());
1356    };
1357    if idempotency_key.trim().is_empty() {
1358        return Err(intent_idempotency_key_error());
1359    }
1360    if enqueue.max_attempts.is_some_and(|value| value <= 0) {
1361        return Err(invalid_intent_max_attempts_error());
1362    }
1363    if enqueue.timeout_seconds.is_some_and(|value| value <= 0) {
1364        return Err(invalid_intent_timeout_error());
1365    }
1366    if let Some(execution_resource_key) = intent.execution_resource_key() {
1367        validate_execution_resource_key(execution_resource_key)?;
1368    }
1369
1370    let stage = enqueue.stage.unwrap_or(JobStage::Queued).as_db_value();
1371    let enqueue_request =
1372        canonical_job_enqueue_request_v1(&enqueue, stage, intent.execution_resource_key())?;
1373    Ok(PreparedIntent {
1374        enqueue,
1375        execution_resource_key: intent.execution_resource_key(),
1376        stage,
1377        enqueue_request,
1378    })
1379}
1380
1381fn invalid_intent_job_type_error() -> Error {
1382    Error::QueryError(QueryError::from_classified(
1383        QueryErrorCategory::Validation,
1384        "job.invalid_job_type",
1385        "Job type must not be blank.",
1386        "job enqueue intent job_type was blank",
1387    ))
1388}
1389
1390fn validate_execution_resource_key_if_present(execution_resource_key: Option<&str>) -> Result<()> {
1391    if let Some(execution_resource_key) = execution_resource_key {
1392        validate_execution_resource_key(execution_resource_key)?;
1393    }
1394    Ok(())
1395}
1396
1397fn intent_outcome(
1398    row: &JobEnqueueIntentOutcomeRow,
1399    disposition: JobEnqueueIntentDisposition,
1400) -> Result<JobEnqueueIntentOutcome> {
1401    let state = match (parse_intent_status(&row.status)?, row.promoted_job_id) {
1402        (JobEnqueueIntentStatus::Pending, None) => JobEnqueueIntentOutcomeState::Pending,
1403        (JobEnqueueIntentStatus::Promoted, Some(job_id)) => {
1404            JobEnqueueIntentOutcomeState::Promoted { job_id }
1405        }
1406        (JobEnqueueIntentStatus::Conflicted, None) => JobEnqueueIntentOutcomeState::Conflicted,
1407        _ => return Err(invalid_intent_row_error()),
1408    };
1409
1410    Ok(JobEnqueueIntentOutcome {
1411        intent_id: row.id,
1412        state,
1413        disposition,
1414    })
1415}
1416
1417fn parse_intent_status(status: &str) -> Result<JobEnqueueIntentStatus> {
1418    status.parse().map_err(|()| invalid_intent_row_error())
1419}
1420
1421fn ensure_one_intent_updated(rows_affected: u64, intent_id: Uuid, operation: &str) -> Result<()> {
1422    if rows_affected == 1 {
1423        return Ok(());
1424    }
1425    Err(Error::QueryError(QueryError::from_classified(
1426        QueryErrorCategory::Internal,
1427        "job.intent_transition_failed",
1428        "Job enqueue intent could not be updated.",
1429        format!(
1430            "job enqueue intent {operation} transition for {intent_id} affected {rows_affected} rows"
1431        ),
1432    )))
1433}
1434
1435fn intent_idempotency_key_error() -> Error {
1436    Error::QueryError(QueryError::from_classified(
1437        QueryErrorCategory::Validation,
1438        "job.intent_invalid_idempotency_key",
1439        "Job enqueue intent idempotency key must not be blank.",
1440        "job enqueue intent idempotency_key was missing or blank",
1441    ))
1442}
1443
1444fn invalid_intent_max_attempts_error() -> Error {
1445    Error::QueryError(QueryError::from_classified(
1446        QueryErrorCategory::Validation,
1447        "job.intent_invalid_max_attempts",
1448        "Job enqueue intent max attempts must be positive.",
1449        "job enqueue intent max_attempts was not positive",
1450    ))
1451}
1452
1453fn invalid_intent_timeout_error() -> Error {
1454    Error::QueryError(QueryError::from_classified(
1455        QueryErrorCategory::Validation,
1456        "job.intent_invalid_timeout",
1457        "Job enqueue intent timeout must be positive.",
1458        "job enqueue intent timeout_seconds was not positive",
1459    ))
1460}
1461
1462fn intent_idempotency_conflict_error(job_type: &str) -> Error {
1463    Error::QueryError(QueryError::from_classified(
1464        QueryErrorCategory::Conflict,
1465        "job.intent_idempotency_conflict",
1466        "Job enqueue intent retry conflicts with the existing idempotency key.",
1467        format!("job enqueue intent request differs for job_type={job_type}"),
1468    ))
1469}
1470
1471fn intent_conflict_missing_existing_error(job_type: &str) -> Error {
1472    Error::QueryError(QueryError::from_classified(
1473        QueryErrorCategory::Internal,
1474        "job.intent_idempotency_conflict_missing_existing",
1475        "Job enqueue intent retry could not be resolved.",
1476        format!(
1477            "job enqueue intent insert for job_type={job_type} conflicted but matching row was not found"
1478        ),
1479    ))
1480}
1481
1482fn invalid_intent_row_error() -> Error {
1483    Error::QueryError(QueryError::from_classified(
1484        QueryErrorCategory::Internal,
1485        "job.intent_invalid_persisted_row",
1486        "Job enqueue intent contains invalid persisted state.",
1487        "job enqueue intent persisted row could not be decoded",
1488    ))
1489}
1490
1491fn intent_snapshot_mismatch_error(intent_id: Uuid) -> Error {
1492    Error::QueryError(QueryError::from_classified(
1493        QueryErrorCategory::Internal,
1494        "job.intent_snapshot_mismatch",
1495        "Job enqueue intent request snapshot is inconsistent.",
1496        format!("job enqueue intent {intent_id} does not match its canonical request snapshot"),
1497    ))
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    use super::*;
1503
1504    fn promotion_query_error(code: &'static str, client_message: &'static str) -> Error {
1505        Error::QueryError(QueryError::from_classified(
1506            QueryErrorCategory::Internal,
1507            code,
1508            client_message,
1509            "test intent promotion failure",
1510        ))
1511    }
1512
1513    #[test]
1514    fn query_failure_diagnostics_expose_only_sanitized_fields() {
1515        let error = QueryError::from_sqlx(
1516            sqlx::Error::Protocol("test promotion protocol failure".to_owned()),
1517            Some("promote test intent"),
1518        );
1519
1520        let diagnostics = error.sanitized_diagnostics();
1521
1522        assert_eq!(diagnostics.code(), "db.query_failed");
1523        assert_eq!(diagnostics.sqlstate(), None);
1524        assert_eq!(diagnostics.constraint(), None);
1525        let debug = format!("{diagnostics:?}");
1526        assert!(!debug.contains("promote test intent"));
1527        assert!(!debug.contains("test promotion protocol failure"));
1528    }
1529
1530    #[test]
1531    fn promotion_failure_action_matrix_is_exhaustive() {
1532        for code in [
1533            "job.intent_invalid_persisted_row",
1534            "job.invalid_job_type",
1535            "job.invalid_execution_resource_key",
1536            "job.invalid_stage",
1537        ] {
1538            let error = promotion_query_error(code, "terminal persisted intent");
1539
1540            assert_eq!(
1541                classify_intent_promotion_failure(&error),
1542                IntentPromotionFailureAction::Conflict {
1543                    code,
1544                    client_message: "terminal persisted intent",
1545                },
1546                "terminal query error must win over the deferred fallback for {code}"
1547            );
1548        }
1549
1550        for code in [
1551            "job.idempotency_conflict_missing_existing",
1552            "job.intent_snapshot_mismatch",
1553            "job.future_row_error",
1554        ] {
1555            let error = promotion_query_error(code, "repairable intent failure");
1556
1557            assert_eq!(
1558                classify_intent_promotion_failure(&error),
1559                IntentPromotionFailureAction::RetryDeferred {
1560                    code,
1561                    client_message: "repairable intent failure",
1562                },
1563                "query error must remain retryable for {code}"
1564            );
1565        }
1566
1567        for error in [
1568            Error::ConfigError("test config failure".to_owned()),
1569            Error::ConnectionError("test connection failure".to_owned()),
1570            Error::MigrationError("test migration failure".to_owned()),
1571        ] {
1572            assert_eq!(
1573                classify_intent_promotion_failure(&error),
1574                IntentPromotionFailureAction::Propagate
1575            );
1576        }
1577    }
1578
1579    #[test]
1580    fn decodes_and_serializes_every_enqueue_intent_outcome_state() {
1581        let intent_id = Uuid::now_v7();
1582        let job_id = Uuid::now_v7();
1583
1584        for (status, promoted_job_id, expected_state) in [
1585            ("PENDING", None, serde_json::json!({"state": "pending"})),
1586            (
1587                "PROMOTED",
1588                Some(job_id),
1589                serde_json::json!({"state": "promoted", "job_id": job_id}),
1590            ),
1591            (
1592                "CONFLICTED",
1593                None,
1594                serde_json::json!({"state": "conflicted"}),
1595            ),
1596        ] {
1597            let outcome = intent_outcome(
1598                &JobEnqueueIntentOutcomeRow {
1599                    id: intent_id,
1600                    status: status.into(),
1601                    promoted_job_id,
1602                    enqueue_request_matches: true,
1603                },
1604                JobEnqueueIntentDisposition::Existing,
1605            )
1606            .expect("decode valid intent outcome state");
1607
1608            assert_eq!(
1609                serde_json::to_value(outcome.state).expect("serialize intent outcome state"),
1610                expected_state
1611            );
1612        }
1613    }
1614
1615    #[test]
1616    fn rejects_impossible_enqueue_intent_outcome_rows() {
1617        let intent_id = Uuid::now_v7();
1618        let job_id = Uuid::now_v7();
1619
1620        for (status, promoted_job_id) in [
1621            ("PENDING", Some(job_id)),
1622            ("PROMOTED", None),
1623            ("CONFLICTED", Some(job_id)),
1624            ("UNKNOWN", None),
1625        ] {
1626            let error = intent_outcome(
1627                &JobEnqueueIntentOutcomeRow {
1628                    id: intent_id,
1629                    status: status.into(),
1630                    promoted_job_id,
1631                    enqueue_request_matches: true,
1632                },
1633                JobEnqueueIntentDisposition::Existing,
1634            )
1635            .expect_err("impossible outcome row must be rejected");
1636            let Error::QueryError(error) = error else {
1637                panic!("expected query error");
1638            };
1639            assert_eq!(error.code(), "job.intent_invalid_persisted_row");
1640        }
1641    }
1642}