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