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 arena_btreemap::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::<Children>(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::default();
154                reply_with_nodes.insert(get.node_id.clone(), BTreeMap::default());
155                let 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: Children = BTreeMap::default();
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::default();
178        reply_with_nodes.insert(get.node_id.clone(), reply_with_children);
179
180        let 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.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: Children = match node_table.get(&**node_id)? {
216                Some(access_guard) => {
217                    let bytes = access_guard.value();
218                    postcard::from_bytes(bytes).unwrap_or_default()
219                }
220                None => BTreeMap::default(),
221            };
222
223            for (child_id, child_data) in update_data {
224                let should_write = !matches!(
225                    children_for_node.get(child_id),
226                    Some(existing) if existing.updated_at > child_data.updated_at
227                );
228
229                if should_write {
230                    children_for_node.insert(child_id.clone(), child_data.clone());
231                }
232            }
233
234            if children_for_node.is_empty() {
235                node_table.remove(&**node_id)?;
236            } else {
237                let bytes = postcard::to_allocvec(&children_for_node).map_err(|e| {
238                    redb::Error::Io(std::io::Error::other(format!(
239                        "postcard serialize: {:?}",
240                        e
241                    )))
242                })?;
243                node_table.insert(&**node_id, bytes.as_slice())?;
244            }
245        }
246
247        let now = SystemTime::now()
248            .duration_since(UNIX_EPOCH)
249            .unwrap_or_default()
250            .as_secs();
251        meta_table.insert("_last_write", now)?;
252        Ok(())
253    }
254
255    /// Handles a single Put by opening a write transaction, applying, and committing.
256    fn handle_put_internal(&self, put: Put) -> Result<(), redb::Error> {
257        let mut wtxn = self.db.begin_write()?;
258        self.apply_put_to_tables(&mut wtxn, put)?;
259        wtxn.commit()?;
260        Ok(())
261    }
262
263    /// Handles a BatchPut by applying all puts in a single transaction.
264    ///
265    /// This preserves atomicity — either all puts commit or none do.
266    fn handle_batch_put(&self, batch: BatchPut) -> Result<(), redb::Error> {
267        let mut wtxn = self.db.begin_write()?;
268        for put in batch.puts {
269            self.apply_put_to_tables(&mut wtxn, put)?;
270        }
271        wtxn.commit()?;
272        Ok(())
273    }
274}
275
276#[async_trait]
277impl Actor for RedbStorage {
278    async fn pre_start(&mut self, _ctx: &ActorContext) {
279        debug!("RedbStorage started at {}", self.path);
280        // Warm the schema so the first read finds tables already present.
281        if let Ok(wtxn) = self.db.begin_write() {
282            let _ = wtxn.open_table(BEAM_NODES);
283            let _ = wtxn.open_table(BEAM_META);
284            let _ = wtxn.commit();
285        }
286    }
287
288    async fn stopping(&mut self, _ctx: &ActorContext) {
289        // redb commits inline within handle(), so all acknowledged writes
290        // are already durable. The flush_storage() call in Node::shutdown()
291        // ensures the mailbox has drained before we reach this point.
292        // Here we log final state for observability.
293        info!(
294            "RedbStorage stopping at {} — all writes committed",
295            self.path
296        );
297    }
298
299    async fn handle(&mut self, message: Arc<Message>, ctx: &ActorContext) {
300        match &*message {
301            Message::Get(get) => self.handle_get(get.clone(), ctx),
302            Message::Put(put) => {
303                let put_id = put.id.clone();
304                let put_from = put.from.clone();
305                let put = put.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 batch = batch.clone();
315                let storage = self.clone();
316                let result =
317                    tokio::task::spawn_blocking(move || storage.handle_batch_put(batch)).await;
318                self.send_batch_put_ack_after_commit(&batch_id, &batch_from, &result, ctx);
319            }
320            Message::Flush(flush) => {
321                let flush_id = flush.id.clone();
322                let from_addr = flush.from.clone();
323                let ctx_addr = ctx.addr.clone();
324
325                // For embedded redb, put() already commits inline (wtxn.commit).
326                // Flush has no additional durability work. Send ack immediately.
327                let mut ack_children = BTreeMap::default();
328                ack_children.insert(
329                    "_flushed".to_string(),
330                    NodeData {
331                        value: Value::Text("true".to_string()),
332                        updated_at: SystemTime::now()
333                            .duration_since(UNIX_EPOCH)
334                            .unwrap_or_default()
335                            .as_millis() as f64,
336                    },
337                );
338                let mut ack_nodes = BTreeMap::default();
339                ack_nodes.insert("_ack".to_string(), ack_children);
340                let put = Put::new(ack_nodes, Some(flush_id), ctx_addr.clone());
341                put.to_string(); // compute checksum
342                let _ = from_addr.send(Message::Put(put));
343            }
344            _ => {}
345        }
346    }
347
348    /// Returns a boxed clone for the storage read/write actor split.
349    ///
350    /// Both the read and write actor share the same `Arc<Database>`, so
351    /// reads see committed writes immediately via redb's MVCC snapshots.
352    fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
353        Some(Box::new(self.clone()))
354    }
355}
356
357impl RedbStorage {
358    /// Sends a put-ack back to the originating node after `spawn_blocking`
359    /// returns. The ack payload uses the same `_ack`/`_err` sentinel as
360    /// the Flush ack and as memory_storage — so `Node::handle_put` drains
361    /// `pending_puts` uniformly across both adapters.
362    ///
363    /// Fires AFTER the commit returns from `spawn_blocking` — that's the
364    /// contract. If the commit failed or the task panicked, we send `_err`
365    /// and the awaiting caller learns the failure.
366    fn send_put_ack_after_commit(
367        &self,
368        put_id: &str,
369        put_from: &Addr,
370        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
371        ctx: &ActorContext,
372    ) {
373        let (ack_children, err_msg) = match result {
374            Ok(Ok(())) => (
375                vec![(
376                    "_ack".to_string(),
377                    NodeData {
378                        value: Value::Text("ok".to_string()),
379                        updated_at: SystemTime::now()
380                            .duration_since(UNIX_EPOCH)
381                            .unwrap_or_default()
382                            .as_millis() as f64,
383                    },
384                )]
385                .into_iter()
386                .collect::<Children>(),
387                None,
388            ),
389            Ok(Err(e)) => {
390                error!("redb put commit failed: {:?}", e);
391                (
392                    vec![(
393                        "_err".to_string(),
394                        NodeData {
395                            value: Value::Text(format!("{:?}", e)),
396                            updated_at: SystemTime::now()
397                                .duration_since(UNIX_EPOCH)
398                                .unwrap_or_default()
399                                .as_millis() as f64,
400                        },
401                    )]
402                    .into_iter()
403                    .collect::<Children>(),
404                    Some(format!("redb put commit failed: {:?}", e)),
405                )
406            }
407            Err(e) => {
408                error!("redb put task panicked: {:?}", e);
409                (
410                    vec![(
411                        "_err".to_string(),
412                        NodeData {
413                            value: Value::Text(format!("task panicked: {:?}", e)),
414                            updated_at: SystemTime::now()
415                                .duration_since(UNIX_EPOCH)
416                                .unwrap_or_default()
417                                .as_millis() as f64,
418                        },
419                    )]
420                    .into_iter()
421                    .collect::<Children>(),
422                    Some(format!("redb put task panicked: {:?}", e)),
423                )
424            }
425        };
426        let mut nodes = BTreeMap::default();
427        nodes.insert("_ack".to_string(), ack_children);
428        let ack = Put::new(nodes, Some(put_id.to_string()), ctx.addr.clone());
429        let _ = put_from.send(Message::Put(ack));
430        if err_msg.is_some() {
431            debug!("redb put ack sent with _err for {}", put_id);
432        }
433    }
434
435    /// Sends a batch_put ack back to the originating node after commit.
436    ///
437    /// Mirrors `send_put_ack_after_commit` but for the batch case. Uses the
438    /// same `_ack`/`_err` sentinel so the originating `Node::handle_put`
439    /// drains `pending_puts` keyed by `batch.id`.
440    fn send_batch_put_ack_after_commit(
441        &self,
442        batch_id: &str,
443        batch_from: &Addr,
444        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
445        ctx: &ActorContext,
446    ) {
447        let (ack_children, err_msg) = match result {
448            Ok(Ok(())) => (
449                vec![(
450                    "_ack".to_string(),
451                    NodeData {
452                        value: Value::Text("ok".to_string()),
453                        updated_at: SystemTime::now()
454                            .duration_since(UNIX_EPOCH)
455                            .unwrap_or_default()
456                            .as_millis() as f64,
457                    },
458                )]
459                .into_iter()
460                .collect::<Children>(),
461                None,
462            ),
463            Ok(Err(e)) => {
464                error!("redb batch_put commit failed: {:?}", e);
465                (
466                    vec![(
467                        "_err".to_string(),
468                        NodeData {
469                            value: Value::Text(format!("{:?}", e)),
470                            updated_at: SystemTime::now()
471                                .duration_since(UNIX_EPOCH)
472                                .unwrap_or_default()
473                                .as_millis() as f64,
474                        },
475                    )]
476                    .into_iter()
477                    .collect::<Children>(),
478                    Some(format!("redb batch_put commit failed: {:?}", e)),
479                )
480            }
481            Err(e) => {
482                error!("redb batch_put task panicked: {:?}", e);
483                (
484                    vec![(
485                        "_err".to_string(),
486                        NodeData {
487                            value: Value::Text(format!("task panicked: {:?}", e)),
488                            updated_at: SystemTime::now()
489                                .duration_since(UNIX_EPOCH)
490                                .unwrap_or_default()
491                                .as_millis() as f64,
492                        },
493                    )]
494                    .into_iter()
495                    .collect::<Children>(),
496                    Some(format!("redb batch_put task panicked: {:?}", e)),
497                )
498            }
499        };
500        let mut nodes = BTreeMap::default();
501        nodes.insert("_ack".to_string(), ack_children);
502        let ack = Put::new(nodes, Some(batch_id.to_string()), ctx.addr.clone());
503        let _ = batch_from.send(Message::Put(ack));
504        if err_msg.is_some() {
505            debug!("redb batch_put ack sent with _err for {}", batch_id);
506        }
507    }
508}
509
510impl Default for RedbStorage {
511    fn default() -> Self {
512        Self::new()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn create_test_storage(suffix: &str) -> RedbStorage {
521        let path = format!("/tmp/beam-test-{}-{}.redb", std::process::id(), suffix);
522        RedbStorage::new_with_config(Config::default(), &path, None)
523    }
524
525    #[tokio::test]
526    async fn test_redb_storage_creates_db() {
527        let storage = create_test_storage("create");
528        assert!(!storage.path.is_empty());
529        let _ = std::fs::remove_file(&storage.path);
530    }
531
532    #[tokio::test]
533    async fn test_redb_storage_default() {
534        let storage = RedbStorage::default();
535        let _ = std::fs::remove_file(&storage.path);
536    }
537
538    #[tokio::test]
539    async fn test_redb_storage_clone() {
540        let storage = create_test_storage("clone");
541        let cloned = storage.clone();
542        assert_eq!(storage.path, cloned.path);
543        let _ = std::fs::remove_file(&storage.path);
544    }
545
546    /// Sentinel-drain protocol test: storage MUST always reply when
547    /// `in_response_to` is set on the Get, regardless of checksum match.
548    ///
549    /// # Why this test exists
550    ///
551    /// BEAM's `Node::handle_put` only sends the `__beam_replay_complete__`
552    /// sentinel after a Put with `in_response_to` is received. If storage
553    /// stays silent when checksum matches, the client's `drain_until_sentinel`
554    /// hangs forever. The client use case doesn't pre-set checksum (so this
555    /// bug is latent), but ANY future caller who caches checksums would hit
556    /// it.
557    ///
558    /// This test forces the bug by pre-computing the reply's checksum and
559    /// putting it on the Get — exactly the pattern a caching client would
560    /// use.
561    #[tokio::test]
562    async fn test_redb_get_always_replies_when_in_response_to_set() {
563        use crate::actor::{Actor, ActorContext};
564        use crate::message::Put;
565        use arena_btreemap::BTreeMap;
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::default();
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::default();
580        nodes.insert("n1".to_string(), children.clone());
581        let seed_put = Put::new(nodes, None, ctx.addr.clone());
582        Actor::handle(&mut storage, Arc::new(Message::Put(seed_put)), &ctx).await;
583
584        // Build a buffered `from` address so we can read the reply.
585        let (tx, rx) = crate::mailbox::mailbox(16);
586        let from_addr = crate::actor::Addr::new(tx);
587        let mut rx = rx;
588
589        // Compute the checksum the storage will produce for the reply.
590        let reply = Put::new(
591            {
592                let mut m = BTreeMap::default();
593                m.insert("n1".to_string(), children.clone());
594                m
595            },
596            Some("get-id-42".to_string()),
597            ctx.addr.clone(),
598        );
599        reply.to_string(); // sets reply.checksum
600        let matching_checksum = reply.checksum;
601
602        // Construct a Get with checksum pre-set to MATCH the reply's
603        // checksum. In the buggy code this triggers the no-reply branch.
604        let get = Get {
605            id: "get-id-42".to_string(),
606            from: from_addr.clone(),
607            recipients: None,
608            node_id: "n1".to_string(),
609            checksum: matching_checksum,
610            child_key: None,
611        };
612
613        Actor::handle(&mut storage, Arc::new(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 =
618            crate::tokio_time::timeout(web_time::Duration::from_millis(500), rx.recv()).await;
619
620        let _ = std::fs::remove_file(&storage.path);
621
622        match received {
623            Ok(Some(msg)) => match &*msg {
624                Message::Put(reply_put) => {
625                    assert_eq!(
626                        reply_put.in_response_to.as_deref(),
627                        Some("get-id-42"),
628                        "reply must carry in_response_to so client can drain sentinel"
629                    );
630                }
631                other => panic!("expected Put reply, got {:?}", other),
632            },
633            Ok(None) => panic!("sender closed before reply sent"),
634            Err(_) => panic!(
635                "BUG: redb_storage stayed silent despite matching in_response_to. \
636                 This hangs drain_until_sentinel forever."
637            ),
638        }
639    }
640}