Skip to main content

burn_pack/
atomic.rs

1//! Writing a file without ever leaving a partial one at its destination.
2//!
3//! [`Writer::write_to_file`](crate::Writer::write_to_file) is built on this, and
4//! it is public because burn-pack is not the only crate that writes a model file whose bytes
5//! are produced during the write. A save whose tensors materialize on demand can fail partway
6//! through for reasons that have nothing to do with the disk, and must not destroy the
7//! checkpoint that was already there.
8
9use alloc::format;
10use alloc::string::ToString;
11use std::fs::File;
12use std::path::{Path, PathBuf};
13
14use crate::base::Error;
15
16/// A scratch file next to an eventual destination, deleted unless it is persisted.
17///
18/// Gives a write its all-or-nothing behaviour: the file is built here and only takes the
19/// destination's name once it is complete. Any earlier return drops the guard, which removes
20/// the partial file.
21///
22/// The destination is fixed at [`create`](Self::create) and the scratch path is a sibling of
23/// it, so publishing stays on one filesystem: never `EXDEV`, and atomic on POSIX. (Windows
24/// documents no atomicity guarantee when the rename replaces an existing file.)
25///
26/// [`commit`](Self::commit) replaces the destination rather than truncating it. Its
27/// permission bits are carried over on Unix, but its ownership and hard links cannot be: the
28/// published file is a new inode. [`commit_new`](Self::commit_new) never replaces anything
29/// and fails if the destination exists.
30///
31/// # Example
32///
33/// ```no_run
34/// # use burn_pack::AtomicFile;
35/// # fn write_my_format(_path: &std::path::Path) -> Result<(), burn_pack::Error> { Ok(()) }
36/// # fn main() -> Result<(), burn_pack::Error> {
37/// let destination = std::path::Path::new("model.myfmt");
38///
39/// // The handle is dropped here because this writer opens the path itself; keep it instead
40/// // when you mean to write through it.
41/// let (scratch, _reserved) = AtomicFile::create(destination)?;
42/// write_my_format(scratch.path())?;
43/// scratch.commit()?;
44/// # Ok(())
45/// # }
46/// ```
47pub struct AtomicFile {
48    path: PathBuf,
49    destination: PathBuf,
50    persisted: bool,
51}
52
53impl AtomicFile {
54    /// Create a scratch file alongside `destination` and hand back a handle onto it.
55    ///
56    /// The guard and its file are handed back together so neither can exist without the
57    /// other: no path is reserved that nothing will clean up, and no file is created that no
58    /// guard is watching. A caller that writes through its own handle (because the library
59    /// doing the writing opens the path itself) can drop the returned one; the exclusive
60    /// create has already done its job by then.
61    ///
62    /// The guard keeps `destination`, so [`commit`](Self::commit) and
63    /// [`commit_new`](Self::commit_new) can only publish to the path the scratch file was
64    /// placed beside. That is what makes the same-filesystem publish a property of the type
65    /// rather than a contract the caller has to honour.
66    ///
67    /// The file is opened with `create_new`, which refuses a path that already exists and
68    /// does not follow a symlink planted there - a plain `File::create` would truncate
69    /// whatever such a link points at. The scratch name is predictable (pid + counter), so a
70    /// stale leftover from a killed run, pid reuse, or a deliberately pre-created link all
71    /// surface as `AlreadyExists`, answered by moving on to the next counter value.
72    pub fn create(destination: &Path) -> Result<(Self, File), Error> {
73        loop {
74            let path = Self::path_beside(destination)?;
75            match File::create_new(&path) {
76                Ok(file) => {
77                    return Ok((
78                        Self {
79                            path,
80                            destination: destination.to_path_buf(),
81                            persisted: false,
82                        },
83                        file,
84                    ));
85                }
86                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
87                Err(e) => {
88                    return Err(Error::IoError(format!(
89                        "cannot create '{}': {e}",
90                        path.display()
91                    )));
92                }
93            }
94        }
95    }
96
97    /// Where to write. Nothing may be written to the destination itself until
98    /// [`commit`](Self::commit) or [`commit_new`](Self::commit_new).
99    pub fn path(&self) -> &Path {
100        &self.path
101    }
102
103    /// Pick a scratch path alongside `destination`.
104    ///
105    /// The name carries the process id and a counter so concurrent writers for the same
106    /// destination pick distinct names; [`create`](Self::create)'s exclusive open is what
107    /// makes the rare collision harmless rather than this scheme's uniqueness.
108    fn path_beside(destination: &Path) -> Result<PathBuf, Error> {
109        use core::sync::atomic::{AtomicU64, Ordering};
110        static NEXT: AtomicU64 = AtomicU64::new(0);
111
112        let name = destination.file_name().ok_or_else(|| {
113            Error::IoError(format!(
114                "cannot write to '{}': not a file path",
115                destination.display()
116            ))
117        })?;
118
119        let mut scratch = name.to_os_string();
120        scratch.push(format!(
121            ".{}-{}.tmp",
122            std::process::id(),
123            NEXT.fetch_add(1, Ordering::Relaxed)
124        ));
125
126        Ok(destination.with_file_name(scratch))
127    }
128
129    /// Flush the completed file to disk and move it onto the destination.
130    ///
131    /// For a caller that wrote through a handle it opened itself and has since closed. The
132    /// scratch file is reopened here only to sync it, which is what orders the data before
133    /// the rename: a crash must not leave the destination pointing at bytes that never
134    /// reached the platter. The reopen asks for write access even though nothing is written
135    /// through it, because Windows backs `sync_all` with `FlushFileBuffers`, which returns
136    /// `Access is denied` on a read-only handle.
137    ///
138    /// A caller that still holds the handle it wrote through should sync and drop that
139    /// handle first: a deferred write error (NFS over quota, a failing disk) is reported to
140    /// the handle that wrote the bytes and is lost once that handle is closed, so the sync
141    /// here cannot stand in for it.
142    pub fn commit(mut self) -> Result<(), Error> {
143        self.sync_scratch()?;
144        self.rename_onto()
145    }
146
147    /// Like [`commit`](Self::commit), but refuses to replace an existing destination.
148    ///
149    /// Returns [`Error::AlreadyExists`] if anything is at the destination when the file is
150    /// published, and the guard removes the scratch file. Checking for the destination
151    /// beforehand cannot give this guarantee: another writer can create it between the check
152    /// and the rename, which would then silently replace it.
153    ///
154    /// The file is published with a hard link, which refuses an existing destination in the
155    /// same call that creates it. On filesystems without hard links (FAT, exFAT, some network
156    /// mounts) this falls back to a check followed by a rename, which leaves a short window.
157    pub fn commit_new(mut self) -> Result<(), Error> {
158        self.sync_scratch()?;
159        self.link_onto()
160    }
161
162    /// Reopen the scratch file and flush it to disk. See [`commit`](Self::commit).
163    fn sync_scratch(&self) -> Result<(), Error> {
164        File::options()
165            .write(true)
166            .open(&self.path)
167            .and_then(|file| file.sync_all())
168            .map_err(|e| {
169                Error::IoError(format!(
170                    "cannot flush '{}' to disk: {e}",
171                    self.path.display()
172                ))
173            })
174    }
175
176    /// Give the scratch file the permission bits of the file it is about to replace.
177    ///
178    /// Publishing by rename hands the destination a brand new inode, which without this
179    /// carries the process umask: a `0600` checkpoint re-saved under umask `022` would come
180    /// back `0644`, widening access to the model that was deliberately kept private. Copying
181    /// the mode across restores what a truncating overwrite would have left. Ownership and
182    /// hard links have no such answer - a new inode cannot keep them - and Windows has no
183    /// mode to copy, its `Permissions` carrying only a read-only flag.
184    ///
185    /// Nothing is copied when the destination does not exist (there is no prior mode, so the
186    /// umask is the right answer) or is not a regular file (the rename replaces a symlink
187    /// rather than following it, so the link target's mode is not the one being replaced).
188    ///
189    /// A failure here fails the save rather than publishing at the umask, since quietly
190    /// widening a checkpoint's permissions is the outcome this exists to prevent. It is
191    /// reported before the rename, so the destination still holds the old file.
192    #[cfg(unix)]
193    fn inherit_permissions(&self) -> Result<(), Error> {
194        let metadata = match std::fs::symlink_metadata(&self.destination) {
195            Ok(metadata) if metadata.is_file() => metadata,
196            _ => return Ok(()),
197        };
198
199        std::fs::set_permissions(&self.path, metadata.permissions()).map_err(|e| {
200            Error::IoError(format!(
201                "cannot carry the permissions of '{}' onto the file replacing it: {e}",
202                self.destination.display()
203            ))
204        })
205    }
206
207    /// Move the completed file onto the destination, replacing any existing file there.
208    ///
209    /// Assumes the data is already durable; [`commit`](Self::commit) is the form that makes
210    /// sure of it.
211    ///
212    /// The rename is made durable by [`sync_parent`](Self::sync_parent), whose failure is
213    /// reported separately because the generic rename error below would wrongly imply the
214    /// save did not happen.
215    ///
216    /// On rename failure the finished file is deleted by the guard, so the error names it:
217    /// the bytes were written and then discarded, which is worth saying out loud.
218    pub(crate) fn rename_onto(&mut self) -> Result<(), Error> {
219        #[cfg(unix)]
220        self.inherit_permissions()?;
221
222        std::fs::rename(&self.path, &self.destination).map_err(|e| {
223            Error::IoError(format!(
224                "cannot move the completed container '{}' onto '{}': {e}",
225                self.path.display(),
226                self.destination.display()
227            ))
228        })?;
229        self.persisted = true;
230
231        self.sync_parent()
232    }
233
234    /// Publish the completed file at the destination only if nothing is there yet.
235    ///
236    /// A hard link from the destination to the scratch file is created and the scratch name
237    /// then removed. Unlike a rename, `link` fails with `AlreadyExists` instead of replacing,
238    /// and the check and the publish are one call, so no other writer can slip in between.
239    /// The destination is never left partial: the link names a file that is already
240    /// complete.
241    ///
242    /// Filesystems without hard links (FAT, exFAT, some network mounts) fail the link with
243    /// some other error. There this falls back to renaming, but only once the destination is
244    /// confirmed absent: that leaves a window between the check and the rename, which is no
245    /// worse than a caller checking first. Any other outcome of the check is an error, so a
246    /// failure to look cannot be mistaken for nothing being there.
247    pub(crate) fn link_onto(&mut self) -> Result<(), Error> {
248        use std::io::ErrorKind;
249
250        match std::fs::hard_link(&self.path, &self.destination) {
251            Ok(()) => {}
252            Err(e) if e.kind() == ErrorKind::AlreadyExists => {
253                return Err(Error::AlreadyExists(self.destination.display().to_string()));
254            }
255            Err(link_error) => {
256                return match std::fs::symlink_metadata(&self.destination) {
257                    Ok(_) => Err(Error::AlreadyExists(self.destination.display().to_string())),
258                    Err(e) if e.kind() == ErrorKind::NotFound => {
259                        self.rename_onto().map_err(|e| match e {
260                            Error::IoError(message) => Error::IoError(format!(
261                                "{message} (after linking failed: {link_error})"
262                            )),
263                            other => other,
264                        })
265                    }
266                    Err(e) => Err(Error::IoError(format!(
267                        "cannot link '{}' onto '{}' ({link_error}), nor check whether it \
268                         exists: {e}",
269                        self.path.display(),
270                        self.destination.display()
271                    ))),
272                };
273            }
274        }
275
276        // The destination now holds the file and the scratch name is a second link to the
277        // same bytes. A name left behind keeps the checkpoint's space in use after the model
278        // is deleted, so failing to remove it is reported rather than ignored.
279        self.persisted = true;
280        std::fs::remove_file(&self.path).map_err(|e| {
281            Error::IoError(format!(
282                "'{}' was saved, but its scratch link '{}' could not be removed: {e}",
283                self.destination.display(),
284                self.path.display()
285            ))
286        })?;
287
288        self.sync_parent()
289    }
290
291    /// Make the destination's new directory entry durable.
292    ///
293    /// On Unix the parent directory is synced, so once a publish returns `Ok`, power loss
294    /// cannot revert it. (Windows has no portable directory sync; NTFS journals metadata on
295    /// its own schedule.) If the sync fails, the error says so explicitly - the new file is at
296    /// the destination and intact, only its durability is unconfirmed.
297    fn sync_parent(&self) -> Result<(), Error> {
298        #[cfg(unix)]
299        {
300            // An empty parent means a bare relative file name; the directory is the cwd.
301            let parent = match self.destination.parent() {
302                Some(parent) if !parent.as_os_str().is_empty() => parent,
303                _ => Path::new("."),
304            };
305            File::open(parent)
306                .and_then(|directory| directory.sync_all())
307                .map_err(|e| {
308                    Error::IoError(format!(
309                        "'{}' was saved, but syncing its directory failed, so it may not \
310                         survive power loss: {e}",
311                        self.destination.display()
312                    ))
313                })?;
314        }
315
316        Ok(())
317    }
318}
319
320impl Drop for AtomicFile {
321    fn drop(&mut self) {
322        if !self.persisted {
323            // Best effort: the write already failed, and a leftover scratch file is a less
324            // useful thing to report than whatever went wrong.
325            let _ = std::fs::remove_file(&self.path);
326        }
327    }
328}