use crate::infrastructure::RedisClient;
use crate::utils::admission::SharedPricingGate;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
use uuid::Uuid;
pub const DEFAULT_PRICING_GATE_KEY: &str = "optionchain:pricing:leases";
const LEASE_WINDOW: Duration = Duration::from_secs(300);
const RENEWAL_INTERVAL: Duration = Duration::from_secs(100);
const RETRY_INTERVAL: Duration = Duration::from_millis(50);
const ACQUIRE_SCRIPT: &str = r"
local clock = redis.call('TIME')
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
if redis.call('ZCARD', KEYS[1]) < limit then
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], window * 2)
return 1
end
return 0
";
const RENEW_SCRIPT: &str = r"
if redis.call('ZSCORE', KEYS[1], ARGV[1]) == false then
return 0
end
local clock = redis.call('TIME')
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
redis.call('ZADD', KEYS[1], now, ARGV[1])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]) * 2)
return 1
";
pub struct RedisPricingGate {
client: Arc<RedisClient>,
key: String,
limit: usize,
}
impl RedisPricingGate {
#[must_use]
pub fn new(client: Arc<RedisClient>, key: impl Into<String>, limit: usize) -> Self {
Self {
client,
key: key.into(),
limit: limit.max(1),
}
}
async fn try_acquire(&self, token: &str) -> Result<bool, String> {
let mut conn = self.client.connection_manager();
let taken: i64 = redis::Script::new(ACQUIRE_SCRIPT)
.key(&self.key)
.arg(LEASE_WINDOW.as_millis().to_string())
.arg(self.limit.to_string())
.arg(token)
.invoke_async(&mut conn)
.await
.map_err(|error| error.to_string())?;
Ok(taken == 1)
}
}
#[async_trait]
impl SharedPricingGate for RedisPricingGate {
async fn acquire(&self) -> Option<String> {
let token = Uuid::new_v4().to_string();
let mut waited = Duration::ZERO;
loop {
match self.try_acquire(&token).await {
Ok(true) => {
debug!(limit = self.limit, "took a deployment-wide pricing lease");
return Some(token);
}
Ok(false) => {}
Err(error) => {
warn!(
%error,
"the deployment-wide pricing gate is unreachable; falling back to this \
instance's own bound"
);
return None;
}
}
tokio::time::sleep(RETRY_INTERVAL).await;
waited += RETRY_INTERVAL;
if waited.as_millis().is_multiple_of(LEASE_WINDOW.as_millis()) {
warn!(
limit = self.limit,
waited_secs = waited.as_secs(),
"still waiting for a deployment-wide pricing lease"
);
}
}
}
async fn renew(&self, token: &str) -> bool {
let mut conn = self.client.connection_manager();
let renewed: Result<i64, _> = redis::Script::new(RENEW_SCRIPT)
.key(&self.key)
.arg(token)
.arg(LEASE_WINDOW.as_millis().to_string())
.invoke_async(&mut conn)
.await;
match renewed {
Ok(1) => true,
Ok(_) => false,
Err(error) => {
warn!(%error, "a pricing lease could not be renewed");
false
}
}
}
fn renewal_interval(&self) -> Duration {
RENEWAL_INTERVAL
}
async fn release(&self, token: &str) {
let mut conn = self.client.connection_manager();
let removed: Result<i64, _> = redis::cmd("ZREM")
.arg(&self.key)
.arg(token)
.query_async(&mut conn)
.await;
if let Err(error) = removed {
warn!(%error, "a pricing lease could not be released; it will expire");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::RedisConfig;
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_a_limit_of_zero_becomes_one() {
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let gate = RedisPricingGate::new(client, "test:limit", 0);
assert_eq!(gate.limit, 1, "a limit of zero would price nothing");
}
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_two_gates_cannot_exceed_the_bound_together() {
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let key = format!("test:pricing:{}", Uuid::new_v4());
let first = RedisPricingGate::new(Arc::clone(&client), key.clone(), 2);
let second = RedisPricingGate::new(Arc::clone(&client), key.clone(), 2);
let one = Uuid::new_v4().to_string();
let two = Uuid::new_v4().to_string();
let three = Uuid::new_v4().to_string();
assert_eq!(
first.try_acquire(&one).await,
Ok(true),
"the first lease fits in a bound of two"
);
assert_eq!(
second.try_acquire(&two).await,
Ok(true),
"the second lease fits in a bound of two"
);
assert_eq!(
second.try_acquire(&three).await,
Ok(false),
"a third lease was granted against a bound of two, so replicas can exceed it"
);
assert!(first.renew(&one).await, "a held lease must renew");
assert!(
!first.renew(&three).await,
"a lease that is not held must not be renewable into existence"
);
first.release(&one).await;
assert_eq!(
second.try_acquire(&three).await,
Ok(true),
"a released lease must free a slot"
);
second.release(&two).await;
second.release(&three).await;
let _: Result<i64, _> = redis::cmd("DEL")
.arg(&key)
.query_async(&mut client.connection_manager())
.await;
}
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_a_full_gate_waits_instead_of_falling_back() {
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let key = format!("test:pricing:{}", Uuid::new_v4());
let gate = RedisPricingGate::new(Arc::clone(&client), key.clone(), 1);
let held = Uuid::new_v4().to_string();
assert_eq!(gate.try_acquire(&held).await, Ok(true));
let mut waiting = Box::pin(gate.acquire());
match futures::future::select(
&mut waiting,
Box::pin(tokio::time::sleep(Duration::from_millis(300))),
)
.await
{
futures::future::Either::Left((outcome, _)) => {
panic!("a full gate must wait, not answer with {outcome:?}")
}
futures::future::Either::Right(((), _)) => {}
}
gate.release(&held).await;
match tokio::time::timeout(Duration::from_secs(5), waiting).await {
Ok(Some(token)) => gate.release(&token).await,
other => panic!("the waiter must take the freed lease: {other:?}"),
}
let _: Result<i64, _> = redis::cmd("DEL")
.arg(&key)
.query_async(&mut client.connection_manager())
.await;
}
}