1use std::collections::hash_map::RandomState;
8use std::fmt::Display;
9use std::future::Future;
10use std::hash::BuildHasher;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU32, Ordering};
13use std::time::{Duration, SystemTime};
14
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17use serde_json::{Value, json};
18use tracing::{Instrument, Span, debug, field, info_span, warn};
19
20use crate::approval::{ApprovalDecision, ApprovalProvider, ApprovalRequest, ErasedApproval};
21use crate::clock::{Clock, SystemClock};
22use crate::effect::{
23 EffectBuilder, EffectContext, EffectFailure, EffectOutcome, EffectSpec, Precondition,
24};
25use crate::error::RuntimeError;
26use crate::failure::{Disposition, FailureClass};
27#[cfg(feature = "fault-injection")]
28use crate::fault::FaultInjector;
29use crate::fault::FaultPoint;
30use crate::fingerprint::fingerprint;
31use crate::handler::{
32 CompensationSubmission, EffectHandler, Handler, Registered, Registry, Resume, Submission,
33};
34use crate::id::{EffectId, EffectName, WorkerId};
35use crate::kind::EffectKind;
36use crate::observer::{EffectObserver, Observation};
37use crate::policy::{RiskPolicy, UnknownPlan};
38use crate::redaction::{Field, Redactor};
39use crate::retention::RetentionPolicy;
40use crate::retry::RetryPolicy;
41use crate::state::{EffectStatus, Transition};
42use crate::store::{
43 EffectRecord, EffectStore, ErrorRecord, Lease, NewEffect, StoreError, TransitionRequest,
44};
45use crate::verification::{NotFoundReading, Verification, VerificationMode, Verifier};
46
47const MAX_ROUNDS: usize = 4;
50
51pub struct Runtime<S> {
53 inner: Arc<Inner<S>>,
54}
55
56struct Inner<S> {
57 store: S,
58 clock: Arc<dyn Clock>,
59 worker: WorkerId,
60 lease_ttl: Duration,
61 retry: RetryPolicy,
62 handlers: Registry<S>,
63 approval: Option<Arc<dyn ErasedApproval>>,
64 policy: RiskPolicy,
65 redactor: Option<Arc<dyn Redactor>>,
66 observers: Vec<Arc<dyn EffectObserver>>,
67 retention: RetentionPolicy,
68 #[cfg(feature = "fault-injection")]
69 faults: Option<Arc<FaultInjector>>,
70}
71
72impl<S> Clone for Runtime<S> {
73 fn clone(&self) -> Self {
74 Self {
75 inner: Arc::clone(&self.inner),
76 }
77 }
78}
79
80#[must_use]
82pub struct RuntimeBuilder<S> {
83 store: S,
84 clock: Arc<dyn Clock>,
85 worker: Option<WorkerId>,
86 lease_ttl: Duration,
87 retry: RetryPolicy,
88 handlers: Registry<S>,
89 approval: Option<Arc<dyn ErasedApproval>>,
90 policy: RiskPolicy,
91 redactor: Option<Arc<dyn Redactor>>,
92 observers: Vec<Arc<dyn EffectObserver>>,
93 retention: RetentionPolicy,
94 #[cfg(feature = "fault-injection")]
95 faults: Option<Arc<FaultInjector>>,
96}
97
98impl<S: EffectStore> RuntimeBuilder<S> {
99 pub fn retention(mut self, policy: RetentionPolicy) -> Self {
103 self.retention = policy;
104 self
105 }
106
107 pub fn observer(mut self, observer: impl EffectObserver) -> Self {
111 self.observers.push(Arc::new(observer));
112 self
113 }
114
115 pub fn redactor(mut self, redactor: impl Redactor) -> Self {
118 self.redactor = Some(Arc::new(redactor));
119 self
120 }
121
122 pub fn risk_policy(mut self, policy: RiskPolicy) -> Self {
126 self.policy = policy;
127 self
128 }
129
130 pub fn approval_provider(mut self, provider: impl ApprovalProvider) -> Self {
134 self.approval = Some(Arc::new(provider));
135 self
136 }
137
138 pub fn register<H: EffectHandler>(mut self, handler: Handler<H>) -> Self {
146 assert!(
147 !self.handlers.contains_key(H::NAME),
148 "a handler is already registered for effect `{}`",
149 H::NAME
150 );
151 self.handlers.insert(H::NAME, Registered::new(handler));
152 self
153 }
154
155 pub fn clock(mut self, clock: impl Clock) -> Self {
159 self.clock = Arc::new(clock);
160 self
161 }
162
163 pub fn worker_id(mut self, worker: WorkerId) -> Self {
166 self.worker = Some(worker);
167 self
168 }
169
170 pub fn lease_ttl(mut self, ttl: Duration) -> Self {
176 self.lease_ttl = ttl.max(Duration::from_millis(3));
177 self
178 }
179
180 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
184 self.retry = policy;
185 self
186 }
187
188 #[cfg(feature = "fault-injection")]
191 pub fn fault_injector(mut self, injector: Arc<FaultInjector>) -> Self {
192 self.faults = Some(injector);
193 self
194 }
195
196 pub fn build(self) -> Runtime<S> {
198 Runtime {
199 inner: Arc::new(Inner {
200 store: self.store,
201 clock: self.clock,
202 worker: self.worker.unwrap_or_else(WorkerId::random),
203 lease_ttl: self.lease_ttl,
204 retry: self.retry,
205 handlers: self.handlers,
206 approval: self.approval,
207 policy: self.policy,
208 redactor: self.redactor,
209 observers: self.observers,
210 retention: self.retention,
211 #[cfg(feature = "fault-injection")]
212 faults: self.faults,
213 }),
214 }
215 }
216}
217
218pub(crate) enum Interrupt {
220 LeaseLost,
222 Error(RuntimeError),
223}
224
225impl From<StoreError> for Interrupt {
226 fn from(error: StoreError) -> Self {
227 match error {
228 StoreError::LeaseLost => Self::LeaseLost,
229 other => Self::Error(other.into()),
230 }
231 }
232}
233
234impl<S: EffectStore> Runtime<S> {
235 pub fn new(store: S) -> Self {
237 Self::builder(store).build()
238 }
239
240 pub fn builder(store: S) -> RuntimeBuilder<S> {
242 RuntimeBuilder {
243 store,
244 clock: Arc::new(SystemClock),
245 worker: None,
246 lease_ttl: Duration::from_secs(30),
247 retry: RetryPolicy::default(),
248 handlers: Registry::new(),
249 approval: None,
250 policy: RiskPolicy::default(),
251 redactor: None,
252 observers: Vec::new(),
253 retention: RetentionPolicy::KEEP_ALL,
254 #[cfg(feature = "fault-injection")]
255 faults: None,
256 }
257 }
258
259 pub fn effect(&self, name: impl Into<String>, key: impl Display) -> EffectBuilder<S> {
263 EffectBuilder::new(self.clone(), name.into(), key.to_string())
264 }
265
266 pub fn submit<H: EffectHandler>(
273 &self,
274 key: impl Display,
275 input: H::Input,
276 ) -> Submission<'_, S, H> {
277 Submission::new(self, key, input)
278 }
279
280 pub fn compensate<H: EffectHandler>(
284 &self,
285 key: impl Display,
286 ) -> CompensationSubmission<'_, S, H> {
287 CompensationSubmission::new(self, key)
288 }
289
290 pub(crate) fn handler<H: EffectHandler>(&self) -> Option<Handler<H>> {
291 self.inner.handlers.get(H::NAME)?.typed::<H>()
292 }
293
294 pub(crate) fn resumer(&self, name: &str) -> Option<Resume<S>> {
295 self.inner.handlers.get(name).map(|r| Arc::clone(&r.resume))
296 }
297
298 pub(crate) fn enforce_contract(
299 &self,
300 record: &EffectRecord,
301 mut requested: crate::store::ExecutionContract,
302 risk: crate::RiskLevel,
303 ) -> Result<(), RuntimeError> {
304 let required = self.inner.policy.requirements(risk, requested.kind);
305 requested.require_approval |= required.approval;
306 requested.automatic_retry &= !required.no_automatic_retry;
307 check_contract(record, &requested)
308 }
309
310 pub fn store(&self) -> &S {
312 &self.inner.store
313 }
314
315 pub fn worker_id(&self) -> &WorkerId {
317 &self.inner.worker
318 }
319
320 pub async fn wait<T: DeserializeOwned>(
335 &self,
336 id: EffectId,
337 timeout: Duration,
338 ) -> Result<EffectOutcome<T>, RuntimeError> {
339 self.wait_with_output_schema(id, timeout, crate::PayloadSchema::default())
340 .await
341 }
342
343 pub async fn wait_with_output_schema<T: DeserializeOwned>(
350 &self,
351 id: EffectId,
352 timeout: Duration,
353 schema: crate::PayloadSchema,
354 ) -> Result<EffectOutcome<T>, RuntimeError> {
355 let deadline = tokio::time::Instant::now() + timeout;
356 let mut pause = Duration::from_millis(10);
357 loop {
358 let record = self
359 .store()
360 .get(id)
361 .await?
362 .ok_or(StoreError::NotFound(id))?;
363 let busy = !settled(record.status) && record.live_lease_owner(self.now()).is_some();
364 let now = tokio::time::Instant::now();
365 if !busy {
366 return report(&record, None, &schema);
367 }
368 if now >= deadline {
369 return Ok(EffectOutcome::InProgress { id });
370 }
371 tokio::time::sleep(pause.min(deadline - now)).await;
372 pause = (pause * 2).min(Duration::from_millis(250));
373 }
374 }
375
376 pub(crate) fn default_retry(&self) -> RetryPolicy {
377 self.inner.retry
378 }
379
380 async fn preserve_late_result(&self, observation: crate::store::LateObservation) {
381 let (effect_id, attempt, lease_epoch) = (
382 observation.effect_id,
383 observation.attempt,
384 observation.lease_epoch,
385 );
386 if let Err(error) = self.store().append_late_observation(observation).await {
387 warn!(effect.id = %effect_id, attempt, lease_epoch, error = %error,
388 "could not preserve supplementary late-result evidence");
389 }
390 }
391
392 pub(crate) fn now(&self) -> SystemTime {
393 self.inner.clock.now()
394 }
395
396 pub(crate) fn retention(&self) -> RetentionPolicy {
397 self.inner.retention
398 }
399
400 pub(crate) fn lease_ttl(&self) -> Duration {
401 self.inner.lease_ttl
402 }
403
404 pub(crate) async fn with_lease<Fut: Future>(
407 &self,
408 lease: &Lease,
409 future: Fut,
410 ) -> Result<Fut::Output, Interrupt> {
411 let ttl = self.inner.lease_ttl;
412 tokio::pin!(future);
413 loop {
414 tokio::select! {
415 output = &mut future => return Ok(output),
416 () = tokio::time::sleep(ttl / 3) => {
417 match self.store().renew_lease(lease, self.now(), ttl).await {
418 Ok(_) => {}
419 Err(StoreError::LeaseLost) => return Err(Interrupt::LeaseLost),
420 Err(e) => warn!(error = %e, "lease renewal failed; will retry"),
421 }
422 }
423 }
424 }
425 }
426
427 pub(crate) async fn transition_leased(
429 &self,
430 record: &EffectRecord,
431 lease: &Lease,
432 actor: Option<&str>,
433 transition: Transition,
434 customize: impl FnOnce(&mut TransitionRequest),
435 ) -> Result<EffectRecord, Interrupt> {
436 let mut request = TransitionRequest::new(record, Some(lease), transition, self.now());
437 request.actor = actor.map(str::to_owned);
438 customize(&mut request);
439 let record = self.commit_transition(record, request).await?;
440 debug!(%transition, status = %record.status, "effect transition");
441 Ok(record)
442 }
443
444 pub(crate) async fn commit_transition(
447 &self,
448 before: &EffectRecord,
449 mut request: TransitionRequest,
450 ) -> Result<EffectRecord, StoreError> {
451 self.redact_request(&mut request, &before.key.name);
452 let transition = request.transition;
453 let after = self.store().transition(request).await?;
454 if !self.inner.observers.is_empty() {
455 let observation = Observation {
456 record: &after,
457 transition,
458 from: before.status,
459 to: after.status,
460 in_previous_status: after
461 .updated_at
462 .duration_since(before.updated_at)
463 .unwrap_or_default(),
464 since_created: after
465 .updated_at
466 .duration_since(after.created_at)
467 .unwrap_or_default(),
468 };
469 self.notify(|observer| observer.on_transition(&observation));
470 }
471 Ok(after)
472 }
473
474 fn notify(&self, call: impl Fn(&dyn EffectObserver)) {
476 for observer in &self.inner.observers {
477 let observer: &dyn EffectObserver = observer.as_ref();
478 if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| call(observer))).is_err() {
479 warn!("an effect observer panicked; ignoring it");
480 }
481 }
482 }
483
484 pub(crate) fn redact(&self, field: Field, effect: &EffectName, value: &mut Value) {
486 if let Some(redactor) = &self.inner.redactor {
487 redactor.redact(field, effect, value);
488 }
489 }
490
491 pub(crate) fn redact_request(&self, request: &mut TransitionRequest, effect: &EffectName) {
493 if self.inner.redactor.is_none() {
494 return;
495 }
496 if let Some(output) = request.output.as_mut() {
497 self.redact(Field::Output, effect, output);
498 }
499 if let Some(payload) = request.payload.as_mut() {
500 self.redact(Field::AuditPayload, effect, payload);
501 }
502 if let Some(error) = request.error.as_mut() {
503 let mut message = Value::String(std::mem::take(&mut error.message));
504 self.redact(Field::ErrorMessage, effect, &mut message);
505 error.message = match message {
506 Value::String(text) => text,
507 other => other.to_string(),
508 };
509 }
510 }
511
512 #[cfg_attr(not(feature = "fault-injection"), allow(clippy::unused_self))]
515 pub(crate) fn checkpoint(&self, point: FaultPoint) {
516 #[cfg(feature = "fault-injection")]
517 if let Some(faults) = &self.inner.faults {
518 faults.reach(point);
519 }
520 #[cfg(not(feature = "fault-injection"))]
521 let _ = point;
522 }
523
524 pub(crate) async fn execute<T, F, Fut, V>(
525 &self,
526 mut spec: EffectSpec,
527 action: F,
528 verifier: V,
529 ) -> Result<EffectOutcome<T>, RuntimeError>
530 where
531 T: Serialize + DeserializeOwned + Send + 'static,
532 F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
533 Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
534 V: Verifier<T>,
535 {
536 let required = self
538 .inner
539 .policy
540 .requirements(spec.risk, spec.capabilities.kind);
541 if required.verification && spec.capabilities.verification == VerificationMode::None {
542 return Err(RuntimeError::PolicyViolation {
543 key: spec.key.to_string(),
544 requirement: "verification",
545 });
546 }
547 spec.require_approval |= required.approval;
548 spec.automatic_retry &= !required.no_automatic_retry;
549
550 if !spec.input_stored
554 && let Some(input) = spec.input.as_mut()
555 {
556 self.redact(Field::Input, &spec.key.name, input);
557 spec.fingerprint = Some(fingerprint(input));
558 }
559
560 let span = info_span!(
561 "agent_effect.execute",
562 effect.name = %spec.key.name,
563 effect.logical_key = %spec.key.key,
564 effect.kind = spec.capabilities.kind.as_str(),
565 effect.risk_level = %spec.risk,
566 effect.id = field::Empty,
567 effect.status = field::Empty,
568 effect.attempt = field::Empty,
569 );
570 if spec.capabilities.unknown_always_escalates() {
571 span.in_scope(|| {
572 warn!(
573 "effect is neither idempotent nor verifiable: \
574 any unknown outcome will need an operator"
575 );
576 });
577 }
578 let runtime = self.clone();
581 let task = tokio::spawn(
582 async move { runtime.drive(spec, action, verifier).await }.instrument(span),
583 );
584 task.await
585 .unwrap_or_else(|e| Err(RuntimeError::Internal(e.to_string())))
586 }
587
588 async fn drive<T, F, Fut, V>(
589 &self,
590 spec: EffectSpec,
591 action: F,
592 verifier: V,
593 ) -> Result<EffectOutcome<T>, RuntimeError>
594 where
595 T: Serialize + DeserializeOwned + Send + 'static,
596 F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
597 Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
598 V: Verifier<T>,
599 {
600 self.checkpoint(FaultPoint::BeforeInsert);
601 let store = self.store();
602 let inserted = store
603 .insert_or_get(NewEffect {
604 id: EffectId::new(),
605 key: spec.key.clone(),
606 kind: spec.capabilities.kind,
607 execution_contract: Some(spec.contract()),
608 input_schema_version: spec.input_schema_version,
609 input: spec.input.clone(),
610 input_fingerprint: spec.fingerprint.clone(),
611 created_by: spec.actor.clone(),
612 now: self.now(),
613 })
614 .await?;
615 let mut record = inserted.record;
616 if inserted.inserted {
617 self.notify(|observer| observer.on_created(&record));
618 }
619 Span::current().record("effect.id", field::display(record.id));
620 self.checkpoint(FaultPoint::AfterInsert);
621 if !inserted.inserted {
622 check_matches(&record, &spec)?;
623 }
624
625 let checks_made = AtomicU32::new(0);
628 for _ in 0..MAX_ROUNDS {
629 if let Some(outcome) = observe(&record, &spec.output_schema)? {
630 return Ok(outcome);
631 }
632 let lease = match store
633 .acquire_lease(
634 record.id,
635 self.worker_id(),
636 self.now(),
637 self.inner.lease_ttl,
638 )
639 .await
640 {
641 Ok(lease) => lease,
642 Err(StoreError::LeaseHeld { .. }) => {
643 return Ok(EffectOutcome::InProgress { id: record.id });
644 }
645 Err(e) => return Err(e.into()),
646 };
647 let current = store
649 .get(record.id)
650 .await?
651 .ok_or(StoreError::NotFound(record.id))?;
652 let driver = Driver {
653 rt: self,
654 spec: &spec,
655 action: &action,
656 verifier: &verifier,
657 lease: &lease,
658 checks_made: &checks_made,
659 };
660 let advanced = driver.advance(current).await;
661 if let Err(e) = store.release_lease(&lease).await {
662 warn!(error = %e, "could not release lease; it will expire");
663 }
664 match advanced {
665 Ok((settled, output)) => {
666 Span::current().record("effect.status", settled.status.as_str());
667 return report(&settled, output, &spec.output_schema);
668 }
669 Err(Interrupt::LeaseLost) => {
670 warn!("lease lost mid-effect; re-reading the record");
671 record = store
672 .get(record.id)
673 .await?
674 .ok_or(StoreError::NotFound(record.id))?;
675 }
676 Err(Interrupt::Error(e)) => return Err(e),
677 }
678 }
679 Ok(EffectOutcome::InProgress { id: record.id })
680 }
681}
682
683fn observe<T: DeserializeOwned>(
690 record: &EffectRecord,
691 schema: &crate::PayloadSchema,
692) -> Result<Option<EffectOutcome<T>>, RuntimeError> {
693 match record.status {
694 status if settled(status) => report(record, None, schema).map(Some),
695 EffectStatus::Pending
696 | EffectStatus::AwaitingApproval
697 | EffectStatus::Executing
698 | EffectStatus::Verifying
699 | EffectStatus::Unknown => Ok(None),
700 _ => Ok(Some(EffectOutcome::InProgress { id: record.id })),
701 }
702}
703
704struct Driver<'a, S, F, V> {
706 rt: &'a Runtime<S>,
707 spec: &'a EffectSpec,
708 action: &'a F,
709 verifier: &'a V,
710 lease: &'a Lease,
711 checks_made: &'a AtomicU32,
713}
714
715impl<S: EffectStore, F, V> Driver<'_, S, F, V> {
716 async fn advance<T, Fut>(
720 &self,
721 mut record: EffectRecord,
722 ) -> Result<(EffectRecord, Option<T>), Interrupt>
723 where
724 T: Serialize + Send + 'static,
725 F: Fn(EffectContext) -> Fut,
726 Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
727 V: Verifier<T>,
728 {
729 let mut output = None;
730 let mut verification_exhausted = false;
731 loop {
732 match record.status {
733 EffectStatus::Pending => {
734 if let Some(at) = record.next_attempt_at {
735 self.sleep_until(at).await?;
736 }
737 if record.attempt_count == 0
738 && let Some(reason) = self.check_precondition(&record).await?
739 {
740 record = self
741 .transition(&record, Transition::PreconditionRejected, |r| {
742 r.error = Some(reason);
743 })
744 .await?;
745 continue;
746 }
747 if record.attempt_count == 0 && self.spec.require_approval && !record.approved {
748 record = self
749 .transition(&record, Transition::RequestApproval, |_| {})
750 .await?;
751 self.rt.checkpoint(FaultPoint::AfterApprovalRequested);
752 continue;
753 }
754 record = self
755 .transition(&record, Transition::StartAttempt, |r| {
756 r.payload = Some(json!({ "worker": self.rt.worker_id() }));
757 })
758 .await?;
759 Span::current().record("effect.attempt", record.attempt_count);
760 self.rt.checkpoint(FaultPoint::AfterAttemptPersisted);
761 let (next, produced, exhausted) = self.attempt(record).await?;
762 record = next;
763 if produced.is_some() {
764 output = produced;
765 }
766 verification_exhausted = exhausted;
767 }
768 EffectStatus::Executing | EffectStatus::Verifying => {
771 record = self
772 .transition(&record, Transition::LeaseExpired, |_| {})
773 .await?;
774 }
775 EffectStatus::AwaitingApproval => match self.ask_approval(&record).await? {
778 ApprovalDecision::Approved { by } => {
779 record = self
780 .transition(&record, Transition::Approve, |r| r.actor = Some(by))
781 .await?;
782 }
783 ApprovalDecision::Denied { by, reason } => {
784 record = self
785 .transition(&record, Transition::Deny, |r| {
786 r.actor = Some(by);
787 r.error = Some(ErrorRecord {
788 class: None,
789 message: reason,
790 });
791 })
792 .await?;
793 }
794 ApprovalDecision::Deferred => break,
795 },
796 EffectStatus::Unknown => match self.spec.capabilities.unknown_plan() {
797 UnknownPlan::Verify if !verification_exhausted => {
798 let (next, verified, exhausted) = self.verify(record, None).await?;
799 record = next;
800 output = verified.or(output);
801 verification_exhausted = exhausted;
802 }
803 UnknownPlan::Verify => break,
806 UnknownPlan::Reexecute if self.may_retry(record.attempt_count) => {
807 record = self
808 .schedule_retry(&record, FailureClass::Ambiguous, None)
809 .await?;
810 }
811 UnknownPlan::Reexecute | UnknownPlan::Escalate => {
812 record = self
813 .transition(&record, Transition::Escalate, |_| {})
814 .await?;
815 }
816 },
817 _ => break,
818 }
819 }
820 Ok((record, output))
821 }
822
823 async fn attempt<T, Fut>(
826 &self,
827 record: EffectRecord,
828 ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
829 where
830 T: Serialize + Send + 'static,
831 F: Fn(EffectContext) -> Fut,
832 Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
833 V: Verifier<T>,
834 {
835 let mut task = tokio::spawn((self.action)(context(&record)));
838 self.rt.checkpoint(FaultPoint::AfterActionStarted);
839 let waited = match self.spec.attempt_timeout {
840 None => self.leased(&mut task).await,
841 Some(limit) => self
842 .leased(tokio::time::timeout(limit, &mut task))
843 .await
844 .map(|timed| {
845 timed.unwrap_or_else(|_| {
846 task.abort();
847 Ok(Err(EffectFailure::ambiguous(format!(
848 "attempt timed out after {limit:?}"
849 ))))
850 })
851 }),
852 };
853 let joined = match waited {
854 Ok(joined) => joined,
855 Err(interrupt) => {
856 let runtime = self.rt.clone();
857 let mut observation = crate::store::LateObservation::new(
858 &record,
859 self.lease,
860 crate::store::LateOutcome::Interrupted,
861 self.rt.now(),
862 );
863 tokio::spawn(async move {
864 let joined = task.await;
865 observation.outcome = late_outcome(&joined);
866 observation.observed_at = runtime.now();
867 runtime.preserve_late_result(observation).await;
868 });
869 return Err(interrupt);
870 }
871 };
872 self.rt.checkpoint(FaultPoint::AfterActionReturned);
873
874 let observation = crate::store::LateObservation::new(
875 &record,
876 self.lease,
877 late_outcome(&joined),
878 self.rt.now(),
879 );
880 let advanced = self.record_result(record, joined).await;
881 if advanced.is_err() {
882 self.rt.preserve_late_result(observation).await;
883 }
884 advanced
885 }
886
887 async fn record_result<T>(
888 &self,
889 record: EffectRecord,
890 joined: Result<Result<T, EffectFailure>, tokio::task::JoinError>,
891 ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
892 where
893 T: Serialize + Send + 'static,
894 V: Verifier<T>,
895 {
896 match joined {
897 Ok(Ok(value)) if self.spec.capabilities.verification != VerificationMode::None => {
898 let (record, verified, exhausted) =
899 self.verify(record, Some(output_json(&value))).await?;
900 let output = match record.status {
901 EffectStatus::Committed => verified.or(Some(value)),
902 _ => None,
903 };
904 Ok((record, output, exhausted))
905 }
906 Ok(Ok(value)) => {
907 let (output, payload) = output_json(&value);
908 let record = self
909 .transition(&record, Transition::Succeeded, |r| {
910 r.output = output;
911 r.payload = payload;
912 })
913 .await?;
914 Ok((record, Some(value), false))
915 }
916 Ok(Err(failure)) => {
917 debug!(%failure, "action failed");
918 let class = failure.class();
919 let scheduling_class =
920 failure
921 .retry_after()
922 .map_or(class, |delay| FailureClass::RateLimited {
923 retry_after: Some(delay),
924 });
925 let error = failure.to_record();
926 let record = match class.disposition() {
927 Disposition::Retry if self.may_retry(record.attempt_count) => {
928 self.schedule_retry(&record, scheduling_class, Some(error))
929 .await?
930 }
931 Disposition::Retry | Disposition::Fail
935 if record.may_have_applied && record.kind != EffectKind::Read =>
936 {
937 let unknown = self
938 .transition(&record, Transition::OutcomeUnknown, |r| {
939 r.error = Some(error);
940 r.payload =
941 Some(json!({ "earlier_attempt_may_have_applied": true }));
942 })
943 .await?;
944 if self.spec.capabilities.unknown_plan() == UnknownPlan::Verify {
945 unknown
946 } else {
947 self.transition(&unknown, Transition::Escalate, |_| {})
948 .await?
949 }
950 }
951 Disposition::Retry | Disposition::Fail => {
952 self.transition(&record, Transition::FailedDefinitively, |r| {
953 r.error = Some(error);
954 })
955 .await?
956 }
957 Disposition::Unknown => {
958 let not_before = failure
959 .retry_after()
960 .and_then(|delay| self.rt.now().checked_add(delay));
961 self.transition(&record, Transition::OutcomeUnknown, |r| {
962 r.error = Some(error);
963 r.next_attempt_at = not_before;
964 })
965 .await?
966 }
967 };
968 Ok((record, None, false))
969 }
970 Err(join_error) => {
971 let record = self
973 .transition(&record, Transition::OutcomeUnknown, |r| {
974 r.error = Some(ErrorRecord {
975 class: Some(FailureClass::Ambiguous),
976 message: format!("action did not complete: {join_error}"),
977 });
978 })
979 .await?;
980 Ok((record, None, false))
981 }
982 }
983 }
984
985 async fn verify<T>(
993 &self,
994 record: EffectRecord,
995 succeeded: Option<(Option<Value>, Option<Value>)>,
996 ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
997 where
998 T: Serialize + Send + 'static,
999 V: Verifier<T>,
1000 {
1001 let mut record = self
1002 .transition(&record, Transition::StartVerification, |r| {
1003 if let Some((output, payload)) = succeeded {
1004 (r.output, r.payload) = (output, payload);
1005 }
1006 })
1007 .await?;
1008 self.rt.checkpoint(FaultPoint::AfterVerificationStarted);
1009 let mode = self.spec.capabilities.verification;
1010 let mut checks = 0;
1011 let mut last_problem = String::from("no check completed");
1012 let verifying_since = record.updated_at;
1018 let ended_before = record.attempt_ended_at.map_or(Duration::ZERO, |ended| {
1019 verifying_since.duration_since(ended).unwrap_or_default()
1020 });
1021 let started = tokio::time::Instant::now();
1022
1023 while self.take_check() {
1024 let check = checks;
1025 checks += 1;
1026 let Some(future) = self.verifier.check(context(&record)) else {
1027 break;
1028 };
1029 let found = match self.leased(tokio::spawn(future)).await? {
1030 Ok(Ok(found)) => found,
1031 Ok(Err(failure)) => {
1032 last_problem = format!("check failed: {failure}");
1033 Verification::Inconclusive
1034 }
1035 Err(join_error) => {
1036 last_problem = format!("check did not complete: {join_error}");
1037 Verification::Inconclusive
1038 }
1039 };
1040 match found {
1041 Verification::Confirmed(value) => {
1042 let (output, payload) = output_json(&value);
1043 record = self
1044 .transition(&record, Transition::VerificationConfirmed, |r| {
1045 r.output = output;
1046 r.payload = payload;
1047 })
1048 .await?;
1049 return Ok((record, Some(value), false));
1050 }
1051 Verification::Conflict { details } => {
1052 record = self
1053 .transition(&record, Transition::VerificationConflict, |r| {
1054 r.error = Some(ErrorRecord {
1055 class: None,
1056 message: details,
1057 });
1058 })
1059 .await?;
1060 return Ok((record, None, false));
1061 }
1062 Verification::NotApplied => {
1063 match mode.read_not_found(ended_before + started.elapsed()) {
1064 NotFoundReading::NotApplied => {
1065 return Ok((self.not_applied(&record).await?, None, false));
1066 }
1067 NotFoundReading::TooEarly { wait } => {
1068 last_problem = "not visible yet within the settle delay".into();
1069 if self.has_checks_left() {
1070 self.sleep(wait).await?;
1071 }
1072 }
1073 }
1074 }
1075 Verification::Inconclusive => {
1076 if last_problem == "no check completed" {
1077 last_problem = "remote system could not tell".into();
1078 }
1079 if self.has_checks_left() {
1080 let delay =
1081 self.retry()
1082 .delay(check, FailureClass::Transient, jitter_sample());
1083 self.sleep(delay).await?;
1084 }
1085 }
1086 }
1087 }
1088
1089 if checks == 0 {
1090 last_problem = "this call's checks were already spent".into();
1091 }
1092 record = self
1093 .transition(&record, Transition::OutcomeUnknown, |r| {
1094 r.error = Some(ErrorRecord {
1095 class: Some(FailureClass::Ambiguous),
1096 message: format!(
1097 "verification inconclusive after {checks} checks: {last_problem}"
1098 ),
1099 });
1100 })
1101 .await?;
1102 Ok((record, None, true))
1103 }
1104
1105 fn take_check(&self) -> bool {
1108 self.checks_made.fetch_add(1, Ordering::Relaxed) < self.retry().max_attempts.max(1)
1109 }
1110
1111 fn has_checks_left(&self) -> bool {
1112 self.checks_made.load(Ordering::Relaxed) < self.retry().max_attempts.max(1)
1113 }
1114
1115 async fn not_applied(&self, record: &EffectRecord) -> Result<EffectRecord, Interrupt> {
1118 let error = ErrorRecord {
1119 class: None,
1120 message: "verification found that the effect did not apply".into(),
1121 };
1122 if self.may_retry(record.attempt_count) {
1123 self.schedule_retry(record, FailureClass::Transient, Some(error))
1124 .await
1125 } else {
1126 self.transition(record, Transition::VerificationNotApplied, |r| {
1127 r.error = Some(error);
1128 })
1129 .await
1130 }
1131 }
1132
1133 async fn check_precondition(
1135 &self,
1136 record: &EffectRecord,
1137 ) -> Result<Option<ErrorRecord>, Interrupt> {
1138 let Some(precondition) = &self.spec.precondition else {
1139 return Ok(None);
1140 };
1141 let max_checks = self.retry().max_attempts.max(1);
1142 let mut last_reason = String::new();
1143 for check in 1..=max_checks {
1144 let rejection = |message: String| {
1145 Some(ErrorRecord {
1146 class: None,
1147 message,
1148 })
1149 };
1150 match self
1151 .leased(tokio::spawn(precondition(context(record))))
1152 .await?
1153 {
1154 Ok(Precondition::Satisfied) => return Ok(None),
1155 Ok(Precondition::Rejected { reason }) => return Ok(rejection(reason)),
1156 Ok(Precondition::RetryLater { after, reason }) => {
1157 last_reason = reason;
1158 if check < max_checks {
1159 self.sleep(after).await?;
1160 }
1161 }
1162 Err(join_error) => {
1164 return Ok(rejection(format!(
1165 "precondition check did not complete: {join_error}"
1166 )));
1167 }
1168 }
1169 }
1170 Ok(Some(ErrorRecord {
1171 class: None,
1172 message: format!("precondition not satisfied after {max_checks} checks: {last_reason}"),
1173 }))
1174 }
1175
1176 async fn schedule_retry(
1180 &self,
1181 record: &EffectRecord,
1182 class: FailureClass,
1183 error: Option<ErrorRecord>,
1184 ) -> Result<EffectRecord, Interrupt> {
1185 let retry = record.attempt_count.saturating_sub(1);
1186 let delay = self.retry().delay(retry, class, jitter_sample());
1187 let at =
1188 (self.rt.now() + delay).max(record.next_attempt_at.unwrap_or(SystemTime::UNIX_EPOCH));
1189 debug!(?delay, ?class, "retry scheduled");
1190 self.transition(record, Transition::ScheduleRetry, |r| {
1191 r.next_attempt_at = Some(at);
1192 r.error = error;
1193 r.payload =
1194 Some(json!({ "delay_ms": u64::try_from(delay.as_millis()).unwrap_or(u64::MAX) }));
1195 })
1196 .await
1197 }
1198
1199 async fn ask_approval(&self, record: &EffectRecord) -> Result<ApprovalDecision, Interrupt> {
1202 let Some(provider) = self.rt.inner.approval.clone() else {
1203 return Ok(ApprovalDecision::Deferred);
1204 };
1205 let request = ApprovalRequest {
1206 effect_id: record.id,
1207 key: record.key.clone(),
1208 kind: record.kind,
1209 risk: self.spec.risk,
1210 input: record.input.clone(),
1211 requested_by: record.created_by.clone(),
1212 };
1213 let task = tokio::spawn(async move { provider.request_boxed(request).await });
1214 Ok(self.leased(task).await?.unwrap_or_else(|e| {
1215 warn!(error = %e, "approval provider failed; deferring");
1216 ApprovalDecision::Deferred
1217 }))
1218 }
1219
1220 async fn sleep_until(&self, at: SystemTime) -> Result<(), Interrupt> {
1221 let wait = at.duration_since(self.rt.now()).unwrap_or_default();
1222 self.sleep(wait).await
1223 }
1224
1225 async fn sleep(&self, duration: Duration) -> Result<(), Interrupt> {
1226 if duration.is_zero() {
1227 return Ok(());
1228 }
1229 self.leased(tokio::time::sleep(duration)).await
1230 }
1231
1232 async fn leased<Fut: Future>(&self, future: Fut) -> Result<Fut::Output, Interrupt> {
1233 self.rt.with_lease(self.lease, future).await
1234 }
1235
1236 async fn transition(
1237 &self,
1238 record: &EffectRecord,
1239 transition: Transition,
1240 customize: impl FnOnce(&mut TransitionRequest),
1241 ) -> Result<EffectRecord, Interrupt> {
1242 self.rt
1243 .transition_leased(
1244 record,
1245 self.lease,
1246 self.spec.actor.as_deref(),
1247 transition,
1248 |request| {
1249 customize(request);
1250 if request.output.is_some() {
1251 request.output_schema_version = Some(self.spec.output_schema.version());
1252 }
1253 },
1254 )
1255 .await
1256 }
1257
1258 fn retry(&self) -> &RetryPolicy {
1259 &self.spec.retry
1260 }
1261
1262 fn may_retry(&self, attempts: u32) -> bool {
1265 self.spec.automatic_retry && self.retry().allows_another(attempts)
1266 }
1267}
1268
1269fn late_outcome<T>(
1270 joined: &Result<Result<T, EffectFailure>, tokio::task::JoinError>,
1271) -> crate::store::LateOutcome {
1272 use crate::store::LateOutcome;
1273 match joined {
1274 Ok(Ok(_)) => LateOutcome::Succeeded,
1275 Ok(Err(_)) => LateOutcome::Failed,
1276 Err(_) => LateOutcome::Interrupted,
1277 }
1278}
1279
1280fn context(record: &EffectRecord) -> EffectContext {
1281 EffectContext {
1282 id: record.id,
1283 key: record.key.clone(),
1284 attempt: record.attempt_count,
1285 }
1286}
1287
1288fn output_json<T: Serialize>(value: &T) -> (Option<Value>, Option<Value>) {
1292 match serde_json::to_value(value) {
1293 Ok(output) => (Some(output), None),
1294 Err(e) => (None, Some(json!({ "output_not_stored": e.to_string() }))),
1295 }
1296}
1297
1298pub(crate) fn jitter_sample() -> f64 {
1301 let bits = RandomState::new().hash_one(0_u8) >> 11;
1302 #[allow(clippy::cast_precision_loss)]
1303 let sample = bits as f64 / (1_u64 << 53) as f64;
1304 sample
1305}
1306
1307fn settled(status: EffectStatus) -> bool {
1309 matches!(
1310 status,
1311 EffectStatus::Committed
1312 | EffectStatus::Failed
1313 | EffectStatus::Rejected
1314 | EffectStatus::NeedsIntervention
1315 | EffectStatus::Compensated
1316 | EffectStatus::CompensationFailed
1317 )
1318}
1319
1320fn check_matches(record: &EffectRecord, spec: &EffectSpec) -> Result<(), RuntimeError> {
1321 if record.input_schema_version != spec.input_schema_version {
1322 return Err(RuntimeError::InputSchemaMismatch {
1323 id: record.id,
1324 stored: record.input_schema_version,
1325 requested: spec.input_schema_version,
1326 });
1327 }
1328 if record.kind != spec.capabilities.kind {
1329 return Err(RuntimeError::KindMismatch {
1330 id: record.id,
1331 stored: record.kind,
1332 requested: spec.capabilities.kind,
1333 });
1334 }
1335 if record.input_fingerprint != spec.fingerprint {
1336 return Err(RuntimeError::InputMismatch { id: record.id });
1337 }
1338 check_contract(record, &spec.contract())
1339}
1340
1341fn check_contract(
1342 record: &EffectRecord,
1343 requested: &crate::store::ExecutionContract,
1344) -> Result<(), RuntimeError> {
1345 match &record.execution_contract {
1346 Some(stored) if !stored.permits(requested) => {
1347 return Err(RuntimeError::ContractMismatch { id: record.id });
1348 }
1349 None if !record.status.is_settled() => {
1350 return Err(RuntimeError::LegacyContract { id: record.id });
1351 }
1352 _ => {}
1353 }
1354 Ok(())
1355}
1356
1357fn report<T: DeserializeOwned>(
1360 record: &EffectRecord,
1361 fresh: Option<T>,
1362 schema: &crate::PayloadSchema,
1363) -> Result<EffectOutcome<T>, RuntimeError> {
1364 let id = record.id;
1365 Ok(match record.status {
1366 EffectStatus::Committed => {
1367 if let Some(value) = fresh {
1368 EffectOutcome::Committed(value)
1369 } else {
1370 let stored = record
1371 .output
1372 .clone()
1373 .ok_or(RuntimeError::OutputUnavailable { id })?;
1374 let migrated = schema
1375 .decode(record.output_schema_version, stored)
1376 .map_err(|source| RuntimeError::OutputPayload { id, source })?;
1377 EffectOutcome::Committed(
1378 serde_json::from_value(migrated)
1379 .map_err(|source| RuntimeError::Output { id, source })?,
1380 )
1381 }
1382 }
1383 EffectStatus::Failed => EffectOutcome::Failed(last_error(record)),
1384 EffectStatus::Rejected => EffectOutcome::Rejected(last_error(record)),
1385 EffectStatus::NeedsIntervention | EffectStatus::CompensationFailed => {
1387 EffectOutcome::NeedsIntervention { id }
1388 }
1389 EffectStatus::Unknown => EffectOutcome::Unknown { id },
1390 EffectStatus::Compensated => EffectOutcome::Compensated { id },
1391 EffectStatus::AwaitingApproval => EffectOutcome::AwaitingApproval { id },
1392 _ => EffectOutcome::InProgress { id },
1393 })
1394}
1395
1396pub(crate) fn last_error(record: &EffectRecord) -> ErrorRecord {
1397 record.last_error.clone().unwrap_or_else(|| ErrorRecord {
1398 class: None,
1399 message: "no error was recorded".into(),
1400 })
1401}