agent_effects/verification.rs
1//! Verification: asking the remote system whether an effect applied.
2//!
3//! A verification runs after every successful attempt (a postcondition) and
4//! to reconcile an attempt whose outcome is unknown.
5
6use std::future::Future;
7use std::pin::Pin;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::effect::{EffectContext, EffectFailure};
13
14/// What a verification found in the remote system.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum Verification<T> {
17 /// The effect applied; carries the remote state, which becomes the
18 /// effect's output.
19 Confirmed(T),
20 /// No trace of the effect. How far this is trusted depends on the
21 /// [`VerificationMode`].
22 NotApplied,
23 /// The remote system cannot tell right now. The runtime checks again
24 /// later and never treats this as "not applied".
25 Inconclusive,
26 /// The remote state contradicts the effect, e.g. a payment exists for
27 /// the order but with a different amount. Needs an operator.
28 Conflict {
29 /// What contradicts what.
30 details: String,
31 },
32}
33
34/// A boxed verification future.
35pub type VerificationFuture<T> =
36 Pin<Box<dyn Future<Output = Result<Verification<T>, EffectFailure>> + Send>>;
37
38/// The verification attached to an effect, if any.
39///
40/// Implemented by [`NoVerification`] and [`VerifyWith`]; set through
41/// `EffectBuilder::verify`. A failing check (`Err`) counts as
42/// [`Verification::Inconclusive`].
43pub trait Verifier<T>: Send + Sync + 'static {
44 /// Starts a check, or `None` if the effect has no verification.
45 fn check(&self, ctx: EffectContext) -> Option<VerificationFuture<T>>;
46}
47
48/// The effect has no verification.
49#[derive(Clone, Copy, Debug, Default)]
50pub struct NoVerification;
51
52impl<T> Verifier<T> for NoVerification {
53 fn check(&self, _ctx: EffectContext) -> Option<VerificationFuture<T>> {
54 None
55 }
56}
57
58/// Verification by a closure.
59#[derive(Clone, Copy, Debug)]
60pub struct VerifyWith<F>(pub(crate) F);
61
62impl<T, F, Fut> Verifier<T> for VerifyWith<F>
63where
64 F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
65 Fut: Future<Output = Result<Verification<T>, EffectFailure>> + Send + 'static,
66{
67 fn check(&self, ctx: EffectContext) -> Option<VerificationFuture<T>> {
68 Some(Box::pin((self.0)(ctx)))
69 }
70}
71
72/// How far a verification result can be trusted.
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case", tag = "mode")]
75pub enum VerificationMode {
76 /// The effect has no verification.
77 #[default]
78 None,
79 /// The remote system reads its own writes: "not found" means the effect
80 /// did not apply.
81 Authoritative,
82 /// The remote system's lookup lags behind its writes, as many search and
83 /// list APIs do. "Not found" is only trusted once `settle` has passed
84 /// since the attempt ended (its action returned or was found
85 /// interrupted); earlier, it counts as inconclusive.
86 EventuallyConsistent {
87 /// How long a write may take to become visible to the lookup.
88 settle: Duration,
89 },
90}
91
92/// How to read a verification that found no trace of the effect.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum NotFoundReading {
95 /// The effect did not apply; re-executing it is safe.
96 NotApplied,
97 /// Too early to tell; verify again after `wait`.
98 TooEarly {
99 /// Remaining time until the lookup can be trusted.
100 wait: Duration,
101 },
102}
103
104impl VerificationMode {
105 /// How to read "not found" when `elapsed` has passed since the attempt
106 /// in question ended.
107 ///
108 /// [`VerificationMode::None`] has no verification to read; it is
109 /// reported as [`NotFoundReading::TooEarly`] with no wait so callers that
110 /// get here by mistake never re-execute.
111 pub fn read_not_found(self, elapsed: Duration) -> NotFoundReading {
112 match self {
113 Self::Authoritative => NotFoundReading::NotApplied,
114 Self::EventuallyConsistent { settle } if elapsed >= settle => {
115 NotFoundReading::NotApplied
116 }
117 Self::EventuallyConsistent { settle } => NotFoundReading::TooEarly {
118 wait: settle.saturating_sub(elapsed),
119 },
120 Self::None => NotFoundReading::TooEarly {
121 wait: Duration::ZERO,
122 },
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn eventually_consistent_lookups_wait_out_the_settle_delay() {
133 let mode = VerificationMode::EventuallyConsistent {
134 settle: Duration::from_secs(5),
135 };
136 assert_eq!(
137 mode.read_not_found(Duration::from_secs(2)),
138 NotFoundReading::TooEarly {
139 wait: Duration::from_secs(3)
140 }
141 );
142 assert_eq!(
143 mode.read_not_found(Duration::from_secs(5)),
144 NotFoundReading::NotApplied
145 );
146 assert_eq!(
147 VerificationMode::None.read_not_found(Duration::MAX),
148 NotFoundReading::TooEarly {
149 wait: Duration::ZERO
150 }
151 );
152 }
153}