Skip to main content

runledger_postgres/jobs/workflows/
enqueue.rs

1use runledger_core::jobs::{
2    WorkflowDependencyReleaseMode, WorkflowRunEnqueue, validate_workflow_run_enqueue,
3};
4use serde::Serialize;
5use serde_json::Value as JsonValue;
6use sqlx::types::Uuid;
7
8use crate::jobs::transaction_isolation::ensure_read_committed_tx;
9use crate::{DbPool, DbTx, Error, Result};
10
11use super::super::row_decode::{
12    parse_job_stage, parse_job_type_name, parse_workflow_step_execution_kind,
13};
14use super::super::rows::WorkflowRunEnqueueRow;
15use super::super::workflow_types::WorkflowRunDbRecord;
16use super::errors::{
17    workflow_enqueue_conflicting_retry_error, workflow_internal_state_error,
18    workflow_legacy_idempotency_snapshot_missing_error,
19};
20use super::read::load_workflow_run_by_id_tx;
21use super::release::{StepReleaseCandidate, StepReleaseCandidateInit, release_candidate_step_tx};
22use super::runtime::recompute_workflow_run_statuses_tx;
23use super::steps::{
24    fetch_job_definition_defaults_tx, insert_workflow_step_dependencies_tx,
25    insert_workflow_steps_tx, workflow_step_effective_organization_id,
26    workflow_step_effective_stage,
27};
28use super::validation::workflow_dag_validation_error;
29
30struct WorkflowRunInsertOutcome {
31    record: WorkflowRunDbRecord,
32    inserted: bool,
33}
34
35#[derive(Serialize)]
36struct CanonicalWorkflowRunEnqueueRequest<'a> {
37    metadata: &'a JsonValue,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    result_step_key: Option<&'a str>,
40    steps: Vec<CanonicalWorkflowStep<'a>>,
41}
42
43#[derive(Serialize)]
44struct CanonicalWorkflowStep<'a> {
45    step_key: &'a str,
46    execution_kind: &'static str,
47    job_type: Option<&'a str>,
48    organization_id: Option<Uuid>,
49    payload: &'a JsonValue,
50    priority: Option<i32>,
51    max_attempts: Option<i32>,
52    timeout_seconds: Option<i32>,
53    stage: Option<&'static str>,
54    dependencies: Vec<CanonicalWorkflowDependency<'a>>,
55}
56
57#[derive(Serialize)]
58struct CanonicalWorkflowDependency<'a> {
59    prerequisite_step_key: &'a str,
60    release_mode: &'static str,
61}
62
63/// Enqueues a workflow run in its own transaction.
64///
65/// Use this API for multi-step work with dependencies, fan-out/fan-in, external
66/// gates, cancellation as one logical run, or workflow-level idempotency. Build
67/// the payload with `WorkflowRunEnqueueBuilder` and
68/// `WorkflowStepEnqueueBuilder`.
69///
70/// Calls without an idempotency key always create a new workflow run. Calls
71/// with an idempotency key return the existing run only when the canonical
72/// enqueue request snapshot matches. Keyed rows without snapshots are rejected
73/// by the idempotency cutover.
74#[doc(alias = "dag")]
75#[doc(alias = "orchestration")]
76#[doc(alias = "dependencies")]
77pub async fn enqueue_workflow_run(
78    pool: &DbPool,
79    payload: &WorkflowRunEnqueue<'_>,
80) -> Result<WorkflowRunDbRecord> {
81    let mut tx = pool
82        .begin()
83        .await
84        .map_err(|error| Error::ConnectionError(error.to_string()))?;
85    let workflow_run = enqueue_workflow_run_tx(&mut tx, payload).await?;
86    tx.commit()
87        .await
88        .map_err(|error| Error::ConnectionError(error.to_string()))?;
89    Ok(workflow_run)
90}
91
92/// Enqueues a workflow run and returns the existing run for an identical keyed retry.
93///
94/// Use this API when composing a workflow enqueue with other database writes in
95/// one transaction. For ordinary dependent work, prefer this workflow path over
96/// direct-job polling or handler-chained follow-up jobs.
97///
98/// Idempotency is strict for the submitted request snapshot. The snapshot is
99/// compared instead of live workflow step rows because steps and dependencies
100/// can be legitimately appended or mutated after initial enqueue. Strict
101/// workflow idempotency applies to keyed rows with an `enqueue_request`
102/// snapshot; unkeyed rows do not store snapshots, and keyed rows without
103/// snapshots are rejected by the idempotency cutover.
104/// Job-step stage is part of the canonical initial request after normalizing an
105/// omitted stage to `Queued`; changing the requested initial stage is treated as
106/// a different enqueue request.
107#[doc(alias = "dag")]
108#[doc(alias = "orchestration")]
109#[doc(alias = "dependencies")]
110pub async fn enqueue_workflow_run_tx(
111    tx: &mut DbTx<'_>,
112    payload: &WorkflowRunEnqueue<'_>,
113) -> Result<WorkflowRunDbRecord> {
114    validate_workflow_run_enqueue(payload).map_err(workflow_dag_validation_error)?;
115    if payload.idempotency_key().is_some() {
116        ensure_read_committed_tx(
117            tx,
118            "workflow idempotent enqueue",
119            "workflow.enqueue_idempotency_unsupported_isolation",
120            "Workflow idempotent enqueue requires READ COMMITTED transaction isolation.",
121        )
122        .await?;
123    }
124
125    let workflow_run_insert = insert_workflow_run_record_tx(tx, payload).await?;
126    let workflow_run = workflow_run_insert.record;
127    if !workflow_run_insert.inserted {
128        // Existing idempotent runs already have their steps and initial releases
129        // committed; never replay workflow initialization for a retry.
130        return Ok(workflow_run);
131    }
132
133    let defaults_by_job_type = fetch_job_definition_defaults_tx(tx, payload.steps()).await?;
134    let step_id_by_key =
135        insert_workflow_steps_tx(tx, payload, workflow_run.id, &defaults_by_job_type).await?;
136    insert_workflow_step_dependencies_tx(tx, payload, workflow_run.id, &step_id_by_key).await?;
137
138    enqueue_root_steps_tx(tx, workflow_run.id).await?;
139    recompute_workflow_run_statuses_tx(tx, &std::collections::BTreeSet::from([workflow_run.id]))
140        .await?;
141
142    load_workflow_run_by_id_tx(
143        tx,
144        workflow_run.id,
145        "load workflow run after enqueue recompute",
146    )
147    .await
148}
149
150async fn insert_workflow_run_record_tx(
151    tx: &mut DbTx<'_>,
152    payload: &WorkflowRunEnqueue<'_>,
153) -> Result<WorkflowRunInsertOutcome> {
154    let enqueue_request = payload
155        .idempotency_key()
156        .map(|_| canonical_workflow_enqueue_request(payload))
157        .transpose()?;
158    // The conflict clause is selected from static literals only; all request
159    // data remains bound below. This dynamic SQL is not SQLx macro-checked, so
160    // keep the returned columns and bind order aligned with WorkflowRunEnqueueRow
161    // and the workflow_runs insert list.
162    let insert_sql = format!(
163        "INSERT INTO workflow_runs (
164            workflow_type,
165            organization_id,
166            status,
167            idempotency_key,
168            result_step_key,
169            metadata,
170            enqueue_request,
171            started_at
172         )
173         VALUES ($1, $2, 'RUNNING', $3, $4, $5::jsonb, $6::jsonb, now())
174         {}
175         RETURNING
176            id,
177            workflow_type,
178            organization_id,
179            status::text AS status,
180            idempotency_key,
181            result_step_key,
182            metadata,
183            NULL::boolean AS enqueue_request_matches,
184            started_at,
185            finished_at,
186            created_at,
187            updated_at",
188        enqueue_workflow_run_idempotency_conflict_clause(payload),
189    );
190    let run_row = sqlx::query_as::<_, WorkflowRunEnqueueRow>(&insert_sql)
191        .bind(payload.workflow_type())
192        .bind(payload.organization_id())
193        .bind(payload.idempotency_key())
194        .bind(payload.result_step_key().map(|step_key| step_key.as_str()))
195        .bind(payload.metadata())
196        .bind(enqueue_request.as_ref())
197        .fetch_optional(&mut **tx)
198        .await
199        .map_err(|error| Error::from_query_sqlx_with_context("enqueue workflow run", error))?;
200
201    if let Some(run_row) = run_row {
202        return Ok(WorkflowRunInsertOutcome {
203            record: run_row.into_record()?,
204            inserted: true,
205        });
206    }
207
208    let (Some(idempotency_key), Some(enqueue_request)) =
209        (payload.idempotency_key(), enqueue_request.as_ref())
210    else {
211        return Err(workflow_internal_state_error(
212            "workflow run insert returned no row without an idempotency key conflict",
213        ));
214    };
215
216    let existing =
217        load_existing_idempotent_workflow_run_tx(tx, payload, idempotency_key, enqueue_request)
218            .await?;
219    validate_existing_idempotent_workflow_run(&existing)?;
220
221    Ok(WorkflowRunInsertOutcome {
222        record: existing.into_record()?,
223        inserted: false,
224    })
225}
226
227async fn load_existing_idempotent_workflow_run_tx(
228    tx: &mut DbTx<'_>,
229    payload: &WorkflowRunEnqueue<'_>,
230    idempotency_key: &str,
231    enqueue_request: &JsonValue,
232) -> Result<WorkflowRunEnqueueRow> {
233    // After INSERT ... ON CONFLICT reports an existing keyed run, this locks the
234    // matched committed row while the enqueue transaction compares and returns
235    // the idempotent result.
236    let run = if let Some(organization_id) = payload.organization_id() {
237        sqlx::query_as!(
238            WorkflowRunEnqueueRow,
239            r#"SELECT
240                id,
241                workflow_type,
242                organization_id,
243                status::text AS "status!",
244                idempotency_key,
245                result_step_key,
246                metadata,
247                enqueue_request = $4::jsonb AS "enqueue_request_matches?",
248                started_at,
249                finished_at,
250                created_at,
251                updated_at
252             FROM workflow_runs
253             WHERE workflow_type = $1
254               AND organization_id = $2
255               AND idempotency_key = $3
256             LIMIT 1
257             FOR SHARE"#,
258            payload.workflow_type() as _,
259            organization_id,
260            idempotency_key,
261            enqueue_request,
262        )
263        .fetch_optional(&mut **tx)
264        .await
265    } else {
266        sqlx::query_as!(
267            WorkflowRunEnqueueRow,
268            r#"SELECT
269                id,
270                workflow_type,
271                organization_id,
272                status::text AS "status!",
273                idempotency_key,
274                result_step_key,
275                metadata,
276                enqueue_request = $3::jsonb AS "enqueue_request_matches?",
277                started_at,
278                finished_at,
279                created_at,
280                updated_at
281             FROM workflow_runs
282             WHERE workflow_type = $1
283               AND organization_id IS NULL
284               AND idempotency_key = $2
285             LIMIT 1
286             FOR SHARE"#,
287            payload.workflow_type() as _,
288            idempotency_key,
289            enqueue_request,
290        )
291        .fetch_optional(&mut **tx)
292        .await
293    };
294
295    run.map_err(|error| {
296        Error::from_query_sqlx_with_context("load idempotent workflow enqueue", error)
297    })?
298    .ok_or_else(|| {
299        workflow_internal_state_error(
300            "workflow run insert conflicted but matching idempotent workflow run was not found",
301        )
302    })
303}
304
305fn enqueue_workflow_run_idempotency_conflict_clause(
306    payload: &WorkflowRunEnqueue<'_>,
307) -> &'static str {
308    // Keep these predicates aligned with the partial unique indexes
309    // uq_workflow_runs_type_idempotency_org and uq_workflow_runs_type_idempotency_global.
310    match (payload.idempotency_key(), payload.organization_id()) {
311        (Some(_), Some(_)) => {
312            "ON CONFLICT (workflow_type, organization_id, idempotency_key)
313             WHERE idempotency_key IS NOT NULL
314               AND organization_id IS NOT NULL
315             DO NOTHING"
316        }
317        (Some(_), None) => {
318            "ON CONFLICT (workflow_type, idempotency_key)
319             WHERE idempotency_key IS NOT NULL
320               AND organization_id IS NULL
321             DO NOTHING"
322        }
323        (None, _) => "",
324    }
325}
326
327fn validate_existing_idempotent_workflow_run(existing: &WorkflowRunEnqueueRow) -> Result<()> {
328    match existing.enqueue_request_matches() {
329        Some(true) => Ok(()),
330        Some(false) => Err(workflow_enqueue_conflicting_retry_error("request")),
331        None => Err(workflow_legacy_idempotency_snapshot_missing_error(
332            existing.workflow_type.as_str(),
333            existing.id,
334        )),
335    }
336}
337
338fn canonical_workflow_enqueue_request(payload: &WorkflowRunEnqueue<'_>) -> Result<JsonValue> {
339    let mut steps = payload
340        .steps()
341        .iter()
342        .map(|step| {
343            let mut dependencies = step
344                .dependencies()
345                .iter()
346                .map(|dependency| CanonicalWorkflowDependency {
347                    prerequisite_step_key: dependency.prerequisite_step_key.as_str(),
348                    release_mode: dependency
349                        .release_mode
350                        .unwrap_or(WorkflowDependencyReleaseMode::OnTerminal)
351                        .as_db_value(),
352                })
353                .collect::<Vec<_>>();
354            dependencies.sort_by(|left, right| {
355                left.prerequisite_step_key
356                    .cmp(right.prerequisite_step_key)
357                    .then(left.release_mode.cmp(right.release_mode))
358            });
359
360            CanonicalWorkflowStep {
361                step_key: step.step_key().as_str(),
362                execution_kind: step.execution_kind().as_db_value(),
363                job_type: step.job_type().map(|job_type| job_type.as_str()),
364                organization_id: workflow_step_effective_organization_id(
365                    payload.organization_id(),
366                    step,
367                ),
368                payload: step.payload(),
369                priority: step.priority(),
370                max_attempts: step.max_attempts(),
371                timeout_seconds: step.timeout_seconds(),
372                stage: workflow_step_effective_stage(step),
373                dependencies,
374            }
375        })
376        .collect::<Vec<_>>();
377    steps.sort_by(|left, right| left.step_key.cmp(right.step_key));
378
379    serde_json::to_value(CanonicalWorkflowRunEnqueueRequest {
380        metadata: payload.metadata(),
381        result_step_key: payload.result_step_key().map(|step_key| step_key.as_str()),
382        steps,
383    })
384    .map_err(|error| {
385        workflow_internal_state_error(format!(
386            "failed to serialize canonical workflow enqueue request: {error}"
387        ))
388    })
389}
390
391pub(crate) async fn enqueue_root_steps_tx(tx: &mut DbTx<'_>, workflow_run_id: Uuid) -> Result<()> {
392    let rows = sqlx::query!(
393        "SELECT
394            id,
395            workflow_run_id,
396            execution_kind::text AS \"execution_kind!\",
397            job_type,
398            organization_id,
399            payload,
400            priority,
401            max_attempts,
402            timeout_seconds,
403            stage
404         FROM workflow_steps
405         WHERE workflow_run_id = $1
406           AND status = 'BLOCKED'
407           AND dependency_count_pending = 0
408         ORDER BY created_at ASC
409         FOR UPDATE",
410        workflow_run_id,
411    )
412    .fetch_all(&mut **tx)
413    .await
414    .map_err(|error| {
415        Error::from_query_sqlx_with_context("lookup root workflow steps for enqueue", error)
416    })?;
417
418    for row in rows {
419        let candidate = StepReleaseCandidate::from_init(StepReleaseCandidateInit {
420            id: row.id,
421            workflow_run_id: row.workflow_run_id,
422            execution_kind: parse_workflow_step_execution_kind(row.execution_kind)?,
423            job_type: row.job_type.map(parse_job_type_name).transpose()?,
424            organization_id: row.organization_id,
425            payload: row.payload,
426            priority: row.priority,
427            max_attempts: row.max_attempts,
428            timeout_seconds: row.timeout_seconds,
429            stage: row.stage.map(parse_job_stage).transpose()?,
430        });
431        release_candidate_step_tx(tx, &candidate).await?;
432    }
433
434    Ok(())
435}
436
437#[cfg(test)]
438mod tests {
439    use runledger_core::jobs::{
440        JobStage, JobType, StepKey, WorkflowRunEnqueueBuilder, WorkflowStepEnqueueBuilder,
441        WorkflowType,
442    };
443    use serde_json::json;
444    use sqlx::types::Uuid;
445
446    use super::canonical_workflow_enqueue_request;
447
448    #[test]
449    fn canonical_workflow_enqueue_request_matches_golden_snapshot() {
450        let run_org = Uuid::now_v7();
451        let step_org = Uuid::now_v7();
452        let metadata = json!({"kind": "golden"});
453        let root_payload = json!({"step": "root"});
454        let child_payload = json!({"step": "child"});
455        let root = WorkflowStepEnqueueBuilder::new_external(StepKey::new("root"), &root_payload)
456            .try_build()
457            .expect("build root step");
458        let child = WorkflowStepEnqueueBuilder::new(
459            StepKey::new("child"),
460            JobType::new("jobs.test.child"),
461            &child_payload,
462        )
463        .organization_id(step_org)
464        .priority(7)
465        .max_attempts(2)
466        .timeout_seconds(45)
467        .stage(JobStage::Scheduled)
468        .depends_on_success(&[StepKey::new("root")])
469        .try_build()
470        .expect("build child step");
471        let workflow =
472            WorkflowRunEnqueueBuilder::new(WorkflowType::new("workflow.test.golden"), &metadata)
473                .organization_id(run_org)
474                .step(child)
475                .step(root)
476                .try_build()
477                .expect("build workflow");
478
479        let canonical =
480            canonical_workflow_enqueue_request(&workflow).expect("canonicalize workflow enqueue");
481
482        assert_eq!(
483            canonical,
484            json!({
485                "metadata": {"kind": "golden"},
486                "steps": [
487                    {
488                        "step_key": "child",
489                        "execution_kind": "JOB",
490                        "job_type": "jobs.test.child",
491                        "organization_id": step_org,
492                        "payload": {"step": "child"},
493                        "priority": 7,
494                        "max_attempts": 2,
495                        "timeout_seconds": 45,
496                        "stage": "scheduled",
497                        "dependencies": [
498                            {
499                                "prerequisite_step_key": "root",
500                                "release_mode": "ON_SUCCESS"
501                            }
502                        ]
503                    },
504                    {
505                        "step_key": "root",
506                        "execution_kind": "EXTERNAL",
507                        "job_type": null,
508                        "organization_id": run_org,
509                        "payload": {"step": "root"},
510                        "priority": null,
511                        "max_attempts": null,
512                        "timeout_seconds": null,
513                        "stage": null,
514                        "dependencies": []
515                    }
516                ]
517            })
518        );
519    }
520
521    #[test]
522    fn canonical_workflow_enqueue_request_includes_result_step_key_when_present() {
523        let metadata = json!({});
524        let payload = json!({"step": "result"});
525        let result = WorkflowStepEnqueueBuilder::new(
526            StepKey::new("result"),
527            JobType::new("jobs.test.result"),
528            &payload,
529        )
530        .try_build()
531        .expect("build result step");
532        let workflow =
533            WorkflowRunEnqueueBuilder::new(WorkflowType::new("workflow.test.result"), &metadata)
534                .step(result)
535                .try_result_step_key("result")
536                .expect("set result step key")
537                .try_build()
538                .expect("build workflow");
539
540        let canonical =
541            canonical_workflow_enqueue_request(&workflow).expect("canonicalize workflow enqueue");
542
543        assert_eq!(canonical.get("result_step_key"), Some(&json!("result")));
544    }
545}