use std::fmt;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use ironflow_store::entities::RunStatus;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubWorkflowOutput {
run_id: Uuid,
workflow_name: String,
status: RunStatus,
cost_usd: Decimal,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl SubWorkflowOutput {
pub fn new(
run_id: Uuid,
workflow_name: &str,
status: RunStatus,
cost_usd: Decimal,
duration_ms: u64,
) -> Self {
Self {
run_id,
workflow_name: workflow_name.to_string(),
status,
cost_usd,
duration_ms,
error: None,
}
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
pub fn run_id(&self) -> Uuid {
self.run_id
}
pub fn workflow_name(&self) -> &str {
&self.workflow_name
}
pub fn status(&self) -> RunStatus {
self.status
}
pub fn cost_usd(&self) -> Decimal {
self.cost_usd
}
pub fn duration_ms(&self) -> u64 {
self.duration_ms
}
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConcurrencyConflict {
key: String,
run_id: Uuid,
}
impl ConcurrencyConflict {
pub fn new(key: impl Into<String>, run_id: Uuid) -> Self {
Self {
key: key.into(),
run_id,
}
}
pub fn key(&self) -> &str {
&self.key
}
pub fn run_id(&self) -> Uuid {
self.run_id
}
}
impl fmt::Display for ConcurrencyConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"conflict on concurrency key {:?} (run {})",
self.key, self.run_id
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SubWorkflowOutcome {
Completed(SubWorkflowOutput),
Conflict(ConcurrencyConflict),
}
impl SubWorkflowOutcome {
pub fn conflict(&self) -> Option<&ConcurrencyConflict> {
match self {
SubWorkflowOutcome::Conflict(conflict) => Some(conflict),
SubWorkflowOutcome::Completed(_) => None,
}
}
pub fn output(&self) -> Option<&SubWorkflowOutput> {
match self {
SubWorkflowOutcome::Completed(output) => Some(output),
SubWorkflowOutcome::Conflict(_) => None,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum RecordedWorkflowStep {
Conflict {
concurrency_conflict: ConcurrencyConflict,
},
Completed(SubWorkflowOutput),
}
impl From<RecordedWorkflowStep> for SubWorkflowOutcome {
fn from(recorded: RecordedWorkflowStep) -> Self {
match recorded {
RecordedWorkflowStep::Conflict {
concurrency_conflict,
} => SubWorkflowOutcome::Conflict(concurrency_conflict),
RecordedWorkflowStep::Completed(output) => SubWorkflowOutcome::Completed(output),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{from_value, json, to_value};
use super::*;
#[test]
fn serializes_to_the_persisted_step_output() {
let run_id = Uuid::now_v7();
let output = SubWorkflowOutput::new(
run_id,
"collect",
RunStatus::Warning,
Decimal::new(25, 2),
1200,
);
assert_eq!(
to_value(&output).expect("serialize"),
json!({
"run_id": run_id,
"workflow_name": "collect",
"status": "warning",
"cost_usd": 0.25,
"duration_ms": 1200,
})
);
}
#[test]
fn a_persisted_step_output_reads_back() {
let run_id = Uuid::now_v7();
let stored = json!({
"run_id": run_id,
"workflow_name": "collect",
"status": "completed",
"cost_usd": 0,
"duration_ms": 7,
});
let output: SubWorkflowOutput = from_value(stored).expect("deserialize");
assert_eq!(output.run_id(), run_id);
assert_eq!(output.status(), RunStatus::Completed);
assert_eq!(output.cost_usd(), Decimal::ZERO);
assert_eq!(output.duration_ms(), 7);
}
#[test]
fn a_recorded_conflict_round_trips() {
let holder = Uuid::now_v7();
let conflict = ConcurrencyConflict::new("issue:12", holder);
let stored = json!({ "concurrency_conflict": conflict });
assert_eq!(
stored,
json!({ "concurrency_conflict": { "key": "issue:12", "run_id": holder } })
);
let recorded: RecordedWorkflowStep = from_value(stored).expect("deserialize");
let outcome = SubWorkflowOutcome::from(recorded);
assert_eq!(outcome, SubWorkflowOutcome::Conflict(conflict));
assert_eq!(
outcome.conflict().map(ConcurrencyConflict::run_id),
Some(holder)
);
assert!(outcome.output().is_none());
}
#[test]
fn a_flat_output_still_reads_back_as_completed() {
let run_id = Uuid::now_v7();
let stored = json!({
"run_id": run_id,
"workflow_name": "collect",
"status": "completed",
"cost_usd": 0,
"duration_ms": 7,
});
let recorded: RecordedWorkflowStep = from_value(stored).expect("deserialize");
let outcome = SubWorkflowOutcome::from(recorded);
let output = outcome.output().expect("completed outcome");
assert_eq!(output.run_id(), run_id);
assert_eq!(output.duration_ms(), 7);
assert!(outcome.conflict().is_none());
}
#[test]
fn conflict_display_names_the_key_and_the_holder() {
let holder = Uuid::now_v7();
let text = ConcurrencyConflict::new("issue:12", holder).to_string();
assert!(text.contains("\"issue:12\""));
assert!(text.contains(&holder.to_string()));
}
#[test]
fn an_error_roundtrips() {
let output = SubWorkflowOutput::new(
Uuid::now_v7(),
"collect",
RunStatus::Failed,
Decimal::ZERO,
3,
)
.with_error("boom");
let back: SubWorkflowOutput =
from_value(to_value(&output).expect("serialize")).expect("deserialize");
assert_eq!(back, output);
assert_eq!(back.error(), Some("boom"));
}
#[test]
fn a_legacy_output_without_error_reads_back_as_none() {
let output: SubWorkflowOutput = from_value(json!({
"run_id": Uuid::now_v7(),
"workflow_name": "collect",
"status": "completed",
"cost_usd": 0,
"duration_ms": 7,
}))
.expect("deserialize");
assert_eq!(output.error(), None);
}
#[test]
fn an_output_without_run_id_is_refused() {
let result = from_value::<SubWorkflowOutput>(json!({
"workflow_name": "collect",
"status": "completed",
"cost_usd": 0,
"duration_ms": 0,
}));
assert!(result.is_err());
}
}