made_core/value_objects/ceremony/
step_lease.rs1use 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 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}