use std::fmt;
use std::marker::PhantomData;
use bytes::Bytes;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::effect::{EffectClass, EffectIntentSubClass, OnAmbiguous};
use crate::error::DurableError;
use crate::ids::{IdempotencyKey, StepId};
pub(crate) const PAYLOAD_VERSION: u8 = 1;
pub struct StepError(Box<dyn std::error::Error + Send + Sync>);
impl StepError {
#[must_use]
pub fn new(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
Self(source.into())
}
pub(crate) fn into_inner(self) -> Box<dyn std::error::Error + Send + Sync> {
self.0
}
}
impl fmt::Debug for StepError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("StepError").field(&self.0).finish()
}
}
impl fmt::Display for StepError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone)]
pub struct StepDescriptor {
name: &'static str,
effect: EffectClass,
on_ambiguous: Option<OnAmbiguous>,
op_fingerprint: Bytes,
}
impl StepDescriptor {
#[must_use]
pub fn idempotent(name: &'static str, op_fingerprint: impl Into<Bytes>) -> Self {
Self {
name,
effect: EffectClass::Idempotent,
on_ambiguous: None,
op_fingerprint: op_fingerprint.into(),
}
}
#[must_use]
pub fn at_least_once(name: &'static str, op_fingerprint: impl Into<Bytes>) -> Self {
Self {
name,
effect: EffectClass::AtLeastOnce,
on_ambiguous: None,
op_fingerprint: op_fingerprint.into(),
}
}
pub fn exactly_once_guarded(
name: &'static str,
sub_class: EffectIntentSubClass,
on_ambiguous: Option<OnAmbiguous>,
op_fingerprint: impl Into<Bytes>,
) -> Result<Self, DurableError> {
let resolved = match on_ambiguous {
Some(policy) => policy,
None if sub_class.requires_explicit_policy() => {
return Err(DurableError::AmbiguityPolicyRequired { step: name });
}
None => OnAmbiguous::Skip,
};
Ok(Self {
name,
effect: EffectClass::ExactlyOnceGuarded,
on_ambiguous: Some(resolved),
op_fingerprint: op_fingerprint.into(),
})
}
#[must_use]
pub fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub fn effect(&self) -> EffectClass {
self.effect
}
#[must_use]
pub fn on_ambiguous(&self) -> Option<OnAmbiguous> {
self.on_ambiguous
}
#[must_use]
pub fn op_fingerprint(&self) -> &Bytes {
&self.op_fingerprint
}
pub(crate) fn fingerprint_input(&self) -> Vec<u8> {
let effect = self.effect.as_str();
let mut input =
Vec::with_capacity(4 + self.name.len() + 4 + effect.len() + self.op_fingerprint.len());
input.extend_from_slice(&u32_len(self.name.len()).to_le_bytes());
input.extend_from_slice(self.name.as_bytes());
input.extend_from_slice(&u32_len(effect.len()).to_le_bytes());
input.extend_from_slice(effect.as_bytes());
input.extend_from_slice(&self.op_fingerprint);
input
}
}
fn u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
#[derive(Debug, Clone, Copy)]
pub struct StepHandle {
step_id: StepId,
idempotency_key: IdempotencyKey,
}
impl StepHandle {
pub(crate) fn new(step_id: StepId, idempotency_key: IdempotencyKey) -> Self {
Self {
step_id,
idempotency_key,
}
}
#[must_use]
pub fn step_id(&self) -> StepId {
self.step_id
}
#[must_use]
pub fn idempotency_key(&self) -> IdempotencyKey {
self.idempotency_key
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepOutcome<T> {
Live(T),
Replayed(T),
}
impl<T> StepOutcome<T> {
#[must_use]
pub fn was_replayed(&self) -> bool {
matches!(self, Self::Replayed(_))
}
#[must_use]
pub fn get(&self) -> &T {
match self {
Self::Live(value) | Self::Replayed(value) => value,
}
}
#[must_use]
pub fn into_inner(self) -> T {
match self {
Self::Live(value) | Self::Replayed(value) => value,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DurableStep<T> {
step_id: StepId,
idempotency_key: IdempotencyKey,
outcome: StepOutcome<T>,
_marker: PhantomData<fn() -> T>,
}
impl<T> DurableStep<T> {
pub(crate) fn live(step_id: StepId, idempotency_key: IdempotencyKey, value: T) -> Self {
Self {
step_id,
idempotency_key,
outcome: StepOutcome::Live(value),
_marker: PhantomData,
}
}
pub(crate) fn replayed(step_id: StepId, idempotency_key: IdempotencyKey, value: T) -> Self {
Self {
step_id,
idempotency_key,
outcome: StepOutcome::Replayed(value),
_marker: PhantomData,
}
}
#[must_use]
pub fn step_id(&self) -> StepId {
self.step_id
}
#[must_use]
pub fn idempotency_key(&self) -> IdempotencyKey {
self.idempotency_key
}
#[must_use]
pub fn was_replayed(&self) -> bool {
self.outcome.was_replayed()
}
#[must_use]
pub fn value(&self) -> &T {
self.outcome.get()
}
#[must_use]
pub fn outcome(&self) -> &StepOutcome<T> {
&self.outcome
}
#[must_use]
pub fn into_value(self) -> T {
self.outcome.into_inner()
}
#[must_use]
pub fn into_outcome(self) -> StepOutcome<T> {
self.outcome
}
}
pub(crate) fn serialize_result<T: Serialize>(
value: &T,
step: &'static str,
) -> Result<Bytes, DurableError> {
serde_json::to_vec(value)
.map(Bytes::from)
.map_err(|_| DurableError::Serialize { step })
}
pub(crate) fn deserialize_result<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, DurableError> {
serde_json::from_slice(bytes).map_err(|_| DurableError::Decode {
context: "step result payload could not be deserialized into its type",
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::ExecutionId;
use std::assert_matches;
#[test]
fn guarded_destructive_requires_explicit_policy() {
let err = StepDescriptor::exactly_once_guarded(
"delete",
EffectIntentSubClass::Destructive,
None,
b"op".to_vec(),
)
.unwrap_err();
assert_matches!(
err,
DurableError::AmbiguityPolicyRequired { step: "delete" }
);
}
#[test]
fn guarded_cost_bearing_defaults_to_skip() {
let desc = StepDescriptor::exactly_once_guarded(
"llm_call",
EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
None,
b"op".to_vec(),
)
.unwrap();
assert_eq!(desc.on_ambiguous(), Some(OnAmbiguous::Skip));
assert_eq!(desc.effect(), EffectClass::ExactlyOnceGuarded);
}
#[test]
fn guarded_explicit_policy_overrides_default() {
let desc = StepDescriptor::exactly_once_guarded(
"llm_call",
EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
Some(OnAmbiguous::Rerun),
b"op".to_vec(),
)
.unwrap();
assert_eq!(desc.on_ambiguous(), Some(OnAmbiguous::Rerun));
}
#[test]
fn non_guarded_descriptors_have_no_policy() {
assert_eq!(
StepDescriptor::idempotent("read", b"op".to_vec()).on_ambiguous(),
None
);
assert_eq!(
StepDescriptor::at_least_once("enqueue", b"op".to_vec()).on_ambiguous(),
None
);
}
#[test]
fn fingerprint_input_is_injective_across_descriptor_fields() {
let base = StepDescriptor::idempotent("a", b"x".to_vec()).fingerprint_input();
let shifted = StepDescriptor::idempotent("ax", b"".to_vec()).fingerprint_input();
assert_ne!(base, shifted);
let other_effect = StepDescriptor::at_least_once("a", b"x".to_vec()).fingerprint_input();
assert_ne!(base, other_effect);
}
#[test]
fn fingerprint_drives_idempotency_key_divergence() {
let exec = ExecutionId::new();
let step = StepId::new(0);
let a = IdempotencyKey::derive(
exec,
step,
&StepDescriptor::idempotent("a", b"x".to_vec()).fingerprint_input(),
);
let b = IdempotencyKey::derive(
exec,
step,
&StepDescriptor::idempotent("b", b"x".to_vec()).fingerprint_input(),
);
assert_ne!(
a, b,
"a different descriptor derives a different idempotency key"
);
}
#[test]
fn step_outcome_and_durable_step_accessors() {
let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(2), b"op");
let live = DurableStep::live(StepId::new(2), key, 41_u32);
assert_eq!(live.step_id(), StepId::new(2));
assert_eq!(live.idempotency_key(), key);
assert!(!live.was_replayed());
assert_eq!(*live.value(), 41);
assert_matches!(live.outcome(), StepOutcome::Live(41));
assert_eq!(live.into_value(), 41);
let replayed = DurableStep::replayed(StepId::new(3), key, 7_u32);
assert!(replayed.was_replayed());
assert_matches!(replayed.into_outcome(), StepOutcome::Replayed(7));
}
#[test]
fn payload_codec_round_trips() {
let bytes = serialize_result(&vec![1_u32, 2, 3], "step").unwrap();
let back: Vec<u32> = deserialize_result(&bytes).unwrap();
assert_eq!(back, vec![1, 2, 3]);
}
#[test]
fn deserialize_fails_closed_on_garbage() {
let err = deserialize_result::<u32>(b"not json").unwrap_err();
assert_matches!(err, DurableError::Decode { .. });
}
#[test]
fn step_error_wraps_message_and_concrete_error() {
assert_eq!(StepError::new("boom").to_string(), "boom");
let io = std::io::Error::other("disk full");
assert!(StepError::new(io).to_string().contains("disk full"));
}
}