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}