Skip to main content

edgestore_repl/
anti_entropy.rs

1//! Pull-only anti-entropy loop with per-peer cursor persistence.
2//!
3//! `AntiEntropyLoop` wakes every N seconds, probes the peer's Merkle root, and if
4//! diverged pulls all missing segments one by one. Progress is tracked in a per-peer
5//! cursor file at `{db_path}/sync/{peer_id}.cursor` (MessagePack format, D08).
6//!
7//! Cursor fields (D08):
8//!   - `last_known_merkle_root` — peer's Merkle root as of last successful sync
9//!   - `segments_pending`       — hashes not yet applied (resume after crash)
10//!   - `last_attempt_secs`      — unix timestamp of last probe attempt
11//!   - `segments_applied_total` — running count of segments applied
12
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use edgestore::replication::ReplicationProtocol;
18use edgestore::{Engine, ImportResult, RemoteStore};
19
20use crate::http_client::HttpReplicationClient;
21
22/// Per-peer cursor: durable progress state for the anti-entropy loop (D08).
23#[derive(serde::Serialize, serde::Deserialize, Default)]
24pub struct PeerCursor {
25    /// Peer's Merkle root from the last completed sync (32 bytes stored as Vec).
26    pub last_known_merkle_root: Vec<u8>,
27    /// Segment hashes that have been identified as missing but not yet applied.
28    pub segments_pending: Vec<Vec<u8>>,
29    /// Unix timestamp (seconds) of the last probe attempt.
30    pub last_attempt_secs: u64,
31    /// Total number of segments applied to date.
32    pub segments_applied_total: u64,
33}
34
35/// Background pull-only anti-entropy loop.
36///
37/// Spawned via `AntiEntropyLoop::start()`. Probes the configured peer every `interval_secs`
38/// seconds. If the Merkle roots differ, pulls all missing segments and applies LWW merges
39/// via `Engine::import_segment`. Per-peer cursor makes progress durable across crashes.
40pub struct AntiEntropyLoop {
41    engine: Arc<Mutex<Engine>>,
42    peer_url: String,
43    peer_id: String,
44    db_path: PathBuf,
45    /// Probe interval in seconds. Default: 30.
46    pub interval_secs: u64,
47    /// Optional durable segment backend. When `Some`, each successfully applied
48    /// segment is uploaded after import (D08). Upload failure is non-fatal — the
49    /// segment is already applied locally.
50    remote_store: Option<Arc<dyn RemoteStore>>,
51}
52
53impl AntiEntropyLoop {
54    /// Create a new anti-entropy loop.
55    ///
56    /// - `engine`   — shared engine (`Arc<Mutex>`) for replication API access.
57    /// - `peer_url` — base URL of the remote peer's `HttpReplicationServer` (e.g. `"http://host:8900"`).
58    /// - `peer_id`  — unique identifier for the peer; used as the cursor file name.
59    /// - `db_path`  — database directory path; cursor file is written under `{db_path}/sync/`.
60    pub fn new(
61        engine: Arc<Mutex<Engine>>,
62        peer_url: String,
63        peer_id: String,
64        db_path: PathBuf,
65    ) -> Self {
66        AntiEntropyLoop {
67            engine,
68            peer_url,
69            peer_id,
70            db_path,
71            interval_secs: 30,
72            remote_store: None,
73        }
74    }
75
76    /// Attach a `RemoteStore` backend. After each segment is successfully applied, the
77    /// loop will call `remote_store.upload(hash, data)`. Upload failures are logged and
78    /// ignored — they do not abort the sync loop.
79    pub fn with_remote_store(mut self, store: Arc<dyn RemoteStore>) -> Self {
80        self.remote_store = Some(store);
81        self
82    }
83
84    /// Override the probe interval (default: 30 seconds).
85    ///
86    /// Useful in tests to reduce the time between anti-entropy cycles.
87    pub fn with_interval(mut self, secs: u64) -> Self {
88        self.interval_secs = secs;
89        self
90    }
91
92    /// Spawn the anti-entropy loop in a background thread.
93    ///
94    /// Returns the `JoinHandle` for the background thread. The thread runs until the
95    /// process exits.
96    pub fn start(self) -> std::thread::JoinHandle<()> {
97        std::thread::spawn(move || loop {
98            std::thread::sleep(Duration::from_secs(self.interval_secs));
99            run_once(
100                &self.engine,
101                &self.peer_url,
102                &self.peer_id,
103                &self.db_path,
104                self.remote_store.as_deref(),
105            );
106        })
107    }
108}
109
110/// Execute one anti-entropy probe-and-pull cycle.
111fn run_once(
112    engine: &Arc<Mutex<Engine>>,
113    peer_url: &str,
114    peer_id: &str,
115    db_path: &Path,
116    remote_store: Option<&dyn RemoteStore>,
117) {
118    // Step 1: Load or create cursor.
119    let cursor_path = cursor_file_path(db_path, peer_id);
120    let mut cursor = load_cursor(&cursor_path);
121
122    // Step 2: Update attempt timestamp.
123    cursor.last_attempt_secs = now_secs();
124    if let Err(e) = flush_cursor(&cursor, &cursor_path) {
125        eprintln!("[anti_entropy] cursor flush error: {}", e);
126    }
127
128    // Step 3: Create client and probe peer Merkle root.
129    let client = HttpReplicationClient::new(peer_url);
130
131    let peer_root = match client.merkle_root() {
132        Ok(r) => r,
133        Err(e) => {
134            eprintln!("[anti_entropy] peer {} merkle_root error: {}", peer_id, e);
135            return;
136        }
137    };
138
139    // Step 4: Compare Merkle roots.
140    let in_sync = {
141        match engine.lock() {
142            Ok(eng) => match eng.compare_merkle(&peer_root) {
143                Ok(same) => same,
144                Err(e) => {
145                    eprintln!("[anti_entropy] compare_merkle error: {}", e);
146                    return;
147                }
148            },
149            Err(_) => {
150                eprintln!("[anti_entropy] engine lock poisoned");
151                return;
152            }
153        }
154    };
155
156    if in_sync {
157        // Roots match — update cursor and skip expensive manifest diff.
158        cursor.last_known_merkle_root = peer_root.to_vec();
159        if let Err(e) = flush_cursor(&cursor, &cursor_path) {
160            eprintln!("[anti_entropy] cursor flush (in-sync) error: {}", e);
161        }
162        return;
163    }
164
165    // Step 5: Fetch peer segment manifest.
166    let peer_segments = match client.list_segments() {
167        Ok(segs) => segs,
168        Err(e) => {
169            eprintln!("[anti_entropy] peer {} list_segments error: {}", peer_id, e);
170            return;
171        }
172    };
173
174    // Step 6: Compute missing segments.
175    let missing: Vec<[u8; 32]> = {
176        match engine.lock() {
177            Ok(eng) => eng.missing_segments(&peer_segments),
178            Err(_) => {
179                eprintln!("[anti_entropy] engine lock poisoned (missing_segments)");
180                return;
181            }
182        }
183    };
184
185    // Step 7: Update cursor with pending hashes.
186    cursor.segments_pending = missing.iter().map(|h| h.to_vec()).collect();
187    if let Err(e) = flush_cursor(&cursor, &cursor_path) {
188        eprintln!("[anti_entropy] cursor flush (pending) error: {}", e);
189    }
190
191    // Step 8: Pull and apply each missing segment.
192    let pending_hashes: Vec<Vec<u8>> = cursor.segments_pending.clone();
193    for hash_vec in &pending_hashes {
194        if hash_vec.len() != 32 {
195            eprintln!(
196                "[anti_entropy] skipping malformed hash (len={})",
197                hash_vec.len()
198            );
199            continue;
200        }
201
202        let mut hash = [0u8; 32];
203        hash.copy_from_slice(hash_vec);
204
205        // Download segment from peer.
206        let data = match client.fetch_segment(&hash) {
207            Ok(d) => d,
208            Err(e) => {
209                eprintln!("[anti_entropy] fetch_segment error: {}", e);
210                continue;
211            }
212        };
213
214        // Import segment into local engine (includes BLAKE3 verification + LWW merge).
215        let result = {
216            match engine.lock() {
217                Ok(mut eng) => eng.import_segment(&data, &hash),
218                Err(_) => {
219                    eprintln!("[anti_entropy] engine lock poisoned (import_segment)");
220                    continue;
221                }
222            }
223        };
224
225        match result {
226            Ok(ImportResult::Applied {
227                keys_written,
228                keys_skipped,
229            }) => {
230                // Remove from pending and update total.
231                cursor.segments_pending.retain(|h| h != hash_vec);
232                cursor.segments_applied_total += 1;
233                if let Err(e) = flush_cursor(&cursor, &cursor_path) {
234                    eprintln!("[anti_entropy] cursor flush (applied) error: {}", e);
235                }
236                eprintln!(
237                    "[anti_entropy] applied segment {}: {} written, {} skipped",
238                    hex_str(&hash),
239                    keys_written,
240                    keys_skipped
241                );
242
243                // Upload to remote store if configured (D08). Non-fatal on error.
244                if let Some(rs) = remote_store {
245                    if let Err(e) = rs.upload(&hash, &data) {
246                        eprintln!(
247                            "[anti_entropy] remote_store upload warning for {}: {}",
248                            hex_str(&hash),
249                            e
250                        );
251                    }
252                }
253            }
254            Ok(ImportResult::Skipped) => {
255                // Already present — remove from pending.
256                cursor.segments_pending.retain(|h| h != hash_vec);
257                cursor.segments_applied_total += 1;
258                if let Err(e) = flush_cursor(&cursor, &cursor_path) {
259                    eprintln!("[anti_entropy] cursor flush (skipped) error: {}", e);
260                }
261            }
262            Ok(ImportResult::HashMismatch) => {
263                // Do NOT remove from pending — will retry next cycle.
264                eprintln!(
265                    "[anti_entropy] BLAKE3 mismatch for segment {} — will retry",
266                    hex_str(&hash)
267                );
268            }
269            Err(e) => {
270                eprintln!("[anti_entropy] import_segment error: {}", e);
271                // Leave in pending — will retry next cycle.
272            }
273        }
274    }
275
276    // Step 9: Update cursor with the peer root we just synced to.
277    cursor.last_known_merkle_root = peer_root.to_vec();
278    if let Err(e) = flush_cursor(&cursor, &cursor_path) {
279        eprintln!("[anti_entropy] cursor flush (final) error: {}", e);
280    }
281}
282
283/// Compute the cursor file path for a given peer.
284fn cursor_file_path(db_path: &Path, peer_id: &str) -> PathBuf {
285    db_path.join("sync").join(format!("{}.cursor", peer_id))
286}
287
288/// Load cursor from disk. Returns a default cursor on parse failure or missing file (D08).
289///
290/// Corrupt cursor is treated as empty to avoid blocking sync on bad state.
291fn load_cursor(cursor_path: &Path) -> PeerCursor {
292    match std::fs::File::open(cursor_path) {
293        Ok(file) => rmp_serde::from_read(file).unwrap_or_default(),
294        Err(_) => PeerCursor::default(),
295    }
296}
297
298/// Flush cursor atomically: write to `.tmp`, then rename to final path (D08, T-04-09).
299///
300/// Atomic write prevents corrupt cursor state on crash mid-write.
301fn flush_cursor(cursor: &PeerCursor, cursor_path: &Path) -> Result<(), std::io::Error> {
302    // Ensure the sync/ directory exists.
303    if let Some(parent) = cursor_path.parent() {
304        std::fs::create_dir_all(parent)?;
305    }
306
307    let tmp_path = cursor_path.with_extension("cursor.tmp");
308
309    let bytes = rmp_serde::to_vec(cursor).map_err(|e| std::io::Error::other(e.to_string()))?;
310
311    std::fs::write(&tmp_path, &bytes)?;
312    std::fs::rename(&tmp_path, cursor_path)?;
313
314    Ok(())
315}
316
317/// Return current Unix timestamp in seconds.
318fn now_secs() -> u64 {
319    SystemTime::now()
320        .duration_since(UNIX_EPOCH)
321        .unwrap_or_default()
322        .as_secs()
323}
324
325/// Format a 32-byte hash as a hex string.
326fn hex_str(hash: &[u8; 32]) -> String {
327    hash.iter().map(|b| format!("{:02x}", b)).collect()
328}