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";
38const 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));
53const _: () = 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;
68const 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
222pub 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
258pub 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 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
453pub 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
502pub 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
558pub 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
666pub 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 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 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 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 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
1093pub 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
1132pub 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 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 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 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}