Skip to main content

velesdb_memory/migration/
switchover.rs

1//! The switch (#1762, PR C3): put the validated destination where the source
2//! was, and free the archive.
3//!
4//! Two renames, each journalled AFTER it happens — a journal that ran ahead of
5//! the disk would let a resume skip a rename that never happened. Every crash
6//! window therefore leaves the disk one step ahead of the journal at most, and
7//! the re-run's job is to recognise that step and journal it late, never to
8//! undo it: going backwards would discard work the disk already holds, and
9//! [`Phase::may_follow`] refuses journal regressions anyway.
10//!
11//! # The one ambiguous landing spot, and its discriminator
12//!
13//! After the second rename and before its journal entry, the disk says: a
14//! store at the source's name, an archive beside it, no destination — the
15//! shape [`SwitchState`] calls two authorities and refuses, because from the
16//! filesystem alone nothing distinguishes "the destination was activated" from
17//! "something else sat down at the source's name". This module has one more
18//! fact available: validation stamped the destination with the TARGET's
19//! provenance, and the old source does not carry that stamp. An occupant WITH
20//! the stamp is the activated destination (continue); an occupant without it
21//! is an impostor (refuse, both stores intact).
22//!
23//! # What commit means
24//!
25//! [`Phase::recovery`] has said since C1 that advancing from
26//! `DestinationActivated` "frees the archive". Commit is that advance: verify
27//! the activated store opens and carries the stamp, delete the archive, then
28//! journal `Committed`. Deletion before journalling, so a crash between the
29//! two re-runs an idempotent commit instead of leaving a freed archive that
30//! the journal still believes in.
31
32use super::query_error;
33use std::path::{Path, PathBuf};
34
35use super::execute::journal_workspace;
36use super::state::{MigrationLock, MigrationState, Phase, SwitchState};
37
38/// The archive slot: a sibling of the source, named after it.
39pub const ARCHIVE_SUFFIX: &str = ".archive";
40
41/// What a completed switch did.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SwitchOutcome {
44    /// Where the rebuilt store now lives — the source's own path.
45    pub activated: PathBuf,
46    /// The archive slot that held the old source until commit freed it.
47    pub archive: PathBuf,
48}
49
50/// Drive the switch from wherever the journal stands to `Committed`.
51///
52/// Requires a validated destination ([`Phase::DestinationValidated`] or
53/// later). Each step is act-then-journal; a re-run after a crash recognises
54/// completed-but-unjournalled steps and journals them late.
55///
56/// # Errors
57/// Returns [`crate::MemoryError`] when the journal is missing or earlier than
58/// validation, the archive slot is occupied, the disk is in a shape no step of
59/// this migration produces, or a rename, deletion, or journal write fails.
60pub fn switch_over(store: &Path, destination: &Path) -> Result<SwitchOutcome, crate::MemoryError> {
61    let workspace = journal_workspace(destination)?;
62    let lock = MigrationLock::acquire(&workspace, "migrate-switch").map_err(query_error)?;
63    let result = switch_locked(store, destination, &workspace, &lock);
64    super::execute::reconcile(result, lock.release())
65}
66
67fn switch_locked(
68    store: &Path,
69    destination: &Path,
70    workspace: &Path,
71    lock: &MigrationLock,
72) -> Result<SwitchOutcome, crate::MemoryError> {
73    let mut state = entry_state(workspace)?;
74    let slots = Slots::resolve(store, &state, destination)?;
75    loop {
76        match state.phase {
77            Phase::Prepared => {
78                return Err(query_error(
79                    "the destination has not been validated; validate it first \
80                     — the switch moves stores and must not be the step that \
81                     discovers a bad rebuild",
82                ));
83            }
84            Phase::DestinationValidated => step_archive(&slots, &mut state, workspace, lock)?,
85            Phase::SourceArchived => step_activate(&slots, &mut state, workspace, lock)?,
86            Phase::DestinationActivated => step_commit(&slots, &mut state, workspace, lock)?,
87            Phase::Committed => {
88                return Ok(SwitchOutcome {
89                    activated: slots.source.clone(),
90                    archive: slots.archive.clone(),
91                });
92            }
93        }
94    }
95}
96
97/// Read the journal, refusing an absent one and a migration already done.
98///
99/// The Committed check runs only at ENTRY: a run that just advanced to
100/// `Committed` reports its outcome, while a fresh invocation on a committed
101/// journal has nothing left to do — the recovery table has said since C1 that
102/// replaying a step would act on a store that is already the new one.
103fn entry_state(workspace: &Path) -> Result<MigrationState, crate::MemoryError> {
104    let state = MigrationState::read(workspace)
105        .map_err(query_error)?
106        .ok_or_else(|| {
107            query_error(format!(
108                "no migration journal at {}; there is nothing to switch",
109                workspace.display()
110            ))
111        })?;
112    if state.phase == Phase::Committed {
113        return Err(query_error(
114            "this migration is complete; there is nothing left to switch, and \
115             replaying a step would act on a store that is already the new one",
116        ));
117    }
118    Ok(state)
119}
120
121/// The three fixed paths of the switch, resolved once and checked against the
122/// journal's identity.
123struct Slots {
124    source: PathBuf,
125    archive: PathBuf,
126    destination: PathBuf,
127}
128
129impl Slots {
130    fn resolve(
131        store: &Path,
132        state: &MigrationState,
133        destination: &Path,
134    ) -> Result<Self, crate::MemoryError> {
135        let source = canonical_slot(store)?;
136        if source != state.source_path {
137            return Err(query_error(format!(
138                "this journal describes a migration of '{}', and the request \
139                 names '{}'; a switch cannot be transferred between stores",
140                state.source_path.display(),
141                source.display()
142            )));
143        }
144        let name = source
145            .file_name()
146            .and_then(|name| name.to_str())
147            .ok_or_else(|| {
148                query_error(format!(
149                    "the source {} has no usable directory name to derive the \
150                     archive slot from",
151                    source.display()
152                ))
153            })?;
154        Ok(Self {
155            archive: source.with_file_name(format!("{name}{ARCHIVE_SUFFIX}")),
156            destination: canonical_slot(destination)?,
157            source,
158        })
159    }
160
161    fn on_disk(&self) -> SwitchState {
162        SwitchState {
163            source: self.source.exists(),
164            archive: self.archive.exists(),
165            destination: self.destination.exists(),
166        }
167    }
168}
169
170/// First rename: the source steps aside into the archive slot.
171fn step_archive(
172    slots: &Slots,
173    state: &mut MigrationState,
174    workspace: &Path,
175    lock: &MigrationLock,
176) -> Result<(), crate::MemoryError> {
177    match slots.on_disk() {
178        // The step is pending: the slot must be free, or renaming would eat
179        // whatever sits there — and the source must still BE the store the
180        // journal describes. A stale journal (a crashed migration, overtaken
181        // by later writes or by a whole second migration) would otherwise
182        // archive the LIVE store here and destroy it at commit.
183        SwitchState {
184            source: true,
185            archive: false,
186            destination: true,
187        } => {
188            require_journalled_fingerprint(&slots.source, state, "source")?;
189            rename_durably(&slots.source, &slots.archive)?;
190        }
191        // The step already happened and its journal entry did not — the crash
192        // window. Journal it late; undoing a rename the disk holds would
193        // discard the step. The archive must fingerprint as the journalled
194        // source, or what was archived is not what this journal describes.
195        SwitchState {
196            source: false,
197            archive: true,
198            destination: true,
199        } => {
200            require_journalled_fingerprint(&slots.archive, state, "archive")?;
201        }
202        SwitchState {
203            source: true,
204            archive: true,
205            ..
206        } => {
207            return Err(query_error(format!(
208                "the archive slot {} is already occupied; renaming the source \
209                 over it would destroy whatever it holds — move it aside \
210                 deliberately, or remove it if it is yours to remove",
211                slots.archive.display()
212            )));
213        }
214        other => return Err(unrecognised_disk(other, Phase::DestinationValidated)),
215    }
216    advance(state, Phase::SourceArchived, workspace, lock)
217}
218
219/// Second rename: the destination takes the source's name.
220fn step_activate(
221    slots: &Slots,
222    state: &mut MigrationState,
223    workspace: &Path,
224    lock: &MigrationLock,
225) -> Result<(), crate::MemoryError> {
226    match slots.on_disk() {
227        SwitchState {
228            source: false,
229            archive: true,
230            destination: true,
231        } => {
232            require_journalled_fingerprint(&slots.archive, state, "archive")?;
233            rename_durably(&slots.destination, &slots.source)?;
234        }
235        // Source-name occupied, archive present, destination gone: either the
236        // rename happened and its journal entry did not, or something else sat
237        // down at the source's name. Two proofs discriminate: the occupant
238        // carries the TARGET's provenance stamp (validation wrote it on the
239        // destination and nothing else has it), and the archive fingerprints
240        // as the journalled source (so this archive belongs to THIS journal,
241        // not to a later migration toward the same target).
242        SwitchState {
243            source: true,
244            archive: true,
245            destination: false,
246        } => late_activation(slots, state)?,
247        // The recovery table's manual advice for this phase is "move the
248        // archive back to the source's name". An operator who followed it and
249        // re-ran presents the journal AHEAD of the disk: source restored,
250        // archive slot empty, destination intact. Redo the first rename —
251        // fingerprint-checked like any other — and continue, rather than
252        // stranding the migration in a shape everything refuses.
253        SwitchState {
254            source: true,
255            archive: false,
256            destination: true,
257        } => redo_after_manual_restore(slots, state)?,
258        other => return Err(unrecognised_disk(other, Phase::SourceArchived)),
259    }
260    advance(state, Phase::DestinationActivated, workspace, lock)
261}
262
263/// The second rename already happened and only the journal is behind. Two
264/// proofs before the late journal entry: the occupant carries the TARGET's
265/// stamp, and the archive fingerprints as THIS journal's source.
266fn late_activation(slots: &Slots, state: &MigrationState) -> Result<(), crate::MemoryError> {
267    require_target_stamp(slots, state)?;
268    require_journalled_fingerprint(&slots.archive, state, "archive")
269}
270
271/// The operator followed the recovery table's manual advice and moved the
272/// archive back; the journal is AHEAD of the disk. Redo both renames —
273/// fingerprint-checked — instead of stranding the migration in a shape
274/// everything refuses.
275fn redo_after_manual_restore(
276    slots: &Slots,
277    state: &MigrationState,
278) -> Result<(), crate::MemoryError> {
279    require_journalled_fingerprint(&slots.source, state, "restored source")?;
280    rename_durably(&slots.source, &slots.archive)?;
281    rename_durably(&slots.destination, &slots.source)
282}
283
284/// Commit: verify the activated store, free the archive, journal the end.
285fn step_commit(
286    slots: &Slots,
287    state: &mut MigrationState,
288    workspace: &Path,
289    lock: &MigrationLock,
290) -> Result<(), crate::MemoryError> {
291    require_target_stamp(slots, state)?;
292    {
293        let _opens = velesdb_core::Database::open(&slots.source)?;
294    }
295    if slots.archive.exists() {
296        // The archive is the ONLY copy of the old data, and no flock stops a
297        // rename or a recursive delete (measured in review: a daemon that
298        // opened the source in a lock-free window keeps writing into the
299        // archive after the first rename, and remove_dir_all succeeds under
300        // it, unlinking its inodes silently). Before destruction, the archive
301        // must still fingerprint as the settled source the journal knows —
302        // anything else means writes landed here that exist nowhere else.
303        require_journalled_fingerprint(&slots.archive, state, "archive")?;
304        std::fs::remove_dir_all(&slots.archive).map_err(|err| {
305            query_error(format!(
306                "the activated store is verified but the archive {} could not \
307                 be freed: {err}; nothing is lost — re-run the switch",
308                slots.archive.display()
309            ))
310        })?;
311    }
312    advance(state, Phase::Committed, workspace, lock)
313}
314
315/// The tree at `path` must still fingerprint as the source this journal was
316/// written about. This is the switch's identity check — the stamp says WHAT
317/// KIND of store an occupant is, the fingerprint says WHICH store a tree is,
318/// and only the second can tell this migration's source from a later state of
319/// the same path.
320fn require_journalled_fingerprint(
321    path: &Path,
322    state: &MigrationState,
323    role: &str,
324) -> Result<(), crate::MemoryError> {
325    let observed = super::filesystem::fingerprint(path)?;
326    if observed == state.source_fingerprint {
327        return Ok(());
328    }
329    Err(query_error(format!(
330        "the {role} at {} no longer fingerprints as the store this journal \
331         describes — something wrote to it after the journal was written. \
332         Nothing was moved or deleted; a store that changed hands must be \
333         inspected, not migrated on a stale journal",
334        path.display(),
335    )))
336}
337
338/// The occupant of the source's name must carry the TARGET's provenance stamp
339/// — the one validation wrote on the destination and the old source never had.
340fn require_target_stamp(slots: &Slots, state: &MigrationState) -> Result<(), crate::MemoryError> {
341    let stamped = crate::embedding_provenance::read(&slots.source)
342        .map_err(query_error)?
343        .filter(|stamp| {
344            stamp.model == state.target_model && stamp.dimension == state.target_dimension
345        });
346    if stamped.is_some() {
347        return Ok(());
348    }
349    Err(query_error(format!(
350        "what occupies {} does not carry the target's provenance stamp \
351         ('{}', {} dimensions), so it cannot be assumed to be the activated \
352         destination; the archive and the destination are left untouched — \
353         inspect {} by hand",
354        slots.source.display(),
355        state.target_model,
356        state.target_dimension,
357        slots.source.display(),
358    )))
359}
360
361fn advance(
362    state: &mut MigrationState,
363    phase: Phase,
364    workspace: &Path,
365    lock: &MigrationLock,
366) -> Result<(), crate::MemoryError> {
367    state.phase = phase;
368    state.write(workspace, lock).map_err(query_error)
369}
370
371fn unrecognised_disk(observed: SwitchState, at: Phase) -> crate::MemoryError {
372    let recovery = observed.recovery();
373    query_error(format!(
374        "the journal stands at {at:?} but the disk does not match any step of \
375         this migration (source: {}, archive: {}, destination: {}). The \
376         recovery table says: {recovery:?}",
377        observed.source, observed.archive, observed.destination,
378    ))
379}
380
381/// A path's canonical form, computable even while its slot is empty: the
382/// parent canonicalises, the final component is carried verbatim.
383fn canonical_slot(path: &Path) -> Result<PathBuf, crate::MemoryError> {
384    let name = path
385        .file_name()
386        .ok_or_else(|| query_error(format!("{} has no final path component", path.display())))?;
387    let parent = path
388        .parent()
389        .filter(|parent| !parent.as_os_str().is_empty());
390    let base = match parent {
391        Some(parent) => parent
392            .canonicalize()
393            .map_err(|err| query_error(format!("cannot resolve {}: {err}", parent.display())))?,
394        None => std::env::current_dir()
395            .map_err(|err| query_error(format!("cannot resolve the working directory: {err}")))?,
396    };
397    Ok(base.join(name))
398}
399
400/// A rename, made durable: the parent directory is synced so the entry's move
401/// survives a power cut, not just a process crash.
402fn rename_durably(from: &Path, to: &Path) -> Result<(), crate::MemoryError> {
403    std::fs::rename(from, to).map_err(|err| {
404        query_error(format!(
405            "cannot rename {} to {}: {err}",
406            from.display(),
407            to.display()
408        ))
409    })?;
410    if let Some(parent) = to.parent() {
411        let directory = std::fs::File::open(parent).map_err(|err| {
412            query_error(format!(
413                "cannot open {} to sync it: {err}",
414                parent.display()
415            ))
416        })?;
417        directory.sync_all().map_err(|err| {
418            query_error(format!(
419                "the rename of {} is visible but not yet durable: {err}; do \
420                 not power off before re-running",
421                to.display()
422            ))
423        })?;
424    }
425    Ok(())
426}