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::{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 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}