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 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 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 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 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}