1use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum EffectStatus {
16 Pending,
19 AwaitingApproval,
21 Executing,
23 Unknown,
25 Verifying,
27 Committed,
29 Failed,
31 Rejected,
34 NeedsIntervention,
36 Compensating,
39 Compensated,
41 CompensationFailed,
44}
45
46pub const ALL_STATUSES: [EffectStatus; 12] = [
48 EffectStatus::Pending,
49 EffectStatus::AwaitingApproval,
50 EffectStatus::Executing,
51 EffectStatus::Unknown,
52 EffectStatus::Verifying,
53 EffectStatus::Committed,
54 EffectStatus::Failed,
55 EffectStatus::Rejected,
56 EffectStatus::NeedsIntervention,
57 EffectStatus::Compensating,
58 EffectStatus::Compensated,
59 EffectStatus::CompensationFailed,
60];
61
62impl EffectStatus {
63 pub const fn is_terminal(self) -> bool {
67 matches!(self, Self::Failed | Self::Rejected | Self::Compensated)
68 }
69
70 pub const fn is_settled(self) -> bool {
78 matches!(
79 self,
80 Self::Committed | Self::Failed | Self::Rejected | Self::Compensated
81 )
82 }
83
84 pub const fn is_in_doubt(self) -> bool {
87 matches!(
88 self,
89 Self::Executing | Self::Unknown | Self::Verifying | Self::NeedsIntervention
90 )
91 }
92
93 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::Pending => "pending",
97 Self::AwaitingApproval => "awaiting_approval",
98 Self::Executing => "executing",
99 Self::Unknown => "unknown",
100 Self::Verifying => "verifying",
101 Self::Committed => "committed",
102 Self::Failed => "failed",
103 Self::Rejected => "rejected",
104 Self::NeedsIntervention => "needs_intervention",
105 Self::Compensating => "compensating",
106 Self::Compensated => "compensated",
107 Self::CompensationFailed => "compensation_failed",
108 }
109 }
110
111 pub fn parse(s: &str) -> Option<Self> {
113 ALL_STATUSES.into_iter().find(|status| status.as_str() == s)
114 }
115
116 pub const fn apply(self, event: Transition) -> Result<Self, InvalidTransition> {
122 use EffectStatus as S;
123 use Transition as T;
124
125 let next = match (self, event) {
126 (S::Pending, T::RequestApproval) => S::AwaitingApproval,
127 (S::AwaitingApproval, T::Approve) => S::Pending,
128 (S::AwaitingApproval, T::Deny) | (S::Pending, T::PreconditionRejected) => S::Rejected,
129
130 (S::Pending, T::StartAttempt) => S::Executing,
131
132 (S::Executing, T::Succeeded)
133 | (S::Verifying, T::VerificationConfirmed)
134 | (S::Unknown | S::NeedsIntervention, T::ResolvedApplied) => S::Committed,
135
136 (S::Executing, T::FailedDefinitively)
137 | (S::Verifying, T::VerificationNotApplied)
138 | (S::Unknown | S::NeedsIntervention, T::ResolvedNotApplied) => S::Failed,
139
140 (S::Executing | S::Verifying | S::Unknown, T::ScheduleRetry)
141 | (S::Unknown | S::NeedsIntervention, T::ResolvedRetry) => S::Pending,
142
143 (S::Executing | S::Verifying, T::OutcomeUnknown | T::LeaseExpired) => S::Unknown,
144
145 (S::Executing | S::Unknown, T::StartVerification) => S::Verifying,
146
147 (S::Unknown, T::Escalate) | (S::Verifying, T::VerificationConflict) => {
148 S::NeedsIntervention
149 }
150
151 (S::Committed, T::StartCompensation)
152 | (S::Compensating, T::ScheduleCompensationRetry | T::StartCompensationRetry)
153 | (S::CompensationFailed, T::ResolvedRetry) => S::Compensating,
154 (S::Compensating, T::CompensationSucceeded)
155 | (S::CompensationFailed, T::ResolvedCompensated) => S::Compensated,
156 (S::Compensating, T::CompensationFailed) => S::CompensationFailed,
157
158 _ => {
159 return Err(InvalidTransition { from: self, event });
160 }
161 };
162 Ok(next)
163 }
164}
165
166impl fmt::Display for EffectStatus {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 f.write_str(self.as_str())
169 }
170}
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum Transition {
179 RequestApproval,
181 Approve,
183 Deny,
185 PreconditionRejected,
187 StartAttempt,
189 Succeeded,
191 FailedDefinitively,
194 ScheduleRetry,
197 OutcomeUnknown,
199 LeaseExpired,
201 StartVerification,
203 VerificationConfirmed,
205 VerificationNotApplied,
208 VerificationConflict,
210 Escalate,
212 ResolvedApplied,
214 ResolvedNotApplied,
216 ResolvedRetry,
219 StartCompensation,
222 ScheduleCompensationRetry,
224 StartCompensationRetry,
227 CompensationSucceeded,
229 CompensationFailed,
231 ResolvedCompensated,
233}
234
235pub const ALL_TRANSITIONS: [Transition; 24] = [
237 Transition::RequestApproval,
238 Transition::Approve,
239 Transition::Deny,
240 Transition::PreconditionRejected,
241 Transition::StartAttempt,
242 Transition::Succeeded,
243 Transition::FailedDefinitively,
244 Transition::ScheduleRetry,
245 Transition::OutcomeUnknown,
246 Transition::LeaseExpired,
247 Transition::StartVerification,
248 Transition::VerificationConfirmed,
249 Transition::VerificationNotApplied,
250 Transition::VerificationConflict,
251 Transition::Escalate,
252 Transition::ResolvedApplied,
253 Transition::ResolvedNotApplied,
254 Transition::ResolvedRetry,
255 Transition::StartCompensation,
256 Transition::ScheduleCompensationRetry,
257 Transition::StartCompensationRetry,
258 Transition::CompensationSucceeded,
259 Transition::CompensationFailed,
260 Transition::ResolvedCompensated,
261];
262
263impl Transition {
264 pub fn parse(s: &str) -> Option<Self> {
266 ALL_TRANSITIONS.into_iter().find(|t| t.as_str() == s)
267 }
268
269 pub const fn as_str(self) -> &'static str {
271 match self {
272 Self::RequestApproval => "effect.approval_requested",
273 Self::Approve => "effect.approved",
274 Self::Deny => "effect.denied",
275 Self::PreconditionRejected => "effect.precondition_rejected",
276 Self::StartAttempt => "effect.attempt_started",
277 Self::Succeeded => "effect.succeeded",
278 Self::FailedDefinitively => "effect.failed",
279 Self::ScheduleRetry => "effect.retry_scheduled",
280 Self::OutcomeUnknown => "effect.outcome_unknown",
281 Self::LeaseExpired => "effect.lease_expired",
282 Self::StartVerification => "effect.verification_started",
283 Self::VerificationConfirmed => "effect.verification_confirmed",
284 Self::VerificationNotApplied => "effect.verification_not_applied",
285 Self::VerificationConflict => "effect.verification_conflict",
286 Self::Escalate => "effect.escalated",
287 Self::ResolvedApplied => "effect.resolved_applied",
288 Self::ResolvedNotApplied => "effect.resolved_not_applied",
289 Self::ResolvedRetry => "effect.resolved_retry",
290 Self::StartCompensation => "effect.compensation_started",
291 Self::ScheduleCompensationRetry => "effect.compensation_retry_scheduled",
292 Self::StartCompensationRetry => "effect.compensation_retry_started",
293 Self::CompensationSucceeded => "effect.compensated",
294 Self::CompensationFailed => "effect.compensation_failed",
295 Self::ResolvedCompensated => "effect.resolved_compensated",
296 }
297 }
298}
299
300impl fmt::Display for Transition {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302 f.write_str(self.as_str())
303 }
304}
305
306#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
308#[error("transition {event} is not allowed from status {from}")]
309pub struct InvalidTransition {
310 pub from: EffectStatus,
312 pub event: Transition,
314}
315
316#[cfg(test)]
317mod tests {
318 use std::collections::{HashSet, VecDeque};
319
320 use proptest::prelude::*;
321
322 use super::*;
323
324 fn successors(status: EffectStatus) -> impl Iterator<Item = EffectStatus> {
325 ALL_TRANSITIONS
326 .into_iter()
327 .filter_map(move |t| status.apply(t).ok())
328 }
329
330 fn reachable_from(start: EffectStatus) -> HashSet<EffectStatus> {
331 let mut seen = HashSet::from([start]);
332 let mut queue = VecDeque::from([start]);
333 while let Some(status) = queue.pop_front() {
334 for next in successors(status) {
335 if seen.insert(next) {
336 queue.push_back(next);
337 }
338 }
339 }
340 seen
341 }
342
343 fn sources_of(target: EffectStatus) -> HashSet<(EffectStatus, Transition)> {
344 ALL_STATUSES
345 .into_iter()
346 .flat_map(|s| ALL_TRANSITIONS.into_iter().map(move |t| (s, t)))
347 .filter(|(s, t)| s.apply(*t) == Ok(target))
348 .collect()
349 }
350
351 #[test]
352 fn compensation_starts_only_from_committed() {
353 let sources = sources_of(EffectStatus::Compensating);
354 let starts: HashSet<_> = sources
355 .iter()
356 .filter(|(from, _)| *from != EffectStatus::Compensating)
357 .copied()
358 .collect();
359 assert_eq!(
360 starts,
361 HashSet::from([
362 (EffectStatus::Committed, Transition::StartCompensation),
363 (EffectStatus::CompensationFailed, Transition::ResolvedRetry),
364 ])
365 );
366 let exits_of_committed: HashSet<_> = ALL_TRANSITIONS
367 .into_iter()
368 .filter(|t| EffectStatus::Committed.apply(*t).is_ok())
369 .collect();
370 assert_eq!(
371 exits_of_committed,
372 HashSet::from([Transition::StartCompensation])
373 );
374 }
375
376 #[test]
377 fn compensated_requires_evidence() {
378 let events: HashSet<Transition> = sources_of(EffectStatus::Compensated)
379 .into_iter()
380 .map(|(_, t)| t)
381 .collect();
382 assert_eq!(
383 events,
384 HashSet::from([
385 Transition::CompensationSucceeded,
386 Transition::ResolvedCompensated
387 ])
388 );
389 }
390
391 #[test]
392 fn terminal_states_have_no_exits() {
393 for status in ALL_STATUSES.into_iter().filter(|s| s.is_terminal()) {
394 assert_eq!(successors(status).count(), 0, "{status} has an exit");
395 }
396 }
397
398 #[test]
399 fn every_state_is_reachable_from_pending() {
400 let reachable = reachable_from(EffectStatus::Pending);
401 for status in ALL_STATUSES {
402 assert!(reachable.contains(&status), "{status} is unreachable");
403 }
404 }
405
406 #[test]
407 fn every_state_can_settle() {
408 for status in ALL_STATUSES {
409 assert!(
410 reachable_from(status).iter().any(|s| s.is_terminal()),
411 "{status} can never reach a terminal state"
412 );
413 }
414 }
415
416 #[test]
417 fn only_pending_starts_an_attempt() {
418 let sources = sources_of(EffectStatus::Executing);
419 assert_eq!(
420 sources,
421 HashSet::from([(EffectStatus::Pending, Transition::StartAttempt)])
422 );
423 }
424
425 #[test]
426 fn in_doubt_states_never_restart_without_a_retry_decision() {
427 for status in ALL_STATUSES.into_iter().filter(|s| s.is_in_doubt()) {
430 for t in ALL_TRANSITIONS {
431 if status.apply(t) == Ok(EffectStatus::Pending) {
432 assert!(
433 matches!(t, Transition::ScheduleRetry | Transition::ResolvedRetry),
434 "{status} --{t}--> pending"
435 );
436 }
437 }
438 }
439 }
440
441 #[test]
442 fn committed_requires_evidence() {
443 let events: HashSet<Transition> = sources_of(EffectStatus::Committed)
444 .into_iter()
445 .map(|(_, t)| t)
446 .collect();
447 assert_eq!(
448 events,
449 HashSet::from([
450 Transition::Succeeded,
451 Transition::VerificationConfirmed,
452 Transition::ResolvedApplied,
453 ])
454 );
455 }
456
457 #[test]
458 fn a_lost_lease_never_counts_as_failure() {
459 for status in ALL_STATUSES {
460 if let Ok(next) = status.apply(Transition::LeaseExpired) {
461 assert_eq!(next, EffectStatus::Unknown);
462 }
463 }
464 }
465
466 #[test]
467 fn storage_representation_round_trips_and_matches_serde() {
468 for status in ALL_STATUSES {
469 assert_eq!(EffectStatus::parse(status.as_str()), Some(status));
470 assert_eq!(
471 serde_json::to_string(&status).unwrap(),
472 format!("\"{}\"", status.as_str())
473 );
474 }
475 let names: HashSet<_> = ALL_TRANSITIONS.iter().map(|t| t.as_str()).collect();
476 assert_eq!(names.len(), ALL_TRANSITIONS.len());
477 for transition in ALL_TRANSITIONS {
478 assert_eq!(Transition::parse(transition.as_str()), Some(transition));
479 }
480 }
481
482 fn transition() -> impl Strategy<Value = Transition> {
483 proptest::sample::select(ALL_TRANSITIONS.to_vec())
484 }
485
486 proptest! {
487 #[test]
488 fn random_histories_respect_invariants(events in prop::collection::vec(transition(), 0..64)) {
489 let mut status = EffectStatus::Pending;
490 for event in events {
491 match status.apply(event) {
492 Ok(next) => {
493 prop_assert!(!status.is_terminal());
494 if status == EffectStatus::Committed {
495 prop_assert_ne!(next, EffectStatus::Executing);
496 }
497 status = next;
498 }
499 Err(err) => {
500 prop_assert_eq!(err.from, status);
501 }
502 }
503 }
504 }
505 }
506}