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_memory::RuntimeMemory;
23use ic_memory::ic_stable_structures::{BTreeMap as StableBTreeMap, DefaultMemoryImpl};
24use serde::Deserialize;
25#[cfg(test)]
26use std::cell::Cell;
27use std::collections::{BTreeMap as HeapBTreeMap, BTreeSet};
28use std::ops::Bound;
29
30#[cfg(test)]
31thread_local! {
32    static JOURNALED_SNAPSHOT_CALL_COUNT: Cell<u64> = const { Cell::new(0) };
33}
34
35#[cfg(test)]
36thread_local! {
37    static INDEX_STORE_ENTRY_READ_COUNT: Cell<u64> = const { Cell::new(0) };
38}
39
40#[cfg(test)]
41fn record_index_store_entry_read() {
42    INDEX_STORE_ENTRY_READ_COUNT.with(|count| {
43        count.set(count.get().saturating_add(1));
44    });
45}
46
47fn visit_index_store_entry<E>(
48    key: &RawIndexStoreKey,
49    value: &IndexEntryValue,
50    visit: &mut impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<bool, E>,
51) -> Result<bool, E> {
52    #[cfg(test)]
53    record_index_store_entry_read();
54
55    visit(key, value)
56}
57
58#[cfg(test)]
59fn record_journaled_snapshot_call() {
60    JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| {
61        count.set(count.get().saturating_add(1));
62    });
63}
64
65#[cfg(test)]
66fn reset_journaled_snapshot_call_count_for_tests() {
67    JOURNALED_SNAPSHOT_CALL_COUNT.with(|count| count.set(0));
68}
69
70#[cfg(test)]
71fn journaled_snapshot_call_count_for_tests() -> u64 {
72    JOURNALED_SNAPSHOT_CALL_COUNT.with(Cell::get)
73}
74
75//
76// IndexState
77//
78// Explicit lifecycle visibility state for one index store.
79// Visibility matters because planner-visible indexes must already be complete:
80// the index contents are fully built and query-visible for reads.
81//
82#[derive(CandidType, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
83pub enum IndexState {
84    Building,
85    #[default]
86    Ready,
87}
88
89impl IndexState {
90    /// Return the stable lowercase text label for this lifecycle state.
91    #[must_use]
92    pub const fn as_str(self) -> &'static str {
93        match self {
94            Self::Building => "building",
95            Self::Ready => "ready",
96        }
97    }
98}
99
100///
101/// IndexStore
102///
103/// Thin persistence wrapper over one journaled or heap BTreeMap.
104///
105/// Invariant: callers provide already-validated `RawIndexStoreKey`/`IndexEntryValue`.
106///
107
108pub struct IndexStore {
109    pub(super) backend: IndexStoreBackend,
110    generation: u64,
111    state: IndexState,
112    access_state_revision: u64,
113    prefix_cardinality: IndexPrefixCardinality,
114}
115
116pub(super) enum IndexStoreBackend {
117    Heap(HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>),
118    Journaled {
119        canonical:
120            StableBTreeMap<RawIndexStoreKey, IndexEntryValue, RuntimeMemory<DefaultMemoryImpl>>,
121        live: HeapBTreeMap<RawIndexStoreKey, IndexEntryValue>,
122        tombstones: BTreeSet<RawIndexStoreKey>,
123        positions: PositionedOverlayMetadata<RawIndexStoreKey>,
124        prefix_cardinality_delta: Box<IndexPrefixCardinalityDelta>,
125    },
126}
127
128/// Preflighted provenance publication for explicit journal index records.
129pub(in crate::db) struct PreparedIndexPositionPublication {
130    keys: Vec<RawIndexStoreKey>,
131    position: JournalOverlayPosition,
132}
133
134/// Preflighted exact retirement for one complete journal batch.
135pub(in crate::db) struct PreparedIndexPositionRetirement {
136    entries: Vec<(RawIndexStoreKey, PositionedOverlayRetirement)>,
137}
138
139/// Control-flow result for index-store traversal visitors.
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub(in crate::db) enum IndexStoreVisit {
142    Continue,
143    Stop,
144}
145
146impl IndexStoreVisit {
147    const fn should_stop(self) -> bool {
148        matches!(self, Self::Stop)
149    }
150}
151
152impl IndexStore {
153    /// Initialize a volatile heap-backed index store.
154    #[must_use]
155    pub const fn init_heap() -> Self {
156        Self {
157            backend: IndexStoreBackend::Heap(HeapBTreeMap::new()),
158            generation: 0,
159            state: IndexState::Ready,
160            access_state_revision: 1,
161            prefix_cardinality: IndexPrefixCardinality::synchronized_empty(),
162        }
163    }
164
165    /// Initialize a journaled cached-stable index store.
166    ///
167    /// Normal writes update only the live materialized projection. The
168    /// canonical stable index is updated by future fold/rebuild paths.
169    #[must_use]
170    pub fn init_journaled(memory: RuntimeMemory<DefaultMemoryImpl>) -> Self {
171        let canonical = StableBTreeMap::init(memory);
172        let prefix_cardinality = if canonical.is_empty() {
173            IndexPrefixCardinality::synchronized_empty()
174        } else {
175            IndexPrefixCardinality::unavailable()
176        };
177        Self {
178            backend: IndexStoreBackend::Journaled {
179                canonical,
180                live: HeapBTreeMap::new(),
181                tombstones: BTreeSet::new(),
182                positions: PositionedOverlayMetadata::new(),
183                prefix_cardinality_delta: Box::new(IndexPrefixCardinalityDelta::unbound_empty()),
184            },
185            generation: 0,
186            state: IndexState::Ready,
187            access_state_revision: 1,
188            // Exact zero cardinality is known for an empty canonical map;
189            // populated maps remain unavailable without a startup scan.
190            prefix_cardinality,
191        }
192    }
193
194    /// Visit all index entries in canonical store order without exposing the
195    /// backing stable-map iterator.
196    pub(in crate::db) fn visit_entries<E>(
197        &self,
198        mut visitor: impl FnMut(&RawIndexStoreKey, &IndexEntryValue) -> Result<IndexStoreVisit, E>,
199    ) -> Result<(), E> {
200        match &self.backend {
201            IndexStoreBackend::Heap(map) => {
202                for (key, value) in map {
203                    #[cfg(test)]
204                    record_index_store_entry_read();
205
206                    if visitor(key, value)?.should_stop() {
207                        return Ok(());
208                    }
209                }
210            }
211            IndexStoreBackend::Journaled { .. } => self.visit_journaled_entries_in_range(
212                (&Bound::Unbounded, &Bound::Unbounded),
213                Direction::Asc,
214                |key, value| visitor(key, value).map(IndexStoreVisit::should_stop),
215            )?,
216        }
217
218        Ok(())
219    }
220
221    pub(in crate::db) fn get(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
222        match &self.backend {
223            IndexStoreBackend::Heap(map) => map.get(key).cloned(),
224            IndexStoreBackend::Journaled { .. } => Self::journaled_get(&self.backend, key),
225        }
226    }
227
228    /// Load one index entry from the canonical predecessor view.
229    pub(in crate::db) fn get_canonical(&self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
230        match &self.backend {
231            IndexStoreBackend::Heap(map) => map.get(key).cloned(),
232            IndexStoreBackend::Journaled { canonical, .. } => canonical.get(key),
233        }
234    }
235
236    /// Return whether the canonical predecessor domain is physically empty.
237    ///
238    /// This bounded root observation never walks live overlays or materializes
239    /// index entries.
240    pub(in crate::db) fn canonical_is_empty(&self) -> Result<bool, crate::error::InternalError> {
241        match &self.backend {
242            IndexStoreBackend::Journaled { canonical, .. } => Ok(canonical.is_empty()),
243            IndexStoreBackend::Heap(_) => Err(crate::error::InternalError::store_invariant()),
244        }
245    }
246
247    pub fn len(&self) -> u64 {
248        match &self.backend {
249            IndexStoreBackend::Heap(map) => u64::try_from(map.len()).unwrap_or(u64::MAX),
250            IndexStoreBackend::Journaled { .. } => {
251                let mut count = 0_u64;
252                let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
253                    count = count.saturating_add(1);
254                    Ok(IndexStoreVisit::Continue)
255                });
256                count
257            }
258        }
259    }
260
261    pub fn is_empty(&self) -> bool {
262        match &self.backend {
263            IndexStoreBackend::Heap(map) => map.is_empty(),
264            IndexStoreBackend::Journaled { .. } => {
265                let mut empty = true;
266                let _: Result<(), std::convert::Infallible> = self.visit_entries(|_key, _value| {
267                    empty = false;
268                    Ok(IndexStoreVisit::Stop)
269                });
270                empty
271            }
272        }
273    }
274
275    #[must_use]
276    pub(in crate::db) const fn generation(&self) -> u64 {
277        self.generation
278    }
279
280    /// Return the explicit lifecycle state for this index store.
281    #[must_use]
282    pub(in crate::db) const fn state(&self) -> IndexState {
283        self.state
284    }
285
286    /// Return the current physical access-readiness revision.
287    #[must_use]
288    pub(in crate::db) const fn access_state_revision(&self) -> u64 {
289        self.access_state_revision
290    }
291
292    /// Return an exact user-index prefix count when the index metadata is
293    /// synchronized with the caller's authoritative row-store generation.
294    #[must_use]
295    pub(in crate::db) fn exact_prefix_cardinality(
296        &self,
297        data_generation: u64,
298        key_kind: IndexKeyKind,
299        index_id: IndexId,
300        components: &[Vec<u8>],
301    ) -> Option<u64> {
302        self.prefix_cardinality
303            .exact_count(data_generation, key_kind, index_id, components)
304    }
305
306    /// Return the exact number of distinct non-empty leading components for
307    /// one user index, bounded by `stop_after`, when metadata is synchronized.
308    #[cfg(test)]
309    pub(in crate::db) fn exact_first_component_distinct_cardinality(
310        &self,
311        data_generation: u64,
312        index_id: IndexId,
313        stop_after: u64,
314    ) -> Result<Option<(u64, u64)>, crate::error::InternalError> {
315        self.prefix_cardinality
316            .exact_first_component_distinct_count(data_generation, index_id, stop_after)
317    }
318
319    /// Sum exact first-component multiplicities within the caller's bounded range and work cap.
320    pub(in crate::db) fn exact_first_component_range_cardinality(
321        &self,
322        data_generation: u64,
323        index_id: IndexId,
324        lower: &Bound<Vec<u8>>,
325        upper: &Bound<Vec<u8>>,
326        stop_after: u64,
327    ) -> Result<Option<(u64, u64, bool)>, crate::error::InternalError> {
328        self.prefix_cardinality.exact_first_component_range_count(
329            data_generation,
330            index_id,
331            lower,
332            upper,
333            stop_after,
334        )
335    }
336
337    pub(in crate::db) fn exact_first_component_numeric_fold(
338        &self,
339        data_generation: u64,
340        index_id: IndexId,
341        stop_after: u64,
342    ) -> Result<Option<(u64, i128, u64, bool)>, crate::error::InternalError> {
343        self.prefix_cardinality.exact_first_component_numeric_fold(
344            data_generation,
345            index_id,
346            stop_after,
347        )
348    }
349
350    /// Return the exact live-overlay delta from canonical for one user-index prefix.
351    #[must_use]
352    pub(in crate::db) fn exact_prefix_cardinality_delta(
353        &self,
354        key_kind: IndexKeyKind,
355        index_id: IndexId,
356        components: &[Vec<u8>],
357    ) -> Option<i64> {
358        match &self.backend {
359            IndexStoreBackend::Heap(_) => Some(0),
360            IndexStoreBackend::Journaled {
361                prefix_cardinality_delta,
362                ..
363            } => prefix_cardinality_delta.exact_delta(key_kind, index_id, components),
364        }
365    }
366
367    /// Return the canonical fold boundary owning the exact live-overlay delta.
368    #[must_use]
369    pub(in crate::db) fn exact_prefix_cardinality_delta_watermark(&self) -> Option<FoldWatermark> {
370        match &self.backend {
371            IndexStoreBackend::Heap(_) => None,
372            IndexStoreBackend::Journaled {
373                prefix_cardinality_delta,
374                ..
375            } => prefix_cardinality_delta.base_watermark(),
376        }
377    }
378
379    /// Return the sum of exact prefix counts for prefixes on the same index
380    /// when synchronized metadata can prove all requested counts.
381    #[must_use]
382    pub(in crate::db) fn exact_prefix_cardinality_sum<'a>(
383        &self,
384        data_generation: u64,
385        key_kind: IndexKeyKind,
386        index_id: IndexId,
387        component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
388        stop_after: Option<u64>,
389    ) -> Option<u64> {
390        self.prefix_cardinality.exact_count_sum(
391            data_generation,
392            key_kind,
393            index_id,
394            component_prefixes,
395            stop_after,
396        )
397    }
398
399    /// Return non-empty exact child prefixes under a sparse set of already-encoded
400    /// parent prefixes when synchronized metadata can prove the bounded child set.
401    #[must_use]
402    pub(in crate::db) fn exact_child_prefixes_for_parent_set<'a>(
403        &self,
404        data_generation: u64,
405        key_kind: IndexKeyKind,
406        index_id: IndexId,
407        parent_component_prefixes: impl IntoIterator<Item = &'a [Vec<u8>]>,
408        max_children: usize,
409    ) -> Option<Vec<Vec<Vec<u8>>>> {
410        self.prefix_cardinality.exact_child_prefixes_for_parent_set(
411            data_generation,
412            key_kind,
413            index_id,
414            parent_component_prefixes,
415            max_children,
416        )
417    }
418
419    /// Mark prefix-cardinality metadata synchronized with the authoritative
420    /// row-store generation after a committed row/index transition.
421    pub(in crate::db) const fn mark_prefix_cardinality_data_generation(&mut self, generation: u64) {
422        self.prefix_cardinality.mark_synchronized(generation);
423    }
424
425    /// Mark this index store as in-progress and therefore ineligible for
426    /// planner visibility until a full authoritative rebuild ends.
427    pub(in crate::db) const fn set_access_state(&mut self, state: IndexState, revision: u64) {
428        self.state = state;
429        self.access_state_revision = revision;
430    }
431
432    pub(crate) fn insert(
433        &mut self,
434        key: RawIndexStoreKey,
435        entry: IndexEntryValue,
436    ) -> Option<IndexEntryValue> {
437        let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
438            self.get(&key)
439        } else {
440            None
441        };
442        let cardinality_key = key.clone();
443        let previous = match &mut self.backend {
444            IndexStoreBackend::Heap(map) => map.insert(key, entry.clone()),
445            IndexStoreBackend::Journaled {
446                live, tombstones, ..
447            } => {
448                tombstones.remove(&key);
449                live.insert(key, entry.clone());
450                previous_journaled
451            }
452        };
453        self.prefix_cardinality
454            .apply_insert(&cardinality_key, previous.as_ref(), &entry);
455        self.apply_prefix_overlay_delta(&cardinality_key, previous.as_ref(), Some(&entry));
456        self.bump_generation();
457        previous
458    }
459
460    /// Insert one key whose absence was proved by complete-domain staging.
461    ///
462    /// Accepted-schema replacement first removes its complete current user
463    /// domain. Raw index identity makes every final key owner-local, so no
464    /// canonical point lookup can add information during mechanical Apply.
465    pub(in crate::db) fn insert_preflighted_absent(
466        &mut self,
467        key: RawIndexStoreKey,
468        entry: IndexEntryValue,
469    ) {
470        let cardinality_key = key.clone();
471        match &mut self.backend {
472            IndexStoreBackend::Heap(map) => {
473                map.insert(key, entry.clone());
474            }
475            IndexStoreBackend::Journaled {
476                live, tombstones, ..
477            } => {
478                tombstones.remove(&key);
479                live.insert(key, entry.clone());
480            }
481        }
482        self.prefix_cardinality
483            .apply_insert(&cardinality_key, None, &entry);
484        self.apply_prefix_overlay_delta(&cardinality_key, None, Some(&entry));
485        self.bump_generation();
486    }
487
488    pub(crate) fn remove(&mut self, key: &RawIndexStoreKey) -> Option<IndexEntryValue> {
489        let previous_journaled = if matches!(self.backend, IndexStoreBackend::Journaled { .. }) {
490            self.get(key)
491        } else {
492            None
493        };
494        let previous = match &mut self.backend {
495            IndexStoreBackend::Heap(map) => map.remove(key),
496            IndexStoreBackend::Journaled {
497                live, tombstones, ..
498            } => {
499                live.remove(key);
500                tombstones.insert(key.clone());
501                previous_journaled
502            }
503        };
504        self.prefix_cardinality.apply_remove(key, previous.as_ref());
505        self.apply_prefix_overlay_delta(key, previous.as_ref(), None);
506        self.bump_generation();
507        previous
508    }
509
510    /// Reset the disposable journaled index overlay without traversing or
511    /// mutating the canonical stable index.
512    pub(in crate::db) fn reset_journaled_live_projection(
513        &mut self,
514        data_generation: u64,
515        fold_watermark: FoldWatermark,
516    ) -> Result<(), crate::error::InternalError> {
517        let IndexStoreBackend::Journaled {
518            canonical,
519            live,
520            tombstones,
521            positions,
522            prefix_cardinality_delta,
523        } = &mut self.backend
524        else {
525            return Err(crate::error::InternalError::store_invariant());
526        };
527
528        live.clear();
529        tombstones.clear();
530        positions.clear();
531        prefix_cardinality_delta.reset(fold_watermark);
532        self.prefix_cardinality = if canonical.is_empty() {
533            let mut cardinality = IndexPrefixCardinality::synchronized_empty();
534            cardinality.mark_synchronized(data_generation);
535            cardinality
536        } else {
537            IndexPrefixCardinality::unavailable()
538        };
539        self.bump_generation();
540
541        Ok(())
542    }
543
544    /// Preflight movement of the exact delta's canonical base boundary.
545    pub(in crate::db) fn preflight_prefix_cardinality_delta_watermark(
546        &self,
547        current: FoldWatermark,
548    ) -> Result<(), crate::error::InternalError> {
549        (self.exact_prefix_cardinality_delta_watermark() == Some(current))
550            .then_some(())
551            .ok_or_else(crate::error::InternalError::store_corruption)
552    }
553
554    /// Publish a preflighted canonical base movement after the complete fold.
555    pub(in crate::db) fn apply_prefix_cardinality_delta_watermark(
556        &mut self,
557        current: FoldWatermark,
558        next: FoldWatermark,
559    ) {
560        if let IndexStoreBackend::Journaled {
561            prefix_cardinality_delta,
562            ..
563        } = &mut self.backend
564        {
565            prefix_cardinality_delta.advance_watermark(current, next);
566        }
567    }
568
569    /// Publish one preflighted positioned derived or explicit index effect.
570    pub(in crate::db) fn publish_preflighted_journal_entry(
571        &mut self,
572        key: RawIndexStoreKey,
573        value: Option<IndexEntryValue>,
574        position: JournalOverlayPosition,
575    ) -> Result<Option<IndexEntryValue>, crate::error::InternalError> {
576        let IndexStoreBackend::Journaled {
577            canonical,
578            live,
579            tombstones,
580            positions,
581            prefix_cardinality_delta,
582        } = &mut self.backend
583        else {
584            return Err(crate::error::InternalError::store_invariant());
585        };
586        let previous = if tombstones.contains(&key) {
587            None
588        } else {
589            live.get(&key).cloned().or_else(|| canonical.get(&key))
590        };
591        let cardinality_key = key.clone();
592        let next_value = value.clone();
593
594        if let Some(value) = value {
595            tombstones.remove(&key);
596            live.insert(key.clone(), value.clone());
597            self.prefix_cardinality
598                .apply_insert(&cardinality_key, previous.as_ref(), &value);
599        } else {
600            live.remove(&key);
601            tombstones.insert(key.clone());
602            self.prefix_cardinality
603                .apply_remove(&cardinality_key, previous.as_ref());
604        }
605        prefix_cardinality_delta.apply_transition(
606            &cardinality_key,
607            previous.as_ref(),
608            next_value.as_ref(),
609        );
610        positions.publish_preflighted(key, position);
611        self.bump_generation();
612
613        Ok(previous)
614    }
615
616    /// Validate and publish one positioned index effect for direct store tests.
617    #[cfg(test)]
618    pub(in crate::db) fn publish_positioned_journal_entry(
619        &mut self,
620        key: RawIndexStoreKey,
621        value: Option<IndexEntryValue>,
622        position: JournalOverlayPosition,
623    ) -> Result<Option<IndexEntryValue>, crate::error::InternalError> {
624        self.preflight_positioned_journal_entry(&key, position)?;
625        self.publish_preflighted_journal_entry(key, value, position)
626    }
627
628    /// Preflight index provenance before marker publication.
629    pub(in crate::db) fn preflight_positioned_journal_entry(
630        &self,
631        key: &RawIndexStoreKey,
632        position: JournalOverlayPosition,
633    ) -> Result<(), crate::error::InternalError> {
634        let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
635            return Err(crate::error::InternalError::store_invariant());
636        };
637        positions.preflight_publish(key, position)
638    }
639
640    /// Preflight explicit index provenance before marker publication.
641    pub(in crate::db) fn prepare_position_publication(
642        &self,
643        keys: impl IntoIterator<Item = RawIndexStoreKey>,
644        position: JournalOverlayPosition,
645    ) -> Result<PreparedIndexPositionPublication, crate::error::InternalError> {
646        let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
647            return Err(crate::error::InternalError::store_invariant());
648        };
649        let keys = keys.into_iter().collect::<BTreeSet<_>>();
650        for key in &keys {
651            positions.preflight_publish(key, position)?;
652        }
653        Ok(PreparedIndexPositionPublication {
654            keys: keys.into_iter().collect(),
655            position,
656        })
657    }
658
659    /// Publish explicit index provenance after its values have been applied.
660    pub(in crate::db) fn publish_prepared_positions(
661        &mut self,
662        prepared: PreparedIndexPositionPublication,
663    ) {
664        let IndexStoreBackend::Journaled { positions, .. } = &mut self.backend else {
665            debug_assert!(
666                false,
667                "preflighted index positions require a journaled store"
668            );
669            return;
670        };
671        for key in prepared.keys {
672            positions.publish_preflighted(key, prepared.position);
673        }
674    }
675
676    /// Preflight exact index-overlay retirement before canonical mutation.
677    pub(in crate::db) fn prepare_position_retirement(
678        &self,
679        keys: impl IntoIterator<Item = RawIndexStoreKey>,
680        position: JournalOverlayPosition,
681    ) -> Result<PreparedIndexPositionRetirement, crate::error::InternalError> {
682        let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
683            return Err(crate::error::InternalError::store_invariant());
684        };
685        let entries = keys
686            .into_iter()
687            .collect::<BTreeSet<_>>()
688            .into_iter()
689            .map(|key| {
690                positions
691                    .preflight_retirement(&key, position)
692                    .map(|retirement| (key, retirement))
693            })
694            .collect::<Result<Vec<_>, _>>()?;
695        Ok(PreparedIndexPositionRetirement { entries })
696    }
697
698    /// Retire only exact index overlays after canonical mutation succeeds.
699    pub(in crate::db) fn apply_prepared_position_retirement(
700        &mut self,
701        prepared: PreparedIndexPositionRetirement,
702    ) {
703        let IndexStoreBackend::Journaled {
704            live,
705            tombstones,
706            positions,
707            ..
708        } = &mut self.backend
709        else {
710            debug_assert!(
711                false,
712                "preflighted index retirement requires a journaled store"
713            );
714            return;
715        };
716        for (key, retirement) in prepared.entries {
717            if retirement == PositionedOverlayRetirement::Exact {
718                live.remove(&key);
719                tombstones.remove(&key);
720                positions.retire_preflighted(&key, retirement);
721            }
722        }
723    }
724
725    #[cfg(test)]
726    fn retire_positioned_journal_effect(
727        &mut self,
728        key: &RawIndexStoreKey,
729        position: JournalOverlayPosition,
730    ) -> Result<PositionedOverlayRetirement, crate::error::InternalError> {
731        let IndexStoreBackend::Journaled { positions, .. } = &self.backend else {
732            return Err(crate::error::InternalError::store_invariant());
733        };
734        let retirement = positions.preflight_retirement(key, position)?;
735        let prepared = PreparedIndexPositionRetirement {
736            entries: vec![(key.clone(), retirement)],
737        };
738        self.apply_prepared_position_retirement(prepared);
739        Ok(retirement)
740    }
741
742    /// Apply one recovered index entry directly to canonical stable storage.
743    pub(in crate::db) fn fold_recovered_journal_entry(
744        &mut self,
745        key: RawIndexStoreKey,
746        value: Option<IndexEntryValue>,
747    ) -> Result<(), crate::error::InternalError> {
748        let IndexStoreBackend::Journaled {
749            canonical,
750            live,
751            tombstones,
752            ..
753        } = &mut self.backend
754        else {
755            return Err(crate::error::InternalError::store_invariant());
756        };
757
758        let visible = !live.contains_key(&key) && !tombstones.contains(&key);
759        let cardinality_key = key.clone();
760        let previous = if let Some(value) = value.as_ref() {
761            canonical.insert(key, value.clone())
762        } else {
763            canonical.remove(&key)
764        };
765        if visible {
766            if let Some(value) = value.as_ref() {
767                self.prefix_cardinality
768                    .apply_insert(&cardinality_key, previous.as_ref(), value);
769            } else {
770                self.prefix_cardinality
771                    .apply_remove(&cardinality_key, previous.as_ref());
772            }
773        } else {
774            self.apply_prefix_overlay_delta(&cardinality_key, value.as_ref(), previous.as_ref());
775        }
776        self.bump_generation();
777
778        Ok(())
779    }
780
781    /// Prove that recovered journal entries can be folded into canonical storage.
782    pub(in crate::db) fn preflight_fold_recovered_journal(
783        &self,
784    ) -> Result<(), crate::error::InternalError> {
785        match self.backend {
786            IndexStoreBackend::Journaled { .. } => Ok(()),
787            IndexStoreBackend::Heap(_) => Err(crate::error::InternalError::store_invariant()),
788        }
789    }
790
791    pub fn clear(&mut self) {
792        match &mut self.backend {
793            IndexStoreBackend::Heap(map) => map.clear(),
794            IndexStoreBackend::Journaled {
795                canonical,
796                live,
797                tombstones,
798                prefix_cardinality_delta,
799                ..
800            } => {
801                live.clear();
802                tombstones.clear();
803                for entry in canonical.iter() {
804                    tombstones.insert(entry.key().clone());
805                }
806                prefix_cardinality_delta.clear_unavailable();
807            }
808        }
809        self.prefix_cardinality.clear_unsynchronized();
810        self.bump_generation();
811    }
812
813    /// Fold the current journaled materialized index view into the canonical
814    /// stable base and clear volatile projection state.
815    #[cfg(any(test, feature = "migration"))]
816    pub(in crate::db) fn fold_journaled_materialized_view(
817        &mut self,
818    ) -> Result<(), crate::error::InternalError> {
819        let entries = Self::journaled_entries_snapshot_for_fold(&self.backend);
820        let IndexStoreBackend::Journaled {
821            canonical,
822            live,
823            tombstones,
824            prefix_cardinality_delta,
825            ..
826        } = &mut self.backend
827        else {
828            return Err(crate::error::InternalError::store_invariant());
829        };
830
831        canonical.clear_new();
832        for (key, value) in entries {
833            canonical.insert(key, value);
834        }
835        live.clear();
836        tombstones.clear();
837        if let Some(watermark) = prefix_cardinality_delta.base_watermark() {
838            prefix_cardinality_delta.reset(watermark);
839        } else {
840            **prefix_cardinality_delta = IndexPrefixCardinalityDelta::unbound_empty();
841        }
842        let data_generation = self.prefix_cardinality.synchronized_generation();
843        self.rebuild_prefix_cardinality_from_entries(data_generation);
844        self.bump_generation();
845
846        Ok(())
847    }
848
849    /// Sum of bytes used by all stored index entries.
850    pub fn memory_bytes(&self) -> u64 {
851        let mut bytes = 0u64;
852        let _: Result<(), std::convert::Infallible> = self.visit_entries(|key, value| {
853            bytes = bytes.saturating_add(key.as_bytes().len() as u64 + value.len() as u64);
854            Ok(IndexStoreVisit::Continue)
855        });
856        bytes
857    }
858
859    /// Return the monotonic perf-only count of index entries yielded by traversal.
860    #[cfg(test)]
861    pub(in crate::db) fn current_entry_read_count() -> u64 {
862        INDEX_STORE_ENTRY_READ_COUNT.with(Cell::get)
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.as_ref();
979        let upper = bounds.1.as_ref();
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                for entry in ordered_overlay_entries(
1011                    canonical.range((lower, upper)),
1012                    live.range((lower, upper)),
1013                    direction,
1014                    |entry| entry.key(),
1015                    |entry| entry.0,
1016                    tombstones,
1017                ) {
1018                    let should_stop = match entry {
1019                        OrderedOverlayEntry::Canonical(canonical_entry) => visit_index_store_entry(
1020                            canonical_entry.key(),
1021                            &canonical_entry.value(),
1022                            &mut visit,
1023                        )?,
1024                        OrderedOverlayEntry::Live((key, value)) => {
1025                            visit_index_store_entry(key, value, &mut visit)?
1026                        }
1027                    };
1028                    if should_stop {
1029                        return Ok(());
1030                    }
1031                }
1032            }
1033            Direction::Desc => {
1034                for entry in ordered_overlay_entries(
1035                    canonical.range((lower, upper)).rev(),
1036                    live.range((lower, upper)).rev(),
1037                    direction,
1038                    |entry| entry.key(),
1039                    |entry| entry.0,
1040                    tombstones,
1041                ) {
1042                    let should_stop = match entry {
1043                        OrderedOverlayEntry::Canonical(canonical_entry) => visit_index_store_entry(
1044                            canonical_entry.key(),
1045                            &canonical_entry.value(),
1046                            &mut visit,
1047                        )?,
1048                        OrderedOverlayEntry::Live((key, value)) => {
1049                            visit_index_store_entry(key, value, &mut visit)?
1050                        }
1051                    };
1052                    if should_stop {
1053                        return Ok(());
1054                    }
1055                }
1056            }
1057        }
1058
1059        Ok(())
1060    }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066    use crate::{
1067        db::{
1068            direction::Direction,
1069            index::{IndexId, IndexKey, IndexKeyKind},
1070            journal::JournalSequence,
1071            key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
1072            positioned_overlay::{JournalOverlayPosition, PositionedOverlayRetirement},
1073            registry::StoreAllocationIdentity,
1074        },
1075        testing::test_memory,
1076        types::EntityTag,
1077    };
1078    use ic_memory::ic_stable_structures::Storable;
1079    use std::{borrow::Cow, convert::Infallible};
1080
1081    fn raw_key(value: u8) -> RawIndexStoreKey {
1082        <RawIndexStoreKey as Storable>::from_bytes(Cow::Owned(vec![value]))
1083    }
1084
1085    fn overlay_position(sequence: u64) -> JournalOverlayPosition {
1086        JournalOverlayPosition::new(
1087            StoreAllocationIdentity::new(231, "test::index"),
1088            JournalSequence::new(sequence),
1089        )
1090    }
1091
1092    fn indexed_raw_key(
1093        index_id: &IndexId,
1094        components: Vec<Vec<u8>>,
1095        primary_key: u64,
1096    ) -> RawIndexStoreKey {
1097        indexed_raw_key_with_kind(index_id, IndexKeyKind::User, components, primary_key)
1098    }
1099
1100    fn indexed_raw_key_with_kind(
1101        index_id: &IndexId,
1102        key_kind: IndexKeyKind,
1103        components: Vec<Vec<u8>>,
1104        primary_key: u64,
1105    ) -> RawIndexStoreKey {
1106        IndexKey::new_from_components_with_primary_key_value(
1107            index_id,
1108            key_kind,
1109            components.as_slice(),
1110            &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(primary_key)),
1111        )
1112        .expect("test index key should build")
1113        .to_raw()
1114        .expect("test index key should encode")
1115    }
1116
1117    fn malformed_index_entry_value() -> IndexEntryValue {
1118        <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![0xFF]))
1119    }
1120
1121    fn missing_index_entry_value() -> IndexEntryValue {
1122        <IndexEntryValue as Storable>::from_bytes(Cow::Owned(vec![1]))
1123    }
1124
1125    #[test]
1126    fn index_prefix_cardinality_requires_explicit_data_generation_sync() {
1127        let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1128        let collection = b"collection-a".to_vec();
1129        let draft = b"Draft".to_vec();
1130        let review = b"Review".to_vec();
1131        let mut store = IndexStore::init_heap();
1132
1133        store.insert(
1134            indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1135            IndexEntryValue::presence(),
1136        );
1137        store.insert(
1138            indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
1139            IndexEntryValue::presence(),
1140        );
1141        store.insert(
1142            indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
1143            IndexEntryValue::presence(),
1144        );
1145
1146        assert_eq!(
1147            store.exact_prefix_cardinality(
1148                0,
1149                IndexKeyKind::User,
1150                index_id,
1151                std::slice::from_ref(&collection),
1152            ),
1153            None,
1154            "raw index mutations must not be trusted until row generation sync is stamped",
1155        );
1156
1157        store.mark_prefix_cardinality_data_generation(7);
1158
1159        assert_eq!(
1160            store.exact_prefix_cardinality(
1161                7,
1162                IndexKeyKind::User,
1163                index_id,
1164                std::slice::from_ref(&collection),
1165            ),
1166            Some(3),
1167        );
1168        assert_eq!(
1169            store.exact_prefix_cardinality(
1170                7,
1171                IndexKeyKind::User,
1172                index_id,
1173                &[collection.clone(), draft],
1174            ),
1175            Some(2),
1176        );
1177        assert_eq!(
1178            store.exact_prefix_cardinality(8, IndexKeyKind::User, index_id, &[collection, review],),
1179            None,
1180            "row generation drift should force the caller to use the existing-row fallback",
1181        );
1182    }
1183
1184    #[test]
1185    fn first_component_distinct_cardinality_is_exact_bounded_and_generation_matched() {
1186        let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1187        let alpha = b"alpha".to_vec();
1188        let beta = b"beta".to_vec();
1189        let alpha_one = indexed_raw_key(&index_id, vec![alpha.clone()], 1);
1190        let alpha_two = indexed_raw_key(&index_id, vec![alpha], 2);
1191        let beta_one = indexed_raw_key(&index_id, vec![beta], 3);
1192        let mut store = IndexStore::init_heap();
1193
1194        assert_eq!(
1195            store
1196                .exact_first_component_distinct_cardinality(0, index_id, 1)
1197                .expect("initialized metadata should be structurally valid"),
1198            Some((0, 0)),
1199            "initialized empty metadata must positively prove exact zero",
1200        );
1201        store.insert(alpha_one.clone(), IndexEntryValue::presence());
1202        store.insert(alpha_two.clone(), IndexEntryValue::presence());
1203        store.insert(beta_one.clone(), IndexEntryValue::presence());
1204        assert_eq!(
1205            store
1206                .exact_first_component_distinct_cardinality(0, index_id, 3)
1207                .expect("invalidated metadata should remain structurally valid"),
1208            None,
1209            "an unstamped mutation must make optional metadata unavailable",
1210        );
1211
1212        store.mark_prefix_cardinality_data_generation(7);
1213        assert_eq!(
1214            store
1215                .exact_first_component_distinct_cardinality(7, index_id, 3)
1216                .expect("synchronized metadata should be structurally valid"),
1217            Some((2, 2)),
1218            "duplicate physical entries must contribute one leading component",
1219        );
1220        assert_eq!(
1221            store
1222                .exact_first_component_distinct_cardinality(7, index_id, 1)
1223                .expect("bounded metadata should be structurally valid"),
1224            Some((1, 1)),
1225            "stop-after must bound both result evidence and metadata work",
1226        );
1227        assert_eq!(
1228            store
1229                .exact_first_component_distinct_cardinality(8, index_id, 3)
1230                .expect("stale metadata should remain structurally valid"),
1231            None,
1232            "row-generation drift must fail closed",
1233        );
1234
1235        store.remove(&alpha_one);
1236        store.remove(&alpha_two);
1237        store.remove(&beta_one);
1238        store.mark_prefix_cardinality_data_generation(8);
1239        assert_eq!(
1240            store
1241                .exact_first_component_distinct_cardinality(8, index_id, 1)
1242                .expect("empty metadata should be structurally valid"),
1243            Some((0, 0)),
1244            "deleting every value must restore a positive exact-zero proof",
1245        );
1246    }
1247
1248    #[test]
1249    fn index_prefix_cardinality_enumerates_bounded_child_prefixes() {
1250        let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1251        let collection = b"collection-a".to_vec();
1252        let other_collection = b"collection-b".to_vec();
1253        let draft = b"Draft".to_vec();
1254        let review = b"Review".to_vec();
1255        let published = b"Published".to_vec();
1256        let mut store = IndexStore::init_heap();
1257
1258        store.insert(
1259            indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1260            IndexEntryValue::presence(),
1261        );
1262        store.insert(
1263            indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 2),
1264            IndexEntryValue::presence(),
1265        );
1266        store.insert(
1267            indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 3),
1268            IndexEntryValue::presence(),
1269        );
1270        store.insert(
1271            indexed_raw_key(
1272                &index_id,
1273                vec![other_collection.clone(), published.clone()],
1274                4,
1275            ),
1276            IndexEntryValue::presence(),
1277        );
1278        store.mark_prefix_cardinality_data_generation(7);
1279
1280        assert_eq!(
1281            store.exact_child_prefixes_for_parent_set(
1282                7,
1283                IndexKeyKind::User,
1284                index_id,
1285                [std::slice::from_ref(&collection)],
1286                4,
1287            ),
1288            Some(vec![
1289                vec![collection.clone(), draft],
1290                vec![collection.clone(), review],
1291            ]),
1292            "child-prefix enumeration should return deterministic unique children under the requested parent",
1293        );
1294        assert_eq!(
1295            store.exact_child_prefixes_for_parent_set(
1296                7,
1297                IndexKeyKind::User,
1298                index_id,
1299                [std::slice::from_ref(&other_collection)],
1300                4,
1301            ),
1302            Some(vec![vec![other_collection, published]]),
1303            "child-prefix enumeration must stay scoped to the requested parent prefix",
1304        );
1305        assert_eq!(
1306            store.exact_child_prefixes_for_parent_set(
1307                8,
1308                IndexKeyKind::User,
1309                index_id,
1310                [std::slice::from_ref(&collection)],
1311                4,
1312            ),
1313            None,
1314            "row generation drift should keep child-prefix expansion fail-closed",
1315        );
1316        assert_eq!(
1317            store.exact_child_prefixes_for_parent_set(
1318                7,
1319                IndexKeyKind::User,
1320                index_id,
1321                [std::slice::from_ref(&collection)],
1322                1,
1323            ),
1324            None,
1325            "over-cap child-prefix expansion should fall back to the existing route",
1326        );
1327    }
1328
1329    #[test]
1330    fn index_prefix_cardinality_batches_sparse_child_prefixes() {
1331        let index_id = IndexId::new(EntityTag::new(0xCA7D), 1);
1332        let collection = b"collection-a".to_vec();
1333        let other_collection = b"collection-b".to_vec();
1334        let missing_a = b"missing-a".to_vec();
1335        let missing_b = b"missing-b".to_vec();
1336        let draft = b"Draft".to_vec();
1337        let review = b"Review".to_vec();
1338        let published = b"Published".to_vec();
1339        let mut store = IndexStore::init_heap();
1340
1341        store.insert(
1342            indexed_raw_key(&index_id, vec![collection.clone(), draft.clone()], 1),
1343            IndexEntryValue::presence(),
1344        );
1345        store.insert(
1346            indexed_raw_key(&index_id, vec![collection.clone(), review.clone()], 2),
1347            IndexEntryValue::presence(),
1348        );
1349        store.insert(
1350            indexed_raw_key(
1351                &index_id,
1352                vec![other_collection.clone(), published.clone()],
1353                3,
1354            ),
1355            IndexEntryValue::presence(),
1356        );
1357        store.mark_prefix_cardinality_data_generation(7);
1358
1359        let parents = [
1360            std::slice::from_ref(&missing_a),
1361            std::slice::from_ref(&collection),
1362            std::slice::from_ref(&missing_b),
1363            std::slice::from_ref(&other_collection),
1364            std::slice::from_ref(&collection),
1365        ];
1366        assert_eq!(
1367            store.exact_child_prefixes_for_parent_set(7, IndexKeyKind::User, index_id, parents, 3,),
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}