Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3e6f29b
feat(private-document-store): add grovedb-private-document-store crate
QuantumExplorer Aug 3, 2026
26a804b
feat: add PrivateDocumentStore element type, gated to GROVE_V4 (#784)
QuantumExplorer Aug 3, 2026
7df52c3
test: raise PrivateDocumentStore patch coverage above the 90% codecov…
QuantumExplorer Aug 3, 2026
fff8363
fix: address CodeRabbit review on PrivateDocumentStore (PR #787)
QuantumExplorer Aug 3, 2026
b94da45
Merge origin/develop into feat/private-document-store
QuantumExplorer Aug 17, 2026
3f1d03e
fix: address the PrivateDocumentStore review findings (PR #787)
QuantumExplorer Aug 19, 2026
677508a
Merge remote-tracking branch 'origin/develop' into feat/private-docum…
QuantumExplorer Aug 19, 2026
4db7b47
fix: thread the declared chunk power into PrivateDocumentStore estimates
QuantumExplorer Aug 19, 2026
637b3ab
fix: address the second review round on PrivateDocumentStore (PR #787)
QuantumExplorer Aug 19, 2026
a6533cd
fix: bill the uncached MMR root read on the lazy path (PR #787)
QuantumExplorer Aug 19, 2026
1d2304c
fix: address the review-body findings on PrivateDocumentStore (PR #787)
QuantumExplorer Aug 19, 2026
2a90139
fix(costs): correct five hash and seek accounting errors on the store…
QuantumExplorer Aug 19, 2026
f05222d
test(pds): cover the corruption and storage-fault paths
QuantumExplorer Aug 19, 2026
540504b
fix(costs): bill compaction work and MMR merge hashes
QuantumExplorer Aug 19, 2026
b7aa74f
fix(costs): drop the duplicated MMR merge charge, and report bagging
QuantumExplorer Aug 19, 2026
f5fb2a7
feat(version): gate the MMR hash-charge corrections behind V0/V1
QuantumExplorer Aug 19, 2026
0e404d6
fix(costs): gate the CommitmentTree compaction under-charge into V4
QuantumExplorer Aug 19, 2026
6fa3277
fix(bulk-append): drop the dead initializer flagged by -D warnings
QuantumExplorer Aug 19, 2026
2c2e95c
refactor: take grove_version directly instead of paired _with_version…
QuantumExplorer Aug 20, 2026
11768ae
refactor(mmr): take grove_version directly on push, get_root and gen_…
QuantumExplorer Aug 20, 2026
f6e76c9
test(pds): cover the empty-at-creation guard on total_count
QuantumExplorer Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ members = [
"grovedb-bulk-append-tree",
"grovedb-dense-fixed-sized-merkle-tree",
"grovedb-query",
"grovedb-private-document-store",
]

[workspace.dependencies]
Expand Down
3 changes: 3 additions & 0 deletions grovedb-bulk-append-tree/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ storage = [
"grovedb-dense-fixed-sized-merkle-tree/storage",
]
mem_store = ["grovedb-merkle-mountain-range/mem_store"]
# Exposes `test_utils::MemStorageContext` to other crates' test builds so the
# in-memory StorageContext harness lives in exactly one place.
test-utils = ["storage"]

[dependencies]
grovedb-merkle-mountain-range = { version = "5.0.1", path = "../grovedb-merkle-mountain-range", default-features = false }
Expand Down
4 changes: 2 additions & 2 deletions grovedb-bulk-append-tree/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ mod error;
pub mod proof;
mod tree;

#[cfg(all(test, feature = "storage"))]
pub(crate) mod test_utils;
#[cfg(all(feature = "storage", any(test, feature = "test-utils")))]
pub mod test_utils;

// Re-export main types
pub use chunk::{deserialize_chunk_blob, serialize_chunk_blob};
Expand Down
46 changes: 42 additions & 4 deletions grovedb-bulk-append-tree/src/test_utils.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Test utilities: in-memory StorageContext for BulkAppendTree tests.

use std::{cell::RefCell, collections::HashMap};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
};

use grovedb_costs::{
storage_cost::key_value_cost::KeyValueStorageCost, ChildrenSizesWithIsSumTree, CostContext,
Expand All @@ -13,22 +16,51 @@ use grovedb_storage::{Batch, RawIterator, StorageContext};
/// Immediate reads and writes backed by a `HashMap`. Only `get` and `put`
/// (data storage) have real implementations; all other `StorageContext`
/// methods panic if called.
///
/// `fail_get` / `fail_put` inject storage faults. Tree code is full of arms
/// that can only be reached when the backing store errors mid-operation —
/// exactly the paths that decide whether a fault surfaces or is silently
/// swallowed — and without injection those arms are untestable.
#[derive(Default)]
pub(crate) struct MemStorageContext {
pub struct MemStorageContext {
pub data: RefCell<HashMap<Vec<u8>, Vec<u8>>>,
pub fail_get: Cell<bool>,
pub fail_put: Cell<bool>,
}

impl MemStorageContext {
pub fn new() -> Self {
Self::default()
}

/// Make every subsequent `get` return a storage error.
pub fn fail_reads(&self) {
self.fail_get.set(true);
}

/// Make every subsequent `put` return a storage error.
pub fn fail_writes(&self) {
self.fail_put.set(true);
}

/// Resume normal operation.
pub fn heal(&self) {
self.fail_get.set(false);
self.fail_put.set(false);
}
}

impl<'db> StorageContext<'db> for MemStorageContext {
type Batch = MemBatch;
type RawIterator = MemRawIterator;

fn get<K: AsRef<[u8]>>(&self, key: K) -> CostResult<Option<Vec<u8>>, grovedb_storage::Error> {
if self.fail_get.get() {
return Err(grovedb_storage::Error::StorageError(
"injected read failure".to_string(),
))
.wrap_with_cost(OperationCost::default());
}
Ok(self.data.borrow().get(key.as_ref()).cloned()).wrap_with_cost(OperationCost::default())
}

Expand All @@ -39,6 +71,12 @@ impl<'db> StorageContext<'db> for MemStorageContext {
_children_sizes: ChildrenSizesWithIsSumTree,
_cost_info: Option<KeyValueStorageCost>,
) -> CostResult<(), grovedb_storage::Error> {
if self.fail_put.get() {
return Err(grovedb_storage::Error::StorageError(
"injected write failure".to_string(),
))
.wrap_with_cost(OperationCost::default());
}
self.data
.borrow_mut()
.insert(key.as_ref().to_vec(), value.to_vec());
Expand Down Expand Up @@ -141,7 +179,7 @@ impl<'db> StorageContext<'db> for MemStorageContext {
// ── Batch and RawIterator stubs ───────────────────────────────────────

/// No-op batch (never used — MemStorageContext does immediate writes).
pub(crate) struct MemBatch;
pub struct MemBatch;

impl Batch for MemBatch {
fn put<K: AsRef<[u8]>>(
Expand Down Expand Up @@ -186,7 +224,7 @@ impl Batch for MemBatch {
}

/// Stub iterator (never used by the bulk append tree).
pub(crate) struct MemRawIterator;
pub struct MemRawIterator;

impl RawIterator for MemRawIterator {
fn seek_to_first(&mut self) -> CostContext<()> {
Expand Down
199 changes: 176 additions & 23 deletions grovedb-bulk-append-tree/src/tree/append.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Append and compaction logic for BulkAppendTree.

use grovedb_costs::{CostResult, CostsExt, OperationCost};
use grovedb_merkle_mountain_range::{
hash_count_for_push, mmr_size_to_leaf_count, MmrKeySize, MmrNode, MmrStore, MMR,
};
Expand Down Expand Up @@ -128,6 +129,76 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
})
}

/// Append a value deferring **both** the dense-tree root and the
/// state root.
///
/// Storage effect is identical to [`append`](Self::append), but the
/// per-insert `compute_root_hash` walk over the dense buffer is skipped.
/// [`append_no_state_root`](Self::append_no_state_root) still pays that
/// walk on every call (via `try_insert`), which makes a run of N appends
/// O(N^2) in hash calls — 65,535 entries at `height = 16` costs ~4.3
/// billion. This variant is O(N) plus one final root computation.
///
/// The caller MUST recover the state root once at the end via
/// [`compute_current_state_root`](Self::compute_current_state_root);
/// until then the dense root is stale in-memory only (it is always
/// recomputed from stored values, never cached).
///
/// Compaction still happens inline when the buffer fills, because the
/// chunk blob is built from the stored values, not from the root.
pub fn append_deferred_roots(
&mut self,
value: &[u8],
) -> CostResult<AppendNoStateRootResult, BulkAppendError> {
let mut cost = OperationCost::default();
let mut hash_count: u32 = 0;
let global_position = self.total_count;

let try_result = match self
.dense_tree
.try_insert_no_root(value)
.unwrap_add_cost(&mut cost)
{
Ok(r) => r,
Err(e) => {
return Err(BulkAppendError::StorageError(format!(
"dense tree insert failed: {}",
e
)))
.wrap_with_cost(cost);
}
};

let compacted = match try_result {
// Inserted into the buffer; no root walk, so no hashes yet.
Some(_position) => false,
None => {
// Buffer full — compact existing entries plus this value.
// Must run before incrementing total_count so self.mmr_size()
// reflects the pre-compaction state.
let (compact_hashes, mmr_root) = match self
.compact_with_value_with_cost(value)
.unwrap_add_cost(&mut cost)
{
Ok(r) => r,
Err(e) => return Err(e).wrap_with_cost(cost),
};
hash_count += compact_hashes;
self.last_mmr_root = Some(mmr_root);
true
}
};

self.total_count += 1;

Ok(AppendNoStateRootResult {
global_position,
hash_count,
compacted,
})
.wrap_with_cost(cost)
}

/// Compute the current state root without modifying the tree.
///
/// Uses the cached MMR root when available, so this is O(1) on the
Expand All @@ -145,37 +216,103 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
Ok(compute_state_root(&mmr_root, &dense_root))
}

/// Cost-propagating variant of
/// [`compute_current_state_root`](Self::compute_current_state_root).
///
/// Identical result; the difference is that the dense-tree root walk's
/// storage reads and hash calls reach the caller instead of being
/// discarded, and the final state-root blake3 is charged on top of them.
/// Callers that bill work — anything returning a `CostResult` — should
/// prefer this.
pub fn compute_current_state_root_with_cost(&self) -> CostResult<[u8; 32], BulkAppendError> {
let mut cost = OperationCost::default();
let mmr_root = match self.last_mmr_root {
Some(r) => r,
// Lazy path: a reopened tree has no cached root, so this read is
// real I/O and must be billed.
None => match self.get_mmr_root_with_cost().unwrap_add_cost(&mut cost) {
Ok(r) => r,
Err(e) => return Err(e).wrap_with_cost(cost),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
let dense_root = match self.dense_tree.root_hash().unwrap_add_cost(&mut cost) {
Ok(r) => r,
Err(e) => {
return Err(BulkAppendError::StorageError(format!(
"dense tree root_hash failed: {}",
e
)))
.wrap_with_cost(cost);
}
};
// `root_hash` already charged the walk itself: `hash_node` bills a
// value hash and a node hash for every filled position it visits, and
// those reached us through `unwrap_add_cost` above. Only the final
// blake3 combining the MMR and dense roots is still unbilled.
cost.hash_node_calls = cost.hash_node_calls.saturating_add(1);
Ok(compute_state_root(&mmr_root, &dense_root)).wrap_with_cost(cost)
}

/// Compact all dense tree entries plus a new value into a chunk blob
/// and append to the chunk MMR. Resets the dense tree.
/// Returns `(hash_count, mmr_root)`.
///
/// Cost-discarding wrapper over
/// [`compact_with_value_with_cost`](Self::compact_with_value_with_cost).
/// Kept so the released `append_no_state_root` path bills exactly what it
/// always has — its costs are dropped here, not at the call site.
fn compact_with_value(&mut self, new_value: &[u8]) -> Result<(u32, [u8; 32]), BulkAppendError> {
self.compact_with_value_with_cost(new_value).unwrap()
}

/// Compact the buffer plus `new_value` into a chunk, propagating cost.
///
/// Compaction is the expensive branch of an append: it reads every
/// buffered entry back out of storage, hashes the serialized blob, and
/// pushes it through the MMR. All of that was previously discarded, so a
/// compacting append looked no more expensive than a buffered one.
fn compact_with_value_with_cost(
&mut self,
new_value: &[u8],
) -> CostResult<(u32, [u8; 32]), BulkAppendError> {
let mut cost = OperationCost::default();
let mut hash_count: u32 = 0;
let count = self.dense_tree.count();

// Read all existing entries from dense tree
let mut entries: Vec<Vec<u8>> = Vec::with_capacity(count as usize + 1);
for i in 0..count {
let value = self
.dense_tree
.get(i)
.unwrap()
.map_err(|e| {
BulkAppendError::StorageError(format!("dense tree get at {} failed: {}", i, e))
})?
.ok_or_else(|| {
BulkAppendError::CorruptedData(format!(
let read = self.dense_tree.get(i).unwrap_add_cost(&mut cost);
let value = match read {
Ok(Some(v)) => v,
Ok(None) => {
return Err(BulkAppendError::CorruptedData(format!(
"dense tree missing value at position {} (count={})",
i, count
))
})?;
)))
.wrap_with_cost(cost);
}
Err(e) => {
return Err(BulkAppendError::StorageError(format!(
"dense tree get at {} failed: {}",
i, e
)))
.wrap_with_cost(cost);
}
};
entries.push(value);
}

// Add the new value that didn't fit
entries.push(new_value.to_vec());

// Serialize chunk blob as a standard MMR leaf — hash = blake3(0x00 || blob)
let blob = serialize_chunk_blob(&entries)?;
let blob = match serialize_chunk_blob(&entries) {
Ok(b) => b,
Err(e) => return Err(e).wrap_with_cost(cost),
};
// `MmrNode::leaf` hashes the blob eagerly.
cost.hash_node_calls = cost.hash_node_calls.saturating_add(1);
let leaf = MmrNode::leaf(blob);

// Append chunk root to MMR
Expand All @@ -192,22 +329,24 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
let mut mmr =
MMR::new_with_overlay(mmr_size, &mmr_store, std::mem::take(&mut self.mmr_overlay));

let push_result = mmr.push(leaf).unwrap();
let push_result = mmr.push(leaf).unwrap_add_cost(&mut cost);
if let Err(e) = push_result {
// Restore overlay before returning error
self.mmr_overlay = mmr.batch.take_overlay();
return Err(BulkAppendError::MmrError(format!("MMR push failed: {}", e)));
return Err(BulkAppendError::MmrError(format!("MMR push failed: {}", e)))
.wrap_with_cost(cost);
}

let root_result = mmr.get_root().unwrap();
let root_result = mmr.get_root().unwrap_add_cost(&mut cost);
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
let root = match root_result {
Ok(node) => node.hash(),
Err(e) => {
self.mmr_overlay = mmr.batch.take_overlay();
return Err(BulkAppendError::MmrError(format!(
"MMR get_root failed: {}",
e
)));
)))
.wrap_with_cost(cost);
}
};

Expand All @@ -220,22 +359,36 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
// Reset dense tree (old values stay in store, overwritten on next cycle)
self.dense_tree.reset();

Ok((hash_count, mmr_root))
Ok((hash_count, mmr_root)).wrap_with_cost(cost)
}

/// Get the MMR root hash, or `[0; 32]` if no chunks exist.
pub(crate) fn get_mmr_root(&self) -> Result<[u8; 32], BulkAppendError> {
self.get_mmr_root_with_cost().unwrap()
}

/// Cost-propagating variant of [`get_mmr_root`](Self::get_mmr_root).
///
/// Matters on the lazy path: `from_state` leaves `last_mmr_root` as
/// `None`, so a REOPENED non-empty tree resolves its root through here —
/// exactly the case proof binding and the integrity walk hit. Discarding
/// the read cost there undercharges their storage I/O.
pub(crate) fn get_mmr_root_with_cost(&self) -> CostResult<[u8; 32], BulkAppendError> {
let mut cost = OperationCost::default();
let mmr_size = self.mmr_size();
if mmr_size == 0 {
return Ok([0u8; 32]);
return Ok([0u8; 32]).wrap_with_cost(cost);
}
let mmr_store = MmrStore::with_key_size(&self.dense_tree.storage, MmrKeySize::U32);
let mmr = MMR::new_with_overlay(mmr_size, &mmr_store, self.mmr_overlay.clone());
let root_node = mmr
.get_root()
.unwrap()
.map_err(|e| BulkAppendError::MmrError(format!("MMR get_root failed: {}", e)))?;
Ok(root_node.hash())
match mmr.get_root().unwrap_add_cost(&mut cost) {
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
Ok(root_node) => Ok(root_node.hash()).wrap_with_cost(cost),
Err(e) => Err(BulkAppendError::MmrError(format!(
"MMR get_root failed: {}",
e
)))
.wrap_with_cost(cost),
}
}

/// Flush the MMR overlay to storage.
Expand Down
Loading
Loading