use std::path::PathBuf;
use parking_lot::Mutex;
use linkrs_core::types::{CommitLsn, Timestamp, TransactionId};
use super::error::TransactionError;
use super::participant::{TransactionCommitDescriptor, TransactionCommitSink};
#[derive(Debug, Clone)]
pub struct PendingFinalization {
pub txn_id: TransactionId,
pub write_timestamp: Timestamp,
pub commit_timestamp: Timestamp,
pub commit_lsn: CommitLsn,
pub descriptor: TransactionCommitDescriptor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SidecarRecord {
txn_id: u64,
write_timestamp: Timestamp,
commit_timestamp: Timestamp,
commit_lsn: u64,
}
pub struct RecoveryManager {
pending_finalizations: Mutex<Vec<PendingFinalization>>,
sidecar_path: Mutex<Option<PathBuf>>,
}
impl RecoveryManager {
pub fn new() -> Self {
Self {
pending_finalizations: Mutex::new(Vec::new()),
sidecar_path: Mutex::new(None),
}
}
pub fn set_sidecar_path(&self, path: impl Into<PathBuf>) {
*self.sidecar_path.lock() = Some(path.into());
}
pub fn record(
&self,
descriptor: &TransactionCommitDescriptor,
commit_timestamp: Timestamp,
commit_lsn: CommitLsn,
) {
let mut queue = self.pending_finalizations.lock();
queue.retain(|pending| pending.txn_id != descriptor.transaction_id);
queue.push(PendingFinalization {
txn_id: descriptor.transaction_id,
write_timestamp: descriptor.write_timestamp,
commit_timestamp,
commit_lsn,
descriptor: descriptor.clone(),
});
self.persist_locked(&queue);
}
fn persist_locked(&self, queue: &[PendingFinalization]) {
let Some(path) = self.sidecar_path.lock().clone() else {
return;
};
let mut contents = String::new();
for pending in queue {
contents.push_str(&format!(
"{} {} {} {}\n",
pending.txn_id.0,
pending.write_timestamp,
pending.commit_timestamp,
pending.commit_lsn.get(),
));
}
let tmp_path = path.with_extension("tmp");
let write_result = std::fs::write(&tmp_path, contents).and_then(|()| {
std::fs::File::open(&tmp_path).and_then(|file| file.sync_all())?;
std::fs::rename(&tmp_path, &path)
});
if let Err(error) = write_result {
let _ = std::fs::remove_file(&tmp_path);
log::warn!(
"Failed to persist {} pending finalization(s) to {}: {}",
queue.len(),
path.display(),
error
);
}
}
fn load_sidecar(&self) -> Vec<SidecarRecord> {
let Some(path) = self.sidecar_path.lock().clone() else {
return Vec::new();
};
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(error) => {
log::warn!(
"Failed to read recovery sidecar {}: {}",
path.display(),
error
);
return Vec::new();
}
};
let mut records = Vec::new();
for (line_number, line) in contents.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let parsed = (
parts.next().and_then(|part| part.parse::<u64>().ok()),
parts.next().and_then(|part| part.parse::<Timestamp>().ok()),
parts.next().and_then(|part| part.parse::<Timestamp>().ok()),
parts.next().and_then(|part| part.parse::<u64>().ok()),
);
match parsed {
(Some(txn_id), Some(write_timestamp), Some(commit_timestamp), Some(commit_lsn)) => {
records.push(SidecarRecord {
txn_id,
write_timestamp,
commit_timestamp,
commit_lsn,
});
}
_ => {
log::warn!(
"Skipping malformed recovery sidecar line {} in {}",
line_number + 1,
path.display()
);
}
}
}
records
}
fn clear_sidecar(&self) {
let Some(path) = self.sidecar_path.lock().clone() else {
return;
};
if let Err(error) = std::fs::remove_file(&path) {
if error.kind() != std::io::ErrorKind::NotFound {
log::warn!(
"Failed to remove recovery sidecar {}: {}",
path.display(),
error
);
}
}
}
pub fn pending_txn_ids(&self) -> Vec<TransactionId> {
self.pending_finalizations
.lock()
.iter()
.map(|pending| pending.txn_id)
.collect()
}
pub fn take_pending(&self, txn_id: TransactionId) -> Option<PendingFinalization> {
let mut queue = self.pending_finalizations.lock();
let position = queue.iter().position(|pending| pending.txn_id == txn_id)?;
Some(queue.remove(position))
}
pub fn sync_sidecar(&self) {
let queue = self.pending_finalizations.lock();
self.persist_locked(&queue);
}
pub fn recover(
&self,
sink: Option<&dyn TransactionCommitSink>,
) -> Result<usize, TransactionError> {
let mut recovered = 0usize;
let pending: Vec<PendingFinalization> = {
let mut queue = self.pending_finalizations.lock();
std::mem::take(&mut *queue)
};
for pf in &pending {
log::info!(
"Recovering pending finalization: txn={:?} write_ts={} commit_ts={} lsn={:?}",
pf.txn_id,
pf.write_timestamp,
pf.commit_timestamp,
pf.commit_lsn,
);
}
recovered += pending.len();
let sidecar_records = self.load_sidecar();
for record in &sidecar_records {
log::info!(
"Recovering sidecar finalization: txn={} write_ts={} commit_ts={} lsn={}",
record.txn_id,
record.write_timestamp,
record.commit_timestamp,
record.commit_lsn,
);
}
recovered += sidecar_records.len();
if let Some(sink) = sink {
let n = sink
.recover_unfinalized_commits()
.map_err(|e| TransactionError::internal(format!("Recovery failed: {}", e)))?;
recovered += n;
}
self.clear_sidecar();
Ok(recovered)
}
}
impl Default for RecoveryManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::participant::TransactionCommitDescriptor;
use linkrs_core::types::{DurabilityLevel, TransactionId};
use crate::types::WriteSet;
fn test_descriptor(txn: u64, write_ts: Timestamp) -> TransactionCommitDescriptor {
TransactionCommitDescriptor::new(
TransactionId::new(txn),
write_ts,
DurabilityLevel::default(),
WriteSet::new(),
)
}
#[test]
fn pending_finalization_round_trip() {
let manager = RecoveryManager::new();
let descriptor = test_descriptor(7, 42);
manager.record(&descriptor, 0, CommitLsn::new(100));
assert_eq!(manager.pending_txn_ids(), vec![TransactionId::new(7)]);
let taken = manager
.take_pending(TransactionId::new(7))
.expect("pending entry");
assert_eq!(taken.txn_id, TransactionId::new(7));
assert_eq!(taken.write_timestamp, 42);
assert_eq!(taken.commit_timestamp, 0);
assert!(manager.pending_txn_ids().is_empty());
assert!(manager.take_pending(TransactionId::new(7)).is_none());
}
#[test]
fn re_recording_replaces_stale_entry() {
let manager = RecoveryManager::new();
let descriptor = test_descriptor(7, 42);
manager.record(&descriptor, 0, CommitLsn::new(100));
manager.record(&descriptor, 43, CommitLsn::new(100));
assert_eq!(manager.pending_txn_ids(), vec![TransactionId::new(7)]);
let taken = manager
.take_pending(TransactionId::new(7))
.expect("pending entry");
assert_eq!(taken.commit_timestamp, 43);
}
}