storage/checkpoint/opening.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! How a handle onto the city repository is opened: on the repository's
7//! own index, or on one writer's private index.
8//!
9//! Several runs checkpoint one city at once. What they share is the object
10//! store, whose objects are named by their content, and references named by
11//! the commit they hold; neither can be written two different ways. The index
12//! is the one file two writers would disagree about, so each writer stages
13//! into its own, under the reserved subtree where no checkpoint stages it.
14//!
15//! Specified by `crates/storage/spec/Checkpoint/Concurrent.lean` §8-39.
16
17use std::path::{Path, PathBuf};
18use std::sync::{Mutex, MutexGuard};
19
20use kernel::{RESERVED_PREFIX, RunId};
21
22use crate::error::StorageError;
23
24use super::commit::{Checkpoint, git_err};
25
26/// Which index a handle stages into.
27pub(crate) enum IndexOwner {
28 /// The repository's own index, the one a person's `git add` writes.
29 City,
30 /// One writer's private index file, deleted when the writer closes.
31 Writer(PathBuf),
32}
33
34/// The steps that move HEAD, a base (`ensure_base`, `base_checkpoint`) and
35/// `land`, take this in one
36/// process, around their staging as well as their commit: both stage an index
37/// another writer may stage at the same moment (the city's own, for a base),
38/// and on Windows two handles rewriting one index file refuse each other's
39/// rename. Across processes HEAD's compare-and-swap is what holds; on a
40/// network drive the reference lock file is not exclusive, and no safe
41/// interface tells the two apart (storage D26). Wave checkpoints never take it,
42/// and `ensure_base` takes it only while the city has no commit.
43static HEAD_MOVES: Mutex<()> = Mutex::new(());
44
45/// Takes the in-process lock over moving HEAD.
46///
47/// # Errors
48/// A lock that a thread which panicked while moving HEAD left behind.
49pub(crate) fn moving_head() -> Result<MutexGuard<'static, ()>, StorageError> {
50 HEAD_MOVES.lock().map_err(|_| StorageError::Checkpoint {
51 op: "move HEAD",
52 detail: "a thread failed while it was moving HEAD".to_owned(),
53 })
54}
55
56/// What a commit does to HEAD. A wave checkpoint, and a base before its pack
57/// is on disk, leave it: a wave checkpoint is filed under its own reference so
58/// a person's `git log` does not grow a line per tool wave. A landing moves
59/// it, because offered work has to be on a branch; a base moves it by
60/// creating the branch afterwards (`checkpoint::base`).
61pub(crate) enum HeadMove {
62 Leave,
63 /// From where HEAD stands when the commit is made: a landing.
64 Advance,
65}
66
67/// What a refused commit says. HEAD moved since it was read, the branch a
68/// base would create already exists, or its lock file is held (on Windows,
69/// also a rename onto a file someone has open): the
70/// compare-and-swap refused, and a silent redo would treat the other writer's
71/// work as this commit's to undo (storage §8-39).
72pub(crate) fn commit_refused(update: Option<&str>, err: git2::Error) -> StorageError {
73 let lost = update.is_some()
74 && matches!(
75 err.code(),
76 git2::ErrorCode::Modified | git2::ErrorCode::Locked | git2::ErrorCode::Exists
77 );
78 if lost {
79 StorageError::Checkpoint {
80 op: "move HEAD",
81 detail: format!("another writer moved or holds HEAD: {}", err.message()),
82 }
83 } else {
84 git_err("commit checkpoint")(err)
85 }
86}
87
88/// Where the private indexes of the writers in `root` live.
89fn writers_dir(root: &Path) -> PathBuf {
90 root.join(RESERVED_PREFIX).join("index")
91}
92
93impl Checkpoint {
94 /// Opens the city repository, initialising one when absent. The
95 /// genesis commit is the first wave's, not this call's: an empty
96 /// repository is a valid state, and inventing history here would
97 /// make the first checkpoint unattributable.
98 ///
99 /// Initialises only where there is no repository: a repository that is
100 /// there and does not open (a damaged `.git`, a permission refused) is
101 /// refused, because initialising over it would hide the history it holds.
102 ///
103 /// # Errors
104 /// A repository that is there and does not open, one that cannot be
105 /// initialised, and a configuration that cannot be read or written.
106 pub fn open(city_root: &Path) -> Result<Checkpoint, StorageError> {
107 let repo = match git2::Repository::open(city_root) {
108 Ok(repo) => repo,
109 Err(err) if err.code() == git2::ErrorCode::NotFound => {
110 git2::Repository::init(city_root).map_err(git_err("init repository"))?
111 }
112 Err(err) => return Err(git_err("open the city repository")(err)),
113 };
114 // The city's files round-trip byte for byte, whatever this
115 // machine's git is configured to do to other people's
116 // repositories. A checkout that rewrote line endings would make
117 // a file disagree with the hash the ledger holds for it, and the
118 // disagreement would look like corruption rather than like a
119 // setting. Checked on every open, because the setting is a property
120 // of this repository rather than of the moment it was created; written
121 // only when it differs, because a write takes the config lock and
122 // two lanes opening at once would race on it.
123 let mut local = repo
124 .config()
125 .and_then(|config| config.open_level(git2::ConfigLevel::Local))
126 .map_err(git_err("read the repository's configuration"))?;
127 let pinned = match local.get_bool("core.autocrlf") {
128 Ok(value) => !value,
129 Err(err) if err.code() == git2::ErrorCode::NotFound => false,
130 Err(err) => return Err(git_err("read the repository's line endings")(err)),
131 };
132 if !pinned {
133 local
134 .set_bool("core.autocrlf", false)
135 .map_err(git_err("pin the repository's line endings"))?;
136 }
137 Ok(Checkpoint {
138 repo,
139 last: None,
140 index: IndexOwner::City,
141 })
142 }
143
144 /// A handle that stages into `writer`'s own index under
145 /// `<root>/.sprawling/index/<run>`, seeded from the repository's index
146 /// so the first checkpoint still skips unchanged files by their stat.
147 ///
148 /// # Errors
149 /// No repository at `root` (writers never create one), and an index file
150 /// that cannot be seeded or opened.
151 pub fn open_writer(root: &Path, writer: RunId) -> Result<Checkpoint, StorageError> {
152 let repo = git2::Repository::open(root)
153 .map_err(git_err("open the city repository for a writer"))?;
154 let dir = writers_dir(root);
155 let own = dir.join(writer.to_string());
156 let seed = repo.path().join("index");
157 let refused = |detail: String| StorageError::Checkpoint {
158 op: "seed a writer's index",
159 detail,
160 };
161 let present = |path: &Path| {
162 path.try_exists()
163 .map_err(|err| refused(format!("{}: {err}", path.display())))
164 };
165 std::fs::create_dir_all(&dir)
166 .map_err(|err| refused(format!("{}: {err}", dir.display())))?;
167 if !present(&own)? && present(&seed)? {
168 std::fs::copy(&seed, &own)
169 .map_err(|err| refused(format!("{}: {err}", own.display())))?;
170 }
171 let mut index = git2::Index::open(&own).map_err(git_err("open a writer's index"))?;
172 repo.set_index(&mut index)
173 .map_err(git_err("give a writer its index"))?;
174 Ok(Checkpoint {
175 repo,
176 last: None,
177 index: IndexOwner::Writer(own),
178 })
179 }
180
181 /// Deletes this writer's index file once its run has landed. A handle on
182 /// the repository's own index deletes nothing.
183 ///
184 /// # Errors
185 /// A file the file system will not delete.
186 pub fn close_writer(self) -> Result<(), StorageError> {
187 let Checkpoint { repo, index, .. } = self;
188 drop(repo);
189 match index {
190 IndexOwner::City => Ok(()),
191 IndexOwner::Writer(own) => match std::fs::remove_file(&own) {
192 Ok(()) => Ok(()),
193 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
194 Err(err) => Err(StorageError::Checkpoint {
195 op: "close a writer's index",
196 detail: format!("{}: {err}", own.display()),
197 }),
198 },
199 }
200 }
201
202 /// Deletes every private index under `root`, and answers how many. Called
203 /// when the city opens, when no run is live, so every one of them is what
204 /// a crash left behind.
205 ///
206 /// # Errors
207 /// A directory that cannot be listed and a file that will not delete.
208 pub fn sweep_writers(root: &Path) -> Result<usize, StorageError> {
209 let dir = writers_dir(root);
210 let refused = |detail: String| StorageError::Checkpoint {
211 op: "sweep the writers' indexes",
212 detail,
213 };
214 let entries = match std::fs::read_dir(&dir) {
215 Ok(entries) => entries,
216 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0),
217 Err(err) => return Err(refused(format!("{}: {err}", dir.display()))),
218 };
219 let mut swept: usize = 0;
220 for entry in entries {
221 let path = entry
222 .map_err(|err| refused(format!("{}: {err}", dir.display())))?
223 .path();
224 std::fs::remove_file(&path)
225 .map_err(|err| refused(format!("{}: {err}", path.display())))?;
226 swept = swept.saturating_add(1);
227 }
228 Ok(swept)
229 }
230}
231
232#[cfg(test)]
233#[allow(
234 clippy::unwrap_used,
235 clippy::expect_used,
236 clippy::panic,
237 clippy::indexing_slicing,
238 reason = "test code"
239)]
240mod tests;