Skip to main content

runledger_postgres/jobs/queue/
definitions.rs

1use std::{error::Error as StdError, fmt};
2
3use crate::{DbPool, DbTx, Error, QueryError, QueryErrorCategory, Result};
4use runledger_core::jobs::{JobType, JobTypeName};
5
6use super::super::errors::validate_pagination;
7use super::super::row_decode::parse_job_type_name;
8use super::super::schedule_definition_guard::{
9    self, GuardLockContext, ScheduleDefinitionLockError,
10};
11use super::super::types::{
12    JobDefinitionListFilter, JobDefinitionRecord, JobDefinitionUpdate, JobDefinitionUpsert,
13    JobScheduleJobTypeReference,
14};
15
16/// Summary of definition rows changed by a catalog sync.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct JobDefinitionCatalogSyncReport {
19    /// Enabled definitions in the exact-sync scope that were absent from the
20    /// catalog and changed to disabled.
21    pub disabled_absent_job_types: Vec<JobTypeName>,
22    /// Catalog definitions changed to disabled because the catalog synced them
23    /// with `is_enabled = false`.
24    pub disabled_catalog_job_types: Vec<JobTypeName>,
25}
26
27/// Enabled-state handling for additive catalog definition sync.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum JobDefinitionCatalogSyncMode {
30    /// Preserve stored `is_enabled` for enabled catalog definitions on conflict.
31    ///
32    /// This keeps operator pauses in place when a worker restarts with the same
33    /// enabled catalog. Disabled definitions in the payload still write
34    /// `is_enabled = false`.
35    PreserveExistingEnabledForEnabledDefinitions,
36    /// Write each payload's `is_enabled` value on insert and conflict.
37    RestoreCatalogEnabledState,
38}
39
40/// Error returned while applying a catalog-owned job-definition sync.
41#[non_exhaustive]
42#[derive(Debug)]
43pub enum JobDefinitionCatalogSyncError {
44    /// An active schedule references an enabled scoped definition absent from
45    /// the catalog.
46    ActiveScheduleForAbsentJobType(JobScheduleJobTypeReference),
47    /// An active schedule references a catalog definition that would be disabled.
48    ActiveScheduleForDisabledJobType(JobScheduleJobTypeReference),
49    /// Applying transaction-local statement timeout bounds failed.
50    CriticalSectionTimeoutFailure(Box<Error>),
51    /// Locking `job_schedules` before disabling definitions failed.
52    ScheduleLockFailure(Box<Error>),
53    /// Locking `job_definitions` before disabling definitions failed.
54    DefinitionLockFailure(Box<Error>),
55    /// Checking active schedules before disabling definitions failed.
56    ScheduleCheckFailure(Box<Error>),
57    /// Sync input failed validation before any catalog writes.
58    ValidationFailure(Box<Error>),
59    /// Inspecting existing definitions before sync failed.
60    DefinitionInspectFailure(Box<Error>),
61    /// Syncing one catalog definition failed.
62    DefinitionSyncFailure {
63        /// Job type whose definition failed to sync.
64        job_type: String,
65        /// Persistence-layer failure returned by the definition write.
66        source: Box<Error>,
67    },
68    /// Disabling absent scoped definitions failed.
69    DisableAbsentFailure(Box<Error>),
70}
71
72impl fmt::Display for JobDefinitionCatalogSyncError {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::ActiveScheduleForAbsentJobType(reference) => write!(
76                f,
77                "active schedule {} still references absent catalog job type {}",
78                reference.schedule_name, reference.job_type
79            ),
80            Self::ActiveScheduleForDisabledJobType(reference) => write!(
81                f,
82                "active schedule {} still references disabled catalog job type {}",
83                reference.schedule_name, reference.job_type
84            ),
85            Self::CriticalSectionTimeoutFailure(error) => {
86                write!(
87                    f,
88                    "failed to bound job definition disable critical section: {error}"
89                )
90            }
91            Self::ScheduleLockFailure(error) => write!(
92                f,
93                "failed to lock job schedules before disabling job definitions: {error}"
94            ),
95            Self::DefinitionLockFailure(error) => write!(
96                f,
97                "failed to lock job definitions before disabling job definitions: {error}"
98            ),
99            Self::ScheduleCheckFailure(error) => write!(
100                f,
101                "failed to check active schedules before disabling job definitions: {error}"
102            ),
103            Self::ValidationFailure(error) => {
104                write!(f, "job definition catalog sync input is invalid: {error}")
105            }
106            Self::DefinitionInspectFailure(error) => {
107                write!(
108                    f,
109                    "failed to inspect job definitions before catalog sync: {error}"
110                )
111            }
112            Self::DefinitionSyncFailure { job_type, source } => {
113                write!(f, "failed to sync job definition {job_type}: {source}")
114            }
115            Self::DisableAbsentFailure(error) => {
116                write!(f, "failed to disable absent job definitions: {error}")
117            }
118        }
119    }
120}
121
122impl StdError for JobDefinitionCatalogSyncError {
123    fn source(&self) -> Option<&(dyn StdError + 'static)> {
124        match self {
125            Self::CriticalSectionTimeoutFailure(error)
126            | Self::ScheduleLockFailure(error)
127            | Self::DefinitionLockFailure(error)
128            | Self::ScheduleCheckFailure(error)
129            | Self::ValidationFailure(error)
130            | Self::DefinitionInspectFailure(error)
131            | Self::DefinitionSyncFailure { source: error, .. }
132            | Self::DisableAbsentFailure(error) => Some(error.as_ref()),
133            Self::ActiveScheduleForAbsentJobType(_) | Self::ActiveScheduleForDisabledJobType(_) => {
134                None
135            }
136        }
137    }
138}
139
140pub async fn sync_catalog_job_definitions_tx(
141    tx: &mut DbTx<'_>,
142    definitions: &[JobDefinitionUpsert<'_>],
143    mode: JobDefinitionCatalogSyncMode,
144) -> std::result::Result<JobDefinitionCatalogSyncReport, JobDefinitionCatalogSyncError> {
145    let disabled_job_types = definition_job_type_names(
146        definitions
147            .iter()
148            .filter(|definition| !definition.is_enabled),
149    )?;
150    let disabled_catalog_job_types = if disabled_job_types.is_empty() {
151        Vec::new()
152    } else {
153        prepare_definition_disable_critical_section_tx(tx).await?;
154        reject_active_schedules_for_disabled_job_types_tx(tx, &disabled_job_types).await?;
155        // Report rows that this sync will newly create as disabled or change
156        // from enabled to disabled. Already-disabled rows are intentionally
157        // omitted from the report.
158        list_job_types_missing_or_enabled_definitions_tx(tx, &disabled_job_types)
159            .await
160            .map_err(|error| {
161                JobDefinitionCatalogSyncError::DefinitionInspectFailure(Box::new(error))
162            })?
163    };
164
165    for definition in definitions {
166        let upsert_result = match (mode, definition.is_enabled) {
167            (JobDefinitionCatalogSyncMode::PreserveExistingEnabledForEnabledDefinitions, true) => {
168                upsert_job_definition_preserving_enabled_tx(tx, definition).await
169            }
170            (JobDefinitionCatalogSyncMode::PreserveExistingEnabledForEnabledDefinitions, false)
171            | (JobDefinitionCatalogSyncMode::RestoreCatalogEnabledState, _) => {
172                apply_job_definition_upsert_tx(tx, definition).await
173            }
174        };
175        upsert_result.map_err(
176            |source| JobDefinitionCatalogSyncError::DefinitionSyncFailure {
177                job_type: definition.job_type.as_str().to_owned(),
178                source: Box::new(source),
179            },
180        )?;
181    }
182
183    Ok(JobDefinitionCatalogSyncReport {
184        disabled_absent_job_types: Vec::new(),
185        disabled_catalog_job_types,
186    })
187}
188
189pub async fn sync_catalog_job_definitions_exact_tx(
190    tx: &mut DbTx<'_>,
191    definitions: &[JobDefinitionUpsert<'_>],
192    scope_job_types: &[JobTypeName],
193) -> std::result::Result<JobDefinitionCatalogSyncReport, JobDefinitionCatalogSyncError> {
194    let catalog_job_types = definition_job_type_names(definitions.iter())?;
195    validate_non_empty_job_types("exact catalog sync job definitions", &catalog_job_types)
196        .map_err(|error| JobDefinitionCatalogSyncError::ValidationFailure(Box::new(error)))?;
197    validate_non_empty_job_types("exact catalog sync scope", scope_job_types)
198        .map_err(|error| JobDefinitionCatalogSyncError::ValidationFailure(Box::new(error)))?;
199
200    let disabled_job_types = definition_job_type_names(
201        definitions
202            .iter()
203            .filter(|definition| !definition.is_enabled),
204    )?;
205    let has_absent_scope_job_types = scope_job_types
206        .iter()
207        .any(|job_type| !catalog_job_types.contains(job_type));
208    let requires_disable_guard = !disabled_job_types.is_empty() || has_absent_scope_job_types;
209    if requires_disable_guard {
210        prepare_definition_disable_critical_section_tx(tx).await?;
211    }
212
213    let disabled_catalog_job_types = if disabled_job_types.is_empty() {
214        Vec::new()
215    } else {
216        reject_active_schedules_for_disabled_job_types_tx(tx, &disabled_job_types).await?;
217        list_job_types_missing_or_enabled_definitions_tx(tx, &disabled_job_types)
218            .await
219            .map_err(|error| {
220                JobDefinitionCatalogSyncError::DefinitionInspectFailure(Box::new(error))
221            })?
222    };
223
224    if has_absent_scope_job_types {
225        if let Some(reference) =
226            schedule_definition_guard::find_active_schedule_for_enabled_absent_job_types_tx(
227                tx,
228                &catalog_job_types,
229                scope_job_types,
230            )
231            .await
232            .map_err(|error| JobDefinitionCatalogSyncError::ScheduleCheckFailure(Box::new(error)))?
233        {
234            return Err(JobDefinitionCatalogSyncError::ActiveScheduleForAbsentJobType(reference));
235        }
236    }
237
238    // Re-enabling catalog definitions does not need the disable guard because it
239    // cannot orphan active schedules or authorize work for a row being disabled.
240    for definition in definitions {
241        apply_job_definition_upsert_tx(tx, definition)
242            .await
243            .map_err(
244                |source| JobDefinitionCatalogSyncError::DefinitionSyncFailure {
245                    job_type: definition.job_type.as_str().to_owned(),
246                    source: Box::new(source),
247                },
248            )?;
249    }
250
251    let disabled_absent_job_types = if has_absent_scope_job_types {
252        disable_enabled_job_definitions_except_tx(tx, &catalog_job_types, scope_job_types)
253            .await
254            .map_err(|error| JobDefinitionCatalogSyncError::DisableAbsentFailure(Box::new(error)))?
255    } else {
256        Vec::new()
257    };
258
259    Ok(JobDefinitionCatalogSyncReport {
260        disabled_absent_job_types,
261        disabled_catalog_job_types,
262    })
263}
264
265/// Creates or updates a job definition inside an existing transaction.
266///
267/// # Errors
268/// Returns an error if PostgreSQL rejects the upsert, or if disabling the
269/// definition would leave an active schedule referencing this job type.
270pub async fn upsert_job_definition_tx(
271    tx: &mut DbTx<'_>,
272    payload: &JobDefinitionUpsert<'_>,
273) -> Result<()> {
274    if !payload.is_enabled {
275        prepare_definition_disable_update_guard_tx(tx).await?;
276        reject_active_schedule_for_disabled_job_type_update_tx(tx, payload.job_type.as_str())
277            .await?;
278    }
279
280    apply_job_definition_upsert_tx(tx, payload).await
281}
282
283async fn apply_job_definition_upsert_tx(
284    tx: &mut DbTx<'_>,
285    payload: &JobDefinitionUpsert<'_>,
286) -> Result<()> {
287    sqlx::query!(
288        "INSERT INTO job_definitions (
289            job_type,
290            version,
291            max_attempts,
292            default_timeout_seconds,
293            default_priority,
294            is_enabled
295         )
296         VALUES ($1, $2, $3, $4, $5, $6)
297         ON CONFLICT (job_type)
298         DO UPDATE
299            SET version = EXCLUDED.version,
300                max_attempts = EXCLUDED.max_attempts,
301                default_timeout_seconds = EXCLUDED.default_timeout_seconds,
302                default_priority = EXCLUDED.default_priority,
303                is_enabled = EXCLUDED.is_enabled,
304                updated_at = now()
305          WHERE job_definitions.version IS DISTINCT FROM EXCLUDED.version
306             OR job_definitions.max_attempts IS DISTINCT FROM EXCLUDED.max_attempts
307             OR job_definitions.default_timeout_seconds IS DISTINCT FROM EXCLUDED.default_timeout_seconds
308             OR job_definitions.default_priority IS DISTINCT FROM EXCLUDED.default_priority
309             OR job_definitions.is_enabled IS DISTINCT FROM EXCLUDED.is_enabled",
310        payload.job_type as _,
311        payload.version,
312        payload.max_attempts,
313        payload.default_timeout_seconds,
314        payload.default_priority,
315        payload.is_enabled,
316    )
317    .execute(&mut **tx)
318    .await
319    .map_err(|error| Error::from_query_sqlx_with_context("upsert job definition", error))?;
320
321    Ok(())
322}
323
324/// Upserts a job definition while preserving an existing row's `is_enabled`.
325///
326/// Inserts use `payload.is_enabled`; updates keep the stored enabled state and
327/// refresh only the catalog-owned version, retry, timeout, and priority fields.
328async fn upsert_job_definition_preserving_enabled_tx(
329    tx: &mut DbTx<'_>,
330    payload: &JobDefinitionUpsert<'_>,
331) -> Result<()> {
332    sqlx::query!(
333        "INSERT INTO job_definitions (
334            job_type,
335            version,
336            max_attempts,
337            default_timeout_seconds,
338            default_priority,
339            is_enabled
340         )
341         VALUES ($1, $2, $3, $4, $5, $6)
342         ON CONFLICT (job_type)
343         DO UPDATE
344            SET version = EXCLUDED.version,
345                max_attempts = EXCLUDED.max_attempts,
346                default_timeout_seconds = EXCLUDED.default_timeout_seconds,
347                default_priority = EXCLUDED.default_priority,
348                is_enabled = job_definitions.is_enabled,
349                updated_at = now()
350          WHERE job_definitions.version IS DISTINCT FROM EXCLUDED.version
351             OR job_definitions.max_attempts IS DISTINCT FROM EXCLUDED.max_attempts
352             OR job_definitions.default_timeout_seconds IS DISTINCT FROM EXCLUDED.default_timeout_seconds
353             OR job_definitions.default_priority IS DISTINCT FROM EXCLUDED.default_priority",
354        payload.job_type as _,
355        payload.version,
356        payload.max_attempts,
357        payload.default_timeout_seconds,
358        payload.default_priority,
359        // Used only by the INSERT path; conflicts preserve the stored value.
360        payload.is_enabled,
361    )
362    .execute(&mut **tx)
363    .await
364    .map_err(|error| {
365        Error::from_query_sqlx_with_context("upsert job definition preserving enabled", error)
366    })?;
367
368    Ok(())
369}
370
371async fn list_job_types_missing_or_enabled_definitions_tx(
372    tx: &mut DbTx<'_>,
373    job_types: &[JobTypeName],
374) -> Result<Vec<JobTypeName>> {
375    let job_types = job_type_strings(job_types);
376    let rows = sqlx::query_scalar!(
377        "SELECT catalog.job_type as \"job_type!\"
378         FROM unnest($1::text[]) AS catalog(job_type)
379         LEFT JOIN job_definitions
380            ON job_definitions.job_type = catalog.job_type
381         WHERE job_definitions.job_type IS NULL
382            OR job_definitions.is_enabled = true",
383        job_types.as_slice(),
384    )
385    .fetch_all(&mut **tx)
386    .await
387    .map_err(|error| {
388        Error::from_query_sqlx_with_context("list missing or enabled job definitions", error)
389    })?;
390
391    parse_job_type_rows(rows)
392}
393
394async fn disable_enabled_job_definitions_except_tx(
395    tx: &mut DbTx<'_>,
396    keep_job_types: &[JobTypeName],
397    scope_job_types: &[JobTypeName],
398) -> Result<Vec<JobTypeName>> {
399    validate_non_empty_job_types("disable enabled job definitions keep list", keep_job_types)?;
400    validate_non_empty_job_types("disable enabled job definitions scope", scope_job_types)?;
401
402    let keep_job_types = job_type_strings(keep_job_types);
403    let scope_job_types = job_type_strings(scope_job_types);
404    let rows = sqlx::query_scalar!(
405        "UPDATE job_definitions
406         SET is_enabled = false,
407             updated_at = now()
408         WHERE is_enabled = true
409           AND job_type <> ALL($1::text[])
410           AND job_type = ANY($2::text[])
411         RETURNING job_type",
412        keep_job_types.as_slice(),
413        scope_job_types.as_slice(),
414    )
415    .fetch_all(&mut **tx)
416    .await
417    .map_err(|error| {
418        Error::from_query_sqlx_with_context("disable enabled job definitions except list", error)
419    })?;
420
421    parse_job_type_rows(rows)
422}
423
424pub async fn insert_job_definition_if_missing_tx(
425    tx: &mut DbTx<'_>,
426    payload: &JobDefinitionUpsert<'_>,
427) -> Result<()> {
428    sqlx::query!(
429        "INSERT INTO job_definitions (
430            job_type,
431            version,
432            max_attempts,
433            default_timeout_seconds,
434            default_priority,
435            is_enabled
436         )
437         VALUES ($1, $2, $3, $4, $5, $6)
438         ON CONFLICT (job_type)
439         DO NOTHING",
440        payload.job_type as _,
441        payload.version,
442        payload.max_attempts,
443        payload.default_timeout_seconds,
444        payload.default_priority,
445        payload.is_enabled,
446    )
447    .execute(&mut **tx)
448    .await
449    .map_err(|error| {
450        Error::from_query_sqlx_with_context("insert job definition if missing", error)
451    })?;
452
453    Ok(())
454}
455
456pub async fn list_job_definitions(
457    pool: &DbPool,
458    filter: &JobDefinitionListFilter<'_>,
459) -> Result<Vec<JobDefinitionRecord>> {
460    validate_pagination(filter.limit, filter.offset)?;
461
462    let escaped_job_type = filter.job_type.map(escape_ilike_pattern);
463
464    let rows = sqlx::query!(
465        "SELECT
466            job_type,
467            version,
468            max_attempts,
469            default_timeout_seconds,
470            default_priority,
471            is_enabled,
472            created_at,
473            updated_at
474         FROM job_definitions
475         WHERE ($1::text IS NULL OR job_type ILIKE '%' || $1 || '%')
476         ORDER BY job_type ASC
477         LIMIT $2
478         OFFSET $3",
479        escaped_job_type.as_deref(),
480        filter.limit,
481        filter.offset,
482    )
483    .fetch_all(pool)
484    .await
485    .map_err(|error| Error::from_query_sqlx_with_context("list job definitions", error))?;
486
487    rows.into_iter()
488        .map(|row| {
489            Ok(JobDefinitionRecord {
490                job_type: parse_job_type_name(row.job_type)?,
491                version: row.version,
492                max_attempts: row.max_attempts,
493                default_timeout_seconds: row.default_timeout_seconds,
494                default_priority: row.default_priority,
495                is_enabled: row.is_enabled,
496                created_at: row.created_at,
497                updated_at: row.updated_at,
498            })
499        })
500        .collect()
501}
502
503fn escape_ilike_pattern(input: &str) -> String {
504    input
505        .replace('\\', "\\\\")
506        .replace('%', "\\%")
507        .replace('_', "\\_")
508}
509
510fn job_type_strings(job_types: &[JobTypeName]) -> Vec<String> {
511    job_types
512        .iter()
513        .map(|job_type| job_type.as_str().to_owned())
514        .collect()
515}
516
517fn definition_job_type_names<'definition, 'payload, I>(
518    definitions: I,
519) -> std::result::Result<Vec<JobTypeName>, JobDefinitionCatalogSyncError>
520where
521    'payload: 'definition,
522    I: IntoIterator<Item = &'definition JobDefinitionUpsert<'payload>>,
523{
524    // JobType::new is intentionally lightweight, so the catalog sync boundary
525    // revalidates names before using them in scope comparisons or reports.
526    let mut job_types = definitions
527        .into_iter()
528        .map(|definition| parse_job_type_name(definition.job_type.as_str().to_owned()))
529        .collect::<Result<Vec<_>>>()
530        .map_err(|error| {
531            JobDefinitionCatalogSyncError::DefinitionInspectFailure(Box::new(error))
532        })?;
533    job_types.sort();
534    Ok(job_types)
535}
536
537async fn prepare_definition_disable_critical_section_tx(
538    tx: &mut DbTx<'_>,
539) -> std::result::Result<(), JobDefinitionCatalogSyncError> {
540    schedule_definition_guard::cap_definition_disable_statement_timeout_tx(tx)
541        .await
542        .map_err(|error| {
543            JobDefinitionCatalogSyncError::CriticalSectionTimeoutFailure(Box::new(error))
544        })?;
545    schedule_definition_guard::lock_schedules_then_definitions_tx(
546        tx,
547        GuardLockContext::DefinitionDisable,
548    )
549    .await
550    .map_err(|error| match error {
551        ScheduleDefinitionLockError::Schedule(error) => {
552            JobDefinitionCatalogSyncError::ScheduleLockFailure(Box::new(error))
553        }
554        ScheduleDefinitionLockError::Definition(error) => {
555            JobDefinitionCatalogSyncError::DefinitionLockFailure(Box::new(error))
556        }
557    })
558}
559
560async fn reject_active_schedules_for_disabled_job_types_tx(
561    tx: &mut DbTx<'_>,
562    job_types: &[JobTypeName],
563) -> std::result::Result<(), JobDefinitionCatalogSyncError> {
564    if let Some(reference) =
565        schedule_definition_guard::find_active_schedule_for_job_types_tx(tx, job_types)
566            .await
567            .map_err(|error| JobDefinitionCatalogSyncError::ScheduleCheckFailure(Box::new(error)))?
568    {
569        return Err(JobDefinitionCatalogSyncError::ActiveScheduleForDisabledJobType(reference));
570    }
571
572    Ok(())
573}
574
575async fn prepare_definition_disable_update_guard_tx(tx: &mut DbTx<'_>) -> Result<()> {
576    schedule_definition_guard::cap_definition_disable_statement_timeout_tx(tx).await?;
577    schedule_definition_guard::lock_schedules_then_definitions_tx(
578        tx,
579        GuardLockContext::DefinitionDisable,
580    )
581    .await
582    .map_err(ScheduleDefinitionLockError::into_error)
583}
584
585async fn reject_active_schedule_for_disabled_job_type_update_tx(
586    tx: &mut DbTx<'_>,
587    job_type: &str,
588) -> Result<()> {
589    if let Some(reference) =
590        schedule_definition_guard::find_active_schedule_for_job_type_tx(tx, job_type).await?
591    {
592        return Err(
593            schedule_definition_guard::active_schedule_for_disabled_definition_error(&reference),
594        );
595    }
596
597    Ok(())
598}
599
600fn parse_job_type_rows(rows: Vec<String>) -> Result<Vec<JobTypeName>> {
601    let mut job_types = rows
602        .into_iter()
603        .map(parse_job_type_name)
604        .collect::<Result<Vec<_>>>()?;
605    job_types.sort();
606    Ok(job_types)
607}
608
609fn validate_non_empty_job_types(context: &'static str, job_types: &[JobTypeName]) -> Result<()> {
610    if job_types.is_empty() {
611        return Err(Error::QueryError(QueryError::from_classified(
612            QueryErrorCategory::Validation,
613            "job_definition.empty_job_type_list",
614            "Job type list must not be empty.",
615            format!("{context}: job type list must not be empty"),
616        )));
617    }
618    Ok(())
619}
620
621pub async fn get_job_definition_by_type(
622    pool: &DbPool,
623    job_type: JobType<'_>,
624) -> Result<Option<JobDefinitionRecord>> {
625    let row = sqlx::query!(
626        "SELECT
627            job_type,
628            version,
629            max_attempts,
630            default_timeout_seconds,
631            default_priority,
632            is_enabled,
633            created_at,
634            updated_at
635         FROM job_definitions
636         WHERE job_type = $1
637         LIMIT 1",
638        job_type as _,
639    )
640    .fetch_optional(pool)
641    .await
642    .map_err(|error| Error::from_query_sqlx_with_context("get job definition by type", error))?;
643
644    row.map(|row| {
645        Ok(JobDefinitionRecord {
646            job_type: parse_job_type_name(row.job_type)?,
647            version: row.version,
648            max_attempts: row.max_attempts,
649            default_timeout_seconds: row.default_timeout_seconds,
650            default_priority: row.default_priority,
651            is_enabled: row.is_enabled,
652            created_at: row.created_at,
653            updated_at: row.updated_at,
654        })
655    })
656    .transpose()
657}
658
659/// Updates mutable operator-owned fields on a job definition.
660///
661/// Returns `Ok(None)` when no definition exists for `job_type`.
662///
663/// # Errors
664/// Returns an error if a transaction cannot be opened or committed, if
665/// PostgreSQL rejects the update, or if disabling the definition would leave an
666/// active schedule referencing this job type.
667pub async fn update_job_definition(
668    pool: &DbPool,
669    job_type: JobType<'_>,
670    payload: &JobDefinitionUpdate,
671) -> Result<Option<JobDefinitionRecord>> {
672    let mut tx = pool.begin().await.map_err(|error| {
673        Error::from_query_sqlx_with_context("begin job definition update transaction", error)
674    })?;
675
676    if payload.is_enabled == Some(false) {
677        prepare_definition_disable_update_guard_tx(&mut tx).await?;
678        reject_active_schedule_for_disabled_job_type_update_tx(&mut tx, job_type.as_str()).await?;
679    }
680
681    let record = apply_job_definition_update_tx(&mut tx, job_type, payload).await?;
682    tx.commit().await.map_err(|error| {
683        Error::from_query_sqlx_with_context("commit job definition update transaction", error)
684    })?;
685
686    Ok(record)
687}
688
689async fn apply_job_definition_update_tx(
690    tx: &mut DbTx<'_>,
691    job_type: JobType<'_>,
692    payload: &JobDefinitionUpdate,
693) -> Result<Option<JobDefinitionRecord>> {
694    let row = sqlx::query!(
695        "UPDATE job_definitions
696         SET max_attempts = COALESCE($2, max_attempts),
697             default_timeout_seconds = COALESCE($3, default_timeout_seconds),
698             default_priority = COALESCE($4, default_priority),
699             is_enabled = COALESCE($5, is_enabled),
700             updated_at = now()
701         WHERE job_type = $1
702         RETURNING
703            job_type,
704            version,
705            max_attempts,
706            default_timeout_seconds,
707            default_priority,
708            is_enabled,
709            created_at,
710            updated_at",
711        job_type as _,
712        payload.max_attempts,
713        payload.default_timeout_seconds,
714        payload.default_priority,
715        payload.is_enabled,
716    )
717    .fetch_optional(&mut **tx)
718    .await
719    .map_err(|error| Error::from_query_sqlx_with_context("update job definition", error))?;
720
721    row.map(|row| {
722        Ok(JobDefinitionRecord {
723            job_type: parse_job_type_name(row.job_type)?,
724            version: row.version,
725            max_attempts: row.max_attempts,
726            default_timeout_seconds: row.default_timeout_seconds,
727            default_priority: row.default_priority,
728            is_enabled: row.is_enabled,
729            created_at: row.created_at,
730            updated_at: row.updated_at,
731        })
732    })
733    .transpose()
734}