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