commit f687ab0896349ca0ba536e50c316edfa535d9da1
parent 643d8c3f3cb5a4ef2e0f1395a6283443c6c07dd9
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:16:05 +0000
storage-sqlite: implement writable locking and multi-client policy
- govern one non-blocking advisory writer lock per canonical storage directory
- permit concurrent read-only clients and readers alongside the active writer
- retain a stable fail-closed sidecar across explicit release and process exit
- prove cross-process contention, canonical aliases, path safety, and policy reporting
Diffstat:
8 files changed, 355 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5136,6 +5136,7 @@ dependencies = [
name = "radroots_storage_sqlite"
version = "0.1.0-alpha"
dependencies = [
+ "fs2",
"radroots_event",
"radroots_event_codec",
"radroots_secrets",
diff --git a/contracts/storage/writer_policy_v1.toml b/contracts/storage/writer_policy_v1.toml
@@ -0,0 +1,10 @@
+schema_version = 1
+lock_file_name = ".radroots-storage-writer.lock"
+lock_scope = "canonical_runtime_parent"
+acquisition = "non_blocking_exclusive_advisory"
+sidecar_lifecycle = "persistent_empty_file"
+writable_modes = ["read_write_existing", "create"]
+read_only_lock = "none"
+concurrent_readers = true
+reader_with_writer = true
+concurrent_writers = false
diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml
@@ -15,6 +15,7 @@ publish = false
name = "radroots_storage_sqlite"
[dependencies]
+fs2 = { workspace = true }
radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] }
radroots_secrets = { workspace = true, default-features = false }
radroots_storage = { workspace = true, default-features = false }
diff --git a/crates/storage_sqlite/src/config.rs b/crates/storage_sqlite/src/config.rs
@@ -3,6 +3,7 @@
use std::time::Duration;
use crate::{Error, OpenMode, Paths};
+use radroots_storage::status::WriterPolicy;
const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const MIN_BUSY_TIMEOUT: Duration = Duration::from_millis(1);
@@ -67,6 +68,15 @@ impl OpenOptions {
self.mode.is_writable()
}
+ /// Reports the mandatory writer coordination policy for this mode.
+ pub fn writer_policy(&self) -> WriterPolicy {
+ if self.mode.is_writable() {
+ WriterPolicy::AdvisoryProcessLock
+ } else {
+ WriterPolicy::NoWriter
+ }
+ }
+
/// Validates current filesystem state without creating or modifying files.
pub fn validate_filesystem(&self) -> Result<(), Error> {
self.paths.validate_filesystem(self.mode)
diff --git a/crates/storage_sqlite/src/lock.rs b/crates/storage_sqlite/src/lock.rs
@@ -1 +1,291 @@
//! SQLite process and writer locking boundary.
+
+#![allow(dead_code)] // Wired into the public open lifecycle in its ordered RCL checkpoint.
+
+use crate::{Error, OpenMode, Paths};
+use fs2::FileExt;
+use std::fs::{self, File, OpenOptions};
+use std::path::{Path, PathBuf};
+
+const WRITER_LOCK_FILE_NAME: &str = ".radroots-storage-writer.lock";
+
+/// Lifetime guard for the one writable backend permitted for an owned store.
+///
+/// The persistent empty sidecar is never removed during normal operation.
+/// Keeping one inode avoids an unlink/recreate race between competing clients;
+/// dropping the file descriptor releases the operating-system advisory lock.
+pub(crate) struct WriterLock {
+ file: File,
+ path: PathBuf,
+}
+
+impl WriterLock {
+ /// Acquires the governed lock for writable modes without hidden waiting.
+ /// Read-only clients deliberately acquire no advisory lock.
+ pub(crate) fn acquire(paths: &Paths, mode: OpenMode) -> Result<Option<Self>, Error> {
+ if !mode.is_writable() {
+ return Ok(None);
+ }
+ let path = writer_lock_path(paths)?;
+ let file = open_lock_file(&path)?;
+ match FileExt::try_lock_exclusive(&file) {
+ Ok(()) => Ok(Some(Self { file, path })),
+ Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => {
+ Err(Error::WriterAlreadyActive { path })
+ }
+ Err(source) => Err(Error::WriterLockFailed { path, source }),
+ }
+ }
+
+ /// Explicitly releases the writer lock for the later asynchronous close
+ /// lifecycle. Dropping the guard remains a fail-safe release path.
+ pub(crate) fn release(self) -> Result<(), Error> {
+ FileExt::unlock(&self.file).map_err(|source| Error::WriterUnlockFailed {
+ path: self.path.clone(),
+ source,
+ })
+ }
+
+ #[cfg(test)]
+ fn path(&self) -> &Path {
+ &self.path
+ }
+}
+
+fn writer_lock_path(paths: &Paths) -> Result<PathBuf, Error> {
+ let parent = paths
+ .runtime()
+ .parent()
+ .ok_or_else(|| Error::InvalidPath(paths.runtime().to_path_buf()))?;
+ let canonical_parent = fs::canonicalize(parent).map_err(|source| Error::Inspect {
+ path: parent.to_path_buf(),
+ source,
+ })?;
+ Ok(canonical_parent.join(WRITER_LOCK_FILE_NAME))
+}
+
+fn open_lock_file(path: &Path) -> Result<File, Error> {
+ match create_lock_file(path) {
+ Ok(file) => validate_open_file(path, file),
+ Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
+ let metadata = fs::symlink_metadata(path).map_err(|source| Error::WriterLockOpen {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ if metadata.file_type().is_symlink() {
+ return Err(Error::SymlinkPath(path.to_path_buf()));
+ }
+ if !metadata.is_file() {
+ return Err(Error::NotAFile(path.to_path_buf()));
+ }
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(path)
+ .map_err(|source| Error::WriterLockOpen {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ validate_open_file(path, file)
+ }
+ Err(source) => Err(Error::WriterLockOpen {
+ path: path.to_path_buf(),
+ source,
+ }),
+ }
+}
+
+fn create_lock_file(path: &Path) -> std::io::Result<File> {
+ let mut options = OpenOptions::new();
+ options.create_new(true).read(true).write(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ options.open(path)
+}
+
+fn validate_open_file(path: &Path, file: File) -> Result<File, Error> {
+ let metadata = file.metadata().map_err(|source| Error::WriterLockOpen {
+ path: path.to_path_buf(),
+ source,
+ })?;
+ if !metadata.is_file() {
+ return Err(Error::NotAFile(path.to_path_buf()));
+ }
+ Ok(file)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde::Deserialize;
+
+ const POLICY: &str = include_str!("../../../contracts/storage/writer_policy_v1.toml");
+
+ #[derive(Deserialize)]
+ struct Policy {
+ schema_version: u32,
+ lock_file_name: String,
+ lock_scope: String,
+ acquisition: String,
+ sidecar_lifecycle: String,
+ writable_modes: Vec<String>,
+ read_only_lock: String,
+ concurrent_readers: bool,
+ reader_with_writer: bool,
+ concurrent_writers: bool,
+ }
+
+ fn paths(directory: &Path) -> Paths {
+ Paths::from_directory(directory).expect("owned paths")
+ }
+
+ #[test]
+ fn implementation_matches_the_governed_multi_client_policy() {
+ let policy = toml::from_str::<Policy>(POLICY).expect("writer policy");
+ assert_eq!(policy.schema_version, 1);
+ assert_eq!(policy.lock_file_name, WRITER_LOCK_FILE_NAME);
+ assert_eq!(policy.lock_scope, "canonical_runtime_parent");
+ assert_eq!(policy.acquisition, "non_blocking_exclusive_advisory");
+ assert_eq!(policy.sidecar_lifecycle, "persistent_empty_file");
+ assert_eq!(policy.writable_modes, ["read_write_existing", "create"]);
+ assert_eq!(policy.read_only_lock, "none");
+ assert!(policy.concurrent_readers);
+ assert!(policy.reader_with_writer);
+ assert!(!policy.concurrent_writers);
+ }
+
+ #[test]
+ fn one_writer_excludes_other_clients_until_explicit_release() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = paths(directory.path());
+ let first = WriterLock::acquire(&paths, OpenMode::Create)
+ .expect("first writer")
+ .expect("writer guard");
+ assert_eq!(first.path(), directory.path().join(WRITER_LOCK_FILE_NAME));
+ assert!(first.path().is_file());
+ assert!(matches!(
+ WriterLock::acquire(&paths, OpenMode::ReadWriteExisting),
+ Err(Error::WriterAlreadyActive { .. })
+ ));
+
+ first.release().expect("release first writer");
+ let next = WriterLock::acquire(&paths, OpenMode::ReadWriteExisting)
+ .expect("next writer")
+ .expect("writer guard");
+ drop(next);
+ assert!(
+ directory.path().join(WRITER_LOCK_FILE_NAME).is_file(),
+ "the stable sidecar must not be unlinked"
+ );
+ }
+
+ #[test]
+ fn read_only_clients_take_no_lock_and_can_coexist_with_a_writer() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = paths(directory.path());
+ let writer = WriterLock::acquire(&paths, OpenMode::Create)
+ .expect("writer")
+ .expect("writer guard");
+ assert!(
+ WriterLock::acquire(&paths, OpenMode::ReadOnly)
+ .expect("first reader")
+ .is_none()
+ );
+ assert!(
+ WriterLock::acquire(&paths, OpenMode::ReadOnly)
+ .expect("second reader")
+ .is_none()
+ );
+ drop(writer);
+ }
+
+ #[test]
+ fn writer_lock_is_observed_by_a_distinct_process() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let paths = paths(directory.path());
+ let writer = WriterLock::acquire(&paths, OpenMode::Create)
+ .expect("writer")
+ .expect("writer guard");
+ let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
+ .arg("--exact")
+ .arg("lock::tests::writer_lock_child_probe")
+ .arg("--nocapture")
+ .env("RADROOTS_WRITER_LOCK_CHILD_PATH", directory.path())
+ .status()
+ .expect("run lock child");
+ assert!(status.success(), "child must observe writer contention");
+ drop(writer);
+ }
+
+ #[test]
+ fn writer_lock_child_probe() {
+ let Some(directory) = std::env::var_os("RADROOTS_WRITER_LOCK_CHILD_PATH") else {
+ return;
+ };
+ assert!(matches!(
+ WriterLock::acquire(&paths(Path::new(&directory)), OpenMode::Create),
+ Err(Error::WriterAlreadyActive { .. })
+ ));
+ }
+
+ #[test]
+ fn stores_in_distinct_canonical_directories_do_not_contend() {
+ let first_directory = tempfile::tempdir().expect("first directory");
+ let second_directory = tempfile::tempdir().expect("second directory");
+ let first = WriterLock::acquire(&paths(first_directory.path()), OpenMode::Create)
+ .expect("first writer")
+ .expect("first guard");
+ let second = WriterLock::acquire(&paths(second_directory.path()), OpenMode::Create)
+ .expect("second writer")
+ .expect("second guard");
+ assert_ne!(first.path(), second.path());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn symlink_lock_files_fail_closed() {
+ use std::os::unix::fs::symlink;
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let target = directory.path().join("unrelated");
+ fs::write(&target, []).expect("target");
+ symlink(&target, directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock symlink");
+ assert!(matches!(
+ WriterLock::acquire(&paths(directory.path()), OpenMode::Create),
+ Err(Error::SymlinkPath(_))
+ ));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn canonical_parent_aliases_share_one_writer_lock() {
+ use std::os::unix::fs::symlink;
+
+ let root = tempfile::tempdir().expect("temporary root");
+ let actual = root.path().join("actual");
+ let alias = root.path().join("alias");
+ fs::create_dir(&actual).expect("actual directory");
+ symlink(&actual, &alias).expect("directory alias");
+ let writer = WriterLock::acquire(&paths(&actual), OpenMode::Create)
+ .expect("writer")
+ .expect("writer guard");
+ assert!(matches!(
+ WriterLock::acquire(&paths(&alias), OpenMode::Create),
+ Err(Error::WriterAlreadyActive { .. })
+ ));
+ drop(writer);
+ }
+
+ #[test]
+ fn non_file_lock_paths_fail_closed() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ fs::create_dir(directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock directory");
+ assert!(matches!(
+ WriterLock::acquire(&paths(directory.path()), OpenMode::Create),
+ Err(Error::NotAFile(_))
+ ));
+ }
+}
diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs
@@ -166,6 +166,21 @@ pub enum Error {
maximum: Duration,
actual: Duration,
},
+ WriterLockOpen {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ WriterAlreadyActive {
+ path: PathBuf,
+ },
+ WriterLockFailed {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ WriterUnlockFailed {
+ path: PathBuf,
+ source: std::io::Error,
+ },
}
impl fmt::Display for Error {
@@ -220,6 +235,26 @@ impl fmt::Display for Error {
formatter,
"SQLite busy timeout {actual:?} must be within {minimum:?}..={maximum:?}"
),
+ Self::WriterLockOpen { path, .. } => write!(
+ formatter,
+ "failed to open SQLite writer lock: {}",
+ path.display()
+ ),
+ Self::WriterAlreadyActive { path } => write!(
+ formatter,
+ "another writable SQLite storage client holds: {}",
+ path.display()
+ ),
+ Self::WriterLockFailed { path, .. } => write!(
+ formatter,
+ "failed to acquire SQLite writer lock: {}",
+ path.display()
+ ),
+ Self::WriterUnlockFailed { path, .. } => write!(
+ formatter,
+ "failed to release SQLite writer lock: {}",
+ path.display()
+ ),
}
}
}
@@ -227,7 +262,10 @@ impl fmt::Display for Error {
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
- Self::Inspect { source, .. } => Some(source),
+ Self::Inspect { source, .. }
+ | Self::WriterLockOpen { source, .. }
+ | Self::WriterLockFailed { source, .. }
+ | Self::WriterUnlockFailed { source, .. } => Some(source),
_ => None,
}
}
diff --git a/crates/storage_sqlite/tests/open_options.rs b/crates/storage_sqlite/tests/open_options.rs
@@ -1,6 +1,7 @@
use std::fs;
use std::time::Duration;
+use radroots_storage::status::WriterPolicy;
use radroots_storage_sqlite::{Error, OpenMode, OpenOptions, Paths};
#[test]
@@ -77,6 +78,7 @@ fn options_fix_connection_invariants_and_bound_busy_timeout() {
assert_eq!(read_only.busy_timeout(), Duration::from_secs(5));
assert!(read_only.foreign_keys_enabled());
assert!(!read_only.wal_enabled());
+ assert_eq!(read_only.writer_policy(), WriterPolicy::NoWriter);
assert!(!read_only.mode().is_writable());
assert!(!read_only.mode().may_create());
@@ -86,6 +88,7 @@ fn options_fix_connection_invariants_and_bound_busy_timeout() {
assert_eq!(create.busy_timeout(), Duration::from_secs(30));
assert!(create.foreign_keys_enabled());
assert!(create.wal_enabled());
+ assert_eq!(create.writer_policy(), WriterPolicy::AdvisoryProcessLock);
assert!(create.mode().is_writable());
assert!(create.mode().may_create());
diff --git a/crates/storage_sqlite/tests/package_boundary.rs b/crates/storage_sqlite/tests/package_boundary.rs
@@ -20,6 +20,7 @@ fn sqlite_storage_declares_the_final_backend_boundaries() {
assert_eq!(
dependency_keys(MANIFEST),
BTreeSet::from([
+ "fs2",
"radroots_event_codec",
"radroots_secrets",
"radroots_storage",