diff --git a/crates/codegraph-graph/Cargo.toml b/crates/codegraph-graph/Cargo.toml index b68baa4bdc..9de89475e6 100644 --- a/crates/codegraph-graph/Cargo.toml +++ b/crates/codegraph-graph/Cargo.toml @@ -84,3 +84,8 @@ criterion = { version = "0.5", features = ["async_tokio"] } [[bench]] name = "search_bloom" harness = false + +[[bench]] +name = "lmdb_batch_read" +harness = false +required-features = ["lmdb"] diff --git a/crates/codegraph-graph/benches/lmdb_batch_read.rs b/crates/codegraph-graph/benches/lmdb_batch_read.rs new file mode 100644 index 0000000000..4dd400b7a3 --- /dev/null +++ b/crates/codegraph-graph/benches/lmdb_batch_read.rs @@ -0,0 +1,128 @@ +//! Benchmark đọc LMDB: so sánh đọc TỪNG node (mỗi lần 1 read-txn) với đọc BATCH +//! (nhiều node trong 1 read-txn) — đo tác động của `get_nodes`/`get_childrens`. +//! +//! ```bash +//! cargo bench -p codegraph-graph --bench lmdb_batch_read --features lmdb +//! ``` +//! +//! Nhóm đo: +//! - `single_read_{1,16,64}` — gọi `get_node` lặp lại cho từng id. +//! - `batch_read_{1,16,64}` — gọi `get_nodes` 1 lần cho cả id. +//! - `search_dfs` — DFS thật trên trie đã dựng (đo end-to-end). + +use codegraph_graph::{CategoryStorage, LmdbStorage}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; + +const N_NODES: usize = 4096; + +/// Bộ id hợp lệ để đọc — dựng sẵn node thật trong LMDB. +fn setup(rt: &tokio::runtime::Runtime) -> (tempfile::TempDir, String, Vec) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bench.lmdb").to_string_lossy().into_owned(); + let ids = rt.block_on(async { + let mut s = LmdbStorage::open(&path).await.unwrap(); + let mut ids = Vec::with_capacity(N_NODES); + for i in 0..N_NODES { + // Prefix ngắn, giá trị record tăng dần — mô phỏng trie phẳng. + let id = s + .new_node(format!("n{i:05}").into_bytes(), i + 1) + .await + .unwrap(); + ids.push(id); + } + ids + }); + (dir, path, ids) +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() +} + +fn bench_batch_vs_single(c: &mut Criterion) { + let rt = runtime(); + let (_dir, path, ids) = setup(&rt); + let st = rt.block_on(async { LmdbStorage::open(&path).await.unwrap() }); + + let mut group = c.benchmark_group("lmdb_node_read"); + + for &k in &[1usize, 16, 64] { + let ids: Vec = ids.iter().copied().take(k).collect(); + + group.bench_function(format!("single_{k}"), |b| { + b.iter(|| { + rt.block_on(async { + for &id in &ids { + black_box(st.get_node(id).await.unwrap()); + } + }); + }); + }); + + group.bench_function(format!("batch_{k}"), |b| { + b.iter(|| { + rt.block_on(async { + black_box(st.get_nodes(&ids).await.unwrap()); + }); + }); + }); + } + + group.finish(); +} + +/// Đọc children: `get_children` lặp vs `get_childrens` batch. +fn bench_children_batch(c: &mut Criterion) { + let rt = runtime(); + let (_dir, path, _ids) = setup(&rt); + let parents = rt.block_on(async { + let mut s = LmdbStorage::open(&path).await.unwrap(); + let mut parents = Vec::new(); + for i in 0..64usize { + let p = s + .new_node(format!("p{i:03}").into_bytes(), i) + .await + .unwrap(); + // Mỗi parent có 32 child. + for j in 0..32usize { + let c = s + .new_node(format!("c{i:03}_{j:03}").into_bytes(), j) + .await + .unwrap(); + let mut tx = s.new_tx(); + tx.add_child(p, c).await.unwrap(); + tx.commit().await.unwrap(); + } + parents.push(p); + } + parents + }); + let st = rt.block_on(async { LmdbStorage::open(&path).await.unwrap() }); + + let mut group = c.benchmark_group("lmdb_children_read"); + for &k in &[1usize, 16, 64] { + let ps: Vec = parents.iter().copied().take(k).collect(); + group.bench_function(format!("single_{k}"), |b| { + b.iter(|| { + rt.block_on(async { + for &p in &ps { + black_box(st.get_children(p).await.unwrap()); + } + }); + }); + }); + group.bench_function(format!("batch_{k}"), |b| { + b.iter(|| { + rt.block_on(async { + black_box(st.get_childrens(&ps).await.unwrap()); + }); + }); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_batch_vs_single, bench_children_batch); +criterion_main!(benches); diff --git a/crates/codegraph-graph/src/radix.rs b/crates/codegraph-graph/src/radix.rs index 5adf2bc8ee..0debe18174 100644 --- a/crates/codegraph-graph/src/radix.rs +++ b/crates/codegraph-graph/src/radix.rs @@ -677,8 +677,16 @@ impl Radix { break; }; - let (prefix_bytes, _record) = - { self.storage.read().await.get_node(frame.node_id).await? }; + // Đọc node của frame + children của frame trong MỘT + // read-txn (`get_nodes`/`get_childrens`): xem + // `CategoryStorage::get_nodes` — giảm số read-txn mỗi + // vòng DFS, tránh chạm trần reader-slot của LMDB khi nhiều + // request search song song. + let (prefix_bytes, _record) = { + let st = self.storage.read().await; + let mut out = st.get_nodes(&[frame.node_id]).await?; + out.pop().expect("get_nodes trả về đúng 1 phần tử") + }; let prefix = Self::to_vec(&prefix_bytes); let result = matcher(&prefix, pattern, frame.pattern_pos); @@ -691,11 +699,9 @@ impl Radix { } let children = { - self.storage - .read() - .await - .get_children(frame.node_id) - .await? + let st = self.storage.read().await; + let mut out = st.get_childrens(&[frame.node_id]).await?; + out.pop().expect("get_childrens trả về đúng 1 phần tử") }; let mut descended = false; while frame.cont_idx < result.continuations.len() { @@ -707,12 +713,24 @@ impl Radix { } let next_elem = pattern[pp]; + // Pre-fetch node của MỌI child còn lại trong 1 + // read-txn thay vì mỗi child 1 txn. Chỉ fetch phần chưa + // duyệt; `base` ánh xạ chỉ số tương đối về `children` + // (giống hệt hành vi cũ từng child một). + let base = frame.child_idx.min(children.len()); + let remaining = &children[base..]; + let child_nodes = if remaining.is_empty() { + Vec::new() + } else { + let st = self.storage.read().await; + st.get_nodes(remaining).await? + }; while frame.child_idx < children.len() { let child = children[frame.child_idx]; + let rel = frame.child_idx - base; frame.child_idx += 1; - let (cp_bytes, _) = - { self.storage.read().await.get_node(child).await? }; - let cp = Self::to_vec(&cp_bytes); + let (cp_bytes, _) = &child_nodes[rel]; + let cp = Self::to_vec(cp_bytes); if cp.is_empty() || cp[0] != next_elem { continue; } @@ -758,12 +776,18 @@ impl Radix { } DfsState::Collect { root, mut stack } => { if let Some((node_id, child_idx)) = stack.pop() { - let (_prefix_bytes, record) = - { self.storage.read().await.get_node(node_id).await? }; + // Gom node + children của cùng `node_id` — 2 read-txn + // điển hình xuống 2 lần gọi batch (mỗi lần 1 txn). + let (record, children) = { + let st = self.storage.read().await; + let mut node = st.get_nodes(&[node_id]).await?; + let node = node.pop().expect("get_nodes trả về đúng 1 phần tử"); + let mut children = st.get_childrens(&[node_id]).await?; + (node.1, children.pop().expect("get_childrens trả 1 phần tử")) + }; if record != storage::EMPTY { records.push(record); } - let children = { self.storage.read().await.get_children(node_id).await? }; if child_idx < children.len() { stack.push((node_id, child_idx + 1)); stack.push((children[child_idx], 0)); diff --git a/crates/codegraph-graph/src/storage.rs b/crates/codegraph-graph/src/storage.rs index fdde6a2fad..68d13fb6ac 100644 --- a/crates/codegraph-graph/src/storage.rs +++ b/crates/codegraph-graph/src/storage.rs @@ -161,6 +161,10 @@ enum TxOp { /// chưa lộ ra cho reader cho tới khi `commit` hoàn tất. /// /// `commit(self: Box)` tiêu thụ chính transaction — không thể commit 2 lần. +// `async_trait` tự sinh `#[must_use]` cho future; double_must_use (clippy 1.99) +// báo trùng vì `Pin>` vốn đã `must_use`. Lỗi nằm trong macro +// của async_trait, không phải code viết tay → allow tại chỗ sinh ra. +#[allow(clippy::double_must_use)] #[async_trait] pub trait Tx: Send { async fn new_node(&mut self, prefix: Vec, record: usize) -> Result; @@ -183,6 +187,7 @@ pub trait Tx: Send { /// khi feature bật → method gọi được qua `dyn CategoryStorage` như cũ. /// Backend không override → default no-op. #[cfg(feature = "bloom-search")] +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait BloomStorage: Send + Sync { async fn set_node_bloom(&mut self, _id: usize, _bloom: &[u8]) -> Result<()> { @@ -199,6 +204,7 @@ pub trait BloomStorage: Send + Sync { /// element id, cùng `clear`. Tách riêng để trait lõi gọn. `CategoryStorage` /// super-bound trait này (luôn) → method gọi được qua `dyn CategoryStorage`. /// Mặc định no-op. +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait NodeMetaStorage: Send + Sync { /// Lưu metadata của node (opaque bytes, VD Node JSON) keyed theo element id. @@ -236,6 +242,7 @@ pub trait NodeMetaStorage: Send + Sync { /// lookup (KMP + DFS). Tách riêng để trait lõi gọn. `CategoryStorage` /// super-bound trait này (luôn) → method gọi được qua `dyn CategoryStorage`. /// Mặc định no-op. +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait ShortcutsStorage: Send + Sync { /// Thêm `node_id` vào shortcut set của element `elem` (encoded bytes). @@ -262,6 +269,7 @@ pub trait ShortcutsStorage: Send + Sync { /// Edge-data storage: lưu/đọc metadata của mỗi edge id (opaque bytes) keyed /// theo edge id. Tách riêng để trait lõi gọn. `CategoryStorage` super-bound /// trait này (luôn) → method gọi được qua `dyn CategoryStorage`. Mặc định no-op. +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait EdgeDataStorage: Send + Sync { /// Lưu dữ liệu edge (opaque bytes, VD CallEdgeMeta JSON) keyed theo edge id. @@ -284,6 +292,7 @@ pub trait EdgeDataStorage: Send + Sync { /// encode u64 LE 8-byte/element. Tách riêng để trait lõi gọn. /// `CategoryStorage` super-bound trait này (luôn) → method gọi được qua /// `dyn CategoryStorage`. Mặc định no-op. +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait ChainStorage: Send + Sync { /// Lưu chain của owner (keyed theo record của owner; u64 LE 8-byte/element). @@ -310,7 +319,8 @@ pub trait ChainStorage: Send + Sync { macro_rules! declare_category_storage { ($($bounds:tt)*) => { /// Radix-node storage: node management + transaction + 5 stream phụ. - #[async_trait] + #[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) +#[async_trait] pub trait CategoryStorage: $($bounds)* { // ── Node management ── async fn new_node(&mut self, prefix: Vec, record: usize) -> Result; @@ -323,6 +333,40 @@ macro_rules! declare_category_storage { async fn get_node(&self, id: usize) -> Result<(Vec, usize)>; async fn get_children(&self, id: usize) -> Result>; + // ── Batch reads (MỐT read-txn cho nhiều id) ── + // + // Radix DFS (`radix.rs::search_dfs`) gọi `get_node`/`get_children` + // cho TỪNG node/child → mỗi lần đọc là một `begin_ro_txn` riêng nên + // một lần search tạo O(nodes) read-txn. Khi nhiều request search chạy + // song song (MCP/GraphQL dùng chung `Arc`), số read-txn + // đồng thời nhân lên và có thể chạm trần reader-slot của LMDB → + // `MDB_BAD_RSLOT`. + // + // 2 hàm này gom nhiều id vào MỘT read-txn. Backend nền (in-memory, + // sqlite, redis, rdbms) không override — default impl gọi lại hàm + // đơn lẻ nên hành vi giữ nguyên. LMDB override để dùng 1 txn. + + /// Đọc nhiều node trong MỘT read-txn — thứ tự khớp `ids`. + /// + /// Node không tồn tại → `Err(BranchOutOfRange)` (giống `get_node`). + async fn get_nodes(&self, ids: &[usize]) -> Result, usize)>> { + let mut out = Vec::with_capacity(ids.len()); + for &id in ids { + out.push(self.get_node(id).await?); + } + Ok(out) + } + + /// Đọc children của nhiều node trong MỐT read-txn — `out[i]` là + /// children của `ids[i]`, đã sort. Node không tồn tại → `Err`. + async fn get_childrens(&self, ids: &[usize]) -> Result>> { + let mut out = Vec::with_capacity(ids.len()); + for &id in ids { + out.push(self.get_children(id).await?); + } + Ok(out) + } + // ── Shard roots (endpoint) ── async fn set_root(&mut self, shard: usize, root: usize) -> Result<()>; async fn get_root(&self, shard: usize) -> Result; @@ -357,6 +401,8 @@ declare_category_storage!( /// - Chỉ `GraphIndex` / `SharedGraphIndex` dùng (`Radix` / `Search` không cần). /// - Backend tối giản có thể bỏ qua (vd: chỉ cần `CategoryStorage` cho test). /// - Cho phép phát triển/scale entity layer độc lập với radix. +// Xem `Tx` — cùng lý do `double_must_use` (clippy 1.99). +#[allow(clippy::double_must_use)] #[async_trait] pub trait EntityStorage: Send + Sync { // ── Symbol registry ── @@ -492,6 +538,7 @@ pub trait EntityStorage: Send + Sync { /// /// Gộp `CategoryStorage` + 5 trait phụ + `EntityStorage`. Backend implement /// 7 `impl` block riêng biệt — review từng phần độc lập được. +#[allow(clippy::double_must_use)] // async_trait sinh `must_use` trùng (clippy 1.99) #[async_trait] pub trait Storage: CategoryStorage diff --git a/crates/codegraph-graph/src/storage/lmdb.rs b/crates/codegraph-graph/src/storage/lmdb.rs index 08a6ef37cb..646680e5db 100644 --- a/crates/codegraph-graph/src/storage/lmdb.rs +++ b/crates/codegraph-graph/src/storage/lmdb.rs @@ -211,44 +211,56 @@ fn open_env_read_only(path: &str) -> lmdb::Result { b.open(Path::new(path)) } -/// Cache read-only `Environment` theo path — 1 env dùng chung cho mọi `probe_version`. +/// Env + DBI handle dùng cho `probe_version`, cache theo path. /// -/// `Environment` là `Send + Sync` nên an toàn để dùng chung; env sống trọn -/// process (không drop) để locktable không bị mở/đóng lặp. +/// `Environment` là `Send + Sync` nên an toàn để dùng chung; `Database` là +/// handle `Copy` chỉ trỏ vào env. Cả hai sống trọn process (không drop) để +/// locktable không bị mở/đóng lặp. #[cfg(feature = "lmdb")] #[cfg_attr(feature = "sqlite", allow(dead_code))] // probe chỉ dùng khi lmdb là backend file -fn probe_env(path: &str) -> lmdb::Result> { +struct ProbeHandle { + env: Arc, + db: Database, +} + +/// Cache read-only `(Environment, Database)` theo path — 1 cặp dùng chung cho +/// mọi `probe_version`. +#[cfg(feature = "lmdb")] +#[cfg_attr(feature = "sqlite", allow(dead_code))] // probe chỉ dùng khi lmdb là backend file +fn probe_env(path: &str) -> lmdb::Result> { let data = Path::new(path).join("data.mdb"); if !data.is_file() { return Err(lmdb::Error::NotFound); } - static CACHE: std::sync::LazyLock>>> = + static CACHE: std::sync::LazyLock>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); let mut cache = CACHE.lock().expect("probe env cache lock"); - if let Some(env) = cache.get(path) { - return Ok(env.clone()); + if let Some(handle) = cache.get(path) { + return Ok(handle.clone()); } let env = Arc::new(open_env_read_only(path)?); - cache.insert(path.to_string(), env.clone()); - Ok(env) + let db = env.open_db(Some(D_VERSION))?; + let handle = Arc::new(ProbeHandle { env, db }); + cache.insert(path.to_string(), handle.clone()); + Ok(handle) } /// Đọc `version` từ file mà KHÔNG tạo file (nếu chưa có) — dùng bởi /// `SharedGraphIndex::ensure_fresh` để dò stale. Mirror `SqliteStorage::probe_version`. /// -/// Reuse env cache (`probe_env`) để không mở/đóng `Environment` mỗi lần gọi — -/// `MDB_BAD_RSLOT` xảy ra khi nhiều `Environment` cùng mở/đóng trên một locktable -/// (lock.mdb) khi nhiều request probe song song (runtime/mcp: mỗi request gọi qua -/// `ensure_fresh` → `current_version`). Cache theo path giữ 1 env read-only dùng -/// chung (sống trọn process) nên không còn tranh chấp slot reader. +/// Reuse cache (`probe_env`) để không mở/đóng `Environment` **và không `open_db`** +/// mỗi lần gọi — `MDB_BAD_RSLOT` xảy ra khi nhiều `Environment` cùng mở/đóng trên +/// một locktable (lock.mdb) khi nhiều request probe song song (runtime/mcp: mỗi +/// request gọi qua `ensure_fresh` → `current_version`). Cache theo path giữ 1 env +/// read-only + DBI handle dùng chung (sống trọn process) nên mỗi request chỉ còn +/// đúng 1 `begin_ro_txn` + 1 `get`. #[cfg(feature = "lmdb")] #[cfg_attr(feature = "sqlite", allow(dead_code))] // probe chỉ dùng khi lmdb là backend file pub async fn probe_version(path: &str) -> Result { - let env = probe_env(path) + let handle = probe_env(path) .map_err(|err| StorageError::Internal(format!("lmdb file not found: {path} ({err})")))?; - let db = env.open_db(Some(D_VERSION)).map_err(e)?; - let tx = env.begin_ro_txn().map_err(e)?; - match tx.get(db, &KEY_ONE).map(de_u64) { + let tx = handle.env.begin_ro_txn().map_err(e)?; + match tx.get(handle.db, &KEY_ONE).map(de_u64) { Ok(v) => Ok(v), Err(lmdb::Error::NotFound) => { Err(StorageError::Internal("lmdb version row missing".into())) @@ -489,6 +501,42 @@ impl CategoryStorage for LmdbStorage { Ok(out) } + /// Batch đọc node: MỘT `begin_ro_txn` cho cả `ids` — xem + /// [`CategoryStorage::get_nodes`] (giải thích `MDB_BAD_RSLOT`). + async fn get_nodes(&self, ids: &[usize]) -> Result, usize)>> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let tx = self.env.begin_ro_txn().map_err(e)?; + let mut out = Vec::with_capacity(ids.len()); + for &id in ids { + let Some(v) = self.get_opt(&tx, self.nodes, &k8(id))? else { + return Err(StorageError::BranchOutOfRange(id)); + }; + out.push(de_node_val(v)); + } + Ok(out) + } + + /// Batch đọc children: MỘT `begin_ro_txn` cho cả `ids`, mỗi list vẫn sort + /// như `get_children` để DFS không đổi thứ tự duyệt. + async fn get_childrens(&self, ids: &[usize]) -> Result>> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let tx = self.env.begin_ro_txn().map_err(e)?; + let mut out = Vec::with_capacity(ids.len()); + for &id in ids { + let mut list = match self.get_opt(&tx, self.children, &k8(id))? { + Some(v) => de_list(v), + None => Vec::new(), + }; + list.sort_unstable(); + out.push(list); + } + Ok(out) + } + async fn set_root(&mut self, shard: usize, root: usize) -> Result<()> { let mut tx = self.env.begin_rw_txn().map_err(e)?; tx.put(self.roots, &k8(shard), &k8(root), WriteFlags::empty()) @@ -1097,6 +1145,125 @@ mod tests { (dir, path) } + /// `get_nodes` phải trả đúng thứ tự `ids` và giống hệt `get_node` từng cái. + #[tokio::test] + async fn test_get_nodes_batch_matches_single() { + let (_d, path) = tmp_path(); + let mut s = LmdbStorage::open(&path).await.unwrap(); + let mut ids: Vec = Vec::new(); + for i in 1..=20usize { + ids.push(s.new_node(vec![i as u8; (i % 5) + 1], i * 3).await.unwrap()); + } + + let batch = s.get_nodes(&ids).await.unwrap(); + assert_eq!(batch.len(), ids.len()); + for (i, &id) in ids.iter().enumerate() { + assert_eq!(batch[i], s.get_node(id).await.unwrap()); + } + + // Rỗng → rỗng, không mở txn nào. + assert!(s.get_nodes(&[]).await.unwrap().is_empty()); + } + + /// `get_childrens` phải trả list đã sort, khớp `get_children` từng cái. + #[tokio::test] + async fn test_get_childrens_batch_matches_single() { + let (_d, path) = tmp_path(); + let mut s = LmdbStorage::open(&path).await.unwrap(); + let a = s.new_node(b"a".to_vec(), 1).await.unwrap(); + let b = s.new_node(b"b".to_vec(), 2).await.unwrap(); + // Hai child của `a`, tạo ngược thứ tự id để chắc chắn phải sort mới ra + // thứ tự tăng dần. + let kids = [ + s.new_node(b"k1".to_vec(), 10).await.unwrap(), + s.new_node(b"k0".to_vec(), 11).await.unwrap(), + ]; + for &kid in &kids { + let mut tx = s.new_tx(); + tx.add_child(a, kid).await.unwrap(); + tx.commit().await.unwrap(); + } + + let batch = s.get_childrens(&[a, b]).await.unwrap(); + assert_eq!(batch[0], s.get_children(a).await.unwrap()); + assert_eq!(batch[1], s.get_children(b).await.unwrap()); + // Children luôn sort tăng dần dù tạo ngược thứ tự. + let mut sorted = kids.to_vec(); + sorted.sort_unstable(); + assert_eq!(batch[0], sorted); + // Node không có child → list rỗng (không phải lỗi). + assert!(batch[1].is_empty()); + + assert!(s.get_childrens(&[]).await.unwrap().is_empty()); + } + + /// Id không tồn tại → `BranchOutOfRange`, giống `get_node` đơn lẻ. + #[tokio::test] + async fn test_batch_missing_id_errors() { + let (_d, path) = tmp_path(); + let s = LmdbStorage::open(&path).await.unwrap(); + assert!(matches!( + s.get_nodes(&[999_999]).await, + Err(StorageError::BranchOutOfRange(_)) + )); + } + + /// Regression `MDB_BAD_RSLOT`: N task đọc SONG SONG phải không lỗi khi + /// reader-slot LMDB có trần (`max_readers`). Trước khi có batch read, + /// mỗi `get_node` là một read-txn nên tải đọc nhân lên rất nhanh. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_batch_reads_no_bad_rslot() { + use std::sync::Arc; + let (_d, path) = tmp_path(); + let ids = { + let mut s = LmdbStorage::open(&path).await.unwrap(); + let mut ids = Vec::new(); + for i in 1..=500usize { + ids.push(s.new_node(vec![(i % 251) as u8; 6], i).await.unwrap()); + } + for &id in ids.iter() { + let mut tx = s.new_tx(); + tx.add_child(EMPTY, id).await.unwrap(); + tx.commit().await.unwrap(); + } + ids + }; + let st = Arc::new(LmdbStorage::open(&path).await.unwrap()); + + // 8 task × 200 vòng batch-read — tổng ~800k lần đọc node. + let mut handles = Vec::new(); + for _ in 0..8 { + let st = Arc::clone(&st); + let ids = ids.clone(); + handles.push(tokio::spawn(async move { + for _ in 0..200 { + let chunk: Vec = ids.iter().copied().take(64).collect(); + let got = st.get_nodes(&chunk).await.unwrap(); + assert_eq!(got.len(), chunk.len()); + } + })); + } + for h in handles { + h.await.unwrap(); + } + } + + /// `probe_version` phải trả đúng version khi gọi lặp lại (dùng env + DBI + /// handle cache) và vẫn lỗi khi path không tồn tại. + #[tokio::test] + async fn test_probe_version_reuses_cached_handle() { + let (_d, path) = tmp_path(); + { + let mut s = LmdbStorage::open(&path).await.unwrap(); + s.set_version(42).await.unwrap(); + } + // Nhiều lần gọi liên tiếp — lần 2+ đi qua cache handle. + for _ in 0..100 { + assert_eq!(probe_version(&path).await.unwrap(), 42); + } + assert!(probe_version("definitely/missing.lmdb").await.is_err()); + } + #[tokio::test] async fn test_new_node_and_get_node() { let (_d, path) = tmp_path();