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 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 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: Arc<Message>, ctx: &ActorContext) {
301        match &*message {
302            Message::Get(get) => self.handle_get(get.clone(), ctx),
303            Message::Put(put) => {
304                let put_id = put.id.clone();
305                let put_from = put.from.clone();
306                let put = put.clone();
307                let storage = self.clone();
308                let result =
309                    tokio::task::spawn_blocking(move || storage.handle_put_internal(put)).await;
310                self.send_put_ack_after_commit(&put_id, &put_from, &result, ctx);
311            }
312            Message::BatchPut(batch) => {
313                let batch_id = batch.id.clone();
314                let batch_from = batch.from.clone();
315                let batch = batch.clone();
316                let storage = self.clone();
317                let result =
318                    tokio::task::spawn_blocking(move || storage.handle_batch_put(batch)).await;
319                self.send_batch_put_ack_after_commit(&batch_id, &batch_from, &result, ctx);
320            }
321            Message::Flush(flush) => {
322                let flush_id = flush.id.clone();
323                let from_addr = flush.from.clone();
324                let ctx_addr = ctx.addr.clone();
325
326                // For embedded redb, put() already commits inline (wtxn.commit).
327                // Flush has no additional durability work. Send ack immediately.
328                let mut ack_children = BTreeMap::new();
329                ack_children.insert(
330                    "_flushed".to_string(),
331                    NodeData {
332                        value: Value::Text("true".to_string()),
333                        updated_at: SystemTime::now()
334                            .duration_since(UNIX_EPOCH)
335                            .unwrap_or_default()
336                            .as_millis() as f64,
337                    },
338                );
339                let mut ack_nodes = BTreeMap::new();
340                ack_nodes.insert("_ack".to_string(), ack_children);
341                let put = Put::new(ack_nodes, Some(flush_id), ctx_addr.clone());
342                put.to_string(); // compute checksum
343                let _ = from_addr.send(Message::Put(put));
344            }
345            _ => {}
346        }
347    }
348
349    /// Returns a boxed clone for the storage read/write actor split.
350    ///
351    /// Both the read and write actor share the same `Arc<Database>`, so
352    /// reads see committed writes immediately via redb's MVCC snapshots.
353    fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
354        Some(Box::new(self.clone()))
355    }
356}
357
358impl RedbStorage {
359    /// Sends a put-ack back to the originating node after `spawn_blocking`
360    /// returns. The ack payload uses the same `_ack`/`_err` sentinel as
361    /// the Flush ack and as memory_storage — so `Node::handle_put` drains
362    /// `pending_puts` uniformly across both adapters.
363    ///
364    /// Fires AFTER the commit returns from `spawn_blocking` — that's the
365    /// contract. If the commit failed or the task panicked, we send `_err`
366    /// and the awaiting caller learns the failure.
367    fn send_put_ack_after_commit(
368        &self,
369        put_id: &str,
370        put_from: &Addr,
371        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
372        ctx: &ActorContext,
373    ) {
374        let (ack_children, err_msg) = match result {
375            Ok(Ok(())) => (
376                vec![(
377                    "_ack".to_string(),
378                    NodeData {
379                        value: Value::Text("ok".to_string()),
380                        updated_at: SystemTime::now()
381                            .duration_since(UNIX_EPOCH)
382                            .unwrap_or_default()
383                            .as_millis() as f64,
384                    },
385                )]
386                .into_iter()
387                .collect::<BTreeMap<_, _>>(),
388                None,
389            ),
390            Ok(Err(e)) => {
391                error!("redb put commit failed: {:?}", e);
392                (
393                    vec![(
394                        "_err".to_string(),
395                        NodeData {
396                            value: Value::Text(format!("{:?}", e)),
397                            updated_at: SystemTime::now()
398                                .duration_since(UNIX_EPOCH)
399                                .unwrap_or_default()
400                                .as_millis() as f64,
401                        },
402                    )]
403                    .into_iter()
404                    .collect(),
405                    Some(format!("redb put commit failed: {:?}", e)),
406                )
407            }
408            Err(e) => {
409                error!("redb put task panicked: {:?}", e);
410                (
411                    vec![(
412                        "_err".to_string(),
413                        NodeData {
414                            value: Value::Text(format!("task panicked: {:?}", e)),
415                            updated_at: SystemTime::now()
416                                .duration_since(UNIX_EPOCH)
417                                .unwrap_or_default()
418                                .as_millis() as f64,
419                        },
420                    )]
421                    .into_iter()
422                    .collect(),
423                    Some(format!("redb put task panicked: {:?}", e)),
424                )
425            }
426        };
427        let mut nodes = BTreeMap::new();
428        nodes.insert("_ack".to_string(), ack_children);
429        let ack = Put::new(nodes, Some(put_id.to_string()), ctx.addr.clone());
430        let _ = put_from.send(Message::Put(ack));
431        if err_msg.is_some() {
432            debug!("redb put ack sent with _err for {}", put_id);
433        }
434    }
435
436    /// Sends a batch_put ack back to the originating node after commit.
437    ///
438    /// Mirrors `send_put_ack_after_commit` but for the batch case. Uses the
439    /// same `_ack`/`_err` sentinel so the originating `Node::handle_put`
440    /// drains `pending_puts` keyed by `batch.id`.
441    fn send_batch_put_ack_after_commit(
442        &self,
443        batch_id: &str,
444        batch_from: &Addr,
445        result: &Result<Result<(), redb::Error>, tokio::task::JoinError>,
446        ctx: &ActorContext,
447    ) {
448        let (ack_children, err_msg) = match result {
449            Ok(Ok(())) => (
450                vec![(
451                    "_ack".to_string(),
452                    NodeData {
453                        value: Value::Text("ok".to_string()),
454                        updated_at: SystemTime::now()
455                            .duration_since(UNIX_EPOCH)
456                            .unwrap_or_default()
457                            .as_millis() as f64,
458                    },
459                )]
460                .into_iter()
461                .collect::<BTreeMap<_, _>>(),
462                None,
463            ),
464            Ok(Err(e)) => {
465                error!("redb batch_put commit failed: {:?}", e);
466                (
467                    vec![(
468                        "_err".to_string(),
469                        NodeData {
470                            value: Value::Text(format!("{:?}", e)),
471                            updated_at: SystemTime::now()
472                                .duration_since(UNIX_EPOCH)
473                                .unwrap_or_default()
474                                .as_millis() as f64,
475                        },
476                    )]
477                    .into_iter()
478                    .collect(),
479                    Some(format!("redb batch_put commit failed: {:?}", e)),
480                )
481            }
482            Err(e) => {
483                error!("redb batch_put task panicked: {:?}", e);
484                (
485                    vec![(
486                        "_err".to_string(),
487                        NodeData {
488                            value: Value::Text(format!("task panicked: {:?}", e)),
489                            updated_at: SystemTime::now()
490                                .duration_since(UNIX_EPOCH)
491                                .unwrap_or_default()
492                                .as_millis() as f64,
493                        },
494                    )]
495                    .into_iter()
496                    .collect(),
497                    Some(format!("redb batch_put task panicked: {:?}", e)),
498                )
499            }
500        };
501        let mut nodes = BTreeMap::new();
502        nodes.insert("_ack".to_string(), ack_children);
503        let ack = Put::new(nodes, Some(batch_id.to_string()), ctx.addr.clone());
504        let _ = batch_from.send(Message::Put(ack));
505        if err_msg.is_some() {
506            debug!("redb batch_put ack sent with _err for {}", batch_id);
507        }
508    }
509}
510
511impl Default for RedbStorage {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    fn create_test_storage(suffix: &str) -> RedbStorage {
522        let path = format!("/tmp/beam-test-{}-{}.redb", std::process::id(), suffix);
523        RedbStorage::new_with_config(Config::default(), &path, None)
524    }
525
526    #[tokio::test]
527    async fn test_redb_storage_creates_db() {
528        let storage = create_test_storage("create");
529        assert!(!storage.path.is_empty());
530        let _ = std::fs::remove_file(&storage.path);
531    }
532
533    #[tokio::test]
534    async fn test_redb_storage_default() {
535        let storage = RedbStorage::default();
536        let _ = std::fs::remove_file(&storage.path);
537    }
538
539    #[tokio::test]
540    async fn test_redb_storage_clone() {
541        let storage = create_test_storage("clone");
542        let cloned = storage.clone();
543        assert_eq!(storage.path, cloned.path);
544        let _ = std::fs::remove_file(&storage.path);
545    }
546
547    /// Sentinel-drain protocol test: storage MUST always reply when
548    /// `in_response_to` is set on the Get, regardless of checksum match.
549    ///
550    /// # Why this test exists
551    ///
552    /// BEAM's `Node::handle_put` only sends the `__beam_replay_complete__`
553    /// sentinel after a Put with `in_response_to` is received. If storage
554    /// stays silent when checksum matches, the client's `drain_until_sentinel`
555    /// hangs forever. The client use case doesn't pre-set checksum (so this
556    /// bug is latent), but ANY future caller who caches checksums would hit
557    /// it.
558    ///
559    /// This test forces the bug by pre-computing the reply's checksum and
560    /// putting it on the Get — exactly the pattern a caching client would
561    /// use.
562    #[tokio::test]
563    async fn test_redb_get_always_replies_when_in_response_to_set() {
564        use crate::actor::{Actor, ActorContext};
565        use crate::message::Put;
566        use std::collections::BTreeMap;
567
568        let mut storage = create_test_storage("ack-always");
569        let ctx = ActorContext::new("test".to_string());
570
571        // Pre-populate: store a child under node "n1" via the Actor entry point.
572        let mut children = BTreeMap::new();
573        children.insert(
574            "k".to_string(),
575            NodeData {
576                value: Value::Text("v".to_string()),
577                updated_at: 0.0,
578            },
579        );
580        let mut nodes = BTreeMap::new();
581        nodes.insert("n1".to_string(), children.clone());
582        let seed_put = Put::new(nodes, None, ctx.addr.clone());
583        Actor::handle(&mut storage, Arc::new(Message::Put(seed_put)), &ctx).await;
584
585        // Build a buffered `from` address so we can read the reply.
586        let (tx, rx) = crate::mailbox::mailbox(16);
587        let from_addr = crate::actor::Addr::new(tx);
588        let mut rx = rx;
589
590        // Compute the checksum the storage will produce for the reply.
591        let reply = Put::new(
592            {
593                let mut m = BTreeMap::new();
594                m.insert("n1".to_string(), children.clone());
595                m
596            },
597            Some("get-id-42".to_string()),
598            ctx.addr.clone(),
599        );
600        reply.to_string(); // sets reply.checksum
601        let matching_checksum = reply.checksum;
602
603        // Construct a Get with checksum pre-set to MATCH the reply's
604        // checksum. In the buggy code this triggers the no-reply branch.
605        let get = Get {
606            id: "get-id-42".to_string(),
607            from: from_addr.clone(),
608            recipients: None,
609            node_id: "n1".to_string(),
610            checksum: matching_checksum,
611            child_key: None,
612        };
613
614        Actor::handle(&mut storage, Arc::new(Message::Get(get)), &ctx).await;
615
616        // Bug: with the old code, no reply arrives (timeout would be required).
617        // Fix: redb_storage MUST always reply when in_response_to is Some.
618        let received =
619            crate::tokio_time::timeout(web_time::Duration::from_millis(500), rx.recv()).await;
620
621        let _ = std::fs::remove_file(&storage.path);
622
623        match received {
624            Ok(Some(msg)) => match &*msg {
625                Message::Put(reply_put) => {
626                    assert_eq!(
627                        reply_put.in_response_to.as_deref(),
628                        Some("get-id-42"),
629                        "reply must carry in_response_to so client can drain sentinel"
630                    );
631                }
632                other => panic!("expected Put reply, got {:?}", other),
633            },
634            Ok(None) => panic!("sender closed before reply sent"),
635            Err(_) => panic!(
636                "BUG: redb_storage stayed silent despite matching in_response_to. \
637                 This hangs drain_until_sentinel forever."
638            ),
639        }
640    }
641}