Skip to main content

agent_effects/
compensation.rs

1//! Undoing committed effects.
2//!
3//! Compensation is a durable lifecycle of its own:
4//! `Committed → Compensating → Compensated`, or `CompensationFailed` when it
5//! fails for good and an operator must finish it. Each attempt is recorded
6//! before it runs, retried with backoff, and resumed after a crash: by the
7//! next call, or by recovery for a registered
8//! [`CompensableEffect`](crate::handler::CompensableEffect).
9//!
10//! A compensation **must be idempotent**: an attempt that crashed or failed
11//! ambiguously is simply run again. Forward
12//! [`CompensationContext::idempotency_key`] to the remote system. It is
13//! stable across attempts and distinct from the effect's own key.
14//!
15//! ```
16//! use agent_effects::{CompensationOutcome, EffectFailure, EffectOutcome, Runtime};
17//! use agent_effects_memory::MemoryStore;
18//!
19//! # #[tokio::main(flavor = "current_thread")]
20//! # async fn main() -> Result<(), agent_effects::RuntimeError> {
21//! let runtime = Runtime::new(MemoryStore::new());
22//! runtime
23//!     .effect("inventory.reserve", "order-7")
24//!     .run(|_| async { Ok::<_, EffectFailure>("reservation-1".to_string()) })
25//!     .await?;
26//!
27//! // Later, the order is cancelled.
28//! let outcome = runtime
29//!     .compensation("inventory.reserve", "order-7")
30//!     .reason("order cancelled")
31//!     .run(|ctx, reservation: Option<String>| async move {
32//!         // Release `reservation`, sending ctx.idempotency_key().
33//!         Ok::<_, EffectFailure>(())
34//!     })
35//!     .await?;
36//! assert_eq!(outcome, CompensationOutcome::Compensated);
37//! # Ok(())
38//! # }
39//! ```
40
41use std::fmt::Display;
42use std::future::Future;
43use std::pin::Pin;
44use std::sync::Arc;
45use std::time::{Duration, SystemTime};
46
47use serde::de::DeserializeOwned;
48use serde_json::{Value, json};
49use tracing::{Instrument, debug, field, info_span, warn};
50
51use crate::effect::EffectFailure;
52use crate::error::RuntimeError;
53use crate::failure::{Disposition, FailureClass};
54use crate::fault::FaultPoint;
55use crate::id::{EffectId, EffectKey, EffectName, IdempotencyKey, LogicalKey};
56use crate::retry::RetryPolicy;
57use crate::runtime::{Interrupt, Runtime, jitter_sample, last_error};
58use crate::state::{EffectStatus, Transition};
59use crate::store::{EffectRecord, EffectStore, ErrorRecord, Lease, StoreError};
60
61/// How many times one call re-reads the record after losing a lease race.
62const MAX_ROUNDS: usize = 4;
63
64/// What a compensation attempt knows about the effect it undoes.
65#[derive(Clone, Debug)]
66pub struct CompensationContext {
67    pub(crate) id: EffectId,
68    pub(crate) key: EffectKey,
69    pub(crate) attempt: u32,
70    pub(crate) reason: Option<String>,
71}
72
73impl CompensationContext {
74    /// The effect's record id.
75    pub fn effect_id(&self) -> EffectId {
76        self.id
77    }
78
79    /// The effect's logical identity.
80    pub fn key(&self) -> &EffectKey {
81        &self.key
82    }
83
84    /// The key to forward to the remote system for the undo. Stable across
85    /// compensation attempts and distinct from the effect's own key.
86    pub fn idempotency_key(&self) -> IdempotencyKey {
87        self.key.compensation_idempotency_key()
88    }
89
90    /// The compensation attempt number, starting at 1.
91    pub fn attempt(&self) -> u32 {
92        self.attempt
93    }
94
95    /// Why the effect is being undone, if the caller said.
96    pub fn reason(&self) -> Option<&str> {
97        self.reason.as_deref()
98    }
99}
100
101/// How a compensation ended, or where it stands.
102#[derive(Clone, Debug, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum CompensationOutcome {
105    /// The effect is undone. Calling again changes nothing.
106    Compensated,
107    /// Undoing failed for good (`CompensationFailed`). An operator must
108    /// finish it by hand ([`Resolution::Compensated`](crate::Resolution::Compensated))
109    /// or order a retry ([`Resolution::Retry`](crate::Resolution::Retry)).
110    Failed(ErrorRecord),
111    /// Another caller or worker holds the effect right now.
112    InProgress {
113        /// The effect.
114        id: EffectId,
115    },
116    /// The effect has not committed, so there is nothing to undo, or it is
117    /// not known whether there is (`Unknown`, `NeedsIntervention`): resolve
118    /// it first.
119    NotCommitted {
120        /// The effect.
121        id: EffectId,
122        /// Its status.
123        status: EffectStatus,
124    },
125}
126
127type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
128
129/// An erased compensation: context, stored input, stored output.
130pub(crate) type Compensator = Arc<
131    dyn Fn(
132            CompensationContext,
133            Option<Value>,
134            Option<Value>,
135        ) -> BoxFuture<Result<(), EffectFailure>>
136        + Send
137        + Sync,
138>;
139
140/// Everything about a compensation except the code that performs it.
141pub(crate) struct CompensationSpec {
142    pub(crate) key: EffectKey,
143    pub(crate) reason: Option<String>,
144    pub(crate) actor: Option<String>,
145    pub(crate) retry: Option<RetryPolicy>,
146    pub(crate) attempt_timeout: Option<Duration>,
147}
148
149/// Undoes a closure effect. Created by [`Runtime::compensation`].
150#[must_use = "a compensation does nothing until `run` is awaited"]
151pub struct CompensationBuilder<'a, S> {
152    runtime: &'a Runtime<S>,
153    name: String,
154    key: String,
155    reason: Option<String>,
156    actor: Option<String>,
157    retry: Option<RetryPolicy>,
158    attempt_timeout: Option<Duration>,
159}
160
161impl<'a, S: EffectStore> CompensationBuilder<'a, S> {
162    pub(crate) fn new(runtime: &'a Runtime<S>, name: String, key: String) -> Self {
163        Self {
164            runtime,
165            name,
166            key,
167            reason: None,
168            actor: None,
169            retry: None,
170            attempt_timeout: None,
171        }
172    }
173
174    /// Why the effect is being undone; recorded in the audit trail.
175    pub fn reason(mut self, reason: impl Into<String>) -> Self {
176        self.reason = Some(reason.into());
177        self
178    }
179
180    /// Who is undoing it, e.g. `agent:support-bot`.
181    pub fn actor(mut self, actor: impl Into<String>) -> Self {
182        self.actor = Some(actor.into());
183        self
184    }
185
186    /// The retry policy for compensation attempts; defaults to the
187    /// runtime's. Its `max_attempts` bounds attempts over the compensation's
188    /// life.
189    pub fn retry(mut self, policy: RetryPolicy) -> Self {
190        self.retry = Some(policy);
191        self
192    }
193
194    /// Gives up waiting for one attempt after `timeout`, and retries.
195    pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
196        self.attempt_timeout = Some(timeout);
197        self
198    }
199
200    /// Undoes the effect with `compensate`, which receives the effect's
201    /// stored output (`None` if it had none). Calling again after it is
202    /// undone returns [`CompensationOutcome::Compensated`] without running
203    /// anything.
204    ///
205    /// # Errors
206    ///
207    /// Infrastructure failures, an invalid name or key, or no such effect.
208    pub async fn run<T, F, Fut>(self, compensate: F) -> Result<CompensationOutcome, RuntimeError>
209    where
210        T: DeserializeOwned + Send + 'static,
211        F: Fn(CompensationContext, Option<T>) -> Fut + Send + Sync + 'static,
212        Fut: Future<Output = Result<(), EffectFailure>> + Send + 'static,
213    {
214        let key = EffectKey::new(EffectName::new(self.name)?, LogicalKey::new(self.key)?);
215        let compensate: Compensator = Arc::new(move |ctx, _input, output| {
216            match output.map(serde_json::from_value::<T>).transpose() {
217                Ok(output) => Box::pin(compensate(ctx, output)),
218                Err(e) => Box::pin(std::future::ready(Err(EffectFailure::permanent(format!(
219                    "stored output does not match the compensation's type: {e}"
220                ))))),
221            }
222        });
223        let spec = CompensationSpec {
224            key,
225            reason: self.reason,
226            actor: self.actor,
227            retry: self.retry,
228            attempt_timeout: self.attempt_timeout,
229        };
230        self.runtime.compensate_effect(spec, compensate).await
231    }
232}
233
234impl<S: EffectStore> Runtime<S> {
235    /// Starts undoing the closure effect `(name, key)`. See
236    /// [`CompensationBuilder::run`].
237    pub fn compensation(
238        &self,
239        name: impl Into<String>,
240        key: impl Display,
241    ) -> CompensationBuilder<'_, S> {
242        CompensationBuilder::new(self, name.into(), key.to_string())
243    }
244
245    /// Drives the compensation of `spec.key` on a spawned task, so dropping
246    /// the caller does not abandon an attempt midway.
247    pub(crate) async fn compensate_effect(
248        &self,
249        spec: CompensationSpec,
250        compensate: Compensator,
251    ) -> Result<CompensationOutcome, RuntimeError> {
252        let span = info_span!(
253            "agent_effect.compensate",
254            effect.name = %spec.key.name,
255            effect.logical_key = %spec.key.key,
256            effect.id = field::Empty,
257        );
258        let runtime = self.clone();
259        tokio::spawn(
260            async move { runtime.drive_compensation(spec, compensate).await }.instrument(span),
261        )
262        .await
263        .unwrap_or_else(|e| Err(RuntimeError::Internal(e.to_string())))
264    }
265
266    async fn drive_compensation(
267        &self,
268        spec: CompensationSpec,
269        compensate: Compensator,
270    ) -> Result<CompensationOutcome, RuntimeError> {
271        let store = self.store();
272        let mut record =
273            store
274                .get_by_key(&spec.key)
275                .await?
276                .ok_or_else(|| RuntimeError::NoSuchEffect {
277                    key: spec.key.to_string(),
278                })?;
279        tracing::Span::current().record("effect.id", field::display(record.id));
280        for _ in 0..MAX_ROUNDS {
281            if let Some(outcome) = observe_compensation(&record) {
282                return Ok(outcome);
283            }
284            let lease = match store
285                .acquire_lease(record.id, self.worker_id(), self.now(), self.lease_ttl())
286                .await
287            {
288                Ok(lease) => lease,
289                Err(StoreError::LeaseHeld { .. }) => {
290                    return Ok(CompensationOutcome::InProgress { id: record.id });
291                }
292                Err(e) => return Err(e.into()),
293            };
294            let current = store
295                .get(record.id)
296                .await?
297                .ok_or(StoreError::NotFound(record.id))?;
298            let result = self
299                .compensate_leased(current, &lease, &spec, &compensate)
300                .await;
301            if let Err(e) = store.release_lease(&lease).await {
302                warn!(error = %e, "could not release lease; it will expire");
303            }
304            match result {
305                Ok(settled) => {
306                    return Ok(observe_compensation(&settled)
307                        .unwrap_or(CompensationOutcome::InProgress { id: settled.id }));
308                }
309                Err(Interrupt::LeaseLost) => {
310                    record = store
311                        .get(record.id)
312                        .await?
313                        .ok_or(StoreError::NotFound(record.id))?;
314                }
315                Err(Interrupt::Error(e)) => return Err(e),
316            }
317        }
318        Ok(CompensationOutcome::InProgress { id: record.id })
319    }
320
321    /// Starts the next attempt of a compensation found `Compensating`, or
322    /// ends it `CompensationFailed` if that attempt is a retry the budget
323    /// does not allow.
324    async fn resume_compensation(
325        &self,
326        record: &EffectRecord,
327        lease: &Lease,
328        actor: Option<&str>,
329        policy: RetryPolicy,
330    ) -> Result<EffectRecord, Interrupt> {
331        let resumed = match record.next_attempt_at {
332            // A retry that was scheduled but has not started: it is not an
333            // attempt yet. Wait for it, then start it.
334            Some(at) => {
335                self.sleep_leased(lease, at).await?;
336                None
337            }
338            // An attempt that a crash or a dead worker cut short. Another
339            // one is a retry, so it needs budget left.
340            None if policy.allows_another(record.compensation_attempts) => {
341                Some(json!({ "resumed": true }))
342            }
343            None => {
344                return self
345                    .transition_leased(record, lease, actor, Transition::CompensationFailed, |r| {
346                        r.error = Some(ErrorRecord {
347                            class: Some(FailureClass::Ambiguous),
348                            message: "a compensation attempt was interrupted, and no retries \
349                                      are left"
350                                .into(),
351                        });
352                    })
353                    .await;
354            }
355        };
356        self.transition_leased(
357            record,
358            lease,
359            actor,
360            Transition::StartCompensationRetry,
361            |r| r.payload = resumed,
362        )
363        .await
364    }
365
366    /// Starts or resumes the compensation and runs attempts until it
367    /// succeeds or fails for good.
368    async fn compensate_leased(
369        &self,
370        record: EffectRecord,
371        lease: &Lease,
372        spec: &CompensationSpec,
373        compensate: &Compensator,
374    ) -> Result<EffectRecord, Interrupt> {
375        let actor = spec.actor.as_deref();
376        let policy = spec.retry.unwrap_or_else(|| self.default_retry());
377        let reason = spec.reason.as_ref().map(|r| json!({ "reason": r }));
378        let mut record = match record.status {
379            EffectStatus::Committed => {
380                self.transition_leased(&record, lease, actor, Transition::StartCompensation, |r| {
381                    r.payload = reason;
382                })
383                .await?
384            }
385            EffectStatus::Compensating => {
386                let resumed = self
387                    .resume_compensation(&record, lease, actor, policy)
388                    .await?;
389                if resumed.status != EffectStatus::Compensating {
390                    return Ok(resumed);
391                }
392                resumed
393            }
394            _ => return Ok(record),
395        };
396        self.checkpoint(FaultPoint::AfterCompensationStarted);
397
398        loop {
399            let failure = match self
400                .attempt_compensation(&record, lease, spec, compensate)
401                .await?
402            {
403                Ok(()) => {
404                    return self
405                        .transition_leased(
406                            &record,
407                            lease,
408                            actor,
409                            Transition::CompensationSucceeded,
410                            |_| {},
411                        )
412                        .await;
413                }
414                Err(failure) => failure,
415            };
416            debug!(%failure, "compensation attempt failed");
417            let class = failure.class();
418            let error = failure.to_record();
419            // Compensations are idempotent, so an ambiguous failure is
420            // simply retried; only a permanent one gives up early.
421            let retryable = !matches!(class.disposition(), Disposition::Fail);
422            if !(retryable && policy.allows_another(record.compensation_attempts)) {
423                return self
424                    .transition_leased(&record, lease, actor, Transition::CompensationFailed, |r| {
425                        r.error = Some(error);
426                    })
427                    .await;
428            }
429            let retry_class = if class == FailureClass::Ambiguous {
430                FailureClass::Transient
431            } else {
432                class
433            };
434            let delay = policy.delay(
435                record.compensation_attempts.saturating_sub(1),
436                retry_class,
437                jitter_sample(),
438            );
439            let at = self.now() + delay;
440            record = self
441                .transition_leased(
442                    &record,
443                    lease,
444                    actor,
445                    Transition::ScheduleCompensationRetry,
446                    |r| {
447                        r.next_attempt_at = Some(at);
448                        r.error = Some(error);
449                    },
450                )
451                .await?;
452            self.sleep_leased(lease, at).await?;
453            record = self
454                .transition_leased(
455                    &record,
456                    lease,
457                    actor,
458                    Transition::StartCompensationRetry,
459                    |_| {},
460                )
461                .await?;
462        }
463    }
464
465    /// Runs one compensation attempt on its own task, under the lease and
466    /// the attempt timeout. A panic or a timeout is an ambiguous failure.
467    async fn attempt_compensation(
468        &self,
469        record: &EffectRecord,
470        lease: &Lease,
471        spec: &CompensationSpec,
472        compensate: &Compensator,
473    ) -> Result<Result<(), EffectFailure>, Interrupt> {
474        let ctx = CompensationContext {
475            id: record.id,
476            key: record.key.clone(),
477            attempt: record.compensation_attempts,
478            reason: spec.reason.clone(),
479        };
480        let mut task = tokio::spawn(compensate(ctx, record.input.clone(), record.output.clone()));
481        let joined = match spec.attempt_timeout {
482            None => self.with_lease(lease, &mut task).await?,
483            Some(limit) => {
484                match self
485                    .with_lease(lease, tokio::time::timeout(limit, &mut task))
486                    .await?
487                {
488                    Ok(joined) => joined,
489                    Err(_elapsed) => {
490                        task.abort();
491                        return Ok(Err(EffectFailure::ambiguous(format!(
492                            "compensation attempt timed out after {limit:?}"
493                        ))));
494                    }
495                }
496            }
497        };
498        Ok(joined.unwrap_or_else(|join_error| {
499            Err(EffectFailure::ambiguous(format!(
500                "compensation did not complete: {join_error}"
501            )))
502        }))
503    }
504
505    async fn sleep_leased(&self, lease: &Lease, until: SystemTime) -> Result<(), Interrupt> {
506        let wait = until.duration_since(self.now()).unwrap_or_default();
507        if wait.is_zero() {
508            return Ok(());
509        }
510        self.with_lease(lease, tokio::time::sleep(wait)).await
511    }
512}
513
514/// The outcome to report without acting, or `None` to try to take the lease
515/// and work on the compensation. Whether a lease is still live is left to
516/// `acquire_lease`, which may judge it by the store's own clock.
517fn observe_compensation(record: &EffectRecord) -> Option<CompensationOutcome> {
518    let id = record.id;
519    match record.status {
520        EffectStatus::Compensated => Some(CompensationOutcome::Compensated),
521        EffectStatus::CompensationFailed => Some(CompensationOutcome::Failed(last_error(record))),
522        EffectStatus::Committed | EffectStatus::Compensating => None,
523        status => Some(CompensationOutcome::NotCommitted { id, status }),
524    }
525}