use arena_btreemap::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use web_time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use fjall::{Database, Keyspace, KeyspaceCreateOptions, PersistMode};
use log::{debug, error, info};
use crate::Config;
use crate::actor::{Actor, ActorContext, Addr};
use crate::message::{BatchPut, Get, Message, Put};
use crate::types::*;
const BEAM_NODES: &str = "beam_nodes_v1";
const BEAM_META: &str = "beam_meta_v1";
pub(crate) const KEY_PREFIX: u8 = 0x00;
pub(crate) fn encode_key(node_id: &str) -> Vec<u8> {
let mut key = vec![KEY_PREFIX];
key.extend_from_slice(node_id.as_bytes());
key
}
#[allow(dead_code)] pub(crate) fn decode_key(key: &[u8]) -> Option<String> {
if key.is_empty() || key[0] != KEY_PREFIX {
return None;
}
std::str::from_utf8(&key[1..]).ok().map(|s| s.to_string())
}
pub struct FjallStorage {
db: Arc<Database>,
nodes: Keyspace,
meta: Keyspace,
path: String,
_config: Config,
}
impl Clone for FjallStorage {
fn clone(&self) -> Self {
Self {
db: Arc::clone(&self.db),
nodes: self.nodes.clone(),
meta: self.meta.clone(),
path: self.path.clone(),
_config: self._config.clone(),
}
}
}
impl Default for FjallStorage {
fn default() -> Self {
Self::new()
}
}
impl FjallStorage {
pub fn new() -> Self {
Self::new_with_config(Config::default(), "beam.fjall")
}
pub fn new_with_config<P: AsRef<Path>>(config: Config, path: P) -> Self {
let path = path.as_ref().to_string_lossy().into_owned();
let db = Database::builder(&path).open().unwrap_or_else(|e| {
panic!("Failed to create/open fjall at {}: {:?}", path, e);
});
let nodes = db
.keyspace(BEAM_NODES, KeyspaceCreateOptions::default)
.unwrap_or_else(|e| {
panic!("Failed to open beam_nodes_v1 keyspace: {:?}", e);
});
let meta = db
.keyspace(BEAM_META, KeyspaceCreateOptions::default)
.unwrap_or_else(|e| {
panic!("Failed to open beam_meta_v1 keyspace: {:?}", e);
});
Self {
db: Arc::new(db),
nodes,
meta,
path,
_config: config,
}
}
fn handle_get(&self, get: &Get, ctx: &ActorContext) {
let children_for_node: Children = match self.nodes.get(encode_key(&get.node_id).as_slice())
{
Ok(Some(slice)) => match postcard::from_bytes(slice.as_ref()) {
Ok(c) => c,
Err(e) => {
error!(
"fjall get: deserialize failed for node_id={}: {:?}",
get.node_id, e
);
return;
}
},
Ok(None) => {
debug!("fjall get: no data for node_id={}", get.node_id);
let mut reply_with_nodes = BTreeMap::default();
reply_with_nodes.insert(get.node_id.clone(), BTreeMap::default()); 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!("fjall get failed for node_id={}: {:?}", get.node_id, e);
return;
}
};
let reply_children = match &get.child_key {
Some(target_key) => {
let mut c: Children = BTreeMap::default();
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::default();
reply_with_nodes.insert(get.node_id.clone(), reply_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!("fjall get: checksum match, not replying");
}
}
fn apply_put(&self, put: &Put) -> Result<(), String> {
for (node_id, update_data) in put.updated_nodes.iter().rev() {
if !node_id.is_empty() && node_id.starts_with('_') {
continue;
}
let key = encode_key(node_id);
let mut children_for_node: Children = match self.nodes.get(key.as_slice()) {
Ok(Some(slice)) => postcard::from_bytes(slice.as_ref()).unwrap_or_default(),
Ok(None) => BTreeMap::default(),
Err(e) => return Err(format!("fjall get for merge: {:?}", e)),
};
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() {
if let Err(e) = self.nodes.remove(key.as_slice()) {
return Err(format!("fjall remove: {:?}", e));
}
} else {
let bytes = postcard::to_allocvec(&children_for_node)
.map_err(|e| format!("postcard serialize: {:?}", e))?;
if let Err(e) = self.nodes.insert(key.as_slice(), bytes) {
return Err(format!("fjall insert: {:?}", e));
}
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Err(e) = self.meta.insert(b"_last_write", now.to_be_bytes().to_vec()) {
debug!("fjall meta insert failed: {:?}", e);
}
Ok(())
}
fn apply_batch_put(&self, batch: &BatchPut) -> Result<(), String> {
let db = (*self.db).clone();
let mut write_batch = db.batch();
for put in &batch.puts {
for (node_id, update_data) in put.updated_nodes.iter().rev() {
if !node_id.is_empty() && node_id.starts_with('_') {
continue;
}
let key = encode_key(node_id);
let mut children_for_node: Children = match self.nodes.get(key.as_slice()) {
Ok(Some(slice)) => postcard::from_bytes(slice.as_ref()).unwrap_or_default(),
Ok(None) => BTreeMap::default(),
Err(e) => return Err(format!("fjall get for batch merge: {:?}", e)),
};
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() {
write_batch.remove(&self.nodes, key.as_slice());
} else {
let bytes = postcard::to_allocvec(&children_for_node)
.map_err(|e| format!("postcard serialize: {:?}", e))?;
write_batch.insert(&self.nodes, key.as_slice(), bytes);
}
}
}
write_batch
.commit()
.map_err(|e| format!("fjall batch commit: {:?}", e))
}
}
fn build_ack_children(result: &Result<(), String>) -> (Children, Option<String>) {
let now_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64;
match result {
Ok(()) => (
vec![(
"_ack".to_string(),
NodeData {
value: Value::Text("ok".to_string()),
updated_at: now_millis,
},
)]
.into_iter()
.collect::<Children>(),
None,
),
Err(e) => {
error!("fjall put failed: {}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(e.clone()),
updated_at: now_millis,
},
)]
.into_iter()
.collect::<Children>(),
Some(e.clone()),
)
}
}
}
fn build_flush_ack_children(
result: &Result<Result<(), String>, tokio::task::JoinError>,
) -> (Children, Option<String>) {
let now_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as f64;
match result {
Ok(Ok(())) => (
vec![(
"_ack".to_string(),
NodeData {
value: Value::Text("ok".to_string()),
updated_at: now_millis,
},
)]
.into_iter()
.collect::<Children>(),
None,
),
Ok(Err(e)) => {
error!("fjall persist failed: {}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(e.clone()),
updated_at: now_millis,
},
)]
.into_iter()
.collect::<Children>(),
Some(e.clone()),
)
}
Err(e) => {
let msg = format!("task panicked: {:?}", e);
error!("fjall flush task panicked: {:?}", e);
(
vec![(
"_err".to_string(),
NodeData {
value: Value::Text(msg.clone()),
updated_at: now_millis,
},
)]
.into_iter()
.collect::<Children>(),
Some(msg),
)
}
}
}
fn send_ack(
put_id: &str,
put_from: &Addr,
ack_children: Children,
err_msg: Option<String>,
ctx: &ActorContext,
) {
let mut nodes = BTreeMap::default();
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!("fjall ack sent with _err for {}", put_id);
}
}
#[async_trait]
impl Actor for FjallStorage {
async fn pre_start(&mut self, _ctx: &ActorContext) {
debug!("FjallStorage started at {}", self.path);
}
async fn stopping(&mut self, _ctx: &ActorContext) {
if let Err(e) = self.db.persist(PersistMode::SyncAll) {
error!("FjallStorage final persist failed: {:?}", e);
}
info!("FjallStorage stopping at {} — journal persisted", self.path);
}
async fn handle(&mut self, message: Arc<Message>, ctx: &ActorContext) {
match &*message {
Message::Get(get) => {
self.handle_get(get, ctx);
}
Message::Put(put) => {
let put_id = put.id.clone();
let put_from = put.from.clone();
let result = self.apply_put(put);
let (ack_children, err_msg) = build_ack_children(&result);
send_ack(&put_id, &put_from, ack_children, err_msg, ctx);
}
Message::BatchPut(batch) => {
let batch_id = batch.id.clone();
let batch_from = batch.from.clone();
let result = self.apply_batch_put(batch);
let (ack_children, err_msg) = build_ack_children(&result);
send_ack(&batch_id, &batch_from, ack_children, err_msg, ctx);
}
Message::Flush(flush) => {
let flush_id = flush.id.clone();
let from_addr = flush.from.clone();
let db = Arc::clone(&self.db);
let result = tokio::task::spawn_blocking(move || {
db.persist(PersistMode::SyncAll)
.map_err(|e| format!("fjall persist: {:?}", e))
})
.await;
let (ack_children, err_msg) = build_flush_ack_children(&result);
send_ack(&flush_id, &from_addr, ack_children, err_msg, ctx);
}
_ => {}
}
}
fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
Some(Box::new(self.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actor::Addr;
fn create_test_storage(suffix: &str) -> FjallStorage {
let path = format!("/tmp/beam-test-fjall-{}-{}", std::process::id(), suffix);
let _ = std::fs::remove_dir_all(&path);
FjallStorage::new_with_config(Config::default(), &path)
}
fn cleanup(path: &str) {
let _ = std::fs::remove_dir_all(path);
}
fn make_node_data(value: &str, ts: f64) -> NodeData {
NodeData {
value: Value::Text(value.to_string()),
updated_at: ts,
}
}
#[test]
fn test_fjall_creates_db() {
let storage = create_test_storage("create");
assert!(!storage.path.is_empty());
assert!(std::path::Path::new(&storage.path).exists());
cleanup(&storage.path);
}
#[test]
fn test_fjall_default() {
let storage = FjallStorage::default();
cleanup(&storage.path);
}
#[test]
fn test_fjall_clone() {
let storage = create_test_storage("clone");
let cloned = storage.clone();
assert_eq!(storage.path, cloned.path);
cleanup(&storage.path);
}
#[test]
fn test_fjall_put_then_get_roundtrips_children() {
let storage = create_test_storage("roundtrip");
let mut children = BTreeMap::default();
children.insert("b".to_string(), make_node_data("hello", 100.0));
let mut updated_nodes = BTreeMap::default();
updated_nodes.insert("a".to_string(), children);
let put = Put::new(updated_nodes, Some("put-1".to_string()), Addr::noop());
storage.apply_put(&put).expect("put should succeed");
let slice = storage
.nodes
.get(encode_key("a").as_slice())
.expect("get should not error")
.expect("node 'a' should exist");
let result: Children =
postcard::from_bytes(slice.as_ref()).expect("deserialize should work");
assert_eq!(result.len(), 1);
let child = result.get("b").unwrap();
match &child.value {
Value::Text(s) => assert_eq!(s, "hello"),
_ => panic!("expected text value"),
}
assert_eq!(child.updated_at, 100.0);
cleanup(&storage.path);
}
#[test]
fn test_fjall_lww_merge_prefers_newer_updated_at() {
let storage = create_test_storage("lww");
let mut children = BTreeMap::default();
children.insert("x".to_string(), make_node_data("old", 100.0));
let mut nodes = BTreeMap::default();
nodes.insert("n1".to_string(), children);
storage
.apply_put(&Put::new(nodes, Some("p1".to_string()), Addr::noop()))
.unwrap();
let mut children2 = BTreeMap::default();
children2.insert("x".to_string(), make_node_data("older", 50.0));
let mut nodes2 = BTreeMap::default();
nodes2.insert("n1".to_string(), children2);
storage
.apply_put(&Put::new(nodes2, Some("p2".to_string()), Addr::noop()))
.unwrap();
let mut children3 = BTreeMap::default();
children3.insert("x".to_string(), make_node_data("newest", 200.0));
let mut nodes3 = BTreeMap::default();
nodes3.insert("n1".to_string(), children3);
storage
.apply_put(&Put::new(nodes3, Some("p3".to_string()), Addr::noop()))
.unwrap();
let slice = storage
.nodes
.get(encode_key("n1").as_slice())
.expect("get should not error")
.expect("node 'n1' should exist");
let result: Children = postcard::from_bytes(slice.as_ref()).unwrap();
let child = result.get("x").unwrap();
match &child.value {
Value::Text(s) => assert_eq!(s, "newest"),
_ => panic!("expected text value"),
}
assert_eq!(child.updated_at, 200.0);
cleanup(&storage.path);
}
#[test]
fn test_fjall_get_missing_node_returns_empty() {
let storage = create_test_storage("missing");
let result = storage
.nodes
.get(encode_key("nonexistent").as_slice())
.unwrap();
assert!(result.is_none(), "fresh db has no records");
cleanup(&storage.path);
}
#[test]
fn test_fjall_persistence_across_reopen() {
let path = format!("/tmp/beam-test-fjall-persist-{}", std::process::id());
let _ = std::fs::remove_dir_all(&path);
{
let storage = FjallStorage::new_with_config(Config::default(), &path);
let mut children = BTreeMap::default();
children.insert("k".to_string(), make_node_data("v1", 100.0));
let mut nodes = BTreeMap::default();
nodes.insert("node1".to_string(), children);
storage
.apply_put(&Put::new(nodes, Some("p1".to_string()), Addr::noop()))
.unwrap();
storage.db.persist(PersistMode::SyncAll).unwrap();
}
{
let storage = FjallStorage::new_with_config(Config::default(), &path);
let slice = storage
.nodes
.get(encode_key("node1").as_slice())
.expect("get should not error")
.expect("node1 should survive reopen");
let result: Children = postcard::from_bytes(slice.as_ref()).unwrap();
let child = result.get("k").unwrap();
match &child.value {
Value::Text(s) => assert_eq!(s, "v1"),
_ => panic!("expected text value"),
}
}
cleanup(&path);
}
#[test]
fn test_fjall_batch_put_atomicity() {
let storage = create_test_storage("batch");
let puts: Vec<Put> = (0..3)
.map(|i| {
let mut children = BTreeMap::default();
children.insert(
format!("c{}", i),
make_node_data(&format!("val{}", i), 100.0 + i as f64),
);
let mut nodes = BTreeMap::default();
nodes.insert(format!("n{}", i), children);
Put::new(nodes, Some(format!("bp{}", i)), Addr::noop())
})
.collect();
let batch = BatchPut::new(puts, Addr::noop());
storage
.apply_batch_put(&batch)
.expect("batch should succeed");
for i in 0..3 {
let slice = storage
.nodes
.get(encode_key(&format!("n{}", i)).as_slice())
.expect("get should not error")
.expect("node should exist after batch put");
let result: Children = postcard::from_bytes(slice.as_ref()).unwrap();
let child = result.get(&format!("c{}", i)).unwrap();
match &child.value {
Value::Text(s) => assert_eq!(s, &format!("val{}", i)),
_ => panic!("expected text value"),
}
}
cleanup(&storage.path);
}
#[test]
fn test_fjall_empty_node_removed_from_keyspace() {
let storage = create_test_storage("empty");
let mut children = BTreeMap::default();
children.insert("x".to_string(), make_node_data("v", 100.0));
let mut nodes = BTreeMap::default();
nodes.insert("n1".to_string(), children);
storage
.apply_put(&Put::new(nodes, Some("p1".to_string()), Addr::noop()))
.unwrap();
assert!(
storage
.nodes
.get(encode_key("n1").as_slice())
.unwrap()
.is_some()
);
let mut empty_nodes = BTreeMap::default();
empty_nodes.insert("n1".to_string(), BTreeMap::default());
storage
.apply_put(&Put::new(empty_nodes, Some("p2".to_string()), Addr::noop()))
.unwrap();
assert!(
storage
.nodes
.get(encode_key("n1").as_slice())
.unwrap()
.is_some()
);
cleanup(&storage.path);
}
}