Skip to main content

linkrs_storage/engine/
persistence_coordinator.rs

1//! Persistence Coordinator
2//!
3//! Unified coordinator for the persistence responsibility chain:
4//!
5//! ```text
6//! Write Operations
7//!     ↓
8//! WAL (Write-Ahead Log) - Guarantees durability
9//!     ↓
10//! Memory (RAM) - Provides fast access
11//!     ↓
12//! Flush (Periodic) - Writes memory data to disk
13//!     ↓
14//! Checkpoint (Periodic) - Creates consistent snapshots
15//!     ↓
16//! Snapshot (Manual) - User-triggered full backup
17//! ```
18//!
19//! Responsibilities:
20//! - WalManager: WAL log management, ensures write-ahead logging
21//! - PropertyGraph::flush_to_disk(): Memory-to-disk flushing (triggered by coordinator)
22//! - CheckpointManager: Checkpoint creation and recovery
23//! - SnapshotManager: Full backup management
24//!
25//! Usage:
26//! 1. Write operations go through WAL first
27//! 2. Periodic flush is triggered by the coordinator based on thresholds
28//! 3. Checkpoints are created periodically or on demand
29//! 4. Snapshots are user-triggered for full backups
30
31use 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
71/// Type alias for the outbox frontier provider callback.
72type 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    /// Fail before the checkpoint's durable data is written.
85    CheckpointRedoBefore,
86    /// Fail after data flush and before checkpoint metadata is written.
87    CheckpointIntentMid,
88    /// Fail after metadata write and before the checkpoint directory is committed.
89    CheckpointCommitMid,
90    /// Fail after the checkpoint directory has been fsynced.
91    CheckpointFsyncAfter,
92    /// Fail immediately before the combined checkpoint manifest becomes visible.
93    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    /// Latest published checkpoint watermark: MVCC snapshot timestamp plus
128    /// WAL reclaim LSN. Written once per checkpoint completion, read by GC
129    /// passes; a restart starts empty and self-heals via lazy manifest load
130    /// in `GcCoordinator`.
131    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    /// Shared storage-event registry for the central `HookBus`.
221    pub fn shared_storage_callbacks(&self) -> Arc<EventSubscriptions<StorageEvent>> {
222        Arc::clone(&self.storage_callbacks)
223    }
224
225    /// Register a runtime observer for storage lifecycle events.
226    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    /// Register a filtered observer invoked only when `filter` returns true.
236    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    /// Remove a previously registered observer. Returns true if present.
245    pub fn unregister_storage_callback(&self, id: SubscriptionId) -> bool {
246        self.storage_callbacks.remove(id)
247    }
248
249    /// Number of registered storage observers.
250    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    /// Notify observers that WAL was truncated (call after `WalManager::truncate`).
262    pub fn notify_wal_truncated(&self, up_to_lsn: u64) {
263        self.emit_storage_event(StorageEvent::WalTruncated { up_to_lsn });
264    }
265
266    /// Refresh entry for the checkpoint watermark (explicit setter).
267    ///
268    /// Called once by the checkpoint completion path after the manifest is
269    /// published. In-memory only and infallible, so it can never fail the
270    /// checkpoint it reports on. GC passes read the cell through
271    /// [`Self::checkpoint_watermark`]; after a restart the cell starts empty
272    /// and `GcCoordinator` self-heals with one lazy manifest load.
273    pub(crate) fn refresh_checkpoint_watermark(&self, snapshot: Timestamp, reclaim_lsn: CommitLsn) {
274        *self.checkpoint_watermark.write() = (Some(snapshot), reclaim_lsn);
275    }
276
277    /// Latest published checkpoint watermark, if any checkpoint completed in
278    /// this process lifetime.
279    pub(crate) fn checkpoint_watermark(&self) -> (Option<Timestamp>, CommitLsn) {
280        *self.checkpoint_watermark.read()
281    }
282
283    /// Notify observers that a GC pass reclaimed entries.
284    pub fn notify_gc_run(&self, reclaimed_entries: u64) {
285        self.emit_storage_event(StorageEvent::GcRun { reclaimed_entries });
286    }
287
288    /// Notify observers of a compaction lifecycle for a space.
289    pub fn notify_compaction_started(&self, space_id: u64) {
290        self.emit_storage_event(StorageEvent::CompactionStarted { space_id });
291    }
292
293    /// Notify observers that a compaction completed for a space.
294    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    /// Enable a deterministic failure at a persistence boundary.
302    #[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    /// Non-blocking attempt to enter checkpoint state.
358    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        // Filtered: only GcRun passes.
432        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        // Unfiltered observer receives both events.
447        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}