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