Skip to main content

linkrs_sync/
dead_letter_queue.rs

1//! Dead Letter Queue for failed index operations
2//!
3//! Stores operations that failed after all retry attempts for later analysis and recovery.
4
5use std::time::{Duration, SystemTime};
6
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10use crate::types::IndexOperation;
11
12/// Dead letter queue entry
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DeadLetterEntry {
15    /// The failed operation
16    pub operation: IndexOperation,
17    /// Error message
18    pub error: String,
19    /// Number of retry attempts
20    pub retry_attempts: u32,
21    /// First failure timestamp
22    pub first_failure: SystemTime,
23    /// Last failure timestamp
24    pub last_failure: SystemTime,
25    /// Whether this entry has been processed for recovery
26    pub recovered: bool,
27}
28
29impl DeadLetterEntry {
30    pub fn new(operation: IndexOperation, error: String, retry_attempts: u32) -> Self {
31        let now = SystemTime::now();
32        Self {
33            operation,
34            error,
35            retry_attempts,
36            first_failure: now,
37            last_failure: now,
38            recovered: false,
39        }
40    }
41
42    pub fn update_failure(&mut self, error: String) {
43        self.error = error;
44        self.last_failure = SystemTime::now();
45    }
46
47    pub fn age(&self) -> Duration {
48        self.first_failure
49            .elapsed()
50            .unwrap_or(Duration::from_secs(0))
51    }
52
53    pub fn mark_recovered(&mut self) {
54        self.recovered = true;
55    }
56}
57
58/// Dead letter queue configuration
59#[derive(Debug, Clone)]
60pub struct DeadLetterQueueConfig {
61    /// Maximum number of entries in the queue
62    pub max_size: usize,
63    /// Maximum age of entries before automatic cleanup
64    pub max_age: Duration,
65    /// Whether to enable automatic cleanup
66    pub auto_cleanup_enabled: bool,
67}
68
69impl DeadLetterQueueConfig {
70    pub fn is_auto_cleanup_enabled(&self) -> bool {
71        self.auto_cleanup_enabled
72    }
73
74    pub fn get_cleanup_interval(&self) -> Duration {
75        self.max_age / 2
76    }
77}
78
79impl Default for DeadLetterQueueConfig {
80    fn default() -> Self {
81        Self {
82            max_size: 10_000,
83            max_age: Duration::from_secs(3600), // 1 hour
84            auto_cleanup_enabled: true,
85        }
86    }
87}
88
89/// Dead Letter Queue
90#[derive(Debug)]
91pub struct DeadLetterQueue {
92    entries: Mutex<Vec<DeadLetterEntry>>,
93    config: DeadLetterQueueConfig,
94}
95
96impl DeadLetterQueue {
97    pub fn new(config: DeadLetterQueueConfig) -> Self {
98        Self {
99            entries: Mutex::new(Vec::with_capacity(config.max_size)),
100            config,
101        }
102    }
103
104    pub fn is_auto_cleanup_enabled(&self) -> bool {
105        self.config.auto_cleanup_enabled
106    }
107
108    pub fn get_cleanup_interval(&self) -> Duration {
109        self.config.max_age / 2
110    }
111
112    /// Add an entry to the dead letter queue
113    pub fn add(&self, entry: DeadLetterEntry) {
114        let mut entries = self.entries.lock();
115
116        // Check if queue is full
117        if entries.len() >= self.config.max_size {
118            // Remove oldest entry if full
119            if !entries.is_empty() {
120                entries.remove(0);
121                log::warn!("Dead letter queue is full, removed oldest entry");
122            }
123        }
124
125        entries.push(entry);
126
127        log::warn!("Added entry to dead letter queue (size: {})", entries.len());
128    }
129
130    /// Get all entries
131    pub fn get_all(&self) -> Vec<DeadLetterEntry> {
132        self.entries.lock().clone()
133    }
134
135    /// Get entries that haven't been recovered
136    pub fn get_unrecovered(&self) -> Vec<DeadLetterEntry> {
137        self.entries
138            .lock()
139            .iter()
140            .filter(|e| !e.recovered)
141            .cloned()
142            .collect()
143    }
144
145    /// Get entries older than specified duration
146    pub fn get_old_entries(&self, age: Duration) -> Vec<DeadLetterEntry> {
147        self.entries
148            .lock()
149            .iter()
150            .filter(|e| e.age() > age)
151            .cloned()
152            .collect()
153    }
154
155    /// Remove an entry by index
156    pub fn remove(&self, index: usize) -> Option<DeadLetterEntry> {
157        let mut entries = self.entries.lock();
158        if index < entries.len() {
159            let entry = entries.remove(index);
160            Some(entry)
161        } else {
162            None
163        }
164    }
165
166    /// Mark an entry as recovered
167    pub fn mark_recovered(&self, index: usize) -> bool {
168        let mut entries = self.entries.lock();
169        if index < entries.len() {
170            entries[index].mark_recovered();
171            true
172        } else {
173            false
174        }
175    }
176
177    /// Cleanup old entries
178    pub fn cleanup(&self) -> usize {
179        let mut entries = self.entries.lock();
180        let initial_len = entries.len();
181
182        entries.retain(|e| e.age() <= self.config.max_age);
183
184        let removed = initial_len - entries.len();
185
186        if removed > 0 {
187            log::info!("Cleaned up {} old dead letter entries", removed);
188        }
189
190        removed
191    }
192
193    /// Get queue size
194    pub fn len(&self) -> usize {
195        self.entries.lock().len()
196    }
197
198    /// Check if queue is empty
199    pub fn is_empty(&self) -> bool {
200        self.entries.lock().is_empty()
201    }
202
203    /// Clear all entries
204    pub fn clear(&self) {
205        let mut entries = self.entries.lock();
206        let count = entries.len();
207        entries.clear();
208
209        log::info!("Cleared {} entries from dead letter queue", count);
210    }
211
212    /// Get statistics about the dead letter queue
213    pub fn get_stats(&self) -> DeadLetterQueueStats {
214        let entries = self.entries.lock();
215        let total = entries.len();
216        let unrecovered = entries.iter().filter(|e| !e.recovered).count();
217        let recovered = total - unrecovered;
218
219        DeadLetterQueueStats {
220            total_entries: total,
221            unrecovered_entries: unrecovered,
222            recovered_entries: recovered,
223            oldest_entry_age: entries
224                .iter()
225                .map(|e| e.age())
226                .max()
227                .unwrap_or(Duration::from_secs(0)),
228        }
229    }
230}
231
232/// Statistics for dead letter queue
233#[derive(Debug, Clone)]
234pub struct DeadLetterQueueStats {
235    pub total_entries: usize,
236    pub unrecovered_entries: usize,
237    pub recovered_entries: usize,
238    pub oldest_entry_age: Duration,
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::types::{ChangeType, IndexOpKey, IndexOperation};
245
246    fn create_test_index_key() -> IndexOpKey {
247        IndexOpKey {
248            space_id: 1,
249            tag_name: "Article".to_string(),
250            field_name: "content".to_string(),
251        }
252    }
253
254    fn create_test_operation() -> IndexOperation {
255        IndexOperation::new_fulltext(
256            IndexOpKey::new(1, "test_tag", "test_field"),
257            ChangeType::Insert,
258            "test_id",
259            Some("test".to_string()),
260        )
261    }
262
263    fn create_test_insert_operation(id: &str, text: &str) -> IndexOperation {
264        IndexOperation::new_fulltext(
265            create_test_index_key(),
266            ChangeType::Insert,
267            id,
268            Some(text.to_string()),
269        )
270    }
271
272    fn create_test_delete_operation(id: &str) -> IndexOperation {
273        IndexOperation::new_fulltext(create_test_index_key(), ChangeType::Delete, id, None)
274    }
275
276    fn create_test_entry(error: &str, retry_attempts: u32) -> DeadLetterEntry {
277        DeadLetterEntry::new(
278            create_test_insert_operation("test_doc", "test content"),
279            error.to_string(),
280            retry_attempts,
281        )
282    }
283
284    #[test]
285    fn test_dead_letter_queue_creation() {
286        let config = DeadLetterQueueConfig::default();
287        let dlq = DeadLetterQueue::new(config);
288
289        assert!(dlq.is_empty(), "New DLQ should be empty");
290        assert_eq!(dlq.len(), 0, "New DLQ should have 0 entries");
291    }
292
293    #[test]
294    fn test_dead_letter_queue_add() {
295        let config = DeadLetterQueueConfig::default();
296        let dlq = DeadLetterQueue::new(config);
297
298        let entry = DeadLetterEntry::new(create_test_operation(), "test error".to_string(), 3);
299
300        dlq.add(entry);
301        assert_eq!(dlq.len(), 1);
302    }
303
304    #[test]
305    fn test_add_entry_to_dlq() {
306        let config = DeadLetterQueueConfig::default();
307        let dlq = DeadLetterQueue::new(config);
308
309        let entry = create_test_entry("Connection failed", 3);
310        dlq.add(entry);
311
312        assert!(
313            !dlq.is_empty(),
314            "DLQ should not be empty after adding entry"
315        );
316        assert_eq!(dlq.len(), 1, "DLQ should have 1 entry");
317    }
318
319    #[test]
320    fn test_multiple_entries_in_dlq() {
321        let config = DeadLetterQueueConfig::default();
322        let dlq = DeadLetterQueue::new(config);
323
324        for i in 0..5 {
325            let entry = DeadLetterEntry::new(
326                create_test_insert_operation(&format!("doc_{}", i), &format!("content {}", i)),
327                format!("Error {}", i),
328                3,
329            );
330            dlq.add(entry);
331        }
332
333        assert_eq!(dlq.len(), 5, "DLQ should have 5 entries");
334
335        let all_entries = dlq.get_all();
336        assert_eq!(all_entries.len(), 5, "Should retrieve all 5 entries");
337    }
338
339    #[test]
340    fn test_dead_letter_queue_max_size() {
341        let config = DeadLetterQueueConfig {
342            max_size: 3,
343            ..DeadLetterQueueConfig::default()
344        };
345        let dlq = DeadLetterQueue::new(config);
346
347        for i in 0..5 {
348            let entry = DeadLetterEntry::new(create_test_operation(), format!("error {}", i), 3);
349            dlq.add(entry);
350        }
351
352        assert_eq!(dlq.len(), 3);
353    }
354
355    #[test]
356    fn test_dlq_max_size_limit() {
357        let config = DeadLetterQueueConfig {
358            max_size: 3,
359            ..DeadLetterQueueConfig::default()
360        };
361        let dlq = DeadLetterQueue::new(config);
362
363        for i in 0..10 {
364            let entry = create_test_entry(&format!("Error {}", i), 3);
365            dlq.add(entry);
366        }
367
368        assert_eq!(dlq.len(), 3, "DLQ should respect max_size limit");
369    }
370
371    #[test]
372    fn test_get_unrecovered_entries() {
373        let config = DeadLetterQueueConfig::default();
374        let dlq = DeadLetterQueue::new(config);
375
376        for i in 0..3 {
377            let entry = create_test_entry(&format!("Error {}", i), 3);
378            dlq.add(entry);
379        }
380
381        let unrecovered = dlq.get_unrecovered();
382        assert_eq!(
383            unrecovered.len(),
384            3,
385            "All entries should be unrecovered initially"
386        );
387
388        dlq.mark_recovered(1);
389
390        let unrecovered_after = dlq.get_unrecovered();
391        assert_eq!(
392            unrecovered_after.len(),
393            2,
394            "Should have 2 unrecovered after marking one as recovered"
395        );
396    }
397
398    #[test]
399    fn test_mark_entry_recovered() {
400        let config = DeadLetterQueueConfig::default();
401        let dlq = DeadLetterQueue::new(config);
402
403        let entry = create_test_entry("Test error", 3);
404        dlq.add(entry);
405
406        let result = dlq.mark_recovered(0);
407        assert!(result, "mark_recovered should return true for valid index");
408
409        let all_entries = dlq.get_all();
410        assert!(
411            all_entries[0].recovered,
412            "Entry should be marked as recovered"
413        );
414    }
415
416    #[test]
417    fn test_dead_letter_queue_recovery() {
418        let config = DeadLetterQueueConfig::default();
419        let dlq = DeadLetterQueue::new(config);
420
421        let entry = DeadLetterEntry::new(create_test_operation(), "test error".to_string(), 3);
422        dlq.add(entry);
423
424        let unrecovered = dlq.get_unrecovered();
425        assert_eq!(unrecovered.len(), 1);
426        assert!(!unrecovered[0].recovered);
427
428        dlq.mark_recovered(0);
429
430        let unrecovered = dlq.get_unrecovered();
431        assert_eq!(unrecovered.len(), 0);
432    }
433
434    #[test]
435    fn test_remove_entry_from_dlq() {
436        let config = DeadLetterQueueConfig::default();
437        let dlq = DeadLetterQueue::new(config);
438
439        for i in 0..3 {
440            let entry = create_test_entry(&format!("Error {}", i), 3);
441            dlq.add(entry);
442        }
443
444        let removed = dlq.remove(1);
445        assert!(removed.is_some(), "remove should return the removed entry");
446        assert_eq!(dlq.len(), 2, "DLQ should have 2 entries after removal");
447
448        let invalid_remove = dlq.remove(10);
449        assert!(
450            invalid_remove.is_none(),
451            "remove should return None for invalid index"
452        );
453    }
454
455    #[test]
456    fn test_dead_letter_queue_cleanup() {
457        let config = DeadLetterQueueConfig {
458            max_age: Duration::from_millis(100),
459            ..DeadLetterQueueConfig::default()
460        };
461        let dlq = DeadLetterQueue::new(config);
462
463        let entry = DeadLetterEntry::new(create_test_operation(), "test error".to_string(), 3);
464        dlq.add(entry);
465
466        // Wait for entry to become old
467        std::thread::sleep(Duration::from_millis(150));
468
469        let removed = dlq.cleanup();
470        assert_eq!(removed, 1);
471        assert_eq!(dlq.len(), 0);
472    }
473
474    #[test]
475    fn test_dlq_cleanup() {
476        let config = DeadLetterQueueConfig {
477            max_age: Duration::from_millis(50),
478            ..DeadLetterQueueConfig::default()
479        };
480        let dlq = DeadLetterQueue::new(config);
481
482        let entry = create_test_entry("Test error", 3);
483        dlq.add(entry);
484
485        assert_eq!(dlq.len(), 1, "Should have 1 entry before cleanup");
486
487        std::thread::sleep(Duration::from_millis(100));
488
489        let removed = dlq.cleanup();
490        assert_eq!(removed, 1, "Should remove 1 old entry");
491        assert_eq!(dlq.len(), 0, "Should have 0 entries after cleanup");
492    }
493
494    #[test]
495    fn test_dlq_statistics() {
496        let config = DeadLetterQueueConfig::default();
497        let dlq = DeadLetterQueue::new(config);
498
499        for i in 0..5 {
500            let entry = create_test_entry(&format!("Error {}", i), 3);
501            dlq.add(entry);
502        }
503
504        dlq.mark_recovered(0);
505        dlq.mark_recovered(2);
506
507        let stats: DeadLetterQueueStats = dlq.get_stats();
508
509        assert_eq!(stats.total_entries, 5, "Should have 5 total entries");
510        assert_eq!(
511            stats.recovered_entries, 2,
512            "Should have 2 recovered entries"
513        );
514        assert_eq!(
515            stats.unrecovered_entries, 3,
516            "Should have 3 unrecovered entries"
517        );
518    }
519
520    #[test]
521    fn test_clear_dlq() {
522        let config = DeadLetterQueueConfig::default();
523        let dlq = DeadLetterQueue::new(config);
524
525        for i in 0..5 {
526            let entry = create_test_entry(&format!("Error {}", i), 3);
527            dlq.add(entry);
528        }
529
530        assert_eq!(dlq.len(), 5, "Should have 5 entries before clear");
531
532        dlq.clear();
533
534        assert!(dlq.is_empty(), "DLQ should be empty after clear");
535        assert_eq!(dlq.len(), 0, "DLQ should have 0 entries after clear");
536    }
537
538    #[test]
539    fn test_entry_age_calculation() {
540        let entry = create_test_entry("Test error", 3);
541
542        std::thread::sleep(Duration::from_millis(100));
543
544        let age = entry.age();
545        assert!(
546            age >= Duration::from_millis(100),
547            "Entry age should be at least 100ms"
548        );
549    }
550
551    #[test]
552    fn test_entry_update_failure() {
553        let mut entry = create_test_entry("Original error", 3);
554
555        entry.update_failure("Updated error message".to_string());
556
557        assert_eq!(
558            entry.error, "Updated error message",
559            "Error message should be updated"
560        );
561        assert!(
562            entry.last_failure > entry.first_failure,
563            "Last failure time should be updated"
564        );
565    }
566
567    #[test]
568    fn test_get_old_entries() {
569        let config = DeadLetterQueueConfig::default();
570        let dlq = DeadLetterQueue::new(config);
571
572        for i in 0..3 {
573            let entry = create_test_entry(&format!("Error {}", i), 3);
574            dlq.add(entry);
575        }
576
577        std::thread::sleep(Duration::from_millis(100));
578
579        let old_entries = dlq.get_old_entries(Duration::from_millis(50));
580        assert_eq!(old_entries.len(), 3, "All entries should be old enough");
581    }
582
583    #[test]
584    fn test_different_operation_types() {
585        let config = DeadLetterQueueConfig::default();
586        let dlq = DeadLetterQueue::new(config);
587
588        let insert_entry = DeadLetterEntry::new(
589            create_test_insert_operation("doc_1", "insert content"),
590            "Insert failed".to_string(),
591            3,
592        );
593        dlq.add(insert_entry);
594
595        let delete_entry = DeadLetterEntry::new(
596            create_test_delete_operation("doc_2"),
597            "Delete failed".to_string(),
598            2,
599        );
600        dlq.add(delete_entry);
601
602        assert_eq!(
603            dlq.len(),
604            2,
605            "Should have 2 entries with different operation types"
606        );
607
608        let all_entries = dlq.get_all();
609        assert!(
610            matches!(all_entries[0].operation.change_type, ChangeType::Insert),
611            "First entry should be Insert operation"
612        );
613        assert!(
614            matches!(all_entries[1].operation.change_type, ChangeType::Delete),
615            "Second entry should be Delete operation"
616        );
617    }
618}