1use 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#[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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct MigrationReport {
75 pub source_version: u32,
77 pub destination_version: u32,
79 pub records: u64,
81 pub value_bytes: u64,
83 pub destination_size: u64,
85 pub ambiguous_legacy_markers: u64,
87}
88
89#[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
131pub type MigrationResult<T> = std::result::Result<T, MigrationError>;
133
134pub 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}