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 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 Some(at) => {
345 self.sleep_leased(lease, at).await?;
346 None
347 }
348 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 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 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
480fn 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}