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 canonical = StableBTreeMap::init(memory);
168 let prefix_cardinality = if canonical.is_empty() {
169 IndexPrefixCardinality::synchronized_empty()
170 } else {
171 IndexPrefixCardinality::unavailable()
172 };
173 Self {
174 backend: IndexStoreBackend::Journaled {
175 canonical,
176 live: HeapBTreeMap::new(),
177 tombstones: BTreeSet::new(),
178 },
179 generation: 0,
180 state: IndexState::Ready,
181 access_state_revision: 1,
182 prefix_cardinality,
185 }
186 }
187
188 pub(in crate::db) fn visit_entries<E>(
191 &self,
192 mut visitor: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<IndexStoreVisit, E>,
193 ) -> Result<(), E> {
194 match &self.backend {
195 IndexStoreBackend::Heap(map) => {
196 for (key, value) in map {
197 #[cfg(all(feature = "sql", feature = "diagnostics"))]
198 record_index_store_entry_read();
199
200 if visitor(key, value)?.should_stop() {
201 return Ok(());
202 }
203 }
204 }
205 IndexStoreBackend::Journaled {
206 canonical: _,
207 live: _,
208 tombstones: _,
209 } => self.visit_journaled_entries_in_range(
210 (&Bound::Unbounded, &Bound::Unbounded),
211 Direction::Asc,
212 |key, value| visitor(key, value).map(IndexStoreVisit::should_stop),
213 )?,
214 }
215
216 Ok(())
217 }
218
219 pub(in crate::db) fn get(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
220 #[cfg(all(feature = "sql", feature = "diagnostics"))]
221 record_index_store_get_call();
222
223 match &self.backend {
224 IndexStoreBackend::Heap(map) => map.get(key).cloned(),
225 IndexStoreBackend::Journaled { .. } => Self::journaled_get(&self.backend, key),
226 }
227 }
228
229 pub fn len(&self) -> u64 {
230 match &self.backend {
231 IndexStoreBackend::Heap(map) => u64::try_from(map.len()).unwrap_or(u64::MAX),
232 IndexStoreBackend::Journaled { .. } => {
233 let mut count = 0_u64;
234 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
235 count = count.saturating_add(1);
236 Ok(IndexStoreVisit::Continue)
237 });
238 count
239 }
240 }
241 }
242
243 pub fn is_empty(&self) -> bool {
244 match &self.backend {
245 IndexStoreBackend::Heap(map) => map.is_empty(),
246 IndexStoreBackend::Journaled { .. } => {
247 let mut empty = true;
248 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
249 empty = false;
250 Ok(IndexStoreVisit::Stop)
251 });
252 empty
253 }
254 }
255 }
256
257 #[must_use]
258 pub(in crate::db) const fn generation(&self) -> u64 {
259 self.generation
260 }
261
262 #[must_use]
264 pub(in crate::db) const fn state(&self) -> IndexState {
265 self.state
266 }
267
268 #[must_use]
270 pub(in crate::db) const fn access_state_revision(&self) -> u64 {
271 self.access_state_revision
272 }
273
274 #[must_use]
277 pub(in crate::db) fn exact_prefix_cardinality(
278 &self,
279 data_generation: u64,
280 key_kind: IndexKeyKind,
281 index_id: IndexId,
282 components: &[Vec<u8>],
283 ) -> Option<u64> {
284 self.prefix_cardinality
285 .exact_count(data_generation, key_kind, index_id, components)
286 }
287
288 #[must_use]
291 pub(in crate::db) fn exact_prefix_cardinality_sum<'a>(
292 &self,
293 data_generation: u64,
294 key_kind: IndexKeyKind,
295 index_id: IndexId,
296 component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
297 stop_after: Option<u64>,
298 ) -> Option<u64> {
299 self.prefix_cardinality.exact_count_sum(
300 data_generation,
301 key_kind,
302 index_id,
303 component_prefixes,
304 stop_after,
305 )
306 }
307
308 #[must_use]
311 pub(in crate::db) fn exact_child_prefixes_for_parent_set<'a>(
312 &self,
313 data_generation: u64,
314 key_kind: IndexKeyKind,
315 index_id: IndexId,
316 parent_component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
317 max_children: usize,
318 ) -> Option<Vec<Vec<Vec<u8>>>> {
319 self.prefix_cardinality.exact_child_prefixes_for_parent_set(
320 data_generation,
321 key_kind,
322 index_id,
323 parent_component_prefixes,
324 max_children,
325 )
326 }
327
328 pub(in crate::db) const fn mark_prefix_cardinality_data_generation(&mut self, generation: u64) {
331 self.prefix_cardinality.mark_synchronized(generation);
332 }
333
334 pub(in crate::db) const fn set_access_state(&mut self, state: IndexState, revision: u64) {
337 self.state = state;
338 self.access_state_revision = revision;
339 }
340
341 pub(crate) fn insert(
342 &mut self,
343 key: RawIndexStoreKey,
344 entry: IndexEntryValue,
345 ) -> Option<IndexEntryValue> {
346 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
347 self.get(&key)
348 } else {
349 None
350 };
351 let cardinality_key = key.clone();
352 let previous = match &mut self.backend {
353 IndexStoreBackend::Heap(map) => map.insert(key, entry.clone()),
354 IndexStoreBackend::Journaled {
355 live, tombstones, ..
356 } => {
357 tombstones.remove(&key);
358 live.insert(key, entry.clone());
359 previous_journaled
360 }
361 };
362 self.prefix_cardinality
363 .apply_insert(&cardinality_key, previous.as_ref(), &entry);
364 self.bump_generation();
365 previous
366 }
367
368 pub(crate) fn remove(&mut self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
369 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
370 self.get(key)
371 } else {
372 None
373 };
374 let previous = match &mut self.backend {
375 IndexStoreBackend::Heap(map) => map.remove(key),
376 IndexStoreBackend::Journaled {
377 live, tombstones, ..
378 } => {
379 live.remove(key);
380 tombstones.insert(key.clone());
381 previous_journaled
382 }
383 };
384 self.prefix_cardinality.apply_remove(key, previous.as_ref());
385 self.bump_generation();
386 previous
387 }
388
389 pub(in crate::db) fn reset_journaled_live_projection(
392 &mut self,
393 data_generation: u64,
394 ) -> Result<(), crate::error::InternalError> {
395 let IndexStoreBackend::Journaled {
396 canonical,
397 live,
398 tombstones,
399 } = &mut self.backend
400 else {
401 return Err(crate::error::InternalError::store_invariant());
402 };
403
404 live.clear();
405 tombstones.clear();
406 self.prefix_cardinality = if canonical.is_empty() {
407 let mut cardinality = IndexPrefixCardinality::synchronized_empty();
408 cardinality.mark_synchronized(data_generation);
409 cardinality
410 } else {
411 IndexPrefixCardinality::unavailable()
412 };
413 self.bump_generation();
414
415 Ok(())
416 }
417
418 pub(in crate::db) fn fold_recovered_journal_entry(
420 &mut self,
421 key: RawIndexStoreKey,
422 value: Option<IndexEntryValue>,
423 ) -> Result<(), crate::error::InternalError> {
424 let IndexStoreBackend::Journaled {
425 canonical,
426 live,
427 tombstones,
428 } = &mut self.backend
429 else {
430 return Err(crate::error::InternalError::store_invariant());
431 };
432
433 let visible = !live.contains_key(&key) && !tombstones.contains(&key);
434 let cardinality_key = key.clone();
435 let previous = if let Some(value) = value.as_ref() {
436 canonical.insert(key, value.clone())
437 } else {
438 canonical.remove(&key)
439 };
440 if visible {
441 if let Some(value) = value.as_ref() {
442 self.prefix_cardinality
443 .apply_insert(&cardinality_key, previous.as_ref(), value);
444 } else {
445 self.prefix_cardinality
446 .apply_remove(&cardinality_key, previous.as_ref());
447 }
448 }
449 self.bump_generation();
450
451 Ok(())
452 }
453
454 pub fn clear(&mut self) {
455 match &mut self.backend {
456 IndexStoreBackend::Heap(map) => map.clear(),
457 IndexStoreBackend::Journaled {
458 canonical,
459 live,
460 tombstones,
461 } => {
462 live.clear();
463 tombstones.clear();
464 for entry in canonical.iter() {
465 tombstones.insert(entry.key().clone());
466 }
467 }
468 }
469 self.prefix_cardinality.clear_unsynchronized();
470 self.bump_generation();
471 }
472
473 #[cfg(any(test, feature = "migration"))]
476 pub(in crate::db) fn fold_journaled_materialized_view(
477 &mut self,
478 ) -> Result<(), crate::error::InternalError> {
479 let entries = Self::journaled_entries_snapshot_for_fold(&self.backend);
480 let IndexStoreBackend::Journaled {
481 canonical,
482 live,
483 tombstones,
484 } = &mut self.backend
485 else {
486 return Err(crate::error::InternalError::store_invariant());
487 };
488
489 canonical.clear_new();
490 for (key, value) in entries {
491 canonical.insert(key, value);
492 }
493 live.clear();
494 tombstones.clear();
495 let data_generation = self.prefix_cardinality.synchronized_generation();
496 self.rebuild_prefix_cardinality_from_entries(data_generation);
497 self.bump_generation();
498
499 Ok(())
500 }
501
502 pub fn memory_bytes(&self) -> u64 {
504 let mut bytes = 0u64;
505 let _: Result<(), std::convert::Infallible> = self.visit_entries(|key, value| {
506 bytes = bytes.saturating_add(key.as_bytes().len() as u64 + value.len() as u64);
507 Ok(IndexStoreVisit::Continue)
508 });
509 bytes
510 }
511
512 #[cfg(all(feature = "sql", feature = "diagnostics"))]
514 pub(in crate::db) fn current_get_call_count() -> u64 {
515 INDEX_STORE_GET_CALL_COUNT.with(Cell::get)
516 }
517
518 #[cfg(all(feature = "sql", feature = "diagnostics"))]
520 pub(in crate::db) fn current_range_scan_call_count() -> u64 {
521 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(Cell::get)
522 }
523
524 #[cfg(all(feature = "sql", feature = "diagnostics"))]
526 pub(in crate::db) fn current_entry_read_count() -> u64 {
527 INDEX_STORE_ENTRY_READ_COUNT.with(Cell::get)
528 }
529
530 #[cfg(all(feature = "sql", feature = "diagnostics"))]
531 pub(in crate::db::index) fn record_range_scan_call() {
532 record_index_store_range_scan_call();
533 }
534
535 #[cfg(all(feature = "sql", feature = "diagnostics"))]
536 pub(in crate::db::index) fn record_merged_entry_reads(count: u64) {
537 INDEX_STORE_ENTRY_READ_COUNT.with(|total| {
538 total.set(total.get().saturating_add(count));
539 });
540 }
541
542 const fn bump_generation(&mut self) {
543 self.generation = self.generation.saturating_add(1);
544 }
545
546 #[cfg(any(test, feature = "migration"))]
547 fn rebuild_prefix_cardinality_from_entries(&mut self, data_generation: Option<u64>) {
548 self.prefix_cardinality.clear_unsynchronized();
549 let entries = Self::entries_snapshot_for_cardinality(&self.backend);
550 for (key, value) in &entries {
551 self.prefix_cardinality.apply_insert(key, None, value);
552 }
553 if let Some(data_generation) = data_generation {
554 self.prefix_cardinality.mark_synchronized(data_generation);
555 }
556 }
557
558 #[cfg(any(test, feature = "migration"))]
559 fn entries_snapshot_for_cardinality(
560 backend: &IndexStoreBackend,
561 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
562 match backend {
563 IndexStoreBackend::Heap(map) => map.clone(),
564 IndexStoreBackend::Journaled { .. } => {
565 Self::journaled_entries_snapshot_for_fold(backend)
566 }
567 }
568 }
569
570 fn journaled_get(
571 backend: &IndexStoreBackend,
572 key: &RawIndexStoreKey,
573 ) -> Option<IndexEntryValue> {
574 let IndexStoreBackend::Journaled {
575 canonical,
576 live,
577 tombstones,
578 } = backend
579 else {
580 return None;
581 };
582
583 if tombstones.contains(key) {
584 return None;
585 }
586 live.get(key).cloned().or_else(|| canonical.get(key))
587 }
588
589 #[cfg(any(test, feature = "migration"))]
590 pub(super) fn journaled_entries_snapshot_for_fold(
591 backend: &IndexStoreBackend,
592 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
593 #[cfg(test)]
594 record_journaled_snapshot_call();
595
596 let IndexStoreBackend::Journaled {
597 canonical,
598 live,
599 tombstones,
600 } = backend
601 else {
602 return HeapBTreeMap::new();
603 };
604
605 let mut entries = HeapBTreeMap::new();
606 for entry in canonical.iter() {
607 let key = entry.key().clone();
608 if !tombstones.contains(&key) {
609 entries.insert(key, entry.value());
610 }
611 }
612 for (key, value) in live {
613 if !tombstones.contains(key) {
614 entries.insert(key.clone(), value.clone());
615 }
616 }
617
618 entries
619 }
620
621 pub(super) fn visit_journaled_entries_in_range<E>(
622 &self,
623 bounds: (&Bound<RawIndexStoreKey>, &Bound<RawIndexStoreKey>),
624 direction: Direction,
625 mut visit: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
626 ) -> Result<(), E> {
627 let IndexStoreBackend::Journaled {
628 canonical,
629 live,
630 tombstones,
631 } = &self.backend
632 else {
633 return Ok(());
634 };
635
636 let lower = bounds.0.clone();
637 let upper = bounds.1.clone();
638 match direction {
639 Direction::Asc if canonical.is_empty() => {
640 for (key, value) in live.range((lower, upper)) {
641 if visit_index_store_entry(key, value, &mut visit)? {
642 return Ok(());
643 }
644 }
645 }
646 Direction::Desc if canonical.is_empty() => {
647 for (key, value) in live.range((lower, upper)).rev() {
648 if visit_index_store_entry(key, value, &mut visit)? {
649 return Ok(());
650 }
651 }
652 }
653 Direction::Asc if live.is_empty() && tombstones.is_empty() => {
654 for entry in canonical.range((lower, upper)) {
655 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
656 return Ok(());
657 }
658 }
659 }
660 Direction::Desc if live.is_empty() && tombstones.is_empty() => {
661 for entry in canonical.range((lower, upper)).rev() {
662 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
663 return Ok(());
664 }
665 }
666 }
667 Direction::Asc => {
668 visit_ordered_overlay(
669 canonical.range((lower.clone(), upper.clone())),
670 live.range((lower, upper)),
671 direction,
672 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
673 |canonical_entry| !tombstones.contains(canonical_entry.key()),
674 |live_entry| !tombstones.contains(live_entry.0),
675 |entry| {
676 let should_stop = match entry {
677 OrderedOverlayEntry::Canonical(canonical_entry) => {
678 visit_index_store_entry(
679 canonical_entry.key(),
680 &canonical_entry.value(),
681 &mut visit,
682 )?
683 }
684 OrderedOverlayEntry::Live((key, value)) => {
685 visit_index_store_entry(key, value, &mut visit)?
686 }
687 };
688 Ok(if should_stop {
689 OrderedOverlayVisit::Stop
690 } else {
691 OrderedOverlayVisit::Continue
692 })
693 },
694 )?;
695 }
696 Direction::Desc => {
697 visit_ordered_overlay(
698 canonical.range((lower.clone(), upper.clone())).rev(),
699 live.range((lower, upper)).rev(),
700 direction,
701 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
702 |canonical_entry| !tombstones.contains(canonical_entry.key()),
703 |live_entry| !tombstones.contains(live_entry.0),
704 |entry| {
705 let should_stop = match entry {
706 OrderedOverlayEntry::Canonical(canonical_entry) => {
707 visit_index_store_entry(
708 canonical_entry.key(),
709 &canonical_entry.value(),
710 &mut visit,
711 )?
712 }
713 OrderedOverlayEntry::Live((key, value)) => {
714 visit_index_store_entry(key, value, &mut visit)?
715 }
716 };
717 Ok(if should_stop {
718 OrderedOverlayVisit::Stop
719 } else {
720 OrderedOverlayVisit::Continue
721 })
722 },
723 )?;
724 }
725 }
726
727 Ok(())
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use crate::{
735 db::{
736 direction::Direction,
737 index::{IndexId, IndexKey, IndexKeyKind},
738 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
739 },
740 testing::test_memory,
741 types::EntityTag,
742 };
743 use ic_stable_structures::Storable;
744 use std::{borrow::Cow, convert::Infallible};
745
746 fn raw_key(value: u8) -> RawIndexStoreKey {
747 <RawIndexStoreKey as Storable>::from_bytes(Cow::Owned(vec![value]))
748 }
749
750 fn indexed_raw_key(
751 index_id: &IndexId,
752 components: Vec<Vec<u8>>,
753 primary_key: u64,
754 ) -> RawIndexStoreKey {
755 indexed_raw_key_with_kind(index_id, IndexKeyKind::User, components, primary_key)
756 }
757
758 fn indexed_raw_key_with_kind(
759 index_id: &IndexId,
760 key_kind: IndexKeyKind,
761 components: Vec<Vec<u8>>,
762 primary_key: u64,
763 ) -> RawIndexStoreKey {
764 IndexKey::new_from_components_with_primary_key_value(
765 index_id,
766 key_kind,
767 components.as_slice(),
768 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(primary_key)),
769 )
770 .expect("test index key should build")
771 .to_raw()
772 .expect("test index key should encode")
773 }
774
775 fn malformed_index_entry_value() -> IndexEntryValue {
776 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![0xFF]))
777 }
778
779 fn missing_index_entry_value() -> IndexEntryValue {
780 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![1]))
781 }
782
783 #[test]
784 fn index_prefix_cardinality_requires_explicit_data_generation_sync() {
785 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
786 let collection = b"collection-a".to_vec();
787 let draft = b"Draft".to_vec();
788 let review = b"Review".to_vec();
789 let mut store = IndexStore::init_heap();
790
791 store.insert(
792 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
793 IndexEntryValue::presence(),
794 );
795 store.insert(
796 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
797 IndexEntryValue::presence(),
798 );
799 store.insert(
800 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
801 IndexEntryValue::presence(),
802 );
803
804 assert_eq!(
805 store.exact_prefix_cardinality(
806 0,
807 IndexKeyKind::User,
808 index_id,
809 std::slice::from_ref(&collection),
810 ),
811 None,
812 "raw index mutations must not be trusted until row generation sync is stamped",
813 );
814
815 store.mark_prefix_cardinality_data_generation(7);
816
817 assert_eq!(
818 store.exact_prefix_cardinality(
819 7,
820 IndexKeyKind::User,
821 index_id,
822 std::slice::from_ref(&collection),
823 ),
824 Some(3),
825 );
826 assert_eq!(
827 store.exact_prefix_cardinality(
828 7,
829 IndexKeyKind::User,
830 index_id,
831 &[collection.clone(), draft],
832 ),
833 Some(2),
834 );
835 assert_eq!(
836 store.exact_prefix_cardinality(8, IndexKeyKind::User, index_id, &[collection, review],),
837 None,
838 "row generation drift should force the caller to use the existing-row fallback",
839 );
840 }
841
842 #[test]
843 fn index_prefix_cardinality_enumerates_bounded_child_prefixes() {
844 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
845 let collection = b"collection-a".to_vec();
846 let other_collection = b"collection-b".to_vec();
847 let draft = b"Draft".to_vec();
848 let review = b"Review".to_vec();
849 let published = b"Published".to_vec();
850 let mut store = IndexStore::init_heap();
851
852 store.insert(
853 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
854 IndexEntryValue::presence(),
855 );
856 store.insert(
857 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
858 IndexEntryValue::presence(),
859 );
860 store.insert(
861 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
862 IndexEntryValue::presence(),
863 );
864 store.insert(
865 indexed_raw_key(
866 &index_id,
867 vec![other_collection.clone(), published.clone()],
868 4,
869 ),
870 IndexEntryValue::presence(),
871 );
872 store.mark_prefix_cardinality_data_generation(7);
873
874 assert_eq!(
875 store.exact_child_prefixes_for_parent_set(
876 7,
877 IndexKeyKind::User,
878 index_id,
879 [std::slice::from_ref(&collection)],
880 4,
881 ),
882 Some(vec![
883 vec![collection.clone(), draft],
884 vec![collection.clone(), review],
885 ]),
886 "child-prefix enumeration should return deterministic unique children under the requested parent",
887 );
888 assert_eq!(
889 store.exact_child_prefixes_for_parent_set(
890 7,
891 IndexKeyKind::User,
892 index_id,
893 [std::slice::from_ref(&other_collection)],
894 4,
895 ),
896 Some(vec![vec![other_collection, published]]),
897 "child-prefix enumeration must stay scoped to the requested parent prefix",
898 );
899 assert_eq!(
900 store.exact_child_prefixes_for_parent_set(
901 8,
902 IndexKeyKind::User,
903 index_id,
904 [std::slice::from_ref(&collection)],
905 4,
906 ),
907 None,
908 "row generation drift should keep child-prefix expansion fail-closed",
909 );
910 assert_eq!(
911 store.exact_child_prefixes_for_parent_set(
912 7,
913 IndexKeyKind::User,
914 index_id,
915 [std::slice::from_ref(&collection)],
916 1,
917 ),
918 None,
919 "over-cap child-prefix expansion should fall back to the existing route",
920 );
921 }
922
923 #[test]
924 fn index_prefix_cardinality_batches_sparse_child_prefixes() {
925 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
926 let collection = b"collection-a".to_vec();
927 let other_collection = b"collection-b".to_vec();
928 let missing_a = b"missing-a".to_vec();
929 let missing_b = b"missing-b".to_vec();
930 let draft = b"Draft".to_vec();
931 let review = b"Review".to_vec();
932 let published = b"Published".to_vec();
933 let mut store = IndexStore::init_heap();
934
935 store.insert(
936 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
937 IndexEntryValue::presence(),
938 );
939 store.insert(
940 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 2),
941 IndexEntryValue::presence(),
942 );
943 store.insert(
944 indexed_raw_key(
945 &index_id,
946 vec![other_collection.clone(), published.clone()],
947 3,
948 ),
949 IndexEntryValue::presence(),
950 );
951 store.mark_prefix_cardinality_data_generation(7);
952
953 let parents = [
954 std::slice::from_ref(&missing_a),
955 std::slice::from_ref(&collection),
956 std::slice::from_ref(&missing_b),
957 std::slice::from_ref(&other_collection),
958 ];
959 assert_eq!(
960 store.exact_child_prefixes_for_parent_set(7, IndexKeyKind::User, index_id, parents, 4,),
961 Some(vec![
962 vec![collection.clone(), draft],
963 vec![collection.clone(), review],
964 vec![other_collection.clone(), published],
965 ]),
966 "batched child-prefix enumeration should skip missing sparse parents and return deterministic real children",
967 );
968 assert_eq!(
969 store.exact_child_prefixes_for_parent_set(
970 7,
971 IndexKeyKind::User,
972 index_id,
973 [
974 std::slice::from_ref(&missing_a),
975 std::slice::from_ref(&missing_b)
976 ],
977 4,
978 ),
979 Some(Vec::new()),
980 "missing-only sparse parent sets should be proven empty when cardinality is synchronized",
981 );
982 assert_eq!(
983 store.exact_child_prefixes_for_parent_set(
984 7,
985 IndexKeyKind::User,
986 index_id,
987 [
988 std::slice::from_ref(&collection),
989 std::slice::from_ref(&other_collection)
990 ],
991 2,
992 ),
993 None,
994 "over-cap sparse parent-set expansion should fail closed",
995 );
996 assert_eq!(
997 store.exact_child_prefixes_for_parent_set(
998 8,
999 IndexKeyKind::User,
1000 index_id,
1001 [std::slice::from_ref(&collection)],
1002 4,
1003 ),
1004 None,
1005 "generation drift should keep batched child-prefix expansion fail-closed",
1006 );
1007 }
1008
1009 #[test]
1010 fn index_prefix_cardinality_ignores_system_index_mutations() {
1011 let user_index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1012 let system_index_id = IndexId::new(EntityTag::new(0xCA7D), 2);
1013 let collection = b"collection-a".to_vec();
1014 let draft = b"Draft".to_vec();
1015 let system_component = b"reverse-edge".to_vec();
1016 let mut store = IndexStore::init_heap();
1017
1018 store.insert(
1019 indexed_raw_key(&user_index_id, vec![collection.clone(), draft.clone()], 1),
1020 IndexEntryValue::presence(),
1021 );
1022 store.mark_prefix_cardinality_data_generation(7);
1023
1024 assert_eq!(
1025 store.exact_prefix_cardinality(
1026 7,
1027 IndexKeyKind::User,
1028 user_index_id,
1029 &[collection.clone(), draft.clone()],
1030 ),
1031 Some(1),
1032 );
1033
1034 let system_key = indexed_raw_key_with_kind(
1035 &system_index_id,
1036 IndexKeyKind::System,
1037 vec![system_component],
1038 1,
1039 );
1040 store.insert(system_key.clone(), IndexEntryValue::presence());
1041 assert_eq!(
1042 store.exact_prefix_cardinality(
1043 7,
1044 IndexKeyKind::User,
1045 user_index_id,
1046 &[collection.clone(), draft.clone()],
1047 ),
1048 Some(1),
1049 "system index writes must not invalidate synchronized user-prefix cardinality",
1050 );
1051
1052 store.remove(&system_key);
1053 assert_eq!(
1054 store.exact_prefix_cardinality(
1055 7,
1056 IndexKeyKind::User,
1057 user_index_id,
1058 &[collection.clone(), draft.clone()],
1059 ),
1060 Some(1),
1061 "system index removals must not invalidate synchronized user-prefix cardinality",
1062 );
1063
1064 let malformed_system_key = indexed_raw_key_with_kind(
1065 &system_index_id,
1066 IndexKeyKind::System,
1067 vec![b"malformed-reverse-edge".to_vec()],
1068 2,
1069 );
1070 store.insert(malformed_system_key.clone(), malformed_index_entry_value());
1071 assert_eq!(
1072 store.exact_prefix_cardinality(
1073 7,
1074 IndexKeyKind::User,
1075 user_index_id,
1076 &[collection.clone(), draft.clone()],
1077 ),
1078 Some(1),
1079 "malformed system index payloads must not invalidate user-prefix cardinality",
1080 );
1081
1082 store.remove(&malformed_system_key);
1083 assert_eq!(
1084 store.exact_prefix_cardinality(
1085 7,
1086 IndexKeyKind::User,
1087 user_index_id,
1088 &[collection.clone(), draft],
1089 ),
1090 Some(1),
1091 "malformed system index removals must not invalidate user-prefix cardinality",
1092 );
1093
1094 let review = b"Review".to_vec();
1095 store.insert(
1096 indexed_raw_key(&user_index_id, vec![collection.clone(), review.clone()], 2),
1097 IndexEntryValue::presence(),
1098 );
1099 assert_eq!(
1100 store.exact_prefix_cardinality(
1101 7,
1102 IndexKeyKind::User,
1103 user_index_id,
1104 &[collection, review]
1105 ),
1106 None,
1107 "user-prefix count changes must still require a fresh row-generation stamp",
1108 );
1109 }
1110
1111 #[test]
1112 fn index_prefix_cardinality_ignores_missing_user_index_mutations() {
1113 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1114 let collection = b"collection-a".to_vec();
1115 let draft = b"Draft".to_vec();
1116 let mut store = IndexStore::init_heap();
1117
1118 store.insert(
1119 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1120 IndexEntryValue::presence(),
1121 );
1122 store.mark_prefix_cardinality_data_generation(7);
1123
1124 let stale_key = indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2);
1125 store.insert(stale_key.clone(), missing_index_entry_value());
1126 assert_eq!(
1127 store.exact_prefix_cardinality(
1128 7,
1129 IndexKeyKind::User,
1130 index_id,
1131 &[collection.clone(), draft.clone()],
1132 ),
1133 Some(1),
1134 "missing user index entries must not affect synchronized prefix cardinality",
1135 );
1136
1137 store.remove(&stale_key);
1138 assert_eq!(
1139 store.exact_prefix_cardinality(7, IndexKeyKind::User, index_id, &[collection, draft],),
1140 Some(1),
1141 "missing user index removals must not affect synchronized prefix cardinality",
1142 );
1143 }
1144
1145 #[cfg(all(feature = "sql", feature = "diagnostics"))]
1146 #[test]
1147 fn index_store_diagnostic_counters_record_gets_range_scans_and_entry_reads() {
1148 let mut store = IndexStore::init_heap();
1149 store.insert(raw_key(7), IndexEntryValue::presence());
1150 store.insert(raw_key(9), IndexEntryValue::presence());
1151
1152 let gets_before = IndexStore::current_get_call_count();
1153 assert_eq!(store.get(&raw_key(7)), Some(IndexEntryValue::presence()));
1154 assert_eq!(store.get(&raw_key(8)), None);
1155
1156 assert_eq!(
1157 IndexStore::current_get_call_count().saturating_sub(gets_before),
1158 2,
1159 "diagnostic index-store get counter should count both hit and miss reads",
1160 );
1161
1162 let range_scans_before = IndexStore::current_range_scan_call_count();
1163 let lower = Bound::Included(raw_key(7));
1164 let upper = Bound::Included(raw_key(9));
1165 store
1166 .visit_raw_entries_in_range((&lower, &upper), Direction::Asc, |_key, _entry| Ok(false))
1167 .expect("raw index range visit should succeed");
1168
1169 assert_eq!(
1170 IndexStore::current_range_scan_call_count().saturating_sub(range_scans_before),
1171 1,
1172 "diagnostic index-store range-scan counter should count one range traversal probe",
1173 );
1174
1175 let entries_before = IndexStore::current_entry_read_count();
1176 store
1177 .visit_entries(|_key, _entry| Ok::<_, Infallible>(IndexStoreVisit::Continue))
1178 .expect("index entry visit should succeed");
1179
1180 assert_eq!(
1181 IndexStore::current_entry_read_count().saturating_sub(entries_before),
1182 2,
1183 "diagnostic index-store entry counter should count yielded traversal entries",
1184 );
1185 }
1186
1187 #[test]
1188 fn journaled_mixed_index_range_traversal_streams_without_snapshot() {
1189 let mut store = IndexStore::init_journaled(test_memory(93));
1190 for value in [1_u8, 3, 5] {
1191 store.insert(raw_key(value), IndexEntryValue::presence());
1192 }
1193 store
1194 .fold_journaled_materialized_view()
1195 .expect("canonical index seed should fold");
1196
1197 store.insert(raw_key(0), IndexEntryValue::presence());
1198 store.insert(raw_key(4), IndexEntryValue::presence());
1199 store.insert(raw_key(5), IndexEntryValue::presence());
1200 store.remove(&raw_key(1));
1201
1202 let lower = Bound::Included(raw_key(0));
1203 let upper = Bound::Included(raw_key(5));
1204
1205 reset_journaled_snapshot_call_count_for_tests();
1206 let mut asc = Vec::new();
1207 store
1208 .visit_journaled_entries_in_range((&lower, &upper), Direction::Asc, |key, _value| {
1209 asc.push(key.as_bytes()[0]);
1210 Ok::<_, Infallible>(asc.len() == 2)
1211 })
1212 .expect("asc journaled index range traversal should succeed");
1213 assert_eq!(asc, vec![0, 3]);
1214 assert_eq!(
1215 journaled_snapshot_call_count_for_tests(),
1216 0,
1217 "mixed journaled index range traversal should preserve early stop without materializing a snapshot",
1218 );
1219
1220 reset_journaled_snapshot_call_count_for_tests();
1221 let mut desc = Vec::new();
1222 store
1223 .visit_journaled_entries_in_range((&lower, &upper), Direction::Desc, |key, _value| {
1224 desc.push(key.as_bytes()[0]);
1225 Ok::<_, Infallible>(desc.len() == 2)
1226 })
1227 .expect("desc journaled index range traversal should succeed");
1228 assert_eq!(desc, vec![5, 4]);
1229 assert_eq!(
1230 journaled_snapshot_call_count_for_tests(),
1231 0,
1232 "mixed reverse journaled index range traversal should preserve early stop without materializing a snapshot",
1233 );
1234 }
1235
1236 #[test]
1237 fn journaled_index_store_reopens_without_materializing_prefix_cardinality() {
1238 let memory = test_memory(94);
1239 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1240 let collection = b"collection-a".to_vec();
1241 let mut store = IndexStore::init_journaled(memory.clone());
1242 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1243 store.insert(key.clone(), IndexEntryValue::presence());
1244 store
1245 .fold_journaled_materialized_view()
1246 .expect("canonical index seed should fold");
1247 drop(store);
1248
1249 let reopened = IndexStore::init_journaled(memory);
1250
1251 assert_eq!(reopened.get(&key), Some(IndexEntryValue::presence()));
1252 assert_eq!(
1253 reopened.exact_prefix_cardinality(
1254 0,
1255 IndexKeyKind::User,
1256 index_id,
1257 std::slice::from_ref(&collection),
1258 ),
1259 None,
1260 "startup must leave optional prefix cardinality unavailable without scanning stable entries",
1261 );
1262 }
1263
1264 #[test]
1265 fn empty_journaled_index_store_retains_exact_prefix_cardinality_without_scanning() {
1266 let memory = test_memory(95);
1267 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1268 let collection = b"collection-a".to_vec();
1269 let mut store = IndexStore::init_journaled(memory.clone());
1270
1271 assert_eq!(
1272 store.exact_prefix_cardinality(
1273 0,
1274 IndexKeyKind::User,
1275 index_id,
1276 std::slice::from_ref(&collection),
1277 ),
1278 Some(0),
1279 );
1280 store
1281 .reset_journaled_live_projection(7)
1282 .expect("empty projection reset should succeed");
1283 assert_eq!(
1284 store.exact_prefix_cardinality(
1285 7,
1286 IndexKeyKind::User,
1287 index_id,
1288 std::slice::from_ref(&collection),
1289 ),
1290 Some(0),
1291 );
1292 drop(store);
1293
1294 let reopened = IndexStore::init_journaled(memory);
1295 assert_eq!(
1296 reopened.exact_prefix_cardinality(
1297 0,
1298 IndexKeyKind::User,
1299 index_id,
1300 std::slice::from_ref(&collection),
1301 ),
1302 Some(0),
1303 );
1304 }
1305
1306 #[test]
1307 fn recovered_index_fold_maintains_available_prefix_cardinality() {
1308 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1309 let collection = b"collection-a".to_vec();
1310 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1311 let mut store = IndexStore::init_journaled(test_memory(96));
1312
1313 store
1314 .fold_recovered_journal_entry(key.clone(), Some(IndexEntryValue::presence()))
1315 .expect("recovered index put should fold");
1316 store.mark_prefix_cardinality_data_generation(1);
1317 assert_eq!(
1318 store.exact_prefix_cardinality(
1319 1,
1320 IndexKeyKind::User,
1321 index_id,
1322 std::slice::from_ref(&collection),
1323 ),
1324 Some(1),
1325 );
1326
1327 store
1328 .fold_recovered_journal_entry(key, None)
1329 .expect("recovered index delete should fold");
1330 store.mark_prefix_cardinality_data_generation(2);
1331 assert_eq!(
1332 store.exact_prefix_cardinality(
1333 2,
1334 IndexKeyKind::User,
1335 index_id,
1336 std::slice::from_ref(&collection),
1337 ),
1338 Some(0),
1339 );
1340 }
1341}