Skip to main content

icydb_core/db/index/
store.rs

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