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#[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 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 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 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 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}