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