Skip to main content

turbo_persistence/
shard.rs

1//! Key hash shards.
2//!
3//! The key space of a family is split into `2^bits` shards by the leading bits of the key hash.
4//! Commits split their SST files at shard boundaries and compaction merges each shard on its own,
5//! so SST files never span a shard boundary. Since shards are hash prefixes, doubling
6//! the shard count splits every shard exactly in half.
7
8use std::ops::RangeInclusive;
9
10use crate::meta_file::StaticSortedFileRange;
11
12/// The number of leading key hash bits that select the shard, i.e. a family has `2^bits` shards.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
14pub struct ShardBits(u8);
15
16impl ShardBits {
17    /// The largest supported value, i.e. 4096 shards.
18    pub const MAX: ShardBits = ShardBits(12);
19
20    /// Panics if `bits` exceeds [`ShardBits::MAX`].
21    pub const fn new(bits: u8) -> Self {
22        assert!(bits <= Self::MAX.0, "too many shard bits");
23        Self(bits)
24    }
25
26    /// Returns `None` if `bits` exceeds [`ShardBits::MAX`].
27    pub fn try_new(bits: u8) -> Option<Self> {
28        (bits <= Self::MAX.0).then_some(Self(bits))
29    }
30
31    /// The number of leading key hash bits.
32    pub fn get(self) -> u8 {
33        self.0
34    }
35
36    /// The number of shards.
37    pub fn count(self) -> u32 {
38        1 << self.0
39    }
40
41    /// Returns the shard that contains `hash`.
42    pub fn shard_of(self, hash: u64) -> u32 {
43        if self.0 == 0 {
44            0
45        } else {
46            (hash >> (u64::BITS - u32::from(self.0))) as u32
47        }
48    }
49
50    /// Returns the range of key hashes in the shard `index`.
51    pub fn range(self, index: u32) -> RangeInclusive<u64> {
52        debug_assert!(index < self.count());
53        if self.0 == 0 {
54            return 0..=u64::MAX;
55        }
56        let shift = u64::BITS - u32::from(self.0);
57        let start = u64::from(index) << shift;
58        let end = start | (u64::MAX >> u32::from(self.0));
59        start..=end
60    }
61
62    /// Computes the shard bits for a family with size `bytes`, targeting a shard size and using
63    /// `self` bits already.
64    ///
65    /// The shard count only doubles once shards hold more than 1.5 times `target_shard_size`, and
66    /// only halves once they hold less than half of it to prevent resharding around a threshold.
67    pub fn maybe_reshard(self, bytes: u64, target_shard_size: u64) -> Self {
68        let target = target_shard_size.max(1);
69        let shard_size = |bits: Self| bytes >> bits.0;
70        let mut bits = self;
71        // Grow while a shard would be over 1.5 * target.
72        while bits < Self::MAX && shard_size(bits) > target.saturating_add(target / 2) {
73            bits.0 += 1;
74        }
75        // Shrink while a shard would be under 0.5 * target.
76        while bits.0 > 0 && shard_size(bits) < target / 2 {
77            bits.0 -= 1;
78        }
79        bits
80    }
81
82    /// Like [`Self::maybe_reshard`], but never shrinks.
83    pub fn maybe_grow(self, bytes: u64, target_shard_size: u64) -> Self {
84        self.maybe_reshard(bytes, target_shard_size).max(self)
85    }
86}
87
88#[derive(PartialEq, Eq, Copy, Clone, Debug)]
89pub(crate) struct MetaEntryIndex {
90    pub meta_index: u32,
91    pub entry_index: u32,
92}
93/// The SST files that can contain the keys of a shard, newest first, which is the order lookups
94/// consult them in. The hash ranges are stored densely so that a lookup scans them sequentially.
95#[derive(Default)]
96pub(crate) struct ShardFiles {
97    pub ranges: Box<[StaticSortedFileRange]>,
98    /// For each range, the index of the meta file and of the entry in the meta file.
99    pub locations: Box<[MetaEntryIndex]>,
100}
101
102/// The SST files of each shard of a family, so lookups only consult the files of the key's shard
103/// instead of every file of the family.
104pub(crate) struct ShardIndex {
105    bits: ShardBits,
106    shards: Box<[ShardFiles]>,
107}
108
109impl Default for ShardIndex {
110    fn default() -> Self {
111        Self {
112            bits: ShardBits::default(),
113            shards: Box::new([ShardFiles::default()]),
114        }
115    }
116}
117
118impl ShardIndex {
119    /// Builds the index of a family, given the hash ranges of the entries of each meta file,
120    /// oldest meta file first. Files that span multiple shards (written with a smaller shard
121    /// count) are listed in every shard they overlap.
122    pub(crate) fn build<'l>(
123        bits: ShardBits,
124        meta_files: impl DoubleEndedIterator<Item = &'l [StaticSortedFileRange]> + ExactSizeIterator,
125    ) -> Self {
126        let mut shards = (0..bits.count())
127            .map(|_| (Vec::new(), Vec::new()))
128            .collect::<Vec<_>>();
129        for (meta_index, ranges) in meta_files.enumerate().rev() {
130            for (entry_index, range) in ranges.iter().enumerate().rev() {
131                let first = bits.shard_of(range.min_hash);
132                let last = bits.shard_of(range.max_hash);
133                for (ranges, locations) in &mut shards[first as usize..=last as usize] {
134                    ranges.push(*range);
135                    locations.push(MetaEntryIndex {
136                        meta_index: meta_index as u32,
137                        entry_index: entry_index as u32,
138                    });
139                }
140            }
141        }
142        Self {
143            bits,
144            shards: shards
145                .into_iter()
146                .map(|(ranges, locations)| ShardFiles {
147                    ranges: ranges.into_boxed_slice(),
148                    locations: locations.into_boxed_slice(),
149                })
150                .collect(),
151        }
152    }
153
154    pub(crate) fn bits(&self) -> ShardBits {
155        self.bits
156    }
157
158    /// The SST files that can contain `hash`.
159    pub(crate) fn candidates(&self, hash: u64) -> &ShardFiles {
160        &self.shards[self.bits.shard_of(hash) as usize]
161    }
162
163    /// The SST files that can contain the keys of `shard`.
164    pub(crate) fn shard(&self, shard: u32) -> &ShardFiles {
165        &self.shards[shard as usize]
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_shard_ranges_cover_the_key_space() {
175        for bits in [0, 1, 2, 3, 10] {
176            let bits = ShardBits::new(bits);
177            let mut next = 0u64;
178            for index in 0..bits.count() {
179                let range = bits.range(index);
180                assert_eq!(*range.start(), next);
181                assert_eq!(bits.shard_of(*range.start()), index);
182                assert_eq!(bits.shard_of(*range.end()), index);
183                next = range.end().wrapping_add(1);
184            }
185            assert_eq!(next, 0, "the last shard ends at u64::MAX");
186        }
187    }
188
189    #[test]
190    fn test_maybe_grow_never_shrinks() {
191        const MB: u64 = 1024 * 1024;
192        let bits = ShardBits::new(3);
193        // 8 shards of 10 MB each would shrink, but don't when only growing.
194        assert!(bits.maybe_reshard(80 * MB, 256 * MB) < bits);
195        assert_eq!(bits.maybe_grow(80 * MB, 256 * MB), bits);
196        // 8 shards of 1 GB each grow either way.
197        assert_eq!(
198            bits.maybe_grow(8 * 1024 * MB, 256 * MB),
199            bits.maybe_reshard(8 * 1024 * MB, 256 * MB)
200        );
201        assert!(bits.maybe_grow(8 * 1024 * MB, 256 * MB) > bits);
202    }
203
204    #[test]
205    fn test_one_more_bit_splits_shards() {
206        let coarse = ShardBits::new(2).range(3);
207        let fine = [ShardBits::new(3).range(6), ShardBits::new(3).range(7)];
208        assert_eq!(coarse.start(), fine[0].start());
209        assert_eq!(fine[0].end() + 1, *fine[1].start());
210        assert_eq!(coarse.end(), fine[1].end());
211    }
212
213    #[test]
214    fn test_shard_index() {
215        let range = |shard, bits| {
216            let r = ShardBits::new(bits).range(shard);
217            StaticSortedFileRange {
218                min_hash: *r.start(),
219                max_hash: *r.end(),
220            }
221        };
222        // An old meta file written with 2 shards, a newer one with 4.
223        let old = [range(0, 1), range(1, 1)];
224        let new = [range(1, 2), range(2, 2), range(3, 2)];
225        let index = ShardIndex::build(ShardBits::new(2), [&old[..], &new[..]].into_iter());
226        let locations = |shard| index.shard(shard).locations.to_vec();
227        assert_eq!(
228            locations(0),
229            vec![MetaEntryIndex {
230                meta_index: 0,
231                entry_index: 0
232            }]
233        );
234        assert_eq!(
235            locations(1),
236            vec![
237                MetaEntryIndex {
238                    meta_index: 1,
239                    entry_index: 0
240                },
241                MetaEntryIndex {
242                    meta_index: 0,
243                    entry_index: 0
244                }
245            ]
246        );
247        assert_eq!(
248            locations(2),
249            vec![
250                MetaEntryIndex {
251                    meta_index: 1,
252                    entry_index: 1
253                },
254                MetaEntryIndex {
255                    meta_index: 0,
256                    entry_index: 1
257                }
258            ]
259        );
260        assert_eq!(
261            locations(3),
262            vec![
263                MetaEntryIndex {
264                    meta_index: 1,
265                    entry_index: 2
266                },
267                MetaEntryIndex {
268                    meta_index: 0,
269                    entry_index: 1
270                }
271            ]
272        );
273
274        assert_eq!(
275            index.candidates(u64::MAX).locations,
276            index.shard(3).locations
277        );
278        for shard in 0..4 {
279            let files = index.shard(shard);
280            assert_eq!(files.ranges.len(), files.locations.len());
281            for (
282                range,
283                &MetaEntryIndex {
284                    meta_index: meta,
285                    entry_index: entry,
286                },
287            ) in files.ranges.iter().zip(&files.locations)
288            {
289                let expected = [&old[..], &new[..]][meta as usize][entry as usize];
290                assert_eq!(range.min_hash, expected.min_hash);
291            }
292        }
293    }
294
295    #[test]
296    fn test_maybe_reshard_has_hysteresis() {
297        const T: u64 = 100;
298        let bits = |b| ShardBits::new(b);
299        // 2 shards of 140 each (1.4x the target) stay 2 shards, 2 of 160 become 4 of 80.
300        assert_eq!(bits(1).maybe_reshard(280, T), bits(1));
301        assert_eq!(bits(1).maybe_reshard(320, T), bits(2));
302        // 4 shards of 60 stay 4 shards, 4 of 40 become 2 of 80.
303        assert_eq!(bits(2).maybe_reshard(240, T), bits(2));
304        assert_eq!(bits(2).maybe_reshard(160, T), bits(1));
305        // Large changes take multiple steps at once.
306        assert_eq!(bits(0).maybe_reshard(1000, T), bits(3));
307        assert_eq!(bits(4).maybe_reshard(10, T), bits(0));
308        assert_eq!(bits(0).maybe_reshard(u64::MAX, 1), ShardBits::MAX);
309        // A change lands inside the band, so adjusting again is stable.
310        for bytes in [0, 50, 149, 151, 499, 1000, 12345] {
311            for start in 0..6 {
312                let once = bits(start).maybe_reshard(bytes, T);
313                assert_eq!(once.maybe_reshard(bytes, T), once, "{bytes} {start}");
314            }
315        }
316    }
317}