Skip to main content

coven_foundation/files/
bootstrap.rs

1//! Hidden stores retain interrupted bootstrap work until explicit retry or
2//! cancellation, or until a listing reclaims one nothing refers to.
3
4use super::{
5    atomic_file::exists, creation, device_reset, lock, protection::Protection,
6    DirectoryCreationError, FileError, StoreDir, StoreLockError,
7};
8use crate::files::StoreDirAccess;
9use crate::id_source::{IdSource, StoreId};
10use crate::locks::lock_unpoisoned;
11use std::{
12    fs, io,
13    path::{Path, PathBuf},
14    sync::Mutex,
15};
16
17/// Filesystem failures while reserving, publishing or cancelling a new device.
18#[derive(Debug, thiserror::Error)]
19pub enum BootstrapDirectoryError {
20    /// A store already exists or preparing its directory failed.
21    #[error(transparent)]
22    Create(#[from] DirectoryCreationError),
23    /// Another bootstrap or an open database prevents this operation.
24    #[error(transparent)]
25    Lock(StoreLockError),
26    /// A filesystem operation failed.
27    #[error(transparent)]
28    File(FileError),
29    /// The work panicked on the blocking pool: a bug, which the caller
30    /// reports (E15).
31    #[error(transparent)]
32    Bug(#[from] crate::task::Bug),
33}
34
35impl From<StoreLockError> for BootstrapDirectoryError {
36    fn from(error: StoreLockError) -> Self {
37        match error {
38            StoreLockError::Bug(bug) => Self::Bug(bug),
39            error => Self::Lock(error),
40        }
41    }
42}
43
44impl From<FileError> for BootstrapDirectoryError {
45    fn from(error: FileError) -> Self {
46        match error {
47            FileError::Bug(bug) => Self::Bug(bug),
48            error => Self::File(error),
49        }
50    }
51}
52
53impl crate::task::CarriesBug for BootstrapDirectoryError {
54    fn bug(&self) -> Option<&crate::task::Bug> {
55        match self {
56            Self::Bug(bug) => Some(bug),
57            Self::File(error) => error.bug(),
58            Self::Create(creation) => creation.bug(),
59            Self::Lock(error) => error.bug(),
60        }
61    }
62}
63
64impl BootstrapDirectoryError {
65    /// The protected file the lock refused, when the device is locked and
66    /// the store's chosen class keeps its files closed (api.md E1).
67    pub fn device_locked(&self) -> Option<&std::path::Path> {
68        let file = match self {
69            Self::File(file) => file,
70            Self::Create(creation) => creation.file()?,
71            Self::Lock(_) | Self::Bug(_) => return None,
72        };
73        match file {
74            FileError::DeviceLocked { path } => Some(path),
75            _ => None,
76        }
77    }
78
79    /// The store is already visible, and only syncing its parent directory
80    /// failed.
81    pub fn published_unsynced(&self) -> bool {
82        matches!(self, Self::Create(DirectoryCreationError::Published { .. }))
83    }
84}
85
86/// An unpublished store at its permanent path, protected by a bootstrap lease.
87/// Dropping preserves resumable work; `cancel` removes it after its database
88/// closes. Publication removes the marker without moving any open files.
89pub struct BootstrapStore {
90    // Drops before the lease: the store's locks are closing before the
91    // bootstrap lock is released, so a call resuming the store waits for a
92    // writer the bootstrap's database may still hold.
93    _closing: lock::StoreClosingGuard,
94    path: PathBuf,
95    id: StoreId,
96    lease: BootstrapLease,
97    protection: Protection,
98    bugs: crate::task::BugSinkRef,
99    /// Whether the directory was there before this call: an earlier call's
100    /// installation, not a new one.
101    resumed: bool,
102}
103
104/// The bootstrap lock, which the bootstrap or replacement that took it holds
105/// alone until it publishes, cancels or is dropped. Its directories name the
106/// lock's instance and open the unpublished store only while it holds it.
107#[derive(Debug)]
108pub(super) struct BootstrapLease {
109    held: Mutex<Option<lock::RegisteredLock>>,
110    instance: lock::LockInstance,
111}
112
113impl BootstrapLease {
114    pub(super) fn new(lock: lock::RegisteredLock) -> Self {
115        Self { instance: lock.instance(), held: Mutex::new(Some(lock)) }
116    }
117
118    pub(super) fn instance(&self) -> lock::LockInstance {
119        self.instance
120    }
121
122    pub(super) fn release(&self) {
123        lock_unpoisoned(&self.held).take();
124    }
125}
126
127impl BootstrapStore {
128    /// The unpublished directory, which opens the store while this bootstrap
129    /// holds its lock.
130    pub fn directory(&self) -> StoreDir {
131        StoreDir::bootstrapping(
132            self.path.clone(),
133            self.id,
134            self.lease.instance(),
135            self.protection,
136            self.bugs.clone(),
137        )
138    }
139
140    /// Whether the installation was left by an earlier call, rather than made
141    /// by this one.
142    pub fn resumed(&self) -> bool {
143        self.resumed
144    }
145
146    /// Publish the loaded, open store by removing its durable marker.
147    /// After a `Create(Published { .. })` error it is already visible.
148    pub fn publish(&self) -> Result<(), BootstrapDirectoryError> {
149        let _reader = self.directory().lock_read_only()?;
150        let published = |source: io::Error| -> BootstrapDirectoryError {
151            DirectoryCreationError::Published { id: self.id, source }.into()
152        };
153        let result = remove_marker(&self.path, published);
154        self.lease.release();
155        result
156    }
157
158    /// Whether the store is published: its marker is gone. After a panic
159    /// in a publication, this says how far it got.
160    pub fn is_published(&self) -> Result<bool, FileError> {
161        Ok(!is_pending(&self.path)?)
162    }
163
164    /// Remove unpublished data and locks after all database work has closed.
165    /// An absent directory succeeds; a published store cannot be cancelled.
166    pub fn cancel(&self) -> Result<(), BootstrapDirectoryError> {
167        let mut held = lock_unpoisoned(&self.lease.held);
168        let guard = self.lock_for_deletion(&mut held)?;
169        if exists("inspect bootstrap path", &self.path)? && !is_pending(&self.path)? {
170            return Err(DirectoryCreationError::AlreadyExists(self.id).into());
171        }
172        if let Some(guard) = guard {
173            guard.remove_directory()?;
174        }
175        held.take();
176        Ok(())
177    }
178
179    /// Lock the store for deletion, handing the deletion the bootstrap lock
180    /// `held` while this bootstrap holds it. Once it has published, the
181    /// deletion takes the lock itself, as any deletion does.
182    fn lock_for_deletion(
183        &self,
184        held: &mut Option<lock::RegisteredLock>,
185    ) -> Result<Option<super::StoreDeletionLock>, StoreLockError> {
186        if held.is_some() {
187            lock::for_deletion(&self.path, self.id, Some(held))
188        } else {
189            self.directory().lock_for_deletion()
190        }
191    }
192}
193
194/// The file whose presence keeps a store unpublished.
195pub(super) const MARKER: &str = ".coven-bootstrap";
196
197pub(super) fn marker(directory: &Path) -> PathBuf {
198    directory.join(MARKER)
199}
200
201/// Remove the marker; only a failure syncing its directory leaves the store
202/// visible, which `published` reports.
203pub(super) fn remove_marker(
204    directory: &Path,
205    published: impl Fn(io::Error) -> BootstrapDirectoryError,
206) -> Result<(), BootstrapDirectoryError> {
207    match super::atomic_file::remove(&marker(directory)) {
208        // Directory removal retains the IO error from syncing its parent.
209        Ok(()) => Ok(()),
210        Err(FileError::AfterRemove { source, .. }) => Err(published(
211            *source.downcast::<io::Error>().expect("directory removal retains its IO cause"),
212        )),
213        Err(error) => Err(error.into()),
214    }
215}
216
217pub(super) fn is_pending(directory: &Path) -> Result<bool, FileError> {
218    exists("inspect bootstrap path", &marker(directory))
219}
220
221pub(super) async fn reserve_bootstrap_directory(
222    root: &Path,
223    id: StoreId,
224    ids: &dyn IdSource,
225    protection: Protection,
226    bugs: crate::task::BugSinkRef,
227) -> Result<BootstrapStore, BootstrapDirectoryError> {
228    creation::create_directory(root)
229        .map_err(|source| FileError::at("create bootstrap layout", root, source))?;
230    let root = fs::canonicalize(root)
231        .map_err(|source| FileError::at("resolve bootstrap layout", root, source))?;
232    let path = root.join(id.to_string());
233    let lock = lock::acquire_bootstrap(&path, id, &bugs).await?;
234    let closing = lock.closing_guard();
235    let lease = BootstrapLease::new(lock);
236    let resumed = exists("inspect bootstrap path", &path)?;
237    if !resumed {
238        creation::create(&root, id, ids, true, protection, bugs.clone())?;
239    }
240    let metadata = fs::symlink_metadata(&path)
241        .map_err(|source| FileError::at("inspect unpublished store", &path, source))?;
242    if !metadata.is_dir() {
243        return Err(FileError::at(
244            "inspect unpublished store",
245            &path,
246            io::Error::new(io::ErrorKind::InvalidInput, "unpublished store is not a directory"),
247        )
248        .into());
249    }
250    // A published or interrupted installation may still have a live writer,
251    // including one in another process. Refuse it before inspecting its data.
252    let _writer = lock::acquire_for_open(&path, id, &bugs).await?;
253    // A store in the middle of a swap is finished by the call that began it.
254    if let Some(record) =
255        device_reset::unfinished(&path, id).map_err(DirectoryCreationError::Settings)?
256    {
257        return Err(StoreLockError::ReplacementUnfinished { store: id, by: record.by }.into());
258    }
259    if !is_pending(&path)? {
260        return Err(DirectoryCreationError::AlreadyExists(id).into());
261    }
262    // Neither lock's holder is writing: a dropped call's saves ran inside it.
263    super::atomic_file::remove_temporaries(&path)?;
264    // A resumed installation may have been made with another class (E1).
265    protection.apply(&path)?;
266    let store = BootstrapStore { _closing: closing, path, id, lease, protection, bugs, resumed };
267    store.directory().settings().map_err(DirectoryCreationError::Settings)?;
268    Ok(store)
269}
270
271#[cfg(test)]
272#[path = "bootstrap_tests.rs"]
273mod tests;