Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
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 @@ -22,7 +22,7 @@ pub use error::BulkAppendError;
pub use grovedb_dense_fixed_sized_merkle_tree::{DenseFixedSizedMerkleTree, DenseTreeProof};
#[cfg(feature = "storage")]
pub use grovedb_merkle_mountain_range::{MmrKeySize, MmrStore};
pub use proof::{BulkAppendTreeProof, BulkAppendTreeProofResult};
pub use tree::{hash::compute_state_root, leaf_count_to_mmr_size, BulkAppendTree};
pub use proof::{position_range_query, BulkAppendTreeProof, BulkAppendTreeProofResult};
pub use tree::{hash::compute_state_root, leaf_count_to_mmr_size, BulkAppendTree, RangePage};
#[cfg(feature = "storage")]
pub use tree::{AppendResult, BufferQueryResult, ChunkQueryResult};
67 changes: 67 additions & 0 deletions grovedb-bulk-append-tree/src/proof/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,25 @@ fn query_to_ranges(query: &Query, total_count: u64) -> Result<Vec<(u64, u64)>, B
Ok(merged)
}

/// Build the canonical [`Query`] selecting the position range
/// `[start, start + limit)`, with positions encoded as 8-byte big-endian
/// keys.
///
/// This is the query shape used by the paginated-scan pattern: prover and
/// verifier both derive it from `(start, limit)`, so a client only needs its
/// cursor and page size. `start + limit` saturates at `u64::MAX`, and
/// verification clamps the range to the tree's provable total count.
pub fn position_range_query(start: u64, limit: u16) -> Query {
let end = start.saturating_add(limit as u64);
Query {
items: vec![QueryItem::Range(
start.to_be_bytes().to_vec()..end.to_be_bytes().to_vec(),
)],
left_to_right: true,
..Query::default()
}
}

/// Check whether `pos` falls inside any of the sorted, non-overlapping ranges.
fn in_ranges(pos: u64, ranges: &[(u64, u64)]) -> bool {
ranges
Expand Down Expand Up @@ -325,6 +344,54 @@ impl BulkAppendTreeProof {
})
}

/// Generate a proof for the paginated position range
/// `[start, start + limit)`.
///
/// Convenience wrapper over [`generate`](Self::generate) using the
/// canonical [`position_range_query`]. The proof is chunk-aligned: it
/// carries each completed chunk blob overlapping the range plus the
/// buffer entries in range, so proof size is O(chunks touched).
///
/// Ranges past the end of the tree are valid and produce a proof of the
/// (empty) result: absence of positions `>= total_count` falls out of
/// the authenticated element's total count, not out of per-position
/// absence proofs.
#[cfg(feature = "storage")]
pub fn generate_for_range<'db, S: StorageContext<'db>>(
tree: &BulkAppendTree<S>,
start: u64,
limit: u16,
) -> Result<Self, BulkAppendError> {
Self::generate(&position_range_query(start, limit), tree)
}

/// Verify this proof against the paginated position range
/// `[start, start + limit)`.
///
/// Convenience wrapper over
/// [`verify_against_query`](Self::verify_against_query) using the
/// canonical [`position_range_query`]. Returns the `(global_position,
/// value)` pairs in the range, ascending and contiguous, clamped to
/// `total_count`. Completeness is enforced: a proof missing any
/// requested position below `total_count` is rejected. Positions
/// `>= total_count` are provably absent by `total_count` itself, which
/// callers must take from the authenticated BulkAppendTree element.
pub fn verify_range(
&self,
expected_state_root: &[u8; 32],
height: u8,
total_count: u64,
start: u64,
limit: u16,
) -> Result<Vec<(u64, Vec<u8>)>, BulkAppendError> {
self.verify_against_query(
expected_state_root,
height,
total_count,
&position_range_query(start, limit),
)
}

/// Verify this proof against an expected state root.
///
/// `height` and `total_count` come from the authenticated BulkAppendTree
Expand Down
166 changes: 166 additions & 0 deletions grovedb-bulk-append-tree/src/proof/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,4 +940,170 @@ mod proof_tests {
);
}
}

// ── generate_for_range / verify_range (paginated scan pattern) ───────

/// Helper: build a tree of `n` values "val_0".."val_{n-1}" and return
/// (state_root, tree).
fn build_indexed_tree(height: u8, n: u32) -> ([u8; 32], BulkAppendTree<MemStorageContext>) {
let values: Vec<Vec<u8>> = (0..n).map(|i| format!("val_{}", i).into_bytes()).collect();
build_test_tree(height, &values)
}

/// Helper: round-trip a range proof and assert the returned page is
/// exactly positions `expected_start..expected_end`.
fn assert_range_roundtrip(
state_root: &[u8; 32],
tree: &BulkAppendTree<MemStorageContext>,
start: u64,
limit: u16,
expected_start: u64,
expected_end: u64,
) {
let proof =
BulkAppendTreeProof::generate_for_range(tree, start, limit).expect("generate range");

// Wire round-trip: encode + decode like a real client
let bytes = proof.encode_to_vec().expect("encode");
let decoded = BulkAppendTreeProof::decode_from_slice(&bytes).expect("decode");

let entries = decoded
.verify_range(state_root, tree.height(), tree.total_count, start, limit)
.expect("verify range");

assert_eq!(entries.len(), (expected_end - expected_start) as usize);
for (i, (pos, value)) in entries.iter().enumerate() {
assert_eq!(*pos, expected_start + i as u64);
assert_eq!(value, format!("val_{}", pos).as_bytes());
}
}

#[test]
fn test_range_roundtrip_buffer_only() {
// height=3, capacity=7: 5 values all in buffer
let (root, tree) = build_indexed_tree(3, 5);
assert_range_roundtrip(&root, &tree, 1, 3, 1, 4);
}

#[test]
fn test_range_roundtrip_across_chunk_boundary() {
// height=2, epoch_size=4: 10 values = 2 chunks + 2 buffered
let (root, tree) = build_indexed_tree(2, 10);
// spans chunk 0 / chunk 1
assert_range_roundtrip(&root, &tree, 3, 3, 3, 6);
// spans chunk 1 / buffer
assert_range_roundtrip(&root, &tree, 6, 4, 6, 10);
}

#[test]
fn test_range_roundtrip_single_entry_pages() {
let (root, tree) = build_indexed_tree(2, 10);
for pos in 0..10u64 {
assert_range_roundtrip(&root, &tree, pos, 1, pos, pos + 1);
}
}

#[test]
fn test_range_roundtrip_empty_range() {
let (root, tree) = build_indexed_tree(2, 10);
// limit 0: proof still verifies against the root, returns nothing
assert_range_roundtrip(&root, &tree, 3, 0, 3, 3);
}

#[test]
fn test_range_roundtrip_past_end() {
let (root, tree) = build_indexed_tree(2, 10);
// starts exactly at total_count
assert_range_roundtrip(&root, &tree, 10, 5, 10, 10);
// starts far past total_count
assert_range_roundtrip(&root, &tree, 1000, 5, 1000, 1000);
// clamped at the end
assert_range_roundtrip(&root, &tree, 8, 100, 8, 10);
}

#[test]
fn test_range_roundtrip_large_multi_chunk_page() {
// height=4, epoch_size=16: 100 values = 6 chunks + 4 buffered.
// One page covering everything touches all chunks and the buffer.
let (root, tree) = build_indexed_tree(4, 100);
assert_range_roundtrip(&root, &tree, 0, 100, 0, 100);
// A large page crossing several chunk boundaries mid-tree
assert_range_roundtrip(&root, &tree, 10, 70, 10, 80);
}

#[test]
fn test_range_roundtrip_empty_tree() {
let (_, tree) = build_indexed_tree(2, 0);
// For an empty tree the state root is blake3("bulk_state" || 0*32 || 0*32)
let root = crate::compute_state_root(&[0u8; 32], &[0u8; 32]);
assert_range_roundtrip(&root, &tree, 0, 10, 0, 0);
}

#[test]
fn test_range_paged_scan_covers_everything() {
// The client scan pattern: page through the whole tree with
// limit=7 (deliberately not aligned to epoch_size=4).
let (root, tree) = build_indexed_tree(2, 30);
let mut cursor = 0u64;
let mut seen = Vec::new();
while cursor < tree.total_count {
let proof =
BulkAppendTreeProof::generate_for_range(&tree, cursor, 7).expect("generate page");
let entries = proof
.verify_range(&root, tree.height(), tree.total_count, cursor, 7)
.expect("verify page");
assert!(!entries.is_empty());
cursor += entries.len() as u64;
seen.extend(entries);
}
assert_eq!(seen.len(), 30);
for (i, (pos, value)) in seen.iter().enumerate() {
assert_eq!(*pos, i as u64);
assert_eq!(value, format!("val_{}", i).as_bytes());
}
}

#[test]
fn test_range_proof_wrong_root_rejected() {
let (root, tree) = build_indexed_tree(2, 10);
let proof = BulkAppendTreeProof::generate_for_range(&tree, 0, 5).expect("generate");
let mut bad_root = root;
bad_root[0] ^= 1;
proof
.verify_range(&bad_root, tree.height(), tree.total_count, 0, 5)
.expect_err("tampered root must be rejected");
}

#[test]
fn test_range_proof_missing_chunk_rejected() {
// Proof generated for [0, 2) (chunk 0 only) must not verify a
// request for [0, 6) which also needs chunk 1.
let (root, tree) = build_indexed_tree(2, 10);
let narrow = BulkAppendTreeProof::generate_for_range(&tree, 0, 2).expect("generate");
narrow
.verify_range(&root, tree.height(), tree.total_count, 0, 6)
.expect_err("proof missing chunk 1 must be rejected for the wider range");
}

#[test]
fn test_position_range_query_shape() {
let q = super::super::position_range_query(5, 3);
assert_eq!(q.items.len(), 1);
match &q.items[0] {
QueryItem::Range(r) => {
assert_eq!(r.start, 5u64.to_be_bytes().to_vec());
assert_eq!(r.end, 8u64.to_be_bytes().to_vec());
}
other => panic!("expected Range item, got {:?}", other),
}

// start + limit saturates instead of wrapping
let q = super::super::position_range_query(u64::MAX - 1, 100);
match &q.items[0] {
QueryItem::Range(r) => {
assert_eq!(r.end, u64::MAX.to_be_bytes().to_vec());
}
other => panic!("expected Range item, got {:?}", other),
}
}
}
9 changes: 9 additions & 0 deletions grovedb-bulk-append-tree/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ use grovedb_storage::{Batch, RawIterator, StorageContext};
#[derive(Default)]
pub(crate) struct MemStorageContext {
pub data: RefCell<HashMap<Vec<u8>, Vec<u8>>>,
/// When set, every `get` fails — simulates a broken backing store for
/// exercising storage-error paths.
pub fail_gets: std::cell::Cell<bool>,
}

impl MemStorageContext {
Expand All @@ -29,6 +32,12 @@ impl<'db> StorageContext<'db> for MemStorageContext {
type RawIterator = MemRawIterator;

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

Expand Down
86 changes: 85 additions & 1 deletion grovedb-bulk-append-tree/src/tree/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use grovedb_merkle_mountain_range::{leaf_to_pos, MmrKeySize, MmrStore, MMR};
use grovedb_query::Query;
use grovedb_storage::StorageContext;

use super::BulkAppendTree;
use super::{BulkAppendTree, RangePage};
use crate::{chunk::deserialize_chunk_blob, BulkAppendError};

/// Result of querying the dense tree buffer.
Expand Down Expand Up @@ -63,6 +63,90 @@ impl<'db, S: StorageContext<'db>> BulkAppendTree<S> {
Ok(BufferQueryResult { entries, proof })
}

// ── Range operations (chunks + buffer) ───────────────────────────

/// Fetch entries for the position range `[start, start + limit)`,
/// clamped to the tree's total count.
///
/// This is the paginated-scan read path: clients walking "all entries
/// since my cursor" call it with their cursor as `start` and advance by
/// `entries.len()`. The read is chunk-aligned — each completed chunk
/// overlapping the range is read and deserialized exactly once, so a
/// page costs O(chunks touched) blob reads plus one read per buffer
/// entry, not O(entries) random reads.
///
/// Absence needs no lookup: positions `>= total_count` do not exist, so
/// a page shorter than `limit` means the end of the tree was reached.
pub fn get_range(&self, start: u64, limit: u16) -> Result<RangePage, BulkAppendError> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve range-read storage costs

get_range returns a plain Result, so the MMR element_at_position(...).unwrap() and every buffer get discard their CostResult.cost. Consequently, bulk_get_range and commitment_tree_get_range report none of the page's seeks or loaded bytes—up to 65,535 uncharged buffer reads. Please return and aggregate a CostResult through both GroveDB wrappers so cost limits reflect the actual work.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Claude. Fixed in acb99bd: get_range now returns a CostResult — the chunk-MMR node reads and every dense-buffer read charge their seeks and loaded bytes (buffer reads go through dense_tree.get directly so nothing is discarded), and the costs aggregate through CommitmentTree::get_range, bulk_get_range, and commitment_tree_get_range. Added test_bulk_get_range_reports_storage_costs, which asserts nonzero seek/loaded-byte costs on a chunk+buffer-spanning page and that an 11-entry page loads more bytes than a 1-entry page.

let total_count = self.total_count;
let end = start.saturating_add(limit as u64).min(total_count);
if start >= end {
return Ok(RangePage {
entries: Vec::new(),
total_count,
});
}
let mut entries = Vec::with_capacity((end - start) as usize);

let epoch_size = self.epoch_size();
let buffer_start = self.chunk_count() * epoch_size;

// Completed chunks overlapping [start, min(end, buffer_start)).
// The MMR (with its overlay clone) is built once and reused for every
// chunk in the page — going through `get_chunk_value` would rebuild
// it, and re-clone the overlay, per chunk.
let chunk_end = end.min(buffer_start);
if start < chunk_end {
let first_chunk = start / epoch_size;
let last_chunk = (chunk_end - 1) / epoch_size;
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());
for chunk_idx in first_chunk..=last_chunk {
let node = mmr
.batch
.element_at_position(leaf_to_pos(chunk_idx))
.unwrap()
.map_err(|e| {
BulkAppendError::MmrError(format!(
"failed to read MMR node for chunk {}: {}",
chunk_idx, e
))
})?;
let blob = node.and_then(|n| n.into_value()).ok_or_else(|| {
BulkAppendError::CorruptedData(format!(
"missing chunk blob for index {}",
chunk_idx
))
})?;
let chunk_entries = deserialize_chunk_blob(&blob)?;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate completed chunk length

deserialize_chunk_blob accepts any valid encoded entry count, but this loop assumes each completed chunk contains exactly epoch_size entries. A short chunk silently omits positions; an oversized chunk can overlap positions from the next chunk. The returned page then violates its contiguous-page contract and cursor scans can stall before total_count. Please reject the chunk as corrupted unless chunk_entries.len() == epoch_size as usize.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Claude. Fixed in acb99bd: get_range now rejects a completed chunk as CorruptedData unless it deserializes to exactly epoch_size entries, with a test that tampers the MMR overlay with both a short and an oversized blob. One note on scope: the check is deliberately only on this raw read path — in proof verification the chunk bytes are bound to the state root (a wrong-length blob changes the root and fails the comparison), and the audit NOTE in proof/mod.rs documents that adding a length check there is redundant by design, so that side is unchanged.

let chunk_start = chunk_idx * epoch_size;
for (i, value) in chunk_entries.into_iter().enumerate() {
let pos = chunk_start + i as u64;
if pos >= start && pos < chunk_end {
entries.push((pos, value));
}
}
}
}

// Buffer tail: positions in [max(start, buffer_start), end)
for pos in start.max(buffer_start)..end {
let buffer_pos = (pos - buffer_start) as u16;
let value = self.get_buffer_value(buffer_pos)?.ok_or_else(|| {
BulkAppendError::CorruptedData(format!(
"missing buffer value at position {}",
buffer_pos
))
})?;
entries.push((pos, value));
}

Ok(RangePage {
entries,
total_count,
})
}

// ── Chunk operations (MMR) ───────────────────────────────────────

/// Get a single completed chunk's raw blob by chunk index.
Expand Down
Loading
Loading