Skip to main content

dejadb_store/
asyncdb.rs

1//! Async-safe access to [`DejaDB`].
2//!
3//! Use [`AsyncDejaDB`] whenever the calling code is async. It is the supported
4//! entry point for Tokio-based hosts, which is where most agent code lives.
5//!
6//! # Why a separate handle
7//!
8//! [`DejaDB`] is a blocking store: it owns a Tokio runtime internally and drives
9//! each operation with `Runtime::block_on`. Tokio does not permit a runtime to be
10//! started from within another runtime, so a [`DejaDB`] driven directly from async
11//! code panics — in two distinct places:
12//!
13//! - when an **operation** is called (`Cannot start a runtime from within a runtime`);
14//! - when the handle is **dropped**, because dropping it drops the runtime it owns.
15//!
16//! The second is the easier one to miss: code can look correct and still panic at
17//! teardown.
18//!
19//! # What this handle does
20//!
21//! - Every operation runs on Tokio's **blocking pool**, where blocking is permitted.
22//! - Concurrent callers **queue asynchronously**, so a burst of operations cannot
23//!   exhaust the host's blocking pool with threads that are only waiting their turn.
24//! - **`Drop` moves the store to a dedicated OS thread**, so teardown never blocks
25//!   an async worker.
26//!
27//! Callers simply `.await`. The blocking [`DejaDB`] API is unchanged, and sync
28//! callers pay nothing for this module.
29//!
30//! # Example
31//!
32//! ```no_run
33//! use dejadb_store::AsyncDejaDB;
34//! use dejadb_core::types::Fact;
35//!
36//! # async fn demo() -> dejadb_core::error::Result<()> {
37//! let db = AsyncDejaDB::open("agent.db").await?;
38//! db.add(Fact::new("john", "prefers", "dark mode")).await?;
39//! let latest = db.latest("caller", "john", "prefers").await?;
40//! # Ok(())
41//! # }
42//! ```
43
44use std::sync::{Arc, Mutex};
45
46use tokio::sync::Semaphore;
47
48use dejadb_core::error::{DejaDbError, Hash, Result};
49use dejadb_core::types::{Grain, GrainType};
50
51use crate::{DejaDB, DejaDbOptions, DeserializedGrain, StoreStats};
52
53struct Shared {
54    db: Mutex<Option<DejaDB>>,
55    // The store is `&mut`-driven, so operations serialise regardless. Queue callers
56    // here rather than in the blocking pool: without this, N concurrent operations
57    // occupy N blocking threads that only wait on the mutex, and can exhaust the
58    // host's pool. One permit keeps at most one blocking thread busy per store.
59    gate: Semaphore,
60}
61
62/// A [`DejaDB`] that is safe to use from async code.
63///
64/// Cheap to clone: every clone shares one store. Operations against it serialise,
65/// because the underlying store is `&mut`-driven; callers queue asynchronously
66/// rather than occupying blocking threads. Clone this into tasks rather than
67/// wrapping it in an `Arc`.
68///
69/// Call [`close`](Self::close) when the store must be shut down before the process
70/// moves on; otherwise dropping the last handle is enough.
71#[derive(Clone)]
72pub struct AsyncDejaDB {
73    inner: Arc<Shared>,
74}
75
76impl AsyncDejaDB {
77    /// Open a store at `path`.
78    pub async fn open(path: &str) -> Result<Self> {
79        let p = path.to_owned();
80        Self::opened(move || DejaDB::open(&p)).await
81    }
82
83    /// Open with explicit [`DejaDbOptions`].
84    pub async fn open_with(path: &str, opts: DejaDbOptions) -> Result<Self> {
85        let p = path.to_owned();
86        Self::opened(move || DejaDB::open_with(&p, opts)).await
87    }
88
89    /// Open an encrypted store.
90    ///
91    /// The key is held in a [`zeroize::Zeroizing`] buffer for as long as this call
92    /// owns it, so the copy handed to the blocking pool is wiped once the store is
93    /// open rather than left in freed memory.
94    pub async fn open_encrypted(path: &str, key: [u8; 32]) -> Result<Self> {
95        let p = path.to_owned();
96        let key = zeroize::Zeroizing::new(key);
97        Self::opened(move || DejaDB::open_encrypted(&p, *key)).await
98    }
99
100    async fn opened<F>(open: F) -> Result<Self>
101    where
102        F: FnOnce() -> Result<DejaDB> + Send + 'static,
103    {
104        let db = offload(open).await?;
105        Ok(Self {
106            inner: Arc::new(Shared {
107                db: Mutex::new(Some(db)),
108                gate: Semaphore::new(1),
109            }),
110        })
111    }
112
113    /// Run an arbitrary operation against the store, safely, on the blocking pool.
114    ///
115    /// This is the escape hatch: any [`DejaDB`] method not wrapped below stays
116    /// reachable without reintroducing the runtime hazard.
117    ///
118    /// ```no_run
119    /// # use dejadb_store::AsyncDejaDB;
120    /// # async fn demo(db: &AsyncDejaDB) -> dejadb_core::error::Result<()> {
121    /// let rebuilt = db.with(|db| db.rebuild_text_index()).await?;
122    /// # Ok(())
123    /// # }
124    /// ```
125    pub async fn with<T, F>(&self, op: F) -> Result<T>
126    where
127        F: FnOnce(&mut DejaDB) -> Result<T> + Send + 'static,
128        T: Send + 'static,
129    {
130        let _permit = self.inner.gate.acquire().await.map_err(|_| closed())?;
131        let inner = Arc::clone(&self.inner);
132        offload(move || {
133            let mut guard = inner.db.lock().map_err(|_| poisoned())?;
134            let db = guard.as_mut().ok_or_else(closed)?;
135            op(db)
136        })
137        .await
138    }
139
140    /// Add a grain, returning its content hash.
141    pub async fn add<G>(&self, grain: G) -> Result<Hash>
142    where
143        G: Grain + Send + 'static,
144    {
145        self.with(move |db| db.add(&grain)).await
146    }
147
148    /// Add a grain only if its content is not already present.
149    pub async fn add_if_novel<G>(&self, grain: G) -> Result<(Hash, bool)>
150    where
151        G: Grain + Send + 'static,
152    {
153        self.with(move |db| db.add_if_novel(&grain)).await
154    }
155
156    /// The current grain for `(namespace, subject, relation)`.
157    pub async fn latest(
158        &self,
159        ns: &str,
160        subject: &str,
161        relation: &str,
162    ) -> Result<Option<DeserializedGrain>> {
163        let (ns, subject, relation) = (ns.to_owned(), subject.to_owned(), relation.to_owned());
164        self.with(move |db| db.latest(&ns, &subject, &relation))
165            .await
166    }
167
168    /// Hybrid recall (triple + text + vector legs, RRF-fused).
169    pub async fn recall_hybrid(
170        &self,
171        ns: &str,
172        subject: Option<&str>,
173        relation: Option<&str>,
174        query: Option<&str>,
175        k: usize,
176        deadline: Option<std::time::Duration>,
177    ) -> Result<Vec<DeserializedGrain>> {
178        let ns = ns.to_owned();
179        let subject = subject.map(str::to_owned);
180        let relation = relation.map(str::to_owned);
181        let query = query.map(str::to_owned);
182        self.with(move |db| {
183            db.recall_hybrid(
184                &ns,
185                subject.as_deref(),
186                relation.as_deref(),
187                query.as_deref(),
188                k,
189                deadline,
190            )
191        })
192        .await
193    }
194
195    /// The most recent grains in a namespace.
196    pub async fn recent(
197        &self,
198        ns: &str,
199        gtype: Option<GrainType>,
200        limit: usize,
201    ) -> Result<Vec<DeserializedGrain>> {
202        let ns = ns.to_owned();
203        self.with(move |db| db.recent(&ns, gtype, limit)).await
204    }
205
206    /// Tombstone a grain by hash.
207    pub async fn forget(&self, hash: Hash) -> Result<()> {
208        self.with(move |db| db.forget(&hash)).await
209    }
210
211    /// Store statistics.
212    pub async fn stats(&self) -> Result<StoreStats> {
213        self.with(|db| db.stats()).await
214    }
215
216    /// Close the store, waiting for teardown to finish.
217    ///
218    /// Dropping the last handle also closes the store, but it does so on a detached
219    /// thread and cannot be awaited: if the process exits immediately afterwards,
220    /// teardown may not have run. Call `close` when the store must be shut down
221    /// before the program moves on — on graceful shutdown, before copying the `.db`
222    /// file, or at the end of a test.
223    ///
224    /// The store is shared by every clone, so this closes it for **all** of them:
225    /// subsequent operations on any handle fail. It waits for an in-flight operation
226    /// to finish first, and closing an already-closed store is a no-op.
227    pub async fn close(self) -> Result<()> {
228        let _permit = self.inner.gate.acquire().await.map_err(|_| closed())?;
229        let taken = {
230            let mut guard = self.inner.db.lock().map_err(|_| poisoned())?;
231            guard.take()
232        };
233        match taken {
234            Some(db) => {
235                offload(move || {
236                    drop(db);
237                    Ok(())
238                })
239                .await
240            }
241            None => Ok(()),
242        }
243    }
244}
245
246impl Drop for AsyncDejaDB {
247    fn drop(&mut self) {
248        // Dropping `DejaDB` drops the runtime it owns, which panics in async context.
249        // `get_mut` yields it only for the last handle with no operation in flight;
250        // otherwise the last holder is a blocking-pool thread, where blocking is legal.
251        if let Some(db) = Arc::get_mut(&mut self.inner)
252            .and_then(|s| s.db.get_mut().ok())
253            .and_then(Option::take)
254        {
255            std::thread::spawn(move || drop(db));
256        }
257    }
258}
259
260/// Run a blocking store operation on Tokio's blocking pool, where `block_on` is legal.
261async fn offload<T, F>(op: F) -> Result<T>
262where
263    F: FnOnce() -> Result<T> + Send + 'static,
264    T: Send + 'static,
265{
266    match tokio::task::spawn_blocking(op).await {
267        Ok(r) => r,
268        Err(e) => Err(DejaDbError::Storage(format!(
269            "dejadb blocking task failed: {e}"
270        ))),
271    }
272}
273
274fn poisoned() -> DejaDbError {
275    DejaDbError::Storage("dejadb handle poisoned by a panic in another task".into())
276}
277
278fn closed() -> DejaDbError {
279    DejaDbError::Storage("dejadb handle already closed".into())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use dejadb_core::types::Fact;
286
287    fn tmp(name: &str) -> String {
288        let dir = tempfile::tempdir().expect("tempdir").keep();
289        dir.join(name).to_string_lossy().into_owned()
290    }
291
292    /// On the blocking API both the call and the drop panic inside a runtime.
293    #[tokio::test]
294    async fn is_usable_from_async_code() {
295        let path = tmp("async-usable.db");
296        let db = AsyncDejaDB::open(&path).await.expect("open");
297
298        let mut fact = Fact::new("john", "prefers", "dark mode");
299        fact.common_mut().namespace = Some("caller".to_string());
300        db.add(fact).await.expect("add");
301
302        let got = db
303            .latest("caller", "john", "prefers")
304            .await
305            .expect("latest")
306            .expect("a grain");
307        assert_eq!(
308            got.fields.get("object").and_then(|v| v.as_str()),
309            Some("dark mode"),
310        );
311
312        drop(db);
313    }
314
315    #[tokio::test]
316    async fn escape_hatch_runs_arbitrary_ops_safely() {
317        let path = tmp("async-with.db");
318        let db = AsyncDejaDB::open(&path).await.expect("open");
319
320        let stats = db.with(|db| db.stats()).await.expect("stats via with");
321        assert_eq!(stats.grains, 0);
322    }
323
324    #[tokio::test]
325    async fn close_awaits_teardown() {
326        let path = tmp("async-close.db");
327        let db = AsyncDejaDB::open(&path).await.expect("open");
328
329        let mut fact = Fact::new("grace", "built", "the compiler");
330        fact.common_mut().namespace = Some("caller".to_string());
331        db.add(fact).await.expect("add");
332
333        db.close().await.expect("close");
334
335        let again = AsyncDejaDB::open(&path).await.expect("reopen");
336        assert!(again
337            .latest("caller", "grace", "built")
338            .await
339            .expect("latest")
340            .is_some());
341        again.close().await.expect("close again");
342    }
343
344    /// Concurrent callers must queue on the semaphore, not in the blocking pool.
345    /// Without the gate each of these would hold a blocking thread just to wait on
346    /// the mutex, and enough of them would starve the host's pool.
347    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
348    async fn concurrent_operations_queue_without_hogging_the_blocking_pool() {
349        let path = tmp("async-concurrent.db");
350        let db = AsyncDejaDB::open(&path).await.expect("open");
351
352        let writes = (0..64).map(|i| {
353            let db = db.clone();
354            tokio::spawn(async move {
355                let mut fact = Fact::new(&format!("s{i}"), "n", &i.to_string());
356                fact.common_mut().namespace = Some("caller".to_string());
357                db.add(fact).await
358            })
359        });
360        for w in writes {
361            w.await.expect("task").expect("add");
362        }
363
364        let stats = db.stats().await.expect("stats");
365        assert_eq!(stats.grains, 64);
366        db.close().await.expect("close");
367    }
368
369    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
370    async fn clones_share_one_store() {
371        let path = tmp("async-clone.db");
372        let db = AsyncDejaDB::open(&path).await.expect("open");
373        let handle = db.clone();
374
375        let mut fact = Fact::new("linus", "wrote", "git");
376        fact.common_mut().namespace = Some("caller".to_string());
377        handle.add(fact).await.expect("add via clone");
378
379        drop(handle);
380
381        assert!(db
382            .latest("caller", "linus", "wrote")
383            .await
384            .expect("latest via original")
385            .is_some());
386    }
387
388    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
389    async fn works_on_a_multi_thread_runtime() {
390        let path = tmp("async-multi.db");
391        let db = AsyncDejaDB::open(&path).await.expect("open");
392
393        let mut fact = Fact::new("ada", "wrote", "the first program");
394        fact.common_mut().namespace = Some("caller".to_string());
395        db.add(fact).await.expect("add");
396
397        assert!(db
398            .latest("caller", "ada", "wrote")
399            .await
400            .expect("latest")
401            .is_some());
402    }
403}