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