Skip to main content

commonware_storage/index/
immutable.rs

1//! An index for keys that are set once at increasing positions and retired only by position.
2//!
3//! The index maps each translated key to the positions it was set at. Positions are inserted at
4//! or above the index's start and retired in prefix order by [`Index::prune`], which suits
5//! append-only logs whose entries are never updated or deleted individually. Entries that must
6//! be updated or removed by key belong in [super::unordered] or [super::ordered].
7//!
8//! Translated keys are kept in a ring indexed by position, so the hash table holds only
9//! positions, and pruning retires exactly the pruned positions: each is removed from the table
10//! under the key its ring slot holds, and the slots are released from the ring's front. No pass
11//! over the live entries is ever needed. The table's allocation is bounded by the most keys
12//! indexed at once and the ring's by the widest span of positions retained at once, indexed
13//! or not.
14//!
15//! ```text
16//!  table: positions under the hash of their key      keys: translated key by position
17//!  +----------+-------+                              position  0   1   2   3   4   5   6   7
18//!  | hash(aa) | 1, 6  |                                      +---+---+---+---+---+---+---+---+
19//!  | hash(c0) | 3     |                                      | - | aa| - | c0| 12| - | aa| - |
20//!  | hash(12) | 4     |                                      +---+---+---+---+---+---+---+---+
21//!  +----------+-------+                                      ^ start
22//!
23//!  get(key)   hash(key) gives candidate positions. Those whose key slot holds translate(key)
24//!             are returned, so a slot and fingerprint shared with another key costs no read.
25//!  prune(5)   for each position in start..5: table.remove(hash(key slot), position). The five
26//!             front slots are released, and exactly the keys at 5 and up remain.
27//! ```
28
29use crate::translator::Translator;
30use commonware_runtime::{
31    Metrics,
32    telemetry::metrics::{Counter, Gauge, MetricsExt as _},
33};
34use commonware_utils::{Widen, handles::Table};
35use core::cmp::Ordering;
36use std::collections::VecDeque;
37
38/// Positions of retained keys by translated key.
39pub struct Index<T: Translator> {
40    translator: T,
41    /// Every indexed position, under the hash of its translated key.
42    table: Table<u64>,
43    /// The translated key at each position from `start` through the last indexed position.
44    /// Positions that were never indexed hold a copy of some key that is never resolved.
45    keys: VecDeque<T::Key>,
46    /// Positions below this were never indexed or have been retired.
47    start: u64,
48
49    items: Gauge,
50    pruned: Counter,
51}
52
53impl<T: Translator> Index<T> {
54    /// Create an empty index for positions at or above `start`, with room for `capacity`
55    /// positions before the table or the key ring grows.
56    pub fn new(ctx: impl Metrics, translator: T, start: u64, capacity: usize) -> Self {
57        Self {
58            translator,
59            table: Table::with_capacity(capacity),
60            keys: VecDeque::with_capacity(capacity),
61            start,
62            items: ctx.gauge("items", "Number of positions in the index"),
63            pruned: ctx.counter("pruned", "Number of positions pruned from the index"),
64        }
65    }
66
67    /// Return the offset of `position` from `start`, which it must not precede.
68    fn offset(start: u64, position: u64) -> usize {
69        usize::try_from(position - start).expect("retained positions must fit in usize")
70    }
71
72    /// Return the translated key at `position`, which must be indexed.
73    fn key_at(keys: &VecDeque<T::Key>, start: u64, position: u64) -> T::Key {
74        keys[Self::offset(start, position)]
75    }
76
77    /// Index `key` at `position`, which must be at or above the start and not indexed already.
78    #[inline]
79    pub fn insert(&mut self, key: &[u8], position: u64) {
80        assert!(
81            position >= self.start,
82            "position {position} must be at or above the index start {}",
83            self.start
84        );
85        let translated = self.translator.transform(key);
86
87        // Record the key in the ring. Positions usually arrive in order, but a batch may index
88        // them in any order, so extend the ring through this position when it is further ahead,
89        // filling the slots skipped on the way with this key.
90        let offset = Self::offset(self.start, position);
91        match offset.cmp(&self.keys.len()) {
92            Ordering::Equal => self.keys.push_back(translated),
93            Ordering::Less => self.keys[offset] = translated,
94            Ordering::Greater => self.keys.resize(offset + 1, translated),
95        }
96
97        // Index the position under the key's hash. The table stores no keys, so only when it
98        // grows does it resolve each stored position back to its key through the ring to rehash.
99        self.table
100            .insert(self.translator.hash_one(translated), position, |position| {
101                self.translator
102                    .hash_one(Self::key_at(&self.keys, self.start, position))
103            });
104        self.items.inc();
105    }
106
107    /// Return the indexed positions whose translated key is that of `key`, in no particular
108    /// order.
109    #[inline]
110    pub fn get(&self, key: &[u8]) -> impl Iterator<Item = u64> + '_ {
111        let translated = self.translator.transform(key);
112        self.table
113            .get(self.translator.hash_one(translated))
114            .filter(move |&position| Self::key_at(&self.keys, self.start, position) == translated)
115    }
116
117    /// Retire every indexed position below `start`.
118    pub fn prune(&mut self, start: u64) {
119        if start <= self.start {
120            return;
121        }
122
123        // Remove each retired position under the key its slot holds. A slot that was never
124        // indexed holds a key whose entries all carry other positions, so it removes nothing.
125        let retired = Self::offset(self.start, start).min(self.keys.len());
126        let mut removed = 0u64;
127        for (offset, key) in self.keys.drain(..retired).enumerate() {
128            let hash = self.translator.hash_one(key);
129            if self.table.remove(hash, self.start + Widen::widen(offset)) {
130                removed += 1;
131            }
132        }
133        self.start = start;
134        self.items.dec_by(removed as i64);
135        self.pruned.inc_by(removed);
136    }
137
138    /// Return the number of indexed positions.
139    pub const fn len(&self) -> usize {
140        self.table.len()
141    }
142
143    /// Return whether no position is indexed.
144    pub const fn is_empty(&self) -> bool {
145        self.table.is_empty()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::translator::{EightCap, TwoCap};
153    use commonware_macros::test_traced;
154    use commonware_runtime::{Runner as _, deterministic};
155    use commonware_utils::GOLDEN_RATIO;
156    use core::hash::BuildHasher as _;
157
158    fn positions<T: Translator>(index: &Index<T>, key: &[u8]) -> Vec<u64> {
159        let mut positions: Vec<u64> = index.get(key).collect();
160        positions.sort_unstable();
161        positions
162    }
163
164    #[test_traced]
165    fn test_get_confirms_translated_key() {
166        deterministic::Runner::default().start(|context| async move {
167            let mut index = Index::new(context, TwoCap, 0, 0);
168            // Two keys collide under TwoCap and the third does not.
169            index.insert(b"aa1", 1);
170            index.insert(b"aa2", 3);
171            index.insert(b"bb1", 4);
172
173            assert_eq!(positions(&index, b"aa1"), [1, 3]);
174            assert_eq!(positions(&index, b"aa9"), [1, 3]);
175            assert_eq!(positions(&index, b"bb1"), [4]);
176            assert!(positions(&index, b"cc1").is_empty());
177            assert_eq!(index.len(), 3);
178        });
179    }
180
181    #[test_traced]
182    fn test_get_rejects_candidates_sharing_slot_and_fingerprint() {
183        deterministic::Runner::default().start(|context| async move {
184            // GOLDEN_RATIO's inverse mod 2^64. Adding INVERSE << 16 to a key adds 1 << 16 to its
185            // hash, so the keys share a home slot (while the table has at most 2^16 slots) and a
186            // fingerprint, and the table yields each as a candidate for the other.
187            const INVERSE: u64 = 0xf1de_83e1_9937_733d;
188            assert_eq!(GOLDEN_RATIO.wrapping_mul(INVERSE), 1);
189            let k1 = 0x1234_5678_9abc_def0u64;
190            let k2 = k1.wrapping_add(INVERSE << 16);
191
192            let mut index = Index::new(context, EightCap, 0, 0);
193            let (h1, h2) = (index.translator.hash_one(k1), index.translator.hash_one(k2));
194            assert_eq!(h2, h1.wrapping_add(1 << 16));
195            assert_eq!(h1 >> 56, h2 >> 56);
196
197            index.insert(&k1.to_be_bytes(), 1);
198            index.insert(&k2.to_be_bytes(), 3);
199            assert_eq!(positions(&index, &k1.to_be_bytes()), [1]);
200            assert_eq!(positions(&index, &k2.to_be_bytes()), [3]);
201        });
202    }
203
204    #[test_traced]
205    fn test_prune_retires_exactly_the_pruned_positions() {
206        deterministic::Runner::default().start(|context| async move {
207            let mut index = Index::new(context, EightCap, 0, 0);
208            // Keys at positions 1, 3, 4, 9 and 20 interleave with unindexed positions.
209            for (i, position) in [1, 3, 4, 9, 20].into_iter().enumerate() {
210                index.insert(&[i as u8; 8], position);
211            }
212            assert_eq!(index.len(), 5);
213            assert_eq!(index.keys.len(), 21);
214
215            // A start between positions retires only those below it and releases their slots.
216            index.prune(2);
217            assert_eq!(index.len(), 4);
218            assert_eq!(index.keys.len(), 19);
219            assert!(positions(&index, &[0u8; 8]).is_empty());
220            assert_eq!(positions(&index, &[1u8; 8]), [3]);
221            assert_eq!(index.pruned.get(), 1);
222
223            // A start below the retired start does nothing.
224            index.prune(1);
225            assert_eq!(index.len(), 4);
226
227            // A start at an indexed position keeps it.
228            index.prune(9);
229            assert_eq!(index.len(), 2);
230            assert_eq!(positions(&index, &[3u8; 8]), [9]);
231            assert_eq!(positions(&index, &[4u8; 8]), [20]);
232
233            // A start past every position empties the index, and later positions index normally.
234            index.prune(100);
235            assert!(index.is_empty());
236            assert!(index.keys.is_empty());
237            assert_eq!(index.pruned.get(), 5);
238            index.insert(&[9u8; 8], 101);
239            assert_eq!(positions(&index, &[9u8; 8]), [101]);
240            assert_eq!(index.keys.len(), 2);
241        });
242    }
243
244    #[test_traced]
245    fn test_start_above_zero() {
246        deterministic::Runner::default().start(|context| async move {
247            let start = 40_967;
248            let mut index = Index::new(context, EightCap, start, 0);
249            index.insert(b"first___", start);
250            index.insert(b"second__", start + 5_000);
251            assert_eq!(positions(&index, b"first___"), [start]);
252
253            index.prune(start + 1);
254            assert!(positions(&index, b"first___").is_empty());
255            assert_eq!(positions(&index, b"second__"), [start + 5_000]);
256            assert_eq!(index.len(), 1);
257        });
258    }
259
260    #[test_traced]
261    fn test_growth_rehashes_through_the_keys() {
262        deterministic::Runner::default().start(|context| async move {
263            // Enough keys to grow the table several times, four per translated key under TwoCap.
264            let mut index = Index::new(context, TwoCap, 0, 0);
265            let key = |i: u32| {
266                let [hi, lo] = ((i % 5_000) as u16).to_be_bytes();
267                [hi, lo, (i / 5_000) as u8]
268            };
269            for i in 0..20_000u32 {
270                index.insert(&key(i), 2 * i as u64 + 1);
271            }
272            assert_eq!(index.len(), 20_000);
273            for i in (0..20_000u32).step_by(997) {
274                assert!(positions(&index, &key(i)).contains(&(2 * i as u64 + 1)));
275            }
276
277            // Retire the first half exactly.
278            index.prune(20_001);
279            assert_eq!(index.len(), 10_000);
280            assert!(!positions(&index, &key(0)).contains(&1));
281            assert!(positions(&index, &key(10_000)).contains(&20_001));
282            assert!(positions(&index, &key(19_999)).contains(&39_999));
283            assert_eq!(index.pruned.get(), 10_000);
284        });
285    }
286}