Skip to main content

rainier_orm/
shard.rs

1//! The sharding executor — turns N physical databases into one logical
2//! [`Executor`] that routes each operation to the right shard.
3//!
4//! This is the *only* sharding-aware executor; it's a drop-in `Executor`, so
5//! the `repo`/`query` layer and the services above are byte-identical whether
6//! they run against a single database or a [`ShardedExecutor`]. The repo layer
7//! decodes a shard-encoded id into a [`ShardRoute`]; the
8//! executor decodes the shard number from it ([`ShardCodec`]) and dispatches.
9//! Swap a `ShardedExecutor<D1…>` for a `SeaOrmExecutor` (MySQL) and the rest of
10//! the program does not change.
11
12use crate::{Dialect, Error, ExecOutcome, Executor, Result, Row, ShardCodec, ShardRoute};
13use sea_query::Value;
14
15/// A physical shard number (the high bits of a shard-encoded id).
16pub type ShardId = u32;
17
18/// Zero-padding width for shard numbers in [`shard_db_name`] (4 → `0001`).
19pub const SHARD_NAME_WIDTH: usize = 4;
20
21/// The deterministic physical database / binding name for a shard:
22/// `"{family}_shard_{n:04}"`.
23///
24/// The shard **family** is a naming prefix for a series of shards, so a logical
25/// shard number maps to a concrete D1 database name with no registry lookup —
26/// e.g. `shard_db_name("users", 1)` → `"users_shard_0001"`. A catalog
27/// (or the provisioner) uses this to find every shard's connection from the
28/// family + number alone.
29pub fn shard_db_name(family: &str, shard: ShardId) -> String {
30    format!("{family}_shard_{shard:0w$}", w = SHARD_NAME_WIDTH)
31}
32
33/// The directory / global database name for a family: `"{family}_directory"`
34/// (the non-sharded tier — routing entries, reference data).
35pub fn directory_db_name(family: &str) -> String {
36    format!("{family}_directory")
37}
38
39/// Maps a routed shard-key value to a physical shard — the routing **policy**,
40/// kept pluggable so the same machinery serves different tiers:
41///
42/// - [`ShardCodec`] (encoded-id): the shard is the high bits of the id. No
43///   lookup, but a fixed id layout — best for the *data* tier (a user's rows).
44/// - [`HashLocator`]: `hash(key) % n`. Even spread without an id layout — useful
45///   when a *directory* table itself outgrows one database and must partition
46///   (the directory caps at tens of millions of rows per database).
47/// - a consumer impl can do anything (e.g. consult a capacity-aware registry).
48///
49/// `locate` is synchronous: in this design the directory→id resolution happens
50/// as an ordinary global query in the service, and the id it returns is already
51/// shard-encoded — so routing never needs an async lookup.
52pub trait ShardLocator {
53    fn locate(&self, key: u64) -> ShardId;
54}
55
56impl ShardLocator for ShardCodec {
57    fn locate(&self, key: u64) -> ShardId {
58        self.shard_of(key)
59    }
60}
61
62/// Routes `hash(key) % shards` — an even spread over a fixed shard count, for
63/// when there's no shard-encoded id to decode (e.g. partitioning a directory by
64/// a hashed key). Resharding still needs a rebalance plan; this is not a
65/// consistent hash.
66#[derive(Clone, Copy, Debug)]
67pub struct HashLocator {
68    pub shards: u32,
69}
70
71impl HashLocator {
72    pub const fn new(shards: u32) -> Self {
73        Self { shards }
74    }
75}
76
77impl ShardLocator for HashLocator {
78    fn locate(&self, key: u64) -> ShardId {
79        (mix64(key) % self.shards.max(1) as u64) as u32
80    }
81}
82
83/// splitmix64 finalizer — scrambles sequential keys before a modulo.
84fn mix64(key: u64) -> u64 {
85    let mut x = key;
86    x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
87    x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
88    x ^ (x >> 31)
89}
90
91/// Fixed hash **slots** with a slot → shard map — the rebalanceable directory
92/// strategy (Redis-Cluster style). A key's slot (`mix(key) % slots`) is
93/// permanent; moving load is a matter of editing the small `map` (which slot
94/// goes to which physical shard), so no key is ever rehashed. The map is
95/// `slots` entries long; several slots typically point at the same shard.
96#[derive(Clone, Debug)]
97pub struct SlotLocator {
98    slots: u32,
99    map: Vec<ShardId>,
100}
101
102impl SlotLocator {
103    /// `map[i]` is the shard for slot `i`; its length is the slot count.
104    pub fn new(map: Vec<ShardId>) -> Self {
105        Self { slots: map.len().max(1) as u32, map }
106    }
107    /// All slots on one shard to start; split later by editing the map.
108    pub fn single(slots: u32, shard: ShardId) -> Self {
109        Self::new(vec![shard; slots.max(1) as usize])
110    }
111    pub fn slot_of(&self, key: u64) -> u32 {
112        (mix64(key) % self.slots as u64) as u32
113    }
114    pub fn map_mut(&mut self) -> &mut Vec<ShardId> {
115        &mut self.map
116    }
117}
118
119impl ShardLocator for SlotLocator {
120    fn locate(&self, key: u64) -> ShardId {
121        self.map[self.slot_of(key) as usize]
122    }
123}
124
125/// Provides the physical executors a [`ShardedExecutor`] routes among: one
126/// **global** (directory / reference) database plus the shards.
127pub trait ShardCatalog {
128    type Exec: Executor;
129    /// The global database — holds non-sharded entities and the directory.
130    fn global(&self) -> &Self::Exec;
131    /// The executor for shard `id`, if present.
132    fn shard(&self, id: ShardId) -> Option<&Self::Exec>;
133    /// All shard ids (for migrations / fan-out).
134    fn shard_ids(&self) -> Vec<ShardId>;
135}
136
137/// An [`Executor`] that routes by shard. `Global` routes (and the unrouted
138/// methods) go to [`ShardCatalog::global`]; `Key(id)` routes decode the shard
139/// from `id` and go to that shard.
140#[derive(Clone)]
141pub struct ShardedExecutor<C, L = ShardCodec> {
142    catalog: C,
143    locator: L,
144    dialect: Dialect,
145    family: String,
146}
147
148impl<C: ShardCatalog, L: ShardLocator> ShardedExecutor<C, L> {
149    /// Build from a catalog, a routing [`ShardLocator`] (e.g. a [`ShardCodec`]),
150    /// and the **shard family name** this connector serves (e.g. `"users"`).
151    /// The family is reported by [`Executor::shard_family`] so callers can do
152    /// sharded reasoning, and it should match the
153    /// [`shard_columns`](crate::Entity::shard_columns) of the entities
154    /// routed through it. All databases must speak the same dialect (D1/SQLite);
155    /// it's read from the global executor.
156    pub fn new(catalog: C, locator: L, family: impl Into<String>) -> Self {
157        let dialect = catalog.global().dialect();
158        Self { catalog, locator, dialect, family: family.into() }
159    }
160
161    pub fn catalog(&self) -> &C {
162        &self.catalog
163    }
164
165    pub fn locator(&self) -> &L {
166        &self.locator
167    }
168
169    /// This family's deterministic database name for `shard` — e.g. with family
170    /// `"users"`, shard 1 → `"users_shard_0001"`. See [`shard_db_name`].
171    pub fn shard_name(&self, shard: ShardId) -> String {
172        shard_db_name(&self.family, shard)
173    }
174
175    fn pick(&self, route: ShardRoute) -> Result<&C::Exec> {
176        match route {
177            ShardRoute::Global => Ok(self.catalog.global()),
178            ShardRoute::Key(id) => {
179                let s = self.locator.locate(id);
180                self.catalog
181                    .shard(s)
182                    .ok_or_else(|| Error::msg(format!("no executor for shard {s} (id {id})")))
183            }
184        }
185    }
186}
187
188impl<C: ShardCatalog, L: ShardLocator> Executor for ShardedExecutor<C, L> {
189    fn dialect(&self) -> Dialect {
190        self.dialect
191    }
192
193    fn shard_family(&self) -> Option<&str> {
194        Some(&self.family)
195    }
196
197    // Unrouted calls (ad-hoc SQL with no entity context) go to the global DB.
198    async fn fetch_all(&self, sql: &str, params: Vec<Value>) -> Result<Vec<Box<dyn Row>>> {
199        self.catalog.global().fetch_all(sql, params).await
200    }
201    async fn execute(&self, sql: &str, params: Vec<Value>) -> Result<ExecOutcome> {
202        self.catalog.global().execute(sql, params).await
203    }
204
205    // Routed calls dispatch to the shard the repo layer picked.
206    async fn fetch_all_routed(
207        &self,
208        route: ShardRoute,
209        sql: &str,
210        params: Vec<Value>,
211    ) -> Result<Vec<Box<dyn Row>>> {
212        self.pick(route)?.fetch_all(sql, params).await
213    }
214    async fn execute_routed(
215        &self,
216        route: ShardRoute,
217        sql: &str,
218        params: Vec<Value>,
219    ) -> Result<ExecOutcome> {
220        self.pick(route)?.execute(sql, params).await
221    }
222}
223
224/// A simple in-memory [`ShardCatalog`]: a global executor + `(id, executor)`
225/// pairs. Convenient for wiring and tests; a consumer can implement the trait
226/// directly to source executors however it likes (e.g. one per D1 binding).
227#[derive(Clone)]
228pub struct MapCatalog<X> {
229    global: X,
230    shards: Vec<(ShardId, X)>,
231}
232
233impl<X: Executor> MapCatalog<X> {
234    pub fn new(global: X) -> Self {
235        Self { global, shards: Vec::new() }
236    }
237    pub fn with_shard(mut self, id: ShardId, exec: X) -> Self {
238        self.shards.push((id, exec));
239        self
240    }
241}
242
243impl<X: Executor> ShardCatalog for MapCatalog<X> {
244    type Exec = X;
245    fn global(&self) -> &X {
246        &self.global
247    }
248    fn shard(&self, id: ShardId) -> Option<&X> {
249        self.shards.iter().find(|(i, _)| *i == id).map(|(_, e)| e)
250    }
251    fn shard_ids(&self) -> Vec<ShardId> {
252        self.shards.iter().map(|(i, _)| *i).collect()
253    }
254}