Skip to main content

allsource_core/infrastructure/persistence/
wal.rs

1use crate::{
2    domain::entities::Event,
3    error::{AllSourceError, Result},
4};
5use chrono::{DateTime, Utc};
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use std::{
9    fs::{self, File, OpenOptions},
10    io::{BufRead, BufReader, BufWriter, Write},
11    path::{Path, PathBuf},
12    sync::Arc,
13};
14
15/// Write-Ahead Log for durability and crash recovery
16pub struct WriteAheadLog {
17    /// Directory where WAL files are stored
18    wal_dir: PathBuf,
19
20    /// Current active WAL file
21    current_file: Arc<RwLock<WALFile>>,
22
23    /// Configuration
24    config: WALConfig,
25
26    /// Statistics
27    stats: Arc<RwLock<WALStats>>,
28
29    /// Current sequence number
30    sequence: Arc<RwLock<u64>>,
31
32    /// Optional broadcast channel for replication โ€” when set, each WAL append
33    /// publishes the entry so the WAL shipper can stream it to followers.
34    /// Wrapped in Mutex so it can be set at runtime (e.g. during follower โ†’ leader promotion).
35    replication_tx: parking_lot::Mutex<Option<tokio::sync::broadcast::Sender<WALEntry>>>,
36}
37
38#[derive(Debug, Clone)]
39pub struct WALConfig {
40    /// Maximum size of a single WAL file before rotation (in bytes)
41    pub max_file_size: usize,
42
43    /// Whether to sync to disk after each write (fsync)
44    pub sync_on_write: bool,
45
46    /// Maximum number of WAL files to keep
47    pub max_wal_files: usize,
48
49    /// Enable WAL compression
50    pub compress: bool,
51
52    /// Interval in milliseconds for background coalesced fsync.
53    /// When set, a background task calls `flush() + sync_all()` every N ms,
54    /// giving near-zero write latency with bounded data loss window.
55    /// When `Some`, `sync_on_write` is forced to `false` to prevent double-fsync.
56    pub fsync_interval_ms: Option<u64>,
57}
58
59impl Default for WALConfig {
60    fn default() -> Self {
61        Self {
62            max_file_size: 64 * 1024 * 1024, // 64 MB
63            sync_on_write: true,
64            max_wal_files: 10,
65            compress: false,
66            fsync_interval_ms: None,
67        }
68    }
69}
70
71#[derive(Debug, Clone, Default, Serialize)]
72pub struct WALStats {
73    pub total_entries: u64,
74    pub total_bytes_written: u64,
75    pub current_file_size: usize,
76    pub files_rotated: u64,
77    pub files_cleaned: u64,
78    pub recovery_count: u64,
79}
80
81/// WAL entry wrapping an event
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WALEntry {
84    /// Sequence number for ordering
85    pub sequence: u64,
86
87    /// Timestamp when written to WAL
88    pub wal_timestamp: DateTime<Utc>,
89
90    /// The event being logged
91    pub event: Event,
92
93    /// Checksum for integrity verification
94    pub checksum: u32,
95}
96
97impl WALEntry {
98    pub fn new(sequence: u64, event: Event) -> Self {
99        let mut entry = Self {
100            sequence,
101            wal_timestamp: Utc::now(),
102            event,
103            checksum: 0,
104        };
105        entry.checksum = entry.calculate_checksum();
106        entry
107    }
108
109    fn calculate_checksum(&self) -> u32 {
110        // Simple CRC32 checksum
111        let data = format!("{}{}{}", self.sequence, self.wal_timestamp, self.event.id);
112        crc32fast::hash(data.as_bytes())
113    }
114
115    pub fn verify(&self) -> bool {
116        self.checksum == self.calculate_checksum()
117    }
118}
119
120/// Represents an active WAL file
121struct WALFile {
122    path: PathBuf,
123    writer: BufWriter<File>,
124    size: usize,
125    created_at: DateTime<Utc>,
126}
127
128impl WALFile {
129    fn new(path: PathBuf) -> Result<Self> {
130        let file = OpenOptions::new()
131            .create(true)
132            .append(true)
133            .open(&path)
134            .map_err(|e| AllSourceError::StorageError(format!("Failed to open WAL file: {e}")))?;
135
136        let size = file.metadata().map(|m| m.len() as usize).unwrap_or(0);
137
138        Ok(Self {
139            path,
140            writer: BufWriter::new(file),
141            size,
142            created_at: Utc::now(),
143        })
144    }
145
146    fn write_entry(&mut self, entry: &WALEntry, sync: bool) -> Result<usize> {
147        // Serialize entry as JSON line
148        let json = serde_json::to_string(entry)?;
149
150        let line = format!("{}\n", json);
151        let bytes_written = line.len();
152
153        self.writer
154            .write_all(line.as_bytes())
155            .map_err(|e| AllSourceError::StorageError(format!("Failed to write to WAL: {e}")))?;
156
157        if sync {
158            self.writer
159                .flush()
160                .map_err(|e| AllSourceError::StorageError(format!("Failed to flush WAL: {e}")))?;
161
162            self.writer
163                .get_ref()
164                .sync_all()
165                .map_err(|e| AllSourceError::StorageError(format!("Failed to sync WAL: {e}")))?;
166        }
167
168        self.size += bytes_written;
169        Ok(bytes_written)
170    }
171
172    fn flush(&mut self) -> Result<()> {
173        self.writer
174            .flush()
175            .map_err(|e| AllSourceError::StorageError(format!("Failed to flush WAL: {}", e)))?;
176        Ok(())
177    }
178}
179
180impl WriteAheadLog {
181    /// Create a new WAL
182    pub fn new(wal_dir: impl Into<PathBuf>, config: WALConfig) -> Result<Self> {
183        let wal_dir = wal_dir.into();
184
185        // Create WAL directory if it doesn't exist
186        fs::create_dir_all(&wal_dir).map_err(|e| {
187            AllSourceError::StorageError(format!("Failed to create WAL directory: {e}"))
188        })?;
189
190        // Create initial WAL file
191        let initial_file_path = Self::generate_wal_filename(&wal_dir, 0);
192        let current_file = WALFile::new(initial_file_path)?;
193
194        tracing::info!("โœ… WAL initialized at: {}", wal_dir.display());
195
196        Ok(Self {
197            wal_dir,
198            current_file: Arc::new(RwLock::new(current_file)),
199            config,
200            stats: Arc::new(RwLock::new(WALStats::default())),
201            sequence: Arc::new(RwLock::new(0)),
202            replication_tx: parking_lot::Mutex::new(None),
203        })
204    }
205
206    /// Generate a WAL filename based on sequence
207    fn generate_wal_filename(dir: &Path, sequence: u64) -> PathBuf {
208        dir.join(format!("wal-{:016x}.log", sequence))
209    }
210
211    /// Write an event to the WAL
212    pub fn append(&self, event: Event) -> Result<u64> {
213        // Get next sequence number
214        let mut seq = self.sequence.write();
215        *seq += 1;
216        let sequence = *seq;
217        drop(seq);
218
219        // Create WAL entry
220        let entry = WALEntry::new(sequence, event);
221
222        // Write to current file
223        let mut current = self.current_file.write();
224        let bytes_written = current.write_entry(&entry, self.config.sync_on_write)?;
225
226        // Update statistics
227        let mut stats = self.stats.write();
228        stats.total_entries += 1;
229        stats.total_bytes_written += bytes_written as u64;
230        stats.current_file_size = current.size;
231        drop(stats);
232
233        // Broadcast to replication channel (if enabled).
234        // Errors are ignored: no receivers simply means no followers are connected.
235        if let Some(ref tx) = *self.replication_tx.lock() {
236            let _ = tx.send(entry);
237        }
238
239        // Check if we need to rotate
240        let should_rotate = current.size >= self.config.max_file_size;
241        drop(current);
242
243        if should_rotate {
244            self.rotate()?;
245        }
246
247        tracing::trace!("WAL entry written: sequence={}", sequence);
248
249        Ok(sequence)
250    }
251
252    /// Rotate to a new WAL file
253    fn rotate(&self) -> Result<()> {
254        let seq = *self.sequence.read();
255        let new_file_path = Self::generate_wal_filename(&self.wal_dir, seq);
256
257        tracing::info!("๐Ÿ”„ Rotating WAL to new file: {:?}", new_file_path);
258
259        let new_file = WALFile::new(new_file_path)?;
260
261        let mut current = self.current_file.write();
262        current.flush()?;
263        *current = new_file;
264
265        let mut stats = self.stats.write();
266        stats.files_rotated += 1;
267        stats.current_file_size = 0;
268        drop(stats);
269
270        // Clean up old WAL files
271        self.cleanup_old_files()?;
272
273        Ok(())
274    }
275
276    /// Clean up old WAL files beyond the retention limit
277    fn cleanup_old_files(&self) -> Result<()> {
278        let mut wal_files = self.list_wal_files()?;
279        wal_files.sort();
280
281        if wal_files.len() > self.config.max_wal_files {
282            let to_remove = wal_files.len() - self.config.max_wal_files;
283            let files_to_delete = &wal_files[..to_remove];
284
285            for file_path in files_to_delete {
286                if let Err(e) = fs::remove_file(file_path) {
287                    tracing::warn!("Failed to remove old WAL file {:?}: {}", file_path, e);
288                } else {
289                    tracing::debug!("๐Ÿ—‘๏ธ Removed old WAL file: {:?}", file_path);
290                    let mut stats = self.stats.write();
291                    stats.files_cleaned += 1;
292                }
293            }
294        }
295
296        Ok(())
297    }
298
299    /// List all WAL files in the directory
300    fn list_wal_files(&self) -> Result<Vec<PathBuf>> {
301        let entries = fs::read_dir(&self.wal_dir).map_err(|e| {
302            AllSourceError::StorageError(format!("Failed to read WAL directory: {e}"))
303        })?;
304
305        let mut wal_files = Vec::new();
306        for entry in entries {
307            let entry = entry.map_err(|e| {
308                AllSourceError::StorageError(format!("Failed to read directory entry: {e}"))
309            })?;
310
311            let path = entry.path();
312            if let Some(name) = path.file_name()
313                && name.to_string_lossy().starts_with("wal-")
314                && name.to_string_lossy().ends_with(".log")
315            {
316                wal_files.push(path);
317            }
318        }
319
320        Ok(wal_files)
321    }
322
323    /// Recover events from WAL files
324    pub fn recover(&self) -> Result<Vec<Event>> {
325        tracing::info!("๐Ÿ”„ Starting WAL recovery...");
326
327        let mut wal_files = self.list_wal_files()?;
328        wal_files.sort();
329
330        let mut recovered_events = Vec::new();
331        let mut max_sequence = 0u64;
332        let mut corrupted_entries = 0;
333
334        for wal_file_path in &wal_files {
335            tracing::debug!("Reading WAL file: {:?}", wal_file_path);
336
337            let file = File::open(wal_file_path).map_err(|e| {
338                AllSourceError::StorageError(format!("Failed to open WAL file for recovery: {e}"))
339            })?;
340
341            let reader = BufReader::new(file);
342
343            for (line_num, line) in reader.lines().enumerate() {
344                let line = line.map_err(|e| {
345                    AllSourceError::StorageError(format!("Failed to read WAL line: {e}"))
346                })?;
347
348                if line.trim().is_empty() {
349                    continue;
350                }
351
352                match serde_json::from_str::<WALEntry>(&line) {
353                    Ok(entry) => {
354                        // Verify checksum
355                        if !entry.verify() {
356                            tracing::warn!(
357                                "Corrupted WAL entry at {:?}:{} (checksum mismatch)",
358                                wal_file_path,
359                                line_num + 1
360                            );
361                            corrupted_entries += 1;
362                            continue;
363                        }
364
365                        max_sequence = max_sequence.max(entry.sequence);
366                        recovered_events.push(entry.event);
367                    }
368                    Err(e) => {
369                        tracing::warn!(
370                            "Failed to parse WAL entry at {:?}:{}: {}",
371                            wal_file_path,
372                            line_num + 1,
373                            e
374                        );
375                        corrupted_entries += 1;
376                    }
377                }
378            }
379        }
380
381        // Update sequence counter
382        let mut seq = self.sequence.write();
383        *seq = max_sequence;
384        drop(seq);
385
386        // Update stats
387        let mut stats = self.stats.write();
388        stats.recovery_count += 1;
389        drop(stats);
390
391        tracing::info!(
392            "โœ… WAL recovery complete: {} events recovered, {} corrupted entries",
393            recovered_events.len(),
394            corrupted_entries
395        );
396
397        Ok(recovered_events)
398    }
399
400    /// Manually flush the current WAL file
401    pub fn flush(&self) -> Result<()> {
402        let mut current = self.current_file.write();
403        current.flush()?;
404        Ok(())
405    }
406
407    /// Flush the BufWriter and fsync the current WAL file to disk.
408    ///
409    /// Called by the background interval-based fsync task. Acquires the write
410    /// lock, flushes buffered data, then issues `sync_all()` to ensure the
411    /// OS has persisted the data to durable storage.
412    pub fn sync(&self) -> Result<()> {
413        let mut current = self.current_file.write();
414        current
415            .writer
416            .flush()
417            .map_err(|e| AllSourceError::StorageError(format!("Failed to flush WAL: {e}")))?;
418        current
419            .writer
420            .get_ref()
421            .sync_all()
422            .map_err(|e| AllSourceError::StorageError(format!("Failed to sync WAL: {e}")))?;
423        Ok(())
424    }
425
426    /// Get the configured fsync interval (if any).
427    pub fn fsync_interval_ms(&self) -> Option<u64> {
428        self.config.fsync_interval_ms
429    }
430
431    /// Truncate WAL after successful checkpoint
432    pub fn truncate(&self) -> Result<()> {
433        tracing::info!("๐Ÿงน Truncating WAL after checkpoint");
434
435        // Close current file
436        let mut current = self.current_file.write();
437        current.flush()?;
438
439        // Remove all WAL files
440        let wal_files = self.list_wal_files()?;
441        for file_path in wal_files {
442            fs::remove_file(&file_path).map_err(|e| {
443                AllSourceError::StorageError(format!("Failed to remove WAL file: {e}"))
444            })?;
445            tracing::debug!("Removed WAL file: {:?}", file_path);
446        }
447
448        // Create new WAL file
449        let new_file_path = Self::generate_wal_filename(&self.wal_dir, 0);
450        *current = WALFile::new(new_file_path)?;
451
452        // Reset sequence
453        let mut seq = self.sequence.write();
454        *seq = 0;
455
456        tracing::info!("โœ… WAL truncated successfully");
457
458        Ok(())
459    }
460
461    /// Get WAL statistics
462    pub fn stats(&self) -> WALStats {
463        (*self.stats.read()).clone()
464    }
465
466    /// Get current sequence number
467    pub fn current_sequence(&self) -> u64 {
468        *self.sequence.read()
469    }
470
471    /// Get the oldest available WAL sequence number.
472    ///
473    /// Returns the first sequence found in the oldest WAL file, or `None`
474    /// if no WAL entries exist. Used by the replication catch-up protocol
475    /// to determine whether a follower can catch up from WAL alone.
476    pub fn oldest_sequence(&self) -> Option<u64> {
477        let mut wal_files = match self.list_wal_files() {
478            Ok(files) => files,
479            Err(_) => return None,
480        };
481
482        if wal_files.is_empty() {
483            return None;
484        }
485
486        wal_files.sort();
487
488        // Read the first entry from the oldest WAL file
489        for wal_file_path in &wal_files {
490            let file = match File::open(wal_file_path) {
491                Ok(f) => f,
492                Err(_) => continue,
493            };
494            let reader = BufReader::new(file);
495            for line in reader.lines() {
496                let line = match line {
497                    Ok(l) => l,
498                    Err(_) => continue,
499                };
500                if line.trim().is_empty() {
501                    continue;
502                }
503                if let Ok(entry) = serde_json::from_str::<WALEntry>(&line) {
504                    return Some(entry.sequence);
505                }
506            }
507        }
508
509        None
510    }
511
512    /// Attach a broadcast sender for WAL replication.
513    ///
514    /// When set, every `append()` call will publish the WAL entry to this
515    /// channel so the WAL shipper can stream it to connected followers.
516    pub fn set_replication_tx(&self, tx: tokio::sync::broadcast::Sender<WALEntry>) {
517        *self.replication_tx.lock() = Some(tx);
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use serde_json::json;
525    use tempfile::TempDir;
526    use uuid::Uuid;
527
528    fn create_test_event() -> Event {
529        Event::reconstruct_from_strings(
530            Uuid::new_v4(),
531            "test.event".to_string(),
532            "test-entity".to_string(),
533            "default".to_string(),
534            json!({"test": "data"}),
535            Utc::now(),
536            None,
537            1,
538        )
539    }
540
541    #[test]
542    fn test_wal_creation() {
543        let temp_dir = TempDir::new().unwrap();
544        let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default());
545        assert!(wal.is_ok());
546    }
547
548    #[test]
549    fn test_wal_append() {
550        let temp_dir = TempDir::new().unwrap();
551        let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
552
553        let event = create_test_event();
554        let seq = wal.append(event);
555        assert!(seq.is_ok());
556        assert_eq!(seq.unwrap(), 1);
557
558        let stats = wal.stats();
559        assert_eq!(stats.total_entries, 1);
560    }
561
562    #[test]
563    fn test_wal_recovery() {
564        let temp_dir = TempDir::new().unwrap();
565        let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
566
567        // Write some events
568        for _ in 0..5 {
569            wal.append(create_test_event()).unwrap();
570        }
571
572        wal.flush().unwrap();
573
574        // Create new WAL instance (simulating restart)
575        let wal2 = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
576        let recovered = wal2.recover().unwrap();
577
578        assert_eq!(recovered.len(), 5);
579    }
580
581    #[test]
582    fn test_wal_rotation() {
583        let temp_dir = TempDir::new().unwrap();
584        let config = WALConfig {
585            max_file_size: 1024, // Small size to trigger rotation
586            ..Default::default()
587        };
588
589        let wal = WriteAheadLog::new(temp_dir.path(), config).unwrap();
590
591        // Write enough events to trigger rotation
592        for _ in 0..50 {
593            wal.append(create_test_event()).unwrap();
594        }
595
596        let stats = wal.stats();
597        assert!(stats.files_rotated > 0);
598    }
599
600    #[test]
601    fn test_wal_entry_checksum() {
602        let event = create_test_event();
603        let entry = WALEntry::new(1, event);
604
605        assert!(entry.verify());
606
607        // Modify and verify it fails
608        let mut corrupted = entry.clone();
609        corrupted.checksum = 0;
610        assert!(!corrupted.verify());
611    }
612
613    #[test]
614    fn test_wal_fsync_interval_config() {
615        let config = WALConfig {
616            fsync_interval_ms: Some(100),
617            ..Default::default()
618        };
619        assert_eq!(config.fsync_interval_ms, Some(100));
620        // Default should have no interval
621        assert_eq!(WALConfig::default().fsync_interval_ms, None);
622    }
623
624    #[test]
625    fn test_wal_sync_method() {
626        let temp_dir = TempDir::new().unwrap();
627        let config = WALConfig {
628            sync_on_write: false, // Disable per-write sync to test explicit sync
629            ..Default::default()
630        };
631        let wal = WriteAheadLog::new(temp_dir.path(), config).unwrap();
632
633        // Write events without per-write fsync
634        for _ in 0..5 {
635            wal.append(create_test_event()).unwrap();
636        }
637
638        // Explicitly sync โ€” should flush + fsync without error
639        wal.sync().unwrap();
640
641        // Verify data survives by recovering from a new WAL instance
642        let wal2 = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
643        let recovered = wal2.recover().unwrap();
644        assert_eq!(recovered.len(), 5);
645    }
646
647    #[test]
648    fn test_wal_truncate() {
649        let temp_dir = TempDir::new().unwrap();
650        let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
651
652        // Write events
653        for _ in 0..5 {
654            wal.append(create_test_event()).unwrap();
655        }
656
657        // Truncate
658        wal.truncate().unwrap();
659
660        // Verify sequence is reset
661        assert_eq!(wal.current_sequence(), 0);
662
663        // Verify recovery returns empty
664        let recovered = wal.recover().unwrap();
665        assert_eq!(recovered.len(), 0);
666    }
667}