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 or resumes the compensation and runs attempts until it
322    /// succeeds or fails for good.
323    async fn compensate_leased(
324        &self,
325        record: EffectRecord,
326        lease: &Lease,
327        spec: &CompensationSpec,
328        compensate: &Compensator,
329    ) -> Result<EffectRecord, Interrupt> {
330        let actor = spec.actor.as_deref();
331        let policy = spec.retry.unwrap_or_else(|| self.default_retry());
332        let reason = spec.reason.as_ref().map(|r| json!({ "reason": r }));
333        let mut record = match record.status {
334            EffectStatus::Committed => {
335                self.transition_leased(&record, lease, actor, Transition::StartCompensation, |r| {
336                    r.payload = reason;
337                })
338                .await?
339            }
340            EffectStatus::Compensating => {
341                let resumed = match record.next_attempt_at {
342                    // A retry that was scheduled but has not started: it
343                    // is not an attempt yet. Wait for it, then start it.
344                    Some(at) => {
345                        self.sleep_leased(lease, at).await?;
346                        None
347                    }
348                    // An attempt that a crash or a dead worker cut short.
349                    None => Some(json!({ "resumed": true })),
350                };
351                self.transition_leased(
352                    &record,
353                    lease,
354                    actor,
355                    Transition::StartCompensationRetry,
356                    |r| r.payload = resumed,
357                )
358                .await?
359            }
360            _ => return Ok(record),
361        };
362        self.checkpoint(FaultPoint::AfterCompensationStarted);
363
364        loop {
365            let failure = match self
366                .attempt_compensation(&record, lease, spec, compensate)
367                .await?
368            {
369                Ok(()) => {
370                    return self
371                        .transition_leased(
372                            &record,
373                            lease,
374                            actor,
375                            Transition::CompensationSucceeded,
376                            |_| {},
377                        )
378                        .await;
379                }
380                Err(failure) => failure,
381            };
382            debug!(%failure, "compensation attempt failed");
383            let class = failure.class();
384            let error = failure.to_record();
385            // Compensations are idempotent, so an ambiguous failure is
386            // simply retried; only a permanent one gives up early.
387            let retryable = !matches!(class.disposition(), Disposition::Fail);
388            if !(retryable && policy.allows_another(record.compensation_attempts)) {
389                return self
390                    .transition_leased(&record, lease, actor, Transition::CompensationFailed, |r| {
391                        r.error = Some(error);
392                    })
393                    .await;
394            }
395            let retry_class = if class == FailureClass::Ambiguous {
396                FailureClass::Transient
397            } else {
398                class
399            };
400            let delay = policy.delay(
401                record.compensation_attempts.saturating_sub(1),
402                retry_class,
403                jitter_sample(),
404            );
405            let at = self.now() + delay;
406            record = self
407                .transition_leased(
408                    &record,
409                    lease,
410                    actor,
411                    Transition::ScheduleCompensationRetry,
412                    |r| {
413                        r.next_attempt_at = Some(at);
414                        r.error = Some(error);
415                    },
416                )
417                .await?;
418            self.sleep_leased(lease, at).await?;
419            record = self
420                .transition_leased(
421                    &record,
422                    lease,
423                    actor,
424                    Transition::StartCompensationRetry,
425                    |_| {},
426                )
427                .await?;
428        }
429    }
430
431    /// Runs one compensation attempt on its own task, under the lease and
432    /// the attempt timeout. A panic or a timeout is an ambiguous failure.
433    async fn attempt_compensation(
434        &self,
435        record: &EffectRecord,
436        lease: &Lease,
437        spec: &CompensationSpec,
438        compensate: &Compensator,
439    ) -> Result<Result<(), EffectFailure>, Interrupt> {
440        let ctx = CompensationContext {
441            id: record.id,
442            key: record.key.clone(),
443            attempt: record.compensation_attempts,
444            reason: spec.reason.clone(),
445        };
446        let mut task = tokio::spawn(compensate(ctx, record.input.clone(), record.output.clone()));
447        let joined = match spec.attempt_timeout {
448            None => self.with_lease(lease, &mut task).await?,
449            Some(limit) => {
450                match self
451                    .with_lease(lease, tokio::time::timeout(limit, &mut task))
452                    .await?
453                {
454                    Ok(joined) => joined,
455                    Err(_elapsed) => {
456                        task.abort();
457                        return Ok(Err(EffectFailure::ambiguous(format!(
458                            "compensation attempt timed out after {limit:?}"
459                        ))));
460                    }
461                }
462            }
463        };
464        Ok(joined.unwrap_or_else(|join_error| {
465            Err(EffectFailure::ambiguous(format!(
466                "compensation did not complete: {join_error}"
467            )))
468        }))
469    }
470
471    async fn sleep_leased(&self, lease: &Lease, until: SystemTime) -> Result<(), Interrupt> {
472        let wait = until.duration_since(self.now()).unwrap_or_default();
473        if wait.is_zero() {
474            return Ok(());
475        }
476        self.with_lease(lease, tokio::time::sleep(wait)).await
477    }
478}
479
480/// The outcome to report without acting, or `None` to try to take the lease
481/// and work on the compensation. Whether a lease is still live is left to
482/// `acquire_lease`, which may judge it by the store's own clock.
483fn observe_compensation(record: &EffectRecord) -> Option<CompensationOutcome> {
484    let id = record.id;
485    match record.status {
486        EffectStatus::Compensated => Some(CompensationOutcome::Compensated),
487        EffectStatus::CompensationFailed => Some(CompensationOutcome::Failed(last_error(record))),
488        EffectStatus::Committed | EffectStatus::Compensating => None,
489        status => Some(CompensationOutcome::NotCommitted { id, status }),
490    }
491}