11//! Async repository for runtime file locks (Teams mode concurrency).
2+ //!
3+ //! Uses PID-based crash detection + TTL fallback for hung processes.
4+ //! Stale locks (dead PID or expired TTL) are auto-cleaned on `acquire()`.
25
3- use chrono:: Utc ;
6+ use chrono:: { Duration , Utc } ;
47use libsql:: { params, Connection } ;
8+ use nix:: sys:: signal;
9+ use nix:: unistd:: Pid ;
510
611use crate :: error:: DbError ;
712
13+ /// Default lock TTL: 45 minutes.
14+ const LOCK_TTL_MINUTES : i64 = 45 ;
15+
816/// Async repository for runtime file locks. Load-bearing for Teams-mode
917/// concurrency: `acquire` on an already-locked file returns
1018/// `DbError::Constraint`.
@@ -17,38 +25,107 @@ impl FileLockRepo {
1725 Self { conn }
1826 }
1927
20- /// Acquire a lock on a file for a task. Returns `DbError::Constraint`
21- /// if the file is already locked by another task.
28+ /// Acquire a lock on a file for a task.
29+ ///
30+ /// Calls `cleanup_stale()` first, then uses `INSERT OR IGNORE` with
31+ /// `holder_pid` and `expires_at`. If the row already exists (rows_affected=0),
32+ /// checks whether it belongs to this task (idempotent Ok) or another
33+ /// (returns `DbError::Constraint`).
2234 pub async fn acquire ( & self , file_path : & str , task_id : & str ) -> Result < ( ) , DbError > {
23- let res = self
35+ self . cleanup_stale ( ) . await ?;
36+
37+ let pid = std:: process:: id ( ) ;
38+ let expires_at = ( Utc :: now ( ) + Duration :: minutes ( LOCK_TTL_MINUTES ) ) . to_rfc3339 ( ) ;
39+
40+ let rows_affected = self
2441 . conn
2542 . execute (
26- "INSERT INTO file_locks (file_path, task_id, locked_at) VALUES (?1, ?2, ?3)" ,
43+ "INSERT OR IGNORE INTO file_locks (file_path, task_id, locked_at, holder_pid, expires_at)
44+ VALUES (?1, ?2, ?3, ?4, ?5)" ,
2745 params ! [
2846 file_path. to_string( ) ,
2947 task_id. to_string( ) ,
3048 Utc :: now( ) . to_rfc3339( ) ,
49+ pid as i64 ,
50+ expires_at,
3151 ] ,
3252 )
33- . await ;
34-
35- match res {
36- Ok ( _) => Ok ( ( ) ) ,
37- Err ( e) => {
38- let msg = e. to_string ( ) ;
39- let low = msg. to_lowercase ( ) ;
40- if low. contains ( "unique constraint" )
41- || low. contains ( "constraint failed" )
42- || low. contains ( "primary key" )
43- {
44- Err ( DbError :: Constraint ( format ! (
45- "file already locked: {file_path}"
46- ) ) )
47- } else {
48- Err ( DbError :: LibSql ( e) )
49- }
53+ . await ?;
54+
55+ if rows_affected > 0 {
56+ return Ok ( ( ) ) ;
57+ }
58+
59+ // Row already exists — check if it's our own lock (idempotent).
60+ let existing_task = self . check ( file_path) . await ?;
61+ if existing_task. as_deref ( ) == Some ( task_id) {
62+ return Ok ( ( ) ) ;
63+ }
64+
65+ Err ( DbError :: Constraint ( format ! (
66+ "file already locked: {file_path}"
67+ ) ) )
68+ }
69+
70+ /// Remove stale locks: dead PIDs (via `kill(pid, 0)`) and expired TTLs.
71+ pub async fn cleanup_stale ( & self ) -> Result < u64 , DbError > {
72+ let mut total_cleaned = 0u64 ;
73+
74+ // 1. Delete expired locks (expires_at < now).
75+ let now = Utc :: now ( ) . to_rfc3339 ( ) ;
76+ let expired = self
77+ . conn
78+ . execute (
79+ "DELETE FROM file_locks WHERE expires_at IS NOT NULL AND expires_at < ?1" ,
80+ params ! [ now] ,
81+ )
82+ . await ?;
83+ total_cleaned += expired;
84+
85+ // 2. Check PIDs of remaining locks — delete dead ones.
86+ let mut rows = self
87+ . conn
88+ . query (
89+ "SELECT file_path, holder_pid FROM file_locks WHERE holder_pid IS NOT NULL" ,
90+ ( ) ,
91+ )
92+ . await ?;
93+
94+ let mut dead_files = Vec :: new ( ) ;
95+ while let Some ( row) = rows. next ( ) . await ? {
96+ let fp: String = row. get ( 0 ) ?;
97+ let pid: i64 = row. get ( 1 ) ?;
98+
99+ if !is_process_alive ( pid as i32 ) {
100+ dead_files. push ( fp) ;
50101 }
51102 }
103+
104+ for fp in & dead_files {
105+ let n = self
106+ . conn
107+ . execute (
108+ "DELETE FROM file_locks WHERE file_path = ?1" ,
109+ params ! [ fp. clone( ) ] ,
110+ )
111+ . await ?;
112+ total_cleaned += n;
113+ }
114+
115+ Ok ( total_cleaned)
116+ }
117+
118+ /// Extend `expires_at` for all locks held by a task (heartbeat).
119+ pub async fn heartbeat ( & self , task_id : & str ) -> Result < u64 , DbError > {
120+ let new_expires = ( Utc :: now ( ) + Duration :: minutes ( LOCK_TTL_MINUTES ) ) . to_rfc3339 ( ) ;
121+ let n = self
122+ . conn
123+ . execute (
124+ "UPDATE file_locks SET expires_at = ?1 WHERE task_id = ?2" ,
125+ params ! [ new_expires, task_id. to_string( ) ] ,
126+ )
127+ . await ?;
128+ Ok ( n)
52129 }
53130
54131 /// Release locks held by a task. Returns number of rows deleted.
@@ -86,3 +163,9 @@ impl FileLockRepo {
86163 }
87164 }
88165}
166+
167+ /// Check if a process is alive using `kill(pid, 0)`.
168+ fn is_process_alive ( pid : i32 ) -> bool {
169+ // kill(pid, 0) returns Ok if process exists, Err(ESRCH) if not.
170+ signal:: kill ( Pid :: from_raw ( pid) , None ) . is_ok ( )
171+ }
0 commit comments