Skip to main content

linkrs_sync/
receiver.rs

1#[cfg(feature = "fulltext")]
2use std::collections::HashSet;
3#[cfg(any(feature = "fulltext", feature = "vector"))]
4use std::sync::Arc;
5#[cfg(any(feature = "fulltext", feature = "vector"))]
6use tokio::sync::{Mutex, RwLock};
7
8use linkrs_core::types::CommitLsn;
9#[cfg(feature = "fulltext")]
10use linkrs_core::wal::IndexMutation;
11
12pub struct ApplyReceipt {
13    pub commit_lsn: CommitLsn,
14    pub idempotency_key: String,
15    pub applied: bool,
16}
17
18pub struct LateArrivalResult {
19    pub accepted: bool,
20    pub reason: String,
21}
22
23#[cfg(feature = "fulltext")]
24pub struct FulltextReceiver {
25    engine: Arc<dyn linkrs_fulltext::engine::FulltextSearchEngine>,
26    receipts: Arc<RwLock<HashSet<String>>>,
27    applied_lsn: Arc<RwLock<CommitLsn>>,
28    apply_lock: Mutex<()>,
29}
30
31// FulltextReceiver keeps JSON because Tantivy commit payloads accept only UTF-8
32// strings; this intentional exception is documented.
33#[cfg(feature = "fulltext")]
34#[derive(serde::Serialize, serde::Deserialize)]
35struct FulltextCommitState {
36    applied_lsn: u64,
37    receipts: HashSet<String>,
38}
39
40#[cfg(feature = "fulltext")]
41impl FulltextReceiver {
42    pub fn new(engine: Arc<dyn linkrs_fulltext::engine::FulltextSearchEngine>) -> Self {
43        let state = engine
44            .commit_payload()
45            .ok()
46            .flatten()
47            .and_then(|payload| serde_json::from_str::<FulltextCommitState>(&payload).ok())
48            .unwrap_or_else(|| FulltextCommitState {
49                applied_lsn: 0,
50                receipts: HashSet::new(),
51            });
52        Self {
53            engine,
54            receipts: Arc::new(RwLock::new(state.receipts)),
55            applied_lsn: Arc::new(RwLock::new(CommitLsn::new(state.applied_lsn))),
56            apply_lock: Mutex::new(()),
57        }
58    }
59
60    pub async fn apply_index_batch(
61        &self,
62        mutations: &[(&IndexMutation, CommitLsn)],
63    ) -> Result<Vec<ApplyReceipt>, String> {
64        let _apply_guard = self.apply_lock.lock().await;
65        let mut deletes = Vec::new();
66        let mut items = Vec::new();
67        let mut receipts = Vec::new();
68        let receipts_guard = self.receipts.read().await;
69
70        for (mutation, lsn) in mutations {
71            let idempotency_key = mutation.idempotency_key.as_str().to_string();
72            if receipts_guard.contains(&idempotency_key) {
73                receipts.push(ApplyReceipt {
74                    commit_lsn: *lsn,
75                    idempotency_key,
76                    applied: true,
77                });
78                continue;
79            }
80            let applied_lsn = *self.applied_lsn.read().await;
81            if *lsn < applied_lsn {
82                return Err(format!(
83                    "late fulltext mutation: commit LSN {} is below applied LSN {}",
84                    lsn, applied_lsn
85                ));
86            }
87
88            match mutation.operation {
89                linkrs_core::wal::IndexOperation::Delete => {
90                    let entity_id = match &mutation.entity_ref {
91                        linkrs_core::wal::EntityRef::Vertex(vid) => vid.to_string(),
92                        linkrs_core::wal::EntityRef::Edge { src, dst, .. } => {
93                            format!("{}->{}", src, dst)
94                        }
95                    };
96                    deletes.push(entity_id);
97                }
98                linkrs_core::wal::IndexOperation::Upsert => {
99                    let entity_id = match &mutation.entity_ref {
100                        linkrs_core::wal::EntityRef::Vertex(vid) => vid.to_string(),
101                        linkrs_core::wal::EntityRef::Edge { src, dst, .. } => {
102                            format!("{}->{}", src, dst)
103                        }
104                    };
105                    let document = String::from_utf8(mutation.document_or_vector.clone())
106                        .map_err(|e| format!("invalid utf-8 in fulltext document: {}", e))?;
107                    items.push((entity_id, document));
108                }
109            }
110            receipts.push(ApplyReceipt {
111                commit_lsn: *lsn,
112                idempotency_key,
113                applied: false,
114            });
115        }
116        drop(receipts_guard);
117
118        if !deletes.is_empty() {
119            let ids: Vec<&str> = deletes.iter().map(|s| s.as_str()).collect();
120            self.engine
121                .delete_batch(ids)
122                .await
123                .map_err(|e| format!("fulltext delete batch failed: {}", e))?;
124        }
125
126        if !items.is_empty() {
127            self.engine
128                .index_batch(items)
129                .await
130                .map_err(|e| format!("fulltext index batch failed: {}", e))?;
131        }
132
133        let mut persisted_receipts = self.receipts.read().await.clone();
134        let mut max_lsn = *self.applied_lsn.read().await;
135        for receipt in &mut receipts {
136            if !receipt.applied {
137                persisted_receipts.insert(receipt.idempotency_key.clone());
138                receipt.applied = true;
139            }
140            if receipt.commit_lsn > max_lsn {
141                max_lsn = receipt.commit_lsn;
142            }
143        }
144        let payload = serde_json::to_string(&FulltextCommitState {
145            applied_lsn: max_lsn.get(),
146            receipts: persisted_receipts.clone(),
147        })
148        .map_err(|error| format!("serialize fulltext commit receipt: {error}"))?;
149        self.engine
150            .commit_with_payload(payload)
151            .await
152            .map_err(|e| format!("fulltext commit failed: {}", e))?;
153        *self.receipts.write().await = persisted_receipts;
154        *self.applied_lsn.write().await = max_lsn;
155
156        Ok(receipts)
157    }
158
159    pub async fn check_late_arrival(
160        &self,
161        commit_lsn: CommitLsn,
162        idempotency_key: &str,
163    ) -> LateArrivalResult {
164        let receipts = self.receipts.read().await;
165        if receipts.contains(idempotency_key) {
166            return LateArrivalResult {
167                accepted: false,
168                reason: "duplicate idempotency key".to_string(),
169            };
170        }
171        let applied_lsn = *self.applied_lsn.read().await;
172        if commit_lsn < applied_lsn {
173            return LateArrivalResult {
174                accepted: false,
175                reason: format!(
176                    "late arrival: commit_lsn {} < applied_lsn {}",
177                    commit_lsn, applied_lsn
178                ),
179            };
180        }
181        LateArrivalResult {
182            accepted: true,
183            reason: String::new(),
184        }
185    }
186
187    pub async fn applied_lsn(&self) -> CommitLsn {
188        *self.applied_lsn.read().await
189    }
190
191    pub async fn is_idempotent(&self, key: &str) -> bool {
192        self.receipts.read().await.contains(key)
193    }
194}
195
196#[cfg(feature = "vector")]
197#[derive(Clone, serde::Serialize, serde::Deserialize)]
198struct VectorCommitState {
199    applied_lsn: u64,
200    receipts: std::collections::HashMap<String, u64>,
201}
202
203#[cfg(feature = "vector")]
204const VECTOR_RECEIPT_MAX_ENTRIES: usize = 8192;
205#[cfg(feature = "vector")]
206const VECTOR_RECEIPT_RETENTION_LSN_WINDOW: u64 = 100_000;
207
208#[cfg(feature = "vector")]
209pub struct VectorReceiver {
210    state: Arc<RwLock<VectorCommitState>>,
211    apply_lock: Mutex<()>,
212    recovery_path: Arc<std::path::PathBuf>,
213}
214
215#[cfg(feature = "vector")]
216impl Default for VectorReceiver {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222#[cfg(feature = "vector")]
223impl VectorReceiver {
224    pub fn new() -> Self {
225        Self {
226            state: Arc::new(RwLock::new(VectorCommitState {
227                applied_lsn: CommitLsn::ZERO.get(),
228                receipts: std::collections::HashMap::new(),
229            })),
230            apply_lock: Mutex::new(()),
231            recovery_path: Arc::new(std::path::PathBuf::new()),
232        }
233    }
234
235    pub fn with_recovery_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
236        self.recovery_path = Arc::new(path.into());
237        self
238    }
239
240    pub fn open(path: impl Into<std::path::PathBuf>) -> Self {
241        let path: std::path::PathBuf = path.into();
242        let state_path = Self::state_file_path(&path);
243        let state = std::fs::read(&state_path)
244            .ok()
245            .and_then(|bytes| postcard::from_bytes::<VectorCommitState>(&bytes).ok())
246            .unwrap_or(VectorCommitState {
247                applied_lsn: 0,
248                receipts: std::collections::HashMap::new(),
249            });
250        Self {
251            state: Arc::new(RwLock::new(state)),
252            apply_lock: Mutex::new(()),
253            recovery_path: Arc::new(path),
254        }
255    }
256
257    fn state_file_path(recovery_dir: &std::path::Path) -> std::path::PathBuf {
258        recovery_dir.join("vector_receiver_state.bin")
259    }
260
261    async fn persist_state(&self, state: &VectorCommitState) -> Result<(), String> {
262        let path = Self::state_file_path(&self.recovery_path);
263        if let Some(parent) = path.parent() {
264            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
265        }
266        let temporary = path.with_extension("tmp");
267        let bytes = postcard::to_allocvec(state).map_err(|e| e.to_string())?;
268        std::fs::write(&temporary, &bytes).map_err(|e| e.to_string())?;
269        std::fs::File::open(&temporary)
270            .and_then(|f| f.sync_all())
271            .map_err(|e| e.to_string())?;
272        std::fs::rename(&temporary, &path).map_err(|e| e.to_string())?;
273        if let Some(parent) = path.parent() {
274            std::fs::File::open(parent)
275                .and_then(|f| f.sync_all())
276                .map_err(|e| e.to_string())?;
277        }
278        Ok(())
279    }
280
281    pub async fn check_late_arrival(
282        &self,
283        commit_lsn: CommitLsn,
284        idempotency_key: &str,
285    ) -> LateArrivalResult {
286        let state = self.state.read().await;
287        if state.receipts.contains_key(idempotency_key) {
288            return LateArrivalResult {
289                accepted: false,
290                reason: "duplicate idempotency key".to_string(),
291            };
292        }
293
294        let current = CommitLsn::new(state.applied_lsn);
295        if commit_lsn < current {
296            // Below the water-level: considered late. Even if the key was
297            // pruned from the LRU window, the SQLite idempotency table still
298            // protects against re-application (see SqliteOutbox::materialize).
299            return LateArrivalResult {
300                accepted: false,
301                reason: format!(
302                    "late arrival: commit_lsn {} < applied_lsn {}",
303                    commit_lsn, current
304                ),
305            };
306        }
307
308        LateArrivalResult {
309            accepted: true,
310            reason: String::new(),
311        }
312    }
313
314    /// Persist an applied mutation receipt before exposing it to later deliveries.
315    ///
316    /// The caller must invoke this only after the vector mutation itself has
317    /// completed.  A persistence failure is returned so delivery remains
318    /// retryable instead of silently losing the idempotency receipt on restart.
319    pub async fn record_application(
320        &self,
321        commit_lsn: CommitLsn,
322        idempotency_key: &str,
323    ) -> Result<(), String> {
324        let _apply_guard = self.apply_lock.lock().await;
325        let mut next = self.state.read().await.clone();
326        next.receipts
327            .insert(idempotency_key.to_string(), commit_lsn.get());
328        next.applied_lsn = next.applied_lsn.max(commit_lsn.get());
329        // LRU + water-level pruning: keep receipt set bounded. Entries whose
330        // LSN is far below the current water-level are dropped; the SQLite
331        // outbox `idempotency` table still guards against replay after a
332        // restart for those older LSNs.
333        if next.receipts.len() > VECTOR_RECEIPT_MAX_ENTRIES {
334            let water_level = next
335                .applied_lsn
336                .saturating_sub(VECTOR_RECEIPT_RETENTION_LSN_WINDOW);
337            next.receipts.retain(|_, lsn| *lsn >= water_level);
338            // If still over capacity (e.g. bursty LSNs within window), drop
339            // the oldest entries by LSN until under the limit.
340            if next.receipts.len() > VECTOR_RECEIPT_MAX_ENTRIES {
341                let mut entries: Vec<(String, u64)> =
342                    next.receipts.iter().map(|(k, v)| (k.clone(), *v)).collect();
343                entries.sort_by_key(|(_, lsn)| *lsn);
344                let to_remove = entries.len() - VECTOR_RECEIPT_MAX_ENTRIES;
345                for (key, _) in entries.into_iter().take(to_remove) {
346                    next.receipts.remove(&key);
347                }
348            }
349        }
350        if !self.recovery_path.as_os_str().is_empty() {
351            self.persist_state(&next).await?;
352        }
353        *self.state.write().await = next;
354        Ok(())
355    }
356
357    pub async fn applied_lsn(&self) -> CommitLsn {
358        CommitLsn::new(self.state.read().await.applied_lsn)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    #[cfg(feature = "vector")]
365    use crate::VectorReceiver;
366    #[cfg(feature = "vector")]
367    use linkrs_core::types::CommitLsn;
368    #[tokio::test]
369    #[cfg(feature = "vector")]
370    async fn vector_receiver_rejects_late_arrival() {
371        let receiver = VectorReceiver::new();
372        receiver
373            .record_application(CommitLsn::new(100), "key-1")
374            .await
375            .expect("record receipt");
376
377        let result = receiver
378            .check_late_arrival(CommitLsn::new(50), "key-2")
379            .await;
380        assert!(!result.accepted);
381        assert!(result.reason.contains("late arrival"));
382
383        let result = receiver
384            .check_late_arrival(CommitLsn::new(100), "key-3")
385            .await;
386        assert!(result.accepted);
387        receiver
388            .record_application(CommitLsn::new(100), "key-3")
389            .await
390            .expect("record receipt");
391
392        let result = receiver
393            .check_late_arrival(CommitLsn::new(200), "key-3")
394            .await;
395        assert!(!result.accepted);
396        assert!(result.reason.contains("duplicate"));
397    }
398
399    #[tokio::test]
400    #[cfg(feature = "vector")]
401    async fn vector_receiver_restores_persisted_receipts() {
402        let directory = tempfile::tempdir().expect("create temporary recovery directory");
403        let receiver = VectorReceiver::open(directory.path());
404        receiver
405            .record_application(CommitLsn::new(100), "key-1")
406            .await
407            .expect("persist receipt");
408
409        let recovered = VectorReceiver::open(directory.path());
410        assert_eq!(recovered.applied_lsn().await, CommitLsn::new(100));
411        let duplicate = recovered
412            .check_late_arrival(CommitLsn::new(100), "key-1")
413            .await;
414        assert!(!duplicate.accepted);
415    }
416}