use super::error;
use crate::StorageResult;
use crate::store::backend::utils::Attempted;
use crate::store::error::StorageError;
use error_stack::ResultExt;
use redb::Database;
use std::path::Path;
use std::sync::Arc;
use tracing::warn;
#[cfg(test)]
use std::io;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
pub(super) type OpenDatabase = Arc<arc_swap::ArcSwapOption<Database>>;
#[cfg(test)]
static FAILING_DISK: parking_lot::Mutex<Option<std::path::PathBuf>> = parking_lot::Mutex::new(None);
#[cfg(test)]
pub(super) static WRITES_LEFT: AtomicUsize = AtomicUsize::new(usize::MAX);
#[cfg(test)]
pub(super) struct ArmedDisk;
#[cfg(test)]
impl Drop for ArmedDisk {
fn drop(&mut self) {
*FAILING_DISK.lock() = None;
WRITES_LEFT.store(usize::MAX, Ordering::SeqCst);
}
}
#[cfg(test)]
pub(super) fn arm_failing_disk(path: &Path) -> ArmedDisk {
WRITES_LEFT.store(usize::MAX, Ordering::SeqCst);
*FAILING_DISK.lock() = Some(path.to_path_buf());
ArmedDisk
}
#[cfg(test)]
#[derive(Debug)]
struct FailingBackend {
inner: redb::backends::FileBackend,
}
#[cfg(test)]
impl FailingBackend {
fn spend(&self) -> Result<(), io::Error> {
if WRITES_LEFT
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
left.checked_sub(1)
})
.is_err()
{
return Err(io::Error::other("simulated disk failure"));
}
Ok(())
}
}
#[cfg(test)]
impl redb::StorageBackend for FailingBackend {
fn len(&self) -> Result<u64, io::Error> {
self.inner.len()
}
fn read(&self, offset: u64, out: &mut [u8]) -> Result<(), io::Error> {
self.inner.read(offset, out)
}
fn set_len(&self, len: u64) -> Result<(), io::Error> {
self.inner.set_len(len)
}
fn sync_data(&self) -> Result<(), io::Error> {
self.spend()?;
self.inner.sync_data()
}
fn write(&self, offset: u64, data: &[u8]) -> Result<(), io::Error> {
self.spend()?;
self.inner.write(offset, data)
}
}
pub(super) fn will_not_read(why: &redb::DatabaseError) -> bool {
match why {
redb::DatabaseError::UpgradeRequired(_) => true,
redb::DatabaseError::Storage(redb::StorageError::Corrupted(_)) => true,
redb::DatabaseError::Storage(redb::StorageError::Io(failed)) => {
failed.kind() == std::io::ErrorKind::InvalidData
}
_ => false,
}
}
pub(super) fn create_database(path: &Path) -> Result<Database, redb::DatabaseError> {
#[cfg(test)]
if FAILING_DISK.lock().as_deref() == Some(path) {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| redb::DatabaseError::Storage(redb::StorageError::Io(e)))?;
return Database::builder().create_with_backend(FailingBackend {
inner: redb::backends::FileBackend::new(file)?,
});
}
Database::create(path)
}
pub(super) fn is_previous_io(report: &error_stack::Report<StorageError>) -> bool {
use redb::StorageError::PreviousIo;
report.frames().any(|frame| {
frame
.downcast_ref::<redb::StorageError>()
.is_some_and(|e| matches!(e, PreviousIo))
|| frame
.downcast_ref::<redb::TransactionError>()
.is_some_and(|e| matches!(e, redb::TransactionError::Storage(PreviousIo)))
|| frame
.downcast_ref::<redb::CommitError>()
.is_some_and(|e| matches!(e, redb::CommitError::Storage(PreviousIo)))
|| frame
.downcast_ref::<redb::TableError>()
.is_some_and(|e| matches!(e, redb::TableError::Storage(PreviousIo)))
|| frame
.downcast_ref::<error::RedbStoreError>()
.is_some_and(|e| {
matches!(
e,
error::RedbStoreError::Storage(PreviousIo)
| error::RedbStoreError::Transaction(redb::TransactionError::Storage(
PreviousIo
))
| error::RedbStoreError::Commit(redb::CommitError::Storage(PreviousIo))
| error::RedbStoreError::Table(redb::TableError::Storage(PreviousIo))
)
})
})
}
pub(super) fn reopen(db: &OpenDatabase, path: &Path) -> StorageResult<()> {
db.store(None);
let fresh = create_database(path)
.doing(StorageError::Open, path)
.attach("reopening the database after an I/O failure")?;
db.store(Some(Arc::new(fresh)));
warn!(
target: "amethystate",
file = %path.display(),
"reopened the database after an I/O failure",
);
Ok(())
}
pub(super) fn for_writing(
db: &OpenDatabase,
path: &Path,
released: &std::sync::atomic::AtomicBool,
) -> StorageResult<Option<Arc<Database>>> {
if let Some(open) = db.load_full() {
return Ok(Some(open));
}
if released.load(std::sync::atomic::Ordering::Acquire) {
return Ok(None);
}
reopen(db, path)?;
Ok(db.load_full())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StoreBackend;
use crate::migration::set::MigrationSet;
use crate::store::StoreExt;
use crate::store::config::StoreConfig;
use crate::stores::RedbStore;
use amethystate_core::path::StorePath;
use amethystate_core::test_utils::TempPath;
use serial_test::serial;
use std::time::Duration;
#[test]
#[serial]
fn the_database_can_be_traded_for_a_fresh_one_under_a_live_store() {
let path = TempPath::new("redb_reopen");
let (store, _) = RedbStore::open(StoreConfig::new(&path), MigrationSet::default()).unwrap();
store
.set(StorePath::from_segments(["before"]), &1u32)
.unwrap();
store.save_now().unwrap();
let _guard = store.inner.write_lock.lock();
reopen(&store.inner.db, &store.inner.path)
.expect("the old handle should have released the file lock");
drop(_guard);
assert_eq!(
store
.get::<u32>(StorePath::from_segments(["before"]))
.unwrap(),
Some(1),
"what was committed before the reopen is still there"
);
store
.set(StorePath::from_segments(["after"]), &2u32)
.unwrap();
store.save_now().unwrap();
assert_eq!(
store
.get::<u32>(StorePath::from_segments(["after"]))
.unwrap(),
Some(2),
"and the store writes through the new handle"
);
}
#[test]
#[serial]
fn a_disk_that_fails_for_real_is_recovered_by_trading_the_handle() {
let path = TempPath::new("redb_previous_io");
let _disk = arm_failing_disk(&path);
let (store, _) = RedbStore::open(StoreConfig::new(&path), MigrationSet::default()).unwrap();
store
.set(StorePath::from_segments(["survivor"]), &42u32)
.unwrap();
WRITES_LEFT.store(0, Ordering::SeqCst);
let _ = store.inner.flush_locked(&StorePath::root());
let latched = store.inner.flush_locked(&StorePath::root()).unwrap_err();
assert!(
is_previous_io(&latched),
"redb latched but the store did not recognise it: {latched:?}"
);
WRITES_LEFT.store(usize::MAX, Ordering::SeqCst);
store
.save_now()
.expect("the store should trade the latched handle in and land the write");
assert_eq!(
store
.get::<u32>(StorePath::from_segments(["survivor"]))
.unwrap(),
Some(42),
"the write buffered before the failure is on disk after the recovery"
);
}
#[test]
#[serial]
fn a_reopen_that_failed_is_tried_again_once_the_disk_is_back() {
let path = TempPath::new("redb_reopen_failed");
let _disk = arm_failing_disk(&path);
let (store, _) = RedbStore::open(StoreConfig::new(&path), MigrationSet::default()).unwrap();
store
.set(StorePath::from_segments(["survivor"]), &42u32)
.unwrap();
WRITES_LEFT.store(0, Ordering::SeqCst);
let _ = store.inner.flush_locked(&StorePath::root());
let _ = store.save_now();
assert!(
store.inner.db.load_full().is_none(),
"the reopen went through on a disk that takes no writes"
);
WRITES_LEFT.store(usize::MAX, Ordering::SeqCst);
store.save_now().unwrap();
assert_eq!(
store
.get::<u32>(StorePath::from_segments(["survivor"]))
.unwrap(),
Some(42)
);
}
#[test]
#[serial]
fn a_closed_store_does_not_take_its_file_back() {
let path = TempPath::new("redb_closed_stays_closed");
let (store, _) = RedbStore::open(StoreConfig::new(&path), MigrationSet::default()).unwrap();
store.inner.close().unwrap();
let _ = store.save_now();
let _ = store.get::<u32>(StorePath::from_segments(["anything"]));
assert!(store.inner.db.load_full().is_none());
}
#[test]
#[serial]
fn a_buffered_write_survives_the_reopen() {
let path = TempPath::new("redb_reopen_buffered");
let mut config = StoreConfig::new(&path);
config.save_debounce = Duration::from_secs(60);
let (store, _) = RedbStore::open(config, MigrationSet::default()).unwrap();
store
.set(StorePath::from_segments(["pending"]), &7u32)
.unwrap();
{
let _guard = store.inner.write_lock.lock();
reopen(&store.inner.db, &store.inner.path).unwrap();
}
store.save_now().unwrap();
assert_eq!(
store
.get::<u32>(StorePath::from_segments(["pending"]))
.unwrap(),
Some(7),
"the write was buffered before the reopen and belongs to the store"
);
}
}