feat(redis): preserve TTL when updating values

This commit is contained in:
二丫讲梵 2026-07-14 23:07:52 +08:00 committed by GitHub
parent 4e3c705f9c
commit c1bda3be87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 126 additions and 9 deletions

View File

@ -786,7 +786,7 @@ async function createRedisKey() {
if (createKeyType.value === "string" || createKeyType.value === "json" || createKeyRawMode.value) {
// Raw text/JSON mode single value
if (createKeyType.value === "string") {
await api.redisSetString(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl);
await api.redisSetString(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl ?? -1);
} else if (createKeyType.value === "json") {
await api.redisJsonSet(props.connectionId, props.db, keyRaw, createKeyValue.value, ttl);
} else if (createKeyType.value === "hash") {

View File

@ -2158,13 +2158,47 @@ pub async fn set_string<C>(con: &mut C, key: &[u8], value: &str, ttl: Option<i64
where
C: ConnectionLike + Send + Sync + Unpin,
{
redis::cmd("SET").arg(key).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())?;
if let Some(t) = ttl {
if t > 0 {
redis::cmd("EXPIRE").arg(key).arg(t).query_async::<()>(con).await.map_err(|e| e.to_string())?;
match ttl {
Some(t) => {
redis::cmd("SET").arg(key).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())?;
if t > 0 {
redis::cmd("EXPIRE").arg(key).arg(t).query_async::<()>(con).await.map_err(|e| e.to_string())?;
}
Ok(())
}
None => set_string_preserving_ttl(con, key, value).await,
}
Ok(())
}
async fn set_string_preserving_ttl<C>(con: &mut C, key: &[u8], value: &str) -> Result<(), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
match redis::cmd("SET").arg(key).arg(value).arg("KEEPTTL").query_async::<()>(con).await {
Ok(()) => Ok(()),
Err(error) if is_unsupported_keepttl_error(&error) => {
let remaining_ms = redis::cmd("PTTL").arg(key).query_async::<i64>(con).await.map_err(|e| e.to_string())?;
let mut command = redis::cmd("SET");
command.arg(key).arg(value);
if remaining_ms >= 0 {
// Redis requires PX to be positive. A zero PTTL means the key is
// about to expire, so one millisecond is the closest equivalent.
command.arg("PX").arg(remaining_ms.max(1));
}
command.query_async::<()>(con).await.map_err(|e| e.to_string())
}
Err(error) => Err(error.to_string()),
}
}
fn is_unsupported_keepttl_error(error: &redis::RedisError) -> bool {
if error.kind() != redis::ErrorKind::ResponseError {
return false;
}
let detail = error.detail().unwrap_or_default().to_ascii_lowercase();
detail.contains("syntax error")
|| ((detail.contains("unknown") || detail.contains("unsupported")) && detail.contains("keepttl"))
}
pub async fn delete_key<C>(con: &mut C, key: &[u8]) -> Result<(), String>
@ -2574,12 +2608,16 @@ mod tests {
use redis::{aio::ConnectionLike, Cmd, ConnectionAddr, Pipeline, RedisFuture};
struct FakeRedisConnection {
responses: VecDeque<RedisRawValue>,
responses: VecDeque<redis::RedisResult<RedisRawValue>>,
commands: Vec<String>,
}
impl FakeRedisConnection {
fn new(responses: Vec<RedisRawValue>) -> Self {
Self { responses: responses.into_iter().map(Ok).collect(), commands: Vec::new() }
}
fn with_results(responses: Vec<redis::RedisResult<RedisRawValue>>) -> Self {
Self { responses: responses.into(), commands: Vec::new() }
}
@ -2592,8 +2630,8 @@ mod tests {
impl ConnectionLike for FakeRedisConnection {
fn req_packed_command<'a>(&'a mut self, cmd: &'a Cmd) -> RedisFuture<'a, RedisRawValue> {
self.commands.push(String::from_utf8_lossy(&cmd.get_packed_command()).into_owned());
let response = self.responses.pop_front().unwrap_or(RedisRawValue::Nil);
Box::pin(async move { Ok(response) })
let response = self.responses.pop_front().unwrap_or(Ok(RedisRawValue::Nil));
Box::pin(async move { response })
}
fn req_packed_commands<'a>(
@ -2745,6 +2783,85 @@ mod tests {
assert_eq!(redis_key_raw_to_bytes(&encoded).unwrap(), bytes);
}
#[tokio::test]
async fn set_string_uses_keepttl_when_no_ttl_is_specified() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Okay]);
super::set_string(&mut con, b"session", "updated", None).await.unwrap();
assert_eq!(con.commands.len(), 1);
assert!(con.commands[0].contains("\r\nSET\r\n"));
assert!(con.commands[0].contains("\r\nKEEPTTL\r\n"));
}
#[tokio::test]
async fn set_string_with_explicit_no_expiry_uses_plain_set() {
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Okay]);
super::set_string(&mut con, b"settings", "updated", Some(-1)).await.unwrap();
assert_eq!(con.commands.len(), 1);
assert!(con.commands[0].contains("\r\nSET\r\n"));
assert!(!con.commands[0].contains("\r\nKEEPTTL\r\n"));
assert!(!con.commands[0].contains("\r\nEXPIRE\r\n"));
}
#[tokio::test]
async fn set_string_falls_back_to_pttl_and_px_when_keepttl_is_unsupported() {
let unsupported = redis::RedisError::from((
redis::ErrorKind::ResponseError,
"An error was signalled by the server",
"syntax error".to_string(),
));
let mut con = FakeRedisConnection::with_results(vec![
Err(unsupported),
Ok(RedisRawValue::Int(4_200)),
Ok(RedisRawValue::Okay),
]);
super::set_string(&mut con, b"session", "updated", None).await.unwrap();
assert_eq!(con.commands.len(), 3);
assert!(con.commands[0].contains("\r\nKEEPTTL\r\n"));
assert!(con.commands[1].contains("\r\nPTTL\r\n"));
assert!(con.commands[2].contains("\r\nPX\r\n$4\r\n4200\r\n"));
}
#[tokio::test]
async fn set_string_fallback_keeps_persistent_keys_persistent() {
let unsupported = redis::RedisError::from((
redis::ErrorKind::ResponseError,
"An error was signalled by the server",
"syntax error".to_string(),
));
let mut con = FakeRedisConnection::with_results(vec![
Err(unsupported),
Ok(RedisRawValue::Int(-1)),
Ok(RedisRawValue::Okay),
]);
super::set_string(&mut con, b"settings", "updated", None).await.unwrap();
assert_eq!(con.commands.len(), 3);
assert!(!con.commands[2].contains("\r\nPX\r\n"));
assert!(!con.commands[2].contains("\r\nKEEPTTL\r\n"));
}
#[tokio::test]
async fn set_string_does_not_fallback_for_unrelated_errors() {
let read_only = redis::RedisError::from((
redis::ErrorKind::ReadOnly,
"The server is read-only",
"You can't write against a read only replica".to_string(),
));
let mut con = FakeRedisConnection::with_results(vec![Err(read_only)]);
let error = super::set_string(&mut con, b"session", "updated", None).await.unwrap_err();
assert!(error.contains("read-only"));
assert_eq!(con.commands.len(), 1);
}
#[test]
fn parses_scan_response_with_binary_keys() {
let raw = RedisRawValue::Array(vec![