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 pub(crate) input_schema_version: u32,
72 pub(crate) output_schema_version: u32,
73}
74
75impl CompensationContext {
76 pub fn input_schema_version(&self) -> u32 {
78 self.input_schema_version
79 }
80
81 pub fn output_schema_version(&self) -> u32 {
83 self.output_schema_version
84 }
85
86 pub fn effect_id(&self) -> EffectId {
88 self.id
89 }
90
91 pub fn key(&self) -> &EffectKey {
93 &self.key
94 }
95
96 pub fn idempotency_key(&self) -> IdempotencyKey {
99 self.key.compensation_idempotency_key()
100 }
101
102 pub fn attempt(&self) -> u32 {
104 self.attempt
105 }
106
107 pub fn reason(&self) -> Option<&str> {
109 self.reason.as_deref()
110 }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum CompensationOutcome {
117 Compensated,
119 Failed(ErrorRecord),
123 InProgress {
125 id: EffectId,
127 },
128 NotCommitted {
132 id: EffectId,
134 status: EffectStatus,
136 },
137}
138
139type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
140
141pub(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
152pub(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#[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 pub fn output_schema(mut self, schema: crate::PayloadSchema) -> Self {
191 self.output_schema = schema;
192 self
193 }
194
195 pub fn reason(mut self, reason: impl Into<String>) -> Self {
197 self.reason = Some(reason.into());
198 self
199 }
200
201 pub fn actor(mut self, actor: impl Into<String>) -> Self {
203 self.actor = Some(actor.into());
204 self
205 }
206
207 pub fn retry(mut self, policy: RetryPolicy) -> Self {
211 self.retry = Some(policy);
212 self
213 }
214
215 pub fn attempt_timeout(mut self, timeout: Duration) -> Self {
217 self.attempt_timeout = Some(timeout);
218 self
219 }
220
221 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 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 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 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 Some(at) => {
366 self.sleep_leased(lease, at).await?;
367 None
368 }
369 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 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 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 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
547fn 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}