From f945e008b8ec25425ec3071eb37c08220ee48130 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 07:05:37 +0700 Subject: [PATCH 01/12] refactor(drive): unify AVG no-prove dispatch into a single count+sum walk (#3687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3687. The AVG dispatcher's no-prove path previously composed parallel `DocumentCountRequest` + `DocumentSumRequest` calls under a shared read transaction and zipped the responses. Correct but double-walked grovedb per AVG query and required count's and sum's routing tables to stay in lock-step (PR #3661 caught one drift). A new `Drive::execute_document_count_and_sum_request` reads `(count, sum)` from each visited PCPS / CountSumTree element in one walk via `Element::count_sum_value_or_default()`. Mode resolution reuses sum's existing versioned routing table so a single source of truth governs the (where × mode) → executor decision. Co-Authored-By: Claude Opus 4.7 (1M context) --- book/src/drive/average-index-examples.md | 6 + .../drive_dispatcher.rs | 1115 ++++++++++++----- .../query/drive_document_average_query/mod.rs | 19 +- .../drive_dispatcher.rs | 212 ++++ .../executors/mod.rs | 21 + .../executors/per_in_value.rs | 202 +++ .../executors/range_no_proof.rs | 219 ++++ .../executors/total.rs | 218 ++++ .../drive_document_count_and_sum_query/mod.rs | 54 + packages/rs-drive/src/query/mod.rs | 7 + 10 files changed, 1736 insertions(+), 337 deletions(-) create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/mod.rs create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs create mode 100644 packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs diff --git a/book/src/drive/average-index-examples.md b/book/src/drive/average-index-examples.md index ad3eb4dc9a3..9e837349db8 100644 --- a/book/src/drive/average-index-examples.md +++ b/book/src/drive/average-index-examples.md @@ -1702,3 +1702,9 @@ The split closely parallels the count and sum chapters — point lookups for Q1 The chapter is grounded in the [`document_average_worst_case`](https://github.com/dashpay/platform/blob/v3.1-dev/packages/rs-drive/benches/document_average_worst_case.rs) bench's measured numbers — Q1–Q7 verify cleanly end-to-end against the shared root hash `8b15f732…ffc7`. A natural expansion follow-up (out of scope here): a worked example of "exact-precision" averages — for callers that need fractional averages (e.g. `avg = 50.7142857…` rather than `50.99`), the protocol-level approach is to return `(count, sum)` and let the client compute in its preferred numeric format (the chapter notes this in [Numerical Considerations](#numerical-considerations) above; a future expansion could walk through the fixed-point vs. floating-point trade-offs). + +### No-proof path: single-walk joint dispatch + +Both the prove path (above) and the no-proof path now read `(count, sum)` from each visited count-sum-bearing element in a **single** grovedb walk. The no-proof path used to compose parallel count + sum sub-requests and zip the responses; that worked under a shared read transaction (atomic) but did twice the grovedb work strictly necessary. As of [#3687](https://github.com/dashpay/platform/issues/3687) the joint dispatcher at `crate::query::drive_document_count_and_sum_query` walks PCPS / CountSumTree elements once per AVG no-prove query and folds `(count, sum)` in Rust — halving the per-query grovedb work. + +One forward-looking optimization remains open in grovedb: a no-prove engine-side `query_aggregate_count_and_sum` accumulator (the proof-side analog `AggregateCountAndSumOnRange` already exists and is what Q5 / Q7 above use). Today's no-prove flat-summed branch walks all matched PCPS elements and folds in Rust; an engine-side accumulator could collapse that fold the way `query_aggregate_sum` collapses the sum-side equivalent. Not a blocker — the Rust-side fold is correctness-equivalent and the per-element count-sum decode is cheap — but it would close the residual perf gap between the no-prove and prove paths. diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 6b8261ef911..6092f71ac1d 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -1,34 +1,36 @@ //! Average-query dispatcher entry point. //! -//! Implementation strategy: **compose** count + sum into the -//! `(count, sum)` pair the client divides. Both executors are real -//! and live in `drive_document_count_query` / -//! `drive_document_sum_query` respectively; the average dispatcher -//! issues both requests under the same `transaction` and zips their -//! responses together by `(in_key, key)` for grouped shapes. +//! Routes a [`DocumentAverageRequest`] to one of two backends: +//! - **No-prove path** → delegates to the joint count-and-sum +//! dispatcher +//! [`Drive::execute_document_count_and_sum_request`], which walks +//! grovedb ONCE and reads both metrics from each visited +//! count-sum-bearing element via +//! [`grovedb::Element::count_sum_value_or_default`]. See its module +//! docstring for the routing / atomicity contract. +//! - **Prove path** → dispatched to +//! [`Drive::execute_document_average_prove`] (defined below), which +//! routes to one of the PCPS / direct-read prove executors based on +//! `(mode, where_clauses)`. The prove path's per-shape rules are +//! unchanged. //! -//! ## Why compose instead of using a single PCPS traversal? +//! ## Why the unified single-walk dispatch //! -//! grovedb's `AggregateCountAndSumOnRange` primitive returns both -//! metrics from one root-hash-committed traversal — cheaper on the -//! wire and atomic — but it only fires when the chosen index has -//! a `ProvableCountProvableSumTree` terminator (i.e. `rangeCountable -//! + rangeSummable`). For doctypes/indexes that lack PCPS-eligibility -//! (just `documentsSummable` without `rangeCountable`, for example) -//! the no-prove path has to compose two reads instead: +//! The previous implementation composed parallel +//! `DocumentCountRequest` + `DocumentSumRequest` calls on the no-prove +//! path under a shared read transaction and zipped the responses. That +//! shape was correct (the shared transaction guaranteed atomicity) +//! but did twice the grovedb work strictly necessary, and required +//! count's and sum's routing tables to stay in lock-step for AVG to +//! compose correctly (PR #3661 caught one drift bug). The single-walk +//! dispatcher in [`crate::query::drive_document_count_and_sum_query`] +//! replaces both concerns: one routing table (sum's), one grovedb +//! walk per AVG no-prove query, halving the per-query work. //! -//! - **No-prove paths**: count + sum are read within the same -//! grovedb snapshot, so they see identical state (no block- -//! boundary race, no off-by-one). When the caller passes a -//! `TransactionArg::None` (the drive-abci query path), the -//! dispatcher opens a short-lived read transaction internally and -//! reuses it across both sub-calls so the atomicity guarantee -//! holds regardless of caller plumbing. The internal transaction -//! is rolled back at the end (read-only, never commits). -//! - **Prove path**: dispatched to -//! [`Drive::execute_document_average_prove`] (defined below), -//! which routes to one of the PCPS / direct-read prove executors -//! based on `(mode, where_clauses)`: +//! ## Prove path shapes (unchanged) +//! +//! The prove-path routing table at +//! [`Self::execute_document_average_prove`] picks one of: //! - empty-where + `documentsCountable + documentsSummable` //! doctype → primary-key count-sum tree direct read //! - range AVG on a `rangeAverageable` index → PCPS @@ -49,17 +51,12 @@ use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::drive_document_average_query::{ - AverageEntry, AverageMode, DocumentAverageRequest, DocumentAverageResponse, -}; -use crate::query::drive_document_count_query::{ - CountMode, DocumentCountRequest, DocumentCountResponse, + AverageMode, DocumentAverageRequest, DocumentAverageResponse, }; use crate::query::drive_document_sum_query::index_picker::{ find_range_summable_index_for_where_clauses, find_summable_index_for_where_clauses, }; -use crate::query::drive_document_sum_query::{ - is_range_operator, DocumentSumRequest, DocumentSumResponse, DriveDocumentSumQuery, SumMode, -}; +use crate::query::drive_document_sum_query::{is_range_operator, DriveDocumentSumQuery}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::version::PlatformVersion; @@ -67,12 +64,16 @@ use grovedb::TransactionArg; #[cfg(feature = "server")] impl Drive { - /// Server-side entry point for the average surface. Composes the - /// count + sum executors and zips their outputs into the - /// `(count, sum)` pair the client divides. + /// Server-side entry point for the average surface. /// - /// See the module docstring for the rationale on composition vs. - /// a single PCPS traversal. + /// Splits prove vs. no-prove at the top level: + /// - `prove = true` → routes to + /// [`Self::execute_document_average_prove`], unchanged from the + /// pre-#3687 implementation. + /// - `prove = false` → routes to + /// [`Self::execute_document_count_and_sum_request`], the joint + /// single-walk dispatcher that replaces the previous parallel + /// count + sum composition. pub fn execute_document_average_request( &self, request: DocumentAverageRequest, @@ -82,116 +83,7 @@ impl Drive { if request.prove { return self.execute_document_average_prove(request, transaction, platform_version); } - - // Map `AverageMode` → matching `CountMode` / `SumMode`. The - // three enums are structurally identical (same four variants); - // each pair just lives in its own namespace. - let (count_mode, sum_mode) = match request.mode { - AverageMode::Aggregate => (CountMode::Aggregate, SumMode::Aggregate), - AverageMode::GroupByIn => (CountMode::GroupByIn, SumMode::GroupByIn), - AverageMode::GroupByRange => (CountMode::GroupByRange, SumMode::GroupByRange), - AverageMode::GroupByCompound => (CountMode::GroupByCompound, SumMode::GroupByCompound), - }; - - // Build parallel sub-requests. Both consume the same - // `where_clauses` + `order_clauses` + `limit` + (false) `prove` - // — the average's shape contract is "two reads of the same - // grovedb snapshot, zipped after." - // - // Architectural follow-up: tracked at - // [dashpay/platform#3687](https://github.com/dashpay/platform/issues/3687). - // The two-sub-request shape will collapse into a single - // `DocumentCountSumRequest` + a unified - // `execute_document_count_and_sum_request` that walks - // grovedb once and reads both metrics from each visited PCPS - // element via `count_sum_value_or_default()`. The prove path - // at `execute_document_average_prove` below already does - // this (one PCPS walk yields both fields); the no-proof - // path currently double-walks. The current two-request - // shape is correct (the local transaction below guarantees - // atomicity); it just does more grovedb work than strictly - // necessary, and the dual-routing requires count's and sum's - // routing tables to stay in lock-step for AVG composition to - // work (already caught one routing divergence). Issue #3687 - // captures the full scope including the four joint per-mode - // no-proof executors that need to land. - let count_request = DocumentCountRequest { - contract: request.contract, - document_type: request.document_type, - where_clauses: request.where_clauses.clone(), - order_clauses: request.order_clauses.clone(), - mode: count_mode, - limit: request.limit, - prove: false, - drive_config: request.drive_config, - }; - let sum_request = DocumentSumRequest { - contract: request.contract, - document_type: request.document_type, - sum_property: request.sum_property, - where_clauses: request.where_clauses, - order_clauses: request.order_clauses, - mode: sum_mode, - limit: request.limit, - prove: false, - drive_config: request.drive_config, - }; - - // Atomicity: both sub-reads must see the same grovedb root. If - // the caller didn't provide a transaction we open a short-lived - // read transaction here and reuse it across both executors so - // a concurrent block commit can't slip between the count and - // sum reads (the attacker-steerable race documented in the - // module-level docstring). The local transaction is read-only - // and dropped without commit at the end of this function. - let local_tx; - let effective_transaction: TransactionArg = if transaction.is_some() { - transaction - } else { - local_tx = self.grove.start_transaction(); - Some(&local_tx) - }; - - let count_response = self.execute_document_count_request( - count_request, - effective_transaction, - platform_version, - )?; - let sum_response = self.execute_document_sum_request( - sum_request, - effective_transaction, - platform_version, - )?; - - // Combine. The two executors emit either Aggregate or Entries - // (Proof is unreachable here since `prove=false` above). The - // mode-pair is symmetric so they must agree on which shape - // they emit — mismatches indicate a routing bug, surface as - // CorruptedCodeExecution. - match (count_response, sum_response) { - (DocumentCountResponse::Aggregate(count), DocumentSumResponse::Aggregate(sum)) => { - Ok(DocumentAverageResponse::Aggregate { count, sum }) - } - ( - DocumentCountResponse::Entries(count_entries), - DocumentSumResponse::Entries(sum_entries), - ) => Ok(DocumentAverageResponse::Entries(zip_entries( - count_entries, - sum_entries, - )?)), - // Mismatched shapes — count executor and sum executor - // disagreed on whether the result fits in a single row. - // Should be impossible because they share the same mode - // and `validate_and_canonicalize_where_clauses` runs the - // same checks on both. - _ => Err(Error::Drive( - crate::error::drive::DriveError::CorruptedCodeExecution( - "average composition: count and sum executors emitted disagreeing \ - response shapes — both should agree on Aggregate vs Entries given \ - identical mode + where + group_by", - ), - )), - } + self.execute_document_count_and_sum_request(request, transaction, platform_version) } /// Prove path of [`Self::execute_document_average_request`]. @@ -246,7 +138,7 @@ impl Drive { // Empty-where AVG fast path: prove the primary-key // count-sum-bearing element directly when the doctype - // declares both `documentsCountable: true` (implied by + // declares both `documents_countable: true` (implied by // having a CountSumTree primary key) and a matching // `documents_summable`. The verifier extracts `(count, // sum)` from one element. @@ -503,188 +395,9 @@ impl Drive { } } -/// Merge per-`(in_key, key)` count entries and sum entries into average -/// entries via a strict two-pointer merge keyed on `(in_key, key)`. -/// -/// Both inputs are emitted by the same executor family with identical -/// `where_clauses` / `order_clauses` / `mode` against the same grovedb -/// snapshot, so they MUST emit the same set of keys in the same -/// ascending `(in_key, key)` order. Any divergence (key on one side -/// only, or different ordering) indicates an executor bug and is -/// surfaced as `CorruptedCodeExecution` rather than silently zeroed at -/// the wire layer — the previous defensive `None`-preservation pattern -/// was indistinguishable from "this key matched zero documents but the -/// sum is nonzero" once the wire mapping flattened `Option` → -/// `u64`, which let attacker-timed inserts between the two reads -/// produce a `count=0, sum=V` bucket that crashed naive `sum / count` -/// clients with a divide-by-zero. With atomicity now enforced inside -/// `execute_document_average_request` (see module docstring), the only -/// remaining cause of divergence is a real executor bug — treating it -/// as fatal is correct. -/// -/// Output is always strictly ascending by `(in_key, key)` (same order -/// the inputs are required to be in). -#[cfg(feature = "server")] -fn zip_entries( - count_entries: Vec, - sum_entries: Vec, -) -> Result, Error> { - use crate::error::drive::DriveError; - - let mut out = Vec::with_capacity(count_entries.len().max(sum_entries.len())); - let mut c_iter = count_entries.into_iter(); - let mut s_iter = sum_entries.into_iter(); - let mut next_c = c_iter.next(); - let mut next_s = s_iter.next(); - - loop { - match (&next_c, &next_s) { - (Some(c), Some(s)) => { - let c_key = (&c.in_key, &c.key); - let s_key = (&s.in_key, &s.key); - match c_key.cmp(&s_key) { - std::cmp::Ordering::Equal => { - let c = next_c.take().expect("checked Some above"); - let s = next_s.take().expect("checked Some above"); - out.push(AverageEntry { - in_key: c.in_key, - key: c.key, - count: c.count, - sum: s.sum, - }); - next_c = c_iter.next(); - next_s = s_iter.next(); - } - std::cmp::Ordering::Less => { - return Err(Error::Drive(DriveError::CorruptedCodeExecution( - "average composition: count executor emitted a (in_key, key) the \ - sum executor didn't — both executors run identical inputs against \ - the same grovedb snapshot, so divergence indicates an executor bug", - ))); - } - std::cmp::Ordering::Greater => { - return Err(Error::Drive(DriveError::CorruptedCodeExecution( - "average composition: sum executor emitted a (in_key, key) the \ - count executor didn't — both executors run identical inputs against \ - the same grovedb snapshot, so divergence indicates an executor bug", - ))); - } - } - } - (Some(_), None) => { - return Err(Error::Drive(DriveError::CorruptedCodeExecution( - "average composition: count executor produced more entries than sum executor \ - — both executors run identical inputs against the same grovedb snapshot, \ - so divergence indicates an executor bug", - ))); - } - (None, Some(_)) => { - return Err(Error::Drive(DriveError::CorruptedCodeExecution( - "average composition: sum executor produced more entries than count executor \ - — both executors run identical inputs against the same grovedb snapshot, \ - so divergence indicates an executor bug", - ))); - } - (None, None) => break, - } - } - Ok(out) -} - #[cfg(all(test, feature = "server"))] mod tests { use super::*; - use crate::error::drive::DriveError; - use crate::query::{SplitCountEntry, SumEntry}; - - fn cc(in_key: Option<&[u8]>, key: &[u8], count: u64) -> SplitCountEntry { - SplitCountEntry { - in_key: in_key.map(|b| b.to_vec()), - key: key.to_vec(), - count: Some(count), - } - } - fn ss(in_key: Option<&[u8]>, key: &[u8], sum: i64) -> SumEntry { - SumEntry { - in_key: in_key.map(|b| b.to_vec()), - key: key.to_vec(), - sum: Some(sum), - } - } - - #[test] - fn zip_entries_merges_aligned_streams_in_ascending_order() { - let count_entries = vec![cc(None, b"a", 1), cc(None, b"b", 2), cc(None, b"c", 3)]; - let sum_entries = vec![ss(None, b"a", 10), ss(None, b"b", 20), ss(None, b"c", 30)]; - let out = zip_entries(count_entries, sum_entries).expect("aligned streams must merge"); - assert_eq!(out.len(), 3); - assert_eq!(out[0].key, b"a"); - assert_eq!(out[0].count, Some(1)); - assert_eq!(out[0].sum, Some(10)); - assert_eq!(out[2].key, b"c"); - assert_eq!(out[2].count, Some(3)); - assert_eq!(out[2].sum, Some(30)); - } - - #[test] - fn zip_entries_errors_when_count_has_an_extra_key() { - // count has `b` but sum doesn't — strict merge must reject. - let count_entries = vec![cc(None, b"a", 1), cc(None, b"b", 2)]; - let sum_entries = vec![ss(None, b"a", 10)]; - let err = zip_entries(count_entries, sum_entries) - .expect_err("divergent streams must surface as CorruptedCodeExecution"); - assert!( - matches!(err, Error::Drive(DriveError::CorruptedCodeExecution(_))), - "expected CorruptedCodeExecution, got {err:?}", - ); - } - - #[test] - fn zip_entries_errors_when_sum_has_an_extra_key() { - let count_entries = vec![cc(None, b"a", 1)]; - let sum_entries = vec![ss(None, b"a", 10), ss(None, b"b", 20)]; - let err = zip_entries(count_entries, sum_entries) - .expect_err("divergent streams must surface as CorruptedCodeExecution"); - assert!( - matches!(err, Error::Drive(DriveError::CorruptedCodeExecution(_))), - "expected CorruptedCodeExecution, got {err:?}", - ); - } - - #[test] - fn zip_entries_errors_when_streams_disagree_on_a_key_in_the_middle() { - // count has `b`, sum has `c` between the matching `a` and `d`. - let count_entries = vec![cc(None, b"a", 1), cc(None, b"b", 2), cc(None, b"d", 4)]; - let sum_entries = vec![ss(None, b"a", 10), ss(None, b"c", 30), ss(None, b"d", 40)]; - let err = zip_entries(count_entries, sum_entries) - .expect_err("middle-of-stream divergence must surface as CorruptedCodeExecution"); - assert!(matches!( - err, - Error::Drive(DriveError::CorruptedCodeExecution(_)) - )); - } - - #[test] - fn zip_entries_handles_compound_in_key_ordering() { - // (Some("X"), "a") < (Some("X"), "b") < (Some("Y"), "a") in - // lexicographic order — verify the merge follows it. - let count_entries = vec![ - cc(Some(b"X"), b"a", 1), - cc(Some(b"X"), b"b", 2), - cc(Some(b"Y"), b"a", 3), - ]; - let sum_entries = vec![ - ss(Some(b"X"), b"a", 10), - ss(Some(b"X"), b"b", 20), - ss(Some(b"Y"), b"a", 30), - ]; - let out = zip_entries(count_entries, sum_entries).expect("aligned compound merge"); - assert_eq!(out.len(), 3); - assert_eq!(out[0].in_key.as_deref(), Some(b"X".as_ref())); - assert_eq!(out[0].key, b"a"); - assert_eq!(out[2].in_key.as_deref(), Some(b"Y".as_ref())); - assert_eq!(out[2].key, b"a"); - } // ── Dispatcher limit-policy regression tests ─────────────────── // @@ -1127,4 +840,750 @@ mod tests { let _: (GroveDBProof, _) = bincode::decode_from_slice(&proof_bytes, bincode_config) .expect("proof bytes must bincode-decode as a GroveDBProof"); } + + // ── Joint count-and-sum no-prove executor cross-checks ──────── + // + // Acceptance criterion 4 of issue #3687: "one [test] per joint + // executor confirming `(count, sum)` match what the current + // double-dispatch produces, against the same grades-contract + // fixture." + // + // Strategy: for each joint executor (Total / PerInValue / + // RangeNoProof — and RangeNoProof's distinct branch), issue the + // AVG no-prove request via `execute_document_average_request` + // AND independently issue separate count + sum requests under + // the same transaction. Assert the joint executor's + // `(count, sum)` matches the zipped pair from the independent + // calls. This pins parity with the pre-#3687 double-dispatch + // behaviour. + + use crate::query::drive_document_average_query::AverageEntry; + use crate::query::drive_document_count_query::{ + CountMode, DocumentCountRequest, DocumentCountResponse, + }; + use crate::query::drive_document_sum_query::{ + DocumentSumRequest, DocumentSumResponse, SumMode, + }; + + /// Issue an independent count + sum pair via the per-surface + /// dispatchers and return the zipped `(count, sum)` aggregate. + /// Used as the source of truth for cross-checking the joint + /// executor's output. + fn independent_count_sum_aggregate( + drive: &Drive, + contract: &dpp::data_contract::DataContract, + document_type: dpp::data_contract::document_type::DocumentTypeRef, + sum_property: &str, + where_clauses: Vec, + drive_config: &DriveConfig, + platform_version: &PlatformVersion, + ) -> (u64, i64) { + let count_request = DocumentCountRequest { + contract, + document_type, + where_clauses: where_clauses.clone(), + order_clauses: Vec::new(), + mode: CountMode::Aggregate, + limit: None, + prove: false, + drive_config, + }; + let sum_request = DocumentSumRequest { + contract, + document_type, + sum_property: sum_property.to_string(), + where_clauses, + order_clauses: Vec::new(), + mode: SumMode::Aggregate, + limit: None, + prove: false, + drive_config, + }; + let count_resp = drive + .execute_document_count_request(count_request, None, platform_version) + .expect("independent count"); + let sum_resp = drive + .execute_document_sum_request(sum_request, None, platform_version) + .expect("independent sum"); + let count = match count_resp { + DocumentCountResponse::Aggregate(c) => c, + other => panic!("expected count Aggregate, got {:?}", other), + }; + let sum = match sum_resp { + DocumentSumResponse::Aggregate(s) => s, + other => panic!("expected sum Aggregate, got {:?}", other), + }; + (count, sum) + } + + /// `execute_document_count_and_sum_total_no_proof` cross-check: + /// empty-where total on a doctype with `documents_summable + + /// documents_countable`. Goes through the primary-key fast path. + #[test] + fn joint_total_executor_matches_independent_count_plus_sum() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // The empty-where Total path requires the doctype's + // documents_summable + documents_countable to be set, but a + // covering `summable + countable` byColor index also works + // for the Equal-only-fully-covered sub-path. Use the latter + // since the test factory above doesn't easily produce + // doctype-level summable+countable. The Equal-only branch + // of execute_document_count_and_sum_total_no_proof still + // routes through `DocumentSumMode::Total` per sum's table. + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "color": {"type": "string", "position": 0, "maxLength": 32}, + "amount": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000}, + }, + "required": ["color", "amount"], + "indices": [{ + "name": "byColor", + "properties": [{"color": "asc"}], + "summable": "amount", + "countable": "countable", + }], + "additionalProperties": false, + }); + let schemas = platform_value!({ "widget": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("red", 5), + ("red", 7), + ("green", 3), + ("green", 4), + ("blue", 1), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + // Aggregate, no where → empty-where Total path. The doctype + // doesn't declare documents_summable here so the executor + // fall-through is the picker path on the byColor index. But + // the empty-where branch requires documents_summable; if the + // doctype lacks it, the picker is invoked with empty where, + // which `find_summable_index_for_where_clauses` rejects + // (zero indexable fields). So we test Equal-only-fully- + // covered instead — same `DocumentSumMode::Total` + // resolution. + let where_clauses = vec![WhereClause { + field: "color".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("red".to_string()), + }]; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: where_clauses.clone(), + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let joint_response = drive + .execute_document_average_request(request, None, platform_version) + .expect("joint total dispatch"); + let (joint_count, joint_sum) = match joint_response { + DocumentAverageResponse::Aggregate { count, sum } => (count, sum), + other => panic!("expected Aggregate, got {:?}", other), + }; + + let (indep_count, indep_sum) = independent_count_sum_aggregate( + &drive, + &data_contract, + document_type, + "amount", + where_clauses, + &drive_config, + platform_version, + ); + + assert_eq!( + (joint_count, joint_sum), + (indep_count, indep_sum), + "joint total executor must produce the same (count, sum) as \ + independent count + sum dispatch (red == 3 docs / sum 17)" + ); + // Sanity check against the fixture: red docs are 5+5+7 = 17 / count 3. + assert_eq!((joint_count, joint_sum), (3, 17)); + } + + /// `execute_document_count_and_sum_per_in_value_no_proof` + /// cross-check: In on a `summable + countable` index. + #[test] + fn joint_per_in_value_executor_matches_independent_count_plus_sum() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "color": {"type": "string", "position": 0, "maxLength": 32}, + "amount": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000}, + }, + "required": ["color", "amount"], + "indices": [{ + "name": "byColor", + "properties": [{"color": "asc"}], + "summable": "amount", + "countable": "countable", + }], + "additionalProperties": false, + }); + let schemas = platform_value!({ "widget": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("red", 7), + ("green", 3), + ("green", 4), + ("blue", 1), + ("blue", 2), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + let color_in = WhereClause { + field: "color".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Text("red".to_string()), + Value::Text("green".to_string()), + ]), + }; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in.clone()], + order_clauses: Vec::new(), + mode: AverageMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let joint_response = drive + .execute_document_average_request(request, None, platform_version) + .expect("joint per-in-value dispatch"); + let joint_entries = match joint_response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + + // Cross-check via independent count + sum per-In dispatch. + let count_request = DocumentCountRequest { + contract: &data_contract, + document_type, + where_clauses: vec![color_in.clone()], + order_clauses: Vec::new(), + mode: CountMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let sum_request = DocumentSumRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in], + order_clauses: Vec::new(), + mode: SumMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let count_resp = drive + .execute_document_count_request(count_request, None, platform_version) + .expect("independent count"); + let sum_resp = drive + .execute_document_sum_request(sum_request, None, platform_version) + .expect("independent sum"); + let count_entries = match count_resp { + DocumentCountResponse::Entries(e) => e, + other => panic!("expected count Entries, got {:?}", other), + }; + let sum_entries = match sum_resp { + DocumentSumResponse::Entries(e) => e, + other => panic!("expected sum Entries, got {:?}", other), + }; + + // Zip by key and assert joint matches. + assert_eq!(joint_entries.len(), count_entries.len()); + assert_eq!(joint_entries.len(), sum_entries.len()); + for ((joint, count), sum) in joint_entries + .iter() + .zip(count_entries.iter()) + .zip(sum_entries.iter()) + { + assert_eq!(joint.key, count.key); + assert_eq!(joint.key, sum.key); + assert_eq!(joint.count, count.count); + assert_eq!(joint.sum, sum.sum); + } + // Two entries — red and green. + assert_eq!(joint_entries.len(), 2); + // red: 2 docs, sum = 12. + // green: 2 docs, sum = 7. + // BTreeMap orders by serialized key bytes (lex on string + // bytes since color is Text). "green" < "red" lex. + let mut by_key: Vec<&AverageEntry> = joint_entries.iter().collect(); + by_key.sort_by(|a, b| a.key.cmp(&b.key)); + let red_entry = by_key + .iter() + .find(|e| e.key.windows(3).any(|w| w == b"red")) + .expect("red entry"); + let green_entry = by_key + .iter() + .find(|e| e.key.windows(5).any(|w| w == b"green")) + .expect("green entry"); + assert_eq!(red_entry.count, Some(2)); + assert_eq!(red_entry.sum, Some(12)); + assert_eq!(green_entry.count, Some(2)); + assert_eq!(green_entry.sum, Some(7)); + } + + /// `execute_document_count_and_sum_range_no_proof` cross-check: + /// distinct GroupByRange on a `rangeAverageable` (PCPS) index. + #[test] + fn joint_range_no_proof_executor_matches_independent_count_plus_sum() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("red", 7), + ("green", 3), + ("green", 4), + ("green", 6), + ("blue", 2), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + // `color > "blue"` on the byColor rangeAverageable index. + let color_gt_blue = WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("blue".to_string()), + }; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_gt_blue.clone()], + order_clauses: Vec::new(), + mode: AverageMode::GroupByRange, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let joint_response = drive + .execute_document_average_request(request, None, platform_version) + .expect("joint range distinct dispatch"); + let joint_entries = match joint_response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + + // Cross-check via independent count + sum distinct dispatch. + let count_request = DocumentCountRequest { + contract: &data_contract, + document_type, + where_clauses: vec![color_gt_blue.clone()], + order_clauses: Vec::new(), + mode: CountMode::GroupByRange, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let sum_request = DocumentSumRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_gt_blue], + order_clauses: Vec::new(), + mode: SumMode::GroupByRange, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let count_resp = drive + .execute_document_count_request(count_request, None, platform_version) + .expect("independent count"); + let sum_resp = drive + .execute_document_sum_request(sum_request, None, platform_version) + .expect("independent sum"); + let count_entries = match count_resp { + DocumentCountResponse::Entries(e) => e, + other => panic!("expected count Entries, got {:?}", other), + }; + let sum_entries = match sum_resp { + DocumentSumResponse::Entries(e) => e, + other => panic!("expected sum Entries, got {:?}", other), + }; + + // Both executors emit per-distinct-key entries in ascending + // serialized-key order; the lengths must match and per-key + // (count, sum) must zip to the same values. + assert_eq!(joint_entries.len(), count_entries.len()); + assert_eq!(joint_entries.len(), sum_entries.len()); + for ((joint, count), sum) in joint_entries + .iter() + .zip(count_entries.iter()) + .zip(sum_entries.iter()) + { + assert_eq!(joint.key, count.key); + assert_eq!(joint.key, sum.key); + assert_eq!(joint.count, count.count); + assert_eq!(joint.sum, sum.sum); + } + // Two distinct keys (green, red); blue is filtered out by + // the range. green: 3 docs, sum=13; red: 2 docs, sum=12. + assert_eq!(joint_entries.len(), 2); + } + + /// Flat-summed range cross-check: `Aggregate + range` on a PCPS + /// index resolves to `DocumentSumMode::RangeNoProof` with + /// `return_distinct_sums_in_range = false`. The joint executor + /// folds visited PCPS elements via `count_sum_value_or_default()` + /// in Rust (no engine-side combined accumulator exists). Pin parity + /// vs. the independent count + sum aggregate dispatch — this is the + /// path where the issue's perf win lands. + #[test] + fn joint_range_aggregate_executor_matches_independent_count_plus_sum() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("red", 7), + ("green", 3), + ("green", 4), + ("green", 6), + ("blue", 2), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + let color_gt_blue = WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Text("blue".to_string()), + }; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_gt_blue.clone()], + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let joint_response = drive + .execute_document_average_request(request, None, platform_version) + .expect("joint range aggregate dispatch"); + let (joint_count, joint_sum) = match joint_response { + DocumentAverageResponse::Aggregate { count, sum } => (count, sum), + other => panic!("expected Aggregate, got {:?}", other), + }; + + let (indep_count, indep_sum) = independent_count_sum_aggregate( + &drive, + &data_contract, + document_type, + "amount", + vec![color_gt_blue], + &drive_config, + platform_version, + ); + + assert_eq!( + (joint_count, joint_sum), + (indep_count, indep_sum), + "joint range-aggregate executor must produce the same (count, sum) \ + as independent count + sum range dispatch" + ); + // Sanity check: color > "blue" matches green (3,4,6 = sum 13) + // + red (5,7 = sum 12); total 5 docs / sum 25. + assert_eq!((joint_count, joint_sum), (5, 25)); + } + + /// Compound-summed range cross-check: `GroupByIn + In + range` on + /// a PCPS index resolves to `DocumentSumMode::RangeNoProof` with + /// `return_distinct_sums_in_range = false`. The joint executor's + /// distinct path query expresses the multi-In outer walk as a + /// single grovedb call (atomicity inherent) and folds each + /// In-branch's PCPS elements into one `(count, sum)` pair via + /// `count_sum_value_or_default()`. + /// + /// Pin parity vs. the independent count + sum dispatch. This is + /// the second untested-flat-summed branch the agent's three tests + /// don't cover. + #[test] + fn joint_range_group_by_in_executor_matches_independent_count_plus_sum() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // PCPS index keyed on (color, amount) so In on color + range + // on amount fits the rangeCountable + rangeSummable shape. + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "color": {"type": "string", "position": 0, "maxLength": 32}, + "amount": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000}, + }, + "required": ["color", "amount"], + "indices": [{ + "name": "byColorAmount", + "properties": [{"color": "asc"}, {"amount": "asc"}], + "summable": "amount", + "rangeSummable": true, + "countable": "countable", + "rangeCountable": true, + }], + "additionalProperties": false, + }); + let schemas = platform_value!({ "widget": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("red", 7), + ("red", 9), + ("green", 3), + ("green", 4), + ("blue", 8), + ("blue", 9), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + // In on color (red, green) + range on amount (≥ 4). + let color_in = WhereClause { + field: "color".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Text("red".to_string()), + Value::Text("green".to_string()), + ]), + }; + let amount_ge_4 = WhereClause { + field: "amount".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::U64(4), + }; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in.clone(), amount_ge_4.clone()], + order_clauses: Vec::new(), + mode: AverageMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let joint_response = drive + .execute_document_average_request(request, None, platform_version) + .expect("joint range GroupByIn dispatch"); + let joint_entries = match joint_response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + + // Independent count + sum GroupByIn dispatch. + let count_request = DocumentCountRequest { + contract: &data_contract, + document_type, + where_clauses: vec![color_in.clone(), amount_ge_4.clone()], + order_clauses: Vec::new(), + mode: CountMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let sum_request = DocumentSumRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in, amount_ge_4], + order_clauses: Vec::new(), + mode: SumMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + let count_resp = drive + .execute_document_count_request(count_request, None, platform_version) + .expect("independent count"); + let sum_resp = drive + .execute_document_sum_request(sum_request, None, platform_version) + .expect("independent sum"); + let count_entries = match count_resp { + DocumentCountResponse::Entries(e) => e, + other => panic!("expected count Entries, got {:?}", other), + }; + let sum_entries = match sum_resp { + DocumentSumResponse::Entries(e) => e, + other => panic!("expected sum Entries, got {:?}", other), + }; + + // The independent count and sum dispatches both produce entries + // for every In branch (with `count`/`sum` reflecting the In + // branch's value); the joint executor must produce the same + // shape. Build a key-keyed map for each and assert pairwise + // equality on the (count, sum) pair. + use std::collections::BTreeMap; + let count_by_key: BTreeMap, Option> = count_entries + .iter() + .map(|e| (e.key.clone(), e.count)) + .collect(); + let sum_by_key: BTreeMap, Option> = + sum_entries.iter().map(|e| (e.key.clone(), e.sum)).collect(); + let joint_by_key: BTreeMap, (Option, Option)> = joint_entries + .iter() + .map(|e| (e.key.clone(), (e.count, e.sum))) + .collect(); + + assert_eq!( + count_by_key.keys().collect::>(), + joint_by_key.keys().collect::>(), + "joint executor must emit the same In-branch keys as independent count" + ); + for (key, (joint_count, joint_sum)) in joint_by_key.iter() { + assert_eq!(joint_count, count_by_key.get(key).unwrap()); + assert_eq!(joint_sum, sum_by_key.get(key).unwrap()); + } + } } diff --git a/packages/rs-drive/src/query/drive_document_average_query/mod.rs b/packages/rs-drive/src/query/drive_document_average_query/mod.rs index 203e603b9a2..e7576ff2499 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/mod.rs @@ -15,15 +15,16 @@ //! [`book/src/drive/average-index-examples.md`](../../../../../book/src/drive/average-index-examples.md) //! for the design and the grades-contract worked example. //! -//! Wired end-to-end: the dispatcher composes the count + sum -//! executors on the no-proof path under a shared read-transaction -//! (see [`drive_dispatcher`] module docstring for the atomicity -//! contract), and the prove path dispatches directly to the PCPS / -//! primary-key proof executors. A planned follow-up tracked at -//! [dashpay/platform#3687](https://github.com/dashpay/platform/issues/3687) -//! will collapse the no-proof path's two-request composition into a -//! single unified executor that reads both metrics from each visited -//! PCPS element in one walk. +//! Wired end-to-end: the dispatcher routes prove-true requests to the +//! PCPS / primary-key proof executors, and prove-false requests to the +//! joint single-walk count-and-sum dispatcher at +//! [`crate::query::drive_document_count_and_sum_query`]. Both paths +//! read `(count, sum)` from each visited count-sum-bearing element in +//! a single grovedb walk — see the +//! [`drive_dispatcher`](drive_dispatcher) module docstring for the +//! routing details and +//! [`crate::query::drive_document_count_and_sum_query`] for the +//! no-prove perf / atomicity contract. #[cfg(feature = "server")] pub mod drive_dispatcher; diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs new file mode 100644 index 00000000000..ec1e199648c --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -0,0 +1,212 @@ +//! Joint count-and-sum dispatcher entry point for the AVG no-prove +//! path. +//! +//! Resolves [issue #3687](https://github.com/dashpay/platform/issues/3687): +//! consumes a [`DocumentAverageRequest`] with `prove = false` and +//! routes it through one of three joint per-mode executors +//! (`Total` / `PerInValue` / `RangeNoProof`), each of which walks +//! grovedb once and reads `(count, sum)` from every visited +//! count-sum-bearing element. The dispatcher delegates routing to +//! sum's versioned mode-detection table — see [the routing +//! table](crate::query::drive_document_sum_query::mode_detection) for +//! the contract. +//! +//! # Perf characteristic +//! +//! One grovedb walk per AVG no-prove query (compared to the pre-#3687 +//! composition of two parallel walks zipped at the dispatcher). The +//! `PerInValue` and compound-aggregate range branches still issue +//! multiple per-In sub-reads, but each of those is a single grovedb +//! call yielding `(count, sum)` together rather than the two parallel +//! calls the composition did before. +//! +//! # Atomicity +//! +//! - `Total` and the distinct `RangeNoProof` branch issue exactly one +//! grovedb read each; atomicity is inherent (one read sees one +//! snapshot). +//! - The `PerInValue` branch and (under the unified walk) the +//! compound-aggregate `RangeNoProof` branch issue multiple per-In +//! reads. Their executors open a short-lived shared read transaction +//! internally when `transaction.is_none()` so the per-In reads see a +//! consistent snapshot. The previous AVG dispatcher's shared-tx +//! plumbing moved into those executors. + +use super::super::drive_document_average_query::{ + AverageMode, DocumentAverageRequest, DocumentAverageResponse, +}; +use super::super::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; +use super::super::drive_document_sum_query::{DocumentSumMode, RangeSumOptions, SumMode}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +#[cfg(feature = "server")] +impl Drive { + /// Joint count-and-sum no-prove dispatcher. + /// + /// Consumes a [`DocumentAverageRequest`] with `prove = false` and + /// produces a [`DocumentAverageResponse`] in the appropriate shape + /// (`Aggregate { count, sum }` for non-grouped modes, + /// `Entries(Vec)` for grouped modes). + /// + /// The dispatcher returns `Proof(_)`-shape responses only when + /// invoked via [`Drive::execute_document_average_request`]'s + /// `prove = true` arm; the no-prove path here never produces proof + /// bytes. + /// + /// # Routing + /// + /// Reuses sum's versioned routing table via + /// [`detect_sum_mode_from_inputs`] with the request's + /// [`AverageMode`] converted 1:1 to [`SumMode`] (the two enums are + /// structurally identical — same four variants). `prove = false` + /// is passed unconditionally because the prove path never reaches + /// this function. The resolved [`DocumentSumMode`] is one of three + /// no-prove values: `Total`, `PerInValue`, or `RangeNoProof`; the + /// four prove-mode values are unreachable. The match arm therefore + /// returns `CorruptedCodeExecution` on those branches rather than + /// silently misbehaving — if the routing table is ever extended + /// with a new prove-only mode that accidentally fires here, we + /// want to fail loudly. + pub fn execute_document_count_and_sum_request( + &self, + request: DocumentAverageRequest, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + // Convert AverageMode → SumMode (1:1 by construction); sum's + // routing table is the single source of truth for the + // `(where_clauses × mode × prove=false)` triple. Forking a + // parallel "count_and_sum mode" table here is what #3687 + // explicitly warned against — PR #3661 caught one drift bug + // (count's `GroupByCompound` row had diverged from sum's), + // and the joint executor's existence is itself the reason a + // single source of truth is now mandatory. + let sum_mode = match request.mode { + AverageMode::Aggregate => SumMode::Aggregate, + AverageMode::GroupByIn => SumMode::GroupByIn, + AverageMode::GroupByRange => SumMode::GroupByRange, + AverageMode::GroupByCompound => SumMode::GroupByCompound, + }; + + let resolved_mode = + detect_sum_mode_from_inputs(&request.where_clauses, sum_mode, false, platform_version)?; + + let contract_id = request.contract.id().to_buffer(); + let document_type_name = request.document_type.name().to_string(); + let where_clauses = request.where_clauses; + let sum_property = request.sum_property; + let order_by_ascending = request + .order_clauses + .first() + .map(|c| c.ascending) + .unwrap_or(true); + + match resolved_mode { + DocumentSumMode::Total => self.execute_document_count_and_sum_total_no_proof( + contract_id, + request.document_type, + document_type_name, + where_clauses, + sum_property, + transaction, + platform_version, + ), + DocumentSumMode::PerInValue => { + let options = RangeSumOptions { + return_distinct_sums_in_range: false, + carrier_outer_limit: None, + left_to_right: order_by_ascending, + }; + self.execute_document_count_and_sum_per_in_value_no_proof( + contract_id, + request.document_type, + document_type_name, + where_clauses, + sum_property, + options, + transaction, + platform_version, + ) + } + DocumentSumMode::RangeNoProof => { + // Distinct flag set for the GroupByRange / + // GroupByCompound shapes (per-distinct-value entries); + // cleared for the Aggregate / GroupByIn shapes (single + // folded pair). Mirrors sum's dispatcher. + let return_distinct = matches!( + request.mode, + AverageMode::GroupByRange | AverageMode::GroupByCompound + ); + let options = RangeSumOptions { + return_distinct_sums_in_range: return_distinct, + carrier_outer_limit: None, + left_to_right: order_by_ascending, + }; + let response = self.execute_document_count_and_sum_range_no_proof( + contract_id, + request.document_type, + document_type_name, + where_clauses, + sum_property, + options, + transaction, + platform_version, + )?; + // Sum's dispatcher applies a second-stage shape on the + // RangeNoProof path: `Aggregate` mode collapses to a + // single value, every other mode (GroupByIn / + // GroupByRange / GroupByCompound) returns `Entries`. + // GroupByIn + range specifically returns + // `Entries(vec![one_entry])` with the In axis folded + // (sum's executor itself emits exactly one entry in + // that case — see its `execute_range_sum_no_proof` + // compound-summed branch). The joint executor returns + // `Aggregate { count, sum }` for the !distinct shape + // because it has both metrics on hand; we re-shape into + // `Entries(vec![one_entry])` here when the caller asked + // for a non-Aggregate mode so the wire shape matches + // what an independent sum + count GroupByIn dispatch + // would produce. Without this re-shape, GroupByIn + + // range AVG silently changes wire shape from `Entries` + // (pre-#3687) to `Aggregate`. + match (request.mode, response) { + (AverageMode::Aggregate, resp) => Ok(resp), + (AverageMode::GroupByIn, DocumentAverageResponse::Aggregate { count, sum }) => { + Ok(DocumentAverageResponse::Entries(vec![ + super::super::drive_document_average_query::AverageEntry { + in_key: None, + key: Vec::new(), + count: Some(count), + sum: Some(sum), + }, + ])) + } + (_, resp) => Ok(resp), + } + } + // The four prove-mode resolutions are unreachable here — + // the dispatcher passes `prove = false` to the routing + // table, and sum's v0 table has no row that maps a + // `prove=false` input to any of these four resolutions. + // Surface as `CorruptedCodeExecution` so a future routing- + // table change that breaks this assumption fails loudly + // rather than producing a runtime panic. + DocumentSumMode::RangeProof + | DocumentSumMode::RangeDistinctProof + | DocumentSumMode::PointLookupProof + | DocumentSumMode::RangeAggregateCarrierProof => Err(Error::Drive( + crate::error::drive::DriveError::CorruptedCodeExecution( + "execute_document_count_and_sum_request: sum's routing table \ + resolved a prove-only mode from a `prove = false` request — \ + this indicates the routing table has been extended without \ + updating the joint dispatcher's match arms", + ), + )), + } + } +} diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/mod.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/mod.rs new file mode 100644 index 00000000000..8c5a4d11c26 --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/mod.rs @@ -0,0 +1,21 @@ +//! Per-`DocumentSumMode` joint count-and-sum no-prove executors. +//! +//! Mirrors [`crate::query::drive_document_sum_query::executors`] +//! one-to-one for the no-prove subset of modes — the prove modes +//! (`RangeProof`, `RangeDistinctProof`, `PointLookupProof`, +//! `RangeAggregateCarrierProof`) are unreachable here because the AVG +//! dispatcher routes prove-true requests through +//! [`Drive::execute_document_average_prove`] before this module is +//! consulted. The three no-prove modes are: +//! +//! - [`total`] — `DocumentSumMode::Total` (empty-where + +//! `documents_summable + documents_countable` fast path AND +//! Equal/In-fully-covered point lookup). +//! - [`per_in_value`] — `DocumentSumMode::PerInValue` (Equal/In on a +//! non-range covered index, emitting one entry per In branch). +//! - [`range_no_proof`] — `DocumentSumMode::RangeNoProof` (range and +//! distinct shapes over a PCPS-eligible index). + +pub mod per_in_value; +pub mod range_no_proof; +pub mod total; diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs new file mode 100644 index 00000000000..0ee4eddd300 --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -0,0 +1,202 @@ +//! Joint count-and-sum `PerInValue` executor for +//! [`DocumentSumMode::PerInValue`] dispatch on the AVG no-prove path. +//! +//! Mirrors [`crate::query::drive_document_sum_query::executors::per_in_value`] +//! with the same two substitutions documented in the sibling +//! [`super::total`] module — element decoding via +//! `count_sum_value_or_default()` and picker-filtering on +//! `idx.countable.is_countable()`. +//! +//! ## Atomicity +//! +//! The per-In fan-out issues one grovedb read per In branch. When the +//! caller didn't provide a transaction, we open a short-lived shared +//! read transaction internally and reuse it across every per-branch +//! read so the joint executor sees a single grovedb snapshot. This is +//! the same atomicity contract the pre-#3687 AVG dispatcher +//! implemented at the dispatcher layer, moved here because the +//! dispatcher itself now does only a single executor call per AVG +//! request — the multi-read concern is purely an internal property of +//! this executor (and its `range_no_proof` sibling, which has the same +//! per-In fan-out shape for the compound-flat-summed branch). + +use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; +use super::super::super::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; +use super::super::super::drive_document_sum_query::{DriveDocumentSumQuery, RangeSumOptions}; +use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::{WhereClause, WhereOperator}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::{QueryResultElement, QueryResultType}; +use grovedb::TransactionArg; + +impl Drive { + /// Cartesian-forks the single `In` clause into one Equal-per-value + /// sub-query against a `summable + countable` index; reads + /// `(count, sum)` from each per-branch point-lookup walk in one + /// grovedb read each, then emits per-In-value + /// [`AverageEntry { in_key: None, key, count, sum }`] entries. + /// + /// Returns [`DocumentAverageResponse::Entries`]. + #[allow(clippy::too_many_arguments)] + pub fn execute_document_count_and_sum_per_in_value_no_proof( + &self, + contract_id: [u8; 32], + document_type: DocumentTypeRef, + document_type_name: String, + where_clauses: Vec, + sum_property: String, + options: RangeSumOptions, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + // Enforce exactly one `In` clause. The sum-side analog + // documents the silent-drop bug this guards against; same + // failure mode would apply here. + let in_clauses: Vec<&WhereClause> = where_clauses + .iter() + .filter(|wc| wc.operator == WhereOperator::In) + .collect(); + if in_clauses.len() != 1 { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "execute_document_count_and_sum_per_in_value_no_proof requires \ + exactly one `in` clause", + ), + )); + } + let in_clause = in_clauses[0].clone(); + let in_values = in_clause.in_values().into_data_with_error()??; + + let other_clauses: Vec = where_clauses + .iter() + .filter(|wc| wc.operator != WhereOperator::In) + .cloned() + .collect(); + + // Open a short-lived shared read transaction if the caller + // didn't provide one. Multiple per-In branches read grovedb + // separately; without a shared snapshot a concurrent block + // commit could slip between branches and produce a + // `(count, sum)` pair from inconsistent state. Read-only; the + // local transaction is dropped without commit at the end. + let local_tx; + let effective_transaction: TransactionArg = if transaction.is_some() { + transaction + } else { + local_tx = self.grove.start_transaction(); + Some(&local_tx) + }; + + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; + // BTreeMap deduplicates by canonical key bytes and orders + // ascending — matches sum-side analog. Storing the full + // (count, sum) pair per key rather than separate maps means + // we never need to zip post-walk; each grovedb read writes + // exactly one entry into the map. + let mut merged: std::collections::BTreeMap, (u64, i64)> = + std::collections::BTreeMap::new(); + for value in in_values.iter() { + let key_bytes = document_type.serialize_value_for_key( + in_clause.field.as_str(), + value, + platform_version, + )?; + if merged.contains_key(&key_bytes) { + continue; + } + + let mut clauses_for_value = other_clauses.clone(); + clauses_for_value.push(WhereClause { + field: in_clause.field.clone(), + operator: WhereOperator::Equal, + value: value.clone(), + }); + + let index = find_summable_index_for_where_clauses( + document_type.indexes(), + &clauses_for_value, + &sum_property, + ) + .filter(|idx| idx.countable.is_countable()) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( + "average query requires an index that declares BOTH \ + `summable: \"\"` AND a countable terminator \ + (`countable: \"countable\"` or `\"countableAllowingOffset\"`) \ + matching the where clause fields" + .to_string(), + )) + })?; + + let sum_query = DriveDocumentSumQuery { + document_type, + contract_id, + document_type_name: document_type_name.clone(), + index, + where_clauses: clauses_for_value, + sum_property: sum_property.clone(), + }; + + let drive_version = &platform_version.drive; + let path_query = sum_query.point_lookup_sum_path_query(platform_version)?; + let mut drive_operations = vec![]; + let (results, _) = self.grove_get_path_query( + &path_query, + effective_transaction, + QueryResultType::QueryElementResultType, + &mut drive_operations, + drive_version, + )?; + + // For the per-In-value branch the inner walk visits at + // most one count-sum-bearing element (the Equal arm fully + // covered the index). Sum across the emitted elements to + // handle the boundary case where the picker accepts an + // index whose terminator is `In`-shaped in the same prop + // (rare for AVG since the dispatched In was lifted out, + // but cheap to do correctly). + let mut count_acc: u64 = 0; + let mut sum_acc: i64 = 0; + for elem in results.elements.iter() { + if let QueryResultElement::ElementResultItem(element) = elem { + let (c, s) = element.count_sum_value_or_default(); + count_acc = count_acc.checked_add(c).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "per-In-value count-and-sum overflowed u64 on the count axis \ + when summing branch elements. Narrow the query or use \ + multiple queries and combine client-side." + .to_string(), + )) + })?; + sum_acc = sum_acc.checked_add(s).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "per-In-value count-and-sum overflowed i64 on the sum axis \ + when summing branch elements. Narrow the query or use \ + multiple queries and combine client-side." + .to_string(), + )) + })?; + } + } + merged.insert(key_bytes, (count_acc, sum_acc)); + } + + let mut entries: Vec = merged + .into_iter() + .map(|(key, (count, sum))| AverageEntry { + in_key: None, + key, + count: Some(count), + sum: Some(sum), + }) + .collect(); + if !options.left_to_right { + entries.reverse(); + } + Ok(DocumentAverageResponse::Entries(entries)) + } +} diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs new file mode 100644 index 00000000000..d9fdd03aa13 --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -0,0 +1,219 @@ +//! Joint count-and-sum `RangeNoProof` executor for +//! [`DocumentSumMode::RangeNoProof`] dispatch on the AVG no-prove path. +//! +//! Mirrors [`crate::query::drive_document_sum_query::executors::range_no_proof`] +//! + [`crate::query::drive_document_sum_query::execute_range_sum::execute_range_sum_no_proof`] +//! with one important structural difference vs. sum: +//! +//! ## The "flat summed" branch can't use the engine-side accumulator +//! +//! Sum's flat-summed branch collapses to a single +//! `grove.query_aggregate_sum` call against the merk-internal +//! `AggregateSumOnRange` primitive (O(log n) — the accumulator returns +//! a single `i64`). The pinned grovedb rev exposes that primitive AND +//! `query_aggregate_count`, but **does not** expose a combined no-prove +//! `query_aggregate_count_and_sum`. The proof-side analog +//! `AggregateCountAndSumOnRange` exists (and is what the AVG prove +//! path uses — see [`Drive::execute_document_average_prove`]'s range +//! arm) but the no-prove engine-side variant is a future grovedb +//! optimization tracked under #3687. +//! +//! As a result, the joint flat-summed branch walks PCPS terminator +//! elements via `grove_get_raw_path_query` against the same +//! `distinct_sum_path_query` builder the distinct branch uses, and +//! folds `(count, sum)` in Rust. This is still **one** grovedb walk +//! per AVG query — exactly the win #3687 captures — versus the +//! pre-#3687 path of issuing parallel `query_aggregate_sum` + +//! `query_aggregate_count` calls and zipping post-hoc. The +//! optimization opportunity (collapse the Rust-side fold into a single +//! engine-side accumulator) lives in grovedb, not here. +//! +//! ## Distinct branch +//! +//! For the distinct shapes (`GroupByRange` / `GroupByCompound` + range) +//! the walk is structurally identical to sum's: same +//! `distinct_sum_path_query` builder, same +//! `QueryPathKeyElementTrioResultType` shape, same `in_key` / +//! `base_path_len` heuristic. The only difference is that each emitted +//! element is decoded via `count_sum_value_or_default()` and produces +//! an [`AverageEntry { count: Some, sum: Some }`] rather than a +//! `SumEntry { sum: Some }`. + +use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; +use super::super::super::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; +use super::super::super::drive_document_sum_query::{DriveDocumentSumQuery, RangeSumOptions}; +use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::{WhereClause, WhereOperator}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::TransactionArg; + +impl Drive { + /// Range-aware joint count-and-sum walk against a + /// `rangeSummable + rangeCountable` (PCPS-eligible) index. + /// + /// Returns either a one-pair [`DocumentAverageResponse::Aggregate`] + /// (flat / compound aggregate shapes) or per-distinct-value + /// [`DocumentAverageResponse::Entries`] (`GroupByRange` / + /// `GroupByCompound`) depending on + /// `options.return_distinct_sums_in_range`. The dispatcher sets that + /// flag based on the request's [`AverageMode`]. + /// + /// Perf: one grovedb walk per query in the flat / distinct branches + /// — halving the work vs. the pre-#3687 composition. + #[allow(clippy::too_many_arguments)] + pub fn execute_document_count_and_sum_range_no_proof( + &self, + contract_id: [u8; 32], + document_type: DocumentTypeRef, + document_type_name: String, + where_clauses: Vec, + sum_property: String, + options: RangeSumOptions, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let index = find_range_summable_index_for_where_clauses( + document_type.indexes(), + &where_clauses, + &sum_property, + ) + .filter(|idx| idx.range_countable) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( + "average range query requires an index that declares BOTH \ + `rangeSummable: true` AND `rangeCountable: true` (a \ + `rangeAverageable: true` index is the shorthand) whose last \ + property matches the range field" + .to_string(), + )) + })?; + + let sum_query = DriveDocumentSumQuery { + document_type, + contract_id, + document_type_name, + index, + where_clauses: where_clauses.clone(), + sum_property, + }; + + let drive_version = &platform_version.drive; + let has_in_on_prefix = where_clauses + .iter() + .any(|wc| wc.operator == WhereOperator::In); + + // One unified walk for all three sub-shapes: the + // `distinct_sum_path_query` builder is the right shape + // regardless of whether we're folding into a single aggregate + // pair (flat / compound-aggregate) or emitting per-distinct + // entries — both cases visit the same PCPS terminator + // elements; only the output shaping differs. + // + // For the compound-aggregate shape (In + range, non-distinct), + // we still need atomicity across the multiple In-branch + // sub-walks the distinct query naturally fans into — the + // distinct path query's outer-keys walk under one grovedb call + // already provides this since it's a single + // `grove_get_raw_path_query` invocation against a multi-Key + // outer query. No separate per-In fan-out required. + let path_query = sum_query.distinct_sum_path_query( + None::, + options.left_to_right, + platform_version, + )?; + let base_path_len = path_query.path.len(); + + let mut drive_operations = vec![]; + let result = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + &mut drive_operations, + drive_version, + ); + let elements = match result { + Ok((elements, _)) => elements, + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathNotFound(_) + | grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + ) => + { + return Ok(if options.return_distinct_sums_in_range { + DocumentAverageResponse::Entries(Vec::new()) + } else { + DocumentAverageResponse::Aggregate { count: 0, sum: 0 } + }); + } + Err(e) => return Err(e), + }; + + if !options.return_distinct_sums_in_range { + // Flat / compound-aggregate: fold every visited PCPS + // element's `(count, sum)` into a single pair. `checked_add` + // on both axes mirrors the per-In-value and total executors. + let mut count_acc: u64 = 0; + let mut sum_acc: i64 = 0; + for triple in elements.to_path_key_elements() { + let (_path, _key, element) = triple; + let (c, s) = element.count_sum_value_or_default(); + count_acc = count_acc.checked_add(c).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "range count-and-sum overflowed u64 on the count axis while \ + folding visited PCPS elements. Narrow the range or use \ + multiple queries." + .to_string(), + )) + })?; + sum_acc = sum_acc.checked_add(s).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "range count-and-sum overflowed i64 on the sum axis while \ + folding visited PCPS elements. Narrow the range or use \ + multiple queries." + .to_string(), + )) + })?; + } + return Ok(DocumentAverageResponse::Aggregate { + count: count_acc, + sum: sum_acc, + }); + } + + // Distinct (GroupByRange / GroupByCompound): emit one entry + // per visited element. `(in_key, key)` shaping matches sum's + // distinct branch. + let mut entries: Vec = Vec::new(); + for triple in elements.to_path_key_elements() { + let (path, key, element) = triple; + let (count, sum) = element.count_sum_value_or_default(); + // Drop empty groups so the output matches sum's distinct + // contract (which drops `sum == 0` rows). For AVG an empty + // group has no averageable signal AND no count signal, + // making the row uninformative. + if count == 0 && sum == 0 { + continue; + } + let in_key = if has_in_on_prefix && path.len() > base_path_len { + Some(path[base_path_len].clone()) + } else { + None + }; + entries.push(AverageEntry { + in_key, + key, + count: Some(count), + sum: Some(sum), + }); + } + + Ok(DocumentAverageResponse::Entries(entries)) + } +} diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs new file mode 100644 index 00000000000..1ea08baea67 --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/total.rs @@ -0,0 +1,218 @@ +//! Joint count-and-sum Total executor for [`DocumentSumMode::Total`] +//! dispatch on the AVG no-prove path. +//! +//! Mirrors [`crate::query::drive_document_sum_query::executors::total`] +//! one-to-one with two substitutions: +//! +//! 1. **Element decoding**: the sum-side executor reads +//! [`Element::sum_value_or_default()`]; this one reads +//! [`Element::count_sum_value_or_default()`] so a single visited +//! element yields both metrics. The terminator's value tree is a +//! `CountSumTree` / `ProvableCountSumTree` / +//! `ProvableCountProvableSumTree` (the count-sum-bearing family), +//! so the joint decoder is well-defined on every shape the picker +//! accepts. +//! 2. **Index selection**: the picker is +//! [`find_summable_index_for_where_clauses`] (sum's), but with an +//! additional `.filter(|idx| idx.countable.is_countable())` so the +//! chosen index also carries the `countable` declaration the count +//! side needs. The AVG prove path's point-lookup arm does the same +//! filter (see `drive_document_average_query::drive_dispatcher:: +//! execute_document_average_prove`'s no-range arm). +//! +//! Routing semantics match sum's: this executor handles both the +//! empty-where case (doctype's `documents_summable + documents_countable` +//! primary-key fast path) AND the Equal-only fully-covered Equal/In +//! point-lookup case. The branch on `where_clauses.is_empty()` inside +//! the executor body is the same one sum's `total.rs` carries. + +use super::super::super::drive_document_average_query::DocumentAverageResponse; +use super::super::super::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; +use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; +use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::WhereClause; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::{QueryResultElement, QueryResultType}; +use grovedb::TransactionArg; + +impl Drive { + /// Joint count-and-sum total for the empty-where / + /// Equal-only-fully-covered point-lookup shape — the AVG no-prove + /// analog of [`Drive::execute_document_sum_total_no_proof`]. + /// + /// Returns [`DocumentAverageResponse::Aggregate { count, sum }`] + /// directly (collapsed to a single pair across all matched + /// documents). The PerInValue branch is the sibling executor that + /// emits one entry per In branch. + #[allow(clippy::too_many_arguments)] + pub fn execute_document_count_and_sum_total_no_proof( + &self, + contract_id: [u8; 32], + document_type: DocumentTypeRef, + document_type_name: String, + where_clauses: Vec, + sum_property: String, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + use dpp::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; + + // Empty-where fast path: unfiltered (count, sum) on a doctype + // that declares BOTH `documents_summable: Some(matching_prop)` + // AND `documents_countable: true` reads the primary-key + // count-sum-bearing tree directly (O(1)). Mirrors the sum-side + // analog with the additional `documents_countable` predicate; + // the AVG prove path's empty-where fast path enforces the same + // pair. + if where_clauses.is_empty() + && document_type.documents_countable() + && document_type + .documents_summable() + .map(|p| p == sum_property) + .unwrap_or(false) + { + let (count, sum) = self.read_primary_key_count_sum_tree( + &contract_id, + &document_type_name, + transaction, + platform_version, + )?; + return Ok(DocumentAverageResponse::Aggregate { count, sum }); + } + + let index = find_summable_index_for_where_clauses( + document_type.indexes(), + &where_clauses, + &sum_property, + ) + .filter(|idx| idx.countable.is_countable()) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::WhereClauseOnNonIndexedProperty( + "average query requires an index that declares BOTH \ + `summable: \"\"` AND a countable terminator \ + (`countable: \"countable\"` or `\"countableAllowingOffset\"`) \ + whose properties exactly match the where clause fields, \ + OR `documentsSummable: \"\"` AND `documentsCountable: true` \ + on the document type for the unfiltered total case" + .to_string(), + )) + })?; + let sum_query = DriveDocumentSumQuery { + document_type, + contract_id, + document_type_name, + index, + where_clauses, + sum_property, + }; + + // Reuse sum's `point_lookup_sum_path_query` builder — the path + // query shape is identical for count-and-sum point lookups; the + // only difference is that we decode each emitted element via + // `count_sum_value_or_default()` rather than + // `sum_value_or_default()`. The terminator's value tree on a + // `summable + countable` index is a `CountSumTree` / + // `ProvableCountSumTree`, so both fields are present on every + // emitted element. + let drive_version = &platform_version.drive; + let path_query = sum_query.point_lookup_sum_path_query(platform_version)?; + let mut drive_operations = vec![]; + let (results, _) = self.grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryElementResultType, + &mut drive_operations, + drive_version, + )?; + + // Fold across emitted count-sum-bearing elements: + // - Equal-only: 0 or 1 element (0 when the branch is absent). + // - In at any position: one element per In branch that has at + // least one doc; missing branches contribute (0, 0). + // + // `checked_add` rather than `saturating_add` / wrapping add so + // an overflowed aggregate fails deterministically with a typed + // query error rather than silently clamping. The sum-side + // executor uses the same pattern for its i64 fold; we + // additionally guard u64 overflow on the count axis. + let mut count_acc: u64 = 0; + let mut sum_acc: i64 = 0; + for elem in results.elements.iter() { + if let QueryResultElement::ElementResultItem(element) = elem { + let (c, s) = element.count_sum_value_or_default(); + count_acc = count_acc.checked_add(c).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "point-lookup count-and-sum overflowed u64 on the count axis \ + when summing per-In branches. Narrow the query (smaller In set) \ + or use multiple queries and combine client-side." + .to_string(), + )) + })?; + sum_acc = sum_acc.checked_add(s).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "point-lookup count-and-sum overflowed i64 on the sum axis \ + when summing per-In branches. Narrow the query (smaller In set) \ + or use multiple queries and combine client-side." + .to_string(), + )) + })?; + } + } + Ok(DocumentAverageResponse::Aggregate { + count: count_acc, + sum: sum_acc, + }) + } + + /// Reads the document-type primary-key tree's count-sum-bearing + /// element (`[contract_doc, contract_id, [1], doctype, 0]`) and + /// returns `count_sum_value_or_default()`. Used by the + /// `documents_summable + documents_countable` empty-where fast path + /// on the joint count-and-sum flow. + /// + /// `insert_contract_operations_v0` unconditionally creates a + /// count-sum-bearing tree at `[..., doctype, 0]` for every applied + /// document type whose `documents_summable` is set AND whose + /// `documents_countable` is true, so a missing element here + /// indicates contract-state corruption or a mis-applied contract — + /// fail fast rather than silently returning `(0, 0)`. Mirrors + /// `read_primary_key_sum_tree`'s contract. + pub(super) fn read_primary_key_count_sum_tree( + &self, + contract_id: &[u8; 32], + document_type_name: &str, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(u64, i64), Error> { + let drive_version = &platform_version.drive; + let path = [ + &[crate::drive::RootTree::DataContractDocuments as u8] as &[u8], + contract_id, + &[1u8], + document_type_name.as_bytes(), + ]; + let mut drive_operations = vec![]; + let element = self + .grove_get_raw_optional( + grovedb_path::SubtreePath::from(path.as_slice()), + &[0], + crate::util::grove_operations::DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + drive_version, + )? + .ok_or_else(|| { + Error::Drive(crate::error::drive::DriveError::CorruptedCodeExecution( + "missing primary-key count-sum tree for an applied document type — \ + insert_contract_operations_v0 must have created it when both \ + documents_summable and documents_countable are set", + )) + })?; + Ok(element.count_sum_value_or_default()) + } +} diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs new file mode 100644 index 00000000000..9887d844074 --- /dev/null +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs @@ -0,0 +1,54 @@ +//! Joint count-and-sum executor surface for the AVG no-prove path. +//! +//! Resolves [issue #3687](https://github.com/dashpay/platform/issues/3687): +//! the AVG dispatcher previously composed `DocumentCountRequest + +//! DocumentSumRequest` on the no-prove path and zipped the two +//! responses, which double-walked grovedb for every AVG query. +//! +//! This module exposes a single [`Drive::execute_document_count_and_sum_request`] +//! entry that consumes the same [`DocumentAverageRequest`] / +//! [`DocumentAverageResponse`] pair used by the prove path and reads +//! both metrics from each visited PCPS / CountSumTree element in one +//! grovedb walk via [`Element::count_sum_value_or_default`]. +//! +//! ## Routing +//! +//! Mode resolution reuses sum's versioned routing table +//! ([`crate::query::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs`]) +//! — the routing decision is consensus-relevant and identical for +//! count, sum, and joint count+sum on the no-prove path, so forking +//! the table here would invite the same drift bug PR #3661 caught +//! (count's `GroupByCompound` row had diverged from sum's). A trivial +//! [`AverageMode`] → [`SumMode`] adapter feeds sum's mode detector; +//! `prove = false` is passed unconditionally because the prove path +//! never enters this module. +//! +//! ## Perf characteristic +//! +//! On the no-prove path this halves the grovedb work per AVG query +//! versus the pre-#3687 composition: one walk yielding `(count, sum)` +//! per visited element instead of two parallel walks zipped at the +//! dispatcher layer. +//! +//! ## A note on the engine-side combined primitive +//! +//! grovedb's pinned rev exposes `query_aggregate_sum` and +//! `query_aggregate_count` as no-prove engine-side accumulators +//! (their proof-side analogs are `AggregateSumOnRange` / +//! `AggregateCountOnRange` / the combined `AggregateCountAndSumOnRange` +//! used by the prove path). There is no engine-side no-prove +//! `query_aggregate_count_and_sum` primitive yet. As a result the +//! range-no-proof "flat summed" branches here walk PCPS elements via +//! `grove_get_raw_path_query` and fold in Rust — see +//! [`executors::range_no_proof`] for the implementation note. This is +//! still one grovedb walk per query (versus sum + count parallel walks +//! before), just not the further reduction of "one merk-internal +//! accumulator call" the prove path benefits from. A future optional +//! grovedb optimization could collapse the Rust-side fold into an +//! engine-side accumulator; #3687 captures the follow-up. + +#[cfg(feature = "server")] +pub mod drive_dispatcher; + +#[cfg(feature = "server")] +pub mod executors; diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index e250dd6be62..21895f8e16a 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -222,6 +222,13 @@ pub mod drive_document_sum_query; #[cfg(any(feature = "server", feature = "verify"))] pub mod drive_document_average_query; +/// Joint count-and-sum no-prove executor surface — backs the AVG +/// no-prove path's unified single-walk dispatch. See its module +/// docstring for the perf / atomicity contract. Server-only because +/// the surface only fires on the no-prove (server-materialized) path. +#[cfg(feature = "server")] +pub mod drive_document_count_and_sum_query; + /// A Query Syntax Validation Result that contains data pub type QuerySyntaxValidationResult = ValidationResult; From e7be89de6eeda15b3ca68a13b4c0f7471057c512 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 07:16:34 +0700 Subject: [PATCH 02/12] =?UTF-8?q?refactor(drive):=20enforce=20mode=C3=97sh?= =?UTF-8?q?ape=20pairing=20in=20joint=20count+sum=20dispatcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review feedback on #3690. The permissive `(_, resp) => Ok(resp)` fallback in the RangeNoProof second-stage shape adapter could silently leak a wrong response shape if a downstream executor changed behavior. Replace with an exhaustive match: every legal mode×shape pairing is named explicitly, and any other combination surfaces as `CorruptedCodeExecution` with a mode-specific message rather than forwarding the unexpected shape to the gRPC handler. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index ec1e199648c..c8898623eab 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -174,8 +174,18 @@ impl Drive { // would produce. Without this re-shape, GroupByIn + // range AVG silently changes wire shape from `Entries` // (pre-#3687) to `Aggregate`. + // Strict mode×shape pairing. Every legal combination + // is named explicitly; any other pairing surfaces as + // `CorruptedCodeExecution` rather than silently + // forwarding a wrong-shape response. A future executor + // change that emits, say, `Entries` from an + // `AverageMode::Aggregate` request would fail loudly + // here instead of leaking the wrong wire shape to the + // gRPC handler. match (request.mode, response) { - (AverageMode::Aggregate, resp) => Ok(resp), + (AverageMode::Aggregate, resp @ DocumentAverageResponse::Aggregate { .. }) => { + Ok(resp) + } (AverageMode::GroupByIn, DocumentAverageResponse::Aggregate { count, sum }) => { Ok(DocumentAverageResponse::Entries(vec![ super::super::drive_document_average_query::AverageEntry { @@ -186,7 +196,33 @@ impl Drive { }, ])) } - (_, resp) => Ok(resp), + ( + AverageMode::GroupByRange | AverageMode::GroupByCompound, + resp @ DocumentAverageResponse::Entries(_), + ) => Ok(resp), + (mode, _) => Err(Error::Drive( + crate::error::drive::DriveError::CorruptedCodeExecution(match mode { + AverageMode::Aggregate => { + "execute_document_count_and_sum_request: \ + RangeNoProof executor emitted a non-Aggregate \ + response for AverageMode::Aggregate — joint \ + range executor's shape contract violated" + } + AverageMode::GroupByIn => { + "execute_document_count_and_sum_request: \ + RangeNoProof executor emitted a non-Aggregate \ + response for AverageMode::GroupByIn — the \ + in-axis fold should yield a single (count, sum) \ + pair the dispatcher re-wraps as Entries" + } + AverageMode::GroupByRange | AverageMode::GroupByCompound => { + "execute_document_count_and_sum_request: \ + RangeNoProof executor emitted a non-Entries \ + response for a distinct grouped mode — joint \ + range executor's shape contract violated" + } + }), + )), } } // The four prove-mode resolutions are unreachable here — From 0f6512ee63cec9c5e866475ca3324a874f02d530 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 07:27:58 +0700 Subject: [PATCH 03/12] fix(drive): close AVG no-proof amplification + honor request.limit (#3690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer findings on #3690: P1: The joint RangeNoProof executor walked every matched PCPS element via `distinct_sum_path_query` and folded `(count, sum)` in Rust for *all* shapes including aggregate. That turned `Aggregate + range` and `GroupByIn + range` into O(matching range keys) reads — a public DAPI request-amplification surface that the pre-#3687 dispatcher avoided by calling grovedb's merk-internal `query_aggregate_count` / `query_aggregate_sum` accumulators (each O(log n)). The fix restores the merk-internal accumulator calls for the aggregate shapes (one count call + one sum call per branch, under a shared read transaction for atomicity). The single-PCPS-walk win still applies on the distinct shapes (`GroupByRange + range`, `GroupByCompound + range`) where one walk yields both axes per visited element vs. two parallel walks zipped post-hoc — which was the original perf rationale in #3687. Compound `(In + range)` per-In fans out (bounded by `In::in_values()`'s 100-cap × 2 accumulator reads per branch), matching count and sum's existing executors. A future optional grovedb optimization could expose a combined no-proof `query_aggregate_count_and_sum` primitive collapsing the two calls into one merk-internal accumulator; #3687 captures the follow-up. P2: The joint dispatcher hard-coded `None` into the distinct walk's `distinct_sum_path_query` limit, ignoring `DocumentAverageRequest::limit` on the no-proof distinct branches. A request without `limit` now falls back to `drive_config.default_query_limit` and an explicit `limit > max_query_limit` clamps to `max_query_limit`, matching count's no-proof distinct policy and the documented contract on `DocumentAverageRequest::limit`. Three new regression tests pin the limit behavior: explicit limit, `None` defaults to `default_query_limit`, and over-max clamps to `max_query_limit`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 231 +++++++++++ .../drive_dispatcher.rs | 33 ++ .../executors/range_no_proof.rs | 377 +++++++++++++----- 3 files changed, 535 insertions(+), 106 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 6092f71ac1d..4238335c225 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -1586,4 +1586,235 @@ mod tests { assert_eq!(joint_sum, sum_by_key.get(key).unwrap()); } } + + /// Distinct AVG no-proof MUST honor the request's `limit` — + /// `GroupByRange` over a wide range should truncate to the + /// caller's `limit` rather than enumerate every distinct in-range + /// terminator. Regression test for the joint dispatcher's + /// `RangeNoProof` distinct branch: prior to the P2 fix the + /// dispatcher hard-coded `None` into `distinct_sum_path_query`, + /// silently returning every matching key. + #[test] + fn distinct_avg_no_proof_honors_explicit_limit() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + // Five distinct color buckets so a `limit = 2` request must + // truncate the result set; otherwise the executor would emit + // all five. + let docs = [ + ("red", 5u64), + ("green", 7), + ("blue", 2), + ("yellow", 4), + ("purple", 9), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + let color_ge_a = WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::Text("a".to_string()), + }; + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_ge_a], + order_clauses: Vec::new(), + mode: AverageMode::GroupByRange, + limit: Some(2), + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("dispatcher should succeed"); + let entries = match response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + assert_eq!( + entries.len(), + 2, + "distinct AVG no-proof must apply the request's `limit = 2` and \ + return exactly 2 entries; got {entries:?}" + ); + } + + /// Distinct AVG no-proof with `limit = None` must default to + /// `drive_config.default_query_limit`, not enumerate every + /// distinct key. Regression test for the same hard-coded `None` + /// the prior implementation passed. + #[test] + fn distinct_avg_no_proof_defaults_limit_to_operator_default_query_limit() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + // Five distinct buckets and an operator-tuned + // `default_query_limit = 3`. The dispatcher must honor the + // operator's runtime default on the no-proof path (this is + // explicitly documented as DIFFERENT from the prove path, + // which uses the compile-time constant for byte-stability of + // proof reconstruction). A regression that leaves limit as + // `None` would emit all 5 entries. + let docs = [ + ("red", 5u64), + ("green", 7), + ("blue", 2), + ("yellow", 4), + ("purple", 9), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig { + default_query_limit: 3, + ..Default::default() + }; + + let color_ge_a = WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::Text("a".to_string()), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_ge_a], + order_clauses: Vec::new(), + mode: AverageMode::GroupByRange, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("dispatcher should succeed"); + let entries = match response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + assert_eq!( + entries.len(), + 3, + "distinct AVG no-proof with `limit = None` must default to \ + `drive_config.default_query_limit` (= 3 here) rather than \ + enumerating all 5 distinct keys; got {entries:?}" + ); + } + + /// Distinct AVG no-proof with `limit > max_query_limit` must + /// clamp to `max_query_limit`, not return an error. Mirrors + /// count's no-proof distinct-walk clamp policy (documented in + /// `DocumentAverageRequest::limit`). + #[test] + fn distinct_avg_no_proof_clamps_limit_to_max_query_limit() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let docs = [ + ("red", 5u64), + ("green", 7), + ("blue", 2), + ("yellow", 4), + ("purple", 9), + ]; + for (i, (color, amount)) in docs.iter().enumerate() { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + // Operator-tuned `max_query_limit = 2`. An explicit `limit = + // 4` MUST clamp to 2 (no-proof policy; the prove path errors + // on this combination instead — see the + // `range_distinct_avg_proof_rejects_limit_over_max` test + // above for the prove counterpart). + let drive_config = DriveConfig { + default_query_limit: 100, + max_query_limit: 2, + ..Default::default() + }; + + let color_ge_a = WhereClause { + field: "color".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::Text("a".to_string()), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_ge_a], + order_clauses: Vec::new(), + mode: AverageMode::GroupByRange, + limit: Some(4), + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("dispatcher should succeed (no-proof clamps, never errors)"); + let entries = match response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + assert_eq!( + entries.len(), + 2, + "distinct AVG no-proof must clamp `limit = 4` to \ + `max_query_limit = 2`; got {entries:?}" + ); + } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index c8898623eab..c851e5b52e7 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -147,6 +147,38 @@ impl Drive { carrier_outer_limit: None, left_to_right: order_by_ascending, }; + // Limit applies only to the distinct branches — the + // aggregate branches return a single collapsed pair + // bounded by grovedb's merk-internal accumulators + // (`query_aggregate_count` / `query_aggregate_sum`) + // regardless of how many documents match. For distinct + // mirror count's no-proof distinct policy: fall back to + // `drive_config.default_query_limit` when unset, clamp + // to `drive_config.max_query_limit` when over. Mirrors + // [`DocumentAverageRequest::limit`]'s documented + // no-proof contract. + let effective_limit = if return_distinct { + Some( + request + .limit + .unwrap_or(request.drive_config.default_query_limit as u32) + .min(request.drive_config.max_query_limit as u32), + ) + } else { + None + }; + let limit_u16 = effective_limit + .map(|l| { + u16::try_from(l).map_err(|_| { + Error::Query(crate::error::query::QuerySyntaxError::Unsupported( + format!( + "limit {} exceeds u16::MAX for distinct AVG no-proof walk", + l + ), + )) + }) + }) + .transpose()?; let response = self.execute_document_count_and_sum_range_no_proof( contract_id, request.document_type, @@ -154,6 +186,7 @@ impl Drive { where_clauses, sum_property, options, + limit_u16, transaction, platform_version, )?; diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index d9fdd03aa13..cc0fd52708a 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -1,45 +1,54 @@ //! Joint count-and-sum `RangeNoProof` executor for //! [`DocumentSumMode::RangeNoProof`] dispatch on the AVG no-prove path. //! -//! Mirrors [`crate::query::drive_document_sum_query::executors::range_no_proof`] -//! + [`crate::query::drive_document_sum_query::execute_range_sum::execute_range_sum_no_proof`] -//! with one important structural difference vs. sum: +//! Two structurally different paths share this executor: //! -//! ## The "flat summed" branch can't use the engine-side accumulator +//! ## Aggregate shapes (`Aggregate + range`, `GroupByIn + range`) //! -//! Sum's flat-summed branch collapses to a single -//! `grove.query_aggregate_sum` call against the merk-internal -//! `AggregateSumOnRange` primitive (O(log n) — the accumulator returns -//! a single `i64`). The pinned grovedb rev exposes that primitive AND -//! `query_aggregate_count`, but **does not** expose a combined no-prove -//! `query_aggregate_count_and_sum`. The proof-side analog -//! `AggregateCountAndSumOnRange` exists (and is what the AVG prove -//! path uses — see [`Drive::execute_document_average_prove`]'s range -//! arm) but the no-prove engine-side variant is a future grovedb -//! optimization tracked under #3687. +//! Use grovedb's merk-internal aggregate primitives — +//! `query_aggregate_count` against the index's +//! `aggregate_count_path_query` AND `query_aggregate_sum` against the +//! same index's `aggregate_sum_path_query`. Two O(log n) merk-internal +//! reads, bounded **regardless of how many documents the range +//! matches**. //! -//! As a result, the joint flat-summed branch walks PCPS terminator -//! elements via `grove_get_raw_path_query` against the same -//! `distinct_sum_path_query` builder the distinct branch uses, and -//! folds `(count, sum)` in Rust. This is still **one** grovedb walk -//! per AVG query — exactly the win #3687 captures — versus the -//! pre-#3687 path of issuing parallel `query_aggregate_sum` + -//! `query_aggregate_count` calls and zipping post-hoc. The -//! optimization opportunity (collapse the Rust-side fold into a single -//! engine-side accumulator) lives in grovedb, not here. +//! grovedb's pinned rev does not yet expose a combined no-proof +//! `query_aggregate_count_and_sum` primitive (its proof-side analog +//! `AggregateCountAndSumOnRange` exists and is what the AVG prove path +//! uses). A future optional grovedb optimization could collapse these +//! into a single merk-internal accumulator; #3687 captures the +//! follow-up. Crucially, the no-proof path here still uses the +//! engine-side bounded accumulators — it does NOT walk every matched +//! PCPS element to fold `(count, sum)` in Rust. That walk shape would +//! turn a public DAPI endpoint into a request-amplification surface +//! (O(matching range keys) per request); the bounded accumulators close +//! that surface and match the worst-case cost the pre-#3687 composed +//! count + sum dispatchers had. //! -//! ## Distinct branch +//! For compound `(In + range)` (with `In` on a prefix property) the +//! aggregate primitive can't fork through an `In`; the executor +//! per-In fans out (≤100 branches per the `In::in_values()` validator +//! cap) and issues one count + one sum aggregate call per branch under +//! a shared read transaction. Worst-case 200 merk-internal reads per +//! request, again independent of matched-document count. //! -//! For the distinct shapes (`GroupByRange` / `GroupByCompound` + range) -//! the walk is structurally identical to sum's: same -//! `distinct_sum_path_query` builder, same -//! `QueryPathKeyElementTrioResultType` shape, same `in_key` / -//! `base_path_len` heuristic. The only difference is that each emitted -//! element is decoded via `count_sum_value_or_default()` and produces -//! an [`AverageEntry { count: Some, sum: Some }`] rather than a -//! `SumEntry { sum: Some }`. +//! ## Distinct shapes (`GroupByRange + range`, `GroupByCompound + range`) +//! +//! Walk PCPS terminator elements via +//! [`DriveDocumentSumQuery::distinct_sum_path_query`] in one +//! `grove_get_raw_path_query` call — the same shape sum's distinct +//! branch uses — and decode each via +//! [`grovedb::Element::count_sum_value_or_default`] to populate +//! [`AverageEntry`] with both `count` and `sum`. **This** is where the +//! single-walk win lives: one walk yields both axes per visited +//! element instead of two parallel walks zipped post-hoc. The +//! distinct walk is bounded by the request's `limit` (default falls +//! back to `drive_config.default_query_limit`, explicit limits are +//! clamped to `drive_config.max_query_limit`) so the public-endpoint +//! amplification surface stays closed on this path too. use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; +use super::super::super::drive_document_count_query::DriveDocumentCountQuery; use super::super::super::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; use super::super::super::drive_document_sum_query::{DriveDocumentSumQuery, RangeSumOptions}; use crate::drive::Drive; @@ -47,10 +56,12 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::{WhereClause, WhereOperator}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; use grovedb::query_result_type::QueryResultType; use grovedb::TransactionArg; +use grovedb_costs::CostContext; impl Drive { /// Range-aware joint count-and-sum walk against a @@ -60,11 +71,13 @@ impl Drive { /// (flat / compound aggregate shapes) or per-distinct-value /// [`DocumentAverageResponse::Entries`] (`GroupByRange` / /// `GroupByCompound`) depending on - /// `options.return_distinct_sums_in_range`. The dispatcher sets that - /// flag based on the request's [`AverageMode`]. + /// `options.return_distinct_sums_in_range`. The dispatcher sets + /// that flag based on the request's + /// [`super::super::super::drive_document_average_query::AverageMode`]. /// - /// Perf: one grovedb walk per query in the flat / distinct branches - /// — halving the work vs. the pre-#3687 composition. + /// `limit` applies only to the distinct branch; the aggregate + /// branches return a single collapsed pair regardless. See the + /// module docstring for the per-shape cost contract. #[allow(clippy::too_many_arguments)] pub fn execute_document_count_and_sum_range_no_proof( &self, @@ -74,6 +87,7 @@ impl Drive { where_clauses: Vec, sum_property: String, options: RangeSumOptions, + limit: Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { @@ -93,39 +107,43 @@ impl Drive { )) })?; + let drive_version = &platform_version.drive; + let has_in_on_prefix = where_clauses + .iter() + .any(|wc| wc.operator == WhereOperator::In); + + if !options.return_distinct_sums_in_range { + // Aggregate shape: use grovedb's merk-internal bounded + // accumulators for `(count, sum)`. Two calls because the + // combined no-proof primitive doesn't exist yet, but each + // is O(log n) — strictly bounded regardless of how many + // documents the range matches. This is the same cost class + // the pre-#3687 composed count + sum dispatchers had. + return self.aggregate_range_count_and_sum( + contract_id, + document_type, + document_type_name, + index, + where_clauses, + sum_property, + has_in_on_prefix, + transaction, + platform_version, + ); + } + + // Distinct shape: walk PCPS terminator elements via the + // distinct path query, bounded by the caller's `limit`. let sum_query = DriveDocumentSumQuery { document_type, contract_id, document_type_name, index, - where_clauses: where_clauses.clone(), + where_clauses, sum_property, }; - - let drive_version = &platform_version.drive; - let has_in_on_prefix = where_clauses - .iter() - .any(|wc| wc.operator == WhereOperator::In); - - // One unified walk for all three sub-shapes: the - // `distinct_sum_path_query` builder is the right shape - // regardless of whether we're folding into a single aggregate - // pair (flat / compound-aggregate) or emitting per-distinct - // entries — both cases visit the same PCPS terminator - // elements; only the output shaping differs. - // - // For the compound-aggregate shape (In + range, non-distinct), - // we still need atomicity across the multiple In-branch - // sub-walks the distinct query naturally fans into — the - // distinct path query's outer-keys walk under one grovedb call - // already provides this since it's a single - // `grove_get_raw_path_query` invocation against a multi-Key - // outer query. No separate per-In fan-out required. - let path_query = sum_query.distinct_sum_path_query( - None::, - options.left_to_right, - platform_version, - )?; + let path_query = + sum_query.distinct_sum_path_query(limit, options.left_to_right, platform_version)?; let base_path_len = path_query.path.len(); let mut drive_operations = vec![]; @@ -146,58 +164,18 @@ impl Drive { | grovedb::Error::PathKeyNotFound(_) ) => { - return Ok(if options.return_distinct_sums_in_range { - DocumentAverageResponse::Entries(Vec::new()) - } else { - DocumentAverageResponse::Aggregate { count: 0, sum: 0 } - }); + return Ok(DocumentAverageResponse::Entries(Vec::new())); } Err(e) => return Err(e), }; - if !options.return_distinct_sums_in_range { - // Flat / compound-aggregate: fold every visited PCPS - // element's `(count, sum)` into a single pair. `checked_add` - // on both axes mirrors the per-In-value and total executors. - let mut count_acc: u64 = 0; - let mut sum_acc: i64 = 0; - for triple in elements.to_path_key_elements() { - let (_path, _key, element) = triple; - let (c, s) = element.count_sum_value_or_default(); - count_acc = count_acc.checked_add(c).ok_or_else(|| { - Error::Query(QuerySyntaxError::Unsupported( - "range count-and-sum overflowed u64 on the count axis while \ - folding visited PCPS elements. Narrow the range or use \ - multiple queries." - .to_string(), - )) - })?; - sum_acc = sum_acc.checked_add(s).ok_or_else(|| { - Error::Query(QuerySyntaxError::Unsupported( - "range count-and-sum overflowed i64 on the sum axis while \ - folding visited PCPS elements. Narrow the range or use \ - multiple queries." - .to_string(), - )) - })?; - } - return Ok(DocumentAverageResponse::Aggregate { - count: count_acc, - sum: sum_acc, - }); - } - - // Distinct (GroupByRange / GroupByCompound): emit one entry - // per visited element. `(in_key, key)` shaping matches sum's - // distinct branch. let mut entries: Vec = Vec::new(); for triple in elements.to_path_key_elements() { let (path, key, element) = triple; let (count, sum) = element.count_sum_value_or_default(); - // Drop empty groups so the output matches sum's distinct - // contract (which drops `sum == 0` rows). For AVG an empty - // group has no averageable signal AND no count signal, - // making the row uninformative. + // Drop empty groups: matches sum's distinct contract + // (which drops `sum == 0`). For AVG an empty group has no + // averageable signal AND no count signal. if count == 0 && sum == 0 { continue; } @@ -216,4 +194,191 @@ impl Drive { Ok(DocumentAverageResponse::Entries(entries)) } + + /// Aggregate-range branch: returns `(count, sum)` via grovedb's + /// merk-internal bounded accumulators. Two calls per branch + /// (count + sum); compound `In + range` per-In fans out and sums + /// per-branch totals. + /// + /// All branches share a read transaction (opened internally when + /// the caller didn't supply one) so the count and sum reads see + /// the same grovedb snapshot — same atomicity contract the + /// pre-#3687 dispatcher implemented across its two sub-dispatches. + #[allow(clippy::too_many_arguments)] + fn aggregate_range_count_and_sum( + &self, + contract_id: [u8; 32], + document_type: DocumentTypeRef, + document_type_name: String, + index: &dpp::data_contract::document_type::Index, + where_clauses: Vec, + sum_property: String, + has_in_on_prefix: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let drive_version = &platform_version.drive; + + // Open a shared read transaction across the count and sum + // accumulator calls (and across per-In branches in the compound + // shape) so they see one snapshot. Read-only; dropped without + // commit at scope end. + let local_tx; + let effective_transaction: TransactionArg = if transaction.is_some() { + transaction + } else { + local_tx = self.grove.start_transaction(); + Some(&local_tx) + }; + + if !has_in_on_prefix { + let (count, sum) = self.flat_aggregate_count_and_sum( + contract_id, + document_type, + document_type_name, + index, + where_clauses, + sum_property, + effective_transaction, + drive_version, + platform_version, + )?; + return Ok(DocumentAverageResponse::Aggregate { count, sum }); + } + + // Compound: per-In fan-out with the In replaced by Equal-per- + // value. Exactly one In clause allowed — sum's existing + // executor documents the silent-drop bug this guards. + let in_clauses: Vec<&WhereClause> = where_clauses + .iter() + .filter(|wc| wc.operator == WhereOperator::In) + .collect(); + if in_clauses.len() != 1 { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "compound range count-and-sum requires exactly one `in` clause", + ), + )); + } + let in_clause = in_clauses[0]; + let in_values = in_clause.in_values().into_data_with_error()??; + let other_clauses: Vec = where_clauses + .iter() + .filter(|wc| wc.operator != WhereOperator::In) + .cloned() + .collect(); + + let mut count_total: u64 = 0; + let mut sum_total: i64 = 0; + let mut seen_keys: std::collections::BTreeSet> = std::collections::BTreeSet::new(); + for value in in_values.iter() { + // Dedupe by canonical serialized bytes — DPP `in_values()` + // already rejects raw-Value duplicates, but defense-in- + // depth for future Value variants that serialize identically. + let key_bytes = document_type.serialize_value_for_key( + in_clause.field.as_str(), + value, + platform_version, + )?; + if !seen_keys.insert(key_bytes) { + continue; + } + + let mut clauses_for_value = other_clauses.clone(); + clauses_for_value.push(WhereClause { + field: in_clause.field.clone(), + operator: WhereOperator::Equal, + value: value.clone(), + }); + + let (branch_count, branch_sum) = self.flat_aggregate_count_and_sum( + contract_id, + document_type, + document_type_name.clone(), + index, + clauses_for_value, + sum_property.clone(), + effective_transaction, + drive_version, + platform_version, + )?; + // `checked_add` rather than `saturating_add` so an + // overflow fails deterministically on both axes — + // matches the pattern in the sibling `total` / + // `per_in_value` executors. + count_total = count_total.checked_add(branch_count).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "compound In-on-prefix range count-and-sum overflowed u64 on the count \ + axis when summing per-In aggregates. Narrow the query (smaller In set \ + or narrower range) or use multiple queries and combine client-side." + .to_string(), + )) + })?; + sum_total = sum_total.checked_add(branch_sum).ok_or_else(|| { + Error::Query(QuerySyntaxError::Unsupported( + "compound In-on-prefix range count-and-sum overflowed i64 on the sum \ + axis when summing per-In aggregates. Narrow the query (smaller In set \ + or narrower range) or use multiple queries and combine client-side." + .to_string(), + )) + })?; + } + + Ok(DocumentAverageResponse::Aggregate { + count: count_total, + sum: sum_total, + }) + } + + /// Flat (no In on prefix) aggregate count + sum: one + /// `query_aggregate_count` call against the count path query, one + /// `query_aggregate_sum` call against the sum path query, both + /// O(log n) at the merk layer. Shared `transaction` enforces + /// snapshot consistency across the two reads. + #[allow(clippy::too_many_arguments)] + fn flat_aggregate_count_and_sum( + &self, + contract_id: [u8; 32], + document_type: DocumentTypeRef, + document_type_name: String, + index: &dpp::data_contract::document_type::Index, + where_clauses: Vec, + sum_property: String, + transaction: TransactionArg, + drive_version: &dpp::version::drive_versions::DriveVersion, + platform_version: &PlatformVersion, + ) -> Result<(u64, i64), Error> { + let count_query = DriveDocumentCountQuery { + document_type, + contract_id, + document_type_name: document_type_name.clone(), + index, + where_clauses: where_clauses.clone(), + }; + let count_path_query = count_query.aggregate_count_path_query(platform_version)?; + let CostContext { value, cost: _ } = self.grove.query_aggregate_count( + &count_path_query, + transaction, + &drive_version.grove_version, + ); + let count = value.map_err(|e| Error::GroveDB(Box::new(e)))?; + + let sum_query = DriveDocumentSumQuery { + document_type, + contract_id, + document_type_name, + index, + where_clauses, + sum_property, + }; + let sum_path_query = sum_query.aggregate_sum_path_query(platform_version)?; + let CostContext { value, cost: _ } = self.grove.query_aggregate_sum( + &sum_path_query, + transaction, + &drive_version.grove_version, + ); + let sum = value.map_err(|e| Error::GroveDB(Box::new(e)))?; + + Ok((count, sum)) + } } From 5ed1f0deea7a86918f790e3aec8934b6558fec8e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 07:36:50 +0700 Subject: [PATCH 04/12] fix(drive): tighten AVG no-proof API contract per PR #3690 review CodeRabbit + thepastaclaw findings on #3690: - Joint dispatcher now rejects direct callers passing `prove = true` with `CorruptedCodeExecution` rather than silently producing a no-proof response. The wrapper `execute_document_average_request` is the only legitimate entry that routes prove requests (to the prove-side dispatcher); reaching the joint dispatcher with `prove = true` is a wiring bug. - Joint dispatcher now runs `validate_and_canonicalize_where_clauses` on the input, restoring the shape contract the pre-#3690 AVG composition path inherited from count's dispatcher (rejects malformed shapes like duplicate Equal on the same field; canonicalizes `[> A, < B]` pairs into `between*`). Without it, malformed AVG queries that count previously rejected reached the joint executors with arbitrarily-collapsed shapes. - `PerInValue` executor now honors `request.limit`, mirroring count's per-In policy. The bound is `min(request.limit, max_query_limit)`; unset `limit` leaves the natural In-cap (100) in place. Reviewer's finding was framed as a blocker on RangeNoProof + PerInValue together; RangeNoProof was already fixed in 0f6512ee63, this closes the remaining PerInValue half. - Reworded the drop-predicate comment in `range_no_proof.rs` to acknowledge the intentional divergence from sum's `sum == 0` predicate. The joint executor preserves `count = N, sum = 0` rows (informative for AVG: a group with N docs whose values sum to zero is meaningful) and only drops grovedb-absent `(0, 0)` rows. Four new regression tests pin: prove=true guard, validator activation, PerInValue limit truncation, and the empty-where primary-key count-sum-tree fast path (the consensus-critical doctype-level Total shape that no prior parity test covered). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 347 ++++++++++++++++++ .../drive_dispatcher.rs | 49 ++- .../executors/per_in_value.rs | 9 + .../executors/range_no_proof.rs | 12 +- 4 files changed, 412 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 4238335c225..76daa2c0b55 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -1817,4 +1817,351 @@ mod tests { `max_query_limit = 2`; got {entries:?}" ); } + + /// `execute_document_count_and_sum_request` must reject a direct + /// caller passing `prove = true`. The wrapper + /// `execute_document_average_request` is the only legitimate entry + /// that routes prove requests (to the prove-side dispatcher); + /// reaching the joint dispatcher with `prove = true` would + /// otherwise silently produce a no-proof response. Regression for + /// the CodeRabbit "enforce no-prove precondition" finding. + #[test] + fn joint_dispatcher_rejects_prove_true_request() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: Vec::new(), + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: true, + drive_config: &drive_config, + }; + + let err = drive + .execute_document_count_and_sum_request(request, None, platform_version) + .expect_err("prove=true direct call must reject"); + let msg = format!("{err:?}"); + assert!( + msg.contains("no-prove"), + "expected the prove=true guard to fire; got: {msg}" + ); + } + + /// AVG no-proof dispatcher must run + /// `validate_and_canonicalize_where_clauses` — same shape contract + /// the pre-#3690 composition path inherited from count's + /// dispatcher. Pin a representative rejection: a duplicate Equal + /// on the same field. Without the validator the executor would + /// either succeed with a silently-collapsed shape or fail downstream + /// with a less precise error. + #[test] + fn joint_dispatcher_runs_validate_and_canonicalize_where_clauses() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = build_widget_contract_pcps(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + // Duplicate Equal on `color` — validator rejects via + // `WhereClause::group_clauses`. + let dup_color_a = WhereClause { + field: "color".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("red".to_string()), + }; + let dup_color_b = WhereClause { + field: "color".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("green".to_string()), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![dup_color_a, dup_color_b], + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let err = drive + .execute_document_average_request(request, None, platform_version) + .expect_err( + "AVG no-proof must reject duplicate Equal on the same field via \ + validate_and_canonicalize_where_clauses", + ); + // The exact error variant comes from `WhereClause::group_clauses` — + // pin only that the call returned `Err` and the error mentions + // the problematic shape rather than a generic index-picker miss. + let msg = format!("{err:?}"); + assert!( + !msg.contains("WhereClauseOnNonIndexedProperty"), + "validator should reject before the index picker would: {msg}" + ); + } + + /// `PerInValue` no-proof AVG must honor `request.limit` on the + /// returned entry list. Regression for the reviewer's "joint + /// dispatcher drops `request.limit`" finding on the PerInValue + /// arm. Count's per-In executor truncates at this same point. + #[test] + fn per_in_value_avg_no_proof_honors_explicit_limit() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // `byColor` index: `summable: "amount"` + `countable: + // "countable"`. No range flags — this is the no-range + // PerInValue shape. + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "color": {"type": "string", "position": 0, "maxLength": 32}, + "amount": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000}, + }, + "required": ["color", "amount"], + "indices": [{ + "name": "byColor", + "properties": [{"color": "asc"}], + "summable": "amount", + "countable": "countable", + }], + "additionalProperties": false, + }); + let schemas = platform_value!({ "widget": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + for (i, (color, amount)) in [("red", 5u64), ("green", 7), ("blue", 2), ("yellow", 4)] + .iter() + .enumerate() + { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig::default(); + + // `In` over 4 color values, `limit = 2` — dispatcher must + // truncate the per-In entry list to 2. + let color_in = WhereClause { + field: "color".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Text("red".to_string()), + Value::Text("green".to_string()), + Value::Text("blue".to_string()), + Value::Text("yellow".to_string()), + ]), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in], + order_clauses: Vec::new(), + mode: AverageMode::GroupByIn, + limit: Some(2), + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("dispatcher should succeed"); + let entries = match response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + assert_eq!( + entries.len(), + 2, + "PerInValue AVG no-proof must apply request.limit = 2 to the per-In \ + entry list (caller asked for 4 In values, dispatcher must truncate); \ + got {entries:?}" + ); + } + + /// Empty-where `Aggregate` AVG MUST exercise the + /// [`Drive::execute_document_count_and_sum_total_no_proof`] + /// primary-key fast path when the doctype declares + /// `documentsAverageable` (= `documentsCountable: true + + /// documentsSummable: ""`). The fast path reads + /// `[contract_doc, contract_id, [1], doctype, 0]` — the PCPS + /// primary-key element — and decodes `(count, sum)` from it in one + /// grovedb call without any index. Consensus-critical: a regression + /// here would silently produce wrong `(count, sum)` for the + /// most-trafficked AVG shape (unfiltered total). + #[test] + fn empty_where_total_executor_uses_primary_key_count_sum_tree_fast_path() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // `documentsAverageable: "amount"` desugars to BOTH + // `documentsCountable: true` AND `documentsSummable: + // "amount"`, which is exactly what the empty-where fast path + // requires. No `indices` block — the fast path doesn't use + // an index, it reads the doctype's primary-key + // count-sum-bearing tree directly at `[..., doctype, 0]`. + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "amount": {"type": "integer", "position": 0, "minimum": 0, "maximum": 1000}, + }, + "required": ["amount"], + "documentsAverageable": "amount", + "additionalProperties": false, + }); + let schemas = platform_value!({ "score": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + // Insert documents directly (no need for the widget helper — + // this doctype has no color property). + let document_type = data_contract + .document_type_for_name("score") + .expect("score type"); + for (i, amount) in [10u64, 20, 30, 40].iter().enumerate() { + let mut properties = std::collections::BTreeMap::new(); + properties.insert("amount".to_string(), Value::U64(*amount)); + let document: Document = DocumentV0 { + id: Identifier::from([(i + 1) as u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties, + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + } + .into(); + let storage_flags = Some(std::borrow::Cow::Owned(StorageFlags::SingleEpoch(0))); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, storage_flags)), + owner_id: None, + }, + contract: &data_contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("insert score"); + } + + let drive_config = DriveConfig::default(); + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: Vec::new(), + order_clauses: Vec::new(), + mode: AverageMode::Aggregate, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("empty-where AVG no-proof must succeed via the primary-key fast path"); + match response { + DocumentAverageResponse::Aggregate { count, sum } => { + assert_eq!( + (count, sum), + (4, 100), + "primary-key count-sum tree fast path must return (4 docs, sum 10+20+30+40 = 100)" + ); + } + other => panic!("expected Aggregate, got {:?}", other), + } + } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index c851e5b52e7..80f79dfc539 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -35,6 +35,7 @@ use super::super::drive_document_average_query::{ AverageMode, DocumentAverageRequest, DocumentAverageResponse, }; +use super::super::drive_document_count_query::drive_dispatcher::validate_and_canonicalize_where_clauses; use super::super::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; use super::super::drive_document_sum_query::{DocumentSumMode, RangeSumOptions, SumMode}; use crate::drive::Drive; @@ -78,6 +79,38 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // No-prove precondition. Direct callers (i.e. anything reaching + // this method without going through + // `execute_document_average_request`'s prove/no-prove split) + // must not slip a `prove = true` request past — this dispatcher + // hard-routes with `prove = false` and would otherwise silently + // hand back a no-prove response to a caller that asked for a + // proof. Reject up front rather than honoring the routing + // mismatch. + if request.prove { + return Err(Error::Drive( + crate::error::drive::DriveError::CorruptedCodeExecution( + "execute_document_count_and_sum_request only serves the no-prove path. \ + For a proven AVG response use \ + `execute_document_average_request` (which routes prove=true to the \ + prove-side dispatcher).", + ), + )); + } + + // Validate + canonicalize the structured `where_clauses` — + // same rejections / canonicalization the regular document- + // query path runs, kept here so the AVG no-prove surface + // keeps the accept/reject contract it had pre-#3690 when the + // composition path ran the validator via count's dispatcher. + // Must run BEFORE `detect_sum_mode_from_inputs` because the + // canonicalizer collapses `[> A, < B]` pairs into a single + // `between*` clause whose presence changes the routing + // decision. See + // [`validate_and_canonicalize_where_clauses`]'s docstring for + // the catalog of rejections. + let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses)?; + // Convert AverageMode → SumMode (1:1 by construction); sum's // routing table is the single source of truth for the // `(where_clauses × mode × prove=false)` triple. Forking a @@ -94,11 +127,10 @@ impl Drive { }; let resolved_mode = - detect_sum_mode_from_inputs(&request.where_clauses, sum_mode, false, platform_version)?; + detect_sum_mode_from_inputs(&where_clauses, sum_mode, false, platform_version)?; let contract_id = request.contract.id().to_buffer(); let document_type_name = request.document_type.name().to_string(); - let where_clauses = request.where_clauses; let sum_property = request.sum_property; let order_by_ascending = request .order_clauses @@ -122,6 +154,18 @@ impl Drive { carrier_outer_limit: None, left_to_right: order_by_ascending, }; + // PerInValue's per-In fan-out is bounded by the + // `In::in_values()` 100-cap, so the executor's worst- + // case backlog is small — but the caller's `limit` + // still applies to the entry list returned (mirroring + // count's no-proof per-In policy at + // `execute_document_count_per_in_value_no_proof`). On + // the no-proof path `limit > max_query_limit` clamps + // to `max_query_limit`; an unset `limit` is `None` + // (no truncation beyond the 100-cap). + let per_in_limit = request + .limit + .map(|l| l.min(request.drive_config.max_query_limit as u32)); self.execute_document_count_and_sum_per_in_value_no_proof( contract_id, request.document_type, @@ -129,6 +173,7 @@ impl Drive { where_clauses, sum_property, options, + per_in_limit, transaction, platform_version, ) diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs index 0ee4eddd300..76c1e3b023f 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -49,6 +49,7 @@ impl Drive { where_clauses: Vec, sum_property: String, options: RangeSumOptions, + limit: Option, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { @@ -197,6 +198,14 @@ impl Drive { if !options.left_to_right { entries.reverse(); } + // Apply caller's `limit` AFTER ordering — same shape as + // count's `execute_document_count_per_in_value_no_proof`. The + // BTreeMap iteration was already ascending; an explicit + // descending order reverses the vec, then the truncate caps + // it at `limit` from the front. + if let Some(l) = limit { + entries.truncate(l as usize); + } Ok(DocumentAverageResponse::Entries(entries)) } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index cc0fd52708a..5c456670bfd 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -173,9 +173,15 @@ impl Drive { for triple in elements.to_path_key_elements() { let (path, key, element) = triple; let (count, sum) = element.count_sum_value_or_default(); - // Drop empty groups: matches sum's distinct contract - // (which drops `sum == 0`). For AVG an empty group has no - // averageable signal AND no count signal. + // Drop fully-empty rows only. Sum's standalone distinct + // executor drops on `sum == 0` regardless of count, but + // that's overly aggressive for AVG: a row with + // `count = N, sum = 0` is informative — it means the + // group has N documents whose averageable values sum to + // zero (e.g. all zero, or signed values that cancel). The + // joint executor preserves such rows and only drops + // grovedb-absent rows that decode as `(0, 0)`. Diverges + // intentionally from sum's drop predicate. if count == 0 && sum == 0 { continue; } From 3a1cea13f022bb336c853fa7a5e35fb08df99bec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 08:59:48 +0700 Subject: [PATCH 05/12] docs(drive): rewrite AVG no-proof chapter section as architecture-current prose The What's Next subsection on the joint dispatcher was written as a post-#3687 migration note ("used to compose... as of #3687... halving the grovedb work"), which doesn't carry meaning for readers arriving after the merge. Two issues besides the tense: - The "single grovedb walk" / "halving the per-query grovedb work" framing applies only to the distinct shapes; the aggregate-range branches use grovedb's bounded merk-internal `query_aggregate_count` + `query_aggregate_sum` accumulators (two O(log n) reads), not a single PCPS walk. The original wording overstated the win. - The references to #3687 and the historical pre-refactor composition belong in PR descriptions / commit messages, not in the book chapter that documents the current architecture. Rewrite as present-tense prose describing the three per-mode shapes (Total/PerInValue point-lookup, RangeNoProof distinct PCPS walk, RangeNoProof aggregate merk accumulators) and the residual grovedb opportunity (a combined no-prove `query_aggregate_count_and_sum` primitive) without referencing the migration history. Co-Authored-By: Claude Opus 4.7 (1M context) --- book/src/drive/average-index-examples.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/book/src/drive/average-index-examples.md b/book/src/drive/average-index-examples.md index 9e837349db8..9a2c1a2c8e1 100644 --- a/book/src/drive/average-index-examples.md +++ b/book/src/drive/average-index-examples.md @@ -1703,8 +1703,12 @@ The chapter is grounded in the [`document_average_worst_case`](https://github.co A natural expansion follow-up (out of scope here): a worked example of "exact-precision" averages — for callers that need fractional averages (e.g. `avg = 50.7142857…` rather than `50.99`), the protocol-level approach is to return `(count, sum)` and let the client compute in its preferred numeric format (the chapter notes this in [Numerical Considerations](#numerical-considerations) above; a future expansion could walk through the fixed-point vs. floating-point trade-offs). -### No-proof path: single-walk joint dispatch +### No-proof path: joint count-and-sum dispatch -Both the prove path (above) and the no-proof path now read `(count, sum)` from each visited count-sum-bearing element in a **single** grovedb walk. The no-proof path used to compose parallel count + sum sub-requests and zip the responses; that worked under a shared read transaction (atomic) but did twice the grovedb work strictly necessary. As of [#3687](https://github.com/dashpay/platform/issues/3687) the joint dispatcher at `crate::query::drive_document_count_and_sum_query` walks PCPS / CountSumTree elements once per AVG no-prove query and folds `(count, sum)` in Rust — halving the per-query grovedb work. +The no-proof AVG path lives in `crate::query::drive_document_count_and_sum_query`. It consumes the same `DocumentAverageRequest` the prove path uses and resolves routing through sum's versioned mode-detection table, so the `(where_clauses × mode)` → executor mapping has a single source of truth shared with the sum and count surfaces. The dispatcher splits on the resolved mode: -One forward-looking optimization remains open in grovedb: a no-prove engine-side `query_aggregate_count_and_sum` accumulator (the proof-side analog `AggregateCountAndSumOnRange` already exists and is what Q5 / Q7 above use). Today's no-prove flat-summed branch walks all matched PCPS elements and folds in Rust; an engine-side accumulator could collapse that fold the way `query_aggregate_sum` collapses the sum-side equivalent. Not a blocker — the Rust-side fold is correctness-equivalent and the per-element count-sum decode is cheap — but it would close the residual perf gap between the no-prove and prove paths. +- **`Total` / `PerInValue`** (no-range Equal/`In` on a `summable + countable` index) walks the point-lookup path query and decodes `(count, sum)` from each visited `CountSumTree` terminator in one call via `Element::count_sum_value_or_default()`. One grovedb call per `In` branch, both metrics together. +- **`RangeNoProof` distinct shapes** (`GroupByRange` / `GroupByCompound` + range on a `rangeAverageable` index) walk `ProvableCountProvableSumTree` terminators once via the same `distinct_sum_path_query` builder the sum surface uses, emitting one `(count, sum)` per distinct in-range key — strictly better than the count + sum surfaces' parallel walks because both metrics come from each visited element. +- **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + range) call grovedb's merk-internal accumulators directly: `query_aggregate_count` against the count path query AND `query_aggregate_sum` against the sum path query, under a shared read transaction for snapshot consistency. Two O(log n) merk reads regardless of how many documents the range matches — keeping the public DAPI endpoint bounded against amplification. + +The reason for the two-shape split on `RangeNoProof`: grovedb exposes `query_aggregate_count` and `query_aggregate_sum` as bounded no-prove accumulators (the proof-side analog `AggregateCountAndSumOnRange` is what Q5 / Q7 above use), but no combined no-prove primitive yet. A future grovedb addition could collapse the two aggregate calls into a single merk-internal `query_aggregate_count_and_sum` accumulator, closing the residual gap with the prove path's combined `AggregateCountAndSumOnRange` walk. Not a blocker — two bounded accumulator reads is the same cost class as the surfaces it composes — but it would simplify the dispatcher's aggregate branch into a one-call read. From f9c70f30198bb1565f378c4feb82be9ed3dc7a4a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:03:00 +0700 Subject: [PATCH 06/12] docs(drive): refresh joint count+sum module/dispatcher docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module docstring at `drive_document_count_and_sum_query/mod.rs` and the dispatcher's docstring at `drive_dispatcher.rs` still described the pre-fix behavior — "the range-no-proof flat summed branches walk PCPS elements via grove_get_raw_path_query and fold in Rust" and "one grovedb walk per AVG no-prove query (halving the work)". Both claims were superseded by the P1 fix in 0f6512ee63: the aggregate range branches now use `query_aggregate_count` + `query_aggregate_sum` merk-internal accumulators, and only the distinct shapes do a single PCPS walk. Rewrite both docstrings as present-tense descriptions of the per-shape execution contract (point-lookup walk for Total/PerInValue, single PCPS walk for distinct range, two bounded accumulator reads for aggregate range — with compound In+range per-In fan-out). Drop the migration-flavored "halves the work" framing; it overstated the win on aggregate range and was a changelog snippet that didn't carry meaning post-merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 50 ++++++------ .../drive_document_count_and_sum_query/mod.rs | 79 +++++++++++-------- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index 80f79dfc539..43fd2e37085 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -1,36 +1,38 @@ //! Joint count-and-sum dispatcher entry point for the AVG no-prove //! path. //! -//! Resolves [issue #3687](https://github.com/dashpay/platform/issues/3687): -//! consumes a [`DocumentAverageRequest`] with `prove = false` and -//! routes it through one of three joint per-mode executors -//! (`Total` / `PerInValue` / `RangeNoProof`), each of which walks -//! grovedb once and reads `(count, sum)` from every visited -//! count-sum-bearing element. The dispatcher delegates routing to -//! sum's versioned mode-detection table — see [the routing +//! Consumes a [`DocumentAverageRequest`] with `prove = false` and +//! dispatches to one of three per-mode executors (`Total` / +//! `PerInValue` / `RangeNoProof`) based on sum's versioned mode- +//! detection table — see [the routing //! table](crate::query::drive_document_sum_query::mode_detection) for -//! the contract. +//! the per-shape contract. //! -//! # Perf characteristic +//! # Per-shape cost //! -//! One grovedb walk per AVG no-prove query (compared to the pre-#3687 -//! composition of two parallel walks zipped at the dispatcher). The -//! `PerInValue` and compound-aggregate range branches still issue -//! multiple per-In sub-reads, but each of those is a single grovedb -//! call yielding `(count, sum)` together rather than the two parallel -//! calls the composition did before. +//! - `Total` / `PerInValue`: point-lookup walk against the index; +//! one grovedb read per In branch yielding `(count, sum)` together +//! via [`grovedb::Element::count_sum_value_or_default`]. +//! - `RangeNoProof` distinct (`GroupByRange` / `GroupByCompound` + +//! range): one grovedb walk against the +//! `ProvableCountProvableSumTree` terminators of +//! `distinct_sum_path_query`, bounded by the request's `limit` +//! (default `drive_config.default_query_limit`, capped at +//! `drive_config.max_query_limit`). +//! - `RangeNoProof` aggregate (`Aggregate` / `GroupByIn` + range): +//! grovedb's merk-internal `query_aggregate_count` + +//! `query_aggregate_sum` accumulators (each O(log n)) under a +//! shared read transaction. Compound `In + range` per-In fans out +//! (≤100 branches × 2 accumulator calls). //! //! # Atomicity //! -//! - `Total` and the distinct `RangeNoProof` branch issue exactly one -//! grovedb read each; atomicity is inherent (one read sees one -//! snapshot). -//! - The `PerInValue` branch and (under the unified walk) the -//! compound-aggregate `RangeNoProof` branch issue multiple per-In -//! reads. Their executors open a short-lived shared read transaction -//! internally when `transaction.is_none()` so the per-In reads see a -//! consistent snapshot. The previous AVG dispatcher's shared-tx -//! plumbing moved into those executors. +//! Executors that issue multiple grovedb reads (`PerInValue`, the +//! aggregate `RangeNoProof` branch, and its compound `In + range` +//! per-In fan-out) open a short-lived shared read transaction +//! internally when `transaction.is_none()` so the sub-reads see a +//! consistent grovedb snapshot. Executors that issue exactly one +//! read get atomicity for free. use super::super::drive_document_average_query::{ AverageMode, DocumentAverageRequest, DocumentAverageResponse, diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs index 9887d844074..97f28496e24 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs @@ -1,15 +1,35 @@ //! Joint count-and-sum executor surface for the AVG no-prove path. //! -//! Resolves [issue #3687](https://github.com/dashpay/platform/issues/3687): -//! the AVG dispatcher previously composed `DocumentCountRequest + -//! DocumentSumRequest` on the no-prove path and zipped the two -//! responses, which double-walked grovedb for every AVG query. -//! -//! This module exposes a single [`Drive::execute_document_count_and_sum_request`] -//! entry that consumes the same [`DocumentAverageRequest`] / -//! [`DocumentAverageResponse`] pair used by the prove path and reads -//! both metrics from each visited PCPS / CountSumTree element in one -//! grovedb walk via [`Element::count_sum_value_or_default`]. +//! [`Drive::execute_document_count_and_sum_request`] consumes the same +//! [`DocumentAverageRequest`] / [`DocumentAverageResponse`] pair used +//! by the prove path and dispatches to one of three per-mode executors +//! depending on the resolved [`DocumentSumMode`]. +//! +//! ## Per-shape execution +//! +//! - **`Total` / `PerInValue`** (empty-where, or Equal/`In` on a +//! `summable + countable` index): point-lookup walk against the +//! index, decoding `(count, sum)` from each visited `CountSumTree` / +//! `ProvableCountSumTree` terminator in one call via +//! [`Element::count_sum_value_or_default`]. One grovedb read per +//! In branch yields both metrics together. +//! - **`RangeNoProof` distinct shapes** (`GroupByRange` / +//! `GroupByCompound` + range on a `rangeAverageable` index): one +//! grovedb walk against +//! [`crate::query::drive_document_sum_query::DriveDocumentSumQuery::distinct_sum_path_query`]'s +//! `ProvableCountProvableSumTree` terminators, emitting one +//! `(count, sum)` per visited distinct in-range key. Bounded by the +//! request's `limit` (default falls back to +//! `drive_config.default_query_limit`, explicit limits are clamped to +//! `drive_config.max_query_limit`). +//! - **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + +//! range): grovedb's merk-internal accumulators — +//! `query_aggregate_count` against the count path query AND +//! `query_aggregate_sum` against the sum path query — under a shared +//! read transaction. Two O(log n) merk-internal reads regardless of +//! how many documents the range matches. Compound `In + range` per-In +//! fans out (≤100 branches per the `In::in_values()` validator cap) +//! and issues one count + one sum accumulator call per branch. //! //! ## Routing //! @@ -17,35 +37,24 @@ //! ([`crate::query::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs`]) //! — the routing decision is consensus-relevant and identical for //! count, sum, and joint count+sum on the no-prove path, so forking -//! the table here would invite the same drift bug PR #3661 caught -//! (count's `GroupByCompound` row had diverged from sum's). A trivial +//! the table here would invite the drift bug PR #3661 caught (count's +//! `GroupByCompound` row had diverged from sum's). A trivial //! [`AverageMode`] → [`SumMode`] adapter feeds sum's mode detector; //! `prove = false` is passed unconditionally because the prove path //! never enters this module. //! -//! ## Perf characteristic -//! -//! On the no-prove path this halves the grovedb work per AVG query -//! versus the pre-#3687 composition: one walk yielding `(count, sum)` -//! per visited element instead of two parallel walks zipped at the -//! dispatcher layer. -//! -//! ## A note on the engine-side combined primitive -//! -//! grovedb's pinned rev exposes `query_aggregate_sum` and -//! `query_aggregate_count` as no-prove engine-side accumulators -//! (their proof-side analogs are `AggregateSumOnRange` / -//! `AggregateCountOnRange` / the combined `AggregateCountAndSumOnRange` -//! used by the prove path). There is no engine-side no-prove -//! `query_aggregate_count_and_sum` primitive yet. As a result the -//! range-no-proof "flat summed" branches here walk PCPS elements via -//! `grove_get_raw_path_query` and fold in Rust — see -//! [`executors::range_no_proof`] for the implementation note. This is -//! still one grovedb walk per query (versus sum + count parallel walks -//! before), just not the further reduction of "one merk-internal -//! accumulator call" the prove path benefits from. A future optional -//! grovedb optimization could collapse the Rust-side fold into an -//! engine-side accumulator; #3687 captures the follow-up. +//! ## Engine-side primitive note +//! +//! grovedb exposes `query_aggregate_sum` and `query_aggregate_count` +//! as no-prove engine-side accumulators (their proof-side analogs are +//! `AggregateSumOnRange` / `AggregateCountOnRange`, plus the combined +//! `AggregateCountAndSumOnRange` the prove path uses). The aggregate +//! `RangeNoProof` branch issues both no-prove accumulators in +//! parallel because no combined no-prove primitive exists. A future +//! `query_aggregate_count_and_sum` grovedb addition could collapse +//! those two calls into a single merk-internal read, but the current +//! two-accumulator shape is already in the same O(log n) cost class +//! and bounded against DAPI amplification. #[cfg(feature = "server")] pub mod drive_dispatcher; From a4c70da9cdc32fde04eccfe68678d246656f137e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:04:45 +0700 Subject: [PATCH 07/12] docs(drive): clarify joint dispatch chapter section per CodeRabbit nits - `rangeAverageable` is real DPP syntactic sugar but never defined inline in this chapter (the grades-contract example uses the longhand `rangeCountable: true + rangeSummable: true`). Spell out the index requirement explicitly and note `rangeAverageable: true` as the shorthand form, so readers searching the chapter for either spelling find a match. - Backtick `Equal` for parity with `` `In` `` in the same phrase. --- book/src/drive/average-index-examples.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/book/src/drive/average-index-examples.md b/book/src/drive/average-index-examples.md index 9a2c1a2c8e1..6ca7a687d32 100644 --- a/book/src/drive/average-index-examples.md +++ b/book/src/drive/average-index-examples.md @@ -1707,8 +1707,8 @@ A natural expansion follow-up (out of scope here): a worked example of "exact-pr The no-proof AVG path lives in `crate::query::drive_document_count_and_sum_query`. It consumes the same `DocumentAverageRequest` the prove path uses and resolves routing through sum's versioned mode-detection table, so the `(where_clauses × mode)` → executor mapping has a single source of truth shared with the sum and count surfaces. The dispatcher splits on the resolved mode: -- **`Total` / `PerInValue`** (no-range Equal/`In` on a `summable + countable` index) walks the point-lookup path query and decodes `(count, sum)` from each visited `CountSumTree` terminator in one call via `Element::count_sum_value_or_default()`. One grovedb call per `In` branch, both metrics together. -- **`RangeNoProof` distinct shapes** (`GroupByRange` / `GroupByCompound` + range on a `rangeAverageable` index) walk `ProvableCountProvableSumTree` terminators once via the same `distinct_sum_path_query` builder the sum surface uses, emitting one `(count, sum)` per distinct in-range key — strictly better than the count + sum surfaces' parallel walks because both metrics come from each visited element. +- **`Total` / `PerInValue`** (no-range `Equal`/`In` on a `summable + countable` index) walks the point-lookup path query and decodes `(count, sum)` from each visited `CountSumTree` terminator in one call via `Element::count_sum_value_or_default()`. One grovedb call per `In` branch, both metrics together. +- **`RangeNoProof` distinct shapes** (`GroupByRange` / `GroupByCompound` + range on an index that declares BOTH `rangeCountable: true` AND `rangeSummable: true` — DPP exposes `rangeAverageable: true` as shorthand for the pair) walk `ProvableCountProvableSumTree` terminators once via the same `distinct_sum_path_query` builder the sum surface uses, emitting one `(count, sum)` per distinct in-range key — strictly better than the count + sum surfaces' parallel walks because both metrics come from each visited element. - **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + range) call grovedb's merk-internal accumulators directly: `query_aggregate_count` against the count path query AND `query_aggregate_sum` against the sum path query, under a shared read transaction for snapshot consistency. Two O(log n) merk reads regardless of how many documents the range matches — keeping the public DAPI endpoint bounded against amplification. The reason for the two-shape split on `RangeNoProof`: grovedb exposes `query_aggregate_count` and `query_aggregate_sum` as bounded no-prove accumulators (the proof-side analog `AggregateCountAndSumOnRange` is what Q5 / Q7 above use), but no combined no-prove primitive yet. A future grovedb addition could collapse the two aggregate calls into a single merk-internal `query_aggregate_count_and_sum` accumulator, closing the residual gap with the prove path's combined `AggregateCountAndSumOnRange` walk. Not a blocker — two bounded accumulator reads is the same cost class as the surfaces it composes — but it would simplify the dispatcher's aggregate branch into a one-call read. From 8dbe4a156ef2238609f0a1ef5b627115fcf83e4e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:21:53 +0700 Subject: [PATCH 08/12] refactor(drive): tighten joint AVG dispatcher per PR #3690 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three thepastaclaw nits on the joint count+sum dispatcher. PerInValue limit fallback. The arm computed `request.limit.map(|l| l.min(max_query_limit))`, leaving the limit as `None` when the caller didn't supply one — neither matching `DocumentAverageRequest::limit`'s documented no-proof contract (unset → `default_query_limit`) nor count's PerInValue policy (hard-coded `MAX_LIMIT_AS_FAILSAFE`). Honor the AVG contract: fall back to `default_query_limit`, clamp to `max_query_limit`. Updated the inline comment to match. New regression test `per_in_value_avg_no_proof_defaults_limit_to_operator_default_query_limit` pins the no-limit-set case. Unreachable u16::try_from. The RangeNoProof arm computed `effective_limit.unwrap_or(default_query_limit as u32).min(max_query_limit as u32)` where both `default_query_limit` and `max_query_limit` are u16 in `DriveConfig`, so the result is always ≤ u16::MAX. The `u16::try_from` + `QuerySyntaxError::Unsupported` branch could not fire — dead code in a consensus-adjacent dispatcher. Collapse to `as u16`. Unused `carrier_outer_limit` field. Both arms constructed `RangeSumOptions { carrier_outer_limit: None, .. }` but the joint executors only read `left_to_right` and (for RangeNoProof) `return_distinct_sums_in_range`. Carrying an always-None field was misleading — a future maintainer could reasonably assume it bounded the walk. Drop `RangeSumOptions` from the joint executor signatures and pass the two booleans directly; `limit` continues to flow as an explicit argument. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 107 ++++++++++++++++++ .../drive_dispatcher.rs | 69 +++++------ .../executors/per_in_value.rs | 27 +++-- .../executors/range_no_proof.rs | 14 +-- 4 files changed, 156 insertions(+), 61 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 76daa2c0b55..639e38323c0 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -2164,4 +2164,111 @@ mod tests { other => panic!("expected Aggregate, got {:?}", other), } } + + /// `PerInValue` no-proof AVG with `limit = None` must default to + /// `drive_config.default_query_limit` per + /// `DocumentAverageRequest::limit`'s documented contract. + /// Regression test paired with the explicit-limit case above; pins + /// the no-proof contract parity reviewers flagged after the + /// initial PerInValue fix landed. + #[test] + fn per_in_value_avg_no_proof_defaults_limit_to_operator_default_query_limit() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Same `summable + countable` `byColor` index as + // `per_in_value_avg_no_proof_honors_explicit_limit`, but with + // `default_query_limit = 2` and `limit = None` on the request + // — the dispatcher must fall back to the operator's runtime + // default and truncate the per-In entry list to 2. + let factory = DataContractFactory::new(PROTOCOL_VERSION_V12).expect("create factory"); + let document_schema = platform_value!({ + "type": "object", + "properties": { + "color": {"type": "string", "position": 0, "maxLength": 32}, + "amount": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000}, + }, + "required": ["color", "amount"], + "indices": [{ + "name": "byColor", + "properties": [{"color": "asc"}], + "summable": "amount", + "countable": "countable", + }], + "additionalProperties": false, + }); + let schemas = platform_value!({ "widget": document_schema }); + let data_contract = factory + .create_with_value_config( + dpp::tests::utils::generate_random_identifier_struct(), + 0, + schemas, + None, + None, + ) + .expect("create data contract") + .data_contract_owned(); + drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("apply contract"); + + for (i, (color, amount)) in [("red", 5u64), ("green", 7), ("blue", 2), ("yellow", 4)] + .iter() + .enumerate() + { + insert_widget(&drive, &data_contract, i, color, *amount); + } + + let document_type = data_contract + .document_type_for_name("widget") + .expect("widget"); + let drive_config = DriveConfig { + default_query_limit: 2, + ..Default::default() + }; + + let color_in = WhereClause { + field: "color".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Text("red".to_string()), + Value::Text("green".to_string()), + Value::Text("blue".to_string()), + Value::Text("yellow".to_string()), + ]), + }; + let request = DocumentAverageRequest { + contract: &data_contract, + document_type, + sum_property: "amount".to_string(), + where_clauses: vec![color_in], + order_clauses: Vec::new(), + mode: AverageMode::GroupByIn, + limit: None, + prove: false, + drive_config: &drive_config, + }; + + let response = drive + .execute_document_average_request(request, None, platform_version) + .expect("dispatcher should succeed"); + let entries = match response { + DocumentAverageResponse::Entries(e) => e, + other => panic!("expected Entries, got {:?}", other), + }; + assert_eq!( + entries.len(), + 2, + "PerInValue AVG no-proof with `limit = None` must default to \ + `drive_config.default_query_limit` (= 2 here) and truncate the \ + per-In entry list; got {entries:?}" + ); + } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index 43fd2e37085..99f4f825ae7 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -39,7 +39,7 @@ use super::super::drive_document_average_query::{ }; use super::super::drive_document_count_query::drive_dispatcher::validate_and_canonicalize_where_clauses; use super::super::drive_document_sum_query::mode_detection::detect_sum_mode_from_inputs; -use super::super::drive_document_sum_query::{DocumentSumMode, RangeSumOptions, SumMode}; +use super::super::drive_document_sum_query::{DocumentSumMode, SumMode}; use crate::drive::Drive; use crate::error::Error; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -151,31 +151,29 @@ impl Drive { platform_version, ), DocumentSumMode::PerInValue => { - let options = RangeSumOptions { - return_distinct_sums_in_range: false, - carrier_outer_limit: None, - left_to_right: order_by_ascending, - }; // PerInValue's per-In fan-out is bounded by the - // `In::in_values()` 100-cap, so the executor's worst- - // case backlog is small — but the caller's `limit` - // still applies to the entry list returned (mirroring - // count's no-proof per-In policy at - // `execute_document_count_per_in_value_no_proof`). On - // the no-proof path `limit > max_query_limit` clamps - // to `max_query_limit`; an unset `limit` is `None` - // (no truncation beyond the 100-cap). + // `In::in_values()` 100-cap. The caller's `limit` + // applies to the returned entry list per + // [`DocumentAverageRequest::limit`]'s documented + // no-proof contract: unset → fall back to + // `drive_config.default_query_limit`, explicit > max + // clamps to `drive_config.max_query_limit`. Count's + // PerInValue arm hard-codes `MAX_LIMIT_AS_FAILSAFE` + // (which contradicts its own documented contract); + // the joint dispatcher honors the AVG contract + // instead. let per_in_limit = request .limit - .map(|l| l.min(request.drive_config.max_query_limit as u32)); + .unwrap_or(request.drive_config.default_query_limit as u32) + .min(request.drive_config.max_query_limit as u32); self.execute_document_count_and_sum_per_in_value_no_proof( contract_id, request.document_type, document_type_name, where_clauses, sum_property, - options, - per_in_limit, + order_by_ascending, + per_in_limit as u16, transaction, platform_version, ) @@ -184,55 +182,42 @@ impl Drive { // Distinct flag set for the GroupByRange / // GroupByCompound shapes (per-distinct-value entries); // cleared for the Aggregate / GroupByIn shapes (single - // folded pair). Mirrors sum's dispatcher. + // folded pair). let return_distinct = matches!( request.mode, AverageMode::GroupByRange | AverageMode::GroupByCompound ); - let options = RangeSumOptions { - return_distinct_sums_in_range: return_distinct, - carrier_outer_limit: None, - left_to_right: order_by_ascending, - }; // Limit applies only to the distinct branches — the // aggregate branches return a single collapsed pair // bounded by grovedb's merk-internal accumulators // (`query_aggregate_count` / `query_aggregate_sum`) // regardless of how many documents match. For distinct - // mirror count's no-proof distinct policy: fall back to + // shapes, follow [`DocumentAverageRequest::limit`]'s + // documented no-proof contract: fall back to // `drive_config.default_query_limit` when unset, clamp - // to `drive_config.max_query_limit` when over. Mirrors - // [`DocumentAverageRequest::limit`]'s documented - // no-proof contract. - let effective_limit = if return_distinct { + // to `drive_config.max_query_limit` when over. Both + // `default_query_limit` and `max_query_limit` are + // `u16` in `DriveConfig`, so the `min()` keeps the + // result in `u16::MAX` regardless of caller input. + let limit_u16 = if return_distinct { Some( request .limit .unwrap_or(request.drive_config.default_query_limit as u32) - .min(request.drive_config.max_query_limit as u32), + .min(request.drive_config.max_query_limit as u32) + as u16, ) } else { None }; - let limit_u16 = effective_limit - .map(|l| { - u16::try_from(l).map_err(|_| { - Error::Query(crate::error::query::QuerySyntaxError::Unsupported( - format!( - "limit {} exceeds u16::MAX for distinct AVG no-proof walk", - l - ), - )) - }) - }) - .transpose()?; let response = self.execute_document_count_and_sum_range_no_proof( contract_id, request.document_type, document_type_name, where_clauses, sum_property, - options, + return_distinct, + order_by_ascending, limit_u16, transaction, platform_version, diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs index 76c1e3b023f..2e12beea1e5 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -22,7 +22,7 @@ use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; use super::super::super::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; -use super::super::super::drive_document_sum_query::{DriveDocumentSumQuery, RangeSumOptions}; +use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; @@ -39,6 +39,11 @@ impl Drive { /// grovedb read each, then emits per-In-value /// [`AverageEntry { in_key: None, key, count, sum }`] entries. /// + /// `left_to_right` controls the entry-list ordering; `limit` + /// truncates the returned entries (callers compute it from + /// `DocumentAverageRequest::limit` with the documented no-proof + /// default/clamp policy). + /// /// Returns [`DocumentAverageResponse::Entries`]. #[allow(clippy::too_many_arguments)] pub fn execute_document_count_and_sum_per_in_value_no_proof( @@ -48,8 +53,8 @@ impl Drive { document_type_name: String, where_clauses: Vec, sum_property: String, - options: RangeSumOptions, - limit: Option, + left_to_right: bool, + limit: u16, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { @@ -195,17 +200,15 @@ impl Drive { sum: Some(sum), }) .collect(); - if !options.left_to_right { + if !left_to_right { entries.reverse(); } - // Apply caller's `limit` AFTER ordering — same shape as - // count's `execute_document_count_per_in_value_no_proof`. The - // BTreeMap iteration was already ascending; an explicit - // descending order reverses the vec, then the truncate caps - // it at `limit` from the front. - if let Some(l) = limit { - entries.truncate(l as usize); - } + // Apply caller's `limit` AFTER ordering: the BTreeMap + // iteration was already ascending; an explicit descending + // order reverses the vec, then `truncate` caps from the + // front to honor the requested direction. Same shape as + // count's `execute_document_count_per_in_value_no_proof`. + entries.truncate(limit as usize); Ok(DocumentAverageResponse::Entries(entries)) } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index 5c456670bfd..88d1d6fab0c 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -50,7 +50,7 @@ use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; use super::super::super::drive_document_count_query::DriveDocumentCountQuery; use super::super::super::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; -use super::super::super::drive_document_sum_query::{DriveDocumentSumQuery, RangeSumOptions}; +use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; @@ -70,9 +70,8 @@ impl Drive { /// Returns either a one-pair [`DocumentAverageResponse::Aggregate`] /// (flat / compound aggregate shapes) or per-distinct-value /// [`DocumentAverageResponse::Entries`] (`GroupByRange` / - /// `GroupByCompound`) depending on - /// `options.return_distinct_sums_in_range`. The dispatcher sets - /// that flag based on the request's + /// `GroupByCompound`) depending on `return_distinct`. The dispatcher + /// sets that flag based on the request's /// [`super::super::super::drive_document_average_query::AverageMode`]. /// /// `limit` applies only to the distinct branch; the aggregate @@ -86,7 +85,8 @@ impl Drive { document_type_name: String, where_clauses: Vec, sum_property: String, - options: RangeSumOptions, + return_distinct: bool, + left_to_right: bool, limit: Option, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -112,7 +112,7 @@ impl Drive { .iter() .any(|wc| wc.operator == WhereOperator::In); - if !options.return_distinct_sums_in_range { + if !return_distinct { // Aggregate shape: use grovedb's merk-internal bounded // accumulators for `(count, sum)`. Two calls because the // combined no-proof primitive doesn't exist yet, but each @@ -143,7 +143,7 @@ impl Drive { sum_property, }; let path_query = - sum_query.distinct_sum_path_query(limit, options.left_to_right, platform_version)?; + sum_query.distinct_sum_path_query(limit, left_to_right, platform_version)?; let base_path_len = path_query.path.len(); let mut drive_operations = vec![]; From dcc3824d4fe09e84a787bd000c585b197f928584 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:13:24 +0700 Subject: [PATCH 09/12] feat(drive): collapse AVG no-proof aggregate range to single grovedb call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the grovedb pin to 9db515ef (head of dashpay/grovedb#676), which adds the combined no-prove `query_aggregate_count_and_sum` accumulator — a merk-internal `(u128, i128)` walk narrowed to `(u64, i64)` at the entry point, mirroring the proof-side `AggregateCountAndSumOnRange` shape. Rewires `flat_aggregate_count_and_sum` in the joint count+sum executor to issue one combined accumulator call against `aggregate_count_and_sum_path_query` instead of two parallel `query_aggregate_count` + `query_aggregate_sum` calls. Same cost class (O(log n) at the merk layer either way) but halves the round-trip count and removes the need for the shared-tx-across-two-reads plumbing on the flat branch. Compound `In + range` per-In fan-out still issues one accumulator call per branch under a shared read transaction — now one call per branch instead of two. The integration lands ahead of the grovedb PR's review so the platform side can exercise the primitive end-to-end. If the grovedb PR's review surfaces changes, the platform pin re-bumps trivially. Updated docstrings on `drive_document_count_and_sum_query/mod.rs`, `drive_dispatcher.rs`, `executors/range_no_proof.rs`, and the `book/src/drive/average-index-examples.md` chapter section to reflect the single-call aggregate branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 158 +++++------------- book/src/drive/average-index-examples.md | 4 +- packages/rs-dpp/Cargo.toml | 2 +- packages/rs-drive-abci/Cargo.toml | 4 +- packages/rs-drive/Cargo.toml | 12 +- .../drive_dispatcher.rs | 12 +- .../executors/range_no_proof.rs | 80 +++------ .../drive_document_count_and_sum_query/mod.rs | 31 ++-- packages/rs-platform-version/Cargo.toml | 2 +- packages/rs-platform-wallet/Cargo.toml | 2 +- packages/rs-sdk/Cargo.toml | 2 +- 11 files changed, 102 insertions(+), 207 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27a84508ae7..b2712e2e406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,7 +513,7 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -1132,7 +1132,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2259,7 +2259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "axum 0.8.9", "bincode", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "blake3", @@ -2733,7 +2733,7 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "blake3", "grovedb-bulk-append-tree", @@ -2749,7 +2749,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "integer-encoding", "intmap", @@ -2759,7 +2759,7 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "blake3", @@ -2772,7 +2772,7 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "bincode_derive", @@ -2787,7 +2787,7 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "grovedb-costs", "hex", @@ -2799,7 +2799,7 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "bincode_derive", @@ -2825,7 +2825,7 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "blake3", @@ -2836,7 +2836,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "hex", ] @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "bincode", "byteorder", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "grovedb-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "blake3", "grovedb-costs", @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2888,7 +2888,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "hex", "itertools 0.14.0", @@ -2897,7 +2897,7 @@ dependencies = [ [[package]] name = "grovedbg-types" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=ad2492dcdc869a1452b0b10fbed8f9b0de1634c6#ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" +source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" dependencies = [ "serde", "serde_with 3.20.0", @@ -3291,7 +3291,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3542,7 +3542,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4293,7 +4293,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5094,8 +5094,8 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck 0.4.1", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -5116,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -5129,7 +5129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -5259,7 +5259,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5297,9 +5297,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6034,7 +6034,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6093,7 +6093,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6933,7 +6933,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8335,7 +8335,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8420,7 +8420,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -8429,16 +8429,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -8456,31 +8447,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -8489,96 +8463,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" diff --git a/book/src/drive/average-index-examples.md b/book/src/drive/average-index-examples.md index 6ca7a687d32..997ab58b8ab 100644 --- a/book/src/drive/average-index-examples.md +++ b/book/src/drive/average-index-examples.md @@ -1709,6 +1709,6 @@ The no-proof AVG path lives in `crate::query::drive_document_count_and_sum_query - **`Total` / `PerInValue`** (no-range `Equal`/`In` on a `summable + countable` index) walks the point-lookup path query and decodes `(count, sum)` from each visited `CountSumTree` terminator in one call via `Element::count_sum_value_or_default()`. One grovedb call per `In` branch, both metrics together. - **`RangeNoProof` distinct shapes** (`GroupByRange` / `GroupByCompound` + range on an index that declares BOTH `rangeCountable: true` AND `rangeSummable: true` — DPP exposes `rangeAverageable: true` as shorthand for the pair) walk `ProvableCountProvableSumTree` terminators once via the same `distinct_sum_path_query` builder the sum surface uses, emitting one `(count, sum)` per distinct in-range key — strictly better than the count + sum surfaces' parallel walks because both metrics come from each visited element. -- **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + range) call grovedb's merk-internal accumulators directly: `query_aggregate_count` against the count path query AND `query_aggregate_sum` against the sum path query, under a shared read transaction for snapshot consistency. Two O(log n) merk reads regardless of how many documents the range matches — keeping the public DAPI endpoint bounded against amplification. +- **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + range) call grovedb's combined merk-internal accumulator directly: `query_aggregate_count_and_sum` against the PCPS path query, yielding `(u64, i64)` from a single O(log n) traversal. Compound `In + range` per-In fans out (≤100 branches per the `In::in_values()` validator cap) and issues one accumulator call per branch under a shared read transaction. Bounded regardless of how many documents the range matches — keeping the public DAPI endpoint closed against amplification. -The reason for the two-shape split on `RangeNoProof`: grovedb exposes `query_aggregate_count` and `query_aggregate_sum` as bounded no-prove accumulators (the proof-side analog `AggregateCountAndSumOnRange` is what Q5 / Q7 above use), but no combined no-prove primitive yet. A future grovedb addition could collapse the two aggregate calls into a single merk-internal `query_aggregate_count_and_sum` accumulator, closing the residual gap with the prove path's combined `AggregateCountAndSumOnRange` walk. Not a blocker — two bounded accumulator reads is the same cost class as the surfaces it composes — but it would simplify the dispatcher's aggregate branch into a one-call read. +The no-prove combined accumulator (`query_aggregate_count_and_sum`) is the symmetric counterpart of the prove-side `AggregateCountAndSumOnRange` primitive Q5 / Q7 above use. Both sides walk the same PCPS terminator shape with `(u128, i128)` accumulators (narrowing to `(u64, i64)` at the entry point) — the only difference is that the no-prove path returns the pair directly while the prove path emits proof bytes the client verifies via `GroveDb::verify_aggregate_count_and_sum_query`. diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index 2da69615029..25565f2438f 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -71,7 +71,7 @@ strum = { version = "0.26", features = ["derive"] } json-schema-compatibility-validator = { path = '../rs-json-schema-compatibility-validator', optional = true } once_cell = "1.19.0" tracing = { version = "0.1.41" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } [dev-dependencies] tokio = { version = "1.40", features = ["full"] } diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index b71a2fccf04..1c481a63978 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -82,7 +82,7 @@ derive_more = { version = "1.0", features = ["from", "deref", "deref_mut"] } async-trait = "0.1.77" console-subscriber = { version = "0.4", optional = true } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f", optional = true } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } nonempty = "0.11" [dev-dependencies] @@ -103,7 +103,7 @@ dpp = { path = "../rs-dpp", default-features = false, features = [ drive = { path = "../rs-drive", features = ["fixtures-and-mocks"] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } strategy-tests = { path = "../strategy-tests" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", features = ["client"] } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", features = ["client"] } assert_matches = "1.5.0" drive-abci = { path = ".", features = ["testing-config", "mocks"] } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f" } diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index de8b48b7006..eb8fe9b45fb 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -52,12 +52,12 @@ enum-map = { version = "2.0.3", optional = true } intmap = { version = "3.0.1", features = ["serde"], optional = true } chrono = { version = "0.4.35", optional = true } itertools = { version = "0.13", optional = true } -grovedb = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", optional = true, default-features = false } -grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", optional = true } -grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" } -grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", optional = true } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" } -grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" } +grovedb = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true, default-features = false } +grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } +grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } +grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } +grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } [dev-dependencies] criterion = "0.5" diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index 99f4f825ae7..0cece8a6fe2 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -20,10 +20,10 @@ //! (default `drive_config.default_query_limit`, capped at //! `drive_config.max_query_limit`). //! - `RangeNoProof` aggregate (`Aggregate` / `GroupByIn` + range): -//! grovedb's merk-internal `query_aggregate_count` + -//! `query_aggregate_sum` accumulators (each O(log n)) under a -//! shared read transaction. Compound `In + range` per-In fans out -//! (≤100 branches × 2 accumulator calls). +//! grovedb's combined `query_aggregate_count_and_sum` accumulator +//! (O(log n), yielding `(u64, i64)` in one traversal). Compound +//! `In + range` per-In fans out (≤100 branches × 1 accumulator +//! call) under a shared read transaction. //! //! # Atomicity //! @@ -189,8 +189,8 @@ impl Drive { ); // Limit applies only to the distinct branches — the // aggregate branches return a single collapsed pair - // bounded by grovedb's merk-internal accumulators - // (`query_aggregate_count` / `query_aggregate_sum`) + // bounded by grovedb's combined merk-internal + // accumulator (`query_aggregate_count_and_sum`) // regardless of how many documents match. For distinct // shapes, follow [`DocumentAverageRequest::limit`]'s // documented no-proof contract: fall back to diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index 88d1d6fab0c..3b6c519214f 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -5,31 +5,19 @@ //! //! ## Aggregate shapes (`Aggregate + range`, `GroupByIn + range`) //! -//! Use grovedb's merk-internal aggregate primitives — -//! `query_aggregate_count` against the index's -//! `aggregate_count_path_query` AND `query_aggregate_sum` against the -//! same index's `aggregate_sum_path_query`. Two O(log n) merk-internal -//! reads, bounded **regardless of how many documents the range -//! matches**. -//! -//! grovedb's pinned rev does not yet expose a combined no-proof -//! `query_aggregate_count_and_sum` primitive (its proof-side analog -//! `AggregateCountAndSumOnRange` exists and is what the AVG prove path -//! uses). A future optional grovedb optimization could collapse these -//! into a single merk-internal accumulator; #3687 captures the -//! follow-up. Crucially, the no-proof path here still uses the -//! engine-side bounded accumulators — it does NOT walk every matched -//! PCPS element to fold `(count, sum)` in Rust. That walk shape would -//! turn a public DAPI endpoint into a request-amplification surface -//! (O(matching range keys) per request); the bounded accumulators close -//! that surface and match the worst-case cost the pre-#3687 composed -//! count + sum dispatchers had. +//! One `grove.query_aggregate_count_and_sum` call against the index's +//! `aggregate_count_and_sum_path_query` — a single merk-internal +//! `(u128, i128)` accumulator (narrowed to `(u64, i64)` at the +//! grovedb entry point) yielding both metrics in one O(log n) +//! traversal. Bounded **regardless of how many documents the range +//! matches**, so the public DAPI surface stays closed against +//! amplification. //! //! For compound `(In + range)` (with `In` on a prefix property) the //! aggregate primitive can't fork through an `In`; the executor //! per-In fans out (≤100 branches per the `In::in_values()` validator -//! cap) and issues one count + one sum aggregate call per branch under -//! a shared read transaction. Worst-case 200 merk-internal reads per +//! cap) and issues one combined accumulator call per branch under a +//! shared read transaction. Worst-case 100 merk-internal reads per //! request, again independent of matched-document count. //! //! ## Distinct shapes (`GroupByRange + range`, `GroupByCompound + range`) @@ -39,16 +27,15 @@ //! `grove_get_raw_path_query` call — the same shape sum's distinct //! branch uses — and decode each via //! [`grovedb::Element::count_sum_value_or_default`] to populate -//! [`AverageEntry`] with both `count` and `sum`. **This** is where the -//! single-walk win lives: one walk yields both axes per visited -//! element instead of two parallel walks zipped post-hoc. The -//! distinct walk is bounded by the request's `limit` (default falls -//! back to `drive_config.default_query_limit`, explicit limits are -//! clamped to `drive_config.max_query_limit`) so the public-endpoint -//! amplification surface stays closed on this path too. +//! [`AverageEntry`] with both `count` and `sum`. One walk yields both +//! axes per visited element instead of two parallel walks zipped +//! post-hoc. The distinct walk is bounded by the request's `limit` +//! (default falls back to `drive_config.default_query_limit`, +//! explicit limits are clamped to `drive_config.max_query_limit`) so +//! the public-endpoint amplification surface stays closed on this +//! path too. use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; -use super::super::super::drive_document_count_query::DriveDocumentCountQuery; use super::super::super::drive_document_sum_query::index_picker::find_range_summable_index_for_where_clauses; use super::super::super::drive_document_sum_query::DriveDocumentSumQuery; use crate::drive::Drive; @@ -337,10 +324,12 @@ impl Drive { } /// Flat (no In on prefix) aggregate count + sum: one - /// `query_aggregate_count` call against the count path query, one - /// `query_aggregate_sum` call against the sum path query, both - /// O(log n) at the merk layer. Shared `transaction` enforces - /// snapshot consistency across the two reads. + /// `query_aggregate_count_and_sum` call against the PCPS path + /// query — a single O(log n) merk-internal accumulator yielding + /// both metrics from one traversal. The combined no-proof + /// primitive landed in grovedb #676; before that this helper had + /// to issue two parallel accumulator calls under a shared read + /// transaction. #[allow(clippy::too_many_arguments)] fn flat_aggregate_count_and_sum( &self, @@ -354,21 +343,6 @@ impl Drive { drive_version: &dpp::version::drive_versions::DriveVersion, platform_version: &PlatformVersion, ) -> Result<(u64, i64), Error> { - let count_query = DriveDocumentCountQuery { - document_type, - contract_id, - document_type_name: document_type_name.clone(), - index, - where_clauses: where_clauses.clone(), - }; - let count_path_query = count_query.aggregate_count_path_query(platform_version)?; - let CostContext { value, cost: _ } = self.grove.query_aggregate_count( - &count_path_query, - transaction, - &drive_version.grove_version, - ); - let count = value.map_err(|e| Error::GroveDB(Box::new(e)))?; - let sum_query = DriveDocumentSumQuery { document_type, contract_id, @@ -377,14 +351,12 @@ impl Drive { where_clauses, sum_property, }; - let sum_path_query = sum_query.aggregate_sum_path_query(platform_version)?; - let CostContext { value, cost: _ } = self.grove.query_aggregate_sum( - &sum_path_query, + let path_query = sum_query.aggregate_count_and_sum_path_query(platform_version)?; + let CostContext { value, cost: _ } = self.grove.query_aggregate_count_and_sum( + &path_query, transaction, &drive_version.grove_version, ); - let sum = value.map_err(|e| Error::GroveDB(Box::new(e)))?; - - Ok((count, sum)) + value.map_err(|e| Error::GroveDB(Box::new(e))) } } diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs index 97f28496e24..78ae8def49d 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/mod.rs @@ -23,13 +23,12 @@ //! `drive_config.default_query_limit`, explicit limits are clamped to //! `drive_config.max_query_limit`). //! - **`RangeNoProof` aggregate shapes** (`Aggregate` / `GroupByIn` + -//! range): grovedb's merk-internal accumulators — -//! `query_aggregate_count` against the count path query AND -//! `query_aggregate_sum` against the sum path query — under a shared -//! read transaction. Two O(log n) merk-internal reads regardless of -//! how many documents the range matches. Compound `In + range` per-In -//! fans out (≤100 branches per the `In::in_values()` validator cap) -//! and issues one count + one sum accumulator call per branch. +//! range): grovedb's combined merk-internal accumulator — +//! `query_aggregate_count_and_sum` against the PCPS path query — +//! yielding `(u64, i64)` from a single O(log n) traversal. Compound +//! `In + range` per-In fans out (≤100 branches per the +//! `In::in_values()` validator cap) and issues one combined +//! accumulator call per branch under a shared read transaction. //! //! ## Routing //! @@ -45,16 +44,14 @@ //! //! ## Engine-side primitive note //! -//! grovedb exposes `query_aggregate_sum` and `query_aggregate_count` -//! as no-prove engine-side accumulators (their proof-side analogs are -//! `AggregateSumOnRange` / `AggregateCountOnRange`, plus the combined -//! `AggregateCountAndSumOnRange` the prove path uses). The aggregate -//! `RangeNoProof` branch issues both no-prove accumulators in -//! parallel because no combined no-prove primitive exists. A future -//! `query_aggregate_count_and_sum` grovedb addition could collapse -//! those two calls into a single merk-internal read, but the current -//! two-accumulator shape is already in the same O(log n) cost class -//! and bounded against DAPI amplification. +//! grovedb exposes `query_aggregate_count_and_sum` as the combined +//! no-prove accumulator (proof-side analog +//! `AggregateCountAndSumOnRange` is what the prove path uses for the +//! same shape). The aggregate `RangeNoProof` branch routes through it +//! directly — one merk-internal `(u128, i128)` walk per request, +//! narrowed to `(u64, i64)` at the grovedb entry. Same cost class +//! and the same shape the prove path uses, just without the proof +//! envelope. #[cfg(feature = "server")] pub mod drive_dispatcher; diff --git a/packages/rs-platform-version/Cargo.toml b/packages/rs-platform-version/Cargo.toml index 17a5a8e90df..c18d5c4eef9 100644 --- a/packages/rs-platform-version/Cargo.toml +++ b/packages/rs-platform-version/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" thiserror = { version = "2.0.12" } bincode = { version = "=2.0.1" } versioned-feature-core = { git = "https://github.com/dashpay/versioned-feature-core", version = "1.0.0" } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6" } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } [features] mock-versions = [] diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 75a11215b2d..87b004ca3aa 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -48,7 +48,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", zeroize = "1" # Shielded pool (optional, behind `shielded` feature) -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } zip32 = { version = "0.2.0", default-features = false, optional = true } [dev-dependencies] diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 19d450ec32d..73cf2009d28 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -18,7 +18,7 @@ drive = { path = "../rs-drive", default-features = false, features = [ ] } drive-proof-verifier = { path = "../rs-drive-proof-verifier", default-features = false } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "ad2492dcdc869a1452b0b10fbed8f9b0de1634c6", features = ["client", "sqlite"], optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", features = ["client", "sqlite"], optional = true } dash-async = { path = "../rs-dash-async" } dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" } From 866b74f50122a3c85f080504b9c963121e6c93c2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:19:48 +0700 Subject: [PATCH 10/12] docs(drive): drop remaining stale "two calls" / "pre-#3687" references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After bumping grovedb to a rev that includes the combined `query_aggregate_count_and_sum` primitive (dcc3824d4f), several docstrings and comments still described the old two-call shape or carried migration-flavored references ("pre-#3687 implementation", "pre-#3690 composition path", "halving the per-query work", "Two calls per branch", "combined no-proof primitive doesn't exist yet"). Sweep them out. Each rewritten as present-tense architecture prose: the aggregate range branch issues one accumulator call per branch; compound In + range per-In fans out to one call per branch under a shared read tx; the joint dispatcher reuses sum's routing table; the top-level AVG dispatcher splits prove/no-prove. No code changes — docs/comments only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../drive_dispatcher.rs | 42 +++++++++---------- .../drive_dispatcher.rs | 26 +++++------- .../executors/per_in_value.rs | 14 +++---- .../executors/range_no_proof.rs | 32 +++++++------- 4 files changed, 49 insertions(+), 65 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 639e38323c0..4810e4ea973 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -14,18 +14,16 @@ //! `(mode, where_clauses)`. The prove path's per-shape rules are //! unchanged. //! -//! ## Why the unified single-walk dispatch +//! ## Joint dispatch //! -//! The previous implementation composed parallel -//! `DocumentCountRequest` + `DocumentSumRequest` calls on the no-prove -//! path under a shared read transaction and zipped the responses. That -//! shape was correct (the shared transaction guaranteed atomicity) -//! but did twice the grovedb work strictly necessary, and required -//! count's and sum's routing tables to stay in lock-step for AVG to -//! compose correctly (PR #3661 caught one drift bug). The single-walk -//! dispatcher in [`crate::query::drive_document_count_and_sum_query`] -//! replaces both concerns: one routing table (sum's), one grovedb -//! walk per AVG no-prove query, halving the per-query work. +//! The no-prove dispatcher at +//! [`crate::query::drive_document_count_and_sum_query`] reads +//! `(count, sum)` together — via grovedb's combined +//! `query_aggregate_count_and_sum` accumulator on the aggregate range +//! branch, and via a single PCPS walk on the distinct-grouped branch. +//! Routing reuses sum's versioned mode-detection table so the +//! `(where_clauses × mode)` → executor decision has a single source +//! of truth shared with the count and sum surfaces. //! //! ## Prove path shapes (unchanged) //! @@ -68,12 +66,10 @@ impl Drive { /// /// Splits prove vs. no-prove at the top level: /// - `prove = true` → routes to - /// [`Self::execute_document_average_prove`], unchanged from the - /// pre-#3687 implementation. + /// [`Self::execute_document_average_prove`]. /// - `prove = false` → routes to /// [`Self::execute_document_count_and_sum_request`], the joint - /// single-walk dispatcher that replaces the previous parallel - /// count + sum composition. + /// dispatcher that reads `(count, sum)` together. pub fn execute_document_average_request( &self, request: DocumentAverageRequest, @@ -854,8 +850,8 @@ mod tests { // AND independently issue separate count + sum requests under // the same transaction. Assert the joint executor's // `(count, sum)` matches the zipped pair from the independent - // calls. This pins parity with the pre-#3687 double-dispatch - // behaviour. + // count + sum surfaces — a cross-check the joint and per-surface + // dispatchers cannot silently disagree. use crate::query::drive_document_average_query::AverageEntry; use crate::query::drive_document_count_query::{ @@ -1869,12 +1865,12 @@ mod tests { } /// AVG no-proof dispatcher must run - /// `validate_and_canonicalize_where_clauses` — same shape contract - /// the pre-#3690 composition path inherited from count's - /// dispatcher. Pin a representative rejection: a duplicate Equal - /// on the same field. Without the validator the executor would - /// either succeed with a silently-collapsed shape or fail downstream - /// with a less precise error. + /// `validate_and_canonicalize_where_clauses` so it shares the same + /// accept/reject contract as the count and document-query + /// surfaces. Pin a representative rejection: a duplicate Equal on + /// the same field. Without the validator the executor would + /// either succeed with a silently-collapsed shape or fail + /// downstream with a less precise error. #[test] fn joint_dispatcher_runs_validate_and_canonicalize_where_clauses() { let drive = setup_drive_with_initial_state_structure(None); diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs index 0cece8a6fe2..ee0eea90811 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/drive_dispatcher.rs @@ -101,14 +101,12 @@ impl Drive { } // Validate + canonicalize the structured `where_clauses` — - // same rejections / canonicalization the regular document- - // query path runs, kept here so the AVG no-prove surface - // keeps the accept/reject contract it had pre-#3690 when the - // composition path ran the validator via count's dispatcher. - // Must run BEFORE `detect_sum_mode_from_inputs` because the - // canonicalizer collapses `[> A, < B]` pairs into a single - // `between*` clause whose presence changes the routing - // decision. See + // same rejections / canonicalization the count and document- + // query surfaces apply, kept here so the AVG no-prove surface + // shares the same accept/reject contract. Must run BEFORE + // `detect_sum_mode_from_inputs` because the canonicalizer + // collapses `[> A, < B]` pairs into a single `between*` + // clause whose presence changes the routing decision. See // [`validate_and_canonicalize_where_clauses`]'s docstring for // the catalog of rejections. let where_clauses = validate_and_canonicalize_where_clauses(request.where_clauses)?; @@ -232,13 +230,11 @@ impl Drive { // that case — see its `execute_range_sum_no_proof` // compound-summed branch). The joint executor returns // `Aggregate { count, sum }` for the !distinct shape - // because it has both metrics on hand; we re-shape into - // `Entries(vec![one_entry])` here when the caller asked - // for a non-Aggregate mode so the wire shape matches - // what an independent sum + count GroupByIn dispatch - // would produce. Without this re-shape, GroupByIn + - // range AVG silently changes wire shape from `Entries` - // (pre-#3687) to `Aggregate`. + // because it has both metrics on hand; we re-shape + // into `Entries(vec![one_entry])` here when the + // caller asked for a non-Aggregate mode so the wire + // shape matches what an independent sum + count + // GroupByIn dispatch would produce. // Strict mode×shape pairing. Every legal combination // is named explicitly; any other pairing surfaces as // `CorruptedCodeExecution` rather than silently diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs index 2e12beea1e5..5a88ea33ecf 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/per_in_value.rs @@ -10,15 +10,11 @@ //! ## Atomicity //! //! The per-In fan-out issues one grovedb read per In branch. When the -//! caller didn't provide a transaction, we open a short-lived shared -//! read transaction internally and reuse it across every per-branch -//! read so the joint executor sees a single grovedb snapshot. This is -//! the same atomicity contract the pre-#3687 AVG dispatcher -//! implemented at the dispatcher layer, moved here because the -//! dispatcher itself now does only a single executor call per AVG -//! request — the multi-read concern is purely an internal property of -//! this executor (and its `range_no_proof` sibling, which has the same -//! per-In fan-out shape for the compound-flat-summed branch). +//! caller didn't provide a transaction the executor opens a short- +//! lived shared read transaction internally and reuses it across every +//! per-branch read so the (count, sum) pair sees a single grovedb +//! snapshot. The compound-aggregate `range_no_proof` sibling does the +//! same on its per-In fan-out branch. use super::super::super::drive_document_average_query::{AverageEntry, DocumentAverageResponse}; use super::super::super::drive_document_sum_query::index_picker::find_summable_index_for_where_clauses; diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index 3b6c519214f..ea7781e0e0a 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -100,12 +100,13 @@ impl Drive { .any(|wc| wc.operator == WhereOperator::In); if !return_distinct { - // Aggregate shape: use grovedb's merk-internal bounded - // accumulators for `(count, sum)`. Two calls because the - // combined no-proof primitive doesn't exist yet, but each - // is O(log n) — strictly bounded regardless of how many - // documents the range matches. This is the same cost class - // the pre-#3687 composed count + sum dispatchers had. + // Aggregate shape: one combined merk-internal accumulator + // call (`query_aggregate_count_and_sum`) yielding + // `(u64, i64)` in O(log n) — strictly bounded regardless + // of how many documents the range matches. Compound + // `In + range` per-In fans out to one accumulator call + // per branch under a shared read transaction (see + // `aggregate_range_count_and_sum` below). return self.aggregate_range_count_and_sum( contract_id, document_type, @@ -189,14 +190,12 @@ impl Drive { } /// Aggregate-range branch: returns `(count, sum)` via grovedb's - /// merk-internal bounded accumulators. Two calls per branch - /// (count + sum); compound `In + range` per-In fans out and sums - /// per-branch totals. - /// - /// All branches share a read transaction (opened internally when - /// the caller didn't supply one) so the count and sum reads see - /// the same grovedb snapshot — same atomicity contract the - /// pre-#3687 dispatcher implemented across its two sub-dispatches. + /// combined `query_aggregate_count_and_sum` accumulator. One call + /// for the flat shape; compound `In + range` per-In fans out and + /// sums per-branch totals (one accumulator call per branch). All + /// branches share a read transaction (opened internally when the + /// caller didn't supply one) so per-In sub-reads see the same + /// grovedb snapshot. #[allow(clippy::too_many_arguments)] fn aggregate_range_count_and_sum( &self, @@ -326,10 +325,7 @@ impl Drive { /// Flat (no In on prefix) aggregate count + sum: one /// `query_aggregate_count_and_sum` call against the PCPS path /// query — a single O(log n) merk-internal accumulator yielding - /// both metrics from one traversal. The combined no-proof - /// primitive landed in grovedb #676; before that this helper had - /// to issue two parallel accumulator calls under a shared read - /// transaction. + /// both metrics from one traversal. #[allow(clippy::too_many_arguments)] fn flat_aggregate_count_and_sum( &self, From 32602eefe518760c072744e972230d42b9e981c3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:47:34 +0700 Subject: [PATCH 11/12] docs(drive): drop stale "count and sum accumulator calls" comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in a thepastaclaw sweep. The shared-read-transaction rationale inside aggregate_range_count_and_sum still framed the snapshot as bridging "the count and sum accumulator calls" — accurate before the grovedb pin bump to 9db515ef, stale after. Each per-In branch now issues a single combined `query_aggregate_count_and_sum` call, so the shared tx only matters for atomicity across per-In branches in the compound shape; the flat path gets atomicity for free from its single read. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../executors/range_no_proof.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs index ea7781e0e0a..70fbbf09ab5 100644 --- a/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs +++ b/packages/rs-drive/src/query/drive_document_count_and_sum_query/executors/range_no_proof.rs @@ -211,10 +211,12 @@ impl Drive { ) -> Result { let drive_version = &platform_version.drive; - // Open a shared read transaction across the count and sum - // accumulator calls (and across per-In branches in the compound - // shape) so they see one snapshot. Read-only; dropped without - // commit at scope end. + // Open a shared read transaction across per-In branches in + // the compound shape so each branch's accumulator call sees + // the same grovedb snapshot. The flat path issues a single + // read and gets atomicity for free; the transaction is + // harmless there. Read-only; dropped without commit at scope + // end. let local_tx; let effective_transaction: TransactionArg = if transaction.is_some() { transaction From 8b829e9a82132b9ea4a77ab589d914525356017f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 11:00:36 +0700 Subject: [PATCH 12/12] chore(drive): bump grovedb pin to develop head (post-#676 merge) dashpay/grovedb#676 (the no-prove `query_aggregate_count_and_sum` primitive) was squash-merged into develop as commit 60f29685. The content is identical to the PR-branch head 9db515ef that the previous commit pinned; just moves to the canonical develop ancestry. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 60 ++++++++++++------------- packages/rs-dpp/Cargo.toml | 2 +- packages/rs-drive-abci/Cargo.toml | 4 +- packages/rs-drive/Cargo.toml | 12 ++--- packages/rs-platform-version/Cargo.toml | 2 +- packages/rs-platform-wallet/Cargo.toml | 2 +- packages/rs-sdk/Cargo.toml | 2 +- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2712e2e406..d4d96883145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,7 +513,7 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -1132,7 +1132,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2259,7 +2259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "axum 0.8.9", "bincode", @@ -2717,7 +2717,7 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "blake3", @@ -2733,7 +2733,7 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "blake3", "grovedb-bulk-append-tree", @@ -2749,7 +2749,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "integer-encoding", "intmap", @@ -2759,7 +2759,7 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "blake3", @@ -2772,7 +2772,7 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "bincode_derive", @@ -2787,7 +2787,7 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "grovedb-costs", "hex", @@ -2799,7 +2799,7 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "bincode_derive", @@ -2825,7 +2825,7 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "blake3", @@ -2836,7 +2836,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "hex", ] @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "bincode", "byteorder", @@ -2860,7 +2860,7 @@ dependencies = [ [[package]] name = "grovedb-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "blake3", "grovedb-costs", @@ -2879,7 +2879,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2888,7 +2888,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "hex", "itertools 0.14.0", @@ -2897,7 +2897,7 @@ dependencies = [ [[package]] name = "grovedbg-types" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=9db515efe59602e536eb75e79308e0cf8f8474be#9db515efe59602e536eb75e79308e0cf8f8474be" +source = "git+https://github.com/dashpay/grovedb?rev=60f29685172653f6007e63d0916bce4633bc23b9#60f29685172653f6007e63d0916bce4633bc23b9" dependencies = [ "serde", "serde_with 3.20.0", @@ -3291,7 +3291,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3542,7 +3542,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4293,7 +4293,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5095,7 +5095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck 0.4.1", - "itertools 0.10.5", + "itertools 0.13.0", "log", "multimap", "petgraph", @@ -5116,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5129,7 +5129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5259,7 +5259,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5297,7 +5297,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -6034,7 +6034,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6093,7 +6093,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6933,7 +6933,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8335,7 +8335,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index 25565f2438f..ebfd571587c 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -71,7 +71,7 @@ strum = { version = "0.26", features = ["derive"] } json-schema-compatibility-validator = { path = '../rs-json-schema-compatibility-validator', optional = true } once_cell = "1.19.0" tracing = { version = "0.1.41" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", optional = true } [dev-dependencies] tokio = { version = "1.40", features = ["full"] } diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 1c481a63978..ea3211d45a0 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -82,7 +82,7 @@ derive_more = { version = "1.0", features = ["from", "deref", "deref_mut"] } async-trait = "0.1.77" console-subscriber = { version = "0.4", optional = true } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f", optional = true } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9" } nonempty = "0.11" [dev-dependencies] @@ -103,7 +103,7 @@ dpp = { path = "../rs-dpp", default-features = false, features = [ drive = { path = "../rs-drive", features = ["fixtures-and-mocks"] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } strategy-tests = { path = "../strategy-tests" } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", features = ["client"] } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", features = ["client"] } assert_matches = "1.5.0" drive-abci = { path = ".", features = ["testing-config", "mocks"] } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f" } diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index eb8fe9b45fb..bd4b74577e6 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -52,12 +52,12 @@ enum-map = { version = "2.0.3", optional = true } intmap = { version = "3.0.1", features = ["serde"], optional = true } chrono = { version = "0.4.35", optional = true } itertools = { version = "0.13", optional = true } -grovedb = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true, default-features = false } -grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } -grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } -grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } -grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } +grovedb = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", optional = true, default-features = false } +grovedb-costs = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", optional = true } +grovedb-path = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9" } +grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", optional = true } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9" } +grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9" } [dev-dependencies] criterion = "0.5" diff --git a/packages/rs-platform-version/Cargo.toml b/packages/rs-platform-version/Cargo.toml index c18d5c4eef9..febc2c1e94e 100644 --- a/packages/rs-platform-version/Cargo.toml +++ b/packages/rs-platform-version/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT" thiserror = { version = "2.0.12" } bincode = { version = "=2.0.1" } versioned-feature-core = { git = "https://github.com/dashpay/versioned-feature-core", version = "1.0.0" } -grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be" } +grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9" } [features] mock-versions = [] diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 87b004ca3aa..846e736e94a 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -48,7 +48,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", zeroize = "1" # Shielded pool (optional, behind `shielded` feature) -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", optional = true } zip32 = { version = "0.2.0", default-features = false, optional = true } [dev-dependencies] diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 73cf2009d28..d31165a6915 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -18,7 +18,7 @@ drive = { path = "../rs-drive", default-features = false, features = [ ] } drive-proof-verifier = { path = "../rs-drive-proof-verifier", default-features = false } -grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9db515efe59602e536eb75e79308e0cf8f8474be", features = ["client", "sqlite"], optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "60f29685172653f6007e63d0916bce4633bc23b9", features = ["client", "sqlite"], optional = true } dash-async = { path = "../rs-dash-async" } dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" }