Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 3 additions & 3 deletions grovedb-bulk-append-tree/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use grovedb_storage::{Batch, RawIterator, StorageContext};
/// (data storage) have real implementations; all other `StorageContext`
/// methods panic if called.
#[derive(Default)]
pub(crate) struct MemStorageContext {
pub struct MemStorageContext {
pub data: RefCell<HashMap<Vec<u8>, Vec<u8>>>,
}

Expand Down Expand Up @@ -141,7 +141,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 +186,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
55 changes: 55 additions & 0 deletions grovedb-bulk-append-tree/src/tree/append.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,61 @@ 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],
) -> Result<AppendNoStateRootResult, BulkAppendError> {
let mut hash_count: u32 = 0;
let global_position = self.total_count;

let try_result = self
.dense_tree
.try_insert_no_root(value)
.unwrap()
.map_err(|e| {
BulkAppendError::StorageError(format!("dense tree insert failed: {}", e))
})?;

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) = self.compact_with_value(value)?;
hash_count += compact_hashes;
self.last_mmr_root = Some(mmr_root);
true
}
};

self.total_count += 1;

Ok(AppendNoStateRootResult {
global_position,
hash_count,
compacted,
})
}

/// Compute the current state root without modifying the tree.
///
/// Uses the cached MMR root when available, so this is O(1) on the
Expand Down
66 changes: 53 additions & 13 deletions grovedb-bulk-append-tree/src/tree/fetch.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Read operations for BulkAppendTree.

use grovedb_costs::{CostResult, CostsExt, OperationCost};
use grovedb_dense_fixed_sized_merkle_tree::DenseTreeProof;
use grovedb_merkle_mountain_range::{leaf_to_pos, MmrKeySize, MmrStore, MMR};
use grovedb_query::Query;
Expand Down Expand Up @@ -38,12 +39,34 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
/// from completed chunks. The position is relative to the current buffer
/// cycle (0-based).
pub fn get_buffer_value(&self, position: u16) -> Result<Option<Vec<u8>>, BulkAppendError> {
self.get_buffer_value_with_cost(position).unwrap()
}

/// Cost-propagating variant of
/// [`get_buffer_value`](Self::get_buffer_value).
///
/// Identical behavior; the difference is that the dense-tree read's
/// `OperationCost` (seek count and loaded bytes) reaches the caller
/// instead of being discarded. Callers that bill reads — anything
/// returning a `CostResult` to GroveDB — must use this variant, or the
/// read is served for free.
pub fn get_buffer_value_with_cost(
&self,
position: u16,
) -> CostResult<Option<Vec<u8>>, BulkAppendError> {
let mut cost = OperationCost::default();
if position >= self.buffer_count() {
return Ok(None);
return Ok(None).wrap_with_cost(cost);
}
self.dense_tree.get(position).unwrap().map_err(|e| {
BulkAppendError::StorageError(format!("dense tree get at {} failed: {}", position, e))
})
let result = self.dense_tree.get(position).unwrap_add_cost(&mut cost);
result
.map_err(|e| {
BulkAppendError::StorageError(format!(
"dense tree get at {} failed: {}",
position, e
))
})
.wrap_with_cost(cost)
}

/// Query the buffer using a dense tree query.
Expand Down Expand Up @@ -73,28 +96,45 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
/// Uses the MMR overlay to find nodes that were pushed during this session
/// but not yet committed to storage.
pub fn get_chunk_value(&self, chunk_index: u64) -> Result<Option<Vec<u8>>, BulkAppendError> {
self.get_chunk_value_with_cost(chunk_index).unwrap()
}

/// Cost-propagating variant of
/// [`get_chunk_value`](Self::get_chunk_value). See
/// [`get_buffer_value_with_cost`](Self::get_buffer_value_with_cost) for
/// why the cost-bearing form exists.
pub fn get_chunk_value_with_cost(
&self,
chunk_index: u64,
) -> CostResult<Option<Vec<u8>>, BulkAppendError> {
let mut cost = OperationCost::default();
if chunk_index >= self.chunk_count() {
return Ok(None);
return Ok(None).wrap_with_cost(cost);
}
let mmr_pos = leaf_to_pos(chunk_index);
let mmr_store = MmrStore::with_key_size(&self.dense_tree.storage, MmrKeySize::U32);
let mmr = MMR::new_with_overlay(self.mmr_size(), &mmr_store, self.mmr_overlay.clone());
let node = mmr
let node = match mmr
.batch
.element_at_position(mmr_pos)
.unwrap()
.map_err(|e| {
BulkAppendError::MmrError(format!(
.unwrap_add_cost(&mut cost)
{
Ok(n) => n,
Err(e) => {
return Err(BulkAppendError::MmrError(format!(
"failed to read MMR node for chunk {}: {}",
chunk_index, e
))
})?;
)))
.wrap_with_cost(cost);
}
};
match node {
Some(n) => Ok(n.into_value()),
Some(n) => Ok(n.into_value()).wrap_with_cost(cost),
None => Err(BulkAppendError::CorruptedData(format!(
"missing MMR leaf for chunk {}",
chunk_index
))),
)))
.wrap_with_cost(cost),
}
}

Expand Down
29 changes: 29 additions & 0 deletions grovedb-dense-fixed-sized-merkle-tree/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,35 @@ impl<'db, S: StorageContext<'db>> DenseFixedSizedMerkleTree<S> {
}
}

/// Insert a value **without** recomputing the root hash.
///
/// Same storage effect as [`try_insert`](Self::try_insert) — the value is
/// written at the next free position and `count` is incremented — but the
/// O(count) `compute_root_hash` walk is skipped. Returns the position, or
/// `None` when the tree is already full.
///
/// Use this when inserting a run of values and only the FINAL root
/// matters: calling [`try_insert`](Self::try_insert) in a loop is
/// O(n^2) in hash calls, because every insert re-walks every filled
/// position. Recover the root once at the end with
/// [`root_hash`](Self::root_hash).
pub fn try_insert_no_root(
&mut self,
value: &[u8],
) -> CostResult<Option<u16>, DenseMerkleError> {
let mut cost = OperationCost::default();

if self.count >= self.capacity() {
return Ok(None).wrap_with_cost(cost);
}

let position = self.count;
cost_return_on_error!(cost, self.put_value(position, value));
self.count += 1;

Ok(Some(position)).wrap_with_cost(cost)
}

/// Get a value by position.
///
/// Returns `None` if position >= count. Returns an error if position <
Expand Down
59 changes: 59 additions & 0 deletions grovedb-element/src/element/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,65 @@ impl Element {
Element::DenseAppendOnlyFixedSizeTree(count, height, flags)
}

/// Set element to an empty private document store.
///
/// Returns `InvalidInput` unless `entry_size >= 1` and `chunk_power` is
/// in `1..=16` (the underlying `BulkAppendTree` dense-buffer height
/// range). Unlike `empty_commitment_tree` / `empty_bulk_append_tree`,
/// the constraints are enforced eagerly here: the configuration is
/// committed into the state root, so an unusable config must never be
/// constructible.
pub fn empty_private_document_store(
entry_size: u32,
chunk_power: u8,
) -> Result<Self, ElementError> {
Self::empty_private_document_store_with_flags(entry_size, chunk_power, None)
}

/// Set element to an empty private document store with flags.
///
/// Same validation as [`Element::empty_private_document_store`].
pub fn empty_private_document_store_with_flags(
entry_size: u32,
chunk_power: u8,
flags: Option<ElementFlags>,
) -> Result<Self, ElementError> {
if entry_size == 0 {
return Err(ElementError::InvalidInput(
"private document store entry_size must be non-zero",
));
}
if !(1..=16).contains(&chunk_power) {
return Err(ElementError::InvalidInput(
"private document store chunk_power must be between 1 and 16",
));
}
Ok(Element::PrivateDocumentStore(
0,
entry_size,
chunk_power,
flags,
))
}

/// Set element to a private document store with all fields.
///
/// Restoration constructor: unchecked, mirroring `new_commitment_tree` /
/// `new_bulk_append_tree` — it rebuilds an element from already-validated
/// state (stored bytes, batch metadata). Invalid configurations are
/// rejected at every real ingress: the `empty_*` constructors, the direct
/// and batch insert paths, and both (de)serialization codecs
/// (`Element::serialize` / `Element::deserialize` / serde) via
/// [`Element::validate_private_document_store_config`].
pub fn new_private_document_store(
total_count: u64,
entry_size: u32,
chunk_power: u8,
flags: Option<ElementFlags>,
) -> Self {
Element::PrivateDocumentStore(total_count, entry_size, chunk_power, flags)
}

/// Set element to an empty provable sum-indexed tree without flags.
pub fn empty_provable_sum_indexed_tree() -> Self {
Element::ProvableSumIndexedTree(None, None, 0, None)
Expand Down
Loading
Loading