1use 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
61const MAX_ROUNDS: usize = 4;
63
64#[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 pub fn effect_id(&self) -> EffectId {
76 self.id
77 }
78
79 pub fn key(&self) -> &EffectKey {
81 &self.key
82 }
83
84 pub fn idempotency_key(&self) -> IdempotencyKey {
87 self.key.compensation_idempotency_key()
88 }
89
90 pub fn attempt(&self) -> u32 {
92 self.attempt
93 }
94
95 pub fn reason(&self) -> Option<&str> {
97 self.reason.as_deref()
98 }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum CompensationOutcome {
105 Compensated,
107 Failed(ErrorRecord),
111 InProgress {
113 id: EffectId,
115 },
116 NotCommitted {
120 id: EffectId,
122 status: EffectStatus,
124 },
125}
126
127type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
128
129pub(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
140pub(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#[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 pub fn reason(mut self, reason: impl Into<String>) -> Self {
176 self.reason = Some(reason.into());
177 self
178 }
179
180 pub fn actor(mut self, actor: impl Into<String>) -> Self {
182 self.actor = Some(actor.into());
183 self
184 }
185
186 pub fn retry(mut self, policy: RetryPolicy) -> Self {
190 self.retry = Some(policy);
191 self
192 }
193
194 pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
196 self.attempt_timeout = Some(timeout);
197 self
198 }
199
200 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 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 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 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 Some(at) => {
335 self.sleep_leased(lease, at).await?;
336 None
337 }
338 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 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 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 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
514fn 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}