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