From 0a6c10555abfeb29d9d21403e302cf6c98138015 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 11 Sep 2026 13:26:30 +0800 Subject: [PATCH] fix(redis): tolerate proxy cluster hash field ttl rejections --- .../redis/RedisKeyBrowser.expiry.spec.ts | 10 ++ .../src/components/redis/RedisKeyBrowser.vue | 2 +- crates/dbx-core/src/db/redis_driver.rs | 139 +++++++++++++++++- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 0ec23a2681..45496576a9 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -1241,6 +1241,16 @@ describe("RedisKeyBrowser command console echo", () => { }); describe("RedisKeyBrowser expiry creation", () => { + it("opens the create-key dialog with an empty key name", async () => { + mountBrowser(); + await settle(); + + requiredElement('button[title="redis.createKey"]').click(); + await settle(); + + expect(requiredElement('input[placeholder="redis.createKeyNamePlaceholder"]').value).toBe(""); + }); + it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => { mountBrowser(); await settle(); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index aa818f9759..878804edb7 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -2930,7 +2930,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); - diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 45efb84262..00c1d5ad2b 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -3197,9 +3197,12 @@ where /// source deletion in the same server-side critical section. Hash-field /// expiry commands were introduced after hashes themselves, so the script /// probes them with `pcall` and preserves expiry when the server supports -/// HTTL/HEXPIRE while remaining usable on older Redis versions. Permission -/// errors are surfaced instead of being mistaken for an unsupported command, -/// since silently dropping a field TTL would be data loss. +/// HTTL/HEXPIRE while remaining usable on older Redis versions. Some Redis +/// cluster implementations reject the whole script before Lua can probe those +/// commands, so this also retries with an equivalent script that never +/// references field-expiry commands. Permission errors are surfaced instead +/// of being mistaken for an unsupported command, since silently dropping a +/// field TTL would be data loss. pub async fn hash_field_update( con: &mut C, key: &[u8], @@ -3210,7 +3213,7 @@ pub async fn hash_field_update( where C: ConnectionLike + Send + Sync + Unpin, { - const SCRIPT: &str = r#" + const SCRIPT_WITH_FIELD_TTL: &str = r#" local key = KEYS[1] local old_field = ARGV[1] local new_field = ARGV[2] @@ -3338,8 +3341,76 @@ where return 2 "#; + const SCRIPT_WITHOUT_FIELD_TTL: &str = r#" + local key = KEYS[1] + local old_field = ARGV[1] + local new_field = ARGV[2] + local value = ARGV[3] + + if redis.call('HEXISTS', key, old_field) == 0 then + return 0 + end + if redis.call('HGET', key, old_field) == false then + return 0 + end + if old_field ~= new_field and redis.call('HEXISTS', key, new_field) == 1 then + return -1 + end + + if old_field ~= new_field then + local delete_probe = redis.pcall('HDEL', key, new_field) + if type(delete_probe) == 'table' and delete_probe.err ~= nil then + return -3 + end + end + + local written = redis.pcall('HSET', key, new_field, value) + if type(written) == 'table' and written.err ~= nil then + return -3 + end + + if old_field == new_field then + return 1 + end + + local deleted = redis.pcall('HDEL', key, old_field) + if type(deleted) == 'table' and deleted.err ~= nil then + redis.pcall('HDEL', key, new_field) + return -3 + end + if tonumber(deleted) ~= 1 then + redis.pcall('HDEL', key, new_field) + return 0 + end + return 2 + "#; + + let error = + match execute_hash_field_update_script(con, SCRIPT_WITH_FIELD_TTL, key, old_field, new_field, value).await { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + if !is_hash_field_update_cluster_script_rejection(&error) { + return Err(error); + } + + execute_hash_field_update_script(con, SCRIPT_WITHOUT_FIELD_TTL, key, old_field, new_field, value).await +} + +async fn execute_hash_field_update_script( + con: &mut C, + script: &str, + key: &[u8], + old_field: &str, + new_field: &str, + value: &str, +) -> Result<(), String> +where + C: ConnectionLike + Send + Sync + Unpin, +{ let result = redis::cmd("EVAL") - .arg(SCRIPT) + .arg(script) .arg(1) .arg(key) .arg(old_field) @@ -3383,6 +3454,11 @@ fn is_hash_field_update_acl_compatibility_error(error: &redis::RedisError) -> bo || detail.contains("hexpire") } +fn is_hash_field_update_cluster_script_rejection(error: &str) -> bool { + let detail = error.to_ascii_lowercase(); + detail.contains("bad lua script for redis cluster") && (detail.contains("httl") || detail.contains("hexpire")) +} + pub async fn list_push(con: &mut C, key: &[u8], value: &str, ttl: Option) -> Result<(), String> where C: ConnectionLike + Send + Sync + Unpin, @@ -4107,6 +4183,7 @@ where fn is_optional_hash_field_expiry_error(error: &redis::RedisError) -> bool { let detail = error.detail().unwrap_or_default().to_ascii_lowercase(); detail.contains("unknown command") + || detail.contains("unknown redis command") || detail.contains("unsupported") || detail.contains("syntax error") || detail.contains("noperm") @@ -6351,6 +6428,22 @@ mod tests { assert_eq!(con.command_count("HEXPIRE"), 1); } + #[tokio::test] + async fn hash_set_degrades_when_field_ttl_is_rejected_as_unknown_redis_command() { + let unsupported = redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "An error was signalled by the server", + "unknown redis command HTTL".to_string(), + )); + let mut con = FakeRedisConnection::with_results(vec![Err(unsupported), Ok(RedisRawValue::Okay)]); + + super::hash_set(&mut con, b"hash-key", "session", "Grace", None).await.unwrap(); + + assert_eq!(con.command_count("HTTL"), 1); + assert_eq!(con.command_count("HSET"), 1); + assert_eq!(con.command_count("HEXPIRE"), 0); + } + #[tokio::test] async fn hash_field_update_uses_one_atomic_script_and_carries_field_ttl() { let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(2)]); @@ -6471,6 +6564,42 @@ mod tests { assert_eq!(con.command_count("HDEL"), 0); } + #[tokio::test] + async fn hash_field_update_falls_back_when_cluster_rejects_field_ttl_script() { + let rejection = redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "An error was signalled by the server", + "bad lua script for redis cluster, redis.call/pcall unknown redis command HTTL".to_string(), + )); + let mut con = FakeRedisConnection::with_results(vec![Err(rejection), Ok(RedisRawValue::Int(1))]); + + super::hash_field_update(&mut con, b"hash-key", "session", "session", "Grace").await.unwrap(); + + assert_eq!(con.command_count("EVAL"), 2); + assert!(con.commands[0].contains("redis.pcall('HTTL'")); + assert!(!con.commands[1].contains("redis.pcall('HTTL'")); + assert!(!con.commands[1].contains("redis.pcall('HEXPIRE'")); + assert!(con.commands[1].contains("redis.call('HSET'") || con.commands[1].contains("redis.pcall('HSET'")); + } + + #[tokio::test] + async fn hash_field_update_falls_back_for_renames_when_cluster_rejects_field_ttl_script() { + let rejection = redis::RedisError::from(( + redis::ErrorKind::ResponseError, + "An error was signalled by the server", + "bad lua script for redis cluster, redis.call/pcall unknown redis command HTTL".to_string(), + )); + let mut con = FakeRedisConnection::with_results(vec![Err(rejection), Ok(RedisRawValue::Int(2))]); + + super::hash_field_update(&mut con, b"hash-key", "session", "account", "Grace").await.unwrap(); + + assert_eq!(con.command_count("EVAL"), 2); + assert!(!con.commands[1].contains("redis.pcall('HTTL'")); + assert!(!con.commands[1].contains("redis.pcall('HEXPIRE'")); + assert!(con.commands[1].contains("old_field ~= new_field")); + assert!(con.commands[1].contains("redis.pcall('HDEL', key, old_field)")); + } + #[tokio::test] async fn hash_field_update_reports_restricted_eval_access_without_fallback() { let mut con = FakeRedisConnection::with_results(vec![Err(noperm("eval"))]);