use crate::config::{SLOT_COUNT, SlotSpec};
use crate::sharding::{ShardKey, SlotId, slot_for_key};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CellSpec {
pub name: String,
pub base_url: String,
pub slots: Vec<SlotSpec>,
}
impl CellSpec {
#[must_use]
pub fn new(name: impl Into<String>, base_url: impl Into<String>, slots: Vec<SlotSpec>) -> Self {
Self {
name: name.into(),
base_url: base_url.into(),
slots,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("cell router: {0}")]
pub struct CellRouterError(String);
#[derive(Debug, Clone)]
pub struct CellRouter {
cells: Vec<CellSpec>,
slot_map: Vec<u16>,
}
impl CellRouter {
pub fn new(cells: Vec<CellSpec>) -> Result<Self, CellRouterError> {
let slot_count = usize::from(SLOT_COUNT);
if cells.is_empty() {
return Err(CellRouterError("give at least one cell".to_owned()));
}
if cells.len() > slot_count {
return Err(CellRouterError(format!(
"at most {slot_count} cells, got {}",
cells.len()
)));
}
let mut names = std::collections::HashSet::new();
for cell in &cells {
if !names.insert(cell.name.as_str()) {
return Err(CellRouterError(format!(
"duplicate cell name {:?}",
cell.name
)));
}
}
let declared = cells.iter().filter(|c| !c.slots.is_empty()).count();
let slot_map = if declared == 0 {
let n = cells.len();
(0..slot_count)
.map(|slot| u16::try_from(slot * n / slot_count).unwrap_or(u16::MAX))
.collect()
} else if declared == cells.len() {
explicit_slot_map(&cells)?
} else {
return Err(CellRouterError(
"give slots to every cell or to none".to_owned(),
));
};
Ok(Self { cells, slot_map })
}
#[must_use]
pub fn for_slot(&self, slot: SlotId) -> &CellSpec {
let index = self
.slot_map
.get(usize::from(slot.0))
.map_or(self.cells.len() - 1, |&i| usize::from(i));
&self.cells[index]
}
#[must_use]
pub fn for_key(&self, key: ShardKey<'_>) -> &CellSpec {
self.for_slot(slot_for_key(key))
}
#[must_use]
pub fn for_tenant(&self, tenant: &str) -> &CellSpec {
self.for_key(ShardKey::Str(tenant))
}
#[must_use]
pub fn url_for(&self, tenant: &str, path_and_query: &str) -> String {
let base = self.for_tenant(tenant).base_url.trim_end_matches('/');
let path = path_and_query.trim_start_matches('/');
format!("{base}/{path}")
}
#[must_use]
pub fn cells(&self) -> &[CellSpec] {
&self.cells
}
}
fn explicit_slot_map(cells: &[CellSpec]) -> Result<Vec<u16>, CellRouterError> {
let slot_count = usize::from(SLOT_COUNT);
let mut map: Vec<Option<u16>> = vec![None; slot_count];
for (index, cell) in cells.iter().enumerate() {
let owner = u16::try_from(index).unwrap_or(u16::MAX);
for spec in &cell.slots {
let slots = spec
.expand()
.map_err(|e| CellRouterError(format!("cell {:?}: {e}", cell.name)))?;
for slot in slots {
let Some(entry) = map.get_mut(usize::from(slot)) else {
return Err(CellRouterError(format!(
"cell {:?}: slot {slot} is out of range (slots are 0..{slot_count})",
cell.name
)));
};
if let Some(other) = entry {
return Err(CellRouterError(format!(
"cell {:?}: slot {slot} is already in cell {:?}",
cell.name,
cells[usize::from(*other)].name
)));
}
*entry = Some(owner);
}
}
}
let missing = map.iter().filter(|owner| owner.is_none()).count();
if missing != 0 {
return Err(CellRouterError(format!(
"{missing} slots have no cell; the cells must cover 0..{slot_count}"
)));
}
Ok(map.into_iter().flatten().collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn cell(name: &str, slots: &[&str]) -> CellSpec {
CellSpec::new(
name,
format!("http://{name}:3000/"),
slots
.iter()
.map(|s| SlotSpec::Range((*s).to_owned()))
.collect(),
)
}
#[test]
fn explicit_slots_route_by_the_shard_hash() {
let router = CellRouter::new(vec![cell("a", &["0-8191"]), cell("b", &["8192-16383"])])
.expect("valid");
assert_eq!(router.for_slot(SlotId(0)).name, "a");
assert_eq!(router.for_slot(SlotId(16383)).name, "b");
let slot = slot_for_key(ShardKey::Str("acme"));
let want = if slot.0 < 8192 { "a" } else { "b" };
assert_eq!(router.for_tenant("acme").name, want);
assert_eq!(router.for_key(ShardKey::Str("acme")).name, want);
}
#[test]
fn empty_slots_split_evenly_in_order() {
let router = CellRouter::new(vec![cell("a", &[]), cell("b", &[])]).expect("valid");
assert_eq!(router.for_slot(SlotId(8191)).name, "a");
assert_eq!(router.for_slot(SlotId(8192)).name, "b");
assert_eq!(router.cells().len(), 2);
}
#[test]
fn url_for_joins_the_base_url_and_the_path() {
let router = CellRouter::new(vec![cell("a", &[])]).expect("valid");
assert_eq!(
router.url_for("acme", "/orders?x=1"),
"http://a:3000/orders?x=1"
);
assert_eq!(
router.url_for("acme", "@evil.example/x"),
"http://a:3000/@evil.example/x"
);
}
#[test]
fn bad_cell_lists_are_rejected() {
assert!(CellRouter::new(vec![]).is_err(), "no cells");
assert!(
CellRouter::new(vec![cell("a", &[]), cell("a", &[])]).is_err(),
"duplicate name"
);
assert!(
CellRouter::new(vec![cell("a", &["0-100"]), cell("b", &["50-16383"])]).is_err(),
"overlap"
);
assert!(
CellRouter::new(vec![cell("a", &["0-100"]), cell("b", &["200-16383"])]).is_err(),
"gap"
);
assert!(
CellRouter::new(vec![cell("a", &["0-16383"]), cell("b", &[])]).is_err(),
"mixed explicit and empty slots"
);
assert!(
CellRouter::new(vec![cell("a", &["0-16384"])]).is_err(),
"out of range"
);
}
}