Skip to main content

sley_core/
atomic.rs

1//! Atomic file publication primitives: lock-file guarded writes published by
2//! rename.
3//!
4//! Consolidates the hand-rolled `create_new(temp)` + optional barrier +
5//! `rename(target)` dances scattered across writers. The [`LockFile`] guard
6//! owns the exclusive-create temp/lock file and removes it on drop unless the
7//! write is explicitly persisted (or deliberately kept, for `mkstemp`-style
8//! creators whose temporary name is the deliverable).
9//!
10//! Naming conventions stay at call sites: [`LockFile::acquire`] uses git's
11//! sibling `<name>.lock` convention, while [`LockFile::create`] accepts an
12//! exact path for load-bearing names such as odb `tmp_obj_<pid>_<n>` temps.
13
14use std::fs;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use crate::{fsync, GitError, Result};
19
20/// Lock path for `path`: the file name suffixed `.lock` in the same directory
21/// (git's lockfile convention, e.g. `packed-refs` -> `packed-refs.lock`).
22pub fn lock_path_for(path: &Path) -> Result<PathBuf> {
23    let Some(file_name) = path.file_name() else {
24        return Err(GitError::InvalidPath(format!(
25            "path has no filename: {}",
26            path.display()
27        )));
28    };
29    let mut lock_name = file_name.to_os_string();
30    lock_name.push(".lock");
31    Ok(path.with_file_name(lock_name))
32}
33
34/// An exclusively created lock/temp file with atomic publication semantics.
35///
36/// Dropping an unpersisted guard closes the handle and removes the file, so
37/// failure paths never leave stale locks behind. Persisting renames the file
38/// over its target, which is atomic within a filesystem.
39#[derive(Debug)]
40pub struct LockFile {
41    path: PathBuf,
42    /// Publication target remembered by [`Self::acquire`]; absent for
43    /// `create`-built guards, which publish through [`Self::persist_into`].
44    target: Option<PathBuf>,
45    file: Option<fs::File>,
46    armed: bool,
47}
48
49impl LockFile {
50    /// Exclusively create the lock at exactly `path`; fails when it already
51    /// exists (`AlreadyExists` kind, inspectable via
52    /// [`GitError::io_kind`](crate::GitError::io_kind)).
53    pub fn create(path: PathBuf) -> Result<Self> {
54        let file = fs::OpenOptions::new()
55            .write(true)
56            .create_new(true)
57            .open(&path)?;
58        Ok(Self {
59            path,
60            target: None,
61            file: Some(file),
62            armed: true,
63        })
64    }
65
66    /// Exclusively create the sibling `<target>.lock` lock for `target`.
67    pub fn acquire(target: &Path) -> Result<Self> {
68        let mut lock = Self::create(lock_path_for(target)?)?;
69        lock.target = Some(target.to_path_buf());
70        Ok(lock)
71    }
72
73    /// The lock file's own path.
74    pub fn path(&self) -> &Path {
75        &self.path
76    }
77
78    /// The publication target this lock was acquired for, when known.
79    pub fn target(&self) -> Option<&Path> {
80        self.target.as_deref()
81    }
82
83    /// Mutable handle for streaming or filter-style writers.
84    pub fn file_mut(&mut self) -> Option<&mut fs::File> {
85        self.file.as_mut()
86    }
87
88    /// Write the full payload through the lock handle.
89    pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
90        let Some(file) = self.file.as_mut() else {
91            return Err(GitError::Io("lock file is already closed".into()));
92        };
93        file.write_all(bytes)?;
94        Ok(())
95    }
96
97    /// Apply `policy`'s barrier for `component` to the lock handle. No-op
98    /// when the policy excludes the component or the test switch disables
99    /// syncing.
100    pub fn sync(&mut self, policy: &fsync::Policy, component: fsync::FsyncComponents) -> Result<()> {
101        let Some(file) = self.file.as_mut() else {
102            return Err(GitError::Io("lock file is already closed".into()));
103        };
104        policy.apply(file, component)?;
105        Ok(())
106    }
107
108    /// Publish onto the target remembered by [`Self::acquire`]: rename the
109    /// lock over it, removing the lock file if the rename fails.
110    pub fn persist(self) -> Result<()> {
111        let Some(target) = self.target.clone() else {
112            return Err(GitError::Io(format!(
113                "lock file {} has no publication target",
114                self.path.display()
115            )));
116        };
117        self.persist_into(&target)
118    }
119
120    /// Publish onto `target`: rename the lock over it. On rename failure the
121    /// lock file is removed and the original error surfaces as
122    /// [`GitError::Io`], matching the pre-existing dance's cleanup order.
123    pub fn persist_into(mut self, target: &Path) -> Result<()> {
124        self.armed = false;
125        let _ = self.file.take();
126        match fs::rename(&self.path, target) {
127            Ok(()) => Ok(()),
128            Err(err) => {
129                let _ = fs::remove_file(&self.path);
130                Err(GitError::Io(err.to_string()))
131            }
132        }
133    }
134
135    /// Publish tolerating a concurrent writer that landed first: when the
136    /// rename fails but `target` now exists, treat the write as won-by-other
137    /// and succeed after removing our temp file. This matches the loose
138    /// object store's race handling, where two processes may materialize the
139    /// same content-addressed object simultaneously.
140    pub fn persist_racy(mut self, target: &Path) -> Result<()> {
141        self.armed = false;
142        let _ = self.file.take();
143        match fs::rename(&self.path, target) {
144            Ok(()) => Ok(()),
145            Err(_) if target.exists() => {
146                let _ = fs::remove_file(&self.path);
147                Ok(())
148            }
149            Err(err) => {
150                let _ = fs::remove_file(&self.path);
151                Err(GitError::Io(err.to_string()))
152            }
153        }
154    }
155
156    /// Disarm drop-cleanup and hand back the (path, handle) pair for
157    /// `mkstemp`-style creators whose temporary file must survive the guard.
158    pub fn keep(mut self) -> (PathBuf, Option<fs::File>) {
159        self.armed = false;
160        (std::mem::take(&mut self.path), self.file.take())
161    }
162}
163
164impl Drop for LockFile {
165    fn drop(&mut self) {
166        if self.armed {
167            self.armed = false;
168            let _ = self.file.take();
169            let _ = fs::remove_file(&self.path);
170        }
171    }
172}
173
174/// Atomically replace `path` with `contents`: sibling `.lock` temp, write,
175/// rename. Fails when the lock already exists; callers create parent
176/// directories themselves when needed.
177pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
178    atomic_write_with(path, |file| {
179        file.write_all(contents)?;
180        Ok(())
181    })
182}
183
184/// Atomically replace `path` using a writer callback, so streamed encoders
185/// avoid buffering the whole payload twice.
186pub fn atomic_write_with(
187    path: &Path,
188    write: impl FnOnce(&mut fs::File) -> Result<()>,
189) -> Result<()> {
190    let mut lock = LockFile::acquire(path)?;
191    match lock.file_mut() {
192        Some(file) => write(file)?,
193        None => return Err(GitError::Io("lock file is already closed".into())),
194    }
195    lock.persist()
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use std::sync::atomic::{AtomicU32, Ordering};
202
203    static COUNTER: AtomicU32 = AtomicU32::new(0);
204
205    fn scratch_dir(name: &str) -> PathBuf {
206        let dir = std::env::temp_dir().join(format!(
207            "sley-core-atomic-{name}-{}-{}",
208            std::process::id(),
209            COUNTER.fetch_add(1, Ordering::Relaxed)
210        ));
211        fs::create_dir_all(&dir).expect("create scratch dir");
212        dir
213    }
214
215    #[test]
216    fn lock_path_for_appends_lock_suffix() {
217        assert_eq!(
218            lock_path_for(Path::new("refs/heads/main")).expect("lock path"),
219            PathBuf::from("refs/heads/main.lock")
220        );
221        assert!(lock_path_for(Path::new("")).is_err());
222    }
223
224    #[test]
225    fn acquire_write_persist_replaces_target_atomically() {
226        let dir = scratch_dir("persist");
227        let target = dir.join("packed-refs");
228        fs::write(&target, b"old").expect("seed target");
229
230        let mut lock = LockFile::acquire(&target).expect("acquire");
231        assert_eq!(lock.path(), target.with_file_name("packed-refs.lock"));
232        lock.write_all(b"new").expect("write");
233        lock.persist().expect("persist");
234
235        assert_eq!(fs::read(&target).expect("read target"), b"new");
236        assert!(!target.with_file_name("packed-refs.lock").exists());
237
238        // A second acquisition must observe the published content.
239        let mut again = LockFile::acquire(&target).expect("re-acquire");
240        again.write_all(b"newer").expect("write");
241        again.persist().expect("persist");
242        assert_eq!(fs::read(&target).expect("read target"), b"newer");
243
244        fs::remove_dir_all(dir).expect("cleanup");
245    }
246
247    #[test]
248    fn dropped_guard_removes_lock_and_atomic_write_fails_when_locked() {
249        let dir = scratch_dir("drop");
250        let target = dir.join("HEAD");
251        let guard = LockFile::acquire(&target).expect("acquire");
252        let lock_path = guard.path().to_path_buf();
253        drop(guard);
254        assert!(!lock_path.exists(), "dropped guard must remove its lock");
255
256        // Held locks reject concurrent writers instead of clobbering.
257        let held = LockFile::acquire(&target).expect("hold");
258        let err = atomic_write(&target, b"x").expect_err("second writer must fail");
259        assert_eq!(err.io_kind(), Some(std::io::ErrorKind::AlreadyExists));
260        drop(held);
261
262        atomic_write(&target, b"payload").expect("atomic write after release");
263        assert_eq!(fs::read(&target).expect("read"), b"payload");
264        fs::remove_dir_all(dir).expect("cleanup");
265    }
266
267    #[test]
268    fn create_keeps_exact_temp_names_for_mkstemp_style_callers() {
269        let dir = scratch_dir("keep");
270        let temp = dir.join("tmp_obj_42_7");
271        let lock = LockFile::create(temp.clone()).expect("create");
272        assert!(temp.exists());
273        let (kept_path, _) = lock.keep();
274        assert_eq!(kept_path, temp);
275        assert!(temp.exists(), "keep() must not delete the file");
276
277        // Double-create is rejected with AlreadyExists.
278        let err = LockFile::create(temp).expect_err("exclusive create");
279        assert_eq!(err.io_kind(), Some(std::io::ErrorKind::AlreadyExists));
280        fs::remove_dir_all(dir).expect("cleanup");
281    }
282
283    #[test]
284    fn persist_racy_succeeds_when_another_writer_landed_first() {
285        let dir = scratch_dir("racy");
286        let target = dir.join("objects/ab/cdef");
287        fs::create_dir_all(target.parent().expect("parent")).expect("fanout");
288        fs::write(&target, b"theirs").expect("concurrent winner");
289
290        let temp = dir.join("tmp_obj_racy");
291        let mut lock = LockFile::create(temp).expect("create temp");
292        lock.write_all(b"ours").expect("write");
293        // POSIX rename replaces the winner's file; Windows refuses to
294        // replace and takes the race arm — both are successes.
295        lock.persist_racy(&target)
296            .expect("racy publish must tolerate the concurrent winner");
297        let contents = fs::read(&target).expect("read");
298        assert!(contents == b"theirs" || contents == b"ours");
299        fs::remove_dir_all(dir).expect("cleanup");
300    }
301}