use std::collections::HashMap;
use futures::future::try_join_all;
use redis::aio::MultiplexedConnection;
use redis::streams::StreamRangeReply;
use scc::HashCache;
use scc::hash_cache::Entry;
use thiserror::Error;
use url::Url;
use crate::event::VehicleId;
use crate::partition::{fnv1a, mix};
use crate::store::Storable;
#[derive(Debug, Error)]
pub enum StoreError {
#[error("redis: {0}")]
Redis(#[from] redis::RedisError),
#[error("serialisation: {0}")]
Serialisation(#[from] postcard::Error),
#[error("no redis endpoints supplied")]
NoEndpoints,
}
type Result<T> = std::result::Result<T, StoreError>;
#[derive(Clone)]
struct Placement {
seeds: Vec<u64>,
}
impl Placement {
fn new(urls: &[Url]) -> Self {
Self {
seeds: urls
.iter()
.map(|url| fnv1a(url.as_str().as_bytes()))
.collect(),
}
}
fn index_for(&self, key: &str) -> usize {
let hash = fnv1a(key.as_bytes());
self.seeds
.iter()
.enumerate()
.max_by_key(|(_, seed)| (mix(hash ^ **seed), **seed))
.map(|(index, _)| index)
.expect("fleet is non-empty, checked in RedisStore::new")
}
}
#[derive(Clone)]
pub struct RedisStore<T: Storable> {
conns: Vec<MultiplexedConnection>,
placement: Placement,
_phantom: std::marker::PhantomData<T>,
}
impl<T: Storable> RedisStore<T> {
pub async fn new(urls: &[Url]) -> Result<Self> {
if urls.is_empty() {
return Err(StoreError::NoEndpoints);
}
let conns = try_join_all(urls.iter().map(|url| async move {
redis::Client::open(url.clone())?
.get_multiplexed_async_connection()
.await
}))
.await?;
Ok(Self {
conns,
placement: Placement::new(urls),
_phantom: std::marker::PhantomData,
})
}
pub async fn get_many(&mut self, vehicle_id: &VehicleId, len: usize) -> Result<Vec<T>> {
let key = format!("vehicle:{}:positions", vehicle_id);
let node = self.placement.index_for(&key);
let reply: StreamRangeReply = redis::cmd("XREVRANGE")
.arg(&key)
.arg("+")
.arg("-")
.arg("COUNT")
.arg(len)
.query_async(&mut self.conns[node])
.await?;
let mut entries = Vec::with_capacity(reply.ids.len());
for stream_id in &reply.ids {
let value = match stream_id.map.get("val") {
Some(redis::Value::BulkString(b)) => b.as_slice(),
_ => continue,
};
let entry: T = postcard::from_bytes(value)?;
entries.push(entry);
}
Ok(entries)
}
pub async fn write_many(&mut self, batch: &[T], limit: usize) -> Result<()> {
if batch.is_empty() {
return Ok(());
}
let mut pipelines: HashMap<usize, redis::Pipeline> = HashMap::new();
for item in batch {
let key = format!("vehicle:{}:positions", item.key());
let value = postcard::to_allocvec(item)?;
pipelines
.entry(self.placement.index_for(&key))
.or_insert_with(redis::pipe)
.cmd("XADD")
.arg(key)
.arg("MAXLEN")
.arg("~")
.arg(limit)
.arg("*")
.arg("shard")
.arg(item.shard_id().to_string())
.arg("val")
.arg(value)
.ignore();
}
try_join_all(pipelines.into_iter().map(|(node, pipeline)| {
let mut conn = self.conns[node].clone();
async move { pipeline.query_async::<()>(&mut conn).await }
}))
.await?;
Ok(())
}
}
pub struct CachedRedisStore<T: Storable> {
store: RedisStore<T>,
cache: HashCache<VehicleId, Vec<T>>,
}
impl<T: Storable> CachedRedisStore<T> {
pub fn new(store: RedisStore<T>, capacity: usize) -> Self {
Self {
store,
cache: HashCache::with_capacity(0, capacity),
}
}
pub async fn get_many(&mut self, vehicle_id: VehicleId, len: usize) -> Result<Vec<T>> {
if let Some(cached) = self.cache.get(&vehicle_id) {
return Ok(cached.get().clone());
}
let entries = self.store.get_many(&vehicle_id, len).await?;
match self.cache.entry(vehicle_id) {
Entry::Occupied(mut window) => {
window.put(entries.clone());
}
Entry::Vacant(window) => {
window.put_entry(entries.clone());
}
}
Ok(entries)
}
pub fn push(&mut self, key: VehicleId, item: T, len: usize) {
match self.cache.entry(key) {
Entry::Occupied(mut occupied) => {
let entries = occupied.get_mut();
entries.insert(0, item);
entries.truncate(len);
}
Entry::Vacant(vacant) => {
vacant.put_entry(vec![item]);
}
}
}
pub async fn write_many(&mut self, batch: &[T], limit: usize) -> Result<()> {
self.store.write_many(batch, limit).await
}
}
#[cfg(test)]
mod tests {
use super::Placement;
use url::Url;
fn fleet(n: usize) -> Vec<Url> {
(0..n)
.map(|i| Url::parse(&format!("redis://valkey-{i:03}:6379")).unwrap())
.collect()
}
fn keys(n: usize) -> Vec<String> {
(0..n).map(|i| format!("vehicle:{i}:positions")).collect()
}
#[test]
fn placement_is_deterministic() {
let urls = fleet(20);
let (a, b) = (Placement::new(&urls), Placement::new(&urls));
for key in keys(1000) {
assert_eq!(a.index_for(&key), b.index_for(&key));
}
}
#[test]
fn placement_ignores_url_order() {
let urls = fleet(20);
let mut shuffled = urls.clone();
shuffled.reverse();
let (direct, reversed) = (Placement::new(&urls), Placement::new(&shuffled));
for key in keys(1000) {
let expected = &urls[direct.index_for(&key)];
let actual = &shuffled[reversed.index_for(&key)];
assert_eq!(
expected, actual,
"{} moved when the list was reordered",
key
);
}
}
#[test]
fn growing_the_fleet_moves_about_one_nth_of_keys() {
let before = Placement::new(&fleet(20));
let after = Placement::new(&fleet(21));
let sample = keys(10_000);
let moved = sample
.iter()
.filter(|key| {
before.index_for(key) != after.index_for(key)
})
.count();
let ratio = moved as f64 / sample.len() as f64;
assert!(
ratio < 0.10,
"{:.1}% of keys moved, expected ~4.8%",
ratio * 100.0
);
}
#[test]
fn placement_spreads_across_the_fleet() {
let size = 20;
let placement = Placement::new(&fleet(size));
let mut counts = vec![0usize; size];
for key in keys(20_000) {
counts[placement.index_for(&key)] += 1;
}
for (index, count) in counts.iter().enumerate() {
assert!(
(500..2000).contains(count),
"primary {} took {} of 20000 keys",
index,
count
);
}
}
}