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