1use crate::{AgentId, EnumParseError, LockId, TenantId, Timestamp};
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::marker::PhantomData;
18use std::str::FromStr;
19use std::time::Duration;
20use uuid::Uuid;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30pub enum LockMode {
31 Exclusive,
33 Shared,
35}
36
37impl LockMode {
38 pub fn as_db_str(&self) -> &'static str {
40 match self {
41 LockMode::Exclusive => "exclusive",
42 LockMode::Shared => "shared",
43 }
44 }
45
46 pub fn from_db_str(s: &str) -> Result<Self, EnumParseError> {
48 match s.to_lowercase().as_str() {
49 "exclusive" => Ok(LockMode::Exclusive),
50 "shared" => Ok(LockMode::Shared),
51 _ => Err(EnumParseError {
52 enum_name: "LockMode",
53 input: s.to_string(),
54 }),
55 }
56 }
57}
58
59impl fmt::Display for LockMode {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(f, "{}", self.as_db_str())
62 }
63}
64
65impl FromStr for LockMode {
66 type Err = EnumParseError;
67
68 fn from_str(s: &str) -> Result<Self, Self::Err> {
69 Self::from_db_str(s)
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81pub struct LockRecord {
82 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
83 pub lock_id: LockId,
84 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
85 pub tenant_id: TenantId,
86 pub resource_type: crate::ResourceType,
87 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
89 pub resource_id: Uuid,
90 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
91 pub holder_agent_id: AgentId,
92 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
93 pub acquired_at: Timestamp,
94 #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
95 pub expires_at: Timestamp,
96 pub mode: LockMode,
97}
98
99impl LockRecord {
100 pub fn is_expired(&self, now: Timestamp) -> bool {
102 now >= self.expires_at
103 }
104
105 pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
107 if now >= self.expires_at {
108 None
109 } else {
110 let duration = self.expires_at - now;
111 duration.to_std().ok()
112 }
113 }
114}
115
116pub trait LockState: private::Sealed + Send + Sync {}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct Acquired;
126impl LockState for Acquired {}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct Released;
131impl LockState for Released {}
132
133mod private {
134 pub trait Sealed {}
135 impl Sealed for super::Acquired {}
136 impl Sealed for super::Released {}
137}
138
139#[derive(Debug, Clone)]
159pub struct Lock<S: LockState> {
160 data: LockRecord,
161 _state: PhantomData<S>,
162}
163
164impl<S: LockState> Lock<S> {
165 pub fn data(&self) -> &LockRecord {
167 &self.data
168 }
169
170 pub fn lock_id(&self) -> LockId {
172 self.data.lock_id
173 }
174
175 pub fn tenant_id(&self) -> TenantId {
177 self.data.tenant_id
178 }
179
180 pub fn resource_type(&self) -> &crate::ResourceType {
182 &self.data.resource_type
183 }
184
185 pub fn resource_id(&self) -> Uuid {
187 self.data.resource_id
188 }
189
190 pub fn holder_agent_id(&self) -> AgentId {
192 self.data.holder_agent_id
193 }
194
195 pub fn mode(&self) -> LockMode {
197 self.data.mode
198 }
199
200 pub fn acquired_at(&self) -> Timestamp {
202 self.data.acquired_at
203 }
204
205 pub fn expires_at(&self) -> Timestamp {
207 self.data.expires_at
208 }
209}
210
211impl Lock<Acquired> {
212 pub fn new(data: LockRecord) -> Self {
216 Lock {
217 data,
218 _state: PhantomData,
219 }
220 }
221
222 pub fn extend(mut self, additional: Duration) -> Self {
227 let additional_chrono = chrono::Duration::from_std(additional)
228 .unwrap_or_else(|_| chrono::Duration::milliseconds(additional.as_millis() as i64));
229 self.data.expires_at += additional_chrono;
230 self
231 }
232
233 pub fn extend_ms(self, additional_ms: i64) -> Self {
237 let additional = chrono::Duration::milliseconds(additional_ms);
238 let mut lock = self;
239 lock.data.expires_at += additional;
240 lock
241 }
242
243 pub fn release(self) -> LockRecord {
248 self.data
249 }
250
251 pub fn is_expired(&self, now: Timestamp) -> bool {
253 self.data.is_expired(now)
254 }
255
256 pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
258 self.data.remaining_duration(now)
259 }
260
261 pub fn into_data(self) -> LockRecord {
263 self.data
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct StoredLock {
277 pub data: LockRecord,
278 pub is_active: bool,
280}
281
282impl StoredLock {
283 pub fn into_acquired(self, now: Timestamp) -> Result<Lock<Acquired>, LockStateError> {
289 if !self.is_active {
290 return Err(LockStateError::NotActive {
291 lock_id: self.data.lock_id,
292 });
293 }
294 if self.data.is_expired(now) {
295 return Err(LockStateError::Expired {
296 lock_id: self.data.lock_id,
297 expired_at: self.data.expires_at,
298 });
299 }
300 Ok(Lock::new(self.data))
301 }
302
303 pub fn data(&self) -> &LockRecord {
305 &self.data
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum LockStateError {
312 NotActive { lock_id: LockId },
314 Expired {
316 lock_id: LockId,
317 expired_at: Timestamp,
318 },
319}
320
321impl fmt::Display for LockStateError {
322 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323 match self {
324 LockStateError::NotActive { lock_id } => {
325 write!(f, "Lock {} is not active", lock_id)
326 }
327 LockStateError::Expired {
328 lock_id,
329 expired_at,
330 } => {
331 write!(f, "Lock {} expired at {}", lock_id, expired_at)
332 }
333 }
334 }
335}
336
337impl std::error::Error for LockStateError {}
338
339pub fn compute_lock_key(resource_type: &str, resource_id: Uuid) -> i64 {
368 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
369 const FNV_PRIME: u64 = 0x100000001b3;
370
371 let mut hash = FNV_OFFSET_BASIS;
372
373 for byte in resource_type.as_bytes() {
375 hash ^= *byte as u64;
376 hash = hash.wrapping_mul(FNV_PRIME);
377 }
378
379 for byte in resource_id.as_bytes() {
381 hash ^= *byte as u64;
382 hash = hash.wrapping_mul(FNV_PRIME);
383 }
384
385 hash as i64
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::EntityIdType;
392 use chrono::Utc;
393 use uuid::Uuid;
394
395 fn make_lock_data() -> LockRecord {
396 let now = Utc::now();
397 LockRecord {
398 lock_id: LockId::now_v7(),
399 tenant_id: TenantId::now_v7(),
400 resource_type: crate::ResourceType::Trajectory,
401 resource_id: Uuid::now_v7(),
402 holder_agent_id: AgentId::now_v7(),
403 acquired_at: now,
404 expires_at: now + chrono::Duration::minutes(5),
405 mode: LockMode::Exclusive,
406 }
407 }
408
409 #[test]
410 fn test_lock_mode_roundtrip() {
411 for mode in [LockMode::Exclusive, LockMode::Shared] {
412 let db_str = mode.as_db_str();
413 let parsed = LockMode::from_db_str(db_str).expect("LockMode roundtrip should succeed");
414 assert_eq!(mode, parsed);
415 }
416 }
417
418 #[test]
419 fn test_lock_extend() {
420 let data = make_lock_data();
421 let original_expires = data.expires_at;
422 let lock = Lock::<Acquired>::new(data);
423
424 let extended = lock.extend(Duration::from_secs(60));
425 assert!(extended.expires_at() > original_expires);
426 }
427
428 #[test]
429 fn test_lock_release_consumes() {
430 let data = make_lock_data();
431 let lock = Lock::<Acquired>::new(data.clone());
432
433 let released_data = lock.release();
434 assert_eq!(released_data.lock_id, data.lock_id);
435 }
437
438 #[test]
439 fn test_stored_lock_conversion() {
440 let now = Utc::now();
441 let data = make_lock_data();
442
443 let stored = StoredLock {
444 data: data.clone(),
445 is_active: true,
446 };
447
448 let acquired = stored
449 .into_acquired(now)
450 .expect("active lock should convert to acquired");
451 assert_eq!(acquired.lock_id(), data.lock_id);
452 }
453
454 #[test]
455 fn test_stored_lock_expired() {
456 let now = Utc::now();
457 let mut data = make_lock_data();
458 data.expires_at = now - chrono::Duration::minutes(1); let stored = StoredLock {
461 data,
462 is_active: true,
463 };
464
465 assert!(matches!(
466 stored.into_acquired(now),
467 Err(LockStateError::Expired { .. })
468 ));
469 }
470
471 #[test]
472 fn test_compute_lock_key_deterministic() {
473 let resource_id = Uuid::now_v7();
474 let resource_type = "trajectory";
475
476 let key1 = compute_lock_key(resource_type, resource_id);
477 let key2 = compute_lock_key(resource_type, resource_id);
478
479 assert_eq!(key1, key2, "Lock key should be deterministic");
480 }
481
482 #[test]
483 fn test_compute_lock_key_uniqueness() {
484 let resource_id1 = Uuid::now_v7();
485 let resource_id2 = Uuid::now_v7();
486 let resource_type1 = "trajectory";
487 let resource_type2 = "scope";
488
489 let key1 = compute_lock_key(resource_type1, resource_id1);
491 let key2 = compute_lock_key(resource_type1, resource_id2);
492 assert_ne!(
493 key1, key2,
494 "Different resource IDs should produce different keys"
495 );
496
497 let key3 = compute_lock_key(resource_type1, resource_id1);
499 let key4 = compute_lock_key(resource_type2, resource_id1);
500 assert_ne!(
501 key3, key4,
502 "Different resource types should produce different keys"
503 );
504 }
505}