use std::collections::BTreeSet;
use std::time::Duration;
use sha2::{Digest, Sha256};
use crate::error::{
ContractResult, ContractViolation, CODE_DEFINITION_BUDGET_ZERO, CODE_DEFINITION_DUPLICATE_STEP,
CODE_DEFINITION_EMPTY, CODE_DEFINITION_MODE_CONFLICT, CODE_DEFINITION_PIVOT_NOT_LAST,
CODE_DEFINITION_RESOLUTION_MISSING,
};
use crate::identity::{
canonical_bytes, DefinitionVersion, ServiceIdentity, StepName, WorkflowName,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compensation {
Compensable,
NonCompensable,
}
impl Compensation {
pub fn as_str(self) -> &'static str {
match self {
Self::Compensable => "compensable",
Self::NonCompensable => "non-compensable",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelMode {
LocalFenceable,
ExternallyCancellable,
ResolveOnly,
}
impl CancelMode {
pub fn as_str(self) -> &'static str {
match self {
Self::LocalFenceable => "local-fenceable",
Self::ExternallyCancellable => "externally-cancellable",
Self::ResolveOnly => "resolve-only",
}
}
pub fn is_external(self) -> bool {
!matches!(self, Self::LocalFenceable)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolutionMode {
Callback,
Poll,
Manual,
}
impl ResolutionMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Callback => "callback",
Self::Poll => "poll",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolutionSpec {
allow_unknown: bool,
mode: Option<ResolutionMode>,
budget: Duration,
}
impl ResolutionSpec {
pub fn forbidden() -> Self {
Self {
allow_unknown: false,
mode: None,
budget: Duration::ZERO,
}
}
pub fn allowed(mode: ResolutionMode, budget: Duration) -> Self {
Self {
allow_unknown: true,
mode: Some(mode),
budget,
}
}
pub fn allow_unknown(&self) -> bool {
self.allow_unknown
}
pub fn mode(&self) -> Option<ResolutionMode> {
self.mode
}
pub fn budget(&self) -> Duration {
self.budget
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeoutPolicy {
AcceptLateSuccess,
CompensateLateSuccess,
}
impl TimeoutPolicy {
pub fn as_str(self) -> &'static str {
match self {
Self::AcceptLateSuccess => "accept-late-success",
Self::CompensateLateSuccess => "compensate-late-success",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepDefinition {
name: StepName,
owner: ServiceIdentity,
compensation: Compensation,
cancel_mode: CancelMode,
resolution: ResolutionSpec,
timeout: Duration,
timeout_policy: TimeoutPolicy,
}
impl StepDefinition {
#[allow(clippy::too_many_arguments)]
pub fn new(
name: StepName,
owner: ServiceIdentity,
compensation: Compensation,
cancel_mode: CancelMode,
resolution: ResolutionSpec,
timeout: Duration,
timeout_policy: TimeoutPolicy,
) -> Self {
Self {
name,
owner,
compensation,
cancel_mode,
resolution,
timeout,
timeout_policy,
}
}
pub fn name(&self) -> &StepName {
&self.name
}
pub fn owner(&self) -> &ServiceIdentity {
&self.owner
}
pub fn compensation(&self) -> Compensation {
self.compensation
}
pub fn cancel_mode(&self) -> CancelMode {
self.cancel_mode
}
pub fn resolution(&self) -> &ResolutionSpec {
&self.resolution
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn timeout_policy(&self) -> TimeoutPolicy {
self.timeout_policy
}
pub fn is_compensable(&self) -> bool {
matches!(self.compensation, Compensation::Compensable)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowDefinition {
name: WorkflowName,
version: DefinitionVersion,
steps: Vec<StepDefinition>,
}
impl WorkflowDefinition {
pub fn new(
name: WorkflowName,
version: DefinitionVersion,
steps: Vec<StepDefinition>,
) -> ContractResult<Self> {
let definition = Self {
name,
version,
steps,
};
definition.validate()?;
Ok(definition)
}
fn validate(&self) -> ContractResult<()> {
if self.steps.is_empty() {
return Err(ContractViolation::new(
CODE_DEFINITION_EMPTY,
format!("workflow `{}` declares no step", self.name.as_str()),
));
}
let mut seen = BTreeSet::new();
for step in &self.steps {
if !seen.insert(step.name.as_str()) {
return Err(ContractViolation::new(
CODE_DEFINITION_DUPLICATE_STEP,
format!(
"workflow `{}` declares duplicate step `{}`",
self.name.as_str(),
step.name.as_str()
),
));
}
}
for (index, step) in self.steps.iter().enumerate() {
self.validate_step(index, step)?;
}
Ok(())
}
fn validate_step(&self, index: usize, step: &StepDefinition) -> ContractResult<()> {
if matches!(step.compensation, Compensation::NonCompensable)
&& index + 1 != self.steps.len()
{
return Err(ContractViolation::new(
CODE_DEFINITION_PIVOT_NOT_LAST,
format!(
"workflow `{}` step `{}` is non-compensable but is followed by further steps",
self.name.as_str(),
step.name.as_str()
),
));
}
if matches!(step.compensation, Compensation::NonCompensable)
&& matches!(step.timeout_policy, TimeoutPolicy::CompensateLateSuccess)
{
return Err(ContractViolation::new(
CODE_DEFINITION_MODE_CONFLICT,
format!(
"workflow `{}` step `{}` is non-compensable and must not compensate on late success",
self.name.as_str(),
step.name.as_str()
),
));
}
if step.timeout.is_zero() {
return Err(ContractViolation::new(
CODE_DEFINITION_BUDGET_ZERO,
format!(
"workflow `{}` step `{}` must declare a non-zero timeout",
self.name.as_str(),
step.name.as_str()
),
));
}
if step.cancel_mode.is_external() != step.resolution.allow_unknown() {
return Err(ContractViolation::new(
CODE_DEFINITION_MODE_CONFLICT,
format!(
"workflow `{}` step `{}` cancel mode `{}` conflicts with allow_unknown={}",
self.name.as_str(),
step.name.as_str(),
step.cancel_mode.as_str(),
step.resolution.allow_unknown()
),
));
}
if step.resolution.allow_unknown() {
if step.resolution.mode().is_none() {
return Err(ContractViolation::new(
CODE_DEFINITION_RESOLUTION_MISSING,
format!(
"workflow `{}` step `{}` allows unknown but declares no resolution mode",
self.name.as_str(),
step.name.as_str()
),
));
}
if step.resolution.budget().is_zero() {
return Err(ContractViolation::new(
CODE_DEFINITION_BUDGET_ZERO,
format!(
"workflow `{}` step `{}` allows unknown but declares no resolution budget",
self.name.as_str(),
step.name.as_str()
),
));
}
} else if step.resolution.mode().is_some() {
return Err(ContractViolation::new(
CODE_DEFINITION_MODE_CONFLICT,
format!(
"workflow `{}` step `{}` forbids unknown but declares a resolution mode",
self.name.as_str(),
step.name.as_str()
),
));
}
Ok(())
}
pub fn name(&self) -> &WorkflowName {
&self.name
}
pub fn version(&self) -> DefinitionVersion {
self.version
}
pub fn steps(&self) -> &[StepDefinition] {
&self.steps
}
pub fn step(&self, name: &StepName) -> Option<&StepDefinition> {
self.steps.iter().find(|step| step.name() == name)
}
pub fn pivot(&self) -> Option<&StepDefinition> {
self.steps.last().filter(|step| !step.is_compensable())
}
pub fn digest(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_bytes(&[
self.name.as_str().as_bytes(),
&self.version.get().to_be_bytes(),
&(self.steps.len() as u64).to_be_bytes(),
]));
for step in &self.steps {
hasher.update(canonical_bytes(&[
step.name.as_str().as_bytes(),
step.owner.as_str().as_bytes(),
step.compensation.as_str().as_bytes(),
step.cancel_mode.as_str().as_bytes(),
&[u8::from(step.resolution.allow_unknown())],
step.resolution
.mode()
.map(ResolutionMode::as_str)
.unwrap_or("")
.as_bytes(),
&step.resolution.budget().as_millis().to_be_bytes(),
&step.timeout.as_millis().to_be_bytes(),
step.timeout_policy.as_str().as_bytes(),
]));
}
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
}