use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use web_time::{SystemTime, UNIX_EPOCH};
use crate::Config;
use crate::actor::{Actor, ActorContext, Addr};
use crate::message::{BatchPut, Get, Message, Put};
use crate::types::*;
use async_trait::async_trait;
use log::{debug, error, info};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
const BEAM_NODES: TableDefinition<&str, &[u8]> = TableDefinition::new("beam_nodes_v1");
const BEAM_META: TableDefinition<&str, u64> = TableDefinition::new("beam_meta_v1");
macro_rules! unwrap_or_return {
($e:expr) => {
match $e {
Ok(v) => v,
Err(e) => {
error!("redb operation failed: {:?}", e);
return;
}
}
};
}
pub struct RedbStorage {
db: Arc<Database>,
path: String,
_config: Config,
}
impl Clone for RedbStorage {
fn clone(&self) -> Self {
Self {
db: Arc::clone(&self.db),
path: self.path.clone(),
_config: self._config.clone(),
}
}
}
impl RedbStorage {
pub fn new() -> Self {
Self::new_with_config(Config::default(), "beam.redb", None)
}
pub fn new_with_config<P: AsRef<Path>>(
config: Config,
path: P,
_max_size: Option<u64>,
) -> Self {
let path = path.as_ref().to_string_lossy().into_owned();
let db = Database::create(&path).unwrap_or_else(|e| {
panic!("Failed to create/open redb at {}: {:?}", path, e);
});
Self {
db: Arc::new(db),
path,
_config: config,
}
}
fn handle_get(&self, get: Get, ctx: &ActorContext) {
let read_txn = unwrap_or_return!(self.db.begin_read());
let table = unwrap_or_return!(read_txn.open_table(BEAM_NODES));
let children_for_node = match table.get(&*get.node_id) {
Ok(Some(access_guard)) => {
let bytes = access_guard.value();
unwrap_or_return!(postcard::from_bytes::<BTreeMap<String, NodeData>>(bytes))
}
Ok(None) => {
debug!("redb get: no data for node_id={}", get.node_id);
let mut reply_with_nodes = BTreeMap::new();
reply_with_nodes.insert(get.node_id.clone(), BTreeMap::new());
let put = Put::new(reply_with_nodes, Some(get.id.clone()), ctx.addr.clone());
put.to_string(); let _ = get.from.send(Message::Put(put));
return;
}
Err(e) => {
error!("redb get failed: {:?}", e);
return;
}
};
let reply_with_children = match &get.child_key {
Some(target_key) => {
let mut c = BTreeMap::new();
if let Some(node_data) = children_for_node.get(target_key) {
c.insert(target_key.clone(), node_data.clone());
}
c
}
None => children_for_node,
};
let mut reply_with_nodes = BTreeMap::new();
reply_with_nodes.insert(get.node_id.clone(), reply_with_children);
let put = Put::new(reply_with_nodes, Some(get.id.clone()), ctx.addr.clone());
put.to_string();
let is_ack = put.in_response_to.is_some();
if is_ack || put.checksum != get.checksum {
let _ = get.from.send(Message::Put(put));
} else {
debug!("redb get: checksum match, not replying");
}
}
fn apply_put_to_tables(
&self,
wtxn: &mut redb::WriteTransaction,
put: Put,
) -> Result<(), redb::Error> {
let mut node_table = wtxn.open_table(BEAM_NODES)?;
let mut meta_table = wtxn.open_table(BEAM_META)?;
for (node_id, update_data) in put.updated_nodes.iter().rev() {
if !node_id.is_empty() && node_id.starts_with('_') {
continue;
}
let mut children_for_node: BTreeMap<String, NodeData> =
match node_table.get(&**node_id)? {
Some(access_guard) => {
let bytes = access_guard.value();
postcard::from_bytes(bytes).unwrap_or_default()
}
None => BTreeMap::new(),
};
for (child_id, child_data) in update_data {
let should_write = !matches!(
children_for_node.get(child_id),
Some(existing) if existing.updated_at > child_data.updated_at
);
if should_write {
children_for_node.insert(child_id.clone(), child_data.clone());
}
}
if children_for_node.is_empty() {
node_table.remove(&**node_id)?;
} else {
let bytes = postcard::to_allocvec(&children_for_node).map_err(|e| {
redb::Error::Io(std::io::Error::other(format!(
"postcard serialize: {:?}",
e
)))
})?;
node_table.insert(&**node_id, bytes.as_slice())?;
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
meta_table.insert("_last_write", now)?;
Ok(())
}
fn handle_put_internal(&self, put: Put) -> Result<(), redb::Error> {
let mut wtxn = self.db.begin_write()?;
self.apply_put_to_tables(&mut wtxn, put)?;
wtxn.commit()?;
Ok(())
}
fn handle_batch_put(&self, batch: BatchPut) -> Result<(), redb::Error> {
let mut wtxn = self.db.begin_write()?;
for put in batch.puts {
self.apply_put_to_tables(&mut wtxn, put)?;
}
wtxn.commit()?;
Ok(())
}
}
#[async_trait]
impl Actor for RedbStorage {
async fn pre_start(&mut self, _ctx: &ActorContext) {
debug!("RedbStorage started at {}", self.path);
if let Ok(wtxn) = self.db.begin_write() {
let _ = wtxn.open_table(BEAM_NODES);
let _ = wtxn.open_table(BEAM_META);
let _ = wtxn.commit();
}
}
async fn stopping(&mut self, _ctx: &ActorContext) {
info!(
"RedbStorage stopping at {} — all writes committed",
self.path
);
}
async fn handle(&mut self, message: Arc<Message>, ctx: &ActorContext) {
match &*message {
Message::Get(get) => self.handle_get(get.clone(), ctx),
Message::Put(put) => {
let put_id = put.id.clone();
let put_from = put.from.clone();
let put = put.clone();
let storage = self.clone();
let result =
tokio::task::spawn_blocking(move || storage.handle_put_internal(put)).await;
self.send_put_ack_after_commit(&put_id, &put_from, &result, ctx);
}
Message::BatchPut(batch) => {
let batch_id = batch.id.clone();
let batch_from = batch.from.clone();
let batch = batch.clone();
let storage = self.clone();
let result =
tokio::task::spawn_blocking(move || storage.handle_batch_put(batch)).await;
self.send_batch_put_ack_after_commit(&batch_id, &batch_from, &result, ctx);
}
Message::Flush(flush) => {
let flush_id = flush.id.clone();
let from_addr = flush.from.clone();
let ctx_addr = ctx.addr.clone();
let mut ack_children = BTreeMap::new();
ack_children.insert(
"_flushed".to_string(),
NodeData {
value: Value::Text("true".to_string()),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
);
let mut ack_nodes = BTreeMap::new();
ack_nodes.insert("_ack".to_string(), ack_children);
let put = Put::new(ack_nodes, Some(flush_id), ctx_addr.clone());
put.to_string(); let _ = from_addr.send(Message::Put(put));
}
_ => {}
}
}
fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
Some(Box::new(self.clone()))
}
}
impl RedbStorage {
fn send_put_ack_after_commit(
&self,
put_id: &str,
put_from: &Addr,
result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
ctx: &ActorContext,
) {
let (ack_children, err_msg) = match result {
Ok(Ok(())) => (
vec![(
"_ack".to_string(),
NodeData {
value: Value::Text("ok".to_string()),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
None,
),
Ok(Err(e)) => {
error!("redb put commit failed: {:?}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(format!("{:?}", e)),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect(),
Some(format!("redb put commit failed: {:?}", e)),
)
}
Err(e) => {
error!("redb put task panicked: {:?}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(format!("task panicked: {:?}", e)),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect(),
Some(format!("redb put task panicked: {:?}", e)),
)
}
};
let mut nodes = BTreeMap::new();
nodes.insert("_ack".to_string(), ack_children);
let ack = Put::new(nodes, Some(put_id.to_string()), ctx.addr.clone());
let _ = put_from.send(Message::Put(ack));
if err_msg.is_some() {
debug!("redb put ack sent with _err for {}", put_id);
}
}
fn send_batch_put_ack_after_commit(
&self,
batch_id: &str,
batch_from: &Addr,
result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
ctx: &ActorContext,
) {
let (ack_children, err_msg) = match result {
Ok(Ok(())) => (
vec![(
"_ack".to_string(),
NodeData {
value: Value::Text("ok".to_string()),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
None,
),
Ok(Err(e)) => {
error!("redb batch_put commit failed: {:?}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(format!("{:?}", e)),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect(),
Some(format!("redb batch_put commit failed: {:?}", e)),
)
}
Err(e) => {
error!("redb batch_put task panicked: {:?}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(format!("task panicked: {:?}", e)),
updated_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64,
},
)]
.into_iter()
.collect(),
Some(format!("redb batch_put task panicked: {:?}", e)),
)
}
};
let mut nodes = BTreeMap::new();
nodes.insert("_ack".to_string(), ack_children);
let ack = Put::new(nodes, Some(batch_id.to_string()), ctx.addr.clone());
let _ = batch_from.send(Message::Put(ack));
if err_msg.is_some() {
debug!("redb batch_put ack sent with _err for {}", batch_id);
}
}
}
impl Default for RedbStorage {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_storage(suffix: &str) -> RedbStorage {
let path = format!("/tmp/beam-test-{}-{}.redb", std::process::id(), suffix);
RedbStorage::new_with_config(Config::default(), &path, None)
}
#[tokio::test]
async fn test_redb_storage_creates_db() {
let storage = create_test_storage("create");
assert!(!storage.path.is_empty());
let _ = std::fs::remove_file(&storage.path);
}
#[tokio::test]
async fn test_redb_storage_default() {
let storage = RedbStorage::default();
let _ = std::fs::remove_file(&storage.path);
}
#[tokio::test]
async fn test_redb_storage_clone() {
let storage = create_test_storage("clone");
let cloned = storage.clone();
assert_eq!(storage.path, cloned.path);
let _ = std::fs::remove_file(&storage.path);
}
#[tokio::test]
async fn test_redb_get_always_replies_when_in_response_to_set() {
use crate::actor::{Actor, ActorContext};
use crate::message::Put;
use std::collections::BTreeMap;
let mut storage = create_test_storage("ack-always");
let ctx = ActorContext::new("test".to_string());
let mut children = BTreeMap::new();
children.insert(
"k".to_string(),
NodeData {
value: Value::Text("v".to_string()),
updated_at: 0.0,
},
);
let mut nodes = BTreeMap::new();
nodes.insert("n1".to_string(), children.clone());
let seed_put = Put::new(nodes, None, ctx.addr.clone());
Actor::handle(&mut storage, Arc::new(Message::Put(seed_put)), &ctx).await;
let (tx, rx) = crate::mailbox::mailbox(16);
let from_addr = crate::actor::Addr::new(tx);
let mut rx = rx;
let reply = Put::new(
{
let mut m = BTreeMap::new();
m.insert("n1".to_string(), children.clone());
m
},
Some("get-id-42".to_string()),
ctx.addr.clone(),
);
reply.to_string(); let matching_checksum = reply.checksum;
let get = Get {
id: "get-id-42".to_string(),
from: from_addr.clone(),
recipients: None,
node_id: "n1".to_string(),
checksum: matching_checksum,
child_key: None,
};
Actor::handle(&mut storage, Arc::new(Message::Get(get)), &ctx).await;
let received =
crate::tokio_time::timeout(web_time::Duration::from_millis(500), rx.recv()).await;
let _ = std::fs::remove_file(&storage.path);
match received {
Ok(Some(msg)) => match &*msg {
Message::Put(reply_put) => {
assert_eq!(
reply_put.in_response_to.as_deref(),
Some("get-id-42"),
"reply must carry in_response_to so client can drain sentinel"
);
}
other => panic!("expected Put reply, got {:?}", other),
},
Ok(None) => panic!("sender closed before reply sent"),
Err(_) => panic!(
"BUG: redb_storage stayed silent despite matching in_response_to. \
This hangs drain_until_sentinel forever."
),
}
}
}