Skip to main content

oxirs/tools/
pitr.rs

1//! Point-in-Time Recovery (PITR) Module
2//!
3//! Transaction log-based recovery system for OxiRS databases.
4//! Enables restoration to specific timestamps or transaction IDs.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fs::{self, File};
9use std::io::{BufRead, BufReader, BufWriter, Write};
10use std::path::{Path, PathBuf};
11
12/// Transaction log entry
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TransactionLogEntry {
15    pub transaction_id: u64,
16    pub timestamp: DateTime<Utc>,
17    pub operation_type: OperationType,
18    pub data: Vec<u8>,
19    pub checksum: String,
20}
21
22/// Types of operations in transaction log
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub enum OperationType {
25    Insert,
26    Delete,
27    Update,
28    BeginTransaction,
29    CommitTransaction,
30    RollbackTransaction,
31}
32
33/// PITR configuration
34pub struct PitrConfig {
35    pub log_dir: PathBuf,
36    pub archive_dir: PathBuf,
37    pub max_log_size: u64,
38    pub auto_archive: bool,
39}
40
41/// Transaction log manager
42pub struct TransactionLog {
43    config: PitrConfig,
44    current_log: Option<BufWriter<File>>,
45    next_transaction_id: u64,
46}
47
48impl TransactionLog {
49    /// Create a new transaction log
50    pub fn new(config: PitrConfig) -> Result<Self, Box<dyn std::error::Error>> {
51        // Ensure directories exist
52        fs::create_dir_all(&config.log_dir)?;
53        fs::create_dir_all(&config.archive_dir)?;
54
55        // Load next transaction ID
56        let next_transaction_id = Self::get_next_transaction_id(&config.log_dir)?;
57
58        Ok(Self {
59            config,
60            current_log: None,
61            next_transaction_id,
62        })
63    }
64
65    /// Append a transaction entry to the log
66    pub fn append(
67        &mut self,
68        operation_type: OperationType,
69        data: Vec<u8>,
70    ) -> Result<u64, Box<dyn std::error::Error>> {
71        // Ensure log file is open
72        if self.current_log.is_none() {
73            self.open_current_log()?;
74        }
75
76        let transaction_id = self.next_transaction_id;
77        self.next_transaction_id += 1;
78
79        let entry = TransactionLogEntry {
80            transaction_id,
81            timestamp: Utc::now(),
82            operation_type,
83            data: data.clone(),
84            checksum: Self::calculate_checksum(&data),
85        };
86
87        // Serialize and write entry
88        let entry_json = serde_json::to_string(&entry)?;
89        if let Some(ref mut log) = self.current_log {
90            writeln!(log, "{}", entry_json)?;
91            log.flush()?;
92        }
93
94        // Check if log needs rotation
95        if self.should_rotate_log()? {
96            self.rotate_log()?;
97        }
98
99        Ok(transaction_id)
100    }
101
102    /// Recover to a specific point in time
103    pub fn recover_to_timestamp(
104        &self,
105        target_timestamp: DateTime<Utc>,
106        _output_dir: &Path,
107    ) -> Result<usize, Box<dyn std::error::Error>> {
108        println!("Starting Point-in-Time Recovery");
109        println!("Target timestamp: {}", target_timestamp.to_rfc3339());
110
111        // Collect all log files (current + archived)
112        let mut log_files = self.collect_log_files()?;
113        log_files.sort();
114
115        let mut applied_transactions = 0;
116
117        // Replay transactions up to the target timestamp
118        for log_file in log_files {
119            applied_transactions += self.replay_log_file(&log_file, Some(target_timestamp))?;
120        }
121
122        println!(
123            "Recovery complete: {} transactions applied",
124            applied_transactions
125        );
126        Ok(applied_transactions)
127    }
128
129    /// Recover to a specific transaction ID
130    pub fn recover_to_transaction(
131        &self,
132        target_transaction_id: u64,
133        _output_dir: &Path,
134    ) -> Result<usize, Box<dyn std::error::Error>> {
135        println!("Starting Point-in-Time Recovery");
136        println!("Target transaction ID: {}", target_transaction_id);
137
138        let mut log_files = self.collect_log_files()?;
139        log_files.sort();
140
141        let mut applied_transactions = 0;
142
143        for log_file in log_files {
144            let file = File::open(&log_file)?;
145            let reader = BufReader::new(file);
146
147            for line in reader.lines() {
148                let line = line?;
149                if line.trim().is_empty() {
150                    continue;
151                }
152
153                let entry: TransactionLogEntry = serde_json::from_str(&line)?;
154
155                if entry.transaction_id > target_transaction_id {
156                    // Stop when we exceed target transaction ID
157                    println!("Reached target transaction ID");
158                    return Ok(applied_transactions);
159                }
160
161                // Verify checksum
162                if !Self::verify_checksum(&entry) {
163                    eprintln!(
164                        "Warning: Checksum mismatch for transaction {}",
165                        entry.transaction_id
166                    );
167                    continue;
168                }
169
170                // Apply transaction (placeholder - actual implementation would replay to store)
171                applied_transactions += 1;
172            }
173        }
174
175        Ok(applied_transactions)
176    }
177
178    /// Create an incremental backup checkpoint
179    pub fn create_checkpoint(&self, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
180        let checkpoint_file = self
181            .config
182            .archive_dir
183            .join(format!("checkpoint_{}.json", name));
184
185        let checkpoint = CheckpointMetadata {
186            name: name.to_string(),
187            timestamp: Utc::now(),
188            last_transaction_id: self.next_transaction_id - 1,
189            log_files: self.collect_log_files()?,
190        };
191
192        let mut file = File::create(&checkpoint_file)?;
193        serde_json::to_writer_pretty(&mut file, &checkpoint)?;
194
195        println!("Checkpoint created: {}", checkpoint_file.display());
196        Ok(checkpoint_file)
197    }
198
199    /// List all available checkpoints
200    pub fn list_checkpoints(&self) -> Result<Vec<CheckpointMetadata>, Box<dyn std::error::Error>> {
201        let mut checkpoints: Vec<CheckpointMetadata> = Vec::new();
202
203        for entry in fs::read_dir(&self.config.archive_dir)? {
204            let entry = entry?;
205            let path = entry.path();
206
207            if path.extension().and_then(|s| s.to_str()) == Some("json")
208                && path
209                    .file_name()
210                    .and_then(|s| s.to_str())
211                    .map(|s| s.starts_with("checkpoint_"))
212                    .unwrap_or(false)
213            {
214                let file = File::open(&path)?;
215                if let Ok(checkpoint) = serde_json::from_reader(file) {
216                    checkpoints.push(checkpoint);
217                }
218            }
219        }
220
221        checkpoints.sort_by_key(|item| item.timestamp);
222        Ok(checkpoints)
223    }
224
225    /// Archive old transaction logs
226    pub fn archive_logs(&mut self) -> Result<usize, Box<dyn std::error::Error>> {
227        // Close current log
228        self.close_current_log()?;
229
230        let mut archived = 0;
231
232        // Move all .wal files to archive
233        for entry in fs::read_dir(&self.config.log_dir)? {
234            let entry = entry?;
235            let path = entry.path();
236
237            if path.extension().and_then(|s| s.to_str()) == Some("wal") {
238                let filename = path.file_name().expect("file path should have a file name");
239                let archive_path = self.config.archive_dir.join(filename);
240
241                fs::rename(&path, &archive_path)?;
242                archived += 1;
243            }
244        }
245
246        println!("Archived {} log file(s)", archived);
247        Ok(archived)
248    }
249
250    // === Private helper methods ===
251
252    fn open_current_log(&mut self) -> Result<(), Box<dyn std::error::Error>> {
253        let log_path = self
254            .config
255            .log_dir
256            .join(format!("transaction_{}.wal", Utc::now().timestamp()));
257
258        let file = File::options().create(true).append(true).open(&log_path)?;
259
260        self.current_log = Some(BufWriter::new(file));
261        Ok(())
262    }
263
264    fn close_current_log(&mut self) -> Result<(), Box<dyn std::error::Error>> {
265        if let Some(mut log) = self.current_log.take() {
266            log.flush()?;
267        }
268        Ok(())
269    }
270
271    fn should_rotate_log(&self) -> Result<bool, Box<dyn std::error::Error>> {
272        // Rotation based on file size
273        for entry in fs::read_dir(&self.config.log_dir)? {
274            let entry = entry?;
275            let path = entry.path();
276
277            if path.extension().and_then(|s| s.to_str()) == Some("wal") {
278                let metadata = fs::metadata(&path)?;
279                if metadata.len() >= self.config.max_log_size {
280                    return Ok(true);
281                }
282            }
283        }
284        Ok(false)
285    }
286
287    fn rotate_log(&mut self) -> Result<(), Box<dyn std::error::Error>> {
288        // Close current log
289        self.close_current_log()?;
290
291        // Archive if enabled
292        if self.config.auto_archive {
293            self.archive_logs()?;
294        }
295
296        // Open new log
297        self.open_current_log()?;
298
299        Ok(())
300    }
301
302    fn collect_log_files(&self) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
303        let mut log_files = Vec::new();
304
305        // Collect from log directory
306        if self.config.log_dir.exists() {
307            for entry in fs::read_dir(&self.config.log_dir)? {
308                let entry = entry?;
309                let path = entry.path();
310                if path.extension().and_then(|s| s.to_str()) == Some("wal") {
311                    log_files.push(path);
312                }
313            }
314        }
315
316        // Collect from archive directory
317        if self.config.archive_dir.exists() {
318            for entry in fs::read_dir(&self.config.archive_dir)? {
319                let entry = entry?;
320                let path = entry.path();
321                if path.extension().and_then(|s| s.to_str()) == Some("wal") {
322                    log_files.push(path);
323                }
324            }
325        }
326
327        Ok(log_files)
328    }
329
330    fn replay_log_file(
331        &self,
332        log_file: &Path,
333        until_timestamp: Option<DateTime<Utc>>,
334    ) -> Result<usize, Box<dyn std::error::Error>> {
335        let file = File::open(log_file)?;
336        let reader = BufReader::new(file);
337        let mut replayed = 0;
338
339        for line in reader.lines() {
340            let line = line?;
341            if line.trim().is_empty() {
342                continue;
343            }
344
345            let entry: TransactionLogEntry = serde_json::from_str(&line)?;
346
347            // Stop if we've passed the target timestamp
348            if let Some(target) = until_timestamp {
349                if entry.timestamp > target {
350                    return Ok(replayed);
351                }
352            }
353
354            // Verify checksum
355            if !Self::verify_checksum(&entry) {
356                eprintln!(
357                    "Warning: Checksum mismatch for transaction {}",
358                    entry.transaction_id
359                );
360                continue;
361            }
362
363            // Apply transaction (placeholder)
364            replayed += 1;
365        }
366
367        Ok(replayed)
368    }
369
370    fn get_next_transaction_id(log_dir: &Path) -> Result<u64, Box<dyn std::error::Error>> {
371        let mut max_id = 0u64;
372
373        if !log_dir.exists() {
374            return Ok(1);
375        }
376
377        // Scan all log files to find highest transaction ID
378        for entry in fs::read_dir(log_dir)? {
379            let entry = entry?;
380            let path = entry.path();
381
382            if path.extension().and_then(|s| s.to_str()) == Some("wal") {
383                let file = File::open(&path)?;
384                let reader = BufReader::new(file);
385
386                for line in reader.lines() {
387                    let line = line?;
388                    if line.trim().is_empty() {
389                        continue;
390                    }
391
392                    if let Ok(entry) = serde_json::from_str::<TransactionLogEntry>(&line) {
393                        max_id = max_id.max(entry.transaction_id);
394                    }
395                }
396            }
397        }
398
399        Ok(max_id + 1)
400    }
401
402    fn calculate_checksum(data: &[u8]) -> String {
403        // SHA-256 one-shot via OxiCrypto (Pure Rust); hex-encode the 32-byte
404        // digest. Produces the same checksum string as the previous `ring`
405        // implementation. `hash_fixed` is an inherent method on `Sha256`, so the
406        // `Hash` trait does not need to be in scope.
407        let hash: [u8; 32] = oxicrypto_hash::Sha256.hash_fixed(data);
408        hex::encode(hash)
409    }
410
411    fn verify_checksum(entry: &TransactionLogEntry) -> bool {
412        let calculated = Self::calculate_checksum(&entry.data);
413        calculated == entry.checksum
414    }
415}
416
417/// Checkpoint metadata
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct CheckpointMetadata {
420    pub name: String,
421    pub timestamp: DateTime<Utc>,
422    pub last_transaction_id: u64,
423    pub log_files: Vec<PathBuf>,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use std::env::temp_dir;
430
431    #[test]
432    fn test_transaction_log_creation() {
433        let temp_log_dir = temp_dir().join("oxirs_pitr_test_logs");
434        let temp_archive_dir = temp_dir().join("oxirs_pitr_test_archives");
435
436        let config = PitrConfig {
437            log_dir: temp_log_dir.clone(),
438            archive_dir: temp_archive_dir.clone(),
439            max_log_size: 10_000_000, // 10MB
440            auto_archive: false,
441        };
442
443        let log = TransactionLog::new(config).unwrap();
444        assert_eq!(log.next_transaction_id, 1);
445
446        // Cleanup
447        let _ = fs::remove_dir_all(temp_log_dir);
448        let _ = fs::remove_dir_all(temp_archive_dir);
449    }
450
451    #[test]
452    fn test_transaction_append() {
453        let temp_log_dir = temp_dir().join("oxirs_pitr_test_logs2");
454        let temp_archive_dir = temp_dir().join("oxirs_pitr_test_archives2");
455
456        let config = PitrConfig {
457            log_dir: temp_log_dir.clone(),
458            archive_dir: temp_archive_dir.clone(),
459            max_log_size: 10_000_000,
460            auto_archive: false,
461        };
462
463        let mut log = TransactionLog::new(config).unwrap();
464
465        // Append several transactions
466        let tx1 = log
467            .append(OperationType::Insert, b"test data 1".to_vec())
468            .unwrap();
469        let tx2 = log
470            .append(OperationType::Update, b"test data 2".to_vec())
471            .unwrap();
472        let tx3 = log
473            .append(OperationType::Delete, b"test data 3".to_vec())
474            .unwrap();
475
476        assert_eq!(tx1, 1);
477        assert_eq!(tx2, 2);
478        assert_eq!(tx3, 3);
479
480        // Cleanup
481        let _ = fs::remove_dir_all(temp_log_dir);
482        let _ = fs::remove_dir_all(temp_archive_dir);
483    }
484
485    #[test]
486    fn test_checkpoint_creation() {
487        let temp_log_dir = temp_dir().join("oxirs_pitr_test_logs3");
488        let temp_archive_dir = temp_dir().join("oxirs_pitr_test_archives3");
489
490        let config = PitrConfig {
491            log_dir: temp_log_dir.clone(),
492            archive_dir: temp_archive_dir.clone(),
493            max_log_size: 10_000_000,
494            auto_archive: false,
495        };
496
497        let mut log = TransactionLog::new(config).unwrap();
498
499        // Add some transactions
500        log.append(OperationType::Insert, b"data1".to_vec())
501            .unwrap();
502        log.append(OperationType::Insert, b"data2".to_vec())
503            .unwrap();
504
505        // Create checkpoint
506        let checkpoint_path = log.create_checkpoint("test_checkpoint").unwrap();
507        assert!(checkpoint_path.exists());
508
509        // List checkpoints
510        let checkpoints = log.list_checkpoints().unwrap();
511        assert_eq!(checkpoints.len(), 1);
512        assert_eq!(checkpoints[0].name, "test_checkpoint");
513
514        // Cleanup
515        let _ = fs::remove_dir_all(temp_log_dir);
516        let _ = fs::remove_dir_all(temp_archive_dir);
517    }
518}