1use std::collections::HashSet;
32use std::path::PathBuf;
33use std::sync::Arc;
34use std::time::{Instant, SystemTime};
35
36use parking_lot::RwLock;
37
38use linkrs_sync::checkpoint_manifest::CheckpointManifestManager;
39
40use crate::engine::snapshot_manager::SnapshotManager;
41use crate::engine::WalManager;
42use crate::index::shard_runtime::IndexBarrierRegistry;
43use linkrs_core::event_dispatch::{EventFilter, EventSubscriptions, SubscriptionId};
44use linkrs_core::types::{CommitLsn, Timestamp};
45use linkrs_core::{StorageError, StorageResult};
46use linkrs_transaction::wal::{CheckpointManager, Lsn, WalConfig};
47
48#[path = "persistence/checkpoint.rs"]
49pub mod checkpoint;
50#[path = "persistence/checkpoint_meta.rs"]
51pub mod checkpoint_meta;
52#[path = "persistence/checkpoint_scheduler.rs"]
53pub mod checkpoint_scheduler;
54#[path = "persistence/config.rs"]
55pub mod config;
56#[path = "persistence/diagnostics.rs"]
57pub mod diagnostics;
58#[path = "persistence/fs_util.rs"]
59pub mod fs_util;
60
61pub use checkpoint::{
62 CheckpointData, CheckpointInfo, CheckpointStats, CHECKPOINT_FORMAT_VERSION,
63 INCREMENTAL_CHECKPOINT_FORMAT_VERSION,
64};
65pub use checkpoint_scheduler::CheckpointScheduler;
66pub use config::PersistenceConfig;
67pub use diagnostics::{
68 CatalogLockDiagnostic, CheckpointDiagnostics, PersistenceDiagnostics, SnapshotStats,
69};
70
71type OutboxFrontierProvider =
73 Arc<dyn Fn() -> StorageResult<Option<linkrs_core::types::CommitLsn>> + Send + Sync>;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PersistenceState {
77 Idle,
78 Checkpointing,
79 Snapshotting,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum PersistenceFaultPoint {
84 CheckpointRedoBefore,
86 CheckpointIntentMid,
88 CheckpointCommitMid,
90 CheckpointFsyncAfter,
92 CheckpointVisibilityPublish,
94 RecoveryScan,
95}
96
97pub(crate) struct PersistenceStateGuard {
98 pub(crate) state: Arc<RwLock<PersistenceState>>,
99}
100
101impl Drop for PersistenceStateGuard {
102 fn drop(&mut self) {
103 *self.state.write() = PersistenceState::Idle;
104 }
105}
106
107pub use crate::engine::storage_events::{GcEventSink, StorageEvent, StorageEventCallback};
108
109pub struct PersistenceCoordinator {
110 pub(crate) config: PersistenceConfig,
111 pub(crate) wal_manager: Option<Arc<RwLock<WalManager>>>,
112 pub(crate) checkpoint_manager: RwLock<CheckpointManager>,
113 pub(crate) snapshot_manager: Option<Arc<SnapshotManager>>,
114 pub(crate) manifest_manager: CheckpointManifestManager,
115 pub(crate) last_checkpoint_time: RwLock<Instant>,
116 pub(crate) last_flush_time: RwLock<Instant>,
117 pub(crate) last_checkpoint_lsn: RwLock<Lsn>,
118 pub(crate) last_flush_lsn: RwLock<Lsn>,
119 pub(crate) last_snapshot_time: RwLock<Option<SystemTime>>,
120 pub(crate) last_checkpoint_error: RwLock<Option<String>>,
121 pub(crate) last_snapshot_error: RwLock<Option<String>>,
122 pub(crate) last_checkpoint_stats: RwLock<Option<CheckpointStats>>,
123 pub(crate) state: Arc<RwLock<PersistenceState>>,
124 pub(crate) fault_points: Arc<RwLock<HashSet<PersistenceFaultPoint>>>,
125 pub(crate) outbox_frontier_provider: RwLock<Option<OutboxFrontierProvider>>,
126 storage_callbacks: Arc<EventSubscriptions<StorageEvent>>,
127 checkpoint_watermark: RwLock<(Option<Timestamp>, CommitLsn)>,
132}
133
134impl PersistenceCoordinator {
135 pub fn new(config: PersistenceConfig) -> StorageResult<Self> {
136 std::fs::create_dir_all(&config.data_dir)?;
137 std::fs::create_dir_all(&config.checkpoint_dir)?;
138
139 let wal_manager = if config.enable_wal {
140 std::fs::create_dir_all(&config.wal_dir)?;
141 let mut wal_cfg = WalConfig::default();
142 if let Some(ref sp) = config.sync_policy {
143 wal_cfg.sync_policy = *sp;
144 }
145 wal_cfg.buffer_size = config.wal_buffer_size;
146 wal_cfg.flush_interval_ms = config.wal_flush_interval_ms;
147 wal_cfg.enable_async_flush = config.wal_enable_async_flush;
148 let mut wal_manager = WalManager::with_config(wal_cfg);
149 wal_manager.open(&config.wal_dir, 0)?;
150 log::info!(
151 "WAL opened at {} (async_flush={})",
152 config.wal_dir.display(),
153 wal_manager.is_async_enabled(),
154 );
155 Some(Arc::new(RwLock::new(wal_manager)))
156 } else {
157 None
158 };
159
160 Self::cleanup_temporary_checkpoints(&config.checkpoint_dir)?;
161 let manifest_dir = config.checkpoint_dir.join("manifests");
162 let manifest_manager = CheckpointManifestManager::new(&manifest_dir);
163 manifest_manager.init().map_err(|error| {
164 StorageError::db_error(format!("Failed to init manifest manager: {error}"))
165 })?;
166 let published_sequence = Self::latest_published_sequence(&manifest_manager)?;
167 Self::cleanup_unpublished_checkpoints(&config.checkpoint_dir, published_sequence)?;
168
169 let mut checkpoint_manager =
170 CheckpointManager::new(&config.wal_dir, &config.checkpoint_dir, None);
171 checkpoint_manager.init().map_err(|e| {
172 linkrs_core::StorageError::db_error(format!("Failed to init checkpoint manager: {}", e))
173 })?;
174 checkpoint_manager
175 .adopt_published_sequence(published_sequence)
176 .map_err(|error| {
177 StorageError::db_error(format!(
178 "Failed to reconcile published checkpoints: {}",
179 error
180 ))
181 })?;
182
183 if let Some(ref wal) = wal_manager {
184 wal.read()
185 .set_checkpoint_seq(checkpoint_manager.current_seq())?;
186 }
187
188 let snapshot_manager = if config.enable_snapshots {
189 std::fs::create_dir_all(&config.snapshot_dir)?;
190 Some(Arc::new(SnapshotManager::new(
191 config.snapshot_dir.clone(),
192 config.data_dir.join("snapshot_work"),
193 )?))
194 } else {
195 None
196 };
197
198 Ok(Self {
199 config,
200 wal_manager,
201 checkpoint_manager: RwLock::new(checkpoint_manager),
202 snapshot_manager,
203 manifest_manager,
204 last_checkpoint_time: RwLock::new(Instant::now()),
205 last_flush_time: RwLock::new(Instant::now()),
206 last_checkpoint_lsn: RwLock::new(Lsn::ZERO),
207 last_flush_lsn: RwLock::new(Lsn::ZERO),
208 last_snapshot_time: RwLock::new(None),
209 last_checkpoint_error: RwLock::new(None),
210 last_snapshot_error: RwLock::new(None),
211 last_checkpoint_stats: RwLock::new(None),
212 state: Arc::new(RwLock::new(PersistenceState::Idle)),
213 fault_points: Arc::new(RwLock::new(HashSet::new())),
214 outbox_frontier_provider: RwLock::new(None),
215 storage_callbacks: Arc::new(EventSubscriptions::new()),
216 checkpoint_watermark: RwLock::new((None, CommitLsn::ZERO)),
217 })
218 }
219
220 pub fn shared_storage_callbacks(&self) -> Arc<EventSubscriptions<StorageEvent>> {
222 Arc::clone(&self.storage_callbacks)
223 }
224
225 pub fn register_storage_callback(&self, callback: StorageEventCallback) -> SubscriptionId {
227 let id = self.storage_callbacks.add(callback);
228 log::debug!(
229 "registered storage callback (total={})",
230 self.storage_callback_count()
231 );
232 id
233 }
234
235 pub fn register_storage_callback_filtered(
237 &self,
238 callback: StorageEventCallback,
239 filter: EventFilter<StorageEvent>,
240 ) -> SubscriptionId {
241 self.storage_callbacks.add_filtered(callback, Some(filter))
242 }
243
244 pub fn unregister_storage_callback(&self, id: SubscriptionId) -> bool {
246 self.storage_callbacks.remove(id)
247 }
248
249 pub fn storage_callback_count(&self) -> usize {
251 self.storage_callbacks.len()
252 }
253
254 fn emit_storage_event(&self, event: StorageEvent) {
255 if self.storage_callbacks.is_empty() {
256 return;
257 }
258 self.storage_callbacks.dispatch("storage", &event);
259 }
260
261 pub fn notify_wal_truncated(&self, up_to_lsn: u64) {
263 self.emit_storage_event(StorageEvent::WalTruncated { up_to_lsn });
264 }
265
266 pub(crate) fn refresh_checkpoint_watermark(&self, snapshot: Timestamp, reclaim_lsn: CommitLsn) {
274 *self.checkpoint_watermark.write() = (Some(snapshot), reclaim_lsn);
275 }
276
277 pub(crate) fn checkpoint_watermark(&self) -> (Option<Timestamp>, CommitLsn) {
280 *self.checkpoint_watermark.read()
281 }
282
283 pub fn notify_gc_run(&self, reclaimed_entries: u64) {
285 self.emit_storage_event(StorageEvent::GcRun { reclaimed_entries });
286 }
287
288 pub fn notify_compaction_started(&self, space_id: u64) {
290 self.emit_storage_event(StorageEvent::CompactionStarted { space_id });
291 }
292
293 pub fn notify_compaction_completed(&self, space_id: u64, reclaimed_bytes: u64) {
295 self.emit_storage_event(StorageEvent::CompactionCompleted {
296 space_id,
297 reclaimed_bytes,
298 });
299 }
300
301 #[cfg(test)]
303 pub fn inject_failure(&self, point: PersistenceFaultPoint) {
304 self.fault_points.write().insert(point);
305 }
306
307 pub(crate) fn fail_if_injected(&self, point: PersistenceFaultPoint) -> StorageResult<()> {
308 if self.fault_points.read().contains(&point) {
309 return Err(StorageError::io_error(format!(
310 "injected persistence failure at {:?}",
311 point
312 )));
313 }
314 Ok(())
315 }
316
317 pub fn wal_manager(&self) -> Option<Arc<RwLock<WalManager>>> {
318 self.wal_manager.clone()
319 }
320
321 pub(crate) fn set_index_barrier_registry(&mut self, registry: IndexBarrierRegistry) {
322 if let Some(wal) = &self.wal_manager {
323 wal.write().set_index_barrier_registry(registry);
324 }
325 }
326
327 pub fn wal_dir(&self) -> PathBuf {
328 self.config.wal_dir.clone()
329 }
330
331 pub fn checkpoint_dir(&self) -> PathBuf {
332 self.config.checkpoint_dir.clone()
333 }
334
335 pub fn data_dir(&self) -> PathBuf {
336 self.config.data_dir.clone()
337 }
338
339 pub(crate) fn enter_state(
340 &self,
341 state: PersistenceState,
342 ) -> StorageResult<PersistenceStateGuard> {
343 let mut current = self.state.write();
344 if *current != PersistenceState::Idle {
345 return Err(StorageError::invalid_operation(format!(
346 "persistence operation is already active: {:?}",
347 *current
348 )));
349 }
350 *current = state;
351 drop(current);
352 Ok(PersistenceStateGuard {
353 state: Arc::clone(&self.state),
354 })
355 }
356
357 pub(crate) fn try_enter_checkpoint(&self) -> StorageResult<PersistenceStateGuard> {
359 let mut current = self.state.try_write().ok_or_else(|| {
360 StorageError::invalid_operation(
361 "persistence operation is already active (lock contention)".to_string(),
362 )
363 })?;
364 if *current != PersistenceState::Idle {
365 return Err(StorageError::invalid_operation(format!(
366 "persistence operation is already active: {:?}",
367 *current
368 )));
369 }
370 *current = PersistenceState::Checkpointing;
371 drop(current);
372 Ok(PersistenceStateGuard {
373 state: Arc::clone(&self.state),
374 })
375 }
376
377 pub fn set_outbox_materialized_lsn_provider(&self, provider: OutboxFrontierProvider) {
378 *self.outbox_frontier_provider.write() = Some(provider);
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::path::PathBuf;
386 use std::time::Duration;
387 use tempfile::TempDir;
388
389 use crate::engine::config::PropertyGraphConfig;
390 use linkrs_transaction::wal::SyncPolicy;
391
392 #[test]
393 fn test_persistence_config_default() {
394 let config = PersistenceConfig::default();
395 assert_eq!(config.data_dir, PathBuf::from("data"));
396 assert_eq!(config.auto_flush_interval, Duration::from_secs(60));
397 }
398
399 #[test]
400 fn storage_callbacks_dispatch_with_panic_isolation() {
401 let temp_dir = TempDir::new().expect("Failed to create temp dir");
402 let config = PersistenceConfig::for_work_dir(temp_dir.path());
403 let coordinator = PersistenceCoordinator::new(config).expect("coordinator");
404 assert_eq!(coordinator.storage_callback_count(), 0);
405
406 let received = Arc::new(std::sync::atomic::AtomicU64::new(0));
407 let probe = received.clone();
408 coordinator.register_storage_callback(Arc::new(move |event| {
409 if matches!(event, StorageEvent::GcRun { .. }) {
410 probe.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
411 }
412 }));
413 coordinator.register_storage_callback(Arc::new(|_| panic!("observer boom")));
414 assert_eq!(coordinator.storage_callback_count(), 2);
415
416 coordinator.notify_gc_run(7);
417 assert_eq!(received.load(std::sync::atomic::Ordering::SeqCst), 1);
418 }
419
420 #[test]
421 fn storage_filtered_subscription_and_unsubscribe() {
422 let temp_dir = TempDir::new().expect("Failed to create temp dir");
423 let config = PersistenceConfig::for_work_dir(temp_dir.path());
424 let coordinator = PersistenceCoordinator::new(config).expect("coordinator");
425 assert_eq!(coordinator.storage_callback_count(), 0);
426
427 let gc_hits = Arc::new(std::sync::atomic::AtomicU64::new(0));
428 let wal_hits = Arc::new(std::sync::atomic::AtomicU64::new(0));
429 let gc_probe = gc_hits.clone();
430 let wal_probe = wal_hits.clone();
431 let filtered_id = coordinator.register_storage_callback_filtered(
433 Arc::new(move |_| {
434 gc_probe.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
435 }),
436 Arc::new(|event| matches!(event, StorageEvent::GcRun { .. })),
437 );
438 let wal_id = coordinator.register_storage_callback(Arc::new(move |_| {
439 wal_probe.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
440 }));
441 assert_eq!(coordinator.storage_callback_count(), 2);
442
443 coordinator.notify_gc_run(3);
444 coordinator.notify_wal_truncated(9);
445 assert_eq!(gc_hits.load(std::sync::atomic::Ordering::SeqCst), 1);
446 assert_eq!(wal_hits.load(std::sync::atomic::Ordering::SeqCst), 2);
448
449 assert!(coordinator.unregister_storage_callback(filtered_id));
450 assert!(!coordinator.unregister_storage_callback(filtered_id));
451 assert_eq!(coordinator.storage_callback_count(), 1);
452 coordinator.notify_gc_run(5);
453 assert_eq!(gc_hits.load(std::sync::atomic::Ordering::SeqCst), 1);
454 assert_eq!(wal_hits.load(std::sync::atomic::Ordering::SeqCst), 3);
455
456 assert!(coordinator.unregister_storage_callback(wal_id));
457 assert_eq!(coordinator.storage_callback_count(), 0);
458 }
459
460 #[test]
461 fn test_should_flush_and_checkpoint_track_lsn_progress() {
462 let temp_dir = TempDir::new().expect("Failed to create temp dir");
463 let config = PersistenceConfig {
464 data_dir: temp_dir.path().join("data"),
465 wal_dir: temp_dir.path().join("wal"),
466 checkpoint_dir: temp_dir.path().join("checkpoint"),
467 snapshot_dir: temp_dir.path().join("snapshots"),
468 auto_flush_interval: Duration::from_secs(3600),
469 auto_checkpoint_interval: Duration::from_secs(3600),
470 checkpoint_threshold: 8,
471 max_wal_size: 16,
472 enable_snapshots: false,
473 snapshot_interval: Duration::from_secs(3600),
474 enable_wal: true,
475 sync_policy: Some(SyncPolicy::EveryWrite),
476 property_graph_config: PropertyGraphConfig::default(),
477 ..Default::default()
478 };
479
480 let coordinator =
481 PersistenceCoordinator::new(config).expect("Failed to create coordinator");
482 assert!(!coordinator.should_flush());
483 assert!(!coordinator.should_checkpoint());
484
485 {
486 let wal = coordinator.wal_manager().expect("WAL should be enabled");
487 wal.write()
488 .append_entry(
489 linkrs_core::wal::types::WalOpType::InsertVertex,
490 0 as linkrs_core::types::Timestamp,
491 &[],
492 )
493 .expect("test WAL append should succeed");
494 }
495
496 assert!(coordinator.should_flush());
497 assert!(coordinator.should_checkpoint());
498
499 coordinator.mark_checkpointed(Lsn::new(40));
500 assert!(!coordinator.should_flush());
501 assert!(!coordinator.should_checkpoint());
502 }
503
504 #[test]
505 fn test_mark_checkpointed_updates_wal_lsn() {
506 let temp_dir = TempDir::new().expect("Failed to create temp dir");
507 let config = PersistenceConfig {
508 data_dir: temp_dir.path().join("data"),
509 wal_dir: temp_dir.path().join("wal"),
510 checkpoint_dir: temp_dir.path().join("checkpoint"),
511 snapshot_dir: temp_dir.path().join("snapshots"),
512 auto_flush_interval: Duration::from_secs(3600),
513 auto_checkpoint_interval: Duration::from_secs(3600),
514 checkpoint_threshold: 8,
515 max_wal_size: 16,
516 enable_snapshots: false,
517 snapshot_interval: Duration::from_secs(3600),
518 enable_wal: true,
519 sync_policy: Some(SyncPolicy::EveryWrite),
520 property_graph_config: PropertyGraphConfig::default(),
521 ..Default::default()
522 };
523
524 let coordinator =
525 PersistenceCoordinator::new(config).expect("Failed to create coordinator");
526
527 let lsn_before;
528 {
529 let wal = coordinator.wal_manager().expect("WAL should be enabled");
530 wal.write()
531 .append_entry(
532 linkrs_core::wal::types::WalOpType::InsertVertex,
533 0 as linkrs_core::types::Timestamp,
534 &[],
535 )
536 .expect("test WAL append should succeed");
537 lsn_before = wal.read().current_lsn();
538 }
539
540 coordinator.mark_checkpointed(Lsn::new(24));
541
542 assert_eq!(
543 coordinator
544 .wal_manager()
545 .expect("WAL should be enabled")
546 .read()
547 .current_lsn(),
548 lsn_before
549 );
550 assert_eq!(*coordinator.last_checkpoint_lsn.read(), Lsn::new(24));
551 }
552
553 #[test]
554 fn checkpoint_stats_report_the_manifest_safe_lsn() {
555 let temp_dir = TempDir::new().expect("Failed to create temp dir");
556 let config = PersistenceConfig {
557 enable_snapshots: false,
558 enable_wal: true,
559 sync_policy: Some(SyncPolicy::EveryWrite),
560 ..PersistenceConfig::for_work_dir(temp_dir.path())
561 };
562 let coordinator = PersistenceCoordinator::new(config).expect("coordinator");
563
564 let wal = coordinator.wal_manager().expect("WAL should be enabled");
565 wal.read()
566 .append_redo(linkrs_core::wal::types::WalOpType::Compact, 1, &())
567 .expect("WAL entry should append");
568 wal.read().sync().expect("WAL should be durable");
569 let checkpoint_lsn = wal.read().durable_lsn();
570 assert!(checkpoint_lsn > Lsn::ZERO);
571
572 let snapshot_dir = temp_dir.path().join("outbox_snapshots");
573 std::fs::create_dir_all(&snapshot_dir).expect("snapshot directory should exist");
574 let snapshot_path = snapshot_dir.join("outbox_snapshot_0.sqlite");
575 let snapshot_bytes = b"valid checksum fixture";
576 std::fs::write(&snapshot_path, snapshot_bytes).expect("snapshot should be written");
577 std::fs::write(
578 snapshot_path.with_extension("checksum"),
579 crc32fast::hash(snapshot_bytes).to_string(),
580 )
581 .expect("snapshot checksum should be written");
582
583 let stats = coordinator
584 .create_checkpoint(
585 |temporary_dir, _| {
586 std::fs::write(temporary_dir.join("table.data"), b"data")?;
587 Ok(CheckpointData {
588 vertex_count: 1,
589 edge_count: 0,
590 data_size: 4,
591 })
592 },
593 1,
594 )
595 .expect("checkpoint should succeed");
596
597 assert_eq!(stats.wal_truncated, 0);
598 assert!(stats.wal_truncated < checkpoint_lsn.into());
599 }
600
601 #[test]
602 fn snapshot_uses_the_completed_checkpoint_as_its_only_source() {
603 let temp_dir = TempDir::new().expect("Failed to create temp dir");
604 let config = PersistenceConfig {
605 data_dir: temp_dir.path().join("data"),
606 wal_dir: temp_dir.path().join("wal"),
607 checkpoint_dir: temp_dir.path().join("checkpoint"),
608 snapshot_dir: temp_dir.path().join("snapshots"),
609 auto_flush_interval: Duration::from_secs(3600),
610 auto_checkpoint_interval: Duration::from_secs(3600),
611 checkpoint_threshold: 8,
612 max_wal_size: 16,
613 enable_snapshots: true,
614 snapshot_interval: Duration::ZERO,
615 enable_wal: false,
616 sync_policy: None,
617 property_graph_config: PropertyGraphConfig::default(),
618 ..Default::default()
619 };
620 std::fs::create_dir_all(&config.data_dir).expect("Failed to create main data dir");
621 std::fs::write(config.data_dir.join("stale.txt"), b"stale")
622 .expect("Failed to write stale main data");
623
624 let snapshots_dir = config.snapshot_dir.clone();
625 let coordinator =
626 PersistenceCoordinator::new(config).expect("Failed to create coordinator");
627 let stats = coordinator
628 .create_checkpoint(
629 |checkpoint_dir, _| {
630 let checkpoint_data = checkpoint_dir.join("data");
631 std::fs::create_dir_all(&checkpoint_data)?;
632 std::fs::write(checkpoint_data.join("fresh.txt"), b"fresh")?;
633 Ok(CheckpointData {
634 vertex_count: 1,
635 edge_count: 1,
636 data_size: 5,
637 })
638 },
639 7,
640 )
641 .expect("Failed to create checkpoint");
642
643 assert!(stats.snapshot_created);
644 let snapshot_dir = snapshots_dir.join(format!("snapshot_{:010}", stats.checkpoint_id));
645 assert_eq!(
646 std::fs::read(snapshot_dir.join("data/fresh.txt"))
647 .expect("Snapshot should contain checkpoint data"),
648 b"fresh"
649 );
650 assert!(!snapshot_dir.join("stale.txt").exists());
651 assert!(snapshot_dir.join("checkpoint.meta").exists());
652
653 let info = coordinator
654 .snapshot_manager
655 .as_ref()
656 .and_then(|manager| manager.get_snapshot(stats.checkpoint_id))
657 .expect("Snapshot metadata should exist");
658 assert_eq!(info.checkpoint_seq, stats.checkpoint_id);
659 assert_eq!(info.wal_lsn, 0);
660 assert_eq!(info.vertex_count, 1);
661 assert_eq!(info.edge_count, 1);
662 }
663
664 #[test]
665 fn failed_checkpoint_is_not_published_and_state_returns_to_idle() {
666 let temp_dir = TempDir::new().expect("Failed to create temp dir");
667 let config = PersistenceConfig {
668 enable_snapshots: false,
669 enable_wal: false,
670 ..PersistenceConfig::for_work_dir(temp_dir.path())
671 };
672 let checkpoint_dir = config.checkpoint_dir.clone();
673 let coordinator =
674 PersistenceCoordinator::new(config.clone()).expect("Failed to create coordinator");
675
676 let result = coordinator.create_checkpoint(
677 |temporary_dir, _| {
678 std::fs::write(temporary_dir.join("partial.data"), b"partial")?;
679 Err(StorageError::db_error("injected flush failure"))
680 },
681 11,
682 );
683
684 assert!(result.is_err());
685 assert_eq!(*coordinator.state.read(), PersistenceState::Idle);
686 assert_eq!(coordinator.checkpoint_manager.read().current_seq(), 0);
687 assert!(!checkpoint_dir.join("checkpoint_1").exists());
688 assert!(checkpoint_dir.join("checkpoint_1.tmp").exists());
689
690 drop(coordinator);
691 let reopened = PersistenceCoordinator::new(config).expect("Failed to reopen coordinator");
692 assert!(!checkpoint_dir.join("checkpoint_1.tmp").exists());
693 assert_eq!(reopened.checkpoint_manager.read().current_seq(), 0);
694 }
695
696 #[test]
697 fn published_checkpoint_manifest_detects_file_corruption() {
698 let temp_dir = TempDir::new().expect("Failed to create temp dir");
699 let config = PersistenceConfig {
700 enable_snapshots: false,
701 enable_wal: false,
702 ..PersistenceConfig::for_work_dir(temp_dir.path())
703 };
704 let checkpoint_dir = config.checkpoint_dir.clone();
705 let coordinator =
706 PersistenceCoordinator::new(config).expect("Failed to create coordinator");
707 coordinator
708 .create_checkpoint(
709 |temporary_dir, _| {
710 let data_dir = temporary_dir.join("data");
711 std::fs::create_dir(&data_dir)?;
712 std::fs::write(data_dir.join("table.data"), b"valid")?;
713 Ok(CheckpointData {
714 vertex_count: 1,
715 edge_count: 0,
716 data_size: 5,
717 })
718 },
719 12,
720 )
721 .expect("Checkpoint should be published");
722
723 std::fs::write(
724 checkpoint_dir.join("checkpoint_1/data/table.data"),
725 b"corrupt",
726 )
727 .expect("Failed to corrupt checkpoint file");
728 let result = coordinator.load_latest_checkpoint(|_| Ok(()));
729 assert!(result.is_err());
730 }
731
732 #[test]
733 fn startup_removes_checkpoint_directories_without_a_published_manifest() {
734 let temp_dir = TempDir::new().expect("Failed to create temp dir");
735 let config = PersistenceConfig {
736 enable_snapshots: false,
737 enable_wal: false,
738 ..PersistenceConfig::for_work_dir(temp_dir.path())
739 };
740 std::fs::create_dir_all(config.checkpoint_dir.join("checkpoint_7.tmp"))
741 .expect("Failed to create temporary checkpoint");
742 std::fs::create_dir_all(config.checkpoint_dir.join("checkpoint_6"))
743 .expect("Failed to create unpublished checkpoint placeholder");
744
745 let _coordinator =
746 PersistenceCoordinator::new(config.clone()).expect("Failed to create coordinator");
747
748 assert!(!config.checkpoint_dir.join("checkpoint_7.tmp").exists());
749 assert!(!config.checkpoint_dir.join("checkpoint_6").exists());
750 }
751
752 #[test]
753 fn checkpoint_retention_keeps_the_latest_published_point() {
754 let temp_dir = TempDir::new().expect("Failed to create temp dir");
755 let config = PersistenceConfig {
756 enable_snapshots: false,
757 enable_wal: false,
758 ..PersistenceConfig::for_work_dir(temp_dir.path())
759 };
760 let coordinator = PersistenceCoordinator::new(config.clone()).expect("coordinator");
761 for sequence in 1..=3 {
762 coordinator
763 .create_checkpoint(
764 |temporary_dir, _| {
765 std::fs::write(
766 temporary_dir.join("table.data"),
767 sequence.to_string().as_bytes(),
768 )?;
769 Ok(CheckpointData {
770 vertex_count: sequence,
771 edge_count: 0,
772 data_size: 1,
773 })
774 },
775 sequence,
776 )
777 .expect("checkpoint should publish");
778 }
779 assert_eq!(coordinator.cleanup_old_checkpoints(1).expect("cleanup"), 2);
780 assert!(!config.checkpoint_dir.join("checkpoint_1").exists());
781 assert!(!config.checkpoint_dir.join("checkpoint_2").exists());
782 assert!(config.checkpoint_dir.join("checkpoint_3").exists());
783 }
784
785 #[test]
786 fn checkpoint_retention_keeps_latest() {
787 let temp_dir = TempDir::new().expect("Failed to create temp dir");
788 let config = PersistenceConfig {
789 enable_snapshots: false,
790 enable_wal: false,
791 ..PersistenceConfig::for_work_dir(temp_dir.path())
792 };
793 let coordinator = PersistenceCoordinator::new(config.clone()).expect("coordinator");
794 for sequence in 1..=3 {
795 coordinator
796 .create_checkpoint(
797 |temporary_dir, _| {
798 std::fs::write(temporary_dir.join("table.data"), b"data")?;
799 Ok(CheckpointData {
800 vertex_count: sequence,
801 edge_count: 0,
802 data_size: 4,
803 })
804 },
805 sequence,
806 )
807 .expect("checkpoint should publish");
808 }
809
810 assert_eq!(coordinator.cleanup_old_checkpoints(1).expect("cleanup"), 2);
811 assert!(config.checkpoint_dir.join("checkpoint_3").exists());
812 }
813
814 #[test]
815 fn every_checkpoint_crash_boundary_recovers_without_publishing_partial_state() {
816 let fault_points = [
817 PersistenceFaultPoint::CheckpointRedoBefore,
818 PersistenceFaultPoint::CheckpointIntentMid,
819 PersistenceFaultPoint::CheckpointCommitMid,
820 PersistenceFaultPoint::CheckpointFsyncAfter,
821 PersistenceFaultPoint::CheckpointVisibilityPublish,
822 ];
823 for point in fault_points {
824 let temp_dir = TempDir::new().expect("Failed to create temp dir");
825 let config = PersistenceConfig {
826 enable_snapshots: false,
827 enable_wal: false,
828 ..PersistenceConfig::for_work_dir(temp_dir.path())
829 };
830 let coordinator = PersistenceCoordinator::new(config.clone()).expect("coordinator");
831 coordinator.inject_failure(point);
832 let result = coordinator.create_checkpoint(
833 |temporary_dir, _| {
834 std::fs::write(temporary_dir.join("table.data"), b"data")?;
835 Ok(CheckpointData {
836 vertex_count: 1,
837 edge_count: 0,
838 data_size: 4,
839 })
840 },
841 1,
842 );
843 assert!(result.is_err(), "fault point {point:?} should fail");
844 assert_eq!(*coordinator.state.read(), PersistenceState::Idle);
845 assert!(coordinator.diagnostics().last_checkpoint_error.is_some());
846 assert!(
847 coordinator
848 .manifest_manager
849 .load_latest()
850 .expect("manifest lookup should succeed")
851 .is_none(),
852 "fault point {point:?} must not publish a checkpoint"
853 );
854 drop(coordinator);
855
856 let recovered = PersistenceCoordinator::new(config.clone())
857 .expect("recovery should discard unpublished checkpoint state");
858 assert!(
859 !config.checkpoint_dir.join("checkpoint_1").exists(),
860 "recovery should remove the unpublished checkpoint for {point:?}"
861 );
862 recovered
863 .create_checkpoint(
864 |temporary_dir, _| {
865 std::fs::write(temporary_dir.join("table.data"), b"data")?;
866 Ok(CheckpointData {
867 vertex_count: 1,
868 edge_count: 0,
869 data_size: 4,
870 })
871 },
872 2,
873 )
874 .expect("a checkpoint should succeed after recovery");
875 assert!(
876 recovered
877 .manifest_manager
878 .load_latest()
879 .expect("manifest lookup should succeed")
880 .is_some(),
881 "recovered checkpoint should be visible for {point:?}"
882 );
883 }
884 }
885}