Skip to main content

reddb_server/runtime/
store_forks.rs

1//! Independent checkpoint forks. Only database/WAL files own durable state.
2use super::*;
3use crate::storage::unified::UnifiedStore;
4use reddb_file::{Vfs, VfsFile};
5use std::io::{Read, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7
8const PREFIX: &str = "fork.entry.";
9const ORIGIN: &str = "fork.origin";
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12enum ForkState {
13    Preparing,
14    Ready,
15    Dropping,
16    Detaching,
17    Promoting,
18    Detached,
19    Promoted,
20}
21
22#[derive(Clone, Debug)]
23struct ForkRecord {
24    name: String,
25    identity: String,
26    parent_store: String,
27    fork_lsn: u64,
28    collections_total: u64,
29    state: ForkState,
30}
31
32impl crate::serde_json::JsonEncode for ForkRecord {
33    fn to_json_value(&self) -> crate::serde_json::Value {
34        let state = match self.state {
35            ForkState::Preparing => "Preparing",
36            ForkState::Ready => "Ready",
37            ForkState::Dropping => "Dropping",
38            ForkState::Detaching => "Detaching",
39            ForkState::Promoting => "Promoting",
40            ForkState::Detached => "Detached",
41            ForkState::Promoted => "Promoted",
42        };
43        crate::json!({
44            "version": "1",
45            "name": self.name.clone(),
46            "identity": self.identity.clone(),
47            "parent_store": self.parent_store.clone(),
48            "fork_lsn": self.fork_lsn.to_string(),
49            "collections_total": self.collections_total.to_string(),
50            "state": state
51        })
52    }
53}
54
55impl crate::serde_json::JsonDecode for ForkRecord {
56    fn from_json_value(value: crate::serde_json::Value) -> Result<Self, String> {
57        let field = |name: &str| {
58            value
59                .get(name)
60                .and_then(|value| value.as_str())
61                .ok_or_else(|| format!("invalid fork field: {name}"))
62        };
63        if field("version")? != "1" {
64            return Err("unsupported fork record version".into());
65        }
66        let state = match field("state")? {
67            "Preparing" => ForkState::Preparing,
68            "Ready" => ForkState::Ready,
69            "Dropping" => ForkState::Dropping,
70            "Detaching" => ForkState::Detaching,
71            "Promoting" => ForkState::Promoting,
72            "Detached" => ForkState::Detached,
73            "Promoted" => ForkState::Promoted,
74            _ => return Err("invalid fork state".into()),
75        };
76        Ok(Self {
77            name: field("name")?.to_string(),
78            identity: field("identity")?.to_string(),
79            parent_store: field("parent_store")?.to_string(),
80            fork_lsn: field("fork_lsn")?
81                .parse::<u64>()
82                .map_err(|error| error.to_string())?,
83            collections_total: field("collections_total")?
84                .parse::<u64>()
85                .map_err(|error| error.to_string())?,
86            state,
87        })
88    }
89}
90
91fn failure(error: impl std::fmt::Display) -> RedDBError {
92    RedDBError::InvalidOperation(error.to_string())
93}
94
95fn key(name: &str) -> RedDBResult<String> {
96    if name.is_empty()
97        || name.trim() != name
98        || name.len() > 128
99        || name.chars().any(char::is_control)
100    {
101        return Err(failure(
102            "fork name must contain 1..128 bytes without surrounding whitespace",
103        ));
104    }
105    Ok(format!("{PREFIX}{}", hex::encode(name)))
106}
107
108fn read_record(store: &UnifiedStore, key: &str) -> RedDBResult<Option<ForkRecord>> {
109    match store.try_get_fork_metadata(key).map_err(failure)? {
110        None | Some(Value::Null) => Ok(None),
111        Some(Value::Text(text)) => {
112            if text.len() > 256 * 1024 {
113                return Err(failure("fork control record exceeds its size limit"));
114            }
115            let record: ForkRecord = crate::serde_json::from_str(&text).map_err(failure)?;
116            let record_key = self::key(&record.name)?;
117            if record.identity.len() != 32
118                || !record.identity.bytes().all(|byte| byte.is_ascii_hexdigit())
119                || (key != ORIGIN && record_key != key)
120            {
121                return Err(failure("invalid internal fork identity"));
122            }
123            Ok(Some(record))
124        }
125        Some(_) => Err(failure("invalid internal fork record")),
126    }
127}
128
129fn write_record(store: &UnifiedStore, key: &str, record: Option<&ForkRecord>) -> RedDBResult<()> {
130    if UnifiedStore::deferred_store_wal_capture_active() {
131        return Err(failure(
132            "store fork operations cannot join an uncommitted transaction",
133        ));
134    }
135    let value = match record {
136        Some(record) => {
137            crate::serde_json::Value::String(crate::serde_json::to_string(record).map_err(failure)?)
138        }
139        None => crate::serde_json::Value::Null,
140    };
141    store.try_set_fork_metadata(key, &value).map_err(failure)?;
142    store.sync_control_metadata().map_err(failure)
143}
144
145fn records(store: &UnifiedStore) -> RedDBResult<Vec<ForkRecord>> {
146    let Some(manager) = store.get_collection("red.storage.forks") else {
147        return Ok(Vec::new());
148    };
149    let mut keys = std::collections::BTreeSet::new();
150    manager
151        .try_for_each_entity(usize::MAX, |entity| {
152            if let EntityData::Row(row) = &entity.data {
153                if let Some(Value::Text(key)) = row.get_field("key") {
154                    if key.starts_with(PREFIX) {
155                        keys.insert(key.to_string());
156                    }
157                }
158            }
159            true
160        })
161        .map_err(failure)?;
162    keys.into_iter()
163        .filter_map(|key| read_record(store, &key).transpose())
164        .collect()
165}
166
167fn fork_path(parent: &Path, record: &ForkRecord) -> PathBuf {
168    parent.with_file_name(format!("fork-{}.rdb", record.identity))
169}
170
171fn sync_directory(path: &Path) -> RedDBResult<()> {
172    reddb_file::StdVfs
173        .sync_dir(
174            path.parent()
175                .filter(|parent| !parent.as_os_str().is_empty())
176                .unwrap_or(Path::new(".")),
177        )
178        .map_err(failure)
179}
180
181fn crash_point(point: &str) {
182    if std::env::var("REDDB_STORE_FORK_CRASH_AT").ok().as_deref() == Some(point) {
183        std::process::exit(173);
184    }
185}
186
187impl ForkRecord {
188    fn info(&self) -> reddb_file::ForkInfo {
189        reddb_file::ForkInfo {
190            name: self.name.clone(),
191            parent_store: self.parent_store.clone(),
192            fork_lsn: self.fork_lsn,
193            hydration_state: reddb_file::ForkHydrationState::Hydrated,
194            collections_total: self.collections_total,
195            shared_by_reference: 0,
196            hydrating: 0,
197            hydrated: self.collections_total,
198        }
199    }
200}
201
202impl RedDBRuntime {
203    fn fork_parent_path(&self) -> RedDBResult<&Path> {
204        let options = self.inner.db.options();
205        if options.mode != crate::api::StorageMode::Persistent
206            || options
207                .metadata
208                .get(crate::api::EPHEMERAL_RUNTIME_METADATA_KEY)
209                .is_some_and(|value| value == "true")
210        {
211            return Err(failure(
212                "store forks require an explicitly persistent database",
213            ));
214        }
215        self.inner
216            .db
217            .path()
218            .ok_or_else(|| failure("store forks require a persistent database"))
219    }
220
221    fn fork_options(&self, path: &Path) -> RedDBOptions {
222        let mut options = self.inner.db.options().clone();
223        options.data_path = Some(path.to_path_buf());
224        options.read_only = false;
225        options.create_if_missing = false;
226        options.force_create = false;
227        options.remote_backend = None;
228        options.remote_backend_atomic = None;
229        options.remote_key = None;
230        options.replica_loop_enabled = false;
231        options.replication = crate::replication::ReplicationConfig::standalone();
232        options.durability_mode = crate::api::DurabilityMode::Strict;
233        options
234    }
235
236    /// Each returned fork is a complete independent database, never an external manifest.
237    pub fn list_store_forks(&self) -> RedDBResult<Vec<reddb_file::ForkInfo>> {
238        let store = self.inner.db.store();
239        let _publication = store.vault_publication_lock.lock();
240        let mut forks: Vec<_> = records(&store)?
241            .into_iter()
242            .filter(|record| record.state == ForkState::Ready)
243            .map(|record| record.info())
244            .collect();
245        // Legacy listings are read-only compatibility; never publish a manifest.
246        if let Some(path) = self.inner.db.path() {
247            forks.extend(
248                store
249                    .operational_manifest_for_path(path)
250                    .list_forks()
251                    .map_err(failure)?,
252            );
253        }
254        forks.sort_by(|left, right| left.name.cmp(&right.name));
255        Ok(forks)
256    }
257
258    /// Also resolves detached/promoted destinations retained in the parent catalog.
259    pub fn fork_store_path(&self, name: &str) -> RedDBResult<Option<PathBuf>> {
260        let parent = self.fork_parent_path()?;
261        Ok(read_record(&self.inner.db.store(), &key(name)?)?
262            .filter(|record| {
263                matches!(
264                    record.state,
265                    ForkState::Ready | ForkState::Detached | ForkState::Promoted
266                )
267            })
268            .map(|record| fork_path(parent, &record)))
269    }
270
271    pub fn fork_store(&self, name: &str) -> RedDBResult<reddb_file::ForkInfo> {
272        self.fork_store_at_lsn(name, None)
273    }
274
275    pub(super) fn fork_store_at_lsn(
276        &self,
277        name: &str,
278        at_lsn: Option<u64>,
279    ) -> RedDBResult<reddb_file::ForkInfo> {
280        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
281        let key = key(name)?;
282        let parent = self.fork_parent_path()?;
283        let store = self.inner.db.store();
284        let _publication = store.vault_publication_lock.lock();
285        if self.current_xid().is_some() || UnifiedStore::deferred_store_wal_capture_active() {
286            return Err(failure(
287                "store forks require an independent committed checkpoint",
288            ));
289        }
290        let fork_lsn = self.primary_logical_head_lsn().max(self.cdc_current_lsn());
291        if at_lsn.is_some_and(|lsn| lsn != fork_lsn) {
292            return Err(failure("FORK STORE AT LSN requires the current committed LSN; historical points require a retained backup and WAL restore"));
293        }
294        self.recover_store_forks()?;
295        if read_record(&store, &key)?.is_some_and(|record| {
296            !matches!(record.state, ForkState::Detached | ForkState::Promoted)
297        }) || store
298            .operational_manifest_for_path(parent)
299            .list_forks()
300            .map_err(failure)?
301            .iter()
302            .any(|fork| fork.name == name)
303        {
304            return Err(failure(format!("store fork already exists: {name}")));
305        }
306        let mut record = ForkRecord {
307            name: name.to_string(),
308            identity: hex::encode(crate::auth::store::random_bytes(16)),
309            parent_store: parent.to_string_lossy().into_owned(),
310            fork_lsn,
311            collections_total: store.list_collections().len() as u64,
312            state: ForkState::Preparing,
313        };
314        let destination = fork_path(parent, &record);
315        if destination.try_exists().map_err(failure)? {
316            return Err(failure("fork destination already exists"));
317        }
318        write_record(&store, &key, Some(&record))?;
319        crash_point("create_intent");
320        self.checkpoint_local()?;
321        let mut create = std::fs::OpenOptions::new();
322        create.write(true).create_new(true);
323        #[cfg(unix)]
324        {
325            use std::os::unix::fs::OpenOptionsExt;
326            create.mode(0o600);
327        }
328        let mut output = create.open(&destination).map_err(failure)?;
329        if let Some(pager) = store.pager() {
330            pager
331                .with_file(|source| -> std::io::Result<()> {
332                    source.seek(SeekFrom::Start(0))?;
333                    let mut remaining = source.file_len()?;
334                    let mut buffer = vec![0; 1024 * 1024];
335                    while remaining != 0 {
336                        let count = remaining.min(buffer.len() as u64) as usize;
337                        source.read_exact(&mut buffer[..count])?;
338                        output.write_all(&buffer[..count])?;
339                        remaining -= count as u64;
340                        crash_point("create_copy");
341                    }
342                    Ok(())
343                })
344                .map_err(failure)?
345                .map_err(failure)?;
346        } else {
347            let mut source = std::fs::File::open(parent).map_err(failure)?;
348            let mut buffer = vec![0; 1024 * 1024];
349            loop {
350                let count = source.read(&mut buffer).map_err(failure)?;
351                if count == 0 {
352                    break;
353                }
354                output.write_all(&buffer[..count]).map_err(failure)?;
355                crash_point("create_copy");
356            }
357        }
358        output.sync_all().map_err(failure)?;
359        drop(output);
360        sync_directory(&destination)?;
361        let fork = crate::storage::RedDB::open_with_options(&self.fork_options(&destination))
362            .map_err(failure)?;
363        for inherited in records(&fork.store())? {
364            write_record(&fork.store(), &self::key(&inherited.name)?, None)?;
365        }
366        // A clone owns its data, not the parent's replication slots or exported files.
367        let fork_store = fork.store();
368        if let Some(metadata) = fork_store.get_collection("red.storage.metadata") {
369            let mut inherited = Vec::new();
370            metadata.try_for_each_entity(usize::MAX, |entity| {
371                if let EntityData::Row(row) = &entity.data {
372                    if matches!(row.get_field("key"), Some(Value::Text(key)) if key.starts_with("replication.")) {
373                        inherited.push(entity.id);
374                    }
375                }
376                true
377            }).map_err(failure)?;
378            fork_store
379                .delete_batch("red.storage.metadata", &inherited)
380                .map_err(failure)?;
381        }
382        if fork_store
383            .try_get_storage_metadata("physical")
384            .map_err(failure)?
385            .is_some()
386        {
387            fork.update_physical_metadata(|metadata| metadata.exports.clear())
388                .map_err(failure)?;
389        }
390        record.state = ForkState::Ready;
391        write_record(&fork.store(), ORIGIN, Some(&record))?;
392        crash_point("create_origin");
393        fork.flush_local_only().map_err(failure)?;
394        drop(fork);
395        crash_point("create_snapshot");
396        write_record(&store, &key, Some(&record))?;
397        crash_point("create_ready");
398        Ok(record.info())
399    }
400
401    pub fn detach_fork_store(&self, name: &str) -> RedDBResult<bool> {
402        self.release_fork(name, ForkState::Detached)
403            .map(|path| path.is_some())
404    }
405
406    /// Returns the independent database to open as the replacement primary.
407    /// The serving parent remains intact; switching a server requires reopening this path.
408    pub fn promote_fork_store(&self, name: &str) -> RedDBResult<PathBuf> {
409        self.release_fork(name, ForkState::Promoted)?
410            .ok_or_else(|| RedDBError::NotFound(format!("store fork '{name}'")))
411    }
412
413    fn release_fork(&self, name: &str, state: ForkState) -> RedDBResult<Option<PathBuf>> {
414        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
415        if self.current_xid().is_some() {
416            return Err(failure(
417                "store forks cannot join an uncommitted transaction",
418            ));
419        }
420        let store = self.inner.db.store();
421        let _publication = store.vault_publication_lock.lock();
422        let key = key(name)?;
423        let Some(mut record) = read_record(&store, &key)? else {
424            return Ok(None);
425        };
426        if record.state == ForkState::Promoted && state == ForkState::Promoted {
427            return Ok(Some(fork_path(self.fork_parent_path()?, &record)));
428        }
429        if record.state != ForkState::Ready {
430            return Ok(None);
431        }
432        let path = fork_path(self.fork_parent_path()?, &record);
433        let fork =
434            crate::storage::RedDB::open_with_options(&self.fork_options(&path)).map_err(failure)?;
435        let origin =
436            read_record(&fork.store(), ORIGIN)?.ok_or_else(|| failure("fork origin is missing"))?;
437        if origin.identity != record.identity {
438            return Err(failure("fork identity differs from its catalog"));
439        }
440        // The parent publishes release first. On interruption the owned file remains
441        // a valid database; the restartable second phase clears its origin.
442        record.state = if state == ForkState::Promoted {
443            ForkState::Promoting
444        } else {
445            ForkState::Detaching
446        };
447        write_record(&store, &key, Some(&record))?;
448        crash_point("release_intent");
449        write_record(&fork.store(), ORIGIN, None)?;
450        fork.flush_local_only().map_err(failure)?;
451        crash_point("release_ready");
452        record.state = state;
453        write_record(&store, &key, Some(&record))?;
454        Ok(Some(path))
455    }
456
457    pub fn drop_fork_store(&self, name: &str) -> RedDBResult<bool> {
458        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
459        if self.current_xid().is_some() {
460            return Err(failure(
461                "store forks cannot join an uncommitted transaction",
462            ));
463        }
464        let store = self.inner.db.store();
465        let _publication = store.vault_publication_lock.lock();
466        let key = key(name)?;
467        let Some(mut record) = read_record(&store, &key)? else {
468            return Ok(false);
469        };
470        if !matches!(record.state, ForkState::Ready | ForkState::Dropping) {
471            return Ok(false);
472        }
473        let path = fork_path(self.fork_parent_path()?, &record);
474        if record.state == ForkState::Ready || path.try_exists().map_err(failure)? {
475            self.verify_fork_origin(&path, &record)?;
476        }
477        record.state = ForkState::Dropping;
478        write_record(&store, &key, Some(&record))?;
479        crash_point("drop_intent");
480        self.remove_owned_fork(&record)?;
481        write_record(&store, &key, None)?;
482        crash_point("drop_ready");
483        Ok(true)
484    }
485
486    fn verify_fork_origin(&self, path: &Path, record: &ForkRecord) -> RedDBResult<()> {
487        let mut options = self.fork_options(path);
488        options.read_only = true;
489        let fork = crate::storage::RedDB::open_with_options(&options).map_err(failure)?;
490        let origin =
491            read_record(&fork.store(), ORIGIN)?.ok_or_else(|| failure("fork origin is missing"))?;
492        if origin.identity != record.identity {
493            return Err(failure("fork identity differs from its catalog"));
494        }
495        Ok(())
496    }
497
498    fn remove_owned_fork(&self, record: &ForkRecord) -> RedDBResult<()> {
499        let path = fork_path(self.fork_parent_path()?, record);
500        let lock = if self.inner.embedded_single_file && self.inner.db.store().pager().is_none() {
501            Some(reddb_file::EmbeddedRdbWriterLock::acquire(&path).map_err(failure)?)
502        } else {
503            None
504        };
505        let file = match std::fs::OpenOptions::new()
506            .read(true)
507            .write(true)
508            .open(&path)
509        {
510            Ok(file) => Some(file),
511            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
512            Err(error) => return Err(failure(error)),
513        };
514        if let Some(file) = &file {
515            fs2::FileExt::try_lock_exclusive(file).map_err(failure)?;
516        }
517        for owned in [
518            reddb_file::unified_wal_path(&path),
519            reddb_file::engine_wal_path(&path),
520            path.clone(),
521        ] {
522            match std::fs::symlink_metadata(&owned) {
523                Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
524                    if owned == path {
525                        crash_point("drop_wal");
526                    }
527                    std::fs::remove_file(&owned).map_err(failure)?;
528                }
529                Ok(_) => return Err(failure("fork artifact is not a regular file")),
530                Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
531                Err(error) => return Err(failure(error)),
532            }
533        }
534        sync_directory(&path)?;
535        drop(file);
536        drop(lock);
537        Ok(())
538    }
539
540    pub(super) fn recover_store_forks(&self) -> RedDBResult<()> {
541        if self.inner.db.options().read_only || self.inner.db.path().is_none() {
542            return Ok(());
543        }
544        let store = self.inner.db.store();
545        let _publication = store.vault_publication_lock.lock();
546        for mut record in records(&store)? {
547            match record.state {
548                ForkState::Preparing => {
549                    let path = fork_path(self.fork_parent_path()?, &record);
550                    let complete = if path.try_exists().map_err(failure)? {
551                        let mut options = self.fork_options(&path);
552                        options.read_only = true;
553                        match crate::storage::RedDB::open_with_options(&options) {
554                            Ok(fork) => read_record(&fork.store(), ORIGIN)?
555                                .is_some_and(|origin| origin.identity == record.identity),
556                            Err(_) => false,
557                        }
558                    } else {
559                        false
560                    };
561                    if complete {
562                        record.state = ForkState::Ready;
563                        write_record(&store, &key(&record.name)?, Some(&record))?;
564                    } else {
565                        self.remove_owned_fork(&record)?;
566                        write_record(&store, &key(&record.name)?, None)?;
567                    }
568                }
569                ForkState::Dropping => {
570                    let path = fork_path(self.fork_parent_path()?, &record);
571                    if path.try_exists().map_err(failure)? {
572                        self.verify_fork_origin(&path, &record)?;
573                    }
574                    self.remove_owned_fork(&record)?;
575                    write_record(&store, &key(&record.name)?, None)?;
576                }
577                ForkState::Detaching | ForkState::Promoting => {
578                    let path = fork_path(self.fork_parent_path()?, &record);
579                    let fork = crate::storage::RedDB::open_with_options(&self.fork_options(&path))
580                        .map_err(failure)?;
581                    if let Some(origin) = read_record(&fork.store(), ORIGIN)? {
582                        if origin.identity != record.identity {
583                            return Err(failure("fork identity differs from its catalog"));
584                        }
585                        write_record(&fork.store(), ORIGIN, None)?;
586                        fork.flush_local_only().map_err(failure)?;
587                    }
588                    record.state = if record.state == ForkState::Promoting {
589                        ForkState::Promoted
590                    } else {
591                        ForkState::Detached
592                    };
593                    write_record(&store, &key(&record.name)?, Some(&record))?;
594                }
595                ForkState::Ready | ForkState::Detached | ForkState::Promoted => (),
596            }
597        }
598        Ok(())
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn fork_codec_preserves_full_width_lsns_and_rejects_path_injection() {
608        let store = UnifiedStore::new();
609        let key = key("candidate").expect("name");
610        let mut record = ForkRecord {
611            name: "candidate".into(),
612            identity: "0123456789abcdef0123456789abcdef".into(),
613            parent_store: "parent.rdb".into(),
614            fork_lsn: u64::MAX,
615            collections_total: u64::MAX,
616            state: ForkState::Ready,
617        };
618        write_record(&store, &key, Some(&record)).expect("write");
619        let decoded = read_record(&store, &key).expect("decode").expect("record");
620        assert_eq!(decoded.fork_lsn, u64::MAX);
621        assert_eq!(decoded.collections_total, u64::MAX);
622        record.identity = "../../foreign".into();
623        write_record(&store, &key, Some(&record)).expect("corrupt fixture");
624        assert!(read_record(&store, &key).is_err());
625        write_record(&store, &key, None).expect("tombstone");
626        assert!(read_record(&store, &key).expect("latest wins").is_none());
627    }
628}