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
291            .primary_logical_head_lsn()
292            .max(self.replication_commit_lsn());
293        if at_lsn.is_some_and(|lsn| lsn != fork_lsn) {
294            return Err(failure("FORK STORE AT LSN requires the current committed LSN; historical points require a retained backup and WAL restore"));
295        }
296        self.recover_store_forks()?;
297        if read_record(&store, &key)?.is_some_and(|record| {
298            !matches!(record.state, ForkState::Detached | ForkState::Promoted)
299        }) || store
300            .operational_manifest_for_path(parent)
301            .list_forks()
302            .map_err(failure)?
303            .iter()
304            .any(|fork| fork.name == name)
305        {
306            return Err(failure(format!("store fork already exists: {name}")));
307        }
308        let mut record = ForkRecord {
309            name: name.to_string(),
310            identity: hex::encode(crate::auth::store::random_bytes(16)),
311            parent_store: parent.to_string_lossy().into_owned(),
312            fork_lsn,
313            collections_total: store.list_collections().len() as u64,
314            state: ForkState::Preparing,
315        };
316        let destination = fork_path(parent, &record);
317        if destination.try_exists().map_err(failure)? {
318            return Err(failure("fork destination already exists"));
319        }
320        write_record(&store, &key, Some(&record))?;
321        crash_point("create_intent");
322        self.checkpoint_local()?;
323        let mut create = std::fs::OpenOptions::new();
324        create.write(true).create_new(true);
325        #[cfg(unix)]
326        {
327            use std::os::unix::fs::OpenOptionsExt;
328            create.mode(0o600);
329        }
330        let mut output = create.open(&destination).map_err(failure)?;
331        if let Some(pager) = store.pager() {
332            pager
333                .with_file(|source| -> std::io::Result<()> {
334                    source.seek(SeekFrom::Start(0))?;
335                    let mut remaining = source.file_len()?;
336                    let mut buffer = vec![0; 1024 * 1024];
337                    while remaining != 0 {
338                        let count = remaining.min(buffer.len() as u64) as usize;
339                        source.read_exact(&mut buffer[..count])?;
340                        output.write_all(&buffer[..count])?;
341                        remaining -= count as u64;
342                        crash_point("create_copy");
343                    }
344                    Ok(())
345                })
346                .map_err(failure)?
347                .map_err(failure)?;
348        } else {
349            let mut source = std::fs::File::open(parent).map_err(failure)?;
350            let mut buffer = vec![0; 1024 * 1024];
351            loop {
352                let count = source.read(&mut buffer).map_err(failure)?;
353                if count == 0 {
354                    break;
355                }
356                output.write_all(&buffer[..count]).map_err(failure)?;
357                crash_point("create_copy");
358            }
359        }
360        output.sync_all().map_err(failure)?;
361        drop(output);
362        sync_directory(&destination)?;
363        let fork = crate::storage::RedDB::open_with_options(&self.fork_options(&destination))
364            .map_err(failure)?;
365        for inherited in records(&fork.store())? {
366            write_record(&fork.store(), &self::key(&inherited.name)?, None)?;
367        }
368        // A clone owns its data, not the parent's replication slots or exported files.
369        let fork_store = fork.store();
370        if let Some(metadata) = fork_store.get_collection("red.storage.metadata") {
371            let mut inherited = Vec::new();
372            metadata.try_for_each_entity(usize::MAX, |entity| {
373                if let EntityData::Row(row) = &entity.data {
374                    if matches!(row.get_field("key"), Some(Value::Text(key)) if key.starts_with("replication.")) {
375                        inherited.push(entity.id);
376                    }
377                }
378                true
379            }).map_err(failure)?;
380            fork_store
381                .delete_batch("red.storage.metadata", &inherited)
382                .map_err(failure)?;
383        }
384        if fork_store
385            .try_get_storage_metadata("physical")
386            .map_err(failure)?
387            .is_some()
388        {
389            fork.update_physical_metadata(|metadata| metadata.exports.clear())
390                .map_err(failure)?;
391        }
392        record.state = ForkState::Ready;
393        write_record(&fork.store(), ORIGIN, Some(&record))?;
394        crash_point("create_origin");
395        fork.flush_local_only().map_err(failure)?;
396        drop(fork);
397        crash_point("create_snapshot");
398        write_record(&store, &key, Some(&record))?;
399        crash_point("create_ready");
400        Ok(record.info())
401    }
402
403    pub fn detach_fork_store(&self, name: &str) -> RedDBResult<bool> {
404        self.release_fork(name, ForkState::Detached)
405            .map(|path| path.is_some())
406    }
407
408    /// Returns the independent database to open as the replacement primary.
409    /// The serving parent remains intact; switching a server requires reopening this path.
410    pub fn promote_fork_store(&self, name: &str) -> RedDBResult<PathBuf> {
411        self.release_fork(name, ForkState::Promoted)?
412            .ok_or_else(|| RedDBError::NotFound(format!("store fork '{name}'")))
413    }
414
415    fn release_fork(&self, name: &str, state: ForkState) -> RedDBResult<Option<PathBuf>> {
416        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
417        if self.current_xid().is_some() {
418            return Err(failure(
419                "store forks cannot join an uncommitted transaction",
420            ));
421        }
422        let store = self.inner.db.store();
423        let _publication = store.vault_publication_lock.lock();
424        let key = key(name)?;
425        let Some(mut record) = read_record(&store, &key)? else {
426            return Ok(None);
427        };
428        if record.state == ForkState::Promoted && state == ForkState::Promoted {
429            return Ok(Some(fork_path(self.fork_parent_path()?, &record)));
430        }
431        if record.state != ForkState::Ready {
432            return Ok(None);
433        }
434        let path = fork_path(self.fork_parent_path()?, &record);
435        let fork =
436            crate::storage::RedDB::open_with_options(&self.fork_options(&path)).map_err(failure)?;
437        let origin =
438            read_record(&fork.store(), ORIGIN)?.ok_or_else(|| failure("fork origin is missing"))?;
439        if origin.identity != record.identity {
440            return Err(failure("fork identity differs from its catalog"));
441        }
442        // The parent publishes release first. On interruption the owned file remains
443        // a valid database; the restartable second phase clears its origin.
444        record.state = if state == ForkState::Promoted {
445            ForkState::Promoting
446        } else {
447            ForkState::Detaching
448        };
449        write_record(&store, &key, Some(&record))?;
450        crash_point("release_intent");
451        write_record(&fork.store(), ORIGIN, None)?;
452        fork.flush_local_only().map_err(failure)?;
453        crash_point("release_ready");
454        record.state = state;
455        write_record(&store, &key, Some(&record))?;
456        Ok(Some(path))
457    }
458
459    pub fn drop_fork_store(&self, name: &str) -> RedDBResult<bool> {
460        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
461        if self.current_xid().is_some() {
462            return Err(failure(
463                "store forks cannot join an uncommitted transaction",
464            ));
465        }
466        let store = self.inner.db.store();
467        let _publication = store.vault_publication_lock.lock();
468        let key = key(name)?;
469        let Some(mut record) = read_record(&store, &key)? else {
470            return Ok(false);
471        };
472        if !matches!(record.state, ForkState::Ready | ForkState::Dropping) {
473            return Ok(false);
474        }
475        let path = fork_path(self.fork_parent_path()?, &record);
476        if record.state == ForkState::Ready || path.try_exists().map_err(failure)? {
477            self.verify_fork_origin(&path, &record)?;
478        }
479        record.state = ForkState::Dropping;
480        write_record(&store, &key, Some(&record))?;
481        crash_point("drop_intent");
482        self.remove_owned_fork(&record)?;
483        write_record(&store, &key, None)?;
484        crash_point("drop_ready");
485        Ok(true)
486    }
487
488    fn verify_fork_origin(&self, path: &Path, record: &ForkRecord) -> RedDBResult<()> {
489        let mut options = self.fork_options(path);
490        options.read_only = true;
491        let fork = crate::storage::RedDB::open_with_options(&options).map_err(failure)?;
492        let origin =
493            read_record(&fork.store(), ORIGIN)?.ok_or_else(|| failure("fork origin is missing"))?;
494        if origin.identity != record.identity {
495            return Err(failure("fork identity differs from its catalog"));
496        }
497        Ok(())
498    }
499
500    fn remove_owned_fork(&self, record: &ForkRecord) -> RedDBResult<()> {
501        let path = fork_path(self.fork_parent_path()?, record);
502        let lock = if self.inner.embedded_single_file && self.inner.db.store().pager().is_none() {
503            Some(reddb_file::EmbeddedRdbWriterLock::acquire(&path).map_err(failure)?)
504        } else {
505            None
506        };
507        let file = match std::fs::OpenOptions::new()
508            .read(true)
509            .write(true)
510            .open(&path)
511        {
512            Ok(file) => Some(file),
513            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
514            Err(error) => return Err(failure(error)),
515        };
516        if lock.is_none() {
517            if let Some(file) = &file {
518                fs2::FileExt::try_lock_exclusive(file).map_err(failure)?;
519            }
520        }
521        for owned in [
522            reddb_file::unified_wal_path(&path),
523            reddb_file::engine_wal_path(&path),
524            path.clone(),
525        ] {
526            match std::fs::symlink_metadata(&owned) {
527                Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
528                    if owned == path {
529                        crash_point("drop_wal");
530                    }
531                    std::fs::remove_file(&owned).map_err(failure)?;
532                }
533                Ok(_) => return Err(failure("fork artifact is not a regular file")),
534                Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
535                Err(error) => return Err(failure(error)),
536            }
537        }
538        sync_directory(&path)?;
539        drop(file);
540        drop(lock);
541        Ok(())
542    }
543
544    pub(super) fn recover_store_forks(&self) -> RedDBResult<()> {
545        if self.inner.db.options().read_only || self.inner.db.path().is_none() {
546            return Ok(());
547        }
548        let store = self.inner.db.store();
549        let _publication = store.vault_publication_lock.lock();
550        for mut record in records(&store)? {
551            match record.state {
552                ForkState::Preparing => {
553                    let path = fork_path(self.fork_parent_path()?, &record);
554                    let complete = if path.try_exists().map_err(failure)? {
555                        let mut options = self.fork_options(&path);
556                        options.read_only = true;
557                        match crate::storage::RedDB::open_with_options(&options) {
558                            Ok(fork) => read_record(&fork.store(), ORIGIN)?
559                                .is_some_and(|origin| origin.identity == record.identity),
560                            Err(_) => false,
561                        }
562                    } else {
563                        false
564                    };
565                    if complete {
566                        record.state = ForkState::Ready;
567                        write_record(&store, &key(&record.name)?, Some(&record))?;
568                    } else {
569                        self.remove_owned_fork(&record)?;
570                        write_record(&store, &key(&record.name)?, None)?;
571                    }
572                }
573                ForkState::Dropping => {
574                    let path = fork_path(self.fork_parent_path()?, &record);
575                    if path.try_exists().map_err(failure)? {
576                        self.verify_fork_origin(&path, &record)?;
577                    }
578                    self.remove_owned_fork(&record)?;
579                    write_record(&store, &key(&record.name)?, None)?;
580                }
581                ForkState::Detaching | ForkState::Promoting => {
582                    let path = fork_path(self.fork_parent_path()?, &record);
583                    let fork = crate::storage::RedDB::open_with_options(&self.fork_options(&path))
584                        .map_err(failure)?;
585                    if let Some(origin) = read_record(&fork.store(), ORIGIN)? {
586                        if origin.identity != record.identity {
587                            return Err(failure("fork identity differs from its catalog"));
588                        }
589                        write_record(&fork.store(), ORIGIN, None)?;
590                        fork.flush_local_only().map_err(failure)?;
591                    }
592                    record.state = if record.state == ForkState::Promoting {
593                        ForkState::Promoted
594                    } else {
595                        ForkState::Detached
596                    };
597                    write_record(&store, &key(&record.name)?, Some(&record))?;
598                }
599                ForkState::Ready | ForkState::Detached | ForkState::Promoted => (),
600            }
601        }
602        Ok(())
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn fork_codec_preserves_full_width_lsns_and_rejects_path_injection() {
612        let store = UnifiedStore::new();
613        let key = key("candidate").expect("name");
614        let mut record = ForkRecord {
615            name: "candidate".into(),
616            identity: "0123456789abcdef0123456789abcdef".into(),
617            parent_store: "parent.rdb".into(),
618            fork_lsn: u64::MAX,
619            collections_total: u64::MAX,
620            state: ForkState::Ready,
621        };
622        write_record(&store, &key, Some(&record)).expect("write");
623        let decoded = read_record(&store, &key).expect("decode").expect("record");
624        assert_eq!(decoded.fork_lsn, u64::MAX);
625        assert_eq!(decoded.collections_total, u64::MAX);
626        record.identity = "../../foreign".into();
627        write_record(&store, &key, Some(&record)).expect("corrupt fixture");
628        assert!(read_record(&store, &key).is_err());
629        write_record(&store, &key, None).expect("tombstone");
630        assert!(read_record(&store, &key).expect("latest wins").is_none());
631    }
632}