Skip to main content

linkrs_sync/
checkpoint_manifest.rs

1//! Combined Checkpoint Manifest
2//!
3//! This module provides the atomic checkpoint manifest that ties together:
4//! - Storage snapshot
5//! - Outbox snapshot
6//! - Native-index manifests
7//!
8//! The manifest is published atomically and serves as the authoritative source
9//! for WAL cleanup decisions via the common safe LSN.
10
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14use linkrs_core::types::{CommitLsn, IndexGeneration};
15use serde::{Deserialize, Serialize};
16
17use crate::sqlite_outbox::OutboxSnapshot;
18
19/// Combined checkpoint manifest that atomically references all snapshot components.
20///
21/// This is the core data structure. It ensures that:
22/// 1. Storage snapshot, outbox snapshot, and index manifests are published together
23/// 2. WAL cleanup uses the common safe LSN from this manifest
24/// 3. Recovery can rebuild from any valid manifest
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct CheckpointManifest {
27    /// Manifest format version for backward compatibility
28    pub format_version: u32,
29    /// Unique checkpoint identifier (monotonically increasing)
30    pub checkpoint_id: u64,
31    /// Wall-clock timestamp when checkpoint was created
32    pub timestamp: u64,
33    /// Storage snapshot LSN - the LSN of the last committed transaction included in storage
34    pub storage_lsn: CommitLsn,
35    /// Outbox snapshot LSN - the materialized LSN of the outbox snapshot
36    pub outbox_lsn: CommitLsn,
37    /// Whether the database has a durable SQLite outbox projection.
38    ///
39    /// This is distinct from `outbox_snapshot`: a checkpoint may be created
40    /// while the outbox is temporarily unavailable. In that case WAL cannot
41    /// be reclaimed past zero because the missing projection must be rebuilt
42    /// from the retained WAL.
43    pub outbox_enabled: bool,
44    /// Common safe LSN - the minimum of storage_lsn and outbox_lsn
45    /// This is the LSN up to which WAL can be safely truncated
46    pub safe_lsn: CommitLsn,
47    /// Reference to the storage snapshot
48    pub storage_snapshot: StorageSnapshotRef,
49    /// Reference to the outbox snapshot (if enabled)
50    pub outbox_snapshot: Option<OutboxSnapshotRef>,
51    /// References to native index manifests
52    pub index_manifests: Vec<IndexManifestRef>,
53    /// Highest commit timestamp that is fully incorporated in this checkpoint.
54    /// Used by recovery to continue timestamp allocation after the checkpoint
55    /// without reusing or skipping timestamps.
56    #[serde(default)]
57    pub max_commit_timestamp: u64,
58    /// MVCC snapshot timestamp whose committed data this checkpoint flushed.
59    /// New alongside the `max_commit_timestamp` fix (which used to carry the
60    /// storage LSN at a different scale): readers must use this field for
61    /// watermark purposes and never interpret the LSN-smelling value in old
62    /// files as a timestamp. `None` for manifests written before the field
63    /// existed (read-tolerated, never backfilled with old logic).
64    #[serde(default)]
65    pub snapshot_timestamp: Option<u64>,
66    /// Schema catalog version at checkpoint time.
67    #[serde(default)]
68    pub schema_catalog_version: u64,
69    /// Monotonic sequence (e.g. allocator) version at checkpoint time.
70    #[serde(default)]
71    pub sequence_version: u64,
72    /// Checksum of the entire manifest for integrity verification
73    pub manifest_checksum: u32,
74}
75
76/// Reference to a storage snapshot
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct StorageSnapshotRef {
79    /// Path to the storage snapshot directory
80    pub path: PathBuf,
81    /// Size in bytes
82    pub size_bytes: u64,
83    /// CRC32 checksum
84    pub checksum: u32,
85    /// Checksummed files contained by the snapshot directory.
86    pub files: Vec<StorageFileRef>,
87    /// Checkpoint sequence number
88    pub checkpoint_seq: u64,
89    /// Number of vertices
90    pub vertex_count: u64,
91    /// Number of edges
92    pub edge_count: u64,
93}
94
95/// Immutable file reference belonging to a storage snapshot.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct StorageFileRef {
98    /// Path relative to the storage snapshot directory.
99    pub path: PathBuf,
100    /// Size in bytes.
101    pub size_bytes: u64,
102    /// CRC32 checksum.
103    pub checksum: u32,
104}
105
106/// Reference to an outbox snapshot
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct OutboxSnapshotRef {
109    /// Path to the outbox snapshot file
110    pub path: PathBuf,
111    /// Size in bytes
112    pub size_bytes: u64,
113    /// CRC32 checksum
114    pub checksum: u32,
115    /// Materialized LSN
116    pub materialized_lsn: CommitLsn,
117}
118
119/// Reference to a native index manifest
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct IndexManifestRef {
122    /// Logical namespace of the index.
123    pub space_id: u64,
124    /// Index identifier
125    pub index_id: u64,
126    /// Index generation
127    pub generation: IndexGeneration,
128    /// Path to the manifest file
129    pub path: PathBuf,
130    /// Size in bytes
131    pub size_bytes: u64,
132    /// CRC32 checksum
133    pub checksum: u32,
134}
135
136impl CheckpointManifest {
137    /// Current manifest format version
138    pub const CURRENT_FORMAT_VERSION: u32 = 5;
139
140    /// Build a storage reference from a fully materialized checkpoint
141    /// directory. The file list is part of the combined manifest so recovery
142    /// can reject a directory that was only partially written or corrupted.
143    pub fn storage_snapshot_from_directory(
144        path: impl AsRef<Path>,
145        checkpoint_seq: u64,
146        vertex_count: u64,
147        edge_count: u64,
148    ) -> Result<StorageSnapshotRef, String> {
149        let path = path.as_ref();
150        let files = collect_storage_files(path)?;
151        let size_bytes = files.iter().map(|file| file.size_bytes).sum();
152        let checksum = storage_files_checksum(&files)?;
153        Ok(StorageSnapshotRef {
154            path: path.to_path_buf(),
155            size_bytes,
156            checksum,
157            files,
158            checkpoint_seq,
159            vertex_count,
160            edge_count,
161        })
162    }
163
164    /// Create a new checkpoint manifest from component snapshots.
165    ///
166    /// This computes the common safe LSN as the minimum of storage and outbox LSNs.
167    /// When the outbox is enabled but no snapshot is available, safe_lsn is
168    /// zero so the retained WAL can rebuild the missing projection.
169    pub fn new(
170        checkpoint_id: u64,
171        storage_lsn: CommitLsn,
172        storage_snapshot: StorageSnapshotRef,
173        outbox_snapshot: Option<OutboxSnapshotRef>,
174        index_manifests: Vec<IndexManifestRef>,
175    ) -> Result<Self, String> {
176        Self::new_with_outbox_state(
177            checkpoint_id,
178            storage_lsn,
179            storage_snapshot,
180            outbox_snapshot,
181            index_manifests,
182            false,
183        )
184    }
185
186    /// Create a manifest for a database whose SQLite outbox is part of the
187    /// durability boundary.
188    pub fn new_with_outbox(
189        checkpoint_id: u64,
190        storage_lsn: CommitLsn,
191        storage_snapshot: StorageSnapshotRef,
192        outbox_snapshot: Option<OutboxSnapshotRef>,
193        index_manifests: Vec<IndexManifestRef>,
194    ) -> Result<Self, String> {
195        Self::new_with_outbox_state(
196            checkpoint_id,
197            storage_lsn,
198            storage_snapshot,
199            outbox_snapshot,
200            index_manifests,
201            true,
202        )
203    }
204
205    fn new_with_outbox_state(
206        checkpoint_id: u64,
207        storage_lsn: CommitLsn,
208        storage_snapshot: StorageSnapshotRef,
209        outbox_snapshot: Option<OutboxSnapshotRef>,
210        index_manifests: Vec<IndexManifestRef>,
211        outbox_enabled: bool,
212    ) -> Result<Self, String> {
213        let outbox_lsn = outbox_snapshot
214            .as_ref()
215            .map(|s| s.materialized_lsn)
216            .unwrap_or(CommitLsn::ZERO);
217
218        // A missing outbox snapshot is safe only when no outbox projection is
219        // configured. If the projection is enabled, retaining WAL from zero
220        // is the only boundary that permits a complete rebuild after restart.
221        let safe_lsn = if outbox_enabled && outbox_snapshot.is_none() {
222            CommitLsn::ZERO
223        } else if outbox_snapshot.is_some() && outbox_lsn < storage_lsn {
224            outbox_lsn
225        } else {
226            storage_lsn
227        };
228
229        let mut manifest = Self {
230            format_version: Self::CURRENT_FORMAT_VERSION,
231            checkpoint_id,
232            timestamp: SystemTime::now()
233                .duration_since(SystemTime::UNIX_EPOCH)
234                .unwrap_or_default()
235                .as_secs(),
236            storage_lsn,
237            outbox_lsn,
238            outbox_enabled,
239            safe_lsn,
240            storage_snapshot,
241            outbox_snapshot,
242            index_manifests,
243            max_commit_timestamp: storage_lsn.get(),
244            snapshot_timestamp: None,
245            schema_catalog_version: 0,
246            sequence_version: 0,
247            manifest_checksum: 0,
248        };
249
250        manifest.manifest_checksum = manifest.compute_checksum()?;
251        Ok(manifest)
252    }
253
254    /// Record the MVCC snapshot timestamp this checkpoint flushed.
255    ///
256    /// Fixes the `max_commit_timestamp` scale misplacement (it used to carry
257    /// the storage LSN) and carries the true input snapshot in the new
258    /// `snapshot_timestamp` field. Recomputes the checksum so the manifest
259    /// stays verifiable. Call at the storage checkpoint path with the input
260    /// snapshot timestamp right before publishing.
261    pub fn with_snapshot_timestamp(mut self, snapshot_ts: u64) -> Result<Self, String> {
262        self.max_commit_timestamp = snapshot_ts;
263        self.snapshot_timestamp = Some(snapshot_ts);
264        self.manifest_checksum = self.compute_checksum()?;
265        Ok(self)
266    }
267
268    /// Compute checksum of the manifest (excluding the checksum field itself)
269    fn compute_checksum(&self) -> Result<u32, String> {
270        let mut clone = self.clone();
271        clone.manifest_checksum = 0;
272        let bytes = postcard::to_allocvec(&clone)
273            .map_err(|error| format!("Failed to serialize checkpoint manifest: {error}"))?;
274        Ok(crc32fast::hash(&bytes))
275    }
276
277    /// Verify the manifest checksum
278    pub fn verify_checksum(&self) -> Result<bool, String> {
279        let expected = self.manifest_checksum;
280        let mut clone = self.clone();
281        clone.manifest_checksum = 0;
282        let bytes = postcard::to_allocvec(&clone)
283            .map_err(|error| format!("Failed to serialize checkpoint manifest: {error}"))?;
284        let computed = crc32fast::hash(&bytes);
285        Ok(computed == expected)
286    }
287
288    /// Validate the manifest structure and references
289    pub fn validate(&self) -> Result<(), String> {
290        if self.format_version != Self::CURRENT_FORMAT_VERSION
291            && self.format_version != Self::CURRENT_FORMAT_VERSION - 1
292        {
293            return Err(format!(
294                "Unsupported manifest format version: {}",
295                self.format_version
296            ));
297        }
298
299        if !self.verify_checksum()? {
300            return Err("Manifest checksum verification failed".to_string());
301        }
302
303        // Validate that safe_lsn is correctly computed
304        let outbox_lsn = self
305            .outbox_snapshot
306            .as_ref()
307            .map(|s| s.materialized_lsn)
308            .unwrap_or(CommitLsn::ZERO);
309
310        let expected_safe = if self.outbox_enabled && self.outbox_snapshot.is_none() {
311            CommitLsn::ZERO
312        } else if self.outbox_snapshot.is_some() && outbox_lsn < self.storage_lsn {
313            outbox_lsn
314        } else {
315            self.storage_lsn
316        };
317
318        if self.safe_lsn != expected_safe {
319            return Err(format!(
320                "Invalid safe_lsn: expected {}, got {}",
321                expected_safe, self.safe_lsn
322            ));
323        }
324
325        if self.storage_snapshot.checkpoint_seq != self.checkpoint_id {
326            return Err(format!(
327                "Storage snapshot checkpoint sequence {} does not match manifest {}",
328                self.storage_snapshot.checkpoint_seq, self.checkpoint_id
329            ));
330        }
331
332        if self.outbox_snapshot.is_some() && self.outbox_lsn > self.storage_lsn {
333            return Err(format!(
334                "Outbox snapshot LSN {} is ahead of storage LSN {}",
335                self.outbox_lsn, self.storage_lsn
336            ));
337        }
338
339        // Validate that outbox LSN is consistent with outbox snapshot presence
340        if self.outbox_snapshot.is_none() && self.outbox_lsn != CommitLsn::ZERO {
341            return Err(
342                "Outbox LSN is non-zero but outbox snapshot reference is missing".to_string(),
343            );
344        }
345
346        verify_storage_reference(&self.storage_snapshot)?;
347        if let Some(snapshot) = &self.outbox_snapshot {
348            verify_file_reference(
349                &snapshot.path,
350                snapshot.size_bytes,
351                snapshot.checksum,
352                "outbox snapshot",
353            )?;
354        }
355        for manifest in &self.index_manifests {
356            verify_file_reference(
357                &manifest.path,
358                manifest.size_bytes,
359                manifest.checksum,
360                "index manifest",
361            )?;
362        }
363
364        Ok(())
365    }
366
367    /// Load a manifest from a file
368    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
369        let bytes = std::fs::read(path.as_ref()).map_err(|error| error.to_string())?;
370        let manifest: Self = postcard::from_bytes(&bytes).map_err(|error| error.to_string())?;
371        manifest.validate()?;
372        Ok(manifest)
373    }
374
375    /// Save the manifest to a temporary file, sync, then atomically rename
376    pub fn save_atomic(&self, destination: impl AsRef<Path>) -> Result<(), String> {
377        let destination = destination.as_ref();
378
379        // Ensure parent directory exists
380        if let Some(parent) = destination.parent() {
381            std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
382        }
383
384        // Write to temporary file first
385        let temporary = destination.with_extension("tmp");
386        let bytes = postcard::to_allocvec(self).map_err(|error| error.to_string())?;
387        std::fs::write(&temporary, &bytes).map_err(|error| error.to_string())?;
388
389        // Sync the file
390        let file = std::fs::OpenOptions::new()
391            .read(true)
392            .open(&temporary)
393            .map_err(|error| error.to_string())?;
394        file.sync_all().map_err(|error| error.to_string())?;
395        drop(file);
396
397        // Atomic rename
398        if destination.exists() {
399            std::fs::remove_file(destination).map_err(|error| error.to_string())?;
400        }
401        std::fs::rename(&temporary, destination).map_err(|error| error.to_string())?;
402
403        // Sync parent directory
404        if let Some(parent) = destination.parent() {
405            std::fs::File::open(parent)
406                .and_then(|dir| dir.sync_all())
407                .map_err(|error| error.to_string())?;
408        }
409
410        Ok(())
411    }
412
413    /// Convert from OutboxSnapshot to OutboxSnapshotRef
414    pub fn outbox_snapshot_from(outbox: &OutboxSnapshot) -> OutboxSnapshotRef {
415        OutboxSnapshotRef {
416            path: outbox.path.clone(),
417            size_bytes: outbox.size_bytes,
418            checksum: outbox.checksum,
419            materialized_lsn: outbox.materialized_lsn,
420        }
421    }
422}
423
424/// Manager for checkpoint manifests
425pub struct CheckpointManifestManager {
426    manifest_dir: PathBuf,
427}
428
429impl CheckpointManifestManager {
430    /// Create a new manifest manager
431    pub fn new(manifest_dir: impl AsRef<Path>) -> Self {
432        Self {
433            manifest_dir: manifest_dir.as_ref().to_path_buf(),
434        }
435    }
436
437    /// Initialize the manifest directory
438    pub fn init(&self) -> Result<(), String> {
439        std::fs::create_dir_all(&self.manifest_dir).map_err(|error| error.to_string())?;
440
441        // Sync directory
442        std::fs::File::open(&self.manifest_dir)
443            .and_then(|dir| dir.sync_all())
444            .map_err(|error| error.to_string())?;
445
446        Ok(())
447    }
448
449    /// Get the path for a manifest file
450    pub fn manifest_path(&self, checkpoint_id: u64) -> PathBuf {
451        self.manifest_dir
452            .join(format!("manifest_{:020}.postcard", checkpoint_id))
453    }
454
455    /// Publish a manifest atomically
456    pub fn publish(&self, manifest: &CheckpointManifest) -> Result<(), String> {
457        let path = self.manifest_path(manifest.checkpoint_id);
458        manifest.save_atomic(&path)?;
459        Ok(())
460    }
461
462    /// Load the latest published manifest
463    pub fn load_latest(&self) -> Result<Option<CheckpointManifest>, String> {
464        if !self.manifest_dir.exists() {
465            return Ok(None);
466        }
467
468        let mut manifests: Vec<(u64, PathBuf)> = std::fs::read_dir(&self.manifest_dir)
469            .map_err(|error| error.to_string())?
470            .filter_map(|entry| entry.ok())
471            .filter_map(|entry| {
472                let path = entry.path();
473                if !path.is_file() {
474                    return None;
475                }
476                let name = path.file_name()?.to_str()?;
477                let id = name
478                    .strip_prefix("manifest_")?
479                    .strip_suffix(".postcard")?
480                    .parse::<u64>()
481                    .ok()?;
482                Some((id, path))
483            })
484            .collect();
485
486        manifests.sort_by_key(|(id, _)| std::cmp::Reverse(*id));
487
488        for (_, path) in manifests {
489            match CheckpointManifest::load(&path) {
490                Ok(manifest) => return Ok(Some(manifest)),
491                Err(error) => {
492                    log::warn!(
493                        "Skipping invalid checkpoint manifest {}: {}",
494                        path.display(),
495                        error
496                    );
497                }
498            }
499        }
500        Ok(None)
501    }
502
503    /// Load a specific manifest by checkpoint ID
504    pub fn load(&self, checkpoint_id: u64) -> Result<Option<CheckpointManifest>, String> {
505        let path = self.manifest_path(checkpoint_id);
506        if !path.exists() {
507            return Ok(None);
508        }
509        CheckpointManifest::load(path).map(Some)
510    }
511
512    /// List all published manifests
513    pub fn list_manifests(&self) -> Result<Vec<u64>, String> {
514        if !self.manifest_dir.exists() {
515            return Ok(Vec::new());
516        }
517
518        let mut ids: Vec<u64> = std::fs::read_dir(&self.manifest_dir)
519            .map_err(|error| error.to_string())?
520            .filter_map(|entry| entry.ok())
521            .filter_map(|entry| {
522                let path = entry.path();
523                let name = path.file_name()?.to_str()?;
524                name.strip_prefix("manifest_")?
525                    .strip_suffix(".postcard")?
526                    .parse::<u64>()
527                    .ok()
528            })
529            .collect();
530
531        ids.sort();
532        Ok(ids)
533    }
534
535    /// Get the safe LSN from the latest manifest
536    pub fn latest_safe_lsn(&self) -> Result<CommitLsn, String> {
537        self.load_latest()?
538            .map(|m| m.safe_lsn)
539            .ok_or_else(|| "No published manifest found".to_string())
540    }
541
542    /// Remove manifests older than the given checkpoint ID
543    pub fn cleanup_old(&self, keep_newer_than: u64) -> Result<usize, String> {
544        let mut removed = 0;
545        for id in self.list_manifests()? {
546            if id < keep_newer_than {
547                let path = self.manifest_path(id);
548                std::fs::remove_file(path).map_err(|error| error.to_string())?;
549                removed += 1;
550            }
551        }
552
553        // Sync directory
554        if removed > 0 {
555            std::fs::File::open(&self.manifest_dir)
556                .and_then(|dir| dir.sync_all())
557                .map_err(|error| error.to_string())?;
558        }
559
560        Ok(removed)
561    }
562}
563
564fn verify_file_reference(
565    path: &Path,
566    expected_size: u64,
567    expected_checksum: u32,
568    kind: &str,
569) -> Result<(), String> {
570    let bytes = std::fs::read(path)
571        .map_err(|error| format!("Failed to read {kind} {}: {error}", path.display()))?;
572    if bytes.len() as u64 != expected_size {
573        return Err(format!("{kind} size mismatch: {}", path.display()));
574    }
575    if crc32fast::hash(&bytes) != expected_checksum {
576        return Err(format!("{kind} checksum mismatch: {}", path.display()));
577    }
578    Ok(())
579}
580
581fn collect_storage_files(root: &Path) -> Result<Vec<StorageFileRef>, String> {
582    fn visit(root: &Path, directory: &Path, files: &mut Vec<StorageFileRef>) -> Result<(), String> {
583        for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? {
584            let path = entry.map_err(|error| error.to_string())?.path();
585            if path.is_dir() {
586                visit(root, &path, files)?;
587                continue;
588            }
589            if !path.is_file() {
590                continue;
591            }
592            // Table redo logs live next to the snapshot but are not part
593            // of it: post-checkpoint commits append here and recovery
594            // replays them on top of the base. Inventorying them would
595            // invalidate every manifest on the first write after publish.
596            // Each log carries its own integrity gate (torn tails fail
597            // the load closed), so exclusion loses no damage detection.
598            if path
599                .file_name()
600                .and_then(|name| name.to_str())
601                .is_some_and(|name| name == "edge_wal.bin")
602            {
603                continue;
604            }
605            let bytes = std::fs::read(&path).map_err(|error| error.to_string())?;
606            let relative = path
607                .strip_prefix(root)
608                .map_err(|error| error.to_string())?
609                .to_path_buf();
610            files.push(StorageFileRef {
611                path: relative,
612                size_bytes: bytes.len() as u64,
613                checksum: crc32fast::hash(&bytes),
614            });
615        }
616        Ok(())
617    }
618
619    if !root.is_dir() {
620        return Err(format!(
621            "Storage snapshot directory does not exist: {}",
622            root.display()
623        ));
624    }
625    let mut files = Vec::new();
626    visit(root, root, &mut files)?;
627    files.sort_by(|left, right| left.path.cmp(&right.path));
628    Ok(files)
629}
630
631fn storage_files_checksum(files: &[StorageFileRef]) -> Result<u32, String> {
632    let bytes = postcard::to_allocvec(files).map_err(|error| error.to_string())?;
633    Ok(crc32fast::hash(&bytes))
634}
635
636fn verify_storage_reference(reference: &StorageSnapshotRef) -> Result<(), String> {
637    let actual = collect_storage_files(&reference.path)?;
638    if actual != reference.files {
639        return Err(format!(
640            "Storage snapshot file list mismatch: {}",
641            reference.path.display()
642        ));
643    }
644    let size_bytes: u64 = actual.iter().map(|file| file.size_bytes).sum();
645    if size_bytes != reference.size_bytes {
646        return Err(format!(
647            "Storage snapshot size mismatch: {}",
648            reference.path.display()
649        ));
650    }
651    let checksum = storage_files_checksum(&actual)?;
652    if checksum != reference.checksum {
653        return Err(format!(
654            "Storage snapshot checksum mismatch: {}",
655            reference.path.display()
656        ));
657    }
658
659    for file in &actual {
660        if file.path.is_absolute()
661            || file
662                .path
663                .components()
664                .any(|component| component == std::path::Component::ParentDir)
665        {
666            return Err(format!(
667                "Invalid storage snapshot file path: {}",
668                file.path.display()
669            ));
670        }
671    }
672    Ok(())
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use tempfile::TempDir;
679
680    fn create_test_storage_snapshot_ref(temp_dir: &Path) -> StorageSnapshotRef {
681        let snapshot_path = temp_dir.join("storage_snapshot");
682        std::fs::create_dir_all(&snapshot_path).unwrap();
683        std::fs::write(snapshot_path.join("checkpoint.meta"), b"checkpoint").unwrap();
684        CheckpointManifest::storage_snapshot_from_directory(&snapshot_path, 1, 100, 50).unwrap()
685    }
686
687    fn create_test_outbox_snapshot_ref(temp_dir: &Path) -> OutboxSnapshotRef {
688        let snapshot_path = temp_dir.join("outbox_snapshot.sqlite");
689        std::fs::write(&snapshot_path, b"test").unwrap();
690        OutboxSnapshotRef {
691            path: snapshot_path,
692            size_bytes: 4,
693            checksum: crc32fast::hash(b"test"),
694            materialized_lsn: CommitLsn::new(100),
695        }
696    }
697
698    #[test]
699    fn test_manifest_creation_and_validation() {
700        let temp_dir = TempDir::new().unwrap();
701        let storage_ref = create_test_storage_snapshot_ref(temp_dir.path());
702        let outbox_ref = create_test_outbox_snapshot_ref(temp_dir.path());
703
704        let manifest = CheckpointManifest::new(
705            1,
706            CommitLsn::new(100),
707            storage_ref,
708            Some(outbox_ref),
709            Vec::new(),
710        )
711        .expect("checkpoint manifest should be created");
712
713        assert!(manifest.validate().is_ok());
714        assert_eq!(manifest.safe_lsn, CommitLsn::new(100));
715        assert_eq!(manifest.storage_lsn, CommitLsn::new(100));
716        assert_eq!(manifest.outbox_lsn, CommitLsn::new(100));
717    }
718
719    #[test]
720    fn test_safe_lsn_is_minimum() {
721        let temp_dir = TempDir::new().unwrap();
722        let storage_ref = create_test_storage_snapshot_ref(temp_dir.path());
723
724        // Storage LSN is lower. The outbox snapshot must not be ahead of the
725        // storage boundary in a combined manifest.
726        let manifest1 = CheckpointManifest::new(
727            1,
728            CommitLsn::new(50),
729            storage_ref.clone(),
730            Some(OutboxSnapshotRef {
731                path: temp_dir.path().join("outbox.sqlite"),
732                size_bytes: 4,
733                checksum: 0,
734                materialized_lsn: CommitLsn::new(50),
735            }),
736            Vec::new(),
737        )
738        .expect("checkpoint manifest should be created");
739        assert_eq!(manifest1.safe_lsn, CommitLsn::new(50));
740
741        // Outbox LSN is lower
742        let manifest2 = CheckpointManifest::new(
743            2,
744            CommitLsn::new(100),
745            storage_ref,
746            Some(OutboxSnapshotRef {
747                path: temp_dir.path().join("outbox2.sqlite"),
748                size_bytes: 4,
749                checksum: 0,
750                materialized_lsn: CommitLsn::new(20),
751            }),
752            Vec::new(),
753        )
754        .expect("checkpoint manifest should be created");
755        assert_eq!(manifest2.safe_lsn, CommitLsn::new(20));
756    }
757
758    #[test]
759    fn enabled_outbox_without_snapshot_keeps_wal_reclaim_boundary_at_zero() {
760        let temp_dir = TempDir::new().unwrap();
761        let manifest = CheckpointManifest::new_with_outbox(
762            1,
763            CommitLsn::new(100),
764            create_test_storage_snapshot_ref(temp_dir.path()),
765            None,
766            Vec::new(),
767        )
768        .expect("checkpoint manifest should be created");
769
770        assert_eq!(manifest.safe_lsn, CommitLsn::ZERO);
771        assert!(manifest.validate().is_ok());
772    }
773
774    #[test]
775    fn test_manifest_save_and_load() {
776        let temp_dir = TempDir::new().unwrap();
777        let storage_ref = create_test_storage_snapshot_ref(temp_dir.path());
778        let outbox_ref = create_test_outbox_snapshot_ref(temp_dir.path());
779
780        let manifest = CheckpointManifest::new(
781            1,
782            CommitLsn::new(100),
783            storage_ref,
784            Some(outbox_ref),
785            Vec::new(),
786        )
787        .expect("checkpoint manifest should be created");
788
789        let manifest_path = temp_dir.path().join("test_manifest.postcard");
790        manifest.save_atomic(&manifest_path).unwrap();
791
792        let loaded = CheckpointManifest::load(&manifest_path).unwrap();
793        assert_eq!(loaded.checkpoint_id, manifest.checkpoint_id);
794        assert_eq!(loaded.safe_lsn, manifest.safe_lsn);
795    }
796
797    fn create_storage_snapshot_ref(temp_dir: &Path, checkpoint_seq: u64) -> StorageSnapshotRef {
798        let snapshot_path = temp_dir.join(format!("storage_snapshot_{}", checkpoint_seq));
799        std::fs::create_dir_all(&snapshot_path).unwrap();
800        std::fs::write(snapshot_path.join("checkpoint.meta"), b"checkpoint").unwrap();
801        CheckpointManifest::storage_snapshot_from_directory(&snapshot_path, checkpoint_seq, 100, 50)
802            .unwrap()
803    }
804
805    #[test]
806    fn test_manifest_manager() {
807        let temp_dir = TempDir::new().unwrap();
808        let manager = CheckpointManifestManager::new(temp_dir.path());
809        manager.init().unwrap();
810
811        let storage_ref1 = create_storage_snapshot_ref(temp_dir.path(), 1);
812        let storage_ref2 = create_storage_snapshot_ref(temp_dir.path(), 2);
813        let outbox_ref = create_test_outbox_snapshot_ref(temp_dir.path());
814
815        let manifest1 = CheckpointManifest::new(
816            1,
817            CommitLsn::new(100),
818            storage_ref1,
819            Some(outbox_ref.clone()),
820            Vec::new(),
821        )
822        .expect("checkpoint manifest should be created");
823        let manifest2 = CheckpointManifest::new(
824            2,
825            CommitLsn::new(100),
826            storage_ref2,
827            Some(outbox_ref),
828            Vec::new(),
829        )
830        .expect("checkpoint manifest should be created");
831
832        manager.publish(&manifest1).unwrap();
833        manager.publish(&manifest2).unwrap();
834
835        let latest = manager.load_latest().unwrap().unwrap();
836        assert_eq!(latest.checkpoint_id, 2);
837        assert_eq!(latest.safe_lsn, CommitLsn::new(100));
838
839        let safe_lsn = manager.latest_safe_lsn().unwrap();
840        assert_eq!(safe_lsn, CommitLsn::new(100));
841
842        let manifests = manager.list_manifests().unwrap();
843        assert_eq!(manifests, vec![1, 2]);
844    }
845
846    #[test]
847    fn test_checksum_verification() {
848        let temp_dir = TempDir::new().unwrap();
849        let storage_ref = create_test_storage_snapshot_ref(temp_dir.path());
850
851        let mut manifest =
852            CheckpointManifest::new(1, CommitLsn::new(100), storage_ref, None, Vec::new())
853                .expect("checkpoint manifest should be created");
854
855        assert!(manifest.verify_checksum().expect("checksum should compute"));
856
857        // Corrupt the manifest
858        manifest.checkpoint_id = 999;
859        assert!(!manifest.verify_checksum().expect("checksum should compute"));
860    }
861
862    #[test]
863    fn test_snapshot_timestamp_fix_and_roundtrip() {
864        let temp_dir = TempDir::new().unwrap();
865        let storage_ref = create_test_storage_snapshot_ref(temp_dir.path());
866
867        // Plain construction keeps the legacy shape (no snapshot field).
868        let legacy = CheckpointManifest::new(
869            1,
870            CommitLsn::new(100),
871            storage_ref.clone(),
872            None,
873            Vec::new(),
874        )
875        .expect("checkpoint manifest should be created");
876        assert_eq!(legacy.snapshot_timestamp, None);
877
878        // The checkpoint path stamps the true input snapshot, fixing the
879        // LSN-scale misplacement, and the stamped manifest round-trips with
880        // a valid checksum.
881        let stamped =
882            CheckpointManifest::new(1, CommitLsn::new(100), storage_ref, None, Vec::new())
883                .expect("checkpoint manifest should be created")
884                .with_snapshot_timestamp(40)
885                .expect("snapshot stamp should apply");
886        assert_eq!(stamped.max_commit_timestamp, 40);
887        assert_eq!(stamped.snapshot_timestamp, Some(40));
888        assert!(stamped.validate().is_ok());
889
890        let path = temp_dir.path().join("stamped.postcard");
891        stamped.save_atomic(&path).unwrap();
892        let loaded = CheckpointManifest::load(&path).unwrap();
893        assert_eq!(loaded.snapshot_timestamp, Some(40));
894        assert_eq!(loaded.max_commit_timestamp, 40);
895    }
896}