rainier_orm/sharding.rs
1//! Sharding **policy** on top of the routing primitives in [`crate::shard`].
2//!
3//! [`crate::shard`] gives you the mechanism — a [`ShardedExecutor`] that routes
4//! each operation to a physical database via a [`ShardLocator`]. This module
5//! adds the *policy* an application needs to actually run a fleet:
6//!
7//! * [`IdAllocator`] — mint shard-encoded, time-ordered `u64` ids (the id
8//! carries its shard in the high bits, so any id self-routes).
9//! * [`ShardRegistry`] / [`ShardRecord`] — the global `shards` table: every
10//! physical shard's name, capacity, and lifecycle [`ShardStatus`].
11//! * [`Placer`] / [`Placement`] — capacity-aware placement: admit new rows to
12//! the active shard until it crosses its admit threshold, then seal it and
13//! promote a standby.
14//! * [`Directory`] / [`DirectoryEntry`] — a global `key → id` map for resolving
15//! a natural key (e.g. a login email) to its shard-encoded id with no fan-out.
16//! * [`refresh_shard_sizes`] — measure each shard's on-disk size (SQLite/D1) and
17//! record it so [`Placer`] can seal a full shard.
18//!
19//! It is all backend-agnostic and wasm-safe — generic over [`Executor`], no
20//! `tokio`/`sqlx`. The control tables ([`ShardRecord`], [`DirectoryEntry`]) are
21//! ordinary [`Entity`]s, so their schema is derived; [`control_tables_ddl`]
22//! renders it for a dialect (the sharded surface applies it to its global DB).
23//!
24//! On a single, unsharded database the routing is a no-op (the default
25//! [`Executor`] methods ignore the route) and the registry/placement simply go
26//! unused — so the *same* application code runs sharded or not.
27//!
28//! [`ShardedExecutor`]: crate::ShardedExecutor
29//! [`ShardLocator`]: crate::ShardLocator
30
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::Arc;
33
34use anyhow::{bail, Result};
35use chrono::{DateTime, Utc};
36
37use crate::{
38 repo, Dialect, Entity, Executor, ShardCatalog, ShardCodec, ShardId, ShardLocator, ShardRoute,
39 ShardedExecutor, StringColumn,
40};
41
42// ---------------------------------------------------------------------------
43// Id allocation
44// ---------------------------------------------------------------------------
45
46/// Custom epoch (2024-01-01T00:00:00Z) so the 42-bit ms timestamp lasts ~139 y.
47const EPOCH_MS: i64 = 1_704_067_200_000;
48/// Counter bits within a millisecond (≤1024 ids/ms before borrowing ahead).
49const COUNTER_BITS: u32 = 10;
50
51/// Mints monotonically increasing, collision-free sequences, and (with a
52/// [`ShardCodec`]) shard-encoded ids.
53///
54/// Snowflake-style: a 42-bit millisecond timestamp (since 2024) plus a counter
55/// form a monotonic [`next_seq`](Self::next_seq); [`next`](Self::next) packs
56/// that into a codec's sequence bits with the shard in the top bits. A single
57/// counter backs every shard — composing with the shard number keeps cross-shard
58/// ids distinct, and the time seed keeps them sortable and restart-safe.
59/// `Clone` shares the counter (it's an `Arc<AtomicU64>`), so cloning into app
60/// state — or into each shard connector — keeps one global monotonic source.
61#[derive(Clone)]
62pub struct IdAllocator {
63 seq: Arc<AtomicU64>,
64}
65
66impl IdAllocator {
67 pub fn new() -> Self {
68 Self { seq: Arc::new(AtomicU64::new(0)) }
69 }
70
71 /// A fresh monotonic, time-seeded sequence value — no shard bits. Compose it
72 /// with a [`ShardCodec`] (see [`next`](Self::next)) to get a shard-encoded id,
73 /// or use it directly as an unsharded id.
74 pub fn next_seq(&self) -> u64 {
75 let now = ((Utc::now().timestamp_millis() - EPOCH_MS).max(0) as u64) << COUNTER_BITS;
76 loop {
77 let prev = self.seq.load(Ordering::Acquire);
78 let next = prev.wrapping_add(1).max(now);
79 if self
80 .seq
81 .compare_exchange_weak(prev, next, Ordering::AcqRel, Ordering::Acquire)
82 .is_ok()
83 {
84 break next;
85 }
86 }
87 }
88
89 /// A fresh shard-encoded id for `shard` under `codec`: `codec`'s high bits
90 /// hold the shard, the low bits a monotonic sequence.
91 pub fn next(&self, codec: ShardCodec, shard: ShardId) -> u64 {
92 codec.compose(shard, self.next_seq())
93 }
94
95 /// The process-global allocator — one monotonic counter shared by every
96 /// caller. A clone shares the counter, so ids stay unique across call sites.
97 /// Codec-free: callers apply their own [`ShardCodec`] via [`next`](Self::next),
98 /// so one global counter serves any number of shard families.
99 pub fn global() -> Self {
100 static G: std::sync::OnceLock<IdAllocator> = std::sync::OnceLock::new();
101 G.get_or_init(IdAllocator::new).clone()
102 }
103}
104
105impl Default for IdAllocator {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111// ---------------------------------------------------------------------------
112// Connector configuration
113// ---------------------------------------------------------------------------
114
115/// The sharding configuration a surface hands its connector — everything the ORM
116/// needs to route and mint, in one struct, so a deployment declares its policy
117/// explicitly in one place rather than threading three loose values.
118///
119/// * `family` — the shard **family**: the naming prefix for shard databases
120/// (`{family}_shard_0001`, see [`crate::shard_db_name`]) and the family this
121/// connector routes within ([`Executor::shard_family`]).
122/// * `codec` — the id **layout**: how the shard is packed into a shard-encoded
123/// id. Drives id minting ([`mint`](Self::mint)) and decoding a row's owning
124/// shard from its key ([`shard_of`](Self::shard_of)).
125/// * `locator` — the routing **model** (the "key/hashing"): how a routed key
126/// maps to a physical shard. The default [`ShardCodec`] reads the shard from
127/// the id's high bits (encoded-id, no lookup); swap in
128/// [`HashLocator`](crate::HashLocator) or [`SlotLocator`](crate::SlotLocator)
129/// for a tier whose key isn't shard-encoded (e.g. a hashed directory).
130///
131/// Build the connector's [`ShardedExecutor`] with [`executor`](Self::executor)
132/// and mint ids with [`mint`](Self::mint); the codec and locator stay
133/// consistent because they come from the one settings value.
134#[derive(Clone, Debug)]
135pub struct ShardingSettings<L = ShardCodec> {
136 pub family: String,
137 pub codec: ShardCodec,
138 pub locator: L,
139}
140
141impl ShardingSettings<ShardCodec> {
142 /// Encoded-id routing — the common case: the shard lives in the id's high
143 /// bits, so the [`ShardCodec`] is *both* the id layout and the locator.
144 pub fn encoded(family: impl Into<String>, codec: ShardCodec) -> Self {
145 Self { family: family.into(), codec, locator: codec }
146 }
147}
148
149impl<L: ShardLocator + Clone> ShardingSettings<L> {
150 /// A custom routing model (hash / slot / …): the `codec` still defines the id
151 /// layout for minting, while `locator` decides where each routed key lands.
152 pub fn with_locator(family: impl Into<String>, codec: ShardCodec, locator: L) -> Self {
153 Self { family: family.into(), codec, locator }
154 }
155
156 /// Build the routed [`ShardedExecutor`] over `catalog` with this policy — the
157 /// connector the generic services run on.
158 pub fn executor<C: ShardCatalog>(&self, catalog: C) -> ShardedExecutor<C, L> {
159 ShardedExecutor::new(catalog, self.locator.clone(), self.family.clone())
160 }
161
162 /// Mint a shard-encoded id on `shard`, using `allocator` for the monotonic
163 /// sequence and this settings' codec for the layout.
164 pub fn mint(&self, allocator: &IdAllocator, shard: ShardId) -> u64 {
165 allocator.next(self.codec, shard)
166 }
167
168 /// The shard that owns `key`, decoded from a shard-encoded id's high bits —
169 /// used to mint a child row on its parent's shard. (Routing an arbitrary,
170 /// non-encoded key is the `locator`'s job, applied inside the executor.)
171 pub fn shard_of(&self, key: u64) -> ShardId {
172 self.codec.shard_of(key)
173 }
174}
175
176// ---------------------------------------------------------------------------
177// Shard registry
178// ---------------------------------------------------------------------------
179
180/// Lifecycle of a shard.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum ShardStatus {
183 /// Being created (database provisioned, schema migrating).
184 Provisioning,
185 /// Accepting new rows (until it fills past its admit threshold).
186 Active,
187 /// Empty and ready — promoted to active the moment the active shard seals.
188 Standby,
189 /// No longer admits new rows; existing keys keep growing into it.
190 Sealed,
191 /// Read-only / retired.
192 Disabled,
193}
194
195impl StringColumn for ShardStatus {
196 fn to_column_str(&self) -> String {
197 match self {
198 ShardStatus::Provisioning => "provisioning",
199 ShardStatus::Active => "active",
200 ShardStatus::Standby => "standby",
201 ShardStatus::Sealed => "sealed",
202 ShardStatus::Disabled => "disabled",
203 }
204 .to_string()
205 }
206 fn from_column_str(s: &str) -> Result<Self> {
207 Ok(match s {
208 "active" => ShardStatus::Active,
209 "standby" => ShardStatus::Standby,
210 "sealed" => ShardStatus::Sealed,
211 "disabled" => ShardStatus::Disabled,
212 _ => ShardStatus::Provisioning,
213 })
214 }
215}
216crate::impl_string_column!(ShardStatus);
217
218/// One row of the global `shards` registry. Not itself sharded.
219#[derive(Debug, Clone, Entity)]
220#[orm(table = "shards")]
221pub struct ShardRecord {
222 /// Logical shard number — the high bits of every id placed here. App-assigned
223 /// (it equals the shard a [`ShardCodec`] encodes), so deliberately NOT
224 /// auto-increment.
225 #[orm(pk)]
226 pub id: u32,
227 /// Deterministic physical db / binding name (see [`crate::shard_db_name`]).
228 pub name: String,
229 /// Backend-specific database id / binding (None on a single-DB deployment).
230 pub database_id: Option<String>,
231 pub status: ShardStatus,
232 pub size_bytes: i64,
233 pub max_bytes: i64,
234 /// Seal to new admissions at this fill ratio (0.50 = 50 %).
235 pub admit_threshold: f64,
236 pub created_at: DateTime<Utc>,
237 pub sealed_at: Option<DateTime<Utc>>,
238 pub last_size_check_at: Option<DateTime<Utc>>,
239}
240
241impl ShardRecord {
242 pub fn fill_ratio(&self) -> f64 {
243 if self.max_bytes <= 0 {
244 return 1.0;
245 }
246 self.size_bytes as f64 / self.max_bytes as f64
247 }
248
249 /// True when this shard is active and still under its admit threshold.
250 pub fn accepts_admission(&self) -> bool {
251 self.status == ShardStatus::Active && self.fill_ratio() < self.admit_threshold
252 }
253}
254
255/// Registry operations over the global executor.
256#[derive(Clone)]
257pub struct ShardRegistry<X> {
258 exec: X,
259}
260
261impl<X: Executor> ShardRegistry<X> {
262 pub fn new(exec: X) -> Self {
263 Self { exec }
264 }
265
266 pub async fn list(&self) -> Result<Vec<ShardRecord>> {
267 repo::all(&self.exec).await
268 }
269
270 pub async fn get(&self, id: u32) -> Result<Option<ShardRecord>> {
271 repo::find_by_pk(&self.exec, id).await
272 }
273
274 /// Insert a shard record (id is app-assigned, the logical shard number).
275 pub async fn register(&self, record: &ShardRecord) -> Result<()> {
276 repo::insert(&self.exec, record).await?;
277 Ok(())
278 }
279
280 pub async fn set_status(&self, id: u32, status: ShardStatus) -> Result<()> {
281 repo::query::<ShardRecord>()
282 .where_eq("id", id)
283 .update(&self.exec, vec![("status", status.to_column_str().into())])
284 .await?;
285 Ok(())
286 }
287
288 /// Record a fresh size measurement (from the size-check job).
289 pub async fn set_size(&self, id: u32, size_bytes: i64) -> Result<()> {
290 let now = Utc::now();
291 repo::query::<ShardRecord>()
292 .where_eq("id", id)
293 .update(
294 &self.exec,
295 vec![("size_bytes", size_bytes.into()), ("last_size_check_at", now.into())],
296 )
297 .await?;
298 Ok(())
299 }
300
301 /// Seal a shard to new admissions (existing keys keep growing into it).
302 pub async fn seal(&self, id: u32) -> Result<()> {
303 let now = Utc::now();
304 repo::query::<ShardRecord>()
305 .where_eq("id", id)
306 .update(
307 &self.exec,
308 vec![
309 ("status", ShardStatus::Sealed.to_column_str().into()),
310 ("sealed_at", now.into()),
311 ],
312 )
313 .await?;
314 Ok(())
315 }
316}
317
318/// The shard ids a [`ShardCatalog`] should wire up — every
319/// non-disabled shard. (The catalog opens an executor per id by deterministic
320/// name.)
321pub fn live_shard_ids(records: &[ShardRecord]) -> Vec<u32> {
322 records.iter().filter(|r| r.status != ShardStatus::Disabled).map(|r| r.id).collect()
323}
324
325// ---------------------------------------------------------------------------
326// Placement
327// ---------------------------------------------------------------------------
328
329/// The outcome of placing a new key: its shard and shard-encoded id.
330#[derive(Debug, Clone, Copy)]
331pub struct Placement {
332 pub shard: ShardId,
333 pub id: u64,
334}
335
336/// Capacity-aware placement: where a new key's data goes, and its id.
337///
338/// Admit new keys to the active shard until it crosses its admit threshold
339/// (~50 %, leaving the existing cohort room to grow), then seal it and promote a
340/// pre-provisioned standby. On a single-database deployment there is one active
341/// shard (id 0) that never fills, so every key lands there.
342#[derive(Clone)]
343pub struct Placer<X> {
344 registry: ShardRegistry<X>,
345 allocator: IdAllocator,
346 codec: ShardCodec,
347}
348
349impl<X: Executor> Placer<X> {
350 pub fn new(registry: ShardRegistry<X>, allocator: IdAllocator, codec: ShardCodec) -> Self {
351 Self { registry, allocator, codec }
352 }
353
354 fn compose(&self, shard: ShardId) -> Placement {
355 Placement { shard, id: self.allocator.next(self.codec, shard) }
356 }
357
358 /// Choose a shard for a new key and mint its shard-encoded id.
359 pub async fn place_new(&self) -> Result<Placement> {
360 let shards = self.registry.list().await?;
361
362 // Fast path: an active shard still under its admit threshold.
363 if let Some(s) = shards.iter().find(|r| r.accepts_admission()) {
364 return Ok(self.compose(s.id));
365 }
366
367 // The admitting shard(s) are full — seal them.
368 for s in shards
369 .iter()
370 .filter(|r| r.status == ShardStatus::Active && r.fill_ratio() >= r.admit_threshold)
371 {
372 self.registry.seal(s.id).await?;
373 }
374
375 // Promote a pre-provisioned standby to active and place there.
376 if let Some(sb) = shards.iter().find(|r| r.status == ShardStatus::Standby) {
377 self.registry.set_status(sb.id, ShardStatus::Active).await?;
378 return Ok(self.compose(sb.id));
379 }
380
381 bail!(
382 "no shard available to admit a new key — provision a standby \
383 (the provisioning controller keeps one ready)"
384 )
385 }
386}
387
388// ---------------------------------------------------------------------------
389// Directory
390// ---------------------------------------------------------------------------
391
392/// Normalise a natural key for the directory (trim + lowercase) — suitable for
393/// case-insensitive keys like email addresses.
394pub fn normalize_key(key: &str) -> String {
395 key.trim().to_lowercase()
396}
397
398/// One directory row: a natural `key` (e.g. a normalized email) → a
399/// shard-encoded `id`. The shard lives *inside* the id, so no `shard` column is
400/// needed and resolving a key is a single global lookup.
401#[derive(Debug, Clone, PartialEq, Eq, Entity)]
402#[orm(table = "shard_directory")]
403pub struct DirectoryEntry {
404 #[orm(pk)]
405 pub key: String,
406 /// Shard-encoded — resolving this is the only lookup a router needs.
407 pub id: u64,
408}
409
410/// Port over the directory so resolution can be mocked / swapped.
411#[allow(async_fn_in_trait)]
412pub trait DirectoryRepository {
413 async fn resolve(&self, key: &str) -> Result<Option<u64>>;
414 async fn put(&self, key: &str, id: u64) -> Result<()>;
415}
416
417#[derive(Clone)]
418pub struct Directory<X> {
419 exec: X,
420}
421
422impl<X: Executor> Directory<X> {
423 pub fn new(exec: X) -> Self {
424 Self { exec }
425 }
426}
427
428impl<X: Executor> DirectoryRepository for Directory<X> {
429 /// Resolve a natural key to its shard-encoded id.
430 async fn resolve(&self, key: &str) -> Result<Option<u64>> {
431 let row: Option<DirectoryEntry> = repo::find_by_pk(&self.exec, normalize_key(key)).await?;
432 Ok(row.map(|r| r.id))
433 }
434
435 /// Record a new `key → id` mapping. Idempotent on the key.
436 async fn put(&self, key: &str, id: u64) -> Result<()> {
437 let row = DirectoryEntry { key: normalize_key(key), id };
438 repo::upsert(&self.exec, &row, &["key"], &["id"]).await?;
439 Ok(())
440 }
441}
442
443// ---------------------------------------------------------------------------
444// Size check
445// ---------------------------------------------------------------------------
446
447/// SQLite/D1: bytes = page_count × page_size.
448const SIZE_SQL: &str = "SELECT (SELECT page_count FROM pragma_page_count()) * \
449 (SELECT page_size FROM pragma_page_size()) AS bytes";
450
451/// Measure every registered shard that has its own database and update its
452/// `size_bytes`, so [`Placer`] can seal a shard once it crosses its admit
453/// threshold. `exec` is the sharded fleet executor; `registry` reads/writes the
454/// global `shards` table; `codec` composes a probe id that routes to each shard.
455///
456/// SQLite/D1-only: the size query uses SQLite pragmas. A single MySQL has no cap
457/// and no size-check; its lone shard never seals.
458pub async fn refresh_shard_sizes<X: Executor>(
459 exec: &X,
460 registry: &ShardRegistry<X>,
461 codec: ShardCodec,
462) -> Result<()> {
463 for s in registry.list().await? {
464 if s.database_id.is_none() {
465 continue; // single-DB / not a real fleet shard
466 }
467 // Route the probe to this shard (any id in the shard targets it).
468 let probe = codec.compose(s.id, 0);
469 // Scope the fetched rows so the (possibly `!Send`) `Box<dyn Row>` handles
470 // drop before the next await — otherwise they'd be held across
471 // `set_size`, making this future `!Send` on a multi-threaded runtime.
472 let bytes = {
473 let rows = exec.fetch_all_routed(ShardRoute::Key(probe), SIZE_SQL, vec![]).await?;
474 rows.first().and_then(|r| r.get_i64("bytes").ok().flatten()).unwrap_or(0)
475 };
476 registry.set_size(s.id, bytes).await?;
477 }
478 Ok(())
479}
480
481// ---------------------------------------------------------------------------
482// Control-table DDL
483// ---------------------------------------------------------------------------
484
485/// The DDL for the sharding **control tables** — the [`ShardRecord`] registry and
486/// the [`DirectoryEntry`] directory — rendered for `dialect`, in execution order.
487///
488/// The control tables live on the sharded surface's *global* database, so that
489/// surface (e.g. a Cloudflare D1 deployment) applies these as its sharding
490/// migration; an unsharded surface never creates them. The schema is derived
491/// from the entities themselves, so it can't drift from what the services query.
492pub fn control_tables_ddl(dialect: Dialect) -> Vec<String> {
493 let mut out = crate::schema::schema_ddl::<ShardRecord>(dialect);
494 out.extend(crate::schema::schema_ddl::<DirectoryEntry>(dialect));
495 out
496}
497
498/// Ensure the sharding control tables exist on `global`, idempotently
499/// (`CREATE TABLE IF NOT EXISTS`).
500///
501/// This is the **ORM's** responsibility, not the application's: a sharded
502/// deployment needs the `shards` registry and the `shard_directory` to function,
503/// but those tables are an implementation detail of sharding itself — so the ORM
504/// creates them, and no flavor (Cloudflare or otherwise) declares them in its
505/// own migrations. [`Migrator::run`](crate::migrate::Migrator::run) calls this
506/// automatically whenever the executor reports it is sharded
507/// ([`Executor::is_sharded`]); call it directly if you migrate without a
508/// `Migrator`. `global` is the sharded connector (its unrouted writes land on
509/// the global/directory database) or that database's own executor.
510pub async fn ensure_control_tables<X: Executor>(global: &X) -> Result<()> {
511 for ddl in control_tables_ddl(global.dialect()) {
512 global.execute(&ddl, vec![]).await?;
513 }
514 Ok(())
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn ids_are_shard_encoded_and_monotonic() {
523 let codec = ShardCodec::DEFAULT;
524 let alloc = IdAllocator::new();
525 let a = alloc.next(codec, 7);
526 let b = alloc.next(codec, 7);
527 assert_eq!(codec.shard_of(a), 7);
528 assert_eq!(codec.shard_of(b), 7);
529 assert!(b > a, "ids increase");
530 // Different shard → different high bits, distinct id space.
531 let c = alloc.next(codec, 9);
532 assert_eq!(codec.shard_of(c), 9);
533 assert_ne!(codec.shard_of(a), codec.shard_of(c));
534 }
535
536 #[test]
537 fn next_seq_is_monotonic() {
538 let alloc = IdAllocator::new();
539 let mut last = 0;
540 for _ in 0..1000 {
541 let n = alloc.next_seq();
542 assert!(n > last, "sequence strictly increases");
543 last = n;
544 }
545 }
546
547 #[test]
548 fn normalize_key_trims_and_lowercases() {
549 assert_eq!(normalize_key(" Foo@Bar.COM "), "foo@bar.com");
550 }
551
552 #[test]
553 fn admission_respects_threshold_and_status() {
554 let mut r = ShardRecord {
555 id: 0,
556 name: "x_shard_0000".into(),
557 database_id: None,
558 status: ShardStatus::Active,
559 size_bytes: 4,
560 max_bytes: 10,
561 admit_threshold: 0.5,
562 created_at: Utc::now(),
563 sealed_at: None,
564 last_size_check_at: None,
565 };
566 assert!(r.accepts_admission(), "active + under threshold admits");
567 r.size_bytes = 6; // 0.6 ≥ 0.5
568 assert!(!r.accepts_admission(), "over threshold seals out");
569 r.size_bytes = 1;
570 r.status = ShardStatus::Sealed;
571 assert!(!r.accepts_admission(), "sealed never admits");
572 }
573
574 #[test]
575 fn settings_encoded_uses_codec_as_locator() {
576 let s = ShardingSettings::encoded("users", ShardCodec::new(8));
577 assert_eq!(s.family, "users");
578 // The locator (the codec) routes an encoded id back to its shard.
579 let id = s.codec.compose(5, 1);
580 assert_eq!(s.locator.locate(id), 5);
581 // mint composes on the requested shard; shard_of decodes it.
582 let alloc = IdAllocator::new();
583 assert_eq!(s.shard_of(s.mint(&alloc, 3)), 3);
584 }
585
586 #[test]
587 fn settings_with_hash_locator_routes_by_hash() {
588 use crate::HashLocator;
589 let s = ShardingSettings::with_locator("dir", ShardCodec::DEFAULT, HashLocator::new(4));
590 // The hash model maps every key into [0, 4) regardless of id layout…
591 for k in 0..200u64 {
592 assert!(s.locator.locate(k) < 4);
593 }
594 // …while the codec still mints under the default layout.
595 let alloc = IdAllocator::new();
596 assert_eq!(s.shard_of(s.mint(&alloc, 2)), 2);
597 }
598
599 #[test]
600 fn control_tables_ddl_renders_both_tables() {
601 let sql = control_tables_ddl(Dialect::Sqlite).join("\n");
602 assert!(sql.contains("shards"), "registry table present");
603 assert!(sql.contains("shard_directory"), "directory table present");
604 }
605}