use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use parking_lot::{Condvar, Mutex};
use redb::{Database, Durability, ReadableDatabase, ReadableTable, Table, TableDefinition};
use uuid::Uuid;
pub use self::redb_bytes::RedbBytes;
use crate::backing_store::{BackingStoreT, Strategy};
mod redb_bytes;
const BLOBS: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("file_backed_blobs");
static WRITE_TRANSACTIONS: AtomicU64 = AtomicU64::new(0);
pub fn total_write_transactions() -> u64 {
WRITE_TRANSACTIONS.load(Ordering::Relaxed)
}
fn count_committed_write_transaction() {
WRITE_TRANSACTIONS.fetch_add(1, Ordering::Relaxed);
}
pub const REDB_FILE_NAME: &str = "file_backed.redb";
pub struct RedbStore<C> {
codec: C,
db: RedbPath,
}
pub trait BlobCodec<T>: Send + Sync + 'static {
fn encode(&self, data: &T) -> anyhow::Result<Vec<u8>>;
fn encode_with<R>(&self, data: &T, use_bytes: impl FnOnce(&[u8]) -> R) -> anyhow::Result<R> {
Ok(use_bytes(&self.encode(data)?))
}
fn decode(&self, data: &[u8]) -> anyhow::Result<T>;
}
#[cfg(feature = "redb-bincodec")]
pub struct BinCodec;
#[cfg(feature = "redb-bincodec")]
impl<T: serde::Serialize + serde::de::DeserializeOwned> BlobCodec<T> for BinCodec {
fn encode(&self, data: &T) -> anyhow::Result<Vec<u8>> {
Ok(bincode::serde::encode_to_vec(
data,
bincode::config::legacy(),
)?)
}
fn decode(&self, data: &[u8]) -> anyhow::Result<T> {
Ok(bincode::serde::decode_from_slice(data, bincode::config::legacy())?.0)
}
}
#[cfg(feature = "redb-prostcodec")]
pub struct ProstCodec;
#[cfg(feature = "redb-prostcodec")]
impl<T: Default + prost::Message> BlobCodec<T> for ProstCodec {
fn encode(&self, data: &T) -> anyhow::Result<Vec<u8>> {
Ok(data.encode_to_vec())
}
fn decode(&self, data: &[u8]) -> anyhow::Result<T> {
Ok(T::decode(data)?)
}
}
#[derive(Clone)]
pub struct RedbPath {
inner: Arc<RedbInner>,
}
struct RedbInner {
path: PathBuf,
db: Database,
group: Mutex<GroupState>,
cv: Condvar,
}
struct GroupState {
txn: Option<redb::WriteTransaction>,
committing: bool,
epoch: u64,
ops_in_txn: usize,
appliers_waiting: usize,
poisoned: bool,
}
struct PoisonArm<'a> {
inner: &'a RedbInner,
armed: bool,
}
impl<'a> PoisonArm<'a> {
fn new(inner: &'a RedbInner) -> Self {
Self { inner, armed: true }
}
fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for PoisonArm<'_> {
fn drop(&mut self) {
if self.armed {
let mut group = self.inner.group.lock();
group.poisoned = true;
self.inner.cv.notify_all();
}
}
}
#[cfg(test)]
static WRITE_COUNT_TEST_LOCK: Mutex<()> = Mutex::new(());
const MAX_OPS_PER_COMMIT: usize = 4096;
impl RedbInner {
fn assert_not_poisoned(&self, group: &GroupState) {
assert!(
!group.poisoned,
"A previous redb write batch for {} panicked",
self.path.display()
);
}
fn wait_for_open_txn<'a>(
&self,
group: &mut parking_lot::MutexGuard<'a, GroupState>,
label: &str,
) {
self.assert_not_poisoned(group);
while group.committing {
group.appliers_waiting += 1;
self.cv.wait(group);
group.appliers_waiting -= 1;
self.assert_not_poisoned(group);
}
if group.txn.is_none() {
let mut txn = self.db.begin_write().unwrap_or_else(|err| {
panic!(
"Failed to begin redb write transaction for {} store {}: {err:?}",
label,
self.path.display()
)
});
txn.set_durability(Durability::None).unwrap_or_else(|err| {
panic!(
"Failed to set redb durability for {} store {}: {err:?}",
label,
self.path.display()
)
});
group.txn = Some(txn);
group.ops_in_txn = 0;
}
}
fn submit_write(&self, label: &str, apply: impl FnOnce(&mut Table<'_, &[u8; 16], &[u8]>)) {
let poison_arm = PoisonArm::new(self);
let mut group = self.group.lock();
self.wait_for_open_txn(&mut group, label);
{
let txn = group.txn.as_ref().unwrap();
let mut table = txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table for {} store {}: {err:?}",
label,
self.path.display()
)
});
apply(&mut table);
}
group.ops_in_txn += 1;
let my_epoch = group.epoch;
if group.appliers_waiting == 0 || group.ops_in_txn >= MAX_OPS_PER_COMMIT {
let txn = group.txn.take().unwrap();
self.commit_batch(group, txn, label);
} else {
while group.epoch == my_epoch {
self.assert_not_poisoned(&group);
self.cv.wait(&mut group);
}
}
poison_arm.disarm();
}
fn commit_durable(&self) {
let poison_arm = PoisonArm::new(self);
let mut group = self.group.lock();
self.wait_for_open_txn(&mut group, "sync");
let mut txn = group.txn.take().unwrap();
txn.set_durability(Durability::Immediate)
.unwrap_or_else(|err| {
panic!(
"Failed to set redb sync durability for {}: {err:?}",
self.path.display()
)
});
self.commit_batch(group, txn, "sync");
poison_arm.disarm();
}
fn commit_batch(
&self,
mut group: parking_lot::MutexGuard<'_, GroupState>,
txn: redb::WriteTransaction,
label: &str,
) {
group.committing = true;
drop(group);
txn.commit().unwrap_or_else(|err| {
panic!(
"Failed to commit redb write batch for {} store {}: {err:?}",
label,
self.path.display()
)
});
count_committed_write_transaction();
let mut group = self.group.lock();
group.committing = false;
group.epoch += 1;
self.cv.notify_all();
}
}
impl RedbPath {
pub fn new(path: PathBuf) -> Self {
let db = Database::create(&path).unwrap_or_else(|err| {
panic!("Failed to open redb database {}: {err:?}", path.display())
});
Self::from_db(path, db)
}
pub fn new_with_cache_size(path: PathBuf, cache_size_bytes: usize) -> Self {
let db = Database::builder()
.set_cache_size(cache_size_bytes)
.create(&path)
.unwrap_or_else(|err| {
panic!("Failed to open redb database {}: {err:?}", path.display())
});
Self::from_db(path, db)
}
pub fn in_dir(dir: &Path) -> Self {
Self::new(db_path_in(dir))
}
pub fn in_dir_with_cache_size(dir: &Path, cache_size_bytes: usize) -> Self {
Self::new_with_cache_size(db_path_in(dir), cache_size_bytes)
}
fn from_db(path: PathBuf, db: Database) -> Self {
let this = Self {
inner: Arc::new(RedbInner {
path,
db,
group: Mutex::new(GroupState {
txn: None,
committing: false,
epoch: 0,
ops_in_txn: 0,
appliers_waiting: 0,
poisoned: false,
}),
cv: Condvar::new(),
}),
};
this.ensure_table();
this
}
pub fn path(&self) -> &Path {
&self.inner.path
}
fn db(&self) -> &Database {
&self.inner.db
}
#[deprecated(
note = "the output bytes are only deterministic on a single machine; they differ \
across machines, so this cannot provide canonical bytes for cross-machine \
comparison"
)]
pub fn write_canonical(&self, dest: &Path) {
assert!(
!dest.exists(),
"Canonical copy destination already exists: {}",
dest.display()
);
let read_txn = self.db().begin_read().unwrap_or_else(|err| {
panic!(
"Failed to begin redb read transaction for {}: {err:?}",
self.path().display()
)
});
let src_table = read_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table in {}: {err:?}",
self.path().display()
)
});
let dest_db = Database::create(dest).unwrap_or_else(|err| {
panic!(
"Failed to create canonical redb database {}: {err:?}",
dest.display()
)
});
let mut write_txn = dest_db.begin_write().unwrap_or_else(|err| {
panic!(
"Failed to begin redb write transaction for {}: {err:?}",
dest.display()
)
});
write_txn
.set_durability(Durability::Immediate)
.unwrap_or_else(|err| {
panic!(
"Failed to set redb durability for {}: {err:?}",
dest.display()
)
});
{
let mut dest_table = write_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!("Failed to open redb table in {}: {err:?}", dest.display())
});
for entry in src_table.iter().unwrap_or_else(|err| {
panic!(
"Failed to iterate redb table in {}: {err:?}",
self.path().display()
)
}) {
let (key, value) = entry.unwrap_or_else(|err| {
panic!(
"Failed to read redb table entry in {}: {err:?}",
self.path().display()
)
});
let old = dest_table
.insert(key.value(), value.value())
.unwrap_or_else(|err| {
panic!(
"Failed to insert into canonical redb {}: {err:?}",
dest.display()
)
});
assert!(old.is_none());
}
}
write_txn.commit().unwrap_or_else(|err| {
panic!(
"Failed to commit canonical redb copy {}: {err:?}",
dest.display()
)
});
count_committed_write_transaction();
}
fn ensure_table(&self) {
let write_txn = self.db().begin_write().unwrap_or_else(|err| {
panic!(
"Failed to begin redb write transaction for {}: {err:?}",
self.path().display()
)
});
{
write_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table in {}: {err:?}",
self.path().display()
)
});
}
write_txn.commit().unwrap_or_else(|err| {
panic!(
"Failed to commit redb table initialization for {}: {err:?}",
self.path().display()
)
});
count_committed_write_transaction();
}
}
impl<C> RedbStore<C> {
pub fn new(codec: C, db: RedbPath) -> Self {
Self { codec, db }
}
}
impl<C: Send + Sync + 'static> BackingStoreT for RedbStore<C> {
type PersistPath = RedbPath;
fn delete(&self, key: Uuid) {
remove_key(&self.db, key, "temporary");
}
fn delete_persisted(&self, path: &Self::PersistPath, key: Uuid) {
remove_key(path, key, "persisted");
}
fn register(&self, src_path: &Self::PersistPath, key: Uuid) {
with_bytes(src_path, key, "persisted", |bytes| {
insert_bytes(&self.db, key, bytes, "temporary");
});
}
fn register_many(&self, src_path: &Self::PersistPath, keys: &[Uuid]) {
for chunk in keys.chunks(MAX_OPS_PER_COMMIT) {
let read_txn = src_path.db().begin_read().unwrap_or_else(|err| {
panic!(
"Failed to begin redb read transaction for persisted store {}: {err:?}",
src_path.path().display()
)
});
let src_table = read_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table for persisted store {}: {err:?}",
src_path.path().display()
)
});
self.db.inner.submit_write("temporary", |table| {
for &key in chunk {
let guard = src_table
.get(key.as_bytes())
.unwrap_or_else(|err| {
panic!(
"Failed to read redb key {} from persisted store {}: {err:?}",
key,
src_path.path().display()
)
})
.unwrap_or_else(|| {
panic!(
"Attempted to register missing redb key {} from persisted store {}",
key,
src_path.path().display()
)
});
let old = table
.insert(key.as_bytes(), guard.value())
.unwrap_or_else(|err| {
panic!(
"Failed to insert redb key {} into temporary store {}: {err:?}",
key,
self.db.path().display()
)
});
assert!(
old.is_none(),
"Attempted to overwrite existing redb key {} in temporary store {}",
key,
self.db.path().display()
);
}
});
}
}
fn persist(&self, dest_path: &Self::PersistPath, key: Uuid) {
with_bytes(&self.db, key, "temporary", |bytes| {
insert_bytes(dest_path, key, bytes, "persisted");
});
}
fn sanitize_path(&self, path: &Self::PersistPath) -> impl IntoIterator<Item = Uuid> {
let read_txn = path.db().begin_read().unwrap_or_else(|err| {
panic!(
"Failed to begin redb read transaction for {}: {err:?}",
path.path().display()
)
});
let table = read_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table in {}: {err:?}",
path.path().display()
)
});
table
.iter()
.unwrap_or_else(|err| {
panic!(
"Failed to iterate redb table in {}: {err:?}",
path.path().display()
)
})
.map(|entry| {
let (key, _) = entry.unwrap_or_else(|err| {
panic!(
"Failed to read redb table entry in {}: {err:?}",
path.path().display()
)
});
Uuid::from_bytes(*key.value())
})
.collect::<Vec<_>>()
}
fn sync_persisted(&self, path: &Self::PersistPath) {
path.inner.commit_durable();
}
}
impl<T, C: BlobCodec<T>> Strategy<T> for RedbStore<C> {
fn store(&self, key: Uuid, data: &T) {
self.codec
.encode_with(data, |bytes| {
insert_bytes(&self.db, key, bytes, "temporary");
})
.unwrap_or_else(|err| {
panic!("Failed to encode data for redb key {key}: {err:?}");
});
}
fn load(&self, key: Uuid) -> T {
with_bytes(&self.db, key, "temporary", |bytes| self.codec.decode(bytes)).unwrap_or_else(
|err| {
panic!("Failed to decode data for redb key {key}: {err:?}");
},
)
}
}
pub fn read_blob(path: &RedbPath, key: Uuid) -> Vec<u8> {
with_blob(path, key, <[u8]>::to_vec)
}
pub fn with_blob<R>(path: &RedbPath, key: Uuid, use_bytes: impl FnOnce(&[u8]) -> R) -> R {
with_bytes(path, key, "persisted", use_bytes)
}
fn with_bytes<R>(path: &RedbPath, key: Uuid, label: &str, use_bytes: impl FnOnce(&[u8]) -> R) -> R {
let read_txn = path.db().begin_read().unwrap_or_else(|err| {
panic!(
"Failed to begin redb read transaction for {} store {}: {err:?}",
label,
path.path().display()
)
});
let table = read_txn.open_table(BLOBS).unwrap_or_else(|err| {
panic!(
"Failed to open redb table for {} store {}: {err:?}",
label,
path.path().display()
)
});
let guard = table
.get(key.as_bytes())
.unwrap_or_else(|err| {
panic!(
"Failed to read redb key {} from {} store {}: {err:?}",
key,
label,
path.path().display()
)
})
.unwrap_or_else(|| {
panic!(
"Attempted to read missing redb key {} from {} store {}",
key,
label,
path.path().display()
)
});
use_bytes(guard.value())
}
fn insert_bytes(path: &RedbPath, key: Uuid, bytes: &[u8], label: &str) {
path.inner.submit_write(label, |table| {
let old = table.insert(key.as_bytes(), bytes).unwrap_or_else(|err| {
panic!(
"Failed to insert redb key {} into {} store {}: {err:?}",
key,
label,
path.path().display()
)
});
assert!(
old.is_none(),
"Attempted to overwrite existing redb key {} in {} store {}",
key,
label,
path.path().display()
);
});
}
fn remove_key(path: &RedbPath, key: Uuid, label: &str) {
path.inner.submit_write(label, |table| {
let old = table.remove(key.as_bytes()).unwrap_or_else(|err| {
panic!(
"Failed to remove redb key {} from {} store {}: {err:?}",
key,
label,
path.path().display()
)
});
assert!(
old.is_some(),
"Attempted to delete missing redb key {} from {} store {}",
key,
label,
path.path().display()
);
});
}
fn db_path_in(dir: &Path) -> PathBuf {
std::fs::create_dir_all(dir)
.unwrap_or_else(|err| panic!("Failed to create directory {}: {err:?}", dir.display()));
dir.join(REDB_FILE_NAME)
}
#[cfg(test)]
mod small_cache_tests {
use std::time::Instant;
use tempfile::tempdir;
use uuid::Uuid;
use super::{RedbPath, insert_bytes, with_bytes};
#[test]
fn scan_once_workload_with_tiny_cache() {
let _count_guard = crate::redbstore::WRITE_COUNT_TEST_LOCK.lock();
const BLOB: usize = 32 * 1024;
const N: usize = 2000;
for cache in [64 * 1024, 1024 * 1024, 50 * 1024 * 1024] {
let dir = tempdir().unwrap();
let keys: Vec<Uuid> = (0..N).map(|i| Uuid::from_u128(i as u128 + 1)).collect();
let start_write = Instant::now();
{
let db = RedbPath::in_dir_with_cache_size(dir.path(), cache);
for (i, &key) in keys.iter().enumerate() {
insert_bytes(&db, key, &vec![(i % 251) as u8; BLOB], "persisted");
}
} let write = start_write.elapsed();
let start_scan = Instant::now();
{
let db = RedbPath::in_dir_with_cache_size(dir.path(), cache);
for (i, &key) in keys.iter().enumerate() {
assert_eq!(
with_bytes(&db, key, "persisted", <[u8]>::to_vec),
vec![(i % 251) as u8; BLOB]
);
}
}
let scan = start_scan.elapsed();
eprintln!("cache {cache:>9}B: insert {N}x{BLOB}B = {write:?}, full scan = {scan:?}");
}
}
}
#[cfg(test)]
mod register_many_tests {
use std::sync::Arc;
use tempfile::tempdir;
use tokio::runtime::Handle;
use uuid::Uuid;
use crate::BackingStore;
use super::{BinCodec, RedbPath, RedbStore, total_write_transactions};
#[tokio::test(flavor = "multi_thread")]
async fn bulk_register_batches_commits() {
let _count_guard = crate::redbstore::WRITE_COUNT_TEST_LOCK.lock();
const N: usize = 512;
let cache_dir = tempdir().unwrap();
let persist_dir = tempdir().unwrap();
let mut keys: Vec<Uuid> = Vec::new();
{
let store = Arc::new(BackingStore::new(
RedbStore::new(BinCodec, RedbPath::in_dir(cache_dir.path())),
Handle::current(),
));
let tracked = Arc::new(
store
.track_path(RedbPath::in_dir(persist_dir.path()))
.await
.unwrap(),
);
let pool: Arc<crate::FBPool<Vec<u8>, _>> =
Arc::new(crate::FBPool::new(Arc::clone(&store), 4));
for i in 0..N {
let item = pool.insert(vec![(i % 251) as u8; 64]);
keys.push(item.key());
item.spawn_persist(&tracked).await.await.unwrap();
drop(item);
}
store.finished().await;
}
let store = Arc::new(BackingStore::new(
RedbStore::new(BinCodec, RedbPath::in_dir(cache_dir.path())),
Handle::current(),
));
let tracked = store
.track_path(RedbPath::in_dir(persist_dir.path()))
.await
.unwrap();
assert_eq!(tracked.all_keys().len(), N);
let before = total_write_transactions();
let registered = tokio::task::spawn_blocking({
let store = Arc::clone(&store);
move || store.blocking_register_all(&tracked)
})
.await
.unwrap();
let txns = total_write_transactions() - before;
assert_eq!(registered.len(), N);
assert!(
txns < (N / 8) as u64,
"bulk register of {N} keys took {txns} write transactions"
);
drop(registered);
store.finished().await;
}
}
#[cfg(test)]
mod group_commit_tests {
use tempfile::tempdir;
use uuid::Uuid;
use super::{RedbPath, insert_bytes, total_write_transactions, with_bytes};
#[test]
fn concurrent_writes_share_commits() {
let _count_guard = crate::redbstore::WRITE_COUNT_TEST_LOCK.lock();
const THREADS: usize = 16;
const OPS_PER_THREAD: usize = 64;
let dir = tempdir().unwrap();
let db = RedbPath::in_dir(dir.path());
let keys: Vec<Uuid> = (0..THREADS * OPS_PER_THREAD)
.map(|i| Uuid::from_u128(i as u128 + 1))
.collect();
let before = total_write_transactions();
std::thread::scope(|s| {
for chunk in keys.chunks(OPS_PER_THREAD) {
let db = &db;
s.spawn(move || {
for &key in chunk {
insert_bytes(db, key, key.as_bytes(), "test");
}
});
}
});
let txns = total_write_transactions() - before;
eprintln!("{} ops committed in {txns} transactions", keys.len());
assert!(
txns < (keys.len() / 2) as u64,
"expected group commit to share transactions: {txns} txns for {} ops",
keys.len()
);
for &key in &keys {
with_bytes(&db, key, "test", |bytes| assert_eq!(bytes, key.as_bytes()));
}
}
}
#[cfg(test)]
#[allow(deprecated)]
mod canonical_tests {
use tempfile::tempdir;
use uuid::Uuid;
use super::{RedbPath, insert_bytes, remove_key};
#[test]
fn canonical_copy_is_history_independent() {
let dir = tempdir().unwrap();
let keys: Vec<Uuid> = (0..64).map(|_| Uuid::new_v4()).collect();
let blob = |key: &Uuid| key.as_bytes().repeat(100);
let straight = RedbPath::new(dir.path().join("straight.redb"));
for key in &keys {
insert_bytes(&straight, *key, &blob(key), "test");
}
let churned = RedbPath::new(dir.path().join("churned.redb"));
for key in keys.iter().rev() {
insert_bytes(&churned, *key, &blob(key), "test");
}
for key in &keys[..32] {
remove_key(&churned, *key, "test");
}
for key in &keys[..32] {
insert_bytes(&churned, *key, &blob(key), "test");
}
straight.write_canonical(&dir.path().join("straight_canonical.redb"));
churned.write_canonical(&dir.path().join("churned_canonical.redb"));
let straight_bytes = std::fs::read(dir.path().join("straight_canonical.redb")).unwrap();
let churned_bytes = std::fs::read(dir.path().join("churned_canonical.redb")).unwrap();
assert_ne!(
std::fs::read(straight.path()).unwrap(),
std::fs::read(churned.path()).unwrap()
);
assert_eq!(straight_bytes, churned_bytes);
}
}
#[cfg(all(test, feature = "redb-bincodec"))]
mod tests {
use std::sync::Arc;
use tempfile::tempdir;
use tokio::runtime::Handle;
use crate::{BackingStore, FBPool};
use super::{BinCodec, RedbPath, RedbStore};
#[tokio::test]
async fn persists_registers_and_loads() {
let cache_dir = tempdir().unwrap();
let persist_dir = tempdir().unwrap();
let cache_path = cache_dir.path().join("cache.redb");
let persist_path = persist_dir.path().join("persist.redb");
let persisted_key;
{
let redb_store = RedbStore::new(BinCodec, RedbPath::new(cache_path.clone()));
let store = Arc::new(BackingStore::new(redb_store, Handle::current()));
let pool: Arc<FBPool<String, _>> = Arc::new(FBPool::new(store.clone(), 1));
let tracked_persist = Arc::new(
store
.track_path(RedbPath::new(persist_path.clone()))
.await
.unwrap(),
);
let item = pool.insert("Persisted Data".to_string());
persisted_key = item.key();
item.spawn_persist(&tracked_persist).await.await.unwrap();
drop(item);
store.finished().await;
}
{
let redb_store = RedbStore::new(BinCodec, RedbPath::new(cache_path));
let store = Arc::new(BackingStore::new(redb_store, Handle::current()));
let pool: Arc<FBPool<String, _>> = Arc::new(FBPool::new(store.clone(), 1));
let tracked_persist =
Arc::new(store.track_path(RedbPath::new(persist_path)).await.unwrap());
assert!(tracked_persist.all_keys().contains(&persisted_key));
let item = pool
.register(&tracked_persist, persisted_key)
.await
.expect("registered item");
let guard = item.load().await;
assert_eq!(*guard, "Persisted Data");
drop(guard);
drop(item);
store.finished().await;
}
}
}