agent_effects/recovery.rs
1//! Recovery and the operator API.
2//!
3//! - [`Runtime::recover`] marks attempts whose worker died as unknown, then
4//! finishes every unsettled effect that has a registered
5//! [handler](crate::handler) from its stored input. Closure effects are
6//! not durable, so they wait for a caller to re-run them;
7//! - [`Runtime::pending`] lists the effects waiting for a caller or an
8//! operator;
9//! - [`Runtime::resolve`] records an operator's decision.
10
11use std::time::Duration;
12
13use serde::Serialize;
14use serde_json::{Value, json};
15use tokio::time::MissedTickBehavior;
16use tracing::{info, warn};
17
18use crate::error::RuntimeError;
19use crate::id::EffectId;
20use crate::runtime::Runtime;
21use crate::state::{EffectStatus, Transition};
22use crate::store::{
23 EffectRecord, EffectStore, ErrorRecord, ListQuery, StoreError, TransitionRequest,
24};
25
26/// Page size for recovery scans.
27const PAGE: usize = 100;
28
29/// What one [`Runtime::recover`] pass did.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct RecoveryReport {
33 /// Effects whose worker's lease expired mid-attempt or mid-verification,
34 /// now marked unknown.
35 pub marked_unknown: Vec<EffectId>,
36 /// Effects that matched the scan but were taken by another caller or
37 /// worker before this pass reached them.
38 pub skipped: Vec<EffectId>,
39 /// Effects with a registered handler that this pass moved forward, and
40 /// the status each ended in. `Committed`, `Failed` and `Rejected` are
41 /// settled; `NeedsIntervention` waits for an operator; anything else is
42 /// still in progress or still unknown.
43 pub resumed: Vec<(EffectId, EffectStatus)>,
44 /// Unsettled effects with no registered handler (closure effects). They
45 /// need a caller to run them again with the same key.
46 pub unhandled: Vec<EffectId>,
47 /// Effects whose handler could not be run, with the reason, e.g. a
48 /// stored input that no longer deserializes.
49 pub resume_errors: Vec<(EffectId, String)>,
50}
51
52/// An operator's decision about an effect the runtime could not resolve.
53#[derive(Clone, Debug, PartialEq)]
54pub enum Resolution {
55 /// The effect applied. `output` becomes its result for every later call,
56 /// so it should deserialize into the type those calls expect.
57 Applied {
58 /// The effect's output, as JSON.
59 output: Option<Value>,
60 },
61 /// The effect did not apply. It ends `Failed`, with the operator's note
62 /// as its error.
63 NotApplied,
64 /// Running the effect again is safe. The next call runs it, even if its
65 /// retry budget is spent, since this is an explicit decision. On a
66 /// failed compensation: try the compensation again.
67 Retry,
68 /// For a failed compensation: the operator undid the effect by hand. It
69 /// ends `Compensated`.
70 Compensated,
71}
72
73impl Resolution {
74 /// [`Resolution::Applied`] with `output` serialized.
75 ///
76 /// # Errors
77 ///
78 /// If `output` cannot be serialized to JSON.
79 pub fn applied<T: Serialize + ?Sized>(output: &T) -> Result<Self, serde_json::Error> {
80 Ok(Self::Applied {
81 output: Some(serde_json::to_value(output)?),
82 })
83 }
84}
85
86impl<S: EffectStore> Runtime<S> {
87 /// One recovery pass, in two steps.
88 ///
89 /// 1. Every effect whose worker's lease expired mid-attempt or
90 /// mid-verification becomes `Unknown`, never `Failed`: it may have
91 /// changed the outside world.
92 /// 2. Every unsettled effect nobody holds (see [`Self::pending`]) whose
93 /// name has a registered [handler](crate::handler) is finished from its
94 /// stored input, exactly as if its caller had called again: verified,
95 /// re-run if that is safe, or escalated. This includes effects left
96 /// `Pending` by a crash. Effects waiting for an operator, and retries
97 /// scheduled for later, are left alone. Unsettled closure effects are
98 /// reported as `unhandled`: only a caller can re-run them.
99 ///
100 /// Effects are resumed one at a time, so a pass lasts as long as their
101 /// retries and verifications take. Safe to run from several workers at
102 /// once: every change happens under a lease.
103 ///
104 /// # Errors
105 ///
106 /// [`RuntimeError::Store`] if the store fails. Progress made before the
107 /// failure is kept. A handler that cannot run is reported in
108 /// `resume_errors` instead.
109 pub async fn recover(&self) -> Result<RecoveryReport, RuntimeError> {
110 let mut report = RecoveryReport::default();
111 self.mark_abandoned(&mut report).await?;
112 self.resume_pending(&mut report).await?;
113 Ok(report)
114 }
115
116 async fn mark_abandoned(&self, report: &mut RecoveryReport) -> Result<(), RuntimeError> {
117 let mut after = None;
118 loop {
119 let mut query = ListQuery::expired_leases(self.now()).limit(PAGE);
120 if let Some(id) = after {
121 query = query.after(id);
122 }
123 let page = self.store().list(query).await?;
124 let full = page.len() == PAGE;
125 after = page.last().map(|record| record.id);
126 for record in page {
127 if self.mark_unknown(record.id).await? {
128 report.marked_unknown.push(record.id);
129 } else {
130 report.skipped.push(record.id);
131 }
132 }
133 if !full {
134 return Ok(());
135 }
136 }
137 }
138
139 async fn resume_pending(&self, report: &mut RecoveryReport) -> Result<(), RuntimeError> {
140 let mut after = None;
141 loop {
142 let page = self.pending(after, PAGE).await?;
143 let full = page.len() == PAGE;
144 after = page.last().map(|record| record.id);
145 for record in page {
146 let id = record.id;
147 let not_due = record.next_attempt_at.is_some_and(|at| at > self.now());
148 let operator = matches!(
149 record.status,
150 EffectStatus::NeedsIntervention
151 | EffectStatus::CompensationFailed
152 | EffectStatus::AwaitingApproval
153 );
154 if operator || not_due {
155 continue;
156 }
157 let Some(resume) = self.resumer(record.key.name.as_str()) else {
158 report.unhandled.push(id);
159 continue;
160 };
161 match resume(self.clone(), record).await {
162 Ok(status) => {
163 info!(effect.id = %id, %status, "recovery resumed effect");
164 report.resumed.push((id, status));
165 }
166 Err(e) => {
167 warn!(effect.id = %id, error = %e, "recovery could not resume effect");
168 report.resume_errors.push((id, e.to_string()));
169 }
170 }
171 }
172 if !full {
173 return Ok(());
174 }
175 }
176 }
177
178 /// Runs [`Self::recover`] every `interval`, forever, followed by
179 /// [`Self::prune`] when the runtime has a
180 /// [retention policy](crate::RetentionPolicy). Spawn it:
181 ///
182 /// ```ignore
183 /// tokio::spawn({
184 /// let runtime = runtime.clone();
185 /// async move { runtime.run_recovery(Duration::from_secs(30)).await }
186 /// });
187 /// ```
188 ///
189 /// A failed pass is logged and retried at the next tick.
190 pub async fn run_recovery(&self, interval: Duration) {
191 let mut ticker = tokio::time::interval(interval);
192 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
193 loop {
194 ticker.tick().await;
195 match self.recover().await {
196 Ok(report) if !report.marked_unknown.is_empty() || !report.resumed.is_empty() => {
197 info!(
198 marked_unknown = report.marked_unknown.len(),
199 resumed = report.resumed.len(),
200 unhandled = report.unhandled.len(),
201 "recovery pass"
202 );
203 }
204 Ok(_) => {}
205 Err(e) => warn!(error = %e, "recovery pass failed"),
206 }
207 match self.prune().await {
208 Ok(report) if report.total() > 0 => {
209 info!(pruned = report.total(), "pruned settled effects");
210 }
211 Ok(_) => {}
212 Err(e) => warn!(error = %e, "pruning failed"),
213 }
214 }
215 }
216
217 /// Effects waiting for a caller to re-run them or an operator to decide,
218 /// ordered by creation, `limit` at a time after `after`.
219 ///
220 /// These are the effects that are unsettled with nobody working on
221 /// them: `Pending` (for example a worker died during a backoff wait),
222 /// `Executing` or `Verifying` with an expired lease (not yet recovered),
223 /// `Unknown`, `NeedsIntervention`, `AwaitingApproval`, an interrupted
224 /// `Compensating`, and `CompensationFailed`.
225 ///
226 /// # Errors
227 ///
228 /// [`RuntimeError::Store`] if the store fails.
229 pub async fn pending(
230 &self,
231 after: Option<EffectId>,
232 limit: usize,
233 ) -> Result<Vec<EffectRecord>, RuntimeError> {
234 let mut query = ListQuery::statuses([
235 EffectStatus::Pending,
236 EffectStatus::AwaitingApproval,
237 EffectStatus::Executing,
238 EffectStatus::Verifying,
239 EffectStatus::Unknown,
240 EffectStatus::NeedsIntervention,
241 EffectStatus::Compensating,
242 EffectStatus::CompensationFailed,
243 ])
244 .limit(limit);
245 query.lease_expired_at = Some(self.now());
246 query.after = after;
247 Ok(self.store().list(query).await?)
248 }
249
250 /// Records an operator's decision about an effect that is `Unknown` or
251 /// `NeedsIntervention`. `actor` identifies the operator (e.g.
252 /// `operator:alice`); `note` says why, for the audit trail.
253 ///
254 /// # Errors
255 ///
256 /// [`RuntimeError::Store`] wrapping:
257 ///
258 /// - [`StoreError::NotFound`] for an unknown id;
259 /// - [`StoreError::InvalidTransition`] if the effect is not unresolved,
260 /// for example already committed;
261 /// - [`StoreError::LeaseHeld`] while a caller or worker is working on it;
262 /// - [`StoreError::VersionConflict`] if it changed during the call.
263 pub async fn resolve(
264 &self,
265 id: EffectId,
266 resolution: Resolution,
267 actor: impl Into<String>,
268 note: impl Into<String>,
269 ) -> Result<EffectRecord, RuntimeError> {
270 self.resolve_with_output_schema(id, resolution, 1, actor, note)
271 .await
272 }
273
274 /// Like [`Self::resolve`], recording `output_schema_version` when an
275 /// operator supplies an applied result. The caller must authorize the
276 /// operator and validate the output; actor text is an audit label only.
277 ///
278 /// # Errors
279 ///
280 /// As for [`Self::resolve`].
281 pub async fn resolve_with_output_schema(
282 &self,
283 id: EffectId,
284 resolution: Resolution,
285 output_schema_version: u32,
286 actor: impl Into<String>,
287 note: impl Into<String>,
288 ) -> Result<EffectRecord, RuntimeError> {
289 let record = self
290 .store()
291 .get(id)
292 .await?
293 .ok_or(StoreError::NotFound(id))?;
294 let note = note.into();
295 let transition = match resolution {
296 Resolution::Applied { .. } => Transition::ResolvedApplied,
297 Resolution::NotApplied => Transition::ResolvedNotApplied,
298 Resolution::Retry => Transition::ResolvedRetry,
299 Resolution::Compensated => Transition::ResolvedCompensated,
300 };
301 let mut request = TransitionRequest::new(&record, None, transition, self.now());
302 request.actor = Some(actor.into());
303 request.payload = Some(json!({ "note": note }));
304 match resolution {
305 Resolution::Applied { output } => {
306 request.output = output;
307 request.output_schema_version = Some(output_schema_version);
308 }
309 Resolution::NotApplied => {
310 request.error = Some(ErrorRecord {
311 class: None,
312 message: note,
313 });
314 }
315 Resolution::Retry | Resolution::Compensated => {}
316 }
317 let record = self.commit_transition(&record, request).await?;
318 info!(effect.id = %id, %transition, "effect resolved by an operator");
319 Ok(record)
320 }
321
322 /// Approves an effect waiting in `AwaitingApproval`. It becomes
323 /// `Pending`: a caller's next call runs it, and so does recovery for a
324 /// registered handler. `actor` is recorded as the approver.
325 ///
326 /// # Errors
327 ///
328 /// As for [`Self::resolve`]; `InvalidTransition` if it is not awaiting
329 /// approval.
330 pub async fn approve(
331 &self,
332 id: EffectId,
333 actor: impl Into<String>,
334 note: impl Into<String>,
335 ) -> Result<EffectRecord, RuntimeError> {
336 self.decide(id, Transition::Approve, actor.into(), note.into())
337 .await
338 }
339
340 /// Denies an effect waiting in `AwaitingApproval`. It ends `Rejected`,
341 /// with `reason` as its error.
342 ///
343 /// # Errors
344 ///
345 /// As for [`Self::approve`].
346 pub async fn deny(
347 &self,
348 id: EffectId,
349 actor: impl Into<String>,
350 reason: impl Into<String>,
351 ) -> Result<EffectRecord, RuntimeError> {
352 self.decide(id, Transition::Deny, actor.into(), reason.into())
353 .await
354 }
355
356 async fn decide(
357 &self,
358 id: EffectId,
359 transition: Transition,
360 actor: String,
361 note: String,
362 ) -> Result<EffectRecord, RuntimeError> {
363 let record = self
364 .store()
365 .get(id)
366 .await?
367 .ok_or(StoreError::NotFound(id))?;
368 let mut request = TransitionRequest::new(&record, None, transition, self.now());
369 request.actor = Some(actor);
370 if transition == Transition::Deny {
371 request.error = Some(ErrorRecord {
372 class: None,
373 message: note.clone(),
374 });
375 }
376 request.payload = Some(json!({ "note": note }));
377 let record = self.commit_transition(&record, request).await?;
378 info!(effect.id = %id, %transition, "approval decided by an operator");
379 Ok(record)
380 }
381
382 /// Moves one effect with an expired lease to `Unknown`. Returns `false`
383 /// if someone else got to it first.
384 async fn mark_unknown(&self, id: EffectId) -> Result<bool, RuntimeError> {
385 let store = self.store();
386 let lease = match store
387 .acquire_lease(id, self.worker_id(), self.now(), self.lease_ttl())
388 .await
389 {
390 Ok(lease) => lease,
391 Err(StoreError::LeaseHeld { .. }) => return Ok(false),
392 Err(e) => return Err(e.into()),
393 };
394 let marked = async {
395 let record = store.get(id).await?.ok_or(StoreError::NotFound(id))?;
396 if !matches!(
397 record.status,
398 EffectStatus::Executing | EffectStatus::Verifying
399 ) {
400 return Ok(false);
401 }
402 let mut request =
403 TransitionRequest::new(&record, Some(&lease), Transition::LeaseExpired, self.now());
404 request.actor = Some(format!("recovery:{}", self.worker_id()));
405 self.commit_transition(&record, request).await?;
406 info!(
407 effect.id = %id,
408 effect.name = %record.key.name,
409 "worker lost mid-effect; outcome is now unknown"
410 );
411 Ok::<_, RuntimeError>(true)
412 }
413 .await;
414 if let Err(e) = store.release_lease(&lease).await {
415 warn!(error = %e, "could not release recovery lease; it will expire");
416 }
417 marked
418 }
419}