feat(redis): add TTL metadata support
This commit is contained in:
1 parent
680358aff8
commit
39ce50b681
15 files changed
+551
-140
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user