graphrun 0.1.0

Durable workflow engine
Documentation
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;

pub const FORWARD_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub const COMPENSATION_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub const RECONCILIATION_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(30);
pub const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
pub const WORKER_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
pub const FORWARD_ATTEMPT_BUDGET: u32 = 5;
pub const COMPENSATION_ATTEMPT_BUDGET: u32 = 10;
pub const MAX_ATTEMPT_BUDGET: u32 = 10;
pub const OPERATOR_EXTENSION: u32 = 10;
pub const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
pub const FORWARD_BACKOFF_CAP: Duration = Duration::from_secs(60);
pub const COMPENSATION_BACKOFF_CAP: Duration = Duration::from_secs(5 * 60);
pub const BACKOFF_MULTIPLIER_MILLIS: u32 = 2_000;
pub const RECONCILIATION_PROBES: u32 = 3;
pub const RECONCILIATION_GAP: Duration = Duration::from_secs(30);
pub const TERMINAL_HISTORY_DAYS: u64 = 30;
pub const TERMINAL_SUMMARY_DAYS: u64 = 90;
pub const COMMAND_RESULT_HOURS: u64 = 24;
pub const UNRESERVED_EVENT_DAYS: u64 = 7;
pub const CHECKPOINT_EVENT_CADENCE: u64 = 256;
pub const SESSION_LEASE: Duration = Duration::from_secs(30);
pub const RENEWAL_INTERVAL: Duration = Duration::from_secs(5);
pub const STOP_DISPATCH_MARGIN: Duration = Duration::from_secs(5);
pub const RENEWAL_RPC_TIMEOUT: Duration = Duration::from_secs(2);
pub const RPC_DEADLINE: Duration = Duration::from_secs(5);
pub const MIN_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
pub const MAX_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
pub const MAX_RUN_TIMEOUT: Duration = Duration::from_secs(365 * 24 * 60 * 60);

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Backoff {
    pub initial_ms: u64,
    pub multiplier_millis: u32,
    pub max_ms: u64,
}

impl Backoff {
    pub fn forward_default() -> Self {
        Self {
            initial_ms: INITIAL_BACKOFF.as_millis() as u64,
            multiplier_millis: BACKOFF_MULTIPLIER_MILLIS,
            max_ms: FORWARD_BACKOFF_CAP.as_millis() as u64,
        }
    }

    pub fn compensation_default() -> Self {
        Self {
            initial_ms: INITIAL_BACKOFF.as_millis() as u64,
            multiplier_millis: BACKOFF_MULTIPLIER_MILLIS,
            max_ms: COMPENSATION_BACKOFF_CAP.as_millis() as u64,
        }
    }

    pub fn validate(&self) -> Result<()> {
        if self.initial_ms == 0 || self.max_ms == 0 {
            return Err(Error::invalid("backoff durations must be positive"));
        }
        if self.initial_ms > self.max_ms {
            return Err(Error::invalid("backoff initial exceeds max"));
        }
        if self.max_ms > Duration::from_secs(60 * 60).as_millis() as u64 {
            return Err(Error::invalid("backoff max exceeds one hour"));
        }
        if !(1_000..=10_000).contains(&self.multiplier_millis) {
            return Err(Error::invalid(
                "backoff multiplier must be between 1 and 10",
            ));
        }
        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryPolicy {
    pub errors: Vec<String>,
    pub max_attempts: u32,
    pub backoff: Backoff,
}

impl RetryPolicy {
    pub fn forward_default() -> Self {
        Self {
            errors: Vec::new(),
            max_attempts: FORWARD_ATTEMPT_BUDGET,
            backoff: Backoff::forward_default(),
        }
    }

    pub fn compensation_default(retryable_codes: Vec<String>) -> Self {
        Self {
            errors: retryable_codes,
            max_attempts: COMPENSATION_ATTEMPT_BUDGET,
            backoff: Backoff::compensation_default(),
        }
    }

    pub fn validate(&self) -> Result<()> {
        if self.max_attempts == 0 || self.max_attempts > MAX_ATTEMPT_BUDGET {
            return Err(Error::invalid("max_attempts is out of range"));
        }
        self.backoff.validate()
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapturedRunPolicy {
    pub forward_attempt_timeout_ms: u64,
    pub compensation_attempt_timeout_ms: u64,
    pub reconciliation_attempt_timeout_ms: u64,
    pub run_timeout_ms: Option<u64>,
    pub terminal_history_days: u64,
    pub terminal_summary_days: u64,
    pub command_result_hours: u64,
    pub unreserved_event_days: u64,
    pub checkpoint_event_cadence: u64,
}

impl CapturedRunPolicy {
    pub fn defaults(run_timeout_ms: Option<u64>) -> Self {
        Self {
            forward_attempt_timeout_ms: FORWARD_ATTEMPT_TIMEOUT.as_millis() as u64,
            compensation_attempt_timeout_ms: COMPENSATION_ATTEMPT_TIMEOUT.as_millis() as u64,
            reconciliation_attempt_timeout_ms: RECONCILIATION_ATTEMPT_TIMEOUT.as_millis() as u64,
            run_timeout_ms,
            terminal_history_days: TERMINAL_HISTORY_DAYS,
            terminal_summary_days: TERMINAL_SUMMARY_DAYS,
            command_result_hours: COMMAND_RESULT_HOURS,
            unreserved_event_days: UNRESERVED_EVENT_DAYS,
            checkpoint_event_cadence: CHECKPOINT_EVENT_CADENCE,
        }
    }
}

pub fn validate_attempt_timeout(timeout: Duration) -> Result<()> {
    if timeout < MIN_ATTEMPT_TIMEOUT || timeout > MAX_ATTEMPT_TIMEOUT {
        return Err(Error::invalid(
            "attempt timeout must be between 1 second and 24 hours",
        ));
    }
    Ok(())
}