feat(redis): add TTL metadata support

This commit is contained in:
dadong authored and GitHub committed 2026-08-20 01:51:11 +08:00
1 parent 680358aff8
commit 39ce50b681
15 files changed
+551 -140

No files matched your search

+109 -9
View File
@@ -2074,8 +2074,8 @@ where
/// Batch-scan keys with server-side multi-SCAN support.
///
/// Performs up to `max_iterations` SCAN cycles in a single call. TYPE metadata
/// is optional so large key-name searches can avoid extra Redis work.
/// Performs up to `max_iterations` SCAN cycles in a single call. TYPE and TTL
/// metadata is optional so large key-name searches can avoid extra Redis work.
/// DBSIZE is only called on the first iteration (cursor == 0).
pub async fn scan_keys_batch<C>(
con: &mut C,
@@ -2116,6 +2116,13 @@ where
} else {
String::new()
};
// 精确命中单个 key 时顺带查询一次 TTL(O(1) 命令),
// 让前端列表行无需再点开 key 就能看到过期信息。
let key_ttl: i64 = if include_types {
redis::cmd("TTL").arg(pattern).query_async(con).await.unwrap_or(-2)
} else {
-2
};
let value_preview = if include_types { redis_key_value_preview(&key_type) } else { String::new() };
@@ -2123,7 +2130,7 @@ where
key_display: redis_key_bytes_to_display(pattern.as_bytes()),
key_raw: redis_key_bytes_to_raw(pattern.as_bytes()),
key_type,
ttl: -2,
ttl: key_ttl,
size: 0,
value_preview,
};
@@ -2154,14 +2161,28 @@ where
let (next_cursor, keys) = parse_scan_keys(raw)?;
if !keys.is_empty() {
let key_types: Vec<String> = if include_types {
let mut pipe = redis::pipe();
let (key_types, key_ttls): (Vec<String>, Vec<i64>) = if include_types {
let mut type_pipe = redis::pipe();
for key in &keys {
pipe.cmd("TYPE").arg(key);
type_pipe.cmd("TYPE").arg(key);
}
pipe.query_async(con).await.unwrap_or_default()
let type_count = type_pipe.len();
let type_values = con.req_packed_commands(&type_pipe, 0, type_count).await.unwrap_or_default();
let key_types = type_values
.iter()
.map(|value| String::from_redis_value(value).unwrap_or_else(|_| "unknown".to_string()))
.collect();
let mut ttl_pipe = redis::pipe();
for key in &keys {
ttl_pipe.cmd("TTL").arg(key);
}
let ttl_count = ttl_pipe.len();
let ttl_values = con.req_packed_commands(&ttl_pipe, 0, ttl_count).await.unwrap_or_default();
let key_ttls = ttl_values.iter().map(|value| i64::from_redis_value(value).unwrap_or(-2)).collect();
(key_types, key_ttls)
} else {
Vec::new()
(Vec::new(), Vec::new())
};
for (index, key) in keys.iter().enumerate() {
@@ -2179,7 +2200,7 @@ where
key_display: redis_key_bytes_to_display(key),
key_raw: redis_key_bytes_to_raw(key),
key_type,
ttl: -2,
ttl: key_ttls.get(index).copied().unwrap_or(-2),
size: 0,
value_preview,
});
@@ -4360,6 +4381,85 @@ mod tests {
assert_eq!(con.command_count("SCAN"), 2);
}
#[tokio::test]
async fn scan_keys_batch_batches_type_and_ttl_metadata() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Int(2),
scan_response("0", vec!["session:a", "cache:b"]),
RedisRawValue::SimpleString("string".to_string()),
RedisRawValue::SimpleString("hash".to_string()),
RedisRawValue::Int(-1),
RedisRawValue::Int(3600),
]);
let result = super::scan_keys_batch(&mut con, 0, "*", 100, 1, true).await.unwrap();
assert_eq!(result.keys.len(), 2);
assert_eq!(result.keys[0].key_type, "string");
assert_eq!(result.keys[0].ttl, -1);
assert_eq!(result.keys[1].key_type, "hash");
assert_eq!(result.keys[1].ttl, 3600);
let type_pipeline = con.commands.iter().find(|packed| packed.contains("\r\nTYPE\r\n")).unwrap();
let ttl_pipeline = con.commands.iter().find(|packed| packed.contains("\r\nTTL\r\n")).unwrap();
assert_eq!(type_pipeline.matches("\r\nTYPE\r\n").count(), 2);
assert_eq!(type_pipeline.matches("\r\nTTL\r\n").count(), 0);
assert_eq!(ttl_pipeline.matches("\r\nTYPE\r\n").count(), 0);
assert_eq!(ttl_pipeline.matches("\r\nTTL\r\n").count(), 2);
}
#[tokio::test]
async fn scan_keys_batch_preserves_types_when_one_ttl_command_fails() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Int(2),
scan_response("0", vec!["session:a", "cache:b"]),
RedisRawValue::SimpleString("string".to_string()),
RedisRawValue::SimpleString("hash".to_string()),
redis::parse_redis_value(b"-NOPERM this user has no permissions to run the 'ttl' command\r\n").unwrap(),
RedisRawValue::Int(3600),
]);
let result = super::scan_keys_batch(&mut con, 0, "*", 100, 1, true).await.unwrap();
assert_eq!(result.keys.len(), 2);
assert_eq!(result.keys[0].key_type, "string");
assert_eq!(result.keys[0].ttl, -2);
assert_eq!(result.keys[1].key_type, "hash");
assert_eq!(result.keys[1].ttl, 3600);
}
#[tokio::test]
async fn scan_keys_batch_skips_type_and_ttl_when_metadata_disabled() {
// include_types=false 是“加载全部”的百万 key 链路,不能多发出任何 TYPE/TTL 命令
let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1), scan_response("0", vec!["cache:b"])]);
let result = super::scan_keys_batch(&mut con, 0, "*", 100, 1, false).await.unwrap();
assert_eq!(result.keys.len(), 1);
assert_eq!(result.keys[0].key_type, "");
assert_eq!(result.keys[0].ttl, -2);
assert_eq!(con.command_count("TYPE"), 0);
assert_eq!(con.command_count("TTL"), 0);
}
#[tokio::test]
async fn scan_keys_batch_exact_match_returns_ttl() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Int(1),
RedisRawValue::Int(1),
RedisRawValue::SimpleString("string".to_string()),
RedisRawValue::Int(-1),
]);
let result = super::scan_keys_batch(&mut con, 0, "session:a", 100, 1, true).await.unwrap();
assert_eq!(result.keys.len(), 1);
assert_eq!(result.keys[0].key_display, "session:a");
assert_eq!(result.keys[0].key_type, "string");
assert_eq!(result.keys[0].ttl, -1);
assert_eq!(con.command_count("EXISTS"), 1);
assert_eq!(con.command_count("TTL"), 1);
}
#[tokio::test]
async fn cluster_key_batch_reuses_node_connections_across_scan_iterations() {
let sessions = tokio::sync::Mutex::new(super::RedisClusterScanSessions::default());
+1 -1
View File
@@ -42,7 +42,7 @@ pub async fn redis_scan_keys_core(
///
/// Performs up to `max_iterations` SCAN cycles server-side in a single API
/// call, dramatically reducing frontend↔backend roundtrips when fetching many
/// keys (e.g. "fetch all" in the key browser). TYPE metadata is optional.
/// keys (e.g. "fetch all" in the key browser). TYPE and TTL metadata is optional.
pub async fn redis_scan_keys_batch_core(
state: &AppState,
connection_id: &str,