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}