Skip to main content

made_core/value_objects/ceremony/
step_lease.rs

1use serde::{Deserialize, Serialize};
2use time::{Duration, OffsetDateTime};
3
4use crate::error::DomainError;
5use crate::value_objects::DurationMs;
6
7use super::{ExecutionProfile, IdempotencyKey, LeaseOwnerId};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct StepLease {
11    owner_id: LeaseOwnerId,
12    idempotency_key: IdempotencyKey,
13    #[serde(with = "time::serde::rfc3339")]
14    acquired_at: OffsetDateTime,
15    #[serde(with = "time::serde::rfc3339")]
16    expires_at: OffsetDateTime,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    execution_profile: Option<Box<ExecutionProfile>>,
19}
20
21impl StepLease {
22    pub fn new(
23        owner_id: LeaseOwnerId,
24        idempotency_key: IdempotencyKey,
25        acquired_at: OffsetDateTime,
26        expires_at: OffsetDateTime,
27    ) -> Result<Self, DomainError> {
28        if expires_at <= acquired_at {
29            return Err(DomainError::InvariantViolated {
30                reason: "step lease must expire after it is acquired",
31            });
32        }
33        Ok(Self {
34            owner_id,
35            idempotency_key,
36            acquired_at,
37            expires_at,
38            execution_profile: None,
39        })
40    }
41
42    /// Acquire a lease that expires `ttl` after `acquired_at`.
43    ///
44    /// Unlike [`StepLease::new`], which takes an already-computed expiry
45    /// instant, this constructor derives the expiry from a typed
46    /// [`DurationMs`] and **fails fast** with [`DomainError::OutOfRange`]
47    /// when the requested lifetime cannot be honoured — either because it
48    /// exceeds the signed-millisecond range the clock accepts or because
49    /// adding it to `acquired_at` overflows the representable calendar.
50    /// The requested TTL is never silently clamped.
51    pub fn acquire(
52        owner_id: LeaseOwnerId,
53        idempotency_key: IdempotencyKey,
54        acquired_at: OffsetDateTime,
55        ttl: DurationMs,
56    ) -> Result<Self, DomainError> {
57        let ttl_millis = i64::try_from(ttl.get()).map_err(|_| DomainError::OutOfRange {
58            field: "step_lease.ttl_ms",
59            value: ttl.get() as f64,
60            min: 0.0,
61            max: i64::MAX as f64,
62        })?;
63        let expires_at = acquired_at
64            .checked_add(Duration::milliseconds(ttl_millis))
65            .ok_or(DomainError::OutOfRange {
66                field: "step_lease.expires_at",
67                value: ttl.get() as f64,
68                min: 0.0,
69                max: i64::MAX as f64,
70            })?;
71        Self::new(owner_id, idempotency_key, acquired_at, expires_at)
72    }
73
74    #[must_use]
75    pub fn owner_id(&self) -> &LeaseOwnerId {
76        &self.owner_id
77    }
78
79    #[must_use]
80    pub fn idempotency_key(&self) -> &IdempotencyKey {
81        &self.idempotency_key
82    }
83
84    #[must_use]
85    pub fn acquired_at(&self) -> OffsetDateTime {
86        self.acquired_at
87    }
88
89    #[must_use]
90    pub fn expires_at(&self) -> OffsetDateTime {
91        self.expires_at
92    }
93
94    #[must_use]
95    pub fn execution_profile(&self) -> Option<&ExecutionProfile> {
96        self.execution_profile.as_deref()
97    }
98
99    #[must_use]
100    pub fn with_execution_profile(mut self, profile: ExecutionProfile) -> Self {
101        self.execution_profile = Some(Box::new(profile));
102        self
103    }
104
105    #[must_use]
106    pub fn is_expired_at(&self, now: OffsetDateTime) -> bool {
107        now >= self.expires_at
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use time::{Duration, OffsetDateTime};
114
115    use super::*;
116
117    fn owner() -> LeaseOwnerId {
118        LeaseOwnerId::new("runner-1").unwrap()
119    }
120
121    fn key() -> IdempotencyKey {
122        IdempotencyKey::new("ceremony-1:open_room:1").unwrap()
123    }
124
125    #[test]
126    fn acquire_expires_ttl_after_acquired_at() {
127        let acquired_at = OffsetDateTime::UNIX_EPOCH;
128        let lease =
129            StepLease::acquire(owner(), key(), acquired_at, DurationMs::from_millis(60_000))
130                .unwrap();
131
132        assert_eq!(lease.acquired_at(), acquired_at);
133        assert_eq!(
134            lease.expires_at(),
135            acquired_at + Duration::milliseconds(60_000)
136        );
137        assert_eq!(lease.owner_id(), &owner());
138        assert_eq!(lease.idempotency_key(), &key());
139    }
140
141    #[test]
142    fn acquire_lease_is_not_expired_before_ttl_elapses() {
143        let acquired_at = OffsetDateTime::UNIX_EPOCH;
144        let lease = StepLease::acquire(owner(), key(), acquired_at, DurationMs::from_millis(1_000))
145            .unwrap();
146
147        assert!(!lease.is_expired_at(acquired_at + Duration::milliseconds(999)));
148        assert!(lease.is_expired_at(acquired_at + Duration::milliseconds(1_000)));
149    }
150
151    #[test]
152    fn acquire_rejects_zero_ttl_as_non_positive_lifetime() {
153        let err = StepLease::acquire(owner(), key(), OffsetDateTime::UNIX_EPOCH, DurationMs::ZERO)
154            .unwrap_err();
155
156        assert!(matches!(err, DomainError::InvariantViolated { .. }));
157    }
158
159    #[test]
160    fn acquire_rejects_ttl_exceeding_signed_millisecond_range() {
161        let err = StepLease::acquire(
162            owner(),
163            key(),
164            OffsetDateTime::UNIX_EPOCH,
165            DurationMs::from_millis(u64::MAX),
166        )
167        .unwrap_err();
168
169        assert!(matches!(
170            err,
171            DomainError::OutOfRange {
172                field: "step_lease.ttl_ms",
173                ..
174            }
175        ));
176    }
177
178    #[test]
179    fn acquire_rejects_ttl_that_overflows_the_calendar() {
180        let err = StepLease::acquire(
181            owner(),
182            key(),
183            OffsetDateTime::UNIX_EPOCH,
184            DurationMs::from_millis(i64::MAX as u64),
185        )
186        .unwrap_err();
187
188        assert!(matches!(
189            err,
190            DomainError::OutOfRange {
191                field: "step_lease.expires_at",
192                ..
193            }
194        ));
195    }
196
197    #[test]
198    fn new_rejects_expiry_not_after_acquired_at() {
199        let now = OffsetDateTime::UNIX_EPOCH;
200        let err = StepLease::new(owner(), key(), now, now).unwrap_err();
201
202        assert!(matches!(err, DomainError::InvariantViolated { .. }));
203    }
204}