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