Skip to main content

runledger_postgres/jobs/workflows/
runtime.rs

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    // A retry keeps started_at as the first time this workflow step began
217    // executing; the next claim should not erase that history. Non-RUNNING
218    // rows are intentionally left alone: non-workflow jobs have no matching
219    // step, and concurrent cancellation or terminal handling must win over
220    // putting a step back into ENQUEUED.
221    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    // Lifecycle callers already hold the job_queue row lock for `job_id`, so
256    // the lookup below is reentrant. Cancellation follows the same job-row-first
257    // ordering before taking the exclusive release advisory lock.
258    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    // Job-backed terminal completion already owns the job row. Real cancellation
356    // locks job rows before taking the exclusive release lock, so cancellation
357    // cannot hold the exclusive lock while also waiting on this job row. If the
358    // two transactions meet in the narrow job-row/advisory-lock cycle, this
359    // bounded wait turns it into workflow.release_conflict instead of an
360    // unbounded deadlock. Waiting here keeps terminal persistence atomic with
361    // dependency release instead of stranding dependents behind a rolled-back
362    // exclusive holder.
363    // Invariant: dependency release runs later in this same transaction, on this
364    // same connection, so its pg_try_advisory_xact_lock_shared call is reentrant
365    // after this blocking shared acquire.
366    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    // External completion already owns the workflow-step row locks. Do not wait
549    // on the shared release advisory lock here: cancellation may own the
550    // exclusive form while waiting on these same rows. Dependent release goes
551    // through try_lock_workflow_run_release_shared_tx, so concurrent
552    // cancellation returns workflow.release_conflict before this transaction can
553    // release new work.
554    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        // Serialize status recomputation per run so concurrent step completions do not
713        // leave the run stuck in RUNNING due to snapshot races.
714        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}