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