Skip to main content

agent_effects_store/
record.rs

1use std::time::{Duration, SystemTime};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::id::{EffectId, EffectKey, WorkerId};
7use crate::kind::EffectKind;
8use crate::state::{EffectStatus, InvalidTransition, Transition};
9use crate::{EffectEvent, ErrorRecord, Lease, NewEffect, StoreError, TransitionRequest};
10
11/// The durable state of one effect.
12///
13/// The methods are pure: they check a change against the record and apply
14/// it in memory. Stores call them inside a transaction and persist the
15/// result, so the rules are the same for every backend.
16#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
17pub struct EffectRecord {
18    /// Record id.
19    pub id: EffectId,
20    /// Logical identity, unique per store.
21    pub key: EffectKey,
22    /// The effect's kind.
23    pub kind: EffectKind,
24    /// Immutable safety assumptions; absent on records created before the
25    /// contract migration. Absence must never grant execution capabilities.
26    #[serde(default)]
27    pub execution_contract: Option<crate::ExecutionContract>,
28    /// Schema of the immutable stored input. Legacy records use version 1.
29    #[serde(default = "initial_schema_version")]
30    pub input_schema_version: u32,
31    /// Schema of the latest stored output. Legacy records use version 1.
32    #[serde(default = "initial_schema_version")]
33    pub output_schema_version: u32,
34    /// Current status.
35    pub status: EffectStatus,
36    /// The input, redacted for storage.
37    pub input: Option<Value>,
38    /// Hash of the unredacted input.
39    pub input_fingerprint: Option<String>,
40    /// The latest action or verification result.
41    pub output: Option<Value>,
42    /// The latest recorded failure.
43    pub last_error: Option<ErrorRecord>,
44    /// Who asked for the effect.
45    pub created_by: Option<String>,
46    /// Attempts started so far.
47    pub attempt_count: u32,
48    /// An attempt may have applied the effect, and no evidence has shown
49    /// otherwise since. Set when an outcome becomes unknown; cleared only by
50    /// a trusted verification or an operator. While it is set, the effect
51    /// cannot become `Failed` through a failed attempt (see [`Self::apply`]).
52    pub may_have_applied: bool,
53    /// Compensation attempts started so far.
54    pub compensation_attempts: u32,
55    /// The effect was approved, so it is not asked again, even after a
56    /// restart.
57    pub approved: bool,
58    /// When a scheduled retry may start.
59    pub next_attempt_at: Option<SystemTime>,
60    /// When the latest attempt started.
61    pub attempt_started_at: Option<SystemTime>,
62    /// When the latest attempt was last known to be in flight: the first
63    /// transition out of `Executing` after it started. Settle delays count
64    /// from here.
65    pub attempt_ended_at: Option<SystemTime>,
66    /// Current lease holder, if any. The lease may have expired.
67    pub lease_owner: Option<WorkerId>,
68    /// Fencing token; incremented by every lease acquisition.
69    pub lease_epoch: u64,
70    /// When the current lease lapses.
71    pub lease_expires_at: Option<SystemTime>,
72    /// Incremented by every transition. Lease operations do not change it.
73    pub version: u64,
74    /// Creation time.
75    pub created_at: SystemTime,
76    /// Time of the latest change.
77    pub updated_at: SystemTime,
78    /// When the effect committed.
79    pub committed_at: Option<SystemTime>,
80}
81
82impl EffectRecord {
83    /// A fresh `Pending` record.
84    pub fn new(new: NewEffect) -> Self {
85        Self {
86            id: new.id,
87            key: new.key,
88            kind: new.kind,
89            execution_contract: new.execution_contract,
90            input_schema_version: new.input_schema_version,
91            output_schema_version: 1,
92            status: EffectStatus::Pending,
93            input: new.input,
94            input_fingerprint: new.input_fingerprint,
95            output: None,
96            last_error: None,
97            created_by: new.created_by,
98            attempt_count: 0,
99            may_have_applied: false,
100            compensation_attempts: 0,
101            approved: false,
102            next_attempt_at: None,
103            attempt_started_at: None,
104            attempt_ended_at: None,
105            lease_owner: None,
106            lease_epoch: 0,
107            lease_expires_at: None,
108            version: 0,
109            created_at: new.now,
110            updated_at: new.now,
111            committed_at: None,
112        }
113    }
114
115    /// The holder of a lease that is still live at `now`.
116    pub fn live_lease_owner(&self, now: SystemTime) -> Option<&WorkerId> {
117        match (&self.lease_owner, self.lease_expires_at) {
118            (Some(owner), Some(expires_at)) if expires_at > now => Some(owner),
119            _ => None,
120        }
121    }
122
123    /// Takes the lease for `ttl`, provided nobody holds a live one.
124    ///
125    /// # Errors
126    ///
127    /// [`StoreError::LeaseHeld`] if a live lease exists, even one held by
128    /// `owner`: two tasks of one worker must not run the same effect either.
129    pub fn acquire_lease(
130        &mut self,
131        owner: &WorkerId,
132        now: SystemTime,
133        ttl: Duration,
134    ) -> Result<Lease, StoreError> {
135        if let Some(holder) = self.live_lease_owner(now) {
136            return Err(StoreError::LeaseHeld {
137                owner: holder.clone(),
138                expires_at: self.lease_expires_at.unwrap_or(now),
139            });
140        }
141        let expires_at = expiry(now, ttl);
142        self.lease_epoch += 1;
143        self.lease_owner = Some(owner.clone());
144        self.lease_expires_at = Some(expires_at);
145        Ok(Lease {
146            effect_id: self.id,
147            owner: owner.clone(),
148            epoch: self.lease_epoch,
149            expires_at,
150        })
151    }
152
153    /// Extends `lease` to `now + ttl`.
154    ///
155    /// # Errors
156    ///
157    /// [`StoreError::LeaseLost`] if the lease expired or was taken over.
158    /// Renewal is strict: an expired lease cannot be revived, even if nobody
159    /// else took it.
160    pub fn renew_lease(
161        &mut self,
162        lease: &Lease,
163        now: SystemTime,
164        ttl: Duration,
165    ) -> Result<Lease, StoreError> {
166        self.check_lease(lease, now)?;
167        let expires_at = expiry(now, ttl);
168        self.lease_expires_at = Some(expires_at);
169        Ok(Lease {
170            expires_at,
171            ..lease.clone()
172        })
173    }
174
175    /// Clears `lease` if it is still the current one, expired or not.
176    /// Returns whether anything changed.
177    pub fn release_lease(&mut self, lease: &Lease) -> bool {
178        let current = lease.effect_id == self.id
179            && self.lease_epoch == lease.epoch
180            && self.lease_owner.as_ref() == Some(&lease.owner);
181        if current {
182            self.lease_owner = None;
183            self.lease_expires_at = None;
184        }
185        current
186    }
187
188    /// Applies `request` and returns the audit event to persist with it.
189    /// On error the record is unchanged.
190    ///
191    /// Checks, in order: the lease (or the absence of a live one), the
192    /// version, and the transition table. Then it updates the bookkeeping:
193    ///
194    /// - [`Transition::StartAttempt`] increments the attempt count, stamps
195    ///   `attempt_started_at`, and clears `attempt_ended_at`,
196    ///   `next_attempt_at` and `output` (an output stored before belongs to
197    ///   an attempt that did not settle the effect, so it must never be
198    ///   replayed); any transition out of `Executing` stamps
199    ///   `attempt_ended_at`;
200    /// - [`Transition::StartCompensation`] sets `compensation_attempts` to 1
201    ///   and [`Transition::StartCompensationRetry`] increments it; both
202    ///   clear `next_attempt_at`;
203    /// - a retry transition, including
204    ///   [`Transition::ScheduleCompensationRetry`], sets `next_attempt_at`
205    ///   (default `now`);
206    /// - reaching `Committed` stamps `committed_at`;
207    /// - reaching `Unknown` sets `may_have_applied`; evidence that the effect
208    ///   did not apply clears it (`VerificationNotApplied`,
209    ///   `ResolvedNotApplied`, or a `ScheduleRetry` out of `Verifying`, which
210    ///   the runtime issues only after a trusted "not applied");
211    /// - `output` and `error`, when given, replace the stored ones.
212    ///
213    /// `Failed` must mean the effect did not apply. A failed attempt proves
214    /// that only for itself, so `FailedDefinitively` is refused while
215    /// `may_have_applied` is set (except for `Read` effects, which apply
216    /// nothing).
217    ///
218    /// # Errors
219    ///
220    /// [`StoreError::LeaseLost`], [`StoreError::LeaseHeld`],
221    /// [`StoreError::VersionConflict`] or [`StoreError::InvalidTransition`].
222    pub fn apply(&mut self, request: TransitionRequest) -> Result<EffectEvent, StoreError> {
223        let now = request.now;
224        match &request.lease {
225            Some(lease) => self.check_lease(lease, now)?,
226            None => {
227                if let Some(owner) = self.live_lease_owner(now) {
228                    return Err(StoreError::LeaseHeld {
229                        owner: owner.clone(),
230                        expires_at: self.lease_expires_at.unwrap_or(now),
231                    });
232                }
233            }
234        }
235        if request.expected_version != self.version {
236            return Err(StoreError::VersionConflict {
237                expected: request.expected_version,
238                actual: self.version,
239            });
240        }
241        let from = self.status;
242        let to = from.apply(request.transition)?;
243        if request.transition == Transition::FailedDefinitively
244            && self.may_have_applied
245            && self.kind != EffectKind::Read
246        {
247            return Err(InvalidTransition {
248                from,
249                event: request.transition,
250            }
251            .into());
252        }
253
254        match request.transition {
255            Transition::OutcomeUnknown => {
256                if request.next_attempt_at.is_some() {
257                    self.next_attempt_at = self.next_attempt_at.max(request.next_attempt_at);
258                }
259            }
260            Transition::StartAttempt => {
261                self.attempt_count = self.attempt_count.saturating_add(1);
262                self.attempt_started_at = Some(now);
263                self.attempt_ended_at = None;
264                self.next_attempt_at = None;
265                self.output = None;
266            }
267            Transition::ScheduleRetry
268            | Transition::ResolvedRetry
269            | Transition::ScheduleCompensationRetry => {
270                self.next_attempt_at = Some(request.next_attempt_at.unwrap_or(now));
271            }
272            Transition::Approve => self.approved = true,
273            Transition::StartCompensation => {
274                self.compensation_attempts = 1;
275                self.next_attempt_at = None;
276            }
277            Transition::StartCompensationRetry => {
278                self.compensation_attempts = self.compensation_attempts.saturating_add(1);
279                self.next_attempt_at = None;
280            }
281            _ => {}
282        }
283        if from == EffectStatus::Executing {
284            self.attempt_ended_at = Some(now);
285        }
286        if to == EffectStatus::Committed {
287            self.committed_at = Some(now);
288        }
289        if to == EffectStatus::Unknown {
290            self.may_have_applied = true;
291        }
292        let shown_not_applied = matches!(
293            request.transition,
294            Transition::VerificationNotApplied | Transition::ResolvedNotApplied
295        ) || (from == EffectStatus::Verifying
296            && request.transition == Transition::ScheduleRetry);
297        if shown_not_applied {
298            self.may_have_applied = false;
299        }
300        if let Some(output) = request.output {
301            self.output = Some(output);
302            self.output_schema_version = request.output_schema_version.unwrap_or(1);
303        }
304        if let Some(error) = request.error {
305            self.last_error = Some(error);
306        }
307        self.status = to;
308        self.version += 1;
309        self.updated_at = now;
310
311        Ok(EffectEvent {
312            effect_id: self.id,
313            sequence: self.version,
314            transition: request.transition,
315            from,
316            to,
317            attempt: self.attempt_count,
318            actor: request.actor,
319            payload: request.payload,
320            at: now,
321        })
322    }
323
324    fn check_lease(&self, lease: &Lease, now: SystemTime) -> Result<(), StoreError> {
325        let valid = lease.effect_id == self.id
326            && self.lease_epoch == lease.epoch
327            && self.lease_owner.as_ref() == Some(&lease.owner)
328            && self
329                .lease_expires_at
330                .is_some_and(|expires_at| expires_at > now);
331        if valid {
332            Ok(())
333        } else {
334            Err(StoreError::LeaseLost)
335        }
336    }
337}
338
339fn initial_schema_version() -> u32 {
340    1
341}
342
343/// `now + ttl`. An overflowing TTL yields an already-expired lease, which
344/// fails safe: the holder is fenced off immediately.
345fn expiry(now: SystemTime, ttl: Duration) -> SystemTime {
346    now.checked_add(ttl).unwrap_or(now)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::id::{EffectName, LogicalKey};
353
354    const TTL: Duration = Duration::from_secs(30);
355
356    fn t(secs: u64) -> SystemTime {
357        SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + secs)
358    }
359
360    fn record() -> EffectRecord {
361        let key = EffectKey::new(
362            EffectName::new("payment.charge").unwrap(),
363            LogicalKey::new("order_1").unwrap(),
364        );
365        EffectRecord::new(NewEffect::new(key, EffectKind::IrreversibleWrite, t(0)))
366    }
367
368    fn worker(name: &str) -> WorkerId {
369        WorkerId::new(name)
370    }
371
372    #[test]
373    fn failed_apply_leaves_the_record_unchanged() {
374        let mut rec = record();
375        let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
376        let before = rec.clone();
377
378        let mut bad = TransitionRequest::new(&rec, Some(&lease), Transition::Succeeded, t(1));
379        bad.output = Some(Value::from(1));
380        assert!(matches!(
381            rec.apply(bad),
382            Err(StoreError::InvalidTransition(_))
383        ));
384        assert_eq!(rec, before);
385    }
386
387    #[test]
388    fn stale_epoch_is_fenced_off_after_takeover() {
389        let mut rec = record();
390        let old = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
391        let new = rec.acquire_lease(&worker("b"), t(30), TTL).unwrap();
392        assert_eq!(new.epoch, old.epoch + 1);
393
394        let request = TransitionRequest::new(&rec, Some(&old), Transition::StartAttempt, t(31));
395        assert!(matches!(rec.apply(request), Err(StoreError::LeaseLost)));
396        assert!(
397            !rec.release_lease(&old),
398            "stale release must not clear b's lease"
399        );
400        assert_eq!(rec.live_lease_owner(t(31)), Some(&worker("b")));
401    }
402
403    #[test]
404    fn same_owner_cannot_double_acquire() {
405        let mut rec = record();
406        rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
407        assert!(matches!(
408            rec.acquire_lease(&worker("a"), t(1), TTL),
409            Err(StoreError::LeaseHeld { .. })
410        ));
411    }
412
413    #[test]
414    fn overflowing_ttl_fails_safe() {
415        let mut rec = record();
416        let lease = rec
417            .acquire_lease(&worker("a"), t(0), Duration::MAX)
418            .unwrap();
419        assert_eq!(lease.expires_at, t(0));
420        assert_eq!(rec.live_lease_owner(t(0)), None);
421    }
422
423    #[test]
424    fn a_failed_attempt_cannot_fail_an_effect_that_may_have_applied() {
425        let mut rec = record();
426        let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
427        for transition in [
428            Transition::StartAttempt,
429            Transition::OutcomeUnknown,
430            Transition::ScheduleRetry,
431            Transition::StartAttempt,
432        ] {
433            rec.apply(TransitionRequest::new(&rec, Some(&lease), transition, t(1)))
434                .unwrap();
435        }
436        assert!(
437            rec.may_have_applied,
438            "the unknown outcome is remembered across retries"
439        );
440        let before = rec.clone();
441        let fail = TransitionRequest::new(&rec, Some(&lease), Transition::FailedDefinitively, t(2));
442        assert!(matches!(
443            rec.apply(fail),
444            Err(StoreError::InvalidTransition(_))
445        ));
446        assert_eq!(rec, before);
447
448        // A trusted verification clears it; then a failure is a failure.
449        for transition in [
450            Transition::StartVerification,
451            Transition::ScheduleRetry,
452            Transition::StartAttempt,
453        ] {
454            rec.apply(TransitionRequest::new(&rec, Some(&lease), transition, t(3)))
455                .unwrap();
456        }
457        assert!(!rec.may_have_applied);
458        rec.apply(TransitionRequest::new(
459            &rec,
460            Some(&lease),
461            Transition::FailedDefinitively,
462            t(4),
463        ))
464        .unwrap();
465        assert_eq!(rec.status, EffectStatus::Failed);
466    }
467
468    #[test]
469    fn bookkeeping_follows_transitions() {
470        let mut rec = record();
471        let lease = rec.acquire_lease(&worker("a"), t(0), TTL).unwrap();
472
473        let event = rec
474            .apply(TransitionRequest::new(
475                &rec,
476                Some(&lease),
477                Transition::StartAttempt,
478                t(1),
479            ))
480            .unwrap();
481        assert_eq!((event.sequence, event.attempt), (1, 1));
482        assert_eq!(rec.attempt_started_at, Some(t(1)));
483
484        let mut retry = TransitionRequest::new(&rec, Some(&lease), Transition::ScheduleRetry, t(2));
485        retry.next_attempt_at = Some(t(10));
486        retry.error = Some(ErrorRecord {
487            class: Some(crate::FailureClass::Transient),
488            message: "connection refused".into(),
489        });
490        rec.apply(retry).unwrap();
491        assert_eq!(rec.next_attempt_at, Some(t(10)));
492        assert_eq!(rec.attempt_ended_at, Some(t(2)));
493        assert!(rec.last_error.is_some());
494
495        rec.apply(TransitionRequest::new(
496            &rec,
497            Some(&lease),
498            Transition::StartAttempt,
499            t(10),
500        ))
501        .unwrap();
502        assert_eq!(rec.attempt_count, 2);
503        assert_eq!(rec.next_attempt_at, None);
504        assert_eq!(rec.attempt_ended_at, None);
505
506        let mut done = TransitionRequest::new(&rec, Some(&lease), Transition::Succeeded, t(11));
507        done.output = Some(serde_json::json!({ "payment": "pi_1" }));
508        let event = rec.apply(done).unwrap();
509        assert_eq!(event.to, EffectStatus::Committed);
510        assert_eq!(rec.committed_at, Some(t(11)));
511        assert_eq!(rec.version, 4);
512    }
513}