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:
1 parent
3752c9086e
commit
3695042c8e
5 files changed
+153
No files matched your search
@@ -23,6 +23,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, type RedisKeyTreeNode } from "@/lib/redisKeyTree";
|
import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, type RedisKeyTreeNode } from "@/lib/redisKeyTree";
|
||||||
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
|
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
|
||||||
|
import { isRedisMutatingCommand } from "@/lib/redisCommandTable";
|
||||||
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redisCommandSession";
|
import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redisCommandSession";
|
||||||
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
|
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
|
||||||
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
|
||||||
@@ -436,10 +437,17 @@ async function runRedisCommand(command: string) {
|
|||||||
output: formatRedisCommandResult(result.value),
|
output: formatRedisCommandResult(result.value),
|
||||||
error: false,
|
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);
|
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
|
||||||
if (result.safety === "confirm") {
|
if (result.safety === "confirm") {
|
||||||
await loadKeys();
|
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
|
// Persist to history
|
||||||
persistRedisHistory(command, true, result.value);
|
persistRedisHistory(command, true, result.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -383,3 +383,81 @@ export function resolveRedisCommandSpec(argvUpper: readonly string[]): RedisComm
|
|||||||
}
|
}
|
||||||
return REDIS_COMMAND_TABLE[main];
|
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,
|
refreshCompletionDatabases,
|
||||||
listElasticsearchCompletionIndices,
|
listElasticsearchCompletionIndices,
|
||||||
listRedisCompletionKeys,
|
listRedisCompletionKeys,
|
||||||
|
invalidateCompletionCache,
|
||||||
exportConnectionsToFile,
|
exportConnectionsToFile,
|
||||||
readImportFile,
|
readImportFile,
|
||||||
importConnectionsFromFile,
|
importConnectionsFromFile,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/lib/mongoShellCommand";
|
} from "@/lib/mongoShellCommand";
|
||||||
import { redisCommandResultToQueryResult } from "@/lib/redisQueryResult";
|
import { redisCommandResultToQueryResult } from "@/lib/redisQueryResult";
|
||||||
import { nextRedisCommandDb } from "@/lib/redisCommandSession";
|
import { nextRedisCommandDb } from "@/lib/redisCommandSession";
|
||||||
|
import { isRedisMutatingCommand } from "@/lib/redisCommandTable";
|
||||||
import { supportsDatabaseFeature } from "@/lib/databaseCapabilities";
|
import { supportsDatabaseFeature } from "@/lib/databaseCapabilities";
|
||||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||||
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport";
|
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)));
|
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.
|
// Track db switches from SELECT N so later commands in the same batch run on the right db.
|
||||||
currentDb = nextRedisCommandDb(currentDb, command, result.value);
|
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) {
|
} catch (e: any) {
|
||||||
allResults.push({ columns: ["Error"], rows: [[e?.message ?? String(e)]], affected_rows: 0, execution_time_ms: 0 });
|
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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user