lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

lock.rs (10709B)


      1 //! SQLite process and writer locking boundary.
      2 
      3 use crate::{Error, OpenMode, Paths};
      4 use fs2::FileExt;
      5 use std::fs::{self, File, OpenOptions};
      6 use std::path::{Path, PathBuf};
      7 
      8 const WRITER_LOCK_FILE_NAME: &str = ".radroots-storage-writer.lock";
      9 
     10 /// Lifetime guard for the one writable backend permitted for an owned store.
     11 ///
     12 /// The persistent empty sidecar is never removed during normal operation.
     13 /// Keeping one inode avoids an unlink/recreate race between competing clients;
     14 /// dropping the file descriptor releases the operating-system advisory lock.
     15 pub(crate) struct WriterLock {
     16     file: File,
     17     path: PathBuf,
     18 }
     19 
     20 impl WriterLock {
     21     /// Acquires the governed lock for writable modes without hidden waiting.
     22     /// Read-only clients deliberately acquire no advisory lock.
     23     #[cfg_attr(coverage_nightly, coverage(off))]
     24     pub(crate) fn acquire(paths: &Paths, mode: OpenMode) -> Result<Option<Self>, Error> {
     25         if !mode.is_writable() {
     26             return Ok(None);
     27         }
     28         let path = writer_lock_path(paths)?;
     29         let file = open_lock_file(&path)?;
     30         match FileExt::try_lock_exclusive(&file) {
     31             Ok(()) => Ok(Some(Self { file, path })),
     32             Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => {
     33                 Err(Error::WriterAlreadyActive { path })
     34             }
     35             Err(source) => Err(Error::WriterLockFailed { path, source }),
     36         }
     37     }
     38 
     39     /// Explicitly releases the writer lock for the later asynchronous close
     40     /// lifecycle. Dropping the guard remains a fail-safe release path.
     41     #[cfg_attr(coverage_nightly, coverage(off))]
     42     pub(crate) fn release(self) -> Result<(), Error> {
     43         FileExt::unlock(&self.file).map_err(|source| Error::WriterUnlockFailed {
     44             path: self.path.clone(),
     45             source,
     46         })
     47     }
     48 
     49     #[cfg(test)]
     50     fn path(&self) -> &Path {
     51         &self.path
     52     }
     53 }
     54 
     55 #[cfg_attr(coverage_nightly, coverage(off))]
     56 fn writer_lock_path(paths: &Paths) -> Result<PathBuf, Error> {
     57     let parent = paths
     58         .runtime()
     59         .parent()
     60         .ok_or_else(|| Error::InvalidPath(paths.runtime().to_path_buf()))?;
     61     let canonical_parent = fs::canonicalize(parent).map_err(|source| Error::Inspect {
     62         path: parent.to_path_buf(),
     63         source,
     64     })?;
     65     Ok(canonical_parent.join(WRITER_LOCK_FILE_NAME))
     66 }
     67 
     68 #[cfg_attr(coverage_nightly, coverage(off))]
     69 fn open_lock_file(path: &Path) -> Result<File, Error> {
     70     match create_lock_file(path) {
     71         Ok(file) => validate_open_file(path, file),
     72         Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
     73             let metadata = fs::symlink_metadata(path).map_err(|source| Error::WriterLockOpen {
     74                 path: path.to_path_buf(),
     75                 source,
     76             })?;
     77             if metadata.file_type().is_symlink() {
     78                 return Err(Error::SymlinkPath(path.to_path_buf()));
     79             }
     80             if !metadata.is_file() {
     81                 return Err(Error::NotAFile(path.to_path_buf()));
     82             }
     83             let file = OpenOptions::new()
     84                 .read(true)
     85                 .write(true)
     86                 .open(path)
     87                 .map_err(|source| Error::WriterLockOpen {
     88                     path: path.to_path_buf(),
     89                     source,
     90                 })?;
     91             validate_open_file(path, file)
     92         }
     93         Err(source) => Err(Error::WriterLockOpen {
     94             path: path.to_path_buf(),
     95             source,
     96         }),
     97     }
     98 }
     99 
    100 #[cfg_attr(coverage_nightly, coverage(off))]
    101 fn create_lock_file(path: &Path) -> std::io::Result<File> {
    102     let mut options = OpenOptions::new();
    103     options.create_new(true).read(true).write(true);
    104     #[cfg(unix)]
    105     {
    106         use std::os::unix::fs::OpenOptionsExt;
    107         options.mode(0o600);
    108     }
    109     options.open(path)
    110 }
    111 
    112 #[cfg_attr(coverage_nightly, coverage(off))]
    113 fn validate_open_file(path: &Path, file: File) -> Result<File, Error> {
    114     let metadata = file.metadata().map_err(|source| Error::WriterLockOpen {
    115         path: path.to_path_buf(),
    116         source,
    117     })?;
    118     if !metadata.is_file() {
    119         return Err(Error::NotAFile(path.to_path_buf()));
    120     }
    121     Ok(file)
    122 }
    123 
    124 #[cfg(test)]
    125 mod tests {
    126     use super::*;
    127     use serde::Deserialize;
    128 
    129     const POLICY: &str = include_str!("../../../contracts/storage/writer_policy_v1.toml");
    130 
    131     #[derive(Deserialize)]
    132     struct Policy {
    133         schema_version: u32,
    134         lock_file_name: String,
    135         lock_scope: String,
    136         acquisition: String,
    137         sidecar_lifecycle: String,
    138         writable_modes: Vec<String>,
    139         read_only_lock: String,
    140         concurrent_readers: bool,
    141         reader_with_writer: bool,
    142         concurrent_writers: bool,
    143     }
    144 
    145     fn paths(directory: &Path) -> Paths {
    146         Paths::from_directory(directory).expect("owned paths")
    147     }
    148 
    149     #[test]
    150     fn implementation_matches_the_governed_multi_client_policy() {
    151         let policy = toml::from_str::<Policy>(POLICY).expect("writer policy");
    152         assert_eq!(policy.schema_version, 1);
    153         assert_eq!(policy.lock_file_name, WRITER_LOCK_FILE_NAME);
    154         assert_eq!(policy.lock_scope, "canonical_runtime_parent");
    155         assert_eq!(policy.acquisition, "non_blocking_exclusive_advisory");
    156         assert_eq!(policy.sidecar_lifecycle, "persistent_empty_file");
    157         assert_eq!(policy.writable_modes, ["read_write_existing", "create"]);
    158         assert_eq!(policy.read_only_lock, "none");
    159         assert!(policy.concurrent_readers);
    160         assert!(policy.reader_with_writer);
    161         assert!(!policy.concurrent_writers);
    162     }
    163 
    164     #[test]
    165     fn one_writer_excludes_other_clients_until_explicit_release() {
    166         let directory = tempfile::tempdir().expect("temporary directory");
    167         let paths = paths(directory.path());
    168         let first = WriterLock::acquire(&paths, OpenMode::Create)
    169             .expect("first writer")
    170             .expect("writer guard");
    171         assert_eq!(first.path(), directory.path().join(WRITER_LOCK_FILE_NAME));
    172         assert!(first.path().is_file());
    173         assert!(matches!(
    174             WriterLock::acquire(&paths, OpenMode::ReadWriteExisting),
    175             Err(Error::WriterAlreadyActive { .. })
    176         ));
    177 
    178         first.release().expect("release first writer");
    179         let next = WriterLock::acquire(&paths, OpenMode::ReadWriteExisting)
    180             .expect("next writer")
    181             .expect("writer guard");
    182         drop(next);
    183         assert!(
    184             directory.path().join(WRITER_LOCK_FILE_NAME).is_file(),
    185             "the stable sidecar must not be unlinked"
    186         );
    187     }
    188 
    189     #[test]
    190     fn read_only_clients_take_no_lock_and_can_coexist_with_a_writer() {
    191         let directory = tempfile::tempdir().expect("temporary directory");
    192         let paths = paths(directory.path());
    193         let writer = WriterLock::acquire(&paths, OpenMode::Create)
    194             .expect("writer")
    195             .expect("writer guard");
    196         assert!(
    197             WriterLock::acquire(&paths, OpenMode::ReadOnly)
    198                 .expect("first reader")
    199                 .is_none()
    200         );
    201         assert!(
    202             WriterLock::acquire(&paths, OpenMode::ReadOnly)
    203                 .expect("second reader")
    204                 .is_none()
    205         );
    206         drop(writer);
    207     }
    208 
    209     #[test]
    210     fn writer_lock_is_observed_by_a_distinct_process() {
    211         let directory = tempfile::tempdir().expect("temporary directory");
    212         let paths = paths(directory.path());
    213         let writer = WriterLock::acquire(&paths, OpenMode::Create)
    214             .expect("writer")
    215             .expect("writer guard");
    216         let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
    217             .arg("--exact")
    218             .arg("lock::tests::writer_lock_child_probe")
    219             .arg("--nocapture")
    220             .env("RADROOTS_WRITER_LOCK_CHILD_PATH", directory.path())
    221             .status()
    222             .expect("run lock child");
    223         assert!(status.success(), "child must observe writer contention");
    224         drop(writer);
    225     }
    226 
    227     #[test]
    228     fn writer_lock_child_probe() {
    229         let Some(directory) = std::env::var_os("RADROOTS_WRITER_LOCK_CHILD_PATH") else {
    230             return;
    231         };
    232         assert!(matches!(
    233             WriterLock::acquire(&paths(Path::new(&directory)), OpenMode::Create),
    234             Err(Error::WriterAlreadyActive { .. })
    235         ));
    236     }
    237 
    238     #[test]
    239     fn stores_in_distinct_canonical_directories_do_not_contend() {
    240         let first_directory = tempfile::tempdir().expect("first directory");
    241         let second_directory = tempfile::tempdir().expect("second directory");
    242         let first = WriterLock::acquire(&paths(first_directory.path()), OpenMode::Create)
    243             .expect("first writer")
    244             .expect("first guard");
    245         let second = WriterLock::acquire(&paths(second_directory.path()), OpenMode::Create)
    246             .expect("second writer")
    247             .expect("second guard");
    248         assert_ne!(first.path(), second.path());
    249     }
    250 
    251     #[cfg(unix)]
    252     #[test]
    253     fn symlink_lock_files_fail_closed() {
    254         use std::os::unix::fs::symlink;
    255 
    256         let directory = tempfile::tempdir().expect("temporary directory");
    257         let target = directory.path().join("unrelated");
    258         fs::write(&target, []).expect("target");
    259         symlink(&target, directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock symlink");
    260         assert!(matches!(
    261             WriterLock::acquire(&paths(directory.path()), OpenMode::Create),
    262             Err(Error::SymlinkPath(_))
    263         ));
    264     }
    265 
    266     #[cfg(unix)]
    267     #[test]
    268     fn canonical_parent_aliases_share_one_writer_lock() {
    269         use std::os::unix::fs::symlink;
    270 
    271         let root = tempfile::tempdir().expect("temporary root");
    272         let actual = root.path().join("actual");
    273         let alias = root.path().join("alias");
    274         fs::create_dir(&actual).expect("actual directory");
    275         symlink(&actual, &alias).expect("directory alias");
    276         let writer = WriterLock::acquire(&paths(&actual), OpenMode::Create)
    277             .expect("writer")
    278             .expect("writer guard");
    279         assert!(matches!(
    280             WriterLock::acquire(&paths(&alias), OpenMode::Create),
    281             Err(Error::WriterAlreadyActive { .. })
    282         ));
    283         drop(writer);
    284     }
    285 
    286     #[test]
    287     fn non_file_lock_paths_fail_closed() {
    288         let directory = tempfile::tempdir().expect("temporary directory");
    289         fs::create_dir(directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock directory");
    290         assert!(matches!(
    291             WriterLock::acquire(&paths(directory.path()), OpenMode::Create),
    292             Err(Error::NotAFile(_))
    293         ));
    294     }
    295 }