use super::client::RedisBackend;
use super::error::map_redis_error;
use crate::backend::interface::LuaExecutor;
use crate::core::RedisCommand;
use crate::error::{OxCacheError, OxCacheResult};
use crate::security;
use async_trait::async_trait;
use std::future::Future;
use std::time::Duration;
fn is_noscript_error(e: &redis::RedisError) -> bool {
e.to_string().contains("NOSCRIPT")
}
async fn lua_timed<T>(
timeout: Duration,
fut: impl Future<Output = OxCacheResult<T>>,
) -> OxCacheResult<T> {
if timeout.is_zero() {
return fut.await;
}
match tokio::time::timeout(timeout, fut).await {
Ok(result) => result,
Err(_) => Err(OxCacheError::Timeout(format!(
"Lua command timed out after {timeout:?}"
))),
}
}
#[cfg(feature = "lua")]
#[async_trait]
impl LuaExecutor for RedisBackend {
async fn eval_lua(
&self,
script: &str,
keys: &[&str],
args: &[&str],
) -> OxCacheResult<redis::Value> {
security::validate_lua_script(script, keys.len())?;
for key in keys {
security::validate_redis_key(key)?;
}
self.execute_once_with_breaker(|| async {
let mut conn = self.conn();
let mut cmd = redis::cmd(RedisCommand::Eval.as_str());
cmd.arg(script).arg(keys.len());
for key in keys {
cmd.arg(key);
}
for arg in args {
cmd.arg(arg);
}
lua_timed(self.lua_timeout(), async {
cmd.query_async(&mut conn).await.map_err(map_redis_error)
})
.await
})
.await
}
async fn eval_sha(
&self,
sha: &str,
keys: &[&str],
args: &[&str],
) -> OxCacheResult<redis::Value> {
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(OxCacheError::InvalidInput(format!(
"Invalid SHA format: expected 40 hexadecimal characters, got {} characters",
sha.len()
)));
}
for key in keys {
security::validate_redis_key(key)?;
}
self.execute_once_with_breaker(|| async {
let mut conn = self.conn();
let mut cmd = redis::cmd(RedisCommand::EvalSha.as_str());
cmd.arg(sha).arg(keys.len());
for key in keys {
cmd.arg(key);
}
for arg in args {
cmd.arg(arg);
}
lua_timed(
self.lua_timeout(),
async {
match cmd.query_async(&mut conn).await {
Ok(result) => Ok(result),
Err(e) if is_noscript_error(&e) => {
Err(OxCacheError::Operation(format!(
"NOSCRIPT: script {sha} not cached. Re-load via script_load() or use eval_lua()"
)))
}
Err(e) => Err(map_redis_error(e)),
}
},
)
.await
})
.await
}
async fn script_load(&self, script: &str) -> OxCacheResult<String> {
security::validate_lua_script(script, 0)?;
self.execute_once_with_breaker(|| async {
let mut conn = self.conn();
lua_timed(self.lua_timeout(), async {
let sha: String = redis::cmd(RedisCommand::Script.as_str())
.arg("LOAD")
.arg(script)
.query_async(&mut conn)
.await
.map_err(map_redis_error)?;
Ok(sha)
})
.await
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_lua_timed_pending_times_out() {
let result: OxCacheResult<redis::Value> =
lua_timed(Duration::from_millis(20), std::future::pending()).await;
match result {
Err(OxCacheError::Timeout(msg)) => {
assert!(msg.contains("20ms"), "unexpected message: {msg}")
}
other => panic!("expected Timeout, got {other:?}"),
}
}
#[tokio::test]
async fn test_lua_timed_immediate_passthrough() {
let result: OxCacheResult<u8> = lua_timed(Duration::from_secs(5), async { Ok(7) }).await;
assert_eq!(result.unwrap(), 7);
}
#[tokio::test]
async fn test_lua_timed_zero_is_unbounded() {
let result: OxCacheResult<u8> = lua_timed(Duration::ZERO, async {
tokio::time::sleep(Duration::from_millis(30)).await;
Ok(9)
})
.await;
assert_eq!(result.unwrap(), 9);
}
}