Skip to main content

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}