1use 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 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 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 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 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 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 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}