use std::sync::Arc;
use super::create_test_manager;
use crate::manager::TransactionManager;
use crate::types::*;
use crate::TransactionErrorKind;
#[test]
fn test_finalize_failure_keeps_reads_invisible_until_recovery() {
use std::sync::atomic::AtomicUsize;
use crate::participant::TransactionCommitSink;
use linkrs_core::types::CommitLsn;
struct FlakyFinalizeSink {
finalize_failures: AtomicUsize,
finalize_calls: AtomicUsize,
}
impl TransactionCommitSink for FlakyFinalizeSink {
fn commit_transaction(&self, _transaction_id: TransactionId) -> Result<CommitLsn, String> {
Ok(CommitLsn::new(11))
}
fn abort_transaction(&self, _transaction_id: TransactionId) -> Result<(), String> {
Ok(())
}
fn finalize_commit(
&self,
_descriptor: &crate::participant::TransactionCommitDescriptor,
_commit_lsn: CommitLsn,
) -> Result<(), String> {
self.finalize_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self
.finalize_failures
.load(std::sync::atomic::Ordering::SeqCst)
> 0
{
self.finalize_failures
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
return Err("injected finalize failure".to_string());
}
Ok(())
}
}
let sink = Arc::new(FlakyFinalizeSink {
finalize_failures: AtomicUsize::new(100),
finalize_calls: AtomicUsize::new(0),
});
let config = TransactionManagerConfig {
auto_cleanup: false,
commit_retry_attempts: 0,
..Default::default()
};
let manager = TransactionManager::new(config).with_commit_sink(sink.clone());
let txn_id = manager
.begin_insert_transaction(TransactionOptions::default())
.expect("transaction should begin");
let start_ts = manager
.get_context(txn_id)
.expect("context should exist")
.timestamp();
let error = manager
.commit_transaction(txn_id)
.expect_err("finalize failure must surface as an error");
assert_eq!(error.kind(), TransactionErrorKind::CommitFailed);
let context = manager
.get_context(txn_id)
.expect("durable transaction stays in the active table");
assert_eq!(context.state(), TransactionState::Committing);
assert_eq!(context.commit_timestamp(), 0);
assert_eq!(manager.read_timestamp(), start_ts + 1);
assert!(!manager.is_transaction_active(txn_id));
let abort_error = manager
.abort_transaction(txn_id)
.expect_err("durable commit must not be abortable");
assert_eq!(abort_error.kind(), TransactionErrorKind::CommitFailed);
sink.finalize_failures
.store(0, std::sync::atomic::Ordering::SeqCst);
manager
.recover_pending_finalization(txn_id)
.expect("recovery should complete finalization");
let commit_ts = context.commit_timestamp();
assert!(commit_ts > start_ts);
assert!(manager.read_timestamp() >= commit_ts);
assert_eq!(context.state(), TransactionState::Committed);
assert!(manager.get_context(txn_id).is_err());
assert!(
sink.finalize_calls
.load(std::sync::atomic::Ordering::SeqCst)
>= 2
);
}
#[test]
fn test_gate_lease_released_exactly_once_on_commit_failure_path() {
use crate::participant::TransactionCommitSink;
use linkrs_core::types::CommitLsn;
struct AlwaysFailFinalizeSink;
impl TransactionCommitSink for AlwaysFailFinalizeSink {
fn commit_transaction(&self, _transaction_id: TransactionId) -> Result<CommitLsn, String> {
Ok(CommitLsn::new(31))
}
fn abort_transaction(&self, _transaction_id: TransactionId) -> Result<(), String> {
Ok(())
}
fn finalize_commit(
&self,
_descriptor: &crate::participant::TransactionCommitDescriptor,
_commit_lsn: CommitLsn,
) -> Result<(), String> {
Err("injected finalize failure".to_string())
}
}
let config = TransactionManagerConfig {
auto_cleanup: false,
commit_retry_attempts: 0,
..Default::default()
};
let manager =
TransactionManager::new(config).with_commit_sink(Arc::new(AlwaysFailFinalizeSink));
let txn_id = manager
.begin_insert_transaction(TransactionOptions::default())
.expect("transaction should begin");
assert_eq!(manager.checkpoint_gate().active_write_count(), 1);
manager
.commit_transaction(txn_id)
.expect_err("finalize failure must surface as an error");
assert_eq!(manager.checkpoint_gate().active_write_count(), 0);
assert_eq!(manager.active_write_count(), 0);
let abort_error = manager
.abort_transaction(txn_id)
.expect_err("durable commit must not be abortable");
assert_eq!(abort_error.kind(), TransactionErrorKind::CommitFailed);
assert_eq!(manager.checkpoint_gate().active_write_count(), 0);
assert_eq!(manager.active_write_count(), 0);
manager
.recover_pending_finalization(txn_id)
.expect_err("finalize still fails");
assert_eq!(manager.checkpoint_gate().active_write_count(), 0);
let context = manager
.get_context(txn_id)
.expect("durable transaction stays in the active table");
assert_eq!(context.state(), TransactionState::Committing);
}
#[test]
fn test_double_abort_keeps_gate_balanced() {
let manager = create_test_manager();
let txn_id = manager
.begin_insert_transaction(TransactionOptions::default())
.expect("transaction should begin");
assert_eq!(manager.checkpoint_gate().active_write_count(), 1);
manager
.abort_transaction(txn_id)
.expect("first abort should succeed");
assert_eq!(manager.checkpoint_gate().active_write_count(), 0);
assert_eq!(manager.active_write_count(), 0);
assert!(manager.abort_transaction(txn_id).is_err());
assert_eq!(manager.checkpoint_gate().active_write_count(), 0);
assert_eq!(manager.active_write_count(), 0);
}
#[test]
fn test_recovery_sidecar_roundtrip_across_restart() {
use crate::participant::TransactionCommitDescriptor;
use crate::recovery::RecoveryManager;
use linkrs_core::types::{CommitLsn, TransactionId};
let dir = tempfile::tempdir().expect("tempdir should be created");
let sidecar = dir.path().join("pending.sidecar");
let recovery = RecoveryManager::new();
recovery.set_sidecar_path(&sidecar);
let descriptor = TransactionCommitDescriptor::new(
TransactionId(9),
4,
crate::DurabilityLevel::Sync,
crate::WriteSet::new(),
);
recovery.record(&descriptor, 0, CommitLsn::new(77));
recovery.record(&descriptor, 0, CommitLsn::new(77));
assert!(sidecar.exists(), "sidecar file should be written");
let restarted = RecoveryManager::new();
restarted.set_sidecar_path(&sidecar);
let recovered = restarted.recover(None).expect("recovery should succeed");
assert_eq!(recovered, 1);
let recovered_again = restarted.recover(None).expect("recovery should succeed");
assert_eq!(recovered_again, 0);
assert!(!sidecar.exists(), "sidecar file should be cleared");
}
#[test]
fn test_startup_recovery_completes_durable_but_unfinalized_commit() {
use std::sync::atomic::AtomicUsize;
use crate::participant::TransactionCommitSink;
use linkrs_core::types::CommitLsn;
struct FailOnceFinalizeSink {
finalize_calls: AtomicUsize,
}
impl TransactionCommitSink for FailOnceFinalizeSink {
fn commit_transaction(&self, _transaction_id: TransactionId) -> Result<CommitLsn, String> {
Ok(CommitLsn::new(21))
}
fn abort_transaction(&self, _transaction_id: TransactionId) -> Result<(), String> {
Ok(())
}
fn finalize_commit(
&self,
_descriptor: &crate::participant::TransactionCommitDescriptor,
_commit_lsn: CommitLsn,
) -> Result<(), String> {
let calls = self
.finalize_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if calls == 0 {
return Err("injected crash between commit and finalize".to_string());
}
Ok(())
}
fn recover_unfinalized_commits(&self) -> Result<usize, String> {
Ok(0)
}
}
let config = TransactionManagerConfig {
auto_cleanup: false,
commit_retry_attempts: 0,
..Default::default()
};
let manager =
TransactionManager::new(config).with_commit_sink(Arc::new(FailOnceFinalizeSink {
finalize_calls: AtomicUsize::new(0),
}));
let txn_id = manager
.begin_insert_transaction(TransactionOptions::default())
.expect("transaction should begin");
let context = manager.get_context(txn_id).expect("context should exist");
let frontier_before = manager.read_timestamp();
manager
.commit_transaction(txn_id)
.expect_err("commit must fail at the crash point");
let recovered = manager
.startup_recovery()
.expect("startup recovery should succeed");
assert_eq!(recovered, 1);
assert_eq!(context.state(), TransactionState::Committed);
assert!(manager.get_context(txn_id).is_err());
assert!(manager.read_timestamp() > frontier_before);
let recovered_again = manager
.startup_recovery()
.expect("second recovery should succeed");
assert_eq!(recovered_again, 0);
}