use crate::infrastructure::CURRENT_SNAPSHOT_GENERATION;
use crate::infrastructure::RedisClient;
use crate::session::SimulationParametersV2;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
use uuid::Uuid;
const FINGERPRINT_NAMESPACE: Uuid =
Uuid::from_u128(0x6f_63_73_5f_74_61_70_65_5f_63_61_63_68_65_76_31);
pub const DEFAULT_TAPE_KEY_PREFIX: &str = "optionchain:tape:";
const PUT_SCRIPT: &str = r"
local ttl = tonumber(ARGV[2])
local score = redis.call('INCR', KEYS[3])
redis.call('EXPIRE', KEYS[3], ttl * 2)
redis.call('SET', KEYS[2], ARGV[1], 'EX', ttl)
redis.call('ZADD', KEYS[1], score, KEYS[2])
local excess = redis.call('ZCARD', KEYS[1]) - tonumber(ARGV[3])
if excess > 0 then
local victims = redis.call('ZRANGE', KEYS[1], 0, excess - 1)
for _, victim in ipairs(victims) do
if victim ~= KEYS[2] then
redis.call('DEL', victim)
end
redis.call('ZREM', KEYS[1], victim)
end
end
redis.call('EXPIRE', KEYS[1], ttl * 2)
return 1
";
const TOUCH_SCRIPT: &str = r"
local ttl = tonumber(ARGV[1])
local score = redis.call('INCR', KEYS[3])
redis.call('EXPIRE', KEYS[3], ttl * 2)
redis.call('ZADD', KEYS[1], score, KEYS[2])
return score
";
const FORGET_SCRIPT: &str = r"
local members = redis.call('ZRANGE', KEYS[1], 0, -1)
local dropped = 0
for _, member in ipairs(members) do
if string.sub(member, -string.len(ARGV[1])) == ARGV[1] then
redis.call('DEL', member)
redis.call('ZREM', KEYS[1], member)
dropped = dropped + 1
end
end
return dropped
";
#[derive(Debug)]
pub enum BuildClaim {
Held(String),
Taken,
Unclaimed,
}
const RELEASE_CLAIM_SCRIPT: &str = r"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
";
const BUILD_CLAIM_SECS: u64 = 120;
#[async_trait]
pub trait SharedTapeCache: Send + Sync {
async fn get(&self, key: &str) -> Option<String>;
async fn put(&self, key: &str, encoded: &str);
async fn forget_simulation(&self, id: &str);
async fn claim_build(&self, key: &str) -> BuildClaim;
async fn release_build(&self, key: &str, token: &str);
}
pub(crate) fn tape_key(id: Uuid, parameters: &SimulationParametersV2) -> String {
let fingerprint = fingerprint(parameters);
format!("{CURRENT_SNAPSHOT_GENERATION}:{fingerprint}:{id}")
}
fn fingerprint(parameters: &SimulationParametersV2) -> Uuid {
match serde_json::to_vec(parameters) {
Ok(canonical) => Uuid::new_v5(&FINGERPRINT_NAMESPACE, &canonical),
Err(error) => {
warn!(%error, "could not fingerprint the parameters; the shared tape cache will miss");
Uuid::new_v4()
}
}
}
pub struct RedisTapeCache {
client: Arc<RedisClient>,
prefix: String,
ttl: Duration,
bound: usize,
owner: Uuid,
}
impl RedisTapeCache {
#[must_use]
pub fn new(
client: Arc<RedisClient>,
prefix: impl Into<String>,
ttl: Duration,
bound: usize,
) -> Self {
Self {
client,
prefix: prefix.into(),
ttl,
bound: bound.max(1),
owner: Uuid::new_v4(),
}
}
fn index_key(&self) -> String {
format!("{}index", self.prefix)
}
fn sequence_key(&self) -> String {
format!("{}sequence", self.prefix)
}
}
#[async_trait]
impl SharedTapeCache for RedisTapeCache {
async fn get(&self, key: &str) -> Option<String> {
let key = format!("{}{key}", self.prefix);
match self.client.get::<String>(&key).await {
Ok(stored) => {
if stored.is_some() {
debug!(%key, "shared tape cache hit");
let mut conn = self.client.connection_manager();
let touched: Result<i64, _> = redis::Script::new(TOUCH_SCRIPT)
.key(self.index_key())
.key(&key)
.key(self.sequence_key())
.arg(self.ttl.as_secs().max(1).to_string())
.invoke_async(&mut conn)
.await;
if let Err(error) = touched {
warn!(%error, %key, "a tape's recency could not be recorded");
}
}
stored
}
Err(error) => {
warn!(%error, %key, "the shared tape cache could not be read");
None
}
}
}
async fn put(&self, key: &str, encoded: &str) {
let key = format!("{}{key}", self.prefix);
let seconds = self.ttl.as_secs().max(1);
let mut conn = self.client.connection_manager();
let written: Result<i64, _> = redis::Script::new(PUT_SCRIPT)
.key(self.index_key())
.key(&key)
.key(self.sequence_key())
.arg(encoded)
.arg(seconds.to_string())
.arg(self.bound.to_string())
.invoke_async(&mut conn)
.await;
if let Err(error) = written {
warn!(%error, %key, "the shared tape cache could not be written");
}
}
async fn forget_simulation(&self, id: &str) {
let mut conn = self.client.connection_manager();
let dropped: Result<i64, _> = redis::Script::new(FORGET_SCRIPT)
.key(self.index_key())
.arg(format!(":{id}"))
.invoke_async(&mut conn)
.await;
match dropped {
Ok(dropped) if dropped > 0 => {
debug!(simulation_id = %id, dropped, "dropped the shared tapes of a gone simulation");
}
Ok(_) => {}
Err(error) => warn!(
%error,
simulation_id = %id,
"the shared tapes of a gone simulation could not be dropped; they will expire"
),
}
}
async fn claim_build(&self, key: &str) -> BuildClaim {
let key = format!("{}build:{key}", self.prefix);
let token = format!("{}:{}", self.owner, Uuid::new_v4());
match self
.client
.set_nx(&key, token.clone(), Some(BUILD_CLAIM_SECS))
.await
{
Ok(true) => {
debug!(%key, "claimed the build");
BuildClaim::Held(token)
}
Ok(false) => BuildClaim::Taken,
Err(error) => {
warn!(%error, %key, "the build claim could not be taken; building anyway");
BuildClaim::Unclaimed
}
}
}
async fn release_build(&self, key: &str, token: &str) {
let key = format!("{}build:{key}", self.prefix);
let mut conn = self.client.connection_manager();
let released: Result<i64, _> = redis::Script::new(RELEASE_CLAIM_SCRIPT)
.key(&key)
.arg(token)
.invoke_async(&mut conn)
.await;
match released {
Ok(1) => {}
Ok(_) => debug!(%key, "a build claim had already expired when it was released"),
Err(error) => {
warn!(%error, %key, "a build claim could not be released; it will expire");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::session::{ExpiryRule, ExpiryRuleKind};
fn parameters(seed: u64) -> SimulationParametersV2 {
let rule = match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, 1) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
};
let request = CreateSimulationRequest {
symbol: "SPX".to_string(),
steps: 4,
start_at: None,
step_interval_seconds: Some(86_400),
timezone: "America/New_York".to_string(),
calendar: None,
expiration_time: "17:00".to_string(),
schedules: vec![rule],
initial_price: 5000.0,
volatility: 0.2,
risk_free_rate: 0.05,
dividend_yield: 0.0,
method: ApiWalkType::GeometricBrownian {
dt: 0.004,
drift: 0.05,
volatility: 0.2,
},
time_frame: ApiTimeFrame::Day,
chain_size: Some(2),
strike_interval: Some(25.0),
skew_slope: None,
smile_curve: None,
spread: None,
spread_proportional: None,
spread_moneyness_widening: None,
spread_tenor_widening: None,
spread_tick: None,
strike_ladder: None,
seed: Some(seed),
};
match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the reference request must convert: {error}"),
}
}
#[test]
fn test_the_key_is_stable_for_the_same_parameters() {
let id = Uuid::from_u128(1);
let left = tape_key(id, ¶meters(42));
let right = tape_key(id, ¶meters(42));
assert_eq!(left, right);
assert!(
left.starts_with(&format!("{CURRENT_SNAPSHOT_GENERATION}:")),
"the key must carry the generation first: {left}"
);
assert!(
left.ends_with(&id.to_string()),
"the key must name the simulation: {left}"
);
}
#[test]
fn test_a_different_seed_is_a_different_key() {
let id = Uuid::from_u128(1);
assert_ne!(
tape_key(id, ¶meters(42)),
tape_key(id, ¶meters(43)),
"two seeds produce two tapes and must not share a key"
);
}
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_the_bound_evicts_the_least_recently_used() {
use crate::infrastructure::RedisConfig;
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let prefix = format!("test:tape:{}:", Uuid::new_v4());
let cache = RedisTapeCache::new(
Arc::clone(&client),
prefix.clone(),
Duration::from_secs(60),
2,
);
cache.put("a:1", "first").await;
cache.put("b:2", "second").await;
assert_eq!(cache.get("a:1").await.as_deref(), Some("first"));
cache.put("c:3", "third").await;
assert_eq!(
cache.get("a:1").await.as_deref(),
Some("first"),
"the recently used tape must survive"
);
assert_eq!(
cache.get("c:3").await.as_deref(),
Some("third"),
"the tape just written must be there"
);
assert_eq!(
cache.get("b:2").await,
None,
"a bound of two must have evicted the least recently used"
);
cache.forget_simulation("3").await;
assert_eq!(
cache.get("c:3").await,
None,
"a forgotten simulation must leave nothing behind"
);
let _: Result<i64, _> = redis::cmd("DEL")
.arg(format!("{prefix}index"))
.arg(format!("{prefix}sequence"))
.arg(format!("{prefix}a:1"))
.query_async(&mut client.connection_manager())
.await;
}
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_recency_strictly_orders_operations_written_back_to_back() {
use crate::infrastructure::RedisConfig;
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let prefix = format!("test:tape:{}:", Uuid::new_v4());
let cache = RedisTapeCache::new(
Arc::clone(&client),
prefix.clone(),
Duration::from_secs(60),
64,
);
let mut previous = f64::MIN;
for step in 0..32 {
let key = format!("k{step}:{step}");
cache.put(&key, "tape").await;
let _ = cache.get(&key).await;
let score: Result<Option<f64>, _> = redis::cmd("ZSCORE")
.arg(format!("{prefix}index"))
.arg(format!("{prefix}{key}"))
.query_async(&mut client.connection_manager())
.await;
match score {
Ok(Some(score)) => {
assert!(
score > previous,
"operation {step} scored {score}, which does not follow {previous}"
);
previous = score;
}
other => panic!("the index must score every written tape: {other:?}"),
}
}
let members: Result<Vec<String>, _> = redis::cmd("ZRANGE")
.arg(format!("{prefix}index"))
.arg(0)
.arg(-1)
.query_async(&mut client.connection_manager())
.await;
let mut cleanup = redis::cmd("DEL");
cleanup.arg(format!("{prefix}index"));
cleanup.arg(format!("{prefix}sequence"));
if let Ok(members) = members {
for member in members {
cleanup.arg(member);
}
}
let _: Result<i64, _> = cleanup.query_async(&mut client.connection_manager()).await;
}
#[tokio::test]
#[ignore = "requires a live Redis matching REDIS_*; run with -- --ignored"]
async fn test_an_expired_claim_is_not_released_by_its_old_holder() {
use crate::infrastructure::RedisConfig;
let client = match RedisClient::new(RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("this test needs a live Redis: {error}"),
};
let prefix = format!("test:tape:{}:", Uuid::new_v4());
let first = RedisTapeCache::new(
Arc::clone(&client),
prefix.clone(),
Duration::from_secs(60),
8,
);
let second = RedisTapeCache::new(
Arc::clone(&client),
prefix.clone(),
Duration::from_secs(60),
8,
);
let key = "generation:fingerprint:simulation";
let held = match first.claim_build(key).await {
BuildClaim::Held(token) => token,
other => panic!("the first claim must be granted: {other:?}"),
};
assert!(
matches!(second.claim_build(key).await, BuildClaim::Taken),
"a claim another instance holds must not be granted twice"
);
let _: Result<i64, _> = redis::cmd("DEL")
.arg(format!("{prefix}build:{key}"))
.query_async(&mut client.connection_manager())
.await;
let taken_over = match second.claim_build(key).await {
BuildClaim::Held(token) => token,
other => panic!("an expired claim must be grantable: {other:?}"),
};
first.release_build(key, &held).await;
assert!(
matches!(first.claim_build(key).await, BuildClaim::Taken),
"a stale release deleted the claim its successor holds"
);
second.release_build(key, &taken_over).await;
assert!(
matches!(first.claim_build(key).await, BuildClaim::Held(_)),
"a released claim must be grantable again"
);
let _: Result<i64, _> = redis::cmd("DEL")
.arg(format!("{prefix}build:{key}"))
.query_async(&mut client.connection_manager())
.await;
}
#[test]
fn test_two_simulations_do_not_share_a_key() {
let parameters = parameters(42);
assert_ne!(
tape_key(Uuid::from_u128(1), ¶meters),
tape_key(Uuid::from_u128(2), ¶meters)
);
}
}