use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{bail, Result};
use chrono::{DateTime, Utc};
use crate::{
repo, Dialect, Entity, Executor, ShardCatalog, ShardCodec, ShardId, ShardLocator, ShardRoute,
ShardedExecutor, StringColumn,
};
const EPOCH_MS: i64 = 1_704_067_200_000;
const COUNTER_BITS: u32 = 10;
#[derive(Clone)]
pub struct IdAllocator {
seq: Arc<AtomicU64>,
}
impl IdAllocator {
pub fn new() -> Self {
Self { seq: Arc::new(AtomicU64::new(0)) }
}
pub fn next_seq(&self) -> u64 {
let now = ((Utc::now().timestamp_millis() - EPOCH_MS).max(0) as u64) << COUNTER_BITS;
loop {
let prev = self.seq.load(Ordering::Acquire);
let next = prev.wrapping_add(1).max(now);
if self
.seq
.compare_exchange_weak(prev, next, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
break next;
}
}
}
pub fn next(&self, codec: ShardCodec, shard: ShardId) -> u64 {
codec.compose(shard, self.next_seq())
}
pub fn global() -> Self {
static G: std::sync::OnceLock<IdAllocator> = std::sync::OnceLock::new();
G.get_or_init(IdAllocator::new).clone()
}
}
impl Default for IdAllocator {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct ShardingSettings<L = ShardCodec> {
pub family: String,
pub codec: ShardCodec,
pub locator: L,
}
impl ShardingSettings<ShardCodec> {
pub fn encoded(family: impl Into<String>, codec: ShardCodec) -> Self {
Self { family: family.into(), codec, locator: codec }
}
}
impl<L: ShardLocator + Clone> ShardingSettings<L> {
pub fn with_locator(family: impl Into<String>, codec: ShardCodec, locator: L) -> Self {
Self { family: family.into(), codec, locator }
}
pub fn executor<C: ShardCatalog>(&self, catalog: C) -> ShardedExecutor<C, L> {
ShardedExecutor::new(catalog, self.locator.clone(), self.family.clone())
}
pub fn mint(&self, allocator: &IdAllocator, shard: ShardId) -> u64 {
allocator.next(self.codec, shard)
}
pub fn shard_of(&self, key: u64) -> ShardId {
self.codec.shard_of(key)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShardStatus {
Provisioning,
Active,
Standby,
Sealed,
Disabled,
}
impl StringColumn for ShardStatus {
fn to_column_str(&self) -> String {
match self {
ShardStatus::Provisioning => "provisioning",
ShardStatus::Active => "active",
ShardStatus::Standby => "standby",
ShardStatus::Sealed => "sealed",
ShardStatus::Disabled => "disabled",
}
.to_string()
}
fn from_column_str(s: &str) -> Result<Self> {
Ok(match s {
"active" => ShardStatus::Active,
"standby" => ShardStatus::Standby,
"sealed" => ShardStatus::Sealed,
"disabled" => ShardStatus::Disabled,
_ => ShardStatus::Provisioning,
})
}
}
crate::impl_string_column!(ShardStatus);
#[derive(Debug, Clone, Entity)]
#[orm(table = "shards")]
pub struct ShardRecord {
#[orm(pk)]
pub id: u32,
pub name: String,
pub database_id: Option<String>,
pub status: ShardStatus,
pub size_bytes: i64,
pub max_bytes: i64,
pub admit_threshold: f64,
pub created_at: DateTime<Utc>,
pub sealed_at: Option<DateTime<Utc>>,
pub last_size_check_at: Option<DateTime<Utc>>,
}
impl ShardRecord {
pub fn fill_ratio(&self) -> f64 {
if self.max_bytes <= 0 {
return 1.0;
}
self.size_bytes as f64 / self.max_bytes as f64
}
pub fn accepts_admission(&self) -> bool {
self.status == ShardStatus::Active && self.fill_ratio() < self.admit_threshold
}
}
#[derive(Clone)]
pub struct ShardRegistry<X> {
exec: X,
}
impl<X: Executor> ShardRegistry<X> {
pub fn new(exec: X) -> Self {
Self { exec }
}
pub async fn list(&self) -> Result<Vec<ShardRecord>> {
repo::all(&self.exec).await
}
pub async fn get(&self, id: u32) -> Result<Option<ShardRecord>> {
repo::find_by_pk(&self.exec, id).await
}
pub async fn register(&self, record: &ShardRecord) -> Result<()> {
repo::insert(&self.exec, record).await?;
Ok(())
}
pub async fn set_status(&self, id: u32, status: ShardStatus) -> Result<()> {
repo::query::<ShardRecord>()
.where_eq("id", id)
.update(&self.exec, vec![("status", status.to_column_str().into())])
.await?;
Ok(())
}
pub async fn set_size(&self, id: u32, size_bytes: i64) -> Result<()> {
let now = Utc::now();
repo::query::<ShardRecord>()
.where_eq("id", id)
.update(
&self.exec,
vec![("size_bytes", size_bytes.into()), ("last_size_check_at", now.into())],
)
.await?;
Ok(())
}
pub async fn seal(&self, id: u32) -> Result<()> {
let now = Utc::now();
repo::query::<ShardRecord>()
.where_eq("id", id)
.update(
&self.exec,
vec![
("status", ShardStatus::Sealed.to_column_str().into()),
("sealed_at", now.into()),
],
)
.await?;
Ok(())
}
}
pub fn live_shard_ids(records: &[ShardRecord]) -> Vec<u32> {
records.iter().filter(|r| r.status != ShardStatus::Disabled).map(|r| r.id).collect()
}
#[derive(Debug, Clone, Copy)]
pub struct Placement {
pub shard: ShardId,
pub id: u64,
}
#[derive(Clone)]
pub struct Placer<X> {
registry: ShardRegistry<X>,
allocator: IdAllocator,
codec: ShardCodec,
}
impl<X: Executor> Placer<X> {
pub fn new(registry: ShardRegistry<X>, allocator: IdAllocator, codec: ShardCodec) -> Self {
Self { registry, allocator, codec }
}
fn compose(&self, shard: ShardId) -> Placement {
Placement { shard, id: self.allocator.next(self.codec, shard) }
}
pub async fn place_new(&self) -> Result<Placement> {
let shards = self.registry.list().await?;
if let Some(s) = shards.iter().find(|r| r.accepts_admission()) {
return Ok(self.compose(s.id));
}
for s in shards
.iter()
.filter(|r| r.status == ShardStatus::Active && r.fill_ratio() >= r.admit_threshold)
{
self.registry.seal(s.id).await?;
}
if let Some(sb) = shards.iter().find(|r| r.status == ShardStatus::Standby) {
self.registry.set_status(sb.id, ShardStatus::Active).await?;
return Ok(self.compose(sb.id));
}
bail!(
"no shard available to admit a new key — provision a standby \
(the provisioning controller keeps one ready)"
)
}
}
pub fn normalize_key(key: &str) -> String {
key.trim().to_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq, Entity)]
#[orm(table = "shard_directory")]
pub struct DirectoryEntry {
#[orm(pk)]
pub key: String,
pub id: u64,
}
#[allow(async_fn_in_trait)]
pub trait DirectoryRepository {
async fn resolve(&self, key: &str) -> Result<Option<u64>>;
async fn put(&self, key: &str, id: u64) -> Result<()>;
}
#[derive(Clone)]
pub struct Directory<X> {
exec: X,
}
impl<X: Executor> Directory<X> {
pub fn new(exec: X) -> Self {
Self { exec }
}
}
impl<X: Executor> DirectoryRepository for Directory<X> {
async fn resolve(&self, key: &str) -> Result<Option<u64>> {
let row: Option<DirectoryEntry> = repo::find_by_pk(&self.exec, normalize_key(key)).await?;
Ok(row.map(|r| r.id))
}
async fn put(&self, key: &str, id: u64) -> Result<()> {
let row = DirectoryEntry { key: normalize_key(key), id };
repo::upsert(&self.exec, &row, &["key"], &["id"]).await?;
Ok(())
}
}
const SIZE_SQL: &str = "SELECT (SELECT page_count FROM pragma_page_count()) * \
(SELECT page_size FROM pragma_page_size()) AS bytes";
pub async fn refresh_shard_sizes<X: Executor>(
exec: &X,
registry: &ShardRegistry<X>,
codec: ShardCodec,
) -> Result<()> {
for s in registry.list().await? {
if s.database_id.is_none() {
continue; }
let probe = codec.compose(s.id, 0);
let bytes = {
let rows = exec.fetch_all_routed(ShardRoute::Key(probe), SIZE_SQL, vec![]).await?;
rows.first().and_then(|r| r.get_i64("bytes").ok().flatten()).unwrap_or(0)
};
registry.set_size(s.id, bytes).await?;
}
Ok(())
}
pub fn control_tables_ddl(dialect: Dialect) -> Vec<String> {
let mut out = crate::schema::schema_ddl::<ShardRecord>(dialect);
out.extend(crate::schema::schema_ddl::<DirectoryEntry>(dialect));
out
}
pub async fn ensure_control_tables<X: Executor>(global: &X) -> Result<()> {
for ddl in control_tables_ddl(global.dialect()) {
global.execute(&ddl, vec![]).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_shard_encoded_and_monotonic() {
let codec = ShardCodec::DEFAULT;
let alloc = IdAllocator::new();
let a = alloc.next(codec, 7);
let b = alloc.next(codec, 7);
assert_eq!(codec.shard_of(a), 7);
assert_eq!(codec.shard_of(b), 7);
assert!(b > a, "ids increase");
let c = alloc.next(codec, 9);
assert_eq!(codec.shard_of(c), 9);
assert_ne!(codec.shard_of(a), codec.shard_of(c));
}
#[test]
fn next_seq_is_monotonic() {
let alloc = IdAllocator::new();
let mut last = 0;
for _ in 0..1000 {
let n = alloc.next_seq();
assert!(n > last, "sequence strictly increases");
last = n;
}
}
#[test]
fn normalize_key_trims_and_lowercases() {
assert_eq!(normalize_key(" Foo@Bar.COM "), "foo@bar.com");
}
#[test]
fn admission_respects_threshold_and_status() {
let mut r = ShardRecord {
id: 0,
name: "x_shard_0000".into(),
database_id: None,
status: ShardStatus::Active,
size_bytes: 4,
max_bytes: 10,
admit_threshold: 0.5,
created_at: Utc::now(),
sealed_at: None,
last_size_check_at: None,
};
assert!(r.accepts_admission(), "active + under threshold admits");
r.size_bytes = 6; assert!(!r.accepts_admission(), "over threshold seals out");
r.size_bytes = 1;
r.status = ShardStatus::Sealed;
assert!(!r.accepts_admission(), "sealed never admits");
}
#[test]
fn settings_encoded_uses_codec_as_locator() {
let s = ShardingSettings::encoded("users", ShardCodec::new(8));
assert_eq!(s.family, "users");
let id = s.codec.compose(5, 1);
assert_eq!(s.locator.locate(id), 5);
let alloc = IdAllocator::new();
assert_eq!(s.shard_of(s.mint(&alloc, 3)), 3);
}
#[test]
fn settings_with_hash_locator_routes_by_hash() {
use crate::HashLocator;
let s = ShardingSettings::with_locator("dir", ShardCodec::DEFAULT, HashLocator::new(4));
for k in 0..200u64 {
assert!(s.locator.locate(k) < 4);
}
let alloc = IdAllocator::new();
assert_eq!(s.shard_of(s.mint(&alloc, 2)), 2);
}
#[test]
fn control_tables_ddl_renders_both_tables() {
let sql = control_tables_ddl(Dialect::Sqlite).join("\n");
assert!(sql.contains("shards"), "registry table present");
assert!(sql.contains("shard_directory"), "directory table present");
}
}