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
This commit is contained in:
haipengno1 2026-06-17 00:24:47 +08:00 committed by GitHub
parent 3752c9086e
commit 3695042c8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 0 deletions

View File

@ -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) {

View File

@ -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;
}

View File

@ -2791,6 +2791,7 @@ export const useConnectionStore = defineStore("connection", () => {
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listRedisCompletionKeys,
invalidateCompletionCache,
exportConnectionsToFile,
readImportFile,
importConnectionsFromFile,

View File

@ -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 });
}

View File

@ -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");
});