Skip to main content

kmp_adapter_embedded/adapter/
migration.rs

1//! Store migration (ADR-012): move a data directory this binary refuses to
2//! open into one it does.
3//!
4//! The fail-fast rule says a `FORMAT_VERSION` older than the binary supports
5//! must be rejected rather than opened as empty memory. This module is the
6//! way out of that rejection, and it is deliberately built on the event log
7//! rather than on the store file: projections are derived state, so a
8//! migration replays history into a fresh store and rebuilds them, instead
9//! of copying materialized tables whose shape is exactly what a format bump
10//! is likely to change.
11//!
12//! Guarantees, in the order they matter:
13//!
14//!   * The source is never opened for writing. It is hashed, copied, and the
15//!     *copy* is what gets opened — so even redb's own recovery after an
16//!     unclean shutdown cannot touch the operator's evidence. The hash is
17//!     checked again at the end.
18//!   * The destination cannot already hold a store. A migration that could
19//!     overwrite memory would be a worse failure than the one it fixes.
20//!   * The result carries a receipt, persisted in the destination: what was
21//!     migrated, from which format, from which bytes.
22//!
23//! What this module does **not** claim: that any particular older format is
24//! translatable. Today one format exists (`1`), so migration is a faithful
25//! replay. When a format bump lands, the translation step belongs here, in
26//! `translate_event`, and the compatibility matrix in
27//! `docs/operations/embedded-release.md` moves in the same pull request.
28
29use std::fs;
30use std::path::{Path, PathBuf};
31
32use kmp_domain::{ContextUpdatedEvent, PortError, ProjectionMutation};
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use super::engine::{Key, Table};
37use super::format_version::{self, StorageEngine};
38use super::store::EmbeddedKernelStore;
39
40/// The scratch copy the migration reads. Lives inside the destination so a
41/// half-finished migration leaves nothing behind in the source directory.
42const SOURCE_COPY_FILE: &str = "migration-source.redb";
43
44/// What a migration did, kept in the store it produced.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct StoreMigrationReceipt {
47    pub source_format: u32,
48    pub source_sha256: String,
49    pub destination_format: u32,
50    pub events_migrated: u64,
51    pub mutations_applied: u64,
52    pub kernel_version: String,
53}
54
55impl StoreMigrationReceipt {
56    /// Single key: a store is the product of one migration, or of none.
57    pub const MIGRATION_ID: &'static str = "store-format-migration";
58}
59
60impl EmbeddedKernelStore {
61    /// Migrates `source_dir` into `destination_dir` and opens the result.
62    /// The destination is created with the default engine.
63    ///
64    /// `derive` is the projection derivation the composition root owns
65    /// (`kmp_application::projection_mutations_for_context_event`), kept
66    /// injected so this adapter stays free of the application layer.
67    pub async fn migrate_data_dir<F>(
68        source_dir: &Path,
69        destination_dir: &Path,
70        derive: F,
71    ) -> Result<(Self, StoreMigrationReceipt), PortError>
72    where
73        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
74    {
75        Self::migrate_data_dir_to(source_dir, destination_dir, StorageEngine::Redb, derive).await
76    }
77
78    /// [`migrate_data_dir`](Self::migrate_data_dir) with the destination
79    /// engine chosen. This is how a store changes engines
80    /// ([ADR-018](../../../../docs/adr/ADR-018-multi-process-embedded-store.md)):
81    /// the event log is the source of truth and projections are derived, so
82    /// a redb store becomes a SQLite store by replaying its history into a
83    /// fresh SQLite directory — the same operation a format bump has always
84    /// been. The source is not modified; the receipt records both formats.
85    pub async fn migrate_data_dir_to<F>(
86        source_dir: &Path,
87        destination_dir: &Path,
88        destination_engine: StorageEngine,
89        derive: F,
90    ) -> Result<(Self, StoreMigrationReceipt), PortError>
91    where
92        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
93    {
94        if same_file(source_dir, destination_dir) {
95            return Err(PortError::InvalidState(
96                "migration source and destination are the same data directory".to_string(),
97            ));
98        }
99        let source_format = format_version::read_stamped_version(source_dir)?;
100        if source_format > StorageEngine::NEWEST_KNOWN_FORMAT_VERSION {
101            return Err(PortError::InvalidState(format!(
102                "migration source `{}` uses format version {source_format}, newer than this \
103                 binary supports ({}); upgrade the binary",
104                source_dir.display(),
105                StorageEngine::NEWEST_KNOWN_FORMAT_VERSION
106            )));
107        }
108        // Every layout before 1 was a redb file; reading those is what this
109        // migration exists for, so an unknown-but-older number is redb.
110        let source_engine =
111            StorageEngine::from_format_version(source_format).unwrap_or(StorageEngine::Redb);
112        // A SQLite store in WAL mode keeps committed data in a sidecar until
113        // checkpointed, so "copy the store file and read the copy" would
114        // silently drop the newest events. Reading it safely needs a
115        // consistent snapshot (`VACUUM INTO`), which is its own piece of
116        // work; until it lands, say so rather than migrate incompletely.
117        if source_engine != StorageEngine::Redb {
118            return Err(PortError::Unavailable(format!(
119                "migration from a {source_engine} store is not supported yet; the source at `{}` \
120                 is left untouched",
121                source_dir.display()
122            )));
123        }
124        let source_store_file = format_version::store_file_path_for(source_dir, source_engine);
125        if !source_store_file.exists() {
126            return Err(PortError::InvalidState(format!(
127                "migration source `{}` holds no store file at `{}`",
128                source_dir.display(),
129                source_store_file.display()
130            )));
131        }
132        let source_sha256 = sha256_of(&source_store_file)?;
133
134        if format_version::existing_store_file(destination_dir).is_some() {
135            // Re-running a migration is a normal operator reflex, and
136            // "already holds a store" is a frightening thing to read when
137            // the truth is that the work is already done. Say which it is.
138            let already = match Self::open(destination_dir) {
139                Ok(store) => store.migration_receipt().await.ok().flatten(),
140                Err(_) => None,
141            };
142            if let Some(receipt) = already
143                && receipt.source_sha256 == source_sha256
144            {
145                return Err(PortError::Conflict(format!(
146                    "migration destination `{}` was already migrated from this exact \
147                     source ({} events, source sha256 {}); nothing to do",
148                    destination_dir.display(),
149                    receipt.events_migrated,
150                    receipt.source_sha256
151                )));
152            }
153            return Err(PortError::Conflict(format!(
154                "migration destination `{}` already holds a store; migrate into a new \
155                 directory rather than over existing memory",
156                destination_dir.display()
157            )));
158        }
159        let events = read_source_events(&source_store_file, source_engine, destination_dir)?;
160
161        let destination = Self::open_with_engine(destination_dir, destination_engine)?;
162        let events_migrated = destination.replay_event_stream(events).await?;
163        let rebuild = destination.rebuild_projections(derive).await?;
164
165        // The source must be exactly what it was. Anything else means the
166        // read-only path leaked, and the operator deserves to hear it from
167        // the migration rather than from a later diff.
168        let source_sha256_after = sha256_of(&source_store_file)?;
169        if source_sha256_after != source_sha256 {
170            return Err(PortError::InvalidState(format!(
171                "migration modified its source `{}`; refusing to report success",
172                source_store_file.display()
173            )));
174        }
175
176        let receipt = StoreMigrationReceipt {
177            source_format,
178            source_sha256,
179            destination_format: destination_engine.format_version(),
180            events_migrated,
181            mutations_applied: rebuild.mutations_applied,
182            kernel_version: env!("CARGO_PKG_VERSION").to_string(),
183        };
184        destination.write_migration_receipt(&receipt).await?;
185        Ok((destination, receipt))
186    }
187
188    /// Migrate once, reopen afterwards: safe to call on every start.
189    ///
190    /// A destination that already holds a store is opened as it is — the
191    /// migration is not repeated, and the receipt (when there is one) says
192    /// where that memory came from.
193    pub async fn open_or_migrate_data_dir<F>(
194        source_dir: &Path,
195        destination_dir: &Path,
196        derive: F,
197    ) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
198    where
199        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
200    {
201        Self::open_or_migrate_data_dir_to(source_dir, destination_dir, StorageEngine::Redb, derive)
202            .await
203    }
204
205    /// [`open_or_migrate_data_dir`](Self::open_or_migrate_data_dir) with the
206    /// destination engine chosen. The engine only matters on the call that
207    /// migrates; a destination that already holds a store opens as whatever
208    /// it is.
209    pub async fn open_or_migrate_data_dir_to<F>(
210        source_dir: &Path,
211        destination_dir: &Path,
212        destination_engine: StorageEngine,
213        derive: F,
214    ) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
215    where
216        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
217    {
218        if format_version::existing_store_file(destination_dir).is_some() {
219            let store = Self::open(destination_dir)?;
220            let receipt = store.migration_receipt().await?;
221            return Ok((store, receipt));
222        }
223        let (store, receipt) =
224            Self::migrate_data_dir_to(source_dir, destination_dir, destination_engine, derive)
225                .await?;
226        Ok((store, Some(receipt)))
227    }
228
229    /// The receipt of the migration that produced this store, if any.
230    pub async fn migration_receipt(&self) -> Result<Option<StoreMigrationReceipt>, PortError> {
231        self.run(|store| {
232            let tx = store.begin_read()?;
233            // A store nobody migrated has no such table; the seam reads a
234            // never-written table as empty.
235            let Some(raw) = tx.get(
236                Table::Migrations,
237                Key::Str(StoreMigrationReceipt::MIGRATION_ID),
238            )?
239            else {
240                return Ok(None);
241            };
242            let receipt = serde_json::from_slice(&raw).map_err(|error| {
243                PortError::InvalidState(format!("migration receipt is unreadable: {error}"))
244            })?;
245            Ok(Some(receipt))
246        })
247        .await
248    }
249
250    async fn write_migration_receipt(
251        &self,
252        receipt: &StoreMigrationReceipt,
253    ) -> Result<(), PortError> {
254        let encoded = serde_json::to_vec(receipt).map_err(|error| {
255            PortError::InvalidState(format!("migration receipt is not encodable: {error}"))
256        })?;
257        self.run(move |store| {
258            let mut tx = store.begin_write()?;
259            tx.insert(
260                Table::Migrations,
261                Key::Str(StoreMigrationReceipt::MIGRATION_ID),
262                &encoded,
263            )?;
264            tx.commit()
265        })
266        .await
267    }
268}
269
270/// Reads the source event log without ever opening the source for writing.
271///
272/// redb may need to recover a file left by an unclean shutdown, and recovery
273/// writes. So the file is copied first and the copy is what gets opened; the
274/// copy is removed before the destination store is created.
275fn read_source_events(
276    source_store_file: &Path,
277    source_engine: StorageEngine,
278    destination_dir: &Path,
279) -> Result<Vec<ContextUpdatedEvent>, PortError> {
280    fs::create_dir_all(destination_dir).map_err(|error| {
281        PortError::Unavailable(format!(
282            "migration could not create destination `{}`: {error}",
283            destination_dir.display()
284        ))
285    })?;
286    let copy_path: PathBuf = destination_dir.join(SOURCE_COPY_FILE);
287    fs::copy(source_store_file, &copy_path).map_err(|error| {
288        PortError::Unavailable(format!(
289            "migration could not copy the source store to `{}`: {error}",
290            copy_path.display()
291        ))
292    })?;
293
294    let events = {
295        let source = EmbeddedKernelStore::open_store_file(&copy_path, source_engine)?;
296        source.read_event_log_blocking()
297    };
298
299    // Best effort: a leftover copy is inert, but leaving it would make the
300    // destination directory lie about what it contains.
301    let _ = fs::remove_file(&copy_path);
302    events
303}
304
305fn sha256_of(path: &Path) -> Result<String, PortError> {
306    let bytes = fs::read(path).map_err(|error| {
307        PortError::Unavailable(format!(
308            "migration could not read `{}`: {error}",
309            path.display()
310        ))
311    })?;
312    let mut hasher = Sha256::new();
313    hasher.update(&bytes);
314    Ok(format!("{:x}", hasher.finalize()))
315}
316
317fn same_file(left: &Path, right: &Path) -> bool {
318    match (fs::canonicalize(left), fs::canonicalize(right)) {
319        (Ok(left), Ok(right)) => left == right,
320        // Unresolvable paths (a destination that does not exist yet) fall
321        // back to the literal comparison, which is what the caller wrote.
322        _ => left == right,
323    }
324}