Skip to main content

appcore_sync/sync/
checkpoint.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: checkpoint.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/06/02 13:08:16 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/06/07 12:00:00 by dnettoRaw
8//      ###########      S: 0.6.1
9// =============================================================================
10
11//! Per-peer checkpoint contracts and local implementations.
12
13use crate::sync::error::SyncResult;
14use crate::sync::persistence::{
15    acquire_persistence_lock, atomic_write, read_bounded_text, split_format,
16};
17use parking_lot::Mutex;
18use std::collections::BTreeMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23/// Stable on-disk format marker for peer checkpoints.
24pub const SYNC_CHECKPOINT_FORMAT_V1: &str = "# appcore-sync-checkpoint-v1";
25const MAX_CHECKPOINT_FILE_BYTES: u64 = 8 * 1024 * 1024;
26const MAX_CHECKPOINT_PEER_ID_BYTES: usize = 256;
27type Checkpoint = (u64, String);
28type CheckpointMap = BTreeMap<String, Checkpoint>;
29
30/// Sync checkpoint storage contract by peer id.
31pub trait SyncCheckpointStore: Send + Sync {
32    /// Returns the last accepted sequence and batch hash for `peer_id`.
33    fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>>;
34    /// Atomically replaces the sequence and batch hash for `peer_id`.
35    fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()>;
36
37    /// Returns the last accepted sequence, or zero when no checkpoint exists.
38    fn get_last_sequence(&self, peer_id: &str) -> SyncResult<u64> {
39        Ok(self
40            .get_checkpoint(peer_id)?
41            .map(|(seq, _)| seq)
42            .unwrap_or(0))
43    }
44
45    /// Updates only the accepted sequence while preserving the stored hash.
46    fn set_last_sequence(&self, peer_id: &str, sequence: u64) -> SyncResult<()> {
47        let hash = self
48            .get_checkpoint(peer_id)?
49            .map(|(_, h)| h)
50            .unwrap_or_default();
51        self.set_checkpoint(peer_id, sequence, &hash)
52    }
53}
54
55/// In-memory checkpoint store for tests/local runtime.
56#[derive(Debug, Clone, Default)]
57pub struct InMemorySyncCheckpointStore {
58    checkpoints: Arc<Mutex<CheckpointMap>>,
59}
60
61impl InMemorySyncCheckpointStore {
62    /// Creates an empty process-local checkpoint store.
63    pub fn new() -> Self {
64        Self {
65            checkpoints: Arc::new(Mutex::new(BTreeMap::new())),
66        }
67    }
68}
69
70impl SyncCheckpointStore for InMemorySyncCheckpointStore {
71    fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>> {
72        validate_peer_id(peer_id)?;
73        let guard = self.checkpoints.lock();
74        Ok(guard.get(peer_id).cloned())
75    }
76
77    fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()> {
78        validate_peer_id(peer_id)?;
79        validate_checkpoint_hash(hash)?;
80        let mut guard = self.checkpoints.lock();
81        guard.insert(peer_id.to_string(), (sequence, hash.to_string()));
82        Ok(())
83    }
84}
85
86/// File-backed checkpoint store (line-based `peer=sequence,hash`).
87#[derive(Debug, Clone)]
88pub struct FileSyncCheckpointStore {
89    file_path: PathBuf,
90    lock: Arc<Mutex<()>>,
91}
92
93impl FileSyncCheckpointStore {
94    /// Opens or creates an atomic line-based checkpoint file.
95    pub fn new(file_path: impl Into<PathBuf>) -> SyncResult<Self> {
96        let file_path = file_path.into();
97        if let Some(parent) = file_path.parent() {
98            fs::create_dir_all(parent)
99                .map_err(|err| crate::sync::error::SyncError::ReplicationFailed(err.to_string()))?;
100        }
101        let _process_lock = acquire_persistence_lock(&file_path)?;
102        if !file_path.exists() {
103            atomic_write(
104                &file_path,
105                format!("{SYNC_CHECKPOINT_FORMAT_V1}\n").as_bytes(),
106            )?;
107        }
108        let store = Self {
109            file_path,
110            lock: Arc::new(Mutex::new(())),
111        };
112        store.read_state()?;
113        Ok(store)
114    }
115
116    /// Returns the durable checkpoint file path.
117    pub fn file_path(&self) -> &Path {
118        &self.file_path
119    }
120
121    fn read_state(&self) -> SyncResult<CheckpointMap> {
122        let text = read_bounded_text(&self.file_path, MAX_CHECKPOINT_FILE_BYTES)?;
123        let formatted = split_format(&text, SYNC_CHECKPOINT_FORMAT_V1)?;
124        parse_checkpoint_map(formatted.body)
125    }
126
127    fn read_map(&self) -> SyncResult<CheckpointMap> {
128        self.read_state()
129    }
130
131    fn write_map(&self, map: &CheckpointMap) -> SyncResult<()> {
132        let mut out = format!("{SYNC_CHECKPOINT_FORMAT_V1}\n");
133        for (peer_id, (sequence, hash)) in map {
134            out.push_str(peer_id);
135            out.push('=');
136            out.push_str(&sequence.to_string());
137            out.push(',');
138            out.push_str(hash);
139            out.push('\n');
140        }
141        atomic_write(&self.file_path, out.as_bytes())
142    }
143}
144
145impl SyncCheckpointStore for FileSyncCheckpointStore {
146    fn get_checkpoint(&self, peer_id: &str) -> SyncResult<Option<(u64, String)>> {
147        validate_peer_id(peer_id)?;
148        let _guard = self.lock.lock();
149        let _process_lock = acquire_persistence_lock(&self.file_path)?;
150        let map = self.read_map()?;
151        Ok(map.get(peer_id).cloned())
152    }
153
154    fn set_checkpoint(&self, peer_id: &str, sequence: u64, hash: &str) -> SyncResult<()> {
155        validate_peer_id(peer_id)?;
156        validate_checkpoint_hash(hash)?;
157        let _guard = self.lock.lock();
158        let _process_lock = acquire_persistence_lock(&self.file_path)?;
159        let mut map = self.read_map()?;
160        map.insert(peer_id.to_string(), (sequence, hash.to_string()));
161        self.write_map(&map)
162    }
163}
164
165fn validate_peer_id(peer_id: &str) -> SyncResult<()> {
166    if peer_id.is_empty() || peer_id.len() > MAX_CHECKPOINT_PEER_ID_BYTES {
167        return Err(crate::sync::error::SyncError::InvalidPeerId);
168    }
169    if !peer_id
170        .chars()
171        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-'))
172    {
173        return Err(crate::sync::error::SyncError::InvalidPeerId);
174    }
175    Ok(())
176}
177
178fn validate_checkpoint_hash(hash: &str) -> SyncResult<()> {
179    if hash.is_empty() || (hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())) {
180        return Ok(());
181    }
182    Err(crate::sync::error::SyncError::ReplicationFailed(
183        "invalid checkpoint hash".to_string(),
184    ))
185}
186
187fn parse_checkpoint_map(text: &str) -> SyncResult<CheckpointMap> {
188    let mut map = BTreeMap::new();
189    for line in text.lines().filter(|line| !line.trim().is_empty()) {
190        let (peer_id, rest) = line.split_once('=').ok_or_else(|| {
191            crate::sync::error::SyncError::ReplicationFailed("invalid checkpoint line".to_string())
192        })?;
193        validate_peer_id(peer_id)?;
194
195        let (sequence_text, hash) = if let Some((seq_t, h_t)) = rest.split_once(',') {
196            (seq_t, h_t.to_string())
197        } else {
198            (rest, "".to_string())
199        };
200
201        let sequence = sequence_text.parse::<u64>().map_err(|_| {
202            crate::sync::error::SyncError::ReplicationFailed(
203                "invalid checkpoint sequence".to_string(),
204            )
205        })?;
206        validate_checkpoint_hash(&hash)?;
207        map.insert(peer_id.to_string(), (sequence, hash));
208    }
209    Ok(map)
210}