use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime};
pub trait LifecycleState:
Copy + Clone + PartialEq + Eq + fmt::Debug + Send + Sync + 'static
{
fn is_transient(&self) -> bool;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleView<S> {
pub state: S,
pub reason: Option<&'static str>,
}
#[derive(Debug)]
pub struct LifecycleSm<S: LifecycleState> {
inner: Mutex<Inner<S>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Inner<S> {
current: S,
next: Option<S>,
transition_at: Option<SystemTime>,
reason: Option<&'static str>,
}
impl<S: LifecycleState> LifecycleSm<S> {
pub fn new(initial: S) -> Self {
Self {
inner: Mutex::new(Inner {
current: initial,
next: None,
transition_at: None,
reason: None,
}),
}
}
pub fn current(&self) -> S {
self.inner.lock().unwrap().current
}
pub fn observe(&self, now: SystemTime) -> LifecycleView<S> {
let mut g = self.inner.lock().unwrap();
if let (Some(next), Some(at)) = (g.next, g.transition_at)
&& now >= at
{
g.current = next;
g.next = None;
g.transition_at = None;
}
LifecycleView {
state: g.current,
reason: g.reason,
}
}
pub fn start_transition(&self, from: S, to: S, delay: Duration) {
let effective = if fast_mode() { Duration::ZERO } else { delay };
let mut g = self.inner.lock().unwrap();
if g.current != from {
return;
}
if effective.is_zero() {
g.current = to;
g.next = None;
g.transition_at = None;
} else {
g.next = Some(to);
g.transition_at = Some(SystemTime::now() + effective);
}
g.reason = None;
}
pub fn fail(&self, failed_state: S, reason: &'static str) {
let mut g = self.inner.lock().unwrap();
g.current = failed_state;
g.next = None;
g.transition_at = None;
g.reason = Some(reason);
}
pub fn is_transient(&self) -> bool {
self.inner.lock().unwrap().current.is_transient()
}
pub fn reject_if_busy(&self, ok_states: &[S]) -> Result<(), crate::error::AwsError> {
let g = self.inner.lock().unwrap();
if ok_states.contains(&g.current) {
return Ok(());
}
Err(crate::error::AwsError::bad_request(
"ResourceInUseException",
format!(
"Resource is in state {:?}; cannot proceed until it reaches one of {:?}.",
g.current, ok_states
),
))
}
}
impl<S: LifecycleState + Serialize + for<'de> Deserialize<'de>> LifecycleSm<S> {
pub fn to_snapshot(&self) -> LifecycleSnapshot<S> {
let g = self.inner.lock().unwrap();
LifecycleSnapshot {
current: g.current,
next: g.next,
transition_at: g.transition_at,
reason: g.reason,
}
}
pub fn from_snapshot(snap: LifecycleSnapshot<S>) -> Self {
Self {
inner: Mutex::new(Inner {
current: snap.current,
next: snap.next,
transition_at: snap.transition_at,
reason: snap.reason,
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleSnapshot<S> {
pub current: S,
pub next: Option<S>,
pub transition_at: Option<SystemTime>,
pub reason: Option<&'static str>,
}
static FAST_MODE: OnceLock<bool> = OnceLock::new();
pub fn fast_mode() -> bool {
*FAST_MODE.get_or_init(|| {
std::env::var("AWSIM_LIFECYCLE_FAST")
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum TestState {
Creating,
Active,
Updating,
Deleting,
Failed,
}
impl LifecycleState for TestState {
fn is_transient(&self) -> bool {
matches!(
self,
TestState::Creating | TestState::Updating | TestState::Deleting
)
}
}
#[test]
fn observe_promotes_after_deadline() {
let sm = LifecycleSm::new(TestState::Creating);
sm.start_transition(
TestState::Creating,
TestState::Active,
Duration::from_millis(10),
);
assert_eq!(sm.observe(SystemTime::now()).state, TestState::Creating);
let later = SystemTime::now() + Duration::from_secs(1);
assert_eq!(sm.observe(later).state, TestState::Active);
assert_eq!(sm.observe(later).state, TestState::Active);
}
#[test]
fn start_transition_ignores_wrong_from_state() {
let sm = LifecycleSm::new(TestState::Active);
sm.start_transition(TestState::Creating, TestState::Updating, Duration::ZERO);
assert_eq!(sm.current(), TestState::Active);
}
#[test]
fn zero_delay_promotes_synchronously() {
let sm = LifecycleSm::new(TestState::Creating);
sm.start_transition(TestState::Creating, TestState::Active, Duration::ZERO);
assert_eq!(sm.current(), TestState::Active);
}
#[test]
fn reject_if_busy_returns_error_when_not_in_allowed_set() {
let sm = LifecycleSm::new(TestState::Updating);
let err = sm.reject_if_busy(&[TestState::Active]).unwrap_err();
assert_eq!(err.code, "ResourceInUseException");
let sm2 = LifecycleSm::new(TestState::Active);
sm2.reject_if_busy(&[TestState::Active]).unwrap();
}
#[test]
fn fail_records_reason_and_terminates() {
let sm = LifecycleSm::new(TestState::Creating);
sm.fail(TestState::Failed, "boot disk corrupt");
let view = sm.observe(SystemTime::now());
assert_eq!(view.state, TestState::Failed);
assert_eq!(view.reason, Some("boot disk corrupt"));
}
#[test]
fn snapshot_round_trip_preserves_pending_transition() {
let sm = LifecycleSm::new(TestState::Creating);
sm.start_transition(
TestState::Creating,
TestState::Active,
Duration::from_secs(60),
);
let snap = sm.to_snapshot();
let restored: LifecycleSm<TestState> = LifecycleSm::from_snapshot(snap);
assert_eq!(restored.current(), TestState::Creating);
let later = SystemTime::now() + Duration::from_secs(120);
assert_eq!(restored.observe(later).state, TestState::Active);
}
#[test]
fn is_transient_reflects_current_state() {
let sm = LifecycleSm::new(TestState::Creating);
assert!(sm.is_transient());
sm.start_transition(TestState::Creating, TestState::Active, Duration::ZERO);
assert!(!sm.is_transient());
}
}