use std::future::Future;
use std::io;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use redis::aio::{ConnectionManager, ConnectionManagerConfig};
use redis::{AsyncCommands, Client, ErrorKind as RedisErrorKind, RedisError, Script};
use serde::{Deserialize, Serialize};
use tokio::time::timeout;
use tracing::warn;
use crate::quota::QuotaSnapshot;
use crate::quota_store::{BackendState, InMemoryQuotaStore, QuotaStore};
use crate::types::RunnerError;
pub const KEY_PREFIX: &str = "embacle:quota";
pub const READING_TTL: Duration = Duration::from_mins(15);
pub const OPERATION_TIMEOUT: Duration = Duration::from_millis(500);
const RECORD_READING_LUA: &str = r"
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
local until_ms = redis.call('GET', KEYS[2])
if until_ms and tonumber(until_ms) <= tonumber(ARGV[3]) then
redis.call('DEL', KEYS[2])
end
return 1
";
#[derive(Debug, Serialize, Deserialize)]
struct WireSnapshot {
key: String,
label: String,
percent: f32,
resets_at_ms: u64,
observed_at_ms: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireReading {
observed_at_ms: u64,
snapshots: Vec<WireSnapshot>,
}
fn to_ms(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
fn from_ms(ms: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_millis(ms)
}
fn adopt_local_penalties(shared: &mut [BackendState], local: &[BackendState]) {
for (theirs, mine) in shared.iter_mut().zip(local) {
let Some(until) = mine.penalty_until else {
continue;
};
let until_ms = to_ms(until);
let passed = theirs
.last_checked
.is_some_and(|read| to_ms(read) >= until_ms);
let later = theirs
.penalty_until
.is_none_or(|shared_until| until_ms > to_ms(shared_until));
if !passed && later {
theirs.penalty_until = Some(from_ms(until_ms));
}
}
}
async fn bounded<T>(op: impl Future<Output = Result<T, RedisError>>) -> Result<T, RedisError> {
timeout(OPERATION_TIMEOUT, op).await.unwrap_or_else(|_| {
Err(RedisError::from(io::Error::new(
io::ErrorKind::TimedOut,
"Redis did not answer within the quota store's operation timeout",
)))
})
}
impl From<&QuotaSnapshot> for WireSnapshot {
fn from(s: &QuotaSnapshot) -> Self {
Self {
key: s.key.clone(),
label: s.label.clone(),
percent: s.percent,
resets_at_ms: to_ms(s.resets_at),
observed_at_ms: to_ms(s.observed_at),
}
}
}
impl From<WireSnapshot> for QuotaSnapshot {
fn from(w: WireSnapshot) -> Self {
Self {
key: w.key,
label: w.label,
percent: w.percent,
resets_at: from_ms(w.resets_at_ms),
observed_at: from_ms(w.observed_at_ms),
}
}
}
pub struct RedisQuotaStore {
conn: ConnectionManager,
namespace: String,
backend_keys: Vec<String>,
local: InMemoryQuotaStore,
}
impl RedisQuotaStore {
pub async fn connect(redis_url: &str, account: &str) -> Result<Self, RunnerError> {
let client = Client::open(redis_url)
.map_err(|e| RunnerError::config(format!("quota store: invalid Redis URL: {e}")))?;
let config = ConnectionManagerConfig::new()
.set_connection_timeout(OPERATION_TIMEOUT)
.set_response_timeout(OPERATION_TIMEOUT);
let conn = ConnectionManager::new_with_config(client, config)
.await
.map_err(|e| {
RunnerError::external_service("redis", format!("quota store: cannot connect: {e}"))
})?;
Ok(Self::from_connection(conn, account))
}
#[must_use]
pub fn from_connection(conn: ConnectionManager, account: &str) -> Self {
Self {
conn,
namespace: format!("{KEY_PREFIX}:{account}"),
backend_keys: Vec::new(),
local: InMemoryQuotaStore::new(),
}
}
#[must_use]
pub fn with_backend_keys<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.backend_keys = keys.into_iter().map(Into::into).collect();
self
}
fn backend_key(&self, index: usize) -> String {
self.backend_keys
.get(index)
.map_or_else(|| index.to_string(), Clone::clone)
}
fn reading_key(&self, index: usize) -> String {
format!("{}:{}:reading", self.namespace, self.backend_key(index))
}
fn penalty_key(&self, index: usize) -> String {
format!("{}:{}:penalty", self.namespace, self.backend_key(index))
}
async fn load_shared(&self, len: usize) -> Result<Vec<BackendState>, RedisError> {
if len == 0 {
return Ok(Vec::new());
}
let keys: Vec<String> = (0..len)
.flat_map(|i| [self.reading_key(i), self.penalty_key(i)])
.collect();
let mut conn = self.conn.clone();
let values: Vec<Option<String>> = conn.mget(&keys).await?;
let mut out = Vec::with_capacity(len);
for (i, pair) in values.chunks(2).enumerate() {
let mut state = BackendState::default();
if let Some(raw) = pair.first().and_then(Option::as_ref) {
match serde_json::from_str::<WireReading>(raw) {
Ok(reading) => {
state.last_checked = Some(from_ms(reading.observed_at_ms));
state.snapshots = reading.snapshots.into_iter().map(Into::into).collect();
}
Err(e) => warn!(
key = %self.reading_key(i),
error = %e,
"quota store: unreadable reading; treating it as absent"
),
}
}
if let Some(raw) = pair.get(1).and_then(Option::as_ref) {
match raw.parse::<u64>() {
Ok(ms) => state.penalty_until = Some(from_ms(ms)),
Err(e) => warn!(
key = %self.penalty_key(i),
error = %e,
"quota store: unreadable penalty; treating it as absent"
),
}
}
out.push(state);
}
out.resize(len, BackendState::default());
Ok(out)
}
async fn record_shared(
&self,
index: usize,
snapshots: &[QuotaSnapshot],
observed_at: SystemTime,
) -> Result<(), RedisError> {
let wire = WireReading {
observed_at_ms: to_ms(observed_at),
snapshots: snapshots.iter().map(WireSnapshot::from).collect(),
};
let body = serde_json::to_string(&wire).map_err(|e| {
RedisError::from((
RedisErrorKind::TypeError,
"quota reading does not serialise",
e.to_string(),
))
})?;
let ttl_ms = u64::try_from(READING_TTL.as_millis()).unwrap_or(u64::MAX);
let mut conn = self.conn.clone();
Script::new(RECORD_READING_LUA)
.key(self.reading_key(index))
.key(self.penalty_key(index))
.arg(body)
.arg(ttl_ms)
.arg(to_ms(observed_at))
.invoke_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn penalty_shared(
&self,
index: usize,
until: Option<SystemTime>,
) -> Result<(), RedisError> {
let key = self.penalty_key(index);
let mut conn = self.conn.clone();
let remaining = until.and_then(|u| u.duration_since(SystemTime::now()).ok());
match (until, remaining) {
(Some(u), Some(left)) if !left.is_zero() => {
let ttl_ms = u64::try_from(left.as_millis()).unwrap_or(u64::MAX).max(1);
conn.pset_ex::<_, _, ()>(&key, to_ms(u), ttl_ms).await
}
_ => conn.del::<_, ()>(&key).await,
}
}
}
#[async_trait]
impl QuotaStore for RedisQuotaStore {
fn name(&self) -> &str {
"redis"
}
async fn load(&self, len: usize) -> Vec<BackendState> {
match bounded(self.load_shared(len)).await {
Ok(mut shared) => {
adopt_local_penalties(&mut shared, &self.local.load(len).await);
shared
}
Err(e) => {
warn!(
error = %e,
"quota store: Redis unreadable; routing on this process's own view"
);
self.local.load(len).await
}
}
}
async fn record_reading(
&self,
index: usize,
snapshots: Vec<QuotaSnapshot>,
observed_at: SystemTime,
) {
if let Err(e) = bounded(self.record_shared(index, &snapshots, observed_at)).await {
warn!(
error = %e,
"quota store: reading not shared; other instances will read it themselves"
);
}
self.local
.record_reading(index, snapshots, observed_at)
.await;
}
async fn set_penalty(&self, index: usize, until: Option<SystemTime>) {
if let Err(e) = bounded(self.penalty_shared(index, until)).await {
warn!(
error = %e,
"quota store: penalty not shared; other instances will each learn it"
);
}
self.local.set_penalty(index, until).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_snapshot_survives_the_wire_to_the_millisecond() {
let snap = QuotaSnapshot {
key: "weekly_scoped:Fable".to_owned(),
label: "Weekly (Fable)".to_owned(),
percent: 87.5,
resets_at: from_ms(1_800_000_000_123),
observed_at: from_ms(1_799_999_000_456),
};
let wire = WireReading {
observed_at_ms: 1_799_999_000_456,
snapshots: vec![WireSnapshot::from(&snap)],
};
let raw = serde_json::to_string(&wire).unwrap(); let back: WireReading = serde_json::from_str(&raw).unwrap(); let restored: Vec<QuotaSnapshot> = back.snapshots.into_iter().map(Into::into).collect();
assert_eq!(restored, vec![snap]);
}
#[test]
fn a_penalty_this_process_holds_outlives_a_read_that_lacks_it() {
let until = from_ms(1_800_000_000_000);
let mut shared = vec![BackendState::default(), BackendState::default()];
let local = vec![
BackendState {
penalty_until: Some(until),
..BackendState::default()
},
BackendState::default(),
];
adopt_local_penalties(&mut shared, &local);
assert_eq!(shared[0].penalty_until, Some(until));
assert_eq!(shared[1].penalty_until, None);
}
#[test]
fn the_later_of_the_shared_and_local_penalty_wins() {
let earlier = from_ms(1_800_000_000_000);
let later = from_ms(1_800_000_600_000);
let penalised = |until| BackendState {
penalty_until: Some(until),
..BackendState::default()
};
let mut shared = vec![penalised(earlier)];
adopt_local_penalties(&mut shared, &[penalised(later)]);
assert_eq!(shared[0].penalty_until, Some(later), "local is later");
let mut shared = vec![penalised(later)];
adopt_local_penalties(&mut shared, &[penalised(earlier)]);
assert_eq!(shared[0].penalty_until, Some(later), "shared is later");
}
#[test]
fn a_shared_reading_past_the_local_penalty_clears_it() {
let until = from_ms(1_800_000_000_000);
let mut shared = vec![BackendState {
last_checked: Some(until),
..BackendState::default()
}];
let local = [BackendState {
penalty_until: Some(until),
..BackendState::default()
}];
adopt_local_penalties(&mut shared, &local);
assert_eq!(shared[0].penalty_until, None);
let mut shared = vec![BackendState {
last_checked: Some(until),
..BackendState::default()
}];
let local = [BackendState {
penalty_until: Some(until + Duration::from_micros(500)),
..BackendState::default()
}];
adopt_local_penalties(&mut shared, &local);
assert_eq!(shared[0].penalty_until, None, "sub-millisecond residue");
}
#[test]
fn an_instant_before_the_epoch_clamps_rather_than_panicking() {
assert_eq!(to_ms(UNIX_EPOCH - Duration::from_secs(1)), 0);
}
}