use std::fmt::Write as _;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use rand::Rng as _;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::task::JoinSet;
use tracing::Instrument as _;
use zeroize::Zeroizing;
use crate::backend::local::now_unix_millis;
use crate::backend::{DurableBackendEnum, ExecutionBackend as _};
use crate::config::DurableConfig;
use crate::effect::{EffectClass, OnAmbiguous};
use crate::error::DurableError;
use crate::ids::{ExecutionId, ExecutionKind, IdempotencyKey, PromiseId, StepId, TimerId};
use crate::journal::{EntryKind, ExecutionStatus, Journal as _, JournalEntry};
use crate::promise::{DurablePromise, RESOLVER_TOKEN_LEN, resolver_token_hash};
use crate::replay::{DEFAULT_SEGMENT_STEPS, ReplayCursor, StepReplay};
use crate::retention::step_cap_thresholds;
use crate::step::{
DurableStep, PAYLOAD_VERSION, StepDescriptor, StepError, StepHandle, deserialize_result,
serialize_result,
};
use crate::waiters::wait_on_notify_or_poll;
use crate::writer::JournalWriterHandle;
#[derive(Debug)]
pub struct DurableContext {
execution_id: ExecutionId,
kind: ExecutionKind,
next_step: AtomicU32,
diverged: AtomicBool,
is_resume: bool,
cursor: ReplayCursor,
backend: Arc<DurableBackendEnum>,
writer: JournalWriterHandle,
max_steps_per_execution: u32,
max_payload_bytes: u64,
soft_step_cap: u32,
poll_interval: Duration,
max_parked_promises: u32,
checkpoint_requested: AtomicBool,
fold_tasks: Mutex<JoinSet<()>>,
}
impl DurableContext {
#[must_use]
pub fn new(
execution_id: ExecutionId,
kind: ExecutionKind,
is_resume: bool,
backend: Arc<DurableBackendEnum>,
writer: JournalWriterHandle,
config: &DurableConfig,
) -> Self {
let cursor = ReplayCursor::new(backend.clone(), execution_id, DEFAULT_SEGMENT_STEPS);
let (soft_step_cap, _hard) = step_cap_thresholds(config.max_steps_per_execution);
Self {
execution_id,
kind,
next_step: AtomicU32::new(0),
diverged: AtomicBool::new(false),
is_resume,
cursor,
backend,
writer,
max_steps_per_execution: config.max_steps_per_execution,
max_payload_bytes: config.max_payload_bytes,
soft_step_cap,
poll_interval: Duration::from_secs(config.promise_poll_interval_secs.max(1)),
max_parked_promises: config.max_parked_promises,
checkpoint_requested: AtomicBool::new(false),
fold_tasks: Mutex::new(JoinSet::new()),
}
}
#[must_use]
pub fn execution_id(&self) -> ExecutionId {
self.execution_id
}
#[must_use]
pub fn kind(&self) -> ExecutionKind {
self.kind
}
#[tracing::instrument(
name = "durable.context.step",
skip_all,
fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
)]
pub async fn step<T, F, Fut>(&self, desc: StepDescriptor, op: F) -> Result<T, DurableError>
where
T: Serialize + DeserializeOwned + Send,
F: FnOnce(StepHandle) -> Fut + Send,
Fut: Future<Output = Result<T, StepError>> + Send,
{
let step_id = self.assign_step_id();
self.run_step_at(step_id, desc, op)
.await
.map(DurableStep::into_value)
}
#[tracing::instrument(
name = "durable.context.step_recorded",
skip_all,
fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
)]
pub async fn step_recorded<T, F, Fut>(
&self,
desc: StepDescriptor,
op: F,
) -> Result<DurableStep<T>, DurableError>
where
T: Serialize + DeserializeOwned + Send,
F: FnOnce(StepHandle) -> Fut + Send,
Fut: Future<Output = Result<T, StepError>> + Send,
{
let step_id = self.assign_step_id();
self.run_step_at(step_id, desc, op).await
}
#[must_use]
pub fn parallel(&self) -> ParallelScope<'_> {
ParallelScope { ctx: self }
}
#[tracing::instrument(
name = "durable.context.promise",
skip_all,
fields(execution_id = %self.execution_id.as_uuid())
)]
pub async fn promise<T>(&self) -> Result<DurablePromise<T>, DurableError> {
let step_id = self.checked_step_id().await?;
let promise_id = PromiseId::derive(self.execution_id, step_id);
if self.backend.promise_state(promise_id).await?.is_some() {
return Ok(DurablePromise::resumed(promise_id));
}
let mut token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
rand::rng().fill_bytes(&mut *token);
let hash = resolver_token_hash(promise_id, self.execution_id, &token);
self.backend
.insert_promise(
promise_id,
self.execution_id,
*hash.as_bytes(),
now_unix_millis(),
)
.await?;
Ok(DurablePromise::fresh(promise_id, token))
}
pub async fn claim_promise_notification(&self, id: PromiseId) -> Result<bool, DurableError> {
self.backend
.claim_promise_notification(id, now_unix_millis())
.await
}
pub async fn await_promise<T: DeserializeOwned>(
&self,
promise: DurablePromise<T>,
) -> Result<T, DurableError> {
let id = promise.id();
let key = id.as_uuid();
let cap = usize::try_from(self.max_parked_promises).unwrap_or(usize::MAX);
let span = tracing::info_span!("durable.promise.await", promise_id = %key);
wait_on_notify_or_poll(
self.backend.promise_waiters(),
key,
Some(cap),
|| self.poll_interval,
|| self.take_resolved_promise::<T>(id),
|| self.take_resolved_promise::<T>(id),
)
.instrument(span)
.await
}
#[tracing::instrument(name = "durable.context.take_resolved_promise", skip_all, fields(promise_id = %id.as_uuid()))]
pub async fn take_resolved_promise<T: DeserializeOwned>(
&self,
id: PromiseId,
) -> Result<Option<T>, DurableError> {
let record = self
.backend
.promise_state(id)
.await?
.ok_or(DurableError::UnknownPromise)?;
if !record.resolved {
return Ok(None);
}
let sealed = record.payload.ok_or(DurableError::Decode {
context: "resolved promise is missing its payload",
})?;
let plaintext = self
.backend
.open_promise_payload(id, record.execution_id, &sealed)?;
deserialize_result(&plaintext).map(Some)
}
#[tracing::instrument(
name = "durable.context.sleep_until",
skip_all,
fields(execution_id = %self.execution_id.as_uuid())
)]
pub async fn sleep_until(&self, due: SystemTime) -> Result<(), DurableError> {
let step_id = self.checked_step_id().await?;
let timer_id = TimerId::derive(self.execution_id, step_id);
let due_ms = system_time_to_millis(due);
match self.backend.timer_state(timer_id).await? {
Some((_, true)) => return Ok(()),
Some((_, false)) => {}
None => {
self.backend
.arm_timer(timer_id, self.execution_id, due_ms, now_unix_millis())
.await?;
}
}
let key = timer_id.as_uuid();
wait_on_notify_or_poll(
self.backend.timer_waiters(),
key,
None,
|| {
let remaining =
u64::try_from(due_ms.saturating_sub(now_unix_millis())).unwrap_or(u64::MAX);
self.poll_interval.min(Duration::from_millis(remaining))
},
|| self.check_timer_due(timer_id, due_ms),
|| async move {
if let Some(value) = self.check_timer_due(timer_id, due_ms).await? {
return Ok(Some(value));
}
if matches!(self.backend.timer_state(timer_id).await?, Some((_, true))) {
return Ok(Some(()));
}
Ok(None)
},
)
.await
}
async fn check_timer_due(
&self,
timer_id: TimerId,
due_ms: i64,
) -> Result<Option<()>, DurableError> {
if now_unix_millis() >= due_ms {
self.backend.mark_timer_fired(timer_id).await?;
return Ok(Some(()));
}
Ok(None)
}
#[must_use]
pub fn resolver_handle(&self) -> crate::promise::DurableHandle {
crate::promise::DurableHandle::new(self.backend.clone())
}
#[tracing::instrument(
name = "durable.context.finalize",
skip(self),
fields(execution_id = %self.execution_id.as_uuid(), status = status.as_str())
)]
pub async fn finalize(&self, status: ExecutionStatus) -> Result<(), DurableError> {
self.backend.finalize(self.execution_id, status).await
}
#[tracing::instrument(name = "durable.context.drain_background", skip_all, fields(execution_id = %self.execution_id.as_uuid()))]
pub async fn drain_background(&self) {
let mut set = {
let mut guard = self
.fold_tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *guard)
};
while set.join_next().await.is_some() {}
}
fn assign_step_id(&self) -> StepId {
StepId::new(self.next_step.fetch_add(1, Ordering::Relaxed))
}
async fn checked_step_id(&self) -> Result<StepId, DurableError> {
let step_id = self.assign_step_id();
self.enforce_step_cap(step_id).await?;
Ok(step_id)
}
async fn enforce_step_cap(&self, step_id: StepId) -> Result<(), DurableError> {
if self.max_steps_per_execution != 0 && step_id.value() >= self.max_steps_per_execution {
if let Err(error) = self
.backend
.finalize(self.execution_id, ExecutionStatus::Aborted)
.await
{
tracing::warn!(%error, "failed to mark step-cap-exceeded execution aborted");
}
return Err(DurableError::StepCapExceeded {
cap: self.max_steps_per_execution,
});
}
Ok(())
}
fn maybe_checkpoint(&self, step_id: StepId) {
if step_id.value() < self.soft_step_cap {
return;
}
if self.checkpoint_requested.swap(true, Ordering::AcqRel) {
return;
}
let backend = self.backend.clone();
let execution_id = self.execution_id;
let up_to = self.soft_step_cap;
let mut guard = self
.fold_tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.spawn(async move {
match backend.checkpoint_fold(execution_id, up_to).await {
Ok(folded) => {
tracing::info!(
execution_id = %execution_id.as_uuid(),
folded,
"durable checkpoint fold compacted the idempotent prefix"
);
}
Err(error) => {
tracing::warn!(%error, "durable checkpoint fold failed");
}
}
});
}
fn replay_active(&self) -> bool {
self.is_resume && !self.diverged.load(Ordering::Acquire)
}
async fn run_step_at<T, F, Fut>(
&self,
step_id: StepId,
desc: StepDescriptor,
op: F,
) -> Result<DurableStep<T>, DurableError>
where
T: Serialize + DeserializeOwned + Send,
F: FnOnce(StepHandle) -> Fut + Send,
Fut: Future<Output = Result<T, StepError>> + Send,
{
self.enforce_step_cap(step_id).await?;
self.maybe_checkpoint(step_id);
let effect = desc.effect();
let idem_key =
IdempotencyKey::derive(self.execution_id, step_id, &desc.fingerprint_input());
let span = tracing::info_span!(
"durable.step.run",
step_id = step_id.value(),
effect_class = effect.as_str(),
replayed = tracing::field::Empty,
);
async move {
if self.replay_active() {
match self.cursor.lookup(step_id).await? {
StepReplay::Result(entry) => {
self.check_divergence(step_id, idem_key, &entry).await?;
let value = replay_value::<T>(step_id, effect, &entry)?;
tracing::Span::current().record("replayed", true);
return Ok(DurableStep::replayed(step_id, idem_key, value));
}
StepReplay::IntentOnly(entry) => {
self.check_divergence(step_id, idem_key, &entry).await?;
return self.resolve_ambiguous(step_id, idem_key, &desc, op).await;
}
StepReplay::Fresh => {}
}
}
if effect == EffectClass::ExactlyOnceGuarded
&& let Some(entry) = self
.backend
.lookup_committed_result(self.execution_id, idem_key)
.await?
{
let value = replay_value::<T>(step_id, effect, &entry)?;
tracing::Span::current().record("replayed", true);
return Ok(DurableStep::replayed(step_id, idem_key, value));
}
tracing::Span::current().record("replayed", false);
if effect == EffectClass::ExactlyOnceGuarded {
let intent = self.intent_entry(step_id, idem_key, effect);
self.append_acked_degrading(intent, desc.name()).await?;
}
let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
let payload = serialize_result(&value, desc.name())?;
self.journal_result(payload, step_id, idem_key, effect, desc.name())
.await?;
Ok(DurableStep::live(step_id, idem_key, value))
}
.instrument(span)
.await
}
#[tracing::instrument(name = "durable.context.check_divergence", skip_all, fields(step_id = step_id.value()))]
async fn check_divergence(
&self,
step_id: StepId,
expected: IdempotencyKey,
entry: &JournalEntry,
) -> Result<(), DurableError> {
if entry.entry.idempotency_key() == Some(expected) {
return Ok(());
}
self.on_divergence(step_id).await;
Err(DurableError::ReplayDivergence { step_id })
}
#[tracing::instrument(name = "durable.context.on_divergence", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
async fn on_divergence(&self, step_id: StepId) {
self.diverged.store(true, Ordering::Release);
tracing::warn!(
execution_id = %self.execution_id.as_uuid(),
step_id = step_id.value(),
"replay divergence detected; marking journal aborted and restarting fresh"
);
if let Err(error) = self
.backend
.finalize(self.execution_id, ExecutionStatus::Aborted)
.await
{
tracing::warn!(%error, "failed to mark diverged execution aborted");
}
}
#[tracing::instrument(name = "durable.context.resolve_ambiguous", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
async fn resolve_ambiguous<T, F, Fut>(
&self,
step_id: StepId,
idem_key: IdempotencyKey,
desc: &StepDescriptor,
op: F,
) -> Result<DurableStep<T>, DurableError>
where
T: Serialize + DeserializeOwned + Send,
F: FnOnce(StepHandle) -> Fut + Send,
Fut: Future<Output = Result<T, StepError>> + Send,
{
let effect = desc.effect();
let policy = desc.on_ambiguous().unwrap_or(OnAmbiguous::Fail);
self.emit_ambiguous_audit(step_id, effect, idem_key, policy);
match policy {
OnAmbiguous::Fail => Err(DurableError::AmbiguousEffect { step_id }),
OnAmbiguous::Skip | OnAmbiguous::Rerun => {
let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
let payload = serialize_result(&value, desc.name())?;
self.journal_result(payload, step_id, idem_key, effect, desc.name())
.await?;
Ok(DurableStep::live(step_id, idem_key, value))
}
}
}
#[tracing::instrument(name = "durable.context.run_op", skip_all, fields(step_id = step_id.value(), step_name = name))]
async fn run_op<T, F, Fut>(
&self,
op: F,
step_id: StepId,
idem_key: IdempotencyKey,
name: &'static str,
) -> Result<T, DurableError>
where
F: FnOnce(StepHandle) -> Fut + Send,
Fut: Future<Output = Result<T, StepError>> + Send,
{
let handle = StepHandle::new(step_id, idem_key);
op(handle)
.await
.map_err(|err| DurableError::step_failed(name, err))
}
#[tracing::instrument(name = "durable.context.journal_result", skip_all, fields(step_id = step_id.value(), effect_class = effect.as_str(), step_name = name))]
async fn journal_result(
&self,
payload: bytes::Bytes,
step_id: StepId,
idem_key: IdempotencyKey,
effect: EffectClass,
name: &'static str,
) -> Result<(), DurableError> {
crate::cipher::ensure_payload_within_limit(payload.len(), self.max_payload_bytes)?;
let entry = JournalEntry {
seq: None,
execution_id: self.execution_id,
kind: self.kind,
step_id,
entry: EntryKind::StepResult {
idempotency_key: idem_key,
payload,
effect,
payload_version: PAYLOAD_VERSION,
},
created_at_ms: now_unix_millis(),
};
match effect {
EffectClass::ExactlyOnceGuarded => self.append_acked_degrading(entry, name).await,
EffectClass::Idempotent | EffectClass::AtLeastOnce => {
self.writer.append_buffered(entry);
Ok(())
}
}
}
fn intent_entry(
&self,
step_id: StepId,
idem_key: IdempotencyKey,
effect: EffectClass,
) -> JournalEntry {
JournalEntry {
seq: None,
execution_id: self.execution_id,
kind: self.kind,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: idem_key,
effect,
hmac: None,
},
created_at_ms: now_unix_millis(),
}
}
#[tracing::instrument(name = "durable.context.append_acked_degrading", skip_all, fields(step_name = name))]
async fn append_acked_degrading(
&self,
entry: JournalEntry,
name: &'static str,
) -> Result<(), DurableError> {
match self.writer.append_acked(entry).await {
Ok(_) => Ok(()),
Err(DurableError::JournalUnavailable) => {
tracing::warn!(
step = name,
"journal writer unavailable; this step degrades to non-durable mode"
);
metrics::counter!("durable.journal.writer.degraded_appends_total").increment(1);
Ok(())
}
Err(error) => Err(error),
}
}
fn emit_ambiguous_audit(
&self,
step_id: StepId,
effect: EffectClass,
idem_key: IdempotencyKey,
policy: OnAmbiguous,
) {
tracing::warn!(
target: "durable.audit",
execution_id = %self.execution_id.as_uuid(),
step_id = step_id.value(),
effect_class = effect.as_str(),
idem_key = %idem_key_hex8(idem_key),
on_ambiguous = policy.as_str(),
"durable step resumed in the ambiguous window; applying on_ambiguous policy"
);
}
}
#[derive(Debug, Clone, Copy)]
pub struct ParallelScope<'a> {
ctx: &'a DurableContext,
}
impl<'a> ParallelScope<'a> {
pub fn step<T, F, Fut>(
&self,
desc: StepDescriptor,
op: F,
) -> impl Future<Output = Result<DurableStep<T>, DurableError>> + Send + 'a
where
T: Serialize + DeserializeOwned + Send + 'a,
F: FnOnce(StepHandle) -> Fut + Send + 'a,
Fut: Future<Output = Result<T, StepError>> + Send + 'a,
{
let step_id = self.ctx.assign_step_id();
let ctx = self.ctx;
async move { ctx.run_step_at(step_id, desc, op).await }
}
}
fn replay_value<T: DeserializeOwned>(
step_id: StepId,
effect: EffectClass,
entry: &JournalEntry,
) -> Result<T, DurableError> {
let _span = tracing::info_span!(
"durable.step.replay",
step_id = step_id.value(),
effect_class = effect.as_str(),
)
.entered();
match &entry.entry {
EntryKind::StepResult { payload, .. } => deserialize_result(payload),
_ => Err(DurableError::Decode {
context: "replayed entry is not a step result",
}),
}
}
fn system_time_to_millis(time: SystemTime) -> i64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
fn idem_key_hex8(key: IdempotencyKey) -> String {
let mut out = String::with_capacity(16);
for byte in &key.as_bytes()[..8] {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use std::assert_matches;
use super::*;
use crate::backend::local::LocalBackend;
use crate::config::DurableConfig;
use crate::effect::EffectIntentSubClass;
use crate::timer::DurableTimerService;
use crate::writer::JournalWriter;
use std::pin::Pin;
use std::sync::atomic::AtomicU32;
use tokio::task::JoinHandle;
type StepFut<'a> =
Pin<Box<dyn Future<Output = Result<DurableStep<u32>, DurableError>> + Send + 'a>>;
fn fast_config() -> DurableConfig {
DurableConfig {
journal_flush_interval_ms: 5,
journal_ack_timeout_ms: 2000,
..DurableConfig::default()
}
}
struct Harness {
ctx: DurableContext,
backend: Arc<LocalBackend>,
writer_task: JoinHandle<()>,
handle: JournalWriterHandle,
}
impl Harness {
async fn open(exec: ExecutionId, is_resume: bool) -> Self {
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
let writer_task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx = DurableContext::new(
exec,
ExecutionKind::AgentTurn,
is_resume,
backend,
handle.clone(),
&fast_config(),
);
Self {
ctx,
backend: local,
writer_task,
handle,
}
}
fn resume(&self) -> DurableContext {
let backend = Arc::new(DurableBackendEnum::Local(self.backend.clone()));
DurableContext::new(
self.ctx.execution_id,
ExecutionKind::AgentTurn,
true,
backend,
self.handle.clone(),
&fast_config(),
)
}
async fn shutdown(self) {
self.writer_task.abort();
let _ = self.writer_task.await;
}
}
#[tokio::test]
async fn fresh_step_runs_op_and_journals_result() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let value: u32 = h
.ctx
.step(
StepDescriptor::idempotent("count", b"op".to_vec()),
|_| async { Ok(7) },
)
.await
.unwrap();
assert_eq!(value, 7);
h.handle.flush().await.unwrap();
let entries = h.backend.read_execution(exec).await.unwrap();
assert_eq!(entries.len(), 1);
assert_matches!(entries[0].entry, EntryKind::StepResult { .. });
h.shutdown().await;
}
#[tokio::test]
async fn replayed_idempotent_step_skips_op() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let desc = || StepDescriptor::idempotent("count", b"op".to_vec());
let first: u32 = h.ctx.step(desc(), |_| async { Ok(11) }).await.unwrap();
assert_eq!(first, 11);
h.handle.flush().await.unwrap();
let resumed = h.resume();
let ran_again = Arc::new(AtomicU32::new(0));
let counter = ran_again.clone();
let replayed: u32 = resumed
.step(desc(), move |_| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(999)
}
})
.await
.unwrap();
assert_eq!(
replayed, 11,
"the journaled value is returned, not the new one"
);
assert_eq!(
ran_again.load(Ordering::SeqCst),
0,
"the operation closure must not run on replay"
);
h.shutdown().await;
}
#[tokio::test]
async fn guarded_step_commits_intent_before_result() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let desc = StepDescriptor::exactly_once_guarded(
"charge",
EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
Some(OnAmbiguous::Skip),
b"op".to_vec(),
)
.unwrap();
let _: u32 = h.ctx.step(desc, |_| async { Ok(5) }).await.unwrap();
h.handle.flush().await.unwrap();
let entries = h.backend.read_execution(exec).await.unwrap();
let kinds: Vec<_> = entries.iter().map(|e| e.entry.tag()).collect();
assert_eq!(
kinds,
vec!["effect_intent", "step_result"],
"intent is journaled before the result"
);
h.shutdown().await;
}
#[tokio::test]
async fn replay_divergence_on_fingerprint_mismatch() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let _: u32 = h
.ctx
.step(
StepDescriptor::idempotent("count", b"v1".to_vec()),
|_| async { Ok(1) },
)
.await
.unwrap();
h.handle.flush().await.unwrap();
let resumed = h.resume();
let err = resumed
.step::<u32, _, _>(
StepDescriptor::idempotent("count", b"v2".to_vec()),
|_| async { Ok(2) },
)
.await
.unwrap_err();
assert_matches!(err, DurableError::ReplayDivergence { .. });
let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
"SELECT status FROM durable_executions WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(h.backend.pool())
.await
.unwrap();
assert_eq!(status, "aborted", "the diverged journal is marked aborted");
h.shutdown().await;
}
#[tokio::test]
async fn ambiguous_window_fail_policy_surfaces_error() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let step_id = StepId::new(0);
let idem = IdempotencyKey::derive(
exec,
step_id,
&StepDescriptor::exactly_once_guarded(
"delete",
EffectIntentSubClass::Destructive,
Some(OnAmbiguous::Fail),
b"op".to_vec(),
)
.unwrap()
.fingerprint_input(),
);
h.backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: idem,
effect: EffectClass::ExactlyOnceGuarded,
hmac: None,
},
created_at_ms: 0,
})
.await
.unwrap();
let resumed = h.resume();
let ran = Arc::new(AtomicU32::new(0));
let counter = ran.clone();
let err = resumed
.step::<u32, _, _>(
StepDescriptor::exactly_once_guarded(
"delete",
EffectIntentSubClass::Destructive,
Some(OnAmbiguous::Fail),
b"op".to_vec(),
)
.unwrap(),
move |_| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(1)
}
},
)
.await
.unwrap_err();
assert_matches!(err, DurableError::AmbiguousEffect { .. });
assert_eq!(
ran.load(Ordering::SeqCst),
0,
"a fail-policy ambiguous step must not re-fire the effect"
);
h.shutdown().await;
}
#[tokio::test]
async fn inv13_committed_guarded_result_is_not_refired() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let desc = || {
StepDescriptor::exactly_once_guarded(
"transfer",
EffectIntentSubClass::MoneyMoving,
Some(OnAmbiguous::Fail),
b"op".to_vec(),
)
.unwrap()
};
let first: u32 = h.ctx.step(desc(), |_| async { Ok(500) }).await.unwrap();
assert_eq!(first, 500);
h.handle.flush().await.unwrap();
let backend = Arc::new(DurableBackendEnum::Local(h.backend.clone()));
let fresh = DurableContext::new(
exec,
ExecutionKind::AgentTurn,
false,
backend,
h.handle.clone(),
&fast_config(),
);
let ran = Arc::new(AtomicU32::new(0));
let counter = ran.clone();
let value: u32 = fresh
.step(desc(), move |_| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(0)
}
})
.await
.unwrap();
assert_eq!(value, 500, "the pre-committed guarded result is returned");
assert_eq!(
ran.load(Ordering::SeqCst),
0,
"the guarded effect must not re-fire"
);
h.shutdown().await;
}
#[tokio::test]
async fn parallel_step_ids_are_completion_order_independent() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let scope = h.ctx.parallel();
let futures: Vec<StepFut> = vec![
Box::pin(scope.step::<u32, _, _>(
StepDescriptor::idempotent("a", b"a".to_vec()),
|handle: StepHandle| async move {
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
Ok(handle.step_id().value())
},
)),
Box::pin(scope.step::<u32, _, _>(
StepDescriptor::idempotent("b", b"b".to_vec()),
|handle: StepHandle| async move {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
Ok(handle.step_id().value())
},
)),
Box::pin(scope.step::<u32, _, _>(
StepDescriptor::idempotent("c", b"c".to_vec()),
|handle: StepHandle| async move { Ok(handle.step_id().value()) },
)),
];
let results = futures::future::try_join_all(futures).await.unwrap();
let ids: Vec<u32> = results
.iter()
.map(DurableStep::step_id)
.map(StepId::value)
.collect();
assert_eq!(ids, vec![0, 1, 2]);
h.shutdown().await;
}
#[tokio::test]
async fn concurrent_steps_under_shared_ref_are_sound() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let scope = h.ctx.parallel();
let futures: Vec<StepFut> = (0..16)
.map(|i| {
Box::pin(scope.step::<u32, _, _>(
StepDescriptor::idempotent("worker", format!("op:{i}").into_bytes()),
move |handle: StepHandle| async move { Ok(handle.step_id().value()) },
)) as StepFut
})
.collect();
let results = futures::future::try_join_all(futures).await.unwrap();
let mut ids: Vec<u32> = results
.iter()
.map(DurableStep::step_id)
.map(StepId::value)
.collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 16, "all 16 concurrent steps got unique ids");
h.handle.flush().await.unwrap();
assert_eq!(h.backend.read_execution(exec).await.unwrap().len(), 16);
h.shutdown().await;
}
#[tokio::test]
async fn op_failure_surfaces_as_step_failed_without_journaling() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let err = h
.ctx
.step::<u32, _, _>(
StepDescriptor::idempotent("boom", b"op".to_vec()),
|_| async { Err(StepError::new("op exploded")) },
)
.await
.unwrap_err();
assert_matches!(err, DurableError::StepFailed { step: "boom", .. });
h.handle.flush().await.unwrap();
assert!(
h.backend.read_execution(exec).await.unwrap().is_empty(),
"a failed step journals no result"
);
h.shutdown().await;
}
#[tokio::test]
async fn finalize_transitions_the_execution_to_the_given_status() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
h.ctx
.finalize(ExecutionStatus::Completed)
.await
.expect("finalize succeeds");
let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
"SELECT status FROM durable_executions WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(h.backend.pool())
.await
.unwrap();
assert_eq!(status, "completed");
h.shutdown().await;
}
fn context_with(
local: &Arc<LocalBackend>,
handle: &JournalWriterHandle,
exec: ExecutionId,
is_resume: bool,
config: &DurableConfig,
) -> DurableContext {
let dispatch = Arc::new(DurableBackendEnum::Local(local.clone()));
DurableContext::new(
exec,
ExecutionKind::AgentTurn,
is_resume,
dispatch,
handle.clone(),
config,
)
}
#[tokio::test]
async fn promise_resolves_with_token_and_await_returns_value() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let promise = h.ctx.promise::<u32>().await.unwrap();
assert!(!promise.is_resumed());
let token = *promise
.resolver_token()
.expect("fresh promise carries a token");
let id = promise.id();
let resolver = h.ctx.resolver_handle();
let (awaited, ack) = tokio::join!(h.ctx.await_promise::<u32>(promise), async {
tokio::time::sleep(Duration::from_millis(25)).await;
resolver.resolve(id, &token, 1234u32).await
});
ack.unwrap();
assert_eq!(
awaited.unwrap(),
1234,
"the awaiter receives the resolved value"
);
h.shutdown().await;
}
#[tokio::test]
async fn wrong_resolver_token_is_rejected_but_correct_one_resolves() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let promise = h.ctx.promise::<String>().await.unwrap();
let id = promise.id();
let token = *promise.resolver_token().unwrap();
let resolver = h.ctx.resolver_handle();
let mut wrong = token;
wrong[0] ^= 0xFF;
assert_matches!(
resolver.resolve(id, &wrong, "forged".to_string()).await,
Err(DurableError::PromiseRejected)
);
assert!(
!h.backend.promise_state(id).await.unwrap().unwrap().resolved,
"a rejected resolution must not resolve the promise"
);
resolver
.resolve(id, &token, "ok".to_string())
.await
.unwrap();
assert!(h.backend.promise_state(id).await.unwrap().unwrap().resolved);
assert_matches!(
resolver
.resolve(PromiseId::new(), &token, "x".to_string())
.await,
Err(DurableError::UnknownPromise)
);
h.shutdown().await;
}
#[tokio::test]
async fn resumed_promise_awaits_the_resolved_value() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let promise = h.ctx.promise::<u32>().await.unwrap();
let id = promise.id();
let token = *promise.resolver_token().unwrap();
h.ctx
.resolver_handle()
.resolve(id, &token, 77u32)
.await
.unwrap();
let resumed = h.resume();
let promise2 = resumed.promise::<u32>().await.unwrap();
assert!(promise2.is_resumed());
assert_eq!(
promise2.id(),
id,
"the resumed promise re-derives the same id"
);
assert_eq!(resumed.await_promise::<u32>(promise2).await.unwrap(), 77);
h.shutdown().await;
}
#[tokio::test]
async fn sleep_until_returns_when_the_instant_passes() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let due = SystemTime::now() + Duration::from_millis(40);
tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due))
.await
.expect("sleep_until completes before the test timeout")
.expect("sleep_until succeeds");
h.shutdown().await;
}
#[tokio::test]
async fn sleep_until_wakes_on_concurrent_fire_before_due() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let due = SystemTime::now() + Duration::from_secs(30);
let timer_id = TimerId::derive(exec, StepId::new(0));
let (result, marked) = tokio::join!(
tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due)),
async {
tokio::time::sleep(Duration::from_millis(25)).await;
h.backend.mark_timer_fired(timer_id).await
}
);
assert!(marked.unwrap(), "the timer transitions to fired");
result
.expect("sleep_until wakes on the concurrent fire before the test timeout")
.expect("sleep_until succeeds");
h.shutdown().await;
}
#[tokio::test]
async fn sleep_until_past_due_returns_immediately_on_resume() {
let exec = ExecutionId::new();
let h = Harness::open(exec, false).await;
let timer = TimerId::derive(exec, StepId::new(0));
h.backend.arm_timer(timer, exec, 1_000, 0).await.unwrap();
let service = DurableTimerService::new(
Arc::new(DurableBackendEnum::Local(h.backend.clone())),
Duration::from_millis(5),
);
service.fire_due().await;
assert_eq!(
h.backend.timer_state(timer).await.unwrap(),
Some((1_000, true))
);
let resumed = h.resume();
tokio::time::timeout(
Duration::from_millis(200),
resumed.sleep_until(SystemTime::now() + Duration::from_hours(1)),
)
.await
.expect("resumed sleep_until returns immediately")
.unwrap();
h.shutdown().await;
}
#[tokio::test]
async fn soft_cap_triggers_checkpoint_fold_and_replay_skips_folded_steps() {
let exec = ExecutionId::new();
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
let task = tokio::spawn(writer.run());
let config = DurableConfig {
max_steps_per_execution: 10,
..fast_config()
};
let ctx = context_with(&local, &handle, exec, false, &config);
let desc = |i: u32| StepDescriptor::idempotent("s", format!("op:{i}").into_bytes());
for i in 0..9 {
let v: u32 = ctx
.step(desc(i), move |_| async move { Ok(i) })
.await
.unwrap();
assert_eq!(v, i);
}
handle.flush().await.unwrap();
ctx.step::<u32, _, _>(desc(9), |_| async { Ok(9) })
.await
.unwrap();
ctx.drain_background().await;
handle.flush().await.unwrap();
let entries = local.read_execution(exec).await.unwrap();
let checkpoints = entries
.iter()
.filter(|e| matches!(e.entry, EntryKind::Checkpoint { .. }))
.count();
assert_eq!(checkpoints, 1, "the soft cap folded one checkpoint");
let surviving: Vec<u32> = entries
.iter()
.filter(|e| matches!(e.entry, EntryKind::StepResult { .. }))
.map(|e| e.step_id.value())
.collect();
assert_eq!(surviving, vec![9], "only the post-fold step row survives");
let resumed = context_with(&local, &handle, exec, true, &config);
let reran = Arc::new(AtomicU32::new(0));
for i in 0..9 {
let counter = reran.clone();
let v: u32 = resumed
.step(desc(i), move |_| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Ok(999)
}
})
.await
.unwrap();
assert_eq!(v, i, "folded step {i} replays its journaled value");
}
assert_eq!(
reran.load(Ordering::SeqCst),
0,
"no folded operation closure re-ran on replay"
);
drop(ctx);
drop(resumed);
drop(handle);
task.await.unwrap();
}
#[tokio::test]
async fn step_cap_is_enforced() {
let exec = ExecutionId::new();
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
let task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx = DurableContext::new(
exec,
ExecutionKind::AgentTurn,
false,
backend,
handle.clone(),
&DurableConfig {
max_steps_per_execution: 1,
..fast_config()
},
);
ctx.step::<u32, _, _>(
StepDescriptor::idempotent("ok", b"op".to_vec()),
|_| async { Ok(0) },
)
.await
.unwrap();
let err = ctx
.step::<u32, _, _>(
StepDescriptor::idempotent("over", b"op".to_vec()),
|_| async { Ok(0) },
)
.await
.unwrap_err();
assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
let (status, finalized_at): (String, Option<i64>) = zeph_db::query_as(zeph_db::sql!(
"SELECT status, finalized_at FROM durable_executions WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(local.pool())
.await
.unwrap();
assert_eq!(
status, "aborted",
"a step-cap-exceeded execution must finalize as aborted"
);
assert!(
finalized_at.is_some(),
"finalize must stamp finalized_at too, not just flip status — otherwise the \
retention sweep (gated on finalized_at, not status alone) still can't reclaim it"
);
drop(ctx);
drop(handle);
task.await.unwrap();
}
#[tokio::test]
async fn step_cap_is_enforced_via_checked_step_id() {
let exec = ExecutionId::new();
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
let task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx = DurableContext::new(
exec,
ExecutionKind::AgentTurn,
false,
backend,
handle.clone(),
&DurableConfig {
max_steps_per_execution: 1,
..fast_config()
},
);
ctx.promise::<u32>().await.unwrap();
let err = ctx.promise::<u32>().await.unwrap_err();
assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
drop(ctx);
drop(handle);
task.await.unwrap();
}
}