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(in crate::db) fn preflight_fold_recovered_journal(
456 &self,
457 ) -> Result<(), crate::error::InternalError> {
458 match self.backend {
459 IndexStoreBackend::Journaled { .. } => Ok(()),
460 IndexStoreBackend::Heap(_) => Err(crate::error::InternalError::store_invariant()),
461 }
462 }
463
464 pub fn clear(&mut self) {
465 match &mut self.backend {
466 IndexStoreBackend::Heap(map) => map.clear(),
467 IndexStoreBackend::Journaled {
468 canonical,
469 live,
470 tombstones,
471 } => {
472 live.clear();
473 tombstones.clear();
474 for entry in canonical.iter() {
475 tombstones.insert(entry.key().clone());
476 }
477 }
478 }
479 self.prefix_cardinality.clear_unsynchronized();
480 self.bump_generation();
481 }
482
483 #[cfg(any(test, feature = "migration"))]
486 pub(in crate::db) fn fold_journaled_materialized_view(
487 &mut self,
488 ) -> Result<(), crate::error::InternalError> {
489 let entries = Self::journaled_entries_snapshot_for_fold(&self.backend);
490 let IndexStoreBackend::Journaled {
491 canonical,
492 live,
493 tombstones,
494 } = &mut self.backend
495 else {
496 return Err(crate::error::InternalError::store_invariant());
497 };
498
499 canonical.clear_new();
500 for (key, value) in entries {
501 canonical.insert(key, value);
502 }
503 live.clear();
504 tombstones.clear();
505 let data_generation = self.prefix_cardinality.synchronized_generation();
506 self.rebuild_prefix_cardinality_from_entries(data_generation);
507 self.bump_generation();
508
509 Ok(())
510 }
511
512 pub fn memory_bytes(&self) -> u64 {
514 let mut bytes = 0u64;
515 let _: Result<(), std::convert::Infallible> = self.visit_entries(|key, value| {
516 bytes = bytes.saturating_add(key.as_bytes().len() as u64 + value.len() as u64);
517 Ok(IndexStoreVisit::Continue)
518 });
519 bytes
520 }
521
522 #[cfg(all(feature = "sql", feature = "diagnostics"))]
524 pub(in crate::db) fn current_get_call_count() -> u64 {
525 INDEX_STORE_GET_CALL_COUNT.with(Cell::get)
526 }
527
528 #[cfg(all(feature = "sql", feature = "diagnostics"))]
530 pub(in crate::db) fn current_range_scan_call_count() -> u64 {
531 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(Cell::get)
532 }
533
534 #[cfg(all(feature = "sql", feature = "diagnostics"))]
536 pub(in crate::db) fn current_entry_read_count() -> u64 {
537 INDEX_STORE_ENTRY_READ_COUNT.with(Cell::get)
538 }
539
540 #[cfg(all(feature = "sql", feature = "diagnostics"))]
541 pub(in crate::db::index) fn record_range_scan_call() {
542 record_index_store_range_scan_call();
543 }
544
545 #[cfg(all(feature = "sql", feature = "diagnostics"))]
546 pub(in crate::db::index) fn record_merged_entry_reads(count: u64) {
547 INDEX_STORE_ENTRY_READ_COUNT.with(|total| {
548 total.set(total.get().saturating_add(count));
549 });
550 }
551
552 const fn bump_generation(&mut self) {
553 self.generation = self.generation.saturating_add(1);
554 }
555
556 #[cfg(any(test, feature = "migration"))]
557 fn rebuild_prefix_cardinality_from_entries(&mut self, data_generation: Option<u64>) {
558 self.prefix_cardinality.clear_unsynchronized();
559 let entries = Self::entries_snapshot_for_cardinality(&self.backend);
560 for (key, value) in &entries {
561 self.prefix_cardinality.apply_insert(key, None, value);
562 }
563 if let Some(data_generation) = data_generation {
564 self.prefix_cardinality.mark_synchronized(data_generation);
565 }
566 }
567
568 #[cfg(any(test, feature = "migration"))]
569 fn entries_snapshot_for_cardinality(
570 backend: &IndexStoreBackend,
571 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
572 match backend {
573 IndexStoreBackend::Heap(map) => map.clone(),
574 IndexStoreBackend::Journaled { .. } => {
575 Self::journaled_entries_snapshot_for_fold(backend)
576 }
577 }
578 }
579
580 fn journaled_get(
581 backend: &IndexStoreBackend,
582 key: &RawIndexStoreKey,
583 ) -> Option<IndexEntryValue> {
584 let IndexStoreBackend::Journaled {
585 canonical,
586 live,
587 tombstones,
588 } = backend
589 else {
590 return None;
591 };
592
593 if tombstones.contains(key) {
594 return None;
595 }
596 live.get(key).cloned().or_else(|| canonical.get(key))
597 }
598
599 #[cfg(any(test, feature = "migration"))]
600 pub(super) fn journaled_entries_snapshot_for_fold(
601 backend: &IndexStoreBackend,
602 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
603 #[cfg(test)]
604 record_journaled_snapshot_call();
605
606 let IndexStoreBackend::Journaled {
607 canonical,
608 live,
609 tombstones,
610 } = backend
611 else {
612 return HeapBTreeMap::new();
613 };
614
615 let mut entries = HeapBTreeMap::new();
616 for entry in canonical.iter() {
617 let key = entry.key().clone();
618 if !tombstones.contains(&key) {
619 entries.insert(key, entry.value());
620 }
621 }
622 for (key, value) in live {
623 if !tombstones.contains(key) {
624 entries.insert(key.clone(), value.clone());
625 }
626 }
627
628 entries
629 }
630
631 pub(super) fn visit_journaled_entries_in_range<E>(
632 &self,
633 bounds: (&Bound<RawIndexStoreKey>, &Bound<RawIndexStoreKey>),
634 direction: Direction,
635 mut visit: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
636 ) -> Result<(), E> {
637 let IndexStoreBackend::Journaled {
638 canonical,
639 live,
640 tombstones,
641 } = &self.backend
642 else {
643 return Ok(());
644 };
645
646 let lower = bounds.0.clone();
647 let upper = bounds.1.clone();
648 match direction {
649 Direction::Asc if canonical.is_empty() => {
650 for (key, value) in live.range((lower, upper)) {
651 if visit_index_store_entry(key, value, &mut visit)? {
652 return Ok(());
653 }
654 }
655 }
656 Direction::Desc if canonical.is_empty() => {
657 for (key, value) in live.range((lower, upper)).rev() {
658 if visit_index_store_entry(key, value, &mut visit)? {
659 return Ok(());
660 }
661 }
662 }
663 Direction::Asc if live.is_empty() && tombstones.is_empty() => {
664 for entry in canonical.range((lower, upper)) {
665 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
666 return Ok(());
667 }
668 }
669 }
670 Direction::Desc if live.is_empty() && tombstones.is_empty() => {
671 for entry in canonical.range((lower, upper)).rev() {
672 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
673 return Ok(());
674 }
675 }
676 }
677 Direction::Asc => {
678 visit_ordered_overlay(
679 canonical.range((lower.clone(), upper.clone())),
680 live.range((lower, upper)),
681 direction,
682 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
683 |canonical_entry| !tombstones.contains(canonical_entry.key()),
684 |live_entry| !tombstones.contains(live_entry.0),
685 |entry| {
686 let should_stop = match entry {
687 OrderedOverlayEntry::Canonical(canonical_entry) => {
688 visit_index_store_entry(
689 canonical_entry.key(),
690 &canonical_entry.value(),
691 &mut visit,
692 )?
693 }
694 OrderedOverlayEntry::Live((key, value)) => {
695 visit_index_store_entry(key, value, &mut visit)?
696 }
697 };
698 Ok(if should_stop {
699 OrderedOverlayVisit::Stop
700 } else {
701 OrderedOverlayVisit::Continue
702 })
703 },
704 )?;
705 }
706 Direction::Desc => {
707 visit_ordered_overlay(
708 canonical.range((lower.clone(), upper.clone())).rev(),
709 live.range((lower, upper)).rev(),
710 direction,
711 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
712 |canonical_entry| !tombstones.contains(canonical_entry.key()),
713 |live_entry| !tombstones.contains(live_entry.0),
714 |entry| {
715 let should_stop = match entry {
716 OrderedOverlayEntry::Canonical(canonical_entry) => {
717 visit_index_store_entry(
718 canonical_entry.key(),
719 &canonical_entry.value(),
720 &mut visit,
721 )?
722 }
723 OrderedOverlayEntry::Live((key, value)) => {
724 visit_index_store_entry(key, value, &mut visit)?
725 }
726 };
727 Ok(if should_stop {
728 OrderedOverlayVisit::Stop
729 } else {
730 OrderedOverlayVisit::Continue
731 })
732 },
733 )?;
734 }
735 }
736
737 Ok(())
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use crate::{
745 db::{
746 direction::Direction,
747 index::{IndexId, IndexKey, IndexKeyKind},
748 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
749 },
750 testing::test_memory,
751 types::EntityTag,
752 };
753 use ic_stable_structures::Storable;
754 use std::{borrow::Cow, convert::Infallible};
755
756 fn raw_key(value: u8) -> RawIndexStoreKey {
757 <RawIndexStoreKey as Storable>::from_bytes(Cow::Owned(vec![value]))
758 }
759
760 fn indexed_raw_key(
761 index_id: &IndexId,
762 components: Vec<Vec<u8>>,
763 primary_key: u64,
764 ) -> RawIndexStoreKey {
765 indexed_raw_key_with_kind(index_id, IndexKeyKind::User, components, primary_key)
766 }
767
768 fn indexed_raw_key_with_kind(
769 index_id: &IndexId,
770 key_kind: IndexKeyKind,
771 components: Vec<Vec<u8>>,
772 primary_key: u64,
773 ) -> RawIndexStoreKey {
774 IndexKey::new_from_components_with_primary_key_value(
775 index_id,
776 key_kind,
777 components.as_slice(),
778 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(primary_key)),
779 )
780 .expect("test index key should build")
781 .to_raw()
782 .expect("test index key should encode")
783 }
784
785 fn malformed_index_entry_value() -> IndexEntryValue {
786 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![0xFF]))
787 }
788
789 fn missing_index_entry_value() -> IndexEntryValue {
790 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![1]))
791 }
792
793 #[test]
794 fn index_prefix_cardinality_requires_explicit_data_generation_sync() {
795 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
796 let collection = b"collection-a".to_vec();
797 let draft = b"Draft".to_vec();
798 let review = b"Review".to_vec();
799 let mut store = IndexStore::init_heap();
800
801 store.insert(
802 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
803 IndexEntryValue::presence(),
804 );
805 store.insert(
806 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
807 IndexEntryValue::presence(),
808 );
809 store.insert(
810 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
811 IndexEntryValue::presence(),
812 );
813
814 assert_eq!(
815 store.exact_prefix_cardinality(
816 0,
817 IndexKeyKind::User,
818 index_id,
819 std::slice::from_ref(&collection),
820 ),
821 None,
822 "raw index mutations must not be trusted until row generation sync is stamped",
823 );
824
825 store.mark_prefix_cardinality_data_generation(7);
826
827 assert_eq!(
828 store.exact_prefix_cardinality(
829 7,
830 IndexKeyKind::User,
831 index_id,
832 std::slice::from_ref(&collection),
833 ),
834 Some(3),
835 );
836 assert_eq!(
837 store.exact_prefix_cardinality(
838 7,
839 IndexKeyKind::User,
840 index_id,
841 &[collection.clone(), draft],
842 ),
843 Some(2),
844 );
845 assert_eq!(
846 store.exact_prefix_cardinality(8, IndexKeyKind::User, index_id, &[collection, review],),
847 None,
848 "row generation drift should force the caller to use the existing-row fallback",
849 );
850 }
851
852 #[test]
853 fn index_prefix_cardinality_enumerates_bounded_child_prefixes() {
854 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
855 let collection = b"collection-a".to_vec();
856 let other_collection = b"collection-b".to_vec();
857 let draft = b"Draft".to_vec();
858 let review = b"Review".to_vec();
859 let published = b"Published".to_vec();
860 let mut store = IndexStore::init_heap();
861
862 store.insert(
863 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
864 IndexEntryValue::presence(),
865 );
866 store.insert(
867 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
868 IndexEntryValue::presence(),
869 );
870 store.insert(
871 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
872 IndexEntryValue::presence(),
873 );
874 store.insert(
875 indexed_raw_key(
876 &index_id,
877 vec![other_collection.clone(), published.clone()],
878 4,
879 ),
880 IndexEntryValue::presence(),
881 );
882 store.mark_prefix_cardinality_data_generation(7);
883
884 assert_eq!(
885 store.exact_child_prefixes_for_parent_set(
886 7,
887 IndexKeyKind::User,
888 index_id,
889 [std::slice::from_ref(&collection)],
890 4,
891 ),
892 Some(vec![
893 vec![collection.clone(), draft],
894 vec![collection.clone(), review],
895 ]),
896 "child-prefix enumeration should return deterministic unique children under the requested parent",
897 );
898 assert_eq!(
899 store.exact_child_prefixes_for_parent_set(
900 7,
901 IndexKeyKind::User,
902 index_id,
903 [std::slice::from_ref(&other_collection)],
904 4,
905 ),
906 Some(vec![vec![other_collection, published]]),
907 "child-prefix enumeration must stay scoped to the requested parent prefix",
908 );
909 assert_eq!(
910 store.exact_child_prefixes_for_parent_set(
911 8,
912 IndexKeyKind::User,
913 index_id,
914 [std::slice::from_ref(&collection)],
915 4,
916 ),
917 None,
918 "row generation drift should keep child-prefix expansion fail-closed",
919 );
920 assert_eq!(
921 store.exact_child_prefixes_for_parent_set(
922 7,
923 IndexKeyKind::User,
924 index_id,
925 [std::slice::from_ref(&collection)],
926 1,
927 ),
928 None,
929 "over-cap child-prefix expansion should fall back to the existing route",
930 );
931 }
932
933 #[test]
934 fn index_prefix_cardinality_batches_sparse_child_prefixes() {
935 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
936 let collection = b"collection-a".to_vec();
937 let other_collection = b"collection-b".to_vec();
938 let missing_a = b"missing-a".to_vec();
939 let missing_b = b"missing-b".to_vec();
940 let draft = b"Draft".to_vec();
941 let review = b"Review".to_vec();
942 let published = b"Published".to_vec();
943 let mut store = IndexStore::init_heap();
944
945 store.insert(
946 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
947 IndexEntryValue::presence(),
948 );
949 store.insert(
950 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 2),
951 IndexEntryValue::presence(),
952 );
953 store.insert(
954 indexed_raw_key(
955 &index_id,
956 vec![other_collection.clone(), published.clone()],
957 3,
958 ),
959 IndexEntryValue::presence(),
960 );
961 store.mark_prefix_cardinality_data_generation(7);
962
963 let parents = [
964 std::slice::from_ref(&missing_a),
965 std::slice::from_ref(&collection),
966 std::slice::from_ref(&missing_b),
967 std::slice::from_ref(&other_collection),
968 ];
969 assert_eq!(
970 store.exact_child_prefixes_for_parent_set(7, IndexKeyKind::User, index_id, parents, 4,),
971 Some(vec![
972 vec![collection.clone(), draft],
973 vec![collection.clone(), review],
974 vec![other_collection.clone(), published],
975 ]),
976 "batched child-prefix enumeration should skip missing sparse parents and return deterministic real children",
977 );
978 assert_eq!(
979 store.exact_child_prefixes_for_parent_set(
980 7,
981 IndexKeyKind::User,
982 index_id,
983 [
984 std::slice::from_ref(&missing_a),
985 std::slice::from_ref(&missing_b)
986 ],
987 4,
988 ),
989 Some(Vec::new()),
990 "missing-only sparse parent sets should be proven empty when cardinality is synchronized",
991 );
992 assert_eq!(
993 store.exact_child_prefixes_for_parent_set(
994 7,
995 IndexKeyKind::User,
996 index_id,
997 [
998 std::slice::from_ref(&collection),
999 std::slice::from_ref(&other_collection)
1000 ],
1001 2,
1002 ),
1003 None,
1004 "over-cap sparse parent-set expansion should fail closed",
1005 );
1006 assert_eq!(
1007 store.exact_child_prefixes_for_parent_set(
1008 8,
1009 IndexKeyKind::User,
1010 index_id,
1011 [std::slice::from_ref(&collection)],
1012 4,
1013 ),
1014 None,
1015 "generation drift should keep batched child-prefix expansion fail-closed",
1016 );
1017 }
1018
1019 #[test]
1020 fn index_prefix_cardinality_ignores_system_index_mutations() {
1021 let user_index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1022 let system_index_id = IndexId::new(EntityTag::new(0xCA7D), 2);
1023 let collection = b"collection-a".to_vec();
1024 let draft = b"Draft".to_vec();
1025 let system_component = b"reverse-edge".to_vec();
1026 let mut store = IndexStore::init_heap();
1027
1028 store.insert(
1029 indexed_raw_key(&user_index_id, vec![collection.clone(), draft.clone()], 1),
1030 IndexEntryValue::presence(),
1031 );
1032 store.mark_prefix_cardinality_data_generation(7);
1033
1034 assert_eq!(
1035 store.exact_prefix_cardinality(
1036 7,
1037 IndexKeyKind::User,
1038 user_index_id,
1039 &[collection.clone(), draft.clone()],
1040 ),
1041 Some(1),
1042 );
1043
1044 let system_key = indexed_raw_key_with_kind(
1045 &system_index_id,
1046 IndexKeyKind::System,
1047 vec![system_component],
1048 1,
1049 );
1050 store.insert(system_key.clone(), IndexEntryValue::presence());
1051 assert_eq!(
1052 store.exact_prefix_cardinality(
1053 7,
1054 IndexKeyKind::User,
1055 user_index_id,
1056 &[collection.clone(), draft.clone()],
1057 ),
1058 Some(1),
1059 "system index writes must not invalidate synchronized user-prefix cardinality",
1060 );
1061
1062 store.remove(&system_key);
1063 assert_eq!(
1064 store.exact_prefix_cardinality(
1065 7,
1066 IndexKeyKind::User,
1067 user_index_id,
1068 &[collection.clone(), draft.clone()],
1069 ),
1070 Some(1),
1071 "system index removals must not invalidate synchronized user-prefix cardinality",
1072 );
1073
1074 let malformed_system_key = indexed_raw_key_with_kind(
1075 &system_index_id,
1076 IndexKeyKind::System,
1077 vec![b"malformed-reverse-edge".to_vec()],
1078 2,
1079 );
1080 store.insert(malformed_system_key.clone(), malformed_index_entry_value());
1081 assert_eq!(
1082 store.exact_prefix_cardinality(
1083 7,
1084 IndexKeyKind::User,
1085 user_index_id,
1086 &[collection.clone(), draft.clone()],
1087 ),
1088 Some(1),
1089 "malformed system index payloads must not invalidate user-prefix cardinality",
1090 );
1091
1092 store.remove(&malformed_system_key);
1093 assert_eq!(
1094 store.exact_prefix_cardinality(
1095 7,
1096 IndexKeyKind::User,
1097 user_index_id,
1098 &[collection.clone(), draft],
1099 ),
1100 Some(1),
1101 "malformed system index removals must not invalidate user-prefix cardinality",
1102 );
1103
1104 let review = b"Review".to_vec();
1105 store.insert(
1106 indexed_raw_key(&user_index_id, vec![collection.clone(), review.clone()], 2),
1107 IndexEntryValue::presence(),
1108 );
1109 assert_eq!(
1110 store.exact_prefix_cardinality(
1111 7,
1112 IndexKeyKind::User,
1113 user_index_id,
1114 &[collection, review]
1115 ),
1116 None,
1117 "user-prefix count changes must still require a fresh row-generation stamp",
1118 );
1119 }
1120
1121 #[test]
1122 fn index_prefix_cardinality_ignores_missing_user_index_mutations() {
1123 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1124 let collection = b"collection-a".to_vec();
1125 let draft = b"Draft".to_vec();
1126 let mut store = IndexStore::init_heap();
1127
1128 store.insert(
1129 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1130 IndexEntryValue::presence(),
1131 );
1132 store.mark_prefix_cardinality_data_generation(7);
1133
1134 let stale_key = indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2);
1135 store.insert(stale_key.clone(), missing_index_entry_value());
1136 assert_eq!(
1137 store.exact_prefix_cardinality(
1138 7,
1139 IndexKeyKind::User,
1140 index_id,
1141 &[collection.clone(), draft.clone()],
1142 ),
1143 Some(1),
1144 "missing user index entries must not affect synchronized prefix cardinality",
1145 );
1146
1147 store.remove(&stale_key);
1148 assert_eq!(
1149 store.exact_prefix_cardinality(7, IndexKeyKind::User, index_id, &[collection, draft],),
1150 Some(1),
1151 "missing user index removals must not affect synchronized prefix cardinality",
1152 );
1153 }
1154
1155 #[cfg(all(feature = "sql", feature = "diagnostics"))]
1156 #[test]
1157 fn index_store_diagnostic_counters_record_gets_range_scans_and_entry_reads() {
1158 let mut store = IndexStore::init_heap();
1159 store.insert(raw_key(7), IndexEntryValue::presence());
1160 store.insert(raw_key(9), IndexEntryValue::presence());
1161
1162 let gets_before = IndexStore::current_get_call_count();
1163 assert_eq!(store.get(&raw_key(7)), Some(IndexEntryValue::presence()));
1164 assert_eq!(store.get(&raw_key(8)), None);
1165
1166 assert_eq!(
1167 IndexStore::current_get_call_count().saturating_sub(gets_before),
1168 2,
1169 "diagnostic index-store get counter should count both hit and miss reads",
1170 );
1171
1172 let range_scans_before = IndexStore::current_range_scan_call_count();
1173 let lower = Bound::Included(raw_key(7));
1174 let upper = Bound::Included(raw_key(9));
1175 store
1176 .visit_raw_entries_in_range((&lower, &upper), Direction::Asc, |_key, _entry| Ok(false))
1177 .expect("raw index range visit should succeed");
1178
1179 assert_eq!(
1180 IndexStore::current_range_scan_call_count().saturating_sub(range_scans_before),
1181 1,
1182 "diagnostic index-store range-scan counter should count one range traversal probe",
1183 );
1184
1185 let entries_before = IndexStore::current_entry_read_count();
1186 store
1187 .visit_entries(|_key, _entry| Ok::<_, Infallible>(IndexStoreVisit::Continue))
1188 .expect("index entry visit should succeed");
1189
1190 assert_eq!(
1191 IndexStore::current_entry_read_count().saturating_sub(entries_before),
1192 2,
1193 "diagnostic index-store entry counter should count yielded traversal entries",
1194 );
1195 }
1196
1197 #[test]
1198 fn journaled_mixed_index_range_traversal_streams_without_snapshot() {
1199 let mut store = IndexStore::init_journaled(test_memory(93));
1200 for value in [1_u8, 3, 5] {
1201 store.insert(raw_key(value), IndexEntryValue::presence());
1202 }
1203 store
1204 .fold_journaled_materialized_view()
1205 .expect("canonical index seed should fold");
1206
1207 store.insert(raw_key(0), IndexEntryValue::presence());
1208 store.insert(raw_key(4), IndexEntryValue::presence());
1209 store.insert(raw_key(5), IndexEntryValue::presence());
1210 store.remove(&raw_key(1));
1211
1212 let lower = Bound::Included(raw_key(0));
1213 let upper = Bound::Included(raw_key(5));
1214
1215 reset_journaled_snapshot_call_count_for_tests();
1216 let mut asc = Vec::new();
1217 store
1218 .visit_journaled_entries_in_range((&lower, &upper), Direction::Asc, |key, _value| {
1219 asc.push(key.as_bytes()[0]);
1220 Ok::<_, Infallible>(asc.len() == 2)
1221 })
1222 .expect("asc journaled index range traversal should succeed");
1223 assert_eq!(asc, vec![0, 3]);
1224 assert_eq!(
1225 journaled_snapshot_call_count_for_tests(),
1226 0,
1227 "mixed journaled index range traversal should preserve early stop without materializing a snapshot",
1228 );
1229
1230 reset_journaled_snapshot_call_count_for_tests();
1231 let mut desc = Vec::new();
1232 store
1233 .visit_journaled_entries_in_range((&lower, &upper), Direction::Desc, |key, _value| {
1234 desc.push(key.as_bytes()[0]);
1235 Ok::<_, Infallible>(desc.len() == 2)
1236 })
1237 .expect("desc journaled index range traversal should succeed");
1238 assert_eq!(desc, vec![5, 4]);
1239 assert_eq!(
1240 journaled_snapshot_call_count_for_tests(),
1241 0,
1242 "mixed reverse journaled index range traversal should preserve early stop without materializing a snapshot",
1243 );
1244 }
1245
1246 #[test]
1247 fn journaled_index_store_reopens_without_materializing_prefix_cardinality() {
1248 let memory = test_memory(94);
1249 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1250 let collection = b"collection-a".to_vec();
1251 let mut store = IndexStore::init_journaled(memory.clone());
1252 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1253 store.insert(key.clone(), IndexEntryValue::presence());
1254 store
1255 .fold_journaled_materialized_view()
1256 .expect("canonical index seed should fold");
1257 drop(store);
1258
1259 let reopened = IndexStore::init_journaled(memory);
1260
1261 assert_eq!(reopened.get(&key), Some(IndexEntryValue::presence()));
1262 assert_eq!(
1263 reopened.exact_prefix_cardinality(
1264 0,
1265 IndexKeyKind::User,
1266 index_id,
1267 std::slice::from_ref(&collection),
1268 ),
1269 None,
1270 "startup must leave optional prefix cardinality unavailable without scanning stable entries",
1271 );
1272 }
1273
1274 #[test]
1275 fn empty_journaled_index_store_retains_exact_prefix_cardinality_without_scanning() {
1276 let memory = test_memory(95);
1277 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1278 let collection = b"collection-a".to_vec();
1279 let mut store = IndexStore::init_journaled(memory.clone());
1280
1281 assert_eq!(
1282 store.exact_prefix_cardinality(
1283 0,
1284 IndexKeyKind::User,
1285 index_id,
1286 std::slice::from_ref(&collection),
1287 ),
1288 Some(0),
1289 );
1290 store
1291 .reset_journaled_live_projection(7)
1292 .expect("empty projection reset should succeed");
1293 assert_eq!(
1294 store.exact_prefix_cardinality(
1295 7,
1296 IndexKeyKind::User,
1297 index_id,
1298 std::slice::from_ref(&collection),
1299 ),
1300 Some(0),
1301 );
1302 drop(store);
1303
1304 let reopened = IndexStore::init_journaled(memory);
1305 assert_eq!(
1306 reopened.exact_prefix_cardinality(
1307 0,
1308 IndexKeyKind::User,
1309 index_id,
1310 std::slice::from_ref(&collection),
1311 ),
1312 Some(0),
1313 );
1314 }
1315
1316 #[test]
1317 fn recovered_index_fold_maintains_available_prefix_cardinality() {
1318 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1319 let collection = b"collection-a".to_vec();
1320 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1321 let mut store = IndexStore::init_journaled(test_memory(96));
1322
1323 store
1324 .fold_recovered_journal_entry(key.clone(), Some(IndexEntryValue::presence()))
1325 .expect("recovered index put should fold");
1326 store.mark_prefix_cardinality_data_generation(1);
1327 assert_eq!(
1328 store.exact_prefix_cardinality(
1329 1,
1330 IndexKeyKind::User,
1331 index_id,
1332 std::slice::from_ref(&collection),
1333 ),
1334 Some(1),
1335 );
1336
1337 store
1338 .fold_recovered_journal_entry(key, None)
1339 .expect("recovered index delete should fold");
1340 store.mark_prefix_cardinality_data_generation(2);
1341 assert_eq!(
1342 store.exact_prefix_cardinality(
1343 2,
1344 IndexKeyKind::User,
1345 index_id,
1346 std::slice::from_ref(&collection),
1347 ),
1348 Some(0),
1349 );
1350 }
1351}