1use std::time::{Duration, SystemTime};
6
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10use crate::types::IndexOperation;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DeadLetterEntry {
15 pub operation: IndexOperation,
17 pub error: String,
19 pub retry_attempts: u32,
21 pub first_failure: SystemTime,
23 pub last_failure: SystemTime,
25 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#[derive(Debug, Clone)]
60pub struct DeadLetterQueueConfig {
61 pub max_size: usize,
63 pub max_age: Duration,
65 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), auto_cleanup_enabled: true,
85 }
86 }
87}
88
89#[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 pub fn add(&self, entry: DeadLetterEntry) {
114 let mut entries = self.entries.lock();
115
116 if entries.len() >= self.config.max_size {
118 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 pub fn get_all(&self) -> Vec<DeadLetterEntry> {
132 self.entries.lock().clone()
133 }
134
135 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 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 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 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 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 pub fn len(&self) -> usize {
195 self.entries.lock().len()
196 }
197
198 pub fn is_empty(&self) -> bool {
200 self.entries.lock().is_empty()
201 }
202
203 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 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#[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 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}