1use runledger_core::jobs::WorkflowType;
2use sqlx::types::Uuid;
3
4use crate::{DbPool, DbTx, Result};
5
6use super::super::errors::validate_pagination;
7use super::super::row_decode::parse_workflow_release_mode;
8use super::super::rows::{WorkflowRunRow, WorkflowStepRow};
9use super::super::workflow_types::{
10 WorkflowRunCountFilter, WorkflowRunDbRecord, WorkflowRunListFilter, WorkflowRunReadCountFilter,
11 WorkflowRunReadListFilter, WorkflowRunReadScope, WorkflowStepDbRecord,
12 WorkflowStepDependencyDbRecord,
13};
14
15#[derive(sqlx::FromRow)]
16struct WorkflowStepDependencyLookupRow {
17 workflow_run_id: Uuid,
18 prerequisite_step_id: Uuid,
19 dependent_step_id: Uuid,
20 release_mode: String,
21 created_at: chrono::DateTime<chrono::Utc>,
22}
23
24fn workflow_step_dependency_db_record_from_lookup_row(
25 row: WorkflowStepDependencyLookupRow,
26) -> Result<WorkflowStepDependencyDbRecord> {
27 Ok(WorkflowStepDependencyDbRecord {
28 workflow_run_id: row.workflow_run_id,
29 prerequisite_step_id: row.prerequisite_step_id,
30 dependent_step_id: row.dependent_step_id,
31 release_mode: parse_workflow_release_mode(row.release_mode)?,
32 created_at: row.created_at,
33 })
34}
35
36const fn legacy_workflow_read_scope(organization_id: Option<Uuid>) -> WorkflowRunReadScope {
37 match organization_id {
38 Some(organization_id) => WorkflowRunReadScope::Organization(organization_id),
39 None => WorkflowRunReadScope::Admin,
40 }
41}
42
43pub async fn get_workflow_run_by_id(
49 pool: &DbPool,
50 organization_id: Option<Uuid>,
51 workflow_run_id: Uuid,
52) -> Result<Option<WorkflowRunDbRecord>> {
53 get_workflow_run_by_id_with_scope(
54 pool,
55 legacy_workflow_read_scope(organization_id),
56 workflow_run_id,
57 )
58 .await
59}
60
61pub async fn get_workflow_run_by_id_with_scope(
63 pool: &DbPool,
64 scope: WorkflowRunReadScope,
65 workflow_run_id: Uuid,
66) -> Result<Option<WorkflowRunDbRecord>> {
67 let (is_admin, organization_id) = scope.visibility_predicate();
68 let row = sqlx::query_as!(
69 WorkflowRunRow,
70 "SELECT
71 id,
72 workflow_type,
73 organization_id,
74 status::text AS \"status!\",
75 idempotency_key,
76 result_step_key,
77 metadata,
78 started_at,
79 finished_at,
80 created_at,
81 updated_at
82 FROM workflow_runs
83 WHERE id = $1
84 AND ($2::bool OR organization_id IS NOT DISTINCT FROM $3::uuid)
85 LIMIT 1",
86 workflow_run_id,
87 is_admin,
88 organization_id,
89 )
90 .fetch_optional(pool)
91 .await
92 .map_err(|error| crate::Error::from_query_sqlx_with_context("get workflow run by id", error))?;
93
94 row.map(WorkflowRunRow::into_record).transpose()
95}
96
97pub(in crate::jobs::workflows) async fn load_workflow_run_by_id_tx(
98 tx: &mut DbTx<'_>,
99 workflow_run_id: Uuid,
100 context: &'static str,
101) -> Result<WorkflowRunDbRecord> {
102 let run_row = sqlx::query_as!(
103 WorkflowRunRow,
104 "SELECT
105 id,
106 workflow_type,
107 organization_id,
108 status::text AS \"status!\",
109 idempotency_key,
110 result_step_key,
111 metadata,
112 started_at,
113 finished_at,
114 created_at,
115 updated_at
116 FROM workflow_runs
117 WHERE id = $1",
118 workflow_run_id,
119 )
120 .fetch_one(&mut **tx)
121 .await
122 .map_err(|error| crate::Error::from_query_sqlx_with_context(context, error))?;
123
124 run_row.into_record()
125}
126
127pub async fn list_workflow_steps(
133 pool: &DbPool,
134 organization_id: Option<Uuid>,
135 workflow_run_id: Uuid,
136) -> Result<Vec<WorkflowStepDbRecord>> {
137 list_workflow_steps_with_scope(
138 pool,
139 legacy_workflow_read_scope(organization_id),
140 workflow_run_id,
141 )
142 .await
143}
144
145pub async fn list_workflow_steps_with_scope(
147 pool: &DbPool,
148 scope: WorkflowRunReadScope,
149 workflow_run_id: Uuid,
150) -> Result<Vec<WorkflowStepDbRecord>> {
151 let (is_admin, organization_id) = scope.visibility_predicate();
152 let rows = sqlx::query_as::<_, WorkflowStepRow>(
153 "SELECT
154 ws.id,
155 ws.workflow_run_id,
156 ws.step_key,
157 ws.execution_kind::text AS execution_kind,
158 ws.job_type,
159 ws.organization_id,
160 ws.payload,
161 ws.priority,
162 ws.max_attempts,
163 ws.timeout_seconds,
164 ws.stage,
165 ws.allow_handler_continuation,
166 ws.execution_resource_key,
167 ws.status::text AS status,
168 ws.job_id,
169 ws.released_at,
170 ws.started_at,
171 ws.finished_at,
172 ws.dependency_count_total,
173 ws.dependency_count_pending,
174 ws.dependency_count_unsatisfied,
175 ws.status_reason,
176 ws.last_error_code,
177 ws.last_error_message,
178 ws.output,
179 ws.created_at,
180 ws.updated_at
181 FROM workflow_steps ws
182 JOIN workflow_runs wr ON wr.id = ws.workflow_run_id
183 WHERE ws.workflow_run_id = $1
184 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)
185 ORDER BY ws.created_at ASC, ws.id ASC",
186 )
187 .bind(workflow_run_id)
188 .bind(is_admin)
189 .bind(organization_id)
190 .fetch_all(pool)
191 .await
192 .map_err(|error| crate::Error::from_query_sqlx_with_context("list workflow steps", error))?;
193
194 rows.into_iter().map(WorkflowStepRow::into_record).collect()
195}
196
197pub async fn list_workflow_steps_page(
203 pool: &DbPool,
204 organization_id: Option<Uuid>,
205 workflow_run_id: Uuid,
206 limit: i64,
207 offset: i64,
208) -> Result<Vec<WorkflowStepDbRecord>> {
209 list_workflow_steps_page_with_scope(
210 pool,
211 legacy_workflow_read_scope(organization_id),
212 workflow_run_id,
213 limit,
214 offset,
215 )
216 .await
217}
218
219pub async fn list_workflow_steps_page_with_scope(
221 pool: &DbPool,
222 scope: WorkflowRunReadScope,
223 workflow_run_id: Uuid,
224 limit: i64,
225 offset: i64,
226) -> Result<Vec<WorkflowStepDbRecord>> {
227 validate_pagination(limit, offset)?;
228
229 let (is_admin, organization_id) = scope.visibility_predicate();
230 let rows = sqlx::query_as::<_, WorkflowStepRow>(
231 "SELECT
232 ws.id,
233 ws.workflow_run_id,
234 ws.step_key,
235 ws.execution_kind::text AS execution_kind,
236 ws.job_type,
237 ws.organization_id,
238 ws.payload,
239 ws.priority,
240 ws.max_attempts,
241 ws.timeout_seconds,
242 ws.stage,
243 ws.allow_handler_continuation,
244 ws.execution_resource_key,
245 ws.status::text AS status,
246 ws.job_id,
247 ws.released_at,
248 ws.started_at,
249 ws.finished_at,
250 ws.dependency_count_total,
251 ws.dependency_count_pending,
252 ws.dependency_count_unsatisfied,
253 ws.status_reason,
254 ws.last_error_code,
255 ws.last_error_message,
256 ws.output,
257 ws.created_at,
258 ws.updated_at
259 FROM workflow_steps ws
260 JOIN workflow_runs wr ON wr.id = ws.workflow_run_id
261 WHERE ws.workflow_run_id = $1
262 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)
263 ORDER BY ws.created_at ASC, ws.id ASC
264 LIMIT $4 OFFSET $5",
265 )
266 .bind(workflow_run_id)
267 .bind(is_admin)
268 .bind(organization_id)
269 .bind(limit)
270 .bind(offset)
271 .fetch_all(pool)
272 .await
273 .map_err(|error| {
274 crate::Error::from_query_sqlx_with_context("list workflow steps page", error)
275 })?;
276
277 rows.into_iter().map(WorkflowStepRow::into_record).collect()
278}
279
280pub async fn count_workflow_steps(
286 pool: &DbPool,
287 organization_id: Option<Uuid>,
288 workflow_run_id: Uuid,
289) -> Result<i64> {
290 count_workflow_steps_with_scope(
291 pool,
292 legacy_workflow_read_scope(organization_id),
293 workflow_run_id,
294 )
295 .await
296}
297
298pub async fn count_workflow_steps_with_scope(
300 pool: &DbPool,
301 scope: WorkflowRunReadScope,
302 workflow_run_id: Uuid,
303) -> Result<i64> {
304 let (is_admin, organization_id) = scope.visibility_predicate();
305 sqlx::query_scalar::<_, i64>(
306 "SELECT COUNT(*)::bigint
307 FROM workflow_steps ws
308 JOIN workflow_runs wr ON wr.id = ws.workflow_run_id
309 WHERE ws.workflow_run_id = $1
310 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)",
311 )
312 .bind(workflow_run_id)
313 .bind(is_admin)
314 .bind(organization_id)
315 .fetch_one(pool)
316 .await
317 .map_err(|error| crate::Error::from_query_sqlx_with_context("count workflow steps", error))
318}
319
320pub async fn list_workflow_runs(
327 pool: &DbPool,
328 filter: &WorkflowRunListFilter<'_>,
329) -> Result<Vec<WorkflowRunDbRecord>> {
330 let scoped_filter = WorkflowRunReadListFilter {
331 scope: legacy_workflow_read_scope(filter.organization_id),
332 status: filter.status,
333 workflow_type: filter.workflow_type,
334 limit: filter.limit,
335 offset: filter.offset,
336 };
337 list_workflow_runs_with_scope(pool, &scoped_filter).await
338}
339
340pub async fn list_workflow_runs_with_scope(
342 pool: &DbPool,
343 filter: &WorkflowRunReadListFilter<'_>,
344) -> Result<Vec<WorkflowRunDbRecord>> {
345 validate_pagination(filter.limit, filter.offset)?;
346
347 let (is_admin, organization_id) = filter.scope.visibility_predicate();
348 let status_text = filter.status.map(|status| status.as_db_value());
349
350 let rows = sqlx::query_as::<_, WorkflowRunRow>(
351 "SELECT
352 id,
353 workflow_type,
354 organization_id,
355 status::text AS status,
356 idempotency_key,
357 result_step_key,
358 metadata,
359 started_at,
360 finished_at,
361 created_at,
362 updated_at
363 FROM workflow_runs
364 WHERE ($1::bool OR organization_id IS NOT DISTINCT FROM $2::uuid)
365 AND ($3::text IS NULL OR status = $3::text::workflow_run_status)
366 AND ($4::text IS NULL OR workflow_type ILIKE '%' || $4 || '%')
367 ORDER BY created_at DESC, id DESC
368 LIMIT $5 OFFSET $6",
369 )
370 .bind(is_admin)
371 .bind(organization_id)
372 .bind(status_text)
373 .bind(filter.workflow_type)
374 .bind(filter.limit)
375 .bind(filter.offset)
376 .fetch_all(pool)
377 .await
378 .map_err(|error| crate::Error::from_query_sqlx_with_context("list workflow runs", error))?;
379
380 rows.into_iter().map(WorkflowRunRow::into_record).collect()
381}
382
383pub async fn count_workflow_runs(
390 pool: &DbPool,
391 filter: &WorkflowRunCountFilter<'_>,
392) -> Result<i64> {
393 let scoped_filter = WorkflowRunReadCountFilter {
394 scope: legacy_workflow_read_scope(filter.organization_id),
395 status: filter.status,
396 workflow_type: filter.workflow_type,
397 };
398 count_workflow_runs_with_scope(pool, &scoped_filter).await
399}
400
401pub async fn count_workflow_runs_with_scope(
403 pool: &DbPool,
404 filter: &WorkflowRunReadCountFilter<'_>,
405) -> Result<i64> {
406 let (is_admin, organization_id) = filter.scope.visibility_predicate();
407 let status_text = filter.status.map(|status| status.as_db_value());
408 sqlx::query_scalar::<_, i64>(
409 "SELECT COUNT(*)::bigint
410 FROM workflow_runs
411 WHERE ($1::bool OR organization_id IS NOT DISTINCT FROM $2::uuid)
412 AND ($3::text IS NULL OR status = $3::text::workflow_run_status)
413 AND ($4::text IS NULL OR workflow_type ILIKE '%' || $4 || '%')",
414 )
415 .bind(is_admin)
416 .bind(organization_id)
417 .bind(status_text)
418 .bind(filter.workflow_type)
419 .fetch_one(pool)
420 .await
421 .map_err(|error| crate::Error::from_query_sqlx_with_context("count workflow runs", error))
422}
423
424pub async fn get_latest_workflow_run_by_type(
430 pool: &DbPool,
431 organization_id: Option<Uuid>,
432 workflow_type: WorkflowType<'_>,
433) -> Result<Option<WorkflowRunDbRecord>> {
434 get_latest_workflow_run_by_type_with_scope(
435 pool,
436 legacy_workflow_read_scope(organization_id),
437 workflow_type,
438 )
439 .await
440}
441
442pub async fn get_latest_workflow_run_by_type_with_scope(
444 pool: &DbPool,
445 scope: WorkflowRunReadScope,
446 workflow_type: WorkflowType<'_>,
447) -> Result<Option<WorkflowRunDbRecord>> {
448 let (is_admin, organization_id) = scope.visibility_predicate();
449 let row = sqlx::query_as::<_, WorkflowRunRow>(
450 "SELECT
451 id,
452 workflow_type,
453 organization_id,
454 status::text AS status,
455 idempotency_key,
456 result_step_key,
457 metadata,
458 started_at,
459 finished_at,
460 created_at,
461 updated_at
462 FROM workflow_runs
463 WHERE ($1::bool OR organization_id IS NOT DISTINCT FROM $2::uuid)
464 AND workflow_type = $3
465 ORDER BY created_at DESC, id DESC
466 LIMIT 1",
467 )
468 .bind(is_admin)
469 .bind(organization_id)
470 .bind(workflow_type.as_str())
471 .fetch_optional(pool)
472 .await
473 .map_err(|error| {
474 crate::Error::from_query_sqlx_with_context("get latest workflow run by type", error)
475 })?;
476
477 let Some(row) = row else {
478 return Ok(None);
479 };
480
481 Ok(Some(row.into_record()?))
482}
483
484pub async fn list_workflow_step_dependencies(
490 pool: &DbPool,
491 organization_id: Option<Uuid>,
492 workflow_run_id: Uuid,
493) -> Result<Vec<WorkflowStepDependencyDbRecord>> {
494 list_workflow_step_dependencies_with_scope(
495 pool,
496 legacy_workflow_read_scope(organization_id),
497 workflow_run_id,
498 )
499 .await
500}
501
502pub async fn list_workflow_step_dependencies_with_scope(
504 pool: &DbPool,
505 scope: WorkflowRunReadScope,
506 workflow_run_id: Uuid,
507) -> Result<Vec<WorkflowStepDependencyDbRecord>> {
508 let (is_admin, organization_id) = scope.visibility_predicate();
509 let rows = sqlx::query_as::<_, WorkflowStepDependencyLookupRow>(
510 "SELECT
511 wsd.workflow_run_id,
512 wsd.prerequisite_step_id,
513 wsd.dependent_step_id,
514 wsd.release_mode::text AS release_mode,
515 wsd.created_at
516 FROM workflow_step_dependencies wsd
517 JOIN workflow_runs wr ON wr.id = wsd.workflow_run_id
518 WHERE wsd.workflow_run_id = $1
519 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)
520 ORDER BY
521 wsd.prerequisite_step_id ASC,
522 wsd.dependent_step_id ASC",
523 )
524 .bind(workflow_run_id)
525 .bind(is_admin)
526 .bind(organization_id)
527 .fetch_all(pool)
528 .await
529 .map_err(|error| {
530 crate::Error::from_query_sqlx_with_context("list workflow step dependencies", error)
531 })?;
532
533 rows.into_iter()
534 .map(workflow_step_dependency_db_record_from_lookup_row)
535 .collect()
536}
537
538pub async fn list_workflow_step_dependencies_page(
545 pool: &DbPool,
546 organization_id: Option<Uuid>,
547 workflow_run_id: Uuid,
548 limit: i64,
549 offset: i64,
550) -> Result<Vec<WorkflowStepDependencyDbRecord>> {
551 list_workflow_step_dependencies_page_with_scope(
552 pool,
553 legacy_workflow_read_scope(organization_id),
554 workflow_run_id,
555 limit,
556 offset,
557 )
558 .await
559}
560
561pub async fn list_workflow_step_dependencies_page_with_scope(
564 pool: &DbPool,
565 scope: WorkflowRunReadScope,
566 workflow_run_id: Uuid,
567 limit: i64,
568 offset: i64,
569) -> Result<Vec<WorkflowStepDependencyDbRecord>> {
570 validate_pagination(limit, offset)?;
571
572 let (is_admin, organization_id) = scope.visibility_predicate();
573 let rows = sqlx::query_as::<_, WorkflowStepDependencyLookupRow>(
574 "SELECT
575 wsd.workflow_run_id,
576 wsd.prerequisite_step_id,
577 wsd.dependent_step_id,
578 wsd.release_mode::text AS release_mode,
579 wsd.created_at
580 FROM workflow_step_dependencies wsd
581 JOIN workflow_runs wr ON wr.id = wsd.workflow_run_id
582 WHERE wsd.workflow_run_id = $1
583 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)
584 ORDER BY
585 wsd.prerequisite_step_id ASC,
586 wsd.dependent_step_id ASC
587 LIMIT $4 OFFSET $5",
588 )
589 .bind(workflow_run_id)
590 .bind(is_admin)
591 .bind(organization_id)
592 .bind(limit)
593 .bind(offset)
594 .fetch_all(pool)
595 .await
596 .map_err(|error| {
597 crate::Error::from_query_sqlx_with_context("list workflow step dependencies page", error)
598 })?;
599
600 rows.into_iter()
601 .map(workflow_step_dependency_db_record_from_lookup_row)
602 .collect()
603}
604
605pub async fn count_workflow_step_dependencies(
612 pool: &DbPool,
613 organization_id: Option<Uuid>,
614 workflow_run_id: Uuid,
615) -> Result<i64> {
616 count_workflow_step_dependencies_with_scope(
617 pool,
618 legacy_workflow_read_scope(organization_id),
619 workflow_run_id,
620 )
621 .await
622}
623
624pub async fn count_workflow_step_dependencies_with_scope(
626 pool: &DbPool,
627 scope: WorkflowRunReadScope,
628 workflow_run_id: Uuid,
629) -> Result<i64> {
630 let (is_admin, organization_id) = scope.visibility_predicate();
631 sqlx::query_scalar::<_, i64>(
632 "SELECT COUNT(*)::bigint
633 FROM workflow_step_dependencies wsd
634 JOIN workflow_runs wr ON wr.id = wsd.workflow_run_id
635 WHERE wsd.workflow_run_id = $1
636 AND ($2::bool OR wr.organization_id IS NOT DISTINCT FROM $3::uuid)",
637 )
638 .bind(workflow_run_id)
639 .bind(is_admin)
640 .bind(organization_id)
641 .fetch_one(pool)
642 .await
643 .map_err(|error| {
644 crate::Error::from_query_sqlx_with_context("count workflow step dependencies", error)
645 })
646}
647
648pub async fn get_workflow_run_id_for_job(pool: &DbPool, job_id: Uuid) -> Result<Option<Uuid>> {
649 sqlx::query_scalar!(
650 "SELECT ws.workflow_run_id FROM workflow_steps ws WHERE ws.job_id = $1",
651 job_id,
652 )
653 .fetch_optional(pool)
654 .await
655 .map_err(|error| {
656 crate::Error::from_query_sqlx_with_context("get workflow run id for job", error)
657 })
658}
659
660pub async fn get_workflow_run_by_type_and_idempotency_key(
661 pool: &DbPool,
662 organization_id: Option<Uuid>,
663 workflow_type: WorkflowType<'_>,
664 idempotency_key: &str,
665) -> Result<Option<WorkflowRunDbRecord>> {
666 let mut tx = pool
667 .begin()
668 .await
669 .map_err(|error| crate::Error::ConnectionError(error.to_string()))?;
670 let run = get_workflow_run_by_type_and_idempotency_key_tx(
671 &mut tx,
672 organization_id,
673 workflow_type,
674 idempotency_key,
675 )
676 .await?;
677 tx.commit()
678 .await
679 .map_err(|error| crate::Error::ConnectionError(error.to_string()))?;
680 Ok(run)
681}
682
683pub async fn get_workflow_run_by_type_and_idempotency_key_tx(
684 tx: &mut DbTx<'_>,
685 organization_id: Option<Uuid>,
686 workflow_type: WorkflowType<'_>,
687 idempotency_key: &str,
688) -> Result<Option<WorkflowRunDbRecord>> {
689 let row = if let Some(organization_id) = organization_id {
690 sqlx::query_as!(
691 WorkflowRunRow,
692 "SELECT
693 id,
694 workflow_type,
695 organization_id,
696 status::text AS \"status!\",
697 idempotency_key,
698 result_step_key,
699 metadata,
700 started_at,
701 finished_at,
702 created_at,
703 updated_at
704 FROM workflow_runs
705 WHERE workflow_type = $1
706 AND idempotency_key = $2
707 AND organization_id = $3
708 LIMIT 1",
709 workflow_type as _,
710 idempotency_key,
711 organization_id,
712 )
713 .fetch_optional(&mut **tx)
714 .await
715 } else {
716 sqlx::query_as!(
717 WorkflowRunRow,
718 "SELECT
719 id,
720 workflow_type,
721 organization_id,
722 status::text AS \"status!\",
723 idempotency_key,
724 result_step_key,
725 metadata,
726 started_at,
727 finished_at,
728 created_at,
729 updated_at
730 FROM workflow_runs
731 WHERE workflow_type = $1
732 AND idempotency_key = $2
733 AND organization_id IS NULL
734 LIMIT 1",
735 workflow_type as _,
736 idempotency_key,
737 )
738 .fetch_optional(&mut **tx)
739 .await
740 }
741 .map_err(|error| {
742 crate::Error::from_query_sqlx_with_context(
743 "get workflow run by type and idempotency key",
744 error,
745 )
746 })?;
747
748 row.map(WorkflowRunRow::into_record).transpose()
749}