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:
parent
3752c9086e
commit
3695042c8e
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2791,6 +2791,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
refreshCompletionDatabases,
|
||||
listElasticsearchCompletionIndices,
|
||||
listRedisCompletionKeys,
|
||||
invalidateCompletionCache,
|
||||
exportConnectionsToFile,
|
||||
readImportFile,
|
||||
importConnectionsFromFile,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue