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 redb::TableDefinition;
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37use super::format_version::{self, SUPPORTED_FORMAT_VERSION};
38use super::store::{EmbeddedKernelStore, commit_error, storage_error, table_error};
39
40/// Migration receipts: `migration_id -> StoreMigrationReceipt` (JSON).
41pub(crate) const MIGRATIONS: TableDefinition<&str, &[u8]> =
42    TableDefinition::new("store_migrations");
43
44/// The scratch copy the migration reads. Lives inside the destination so a
45/// half-finished migration leaves nothing behind in the source directory.
46const SOURCE_COPY_FILE: &str = "migration-source.redb";
47
48/// What a migration did, kept in the store it produced.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct StoreMigrationReceipt {
51    pub source_format: u32,
52    pub source_sha256: String,
53    pub destination_format: u32,
54    pub events_migrated: u64,
55    pub mutations_applied: u64,
56    pub kernel_version: String,
57}
58
59impl StoreMigrationReceipt {
60    /// Single key: a store is the product of one migration, or of none.
61    pub const MIGRATION_ID: &'static str = "store-format-migration";
62}
63
64impl EmbeddedKernelStore {
65    /// Migrates `source_dir` into `destination_dir` and opens the result.
66    ///
67    /// `derive` is the projection derivation the composition root owns
68    /// (`kmp_application::projection_mutations_for_context_event`), kept
69    /// injected so this adapter stays free of the application layer.
70    pub async fn migrate_data_dir<F>(
71        source_dir: &Path,
72        destination_dir: &Path,
73        derive: F,
74    ) -> Result<(Self, StoreMigrationReceipt), PortError>
75    where
76        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
77    {
78        let source_store_file = format_version::store_file_path(source_dir);
79        let destination_store_file = format_version::store_file_path(destination_dir);
80
81        if same_file(&source_store_file, &destination_store_file) {
82            return Err(PortError::InvalidState(
83                "migration source and destination are the same data directory".to_string(),
84            ));
85        }
86        if !source_store_file.exists() {
87            return Err(PortError::InvalidState(format!(
88                "migration source `{}` holds no store file at `{}`",
89                source_dir.display(),
90                source_store_file.display()
91            )));
92        }
93        let source_format = format_version::read_stamped_version(source_dir)?;
94        if source_format > SUPPORTED_FORMAT_VERSION {
95            return Err(PortError::InvalidState(format!(
96                "migration source `{}` uses format version {source_format}, newer than this \
97                 binary supports ({SUPPORTED_FORMAT_VERSION}); upgrade the binary",
98                source_dir.display()
99            )));
100        }
101        let source_sha256 = sha256_of(&source_store_file)?;
102
103        if destination_store_file.exists() {
104            // Re-running a migration is a normal operator reflex, and
105            // "already holds a store" is a frightening thing to read when
106            // the truth is that the work is already done. Say which it is.
107            let already = match Self::open(destination_dir) {
108                Ok(store) => store.migration_receipt().await.ok().flatten(),
109                Err(_) => None,
110            };
111            if let Some(receipt) = already
112                && receipt.source_sha256 == source_sha256
113            {
114                return Err(PortError::Conflict(format!(
115                    "migration destination `{}` was already migrated from this exact \
116                     source ({} events, source sha256 {}); nothing to do",
117                    destination_dir.display(),
118                    receipt.events_migrated,
119                    receipt.source_sha256
120                )));
121            }
122            return Err(PortError::Conflict(format!(
123                "migration destination `{}` already holds a store; migrate into a new \
124                 directory rather than over existing memory",
125                destination_dir.display()
126            )));
127        }
128        let events = read_source_events(&source_store_file, destination_dir)?;
129
130        let destination = Self::open(destination_dir)?;
131        let events_migrated = destination.replay_event_stream(events).await?;
132        let rebuild = destination.rebuild_projections(derive).await?;
133
134        // The source must be exactly what it was. Anything else means the
135        // read-only path leaked, and the operator deserves to hear it from
136        // the migration rather than from a later diff.
137        let source_sha256_after = sha256_of(&source_store_file)?;
138        if source_sha256_after != source_sha256 {
139            return Err(PortError::InvalidState(format!(
140                "migration modified its source `{}`; refusing to report success",
141                source_store_file.display()
142            )));
143        }
144
145        let receipt = StoreMigrationReceipt {
146            source_format,
147            source_sha256,
148            destination_format: SUPPORTED_FORMAT_VERSION,
149            events_migrated,
150            mutations_applied: rebuild.mutations_applied,
151            kernel_version: env!("CARGO_PKG_VERSION").to_string(),
152        };
153        destination.write_migration_receipt(&receipt).await?;
154        Ok((destination, receipt))
155    }
156
157    /// Migrate once, reopen afterwards: safe to call on every start.
158    ///
159    /// A destination that already holds a store is opened as it is — the
160    /// migration is not repeated, and the receipt (when there is one) says
161    /// where that memory came from.
162    pub async fn open_or_migrate_data_dir<F>(
163        source_dir: &Path,
164        destination_dir: &Path,
165        derive: F,
166    ) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
167    where
168        F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
169    {
170        if format_version::store_file_path(destination_dir).exists() {
171            let store = Self::open(destination_dir)?;
172            let receipt = store.migration_receipt().await?;
173            return Ok((store, receipt));
174        }
175        let (store, receipt) = Self::migrate_data_dir(source_dir, destination_dir, derive).await?;
176        Ok((store, Some(receipt)))
177    }
178
179    /// The receipt of the migration that produced this store, if any.
180    pub async fn migration_receipt(&self) -> Result<Option<StoreMigrationReceipt>, PortError> {
181        self.run(|store| {
182            let tx = store.begin_read()?;
183            let table = match tx.open_table(MIGRATIONS) {
184                Ok(table) => table,
185                // A store nobody migrated simply has no such table.
186                Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
187                Err(error) => return Err(table_error(error)),
188            };
189            let Some(raw) = table
190                .get(StoreMigrationReceipt::MIGRATION_ID)
191                .map_err(storage_error)?
192            else {
193                return Ok(None);
194            };
195            let receipt = serde_json::from_slice(raw.value()).map_err(|error| {
196                PortError::InvalidState(format!("migration receipt is unreadable: {error}"))
197            })?;
198            Ok(Some(receipt))
199        })
200        .await
201    }
202
203    async fn write_migration_receipt(
204        &self,
205        receipt: &StoreMigrationReceipt,
206    ) -> Result<(), PortError> {
207        let encoded = serde_json::to_vec(receipt).map_err(|error| {
208            PortError::InvalidState(format!("migration receipt is not encodable: {error}"))
209        })?;
210        self.run(move |store| {
211            let tx = store.begin_write()?;
212            {
213                let mut table = tx.open_table(MIGRATIONS).map_err(table_error)?;
214                table
215                    .insert(StoreMigrationReceipt::MIGRATION_ID, encoded.as_slice())
216                    .map_err(storage_error)?;
217            }
218            tx.commit().map_err(commit_error)
219        })
220        .await
221    }
222}
223
224/// Reads the source event log without ever opening the source for writing.
225///
226/// redb may need to recover a file left by an unclean shutdown, and recovery
227/// writes. So the file is copied first and the copy is what gets opened; the
228/// copy is removed before the destination store is created.
229fn read_source_events(
230    source_store_file: &Path,
231    destination_dir: &Path,
232) -> Result<Vec<ContextUpdatedEvent>, PortError> {
233    fs::create_dir_all(destination_dir).map_err(|error| {
234        PortError::Unavailable(format!(
235            "migration could not create destination `{}`: {error}",
236            destination_dir.display()
237        ))
238    })?;
239    let copy_path: PathBuf = destination_dir.join(SOURCE_COPY_FILE);
240    fs::copy(source_store_file, &copy_path).map_err(|error| {
241        PortError::Unavailable(format!(
242            "migration could not copy the source store to `{}`: {error}",
243            copy_path.display()
244        ))
245    })?;
246
247    let events = {
248        let source = EmbeddedKernelStore::open_store_file(&copy_path)?;
249        source.read_event_log_blocking()
250    };
251
252    // Best effort: a leftover copy is inert, but leaving it would make the
253    // destination directory lie about what it contains.
254    let _ = fs::remove_file(&copy_path);
255    events
256}
257
258fn sha256_of(path: &Path) -> Result<String, PortError> {
259    let bytes = fs::read(path).map_err(|error| {
260        PortError::Unavailable(format!(
261            "migration could not read `{}`: {error}",
262            path.display()
263        ))
264    })?;
265    let mut hasher = Sha256::new();
266    hasher.update(&bytes);
267    Ok(format!("{:x}", hasher.finalize()))
268}
269
270fn same_file(left: &Path, right: &Path) -> bool {
271    match (fs::canonicalize(left), fs::canonicalize(right)) {
272        (Ok(left), Ok(right)) => left == right,
273        // Unresolvable paths (a destination that does not exist yet) fall
274        // back to the literal comparison, which is what the caller wrote.
275        _ => left == right,
276    }
277}