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::str::FromStr;
18use std::time::Duration;
19use uuid::Uuid;
20
21// ============================================================================
22// LOCK MODE ENUM (replaces String)
23// ============================================================================
24
25/// Lock mode determining concurrency behavior.
26#[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    /// Only one holder can acquire the lock
31    Exclusive,
32    /// Multiple holders can acquire the lock
33    Shared,
34}
35
36impl LockMode {
37    /// Convert to database string representation.
38    pub fn as_db_str(&self) -> &'static str {
39        match self {
40            LockMode::Exclusive => "exclusive",
41            LockMode::Shared => "shared",
42        }
43    }
44
45    /// Parse from database string representation.
46    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// ============================================================================
73// LOCK DATA (internal storage, state-independent)
74// ============================================================================
75
76/// Internal data storage for a lock, independent of typestate.
77/// This is what gets persisted to the database.
78#[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    /// The ID of the resource being locked (generic UUID since it can be any entity type)
87    #[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    /// Check if the lock has expired based on current time.
100    pub fn is_expired(&self, now: Timestamp) -> bool {
101        now >= self.expires_at
102    }
103
104    /// Calculate remaining duration until expiry.
105    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
115// ============================================================================
116// TYPESTATE STATE MACHINE + WRAPPER
117// ============================================================================
118
119define_state_machine! {
120    /// Marker trait for lock states.
121    pub trait LockState;
122    /// Lock is currently held (acquired).
123    Acquired,
124    /// Lock has been released (for documentation; locks in this state don't exist at runtime).
125    Released,
126}
127
128define_typestate_wrapper! {
129    /// A lock with compile-time state tracking.
130    ///
131    /// The type parameter `S` indicates the current state of the lock.
132    /// Methods are only available in appropriate states:
133    /// - `Lock<Acquired>`: Can be extended or released
134    /// - `Lock<Released>`: Cannot be used (transitions consume the lock)
135    pub struct Lock<LockState> wraps LockRecord;
136    initial_state: Acquired;
137}
138
139impl<S: LockState> Lock<S> {
140    /// Get the lock ID.
141    pub fn lock_id(&self) -> LockId {
142        self.data.lock_id
143    }
144
145    /// Get the tenant ID.
146    pub fn tenant_id(&self) -> TenantId {
147        self.data.tenant_id
148    }
149
150    /// Get the resource type being locked.
151    pub fn resource_type(&self) -> &crate::ResourceType {
152        &self.data.resource_type
153    }
154
155    /// Get the resource ID being locked.
156    pub fn resource_id(&self) -> Uuid {
157        self.data.resource_id
158    }
159
160    /// Get the agent holding the lock.
161    pub fn holder_agent_id(&self) -> AgentId {
162        self.data.holder_agent_id
163    }
164
165    /// Get the lock mode.
166    pub fn mode(&self) -> LockMode {
167        self.data.mode
168    }
169
170    /// Get when the lock was acquired.
171    pub fn acquired_at(&self) -> Timestamp {
172        self.data.acquired_at
173    }
174
175    /// Get when the lock expires.
176    pub fn expires_at(&self) -> Timestamp {
177        self.data.expires_at
178    }
179}
180
181impl Lock<Acquired> {
182    /// Extend the lock duration.
183    ///
184    /// Returns a new `Lock<Acquired>` with the updated expiry time.
185    /// The original lock is consumed.
186    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    /// Extend the lock by milliseconds.
194    ///
195    /// Convenience method for extending by a millisecond count.
196    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    /// Release the lock and return the underlying data.
204    ///
205    /// Consumes the lock, preventing further operations.
206    /// The returned data can be used to update the database.
207    pub fn release(self) -> LockRecord {
208        self.data
209    }
210
211    /// Check if the lock has expired.
212    pub fn is_expired(&self, now: Timestamp) -> bool {
213        self.data.is_expired(now)
214    }
215
216    /// Get remaining duration until expiry.
217    pub fn remaining_duration(&self, now: Timestamp) -> Option<Duration> {
218        self.data.remaining_duration(now)
219    }
220}
221
222// ============================================================================
223// DATABASE BOUNDARY: STORED LOCK
224// ============================================================================
225
226/// A lock as stored in the database (status-agnostic).
227///
228/// When loading from the database, we don't know the state at compile time.
229/// Use the `into_acquired` method to validate and convert to a typed lock.
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231pub struct StoredLock {
232    pub data: LockRecord,
233    /// Whether this lock is currently active (not expired/released)
234    pub is_active: bool,
235}
236
237impl StoredLock {
238    /// Convert to an acquired lock if the lock is active and not expired.
239    ///
240    /// # Errors
241    ///
242    /// Returns `Err` if the lock is not active or has expired.
243    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    /// Get the underlying data without state validation.
259    pub fn data(&self) -> &LockRecord {
260        &self.data
261    }
262}
263
264/// Errors when transitioning lock states.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum LockStateError {
267    /// Lock is not in the active state.
268    NotActive { lock_id: LockId },
269    /// Lock has expired.
270    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
294// ============================================================================
295// LOCK KEY COMPUTATION (for PostgreSQL advisory locks)
296// ============================================================================
297
298/// Compute a stable i64 key for advisory locks using FNV-1a hash.
299///
300/// FNV-1a is deterministic across Rust versions and compilations, making it
301/// suitable for distributed lock coordination via PostgreSQL advisory locks.
302///
303/// # Arguments
304///
305/// * `resource_type` - The type of resource being locked (e.g., "trajectory", "scope")
306/// * `resource_id` - The unique identifier of the resource
307///
308/// # Returns
309///
310/// A stable i64 hash that can be used with PostgreSQL's `pg_advisory_lock()`.
311///
312/// # Example
313///
314/// ```
315/// use cellstate_core::compute_lock_key;
316/// use uuid::Uuid;
317///
318/// let resource_id = Uuid::now_v7();
319/// let lock_key = compute_lock_key("trajectory", resource_id);
320/// // Use lock_key with pg_advisory_lock(lock_key)
321/// ```
322pub 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    // Hash resource type
329    for byte in resource_type.as_bytes() {
330        hash ^= *byte as u64;
331        hash = hash.wrapping_mul(FNV_PRIME);
332    }
333
334    // Hash resource ID bytes
335    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        // lock is now consumed and cannot be used
391    }
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); // Already expired
414
415        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        // Same type, different IDs
445        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        // Different type, same ID
453        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}