1use std::collections::{BTreeSet, VecDeque};
2
3use chrono::{DateTime, Utc};
4use runledger_core::jobs::{
5 WorkflowDependencyReleaseMode, WorkflowRunStatus, WorkflowStepExecutionKind, WorkflowStepStatus,
6};
7use serde_json::Value;
8use sqlx::types::Uuid;
9
10use crate::jobs::transaction_isolation::ensure_read_committed_tx;
11use crate::{DbTx, Error, Result};
12
13use super::super::row_decode::{
14 parse_job_stage, parse_job_type_name, parse_step_key_name, parse_workflow_release_mode,
15 parse_workflow_run_status, parse_workflow_step_execution_kind, parse_workflow_step_status,
16};
17use super::super::workflow_types::{CompleteExternalWorkflowStepInput, WorkflowStepDbRecord};
18use super::errors::{
19 workflow_external_completion_conflict_error, workflow_external_completion_invalid_status_error,
20 workflow_external_completion_metadata_conflict_error,
21 workflow_external_completion_output_conflict_error,
22 workflow_external_completion_output_invalid_error, workflow_external_step_not_external_error,
23 workflow_external_step_not_found_error, workflow_external_step_not_waiting_error,
24 workflow_internal_state_error, workflow_release_conflict_error,
25};
26use super::locking::{
27 lock_workflow_run_release_shared_tx, lock_workflow_step_rows_for_update_tx,
28 try_lock_workflow_run_release_shared_tx,
29};
30use super::release::{StepReleaseCandidate, StepReleaseCandidateInit, release_candidate_step_tx};
31
32pub(crate) const WORKFLOW_RUN_TERMINAL_CHANNEL: &str = "runledger_workflow_run_terminal";
33
34#[derive(sqlx::FromRow)]
35struct WorkflowStepRow {
36 id: Uuid,
37 workflow_run_id: Uuid,
38 step_key: String,
39 execution_kind: String,
40 job_type: Option<String>,
41 organization_id: Option<Uuid>,
42 payload: serde_json::Value,
43 priority: Option<i32>,
44 max_attempts: Option<i32>,
45 timeout_seconds: Option<i32>,
46 stage: Option<String>,
47 status: String,
48 job_id: Option<Uuid>,
49 released_at: Option<DateTime<Utc>>,
50 started_at: Option<DateTime<Utc>>,
51 finished_at: Option<DateTime<Utc>>,
52 dependency_count_total: i32,
53 dependency_count_pending: i32,
54 dependency_count_unsatisfied: i32,
55 status_reason: Option<String>,
56 last_error_code: Option<String>,
57 last_error_message: Option<String>,
58 output: Option<Value>,
59 created_at: DateTime<Utc>,
60 updated_at: DateTime<Utc>,
61}
62
63fn workflow_step_db_record_from_row(row: WorkflowStepRow) -> Result<WorkflowStepDbRecord> {
64 Ok(WorkflowStepDbRecord {
65 id: row.id,
66 workflow_run_id: row.workflow_run_id,
67 step_key: parse_step_key_name(row.step_key)?,
68 execution_kind: parse_workflow_step_execution_kind(row.execution_kind)?,
69 job_type: row.job_type.map(parse_job_type_name).transpose()?,
70 organization_id: row.organization_id,
71 payload: row.payload,
72 priority: row.priority,
73 max_attempts: row.max_attempts,
74 timeout_seconds: row.timeout_seconds,
75 stage: row.stage.map(parse_job_stage).transpose()?,
76 status: parse_workflow_step_status(row.status)?,
77 job_id: row.job_id,
78 released_at: row.released_at,
79 started_at: row.started_at,
80 finished_at: row.finished_at,
81 dependency_count_total: row.dependency_count_total,
82 dependency_count_pending: row.dependency_count_pending,
83 dependency_count_unsatisfied: row.dependency_count_unsatisfied,
84 status_reason: row.status_reason,
85 last_error_code: row.last_error_code,
86 last_error_message: row.last_error_message,
87 output: row.output,
88 created_at: row.created_at,
89 updated_at: row.updated_at,
90 })
91}
92
93fn validate_external_completion_status(terminal_status: WorkflowStepStatus) -> Result<()> {
94 if terminal_status.is_terminal() {
95 return Ok(());
96 }
97
98 Err(workflow_external_completion_invalid_status_error(
99 terminal_status,
100 ))
101}
102
103fn validate_external_completion_output(
104 terminal_status: WorkflowStepStatus,
105 output: Option<&Value>,
106) -> Result<()> {
107 if output.is_none() || terminal_status == WorkflowStepStatus::Succeeded {
108 return Ok(());
109 }
110
111 Err(workflow_external_completion_output_invalid_error(
112 terminal_status,
113 ))
114}
115
116fn validate_terminal_transition_status(terminal_status: WorkflowStepStatus) -> Result<()> {
117 if !terminal_status.is_terminal() {
118 return Err(workflow_internal_state_error(
119 "workflow step terminal transition requires terminal status",
120 ));
121 }
122
123 Ok(())
124}
125
126fn external_completion_metadata_matches(
127 step: &WorkflowStepDbRecord,
128 input: &CompleteExternalWorkflowStepInput<'_>,
129) -> bool {
130 step.status_reason.as_deref() == input.status_reason
131 && step.last_error_code.as_deref() == input.last_error_code
132 && step.last_error_message.as_deref() == input.last_error_message
133}
134
135async fn jsonb_values_match_tx(
136 tx: &mut DbTx<'_>,
137 left: Option<&Value>,
138 right: Option<&Value>,
139) -> Result<bool> {
140 let matches = sqlx::query_scalar!(
141 "SELECT $1::jsonb IS NOT DISTINCT FROM $2::jsonb AS \"matches!\"",
142 left,
143 right,
144 )
145 .fetch_one(&mut **tx)
146 .await
147 .map_err(|error| {
148 Error::from_query_sqlx_with_context("compare workflow external step output", error)
149 })?;
150
151 Ok(matches)
152}
153
154pub(crate) async fn mark_workflow_step_running_for_claim_tx(
155 tx: &mut DbTx<'_>,
156 job_id: Uuid,
157) -> Result<()> {
158 sqlx::query!(
159 "UPDATE workflow_steps
160 SET status = 'RUNNING',
161 started_at = COALESCE(started_at, now()),
162 output = NULL,
163 updated_at = now()
164 WHERE job_id = $1
165 AND status IN ('ENQUEUED', 'RUNNING')",
166 job_id,
167 )
168 .execute(&mut **tx)
169 .await
170 .map_err(|error| {
171 Error::from_query_sqlx_with_context("mark workflow step running for claim", error)
172 })?;
173
174 Ok(())
175}
176
177pub(crate) async fn mark_workflow_step_enqueued_for_claim_release_tx(
178 tx: &mut DbTx<'_>,
179 job_id: Uuid,
180 reset_started_at: bool,
181) -> Result<()> {
182 sqlx::query!(
183 "UPDATE workflow_steps
184 SET status = 'ENQUEUED',
185 started_at = CASE
186 WHEN $2 THEN NULL
187 ELSE started_at
188 END,
189 finished_at = NULL,
190 status_reason = NULL,
191 last_error_code = NULL,
192 last_error_message = NULL,
193 output = NULL,
194 updated_at = now()
195 WHERE job_id = $1
196 AND status = 'RUNNING'",
197 job_id,
198 reset_started_at,
199 )
200 .execute(&mut **tx)
201 .await
202 .map_err(|error| {
203 Error::from_query_sqlx_with_context("mark workflow step enqueued for claim release", error)
204 })?;
205
206 Ok(())
207}
208
209pub(crate) async fn mark_workflow_step_enqueued_for_retry_tx(
210 tx: &mut DbTx<'_>,
211 job_id: Uuid,
212 status_reason: Option<&str>,
213 last_error_code: Option<&str>,
214 last_error_message: Option<&str>,
215) -> Result<()> {
216 sqlx::query!(
222 "UPDATE workflow_steps
223 SET status = 'ENQUEUED',
224 finished_at = NULL,
225 status_reason = $2,
226 last_error_code = $3,
227 last_error_message = $4,
228 output = NULL,
229 updated_at = now()
230 WHERE job_id = $1
231 AND status = 'RUNNING'",
232 job_id,
233 status_reason,
234 last_error_code,
235 last_error_message,
236 )
237 .execute(&mut **tx)
238 .await
239 .map_err(|error| {
240 Error::from_query_sqlx_with_context("mark workflow step enqueued for retry", error)
241 })?;
242
243 Ok(())
244}
245
246pub(crate) async fn process_workflow_step_terminal_by_job_id_tx(
247 tx: &mut DbTx<'_>,
248 job_id: Uuid,
249 terminal_status: WorkflowStepStatus,
250 status_reason: Option<&str>,
251 last_error_code: Option<&str>,
252 last_error_message: Option<&str>,
253 output: Option<&Value>,
254) -> Result<()> {
255 validate_terminal_transition_status(terminal_status)?;
259
260 let linked_workflow_step_id: Option<Uuid> = sqlx::query_scalar!(
261 "SELECT workflow_step_id FROM job_queue WHERE id = $1 FOR UPDATE",
262 job_id
263 )
264 .fetch_optional(&mut **tx)
265 .await
266 .map_err(|error| {
267 Error::from_query_sqlx_with_context(
268 "lookup workflow step linkage for job integrity check",
269 error,
270 )
271 })?
272 .flatten();
273
274 if linked_workflow_step_id.is_some() {
275 ensure_read_committed_tx(
276 tx,
277 "workflow job terminal completion",
278 "workflow.terminal_completion_unsupported_isolation",
279 "Workflow job completion requires READ COMMITTED transaction isolation.",
280 )
281 .await?;
282 }
283
284 let row = sqlx::query!(
285 "SELECT id, workflow_run_id, status::text AS \"status!\"
286 FROM workflow_steps
287 WHERE job_id = $1
288 FOR UPDATE",
289 job_id,
290 )
291 .fetch_optional(&mut **tx)
292 .await
293 .map_err(|error| {
294 Error::from_query_sqlx_with_context(
295 "lock workflow step by job id for terminal update",
296 error,
297 )
298 })?;
299
300 let Some(row) = row else {
301 if let Some(workflow_step_id) = linked_workflow_step_id {
302 return Err(workflow_internal_state_error(format!(
303 "workflow-managed job {job_id} links to workflow step {workflow_step_id} but workflow_steps.job_id has no matching row"
304 )));
305 }
306
307 return Ok(());
308 };
309
310 let step_id: Uuid = row.id;
311 let workflow_run_id: Uuid = row.workflow_run_id;
312 let current_status = parse_workflow_step_status(row.status)?;
313
314 if linked_workflow_step_id != Some(step_id) {
315 let message = match linked_workflow_step_id {
316 Some(linked_step_id) => format!(
317 "workflow step linkage mismatch for job {job_id}: job_queue.workflow_step_id={linked_step_id}, workflow_steps.id={step_id}"
318 ),
319 None => format!(
320 "workflow step linkage mismatch for job {job_id}: job_queue.workflow_step_id is NULL, workflow_steps.id={step_id}"
321 ),
322 };
323 return Err(workflow_internal_state_error(message));
324 }
325
326 if current_status.is_terminal() {
327 return Ok(());
328 }
329
330 sqlx::query!(
331 "UPDATE workflow_steps
332 SET status = $2::text::workflow_step_status,
333 finished_at = COALESCE(finished_at, now()),
334 status_reason = $3,
335 last_error_code = $4,
336 last_error_message = $5,
337 output = CASE
338 WHEN $2::text::workflow_step_status = 'SUCCEEDED' THEN $6::jsonb
339 ELSE NULL
340 END,
341 updated_at = now()
342 WHERE id = $1",
343 step_id,
344 terminal_status.as_db_value(),
345 status_reason,
346 last_error_code,
347 last_error_message,
348 output,
349 )
350 .execute(&mut **tx)
351 .await
352 .map_err(|error| Error::from_query_sqlx_with_context("mark workflow step terminal", error))?;
353
354 let mut touched_run_ids = BTreeSet::from([workflow_run_id]);
355 lock_workflow_run_release_shared_tx(tx, workflow_run_id).await?;
367 resolve_terminal_step_queue_tx(tx, step_id, terminal_status, &mut touched_run_ids).await?;
368 recompute_workflow_run_statuses_tx(tx, &touched_run_ids).await?;
369
370 Ok(())
371}
372
373pub async fn complete_external_workflow_step(
374 pool: &crate::DbPool,
375 input: &CompleteExternalWorkflowStepInput<'_>,
376) -> Result<WorkflowStepDbRecord> {
377 let mut tx = pool
378 .begin()
379 .await
380 .map_err(|error| Error::ConnectionError(error.to_string()))?;
381 let step = complete_external_workflow_step_tx(&mut tx, input).await?;
382 tx.commit()
383 .await
384 .map_err(|error| Error::ConnectionError(error.to_string()))?;
385 Ok(step)
386}
387
388pub async fn complete_external_workflow_step_tx(
389 tx: &mut DbTx<'_>,
390 input: &CompleteExternalWorkflowStepInput<'_>,
391) -> Result<WorkflowStepDbRecord> {
392 validate_external_completion_status(input.terminal_status)?;
393 validate_external_completion_output(input.terminal_status, input.output)?;
394 ensure_read_committed_tx(
395 tx,
396 "workflow external step completion",
397 "workflow.external_completion_unsupported_isolation",
398 "External workflow step completion requires READ COMMITTED transaction isolation.",
399 )
400 .await?;
401 lock_workflow_step_rows_for_update_tx(tx, input.workflow_run_id, input.organization_id).await?;
402
403 let row = sqlx::query_as!(
404 WorkflowStepRow,
405 "SELECT
406 ws.id,
407 ws.workflow_run_id,
408 ws.step_key,
409 ws.execution_kind::text AS \"execution_kind!\",
410 ws.job_type,
411 ws.organization_id,
412 ws.payload,
413 ws.priority,
414 ws.max_attempts,
415 ws.timeout_seconds,
416 ws.stage,
417 ws.status::text AS \"status!\",
418 ws.job_id,
419 ws.released_at,
420 ws.started_at,
421 ws.finished_at,
422 ws.dependency_count_total,
423 ws.dependency_count_pending,
424 ws.dependency_count_unsatisfied,
425 ws.status_reason,
426 ws.last_error_code,
427 ws.last_error_message,
428 ws.output,
429 ws.created_at,
430 ws.updated_at
431 FROM workflow_steps ws
432 JOIN workflow_runs wr ON wr.id = ws.workflow_run_id
433 WHERE ws.workflow_run_id = $1
434 AND ws.step_key = $2
435 AND ($3::uuid IS NULL OR wr.organization_id = $3)
436 FOR UPDATE",
437 input.workflow_run_id,
438 input.step_key.as_str(),
439 input.organization_id,
440 )
441 .fetch_optional(&mut **tx)
442 .await
443 .map_err(|error| {
444 Error::from_query_sqlx_with_context("lock external workflow step for completion", error)
445 })?
446 .ok_or_else(workflow_external_step_not_found_error)?;
447
448 let stored_output = row.output.clone();
449 let step = workflow_step_db_record_from_row(row)?;
450 if step.execution_kind != WorkflowStepExecutionKind::External {
451 return Err(workflow_external_step_not_external_error(
452 step.step_key.as_str(),
453 ));
454 }
455
456 if step.status.is_terminal() {
457 if step.status == input.terminal_status {
458 if step.status == WorkflowStepStatus::Succeeded
459 && !jsonb_values_match_tx(tx, stored_output.as_ref(), input.output).await?
460 {
461 return Err(workflow_external_completion_output_conflict_error(
462 step.step_key.as_str(),
463 ));
464 }
465
466 if !external_completion_metadata_matches(&step, input) {
467 return Err(workflow_external_completion_metadata_conflict_error(
468 step.step_key.as_str(),
469 ));
470 }
471
472 return Ok(step);
473 }
474
475 return Err(workflow_external_completion_conflict_error(
476 step.step_key.as_str(),
477 step.status,
478 input.terminal_status,
479 ));
480 }
481
482 if step.status != WorkflowStepStatus::WaitingForExternal {
483 return Err(workflow_external_step_not_waiting_error(
484 step.step_key.as_str(),
485 step.status,
486 ));
487 }
488
489 if !try_lock_workflow_run_release_shared_tx(tx, step.workflow_run_id).await? {
490 return Err(workflow_release_conflict_error(step.workflow_run_id));
491 }
492
493 let updated = sqlx::query_as!(
494 WorkflowStepRow,
495 "UPDATE workflow_steps
496 SET status = $2::text::workflow_step_status,
497 finished_at = COALESCE(finished_at, now()),
498 status_reason = $3,
499 last_error_code = $4,
500 last_error_message = $5,
501 output = CASE
502 WHEN $2::text::workflow_step_status = 'SUCCEEDED' THEN $6::jsonb
503 ELSE NULL
504 END,
505 updated_at = now()
506 WHERE id = $1
507 RETURNING
508 id,
509 workflow_run_id,
510 step_key,
511 execution_kind::text AS \"execution_kind!\",
512 job_type,
513 organization_id,
514 payload,
515 priority,
516 max_attempts,
517 timeout_seconds,
518 stage,
519 status::text AS \"status!\",
520 job_id,
521 released_at,
522 started_at,
523 finished_at,
524 dependency_count_total,
525 dependency_count_pending,
526 dependency_count_unsatisfied,
527 status_reason,
528 last_error_code,
529 last_error_message,
530 output,
531 created_at,
532 updated_at",
533 step.id,
534 input.terminal_status.as_db_value(),
535 input.status_reason,
536 input.last_error_code,
537 input.last_error_message,
538 input.output,
539 )
540 .fetch_one(&mut **tx)
541 .await
542 .map_err(|error| {
543 Error::from_query_sqlx_with_context("mark external workflow step terminal", error)
544 })?;
545
546 let updated = workflow_step_db_record_from_row(updated)?;
547 let mut touched_run_ids = BTreeSet::from([updated.workflow_run_id]);
548 resolve_terminal_step_queue_tx(tx, updated.id, updated.status, &mut touched_run_ids).await?;
555 recompute_workflow_run_statuses_tx(tx, &touched_run_ids).await?;
556
557 Ok(updated)
558}
559
560pub(crate) async fn resolve_terminal_step_queue_tx(
561 tx: &mut DbTx<'_>,
562 initial_step_id: Uuid,
563 initial_terminal_status: WorkflowStepStatus,
564 touched_run_ids: &mut BTreeSet<Uuid>,
565) -> Result<()> {
566 let mut terminal_queue = VecDeque::from([(initial_step_id, initial_terminal_status)]);
567
568 while let Some((prerequisite_step_id, prerequisite_terminal_status)) =
569 terminal_queue.pop_front()
570 {
571 let edges = sqlx::query!(
572 "SELECT dependent_step_id, release_mode::text AS \"release_mode!\"
573 FROM workflow_step_dependencies
574 WHERE prerequisite_step_id = $1",
575 prerequisite_step_id,
576 )
577 .fetch_all(&mut **tx)
578 .await
579 .map_err(|error| {
580 Error::from_query_sqlx_with_context("lookup workflow step dependency edges", error)
581 })?;
582
583 for edge in edges {
584 let dependent_step_id: Uuid = edge.dependent_step_id;
585 let release_mode = parse_workflow_release_mode(edge.release_mode)?;
586 let dependency_unsatisfied =
587 matches!(release_mode, WorkflowDependencyReleaseMode::OnSuccess)
588 && !matches!(prerequisite_terminal_status, WorkflowStepStatus::Succeeded);
589
590 let row = sqlx::query!(
591 "UPDATE workflow_steps
592 SET dependency_count_pending = dependency_count_pending - 1,
593 dependency_count_unsatisfied = dependency_count_unsatisfied +
594 CASE WHEN $2 THEN 1 ELSE 0 END,
595 updated_at = now()
596 WHERE id = $1
597 RETURNING
598 id,
599 workflow_run_id,
600 execution_kind::text AS \"execution_kind!\",
601 job_type,
602 organization_id,
603 payload,
604 priority,
605 max_attempts,
606 timeout_seconds,
607 stage,
608 status::text AS \"status!\",
609 dependency_count_pending,
610 dependency_count_unsatisfied",
611 dependent_step_id,
612 dependency_unsatisfied,
613 )
614 .fetch_one(&mut **tx)
615 .await
616 .map_err(|error| {
617 Error::from_query_sqlx_with_context(
618 "update workflow step dependency counters",
619 error,
620 )
621 })?;
622
623 let candidate = StepReleaseCandidate::from_init(StepReleaseCandidateInit {
624 id: row.id,
625 workflow_run_id: row.workflow_run_id,
626 execution_kind: parse_workflow_step_execution_kind(row.execution_kind)?,
627 job_type: row.job_type.map(parse_job_type_name).transpose()?,
628 organization_id: row.organization_id,
629 payload: row.payload,
630 priority: row.priority,
631 max_attempts: row.max_attempts,
632 timeout_seconds: row.timeout_seconds,
633 stage: row.stage.map(parse_job_stage).transpose()?,
634 });
635 let status = parse_workflow_step_status(row.status)?;
636 let dependency_count_pending: i32 = row.dependency_count_pending;
637 let dependency_count_unsatisfied: i32 = row.dependency_count_unsatisfied;
638 touched_run_ids.insert(candidate.workflow_run_id());
639
640 if dependency_count_pending != 0 {
641 continue;
642 }
643
644 if status != WorkflowStepStatus::Blocked {
645 continue;
646 }
647
648 if dependency_count_unsatisfied == 0 {
649 release_candidate_step_tx(tx, &candidate).await?;
650 continue;
651 }
652
653 let canceled_row = sqlx::query_scalar!(
654 "UPDATE workflow_steps
655 SET status = 'CANCELED',
656 finished_at = COALESCE(finished_at, now()),
657 status_reason = 'workflow.dependency_unsatisfied',
658 last_error_code = 'workflow.dependency_unsatisfied',
659 last_error_message = 'Step dependency requirements were not satisfied.',
660 output = NULL,
661 updated_at = now()
662 WHERE id = $1
663 AND status = 'BLOCKED'
664 RETURNING workflow_run_id",
665 candidate.id(),
666 )
667 .fetch_optional(&mut **tx)
668 .await
669 .map_err(|error| {
670 Error::from_query_sqlx_with_context("cancel blocked workflow step", error)
671 })?;
672
673 if let Some(workflow_run_id) = canceled_row {
674 touched_run_ids.insert(workflow_run_id);
675 terminal_queue.push_back((candidate.id(), WorkflowStepStatus::Canceled));
676 }
677 }
678 }
679
680 Ok(())
681}
682
683pub(crate) async fn notify_workflow_run_terminal_tx(
684 tx: &mut DbTx<'_>,
685 workflow_run_id: Uuid,
686 status: WorkflowRunStatus,
687) -> Result<()> {
688 sqlx::query!(
689 "SELECT pg_notify(
690 $1,
691 json_build_object(
692 'workflow_run_id', $2::uuid::text,
693 'status', $3::text
694 )::text
695 )",
696 WORKFLOW_RUN_TERMINAL_CHANNEL,
697 workflow_run_id,
698 status.as_db_value(),
699 )
700 .execute(&mut **tx)
701 .await
702 .map_err(|error| Error::from_query_sqlx_with_context("notify workflow run terminal", error))?;
703
704 Ok(())
705}
706
707pub(crate) async fn recompute_workflow_run_statuses_tx(
708 tx: &mut DbTx<'_>,
709 touched_run_ids: &BTreeSet<Uuid>,
710) -> Result<()> {
711 for workflow_run_id in touched_run_ids {
712 let run_status = sqlx::query!(
715 "SELECT status::text AS \"status!\"
716 FROM workflow_runs
717 WHERE id = $1
718 FOR UPDATE",
719 *workflow_run_id
720 )
721 .fetch_optional(&mut **tx)
722 .await
723 .map_err(|error| {
724 Error::from_query_sqlx_with_context("lock workflow run for status recompute", error)
725 })?;
726 let Some(run_status) = run_status else {
727 continue;
728 };
729 let previous_status = parse_workflow_run_status(run_status.status)?;
730
731 let row = sqlx::query!(
732 "SELECT
733 COUNT(*) FILTER (
734 WHERE status IN ('BLOCKED', 'ENQUEUED', 'RUNNING')
735 )::bigint AS \"active_steps!\",
736 COUNT(*) FILTER (
737 WHERE status = 'WAITING_FOR_EXTERNAL'
738 )::bigint AS \"waiting_steps!\",
739 COUNT(*) FILTER (
740 WHERE status IN ('FAILED', 'CANCELED')
741 )::bigint AS \"errored_steps!\"
742 FROM workflow_steps
743 WHERE workflow_run_id = $1",
744 *workflow_run_id,
745 )
746 .fetch_one(&mut **tx)
747 .await
748 .map_err(|error| {
749 Error::from_query_sqlx_with_context("recompute workflow run status counters", error)
750 })?;
751
752 let active_steps: i64 = row.active_steps;
753 let waiting_steps: i64 = row.waiting_steps;
754 let errored_steps: i64 = row.errored_steps;
755
756 let next_status = if active_steps > 0 {
757 WorkflowRunStatus::Running
758 } else if waiting_steps > 0 {
759 WorkflowRunStatus::WaitingForExternal
760 } else if errored_steps > 0 {
761 WorkflowRunStatus::CompletedWithErrors
762 } else {
763 WorkflowRunStatus::Succeeded
764 };
765
766 if previous_status == next_status && next_status.is_terminal() {
767 continue;
768 }
769
770 let updated = sqlx::query!(
771 "UPDATE workflow_runs
772 SET status = $2::text::workflow_run_status,
773 finished_at = CASE
774 WHEN $2::text::workflow_run_status IN ('RUNNING', 'WAITING_FOR_EXTERNAL')
775 THEN NULL
776 ELSE COALESCE(finished_at, now())
777 END,
778 result = CASE
779 WHEN $2::text::workflow_run_status = 'SUCCEEDED' THEN (
780 SELECT ws.output
781 FROM workflow_steps ws
782 WHERE ws.workflow_run_id = workflow_runs.id
783 AND ws.step_key = workflow_runs.result_step_key
784 AND ws.status = 'SUCCEEDED'
785 LIMIT 1
786 )
787 WHEN $2::text::workflow_run_status IN ('COMPLETED_WITH_ERRORS', 'CANCELED')
788 THEN NULL
789 ELSE result
790 END,
791 updated_at = now()
792 WHERE id = $1
793 AND status <> 'CANCELED'",
794 *workflow_run_id,
795 next_status.as_db_value(),
796 )
797 .execute(&mut **tx)
798 .await
799 .map_err(|error| {
800 Error::from_query_sqlx_with_context("update workflow run recomputed status", error)
801 })?;
802
803 if updated.rows_affected() > 0
804 && next_status.is_terminal()
805 && !previous_status.is_terminal()
806 {
807 notify_workflow_run_terminal_tx(tx, *workflow_run_id, next_status).await?;
808 }
809 }
810
811 Ok(())
812}
813
814#[cfg(test)]
815mod tests {
816 use crate::{Error, QueryErrorCategory};
817
818 #[test]
819 fn workflow_terminal_transition_requires_terminal_status() {
820 let result = super::validate_terminal_transition_status(
821 runledger_core::jobs::WorkflowStepStatus::Running,
822 );
823 match result {
824 Err(Error::QueryError(query_error)) => {
825 assert_eq!(query_error.category(), QueryErrorCategory::Internal);
826 assert_eq!(query_error.code(), "workflow.internal_state");
827 assert!(
828 query_error
829 .internal_message()
830 .contains("workflow step terminal transition requires terminal status"),
831 "unexpected internal message: {}",
832 query_error.internal_message()
833 );
834 }
835 other => panic!("expected internal workflow state error, got {other:?}"),
836 }
837
838 assert!(
839 super::validate_terminal_transition_status(
840 runledger_core::jobs::WorkflowStepStatus::Succeeded
841 )
842 .is_ok()
843 );
844 assert!(
845 super::validate_terminal_transition_status(
846 runledger_core::jobs::WorkflowStepStatus::Failed
847 )
848 .is_ok()
849 );
850 }
851}