1use crate::db::index::{IndexId, IndexKeyKind};
7use crate::db::{
8 direction::Direction,
9 index::{
10 IndexEntryValue,
11 cardinality::{IndexPrefixCardinality, IndexPrefixCardinalityDelta},
12 key::RawIndexStoreKey,
13 },
14 journal::FoldWatermark,
15 ordered_overlay::{OrderedOverlayEntry, OrderedOverlayVisit, visit_ordered_overlay},
16 positioned_overlay::{
17 JournalOverlayPosition, PositionedOverlayMetadata, PositionedOverlayRetirement,
18 },
19};
20
21use candid::CandidType;
22use ic_stable_structures::{
23 BTreeMap as StableBTreeMap, DefaultMemoryImpl, memory_manager::VirtualMemory,
24};
25use serde::Deserialize;
26#[cfg(any(test, all(feature = "sql", feature = "diagnostics")))]
27use std::cell::Cell;
28use std::collections::{BTreeMap as HeapBTreeMap, BTreeSet};
29use std::ops::Bound;
30
31#[cfg(test)]
32thread_local! {
33 static JOURNALED_SNAPSHOT_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
34}
35
36#[cfg(all(feature = "sql", feature = "diagnostics"))]
37thread_local! {
38 static INDEX_STORE_GET_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
39 static INDEX_STORE_RANGE_SCAN_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
40 static INDEX_STORE_ENTRY_READ_COUNT: Cell<u64> = const { Cell::new(0) };
41}
42
43#[cfg(all(feature = "sql", feature = "diagnostics"))]
44fn record_index_store_get_call() {
45 INDEX_STORE_GET_CALL_COUNT.with(|count| {
46 count.set(count.get().saturating_add(1));
47 });
48}
49
50#[cfg(all(feature = "sql", feature = "diagnostics"))]
51fn record_index_store_range_scan_call() {
52 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(|count| {
53 count.set(count.get().saturating_add(1));
54 });
55}
56
57#[cfg(all(feature = "sql", feature = "diagnostics"))]
58fn record_index_store_entry_read() {
59 INDEX_STORE_ENTRY_READ_COUNT.with(|count| {
60 count.set(count.get().saturating_add(1));
61 });
62}
63
64fn visit_index_store_entry<E>(
65 key: &RawIndexStoreKey,
66 value: &IndexEntryValue,
67 visit: &mut impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
68) -> Result<bool, E> {
69 #[cfg(all(feature = "sql", feature = "diagnostics"))]
70 record_index_store_entry_read();
71
72 visit(key, value)
73}
74
75#[cfg(test)]
76fn record_journaled_snapshot_call() {
77 JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| {
78 count.set(count.get().saturating_add(1));
79 });
80}
81
82#[cfg(test)]
83fn reset_journaled_snapshot_call_count_for_tests() {
84 JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| count.set(0));
85}
86
87#[cfg(test)]
88fn journaled_snapshot_call_count_for_tests() -> u64 {
89 JOURNALED_SNAPSHOT_CALL_COUNT.with(Cell::get)
90}
91
92#[derive(CandidType, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
100pub enum IndexState {
101 Building,
102 #[default]
103 Ready,
104}
105
106impl IndexState {
107 #[must_use]
109 pub const fn as_str(self) -> &'static str {
110 match self {
111 Self::Building => "building",
112 Self::Ready => "ready",
113 }
114 }
115}
116
117pub struct IndexStore {
126 pub(super) backend: IndexStoreBackend,
127 generation: u64,
128 state: IndexState,
129 access_state_revision: u64,
130 prefix_cardinality: IndexPrefixCardinality,
131}
132
133pub(super) enum IndexStoreBackend {
134 Heap(HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>),
135 Journaled {
136 canonical:
137 StableBTreeMap<RawIndexStoreKey, IndexEntryValue, VirtualMemory<DefaultMemoryImpl>>,
138 live: HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>,
139 tombstones: BTreeSet<RawIndexStoreKey>,
140 positions: PositionedOverlayMetadata<RawIndexStoreKey>,
141 prefix_cardinality_delta: Box<IndexPrefixCardinalityDelta>,
142 },
143}
144
145pub(in crate::db) struct PreparedIndexPositionPublication {
147 keys: Vec<RawIndexStoreKey>,
148 position: JournalOverlayPosition,
149}
150
151pub(in crate::db) struct PreparedIndexPositionRetirement {
153 entries: Vec<(RawIndexStoreKey, PositionedOverlayRetirement)>,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub(in crate::db) enum IndexStoreVisit {
159 Continue,
160 Stop,
161}
162
163impl IndexStoreVisit {
164 const fn should_stop(self) -> bool {
165 matches!(self, Self::Stop)
166 }
167}
168
169impl IndexStore {
170 #[must_use]
172 pub const fn init_heap() -> Self {
173 Self {
174 backend: IndexStoreBackend::Heap(HeapBTreeMap::new()),
175 generation: 0,
176 state: IndexState::Ready,
177 access_state_revision: 1,
178 prefix_cardinality: IndexPrefixCardinality::synchronized_empty(),
179 }
180 }
181
182 #[must_use]
187 pub fn init_journaled(memory: VirtualMemory<DefaultMemoryImpl>) -> Self {
188 let canonical = StableBTreeMap::init(memory);
189 let prefix_cardinality = if canonical.is_empty() {
190 IndexPrefixCardinality::synchronized_empty()
191 } else {
192 IndexPrefixCardinality::unavailable()
193 };
194 Self {
195 backend: IndexStoreBackend::Journaled {
196 canonical,
197 live: HeapBTreeMap::new(),
198 tombstones: BTreeSet::new(),
199 positions: PositionedOverlayMetadata::new(),
200 prefix_cardinality_delta: Box::new(IndexPrefixCardinalityDelta::unbound_empty()),
201 },
202 generation: 0,
203 state: IndexState::Ready,
204 access_state_revision: 1,
205 prefix_cardinality,
208 }
209 }
210
211 pub(in crate::db) fn visit_entries<E>(
214 &self,
215 mut visitor: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<IndexStoreVisit, E>,
216 ) -> Result<(), E> {
217 match &self.backend {
218 IndexStoreBackend::Heap(map) => {
219 for (key, value) in map {
220 #[cfg(all(feature = "sql", feature = "diagnostics"))]
221 record_index_store_entry_read();
222
223 if visitor(key, value)?.should_stop() {
224 return Ok(());
225 }
226 }
227 }
228 IndexStoreBackend::Journaled { .. } => self.visit_journaled_entries_in_range(
229 (&Bound::Unbounded, &Bound::Unbounded),
230 Direction::Asc,
231 |key, value| visitor(key, value).map(IndexStoreVisit::should_stop),
232 )?,
233 }
234
235 Ok(())
236 }
237
238 pub(in crate::db) fn get(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
239 #[cfg(all(feature = "sql", feature = "diagnostics"))]
240 record_index_store_get_call();
241
242 match &self.backend {
243 IndexStoreBackend::Heap(map) => map.get(key).cloned(),
244 IndexStoreBackend::Journaled { .. } => Self::journaled_get(&self.backend, key),
245 }
246 }
247
248 pub(in crate::db) fn get_canonical(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
250 match &self.backend {
251 IndexStoreBackend::Heap(map) => map.get(key).cloned(),
252 IndexStoreBackend::Journaled { canonical, .. } => canonical.get(key),
253 }
254 }
255
256 pub(in crate::db) fn canonical_is_empty(&self) -> Result<bool, crate::error::InternalError> {
261 match &self.backend {
262 IndexStoreBackend::Journaled { canonical, .. } => Ok(canonical.is_empty()),
263 IndexStoreBackend::Heap(_) => Err(crate::error::InternalError::store_invariant()),
264 }
265 }
266
267 pub fn len(&self) -> u64 {
268 match &self.backend {
269 IndexStoreBackend::Heap(map) => u64::try_from(map.len()).unwrap_or(u64::MAX),
270 IndexStoreBackend::Journaled { .. } => {
271 let mut count = 0_u64;
272 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
273 count = count.saturating_add(1);
274 Ok(IndexStoreVisit::Continue)
275 });
276 count
277 }
278 }
279 }
280
281 pub fn is_empty(&self) -> bool {
282 match &self.backend {
283 IndexStoreBackend::Heap(map) => map.is_empty(),
284 IndexStoreBackend::Journaled { .. } => {
285 let mut empty = true;
286 let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
287 empty = false;
288 Ok(IndexStoreVisit::Stop)
289 });
290 empty
291 }
292 }
293 }
294
295 #[must_use]
296 pub(in crate::db) const fn generation(&self) -> u64 {
297 self.generation
298 }
299
300 #[must_use]
302 pub(in crate::db) const fn state(&self) -> IndexState {
303 self.state
304 }
305
306 #[must_use]
308 pub(in crate::db) const fn access_state_revision(&self) -> u64 {
309 self.access_state_revision
310 }
311
312 #[must_use]
315 pub(in crate::db) fn exact_prefix_cardinality(
316 &self,
317 data_generation: u64,
318 key_kind: IndexKeyKind,
319 index_id: IndexId,
320 components: &[Vec<u8>],
321 ) -> Option<u64> {
322 self.prefix_cardinality
323 .exact_count(data_generation, key_kind, index_id, components)
324 }
325
326 #[must_use]
328 pub(in crate::db) fn exact_prefix_cardinality_delta(
329 &self,
330 key_kind: IndexKeyKind,
331 index_id: IndexId,
332 components: &[Vec<u8>],
333 ) -> Option<i64> {
334 match &self.backend {
335 IndexStoreBackend::Heap(_) => Some(0),
336 IndexStoreBackend::Journaled {
337 prefix_cardinality_delta,
338 ..
339 } => prefix_cardinality_delta.exact_delta(key_kind, index_id, components),
340 }
341 }
342
343 #[must_use]
345 pub(in crate::db) fn exact_prefix_cardinality_delta_watermark(&self) -> Option<FoldWatermark> {
346 match &self.backend {
347 IndexStoreBackend::Heap(_) => None,
348 IndexStoreBackend::Journaled {
349 prefix_cardinality_delta,
350 ..
351 } => prefix_cardinality_delta.base_watermark(),
352 }
353 }
354
355 #[must_use]
358 pub(in crate::db) fn exact_prefix_cardinality_sum<'a>(
359 &self,
360 data_generation: u64,
361 key_kind: IndexKeyKind,
362 index_id: IndexId,
363 component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
364 stop_after: Option<u64>,
365 ) -> Option<u64> {
366 self.prefix_cardinality.exact_count_sum(
367 data_generation,
368 key_kind,
369 index_id,
370 component_prefixes,
371 stop_after,
372 )
373 }
374
375 #[must_use]
378 pub(in crate::db) fn exact_child_prefixes_for_parent_set<'a>(
379 &self,
380 data_generation: u64,
381 key_kind: IndexKeyKind,
382 index_id: IndexId,
383 parent_component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
384 max_children: usize,
385 ) -> Option<Vec<Vec<Vec<u8>>>> {
386 self.prefix_cardinality.exact_child_prefixes_for_parent_set(
387 data_generation,
388 key_kind,
389 index_id,
390 parent_component_prefixes,
391 max_children,
392 )
393 }
394
395 pub(in crate::db) const fn mark_prefix_cardinality_data_generation(&mut self, generation: u64) {
398 self.prefix_cardinality.mark_synchronized(generation);
399 }
400
401 pub(in crate::db) const fn set_access_state(&mut self, state: IndexState, revision: u64) {
404 self.state = state;
405 self.access_state_revision = revision;
406 }
407
408 pub(crate) fn insert(
409 &mut self,
410 key: RawIndexStoreKey,
411 entry: IndexEntryValue,
412 ) -> Option<IndexEntryValue> {
413 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
414 self.get(&key)
415 } else {
416 None
417 };
418 let cardinality_key = key.clone();
419 let previous = match &mut self.backend {
420 IndexStoreBackend::Heap(map) => map.insert(key, entry.clone()),
421 IndexStoreBackend::Journaled {
422 live, tombstones, ..
423 } => {
424 tombstones.remove(&key);
425 live.insert(key, entry.clone());
426 previous_journaled
427 }
428 };
429 self.prefix_cardinality
430 .apply_insert(&cardinality_key, previous.as_ref(), &entry);
431 self.apply_prefix_overlay_delta(&cardinality_key, previous.as_ref(), Some(&entry));
432 self.bump_generation();
433 previous
434 }
435
436 pub(in crate::db) fn insert_preflighted_absent(
442 &mut self,
443 key: RawIndexStoreKey,
444 entry: IndexEntryValue,
445 ) {
446 let cardinality_key = key.clone();
447 match &mut self.backend {
448 IndexStoreBackend::Heap(map) => {
449 map.insert(key, entry.clone());
450 }
451 IndexStoreBackend::Journaled {
452 live, tombstones, ..
453 } => {
454 tombstones.remove(&key);
455 live.insert(key, entry.clone());
456 }
457 }
458 self.prefix_cardinality
459 .apply_insert(&cardinality_key, None, &entry);
460 self.apply_prefix_overlay_delta(&cardinality_key, None, Some(&entry));
461 self.bump_generation();
462 }
463
464 pub(crate) fn remove(&mut self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
465 let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
466 self.get(key)
467 } else {
468 None
469 };
470 let previous = match &mut self.backend {
471 IndexStoreBackend::Heap(map) => map.remove(key),
472 IndexStoreBackend::Journaled {
473 live, tombstones, ..
474 } => {
475 live.remove(key);
476 tombstones.insert(key.clone());
477 previous_journaled
478 }
479 };
480 self.prefix_cardinality.apply_remove(key, previous.as_ref());
481 self.apply_prefix_overlay_delta(key, previous.as_ref(), None);
482 self.bump_generation();
483 previous
484 }
485
486 pub(in crate::db) fn reset_journaled_live_projection(
489 &mut self,
490 data_generation: u64,
491 fold_watermark: FoldWatermark,
492 ) -> Result<(), crate::error::InternalError> {
493 let IndexStoreBackend::Journaled {
494 canonical,
495 live,
496 tombstones,
497 positions,
498 prefix_cardinality_delta,
499 } = &mut self.backend
500 else {
501 return Err(crate::error::InternalError::store_invariant());
502 };
503
504 live.clear();
505 tombstones.clear();
506 positions.clear();
507 prefix_cardinality_delta.reset(fold_watermark);
508 self.prefix_cardinality = if canonical.is_empty() {
509 let mut cardinality = IndexPrefixCardinality::synchronized_empty();
510 cardinality.mark_synchronized(data_generation);
511 cardinality
512 } else {
513 IndexPrefixCardinality::unavailable()
514 };
515 self.bump_generation();
516
517 Ok(())
518 }
519
520 pub(in crate::db) fn preflight_prefix_cardinality_delta_watermark(
522 &self,
523 current: FoldWatermark,
524 ) -> Result<(), crate::error::InternalError> {
525 (self.exact_prefix_cardinality_delta_watermark() == Some(current))
526 .then_some(())
527 .ok_or_else(crate::error::InternalError::store_corruption)
528 }
529
530 pub(in crate::db) fn apply_prefix_cardinality_delta_watermark(
532 &mut self,
533 current: FoldWatermark,
534 next: FoldWatermark,
535 ) {
536 if let IndexStoreBackend::Journaled {
537 prefix_cardinality_delta,
538 ..
539 } = &mut self.backend
540 {
541 prefix_cardinality_delta.advance_watermark(current, next);
542 }
543 }
544
545 pub(in crate::db) fn publish_preflighted_journal_entry(
547 &mut self,
548 key: RawIndexStoreKey,
549 value: Option<IndexEntryValue>,
550 position: JournalOverlayPosition,
551 ) -> Result<Option<IndexEntryValue>, crate::error::InternalError> {
552 let IndexStoreBackend::Journaled {
553 canonical,
554 live,
555 tombstones,
556 positions,
557 prefix_cardinality_delta,
558 } = &mut self.backend
559 else {
560 return Err(crate::error::InternalError::store_invariant());
561 };
562 let previous = if tombstones.contains(&key) {
563 None
564 } else {
565 live.get(&key).cloned().or_else(|| canonical.get(&key))
566 };
567 let cardinality_key = key.clone();
568 let next_value = value.clone();
569
570 if let Some(value) = value {
571 tombstones.remove(&key);
572 live.insert(key.clone(), value.clone());
573 self.prefix_cardinality
574 .apply_insert(&cardinality_key, previous.as_ref(), &value);
575 } else {
576 live.remove(&key);
577 tombstones.insert(key.clone());
578 self.prefix_cardinality
579 .apply_remove(&cardinality_key, previous.as_ref());
580 }
581 prefix_cardinality_delta.apply_transition(
582 &cardinality_key,
583 previous.as_ref(),
584 next_value.as_ref(),
585 );
586 positions.publish_preflighted(key, position);
587 self.bump_generation();
588
589 Ok(previous)
590 }
591
592 #[cfg(test)]
594 pub(in crate::db) fn publish_positioned_journal_entry(
595 &mut self,
596 key: RawIndexStoreKey,
597 value: Option<IndexEntryValue>,
598 position: JournalOverlayPosition,
599 ) -> Result<Option<IndexEntryValue>, crate::error::InternalError> {
600 self.preflight_positioned_journal_entry(&key, position)?;
601 self.publish_preflighted_journal_entry(key, value, position)
602 }
603
604 pub(in crate::db) fn preflight_positioned_journal_entry(
606 &self,
607 key: &RawIndexStoreKey,
608 position: JournalOverlayPosition,
609 ) -> Result<(), crate::error::InternalError> {
610 let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
611 return Err(crate::error::InternalError::store_invariant());
612 };
613 positions.preflight_publish(key, position)
614 }
615
616 pub(in crate::db) fn prepare_position_publication(
618 &self,
619 keys: impl IntoIterator<Item = RawIndexStoreKey>,
620 position: JournalOverlayPosition,
621 ) -> Result<PreparedIndexPositionPublication, crate::error::InternalError> {
622 let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
623 return Err(crate::error::InternalError::store_invariant());
624 };
625 let keys = keys.into_iter().collect::<BTreeSet<_>>();
626 for key in &keys {
627 positions.preflight_publish(key, position)?;
628 }
629 Ok(PreparedIndexPositionPublication {
630 keys: keys.into_iter().collect(),
631 position,
632 })
633 }
634
635 pub(in crate::db) fn publish_prepared_positions(
637 &mut self,
638 prepared: PreparedIndexPositionPublication,
639 ) {
640 let IndexStoreBackend::Journaled { positions, .. } = &mut self.backend else {
641 debug_assert!(
642 false,
643 "preflighted index positions require a journaled store"
644 );
645 return;
646 };
647 for key in prepared.keys {
648 positions.publish_preflighted(key, prepared.position);
649 }
650 }
651
652 pub(in crate::db) fn prepare_position_retirement(
654 &self,
655 keys: impl IntoIterator<Item = RawIndexStoreKey>,
656 position: JournalOverlayPosition,
657 ) -> Result<PreparedIndexPositionRetirement, crate::error::InternalError> {
658 let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
659 return Err(crate::error::InternalError::store_invariant());
660 };
661 let entries = keys
662 .into_iter()
663 .collect::<BTreeSet<_>>()
664 .into_iter()
665 .map(|key| {
666 positions
667 .preflight_retirement(&key, position)
668 .map(|retirement| (key, retirement))
669 })
670 .collect::<Result<Vec<_>, _>>()?;
671 Ok(PreparedIndexPositionRetirement { entries })
672 }
673
674 pub(in crate::db) fn apply_prepared_position_retirement(
676 &mut self,
677 prepared: PreparedIndexPositionRetirement,
678 ) {
679 let IndexStoreBackend::Journaled {
680 live,
681 tombstones,
682 positions,
683 ..
684 } = &mut self.backend
685 else {
686 debug_assert!(
687 false,
688 "preflighted index retirement requires a journaled store"
689 );
690 return;
691 };
692 for (key, retirement) in prepared.entries {
693 if retirement == PositionedOverlayRetirement::Exact {
694 live.remove(&key);
695 tombstones.remove(&key);
696 positions.retire_preflighted(&key, retirement);
697 }
698 }
699 }
700
701 #[cfg(test)]
702 fn retire_positioned_journal_effect(
703 &mut self,
704 key: &RawIndexStoreKey,
705 position: JournalOverlayPosition,
706 ) -> Result<PositionedOverlayRetirement, crate::error::InternalError> {
707 let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
708 return Err(crate::error::InternalError::store_invariant());
709 };
710 let retirement = positions.preflight_retirement(key, position)?;
711 let prepared = PreparedIndexPositionRetirement {
712 entries: vec![(key.clone(), retirement)],
713 };
714 self.apply_prepared_position_retirement(prepared);
715 Ok(retirement)
716 }
717
718 pub(in crate::db) fn fold_recovered_journal_entry(
720 &mut self,
721 key: RawIndexStoreKey,
722 value: Option<IndexEntryValue>,
723 ) -> Result<(), crate::error::InternalError> {
724 let IndexStoreBackend::Journaled {
725 canonical,
726 live,
727 tombstones,
728 ..
729 } = &mut self.backend
730 else {
731 return Err(crate::error::InternalError::store_invariant());
732 };
733
734 let visible = !live.contains_key(&key) && !tombstones.contains(&key);
735 let cardinality_key = key.clone();
736 let previous = if let Some(value) = value.as_ref() {
737 canonical.insert(key, value.clone())
738 } else {
739 canonical.remove(&key)
740 };
741 if visible {
742 if let Some(value) = value.as_ref() {
743 self.prefix_cardinality
744 .apply_insert(&cardinality_key, previous.as_ref(), value);
745 } else {
746 self.prefix_cardinality
747 .apply_remove(&cardinality_key, previous.as_ref());
748 }
749 } else {
750 self.apply_prefix_overlay_delta(&cardinality_key, value.as_ref(), previous.as_ref());
751 }
752 self.bump_generation();
753
754 Ok(())
755 }
756
757 pub(in crate::db) fn preflight_fold_recovered_journal(
759 &self,
760 ) -> Result<(), crate::error::InternalError> {
761 match self.backend {
762 IndexStoreBackend::Journaled { .. } => Ok(()),
763 IndexStoreBackend::Heap(_) => Err(crate::error::InternalError::store_invariant()),
764 }
765 }
766
767 pub fn clear(&mut self) {
768 match &mut self.backend {
769 IndexStoreBackend::Heap(map) => map.clear(),
770 IndexStoreBackend::Journaled {
771 canonical,
772 live,
773 tombstones,
774 prefix_cardinality_delta,
775 ..
776 } => {
777 live.clear();
778 tombstones.clear();
779 for entry in canonical.iter() {
780 tombstones.insert(entry.key().clone());
781 }
782 prefix_cardinality_delta.clear_unavailable();
783 }
784 }
785 self.prefix_cardinality.clear_unsynchronized();
786 self.bump_generation();
787 }
788
789 #[cfg(any(test, feature = "migration"))]
792 pub(in crate::db) fn fold_journaled_materialized_view(
793 &mut self,
794 ) -> Result<(), crate::error::InternalError> {
795 let entries = Self::journaled_entries_snapshot_for_fold(&self.backend);
796 let IndexStoreBackend::Journaled {
797 canonical,
798 live,
799 tombstones,
800 prefix_cardinality_delta,
801 ..
802 } = &mut self.backend
803 else {
804 return Err(crate::error::InternalError::store_invariant());
805 };
806
807 canonical.clear_new();
808 for (key, value) in entries {
809 canonical.insert(key, value);
810 }
811 live.clear();
812 tombstones.clear();
813 if let Some(watermark) = prefix_cardinality_delta.base_watermark() {
814 prefix_cardinality_delta.reset(watermark);
815 } else {
816 **prefix_cardinality_delta = IndexPrefixCardinalityDelta::unbound_empty();
817 }
818 let data_generation = self.prefix_cardinality.synchronized_generation();
819 self.rebuild_prefix_cardinality_from_entries(data_generation);
820 self.bump_generation();
821
822 Ok(())
823 }
824
825 pub fn memory_bytes(&self) -> u64 {
827 let mut bytes = 0u64;
828 let _: Result<(), std::convert::Infallible> = self.visit_entries(|key, value| {
829 bytes = bytes.saturating_add(key.as_bytes().len() as u64 + value.len() as u64);
830 Ok(IndexStoreVisit::Continue)
831 });
832 bytes
833 }
834
835 #[cfg(all(feature = "sql", feature = "diagnostics"))]
837 pub(in crate::db) fn current_get_call_count() -> u64 {
838 INDEX_STORE_GET_CALL_COUNT.with(Cell::get)
839 }
840
841 #[cfg(all(feature = "sql", feature = "diagnostics"))]
843 pub(in crate::db) fn current_range_scan_call_count() -> u64 {
844 INDEX_STORE_RANGE_SCAN_CALL_COUNT.with(Cell::get)
845 }
846
847 #[cfg(all(feature = "sql", feature = "diagnostics"))]
849 pub(in crate::db) fn current_entry_read_count() -> u64 {
850 INDEX_STORE_ENTRY_READ_COUNT.with(Cell::get)
851 }
852
853 #[cfg(all(feature = "sql", feature = "diagnostics"))]
854 pub(in crate::db::index) fn record_range_scan_call() {
855 record_index_store_range_scan_call();
856 }
857
858 #[cfg(all(feature = "sql", feature = "diagnostics"))]
859 pub(in crate::db::index) fn record_merged_entry_reads(count: u64) {
860 INDEX_STORE_ENTRY_READ_COUNT.with(|total| {
861 total.set(total.get().saturating_add(count));
862 });
863 }
864
865 const fn bump_generation(&mut self) {
866 self.generation = self.generation.saturating_add(1);
867 }
868
869 fn apply_prefix_overlay_delta(
870 &mut self,
871 key: &RawIndexStoreKey,
872 previous: Option<&IndexEntryValue>,
873 next: Option<&IndexEntryValue>,
874 ) {
875 let IndexStoreBackend::Journaled {
876 prefix_cardinality_delta,
877 ..
878 } = &mut self.backend
879 else {
880 return;
881 };
882 prefix_cardinality_delta.apply_transition(key, previous, next);
883 }
884
885 #[cfg(any(test, feature = "migration"))]
886 fn rebuild_prefix_cardinality_from_entries(&mut self, data_generation: Option<u64>) {
887 self.prefix_cardinality.clear_unsynchronized();
888 let entries = Self::entries_snapshot_for_cardinality(&self.backend);
889 for (key, value) in &entries {
890 self.prefix_cardinality.apply_insert(key, None, value);
891 }
892 if let Some(data_generation) = data_generation {
893 self.prefix_cardinality.mark_synchronized(data_generation);
894 }
895 }
896
897 #[cfg(any(test, feature = "migration"))]
898 fn entries_snapshot_for_cardinality(
899 backend: &IndexStoreBackend,
900 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
901 match backend {
902 IndexStoreBackend::Heap(map) => map.clone(),
903 IndexStoreBackend::Journaled { .. } => {
904 Self::journaled_entries_snapshot_for_fold(backend)
905 }
906 }
907 }
908
909 fn journaled_get(
910 backend: &IndexStoreBackend,
911 key: &RawIndexStoreKey,
912 ) -> Option<IndexEntryValue> {
913 let IndexStoreBackend::Journaled {
914 canonical,
915 live,
916 tombstones,
917 ..
918 } = backend
919 else {
920 return None;
921 };
922
923 if tombstones.contains(key) {
924 return None;
925 }
926 live.get(key).cloned().or_else(|| canonical.get(key))
927 }
928
929 #[cfg(any(test, feature = "migration"))]
930 pub(super) fn journaled_entries_snapshot_for_fold(
931 backend: &IndexStoreBackend,
932 ) -> HeapBTreeMap<RawIndexStoreKey, IndexEntryValue> {
933 #[cfg(test)]
934 record_journaled_snapshot_call();
935
936 let IndexStoreBackend::Journaled {
937 canonical,
938 live,
939 tombstones,
940 ..
941 } = backend
942 else {
943 return HeapBTreeMap::new();
944 };
945
946 let mut entries = HeapBTreeMap::new();
947 for entry in canonical.iter() {
948 let key = entry.key().clone();
949 if !tombstones.contains(&key) {
950 entries.insert(key, entry.value());
951 }
952 }
953 for (key, value) in live {
954 if !tombstones.contains(key) {
955 entries.insert(key.clone(), value.clone());
956 }
957 }
958
959 entries
960 }
961
962 pub(super) fn visit_journaled_entries_in_range<E>(
963 &self,
964 bounds: (&Bound<RawIndexStoreKey>, &Bound<RawIndexStoreKey>),
965 direction: Direction,
966 mut visit: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
967 ) -> Result<(), E> {
968 let IndexStoreBackend::Journaled {
969 canonical,
970 live,
971 tombstones,
972 ..
973 } = &self.backend
974 else {
975 return Ok(());
976 };
977
978 let lower = bounds.0.clone();
979 let upper = bounds.1.clone();
980 match direction {
981 Direction::Asc if canonical.is_empty() => {
982 for (key, value) in live.range((lower, upper)) {
983 if visit_index_store_entry(key, value, &mut visit)? {
984 return Ok(());
985 }
986 }
987 }
988 Direction::Desc if canonical.is_empty() => {
989 for (key, value) in live.range((lower, upper)).rev() {
990 if visit_index_store_entry(key, value, &mut visit)? {
991 return Ok(());
992 }
993 }
994 }
995 Direction::Asc if live.is_empty() && tombstones.is_empty() => {
996 for entry in canonical.range((lower, upper)) {
997 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
998 return Ok(());
999 }
1000 }
1001 }
1002 Direction::Desc if live.is_empty() && tombstones.is_empty() => {
1003 for entry in canonical.range((lower, upper)).rev() {
1004 if visit_index_store_entry(entry.key(), &entry.value(), &mut visit)? {
1005 return Ok(());
1006 }
1007 }
1008 }
1009 Direction::Asc => {
1010 visit_ordered_overlay(
1011 canonical.range((lower.clone(), upper.clone())),
1012 live.range((lower, upper)),
1013 direction,
1014 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
1015 |canonical_entry| !tombstones.contains(canonical_entry.key()),
1016 |live_entry| !tombstones.contains(live_entry.0),
1017 |entry| {
1018 let should_stop = match entry {
1019 OrderedOverlayEntry::Canonical(canonical_entry) => {
1020 visit_index_store_entry(
1021 canonical_entry.key(),
1022 &canonical_entry.value(),
1023 &mut visit,
1024 )?
1025 }
1026 OrderedOverlayEntry::Live((key, value)) => {
1027 visit_index_store_entry(key, value, &mut visit)?
1028 }
1029 };
1030 Ok(if should_stop {
1031 OrderedOverlayVisit::Stop
1032 } else {
1033 OrderedOverlayVisit::Continue
1034 })
1035 },
1036 )?;
1037 }
1038 Direction::Desc => {
1039 visit_ordered_overlay(
1040 canonical.range((lower.clone(), upper.clone())).rev(),
1041 live.range((lower, upper)).rev(),
1042 direction,
1043 |canonical_entry, live_entry| canonical_entry.key().cmp(live_entry.0),
1044 |canonical_entry| !tombstones.contains(canonical_entry.key()),
1045 |live_entry| !tombstones.contains(live_entry.0),
1046 |entry| {
1047 let should_stop = match entry {
1048 OrderedOverlayEntry::Canonical(canonical_entry) => {
1049 visit_index_store_entry(
1050 canonical_entry.key(),
1051 &canonical_entry.value(),
1052 &mut visit,
1053 )?
1054 }
1055 OrderedOverlayEntry::Live((key, value)) => {
1056 visit_index_store_entry(key, value, &mut visit)?
1057 }
1058 };
1059 Ok(if should_stop {
1060 OrderedOverlayVisit::Stop
1061 } else {
1062 OrderedOverlayVisit::Continue
1063 })
1064 },
1065 )?;
1066 }
1067 }
1068
1069 Ok(())
1070 }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use crate::{
1077 db::{
1078 direction::Direction,
1079 index::{IndexId, IndexKey, IndexKeyKind},
1080 journal::JournalSequence,
1081 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
1082 positioned_overlay::{JournalOverlayPosition, PositionedOverlayRetirement},
1083 registry::StoreAllocationIdentity,
1084 },
1085 testing::test_memory,
1086 types::EntityTag,
1087 };
1088 use ic_stable_structures::Storable;
1089 use std::{borrow::Cow, convert::Infallible};
1090
1091 fn raw_key(value: u8) -> RawIndexStoreKey {
1092 <RawIndexStoreKey as Storable>::from_bytes(Cow::Owned(vec![value]))
1093 }
1094
1095 fn overlay_position(sequence: u64) -> JournalOverlayPosition {
1096 JournalOverlayPosition::new(
1097 StoreAllocationIdentity::new(231, "test::index"),
1098 JournalSequence::new(sequence),
1099 )
1100 }
1101
1102 fn indexed_raw_key(
1103 index_id: &IndexId,
1104 components: Vec<Vec<u8>>,
1105 primary_key: u64,
1106 ) -> RawIndexStoreKey {
1107 indexed_raw_key_with_kind(index_id, IndexKeyKind::User, components, primary_key)
1108 }
1109
1110 fn indexed_raw_key_with_kind(
1111 index_id: &IndexId,
1112 key_kind: IndexKeyKind,
1113 components: Vec<Vec<u8>>,
1114 primary_key: u64,
1115 ) -> RawIndexStoreKey {
1116 IndexKey::new_from_components_with_primary_key_value(
1117 index_id,
1118 key_kind,
1119 components.as_slice(),
1120 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(primary_key)),
1121 )
1122 .expect("test index key should build")
1123 .to_raw()
1124 .expect("test index key should encode")
1125 }
1126
1127 fn malformed_index_entry_value() -> IndexEntryValue {
1128 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![0xFF]))
1129 }
1130
1131 fn missing_index_entry_value() -> IndexEntryValue {
1132 <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![1]))
1133 }
1134
1135 #[test]
1136 fn index_prefix_cardinality_requires_explicit_data_generation_sync() {
1137 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1138 let collection = b"collection-a".to_vec();
1139 let draft = b"Draft".to_vec();
1140 let review = b"Review".to_vec();
1141 let mut store = IndexStore::init_heap();
1142
1143 store.insert(
1144 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1145 IndexEntryValue::presence(),
1146 );
1147 store.insert(
1148 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
1149 IndexEntryValue::presence(),
1150 );
1151 store.insert(
1152 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
1153 IndexEntryValue::presence(),
1154 );
1155
1156 assert_eq!(
1157 store.exact_prefix_cardinality(
1158 0,
1159 IndexKeyKind::User,
1160 index_id,
1161 std::slice::from_ref(&collection),
1162 ),
1163 None,
1164 "raw index mutations must not be trusted until row generation sync is stamped",
1165 );
1166
1167 store.mark_prefix_cardinality_data_generation(7);
1168
1169 assert_eq!(
1170 store.exact_prefix_cardinality(
1171 7,
1172 IndexKeyKind::User,
1173 index_id,
1174 std::slice::from_ref(&collection),
1175 ),
1176 Some(3),
1177 );
1178 assert_eq!(
1179 store.exact_prefix_cardinality(
1180 7,
1181 IndexKeyKind::User,
1182 index_id,
1183 &[collection.clone(), draft],
1184 ),
1185 Some(2),
1186 );
1187 assert_eq!(
1188 store.exact_prefix_cardinality(8, IndexKeyKind::User, index_id, &[collection, review],),
1189 None,
1190 "row generation drift should force the caller to use the existing-row fallback",
1191 );
1192 }
1193
1194 #[test]
1195 fn index_prefix_cardinality_enumerates_bounded_child_prefixes() {
1196 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1197 let collection = b"collection-a".to_vec();
1198 let other_collection = b"collection-b".to_vec();
1199 let draft = b"Draft".to_vec();
1200 let review = b"Review".to_vec();
1201 let published = b"Published".to_vec();
1202 let mut store = IndexStore::init_heap();
1203
1204 store.insert(
1205 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1206 IndexEntryValue::presence(),
1207 );
1208 store.insert(
1209 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
1210 IndexEntryValue::presence(),
1211 );
1212 store.insert(
1213 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
1214 IndexEntryValue::presence(),
1215 );
1216 store.insert(
1217 indexed_raw_key(
1218 &index_id,
1219 vec![other_collection.clone(), published.clone()],
1220 4,
1221 ),
1222 IndexEntryValue::presence(),
1223 );
1224 store.mark_prefix_cardinality_data_generation(7);
1225
1226 assert_eq!(
1227 store.exact_child_prefixes_for_parent_set(
1228 7,
1229 IndexKeyKind::User,
1230 index_id,
1231 [std::slice::from_ref(&collection)],
1232 4,
1233 ),
1234 Some(vec![
1235 vec![collection.clone(), draft],
1236 vec![collection.clone(), review],
1237 ]),
1238 "child-prefix enumeration should return deterministic unique children under the requested parent",
1239 );
1240 assert_eq!(
1241 store.exact_child_prefixes_for_parent_set(
1242 7,
1243 IndexKeyKind::User,
1244 index_id,
1245 [std::slice::from_ref(&other_collection)],
1246 4,
1247 ),
1248 Some(vec![vec![other_collection, published]]),
1249 "child-prefix enumeration must stay scoped to the requested parent prefix",
1250 );
1251 assert_eq!(
1252 store.exact_child_prefixes_for_parent_set(
1253 8,
1254 IndexKeyKind::User,
1255 index_id,
1256 [std::slice::from_ref(&collection)],
1257 4,
1258 ),
1259 None,
1260 "row generation drift should keep child-prefix expansion fail-closed",
1261 );
1262 assert_eq!(
1263 store.exact_child_prefixes_for_parent_set(
1264 7,
1265 IndexKeyKind::User,
1266 index_id,
1267 [std::slice::from_ref(&collection)],
1268 1,
1269 ),
1270 None,
1271 "over-cap child-prefix expansion should fall back to the existing route",
1272 );
1273 }
1274
1275 #[test]
1276 fn index_prefix_cardinality_batches_sparse_child_prefixes() {
1277 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1278 let collection = b"collection-a".to_vec();
1279 let other_collection = b"collection-b".to_vec();
1280 let missing_a = b"missing-a".to_vec();
1281 let missing_b = b"missing-b".to_vec();
1282 let draft = b"Draft".to_vec();
1283 let review = b"Review".to_vec();
1284 let published = b"Published".to_vec();
1285 let mut store = IndexStore::init_heap();
1286
1287 store.insert(
1288 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1289 IndexEntryValue::presence(),
1290 );
1291 store.insert(
1292 indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 2),
1293 IndexEntryValue::presence(),
1294 );
1295 store.insert(
1296 indexed_raw_key(
1297 &index_id,
1298 vec![other_collection.clone(), published.clone()],
1299 3,
1300 ),
1301 IndexEntryValue::presence(),
1302 );
1303 store.mark_prefix_cardinality_data_generation(7);
1304
1305 let parents = [
1306 std::slice::from_ref(&missing_a),
1307 std::slice::from_ref(&collection),
1308 std::slice::from_ref(&missing_b),
1309 std::slice::from_ref(&other_collection),
1310 ];
1311 assert_eq!(
1312 store.exact_child_prefixes_for_parent_set(7, IndexKeyKind::User, index_id, parents, 4,),
1313 Some(vec![
1314 vec![collection.clone(), draft],
1315 vec![collection.clone(), review],
1316 vec![other_collection.clone(), published],
1317 ]),
1318 "batched child-prefix enumeration should skip missing sparse parents and return deterministic real children",
1319 );
1320 assert_eq!(
1321 store.exact_child_prefixes_for_parent_set(
1322 7,
1323 IndexKeyKind::User,
1324 index_id,
1325 [
1326 std::slice::from_ref(&missing_a),
1327 std::slice::from_ref(&missing_b)
1328 ],
1329 4,
1330 ),
1331 Some(Vec::new()),
1332 "missing-only sparse parent sets should be proven empty when cardinality is synchronized",
1333 );
1334 assert_eq!(
1335 store.exact_child_prefixes_for_parent_set(
1336 7,
1337 IndexKeyKind::User,
1338 index_id,
1339 [
1340 std::slice::from_ref(&collection),
1341 std::slice::from_ref(&other_collection)
1342 ],
1343 2,
1344 ),
1345 None,
1346 "over-cap sparse parent-set expansion should fail closed",
1347 );
1348 assert_eq!(
1349 store.exact_child_prefixes_for_parent_set(
1350 8,
1351 IndexKeyKind::User,
1352 index_id,
1353 [std::slice::from_ref(&collection)],
1354 4,
1355 ),
1356 None,
1357 "generation drift should keep batched child-prefix expansion fail-closed",
1358 );
1359 }
1360
1361 #[test]
1362 fn index_prefix_cardinality_ignores_system_index_mutations() {
1363 let user_index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1364 let system_index_id = IndexId::new(EntityTag::new(0xCA7D), 2);
1365 let collection = b"collection-a".to_vec();
1366 let draft = b"Draft".to_vec();
1367 let system_component = b"reverse-edge".to_vec();
1368 let mut store = IndexStore::init_heap();
1369
1370 store.insert(
1371 indexed_raw_key(&user_index_id, vec![collection.clone(), draft.clone()], 1),
1372 IndexEntryValue::presence(),
1373 );
1374 store.mark_prefix_cardinality_data_generation(7);
1375
1376 assert_eq!(
1377 store.exact_prefix_cardinality(
1378 7,
1379 IndexKeyKind::User,
1380 user_index_id,
1381 &[collection.clone(), draft.clone()],
1382 ),
1383 Some(1),
1384 );
1385
1386 let system_key = indexed_raw_key_with_kind(
1387 &system_index_id,
1388 IndexKeyKind::System,
1389 vec![system_component],
1390 1,
1391 );
1392 store.insert(system_key.clone(), IndexEntryValue::presence());
1393 assert_eq!(
1394 store.exact_prefix_cardinality(
1395 7,
1396 IndexKeyKind::User,
1397 user_index_id,
1398 &[collection.clone(), draft.clone()],
1399 ),
1400 Some(1),
1401 "system index writes must not invalidate synchronized user-prefix cardinality",
1402 );
1403
1404 store.remove(&system_key);
1405 assert_eq!(
1406 store.exact_prefix_cardinality(
1407 7,
1408 IndexKeyKind::User,
1409 user_index_id,
1410 &[collection.clone(), draft.clone()],
1411 ),
1412 Some(1),
1413 "system index removals must not invalidate synchronized user-prefix cardinality",
1414 );
1415
1416 let malformed_system_key = indexed_raw_key_with_kind(
1417 &system_index_id,
1418 IndexKeyKind::System,
1419 vec![b"malformed-reverse-edge".to_vec()],
1420 2,
1421 );
1422 store.insert(malformed_system_key.clone(), malformed_index_entry_value());
1423 assert_eq!(
1424 store.exact_prefix_cardinality(
1425 7,
1426 IndexKeyKind::User,
1427 user_index_id,
1428 &[collection.clone(), draft.clone()],
1429 ),
1430 Some(1),
1431 "malformed system index payloads must not invalidate user-prefix cardinality",
1432 );
1433
1434 store.remove(&malformed_system_key);
1435 assert_eq!(
1436 store.exact_prefix_cardinality(
1437 7,
1438 IndexKeyKind::User,
1439 user_index_id,
1440 &[collection.clone(), draft],
1441 ),
1442 Some(1),
1443 "malformed system index removals must not invalidate user-prefix cardinality",
1444 );
1445
1446 let review = b"Review".to_vec();
1447 store.insert(
1448 indexed_raw_key(&user_index_id, vec![collection.clone(), review.clone()], 2),
1449 IndexEntryValue::presence(),
1450 );
1451 assert_eq!(
1452 store.exact_prefix_cardinality(
1453 7,
1454 IndexKeyKind::User,
1455 user_index_id,
1456 &[collection, review]
1457 ),
1458 None,
1459 "user-prefix count changes must still require a fresh row-generation stamp",
1460 );
1461 }
1462
1463 #[test]
1464 fn index_prefix_cardinality_ignores_missing_user_index_mutations() {
1465 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1466 let collection = b"collection-a".to_vec();
1467 let draft = b"Draft".to_vec();
1468 let mut store = IndexStore::init_heap();
1469
1470 store.insert(
1471 indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1472 IndexEntryValue::presence(),
1473 );
1474 store.mark_prefix_cardinality_data_generation(7);
1475
1476 let stale_key = indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2);
1477 store.insert(stale_key.clone(), missing_index_entry_value());
1478 assert_eq!(
1479 store.exact_prefix_cardinality(
1480 7,
1481 IndexKeyKind::User,
1482 index_id,
1483 &[collection.clone(), draft.clone()],
1484 ),
1485 Some(1),
1486 "missing user index entries must not affect synchronized prefix cardinality",
1487 );
1488
1489 store.remove(&stale_key);
1490 assert_eq!(
1491 store.exact_prefix_cardinality(7, IndexKeyKind::User, index_id, &[collection, draft],),
1492 Some(1),
1493 "missing user index removals must not affect synchronized prefix cardinality",
1494 );
1495 }
1496
1497 #[cfg(all(feature = "sql", feature = "diagnostics"))]
1498 #[test]
1499 fn index_store_diagnostic_counters_record_gets_range_scans_and_entry_reads() {
1500 let mut store = IndexStore::init_heap();
1501 store.insert(raw_key(7), IndexEntryValue::presence());
1502 store.insert(raw_key(9), IndexEntryValue::presence());
1503
1504 let gets_before = IndexStore::current_get_call_count();
1505 assert_eq!(store.get(&raw_key(7)), Some(IndexEntryValue::presence()));
1506 assert_eq!(store.get(&raw_key(8)), None);
1507
1508 assert_eq!(
1509 IndexStore::current_get_call_count().saturating_sub(gets_before),
1510 2,
1511 "diagnostic index-store get counter should count both hit and miss reads",
1512 );
1513
1514 let range_scans_before = IndexStore::current_range_scan_call_count();
1515 let lower = Bound::Included(raw_key(7));
1516 let upper = Bound::Included(raw_key(9));
1517 store
1518 .visit_raw_entries_in_range((&lower, &upper), Direction::Asc, |_key, _entry| Ok(false))
1519 .expect("raw index range visit should succeed");
1520
1521 assert_eq!(
1522 IndexStore::current_range_scan_call_count().saturating_sub(range_scans_before),
1523 1,
1524 "diagnostic index-store range-scan counter should count one range traversal probe",
1525 );
1526
1527 let entries_before = IndexStore::current_entry_read_count();
1528 store
1529 .visit_entries(|_key, _entry| Ok::<_, Infallible>(IndexStoreVisit::Continue))
1530 .expect("index entry visit should succeed");
1531
1532 assert_eq!(
1533 IndexStore::current_entry_read_count().saturating_sub(entries_before),
1534 2,
1535 "diagnostic index-store entry counter should count yielded traversal entries",
1536 );
1537 }
1538
1539 #[test]
1540 fn journaled_mixed_index_range_traversal_streams_without_snapshot() {
1541 let mut store = IndexStore::init_journaled(test_memory(93));
1542 for value in [1_u8, 3, 5] {
1543 store.insert(raw_key(value), IndexEntryValue::presence());
1544 }
1545 store
1546 .fold_journaled_materialized_view()
1547 .expect("canonical index seed should fold");
1548
1549 store.insert(raw_key(0), IndexEntryValue::presence());
1550 store.insert(raw_key(4), IndexEntryValue::presence());
1551 store.insert(raw_key(5), IndexEntryValue::presence());
1552 store.remove(&raw_key(1));
1553
1554 let lower = Bound::Included(raw_key(0));
1555 let upper = Bound::Included(raw_key(5));
1556
1557 reset_journaled_snapshot_call_count_for_tests();
1558 let mut asc = Vec::new();
1559 store
1560 .visit_journaled_entries_in_range((&lower, &upper), Direction::Asc, |key, _value| {
1561 asc.push(key.as_bytes()[0]);
1562 Ok::<_, Infallible>(asc.len() == 2)
1563 })
1564 .expect("asc journaled index range traversal should succeed");
1565 assert_eq!(asc, vec![0, 3]);
1566 assert_eq!(
1567 journaled_snapshot_call_count_for_tests(),
1568 0,
1569 "mixed journaled index range traversal should preserve early stop without materializing a snapshot",
1570 );
1571
1572 reset_journaled_snapshot_call_count_for_tests();
1573 let mut desc = Vec::new();
1574 store
1575 .visit_journaled_entries_in_range((&lower, &upper), Direction::Desc, |key, _value| {
1576 desc.push(key.as_bytes()[0]);
1577 Ok::<_, Infallible>(desc.len() == 2)
1578 })
1579 .expect("desc journaled index range traversal should succeed");
1580 assert_eq!(desc, vec![5, 4]);
1581 assert_eq!(
1582 journaled_snapshot_call_count_for_tests(),
1583 0,
1584 "mixed reverse journaled index range traversal should preserve early stop without materializing a snapshot",
1585 );
1586 }
1587
1588 #[test]
1589 fn journaled_index_store_reopens_without_materializing_prefix_cardinality() {
1590 let memory = test_memory(94);
1591 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1592 let collection = b"collection-a".to_vec();
1593 let mut store = IndexStore::init_journaled(memory.clone());
1594 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1595 store.insert(key.clone(), IndexEntryValue::presence());
1596 store
1597 .fold_journaled_materialized_view()
1598 .expect("canonical index seed should fold");
1599 drop(store);
1600
1601 let mut reopened = IndexStore::init_journaled(memory);
1602
1603 assert_eq!(reopened.get(&key), Some(IndexEntryValue::presence()));
1604 assert_eq!(
1605 reopened.exact_prefix_cardinality(
1606 0,
1607 IndexKeyKind::User,
1608 index_id,
1609 std::slice::from_ref(&collection),
1610 ),
1611 None,
1612 "startup must leave optional prefix cardinality unavailable without scanning stable entries",
1613 );
1614 assert_eq!(
1615 reopened.exact_prefix_cardinality_delta(
1616 IndexKeyKind::User,
1617 index_id,
1618 std::slice::from_ref(&collection),
1619 ),
1620 Some(0),
1621 );
1622 let second = indexed_raw_key(&index_id, vec![collection.clone()], 2);
1623 reopened.insert(second.clone(), IndexEntryValue::presence());
1624 assert_eq!(
1625 reopened.exact_prefix_cardinality_delta(
1626 IndexKeyKind::User,
1627 index_id,
1628 std::slice::from_ref(&collection),
1629 ),
1630 Some(1),
1631 );
1632 reopened
1633 .fold_recovered_journal_entry(second, Some(IndexEntryValue::presence()))
1634 .expect("matching canonical index entry should fold");
1635 assert_eq!(
1636 reopened.exact_prefix_cardinality_delta(
1637 IndexKeyKind::User,
1638 index_id,
1639 std::slice::from_ref(&collection),
1640 ),
1641 Some(0),
1642 "canonical fold must consume only its exact overlay prefix contribution",
1643 );
1644 }
1645
1646 #[test]
1647 fn empty_journaled_index_store_retains_exact_prefix_cardinality_without_scanning() {
1648 let memory = test_memory(95);
1649 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1650 let collection = b"collection-a".to_vec();
1651 let mut store = IndexStore::init_journaled(memory.clone());
1652
1653 assert_eq!(
1654 store.exact_prefix_cardinality(
1655 0,
1656 IndexKeyKind::User,
1657 index_id,
1658 std::slice::from_ref(&collection),
1659 ),
1660 Some(0),
1661 );
1662 store
1663 .reset_journaled_live_projection(7, FoldWatermark::initial())
1664 .expect("empty projection reset should succeed");
1665 assert_eq!(
1666 store.exact_prefix_cardinality(
1667 7,
1668 IndexKeyKind::User,
1669 index_id,
1670 std::slice::from_ref(&collection),
1671 ),
1672 Some(0),
1673 );
1674 drop(store);
1675
1676 let reopened = IndexStore::init_journaled(memory);
1677 assert_eq!(
1678 reopened.exact_prefix_cardinality(
1679 0,
1680 IndexKeyKind::User,
1681 index_id,
1682 std::slice::from_ref(&collection),
1683 ),
1684 Some(0),
1685 );
1686 }
1687
1688 #[test]
1689 fn journaled_prefix_delta_binds_and_advances_its_canonical_watermark() {
1690 let mut store = IndexStore::init_journaled(test_memory(99));
1691 assert_eq!(store.exact_prefix_cardinality_delta_watermark(), None);
1692
1693 let current = FoldWatermark::new(JournalSequence::new(7), 3);
1694 let next = FoldWatermark::new(JournalSequence::new(8), 4);
1695 store
1696 .reset_journaled_live_projection(0, current)
1697 .expect("delta reset should bind the current canonical watermark");
1698 assert_eq!(
1699 store.exact_prefix_cardinality_delta_watermark(),
1700 Some(current)
1701 );
1702 store
1703 .preflight_prefix_cardinality_delta_watermark(current)
1704 .expect("matching watermark should preflight");
1705 assert!(
1706 store
1707 .preflight_prefix_cardinality_delta_watermark(next)
1708 .is_err(),
1709 "a stale or future base must fail closed",
1710 );
1711
1712 store.apply_prefix_cardinality_delta_watermark(current, next);
1713 assert_eq!(store.exact_prefix_cardinality_delta_watermark(), Some(next));
1714 }
1715
1716 #[test]
1717 fn recovered_index_fold_maintains_available_prefix_cardinality() {
1718 let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1719 let collection = b"collection-a".to_vec();
1720 let key = indexed_raw_key(&index_id, vec![collection.clone()], 1);
1721 let mut store = IndexStore::init_journaled(test_memory(96));
1722
1723 store
1724 .fold_recovered_journal_entry(key.clone(), Some(IndexEntryValue::presence()))
1725 .expect("recovered index put should fold");
1726 store.mark_prefix_cardinality_data_generation(1);
1727 assert_eq!(
1728 store.exact_prefix_cardinality(
1729 1,
1730 IndexKeyKind::User,
1731 index_id,
1732 std::slice::from_ref(&collection),
1733 ),
1734 Some(1),
1735 );
1736
1737 store
1738 .fold_recovered_journal_entry(key, None)
1739 .expect("recovered index delete should fold");
1740 store.mark_prefix_cardinality_data_generation(2);
1741 assert_eq!(
1742 store.exact_prefix_cardinality(
1743 2,
1744 IndexKeyKind::User,
1745 index_id,
1746 std::slice::from_ref(&collection),
1747 ),
1748 Some(0),
1749 );
1750 }
1751
1752 #[test]
1753 fn positioned_index_overlay_preserves_later_membership_until_exact_retirement() {
1754 let key = raw_key(7);
1755 let mut store = IndexStore::init_journaled(test_memory(97));
1756 store
1757 .fold_recovered_journal_entry(key.clone(), Some(IndexEntryValue::presence()))
1758 .expect("canonical membership should seed");
1759
1760 store
1761 .publish_positioned_journal_entry(key.clone(), None, overlay_position(1))
1762 .expect("positioned tombstone should publish");
1763 store
1764 .publish_positioned_journal_entry(
1765 key.clone(),
1766 Some(IndexEntryValue::presence()),
1767 overlay_position(2),
1768 )
1769 .expect("later membership should supersede the tombstone");
1770 store
1771 .fold_recovered_journal_entry(key.clone(), None)
1772 .expect("tombstone batch should become canonical");
1773 assert_eq!(
1774 store
1775 .retire_positioned_journal_effect(&key, overlay_position(1))
1776 .expect("older retirement should preserve later membership"),
1777 PositionedOverlayRetirement::Superseded,
1778 );
1779 assert_eq!(store.get(&key), Some(IndexEntryValue::presence()));
1780
1781 store
1782 .fold_recovered_journal_entry(key.clone(), Some(IndexEntryValue::presence()))
1783 .expect("membership batch should become canonical");
1784 assert_eq!(
1785 store
1786 .retire_positioned_journal_effect(&key, overlay_position(2))
1787 .expect("latest retirement should be exact"),
1788 PositionedOverlayRetirement::Exact,
1789 );
1790 assert_eq!(store.get(&key), Some(IndexEntryValue::presence()));
1791
1792 let mut visible = Vec::new();
1793 store
1794 .visit_entries(|visited_key, value| {
1795 visible.push((visited_key.clone(), value.clone()));
1796 Ok::<_, Infallible>(IndexStoreVisit::Continue)
1797 })
1798 .expect("positioned index should remain range-visible");
1799 assert_eq!(visible, vec![(key, IndexEntryValue::presence())]);
1800 }
1801
1802 #[test]
1803 fn positioned_index_overlay_coalesces_repeated_same_batch_target() {
1804 let key = raw_key(8);
1805 let position = overlay_position(3);
1806 let mut store = IndexStore::init_journaled(test_memory(98));
1807
1808 store
1809 .publish_positioned_journal_entry(
1810 key.clone(),
1811 Some(IndexEntryValue::presence()),
1812 position,
1813 )
1814 .expect("first same-batch effect should publish");
1815 store
1816 .publish_positioned_journal_entry(key.clone(), None, position)
1817 .expect("final same-batch effect should coalesce by logical target");
1818 store
1819 .fold_recovered_journal_entry(key.clone(), None)
1820 .expect("coalesced final effect should become canonical");
1821 assert_eq!(
1822 store
1823 .retire_positioned_journal_effect(&key, position)
1824 .expect("coalesced target should retire once"),
1825 PositionedOverlayRetirement::Exact,
1826 );
1827 assert!(store.get(&key).is_none());
1828 }
1829}