Skip to main content

sochdb_storage/
lock.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Advisory File Locking for Database Exclusivity
19//!
20//! This module implements cross-platform advisory file locking to enforce
21//! single-writer database exclusivity at the filesystem level.
22//!
23//! ## Problem
24//!
25//! Process-local synchronization primitives (`Mutex`, `RwLock`, `AtomicU64`)
26//! provide zero protection against concurrent multi-process access. When
27//! multiple OS processes open the same database files, data corruption occurs:
28//!
29//! 1. Process A appends entry at offset X, increments local sequence to N
30//! 2. Process B appends entry at offset X+∆, has independent sequence M≠N
31//! 3. Recovery sees inconsistent sequences → data loss
32//!
33//! ## Solution
34//!
35//! Use POSIX advisory locks (`flock`/`fcntl`) to enforce:
36//! - Single-process exclusive access to database files
37//! - Fail-fast behavior for concurrent access attempts
38//! - Automatic lock release on process crash
39//!
40//! ## Platform Support
41//!
42//! - **Unix/Linux/macOS**: Uses `flock()` system call
43//! - **Windows**: Uses `LockFileEx()` with `LOCKFILE_EXCLUSIVE_LOCK`
44//!
45//! ## Usage
46//!
47//! ```rust,ignore
48//! use sochdb_storage::lock::DatabaseLock;
49//!
50//! // Acquire exclusive lock (fails fast if already locked)
51//! let lock = DatabaseLock::acquire("/path/to/db")?;
52//!
53//! // Lock held for lifetime of `lock` variable
54//! // ... database operations ...
55//!
56//! // Lock automatically released on drop
57//! drop(lock);
58//! ```
59
60use std::fs::{File, OpenOptions};
61use std::io::{Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{Duration, Instant};
64
65use sochdb_core::SochDBError;
66
67// =============================================================================
68// Error Types
69// =============================================================================
70
71/// Errors specific to database locking operations
72#[derive(Debug)]
73pub enum LockError {
74    /// Database is locked by another process
75    DatabaseLocked {
76        /// PID of the process holding the lock (if known)
77        holder_pid: Option<u32>,
78        /// Path to the lock file
79        lock_path: PathBuf,
80    },
81    /// Lock acquisition timed out
82    Timeout {
83        /// How long we waited
84        elapsed: Duration,
85        /// The configured timeout
86        timeout: Duration,
87    },
88    /// Stale lock detected (holder process no longer exists)
89    StaleLock {
90        /// PID that was recorded in the lock file
91        stale_pid: u32,
92    },
93    /// I/O error during lock operations
94    Io(std::io::Error),
95}
96
97impl std::fmt::Display for LockError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            LockError::DatabaseLocked { holder_pid, lock_path } => {
101                if let Some(pid) = holder_pid {
102                    write!(f, "Database is locked by process {} (lock file: {})", 
103                           pid, lock_path.display())
104                } else {
105                    write!(f, "Database is locked (lock file: {})", lock_path.display())
106                }
107            }
108            LockError::Timeout { elapsed, timeout } => {
109                write!(f, "Lock acquisition timed out after {:?} (timeout: {:?})", 
110                       elapsed, timeout)
111            }
112            LockError::StaleLock { stale_pid } => {
113                write!(f, "Stale lock detected from crashed process {}", stale_pid)
114            }
115            LockError::Io(e) => write!(f, "Lock I/O error: {}", e),
116        }
117    }
118}
119
120impl std::error::Error for LockError {
121    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
122        match self {
123            LockError::Io(e) => Some(e),
124            _ => None,
125        }
126    }
127}
128
129impl From<std::io::Error> for LockError {
130    fn from(e: std::io::Error) -> Self {
131        LockError::Io(e)
132    }
133}
134
135impl From<LockError> for SochDBError {
136    fn from(e: LockError) -> Self {
137        match e {
138            LockError::DatabaseLocked { holder_pid, lock_path } => {
139                SochDBError::LockError(format!(
140                    "Database locked by PID {:?} (lock: {})", 
141                    holder_pid, lock_path.display()
142                ))
143            }
144            LockError::Timeout { elapsed, timeout } => {
145                SochDBError::LockError(format!(
146                    "Lock timeout after {:?} (max: {:?})", elapsed, timeout
147                ))
148            }
149            LockError::StaleLock { stale_pid } => {
150                SochDBError::LockError(format!(
151                    "Stale lock from crashed process {}", stale_pid
152                ))
153            }
154            LockError::Io(e) => SochDBError::Io(e),
155        }
156    }
157}
158
159// =============================================================================
160// Lock Configuration
161// =============================================================================
162
163/// Configuration for database lock behavior
164#[derive(Debug, Clone)]
165pub struct LockConfig {
166    /// Timeout for lock acquisition (None = fail immediately)
167    pub timeout: Option<Duration>,
168    /// Interval between lock retry attempts
169    pub retry_interval: Duration,
170    /// Whether to detect and recover from stale locks
171    pub detect_stale_locks: bool,
172    /// Lock file name (relative to database directory)
173    pub lock_file_name: String,
174}
175
176impl Default for LockConfig {
177    fn default() -> Self {
178        Self {
179            timeout: Some(Duration::from_secs(5)),
180            retry_interval: Duration::from_millis(100),
181            detect_stale_locks: true,
182            lock_file_name: ".lock".to_string(),
183        }
184    }
185}
186
187impl LockConfig {
188    /// Create config with no timeout (fail immediately if locked)
189    pub fn no_wait() -> Self {
190        Self {
191            timeout: None,
192            ..Default::default()
193        }
194    }
195
196    /// Create config with specific timeout
197    pub fn with_timeout(timeout: Duration) -> Self {
198        Self {
199            timeout: Some(timeout),
200            ..Default::default()
201        }
202    }
203}
204
205// =============================================================================
206// Database Lock
207// =============================================================================
208
209/// Exclusive advisory lock on a database directory
210///
211/// This lock ensures single-process access to a SochDB database.
212/// The lock is automatically released when this struct is dropped.
213///
214/// ## Implementation
215///
216/// Uses POSIX `flock()` on Unix systems and `LockFileEx()` on Windows.
217/// The lock file also contains the PID of the lock holder for debugging
218/// and stale lock detection.
219///
220/// ## Safety
221///
222/// Advisory locks are cooperative - they only work if all processes
223/// attempting to access the database use this locking mechanism.
224pub struct DatabaseLock {
225    /// Open file handle (keeps the lock active)
226    lock_file: File,
227    /// Path to the lock file
228    path: PathBuf,
229    /// Our PID (for diagnostics)
230    our_pid: u32,
231}
232
233impl DatabaseLock {
234    /// Acquire exclusive lock on a database directory
235    ///
236    /// Uses the default timeout (5 seconds) to allow concurrent processes
237    /// to wait for the lock, similar to SQLite's busy timeout behavior.
238    /// This enables multi-process patterns like ProcessPoolExecutor where
239    /// each worker serializes database access through lock contention.
240    ///
241    /// # Arguments
242    ///
243    /// * `db_path` - Path to the database directory
244    ///
245    /// # Returns
246    ///
247    /// Returns `Ok(DatabaseLock)` if lock acquired successfully.
248    /// Returns `Err(LockError::Timeout)` if lock not acquired within 5 seconds.
249    /// Returns `Err(LockError::DatabaseLocked)` if another process holds the lock
250    /// and stale lock detection fails.
251    ///
252    /// # Example
253    ///
254    /// ```rust,ignore
255    /// let lock = DatabaseLock::acquire("/path/to/db")?;
256    /// // Lock is held until `lock` is dropped
257    /// ```
258    pub fn acquire<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
259        Self::acquire_with_config(db_path, &LockConfig::default())
260    }
261
262    /// Acquire exclusive lock without waiting (fail immediately if locked)
263    ///
264    /// Use this only when you want to detect if another process has the
265    /// database open, without blocking.
266    pub fn acquire_no_wait<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
267        Self::acquire_with_config(db_path, &LockConfig::no_wait())
268    }
269
270    /// Acquire exclusive lock with timeout
271    ///
272    /// Will retry lock acquisition until timeout expires.
273    ///
274    /// # Arguments
275    ///
276    /// * `db_path` - Path to the database directory
277    /// * `timeout` - Maximum time to wait for lock
278    pub fn acquire_with_timeout<P: AsRef<Path>>(
279        db_path: P, 
280        timeout: Duration
281    ) -> std::result::Result<Self, LockError> {
282        Self::acquire_with_config(db_path, &LockConfig::with_timeout(timeout))
283    }
284
285    /// Acquire exclusive lock with full configuration
286    pub fn acquire_with_config<P: AsRef<Path>>(
287        db_path: P,
288        config: &LockConfig,
289    ) -> std::result::Result<Self, LockError> {
290        let db_path = db_path.as_ref();
291        let lock_path = db_path.join(&config.lock_file_name);
292
293        // Ensure database directory exists
294        if !db_path.exists() {
295            std::fs::create_dir_all(db_path)?;
296        }
297
298        let deadline = config.timeout.map(|t| Instant::now() + t);
299        let our_pid = std::process::id();
300
301        loop {
302            // Try to open/create lock file
303            let file = OpenOptions::new()
304                .create(true)
305                .read(true)
306                .write(true)
307                .open(&lock_path)?;
308
309            // Attempt to acquire exclusive lock
310            match Self::try_flock(&file, false) {
311                Ok(()) => {
312                    // Lock acquired! Write our PID
313                    Self::write_pid(&file, our_pid)?;
314                    
315                    return Ok(Self {
316                        lock_file: file,
317                        path: lock_path,
318                        our_pid,
319                    });
320                }
321                Err(LockError::DatabaseLocked { .. }) => {
322                    // Lock is held by another process
323                    
324                    // Check for stale lock
325                    let mut should_retry = false;
326                    if config.detect_stale_locks {
327                        if let Some(holder_pid) = Self::read_pid(&file) {
328                            if !Self::process_exists(holder_pid) {
329                                // Process is dead - try to take over
330                                // We need to close and reopen to clear state
331                                drop(file);
332                                
333                                // Force remove the lock file
334                                if std::fs::remove_file(&lock_path).is_ok() {
335                                    should_retry = true;
336                                }
337                            }
338                        }
339                    }
340                    
341                    if should_retry {
342                        continue; // Retry acquisition
343                    }
344
345                    // Check timeout
346                    if let Some(deadline) = deadline {
347                        if Instant::now() >= deadline {
348                            return Err(LockError::Timeout {
349                                elapsed: config.timeout.unwrap_or_default(),
350                                timeout: config.timeout.unwrap_or_default(),
351                            });
352                        }
353                        
354                        // Wait and retry
355                        std::thread::sleep(config.retry_interval);
356                        continue;
357                    } else {
358                        // No timeout - fail immediately
359                        // Note: file may have been dropped above, so we can't read PID
360                        return Err(LockError::DatabaseLocked { 
361                            holder_pid: None, 
362                            lock_path 
363                        });
364                    }
365                }
366                Err(e) => return Err(e),
367            }
368        }
369    }
370
371    /// Get path to the lock file
372    pub fn path(&self) -> &Path {
373        &self.path
374    }
375
376    /// Get PID of lock holder (us)
377    pub fn pid(&self) -> u32 {
378        self.our_pid
379    }
380
381    /// Check if a given PID is holding the lock on a database
382    ///
383    /// Useful for diagnostics without attempting to acquire.
384    pub fn get_lock_holder<P: AsRef<Path>>(db_path: P) -> Option<u32> {
385        let lock_path = db_path.as_ref().join(".lock");
386        let file = File::open(&lock_path).ok()?;
387        Self::read_pid(&file)
388    }
389
390    /// Write PID to lock file
391    fn write_pid(file: &File, pid: u32) -> std::result::Result<(), LockError> {
392        use std::io::Seek;
393        let mut file = file;
394        file.seek(std::io::SeekFrom::Start(0))?;
395        file.set_len(0)?;
396        writeln!(file, "{}", pid)?;
397        file.sync_all()?;
398        Ok(())
399    }
400
401    /// Read PID from lock file
402    fn read_pid(file: &File) -> Option<u32> {
403        use std::io::Seek;
404        let mut file = file;
405        let _ = file.seek(std::io::SeekFrom::Start(0));
406        let mut contents = String::new();
407        file.read_to_string(&mut contents).ok()?;
408        contents.trim().parse().ok()
409    }
410
411    /// Check if a process exists
412    #[cfg(unix)]
413    fn process_exists(pid: u32) -> bool {
414        // kill(pid, 0) checks if process exists without sending a signal
415        // Returns 0 if process exists, -1 with ESRCH if not
416        let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
417        if result == 0 {
418            true
419        } else {
420            // Check if error is ESRCH (no such process)
421            let errno = std::io::Error::last_os_error().raw_os_error();
422            errno != Some(libc::ESRCH)
423        }
424    }
425
426    #[cfg(windows)]
427    fn process_exists(pid: u32) -> bool {
428        unsafe {
429            let handle = windows_sys::Win32::System::Threading::OpenProcess(
430                windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION,
431                0,
432                pid,
433            );
434            if handle == 0 || handle == -1 {
435                false
436            } else {
437                windows_sys::Win32::Foundation::CloseHandle(handle);
438                true
439            }
440        }
441    }
442
443    #[cfg(not(any(unix, windows)))]
444    fn process_exists(_pid: u32) -> bool {
445        // On unknown platforms, assume process exists to be safe
446        true
447    }
448
449    /// Try to acquire flock on file
450    #[cfg(unix)]
451    fn try_flock(file: &File, blocking: bool) -> std::result::Result<(), LockError> {
452        use std::os::unix::io::AsRawFd;
453        
454        let fd = file.as_raw_fd();
455        let operation = if blocking {
456            libc::LOCK_EX
457        } else {
458            libc::LOCK_EX | libc::LOCK_NB
459        };
460
461        let result = unsafe { libc::flock(fd, operation) };
462        
463        if result == 0 {
464            Ok(())
465        } else {
466            let err = std::io::Error::last_os_error();
467            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
468                Err(LockError::DatabaseLocked {
469                    holder_pid: None,
470                    lock_path: PathBuf::new(),
471                })
472            } else {
473                Err(LockError::Io(err))
474            }
475        }
476    }
477
478    #[cfg(windows)]
479    fn try_flock(file: &File, blocking: bool) -> std::result::Result<(), LockError> {
480        use std::os::windows::io::AsRawHandle;
481        
482        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
483        
484        let flags = windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK
485            | if blocking { 0 } else { windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY };
486        
487        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
488        
489        let result = unsafe {
490            windows_sys::Win32::Storage::FileSystem::LockFileEx(
491                handle,
492                flags,
493                0,
494                1,
495                0,
496                &mut overlapped,
497            )
498        };
499        
500        if result != 0 {
501            Ok(())
502        } else {
503            let err = std::io::Error::last_os_error();
504            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
505                Err(LockError::DatabaseLocked {
506                    holder_pid: None,
507                    lock_path: PathBuf::new(),
508                })
509            } else {
510                Err(LockError::Io(err))
511            }
512        }
513    }
514
515    #[cfg(not(any(unix, windows)))]
516    fn try_flock(_file: &File, _blocking: bool) -> std::result::Result<(), LockError> {
517        // On unsupported platforms, assume success (no locking)
518        // This is unsafe but allows compilation
519        Ok(())
520    }
521
522    /// Release the lock (called automatically on drop)
523    #[cfg(unix)]
524    fn release(&self) {
525        use std::os::unix::io::AsRawFd;
526        let fd = self.lock_file.as_raw_fd();
527        unsafe { libc::flock(fd, libc::LOCK_UN) };
528    }
529
530    #[cfg(windows)]
531    fn release(&self) {
532        use std::os::windows::io::AsRawHandle;
533        let handle = self.lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
534        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
535        unsafe {
536            windows_sys::Win32::Storage::FileSystem::UnlockFileEx(
537                handle,
538                0,
539                1,
540                0,
541                &mut overlapped,
542            );
543        }
544    }
545
546    #[cfg(not(any(unix, windows)))]
547    fn release(&self) {
548        // No-op on unsupported platforms
549    }
550}
551
552impl Drop for DatabaseLock {
553    fn drop(&mut self) {
554        self.release();
555        // Lock file is removed when the last handle is closed
556        // We explicitly remove it for cleaner state
557        let _ = std::fs::remove_file(&self.path);
558    }
559}
560
561// =============================================================================
562// Reader-Writer Lock Protocol (Task 3)
563// =============================================================================
564
565/// Shared-Exclusive lock state stored in lock file header
566///
567/// Format (16 bytes):
568/// ```text
569/// ┌────────────┬────────────────┬──────────────────┬─────────┐
570/// │ reader_cnt │ writer_intent  │ writer_active    │ padding │
571/// │ (4 bytes)  │ (4 bytes)      │ (4 bytes)        │ (4 B)   │
572/// └────────────┴────────────────┴──────────────────┴─────────┘
573/// ```
574#[repr(C)]
575#[derive(Debug, Clone, Copy, Default)]
576pub struct RwLockState {
577    /// Number of active readers
578    pub reader_count: u32,
579    /// Writer waiting to acquire (prevents reader starvation)
580    pub writer_intent: u32,
581    /// Writer currently active
582    pub writer_active: u32,
583    /// Reserved for future use
584    pub _padding: u32,
585}
586
587/// Connection mode for database access
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum ConnectionMode {
590    /// Read-only access (acquires shared lock)
591    ReadOnly,
592    /// Read-write access (acquires exclusive lock)
593    ReadWrite,
594}
595
596/// Reader-Writer database lock for concurrent read access
597///
598/// Implements a shared-exclusive lock protocol:
599/// - Multiple concurrent readers allowed
600/// - Single exclusive writer
601/// - Writer intent prevents reader starvation
602pub struct RwDatabaseLock {
603    /// Open file handle
604    lock_file: File,
605    /// Path to lock file
606    path: PathBuf,
607    /// Our connection mode
608    mode: ConnectionMode,
609    /// Our PID
610    our_pid: u32,
611}
612
613impl RwDatabaseLock {
614    /// Acquire a shared (read-only) lock
615    ///
616    /// Multiple processes can hold shared locks simultaneously.
617    /// Blocks if a writer is active or waiting.
618    pub fn acquire_shared<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
619        Self::acquire_with_mode(db_path, ConnectionMode::ReadOnly, &LockConfig::default())
620    }
621
622    /// Acquire an exclusive (read-write) lock
623    ///
624    /// Only one process can hold an exclusive lock.
625    /// Blocks if any readers or another writer is active.
626    pub fn acquire_exclusive<P: AsRef<Path>>(db_path: P) -> std::result::Result<Self, LockError> {
627        Self::acquire_with_mode(db_path, ConnectionMode::ReadWrite, &LockConfig::default())
628    }
629
630    /// Acquire lock with specified mode and configuration
631    pub fn acquire_with_mode<P: AsRef<Path>>(
632        db_path: P,
633        mode: ConnectionMode,
634        config: &LockConfig,
635    ) -> std::result::Result<Self, LockError> {
636        let db_path = db_path.as_ref();
637        let lock_path = db_path.join(&config.lock_file_name);
638        
639        if !db_path.exists() {
640            std::fs::create_dir_all(db_path)?;
641        }
642
643        let file = OpenOptions::new()
644            .create(true)
645            .read(true)
646            .write(true)
647            .open(&lock_path)?;
648
649        let our_pid = std::process::id();
650        let deadline = config.timeout.map(|t| Instant::now() + t);
651
652        loop {
653            match mode {
654                ConnectionMode::ReadOnly => {
655                    // Acquire shared lock
656                    if Self::try_shared_lock(&file)? {
657                        return Ok(Self {
658                            lock_file: file,
659                            path: lock_path,
660                            mode,
661                            our_pid,
662                        });
663                    }
664                }
665                ConnectionMode::ReadWrite => {
666                    // Acquire exclusive lock
667                    if Self::try_exclusive_lock(&file)? {
668                        return Ok(Self {
669                            lock_file: file,
670                            path: lock_path,
671                            mode,
672                            our_pid,
673                        });
674                    }
675                }
676            }
677
678            // Check timeout
679            if let Some(deadline) = deadline {
680                if Instant::now() >= deadline {
681                    return Err(LockError::Timeout {
682                        elapsed: config.timeout.unwrap_or_default(),
683                        timeout: config.timeout.unwrap_or_default(),
684                    });
685                }
686                std::thread::sleep(config.retry_interval);
687            } else {
688                return Err(LockError::DatabaseLocked {
689                    holder_pid: None,
690                    lock_path,
691                });
692            }
693        }
694    }
695
696    /// Get connection mode
697    pub fn mode(&self) -> ConnectionMode {
698        self.mode
699    }
700
701    /// Check if this is a read-only connection
702    pub fn is_readonly(&self) -> bool {
703        self.mode == ConnectionMode::ReadOnly
704    }
705
706    #[cfg(unix)]
707    fn try_shared_lock(file: &File) -> std::result::Result<bool, LockError> {
708        use std::os::unix::io::AsRawFd;
709        let fd = file.as_raw_fd();
710        let result = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
711        if result == 0 {
712            Ok(true)
713        } else {
714            let err = std::io::Error::last_os_error();
715            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
716                Ok(false)
717            } else {
718                Err(LockError::Io(err))
719            }
720        }
721    }
722
723    #[cfg(unix)]
724    fn try_exclusive_lock(file: &File) -> std::result::Result<bool, LockError> {
725        use std::os::unix::io::AsRawFd;
726        let fd = file.as_raw_fd();
727        let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
728        if result == 0 {
729            Ok(true)
730        } else {
731            let err = std::io::Error::last_os_error();
732            if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
733                Ok(false)
734            } else {
735                Err(LockError::Io(err))
736            }
737        }
738    }
739
740    #[cfg(windows)]
741    fn try_shared_lock(file: &File) -> std::result::Result<bool, LockError> {
742        use std::os::windows::io::AsRawHandle;
743        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
744        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
745        
746        let result = unsafe {
747            windows_sys::Win32::Storage::FileSystem::LockFileEx(
748                handle,
749                windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY,
750                0, 1, 0,
751                &mut overlapped,
752            )
753        };
754        
755        if result != 0 {
756            Ok(true)
757        } else {
758            let err = std::io::Error::last_os_error();
759            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
760                Ok(false)
761            } else {
762                Err(LockError::Io(err))
763            }
764        }
765    }
766
767    #[cfg(windows)]
768    fn try_exclusive_lock(file: &File) -> std::result::Result<bool, LockError> {
769        use std::os::windows::io::AsRawHandle;
770        let handle = file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
771        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
772        
773        let result = unsafe {
774            windows_sys::Win32::Storage::FileSystem::LockFileEx(
775                handle,
776                windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK 
777                    | windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY,
778                0, 1, 0,
779                &mut overlapped,
780            )
781        };
782        
783        if result != 0 {
784            Ok(true)
785        } else {
786            let err = std::io::Error::last_os_error();
787            if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) {
788                Ok(false)
789            } else {
790                Err(LockError::Io(err))
791            }
792        }
793    }
794
795    #[cfg(not(any(unix, windows)))]
796    fn try_shared_lock(_file: &File) -> std::result::Result<bool, LockError> {
797        Ok(true)
798    }
799
800    #[cfg(not(any(unix, windows)))]
801    fn try_exclusive_lock(_file: &File) -> std::result::Result<bool, LockError> {
802        Ok(true)
803    }
804
805    #[cfg(unix)]
806    fn release(&self) {
807        use std::os::unix::io::AsRawFd;
808        let fd = self.lock_file.as_raw_fd();
809        unsafe { libc::flock(fd, libc::LOCK_UN) };
810    }
811
812    #[cfg(windows)]
813    fn release(&self) {
814        use std::os::windows::io::AsRawHandle;
815        let handle = self.lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
816        let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = unsafe { std::mem::zeroed() };
817        unsafe {
818            windows_sys::Win32::Storage::FileSystem::UnlockFileEx(handle, 0, 1, 0, &mut overlapped);
819        }
820    }
821
822    #[cfg(not(any(unix, windows)))]
823    fn release(&self) {}
824}
825
826impl Drop for RwDatabaseLock {
827    fn drop(&mut self) {
828        self.release();
829    }
830}
831
832// =============================================================================
833// Tests
834// =============================================================================
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use std::thread;
840    use tempfile::TempDir;
841
842    #[test]
843    fn test_exclusive_lock_basic() {
844        let dir = TempDir::new().unwrap();
845        let db_path = dir.path();
846
847        // First lock should succeed
848        let lock1 = DatabaseLock::acquire(db_path);
849        assert!(lock1.is_ok());
850
851        // Second lock should fail immediately when using no_wait
852        let lock2 = DatabaseLock::acquire_no_wait(db_path);
853        assert!(matches!(lock2, Err(LockError::DatabaseLocked { .. })));
854
855        // After releasing first lock, second should succeed
856        drop(lock1);
857        let lock3 = DatabaseLock::acquire(db_path);
858        assert!(lock3.is_ok());
859    }
860
861    #[test]
862    fn test_acquire_default_timeout() {
863        let dir = TempDir::new().unwrap();
864        let db_path = dir.path().to_path_buf();
865
866        // Acquire lock
867        let _lock = DatabaseLock::acquire(&db_path).unwrap();
868
869        // acquire() with default config should timeout (5s), not fail immediately
870        // Spawn a thread that releases the lock after 200ms
871        let db_path2 = db_path.clone();
872        let lock_holder = _lock;
873        let handle = thread::spawn(move || {
874            thread::sleep(Duration::from_millis(200));
875            drop(lock_holder);
876        });
877
878        // Second acquire should succeed within 5s (lock released after 200ms)
879        let start = Instant::now();
880        let result = DatabaseLock::acquire(&db_path2);
881        let elapsed = start.elapsed();
882
883        assert!(result.is_ok(), "acquire() should succeed after lock is released");
884        assert!(elapsed >= Duration::from_millis(100), "should have waited for lock");
885        assert!(elapsed < Duration::from_secs(2), "should not wait too long");
886
887        handle.join().unwrap();
888    }
889
890    #[test]
891    fn test_lock_with_timeout() {
892        let dir = TempDir::new().unwrap();
893        let db_path = dir.path().to_path_buf();
894
895        // Acquire lock
896        let _lock = DatabaseLock::acquire(&db_path).unwrap();
897
898        // Try with short timeout - should fail
899        let start = Instant::now();
900        let result = DatabaseLock::acquire_with_timeout(&db_path, Duration::from_millis(100));
901        let elapsed = start.elapsed();
902
903        assert!(matches!(result, Err(LockError::Timeout { .. })));
904        assert!(elapsed >= Duration::from_millis(100));
905        assert!(elapsed < Duration::from_millis(500)); // Shouldn't be too long
906    }
907
908    #[test]
909    fn test_lock_pid_recorded() {
910        let dir = TempDir::new().unwrap();
911        let db_path = dir.path();
912
913        let lock = DatabaseLock::acquire(db_path).unwrap();
914        let our_pid = std::process::id();
915        
916        assert_eq!(lock.pid(), our_pid);
917        
918        // Check we can read the holder
919        let holder = DatabaseLock::get_lock_holder(db_path);
920        assert_eq!(holder, Some(our_pid));
921    }
922
923    #[test]
924    fn test_shared_lock_multiple_readers() {
925        let dir = TempDir::new().unwrap();
926        let db_path = dir.path();
927
928        // Multiple shared locks should succeed
929        let lock1 = RwDatabaseLock::acquire_shared(db_path);
930        let lock2 = RwDatabaseLock::acquire_shared(db_path);
931
932        assert!(lock1.is_ok());
933        assert!(lock2.is_ok());
934    }
935
936    #[test]
937    fn test_exclusive_blocks_shared() {
938        let dir = TempDir::new().unwrap();
939        let db_path = dir.path();
940
941        // Exclusive lock first
942        let _exclusive = RwDatabaseLock::acquire_exclusive(db_path).unwrap();
943
944        // Shared lock should fail immediately with no timeout
945        let shared = RwDatabaseLock::acquire_with_mode(
946            db_path,
947            ConnectionMode::ReadOnly,
948            &LockConfig::no_wait(),
949        );
950        
951        assert!(matches!(shared, Err(LockError::DatabaseLocked { .. })));
952    }
953}