cellstate_core/
lock.rs

1//! Lock typestate for compile-time safety of lock lifecycle.
2//!
3//! Uses the typestate pattern to make invalid state transitions uncompilable.
4//! A lock can only be released or extended when it's in the Acquired state.
5//!
6//! # State Transition Diagram
7//!
8//! ```text
9//! (unlocked) ─── acquire() ──→ Acquired ─── release() ──→ (unlocked)
10//!                                  │
11//!                             extend() ↺
12//! ```
13
14use 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// ============================================================================
23// LOCK MODE ENUM (replaces String)
24// ============================================================================
25
26/// Lock mode determining concurrency behavior.
27#[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    /// Only one holder can acquire the lock
32    Exclusive,
33    /// Multiple holders can acquire the lock
34    Shared,
35}
36
37impl LockMode {
38    /// Convert to database string representation.
39    pub fn as_db_str(&self) -> &'static str {
40        match self {
41            LockMode::Exclusive => "exclusive",
42            LockMode::Shared => "shared",
43        }
44    }
45
46    /// Parse from database string representation.
47    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// ============================================================================
74// LOCK DATA (internal storage, state-independent)
75// ============================================================================
76
77/// Internal data storage for a lock, independent of typestate.
78/// This is what gets persisted to the database.
79#[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    /// The ID of the resource being locked (generic UUID since it can be any entity type)
88    #[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    /// Check if the lock has expired based on current time.
101    pub fn is_expired(&self, now: Timestamp) -> bool {
102        now >= self.expires_at
103    }
104
105    /// Calculate remaining duration until expiry.
106    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
116// ============================================================================
117// TYPESTATE MARKERS
118// ============================================================================
119
120/// Marker trait for lock states.
121pub trait LockState: private::Sealed + Send + Sync {}
122
123/// Lock is currently held (acquired).
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct Acquired;
126impl LockState for Acquired {}
127
128/// Lock has been released (for documentation; locks in this state don't exist at runtime).
129#[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// ============================================================================
140// LOCK TYPESTATE WRAPPER
141// ============================================================================
142
143/// A lock with compile-time state tracking.
144///
145/// The type parameter `S` indicates the current state of the lock.
146/// Methods are only available in appropriate states:
147/// - `Lock<Acquired>`: Can be extended or released
148/// - `Lock<Released>`: Cannot be used (transitions consume the lock)
149///
150/// # Example
151///
152/// ```ignore
153/// let lock: Lock<Acquired> = acquire_lock(...);
154/// let extended_lock = lock.extend(Duration::from_secs(30)); // OK
155/// let data = extended_lock.release(); // OK, consumes the lock
156/// // Can't use extended_lock anymore - it was consumed!
157/// ```
158#[derive(Debug, Clone)]
159pub struct Lock<S: LockState> {
160    data: LockRecord,
161    _state: PhantomData<S>,
162}
163
164impl<S: LockState> Lock<S> {
165    /// Access the underlying lock data (read-only).
166    pub fn data(&self) -> &LockRecord {
167        &self.data
168    }
169
170    /// Get the lock ID.
171    pub fn lock_id(&self) -> LockId {
172        self.data.lock_id
173    }
174
175    /// Get the tenant ID.
176    pub fn tenant_id(&self) -> TenantId {
177        self.data.tenant_id
178    }
179
180    /// Get the resource type being locked.
181    pub fn resource_type(&self) -> &crate::ResourceType {
182        &self.data.resource_type
183    }
184
185    /// Get the resource ID being locked.
186    pub fn resource_id(&self) -> Uuid {
187        self.data.resource_id
188    }
189
190    /// Get the agent holding the lock.
191    pub fn holder_agent_id(&self) -> AgentId {
192        self.data.holder_agent_id
193    }
194
195    /// Get the lock mode.
196    pub fn mode(&self) -> LockMode {
197        self.data.mode
198    }
199
200    /// Get when the lock was acquired.
201    pub fn acquired_at(&self) -> Timestamp {
202        self.data.acquired_at
203    }
204
205    /// Get when the lock expires.
206    pub fn expires_at(&self) -> Timestamp {
207        self.data.expires_at
208    }
209}
210
211impl Lock<Acquired> {
212    /// Create a new acquired lock from data.
213    ///
214    /// This should only be called when a lock is successfully acquired.
215    pub fn new(data: LockRecord) -> Self {
216        Lock {
217            data,
218            _state: PhantomData,
219        }
220    }
221
222    /// Extend the lock duration.
223    ///
224    /// Returns a new `Lock<Acquired>` with the updated expiry time.
225    /// The original lock is consumed.
226    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    /// Extend the lock by milliseconds.
234    ///
235    /// Convenience method for extending by a millisecond count.
236    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    /// Release the lock and return the underlying data.
244    ///
245    /// Consumes the lock, preventing further operations.
246    /// The returned data can be used to update the database.
247    pub fn release(self) -> LockRecord {
248        self.data
249    }
250
251    /// Check if the lock has expired.
252    pub fn is_expired(&self, now: Timestamp) -> bool {
253        self.data.is_expired(now)
254    }
255
256    /// Get remaining duration until expiry.
257    pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
258        self.data.remaining_duration(now)
259    }
260
261    /// Consume the lock and return just the data (for serialization).
262    pub fn into_data(self) -> LockRecord {
263        self.data
264    }
265}
266
267// ============================================================================
268// DATABASE BOUNDARY: STORED LOCK
269// ============================================================================
270
271/// A lock as stored in the database (status-agnostic).
272///
273/// When loading from the database, we don't know the state at compile time.
274/// Use the `into_acquired` method to validate and convert to a typed lock.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct StoredLock {
277    pub data: LockRecord,
278    /// Whether this lock is currently active (not expired/released)
279    pub is_active: bool,
280}
281
282impl StoredLock {
283    /// Convert to an acquired lock if the lock is active and not expired.
284    ///
285    /// # Errors
286    ///
287    /// Returns `Err` if the lock is not active or has expired.
288    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    /// Get the underlying data without state validation.
304    pub fn data(&self) -> &LockRecord {
305        &self.data
306    }
307}
308
309/// Errors when transitioning lock states.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum LockStateError {
312    /// Lock is not in the active state.
313    NotActive { lock_id: LockId },
314    /// Lock has expired.
315    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
339// ============================================================================
340// LOCK KEY COMPUTATION (for PostgreSQL advisory locks)
341// ============================================================================
342
343/// Compute a stable i64 key for advisory locks using FNV-1a hash.
344///
345/// FNV-1a is deterministic across Rust versions and compilations, making it
346/// suitable for distributed lock coordination via PostgreSQL advisory locks.
347///
348/// # Arguments
349///
350/// * `resource_type` - The type of resource being locked (e.g., "trajectory", "scope")
351/// * `resource_id` - The unique identifier of the resource
352///
353/// # Returns
354///
355/// A stable i64 hash that can be used with PostgreSQL's `pg_advisory_lock()`.
356///
357/// # Example
358///
359/// ```
360/// use cellstate_core::compute_lock_key;
361/// use uuid::Uuid;
362///
363/// let resource_id = Uuid::now_v7();
364/// let lock_key = compute_lock_key("trajectory", resource_id);
365/// // Use lock_key with pg_advisory_lock(lock_key)
366/// ```
367pub 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    // Hash resource type
374    for byte in resource_type.as_bytes() {
375        hash ^= *byte as u64;
376        hash = hash.wrapping_mul(FNV_PRIME);
377    }
378
379    // Hash resource ID bytes
380    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        // lock is now consumed and cannot be used
436    }
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); // Already expired
459
460        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        // Same type, different IDs
490        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        // Different type, same ID
498        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}