perf(redis): reduce fetch-all rendering overhead

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-07-22 17:45:54 +08:00
1 parent a81d6da604
commit 2fa4ffd074
5 files changed
+117 -35

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue"; import { computed, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue"; import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller"; import { RecycleScroller } from "vue-virtual-scroller";
@@ -34,6 +34,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
import { useToast } from "@/composables/useToast"; import { useToast } from "@/composables/useToast";
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern"; import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp"; import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset"; import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
@@ -66,11 +67,14 @@ const props = defineProps<{
blockDangerousRedisCommands: boolean; blockDangerousRedisCommands: boolean;
}>(); }>();
const flatKeys = ref<RedisKeyInfo[]>([]); const flatKeys = shallowRef<RedisKeyInfo[]>([]);
const treeKeys = ref<RedisKeyTreeNode[]>([]); const treeKeys = ref<RedisKeyTreeNode[]>([]);
const loading = ref(false); const loading = ref(false);
const loadingMore = ref(false); const loadingMore = ref(false);
const searchPending = ref(false);
const isFetchingAll = ref(false); const isFetchingAll = ref(false);
const fetchAllStopRequested = ref(false);
const fetchAllLoadedCount = ref(0);
const rootRef = ref<HTMLElement>(); const rootRef = ref<HTMLElement>();
const commandTerminalRef = ref<HTMLElement>(); const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref(""); const searchPattern = ref("");
@@ -131,19 +135,20 @@ watch(redisKeySeparator, () => {
if (flatKeys.value.length > 0) rebuildTree(false); if (flatKeys.value.length > 0) rebuildTree(false);
}); });
const lastTotalKeys = ref(0); const lastTotalKeys = ref(0);
const displayedKeyCount = computed(() => (isFetchingAll.value ? fetchAllLoadedCount.value : flatKeys.value.length));
const fetchAllProgressText = computed(() => { const fetchAllProgressText = computed(() => {
if (!isFetchingAll.value) return ""; if (!isFetchingAll.value) return "";
if (lastTotalKeys.value > 0) { if (lastTotalKeys.value > 0) {
return t("redis.fetchAllProgress", { loaded: flatKeys.value.length, total: lastTotalKeys.value }); return t("redis.fetchAllProgress", { loaded: displayedKeyCount.value, total: lastTotalKeys.value });
} }
return t("redis.fetchAllProgressUnknown", { loaded: flatKeys.value.length }); return t("redis.fetchAllProgressUnknown", { loaded: displayedKeyCount.value });
}); });
const keyCountText = computed(() => { const keyCountText = computed(() => {
if (loading.value && flatKeys.value.length === 0) return loadingEmptyText.value; if (loading.value && flatKeys.value.length === 0) return loadingEmptyText.value;
if (!isSearchMode.value && lastTotalKeys.value > 0) { if (!isSearchMode.value && lastTotalKeys.value > 0) {
return t("redis.loadedKeys", { loaded: flatKeys.value.length, total: lastTotalKeys.value }); return t("redis.loadedKeys", { loaded: displayedKeyCount.value, total: lastTotalKeys.value });
} }
return t("redis.keys", { count: flatKeys.value.length }); return t("redis.keys", { count: displayedKeyCount.value });
}); });
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null); const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => { const dangerDetails = computed(() => {
@@ -236,13 +241,7 @@ async function updateCreateKeyTypeHelpOffset() {
watch(activeCreateKeyTypeHelp, () => { watch(activeCreateKeyTypeHelp, () => {
void updateCreateKeyTypeHelpOffset(); void updateCreateKeyTypeHelpOffset();
}); });
const visibleRows = computed(() => { const visibleRows = computed(() => (useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value)));
const rows = useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value);
return rows.map((row) => ({
...row,
id: row.node.id,
}));
});
let commandHistoryId = 0; let commandHistoryId = 0;
function countLeaves(node: RedisKeyTreeNode): number { function countLeaves(node: RedisKeyTreeNode): number {
@@ -330,16 +329,14 @@ async function fetchScanBatchPage(maxIterations: number, options: { count?: numb
return api.redisScanKeysBatch(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize, maxIterations, options.includeTypes ?? false); return api.redisScanKeysBatch(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize, maxIterations, options.includeTypes ?? false);
} }
function appendScanResult(result: RedisScanResult, options: { updateTree?: boolean } = {}) { function appendScanResult(result: RedisScanResult, options: { updateTree?: boolean; buffer?: RedisKeyInfo[] } = {}): number {
const newKeys: RedisKeyInfo[] = []; const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws);
for (const key of result.keys) { if (options.buffer) {
if (loadedKeyRaws.has(key.key_raw)) continue; for (const key of newKeys) options.buffer.push(key);
loadedKeyRaws.add(key.key_raw); } else if (newKeys.length > 0) {
newKeys.push(key);
}
if (newKeys.length > 0) {
flatKeys.value = [...flatKeys.value, ...newKeys]; flatKeys.value = [...flatKeys.value, ...newKeys];
} }
const loadedCount = flatKeys.value.length + (options.buffer?.length ?? 0);
scanCursor.value = result.cursor; scanCursor.value = result.cursor;
hasMore.value = result.cursor !== 0; hasMore.value = result.cursor !== 0;
// DBSIZE is only called on the first batch page (cursor==0); subsequent // DBSIZE is only called on the first batch page (cursor==0); subsequent
@@ -362,9 +359,11 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
} }
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length, loaded: isSearchMode.value ? undefined : loadedCount,
total: result.total_keys > 0 || (result.cursor === 0 && result.keys.length === 0) ? result.total_keys : undefined, total: result.total_keys > 0 || (result.cursor === 0 && result.keys.length === 0) ? result.total_keys : undefined,
}); });
return newKeys.length;
} }
async function scanNextPage(requestId = searchRequestId): Promise<boolean> { async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
@@ -383,8 +382,13 @@ async function streamValueSearch(requestId: number) {
async function loadKeys() { async function loadKeys() {
if (!redisBrowserIsActive) return; if (!redisBrowserIsActive) return;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = null;
searchPending.value = false;
const requestId = ++searchRequestId; const requestId = ++searchRequestId;
isFetchingAll.value = false; isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
loading.value = true; loading.value = true;
loadedKeyRaws.clear(); loadedKeyRaws.clear();
flatKeys.value = []; flatKeys.value = [];
@@ -424,33 +428,39 @@ async function loadMore() {
// Fetch-all uses large key-only SCAN pages and rebuilds the tree once at the // Fetch-all uses large key-only SCAN pages and rebuilds the tree once at the
// end; per-page tree sorting dominates runtime on million-key pattern scans. // end; per-page tree sorting dominates runtime on million-key pattern scans.
const FETCH_ALL_SCAN_COUNT = 50000; const FETCH_ALL_SCAN_COUNT = 50000;
const FETCH_ALL_BATCH_ITERATIONS = 1; const FETCH_ALL_BATCH_ITERATIONS = 8;
async function fetchAll() { async function fetchAll() {
if (!hasMore.value || isFetchingAll.value) return; if (!hasMore.value || isFetchingAll.value) return;
const requestId = searchRequestId; const requestId = searchRequestId;
const bufferedKeys: RedisKeyInfo[] = [];
isFetchingAll.value = true; isFetchingAll.value = true;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = flatKeys.value.length;
let changed = false; let changed = false;
try { try {
while (requestId === searchRequestId && isFetchingAll.value && hasMore.value) { while (requestId === searchRequestId && !fetchAllStopRequested.value && hasMore.value) {
const result = await fetchScanBatchPage(FETCH_ALL_BATCH_ITERATIONS, { const result = await fetchScanBatchPage(FETCH_ALL_BATCH_ITERATIONS, {
count: FETCH_ALL_SCAN_COUNT, count: FETCH_ALL_SCAN_COUNT,
includeTypes: false, includeTypes: false,
}); });
if (requestId !== searchRequestId) break; if (requestId !== searchRequestId) break;
appendScanResult(result, { updateTree: false }); changed = appendScanResult(result, { updateTree: false, buffer: bufferedKeys }) > 0 || changed;
changed = true; fetchAllLoadedCount.value = flatKeys.value.length + bufferedKeys.length;
} }
} finally { } finally {
if (requestId === searchRequestId) { if (requestId === searchRequestId) {
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
if (changed && !useFlatKeySearchRows.value) rebuildTree(isSearchMode.value); if (changed && !useFlatKeySearchRows.value) rebuildTree(isSearchMode.value);
isFetchingAll.value = false; isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
} }
} }
} }
function stopFetchAll() { function stopFetchAll() {
isFetchingAll.value = false; fetchAllStopRequested.value = true;
} }
function toggleGroup(groupId: string) { function toggleGroup(groupId: string) {
@@ -553,6 +563,10 @@ function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openCo
} }
function resetLoadedKeys() { function resetLoadedKeys() {
searchRequestId++;
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
loadedKeyRaws.clear(); loadedKeyRaws.clear();
flatKeys.value = []; flatKeys.value = [];
treeKeys.value = []; treeKeys.value = [];
@@ -945,7 +959,10 @@ let hasAutoFocusedSearch = false;
function onSearchInput() { function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(loadKeys, 400); searchPending.value = true;
searchTimer = setTimeout(() => {
void loadKeys();
}, 400);
} }
function setSearchMode(mode: RedisSearchMode) { function setSearchMode(mode: RedisSearchMode) {
@@ -1017,8 +1034,11 @@ function pauseRedisBrowserBackgroundWork() {
redisBrowserIsActive = false; redisBrowserIsActive = false;
searchRequestId++; searchRequestId++;
isFetchingAll.value = false; isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
loading.value = false; loading.value = false;
loadingMore.value = false; loadingMore.value = false;
searchPending.value = false;
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = null; searchTimer = null;
unregisterRedisDbFlushedListener(); unregisterRedisDbFlushedListener();
@@ -1195,7 +1215,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center"> <div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
<template v-if="hasMore"> <template v-if="hasMore">
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span> <span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore" @click="loadMore"> <Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" /> <Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }} {{ t("redis.loadMoreKeys") }}
</Button> </Button>
@@ -1245,11 +1265,11 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</template> </template>
</RecycleScroller> </RecycleScroller>
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5"> <div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading" @click="loadMore"> <Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" /> <Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }} {{ t("redis.loadMoreKeys") }}
</Button> </Button>
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || !hasMore" @click="fetchAll"> <Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || !hasMore" @click="fetchAll">
{{ t("redis.fetchAllKeys") }} {{ t("redis.fetchAllKeys") }}
</Button> </Button>
</div> </div>
@@ -1257,7 +1277,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div class="text-xs text-muted-foreground text-center"> <div class="text-xs text-muted-foreground text-center">
{{ fetchAllProgressText }} {{ fetchAllProgressText }}
</div> </div>
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" @click="stopFetchAll"> <Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested" @click="stopFetchAll">
{{ t("redis.stopFetchAll") }} {{ t("redis.stopFetchAll") }}
</Button> </Button>
</div> </div>
@@ -0,0 +1,13 @@
import type { RedisKeyInfo } from "@/lib/backend/api";
export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<string>): RedisKeyInfo[] {
const uniqueKeys: RedisKeyInfo[] = [];
for (const key of keys) {
if (loadedKeyRaws.has(key.key_raw)) continue;
loadedKeyRaws.add(key.key_raw);
uniqueKeys.push(key);
}
return uniqueKeys;
}
+7 -3
View File
@@ -25,6 +25,7 @@ export interface RedisKeyTreeGroupNode {
export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode; export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode;
export interface RedisKeyTreeRow { export interface RedisKeyTreeRow {
id: string;
node: RedisKeyTreeNode; node: RedisKeyTreeNode;
depth: number; depth: number;
} }
@@ -44,10 +45,12 @@ function buildLeafId(db: number, keyRaw: string): string {
} }
export function redisKeyToFlatTreeRow(key: RedisKeyInfo, db: number): RedisKeyTreeRow { export function redisKeyToFlatTreeRow(key: RedisKeyInfo, db: number): RedisKeyTreeRow {
const nodeId = buildLeafId(db, key.key_raw);
return { return {
id: nodeId,
node: { node: {
kind: "leaf", kind: "leaf",
id: buildLeafId(db, key.key_raw), id: nodeId,
label: key.key_display, label: key.key_display,
fullKeyDisplay: key.key_display, fullKeyDisplay: key.key_display,
keyRaw: key.key_raw, keyRaw: key.key_raw,
@@ -220,7 +223,7 @@ export function flattenVisibleRedisKeyTree(nodes: RedisKeyTreeNode[], expandedGr
const stack: RedisKeyTreeRow[] = []; const stack: RedisKeyTreeRow[] = [];
for (let index = nodes.length - 1; index >= 0; index--) { for (let index = nodes.length - 1; index >= 0; index--) {
stack.push({ node: nodes[index], depth }); stack.push({ id: nodes[index].id, node: nodes[index], depth });
} }
while (stack.length > 0) { while (stack.length > 0) {
@@ -231,7 +234,8 @@ export function flattenVisibleRedisKeyTree(nodes: RedisKeyTreeNode[], expandedGr
const childDepth = row.depth + 1; const childDepth = row.depth + 1;
for (let index = row.node.children.length - 1; index >= 0; index--) { for (let index = row.node.children.length - 1; index >= 0; index--) {
stack.push({ node: row.node.children[index], depth: childDepth }); const child = row.node.children[index];
stack.push({ id: child.id, node: child, depth: childDepth });
} }
} }
+42
View File
@@ -0,0 +1,42 @@
import { test } from "vitest";
import assert from "node:assert/strict";
import { collectUniqueRedisKeys } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts";
import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts";
function makeKey(key: string): RedisKeyInfo {
return { key_display: key, key_raw: key, key_type: "", ttl: -2 };
}
test("collectUniqueRedisKeys filters keys loaded by earlier batches", () => {
const loadedKeyRaws = new Set(["existing"]);
const keys = collectUniqueRedisKeys([makeKey("existing"), makeKey("new-1"), makeKey("new-2")], loadedKeyRaws);
assert.deepEqual(
keys.map((key) => key.key_raw),
["new-1", "new-2"],
);
assert.deepEqual([...loadedKeyRaws], ["existing", "new-1", "new-2"]);
});
test("collectUniqueRedisKeys filters duplicates within one batch", () => {
const loadedKeyRaws = new Set<string>();
const keys = collectUniqueRedisKeys([makeKey("one"), makeKey("one"), makeKey("two")], loadedKeyRaws);
assert.deepEqual(
keys.map((key) => key.key_raw),
["one", "two"],
);
});
test("collectUniqueRedisKeys handles large batches without changing key objects", () => {
const loadedKeyRaws = new Set<string>();
const input = Array.from({ length: 50_000 }, (_, index) => makeKey(`key:${index}`));
const keys = collectUniqueRedisKeys(input, loadedKeyRaws);
assert.equal(keys.length, input.length);
assert.equal(keys[0], input[0]);
assert.equal(keys.at(-1), input.at(-1));
});
+3
View File
@@ -66,6 +66,7 @@ test("collectExpandedGroupIds and flattenVisibleRedisKeyTree expand all search p
rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`), rows.map(({ node, depth }) => `${depth}:${node.kind}:${node.label}`),
["0:group:user", "1:group:profile", "2:leaf:name", "1:leaf:settings"], ["0:group:user", "1:group:profile", "2:leaf:name", "1:leaf:settings"],
); );
assert.ok(rows.every((row) => row.id === row.node.id));
}); });
test("flattenVisibleRedisKeyTree handles very large expanded groups without stack overflow", () => { test("flattenVisibleRedisKeyTree handles very large expanded groups without stack overflow", () => {
@@ -83,12 +84,14 @@ test("flattenVisibleRedisKeyTree handles very large expanded groups without stac
assert.equal(rows[0]?.node.label, "user"); assert.equal(rows[0]?.node.label, "user");
assert.equal(rows[1]?.depth, 1); assert.equal(rows[1]?.depth, 1);
assert.equal(rows.at(-1)?.depth, 1); assert.equal(rows.at(-1)?.depth, 1);
assert.ok(rows.every((row) => row.id === row.node.id));
}); });
test("redisKeyToFlatTreeRow keeps search results flat with the full key label", () => { test("redisKeyToFlatTreeRow keeps search results flat with the full key label", () => {
const row = redisKeyToFlatTreeRow(makeKey("user:profile:1", "user:profile:1", ""), 0); const row = redisKeyToFlatTreeRow(makeKey("user:profile:1", "user:profile:1", ""), 0);
assert.equal(row.depth, 0); assert.equal(row.depth, 0);
assert.equal(row.id, row.node.id);
assert.equal(row.node.kind, "leaf"); assert.equal(row.node.kind, "leaf");
if (row.node.kind !== "leaf") return; if (row.node.kind !== "leaf") return;
assert.equal(row.node.label, "user:profile:1"); assert.equal(row.node.label, "user:profile:1");