1#[cfg(any(test, feature = "query"))]
7use crate::db::index::{IndexId, IndexKeyKind};
8use crate::db::{
9 direction::Direction,
10 index::{IndexEntryValue, cardinality::IndexPrefixCardinality, key::RawIndexStoreKey},
11 ordered_overlay::{OrderedOverlayEntry, OrderedOverlayVisit, visit_ordered_overlay},
12};
13
14use candid::CandidType;
15use ic_stable_structures::{
16 BTreeMap as StableBTreeMap, DefaultMemoryImpl, memory_manager::VirtualMemory,
17};
18use serde::Deserialize;
19#[cfg(any(test, feature = "diagnostics"))]
20use std::cell::Cell;
21use std::collections::{BTreeMap as HeapBTreeMap, BTreeSet};
22use std::ops::Bound;
23
24#[cfg(test)]
25thread_local! {
26 static JOURNALED_SNAPSHOT_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
27}
28
29#[cfg(feature = "diagnostics")]
30thread_local! {
31 static INDEX_STORE_GET_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
32}
33
34#[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
35thread_local! {
36 static INDEX_STORE_RANGE_SCAN_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
37 static INDEX_STORE_ENTRY_READ_COUNT: Cell<u64> = const { Cell::new(0) };
38 static INDEX_STORE_PREFIX_CARDINALITY_LOOKUP_COUNT: Cell<u64> = const { Cell::new(0) };
39}
40
41#[cfg(feature = "diagnostics")]
42fn record_index_store_get_call() {
43 INDEX_STORE_GET_CALL_COUNT.with(|count| {
44 count.set(count.get().saturating_add(1));
45 });
46}
47
48#[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
49fn record_index_store_range_scan_call() {
50 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(|count| {
51 count.set(count.get().saturating_add(1));
52 });
53}
54
55#[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
56fn record_index_store_entry_read() {
57 INDEX_STORE_ENTRY_READ_COUNT.with(|count| {
58 count.set(count.get().saturating_add(1));
59 });
60}
61
62#[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
63fn record_index_store_prefix_cardinality_lookup() {
64 INDEX_STORE_PREFIX_CARDINALITY_LOOKUP_COUNT.with(|count| {
65 count.set(count.get().saturating_add(1));
66 });
67}
68
69fn visit_index_store_entry<E>(
70 key: &RawIndexStoreKey,
71 value: &IndexEntryValue,
72 visit: &mut impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
73) -> Result<bool, E> {
74 #[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
75 record_index_store_entry_read();
76
77 visit(key, value)
78}
79
80#[cfg(test)]
81fn record_journaled_snapshot_call() {
82 JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| {
83 count.set(count.get().saturating_add(1));
84 });
85}
86
87#[cfg(test)]
88fn reset_journaled_snapshot_call_count_for_tests() {
89 JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| count.set(0));
90}
91
92#[cfg(test)]
93fn journaled_snapshot_call_count_for_tests() -> u64 {
94 JOURNALED_SNAPSHOT_CALL_COUNT.with(Cell::get)
95}
96
97#[derive(CandidType, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
105pub enum IndexState {
106 Building,
107 #[default]
108 Ready,
109}
110
111impl IndexState {
112 #[must_use]
114 pub const fn as_str(self) -> &'static str {
115 match self {
116 Self::Building => "building",
117 Self::Ready => "ready",
118 }
119 }
120}
121
122pub struct IndexStore {
131 pub(super) backend: IndexStoreBackend,
132 generation: u64,
133 state: IndexState,
134 prefix_cardinality: IndexPrefixCardinality,
135}
136
137pub(super) enum IndexStoreBackend {
138 Heap(HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>),
139 Journaled {
140 canonical:
141 StableBTreeMap<RawIndexStoreKey, IndexEntryValue, VirtualMemory<DefaultMemoryImpl>>,
142 live: HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>,
143 tombstones: BTreeSet<RawIndexStoreKey>,
144 },
145}
146
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub(in crate::db) enum IndexStoreVisit {
150 Continue,
151 Stop,
152}
153
154impl IndexStoreVisit {
155 const fn should_stop(self) -> bool {
156 matches!(self, Self::Stop)
157 }
158}
159
160impl IndexStore {
161 #[must_use]
163 pub const fn init_heap() -> Self {
164 Self {
165 backend: IndexStoreBackend::Heap(HeapBTreeMap::new()),
166 generation: 0,
167 state: IndexState::Ready,
168 prefix_cardinality: IndexPrefixCardinality::synchronized_empty(),
169 }
170 }
171
172 #[must_use]
177 pub fn init_journaled(memory: VirtualMemory<DefaultMemoryImpl>) -> Self {
178 let mut store = Self {
179 backend: IndexStoreBackend::Journaled {
180 canonical: StableBTreeMap::init(memory),
181 live: HeapBTreeMap::new(),
182 tombstones: BTreeSet::new(),
183 },
184 generation: 0,
185 state: IndexState::Ready,
186 prefix_cardinality: IndexPrefixCardinality::synchronized_empty(),
187 };
188 store.rebuild_prefix_cardinality_from_entries(Some(0));
189 store
190 }
191
192 pub(in crate::db) fn visit_entries<E>(
195 &self,
196 mut visitor: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<IndexStoreVisit, E>,
197 ) -> Result<(), E> {
198 match &self.backend {
199 IndexStoreBackend::Heap(map) => {
200 for (key, value) in map {
201 #[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
202 record_index_store_entry_read();
203
204 if visitor(key, value)?.should_stop() {
205 return Ok(());
206 }
207 }
208 }
209 IndexStoreBackend::Journaled {
210 canonical: _,
211 live: _,
212 tombstones: _,
213 } => self.visit_journaled_entries_in_range(
214 (&Bound::Unbounded, &Bound::Unbounded),
215 Direction::Asc,
216 |key, value| visitor(key, value).map(IndexStoreVisit::should_stop),
217 )?,
218 }
219
220 Ok(())
221 }
222
223 pub(in crate::db) fn get(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
224 #[cfg(feature = "diagnostics")]
225 record_index_store_get_call();
226
227 match &self.backend {
228 IndexStoreBackend::Heap(map) => map.get(key).cloned(),
229 IndexStoreBackend::Journaled { .. } => Self::journaled_get(&self.backend, key),
230 }
231 }
232
233 pub fn len(&self) -> u64 {
234 match &self.backend {
235 IndexStoreBackend::Heap(map) => u64::try_from(map.len()).unwrap_or(u64::MAX),
236 IndexStoreBackend::Journaled { .. } => {
237 let mut count = 0_u64;
238 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
239 count = count.saturating_add(1);
240 Ok(IndexStoreVisit::Continue)
241 });
242 count
243 }
244 }
245 }
246
247 pub fn is_empty(&self) -> bool {
248 match &self.backend {
249 IndexStoreBackend::Heap(map) => map.is_empty(),
250 IndexStoreBackend::Journaled { .. } => {
251 let mut empty = true;
252 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
253 empty = false;
254 Ok(IndexStoreVisit::Stop)
255 });
256 empty
257 }
258 }
259 }
260
261 #[must_use]
262 pub(in crate::db) const fn generation(&self) -> u64 {
263 self.generation
264 }
265
266 #[must_use]
268 pub(in crate::db) const fn state(&self) -> IndexState {
269 self.state
270 }
271
272 #[must_use]
275 #[cfg(any(test, feature = "query"))]
276 pub(in crate::db) fn exact_prefix_cardinality(
277 &self,
278 data_generation: u64,
279 key_kind: IndexKeyKind,
280 index_id: IndexId,
281 components: &[Vec<u8>],
282 ) -> Option<u64> {
283 #[cfg(any(test, feature = "diagnostics"))]
284 record_index_store_prefix_cardinality_lookup();
285
286 self.prefix_cardinality
287 .exact_count(data_generation, key_kind, index_id, components)
288 }
289
290 #[must_use]
293 #[cfg(any(test, feature = "query"))]
294 pub(in crate::db) fn exact_prefix_cardinality_sum<'a>(
295 &self,
296 data_generation: u64,
297 key_kind: IndexKeyKind,
298 index_id: IndexId,
299 component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
300 stop_after: Option<u64>,
301 ) -> Option<u64> {
302 #[cfg(any(test, feature = "diagnostics"))]
303 record_index_store_prefix_cardinality_lookup();
304
305 self.prefix_cardinality.exact_count_sum(
306 data_generation,
307 key_kind,
308 index_id,
309 component_prefixes,
310 stop_after,
311 )
312 }
313
314 #[must_use]
317 #[cfg(any(test, feature = "query"))]
318 pub(in crate::db) fn exact_child_prefixes_for_parent_set<'a>(
319 &self,
320 data_generation: u64,
321 key_kind: IndexKeyKind,
322 index_id: IndexId,
323 parent_component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
324 max_children: usize,
325 ) -> Option<Vec<Vec<Vec<u8>>>> {
326 #[cfg(any(test, feature = "diagnostics"))]
327 record_index_store_prefix_cardinality_lookup();
328
329 self.prefix_cardinality.exact_child_prefixes_for_parent_set(
330 data_generation,
331 key_kind,
332 index_id,
333 parent_component_prefixes,
334 max_children,
335 )
336 }
337
338 pub(in crate::db) const fn mark_prefix_cardinality_data_generation(&mut self, generation: u64) {
341 self.prefix_cardinality.mark_synchronized(generation);
342 }
343
344 pub(in crate::db) const fn mark_building(&mut self) {
347 self.state = IndexState::Building;
348 }
349
350 pub(in crate::db) const fn mark_ready(&mut self) {
352 self.state = IndexState::Ready;
353 }
354
355 pub(crate) fn insert(
356 &mut self,
357 key: RawIndexStoreKey,
358 entry: IndexEntryValue,
359 ) -> Option<IndexEntryValue> {
360 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
361 self.get(&key)
362 } else {
363 None
364 };
365 let cardinality_key = key.clone();
366 let previous = match &mut self.backend {
367 IndexStoreBackend::Heap(map) => map.insert(key, entry.clone()),
368 IndexStoreBackend::Journaled {
369 live, tombstones, ..
370 } => {
371 tombstones.remove(&key);
372 live.insert(key, entry.clone());
373 previous_journaled
374 }
375 };
376 self.prefix_cardinality
377 .apply_insert(&cardinality_key, previous.as_ref(), &entry);
378 self.bump_generation();
379 previous
380 }
381
382 pub(crate) fn remove(&mut self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
383 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
384 self.get(key)
385 } else {
386 None
387 };
388 let previous = match &mut self.backend {
389 IndexStoreBackend::Heap(map) => map.remove(key),
390 IndexStoreBackend::Journaled {
391 live, tombstones, ..
392 } => {
393 live.remove(key);
394 tombstones.insert(key.clone());
395 previous_journaled
396 }
397 };
398 self.prefix_cardinality.apply_remove(key, previous.as_ref());
399 self.bump_generation();
400 previous
401 }
402
403 pub fn clear(&mut self) {
404 match &mut self.backend {
405 IndexStoreBackend::Heap(map) => map.clear(),
406 IndexStoreBackend::Journaled {
407 canonical,
408 live,
409 tombstones,
410 } => {
411 live.clear();
412 tombstones.clear();
413 for entry in canonical.iter() {
414 tombstones.insert(entry.key().clone());
415 }
416 }
417 }
418 self.prefix_cardinality.clear_unsynchronized();
419 self.bump_generation();
420 }
421
422 pub(in crate::db) fn fold_journaled_materialized_view(
425 &mut self,
426 ) -> Result<(), crate::error::InternalError> {
427 let entries = Self::journaled_entries_snapshot_for_fold(&self.backend);
428 let IndexStoreBackend::Journaled {
429 canonical,
430 live,
431 tombstones,
432 } = &mut self.backend
433 else {
434 return Err(crate::error::InternalError::store_invariant());
435 };
436
437 canonical.clear_new();
438 for (key, value) in entries {
439 canonical.insert(key, value);
440 }
441 live.clear();
442 tombstones.clear();
443 let data_generation = self.prefix_cardinality.synchronized_generation();
444 self.rebuild_prefix_cardinality_from_entries(data_generation);
445 self.bump_generation();
446
447 Ok(())
448 }
449
450 pub fn memory_bytes(&self) -> u64 {
452 let mut bytes = 0u64;
453 let _: Result<(), std::convert::Infallible> = self.visit_entries(|key, value| {
454 bytes = bytes.saturating_add(key.as_bytes().len() as u64 + value.len() as u64);
455 Ok(IndexStoreVisit::Continue)
456 });
457 bytes
458 }
459
460 #[cfg(all(feature = "diagnostics", any(test, feature = "query")))]
462 pub(in crate::db) fn current_get_call_count() -> u64 {
463 INDEX_STORE_GET_CALL_COUNT.with(Cell::get)
464 }
465
466 #[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
468 #[cfg_attr(
469 all(test, not(feature = "diagnostics")),
470 expect(dead_code, reason = "counter is consumed by diagnostics-shaped tests")
471 )]
472 pub(in crate::db) fn current_range_scan_call_count() -> u64 {
473 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(Cell::get)
474 }
475
476 #[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
478 #[cfg_attr(
479 all(test, not(feature = "diagnostics")),
480 expect(dead_code, reason = "counter is consumed by diagnostics-shaped tests")
481 )]
482 pub(in crate::db) fn current_entry_read_count() -> u64 {
483 INDEX_STORE_ENTRY_READ_COUNT.with(Cell::get)
484 }
485
486 #[cfg(any(test, all(feature = "query", feature = "diagnostics")))]
487 pub(in crate::db::index) fn record_range_scan_call() {
488 record_index_store_range_scan_call();
489 }
490
491 const fn bump_generation(&mut self) {
492 self.generation = self.generation.saturating_add(1);
493 }
494
495 fn rebuild_prefix_cardinality_from_entries(&mut self, data_generation: Option<u64>) {
496 self.prefix_cardinality.clear_unsynchronized();
497 let entries = Self::entries_snapshot_for_cardinality(&self.backend);
498 for (key, value) in &entries {
499 self.prefix_cardinality.apply_insert(key, None, value);
500 }
501 if let Some(data_generation) = data_generation {
502 self.prefix_cardinality.mark_synchronized(data_generation);
503 }
504 }
505
506 fn entries_snapshot_for_cardinality(
507 backend: &IndexStoreBackend,
508 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
509 match backend {
510 IndexStoreBackend::Heap(map) => map.clone(),
511 IndexStoreBackend::Journaled { .. } => {
512 Self::journaled_entries_snapshot_for_fold(backend)
513 }
514 }
515 }
516
517 fn journaled_get(
518 backend: &IndexStoreBackend,
519 key: &RawIndexStoreKey,
520 ) -> Option<IndexEntryValue> {
521 let IndexStoreBackend::Journaled {
522 canonical,
523 live,
524 tombstones,
525 } = backend
526 else {
527 return None;
528 };
529
530 if tombstones.contains(key) {
531 return None;
532 }
533 live.get(key).cloned().or_else(|| canonical.get(key))
534 }
535
536 pub(super) fn journaled_entries_snapshot_for_fold(
537 backend: &IndexStoreBackend,
538 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
539 #[cfg(test)]
540 record_journaled_snapshot_call();
541
542 let IndexStoreBackend::Journaled {
543 canonical,
544 live,
545 tombstones,
546 } = backend
547 else {
548 return HeapBTreeMap::new();
549 };
550
551 let mut entries = HeapBTreeMap::new();
552 for entry in canonical.iter() {
553 let key = entry.key().clone();
554 if !tombstones.contains(&key) {
555 entries.insert(key, entry.value());
556 }
557 }
558 for (key, value) in live {
559 if !tombstones.contains(key) {
560 entries.insert(key.clone(), value.clone());
561 }
562 }
563
564 entries
565 }
566
567 pub(super) fn visit_journaled_entries_in_range<E>(
568 &self,
569 bounds: (&Bound<RawIndexStoreKey>, &Bound<RawIndexStoreKey>),
570 direction: Direction,
571 mut visit: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
572 ) -> Result<(), E> {
573 let IndexStoreBackend::Journaled {
574 canonical,
575 live,
576 tombstones,
577 } = &self.backend
578 else {
579 return Ok(());
580 };
581
582 let lower = bounds.0.clone();
583 let upper = bounds.1.clone();
584 match direction {
585 Direction::Asc if canonical.is_empty() => {
586 for (key, value) in live.range((lower, upper)) {
587 if visit_index_store_entry(key, value, &mut visit)? {
588 return Ok(());
589 }
590 }
591 }
592 Direction::Desc if canonical.is_empty() => {
593 for (key, value) in live.range((lower, upper)).rev() {
594 if visit_index_store_entry(key, value, &mut visit)? {
595 return Ok(());
596 }
597 }
598 }
599 Direction::Asc if live.is_empty() && tombstones.is_empty() => {
600 for entry in canonical.range((lower, upper)) {
601 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
602 return Ok(());
603 }
604 }
605 }
606 Direction::Desc if live.is_empty() && tombstones.is_empty() => {
607 for entry in canonical.range((lower, upper)).rev() {
608 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
609 return Ok(());
610 }
611 }
612 }
613 Direction::Asc => {
614 visit_ordered_overlay(
615 canonical.range((lower.clone(), upper.clone())),
616 live.range((lower, upper)),
617 direction,
618 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
619 |canonical_entry| !tombstones.contains(canonical_entry.key()),
620 |live_entry| !tombstones.contains(live_entry.0),
621 |entry| {
622 let should_stop = match entry {
623 OrderedOverlayEntry::Canonical(canonical_entry) => {
624 visit_index_store_entry(
625 canonical_entry.key(),
626 &canonical_entry.value(),
627 &mut visit,
628 )?
629 }
630 OrderedOverlayEntry::Live((key, value)) => {
631 visit_index_store_entry(key, value, &mut visit)?
632 }
633 };
634 Ok(if should_stop {
635 OrderedOverlayVisit::Stop
636 } else {
637 OrderedOverlayVisit::Continue
638 })
639 },
640 )?;
641 }
642 Direction::Desc => {
643 visit_ordered_overlay(
644 canonical.range((lower.clone(), upper.clone())).rev(),
645 live.range((lower, upper)).rev(),
646 direction,
647 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
648 |canonical_entry| !tombstones.contains(canonical_entry.key()),
649 |live_entry| !tombstones.contains(live_entry.0),
650 |entry| {
651 let should_stop = match entry {
652 OrderedOverlayEntry::Canonical(canonical_entry) => {
653 visit_index_store_entry(
654 canonical_entry.key(),
655 &canonical_entry.value(),
656 &mut visit,
657 )?
658 }
659 OrderedOverlayEntry::Live((key, value)) => {
660 visit_index_store_entry(key, value, &mut visit)?
661 }
662 };
663 Ok(if should_stop {
664 OrderedOverlayVisit::Stop
665 } else {
666 OrderedOverlayVisit::Continue
667 })
668 },
669 )?;
670 }
671 }
672
673 Ok(())
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::{
681 db::{
682 direction::Direction,
683 index::{IndexId, IndexKey, IndexKeyKind},
684 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
685 },
686 testing::test_memory,
687 types::EntityTag,
688 };
689 use ic_stable_structures::Storable;
690 use std::{borrow::Cow, convert::Infallible};
691
692 fn raw_key(value: u8) -> RawIndexStoreKey {
693 <RawIndexStoreKey as Storable>::from_bytes(Cow::Owned(vec![value]))
694 }
695
696 fn indexed_raw_key(
697 index_id: &IndexId,
698 components: Vec<Vec<u8>>,
699 primary_key: u64,
700 ) -> RawIndexStoreKey {
701 indexed_raw_key_with_kind(index_id, IndexKeyKind::User, components, primary_key)
702 }
703
704 fn indexed_raw_key_with_kind(
705 index_id: &IndexId,
706 key_kind: IndexKeyKind,
707 components: Vec<Vec<u8>>,
708 primary_key: u64,
709 ) -> RawIndexStoreKey {
710 IndexKey::new_from_components_with_primary_key_value(
711 index_id,
712 key_kind,
713 components.as_slice(),
714 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(primary_key)),
715 )
716 .expect("test index key should build")
717 .to_raw()
718 .expect("test index key should encode")
719 }
720
721 fn malformed_index_entry_value() -> IndexEntryValue {
722 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![0xFF]))
723 }
724
725 fn missing_index_entry_value() -> IndexEntryValue {
726 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![1]))
727 }
728
729 #[test]
730 fn index_prefix_cardinality_requires_explicit_data_generation_sync() {
731 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
732 let collection = b"collection-a".to_vec();
733 let draft = b"Draft".to_vec();
734 let review = b"Review".to_vec();
735 let mut store = IndexStore::init_heap();
736
737 store.insert(
738 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
739 IndexEntryValue::presence(),
740 );
741 store.insert(
742 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
743 IndexEntryValue::presence(),
744 );
745 store.insert(
746 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
747 IndexEntryValue::presence(),
748 );
749
750 assert_eq!(
751 store.exact_prefix_cardinality(
752 0,
753 IndexKeyKind::User,
754 index_id,
755 std::slice::from_ref(&collection),
756 ),
757 None,
758 "raw index mutations must not be trusted until row generation sync is stamped",
759 );
760
761 store.mark_prefix_cardinality_data_generation(7);
762
763 assert_eq!(
764 store.exact_prefix_cardinality(
765 7,
766 IndexKeyKind::User,
767 index_id,
768 std::slice::from_ref(&collection),
769 ),
770 Some(3),
771 );
772 assert_eq!(
773 store.exact_prefix_cardinality(
774 7,
775 IndexKeyKind::User,
776 index_id,
777 &[collection.clone(), draft],
778 ),
779 Some(2),
780 );
781 assert_eq!(
782 store.exact_prefix_cardinality(8, IndexKeyKind::User, index_id, &[collection, review],),
783 None,
784 "row generation drift should force the caller to use the existing-row fallback",
785 );
786 }
787
788 #[test]
789 fn index_prefix_cardinality_enumerates_bounded_child_prefixes() {
790 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
791 let collection = b"collection-a".to_vec();
792 let other_collection = b"collection-b".to_vec();
793 let draft = b"Draft".to_vec();
794 let review = b"Review".to_vec();
795 let published = b"Published".to_vec();
796 let mut store = IndexStore::init_heap();
797
798 store.insert(
799 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
800 IndexEntryValue::presence(),
801 );
802 store.insert(
803 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
804 IndexEntryValue::presence(),
805 );
806 store.insert(
807 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
808 IndexEntryValue::presence(),
809 );
810 store.insert(
811 indexed_raw_key(
812 &index_id,
813 vec![other_collection.clone(), published.clone()],
814 4,
815 ),
816 IndexEntryValue::presence(),
817 );
818 store.mark_prefix_cardinality_data_generation(7);
819
820 assert_eq!(
821 store.exact_child_prefixes_for_parent_set(
822 7,
823 IndexKeyKind::User,
824 index_id,
825 [std::slice::from_ref(&collection)],
826 4,
827 ),
828 Some(vec![
829 vec![collection.clone(), draft],
830 vec![collection.clone(), review],
831 ]),
832 "child-prefix enumeration should return deterministic unique children under the requested parent",
833 );
834 assert_eq!(
835 store.exact_child_prefixes_for_parent_set(
836 7,
837 IndexKeyKind::User,
838 index_id,
839 [std::slice::from_ref(&other_collection)],
840 4,
841 ),
842 Some(vec![vec![other_collection, published]]),
843 "child-prefix enumeration must stay scoped to the requested parent prefix",
844 );
845 assert_eq!(
846 store.exact_child_prefixes_for_parent_set(
847 8,
848 IndexKeyKind::User,
849 index_id,
850 [std::slice::from_ref(&collection)],
851 4,
852 ),
853 None,
854 "row generation drift should keep child-prefix expansion fail-closed",
855 );
856 assert_eq!(
857 store.exact_child_prefixes_for_parent_set(
858 7,
859 IndexKeyKind::User,
860 index_id,
861 [std::slice::from_ref(&collection)],
862 1,
863 ),
864 None,
865 "over-cap child-prefix expansion should fall back to the existing route",
866 );
867 }
868
869 #[test]
870 fn index_prefix_cardinality_batches_sparse_child_prefixes() {
871 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
872 let collection = b"collection-a".to_vec();
873 let other_collection = b"collection-b".to_vec();
874 let missing_a = b"missing-a".to_vec();
875 let missing_b = b"missing-b".to_vec();
876 let draft = b"Draft".to_vec();
877 let review = b"Review".to_vec();
878 let published = b"Published".to_vec();
879 let mut store = IndexStore::init_heap();
880
881 store.insert(
882 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
883 IndexEntryValue::presence(),
884 );
885 store.insert(
886 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 2),
887 IndexEntryValue::presence(),
888 );
889 store.insert(
890 indexed_raw_key(
891 &index_id,
892 vec![other_collection.clone(), published.clone()],
893 3,
894 ),
895 IndexEntryValue::presence(),
896 );
897 store.mark_prefix_cardinality_data_generation(7);
898
899 let parents = [
900 std::slice::from_ref(&missing_a),
901 std::slice::from_ref(&collection),
902 std::slice::from_ref(&missing_b),
903 std::slice::from_ref(&other_collection),
904 ];
905 assert_eq!(
906 store.exact_child_prefixes_for_parent_set(7, IndexKeyKind::User, index_id, parents, 4,),
907 Some(vec![
908 vec![collection.clone(), draft],
909 vec![collection.clone(), review],
910 vec![other_collection.clone(), published],
911 ]),
912 "batched child-prefix enumeration should skip missing sparse parents and return deterministic real children",
913 );
914 assert_eq!(
915 store.exact_child_prefixes_for_parent_set(
916 7,
917 IndexKeyKind::User,
918 index_id,
919 [
920 std::slice::from_ref(&missing_a),
921 std::slice::from_ref(&missing_b)
922 ],
923 4,
924 ),
925 Some(Vec::new()),
926 "missing-only sparse parent sets should be proven empty when cardinality is synchronized",
927 );
928 assert_eq!(
929 store.exact_child_prefixes_for_parent_set(
930 7,
931 IndexKeyKind::User,
932 index_id,
933 [
934 std::slice::from_ref(&collection),
935 std::slice::from_ref(&other_collection)
936 ],
937 2,
938 ),
939 None,
940 "over-cap sparse parent-set expansion should fail closed",
941 );
942 assert_eq!(
943 store.exact_child_prefixes_for_parent_set(
944 8,
945 IndexKeyKind::User,
946 index_id,
947 [std::slice::from_ref(&collection)],
948 4,
949 ),
950 None,
951 "generation drift should keep batched child-prefix expansion fail-closed",
952 );
953 }
954
955 #[test]
956 fn index_prefix_cardinality_ignores_system_index_mutations() {
957 let user_index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
958 let system_index_id = IndexId::new(EntityTag::new(0xCA7D), 2);
959 let collection = b"collection-a".to_vec();
960 let draft = b"Draft".to_vec();
961 let system_component = b"reverse-edge".to_vec();
962 let mut store = IndexStore::init_heap();
963
964 store.insert(
965 indexed_raw_key(&user_index_id, vec![collection.clone(), draft.clone()], 1),
966 IndexEntryValue::presence(),
967 );
968 store.mark_prefix_cardinality_data_generation(7);
969
970 assert_eq!(
971 store.exact_prefix_cardinality(
972 7,
973 IndexKeyKind::User,
974 user_index_id,
975 &[collection.clone(), draft.clone()],
976 ),
977 Some(1),
978 );
979
980 let system_key = indexed_raw_key_with_kind(
981 &system_index_id,
982 IndexKeyKind::System,
983 vec![system_component],
984 1,
985 );
986 store.insert(system_key.clone(), IndexEntryValue::presence());
987 assert_eq!(
988 store.exact_prefix_cardinality(
989 7,
990 IndexKeyKind::User,
991 user_index_id,
992 &[collection.clone(), draft.clone()],
993 ),
994 Some(1),
995 "system index writes must not invalidate synchronized user-prefix cardinality",
996 );
997
998 store.remove(&system_key);
999 assert_eq!(
1000 store.exact_prefix_cardinality(
1001 7,
1002 IndexKeyKind::User,
1003 user_index_id,
1004 &[collection.clone(), draft.clone()],
1005 ),
1006 Some(1),
1007 "system index removals must not invalidate synchronized user-prefix cardinality",
1008 );
1009
1010 let malformed_system_key = indexed_raw_key_with_kind(
1011 &system_index_id,
1012 IndexKeyKind::System,
1013 vec![b"malformed-reverse-edge".to_vec()],
1014 2,
1015 );
1016 store.insert(malformed_system_key.clone(), malformed_index_entry_value());
1017 assert_eq!(
1018 store.exact_prefix_cardinality(
1019 7,
1020 IndexKeyKind::User,
1021 user_index_id,
1022 &[collection.clone(), draft.clone()],
1023 ),
1024 Some(1),
1025 "malformed system index payloads must not invalidate user-prefix cardinality",
1026 );
1027
1028 store.remove(&malformed_system_key);
1029 assert_eq!(
1030 store.exact_prefix_cardinality(
1031 7,
1032 IndexKeyKind::User,
1033 user_index_id,
1034 &[collection.clone(), draft],
1035 ),
1036 Some(1),
1037 "malformed system index removals must not invalidate user-prefix cardinality",
1038 );
1039
1040 let review = b"Review".to_vec();
1041 store.insert(
1042 indexed_raw_key(&user_index_id, vec![collection.clone(), review.clone()], 2),
1043 IndexEntryValue::presence(),
1044 );
1045 assert_eq!(
1046 store.exact_prefix_cardinality(
1047 7,
1048 IndexKeyKind::User,
1049 user_index_id,
1050 &[collection, review]
1051 ),
1052 None,
1053 "user-prefix count changes must still require a fresh row-generation stamp",
1054 );
1055 }
1056
1057 #[test]
1058 fn index_prefix_cardinality_ignores_missing_user_index_mutations() {
1059 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1060 let collection = b"collection-a".to_vec();
1061 let draft = b"Draft".to_vec();
1062 let mut store = IndexStore::init_heap();
1063
1064 store.insert(
1065 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1066 IndexEntryValue::presence(),
1067 );
1068 store.mark_prefix_cardinality_data_generation(7);
1069
1070 let stale_key = indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2);
1071 store.insert(stale_key.clone(), missing_index_entry_value());
1072 assert_eq!(
1073 store.exact_prefix_cardinality(
1074 7,
1075 IndexKeyKind::User,
1076 index_id,
1077 &[collection.clone(), draft.clone()],
1078 ),
1079 Some(1),
1080 "missing user index entries must not affect synchronized prefix cardinality",
1081 );
1082
1083 store.remove(&stale_key);
1084 assert_eq!(
1085 store.exact_prefix_cardinality(7, IndexKeyKind::User, index_id, &[collection, draft],),
1086 Some(1),
1087 "missing user index removals must not affect synchronized prefix cardinality",
1088 );
1089 }
1090
1091 #[cfg(feature = "diagnostics")]
1092 #[test]
1093 fn index_store_diagnostic_counters_record_gets_range_scans_and_entry_reads() {
1094 let mut store = IndexStore::init_heap();
1095 store.insert(raw_key(7), IndexEntryValue::presence());
1096 store.insert(raw_key(9), IndexEntryValue::presence());
1097
1098 let gets_before = IndexStore::current_get_call_count();
1099 assert_eq!(store.get(&raw_key(7)), Some(IndexEntryValue::presence()));
1100 assert_eq!(store.get(&raw_key(8)), None);
1101
1102 assert_eq!(
1103 IndexStore::current_get_call_count().saturating_sub(gets_before),
1104 2,
1105 "diagnostic index-store get counter should count both hit and miss reads",
1106 );
1107
1108 let range_scans_before = IndexStore::current_range_scan_call_count();
1109 let lower = Bound::Included(raw_key(7));
1110 let upper = Bound::Included(raw_key(9));
1111 store
1112 .visit_raw_entries_in_range((&lower, &upper), Direction::Asc, |_key, _entry| Ok(false))
1113 .expect("raw index range visit should succeed");
1114
1115 assert_eq!(
1116 IndexStore::current_range_scan_call_count().saturating_sub(range_scans_before),
1117 1,
1118 "diagnostic index-store range-scan counter should count one range traversal probe",
1119 );
1120
1121 let entries_before = IndexStore::current_entry_read_count();
1122 store
1123 .visit_entries(|_key, _entry| Ok::<_, Infallible>(IndexStoreVisit::Continue))
1124 .expect("index entry visit should succeed");
1125
1126 assert_eq!(
1127 IndexStore::current_entry_read_count().saturating_sub(entries_before),
1128 2,
1129 "diagnostic index-store entry counter should count yielded traversal entries",
1130 );
1131 }
1132
1133 #[test]
1134 fn journaled_mixed_index_range_traversal_streams_without_snapshot() {
1135 let mut store = IndexStore::init_journaled(test_memory(93));
1136 for value in [1_u8, 3, 5] {
1137 store.insert(raw_key(value), IndexEntryValue::presence());
1138 }
1139 store
1140 .fold_journaled_materialized_view()
1141 .expect("canonical index seed should fold");
1142
1143 store.insert(raw_key(0), IndexEntryValue::presence());
1144 store.insert(raw_key(4), IndexEntryValue::presence());
1145 store.insert(raw_key(5), IndexEntryValue::presence());
1146 store.remove(&raw_key(1));
1147
1148 let lower = Bound::Included(raw_key(0));
1149 let upper = Bound::Included(raw_key(5));
1150
1151 reset_journaled_snapshot_call_count_for_tests();
1152 let mut asc = Vec::new();
1153 store
1154 .visit_journaled_entries_in_range((&lower, &upper), Direction::Asc, |key, _value| {
1155 asc.push(key.as_bytes()[0]);
1156 Ok::<_, Infallible>(asc.len() == 2)
1157 })
1158 .expect("asc journaled index range traversal should succeed");
1159 assert_eq!(asc, vec![0, 3]);
1160 assert_eq!(
1161 journaled_snapshot_call_count_for_tests(),
1162 0,
1163 "mixed journaled index range traversal should preserve early stop without materializing a snapshot",
1164 );
1165
1166 reset_journaled_snapshot_call_count_for_tests();
1167 let mut desc = Vec::new();
1168 store
1169 .visit_journaled_entries_in_range((&lower, &upper), Direction::Desc, |key, _value| {
1170 desc.push(key.as_bytes()[0]);
1171 Ok::<_, Infallible>(desc.len() == 2)
1172 })
1173 .expect("desc journaled index range traversal should succeed");
1174 assert_eq!(desc, vec![5, 4]);
1175 assert_eq!(
1176 journaled_snapshot_call_count_for_tests(),
1177 0,
1178 "mixed reverse journaled index range traversal should preserve early stop without materializing a snapshot",
1179 );
1180 }
1181}