allsource_core/infrastructure/persistence/
wal.rs1use 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
15pub struct WriteAheadLog {
17 wal_dir: PathBuf,
19
20 current_file: Arc<RwLock<WALFile>>,
22
23 config: WALConfig,
25
26 stats: Arc<RwLock<WALStats>>,
28
29 sequence: Arc<RwLock<u64>>,
31
32 replication_tx: parking_lot::Mutex<Option<tokio::sync::broadcast::Sender<WALEntry>>>,
36}
37
38#[derive(Debug, Clone)]
39pub struct WALConfig {
40 pub max_file_size: usize,
42
43 pub sync_on_write: bool,
45
46 pub max_wal_files: usize,
48
49 pub compress: bool,
51
52 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, 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#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WALEntry {
84 pub sequence: u64,
86
87 pub wal_timestamp: DateTime<Utc>,
89
90 pub event: Event,
92
93 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 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
120struct 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 let json = serde_json::to_string(entry)?;
149
150 let line = format!("{json}\n");
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 pub fn new(wal_dir: impl Into<PathBuf>, config: WALConfig) -> Result<Self> {
183 let wal_dir = wal_dir.into();
184
185 fs::create_dir_all(&wal_dir).map_err(|e| {
187 AllSourceError::StorageError(format!("Failed to create WAL directory: {e}"))
188 })?;
189
190 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 fn generate_wal_filename(dir: &Path, sequence: u64) -> PathBuf {
208 dir.join(format!("wal-{sequence:016x}.log"))
209 }
210
211 #[cfg_attr(feature = "hotpath", hotpath::measure)]
213 pub fn append(&self, event: Event) -> Result<u64> {
214 let mut seq = self.sequence.write();
216 *seq += 1;
217 let sequence = *seq;
218 drop(seq);
219
220 let entry = WALEntry::new(sequence, event);
222
223 let mut current = self.current_file.write();
225 let bytes_written = current.write_entry(&entry, self.config.sync_on_write)?;
226
227 let mut stats = self.stats.write();
229 stats.total_entries += 1;
230 stats.total_bytes_written += bytes_written as u64;
231 stats.current_file_size = current.size;
232 drop(stats);
233
234 if let Some(ref tx) = *self.replication_tx.lock() {
237 let _ = tx.send(entry);
238 }
239
240 let should_rotate = current.size >= self.config.max_file_size;
242 drop(current);
243
244 if should_rotate {
245 self.rotate()?;
246 }
247
248 tracing::trace!("WAL entry written: sequence={}", sequence);
249
250 Ok(sequence)
251 }
252
253 #[cfg_attr(feature = "hotpath", hotpath::measure)]
255 fn rotate(&self) -> Result<()> {
256 let seq = *self.sequence.read();
257 let new_file_path = Self::generate_wal_filename(&self.wal_dir, seq);
258
259 tracing::info!("๐ Rotating WAL to new file: {:?}", new_file_path);
260
261 let new_file = WALFile::new(new_file_path)?;
262
263 let mut current = self.current_file.write();
264 current.flush()?;
265 *current = new_file;
266
267 let mut stats = self.stats.write();
268 stats.files_rotated += 1;
269 stats.current_file_size = 0;
270 drop(stats);
271
272 self.cleanup_old_files()?;
274
275 Ok(())
276 }
277
278 #[cfg_attr(feature = "hotpath", hotpath::measure)]
280 fn cleanup_old_files(&self) -> Result<()> {
281 let mut wal_files = self.list_wal_files()?;
282 wal_files.sort();
283
284 if wal_files.len() > self.config.max_wal_files {
285 let to_remove = wal_files.len() - self.config.max_wal_files;
286 let files_to_delete = &wal_files[..to_remove];
287
288 for file_path in files_to_delete {
289 if let Err(e) = fs::remove_file(file_path) {
290 tracing::warn!("Failed to remove old WAL file {:?}: {}", file_path, e);
291 } else {
292 tracing::debug!("๐๏ธ Removed old WAL file: {:?}", file_path);
293 let mut stats = self.stats.write();
294 stats.files_cleaned += 1;
295 }
296 }
297 }
298
299 Ok(())
300 }
301
302 fn list_wal_files(&self) -> Result<Vec<PathBuf>> {
304 let entries = fs::read_dir(&self.wal_dir).map_err(|e| {
305 AllSourceError::StorageError(format!("Failed to read WAL directory: {e}"))
306 })?;
307
308 let mut wal_files = Vec::new();
309 for entry in entries {
310 let entry = entry.map_err(|e| {
311 AllSourceError::StorageError(format!("Failed to read directory entry: {e}"))
312 })?;
313
314 let path = entry.path();
315 if let Some(name) = path.file_name()
316 && name.to_string_lossy().starts_with("wal-")
317 && name.to_string_lossy().ends_with(".log")
318 {
319 wal_files.push(path);
320 }
321 }
322
323 Ok(wal_files)
324 }
325
326 #[cfg_attr(feature = "hotpath", hotpath::measure)]
328 pub fn recover(&self) -> Result<Vec<Event>> {
329 tracing::info!("๐ Starting WAL recovery...");
330
331 let mut wal_files = self.list_wal_files()?;
332 wal_files.sort();
333
334 let mut recovered_events = Vec::new();
335 let mut max_sequence = 0u64;
336 let mut corrupted_entries = 0;
337
338 for wal_file_path in &wal_files {
339 tracing::debug!("Reading WAL file: {:?}", wal_file_path);
340
341 let file = File::open(wal_file_path).map_err(|e| {
342 AllSourceError::StorageError(format!("Failed to open WAL file for recovery: {e}"))
343 })?;
344
345 let reader = BufReader::new(file);
346
347 for (line_num, line) in reader.lines().enumerate() {
348 let line = match line {
349 Ok(l) => l,
350 Err(e) => {
351 tracing::warn!(
352 "I/O error reading WAL line at {:?}:{}: {}",
353 wal_file_path,
354 line_num + 1,
355 e
356 );
357 corrupted_entries += 1;
358 continue;
359 }
360 };
361
362 if line.trim().is_empty() {
363 continue;
364 }
365
366 match serde_json::from_str::<WALEntry>(&line) {
367 Ok(entry) => {
368 if !entry.verify() {
370 tracing::warn!(
371 "Corrupted WAL entry at {:?}:{} (checksum mismatch)",
372 wal_file_path,
373 line_num + 1
374 );
375 corrupted_entries += 1;
376 continue;
377 }
378
379 max_sequence = max_sequence.max(entry.sequence);
380 recovered_events.push(entry.event);
381 }
382 Err(e) => {
383 tracing::warn!(
384 "Failed to parse WAL entry at {:?}:{}: {}",
385 wal_file_path,
386 line_num + 1,
387 e
388 );
389 corrupted_entries += 1;
390 }
391 }
392 }
393 }
394
395 let mut seq = self.sequence.write();
397 *seq = max_sequence;
398 drop(seq);
399
400 let mut stats = self.stats.write();
402 stats.recovery_count += 1;
403 drop(stats);
404
405 tracing::info!(
406 "โ
WAL recovery complete: {} events recovered, {} corrupted entries",
407 recovered_events.len(),
408 corrupted_entries
409 );
410
411 Ok(recovered_events)
412 }
413
414 #[cfg_attr(feature = "hotpath", hotpath::measure)]
416 pub fn flush(&self) -> Result<()> {
417 let mut current = self.current_file.write();
418 current.flush()?;
419 Ok(())
420 }
421
422 #[cfg_attr(feature = "hotpath", hotpath::measure)]
428 pub fn sync(&self) -> Result<()> {
429 let mut current = self.current_file.write();
430 current
431 .writer
432 .flush()
433 .map_err(|e| AllSourceError::StorageError(format!("Failed to flush WAL: {e}")))?;
434 current
435 .writer
436 .get_ref()
437 .sync_all()
438 .map_err(|e| AllSourceError::StorageError(format!("Failed to sync WAL: {e}")))?;
439 Ok(())
440 }
441
442 pub fn fsync_interval_ms(&self) -> Option<u64> {
444 self.config.fsync_interval_ms
445 }
446
447 #[cfg_attr(feature = "hotpath", hotpath::measure)]
449 pub fn truncate(&self) -> Result<()> {
450 tracing::info!("๐งน Truncating WAL after checkpoint");
451
452 let mut current = self.current_file.write();
454 current.flush()?;
455
456 let wal_files = self.list_wal_files()?;
458 for file_path in wal_files {
459 fs::remove_file(&file_path).map_err(|e| {
460 AllSourceError::StorageError(format!("Failed to remove WAL file: {e}"))
461 })?;
462 tracing::debug!("Removed WAL file: {:?}", file_path);
463 }
464
465 let new_file_path = Self::generate_wal_filename(&self.wal_dir, 0);
467 *current = WALFile::new(new_file_path)?;
468
469 let mut seq = self.sequence.write();
471 *seq = 0;
472
473 tracing::info!("โ
WAL truncated successfully");
474
475 Ok(())
476 }
477
478 pub fn stats(&self) -> WALStats {
480 (*self.stats.read()).clone()
481 }
482
483 pub fn on_disk_stats(&self) -> Result<(u64, usize)> {
496 let wal_files = self.list_wal_files()?;
497 let mut total_bytes = 0u64;
498 for path in &wal_files {
499 if let Ok(metadata) = fs::metadata(path) {
500 total_bytes += metadata.len();
501 }
502 }
503 Ok((total_bytes, wal_files.len()))
504 }
505
506 pub fn current_sequence(&self) -> u64 {
508 *self.sequence.read()
509 }
510
511 pub fn oldest_sequence(&self) -> Option<u64> {
517 let Ok(mut wal_files) = self.list_wal_files() else {
518 return None;
519 };
520
521 if wal_files.is_empty() {
522 return None;
523 }
524
525 wal_files.sort();
526
527 for wal_file_path in &wal_files {
529 let Ok(file) = File::open(wal_file_path) else {
530 continue;
531 };
532 let reader = BufReader::new(file);
533 for line in reader.lines() {
534 let Ok(line) = line else {
535 continue;
536 };
537 if line.trim().is_empty() {
538 continue;
539 }
540 if let Ok(entry) = serde_json::from_str::<WALEntry>(&line) {
541 return Some(entry.sequence);
542 }
543 }
544 }
545
546 None
547 }
548
549 pub fn set_replication_tx(&self, tx: tokio::sync::broadcast::Sender<WALEntry>) {
554 *self.replication_tx.lock() = Some(tx);
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use serde_json::json;
562 use tempfile::TempDir;
563 use uuid::Uuid;
564
565 fn create_test_event() -> Event {
566 Event::reconstruct_from_strings(
567 Uuid::new_v4(),
568 "test.event".to_string(),
569 "test-entity".to_string(),
570 "default".to_string(),
571 json!({"test": "data"}),
572 Utc::now(),
573 None,
574 1,
575 )
576 }
577
578 #[test]
579 fn test_wal_creation() {
580 let temp_dir = TempDir::new().unwrap();
581 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default());
582 assert!(wal.is_ok());
583 }
584
585 #[test]
586 fn test_wal_append() {
587 let temp_dir = TempDir::new().unwrap();
588 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
589
590 let event = create_test_event();
591 let seq = wal.append(event);
592 assert!(seq.is_ok());
593 assert_eq!(seq.unwrap(), 1);
594
595 let stats = wal.stats();
596 assert_eq!(stats.total_entries, 1);
597 }
598
599 #[test]
600 fn test_wal_on_disk_stats() {
601 let temp_dir = TempDir::new().unwrap();
602 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
603
604 let (bytes0, segs0) = wal.on_disk_stats().unwrap();
607 assert_eq!(bytes0, 0);
608 assert!(segs0 <= 1);
609
610 for _ in 0..5 {
611 wal.append(create_test_event()).unwrap();
612 }
613 wal.flush().unwrap();
614
615 let (bytes, segs) = wal.on_disk_stats().unwrap();
616 assert!(segs >= 1, "expected at least one WAL segment, got {segs}");
617 assert!(
618 bytes > 0,
619 "expected non-zero WAL bytes after appends, got {bytes}"
620 );
621 }
622
623 #[test]
624 fn test_wal_recovery() {
625 let temp_dir = TempDir::new().unwrap();
626 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
627
628 for _ in 0..5 {
630 wal.append(create_test_event()).unwrap();
631 }
632
633 wal.flush().unwrap();
634
635 let wal2 = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
637 let recovered = wal2.recover().unwrap();
638
639 assert_eq!(recovered.len(), 5);
640 }
641
642 #[test]
643 fn test_wal_recovery_with_partial_write() {
644 let temp_dir = TempDir::new().unwrap();
645 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
646
647 for _ in 0..3 {
649 wal.append(create_test_event()).unwrap();
650 }
651 wal.flush().unwrap();
652
653 let wal_file_path = temp_dir.path().join("wal-0000000000000000.log");
655 use std::io::Write as _;
656 let mut f = std::fs::OpenOptions::new()
657 .append(true)
658 .open(&wal_file_path)
659 .unwrap();
660 f.write_all(b"{\"partial\": true, \"seq\"\n").unwrap(); drop(f);
662
663 let wal2 = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
665 let recovered = wal2.recover().unwrap();
666 assert_eq!(
667 recovered.len(),
668 3,
669 "Should recover only the 3 valid events, not the partial one"
670 );
671 }
672
673 #[test]
674 fn test_wal_rotation() {
675 let temp_dir = TempDir::new().unwrap();
676 let config = WALConfig {
677 max_file_size: 1024, ..Default::default()
679 };
680
681 let wal = WriteAheadLog::new(temp_dir.path(), config).unwrap();
682
683 for _ in 0..50 {
685 wal.append(create_test_event()).unwrap();
686 }
687
688 let stats = wal.stats();
689 assert!(stats.files_rotated > 0);
690 }
691
692 #[test]
693 fn test_wal_entry_checksum() {
694 let event = create_test_event();
695 let entry = WALEntry::new(1, event);
696
697 assert!(entry.verify());
698
699 let mut corrupted = entry.clone();
701 corrupted.checksum = 0;
702 assert!(!corrupted.verify());
703 }
704
705 #[test]
706 fn test_wal_fsync_interval_config() {
707 let config = WALConfig {
708 fsync_interval_ms: Some(100),
709 ..Default::default()
710 };
711 assert_eq!(config.fsync_interval_ms, Some(100));
712 assert_eq!(WALConfig::default().fsync_interval_ms, None);
714 }
715
716 #[test]
717 fn test_wal_sync_method() {
718 let temp_dir = TempDir::new().unwrap();
719 let config = WALConfig {
720 sync_on_write: false, ..Default::default()
722 };
723 let wal = WriteAheadLog::new(temp_dir.path(), config).unwrap();
724
725 for _ in 0..5 {
727 wal.append(create_test_event()).unwrap();
728 }
729
730 wal.sync().unwrap();
732
733 let wal2 = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
735 let recovered = wal2.recover().unwrap();
736 assert_eq!(recovered.len(), 5);
737 }
738
739 #[test]
740 fn test_wal_truncate() {
741 let temp_dir = TempDir::new().unwrap();
742 let wal = WriteAheadLog::new(temp_dir.path(), WALConfig::default()).unwrap();
743
744 for _ in 0..5 {
746 wal.append(create_test_event()).unwrap();
747 }
748
749 wal.truncate().unwrap();
751
752 assert_eq!(wal.current_sequence(), 0);
754
755 let recovered = wal.recover().unwrap();
757 assert_eq!(recovered.len(), 0);
758 }
759}