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.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 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 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 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}