Skip to main content

faro_sharding/
lib.rs

1#![deny(missing_docs)]
2
3//! Faro Sharding is a technique for sharding keys such that adding new destinations does not move data between existing destinations. Only between existing and the new destination.
4//!
5//! For example, if we have data sharded among 10 servers, when we add an 11th, we don't want to move data from 3 to 5. Faro Sharding guarantees that won't happen.
6//!
7//! # Example
8//! ```
9//! use faro_sharding::shard_for;
10//! for locations in 4..50 {
11//!   assert_eq!(shard_for("foo", locations), 2);
12//! }
13//! assert_eq!(shard_for("foo", 50), 49);
14//! ```
15//!
16//! # Distribution
17//!
18//! Faro Sharding shows high quality even distribution among locations. With 1,000,000 keys and 100 locations, the largest has 10229 keys (1.02%) and the smallest has 9761 keys (0.98%).
19//!
20//! If you want to evaluate the distribution with your own hashing function, you can modify `examples/distribution.rs` to use your hasher.
21//!
22//! # Algorithm
23//!
24//! Inspired by [JumpHash](https://arxiv.org/abs/1406.2294), Faro Sharding sequentially hashes the initial key and then the results of those hashes. Different from JumpHash, Faro Sharding only changes the shard for a key when `hash % i == 0`.
25//!
26//! Faro Sharding can only move a key to a shard on the step corresponding to that shard number. That means that the only movement happens to shard N on step N. Additionally, approximately 1/N of keys are moved on step N, resulting in approximately even distribution.
27
28use std::hash::*;
29
30#[cfg(feature = "seahash")]
31mod with_seahash {
32    use crate::shard_with_hasher;
33    use seahash::*;
34    use std::hash::*;
35
36    /// [shard_with_hasher] using [SeaHasher].
37    pub fn shard_for(key: impl Hash, total_destinations: u64) -> u64 {
38        shard_with_hasher(key, total_destinations, &BuildSeaHasher)
39    }
40
41    struct BuildSeaHasher;
42
43    impl BuildHasher for BuildSeaHasher {
44        type Hasher = SeaHasher;
45
46        fn build_hasher(&self) -> Self::Hasher {
47            SeaHasher::new()
48        }
49    }
50}
51#[cfg(feature = "seahash")]
52pub use with_seahash::shard_for;
53
54/// Returns the index of the shard for the provided key using the hasher provided.
55///
56/// If you want a stable output, your hasher must return the exact same hash given the same input. Do not use a hasher with random state.
57///
58/// # Panics
59///
60/// If total_destinations == 0.
61pub fn shard_with_hasher(
62    key: impl Hash,
63    total_destinations: u64,
64    hasher: &impl BuildHasher,
65) -> u64 {
66    let mut final_shard = 0;
67    let mut last_hash = hasher.hash_one(key);
68
69    assert_ne!(total_destinations, 0, "total_destinations must be > 0");
70
71    for n in 1..total_destinations {
72        let hash = hasher.hash_one(last_hash);
73        if hash % (n + 1) == 0 {
74            final_shard = n;
75        }
76        last_hash = hash;
77    }
78
79    debug_assert!(final_shard < total_destinations);
80    final_shard
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    use proptest::*;
88
89    proptest! {
90        #[test]
91        fn does_not_move_between_existing_destinations(key: String) {
92            let mut last_shard = shard_for(&key, 1);
93            for n in 2..=1024 {
94                let next_shard = shard_for(&key, n);
95                prop_assert!(next_shard == last_shard || next_shard == n - 1);
96                last_shard = next_shard;
97            }
98        }
99
100        #[test]
101        fn assigns_to_different_shards(base: String, locations in 16u64..=512u64) {
102            let base_shard = shard_for(&base, locations);
103
104            for n in 0..1024 {
105                let shard = shard_for(&format!("{base}-{n}"), locations);
106                if shard != base_shard {
107                    return Ok(());
108                }
109            }
110            prop_assert!(false);
111        }
112    }
113
114    #[test]
115    fn pinning_default_shard() {
116        // It is critical that the default `shard_for` implementation always returns the same shards for correctness of our user's systems. Do not remove or change the values here. Adding new entries is ok.
117        let shards = maplit::btreemap! {
118            "foo" => 49,
119            "bar" => 14,
120            "baz" => 9,
121            "qux" => 60,
122            "quux" => 69,
123        };
124
125        for (key, expected_shard) in shards {
126            assert_eq!(
127                shard_for(&key, 73),
128                expected_shard,
129                "Incorrect shard for {key}"
130            );
131        }
132    }
133
134    #[test]
135    fn distributes_to_every_shard() {
136        let locations = 16;
137
138        let mut shard_counts = std::collections::HashMap::<_, u64>::new();
139        for i in 0..1000 {
140            *shard_counts.entry(shard_for(i, locations)).or_default() += 1;
141        }
142
143        for l in 0..locations {
144            assert!(shard_counts.contains_key(&l), "No entries for shard {l}");
145        }
146    }
147}