1use std::fmt::Write as _;
41use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, SystemTime};
44
45use rand::Rng as _;
46use serde::Serialize;
47use serde::de::DeserializeOwned;
48use tokio::task::JoinSet;
49use tracing::Instrument as _;
50use zeroize::Zeroizing;
51
52use crate::backend::local::now_unix_millis;
53use crate::backend::{DurableBackendEnum, ExecutionBackend as _};
54use crate::config::DurableConfig;
55use crate::effect::{EffectClass, OnAmbiguous};
56use crate::error::DurableError;
57use crate::ids::{ExecutionId, ExecutionKind, IdempotencyKey, PromiseId, StepId, TimerId};
58use crate::journal::{EntryKind, ExecutionStatus, Journal as _, JournalEntry};
59use crate::promise::{DurablePromise, RESOLVER_TOKEN_LEN, resolver_token_hash};
60use crate::replay::{DEFAULT_SEGMENT_STEPS, ReplayCursor, StepReplay};
61use crate::retention::step_cap_thresholds;
62use crate::step::{
63 DurableStep, PAYLOAD_VERSION, StepDescriptor, StepError, StepHandle, deserialize_result,
64 serialize_result,
65};
66use crate::waiters::wait_on_notify_or_poll;
67use crate::writer::JournalWriterHandle;
68
69#[derive(Debug)]
76pub struct DurableContext {
77 execution_id: ExecutionId,
78 kind: ExecutionKind,
79 next_step: AtomicU32,
80 diverged: AtomicBool,
81 is_resume: bool,
82 cursor: ReplayCursor,
83 backend: Arc<DurableBackendEnum>,
84 writer: JournalWriterHandle,
85 max_steps_per_execution: u32,
86 max_payload_bytes: u64,
87 soft_step_cap: u32,
89 poll_interval: Duration,
91 max_parked_promises: u32,
93 checkpoint_requested: AtomicBool,
95 fold_tasks: Mutex<JoinSet<()>>,
97}
98
99impl DurableContext {
100 #[must_use]
108 pub fn new(
109 execution_id: ExecutionId,
110 kind: ExecutionKind,
111 is_resume: bool,
112 backend: Arc<DurableBackendEnum>,
113 writer: JournalWriterHandle,
114 config: &DurableConfig,
115 ) -> Self {
116 let cursor = ReplayCursor::new(backend.clone(), execution_id, DEFAULT_SEGMENT_STEPS);
117 let (soft_step_cap, _hard) = step_cap_thresholds(config.max_steps_per_execution);
118 Self {
119 execution_id,
120 kind,
121 next_step: AtomicU32::new(0),
122 diverged: AtomicBool::new(false),
123 is_resume,
124 cursor,
125 backend,
126 writer,
127 max_steps_per_execution: config.max_steps_per_execution,
128 max_payload_bytes: config.max_payload_bytes,
129 soft_step_cap,
130 poll_interval: Duration::from_secs(config.promise_poll_interval_secs.max(1)),
131 max_parked_promises: config.max_parked_promises,
132 checkpoint_requested: AtomicBool::new(false),
133 fold_tasks: Mutex::new(JoinSet::new()),
134 }
135 }
136
137 #[must_use]
139 pub fn execution_id(&self) -> ExecutionId {
140 self.execution_id
141 }
142
143 #[must_use]
145 pub fn kind(&self) -> ExecutionKind {
146 self.kind
147 }
148
149 #[tracing::instrument(
183 name = "durable.context.step",
184 skip_all,
185 fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
186 )]
187 pub async fn step<T, F, Fut>(&self, desc: StepDescriptor, op: F) -> Result<T, DurableError>
188 where
189 T: Serialize + DeserializeOwned + Send,
190 F: FnOnce(StepHandle) -> Fut + Send,
191 Fut: Future<Output = Result<T, StepError>> + Send,
192 {
193 let step_id = self.assign_step_id();
194 self.run_step_at(step_id, desc, op)
195 .await
196 .map(DurableStep::into_value)
197 }
198
199 #[tracing::instrument(
205 name = "durable.context.step_recorded",
206 skip_all,
207 fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
208 )]
209 pub async fn step_recorded<T, F, Fut>(
210 &self,
211 desc: StepDescriptor,
212 op: F,
213 ) -> Result<DurableStep<T>, DurableError>
214 where
215 T: Serialize + DeserializeOwned + Send,
216 F: FnOnce(StepHandle) -> Fut + Send,
217 Fut: Future<Output = Result<T, StepError>> + Send,
218 {
219 let step_id = self.assign_step_id();
220 self.run_step_at(step_id, desc, op).await
221 }
222
223 #[must_use]
229 pub fn parallel(&self) -> ParallelScope<'_> {
230 ParallelScope { ctx: self }
231 }
232
233 #[tracing::instrument(
246 name = "durable.context.promise",
247 skip_all,
248 fields(execution_id = %self.execution_id.as_uuid())
249 )]
250 pub async fn promise<T>(&self) -> Result<DurablePromise<T>, DurableError> {
251 let step_id = self.checked_step_id().await?;
252 let promise_id = PromiseId::derive(self.execution_id, step_id);
253
254 if self.backend.promise_state(promise_id).await?.is_some() {
256 return Ok(DurablePromise::resumed(promise_id));
257 }
258
259 let mut token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
260 rand::rng().fill_bytes(&mut *token);
261 let hash = resolver_token_hash(promise_id, self.execution_id, &token);
262 self.backend
263 .insert_promise(
264 promise_id,
265 self.execution_id,
266 *hash.as_bytes(),
267 now_unix_millis(),
268 )
269 .await?;
270 Ok(DurablePromise::fresh(promise_id, token))
271 }
272
273 pub async fn claim_promise_notification(&self, id: PromiseId) -> Result<bool, DurableError> {
290 self.backend
291 .claim_promise_notification(id, now_unix_millis())
292 .await
293 }
294
295 pub async fn await_promise<T: DeserializeOwned>(
308 &self,
309 promise: DurablePromise<T>,
310 ) -> Result<T, DurableError> {
311 let id = promise.id();
312 let key = id.as_uuid();
313 let cap = usize::try_from(self.max_parked_promises).unwrap_or(usize::MAX);
314 let span = tracing::info_span!("durable.promise.await", promise_id = %key);
315 wait_on_notify_or_poll(
316 self.backend.promise_waiters(),
317 key,
318 Some(cap),
319 || self.poll_interval,
320 || self.take_resolved_promise::<T>(id),
321 || self.take_resolved_promise::<T>(id),
322 )
323 .instrument(span)
324 .await
325 }
326
327 #[tracing::instrument(name = "durable.context.take_resolved_promise", skip_all, fields(promise_id = %id.as_uuid()))]
342 pub async fn take_resolved_promise<T: DeserializeOwned>(
343 &self,
344 id: PromiseId,
345 ) -> Result<Option<T>, DurableError> {
346 let record = self
347 .backend
348 .promise_state(id)
349 .await?
350 .ok_or(DurableError::UnknownPromise)?;
351 if !record.resolved {
352 return Ok(None);
353 }
354 let sealed = record.payload.ok_or(DurableError::Decode {
355 context: "resolved promise is missing its payload",
356 })?;
357 let plaintext = self
358 .backend
359 .open_promise_payload(id, record.execution_id, &sealed)?;
360 deserialize_result(&plaintext).map(Some)
361 }
362
363 #[tracing::instrument(
376 name = "durable.context.sleep_until",
377 skip_all,
378 fields(execution_id = %self.execution_id.as_uuid())
379 )]
380 pub async fn sleep_until(&self, due: SystemTime) -> Result<(), DurableError> {
381 let step_id = self.checked_step_id().await?;
382 let timer_id = TimerId::derive(self.execution_id, step_id);
383 let due_ms = system_time_to_millis(due);
384
385 match self.backend.timer_state(timer_id).await? {
386 Some((_, true)) => return Ok(()),
388 Some((_, false)) => {}
390 None => {
392 self.backend
393 .arm_timer(timer_id, self.execution_id, due_ms, now_unix_millis())
394 .await?;
395 }
396 }
397
398 let key = timer_id.as_uuid();
399 wait_on_notify_or_poll(
400 self.backend.timer_waiters(),
401 key,
402 None,
403 || {
404 let remaining =
405 u64::try_from(due_ms.saturating_sub(now_unix_millis())).unwrap_or(u64::MAX);
406 self.poll_interval.min(Duration::from_millis(remaining))
407 },
408 || self.check_timer_due(timer_id, due_ms),
411 || async move {
412 if let Some(value) = self.check_timer_due(timer_id, due_ms).await? {
416 return Ok(Some(value));
417 }
418 if matches!(self.backend.timer_state(timer_id).await?, Some((_, true))) {
419 return Ok(Some(()));
420 }
421 Ok(None)
422 },
423 )
424 .await
425 }
426
427 async fn check_timer_due(
432 &self,
433 timer_id: TimerId,
434 due_ms: i64,
435 ) -> Result<Option<()>, DurableError> {
436 if now_unix_millis() >= due_ms {
437 self.backend.mark_timer_fired(timer_id).await?;
439 return Ok(Some(()));
440 }
441 Ok(None)
442 }
443
444 #[must_use]
450 pub fn resolver_handle(&self) -> crate::promise::DurableHandle {
451 crate::promise::DurableHandle::new(self.backend.clone())
452 }
453
454 #[tracing::instrument(
483 name = "durable.context.finalize",
484 skip(self),
485 fields(execution_id = %self.execution_id.as_uuid(), status = status.as_str())
486 )]
487 pub async fn finalize(&self, status: ExecutionStatus) -> Result<(), DurableError> {
488 self.backend.finalize(self.execution_id, status).await
489 }
490
491 #[tracing::instrument(name = "durable.context.drain_background", skip_all, fields(execution_id = %self.execution_id.as_uuid()))]
496 pub async fn drain_background(&self) {
497 let mut set = {
498 let mut guard = self
499 .fold_tasks
500 .lock()
501 .unwrap_or_else(std::sync::PoisonError::into_inner);
502 std::mem::take(&mut *guard)
503 };
504 while set.join_next().await.is_some() {}
505 }
506
507 fn assign_step_id(&self) -> StepId {
509 StepId::new(self.next_step.fetch_add(1, Ordering::Relaxed))
510 }
511
512 async fn checked_step_id(&self) -> Result<StepId, DurableError> {
514 let step_id = self.assign_step_id();
515 self.enforce_step_cap(step_id).await?;
516 Ok(step_id)
517 }
518
519 async fn enforce_step_cap(&self, step_id: StepId) -> Result<(), DurableError> {
529 if self.max_steps_per_execution != 0 && step_id.value() >= self.max_steps_per_execution {
530 if let Err(error) = self
531 .backend
532 .finalize(self.execution_id, ExecutionStatus::Aborted)
533 .await
534 {
535 tracing::warn!(%error, "failed to mark step-cap-exceeded execution aborted");
536 }
537 return Err(DurableError::StepCapExceeded {
538 cap: self.max_steps_per_execution,
539 });
540 }
541 Ok(())
542 }
543
544 fn maybe_checkpoint(&self, step_id: StepId) {
551 if step_id.value() < self.soft_step_cap {
552 return;
553 }
554 if self.checkpoint_requested.swap(true, Ordering::AcqRel) {
555 return;
556 }
557 let backend = self.backend.clone();
558 let execution_id = self.execution_id;
559 let up_to = self.soft_step_cap;
560 let mut guard = self
561 .fold_tasks
562 .lock()
563 .unwrap_or_else(std::sync::PoisonError::into_inner);
564 guard.spawn(async move {
565 match backend.checkpoint_fold(execution_id, up_to).await {
566 Ok(folded) => {
567 tracing::info!(
568 execution_id = %execution_id.as_uuid(),
569 folded,
570 "durable checkpoint fold compacted the idempotent prefix"
571 );
572 }
573 Err(error) => {
574 tracing::warn!(%error, "durable checkpoint fold failed");
575 }
576 }
577 });
578 }
579
580 fn replay_active(&self) -> bool {
582 self.is_resume && !self.diverged.load(Ordering::Acquire)
583 }
584
585 async fn run_step_at<T, F, Fut>(
587 &self,
588 step_id: StepId,
589 desc: StepDescriptor,
590 op: F,
591 ) -> Result<DurableStep<T>, DurableError>
592 where
593 T: Serialize + DeserializeOwned + Send,
594 F: FnOnce(StepHandle) -> Fut + Send,
595 Fut: Future<Output = Result<T, StepError>> + Send,
596 {
597 self.enforce_step_cap(step_id).await?;
598 self.maybe_checkpoint(step_id);
600 let effect = desc.effect();
601 let idem_key =
602 IdempotencyKey::derive(self.execution_id, step_id, &desc.fingerprint_input());
603
604 let span = tracing::info_span!(
605 "durable.step.run",
606 step_id = step_id.value(),
607 effect_class = effect.as_str(),
608 replayed = tracing::field::Empty,
609 );
610 async move {
611 if self.replay_active() {
613 match self.cursor.lookup(step_id).await? {
614 StepReplay::Result(entry) => {
615 self.check_divergence(step_id, idem_key, &entry).await?;
616 let value = replay_value::<T>(step_id, effect, &entry)?;
617 tracing::Span::current().record("replayed", true);
618 return Ok(DurableStep::replayed(step_id, idem_key, value));
619 }
620 StepReplay::IntentOnly(entry) => {
621 self.check_divergence(step_id, idem_key, &entry).await?;
622 return self.resolve_ambiguous(step_id, idem_key, &desc, op).await;
623 }
624 StepReplay::Fresh => {}
625 }
626 }
627
628 if effect == EffectClass::ExactlyOnceGuarded
631 && let Some(entry) = self
632 .backend
633 .lookup_committed_result(self.execution_id, idem_key)
634 .await?
635 {
636 let value = replay_value::<T>(step_id, effect, &entry)?;
637 tracing::Span::current().record("replayed", true);
638 return Ok(DurableStep::replayed(step_id, idem_key, value));
639 }
640
641 tracing::Span::current().record("replayed", false);
643 if effect == EffectClass::ExactlyOnceGuarded {
644 let intent = self.intent_entry(step_id, idem_key, effect);
646 self.append_acked_degrading(intent, desc.name()).await?;
647 }
648 let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
649 let payload = serialize_result(&value, desc.name())?;
653 self.journal_result(payload, step_id, idem_key, effect, desc.name())
654 .await?;
655 Ok(DurableStep::live(step_id, idem_key, value))
656 }
657 .instrument(span)
658 .await
659 }
660
661 #[tracing::instrument(name = "durable.context.check_divergence", skip_all, fields(step_id = step_id.value()))]
663 async fn check_divergence(
664 &self,
665 step_id: StepId,
666 expected: IdempotencyKey,
667 entry: &JournalEntry,
668 ) -> Result<(), DurableError> {
669 if entry.entry.idempotency_key() == Some(expected) {
672 return Ok(());
673 }
674 self.on_divergence(step_id).await;
675 Err(DurableError::ReplayDivergence { step_id })
676 }
677
678 #[tracing::instrument(name = "durable.context.on_divergence", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
680 async fn on_divergence(&self, step_id: StepId) {
681 self.diverged.store(true, Ordering::Release);
682 tracing::warn!(
683 execution_id = %self.execution_id.as_uuid(),
684 step_id = step_id.value(),
685 "replay divergence detected; marking journal aborted and restarting fresh"
686 );
687 if let Err(error) = self
688 .backend
689 .finalize(self.execution_id, ExecutionStatus::Aborted)
690 .await
691 {
692 tracing::warn!(%error, "failed to mark diverged execution aborted");
693 }
694 }
695
696 #[tracing::instrument(name = "durable.context.resolve_ambiguous", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
698 async fn resolve_ambiguous<T, F, Fut>(
699 &self,
700 step_id: StepId,
701 idem_key: IdempotencyKey,
702 desc: &StepDescriptor,
703 op: F,
704 ) -> Result<DurableStep<T>, DurableError>
705 where
706 T: Serialize + DeserializeOwned + Send,
707 F: FnOnce(StepHandle) -> Fut + Send,
708 Fut: Future<Output = Result<T, StepError>> + Send,
709 {
710 let effect = desc.effect();
711 let policy = desc.on_ambiguous().unwrap_or(OnAmbiguous::Fail);
712 self.emit_ambiguous_audit(step_id, effect, idem_key, policy);
713 match policy {
714 OnAmbiguous::Fail => Err(DurableError::AmbiguousEffect { step_id }),
716 OnAmbiguous::Skip | OnAmbiguous::Rerun => {
721 let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
722 let payload = serialize_result(&value, desc.name())?;
723 self.journal_result(payload, step_id, idem_key, effect, desc.name())
724 .await?;
725 Ok(DurableStep::live(step_id, idem_key, value))
726 }
727 }
728 }
729
730 #[tracing::instrument(name = "durable.context.run_op", skip_all, fields(step_id = step_id.value(), step_name = name))]
732 async fn run_op<T, F, Fut>(
733 &self,
734 op: F,
735 step_id: StepId,
736 idem_key: IdempotencyKey,
737 name: &'static str,
738 ) -> Result<T, DurableError>
739 where
740 F: FnOnce(StepHandle) -> Fut + Send,
741 Fut: Future<Output = Result<T, StepError>> + Send,
742 {
743 let handle = StepHandle::new(step_id, idem_key);
744 op(handle)
745 .await
746 .map_err(|err| DurableError::step_failed(name, err))
747 }
748
749 #[tracing::instrument(name = "durable.context.journal_result", skip_all, fields(step_id = step_id.value(), effect_class = effect.as_str(), step_name = name))]
751 async fn journal_result(
752 &self,
753 payload: bytes::Bytes,
754 step_id: StepId,
755 idem_key: IdempotencyKey,
756 effect: EffectClass,
757 name: &'static str,
758 ) -> Result<(), DurableError> {
759 crate::cipher::ensure_payload_within_limit(payload.len(), self.max_payload_bytes)?;
761 let entry = JournalEntry {
762 seq: None,
763 execution_id: self.execution_id,
764 kind: self.kind,
765 step_id,
766 entry: EntryKind::StepResult {
767 idempotency_key: idem_key,
768 payload,
769 effect,
770 payload_version: PAYLOAD_VERSION,
771 },
772 created_at_ms: now_unix_millis(),
773 };
774 match effect {
775 EffectClass::ExactlyOnceGuarded => self.append_acked_degrading(entry, name).await,
777 EffectClass::Idempotent | EffectClass::AtLeastOnce => {
779 self.writer.append_buffered(entry);
780 Ok(())
781 }
782 }
783 }
784
785 fn intent_entry(
787 &self,
788 step_id: StepId,
789 idem_key: IdempotencyKey,
790 effect: EffectClass,
791 ) -> JournalEntry {
792 JournalEntry {
793 seq: None,
794 execution_id: self.execution_id,
795 kind: self.kind,
796 step_id,
797 entry: EntryKind::EffectIntent {
798 idempotency_key: idem_key,
799 effect,
800 hmac: None,
802 },
803 created_at_ms: now_unix_millis(),
804 }
805 }
806
807 #[tracing::instrument(name = "durable.context.append_acked_degrading", skip_all, fields(step_name = name))]
809 async fn append_acked_degrading(
810 &self,
811 entry: JournalEntry,
812 name: &'static str,
813 ) -> Result<(), DurableError> {
814 match self.writer.append_acked(entry).await {
815 Ok(_) => Ok(()),
816 Err(DurableError::JournalUnavailable) => {
817 tracing::warn!(
818 step = name,
819 "journal writer unavailable; this step degrades to non-durable mode"
820 );
821 metrics::counter!("durable.journal.writer.degraded_appends_total").increment(1);
822 Ok(())
823 }
824 Err(error) => Err(error),
825 }
826 }
827
828 fn emit_ambiguous_audit(
830 &self,
831 step_id: StepId,
832 effect: EffectClass,
833 idem_key: IdempotencyKey,
834 policy: OnAmbiguous,
835 ) {
836 tracing::warn!(
837 target: "durable.audit",
838 execution_id = %self.execution_id.as_uuid(),
839 step_id = step_id.value(),
840 effect_class = effect.as_str(),
841 idem_key = %idem_key_hex8(idem_key),
842 on_ambiguous = policy.as_str(),
843 "durable step resumed in the ambiguous window; applying on_ambiguous policy"
844 );
845 }
846}
847
848#[derive(Debug, Clone, Copy)]
854pub struct ParallelScope<'a> {
855 ctx: &'a DurableContext,
856}
857
858impl<'a> ParallelScope<'a> {
859 pub fn step<T, F, Fut>(
868 &self,
869 desc: StepDescriptor,
870 op: F,
871 ) -> impl Future<Output = Result<DurableStep<T>, DurableError>> + Send + 'a
872 where
873 T: Serialize + DeserializeOwned + Send + 'a,
874 F: FnOnce(StepHandle) -> Fut + Send + 'a,
875 Fut: Future<Output = Result<T, StepError>> + Send + 'a,
876 {
877 let step_id = self.ctx.assign_step_id();
878 let ctx = self.ctx;
879 async move { ctx.run_step_at(step_id, desc, op).await }
880 }
881}
882
883fn replay_value<T: DeserializeOwned>(
885 step_id: StepId,
886 effect: EffectClass,
887 entry: &JournalEntry,
888) -> Result<T, DurableError> {
889 let _span = tracing::info_span!(
890 "durable.step.replay",
891 step_id = step_id.value(),
892 effect_class = effect.as_str(),
893 )
894 .entered();
895 match &entry.entry {
896 EntryKind::StepResult { payload, .. } => deserialize_result(payload),
897 _ => Err(DurableError::Decode {
898 context: "replayed entry is not a step result",
899 }),
900 }
901}
902
903fn system_time_to_millis(time: SystemTime) -> i64 {
907 time.duration_since(SystemTime::UNIX_EPOCH)
908 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
909}
910
911fn idem_key_hex8(key: IdempotencyKey) -> String {
916 let mut out = String::with_capacity(16);
917 for byte in &key.as_bytes()[..8] {
918 let _ = write!(out, "{byte:02x}");
919 }
920 out
921}
922
923#[cfg(all(test, feature = "sqlite"))]
924mod tests {
925 use std::assert_matches;
926
927 use super::*;
928 use crate::backend::local::LocalBackend;
929 use crate::config::DurableConfig;
930 use crate::effect::EffectIntentSubClass;
931 use crate::timer::DurableTimerService;
932 use crate::writer::JournalWriter;
933 use std::pin::Pin;
934 use std::sync::atomic::AtomicU32;
935 use tokio::task::JoinHandle;
936
937 type StepFut<'a> =
940 Pin<Box<dyn Future<Output = Result<DurableStep<u32>, DurableError>> + Send + 'a>>;
941
942 fn fast_config() -> DurableConfig {
943 DurableConfig {
944 journal_flush_interval_ms: 5,
945 journal_ack_timeout_ms: 2000,
946 ..DurableConfig::default()
947 }
948 }
949
950 struct Harness {
952 ctx: DurableContext,
953 backend: Arc<LocalBackend>,
954 writer_task: JoinHandle<()>,
955 handle: JournalWriterHandle,
956 }
957
958 impl Harness {
959 async fn open(exec: ExecutionId, is_resume: bool) -> Self {
960 let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
961 local.init().await.unwrap();
962 local
963 .open_execution(exec, ExecutionKind::AgentTurn)
964 .await
965 .unwrap();
966 let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
967 let writer_task = tokio::spawn(writer.run());
968 let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
969 let ctx = DurableContext::new(
970 exec,
971 ExecutionKind::AgentTurn,
972 is_resume,
973 backend,
974 handle.clone(),
975 &fast_config(),
976 );
977 Self {
978 ctx,
979 backend: local,
980 writer_task,
981 handle,
982 }
983 }
984
985 fn resume(&self) -> DurableContext {
987 let backend = Arc::new(DurableBackendEnum::Local(self.backend.clone()));
988 DurableContext::new(
989 self.ctx.execution_id,
990 ExecutionKind::AgentTurn,
991 true,
992 backend,
993 self.handle.clone(),
994 &fast_config(),
995 )
996 }
997
998 async fn shutdown(self) {
999 self.writer_task.abort();
1004 let _ = self.writer_task.await;
1005 }
1006 }
1007
1008 #[tokio::test]
1009 async fn fresh_step_runs_op_and_journals_result() {
1010 let exec = ExecutionId::new();
1012 let h = Harness::open(exec, false).await;
1013 let value: u32 = h
1014 .ctx
1015 .step(
1016 StepDescriptor::idempotent("count", b"op".to_vec()),
1017 |_| async { Ok(7) },
1018 )
1019 .await
1020 .unwrap();
1021 assert_eq!(value, 7);
1022 h.handle.flush().await.unwrap();
1023
1024 let entries = h.backend.read_execution(exec).await.unwrap();
1025 assert_eq!(entries.len(), 1);
1026 assert_matches!(entries[0].entry, EntryKind::StepResult { .. });
1027 h.shutdown().await;
1028 }
1029
1030 #[tokio::test]
1031 async fn replayed_idempotent_step_skips_op() {
1032 let exec = ExecutionId::new();
1034 let h = Harness::open(exec, false).await;
1035 let desc = || StepDescriptor::idempotent("count", b"op".to_vec());
1036 let first: u32 = h.ctx.step(desc(), |_| async { Ok(11) }).await.unwrap();
1037 assert_eq!(first, 11);
1038 h.handle.flush().await.unwrap();
1039
1040 let resumed = h.resume();
1041 let ran_again = Arc::new(AtomicU32::new(0));
1042 let counter = ran_again.clone();
1043 let replayed: u32 = resumed
1044 .step(desc(), move |_| {
1045 let counter = counter.clone();
1046 async move {
1047 counter.fetch_add(1, Ordering::SeqCst);
1048 Ok(999)
1049 }
1050 })
1051 .await
1052 .unwrap();
1053 assert_eq!(
1054 replayed, 11,
1055 "the journaled value is returned, not the new one"
1056 );
1057 assert_eq!(
1058 ran_again.load(Ordering::SeqCst),
1059 0,
1060 "the operation closure must not run on replay"
1061 );
1062 h.shutdown().await;
1063 }
1064
1065 #[tokio::test]
1066 async fn guarded_step_commits_intent_before_result() {
1067 let exec = ExecutionId::new();
1069 let h = Harness::open(exec, false).await;
1070 let desc = StepDescriptor::exactly_once_guarded(
1071 "charge",
1072 EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
1073 Some(OnAmbiguous::Skip),
1074 b"op".to_vec(),
1075 )
1076 .unwrap();
1077 let _: u32 = h.ctx.step(desc, |_| async { Ok(5) }).await.unwrap();
1078 h.handle.flush().await.unwrap();
1079
1080 let entries = h.backend.read_execution(exec).await.unwrap();
1081 let kinds: Vec<_> = entries.iter().map(|e| e.entry.tag()).collect();
1082 assert_eq!(
1083 kinds,
1084 vec!["effect_intent", "step_result"],
1085 "intent is journaled before the result"
1086 );
1087 h.shutdown().await;
1088 }
1089
1090 #[tokio::test]
1091 async fn replay_divergence_on_fingerprint_mismatch() {
1092 let exec = ExecutionId::new();
1094 let h = Harness::open(exec, false).await;
1095 let _: u32 = h
1096 .ctx
1097 .step(
1098 StepDescriptor::idempotent("count", b"v1".to_vec()),
1099 |_| async { Ok(1) },
1100 )
1101 .await
1102 .unwrap();
1103 h.handle.flush().await.unwrap();
1104
1105 let resumed = h.resume();
1106 let err = resumed
1108 .step::<u32, _, _>(
1109 StepDescriptor::idempotent("count", b"v2".to_vec()),
1110 |_| async { Ok(2) },
1111 )
1112 .await
1113 .unwrap_err();
1114 assert_matches!(err, DurableError::ReplayDivergence { .. });
1115
1116 let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
1117 "SELECT status FROM durable_executions WHERE execution_id = ?"
1118 ))
1119 .bind(exec.as_uuid().to_string())
1120 .fetch_one(h.backend.pool())
1121 .await
1122 .unwrap();
1123 assert_eq!(status, "aborted", "the diverged journal is marked aborted");
1124 h.shutdown().await;
1125 }
1126
1127 #[tokio::test]
1128 async fn ambiguous_window_fail_policy_surfaces_error() {
1129 let exec = ExecutionId::new();
1131 let h = Harness::open(exec, false).await;
1132 let step_id = StepId::new(0);
1133 let idem = IdempotencyKey::derive(
1134 exec,
1135 step_id,
1136 &StepDescriptor::exactly_once_guarded(
1137 "delete",
1138 EffectIntentSubClass::Destructive,
1139 Some(OnAmbiguous::Fail),
1140 b"op".to_vec(),
1141 )
1142 .unwrap()
1143 .fingerprint_input(),
1144 );
1145 h.backend
1147 .append(JournalEntry {
1148 seq: None,
1149 execution_id: exec,
1150 kind: ExecutionKind::AgentTurn,
1151 step_id,
1152 entry: EntryKind::EffectIntent {
1153 idempotency_key: idem,
1154 effect: EffectClass::ExactlyOnceGuarded,
1155 hmac: None,
1156 },
1157 created_at_ms: 0,
1158 })
1159 .await
1160 .unwrap();
1161
1162 let resumed = h.resume();
1163 let ran = Arc::new(AtomicU32::new(0));
1164 let counter = ran.clone();
1165 let err = resumed
1166 .step::<u32, _, _>(
1167 StepDescriptor::exactly_once_guarded(
1168 "delete",
1169 EffectIntentSubClass::Destructive,
1170 Some(OnAmbiguous::Fail),
1171 b"op".to_vec(),
1172 )
1173 .unwrap(),
1174 move |_| {
1175 let counter = counter.clone();
1176 async move {
1177 counter.fetch_add(1, Ordering::SeqCst);
1178 Ok(1)
1179 }
1180 },
1181 )
1182 .await
1183 .unwrap_err();
1184 assert_matches!(err, DurableError::AmbiguousEffect { .. });
1185 assert_eq!(
1186 ran.load(Ordering::SeqCst),
1187 0,
1188 "a fail-policy ambiguous step must not re-fire the effect"
1189 );
1190 h.shutdown().await;
1191 }
1192
1193 #[tokio::test]
1194 async fn inv13_committed_guarded_result_is_not_refired() {
1195 let exec = ExecutionId::new();
1198 let h = Harness::open(exec, false).await;
1199 let desc = || {
1200 StepDescriptor::exactly_once_guarded(
1201 "transfer",
1202 EffectIntentSubClass::MoneyMoving,
1203 Some(OnAmbiguous::Fail),
1204 b"op".to_vec(),
1205 )
1206 .unwrap()
1207 };
1208 let first: u32 = h.ctx.step(desc(), |_| async { Ok(500) }).await.unwrap();
1209 assert_eq!(first, 500);
1210 h.handle.flush().await.unwrap();
1211
1212 let backend = Arc::new(DurableBackendEnum::Local(h.backend.clone()));
1215 let fresh = DurableContext::new(
1216 exec,
1217 ExecutionKind::AgentTurn,
1218 false,
1219 backend,
1220 h.handle.clone(),
1221 &fast_config(),
1222 );
1223 let ran = Arc::new(AtomicU32::new(0));
1224 let counter = ran.clone();
1225 let value: u32 = fresh
1226 .step(desc(), move |_| {
1227 let counter = counter.clone();
1228 async move {
1229 counter.fetch_add(1, Ordering::SeqCst);
1230 Ok(0)
1231 }
1232 })
1233 .await
1234 .unwrap();
1235 assert_eq!(value, 500, "the pre-committed guarded result is returned");
1236 assert_eq!(
1237 ran.load(Ordering::SeqCst),
1238 0,
1239 "the guarded effect must not re-fire"
1240 );
1241 h.shutdown().await;
1242 }
1243
1244 #[tokio::test]
1245 async fn parallel_step_ids_are_completion_order_independent() {
1246 let exec = ExecutionId::new();
1248 let h = Harness::open(exec, false).await;
1249 let scope = h.ctx.parallel();
1250 let futures: Vec<StepFut> = vec![
1253 Box::pin(scope.step::<u32, _, _>(
1254 StepDescriptor::idempotent("a", b"a".to_vec()),
1255 |handle: StepHandle| async move {
1256 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1258 Ok(handle.step_id().value())
1259 },
1260 )),
1261 Box::pin(scope.step::<u32, _, _>(
1262 StepDescriptor::idempotent("b", b"b".to_vec()),
1263 |handle: StepHandle| async move {
1264 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1265 Ok(handle.step_id().value())
1266 },
1267 )),
1268 Box::pin(scope.step::<u32, _, _>(
1269 StepDescriptor::idempotent("c", b"c".to_vec()),
1270 |handle: StepHandle| async move { Ok(handle.step_id().value()) },
1271 )),
1272 ];
1273 let results = futures::future::try_join_all(futures).await.unwrap();
1274 let ids: Vec<u32> = results
1275 .iter()
1276 .map(DurableStep::step_id)
1277 .map(StepId::value)
1278 .collect();
1279 assert_eq!(ids, vec![0, 1, 2]);
1281 h.shutdown().await;
1282 }
1283
1284 #[tokio::test]
1285 async fn concurrent_steps_under_shared_ref_are_sound() {
1286 let exec = ExecutionId::new();
1289 let h = Harness::open(exec, false).await;
1290 let scope = h.ctx.parallel();
1291 let futures: Vec<StepFut> = (0..16)
1292 .map(|i| {
1293 Box::pin(scope.step::<u32, _, _>(
1294 StepDescriptor::idempotent("worker", format!("op:{i}").into_bytes()),
1295 move |handle: StepHandle| async move { Ok(handle.step_id().value()) },
1296 )) as StepFut
1297 })
1298 .collect();
1299 let results = futures::future::try_join_all(futures).await.unwrap();
1300 let mut ids: Vec<u32> = results
1301 .iter()
1302 .map(DurableStep::step_id)
1303 .map(StepId::value)
1304 .collect();
1305 ids.sort_unstable();
1306 ids.dedup();
1307 assert_eq!(ids.len(), 16, "all 16 concurrent steps got unique ids");
1308 h.handle.flush().await.unwrap();
1309 assert_eq!(h.backend.read_execution(exec).await.unwrap().len(), 16);
1310 h.shutdown().await;
1311 }
1312
1313 #[tokio::test]
1314 async fn op_failure_surfaces_as_step_failed_without_journaling() {
1315 let exec = ExecutionId::new();
1316 let h = Harness::open(exec, false).await;
1317 let err = h
1318 .ctx
1319 .step::<u32, _, _>(
1320 StepDescriptor::idempotent("boom", b"op".to_vec()),
1321 |_| async { Err(StepError::new("op exploded")) },
1322 )
1323 .await
1324 .unwrap_err();
1325 assert_matches!(err, DurableError::StepFailed { step: "boom", .. });
1326 h.handle.flush().await.unwrap();
1327 assert!(
1328 h.backend.read_execution(exec).await.unwrap().is_empty(),
1329 "a failed step journals no result"
1330 );
1331 h.shutdown().await;
1332 }
1333
1334 #[tokio::test]
1335 async fn finalize_transitions_the_execution_to_the_given_status() {
1336 let exec = ExecutionId::new();
1339 let h = Harness::open(exec, false).await;
1340 h.ctx
1341 .finalize(ExecutionStatus::Completed)
1342 .await
1343 .expect("finalize succeeds");
1344
1345 let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
1346 "SELECT status FROM durable_executions WHERE execution_id = ?"
1347 ))
1348 .bind(exec.as_uuid().to_string())
1349 .fetch_one(h.backend.pool())
1350 .await
1351 .unwrap();
1352 assert_eq!(status, "completed");
1353 h.shutdown().await;
1354 }
1355
1356 fn context_with(
1358 local: &Arc<LocalBackend>,
1359 handle: &JournalWriterHandle,
1360 exec: ExecutionId,
1361 is_resume: bool,
1362 config: &DurableConfig,
1363 ) -> DurableContext {
1364 let dispatch = Arc::new(DurableBackendEnum::Local(local.clone()));
1365 DurableContext::new(
1366 exec,
1367 ExecutionKind::AgentTurn,
1368 is_resume,
1369 dispatch,
1370 handle.clone(),
1371 config,
1372 )
1373 }
1374
1375 #[tokio::test]
1376 async fn promise_resolves_with_token_and_await_returns_value() {
1377 let exec = ExecutionId::new();
1379 let h = Harness::open(exec, false).await;
1380 let promise = h.ctx.promise::<u32>().await.unwrap();
1381 assert!(!promise.is_resumed());
1382 let token = *promise
1383 .resolver_token()
1384 .expect("fresh promise carries a token");
1385 let id = promise.id();
1386 let resolver = h.ctx.resolver_handle();
1387
1388 let (awaited, ack) = tokio::join!(h.ctx.await_promise::<u32>(promise), async {
1389 tokio::time::sleep(Duration::from_millis(25)).await;
1390 resolver.resolve(id, &token, 1234u32).await
1391 });
1392 ack.unwrap();
1393 assert_eq!(
1394 awaited.unwrap(),
1395 1234,
1396 "the awaiter receives the resolved value"
1397 );
1398 h.shutdown().await;
1399 }
1400
1401 #[tokio::test]
1402 async fn wrong_resolver_token_is_rejected_but_correct_one_resolves() {
1403 let exec = ExecutionId::new();
1405 let h = Harness::open(exec, false).await;
1406 let promise = h.ctx.promise::<String>().await.unwrap();
1407 let id = promise.id();
1408 let token = *promise.resolver_token().unwrap();
1409 let resolver = h.ctx.resolver_handle();
1410
1411 let mut wrong = token;
1414 wrong[0] ^= 0xFF;
1415 assert_matches!(
1416 resolver.resolve(id, &wrong, "forged".to_string()).await,
1417 Err(DurableError::PromiseRejected)
1418 );
1419 assert!(
1420 !h.backend.promise_state(id).await.unwrap().unwrap().resolved,
1421 "a rejected resolution must not resolve the promise"
1422 );
1423
1424 resolver
1426 .resolve(id, &token, "ok".to_string())
1427 .await
1428 .unwrap();
1429 assert!(h.backend.promise_state(id).await.unwrap().unwrap().resolved);
1430
1431 assert_matches!(
1433 resolver
1434 .resolve(PromiseId::new(), &token, "x".to_string())
1435 .await,
1436 Err(DurableError::UnknownPromise)
1437 );
1438 h.shutdown().await;
1439 }
1440
1441 #[tokio::test]
1442 async fn resumed_promise_awaits_the_resolved_value() {
1443 let exec = ExecutionId::new();
1445 let h = Harness::open(exec, false).await;
1446 let promise = h.ctx.promise::<u32>().await.unwrap();
1447 let id = promise.id();
1448 let token = *promise.resolver_token().unwrap();
1449 h.ctx
1450 .resolver_handle()
1451 .resolve(id, &token, 77u32)
1452 .await
1453 .unwrap();
1454
1455 let resumed = h.resume();
1457 let promise2 = resumed.promise::<u32>().await.unwrap();
1458 assert!(promise2.is_resumed());
1459 assert_eq!(
1460 promise2.id(),
1461 id,
1462 "the resumed promise re-derives the same id"
1463 );
1464 assert_eq!(resumed.await_promise::<u32>(promise2).await.unwrap(), 77);
1465 h.shutdown().await;
1466 }
1467
1468 #[tokio::test]
1469 async fn sleep_until_returns_when_the_instant_passes() {
1470 let exec = ExecutionId::new();
1471 let h = Harness::open(exec, false).await;
1472 let due = SystemTime::now() + Duration::from_millis(40);
1474 tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due))
1475 .await
1476 .expect("sleep_until completes before the test timeout")
1477 .expect("sleep_until succeeds");
1478 h.shutdown().await;
1479 }
1480
1481 #[tokio::test]
1482 async fn sleep_until_wakes_on_concurrent_fire_before_due() {
1483 let exec = ExecutionId::new();
1487 let h = Harness::open(exec, false).await;
1488 let due = SystemTime::now() + Duration::from_secs(30);
1490 let timer_id = TimerId::derive(exec, StepId::new(0));
1491
1492 let (result, marked) = tokio::join!(
1493 tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due)),
1494 async {
1495 tokio::time::sleep(Duration::from_millis(25)).await;
1497 h.backend.mark_timer_fired(timer_id).await
1498 }
1499 );
1500 assert!(marked.unwrap(), "the timer transitions to fired");
1501 result
1502 .expect("sleep_until wakes on the concurrent fire before the test timeout")
1503 .expect("sleep_until succeeds");
1504 h.shutdown().await;
1505 }
1506
1507 #[tokio::test]
1508 async fn sleep_until_past_due_returns_immediately_on_resume() {
1509 let exec = ExecutionId::new();
1511 let h = Harness::open(exec, false).await;
1512 let timer = TimerId::derive(exec, StepId::new(0));
1514 h.backend.arm_timer(timer, exec, 1_000, 0).await.unwrap();
1515
1516 let service = DurableTimerService::new(
1518 Arc::new(DurableBackendEnum::Local(h.backend.clone())),
1519 Duration::from_millis(5),
1520 );
1521 service.fire_due().await;
1522 assert_eq!(
1523 h.backend.timer_state(timer).await.unwrap(),
1524 Some((1_000, true))
1525 );
1526
1527 let resumed = h.resume();
1529 tokio::time::timeout(
1530 Duration::from_millis(200),
1531 resumed.sleep_until(SystemTime::now() + Duration::from_hours(1)),
1532 )
1533 .await
1534 .expect("resumed sleep_until returns immediately")
1535 .unwrap();
1536 h.shutdown().await;
1537 }
1538
1539 #[tokio::test]
1540 async fn soft_cap_triggers_checkpoint_fold_and_replay_skips_folded_steps() {
1541 let exec = ExecutionId::new();
1543 let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1544 local.init().await.unwrap();
1545 local
1546 .open_execution(exec, ExecutionKind::AgentTurn)
1547 .await
1548 .unwrap();
1549 let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1550 let task = tokio::spawn(writer.run());
1551 let config = DurableConfig {
1552 max_steps_per_execution: 10,
1553 ..fast_config()
1554 };
1555 let ctx = context_with(&local, &handle, exec, false, &config);
1556
1557 let desc = |i: u32| StepDescriptor::idempotent("s", format!("op:{i}").into_bytes());
1558 for i in 0..9 {
1560 let v: u32 = ctx
1561 .step(desc(i), move |_| async move { Ok(i) })
1562 .await
1563 .unwrap();
1564 assert_eq!(v, i);
1565 }
1566 handle.flush().await.unwrap();
1567 ctx.step::<u32, _, _>(desc(9), |_| async { Ok(9) })
1569 .await
1570 .unwrap();
1571 ctx.drain_background().await;
1572 handle.flush().await.unwrap();
1573
1574 let entries = local.read_execution(exec).await.unwrap();
1576 let checkpoints = entries
1577 .iter()
1578 .filter(|e| matches!(e.entry, EntryKind::Checkpoint { .. }))
1579 .count();
1580 assert_eq!(checkpoints, 1, "the soft cap folded one checkpoint");
1581 let surviving: Vec<u32> = entries
1582 .iter()
1583 .filter(|e| matches!(e.entry, EntryKind::StepResult { .. }))
1584 .map(|e| e.step_id.value())
1585 .collect();
1586 assert_eq!(surviving, vec![9], "only the post-fold step row survives");
1587
1588 let resumed = context_with(&local, &handle, exec, true, &config);
1590 let reran = Arc::new(AtomicU32::new(0));
1591 for i in 0..9 {
1592 let counter = reran.clone();
1593 let v: u32 = resumed
1594 .step(desc(i), move |_| {
1595 let counter = counter.clone();
1596 async move {
1597 counter.fetch_add(1, Ordering::SeqCst);
1598 Ok(999)
1599 }
1600 })
1601 .await
1602 .unwrap();
1603 assert_eq!(v, i, "folded step {i} replays its journaled value");
1604 }
1605 assert_eq!(
1606 reran.load(Ordering::SeqCst),
1607 0,
1608 "no folded operation closure re-ran on replay"
1609 );
1610
1611 drop(ctx);
1612 drop(resumed);
1613 drop(handle);
1614 task.await.unwrap();
1615 }
1616
1617 #[tokio::test]
1618 async fn step_cap_is_enforced() {
1619 let exec = ExecutionId::new();
1620 let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1621 local.init().await.unwrap();
1622 local
1623 .open_execution(exec, ExecutionKind::AgentTurn)
1624 .await
1625 .unwrap();
1626 let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1627 let task = tokio::spawn(writer.run());
1628 let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
1629 let ctx = DurableContext::new(
1630 exec,
1631 ExecutionKind::AgentTurn,
1632 false,
1633 backend,
1634 handle.clone(),
1635 &DurableConfig {
1636 max_steps_per_execution: 1,
1637 ..fast_config()
1638 },
1639 );
1640 ctx.step::<u32, _, _>(
1642 StepDescriptor::idempotent("ok", b"op".to_vec()),
1643 |_| async { Ok(0) },
1644 )
1645 .await
1646 .unwrap();
1647 let err = ctx
1648 .step::<u32, _, _>(
1649 StepDescriptor::idempotent("over", b"op".to_vec()),
1650 |_| async { Ok(0) },
1651 )
1652 .await
1653 .unwrap_err();
1654 assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
1655
1656 let (status, finalized_at): (String, Option<i64>) = zeph_db::query_as(zeph_db::sql!(
1660 "SELECT status, finalized_at FROM durable_executions WHERE execution_id = ?"
1661 ))
1662 .bind(exec.as_uuid().to_string())
1663 .fetch_one(local.pool())
1664 .await
1665 .unwrap();
1666 assert_eq!(
1667 status, "aborted",
1668 "a step-cap-exceeded execution must finalize as aborted"
1669 );
1670 assert!(
1671 finalized_at.is_some(),
1672 "finalize must stamp finalized_at too, not just flip status — otherwise the \
1673 retention sweep (gated on finalized_at, not status alone) still can't reclaim it"
1674 );
1675
1676 drop(ctx);
1677 drop(handle);
1678 task.await.unwrap();
1679 }
1680
1681 #[tokio::test]
1682 async fn step_cap_is_enforced_via_checked_step_id() {
1683 let exec = ExecutionId::new();
1686 let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1687 local.init().await.unwrap();
1688 local
1689 .open_execution(exec, ExecutionKind::AgentTurn)
1690 .await
1691 .unwrap();
1692 let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1693 let task = tokio::spawn(writer.run());
1694 let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
1695 let ctx = DurableContext::new(
1696 exec,
1697 ExecutionKind::AgentTurn,
1698 false,
1699 backend,
1700 handle.clone(),
1701 &DurableConfig {
1702 max_steps_per_execution: 1,
1703 ..fast_config()
1704 },
1705 );
1706 ctx.promise::<u32>().await.unwrap();
1708 let err = ctx.promise::<u32>().await.unwrap_err();
1709 assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
1710 drop(ctx);
1711 drop(handle);
1712 task.await.unwrap();
1713 }
1714}