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