Skip to main content

coven_foundation/files/
replacement.rs

1//! A new installation of a published store, loaded beside the store's own
2//! files before it takes their place (§19.3).
3//!
4//! `reset_device` and `restore_device` load the replacement in a hidden
5//! directory inside the store's directory, while the store stays as it was
6//! and its writer lock stays held. Before the fresh device registers, the
7//! replacement records itself: from then on the store refuses opens and the
8//! replacement only moves forward, unless the facts end it before the swap.
9//! Once the registration applied, the swap moves the old local state into an
10//! archive and the new installation into place; a restore then removes the
11//! archive. A failure before the record leaves the store untouched. A
12//! replacement recorded or swapping is finished by the next call of the same
13//! kind, and the other kind is refused.
14
15use super::{
16    atomic_file::{self, exists},
17    bootstrap::{self, BootstrapLease},
18    creation, device_reset,
19    file_lock::FileLock,
20    lock,
21    protection::Protection,
22    settings, BootstrapDirectoryError, BootstrapStore, DeviceReset, DirectoryCreationError,
23    FileError, ReplacedBy, ReplacementPhase, ReplacementRecord, StoreDir, StoreDirAccess,
24    StoreLock, StoreLockError,
25};
26use crate::id_source::{IdSource, StoreId};
27use crate::locks::lock_unpoisoned;
28use std::{
29    fs, io,
30    path::{Path, PathBuf},
31    sync::Mutex,
32};
33
34/// A replacement of the installation in a published store's directory. It
35/// holds the store's bootstrap lease, and its writer lock until the new
36/// installation is in place. Dropping it keeps its work for the next call.
37pub struct ReplacementStore {
38    // Drops before the lease: the store's locks are closing before the
39    // bootstrap lock is released.
40    _closing: lock::StoreClosingGuard,
41    path: PathBuf,
42    id: StoreId,
43    reset: DeviceReset,
44    by: ReplacedBy,
45    lease: BootstrapLease,
46    /// Keeps every handle out while the new installation loads and swaps in.
47    writer: Mutex<Option<StoreLock>>,
48    /// Keeps read-only handles out from the record until the swap, so a
49    /// recorded replacement never waits on one (§19.3).
50    readers: Mutex<Option<FileLock>>,
51    /// The replacement's record, once written: past it, it only goes forward.
52    record: Mutex<Option<ReplacementRecord>>,
53    /// The new installation's own bootstrap while it loads in the hidden
54    /// directory; none once it has swapped in.
55    loading: Mutex<Option<BootstrapStore>>,
56    protection: Protection,
57    bugs: crate::task::BugSinkRef,
58}
59
60impl ReplacementStore {
61    /// The installation being replaced and its fresh successor.
62    pub fn device_reset(&self) -> DeviceReset {
63        self.reset
64    }
65
66    /// Whether the new installation has taken the store's place.
67    pub fn is_swapped(&self) -> bool {
68        lock_unpoisoned(&self.loading).is_none()
69    }
70
71    /// Where the new installation is: its hidden directory while it loads,
72    /// then the store's own, unpublished until `publish`.
73    pub fn directory(&self) -> StoreDir {
74        match &*lock_unpoisoned(&self.loading) {
75            Some(loading) => loading.directory(),
76            None => StoreDir::bootstrapping(
77                self.path.clone(),
78                self.id,
79                self.lease.instance(),
80                self.protection,
81                self.bugs.clone(),
82            ),
83        }
84    }
85
86    /// The replaced installation's database: in place while the new one
87    /// loads, then in its archive. Only its operation journal is read.
88    pub fn discarded_database_path(&self) -> PathBuf {
89        match &*lock_unpoisoned(&self.loading) {
90            Some(_) => self.path.join(super::directory::DATABASE),
91            None => device_reset::discarded_database(&self.path, self.reset.old_device),
92        }
93    }
94
95    /// Whether the replacement is recorded: past its point of no return.
96    pub fn is_recorded(&self) -> bool {
97        lock_unpoisoned(&self.record).is_some()
98    }
99
100    /// Record the replacement before its fresh device registers, naming the
101    /// call that began it and the store's `name` in the loaded database:
102    /// from here the store refuses opens and lists as this call's to finish,
103    /// and read-only handles are kept out until the swap. A reader holding
104    /// the store refuses the record, changing nothing. Recording again keeps
105    /// the first record.
106    pub fn record(&self, name: String) -> Result<(), BootstrapDirectoryError> {
107        let mut recorded = lock_unpoisoned(&self.record);
108        if recorded.is_some() {
109            return Ok(());
110        }
111        let readers = self.exclude_readers()?;
112        let record = ReplacementRecord {
113            reset: self.reset,
114            by: self.by,
115            name,
116            phase: ReplacementPhase::Registering,
117        };
118        let kept =
119            device_reset::record(&self.path, &record).map_err(DirectoryCreationError::from)?;
120        *lock_unpoisoned(&self.readers) = Some(readers);
121        *recorded = Some(kept);
122        Ok(())
123    }
124
125    /// Mark the swap as begun, recording the replacement first when it has
126    /// no registration to precede (an unconnected reset), then move the old
127    /// local state into its archive and the loaded installation into the
128    /// store's directory, keeping the store's custody files in place. Then
129    /// release the writer and reader locks: from here the marker refuses
130    /// other opens. The loading database must be closed. A failure leaves the
131    /// swap for the next call to finish; nothing moves back.
132    pub fn swap(&self, name: String) -> Result<(), BootstrapDirectoryError> {
133        let mut loading = lock_unpoisoned(&self.loading);
134        if loading.is_none() {
135            return Ok(());
136        }
137        // No reader may hold the moving files open.
138        let held = lock_unpoisoned(&self.readers).take();
139        let _readers = match held {
140            Some(readers) => readers,
141            None => self.exclude_readers()?,
142        };
143        let recorded = lock_unpoisoned(&self.record).clone();
144        let record = match recorded {
145            Some(record) => record,
146            None => device_reset::record(
147                &self.path,
148                &ReplacementRecord {
149                    reset: self.reset,
150                    by: self.by,
151                    name,
152                    phase: ReplacementPhase::Swapping,
153                },
154            )
155            .map_err(DirectoryCreationError::from)?,
156        };
157        device_reset::swapping(&self.path, &record).map_err(DirectoryCreationError::from)?;
158        device_reset::forward(&self.path, self.id, self.reset)?;
159        drop(loading.take());
160        self.release_writer();
161        Ok(())
162    }
163
164    /// End a recorded replacement before its swap, once the facts say nothing
165    /// in storage names its fresh device as the old one's active replacement:
166    /// remove the record, so the old installation is the store again, then
167    /// the new installation. The loading database must be closed. A failure
168    /// part way leaves the new installation without its record, which the
169    /// next call continues as an unrecorded load.
170    pub fn abandon(self) -> Result<(), BootstrapDirectoryError> {
171        assert!(!self.is_swapped(), "only an unswapped replacement is abandoned");
172        device_reset::finish(&self.path)?;
173        lock_unpoisoned(&self.record).take();
174        lock_unpoisoned(&self.readers).take();
175        self.cancel()
176    }
177
178    fn exclude_readers(&self) -> Result<FileLock, StoreLockError> {
179        lock::exclude_readers(&self.path, self.id)
180    }
181
182    /// Remove the swapped-out installation's archive before publication: a
183    /// restore keeps no old copy. The archive holds the old installation's
184    /// files and cache, of any size, so the removal runs on the blocking pool
185    /// (§21.2). A failure leaves the swap for the next call to finish.
186    pub async fn remove_replaced(&self) -> Result<(), BootstrapDirectoryError> {
187        assert!(self.is_swapped(), "the old installation is removed after the swap");
188        let path = self.path.clone();
189        let old = self.reset.old_device;
190        crate::task::blocking_completing(
191            "remove the replaced installation",
192            self.bugs.clone(),
193            move || device_reset::discard(&path, old),
194        )
195        .await
196        .map_err(FileError::from)??;
197        Ok(())
198    }
199
200    /// Publish the swapped installation: remove the hidden directory, then
201    /// the marker, then the record. A failure before the marker leaves the
202    /// swap for the next call to finish. After a `Create(Published { .. })`
203    /// error the store is visible.
204    pub fn publish(&self) -> Result<(), BootstrapDirectoryError> {
205        assert!(self.is_swapped(), "publication follows the swap");
206        remove_area(&self.path)?;
207        let published = |source: io::Error| -> BootstrapDirectoryError {
208            DirectoryCreationError::Published { id: self.id, source }.into()
209        };
210        let result = bootstrap::remove_marker(&self.path, published);
211        self.lease.release();
212        result?;
213        device_reset::finish(&self.path).map_err(|error| published(io::Error::other(error)))
214    }
215
216    /// Remove the unrecorded replacement after its database closed, leaving
217    /// the store as it was. A later call starts a new one with a fresh id.
218    pub fn cancel(self) -> Result<(), BootstrapDirectoryError> {
219        assert!(!self.is_recorded(), "a recorded replacement only moves forward");
220        let loading = lock_unpoisoned(&self.loading)
221            .take()
222            .expect("only an unswapped replacement is cancelled");
223        loading.cancel()?;
224        drop(loading);
225        Ok(remove_area(&self.path)?)
226    }
227
228    fn release_writer(&self) {
229        lock_unpoisoned(&self.writer).take();
230    }
231}
232
233fn remove_area(directory: &Path) -> Result<(), FileError> {
234    let area = directory.join(device_reset::AREA);
235    if exists("inspect replacement area", &area)? {
236        fs::remove_dir_all(&area)
237            .map_err(|source| FileError::at("remove replacement area", &area, source))?;
238        atomic_file::sync_publication(&area)?;
239    }
240    Ok(())
241}
242
243/// Hold the store's bootstrap and writer locks, then continue a swap already
244/// under way or begin loading a replacement, resuming one that was loading.
245/// A swap that the other call began is refused before anything changes.
246pub(super) async fn begin(
247    root: &Path,
248    id: StoreId,
249    by: ReplacedBy,
250    ids: &dyn IdSource,
251    protection: Protection,
252    bugs: crate::task::BugSinkRef,
253) -> Result<ReplacementStore, BootstrapDirectoryError> {
254    let root = fs::canonicalize(root)
255        .map_err(|source| FileError::at("resolve store layout", root, source))?;
256    let path = root.join(id.to_string());
257    let lock = lock::acquire_bootstrap(&path, id, &bugs).await?;
258    let closing = lock.closing_guard();
259    let lease = BootstrapLease::new(lock);
260    // A live handle, in this process or another, refuses the replacement.
261    let writer = lock::acquire_for_open(&path, id, &bugs).await?;
262    let current = settings::read(&path, id).map_err(DirectoryCreationError::Settings)?;
263    // The device is leaving the store: only finishing that deletion, or
264    // discarding the store, ends it.
265    if super::deletion::read(&path)?.is_some() {
266        return Err(StoreLockError::DeletionUnfinished(id).into());
267    }
268    let pending = bootstrap::is_pending(&path)?;
269    let recorded = match device_reset::read(&path)? {
270        // Publication removed the marker but not the record: that replacement
271        // is finished, and this call begins a new one.
272        Some(record) if !pending && current.device_id == record.reset.new_device => {
273            device_reset::finish(&path)?;
274            None
275        }
276        recorded => recorded,
277    };
278    match &recorded {
279        // Finishing the other call's swap would load under different keys,
280        // or keep or remove a different copy.
281        Some(record) if record.by != by => {
282            return Err(StoreLockError::ReplacementUnfinished { store: id, by: record.by }.into())
283        }
284        None if pending => return Err(StoreLockError::BootstrapPending(id).into()),
285        _ => {}
286    }
287    // The installation that stays until the swap, and the replacement's
288    // area, made inside the store's directory, take the class (E1).
289    protection.apply(&path)?;
290    let (reset, loading, readers) = match &recorded {
291        // An interrupted swap finishes the swap.
292        Some(record) if record.phase == ReplacementPhase::Swapping => {
293            let _readers = lock::exclude_readers(&path, id)?;
294            device_reset::forward(&path, id, record.reset)?;
295            (record.reset, None, None)
296        }
297        // A recorded replacement continues its load, registration included,
298        // with the fresh id it recorded; read-only handles stay out.
299        Some(record) => {
300            let readers = lock::exclude_readers(&path, id)?;
301            let area = path.join(device_reset::AREA);
302            let loading_path = device_reset::loading(&path, id);
303            if !exists("inspect recorded replacement", &loading_path)? {
304                return Err(FileError::at(
305                    "resume a recorded replacement",
306                    &loading_path,
307                    io::Error::new(
308                        io::ErrorKind::NotFound,
309                        "the recorded replacement's installation is missing",
310                    ),
311                )
312                .into());
313            }
314            let loading =
315                bootstrap::reserve_bootstrap_directory(&area, id, ids, protection, bugs.clone())
316                    .await?;
317            let new =
318                loading.directory().settings().map_err(DirectoryCreationError::Settings)?.device_id;
319            if new != record.reset.new_device {
320                return Err(FileError::at(
321                    "resume a recorded replacement",
322                    &loading_path,
323                    io::Error::new(
324                        io::ErrorKind::InvalidData,
325                        "the loaded installation is not the recorded fresh device",
326                    ),
327                )
328                .into());
329            }
330            (record.reset, Some(loading), Some(readers))
331        }
332        None => {
333            let area = path.join(device_reset::AREA);
334            // A cancelled replacement may have left its area's lock files.
335            if !exists("inspect loading replacement", &device_reset::loading(&path, id))? {
336                remove_area(&path)?;
337            }
338            creation::create_directory(&area)
339                .map_err(|source| FileError::at("create replacement area", &area, source))?;
340            let loading =
341                bootstrap::reserve_bootstrap_directory(&area, id, ids, protection, bugs.clone())
342                    .await?;
343            let new =
344                loading.directory().settings().map_err(DirectoryCreationError::Settings)?.device_id;
345            (DeviceReset { old_device: current.device_id, new_device: new }, Some(loading), None)
346        }
347    };
348    // Once swapped in, the marker refuses other opens.
349    let writer = loading.is_some().then_some(writer);
350    Ok(ReplacementStore {
351        _closing: closing,
352        path,
353        id,
354        reset,
355        by,
356        lease,
357        writer: Mutex::new(writer),
358        readers: Mutex::new(readers),
359        record: Mutex::new(recorded),
360        loading: Mutex::new(loading),
361        protection,
362        bugs,
363    })
364}
365
366#[cfg(test)]
367#[path = "replacement_tests.rs"]
368mod tests;