Skip to main content

reddb_server/cluster/
slot.rs

1//! Hash-slot routing primitives for cluster shard keys.
2//!
3//! The ownership catalog is still expressed as `collection -> range`: ranges are
4//! the move/fencing unit. In hash mode, those range bounds are over stable hash
5//! slots instead of the raw user shard key. This module is the explicit
6//! `shard_key -> hash -> slot -> range-key` layer shared by routing code.
7
8/// Fixed production hash-slot count, matching the cluster slot-map ADR.
9pub const PRODUCTION_HASH_SLOT_COUNT: u16 = 16_384;
10
11/// One logical hash bucket in the cluster slot map.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct HashSlot(u16);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct HashSlotError {
17    attempted: u16,
18}
19
20impl HashSlotError {
21    pub fn attempted(self) -> u16 {
22        self.attempted
23    }
24}
25
26impl std::fmt::Display for HashSlotError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(
29            f,
30            "hash slot {} is outside the valid 0..{} range",
31            self.attempted, PRODUCTION_HASH_SLOT_COUNT
32        )
33    }
34}
35
36impl std::error::Error for HashSlotError {}
37
38impl HashSlot {
39    /// Construct a slot, rejecting values outside `0..PRODUCTION_HASH_SLOT_COUNT`.
40    pub fn new(value: u16) -> Result<Self, HashSlotError> {
41        if value >= PRODUCTION_HASH_SLOT_COUNT {
42            return Err(HashSlotError { attempted: value });
43        }
44        Ok(Self(value))
45    }
46
47    pub fn value(self) -> u16 {
48        self.0
49    }
50
51    /// The lexicographically-sortable range key used in [`RangeBounds`].
52    ///
53    /// Big-endian encoding preserves numeric slot order under byte comparison,
54    /// so `[slot_a, slot_b)` ranges work with the catalog's existing bounds
55    /// predicate.
56    ///
57    /// [`RangeBounds`]: super::ownership::RangeBounds
58    pub fn range_key(self) -> [u8; 2] {
59        self.0.to_be_bytes()
60    }
61}
62
63/// Hash a logical shard key into the fixed production slot map.
64pub fn hash_shard_key_to_slot(shard_key: &[u8]) -> HashSlot {
65    let digest = blake3::hash(shard_key);
66    let mut prefix = [0u8; 8];
67    prefix.copy_from_slice(&digest.as_bytes()[..8]);
68    let slot = (u64::from_be_bytes(prefix) % u64::from(PRODUCTION_HASH_SLOT_COUNT)) as u16;
69    HashSlot(slot)
70}
71
72/// Hash a logical shard key into the catalog range key used by hash-mode ranges.
73pub fn hash_shard_key_to_range_key(shard_key: &[u8]) -> [u8; 2] {
74    hash_shard_key_to_slot(shard_key).range_key()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn hash_slots_are_bounded_by_the_production_slot_count() {
83        assert_eq!(HashSlot::new(0).unwrap().value(), 0);
84        assert_eq!(
85            HashSlot::new(PRODUCTION_HASH_SLOT_COUNT - 1)
86                .unwrap()
87                .value(),
88            PRODUCTION_HASH_SLOT_COUNT - 1
89        );
90        let err = HashSlot::new(PRODUCTION_HASH_SLOT_COUNT).unwrap_err();
91        assert_eq!(err.attempted(), PRODUCTION_HASH_SLOT_COUNT);
92    }
93
94    #[test]
95    fn range_key_preserves_numeric_slot_order() {
96        let before = HashSlot::new(255).unwrap().range_key();
97        let after = HashSlot::new(256).unwrap().range_key();
98        assert!(before < after);
99    }
100
101    #[test]
102    fn shard_key_hashing_is_stable_and_in_range() {
103        let first = hash_shard_key_to_slot(b"tenant:42");
104        let second = hash_shard_key_to_slot(b"tenant:42");
105        assert_eq!(first, second);
106        assert!(first.value() < PRODUCTION_HASH_SLOT_COUNT);
107    }
108}