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