From 3695042c8e4ac9fc00d96d9ef78968d313ac3073 Mon Sep 17 00:00:00 2001 From: haipengno1 Date: Wed, 17 Jun 2026 00:24:47 +0800 Subject: [PATCH] fix(redis): invalidate key completion cache after write commands The Redis key-name completion cache was only cleared on connection lifecycle events, so SET/DEL/EXPIRE/... left autocomplete showing stale keys. Drop the cache for the db each write command ran on: - add isRedisMutatingCommand() based on the command-table safety field (confirm = write; destructive blocked cmds minus read-only/admin ones) - invalidate after each mutating command in queryStore batch execution and in RedisKeyBrowser.runRedisCommand --- .../src/components/redis/RedisKeyBrowser.vue | 8 ++ apps/desktop/src/lib/redisCommandTable.ts | 78 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 1 + apps/desktop/src/stores/queryStore.ts | 6 ++ packages/app-tests/redisCommandTable.test.ts | 60 ++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 packages/app-tests/redisCommandTable.test.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index f7995f703..50c63c4b9 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -23,6 +23,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, type RedisKeyTreeNode } from "@/lib/redisKeyTree"; import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety"; +import { isRedisMutatingCommand } from "@/lib/redisCommandTable"; import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redisCommandSession"; import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation"; import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts"; @@ -436,10 +437,17 @@ async function runRedisCommand(command: string) { output: formatRedisCommandResult(result.value), error: false, }); + // The db this command ran on — capture before nextRedisCommandDb() advances it. + const executedDb = commandDb.value; commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value); if (result.safety === "confirm") { await loadKeys(); } + // Drop the cached key-name completion for this db so the editor's autocomplete + // reflects keys added/removed/renamed by SET/DEL/RENAME/... + if (isRedisMutatingCommand(command)) { + connectionStore.invalidateCompletionCache(props.connectionId, String(executedDb)); + } // Persist to history persistRedisHistory(command, true, result.value); } catch (error) { diff --git a/apps/desktop/src/lib/redisCommandTable.ts b/apps/desktop/src/lib/redisCommandTable.ts index 0a9605d7c..e4cd55ed9 100644 --- a/apps/desktop/src/lib/redisCommandTable.ts +++ b/apps/desktop/src/lib/redisCommandTable.ts @@ -383,3 +383,81 @@ export function resolveRedisCommandSpec(argvUpper: readonly string[]): RedisComm } return REDIS_COMMAND_TABLE[main]; } + +/** + * Minimal argv tokenizer for the first two tokens of a Redis command line — + * enough to resolve a spec (which only needs the command head, optionally a + * subcommand). Single-quoted, double-quoted and unquoted tokens are supported + * with backslash escapes; quoting errors are tolerated (the token still resolves). + * Kept local so this module stays self-contained (no dependency on the syntax + * diagnostics tokenizer). + */ +function firstRedisArgvUpper(line: string): string[] { + const tokens: string[] = []; + let i = 0; + const n = line.length; + while (i < n && tokens.length < 2) { + while (i < n && /\s/.test(line[i])) i++; + if (i >= n) break; + const quote = line[i] === '"' || line[i] === "'" ? line[i] : ""; + let token = ""; + let escaping = false; + if (quote) i++; + while (i < n) { + const ch = line[i]; + if (escaping) { + token += ch; + escaping = false; + i++; + continue; + } + if (ch === "\\") { + escaping = true; + i++; + continue; + } + if (quote && ch === quote) { + i++; + break; + } + if (!quote && /\s/.test(ch)) break; + token += ch; + i++; + } + tokens.push(token.toUpperCase()); + } + return tokens; +} + +// Commands marked `safety: "blocked"` because they are dangerous/administrative, +// but which do NOT actually change the key set of the current db — so they should +// NOT trigger key-name completion cache invalidation. (e.g. KEYS is read-only; +// SAVE/BGSAVE/SHUTDOWN/REPLICAOF/SLAVEOF are server admin.) Everything else in the +// `blocked` set (FLUSHALL, MIGRATE, EVAL[ESHA]) may mutate keys and is left in. +const NON_MUTATING_BLOCKED = new Set(["KEYS", "BGSAVE", "SAVE", "SHUTDOWN", "REPLICAOF", "SLAVEOF"]); + +/** + * True when a command may change the key set of the db it ran on (adds/edits/ + * removes/renames keys, or wipes the db), and therefore the cached key-name + * completion for that db is potentially stale and should be dropped. + * + * Mapping from the diagnostic `safety` field: + * - `confirm` → every write command mutates keys (SET/DEL/INCR/HSET/...). + * - `blocked` → mostly destructive/admin; we keep the ones that may touch keys + * (FLUSHALL, MIGRATE, EVAL[ESHA]) and exclude the read-only/admin + * ones in `NON_MUTATING_BLOCKED`. + * - `allowed` → read-only (GET/LRANGE/SCAN/...), never invalidates. + * + * Commands absent from the table (unknown or read-only extensions) are treated as + * non-mutating so we never thrash the cache on lookups. + */ +export function isRedisMutatingCommand(command: string): boolean { + const argv = firstRedisArgvUpper(command); + const spec = resolveRedisCommandSpec(argv); + if (!spec) return false; + if (spec.safety === "confirm") return true; + if (spec.safety === "blocked") { + return !NON_MUTATING_BLOCKED.has(argv[0]); + } + return false; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 4ded0cfe4..878705014 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2791,6 +2791,7 @@ export const useConnectionStore = defineStore("connection", () => { refreshCompletionDatabases, listElasticsearchCompletionIndices, listRedisCompletionKeys, + invalidateCompletionCache, exportConnectionsToFile, readImportFile, importConnectionsFromFile, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 65408ebde..8ca46aa19 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -24,6 +24,7 @@ import { } from "@/lib/mongoShellCommand"; import { redisCommandResultToQueryResult } from "@/lib/redisQueryResult"; import { nextRedisCommandDb } from "@/lib/redisCommandSession"; +import { isRedisMutatingCommand } from "@/lib/redisCommandTable"; import { supportsDatabaseFeature } from "@/lib/databaseCapabilities"; import { editablePrimaryKeys } from "@/lib/tableEditing"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport"; @@ -1202,6 +1203,11 @@ export const useQueryStore = defineStore("query", () => { allResults.push(markQueryResultRowsRaw(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, result.command))); // Track db switches from SELECT N so later commands in the same batch run on the right db. currentDb = nextRedisCommandDb(currentDb, command, result.value); + // Write commands (SET/DEL/...) mutate the key set — drop the cached key-name completion + // for the db this command ran on so the next autocomplete fetch reflects the new keys. + if (isRedisMutatingCommand(command)) { + connStore.invalidateCompletionCache(tab.connectionId, String(currentDb)); + } } catch (e: any) { allResults.push({ columns: ["Error"], rows: [[e?.message ?? String(e)]], affected_rows: 0, execution_time_ms: 0 }); } diff --git a/packages/app-tests/redisCommandTable.test.ts b/packages/app-tests/redisCommandTable.test.ts new file mode 100644 index 000000000..5d1a72a7f --- /dev/null +++ b/packages/app-tests/redisCommandTable.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { isRedisMutatingCommand, resolveRedisCommandSpec } from "../../apps/desktop/src/lib/redisCommandTable.ts"; + +test("write commands are flagged as mutating", () => { + assert.equal(isRedisMutatingCommand("SET foo bar"), true); + assert.equal(isRedisMutatingCommand("DEL foo"), true); + assert.equal(isRedisMutatingCommand("HSET h k v"), true); + assert.equal(isRedisMutatingCommand("LPUSH list a"), true); + assert.equal(isRedisMutatingCommand("INCR counter"), true); + assert.equal(isRedisMutatingCommand("EXPIRE foo 60"), true); + assert.equal(isRedisMutatingCommand("RENAME a b"), true); +}); + +test("subcommand mutations are detected via MAIN SUB spec", () => { + assert.equal(isRedisMutatingCommand("XGROUP CREATE s g 0"), true); + assert.equal(isRedisMutatingCommand("XADD stream * field value"), true); + assert.equal(isRedisMutatingCommand("CLUSTER RESET HARD"), true); +}); + +test("destructive/blocked commands are flagged as mutating", () => { + assert.equal(isRedisMutatingCommand("FLUSHDB"), true); + assert.equal(isRedisMutatingCommand("FLUSHALL"), true); +}); + +test("read-only commands are not mutating", () => { + assert.equal(isRedisMutatingCommand("GET foo"), false); + assert.equal(isRedisMutatingCommand("LRANGE list 0 -1"), false); + assert.equal(isRedisMutatingCommand("HGETALL h"), false); + assert.equal(isRedisMutatingCommand("KEYS *"), false); + assert.equal(isRedisMutatingCommand("TYPE foo"), false); + assert.equal(isRedisMutatingCommand("SCAN 0"), false); + assert.equal(isRedisMutatingCommand("SELECT 1"), false); + assert.equal(isRedisMutatingCommand("INFO"), false); +}); + +test("read-only subcommands are not mutating", () => { + // XLEN is a read on a stream; XINFO ... is read + assert.equal(isRedisMutatingCommand("XLEN stream"), false); +}); + +test("case-insensitive and quoted command tokens", () => { + assert.equal(isRedisMutatingCommand("set foo bar"), true); + assert.equal(isRedisMutatingCommand("del foo"), true); + assert.equal(isRedisMutatingCommand('get "weird key"'), false); +}); + +test("unknown / empty commands are treated as non-mutating (no cache thrash)", () => { + assert.equal(isRedisMutatingCommand(""), false); + assert.equal(isRedisMutatingCommand("NOTACMD x y"), false); +}); + +test("resolveRedisCommandSpec resolves subcommand then main", () => { + const sub = resolveRedisCommandSpec(["XGROUP", "CREATE"]); + assert.ok(sub); + assert.equal(sub?.safety, "confirm"); + const main = resolveRedisCommandSpec(["GET"]); + assert.ok(main); + assert.equal(main?.group, "string"); +});