#![cfg_attr(
not(feature = "memory"),
doc = r#"
Without `memory`, neither the root reexport nor the backend module is public:
```compile_fail
use actordb::store::MemoryStore;
```
```compile_fail
use actordb::store::memory::MemoryStore;
```
"#
)]
#![cfg_attr(
not(feature = "rocksdb"),
doc = r#"
Without `rocksdb`, neither the root reexport nor the backend module is public:
```compile_fail
use actordb::store::RocksDbStore;
```
```compile_fail
use actordb::store::rocksdb::RocksDbStore;
```
RocksDB open options and Node's convenience constructor are also opt-in:
```compile_fail
use actordb::store::RocksDbOptions;
```
```compile_fail
let config = actordb::node::NodeConfig::with_rocksdb("./data/node-a");
```
"#
)]
use std::fmt;
use crate::actor::VersionedValue;
pub(crate) mod executor;
#[cfg(feature = "memory")]
pub mod memory;
pub mod prefixed;
#[cfg(feature = "rocksdb")]
pub mod rocksdb;
#[cfg(feature = "memory")]
pub use memory::MemoryStore;
pub use prefixed::PrefixedStore;
#[cfg(feature = "rocksdb")]
pub use rocksdb::RocksDbStore;
#[derive(Clone, Debug, bon::Builder)]
pub struct PersistenceConfig {
#[builder(default = 10_000)]
max_records: usize,
#[builder(default = 64 * 1024 * 1024)]
max_bytes: usize,
#[builder(default = 256 * 1024 * 1024)]
node_max_bytes: usize,
#[builder(default = 4)]
storage_concurrency: usize,
#[builder(skip = std::sync::Arc::new(std::sync::OnceLock::new()))]
resources: std::sync::Arc<std::sync::OnceLock<PersistenceResources>>,
}
#[derive(Debug)]
struct PersistenceResources {
aggregate: std::sync::Arc<crate::retained::ByteBudget>,
executor: std::sync::Arc<executor::StorageExecutor>,
}
impl Default for PersistenceConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl PersistenceConfig {
fn resources(&self) -> &PersistenceResources {
self.resources.get_or_init(|| PersistenceResources {
aggregate: crate::retained::ByteBudget::new(self.node_max_bytes),
executor: executor::StorageExecutor::new(self.storage_concurrency),
})
}
pub(crate) fn aggregate(&self) -> std::sync::Arc<crate::retained::ByteBudget> {
self.resources().aggregate.clone()
}
pub(crate) fn executor(&self) -> std::sync::Arc<executor::StorageExecutor> {
self.resources().executor.clone()
}
pub fn storage_concurrency(&self) -> usize {
self.storage_concurrency
}
pub fn active_storage_jobs(&self) -> usize {
self.resources().executor.active()
}
pub fn max_records(&self) -> usize {
self.max_records
}
pub fn max_bytes(&self) -> usize {
self.max_bytes
}
pub fn node_max_bytes(&self) -> usize {
self.node_max_bytes
}
pub fn retained_bytes(&self) -> usize {
self.resources().aggregate.used()
}
pub fn validate(&self) -> Result<(), String> {
if self.storage_concurrency == 0
|| self.storage_concurrency > u32::MAX as usize
|| self.storage_concurrency > tokio::sync::Semaphore::MAX_PERMITS
{
return Err("storage concurrency must fit a positive u32".into());
}
if self.max_records == 0 || self.max_bytes == 0 || self.node_max_bytes == 0 {
return Err("persistence admission limits must be nonzero".to_string());
}
Ok(())
}
pub(crate) fn for_runtime(&self) -> Result<Self, String> {
self.validate()?;
let mut runtime = self.clone();
runtime.resources = std::sync::Arc::new(std::sync::OnceLock::new());
Ok(runtime)
}
}
#[derive(Debug)]
pub struct StoreError(pub String);
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "store error: {}", self.0)
}
}
impl std::error::Error for StoreError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoreIdentity {
node_id: String,
fragment_count: u32,
}
impl StoreIdentity {
pub fn new(node_id: String, fragment_count: u32) -> Self {
Self {
node_id,
fragment_count,
}
}
pub fn node_id(&self) -> &str {
&self.node_id
}
pub fn fragment_count(&self) -> u32 {
self.fragment_count
}
}
pub struct StoreBinding {
release: Option<Box<dyn FnOnce() + Send + Sync>>,
}
impl StoreBinding {
pub fn unmanaged() -> Self {
Self { release: None }
}
pub fn managed(release: impl FnOnce() + Send + Sync + 'static) -> Self {
Self {
release: Some(Box::new(release)),
}
}
pub(crate) fn abandon(&mut self) {
if let Some(release) = self.release.take() {
std::mem::forget(release);
}
}
}
impl Drop for StoreBinding {
fn drop(&mut self) {
if let Some(release) = self.release.take() {
release();
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PrefixDeleteSupport {
#[default]
Retained,
Supported,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StoreCommitDurability {
#[default]
Buffered,
Stable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrefixDeleteOutcome {
Deleted,
Retained,
}
pub trait KvStore: Send + Sync + 'static {
fn bind(&self, _identity: &StoreIdentity) -> Result<StoreBinding, StoreError> {
Ok(StoreBinding::unmanaged())
}
fn load(&self) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError>;
fn load_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError> {
Ok(self
.load()?
.into_iter()
.filter(|(key, _)| key.starts_with(prefix))
.collect())
}
fn commit_durability(&self) -> StoreCommitDurability {
StoreCommitDurability::Buffered
}
fn stable_fence(&self) -> Result<StoreCommitDurability, StoreError> {
Ok(self.commit_durability())
}
fn write_batch(&self, writes: &[(Vec<u8>, VersionedValue)]) -> Result<(), StoreError>;
fn prefix_delete_support(&self) -> PrefixDeleteSupport {
PrefixDeleteSupport::Retained
}
fn delete_prefix(&self, _prefix: &[u8]) -> Result<PrefixDeleteOutcome, StoreError> {
Ok(PrefixDeleteOutcome::Retained)
}
}
#[cfg(feature = "rocksdb")]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RocksDbDurability {
AsyncWal,
#[default]
SyncWal,
}
#[cfg(feature = "rocksdb")]
#[derive(Clone, Copy, Debug, PartialEq, Eq, bon::Builder)]
pub struct RocksDbOptions {
#[builder(default = RocksDbDurability::SyncWal)]
durability: RocksDbDurability,
#[builder(default = false, name = legacy_manifest_adoption)]
adopt_legacy_manifest: bool,
}
#[cfg(feature = "rocksdb")]
impl Default for RocksDbOptions {
fn default() -> Self {
Self::builder().build()
}
}
#[cfg(feature = "rocksdb")]
impl RocksDbOptions {
pub fn with_durability(mut self, durability: RocksDbDurability) -> Self {
self.durability = durability;
self
}
pub fn with_legacy_manifest_adoption(mut self, allow: bool) -> Self {
self.adopt_legacy_manifest = allow;
self
}
pub fn durability(&self) -> RocksDbDurability {
self.durability
}
pub fn legacy_manifest_adoption(&self) -> bool {
self.adopt_legacy_manifest
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persistence_admission_rejects_zero_executor_slots() {
assert!(PersistenceConfig::builder()
.storage_concurrency(0)
.build()
.validate()
.is_err());
assert!(PersistenceConfig::default().validate().is_ok());
}
#[test]
fn custom_store_capabilities_default_to_retention_and_buffering() {
struct CustomStore;
impl KvStore for CustomStore {
fn load(&self) -> Result<Vec<(Vec<u8>, VersionedValue)>, StoreError> {
Ok(Vec::new())
}
fn write_batch(&self, _writes: &[(Vec<u8>, VersionedValue)]) -> Result<(), StoreError> {
Ok(())
}
}
let store = CustomStore;
assert_eq!(store.commit_durability(), StoreCommitDurability::Buffered);
assert_eq!(
store.stable_fence().unwrap(),
StoreCommitDurability::Buffered
);
assert_eq!(store.prefix_delete_support(), PrefixDeleteSupport::Retained);
}
}