Skip to main content

beam/adapters/
redb_storage.rs

1//! Persistent embedded storage adapter using [`redb`](https://crates.io/crates/redb).
2//!
3//! [`RedbStorage`] stores the BEAM graph in a redb database file on disk.
4//! It provides ACID transactions with automatic crash recovery.
5//!
6//! # Schema
7//!
8//! Two tables:
9//! - `beam_nodes_v1`: key = `node_id` (&str), value = `postcard(Children)` (BTreeMap<String, NodeData>)
10//! - `beam_meta_v1`: key = metadata key (&str), value = `u64` timestamp
11//!
12//! # Semantics
13//!
14//! - **Get**: Reads from a read transaction. If the node exists, replies
15//!   with its children. If not, sends an empty reply (sentinel) so `.map()`
16//!   listeners don't hang. Skips reply if checksum matches (already sent).
17//! - **Put**: Opens a write transaction, merges `updated_nodes` using
18//!   `updated_at` conflict resolution (last-write-wins), commits.
19//! - **BatchPut**: All puts in the batch are applied in a single transaction.
20//! - **Flush**: No additional work (puts already commit inline). Sends
21//!   immediate ack for barrier semantics.
22//!
23//! # Conflict Resolution
24//!
25//! For each child, the incoming `updated_at` is compared to the existing one.
26//! If `incoming.updated_at >= existing.updated_at`, the child is overwritten.
27//! This implements last-write-wins (LWW) per child.
28//!
29//! # Thread Safety
30//!
31//! The `Database` handle is `Arc`-wrapped and safe to share. Reads use
32//! `begin_read()` (concurrent) and writes use `begin_write()` (exclusive).
33//! Write commits run inside `spawn_blocking` so the fsync never blocks the
34//! async runtime's worker threads.
35
36use std::collections::BTreeMap;
37use std::path::Path;
38use std::sync::Arc;
39use web_time::{SystemTime, UNIX_EPOCH};
40
41use crate::Config;
42use crate::actor::{Actor, ActorContext, Addr};
43use crate::message::{BatchPut, Get, Message, Put};
44use crate::types::*;
45
46use async_trait::async_trait;
47use log::{debug, error, info};
48use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
49
50/// Table definition for graph node data.
51const BEAM_NODES: TableDefinition<&str, &[u8]> = TableDefinition::new("beam_nodes_v1");
52/// Table definition for metadata (e.g. last write timestamp).
53const BEAM_META: TableDefinition<&str, u64> = TableDefinition::new("beam_meta_v1");
54
55/// Macro to unwrap a redb result or log and return early on error.
56macro_rules! unwrap_or_return {
57    ($e:expr) => {
58        match $e {
59            Ok(v) => v,
60            Err(e) => {
61                error!("redb operation failed: {:?}", e);
62                return;
63            }
64        }
65    };
66}
67
68/// redb-backed persistent storage adapter for BEAM.
69///
70/// Stores the graph in a single redb database file. Each `Put` commits
71/// inline (ACID) inside `spawn_blocking` so the fsync does not block the
72/// async runtime. Reads are concurrent. Flush is an immediate ack (puts
73/// already fsync on commit).
74///
75/// # Example
76///
77/// ```ignore
78/// use beam::adapters::RedbStorage;
79/// use beam::Config;
80///
81/// let storage = RedbStorage::new_with_config(Config::default(), "my-db.redb", None);
82/// ```
83pub struct RedbStorage {
84    db: Arc<Database>,
85    path: String,
86    _config: Config,
87}
88
89impl Clone for RedbStorage {
90    fn clone(&self) -> Self {
91        Self {
92            db: Arc::clone(&self.db),
93            path: self.path.clone(),
94            _config: self._config.clone(),
95        }
96    }
97}
98
99impl RedbStorage {
100    /// Creates a new redb storage at the default path `beam.redb`.
101    ///
102    /// # Panics
103    ///
104    /// Panics if the database cannot be created or opened.
105    pub fn new() -> Self {
106        Self::new_with_config(Config::default(), "beam.redb", None)
107    }
108
109    /// Creates a new redb storage with explicit config and path.
110    ///
111    /// # Arguments
112    ///
113    /// * `config` - Node configuration
114    /// * `path` - File path for the redb database
115    /// * `_max_size` - Optional maximum database size (currently unused)
116    ///
117    /// # Panics
118    ///
119    /// Panics if the database cannot be created or opened at the given path.
120    pub fn new_with_config<P: AsRef<Path>>(
121        config: Config,
122        path: P,
123        _max_size: Option<u64>,
124    ) -> Self {
125        let path = path.as_ref().to_string_lossy().into_owned();
126        let db = Database::create(&path).unwrap_or_else(|e| {
127            panic!("Failed to create/open redb at {}: {:?}", path, e);
128        });
129        Self {
130            db: Arc::new(db),
131            path,
132            _config: config,
133        }
134    }
135
136    /// Handles a `Get` by reading from the database and replying with children.
137    ///
138    /// If the node doesn't exist, sends an empty reply (sentinel) so `.map()`
139    /// listeners don't hang. If the checksum matches the request, the reply
140    /// is suppressed (already sent the same data).
141    fn handle_get(&self, get: Get, ctx: &ActorContext) {
142        let read_txn = unwrap_or_return!(self.db.begin_read());
143        let table = unwrap_or_return!(read_txn.open_table(BEAM_NODES));
144
145        let children_for_node = match table.get(&*get.node_id) {
146            Ok(Some(access_guard)) => {
147                let bytes = access_guard.value();
148                unwrap_or_return!(postcard::from_bytes::<BTreeMap<String, NodeData>>(bytes))
149            }
150            Ok(None) => {
151                debug!("redb get: no data for node_id={}", get.node_id);
152                // Empty set is still a valid replay — send sentinel so `.map()` listeners don't hang.
153                let mut reply_with_nodes = BTreeMap::new();
154                reply_with_nodes.insert(get.node_id.clone(), BTreeMap::new());
155                let mut put = Put::new(reply_with_nodes, Some(get.id.clone()), ctx.addr.clone());
156                put.to_string(); // compute checksum
157                let _ = get.from.send(Message::Put(put));
158                return;
159            }
160            Err(e) => {
161                error!("redb get failed: {:?}", e);
162                return;
163            }
164        };
165
166        let reply_with_children = match &get.child_key {
167            Some(target_key) => {
168                let mut c = BTreeMap::new();
169                if let Some(node_data) = children_for_node.get(target_key) {
170                    c.insert(target_key.clone(), node_data.clone());
171                }
172                c
173            }
174            None => children_for_node,
175        };
176
177        let mut reply_with_nodes = BTreeMap::new();
178        reply_with_nodes.insert(get.node_id.clone(), reply_with_children);
179
180        let mut put = Put::new(reply_with_nodes, Some(get.id.clone()), ctx.addr.clone());
181        put.to_string(); // compute checksum
182
183        // Ack replies (those with `in_response_to`) MUST always be sent,
184        // regardless of checksum match. The client uses the reply's
185        // presence to drive its `__beam_replay_complete__` sentinel-drain;
186        // a silent ack would hang the drain forever. The checksum-match
187        // optimization is reserved for live broadcasts where the caller
188        // already has the data.
189        let is_ack = put.in_response_to.is_some();
190        if is_ack || put.checksum != get.checksum {
191            let _ = get.from.send(Message::Put(put));
192        } else {
193            debug!("redb get: checksum match, not replying");
194        }
195    }
196
197    /// Applies a single Put to the given write transaction.
198    ///
199    /// For each node in `put.updated_nodes`, merges children using LWW
200    /// conflict resolution. Empty nodes are removed from the table.
201    fn apply_put_to_tables(
202        &self,
203        wtxn: &mut redb::WriteTransaction,
204        put: Put,
205    ) -> Result<(), redb::Error> {
206        let mut node_table = wtxn.open_table(BEAM_NODES)?;
207        let mut meta_table = wtxn.open_table(BEAM_META)?;
208
209        for (node_id, update_data) in put.updated_nodes.into_iter().rev() {
210            // Skip internal control keys (e.g. _flushed, _ack)
211            if !node_id.is_empty() && node_id.starts_with('_') {
212                continue;
213            }
214
215            let mut children_for_node: BTreeMap<String, NodeData> =
216                match node_table.get(&*node_id)? {
217                    Some(access_guard) => {
218                        let bytes = access_guard.value();
219                        postcard::from_bytes(bytes).unwrap_or_default()
220                    }
221                    None => BTreeMap::new(),
222                };
223
224            for (child_id, child_data) in update_data {
225                let should_write = !matches!(
226                    children_for_node.get(&child_id),
227                    Some(existing) if existing.updated_at > child_data.updated_at
228                );
229
230                if should_write {
231                    children_for_node.insert(child_id, child_data);
232                }
233            }
234
235            if children_for_node.is_empty() {
236                node_table.remove(&*node_id)?;
237            } else {
238                let bytes = postcard::to_allocvec(&children_for_node).map_err(|e| {
239                    redb::Error::Io(std::io::Error::other(format!(
240                        "postcard serialize: {:?}",
241                        e
242                    )))
243                })?;
244                node_table.insert(&*node_id, bytes.as_slice())?;
245            }
246        }
247
248        let now = SystemTime::now()
249            .duration_since(UNIX_EPOCH)
250            .unwrap_or_default()
251            .as_secs();
252        meta_table.insert("_last_write", now)?;
253        Ok(())
254    }
255
256    /// Handles a single Put by opening a write transaction, applying, and committing.
257    fn handle_put_internal(&self, put: Put) -> Result<(), redb::Error> {
258        let mut wtxn = self.db.begin_write()?;
259        self.apply_put_to_tables(&mut wtxn, put)?;
260        wtxn.commit()?;
261        Ok(())
262    }
263
264    /// Handles a BatchPut by applying all puts in a single transaction.
265    ///
266    /// This preserves atomicity — either all puts commit or none do.
267    fn handle_batch_put(&self, batch: BatchPut) -> Result<(), redb::Error> {
268        let mut wtxn = self.db.begin_write()?;
269        for put in batch.puts {
270            self.apply_put_to_tables(&mut wtxn, put)?;
271        }
272        wtxn.commit()?;
273        Ok(())
274    }
275}
276
277#[async_trait]
278impl Actor for RedbStorage {
279    async fn pre_start(&mut self, _ctx: &ActorContext) {
280        debug!("RedbStorage started at {}", self.path);
281        // Warm the schema so the first read finds tables already present.
282        if let Ok(wtxn) = self.db.begin_write() {
283            let _ = wtxn.open_table(BEAM_NODES);
284            let _ = wtxn.open_table(BEAM_META);
285            let _ = wtxn.commit();
286        }
287    }
288
289    async fn stopping(&mut self, _ctx: &ActorContext) {
290        // redb commits inline within handle(), so all acknowledged writes
291        // are already durable. The flush_storage() call in Node::shutdown()
292        // ensures the mailbox has drained before we reach this point.
293        // Here we log final state for observability.
294        info!(
295            "RedbStorage stopping at {} — all writes committed",
296            self.path
297        );
298    }
299
300    async fn handle(&mut self, message: Message, ctx: &ActorContext) {
301        match message {
302            Message::Get(get) => self.handle_get(get, ctx),
303            Message::Put(put) => {
304                let put_id = put.id.clone();
305                let put_from = put.from.clone();
306                let storage = self.clone();
307                let result =
308                    tokio::task::spawn_blocking(move || storage.handle_put_internal(put)).await;
309                self.send_put_ack_after_commit(&put_id, &put_from, &result, ctx);
310            }
311            Message::BatchPut(batch) => {
312                let batch_id = batch.id.clone();
313                let batch_from = batch.from.clone();
314                let storage = self.clone();
315                let result =
316                    tokio::task::spawn_blocking(move || storage.handle_batch_put(batch)).await;
317                self.send_batch_put_ack_after_commit(&batch_id, &batch_from, &result, ctx);
318            }
319            Message::Flush(flush) => {
320                let flush_id = flush.id.clone();
321                let from_addr = flush.from.clone();
322                let ctx_addr = ctx.addr.clone();
323
324                // For embedded redb, put() already commits inline (wtxn.commit).
325                // Flush has no additional durability work. Send ack immediately.
326                let mut ack_children = BTreeMap::new();
327                ack_children.insert(
328                    "_flushed".to_string(),
329                    NodeData {
330                        value: Value::Text("true".to_string()),
331                        updated_at: SystemTime::now()
332                            .duration_since(UNIX_EPOCH)
333                            .unwrap_or_default()
334                            .as_millis() as f64,
335                    },
336                );
337                let mut ack_nodes = BTreeMap::new();
338                ack_nodes.insert("_ack".to_string(), ack_children);
339                let mut put = Put::new(ack_nodes, Some(flush_id), ctx_addr.clone());
340                put.to_string(); // compute checksum
341                let _ = from_addr.send(Message::Put(put));
342            }
343            _ => {}
344        }
345    }
346
347    /// Returns a boxed clone for the storage read/write actor split.
348    ///
349    /// Both the read and write actor share the same `Arc<Database>`, so
350    /// reads see committed writes immediately via redb's MVCC snapshots.
351    fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
352        Some(Box::new(self.clone()))
353    }
354}
355
356impl RedbStorage {
357    /// Sends a put-ack back to the originating node after `spawn_blocking`
358    /// returns. The ack payload uses the same `_ack`/`_err` sentinel as
359    /// the Flush ack and as memory_storage — so `Node::handle_put` drains
360    /// `pending_puts` uniformly across both adapters.
361    ///
362    /// Fires AFTER the commit returns from `spawn_blocking` — that's the
363    /// contract. If the commit failed or the task panicked, we send `_err`
364    /// and the awaiting caller learns the failure.
365    fn send_put_ack_after_commit(
366        &self,
367        put_id: &str,
368        put_from: &Addr,
369        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
370        ctx: &ActorContext,
371    ) {
372        let (ack_children, err_msg) = match result {
373            Ok(Ok(())) => (
374                vec![(
375                    "_ack".to_string(),
376                    NodeData {
377                        value: Value::Text("ok".to_string()),
378                        updated_at: SystemTime::now()
379                            .duration_since(UNIX_EPOCH)
380                            .unwrap_or_default()
381                            .as_millis() as f64,
382                    },
383                )]
384                .into_iter()
385                .collect::<BTreeMap<_, _>>(),
386                None,
387            ),
388            Ok(Err(e)) => {
389                error!("redb put commit failed: {:?}", e);
390                (
391                    vec![(
392                        "_err".to_string(),
393                        NodeData {
394                            value: Value::Text(format!("{:?}", e)),
395                            updated_at: SystemTime::now()
396                                .duration_since(UNIX_EPOCH)
397                                .unwrap_or_default()
398                                .as_millis() as f64,
399                        },
400                    )]
401                    .into_iter()
402                    .collect(),
403                    Some(format!("redb put commit failed: {:?}", e)),
404                )
405            }
406            Err(e) => {
407                error!("redb put task panicked: {:?}", e);
408                (
409                    vec![(
410                        "_err".to_string(),
411                        NodeData {
412                            value: Value::Text(format!("task panicked: {:?}", e)),
413                            updated_at: SystemTime::now()
414                                .duration_since(UNIX_EPOCH)
415                                .unwrap_or_default()
416                                .as_millis() as f64,
417                        },
418                    )]
419                    .into_iter()
420                    .collect(),
421                    Some(format!("redb put task panicked: {:?}", e)),
422                )
423            }
424        };
425        let mut nodes = BTreeMap::new();
426        nodes.insert("_ack".to_string(), ack_children);
427        let ack = Put::new(nodes, Some(put_id.to_string()), ctx.addr.clone());
428        let _ = put_from.send(Message::Put(ack));
429        if err_msg.is_some() {
430            debug!("redb put ack sent with _err for {}", put_id);
431        }
432    }
433
434    /// Sends a batch_put ack back to the originating node after commit.
435    ///
436    /// Mirrors `send_put_ack_after_commit` but for the batch case. Uses the
437    /// same `_ack`/`_err` sentinel so the originating `Node::handle_put`
438    /// drains `pending_puts` keyed by `batch.id`.
439    fn send_batch_put_ack_after_commit(
440        &self,
441        batch_id: &str,
442        batch_from: &Addr,
443        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
444        ctx: &ActorContext,
445    ) {
446        let (ack_children, err_msg) = match result {
447            Ok(Ok(())) => (
448                vec![(
449                    "_ack".to_string(),
450                    NodeData {
451                        value: Value::Text("ok".to_string()),
452                        updated_at: SystemTime::now()
453                            .duration_since(UNIX_EPOCH)
454                            .unwrap_or_default()
455                            .as_millis() as f64,
456                    },
457                )]
458                .into_iter()
459                .collect::<BTreeMap<_, _>>(),
460                None,
461            ),
462            Ok(Err(e)) => {
463                error!("redb batch_put commit failed: {:?}", e);
464                (
465                    vec![(
466                        "_err".to_string(),
467                        NodeData {
468                            value: Value::Text(format!("{:?}", e)),
469                            updated_at: SystemTime::now()
470                                .duration_since(UNIX_EPOCH)
471                                .unwrap_or_default()
472                                .as_millis() as f64,
473                        },
474                    )]
475                    .into_iter()
476                    .collect(),
477                    Some(format!("redb batch_put commit failed: {:?}", e)),
478                )
479            }
480            Err(e) => {
481                error!("redb batch_put task panicked: {:?}", e);
482                (
483                    vec![(
484                        "_err".to_string(),
485                        NodeData {
486                            value: Value::Text(format!("task panicked: {:?}", e)),
487                            updated_at: SystemTime::now()
488                                .duration_since(UNIX_EPOCH)
489                                .unwrap_or_default()
490                                .as_millis() as f64,
491                        },
492                    )]
493                    .into_iter()
494                    .collect(),
495                    Some(format!("redb batch_put task panicked: {:?}", e)),
496                )
497            }
498        };
499        let mut nodes = BTreeMap::new();
500        nodes.insert("_ack".to_string(), ack_children);
501        let ack = Put::new(nodes, Some(batch_id.to_string()), ctx.addr.clone());
502        let _ = batch_from.send(Message::Put(ack));
503        if err_msg.is_some() {
504            debug!("redb batch_put ack sent with _err for {}", batch_id);
505        }
506    }
507}
508
509impl Default for RedbStorage {
510    fn default() -> Self {
511        Self::new()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn create_test_storage(suffix: &str) -> RedbStorage {
520        let path = format!("/tmp/beam-test-{}-{}.redb", std::process::id(), suffix);
521        RedbStorage::new_with_config(Config::default(), &path, None)
522    }
523
524    #[tokio::test]
525    async fn test_redb_storage_creates_db() {
526        let storage = create_test_storage("create");
527        assert!(!storage.path.is_empty());
528        let _ = std::fs::remove_file(&storage.path);
529    }
530
531    #[tokio::test]
532    async fn test_redb_storage_default() {
533        let storage = RedbStorage::default();
534        let _ = std::fs::remove_file(&storage.path);
535    }
536
537    #[tokio::test]
538    async fn test_redb_storage_clone() {
539        let storage = create_test_storage("clone");
540        let cloned = storage.clone();
541        assert_eq!(storage.path, cloned.path);
542        let _ = std::fs::remove_file(&storage.path);
543    }
544
545    /// Sentinel-drain protocol test: storage MUST always reply when
546    /// `in_response_to` is set on the Get, regardless of checksum match.
547    ///
548    /// # Why this test exists
549    ///
550    /// BEAM's `Node::handle_put` only sends the `__beam_replay_complete__`
551    /// sentinel after a Put with `in_response_to` is received. If storage
552    /// stays silent when checksum matches, the client's `drain_until_sentinel`
553    /// hangs forever. The client use case doesn't pre-set checksum (so this
554    /// bug is latent), but ANY future caller who caches checksums would hit
555    /// it.
556    ///
557    /// This test forces the bug by pre-computing the reply's checksum and
558    /// putting it on the Get — exactly the pattern a caching client would
559    /// use.
560    #[tokio::test]
561    async fn test_redb_get_always_replies_when_in_response_to_set() {
562        use crate::actor::{Actor, ActorContext};
563        use crate::message::Put;
564        use std::collections::BTreeMap;
565        use tokio::sync::mpsc::unbounded_channel;
566
567        let mut storage = create_test_storage("ack-always");
568        let ctx = ActorContext::new("test".to_string());
569
570        // Pre-populate: store a child under node "n1" via the Actor entry point.
571        let mut children = BTreeMap::new();
572        children.insert(
573            "k".to_string(),
574            NodeData {
575                value: Value::Text("v".to_string()),
576                updated_at: 0.0,
577            },
578        );
579        let mut nodes = BTreeMap::new();
580        nodes.insert("n1".to_string(), children.clone());
581        let seed_put = Put::new(nodes, None, ctx.addr.clone());
582        Actor::handle(&mut storage, Message::Put(seed_put), &ctx).await;
583
584        // Build a buffered `from` address so we can read the reply.
585        let (tx, mut rx) = unbounded_channel::<Message>();
586        let from_addr = crate::actor::Addr::new(tx);
587
588        // Compute the checksum the storage will produce for the reply.
589        let mut reply = Put::new(
590            {
591                let mut m = BTreeMap::new();
592                m.insert("n1".to_string(), children.clone());
593                m
594            },
595            Some("get-id-42".to_string()),
596            ctx.addr.clone(),
597        );
598        reply.to_string(); // sets reply.checksum
599        let matching_checksum = reply.checksum;
600
601        // Construct a Get with checksum pre-set to MATCH the reply's
602        // checksum. In the buggy code this triggers the no-reply branch.
603        let get = Get {
604            id: "get-id-42".to_string(),
605            from: from_addr.clone(),
606            recipients: None,
607            node_id: "n1".to_string(),
608            checksum: matching_checksum,
609            child_key: None,
610            json_str: None,
611        };
612
613        Actor::handle(&mut storage, Message::Get(get), &ctx).await;
614
615        // Bug: with the old code, no reply arrives (timeout would be required).
616        // Fix: redb_storage MUST always reply when in_response_to is Some.
617        let received = crate::tokio_time::timeout(web_time::Duration::from_millis(500), rx.recv()).await;
618
619        let _ = std::fs::remove_file(&storage.path);
620
621        match received {
622            Ok(Some(Message::Put(reply_put))) => {
623                assert_eq!(
624                    reply_put.in_response_to.as_deref(),
625                    Some("get-id-42"),
626                    "reply must carry in_response_to so client can drain sentinel"
627                );
628            }
629            Ok(Some(other)) => panic!("expected Put reply, got {:?}", other),
630            Ok(None) => panic!("sender closed before reply sent"),
631            Err(_) => panic!(
632                "BUG: redb_storage stayed silent despite matching in_response_to. \
633                 This hangs drain_until_sentinel forever."
634            ),
635        }
636    }
637}