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