Skip to main content

feoxdb/core/store/
migration.rs

1//! Offline migration from legacy store formats.
2
3use std::ffi::OsString;
4use std::fs::{self, File, OpenOptions};
5use std::io;
6use std::ops::Bound;
7use std::path::{Path, PathBuf};
8use std::process;
9use std::sync::atomic::Ordering;
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use crossbeam_epoch as epoch;
14use thiserror::Error;
15
16use crate::constants::{
17    FEOX_BLOCK_SIZE, FEOX_DATA_START_BLOCK, MAX_DEVICE_SIZE, MAX_RECOVERABLE_KEY_SIZE,
18};
19use crate::core::record::Record;
20use crate::error::FeoxError;
21use crate::storage::format::get_format_ref;
22
23use super::{FeoxStore, StoreConfig};
24
25const MIGRATION_HASH_BITS: u32 = 18;
26const MIGRATION_SCAN_RECORDS: usize = 256;
27const MIGRATION_FLUSH_RECORDS: u64 = 4_096;
28const MIGRATION_FLUSH_BYTES: u64 = 64 * 1024 * 1024;
29const TEMP_CREATE_ATTEMPTS: usize = 16;
30
31/// Options for copying a v1 or v2 store into a new v3 store.
32///
33/// The source must be offline for the entire migration. FeOxDB opens it
34/// read-only but does not lock it against another process.
35#[derive(Clone, Debug)]
36pub struct MigrationOptions {
37    source: PathBuf,
38    destination: PathBuf,
39    allow_ambiguous_legacy_recovery: bool,
40    hash_bits: u32,
41}
42
43impl MigrationOptions {
44    /// Create migration options for an offline source and a new destination.
45    ///
46    /// The destination must not exist and is never overwritten.
47    pub fn new(source: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Self {
48        Self {
49            source: source.into(),
50            destination: destination.into(),
51            allow_ambiguous_legacy_recovery: false,
52            hash_bits: MIGRATION_HASH_BITS,
53        }
54    }
55
56    /// Permit the historical skip-one interpretation of legacy deletion markers.
57    ///
58    /// A v1/v2 marker does not encode the retired extent length. Enabling this can
59    /// expose continuation bytes from a deleted multi-sector record as a record.
60    pub fn allow_ambiguous_legacy_recovery(mut self, allow: bool) -> Self {
61        self.allow_ambiguous_legacy_recovery = allow;
62        self
63    }
64
65    #[cfg(test)]
66    pub(crate) fn hash_bits(mut self, hash_bits: u32) -> Self {
67        self.hash_bits = hash_bits;
68        self
69    }
70}
71
72/// Summary of a completed and verified migration.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct MigrationReport {
75    /// Legacy source format version.
76    pub source_version: u32,
77    /// Format version written to the destination.
78    pub destination_version: u32,
79    /// Number of recovered records copied.
80    pub records: u64,
81    /// Total logical value bytes copied.
82    pub value_bytes: u64,
83    /// Allocated destination file size.
84    pub destination_size: u64,
85    /// Number of ambiguous legacy markers accepted by explicit opt-in.
86    pub ambiguous_legacy_markers: u64,
87}
88
89/// Error returned by an offline migration.
90#[derive(Error, Debug)]
91pub enum MigrationError {
92    #[error("destination path has no file name: {0:?}")]
93    InvalidDestination(PathBuf),
94
95    #[error("destination already exists: {0:?}")]
96    DestinationExists(PathBuf),
97
98    #[error("source store already uses format v{0}")]
99    CurrentFormat(u32),
100
101    #[error("source key is {length} bytes; format v3 supports at most {maximum}")]
102    KeyTooLarge { length: usize, maximum: usize },
103
104    #[error("format v3 destination would exceed the maximum device size")]
105    DestinationTooLarge,
106
107    #[error("source changed while migration was running")]
108    SourceChanged,
109
110    #[error("temporary destination changed before publication")]
111    DestinationChanged,
112
113    #[error("migration verification failed at record {0}")]
114    VerificationFailed(u64),
115
116    #[error("ambiguous legacy deletion marker requires explicit unsafe migration opt-in")]
117    AmbiguousLegacyRecovery,
118
119    #[error("{operation} {path:?}: {source}")]
120    Io {
121        operation: &'static str,
122        path: PathBuf,
123        #[source]
124        source: io::Error,
125    },
126
127    #[error(transparent)]
128    Store(#[from] FeoxError),
129}
130
131/// Result type returned by migration operations.
132pub type MigrationResult<T> = std::result::Result<T, MigrationError>;
133
134/// Copy an offline format v1 or v2 store into a new format v3 store.
135///
136/// The source is opened read-only and remains unchanged. Migration resolves the
137/// newest record generation for each key with TTL filtering disabled, then
138/// preserves each winning record's timestamp and absolute TTL expiry exactly.
139/// Consequently, an expired newest generation remains the winner and an older
140/// value cannot reappear when the migrated store is opened with TTL enabled.
141///
142/// Data is written to a sibling temporary file, flushed, reopened, and compared
143/// record-for-record before it is published at the destination without
144/// overwriting an existing path. Verification proves that the logical copy
145/// matches recovery; legacy v1/v2 data has no v3 record checksums, so it cannot
146/// prove that the source itself was uncorrupted.
147///
148/// No process may write the source or manipulate the destination paths during
149/// this operation. The implementation performs best-effort identity and metadata
150/// checks, not cooperative locking.
151pub fn migrate(options: MigrationOptions) -> MigrationResult<MigrationReport> {
152    let source_file = open_read_only_file(&options.source)?;
153    let source_stamp = FileStamp::read_file(&source_file, &options.source)?;
154    if FileStamp::read(&options.source)? != source_stamp {
155        return Err(MigrationError::SourceChanged);
156    }
157    let source = build_read_only(
158        source_file,
159        options.allow_ambiguous_legacy_recovery,
160        options.hash_bits,
161    )?;
162    if FileStamp::read_store_file(&source, &options.source)? != source_stamp
163        || FileStamp::read(&options.source)? != source_stamp
164    {
165        return Err(MigrationError::SourceChanged);
166    }
167    let source_version = source.format_version;
168    if source_version >= 3 {
169        return Err(MigrationError::CurrentFormat(source_version));
170    }
171
172    let layout = source_layout(&source)?;
173    let destination_size = source.device_size.max(layout.required_size);
174    if destination_size > MAX_DEVICE_SIZE {
175        return Err(MigrationError::DestinationTooLarge);
176    }
177
178    let mut destination_guard = DestinationGuard::create(&options.destination)?;
179    let destination = FeoxStore::with_config_for_migration_destination(
180        migration_config(options.hash_bits, Some(destination_size)),
181        destination_guard.take_file(),
182    )?;
183
184    copy_records(&source, &destination)?;
185    destination.flush()?;
186    let verification_file = destination
187        .device_file
188        .as_ref()
189        .ok_or(FeoxError::NoDevice)?
190        .try_clone()
191        .map_err(|source| MigrationError::Io {
192            operation: "cloning temporary destination",
193            path: destination_guard.temporary.clone(),
194            source,
195        })?;
196    drop(destination);
197
198    let verified = build_read_only(verification_file, false, options.hash_bits)?;
199    if verified.format_version != 3 {
200        return Err(MigrationError::VerificationFailed(0));
201    }
202    let records = verify_records(&source, &verified)?;
203    let destination_stamp =
204        FileStamp::read_store_file(&verified, destination_guard.temporary_path())?;
205    drop(verified);
206
207    if FileStamp::read_store_file(&source, &options.source)? != source_stamp
208        || FileStamp::read(&options.source)? != source_stamp
209    {
210        return Err(MigrationError::SourceChanged);
211    }
212
213    destination_guard.publish(&destination_stamp)?;
214    Ok(MigrationReport {
215        source_version,
216        destination_version: 3,
217        records,
218        value_bytes: layout.value_bytes,
219        destination_size,
220        ambiguous_legacy_markers: source.ambiguous_legacy_markers,
221    })
222}
223
224struct SourceLayout {
225    required_size: u64,
226    value_bytes: u64,
227}
228
229fn source_layout(store: &FeoxStore) -> MigrationResult<SourceLayout> {
230    let format = get_format_ref(3);
231    let mut after = None;
232    let mut sectors = FEOX_DATA_START_BLOCK;
233    let mut value_bytes = 0_u64;
234
235    loop {
236        let records = record_batch(store, after.as_deref());
237        let Some(last) = records.last() else {
238            break;
239        };
240        after = Some(last.key.clone());
241
242        for record in records {
243            if record.key.len() > MAX_RECOVERABLE_KEY_SIZE {
244                return Err(MigrationError::KeyTooLarge {
245                    length: record.key.len(),
246                    maximum: MAX_RECOVERABLE_KEY_SIZE,
247                });
248            }
249            let record_sectors = format
250                .total_size(record.key.len(), record.value_len)
251                .div_ceil(FEOX_BLOCK_SIZE) as u64;
252            sectors = sectors
253                .checked_add(record_sectors)
254                .ok_or(MigrationError::DestinationTooLarge)?;
255            value_bytes = value_bytes
256                .checked_add(record.value_len as u64)
257                .ok_or(MigrationError::DestinationTooLarge)?;
258        }
259    }
260
261    let required_size = sectors
262        .checked_mul(FEOX_BLOCK_SIZE as u64)
263        .ok_or(MigrationError::DestinationTooLarge)?;
264    Ok(SourceLayout {
265        required_size,
266        value_bytes,
267    })
268}
269
270fn copy_records(source: &FeoxStore, destination: &FeoxStore) -> MigrationResult<()> {
271    let mut after = None;
272    let mut visited = 0_u64;
273    let mut pending_records = 0_u64;
274    let mut pending_bytes = 0_u64;
275
276    loop {
277        let records = record_batch(source, after.as_deref());
278        let Some(last) = records.last() else {
279            break;
280        };
281        after = Some(last.key.clone());
282
283        for record in records {
284            let value = source.resolve_value_ref(&record.key, &record)?;
285            let value_len = value.len() as u64;
286            let inserted = destination.insert_migrated_bytes(
287                &record.key,
288                value,
289                record.timestamp,
290                record.ttl_expiry.load(Ordering::Acquire),
291            )?;
292            if !inserted {
293                return Err(MigrationError::VerificationFailed(visited));
294            }
295            visited += 1;
296            pending_records += 1;
297            pending_bytes += value_len;
298            if pending_records >= MIGRATION_FLUSH_RECORDS || pending_bytes >= MIGRATION_FLUSH_BYTES
299            {
300                destination.flush()?;
301                pending_records = 0;
302                pending_bytes = 0;
303            }
304        }
305    }
306
307    Ok(())
308}
309
310fn verify_records(source: &FeoxStore, destination: &FeoxStore) -> MigrationResult<u64> {
311    let mut source_after = None;
312    let mut destination_after = None;
313    let mut records = 0;
314
315    loop {
316        let source_records = record_batch(source, source_after.as_deref());
317        let destination_records = record_batch(destination, destination_after.as_deref());
318        if source_records.is_empty() && destination_records.is_empty() {
319            return Ok(records);
320        }
321        if source_records.len() != destination_records.len() {
322            return Err(MigrationError::VerificationFailed(records));
323        }
324
325        source_after = source_records.last().map(|record| record.key.clone());
326        destination_after = destination_records.last().map(|record| record.key.clone());
327
328        for (source_record, destination_record) in
329            source_records.into_iter().zip(destination_records)
330        {
331            let same_metadata = source_record.key == destination_record.key
332                && source_record.timestamp == destination_record.timestamp
333                && source_record.ttl_expiry.load(Ordering::Acquire)
334                    == destination_record.ttl_expiry.load(Ordering::Acquire);
335            if !same_metadata
336                || source.resolve_value_ref(&source_record.key, &source_record)?
337                    != destination
338                        .resolve_value_ref(&destination_record.key, &destination_record)?
339            {
340                return Err(MigrationError::VerificationFailed(records));
341            }
342            records += 1;
343        }
344    }
345}
346
347fn record_batch(store: &FeoxStore, after: Option<&[u8]>) -> Vec<Arc<Record>> {
348    let guard = &epoch::pin();
349    let mut cursor = match after {
350        Some(key) => store.tree.lower_bound(Bound::Excluded(key)),
351        None => store.tree.front(),
352    };
353    let mut records = Vec::with_capacity(MIGRATION_SCAN_RECORDS);
354
355    while records.len() < MIGRATION_SCAN_RECORDS {
356        let Some(entry) = cursor else {
357            break;
358        };
359        records.push(Arc::clone(entry.value().load(guard)));
360        cursor = entry.next();
361    }
362
363    records
364}
365
366fn open_read_only_file(path: &Path) -> MigrationResult<File> {
367    OpenOptions::new()
368        .read(true)
369        .open(path)
370        .map_err(|source| MigrationError::Io {
371            operation: "opening",
372            path: path.to_path_buf(),
373            source,
374        })
375}
376
377fn build_read_only(
378    file: File,
379    allow_ambiguous_legacy_recovery: bool,
380    hash_bits: u32,
381) -> MigrationResult<FeoxStore> {
382    FeoxStore::with_config_for_migration_source(
383        migration_config(hash_bits, None),
384        allow_ambiguous_legacy_recovery,
385        file,
386    )
387    .map_err(|error| match error {
388        FeoxError::AmbiguousLegacyTombstone => MigrationError::AmbiguousLegacyRecovery,
389        error => MigrationError::Store(error),
390    })
391}
392
393fn migration_config(hash_bits: u32, file_size: Option<u64>) -> StoreConfig {
394    StoreConfig {
395        hash_bits,
396        memory_only: false,
397        enable_caching: false,
398        device_path: None,
399        file_size,
400        max_memory: None,
401        enable_ttl: false,
402        ttl_config: None,
403    }
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
407struct FileStamp {
408    len: u64,
409    modified: Option<SystemTime>,
410    created: Option<SystemTime>,
411    #[cfg(unix)]
412    device: u64,
413    #[cfg(unix)]
414    inode: u64,
415}
416
417impl FileStamp {
418    fn read(path: &Path) -> MigrationResult<Self> {
419        let metadata = fs::metadata(path).map_err(|source| MigrationError::Io {
420            operation: "reading",
421            path: path.to_path_buf(),
422            source,
423        })?;
424        Ok(Self::from_metadata(metadata))
425    }
426
427    fn read_store_file(store: &FeoxStore, path: &Path) -> MigrationResult<Self> {
428        let file = store.device_file.as_ref().ok_or(FeoxError::NoDevice)?;
429        Self::read_file(file, path)
430    }
431
432    fn read_regular(path: &Path) -> MigrationResult<Option<Self>> {
433        let metadata = fs::symlink_metadata(path).map_err(|source| MigrationError::Io {
434            operation: "reading",
435            path: path.to_path_buf(),
436            source,
437        })?;
438        Ok(metadata
439            .file_type()
440            .is_file()
441            .then(|| Self::from_metadata(metadata)))
442    }
443
444    fn read_file(file: &File, path: &Path) -> MigrationResult<Self> {
445        let metadata = file.metadata().map_err(|source| MigrationError::Io {
446            operation: "reading metadata for",
447            path: path.to_path_buf(),
448            source,
449        })?;
450        Ok(Self::from_metadata(metadata))
451    }
452
453    fn from_metadata(metadata: fs::Metadata) -> Self {
454        #[cfg(unix)]
455        use std::os::unix::fs::MetadataExt;
456
457        Self {
458            len: metadata.len(),
459            modified: metadata.modified().ok(),
460            created: metadata.created().ok(),
461            #[cfg(unix)]
462            device: metadata.dev(),
463            #[cfg(unix)]
464            inode: metadata.ino(),
465        }
466    }
467}
468
469struct DestinationGuard {
470    destination: PathBuf,
471    temporary: PathBuf,
472    file: Option<File>,
473}
474
475impl DestinationGuard {
476    fn create(destination: &Path) -> MigrationResult<Self> {
477        match fs::symlink_metadata(destination) {
478            Ok(_) => {
479                return Err(MigrationError::DestinationExists(destination.to_path_buf()));
480            }
481            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
482            Err(source) => {
483                return Err(MigrationError::Io {
484                    operation: "checking",
485                    path: destination.to_path_buf(),
486                    source,
487                });
488            }
489        }
490
491        let file_name = destination
492            .file_name()
493            .ok_or_else(|| MigrationError::InvalidDestination(destination.to_path_buf()))?;
494        let parent = destination.parent().unwrap_or_else(|| Path::new("."));
495        let mut last_collision = None;
496
497        for _ in 0..TEMP_CREATE_ATTEMPTS {
498            let mut temporary_name = OsString::from(".");
499            temporary_name.push(file_name);
500            temporary_name.push(format!(
501                ".feox-migrate-{}-{:016x}.tmp",
502                process::id(),
503                rand::random::<u64>()
504            ));
505            let temporary = parent.join(temporary_name);
506            match OpenOptions::new()
507                .read(true)
508                .write(true)
509                .create_new(true)
510                .open(&temporary)
511            {
512                Ok(file) => {
513                    return Ok(Self {
514                        destination: destination.to_path_buf(),
515                        temporary,
516                        file: Some(file),
517                    });
518                }
519                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
520                    last_collision = Some(error);
521                }
522                Err(source) => {
523                    return Err(MigrationError::Io {
524                        operation: "creating temporary destination beside",
525                        path: destination.to_path_buf(),
526                        source,
527                    });
528                }
529            }
530        }
531
532        Err(MigrationError::Io {
533            operation: "creating temporary destination beside",
534            path: destination.to_path_buf(),
535            source: last_collision.unwrap_or_else(|| {
536                io::Error::new(io::ErrorKind::AlreadyExists, "temporary name collision")
537            }),
538        })
539    }
540
541    fn take_file(&mut self) -> File {
542        self.file
543            .take()
544            .expect("temporary destination file missing")
545    }
546
547    fn temporary_path(&self) -> &Path {
548        &self.temporary
549    }
550
551    fn publish(&mut self, expected: &FileStamp) -> MigrationResult<()> {
552        if FileStamp::read_regular(&self.temporary)?.as_ref() != Some(expected) {
553            return Err(MigrationError::DestinationChanged);
554        }
555        fs::hard_link(&self.temporary, &self.destination).map_err(|source| {
556            if source.kind() == io::ErrorKind::AlreadyExists {
557                MigrationError::DestinationExists(self.destination.clone())
558            } else {
559                MigrationError::Io {
560                    operation: "publishing",
561                    path: self.destination.clone(),
562                    source,
563                }
564            }
565        })?;
566
567        match FileStamp::read_regular(&self.destination) {
568            Ok(stamp) if stamp.as_ref() == Some(expected) => {}
569            Ok(_) => {
570                self.rollback_publication();
571                return Err(MigrationError::DestinationChanged);
572            }
573            Err(error) => {
574                self.rollback_publication();
575                return Err(error);
576            }
577        }
578
579        if let Err(source) = sync_parent_directory(&self.destination) {
580            self.rollback_publication();
581            return Err(MigrationError::Io {
582                operation: "syncing destination directory for",
583                path: self.destination.clone(),
584                source,
585            });
586        }
587
588        if let Err(source) = fs::remove_file(&self.temporary) {
589            self.rollback_publication();
590            return Err(MigrationError::Io {
591                operation: "removing temporary destination for",
592                path: self.destination.clone(),
593                source,
594            });
595        }
596        let _ = sync_parent_directory(&self.destination);
597        Ok(())
598    }
599
600    fn rollback_publication(&self) {
601        let _ = fs::remove_file(&self.destination);
602        let _ = sync_parent_directory(&self.destination);
603    }
604}
605
606impl Drop for DestinationGuard {
607    fn drop(&mut self) {
608        drop(self.file.take());
609        let _ = fs::remove_file(&self.temporary);
610    }
611}
612
613#[cfg(unix)]
614fn sync_parent_directory(path: &Path) -> io::Result<()> {
615    let parent = path
616        .parent()
617        .filter(|parent| !parent.as_os_str().is_empty());
618    File::open(parent.unwrap_or_else(|| Path::new(".")))?.sync_all()
619}
620
621#[cfg(not(unix))]
622fn sync_parent_directory(_: &Path) -> io::Result<()> {
623    Ok(())
624}