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