1use crate::{AgentId, EnumParseError, LockId, TenantId, Timestamp};
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::str::FromStr;
18use std::time::Duration;
19use uuid::Uuid;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub enum LockMode {
30 Exclusive,
32 Shared,
34}
35
36impl LockMode {
37 pub fn as_db_str(&self) -> &'static str {
39 match self {
40 LockMode::Exclusive => "exclusive",
41 LockMode::Shared => "shared",
42 }
43 }
44
45 pub fn from_db_str(s: &str) -> Result<Self, EnumParseError> {
47 match s.to_lowercase().as_str() {
48 "exclusive" => Ok(LockMode::Exclusive),
49 "shared" => Ok(LockMode::Shared),
50 _ => Err(EnumParseError {
51 enum_name: "LockMode",
52 input: s.to_string(),
53 }),
54 }
55 }
56}
57
58impl fmt::Display for LockMode {
59 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60 write!(f, "{}", self.as_db_str())
61 }
62}
63
64impl FromStr for LockMode {
65 type Err = EnumParseError;
66
67 fn from_str(s: &str) -> Result<Self, Self::Err> {
68 Self::from_db_str(s)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80pub struct LockRecord {
81 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
82 pub lock_id: LockId,
83 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
84 pub tenant_id: TenantId,
85 pub resource_type: crate::ResourceType,
86 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
88 pub resource_id: Uuid,
89 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
90 pub holder_agent_id: AgentId,
91 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
92 pub acquired_at: Timestamp,
93 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
94 pub expires_at: Timestamp,
95 pub mode: LockMode,
96}
97
98impl LockRecord {
99 pub fn is_expired(&self, now: Timestamp) -> bool {
101 now >= self.expires_at
102 }
103
104 pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
106 if now >= self.expires_at {
107 None
108 } else {
109 let duration = self.expires_at - now;
110 duration.to_std().ok()
111 }
112 }
113}
114
115define_state_machine! {
120 pub trait LockState;
122 Acquired,
124 Released,
126}
127
128define_typestate_wrapper! {
129 pub struct Lock<LockState> wraps LockRecord;
136 initial_state: Acquired;
137}
138
139impl<S: LockState> Lock<S> {
140 pub fn lock_id(&self) -> LockId {
142 self.data.lock_id
143 }
144
145 pub fn tenant_id(&self) -> TenantId {
147 self.data.tenant_id
148 }
149
150 pub fn resource_type(&self) -> &crate::ResourceType {
152 &self.data.resource_type
153 }
154
155 pub fn resource_id(&self) -> Uuid {
157 self.data.resource_id
158 }
159
160 pub fn holder_agent_id(&self) -> AgentId {
162 self.data.holder_agent_id
163 }
164
165 pub fn mode(&self) -> LockMode {
167 self.data.mode
168 }
169
170 pub fn acquired_at(&self) -> Timestamp {
172 self.data.acquired_at
173 }
174
175 pub fn expires_at(&self) -> Timestamp {
177 self.data.expires_at
178 }
179}
180
181impl Lock<Acquired> {
182 pub fn extend(mut self, additional: Duration) -> Self {
187 let additional_chrono = chrono::Duration::from_std(additional)
188 .unwrap_or_else(|_| chrono::Duration::milliseconds(additional.as_millis() as i64));
189 self.data.expires_at += additional_chrono;
190 self
191 }
192
193 pub fn extend_ms(self, additional_ms: i64) -> Self {
197 let additional = chrono::Duration::milliseconds(additional_ms);
198 let mut lock = self;
199 lock.data.expires_at += additional;
200 lock
201 }
202
203 pub fn release(self) -> LockRecord {
208 self.data
209 }
210
211 pub fn is_expired(&self, now: Timestamp) -> bool {
213 self.data.is_expired(now)
214 }
215
216 pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
218 self.data.remaining_duration(now)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231pub struct StoredLock {
232 pub data: LockRecord,
233 pub is_active: bool,
235}
236
237impl StoredLock {
238 pub fn into_acquired(self, now: Timestamp) -> Result<Lock<Acquired>, LockStateError> {
244 if !self.is_active {
245 return Err(LockStateError::NotActive {
246 lock_id: self.data.lock_id,
247 });
248 }
249 if self.data.is_expired(now) {
250 return Err(LockStateError::Expired {
251 lock_id: self.data.lock_id,
252 expired_at: self.data.expires_at,
253 });
254 }
255 Ok(Lock::new(self.data))
256 }
257
258 pub fn data(&self) -> &LockRecord {
260 &self.data
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum LockStateError {
267 NotActive { lock_id: LockId },
269 Expired {
271 lock_id: LockId,
272 expired_at: Timestamp,
273 },
274}
275
276impl fmt::Display for LockStateError {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 match self {
279 LockStateError::NotActive { lock_id } => {
280 write!(f, "Lock {} is not active", lock_id)
281 }
282 LockStateError::Expired {
283 lock_id,
284 expired_at,
285 } => {
286 write!(f, "Lock {} expired at {}", lock_id, expired_at)
287 }
288 }
289 }
290}
291
292impl std::error::Error for LockStateError {}
293
294pub fn compute_lock_key(resource_type: &str, resource_id: Uuid) -> i64 {
323 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
324 const FNV_PRIME: u64 = 0x100000001b3;
325
326 let mut hash = FNV_OFFSET_BASIS;
327
328 for byte in resource_type.as_bytes() {
330 hash ^= *byte as u64;
331 hash = hash.wrapping_mul(FNV_PRIME);
332 }
333
334 for byte in resource_id.as_bytes() {
336 hash ^= *byte as u64;
337 hash = hash.wrapping_mul(FNV_PRIME);
338 }
339
340 hash as i64
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::EntityIdType;
347 use chrono::Utc;
348 use uuid::Uuid;
349
350 fn make_lock_data() -> LockRecord {
351 let now = Utc::now();
352 LockRecord {
353 lock_id: LockId::now_v7(),
354 tenant_id: TenantId::now_v7(),
355 resource_type: crate::ResourceType::Trajectory,
356 resource_id: Uuid::now_v7(),
357 holder_agent_id: AgentId::now_v7(),
358 acquired_at: now,
359 expires_at: now + chrono::Duration::minutes(5),
360 mode: LockMode::Exclusive,
361 }
362 }
363
364 #[test]
365 fn test_lock_mode_roundtrip() {
366 for mode in [LockMode::Exclusive, LockMode::Shared] {
367 let db_str = mode.as_db_str();
368 let parsed = LockMode::from_db_str(db_str).expect("LockMode roundtrip should succeed");
369 assert_eq!(mode, parsed);
370 }
371 }
372
373 #[test]
374 fn test_lock_extend() {
375 let data = make_lock_data();
376 let original_expires = data.expires_at;
377 let lock = Lock::<Acquired>::new(data);
378
379 let extended = lock.extend(Duration::from_secs(60));
380 assert!(extended.expires_at() > original_expires);
381 }
382
383 #[test]
384 fn test_lock_release_consumes() {
385 let data = make_lock_data();
386 let lock = Lock::<Acquired>::new(data.clone());
387
388 let released_data = lock.release();
389 assert_eq!(released_data.lock_id, data.lock_id);
390 }
392
393 #[test]
394 fn test_stored_lock_conversion() {
395 let now = Utc::now();
396 let data = make_lock_data();
397
398 let stored = StoredLock {
399 data: data.clone(),
400 is_active: true,
401 };
402
403 let acquired = stored
404 .into_acquired(now)
405 .expect("active lock should convert to acquired");
406 assert_eq!(acquired.lock_id(), data.lock_id);
407 }
408
409 #[test]
410 fn test_stored_lock_expired() {
411 let now = Utc::now();
412 let mut data = make_lock_data();
413 data.expires_at = now - chrono::Duration::minutes(1); let stored = StoredLock {
416 data,
417 is_active: true,
418 };
419
420 assert!(matches!(
421 stored.into_acquired(now),
422 Err(LockStateError::Expired { .. })
423 ));
424 }
425
426 #[test]
427 fn test_compute_lock_key_deterministic() {
428 let resource_id = Uuid::now_v7();
429 let resource_type = "trajectory";
430
431 let key1 = compute_lock_key(resource_type, resource_id);
432 let key2 = compute_lock_key(resource_type, resource_id);
433
434 assert_eq!(key1, key2, "Lock key should be deterministic");
435 }
436
437 #[test]
438 fn test_compute_lock_key_uniqueness() {
439 let resource_id1 = Uuid::now_v7();
440 let resource_id2 = Uuid::now_v7();
441 let resource_type1 = "trajectory";
442 let resource_type2 = "scope";
443
444 let key1 = compute_lock_key(resource_type1, resource_id1);
446 let key2 = compute_lock_key(resource_type1, resource_id2);
447 assert_ne!(
448 key1, key2,
449 "Different resource IDs should produce different keys"
450 );
451
452 let key3 = compute_lock_key(resource_type1, resource_id1);
454 let key4 = compute_lock_key(resource_type2, resource_id1);
455 assert_ne!(
456 key3, key4,
457 "Different resource types should produce different keys"
458 );
459 }
460}