Skip to main content

runledger_postgres/jobs/workflows/runtime/
terminal.rs

1use std::collections::VecDeque;
2
3use runledger_core::jobs::{
4    WorkflowDependencyReleaseMode, WorkflowStepExecutionKind, WorkflowStepStatus,
5};
6use serde_json::Value;
7use sqlx::types::Uuid;
8
9use crate::jobs::transaction_isolation::{ReadCommittedTx, ensure_read_committed_tx};
10use crate::{DbTx, Error, Result};
11
12use super::super::super::row_decode::{
13    parse_job_stage, parse_job_type_name, parse_workflow_release_mode,
14    parse_workflow_step_execution_kind, parse_workflow_step_status,
15};
16use super::super::super::rows::WorkflowStepRow;
17use super::super::super::workflow_types::{
18    CompleteExternalWorkflowStepInput, WorkflowStepDbRecord,
19};
20use super::super::errors::{
21    workflow_external_completion_conflict_error,
22    workflow_external_completion_metadata_conflict_error,
23    workflow_external_completion_output_conflict_error, workflow_external_step_not_external_error,
24    workflow_external_step_not_found_error, workflow_external_step_not_waiting_error,
25    workflow_internal_state_error, workflow_release_conflict_error,
26};
27use super::super::locking::{
28    lock_workflow_run_release_shared_tx, lock_workflow_step_rows_for_update_tx,
29    try_lock_workflow_run_release_shared_tx,
30};
31use super::super::release::{
32    StepReleaseCandidate, StepReleaseCandidateInit, release_candidate_step_tx,
33};
34use super::run_status::recompute_workflow_run_status_tx;
35
36fn validate_terminal_transition_status(terminal_status: WorkflowStepStatus) -> Result<()> {
37    if !terminal_status.is_terminal() {
38        return Err(workflow_internal_state_error(
39            "workflow step terminal transition requires terminal status",
40        ));
41    }
42
43    Ok(())
44}
45
46fn external_completion_metadata_matches(
47    step: &WorkflowStepDbRecord,
48    input: &CompleteExternalWorkflowStepInput<'_>,
49) -> bool {
50    step.status_reason.as_deref() == input.status_reason
51        && step.last_error_code.as_deref() == input.last_error_code
52        && step.last_error_message.as_deref() == input.last_error_message
53}
54
55struct WorkflowStepTerminalTransition<'reason, 'code, 'message, 'output> {
56    job_id: Uuid,
57    terminal_status: WorkflowStepStatus,
58    status_reason: Option<&'reason str>,
59    last_error_code: Option<&'code str>,
60    last_error_message: Option<&'message str>,
61    output: Option<&'output Value>,
62}
63
64struct LockedWorkflowStepTerminalState {
65    id: Uuid,
66    workflow_run_id: Uuid,
67    status: WorkflowStepStatus,
68}
69
70async fn jsonb_values_match_tx(
71    tx: &mut DbTx<'_>,
72    left: Option<&Value>,
73    right: Option<&Value>,
74) -> Result<bool> {
75    let matches = sqlx::query_scalar!(
76        "SELECT $1::jsonb IS NOT DISTINCT FROM $2::jsonb AS \"matches!\"",
77        left,
78        right,
79    )
80    .fetch_one(&mut **tx)
81    .await
82    .map_err(|error| {
83        Error::from_query_sqlx_with_context("compare workflow external step output", error)
84    })?;
85
86    Ok(matches)
87}
88
89pub(crate) async fn process_workflow_step_terminal_by_job_id_tx(
90    tx: &mut DbTx<'_>,
91    job_id: Uuid,
92    terminal_status: WorkflowStepStatus,
93    status_reason: Option<&str>,
94    last_error_code: Option<&str>,
95    last_error_message: Option<&str>,
96    output: Option<&Value>,
97) -> Result<()> {
98    // Lifecycle callers already hold the job_queue row lock for `job_id`, so
99    // the lookup below is reentrant. Cancellation follows the same job-row-first
100    // ordering before taking the exclusive release advisory lock.
101    validate_terminal_transition_status(terminal_status)?;
102    let transition = WorkflowStepTerminalTransition {
103        job_id,
104        terminal_status,
105        status_reason,
106        last_error_code,
107        last_error_message,
108        output,
109    };
110
111    let workflow_managed = sqlx::query_scalar!(
112        r#"SELECT EXISTS (
113                SELECT 1
114                FROM workflow_steps ws
115                WHERE ws.job_id = jq.id
116            ) AS "workflow_managed!"
117         FROM job_queue jq
118         WHERE jq.id = $1
119         FOR UPDATE OF jq"#,
120        transition.job_id
121    )
122    .fetch_optional(&mut **tx)
123    .await
124    .map_err(|error| {
125        Error::from_query_sqlx_with_context("lookup workflow step ownership by job id", error)
126    })?
127    .unwrap_or(false);
128
129    if !workflow_managed {
130        return Ok(());
131    }
132
133    let mut read_committed_tx = ensure_read_committed_tx(
134        tx,
135        "workflow job terminal completion",
136        "workflow.terminal_completion_unsupported_isolation",
137        "Workflow job completion requires READ COMMITTED transaction isolation.",
138    )
139    .await?;
140
141    process_linked_workflow_step_terminal_by_job_id_read_committed_tx(
142        &mut read_committed_tx,
143        &transition,
144    )
145    .await
146}
147
148async fn process_linked_workflow_step_terminal_by_job_id_read_committed_tx(
149    tx: &mut ReadCommittedTx<'_, '_>,
150    transition: &WorkflowStepTerminalTransition<'_, '_, '_, '_>,
151) -> Result<()> {
152    let tx = tx.as_tx();
153    let Some(step) = lock_workflow_step_for_terminal_transition_tx(tx, transition.job_id).await?
154    else {
155        return Err(workflow_internal_state_error(format!(
156            "workflow-managed job {} lost its workflow_steps.job_id relationship while locked",
157            transition.job_id,
158        )));
159    };
160
161    if step.status.is_terminal() {
162        return Ok(());
163    }
164
165    sqlx::query!(
166        "UPDATE workflow_steps
167         SET status = $2::text::workflow_step_status,
168             finished_at = COALESCE(finished_at, now()),
169             status_reason = $3,
170             last_error_code = $4,
171             last_error_message = $5,
172             output = CASE
173                WHEN $2::text::workflow_step_status = 'SUCCEEDED' THEN $6::jsonb
174                ELSE NULL
175             END,
176             updated_at = now()
177         WHERE id = $1",
178        step.id,
179        transition.terminal_status.as_db_value(),
180        transition.status_reason,
181        transition.last_error_code,
182        transition.last_error_message,
183        transition.output,
184    )
185    .execute(&mut **tx)
186    .await
187    .map_err(|error| Error::from_query_sqlx_with_context("mark workflow step terminal", error))?;
188
189    // Job-backed terminal completion already owns the job row. Real cancellation
190    // locks job rows before taking the exclusive release lock, so cancellation
191    // cannot hold the exclusive lock while also waiting on this job row. If the
192    // two transactions meet in the narrow job-row/advisory-lock cycle, this
193    // bounded wait turns it into workflow.release_conflict instead of an
194    // unbounded deadlock. Waiting here keeps terminal persistence atomic with
195    // dependency release instead of stranding dependents behind a rolled-back
196    // exclusive holder.
197    // Invariant: dependency release runs later in this same transaction, on this
198    // same connection, so its pg_try_advisory_xact_lock_shared call is reentrant
199    // after this blocking shared acquire.
200    lock_workflow_run_release_shared_tx(tx, step.workflow_run_id).await?;
201    resolve_terminal_step_queue_tx(
202        tx,
203        step.workflow_run_id,
204        step.id,
205        transition.terminal_status,
206    )
207    .await?;
208    recompute_workflow_run_status_tx(tx, step.workflow_run_id).await?;
209
210    Ok(())
211}
212
213async fn lock_workflow_step_for_terminal_transition_tx(
214    tx: &mut DbTx<'_>,
215    job_id: Uuid,
216) -> Result<Option<LockedWorkflowStepTerminalState>> {
217    let row = sqlx::query!(
218        "SELECT id, workflow_run_id, status::text AS \"status!\"
219         FROM workflow_steps
220         WHERE job_id = $1
221         FOR UPDATE",
222        job_id,
223    )
224    .fetch_optional(&mut **tx)
225    .await
226    .map_err(|error| {
227        Error::from_query_sqlx_with_context(
228            "lock workflow step by job id for terminal update",
229            error,
230        )
231    })?;
232
233    row.map(|row| {
234        Ok(LockedWorkflowStepTerminalState {
235            id: row.id,
236            workflow_run_id: row.workflow_run_id,
237            status: parse_workflow_step_status(row.status)?,
238        })
239    })
240    .transpose()
241}
242
243pub async fn complete_external_workflow_step(
244    pool: &crate::DbPool,
245    input: &CompleteExternalWorkflowStepInput<'_>,
246) -> Result<WorkflowStepDbRecord> {
247    let mut tx = pool
248        .begin()
249        .await
250        .map_err(|error| Error::ConnectionError(error.to_string()))?;
251    let step = complete_external_workflow_step_tx(&mut tx, input).await?;
252    tx.commit()
253        .await
254        .map_err(|error| Error::ConnectionError(error.to_string()))?;
255    Ok(step)
256}
257
258pub async fn complete_external_workflow_step_tx(
259    tx: &mut DbTx<'_>,
260    input: &CompleteExternalWorkflowStepInput<'_>,
261) -> Result<WorkflowStepDbRecord> {
262    let mut read_committed_tx = ensure_read_committed_tx(
263        tx,
264        "workflow external step completion",
265        "workflow.external_completion_unsupported_isolation",
266        "External workflow step completion requires READ COMMITTED transaction isolation.",
267    )
268    .await?;
269
270    complete_external_workflow_step_read_committed_tx(&mut read_committed_tx, input).await
271}
272
273async fn complete_external_workflow_step_read_committed_tx(
274    tx: &mut ReadCommittedTx<'_, '_>,
275    input: &CompleteExternalWorkflowStepInput<'_>,
276) -> Result<WorkflowStepDbRecord> {
277    let tx = tx.as_tx();
278    let terminal_status = input.outcome.status();
279    let output = input.outcome.output();
280
281    lock_workflow_step_rows_for_update_tx(tx, input.workflow_run_id, input.organization_id).await?;
282
283    let row = sqlx::query_as!(
284        WorkflowStepRow,
285        "SELECT
286            ws.id,
287            ws.workflow_run_id,
288            ws.step_key,
289            ws.execution_kind::text AS \"execution_kind!\",
290            ws.job_type,
291            ws.organization_id,
292            ws.payload,
293            ws.priority,
294            ws.max_attempts,
295            ws.timeout_seconds,
296            ws.stage,
297            ws.allow_handler_continuation,
298            ws.execution_resource_key,
299            ws.status::text AS \"status!\",
300            ws.job_id,
301            ws.released_at,
302            ws.started_at,
303            ws.finished_at,
304            ws.dependency_count_total,
305            ws.dependency_count_pending,
306            ws.dependency_count_unsatisfied,
307            ws.status_reason,
308            ws.last_error_code,
309            ws.last_error_message,
310            ws.output,
311            ws.created_at,
312            ws.updated_at
313         FROM workflow_steps ws
314         JOIN workflow_runs wr ON wr.id = ws.workflow_run_id
315         WHERE ws.workflow_run_id = $1
316           AND ws.step_key = $2
317           AND ($3::uuid IS NULL OR wr.organization_id = $3)
318         FOR UPDATE",
319        input.workflow_run_id,
320        input.step_key.as_str(),
321        input.organization_id,
322    )
323    .fetch_optional(&mut **tx)
324    .await
325    .map_err(|error| {
326        Error::from_query_sqlx_with_context("lock external workflow step for completion", error)
327    })?
328    .ok_or_else(workflow_external_step_not_found_error)?;
329
330    let stored_output = row.output.clone();
331    let step = row.into_record()?;
332    if step.execution_kind != WorkflowStepExecutionKind::External {
333        return Err(workflow_external_step_not_external_error(
334            step.step_key.as_str(),
335        ));
336    }
337
338    if step.status.is_terminal() {
339        if step.status == terminal_status {
340            if step.status == WorkflowStepStatus::Succeeded
341                && !jsonb_values_match_tx(tx, stored_output.as_ref(), output).await?
342            {
343                return Err(workflow_external_completion_output_conflict_error(
344                    step.step_key.as_str(),
345                ));
346            }
347
348            if !external_completion_metadata_matches(&step, input) {
349                return Err(workflow_external_completion_metadata_conflict_error(
350                    step.step_key.as_str(),
351                ));
352            }
353
354            return Ok(step);
355        }
356
357        return Err(workflow_external_completion_conflict_error(
358            step.step_key.as_str(),
359            step.status,
360            terminal_status,
361        ));
362    }
363
364    if step.status != WorkflowStepStatus::WaitingForExternal {
365        return Err(workflow_external_step_not_waiting_error(
366            step.step_key.as_str(),
367            step.status,
368        ));
369    }
370
371    if !try_lock_workflow_run_release_shared_tx(tx, step.workflow_run_id).await? {
372        return Err(workflow_release_conflict_error(step.workflow_run_id));
373    }
374
375    let updated = sqlx::query_as!(
376        WorkflowStepRow,
377        "UPDATE workflow_steps
378         SET status = $2::text::workflow_step_status,
379             finished_at = COALESCE(finished_at, now()),
380             status_reason = $3,
381             last_error_code = $4,
382             last_error_message = $5,
383             output = CASE
384                WHEN $2::text::workflow_step_status = 'SUCCEEDED' THEN $6::jsonb
385                ELSE NULL
386             END,
387             updated_at = now()
388         WHERE id = $1
389         RETURNING
390            id,
391            workflow_run_id,
392            step_key,
393            execution_kind::text AS \"execution_kind!\",
394            job_type,
395            organization_id,
396            payload,
397            priority,
398            max_attempts,
399            timeout_seconds,
400            stage,
401            allow_handler_continuation,
402            execution_resource_key,
403            status::text AS \"status!\",
404            job_id,
405            released_at,
406            started_at,
407            finished_at,
408            dependency_count_total,
409            dependency_count_pending,
410            dependency_count_unsatisfied,
411            status_reason,
412            last_error_code,
413            last_error_message,
414            output,
415            created_at,
416            updated_at",
417        step.id,
418        terminal_status.as_db_value(),
419        input.status_reason,
420        input.last_error_code,
421        input.last_error_message,
422        output,
423    )
424    .fetch_one(&mut **tx)
425    .await
426    .map_err(|error| {
427        Error::from_query_sqlx_with_context("mark external workflow step terminal", error)
428    })?;
429
430    let updated = updated.into_record()?;
431    // External completion already owns the workflow-step row locks. Do not wait
432    // on the shared release advisory lock here: cancellation may own the
433    // exclusive form while waiting on these same rows. Dependent release goes
434    // through try_lock_workflow_run_release_shared_tx, so concurrent
435    // cancellation returns workflow.release_conflict before this transaction can
436    // release new work.
437    resolve_terminal_step_queue_tx(tx, updated.workflow_run_id, updated.id, updated.status).await?;
438    recompute_workflow_run_status_tx(tx, updated.workflow_run_id).await?;
439
440    Ok(updated)
441}
442
443pub(crate) async fn resolve_terminal_step_queue_tx(
444    tx: &mut DbTx<'_>,
445    workflow_run_id: Uuid,
446    initial_step_id: Uuid,
447    initial_terminal_status: WorkflowStepStatus,
448) -> Result<()> {
449    let mut terminal_queue = VecDeque::from([(initial_step_id, initial_terminal_status)]);
450
451    while let Some((prerequisite_step_id, prerequisite_terminal_status)) =
452        terminal_queue.pop_front()
453    {
454        let edges = sqlx::query!(
455            "SELECT workflow_run_id, dependent_step_id,
456                    release_mode::text AS \"release_mode!\"
457             FROM workflow_step_dependencies
458             WHERE prerequisite_step_id = $1
459             ORDER BY dependent_step_id ASC",
460            prerequisite_step_id,
461        )
462        .fetch_all(&mut **tx)
463        .await
464        .map_err(|error| {
465            Error::from_query_sqlx_with_context("lookup workflow step dependency edges", error)
466        })?;
467
468        let mut dependent_step_ids = Vec::with_capacity(edges.len());
469        let mut dependency_unsatisfied = Vec::with_capacity(edges.len());
470        for edge in edges {
471            if edge.workflow_run_id != workflow_run_id {
472                return Err(workflow_internal_state_error(format!(
473                    "workflow dependency edge from prerequisite step {prerequisite_step_id} belongs to run {}, expected {workflow_run_id}",
474                    edge.workflow_run_id,
475                )));
476            }
477
478            let dependent_step_id: Uuid = edge.dependent_step_id;
479            let release_mode = parse_workflow_release_mode(edge.release_mode)?;
480            let is_unsatisfied = matches!(release_mode, WorkflowDependencyReleaseMode::OnSuccess)
481                && !matches!(prerequisite_terminal_status, WorkflowStepStatus::Succeeded);
482
483            dependent_step_ids.push(dependent_step_id);
484            dependency_unsatisfied.push(is_unsatisfied);
485        }
486
487        if dependent_step_ids.is_empty() {
488            continue;
489        }
490
491        // Intersecting fan-outs can update the same dependent from concurrent
492        // prerequisite completions. Lock the entire direct batch in stable UUID
493        // order before changing any counters so every completion acquires shared
494        // rows in the same order. The following UPDATE runs as a fresh READ
495        // COMMITTED statement and therefore observes counters committed while
496        // this lock acquisition waited.
497        let locked_dependent_step_ids = sqlx::query_scalar!(
498            "SELECT id
499             FROM workflow_steps
500             WHERE id = ANY($1::uuid[])
501               AND workflow_run_id = $2
502             ORDER BY id ASC
503             FOR UPDATE",
504            &dependent_step_ids,
505            workflow_run_id,
506        )
507        .fetch_all(&mut **tx)
508        .await
509        .map_err(|error| {
510            Error::from_query_sqlx_with_context(
511                "lock direct workflow dependents for counter update",
512                error,
513            )
514        })?;
515
516        if let Some(dependent_step_id) = dependent_step_ids.iter().find(|dependent_step_id| {
517            locked_dependent_step_ids
518                .binary_search(dependent_step_id)
519                .is_err()
520        }) {
521            return Err(workflow_internal_state_error(format!(
522                "workflow dependency edge from prerequisite step {prerequisite_step_id} references dependent step {dependent_step_id} outside expected run {workflow_run_id}",
523            )));
524        }
525
526        let mut rows = sqlx::query!(
527            "WITH dependency_updates AS (
528                SELECT dependent_step_id, dependency_unsatisfied
529                FROM unnest($1::uuid[], $2::boolean[])
530                    AS direct_dependents(dependent_step_id, dependency_unsatisfied)
531             )
532             UPDATE workflow_steps AS ws
533             SET dependency_count_pending = ws.dependency_count_pending - 1,
534                 dependency_count_unsatisfied = ws.dependency_count_unsatisfied +
535                    CASE WHEN dependency_updates.dependency_unsatisfied THEN 1 ELSE 0 END,
536                 updated_at = now()
537             FROM dependency_updates
538             WHERE ws.id = dependency_updates.dependent_step_id
539               AND ws.workflow_run_id = $3
540             RETURNING
541                ws.id,
542                ws.workflow_run_id,
543                ws.execution_kind::text AS \"execution_kind!\",
544                ws.job_type,
545                ws.organization_id,
546                ws.payload,
547                ws.priority,
548                ws.max_attempts,
549                ws.timeout_seconds,
550                ws.stage,
551                ws.execution_resource_key,
552                ws.status::text AS \"status!\",
553                ws.dependency_count_pending,
554                ws.dependency_count_unsatisfied",
555            &dependent_step_ids,
556            &dependency_unsatisfied,
557            workflow_run_id,
558        )
559        .fetch_all(&mut **tx)
560        .await
561        .map_err(|error| {
562            Error::from_query_sqlx_with_context(
563                "batch update workflow step dependency counters",
564                error,
565            )
566        })?;
567        rows.sort_by_key(|row| row.id);
568
569        if rows
570            .iter()
571            .map(|row| row.id)
572            .ne(dependent_step_ids.iter().copied())
573        {
574            return Err(workflow_internal_state_error(format!(
575                "workflow dependency batch from prerequisite step {prerequisite_step_id} updated an unexpected dependent set in run {workflow_run_id}",
576            )));
577        }
578
579        for row in rows {
580            let candidate = StepReleaseCandidate::from_decoded_fields(StepReleaseCandidateInit {
581                id: row.id,
582                workflow_run_id: row.workflow_run_id,
583                execution_kind: parse_workflow_step_execution_kind(row.execution_kind)?,
584                job_type: row.job_type.map(parse_job_type_name).transpose()?,
585                organization_id: row.organization_id,
586                payload: row.payload,
587                priority: row.priority,
588                max_attempts: row.max_attempts,
589                timeout_seconds: row.timeout_seconds,
590                stage: row.stage.map(parse_job_stage).transpose()?,
591                execution_resource_key: row.execution_resource_key,
592            });
593            let status = parse_workflow_step_status(row.status)?;
594            let dependency_count_pending: i32 = row.dependency_count_pending;
595            let dependency_count_unsatisfied: i32 = row.dependency_count_unsatisfied;
596            if dependency_count_pending != 0 {
597                continue;
598            }
599
600            if status != WorkflowStepStatus::Blocked {
601                continue;
602            }
603
604            if dependency_count_unsatisfied == 0 {
605                release_candidate_step_tx(tx, &candidate).await?;
606                continue;
607            }
608
609            let canceled_row = sqlx::query_scalar!(
610                "UPDATE workflow_steps
611                 SET status = 'CANCELED',
612                     finished_at = COALESCE(finished_at, now()),
613                     status_reason = 'workflow.dependency_unsatisfied',
614                     last_error_code = 'workflow.dependency_unsatisfied',
615                     last_error_message = 'Step dependency requirements were not satisfied.',
616                     output = NULL,
617                     updated_at = now()
618                 WHERE id = $1
619                   AND workflow_run_id = $2
620                   AND status = 'BLOCKED'
621                 RETURNING id",
622                candidate.id(),
623                workflow_run_id,
624            )
625            .fetch_optional(&mut **tx)
626            .await
627            .map_err(|error| {
628                Error::from_query_sqlx_with_context("cancel blocked workflow step", error)
629            })?;
630
631            if canceled_row.is_some() {
632                terminal_queue.push_back((candidate.id(), WorkflowStepStatus::Canceled));
633            }
634        }
635    }
636
637    Ok(())
638}
639
640#[cfg(test)]
641mod tests {
642    use crate::{Error, QueryErrorCategory};
643
644    #[test]
645    fn workflow_terminal_transition_requires_terminal_status() {
646        let result = super::validate_terminal_transition_status(
647            runledger_core::jobs::WorkflowStepStatus::Running,
648        );
649        match result {
650            Err(Error::QueryError(query_error)) => {
651                assert_eq!(query_error.category(), QueryErrorCategory::Internal);
652                assert_eq!(query_error.code(), "workflow.internal_state");
653                assert!(
654                    query_error
655                        .internal_message()
656                        .contains("workflow step terminal transition requires terminal status"),
657                    "unexpected internal message: {}",
658                    query_error.internal_message()
659                );
660            }
661            other => panic!("expected internal workflow state error, got {other:?}"),
662        }
663
664        assert!(
665            super::validate_terminal_transition_status(
666                runledger_core::jobs::WorkflowStepStatus::Succeeded
667            )
668            .is_ok()
669        );
670        assert!(
671            super::validate_terminal_transition_status(
672                runledger_core::jobs::WorkflowStepStatus::Failed
673            )
674            .is_ok()
675        );
676    }
677}