Skip to main content

reddb_server/
document_migration.rs

1//! All-at-once, reversible migration to the native binary document body
2//! (PRD-1398, ADR-0063).
3//!
4//! A legacy DOCUMENT store keeps two copies of every document — a plain-JSON
5//! `body` and the materialised promoted columns. The single-source slices
6//! (#1402/#1403) make the binary `body` the one source of truth, but existing
7//! stores still hold plain-JSON bodies with promoted columns. This tool
8//! performs the cutover, modelled on the maintainer's explicit safety margin:
9//! the canonical data is never touched until the very end.
10//!
11//! The migration is **all-at-once but reversible**:
12//!
13//! 1. Read every document from the source store (untouched, read-only).
14//! 2. Build a **fresh store in new files alongside** the source, rewriting each
15//!    document into the native binary container ([`crate::document_body`]).
16//! 3. Auto-`CREATE INDEX` for **every previously-promoted field** so queries
17//!    that relied on the implicit promoted-column filter stay fast — avoiding a
18//!    silent post-deploy performance regression.
19//! 4. **Verify document counts** match per collection; any mismatch aborts the
20//!    migration *before* the swap, leaving the source store untouched.
21//! 5. **Atomically swap** the new files into place and **retain the old files**
22//!    as the rollback point.
23//!
24//! The unit of migration is a store **directory** (the directory that holds the
25//! `db.rdb` data file and its sibling artifacts — WAL, snapshots, audit log).
26//! Swapping whole directories with `rename(2)` is atomic on POSIX and lets the
27//! pre-migration directory survive untouched as the rollback point.
28
29use std::collections::BTreeSet;
30use std::path::{Path, PathBuf};
31
32use crate::application::{CreateDocumentInput, EntityUseCases};
33use crate::catalog::CollectionModel;
34use crate::storage::EntityData;
35use crate::{RedDBError, RedDBOptions, RedDBResult, RedDBRuntime};
36use reddb_types::Value;
37
38/// The data file name inside a store directory. Matches the convention used by
39/// [`RedDBOptions::in_memory`], whose siblings (WAL, audit, snapshots) derive
40/// from the data path's parent directory.
41const DATA_FILE: &str = "db.rdb";
42
43/// Suffix for the freshly-built store directory written alongside the source.
44const MIGRATING_SUFFIX: &str = "migrating";
45
46/// Suffix for the retained pre-migration store directory (the rollback point).
47const BACKUP_SUFFIX: &str = "pre-migration";
48
49/// Per-collection outcome of a migration.
50#[derive(Debug, Clone)]
51pub struct CollectionMigration {
52    /// Collection name.
53    pub name: String,
54    /// Document count read from the source store.
55    pub source_documents: usize,
56    /// Document count written into the migrated store.
57    pub migrated_documents: usize,
58    /// Previously-promoted fields that received an auto-created index, sorted.
59    pub auto_indexed_fields: Vec<String>,
60}
61
62/// Summary of a completed, swapped migration.
63#[derive(Debug, Clone)]
64pub struct MigrationReport {
65    /// Per-collection outcomes, in collection-name order.
66    pub collections: Vec<CollectionMigration>,
67    /// Directory holding the retained pre-migration files (rollback point).
68    pub backup_dir: PathBuf,
69    /// Total documents migrated across all collections.
70    pub total_documents: usize,
71}
72
73/// One document collection's data gathered from the source store.
74struct SourceCollection {
75    name: String,
76    bodies: Vec<crate::json::Value>,
77    /// Previously-promoted top-level fields (materialised as columns), sorted
78    /// and de-duplicated across every document.
79    legacy_fields: Vec<String>,
80}
81
82/// Migrate the DOCUMENT collections of the store in `store_dir` to the native
83/// binary body, reversibly.
84///
85/// On success the store at `store_dir` holds the migrated (binary-body) files
86/// and the pre-migration files are retained at the returned
87/// [`MigrationReport::backup_dir`]. On any failure *before* the swap, the
88/// source store is left exactly as it was and the partially-built migrated
89/// directory is removed.
90pub fn migrate_store_to_binary_body(store_dir: &Path) -> RedDBResult<MigrationReport> {
91    if !store_dir.is_dir() {
92        return Err(RedDBError::InvalidOperation(format!(
93            "migration source is not a directory: {}",
94            store_dir.display()
95        )));
96    }
97
98    // Phase 1 — read the source store. Scoped so the runtime (and its file
99    // handles) are dropped before we touch the filesystem for the swap.
100    let collections = read_source_collections(store_dir)?;
101
102    // Phase 2 — build the migrated store in fresh files alongside the source.
103    let migrating_dir = sibling_dir(store_dir, MIGRATING_SUFFIX)?;
104    let outcome = build_migrated_store(&migrating_dir, &collections);
105    let report_collections = match outcome {
106        Ok(report) => report,
107        Err(err) => {
108            // Never leave a half-built store around; the source is untouched.
109            let _ = std::fs::remove_dir_all(&migrating_dir);
110            return Err(err);
111        }
112    };
113
114    // Phase 3 — atomic swap, retaining the old files for rollback.
115    let backup_dir = swap_in_migrated_store(store_dir, &migrating_dir)?;
116
117    let total_documents = report_collections
118        .iter()
119        .map(|c| c.migrated_documents)
120        .sum();
121    Ok(MigrationReport {
122        collections: report_collections,
123        backup_dir,
124        total_documents,
125    })
126}
127
128/// Open the source store read-only-ish and pull every document body plus the
129/// set of previously-promoted fields per DOCUMENT collection.
130fn read_source_collections(store_dir: &Path) -> RedDBResult<Vec<SourceCollection>> {
131    let source = RedDBRuntime::with_options(RedDBOptions::persistent(store_dir.join(DATA_FILE)))?;
132
133    let mut document_collections: Vec<String> = source
134        .catalog()
135        .collections
136        .into_iter()
137        .filter(|descriptor| descriptor.model == CollectionModel::Document)
138        .map(|descriptor| descriptor.name)
139        .collect();
140    document_collections.sort();
141
142    let mut out = Vec::with_capacity(document_collections.len());
143    for name in document_collections {
144        let mut bodies = Vec::new();
145        let mut legacy_fields: BTreeSet<String> = BTreeSet::new();
146        let mut cursor = None;
147        loop {
148            let page = source.scan_collection(&name, cursor, 1_000)?;
149            for entity in &page.items {
150                let EntityData::Row(row) = &entity.data else {
151                    continue;
152                };
153                // Every previously-promoted field is materialised as a named
154                // column next to `body`; the body itself is the full document.
155                for (field, _value) in row.iter_fields() {
156                    if field != "body"
157                        && !crate::reserved_fields::is_reserved_public_item_field(field)
158                    {
159                        legacy_fields.insert(field.to_string());
160                    }
161                }
162                bodies.push(decode_body(row)?);
163            }
164            match page.next {
165                Some(next) => cursor = Some(next),
166                None => break,
167            }
168        }
169        out.push(SourceCollection {
170            name,
171            bodies,
172            legacy_fields: legacy_fields.into_iter().collect(),
173        });
174    }
175
176    Ok(out)
177}
178
179/// Decode a document row's `body` column to its JSON form, transparently
180/// handling both legacy plain-JSON and (already-)binary bodies.
181fn decode_body(row: &crate::storage::RowData) -> RedDBResult<crate::json::Value> {
182    match row.get_field("body") {
183        Some(Value::Json(bytes)) => Ok(reddb_types::value_json::json_bytes_to_json(bytes)),
184        Some(other) => Ok(other.to_json()),
185        None => Err(RedDBError::InvalidOperation(
186            "document row is missing its `body` field".to_string(),
187        )),
188    }
189}
190
191/// Build a fresh binary-body store in `migrating_dir` from the gathered source
192/// collections, verifying counts before returning.
193fn build_migrated_store(
194    migrating_dir: &Path,
195    collections: &[SourceCollection],
196) -> RedDBResult<Vec<CollectionMigration>> {
197    std::fs::create_dir_all(migrating_dir).map_err(RedDBError::Io)?;
198
199    let target =
200        RedDBRuntime::with_options(RedDBOptions::persistent(migrating_dir.join(DATA_FILE)))?;
201    // Every document write below stores the body in the native binary
202    // container. Reads stay JSON regardless of this flag (self-describing
203    // `RDOC` magic), so the wire/clients are unaffected.
204    target.execute_query("SET CONFIG storage.binary_document_body = true")?;
205
206    let entities = EntityUseCases::new(&target);
207    let mut report = Vec::with_capacity(collections.len());
208
209    for collection in collections {
210        target.execute_query(&format!("CREATE DOCUMENT {}", collection.name))?;
211
212        for body in &collection.bodies {
213            entities.create_document(CreateDocumentInput {
214                collection: collection.name.clone(),
215                body: body.clone(),
216                metadata: Vec::new(),
217                node_links: Vec::new(),
218                vector_links: Vec::new(),
219            })?;
220        }
221
222        // Auto-`CREATE INDEX` for every previously-promoted field so queries
223        // that relied on the implicit promoted-column filter stay fast.
224        for field in &collection.legacy_fields {
225            target.execute_query(&format!(
226                "CREATE INDEX {index} ON {collection} ({field}) USING BTREE",
227                index = auto_index_name(&collection.name, field),
228                collection = collection.name,
229                field = field,
230            ))?;
231        }
232
233        // Verify the document count before this becomes live: a bad rewrite
234        // must be caught here, not after the swap.
235        let migrated = target.scan_collection(&collection.name, None, 1)?.total;
236        let source = collection.bodies.len();
237        ensure_counts_match(&collection.name, source, migrated)?;
238
239        report.push(CollectionMigration {
240            name: collection.name.clone(),
241            source_documents: source,
242            migrated_documents: migrated,
243            auto_indexed_fields: collection.legacy_fields.clone(),
244        });
245    }
246
247    // Flush + checkpoint so the migrated files are durable before we drop the
248    // runtime and rename the directory into place.
249    target.flush()?;
250    target.checkpoint()?;
251    drop(target);
252
253    Ok(report)
254}
255
256/// Atomically swap `migrating_dir` into `store_dir`, moving the existing files
257/// aside to a retained backup directory. Returns the backup directory path.
258fn swap_in_migrated_store(store_dir: &Path, migrating_dir: &Path) -> RedDBResult<PathBuf> {
259    let backup_dir = sibling_dir(store_dir, BACKUP_SUFFIX)?;
260
261    // Move the pre-migration files aside (rollback point) …
262    std::fs::rename(store_dir, &backup_dir).map_err(RedDBError::Io)?;
263    // … then move the migrated files into place. If this second step fails,
264    // restore the original directory so the store is never left missing.
265    if let Err(err) = std::fs::rename(migrating_dir, store_dir) {
266        let _ = std::fs::rename(&backup_dir, store_dir);
267        return Err(RedDBError::Io(err));
268    }
269
270    Ok(backup_dir)
271}
272
273/// A sibling path of `store_dir` with `<name>.<suffix>`, guaranteed not to
274/// already exist (so a stale directory never silently shadows the swap).
275fn sibling_dir(store_dir: &Path, suffix: &str) -> RedDBResult<PathBuf> {
276    let file_name = store_dir
277        .file_name()
278        .and_then(|name| name.to_str())
279        .ok_or_else(|| {
280            RedDBError::InvalidOperation(format!(
281                "store directory has no usable name: {}",
282                store_dir.display()
283            ))
284        })?;
285    let parent = store_dir.parent().unwrap_or_else(|| Path::new("."));
286    let candidate = parent.join(format!("{file_name}.{suffix}"));
287    if candidate.exists() {
288        return Err(RedDBError::InvalidOperation(format!(
289            "migration sibling directory already exists: {}",
290            candidate.display()
291        )));
292    }
293    Ok(candidate)
294}
295
296/// Guard the pre-swap invariant: the migrated collection must hold exactly as
297/// many documents as the source. A mismatch aborts the migration *before* the
298/// swap, so the canonical source store is never touched.
299fn ensure_counts_match(collection: &str, source: usize, migrated: usize) -> RedDBResult<()> {
300    if migrated != source {
301        return Err(RedDBError::InvalidOperation(format!(
302            "document count mismatch for collection '{collection}': source {source} != \
303             migrated {migrated}; aborting before swap"
304        )));
305    }
306    Ok(())
307}
308
309/// Deterministic, identifier-safe name for an auto-created migration index.
310fn auto_index_name(collection: &str, field: &str) -> String {
311    fn sanitize(input: &str) -> String {
312        input
313            .chars()
314            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
315            .collect()
316    }
317    format!("mig_{}_{}", sanitize(collection), sanitize(field))
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn matching_counts_pass_mismatch_aborts() {
326        assert!(ensure_counts_match("docs", 4, 4).is_ok());
327        // A short rewrite must abort before the swap.
328        let err = ensure_counts_match("docs", 4, 3).expect_err("mismatch aborts");
329        assert!(matches!(err, RedDBError::InvalidOperation(_)));
330    }
331
332    #[test]
333    fn auto_index_name_is_identifier_safe() {
334        assert_eq!(auto_index_name("docs", "score"), "mig_docs_score");
335        assert_eq!(
336            auto_index_name("my-docs", "user.name"),
337            "mig_my_docs_user_name"
338        );
339    }
340
341    #[test]
342    fn sibling_dir_rejects_existing() {
343        let base = std::env::temp_dir().join(format!(
344            "reddb-mig-sibling-{}-{}",
345            std::process::id(),
346            "store"
347        ));
348        let _ = std::fs::remove_dir_all(&base);
349        std::fs::create_dir_all(&base).unwrap();
350
351        // No sibling yet → ok.
352        let sib = sibling_dir(&base, "migrating").unwrap();
353        assert!(sib
354            .file_name()
355            .and_then(|name| name.to_str())
356            .is_some_and(|name| name.ends_with(".migrating")));
357
358        // Create it, then the helper must refuse to reuse it.
359        std::fs::create_dir_all(&sib).unwrap();
360        assert!(sibling_dir(&base, "migrating").is_err());
361
362        let _ = std::fs::remove_dir_all(&base);
363        let _ = std::fs::remove_dir_all(&sib);
364    }
365}