1use super::query_error;
33use std::path::{Path, PathBuf};
34
35use super::execute::journal_workspace;
36use super::state::{MigrationLock, MigrationState, Phase, SwitchState};
37
38pub const ARCHIVE_SUFFIX: &str = ".archive";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SwitchOutcome {
44 pub activated: PathBuf,
46 pub archive: PathBuf,
48}
49
50pub 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
97fn 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
121struct 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
170fn 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 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 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
219fn 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 SwitchState {
243 source: true,
244 archive: true,
245 destination: false,
246 } => late_activation(slots, state)?,
247 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
263fn 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
271fn 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
284fn 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 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
315fn 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
338fn 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
381fn 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
400fn 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}