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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct JobDefinitionCatalogSyncReport {
19 pub disabled_absent_job_types: Vec<JobTypeName>,
22 pub disabled_catalog_job_types: Vec<JobTypeName>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum JobDefinitionCatalogSyncMode {
30 PreserveExistingEnabledForEnabledDefinitions,
36 RestoreCatalogEnabledState,
38}
39
40#[non_exhaustive]
42#[derive(Debug)]
43pub enum JobDefinitionCatalogSyncError {
44 ActiveScheduleForAbsentJobType(JobScheduleJobTypeReference),
47 ActiveScheduleForDisabledJobType(JobScheduleJobTypeReference),
49 CriticalSectionTimeoutFailure(Box<Error>),
51 ScheduleLockFailure(Box<Error>),
53 DefinitionLockFailure(Box<Error>),
55 ScheduleCheckFailure(Box<Error>),
57 ValidationFailure(Box<Error>),
59 DefinitionInspectFailure(Box<Error>),
61 DefinitionSyncFailure {
63 job_type: String,
65 source: Box<Error>,
67 },
68 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 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 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
265pub 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
324async 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 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 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
659pub 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}