diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index ed95bf4fb..297b894f5 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -252,7 +252,7 @@ pub use operations::get::{AxisAggregateValue, PathQueryRun}; // never name this type, so widening the gate here to `any(minimal, verify)` // only breaks that cut on an unresolved import. #[cfg(feature = "minimal")] -pub use operations::indexed_tree::IndexedTopKPage; +pub use operations::indexed_tree::{IndexedTopKKeysPage, IndexedTopKPage}; #[cfg(any(feature = "minimal", feature = "verify"))] pub use query::{ aggregate_sum_path_query::AggregateSumPathQuery, AggregateKind, GroveBranchQueryResult, diff --git a/grovedb/src/operations/indexed_tree.rs b/grovedb/src/operations/indexed_tree.rs index 17f6fd3f9..adab52d06 100644 --- a/grovedb/src/operations/indexed_tree.rs +++ b/grovedb/src/operations/indexed_tree.rs @@ -1304,18 +1304,20 @@ impl GroveDb { // already uses in `operations/proof/indexed_axis.rs`. // ----------------------------------------------------------------- - /// One implementation of the `indexed__top_k` shape. See the - /// per-axis wrappers for the public contract. - fn indexed_axis_top_k_generic<'b, B, T>( + /// The ranking half of the `indexed__top_k` shape: the top-`k` + /// `(value, original_key)` pairs straight from the secondary, with no + /// primary read. Both the resolving wrapper and the keys-only wrapper + /// are built on this, so the two can never disagree about the page. + fn indexed_axis_top_k_rows_generic<'b, B, T>( &self, path: SubtreePath<'b, B>, axis: IndexAxis, k: u16, descending: bool, - transaction: TransactionArg, + tx_ref: &Transaction, grove_version: &GroveVersion, decode: impl Fn(&[u8]) -> Option<(T, Vec)>, - ) -> CostResult>, Error> + ) -> CostResult)>, Error> where B: AsRef<[u8]> + 'b, { @@ -1324,34 +1326,93 @@ impl GroveDb { grove_version.grovedb_versions.operations.indexed_axis.read ); let mut cost = OperationCost::default(); - let tx = TxRef::new(&self.db, transaction); - let tx_ref = tx.as_ref(); let secondary_merk = cost_return_on_error!( &mut cost, - self.open_validated_axis_secondary(path.clone(), axis, tx_ref, grove_version) + self.open_validated_axis_secondary(path, axis, tx_ref, grove_version) ); + collect_top_k_via_iterator(&secondary_merk, axis, k, descending, &decode).add_cost(cost) + } + + /// One implementation of the `indexed__top_k` shape. See the + /// per-axis wrappers for the public contract. + fn indexed_axis_top_k_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + k: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult>, Error> + where + B: AsRef<[u8]> + 'b, + { + let mut cost = OperationCost::default(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); let rows = cost_return_on_error!( &mut cost, - collect_top_k_via_iterator(&secondary_merk, axis, k, descending, &decode) + self.indexed_axis_top_k_rows_generic( + path.clone(), + axis, + k, + descending, + tx_ref, + grove_version, + decode, + ) ); - drop(secondary_merk); resolve_axis_entries(self, path, rows, tx_ref, grove_version).add_cost(cost) } + /// Keys-only `indexed__top_k`: the ranking pairs without + /// resolving any primary value. See + /// [`Self::indexed_count_top_k_keys`] for why this exists. + fn indexed_axis_top_k_keys_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + k: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + { + let tx = TxRef::new(&self.db, transaction); + self.indexed_axis_top_k_rows_generic( + path, + axis, + k, + descending, + tx.as_ref(), + grove_version, + decode, + ) + } + /// One implementation of the `indexed__top_k_paginated` shape. - fn indexed_axis_top_k_paginated_generic<'b, B, T>( + /// The ranking half of the `indexed__top_k_paginated` shape: + /// the page's `(value, original_key)` pairs plus the skipped count, + /// produced entirely inside the pinned secondary view, with no primary + /// read. Both the resolving wrapper and the keys-only wrapper are built + /// on this. + fn indexed_axis_top_k_paginated_rows_generic<'b, B, T>( &self, path: SubtreePath<'b, B>, axis: IndexAxis, k: u16, offset: u64, descending: bool, - transaction: TransactionArg, + tx_ref: &Transaction, grove_version: &GroveVersion, decode: impl Fn(&[u8]) -> Option<(T, Vec)>, - ) -> CostResult, Error> + ) -> CostResult<(Vec<(T, Vec)>, u64), Error> where B: AsRef<[u8]> + 'b, { @@ -1360,8 +1421,6 @@ impl GroveDb { grove_version.grovedb_versions.operations.indexed_axis.read ); let mut cost = OperationCost::default(); - let tx = TxRef::new(&self.db, transaction); - let tx_ref = tx.as_ref(); let secondary_merk = cost_return_on_error!( &mut cost, @@ -1380,13 +1439,7 @@ impl GroveDb { &mut cost, collect_top_k_via_iterator(&secondary_merk, axis, k, descending, &decode) ); - drop(secondary_merk); - return resolve_axis_entries(self, path, rows, tx_ref, grove_version) - .map_ok(|entries| IndexedTopKPage { - entries, - skipped: 0, - }) - .add_cost(cost); + return Ok((rows, 0)).wrap_with_cost(cost); } // The open above serves validation (path shape, element variant, // axis compatibility) and the offset-0 fast path only. For the @@ -1415,9 +1468,6 @@ impl GroveDb { }; let parent_prefix = RocksDbStorage::build_prefix(parent_path.clone()).unwrap_add_cost(&mut cost); - // Kept for resolving the page's primary values once the counted - // descent has produced its keys. - let path_for_resolution = path.clone(); let primary_prefix = RocksDbStorage::build_prefix(path).unwrap_add_cost(&mut cost); let secondary_prefix = RocksDbStorage::secondary_prefix_for(&primary_prefix, axis.tag()) .unwrap_add_cost(&mut cost); @@ -1477,11 +1527,78 @@ impl GroveDb { } } } - resolve_axis_entries(self, path_for_resolution, rows, tx_ref, grove_version) + Ok((rows, skipped)).wrap_with_cost(cost) + } + + /// One implementation of the `indexed__top_k_paginated` shape: + /// the ranking page with every entry's primary value resolved under + /// the same transaction the secondary was read under. + fn indexed_axis_top_k_paginated_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult, Error> + where + B: AsRef<[u8]> + 'b, + { + let mut cost = OperationCost::default(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); + let (rows, skipped) = cost_return_on_error!( + &mut cost, + self.indexed_axis_top_k_paginated_rows_generic( + path.clone(), + axis, + k, + offset, + descending, + tx_ref, + grove_version, + decode, + ) + ); + resolve_axis_entries(self, path, rows, tx_ref, grove_version) .map_ok(|entries| IndexedTopKPage { entries, skipped }) .add_cost(cost) } + /// Keys-only `indexed__top_k_paginated`: the ranking page and + /// skipped count without resolving any primary value. See + /// [`Self::indexed_count_top_k_paginated_keys`] for why this exists. + fn indexed_axis_top_k_paginated_keys_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult, Error> + where + B: AsRef<[u8]> + 'b, + { + let tx = TxRef::new(&self.db, transaction); + self.indexed_axis_top_k_paginated_rows_generic( + path, + axis, + k, + offset, + descending, + tx.as_ref(), + grove_version, + decode, + ) + .map_ok(|(entries, skipped)| IndexedTopKKeysPage { entries, skipped }) + } + /// One implementation of the `indexed__range` shape. The /// per-axis wrapper resolves `lo`/`hi` into the secondary keyspace /// bounds `lo_bytes` (inclusive lower) and `upper_bytes` (exclusive @@ -1489,7 +1606,11 @@ impl GroveDb { /// maximum) and passes them here. `decode` returns the typed value /// per matched key. #[allow(clippy::too_many_arguments)] - fn indexed_axis_range_generic<'b, B, T>( + /// The ranking half of the `indexed__range` shape: the in-range + /// `(value, original_key)` pairs straight from the secondary, with no + /// primary read. Both the resolving wrapper and the keys-only wrapper + /// are built on this. + fn indexed_axis_range_rows_generic<'b, B, T>( &self, path: SubtreePath<'b, B>, axis: IndexAxis, @@ -1497,10 +1618,10 @@ impl GroveDb { upper_bytes: Option>, descending: bool, limit: u16, - transaction: TransactionArg, + tx_ref: &Transaction, grove_version: &GroveVersion, decode: impl Fn(&[u8]) -> Option<(T, Vec)>, - ) -> CostResult>, Error> + ) -> CostResult)>, Error> where B: AsRef<[u8]> + 'b, { @@ -1509,12 +1630,10 @@ impl GroveDb { grove_version.grovedb_versions.operations.indexed_axis.read ); let mut cost = OperationCost::default(); - let tx = TxRef::new(&self.db, transaction); - let tx_ref = tx.as_ref(); let secondary_merk = cost_return_on_error!( &mut cost, - self.open_validated_axis_secondary(path.clone(), axis, tx_ref, grove_version) + self.open_validated_axis_secondary(path, axis, tx_ref, grove_version) ); let mut q = Query::new(); @@ -1543,7 +1662,77 @@ impl GroveDb { drop(iter); drop(secondary_merk); - resolve_axis_entries(self, path, results, tx_ref, grove_version).add_cost(cost) + Ok(results).wrap_with_cost(cost) + } + + /// One implementation of the `indexed__range` shape: the + /// in-range entries with every primary value resolved under the same + /// transaction the secondary was read under. + fn indexed_axis_range_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + lo_bytes: Vec, + upper_bytes: Option>, + descending: bool, + limit: u16, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult>, Error> + where + B: AsRef<[u8]> + 'b, + { + let mut cost = OperationCost::default(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); + let rows = cost_return_on_error!( + &mut cost, + self.indexed_axis_range_rows_generic( + path.clone(), + axis, + lo_bytes, + upper_bytes, + descending, + limit, + tx_ref, + grove_version, + decode, + ) + ); + resolve_axis_entries(self, path, rows, tx_ref, grove_version).add_cost(cost) + } + + /// Keys-only `indexed__range`: the in-range ranking pairs + /// without resolving any primary value. See + /// [`Self::indexed_count_range_keys`] for why this exists. + fn indexed_axis_range_keys_generic<'b, B, T>( + &self, + path: SubtreePath<'b, B>, + axis: IndexAxis, + lo_bytes: Vec, + upper_bytes: Option>, + descending: bool, + limit: u16, + transaction: TransactionArg, + grove_version: &GroveVersion, + decode: impl Fn(&[u8]) -> Option<(T, Vec)>, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + { + let tx = TxRef::new(&self.db, transaction); + self.indexed_axis_range_rows_generic( + path, + axis, + lo_bytes, + upper_bytes, + descending, + limit, + tx.as_ref(), + grove_version, + decode, + ) } // ---- count axis ---- @@ -2235,6 +2424,295 @@ impl GroveDb { .add_cost(cost) } + // ----------------------------------------------------------------- + // Keys-only reads. + // + // The resolving reads above return each entry with its primary value + // resolved. That resolution happens AFTER the secondary page was + // collected, through the transaction the caller supplied — and a + // caller that supplied `None` gets point reads outside the pinned + // iterator view the page came from, so a primary deleted or rewritten + // by a commit in between is reported as corruption or paired with a + // page from the older view. A caller that only ranks (leaderboards, + // ranking views, anything that projects to `key_pair()`) pays up to + // `k` primary reads for values it discards, and inherits that window + // for nothing. + // + // The `_keys` variants return the ranking pairs straight from the + // secondary view and never open the primary. They are served by the + // same `_rows_generic` cores as the resolving variants, so the two + // agree on the page by construction. + // ----------------------------------------------------------------- + + /// Keys-only [`Self::indexed_count_top_k`]: the top-`k` + /// `(count, original_key)` pairs, with no primary value resolved. + pub fn indexed_count_top_k_keys<'b, B, P>( + &self, + path: P, + k: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_keys_generic( + path.into(), + IndexAxis::Count, + k, + descending, + transaction, + grove_version, + decode_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_count_top_k_paginated`]: the page's + /// `(count, original_key)` pairs and the skipped count, produced + /// entirely inside the pinned secondary view, with no primary value + /// resolved. + pub fn indexed_count_top_k_paginated_keys<'b, B, P>( + &self, + path: P, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_paginated_keys_generic( + path.into(), + IndexAxis::Count, + k, + offset, + descending, + transaction, + grove_version, + decode_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_count_range`]: the in-range + /// `(count, original_key)` pairs, with no primary value resolved. + pub fn indexed_count_range_keys<'b, B, P>( + &self, + path: P, + lo_count: u64, + hi_count: u64, + descending: bool, + limit: u16, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + grovedb_version::check_grovedb_v0_with_cost!( + "indexed_count_range", + grove_version.grovedb_versions.operations.indexed_axis.read + ); + let cost = OperationCost::default(); + if lo_count > hi_count { + return Ok(Vec::new()).wrap_with_cost(cost); + } + let (lo_bytes, upper_bytes) = count_range_bounds(lo_count, hi_count); + self.indexed_axis_range_keys_generic( + path.into(), + IndexAxis::Count, + lo_bytes, + upper_bytes, + descending, + limit, + transaction, + grove_version, + decode_secondary_key, + ) + .add_cost(cost) + } + + /// Keys-only [`Self::indexed_sum_top_k`]. + pub fn indexed_sum_top_k_keys<'b, B, P>( + &self, + path: P, + k: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_keys_generic( + path.into(), + IndexAxis::Sum, + k, + descending, + transaction, + grove_version, + decode_sum_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_sum_top_k_paginated`]. + pub fn indexed_sum_top_k_paginated_keys<'b, B, P>( + &self, + path: P, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_paginated_keys_generic( + path.into(), + IndexAxis::Sum, + k, + offset, + descending, + transaction, + grove_version, + decode_sum_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_sum_range`]. + pub fn indexed_sum_range_keys<'b, B, P>( + &self, + path: P, + lo_sum: i64, + hi_sum: i64, + descending: bool, + limit: u16, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + grovedb_version::check_grovedb_v0_with_cost!( + "indexed_sum_range", + grove_version.grovedb_versions.operations.indexed_axis.read + ); + let cost = OperationCost::default(); + if lo_sum > hi_sum { + return Ok(Vec::new()).wrap_with_cost(cost); + } + let (lo_bytes, upper_bytes) = sum_range_bounds(lo_sum, hi_sum); + self.indexed_axis_range_keys_generic( + path.into(), + IndexAxis::Sum, + lo_bytes, + upper_bytes, + descending, + limit, + transaction, + grove_version, + decode_sum_secondary_key, + ) + .add_cost(cost) + } + + /// Keys-only [`Self::indexed_avg_top_k`]. + pub fn indexed_avg_top_k_keys<'b, B, P>( + &self, + path: P, + k: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_keys_generic( + path.into(), + IndexAxis::Avg, + k, + descending, + transaction, + grove_version, + decode_avg_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_avg_top_k_paginated`]. + pub fn indexed_avg_top_k_paginated_keys<'b, B, P>( + &self, + path: P, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + self.indexed_axis_top_k_paginated_keys_generic( + path.into(), + IndexAxis::Avg, + k, + offset, + descending, + transaction, + grove_version, + decode_avg_secondary_key, + ) + } + + /// Keys-only [`Self::indexed_avg_range`]. + pub fn indexed_avg_range_keys<'b, B, P>( + &self, + path: P, + lo_avg: i128, + hi_avg: i128, + descending: bool, + limit: u16, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult)>, Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + grovedb_version::check_grovedb_v0_with_cost!( + "indexed_avg_range", + grove_version.grovedb_versions.operations.indexed_axis.read + ); + let cost = OperationCost::default(); + if lo_avg > hi_avg { + return Ok(Vec::new()).wrap_with_cost(cost); + } + let (lo_bytes, upper_bytes) = avg_range_bounds(lo_avg, hi_avg); + self.indexed_axis_range_keys_generic( + path.into(), + IndexAxis::Avg, + lo_bytes, + upper_bytes, + descending, + limit, + transaction, + grove_version, + decode_avg_secondary_key, + ) + .add_cost(cost) + } + /// Shared scaffolding for the per-axis direct query APIs: validate /// that the indexed-tree at `path` carries the requested `axis`, /// read that axis's secondary root key, and open the secondary @@ -2590,6 +3068,41 @@ pub(crate) fn max_item_key_len_for_axis(axis: IndexAxis) -> usize { } } +/// Inclusive count range `[lo, hi]` → the secondary's byte bounds: +/// `[encode(lo), encode(hi + 1))`, open-ended when `hi` is the maximum. +fn count_range_bounds(lo_count: u64, hi_count: u64) -> (Vec, Option>) { + let lo_bytes = lo_count.to_be_bytes().to_vec(); + let upper_bytes = if hi_count == u64::MAX { + None + } else { + Some((hi_count + 1).to_be_bytes().to_vec()) + }; + (lo_bytes, upper_bytes) +} + +/// Inclusive sum range `[lo, hi]` → the secondary's byte bounds; the sum +/// sort key is lex-equivalent to signed numeric order. +fn sum_range_bounds(lo_sum: i64, hi_sum: i64) -> (Vec, Option>) { + let lo_bytes = encode_sum_sort_key(lo_sum).to_vec(); + let upper_bytes = if hi_sum == i64::MAX { + None + } else { + Some(encode_sum_sort_key(hi_sum + 1).to_vec()) + }; + (lo_bytes, upper_bytes) +} + +/// Inclusive avg range `[lo, hi]` → the secondary's byte bounds. +fn avg_range_bounds(lo_avg: i128, hi_avg: i128) -> (Vec, Option>) { + let lo_bytes = encode_avg_sort_key(lo_avg).to_vec(); + let upper_bytes = if hi_avg == i128::MAX { + None + } else { + Some(encode_avg_sort_key(hi_avg + 1).to_vec()) + }; + (lo_bytes, upper_bytes) +} + /// Inverse of `make_secondary_key`: split a secondary key into /// `(count, original_key)`. Returns `None` if the key is shorter than the /// 8-byte count prefix. @@ -2808,6 +3321,18 @@ fn provable_count_from_aggregate(aggregate: AggregateData) -> Result } } +/// A keys-only page of a paginated indexed-axis read: the ranking pairs +/// and the skipped count, with no primary values resolved. See +/// [`GroveDb::indexed_count_top_k_paginated_keys`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedTopKKeysPage { + /// `(ordering_value, original_key)` pairs in directional order. + pub entries: Vec<(T, Vec)>, + /// How many entries the offset actually skipped — the same quantity + /// [`IndexedTopKPage::skipped`] reports. + pub skipped: u64, +} + /// One page of an `indexed__top_k_paginated` read. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexedTopKPage { diff --git a/grovedb/src/tests/indexed_axis_keys_only_read_tests.rs b/grovedb/src/tests/indexed_axis_keys_only_read_tests.rs new file mode 100644 index 000000000..ec8219841 --- /dev/null +++ b/grovedb/src/tests/indexed_axis_keys_only_read_tests.rs @@ -0,0 +1,250 @@ +//! Keys-only indexed-axis reads: `indexed__{top_k,top_k_paginated, +//! range}_keys` return the ranking pairs straight from the secondary view +//! and never open the primary. They must agree with the resolving reads +//! on the page (same `_rows_generic` core), and cost strictly less. + +#[cfg(test)] +mod tests { + use grovedb_element::indexed::IndexAxis; + use grovedb_version::version::GroveVersion; + + use crate::{ + query_result_type::IndexedAxisEntrySliceExt, + tests::{make_test_grovedb, TEST_LEAF}, + Element, GroveDb, + }; + + const PCPSIT: &[u8] = b"pcpsit"; + + /// A three-axis (count, sum, avg) indexed tree with `(key, sum)` + /// entries; count is 1 per entry, so the count axis orders by key. + fn build(db: &GroveDb, gv: &GroveVersion, entries: &[(&[u8], i64)]) { + let axes: Vec<(u8, Option>)> = vec![ + (IndexAxis::Count.tag(), None), + (IndexAxis::Sum.tag(), None), + (IndexAxis::Avg.tag(), None), + ]; + db.insert( + [TEST_LEAF].as_ref(), + PCPSIT, + Element::empty_provable_count_provable_sum_indexed_tree(axes).expect("canonical axes"), + None, + None, + gv, + ) + .unwrap() + .expect("create pcpsit"); + for (key, sum) in entries { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, PCPSIT].as_ref(), + key, + Element::new_item_with_sum_item(b"v".to_vec(), *sum), + None, + gv, + ) + .unwrap() + .expect("insert entry"); + } + } + + fn path() -> Vec<&'static [u8]> { + vec![TEST_LEAF, PCPSIT] + } + + /// The paginated keys-only page equals the resolving page projected + /// to its ranking pairs, including the skipped count, on every axis, + /// both directions, with and without an offset. + #[test] + fn paginated_keys_agree_with_resolving_pages_on_every_axis() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build( + &db, + gv, + &[(b"a", 50), (b"b", 10), (b"c", 30), (b"d", 20), (b"e", 40)], + ); + + for descending in [true, false] { + for offset in [0u64, 2] { + let p = path(); + let (sum_keys, sum_full) = ( + db.indexed_sum_top_k_paginated_keys( + p.as_slice(), + 2, + offset, + descending, + None, + gv, + ) + .unwrap() + .expect("sum keys"), + db.indexed_sum_top_k_paginated(p.as_slice(), 2, offset, descending, None, gv) + .unwrap() + .expect("sum full"), + ); + assert_eq!(sum_keys.entries, sum_full.entries.key_pairs()); + assert_eq!(sum_keys.skipped, sum_full.skipped); + + let (count_keys, count_full) = ( + db.indexed_count_top_k_paginated_keys( + p.as_slice(), + 2, + offset, + descending, + None, + gv, + ) + .unwrap() + .expect("count keys"), + db.indexed_count_top_k_paginated(p.as_slice(), 2, offset, descending, None, gv) + .unwrap() + .expect("count full"), + ); + assert_eq!(count_keys.entries, count_full.entries.key_pairs()); + assert_eq!(count_keys.skipped, count_full.skipped); + + let (avg_keys, avg_full) = ( + db.indexed_avg_top_k_paginated_keys( + p.as_slice(), + 2, + offset, + descending, + None, + gv, + ) + .unwrap() + .expect("avg keys"), + db.indexed_avg_top_k_paginated(p.as_slice(), 2, offset, descending, None, gv) + .unwrap() + .expect("avg full"), + ); + assert_eq!(avg_keys.entries, avg_full.entries.key_pairs()); + assert_eq!(avg_keys.skipped, avg_full.skipped); + } + } + } + + /// Range and plain top-k keys-only reads agree with their resolving + /// counterparts. + #[test] + fn range_and_top_k_keys_agree_with_resolving_reads() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build( + &db, + gv, + &[(b"a", 50), (b"b", 10), (b"c", 30), (b"d", 20), (b"e", 40)], + ); + let p = path(); + + let keys = db + .indexed_sum_range_keys(p.as_slice(), 15, 45, false, 10, None, gv) + .unwrap() + .expect("sum range keys"); + let full = db + .indexed_sum_range(p.as_slice(), 15, 45, false, 10, None, gv) + .unwrap() + .expect("sum range full"); + assert_eq!(keys, full.key_pairs()); + assert_eq!( + keys, + vec![ + (20, b"d".to_vec()), + (30, b"c".to_vec()), + (40, b"e".to_vec()) + ] + ); + + let keys = db + .indexed_count_range_keys(p.as_slice(), 1, 1, true, 10, None, gv) + .unwrap() + .expect("count range keys"); + let full = db + .indexed_count_range(p.as_slice(), 1, 1, true, 10, None, gv) + .unwrap() + .expect("count range full"); + assert_eq!(keys, full.key_pairs()); + + let keys = db + .indexed_avg_range_keys(p.as_slice(), i128::MIN, i128::MAX, true, 3, None, gv) + .unwrap() + .expect("avg range keys"); + let full = db + .indexed_avg_range(p.as_slice(), i128::MIN, i128::MAX, true, 3, None, gv) + .unwrap() + .expect("avg range full"); + assert_eq!(keys, full.key_pairs()); + + let keys = db + .indexed_sum_top_k_keys(p.as_slice(), 3, true, None, gv) + .unwrap() + .expect("sum top k keys"); + let full = db + .indexed_sum_top_k(p.as_slice(), 3, true, None, gv) + .unwrap() + .expect("sum top k full"); + assert_eq!(keys, full.key_pairs()); + let keys = db + .indexed_count_top_k_keys(p.as_slice(), 3, false, None, gv) + .unwrap() + .expect("count top k keys"); + let full = db + .indexed_count_top_k(p.as_slice(), 3, false, None, gv) + .unwrap() + .expect("count top k full"); + assert_eq!(keys, full.key_pairs()); + let keys = db + .indexed_avg_top_k_keys(p.as_slice(), 3, true, None, gv) + .unwrap() + .expect("avg top k keys"); + let full = db + .indexed_avg_top_k(p.as_slice(), 3, true, None, gv) + .unwrap() + .expect("avg top k full"); + assert_eq!(keys, full.key_pairs()); + } + + /// A keys-only read never opens the primary, so it costs strictly + /// fewer seeks than the resolving read of the same page (which pays + /// one primary read per entry), and degenerate bounds stay the same + /// empty answer. + #[test] + fn keys_only_reads_skip_the_primary_reads() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build( + &db, + gv, + &[(b"a", 50), (b"b", 10), (b"c", 30), (b"d", 20), (b"e", 40)], + ); + let p = path(); + + let keys_cost = db + .indexed_sum_top_k_paginated_keys(p.as_slice(), 3, 1, true, None, gv) + .cost; + let full_cost = db + .indexed_sum_top_k_paginated(p.as_slice(), 3, 1, true, None, gv) + .cost; + assert!( + keys_cost.seek_count < full_cost.seek_count, + "keys-only must not pay the primary reads: {} vs {}", + keys_cost.seek_count, + full_cost.seek_count + ); + assert!(keys_cost.storage_loaded_bytes < full_cost.storage_loaded_bytes); + + let keys_cost = db + .indexed_sum_range_keys(p.as_slice(), 0, 100, false, 10, None, gv) + .cost; + let full_cost = db + .indexed_sum_range(p.as_slice(), 0, 100, false, 10, None, gv) + .cost; + assert!(keys_cost.seek_count < full_cost.seek_count); + + let inverted = db + .indexed_sum_range_keys(p.as_slice(), 10, 5, false, 10, None, gv) + .unwrap() + .expect("inverted bounds"); + assert!(inverted.is_empty()); + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 027b0d37a..5596fc04f 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -57,6 +57,7 @@ mod estimated_costs_average_case_tests; mod estimated_costs_worst_case_tests; mod get_cost_estimator_tests; mod grove_query_result_tests; +mod indexed_axis_keys_only_read_tests; mod indexed_axis_nested_and_bounds_tests; mod indexed_axis_offset_proof_tests; mod indexed_axis_paginated_cost_tests;