Skip to main content

reddb_server/
pager_zone_migration.rs

1//! Offline, reversible migration between the sidecar-backed pager layout and
2//! the zoned `.rdb` (ADR 0038 ยง4 phase 1).
3//!
4//! A legacy store keeps its page-0 database header in a `rdb-hdr` sidecar and
5//! its manifest page in a `rdb-meta` sidecar, each a whole-page shadow written
6//! before the in-file copy. The zoned form retires both: page 0 becomes a
7//! superblock ping-pong pair, and page 1 plus its overflow chain is the
8//! internal manifest, rooted by the superblock.
9//!
10//! Per the house no-backcompat posture the engine never reads the old form โ€”
11//! [`crate::storage::engine::pager::Pager::open`] refuses it and names this
12//! module. Conversion is an explicit, offline step, modelled on the reversible
13//! document-body migration ([`crate::document_migration`]):
14//!
15//! 1. Copy the data file to a retained `<data>.pre-migration` rollback point.
16//! 2. Rebuild page 0's two superblock slots from the authoritative header โ€”
17//!    preferring the in-file page 0 and falling back to the `rdb-hdr` shadow
18//!    when page 0 is the torn one (that is what the shadow was for).
19//! 3. Restore page 1 from the `rdb-meta` shadow if the in-file copy fails its
20//!    checksum, then fsync.
21//! 4. Only once the file is durable, unlink the sidecars.
22//!
23//! Every step before the unlink leaves the source readable by the *old* engine,
24//! and [`revert_to_sidecars`] walks it back. Nothing here touches the live
25//! filename contract: the retired names come from `reddb_file::layout::retired`.
26
27use std::fs::{self, File, OpenOptions};
28use std::io::{Read, Seek, SeekFrom, Write};
29use std::path::{Path, PathBuf};
30
31use reddb_file::layout::retired;
32
33const PAGE_SIZE: usize = reddb_file::PAGED_PAGE_SIZE;
34const SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
35const ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
36const SUPERBLOCK_TRAILER_OFFSET: usize = reddb_file::PAGED_SUPERBLOCK_TRAILER_OFFSET;
37
38/// Suffix for the retained pre-migration data file (the rollback point).
39const BACKUP_SUFFIX: &str = "pre-migration";
40
41/// What a migration did, so the caller can log or assert it.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ZoneMigrationReport {
44    /// The data file that now carries the zoned layout.
45    pub data_path: PathBuf,
46    /// Retained copy of the pre-migration data file.
47    pub backup_path: PathBuf,
48    /// Retired sidecars removed by the migration, in the order they were found.
49    pub removed_sidecars: Vec<PathBuf>,
50    /// `true` when page 0 was torn and the `rdb-hdr` shadow supplied the header.
51    pub header_recovered_from_shadow: bool,
52    /// `true` when page 1 was torn and the `rdb-meta` shadow supplied the
53    /// manifest page.
54    pub manifest_recovered_from_shadow: bool,
55}
56
57#[derive(Debug)]
58pub enum ZoneMigrationError {
59    /// The data file does not exist.
60    MissingStore(PathBuf),
61    /// No retired sidecar is present, so there is nothing to migrate.
62    NotALegacyStore(PathBuf),
63    /// The store is already zoned (a valid superblock zone is present).
64    AlreadyZoned(PathBuf),
65    /// No usable database header survives in page 0 or the `rdb-hdr` shadow.
66    HeaderUnrecoverable(PathBuf),
67    /// This tool cannot relocate pages from the pre-phase-3 layout.
68    PrePhase3Unsupported {
69        path: PathBuf,
70        version: u32,
71    },
72    /// A legacy DWB sidecar needs recovery before the layout can be changed.
73    LegacyDwbUnsupported(PathBuf),
74    Io(std::io::Error),
75}
76
77impl std::fmt::Display for ZoneMigrationError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::MissingStore(path) => write!(f, "no store at {}", path.display()),
81            Self::NotALegacyStore(path) => write!(
82                f,
83                "{} carries no retired rdb-hdr/rdb-meta sidecar for this migration",
84                path.display()
85            ),
86            Self::AlreadyZoned(path) => write!(
87                f,
88                "{} already has a valid superblock zone; migrating again would discard it",
89                path.display()
90            ),
91            Self::HeaderUnrecoverable(path) => write!(
92                f,
93                "neither page 0 of {} nor its rdb-hdr shadow holds a readable database \
94                 header, so no superblock can be seeded from this store. This is a damaged \
95                 store. Preserve the database and its recovery files; restore from a \
96                 verified backup",
97                path.display()
98            ),
99            Self::PrePhase3Unsupported { path, version } => write!(
100                f,
101                "{} uses pre-phase-3 pager version {version:#010x}; this migration tool \
102                 cannot relocate its pages or recovery state. The source was not changed; \
103                 retain its complete backup and use the original engine until a qualified \
104                 conversion is available",
105                path.display()
106            ),
107            Self::LegacyDwbUnsupported(path) => write!(
108                f,
109                "{} is a legacy double-write sidecar; this migration tool cannot verify or \
110                 recover its contents. The source was not changed; retain the complete \
111                 store until a qualified conversion is available",
112                path.display()
113            ),
114            Self::Io(err) => write!(f, "io error: {err}"),
115        }
116    }
117}
118
119impl std::error::Error for ZoneMigrationError {}
120
121impl From<std::io::Error> for ZoneMigrationError {
122    fn from(err: std::io::Error) -> Self {
123        Self::Io(err)
124    }
125}
126
127type Result<T> = std::result::Result<T, ZoneMigrationError>;
128
129/// The retained rollback point for `data_path`.
130pub fn backup_path_for(data_path: &Path) -> PathBuf {
131    let mut path = data_path.as_os_str().to_os_string();
132    path.push(".");
133    path.push(BACKUP_SUFFIX);
134    PathBuf::from(path)
135}
136
137/// Convert a legacy sidecar-backed store at `data_path` into the zoned form.
138///
139/// The store must be closed. On success the data file carries a superblock
140/// ping-pong pair, the retired sidecars are gone, and the pre-migration file is
141/// retained at [`ZoneMigrationReport::backup_path`]. On any failure before the
142/// sidecars are unlinked the source store is left exactly as it was.
143pub fn migrate_to_zoned(data_path: &Path) -> Result<ZoneMigrationReport> {
144    if !data_path.exists() {
145        return Err(ZoneMigrationError::MissingStore(data_path.to_path_buf()));
146    }
147    let dwb = retired::first_present_phase3_dwb_sidecar(data_path);
148    let sidecars = present_sidecars(data_path);
149    if sidecars.is_empty() {
150        // A partial earlier migration may have removed the phase-1
151        // sidecars while leaving the DWB behind.
152        if let Some(sidecar) = dwb {
153            return Err(ZoneMigrationError::LegacyDwbUnsupported(sidecar));
154        }
155        return Err(ZoneMigrationError::NotALegacyStore(data_path.to_path_buf()));
156    }
157    if has_valid_superblock_zone(data_path)? {
158        return Err(ZoneMigrationError::AlreadyZoned(data_path.to_path_buf()));
159    }
160
161    let page_zero = read_page(data_path, 0)?;
162    // Page 0 is authoritative unless it is the torn one โ€” which is exactly the
163    // case the `rdb-hdr` shadow existed to cover.
164    let (header_image, header_recovered_from_shadow) =
165        if reddb_file::database_header_magic_matches(&page_zero) {
166            (page_zero, false)
167        } else {
168            let shadow = read_sidecar_page(&retired::pager_header_shadow_path_v0(data_path))?
169                .filter(|page| reddb_file::database_header_magic_matches(page))
170                .ok_or_else(|| ZoneMigrationError::HeaderUnrecoverable(data_path.to_path_buf()))?;
171            (shadow, true)
172        };
173
174    // Phase 1 only moves header/manifest shadows. A pre-phase-3 file can
175    // contain live pages in the future DWB zone, and a sidecar may contain
176    // recovery state. Neither can safely be retired by this conversion.
177    // Check before creating a backup or mutating any source file.
178    let header = reddb_file::decode_database_header(&header_image)
179        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
180    if header.version < reddb_file::PAGE_DWB_ZONE_FILE_VERSION {
181        return Err(ZoneMigrationError::PrePhase3Unsupported {
182            path: data_path.to_path_buf(),
183            version: header.version,
184        });
185    }
186    if let Some(sidecar) = dwb {
187        return Err(ZoneMigrationError::LegacyDwbUnsupported(sidecar));
188    }
189    let backup_path = backup_path_for(data_path);
190    create_rollback_copy(data_path, &backup_path)?;
191
192    let manifest_recovered_from_shadow = restore_manifest_page_if_torn(data_path)?;
193
194    // Seed both superblock copies so the ping-pong invariant holds from the
195    // migrated store's very first update.
196    let mut slot = [0u8; SLOT_SIZE];
197    slot.copy_from_slice(&header_image[..SLOT_SIZE]);
198    let mut file = OpenOptions::new().read(true).write(true).open(data_path)?;
199    for (copy_index, generation) in [(0usize, 1u64), (1usize, 2u64)] {
200        reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
201            .map_err(|err| std::io::Error::other(err.to_string()))?;
202        write_at(&mut file, superblock_offset(copy_index), &slot)?;
203    }
204    file.sync_all()?;
205    drop(file);
206
207    // The file is durable in its zoned form; only now is it safe to drop the
208    // sidecars. A crash before this point leaves a store the old engine reads.
209    for sidecar in &sidecars {
210        fs::remove_file(sidecar)?;
211    }
212
213    Ok(ZoneMigrationReport {
214        data_path: data_path.to_path_buf(),
215        backup_path,
216        removed_sidecars: sidecars,
217        header_recovered_from_shadow,
218        manifest_recovered_from_shadow,
219    })
220}
221
222/// Create a durable rollback point without replacing an earlier attempt.
223/// A failed copy is retained so a retry cannot silently overwrite it.
224fn create_rollback_copy(data_path: &Path, backup_path: &Path) -> std::io::Result<()> {
225    let mut source = File::open(data_path)?;
226    let mut options = OpenOptions::new();
227    options.write(true).create_new(true);
228    #[cfg(unix)]
229    {
230        use std::os::unix::fs::OpenOptionsExt;
231        options.mode(0o600);
232    }
233    let mut backup = options.open(backup_path)?;
234    std::io::copy(&mut source, &mut backup)?;
235    backup.sync_all()?;
236    #[cfg(unix)]
237    {
238        let parent = backup_path
239            .parent()
240            .filter(|p| !p.as_os_str().is_empty())
241            .unwrap_or(Path::new("."));
242        File::open(parent)?.sync_all()?;
243    }
244    Ok(())
245}
246
247/// Walk a migration back: rebuild page 0 as a plain checksummed header page and
248/// re-create the retired sidecars beside it.
249///
250/// This is a true inverse of [`migrate_to_zoned`], not a backup restore, so a
251/// store that still uses legacy reader capabilities reverts to a coherent
252/// legacy store after writes. New publication capabilities are refused before
253/// any source or rollback file is changed. The retained `.pre-migration` file is
254/// dropped last: once the legacy shape is durable it has nothing left to roll
255/// back to.
256pub fn revert_to_sidecars(data_path: &Path) -> Result<ZoneMigrationReport> {
257    if !data_path.exists() {
258        return Err(ZoneMigrationError::MissingStore(data_path.to_path_buf()));
259    }
260
261    let image = newest_superblock_image(data_path)?
262        .ok_or_else(|| ZoneMigrationError::HeaderUnrecoverable(data_path.to_path_buf()))?;
263
264    // This inverse restores the legacy fixed manifest and recovery protocol.
265    // Validate that contract before stripping either independently sealed slot.
266    reddb_file::decode_database_header(&image)
267        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
268
269    // Strip the slot trailer and restore the whole-page checksum: this is
270    // byte-for-byte the page 0 the sidecar-era pager wrote.
271    let mut header_page = [0u8; PAGE_SIZE];
272    header_page[..SLOT_SIZE].copy_from_slice(&image);
273    header_page[SUPERBLOCK_TRAILER_OFFSET..SLOT_SIZE].fill(0);
274    reddb_file::clear_paged_page_checksum(&mut header_page);
275    let checksum = crate::storage::engine::crc32::crc32(&header_page);
276    reddb_file::set_paged_page_checksum(&mut header_page, checksum);
277
278    let manifest_page = read_page(data_path, 1)?;
279
280    let mut file = OpenOptions::new().read(true).write(true).open(data_path)?;
281    write_at(&mut file, 0, &header_page)?;
282    file.sync_all()?;
283    drop(file);
284
285    write_sidecar_page(
286        &retired::pager_header_shadow_path_v0(data_path),
287        &header_page,
288    )?;
289    write_sidecar_page(
290        &retired::pager_meta_shadow_path_v0(data_path),
291        &manifest_page,
292    )?;
293
294    let backup_path = backup_path_for(data_path);
295    if backup_path.exists() {
296        fs::remove_file(&backup_path)?;
297    }
298
299    Ok(ZoneMigrationReport {
300        data_path: data_path.to_path_buf(),
301        backup_path,
302        removed_sidecars: Vec::new(),
303        header_recovered_from_shadow: false,
304        manifest_recovered_from_shadow: false,
305    })
306}
307
308/// The newest valid superblock slot image, or `None` when the zone is absent
309/// or unrecoverable.
310fn newest_superblock_image(data_path: &Path) -> Result<Option<[u8; SLOT_SIZE]>> {
311    let zone = read_superblock_zone(data_path)?;
312    let Some(selection) = reddb_file::select_paged_superblock(&zone) else {
313        return Ok(None);
314    };
315    let start = selection.copy_index * SLOT_SIZE;
316    let mut image = [0u8; SLOT_SIZE];
317    image.copy_from_slice(&zone[start..start + SLOT_SIZE]);
318    Ok(Some(image))
319}
320
321fn read_superblock_zone(data_path: &Path) -> Result<[u8; ZONE_SIZE]> {
322    let mut zone = [0u8; ZONE_SIZE];
323    let mut file = File::open(data_path)?;
324    let len = file.metadata()?.len().min(ZONE_SIZE as u64) as usize;
325    if len > 0 {
326        file.read_exact(&mut zone[..len])?;
327    }
328    Ok(zone)
329}
330
331fn present_sidecars(data_path: &Path) -> Vec<PathBuf> {
332    let mut seen: Vec<PathBuf> = Vec::new();
333    for candidate in retired::phase1_sidecar_paths(data_path) {
334        if candidate.exists() && !seen.contains(&candidate) {
335            seen.push(candidate);
336        }
337    }
338    seen
339}
340
341fn has_valid_superblock_zone(data_path: &Path) -> Result<bool> {
342    let zone = read_superblock_zone(data_path)?;
343    Ok(reddb_file::select_paged_superblock(&zone).is_some())
344}
345
346/// Overwrite page 1 from the `rdb-meta` shadow when the in-file page fails its
347/// checksum. Returns whether the shadow was used.
348fn restore_manifest_page_if_torn(data_path: &Path) -> Result<bool> {
349    let page_one = read_page(data_path, 1)?;
350    if page_checksum_valid(&page_one) {
351        return Ok(false);
352    }
353    let Some(shadow) = read_sidecar_page(&retired::pager_meta_shadow_path_v0(data_path))? else {
354        return Ok(false);
355    };
356    if !page_checksum_valid(&shadow) {
357        return Ok(false);
358    }
359    let mut file = OpenOptions::new().read(true).write(true).open(data_path)?;
360    write_at(&mut file, PAGE_SIZE as u64, &shadow)?;
361    file.sync_all()?;
362    Ok(true)
363}
364
365/// Verify a page's own CRC the way the pager's `Page::verify_checksum` does:
366/// CRC over the page with the checksum field zeroed.
367fn page_checksum_valid(page: &[u8; PAGE_SIZE]) -> bool {
368    let stored = reddb_file::paged_page_checksum(page);
369    let mut scratch = *page;
370    reddb_file::clear_paged_page_checksum(&mut scratch);
371    stored == crate::storage::engine::crc32::crc32(&scratch)
372}
373
374fn superblock_offset(copy_index: usize) -> u64 {
375    reddb_file::paged_superblock_slot_offset(copy_index)
376}
377
378fn read_page(path: &Path, page_id: u64) -> Result<[u8; PAGE_SIZE]> {
379    let mut file = File::open(path)?;
380    let mut page = [0u8; PAGE_SIZE];
381    file.seek(SeekFrom::Start(page_id * PAGE_SIZE as u64))?;
382    file.read_exact(&mut page)?;
383    Ok(page)
384}
385
386fn read_sidecar_page(path: &Path) -> Result<Option<[u8; PAGE_SIZE]>> {
387    if !path.exists() {
388        return Ok(None);
389    }
390    let mut file = File::open(path)?;
391    let mut page = [0u8; PAGE_SIZE];
392    match file.read_exact(&mut page) {
393        Ok(()) => Ok(Some(page)),
394        // A shadow shorter than a page is itself torn: it recovers nothing.
395        Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None),
396        Err(err) => Err(err.into()),
397    }
398}
399
400fn write_sidecar_page(path: &Path, page: &[u8; PAGE_SIZE]) -> Result<()> {
401    let mut file = File::create(path)?;
402    file.write_all(page)?;
403    file.sync_all()?;
404    Ok(())
405}
406
407fn write_at(file: &mut File, offset: u64, bytes: &[u8]) -> Result<()> {
408    file.seek(SeekFrom::Start(offset))?;
409    file.write_all(bytes)?;
410    Ok(())
411}