Skip to main content

velesdb_memory/migration/state/
switch.rs

1/// Where a migration has got to.
2///
3/// Ordered as the migration performs them. `Committed` is the only terminal
4/// success; every other value names a place the process can be found stopped.
5#[derive(
6    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
7)]
8#[serde(rename_all = "snake_case")]
9pub enum Phase {
10    /// The state exists, the destination does not. Nothing has been moved.
11    Prepared,
12    /// The destination is built and has been checked against the source.
13    DestinationValidated,
14    /// The source has been moved aside, under its archive name.
15    SourceArchived,
16    /// The destination now sits at the source's name.
17    DestinationActivated,
18    /// The archive has been released. The migration is over.
19    Committed,
20}
21
22/// Every phase, in order — so an exhaustive check cannot silently miss one
23/// added later.
24pub const PHASES: &[Phase] = &[
25    Phase::Prepared,
26    Phase::DestinationValidated,
27    Phase::SourceArchived,
28    Phase::DestinationActivated,
29    Phase::Committed,
30];
31
32/// What to do with a migration found stopped.
33///
34/// There is deliberately no "clean up and carry on" variant. Every outcome
35/// either moves forward from a known point, puts the source back, refuses, or
36/// hands over to a human — and the last two change nothing on disk.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
38#[serde(tag = "recovery", rename_all = "snake_case")]
39pub enum Recovery {
40    /// Resume forward, starting at the named phase.
41    Continue {
42        /// The phase to perform next.
43        next: Phase,
44        /// Why continuing is safe from here.
45        rationale: String,
46    },
47    /// Put the source back where it was. Only ever from a state where the
48    /// source is provably intact.
49    Restore {
50        /// Exactly what to move where.
51        action: String,
52    },
53    /// Change nothing. The state on disk does not determine what happened.
54    Refuse {
55        /// What is ambiguous, and what a human must look at.
56        reason: String,
57    },
58}
59
60impl Phase {
61    /// Whether `self` is a permitted journal update after `previous`.
62    ///
63    /// Rewriting the same phase is idempotent. Advancing exactly one phase is
64    /// permitted. Skips and regressions are refused so the journal can never
65    /// claim that an unrecorded destructive step happened.
66    pub(super) fn may_follow(self, previous: Self) -> bool {
67        match previous {
68            Self::Prepared => matches!(self, Self::Prepared | Self::DestinationValidated),
69            Self::DestinationValidated => {
70                matches!(self, Self::DestinationValidated | Self::SourceArchived)
71            }
72            Self::SourceArchived => {
73                matches!(self, Self::SourceArchived | Self::DestinationActivated)
74            }
75            Self::DestinationActivated => {
76                matches!(self, Self::DestinationActivated | Self::Committed)
77            }
78            Self::Committed => matches!(self, Self::Committed),
79        }
80    }
81
82    /// What to do when a migration is found stopped in this phase.
83    ///
84    /// Total by construction — the match has no wildcard arm, so a phase added
85    /// later fails to compile until its action is decided rather than
86    /// inheriting someone else's.
87    #[must_use]
88    pub fn recovery(self) -> Recovery {
89        match self {
90            Self::Prepared => Recovery::Continue {
91                next: Self::DestinationValidated,
92                rationale: "nothing has been moved: the destination is built from the source, \
93                            which is still in place and still authoritative."
94                    .to_owned(),
95            },
96            Self::DestinationValidated => Recovery::Continue {
97                next: Self::SourceArchived,
98                rationale: "the destination is built and checked, and the source is untouched. \
99                            The next step is the first that moves anything."
100                    .to_owned(),
101            },
102            Self::SourceArchived => Recovery::Restore {
103                action: "move the archive back to the source name. The destination was never \
104                         activated, so the source is the only authority and putting it back is \
105                         the whole recovery."
106                    .to_owned(),
107            },
108            Self::DestinationActivated => Recovery::Continue {
109                next: Self::Committed,
110                rationale: "the destination is in place and the archive still exists. Going \
111                            forward releases the archive; going back would discard a destination \
112                            that is already the live store."
113                    .to_owned(),
114            },
115            Self::Committed => Recovery::Refuse {
116                reason: "the migration finished. There is nothing to resume, and re-running any \
117                         step would act on a store that is already the new one."
118                    .to_owned(),
119            },
120        }
121    }
122}
123
124/// Which of the three directories exist when a switch-over is interrupted.
125///
126/// The switch is two renames, and calling that pair "atomic" is exactly the
127/// claim this type refuses to make: between them the disk shows a combination
128/// that has to be read on its own terms. All eight are enumerated, and the ones
129/// that do not determine what happened are refused rather than guessed.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct SwitchState {
132    /// A directory sits at the source's name.
133    pub source: bool,
134    /// A directory sits at the archive's name.
135    pub archive: bool,
136    /// A directory sits at the destination's name.
137    pub destination: bool,
138}
139
140const SWITCH_NOTHING: &str =
141    "neither the source, the archive nor the destination exists. Whatever happened here is not      recoverable from the filesystem, and inventing a starting point would be inventing data.";
142const SWITCH_ORPHAN_DESTINATION: &str =
143    "only the destination exists. The source is gone and so is the archive, so nothing on disk      says whether the destination is a completed migration or an abandoned one. Renaming it into      place would be a guess.";
144const SWITCH_ARCHIVE_ONLY: &str =
145    "move the archive back to the source name. It is the only copy of the data, and no      destination was ever put in its place.";
146const SWITCH_MID_RENAME: &str =
147    "move the archive back to the source name, leaving the destination where it is. The switch      stopped between its two renames; the source is intact under the archive name and is still      the authority.";
148const SWITCH_UNTOUCHED: &str =
149    "only the source exists, exactly as before any migration. Start over from the beginning;      nothing needs undoing.";
150const SWITCH_BUILT_NOT_SWITCHED: &str =
151    "the source is in place and a destination exists beside it. Nothing has been moved, so the      destination can be validated against the source before anything is.";
152const SWITCH_TWO_AUTHORITIES: &str =
153    "a source and an archive both exist and there is no destination. Two directories claim to      hold the data and nothing distinguishes a half-finished restore from a half-finished      archive. Deleting or renaming either would destroy the one that turns out to be current.";
154const SWITCH_IMPOSSIBLE: &str =
155    "the source, the archive and the destination all exist. No sequence of this migration      produces all three at once, so the directory has been touched by something else. Nothing      here may be removed or renamed automatically.";
156
157impl SwitchState {
158    /// Every combination of the three, so a test can be exhaustive by
159    /// construction rather than by a list someone maintained by hand.
160    #[must_use]
161    pub fn all() -> Vec<Self> {
162        let mut out = Vec::with_capacity(8);
163        for source in [false, true] {
164            for archive in [false, true] {
165                for destination in [false, true] {
166                    out.push(Self {
167                        source,
168                        archive,
169                        destination,
170                    });
171                }
172            }
173        }
174        out
175    }
176
177    /// What to do, given only what is on disk.
178    ///
179    /// Reads. It does not move, rename or delete anything — deciding and acting
180    /// are separated precisely so that a wrong decision cannot already have
181    /// destroyed the evidence.
182    #[must_use]
183    pub fn recovery(self) -> Recovery {
184        let refuse = |reason: &str| Recovery::Refuse {
185            reason: reason.to_owned(),
186        };
187        let restore = |action: &str| Recovery::Restore {
188            action: action.to_owned(),
189        };
190        let go = |next: Phase, rationale: &str| Recovery::Continue {
191            next,
192            rationale: rationale.to_owned(),
193        };
194        match (self.source, self.archive, self.destination) {
195            (false, false, false) => refuse(SWITCH_NOTHING),
196            (false, false, true) => refuse(SWITCH_ORPHAN_DESTINATION),
197            (false, true, false) => restore(SWITCH_ARCHIVE_ONLY),
198            (false, true, true) => restore(SWITCH_MID_RENAME),
199            (true, false, false) => go(Phase::Prepared, SWITCH_UNTOUCHED),
200            (true, false, true) => go(Phase::DestinationValidated, SWITCH_BUILT_NOT_SWITCHED),
201            (true, true, false) => refuse(SWITCH_TWO_AUTHORITIES),
202            (true, true, true) => refuse(SWITCH_IMPOSSIBLE),
203        }
204    }
205}