From c9c36a84531802418280628f03888e1bd33dab83 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Fri, 4 Sep 2026 20:23:27 +0800 Subject: [PATCH] perf(redis): avoid rebuilding key tree on detail load --- .../redis/RedisKeyBrowser.expiry.spec.ts | 108 ++++++++++++++++- .../src/components/redis/RedisKeyBrowser.vue | 111 ++++++++++++------ apps/desktop/src/lib/redis/redisKeyTree.ts | 44 ++++++- packages/app-tests/redisKeyTree.test.ts | 101 +++++++++++++++- 4 files changed, 321 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 64e42169f2..c0ee00ea51 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000), + createRedisKeyTreeIndex: vi.fn(), + flattenVisibleRedisKeyTree: vi.fn(), toast: vi.fn(), updateRedisDbKeyStats: vi.fn(), listRedisCompletionCommandDocs: vi.fn(), @@ -33,6 +35,7 @@ const mocks = vi.hoisted(() => ({ infiniteScroll: false, queryResultMaxRowsEnabled: true, queryResultMaxRows: 5000, + loadedTtl: -1, })); vi.mock("@/lib/backend/api", () => ({ @@ -56,7 +59,14 @@ vi.mock("@/lib/backend/api", () => ({ vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree }; + mocks.createRedisKeyTreeIndex.mockImplementation(actual.createRedisKeyTreeIndex); + mocks.flattenVisibleRedisKeyTree.mockImplementation(actual.flattenVisibleRedisKeyTree); + return { + ...actual, + canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree, + createRedisKeyTreeIndex: mocks.createRedisKeyTreeIndex, + flattenVisibleRedisKeyTree: mocks.flattenVisibleRedisKeyTree, + }; }); vi.mock("@/stores/connectionStore", () => ({ @@ -287,7 +297,26 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => { vi.mock("./RedisValueViewer.vue", async () => { const { defineComponent, h } = await import("vue"); - return { default: defineComponent({ setup: () => () => h("div") }) }; + return { + default: defineComponent({ + props: { keyDisplay: String, keyRaw: String }, + emits: ["loaded"], + setup(props, { emit }) { + return () => + h("button", { + "data-test-emit-key-loaded": "", + onClick: () => + emit("loaded", { + key_display: props.keyDisplay, + key_raw: props.keyRaw, + ttl: mocks.loadedTtl, + redis_type: "hash", + data: { kind: "hash", items: [] }, + }), + }); + }, + }), + }; }); vi.mock("./RedisPubSubPanel.vue", async () => { @@ -379,6 +408,7 @@ function resetApiMocks() { mocks.infiniteScroll = false; mocks.queryResultMaxRowsEnabled = true; mocks.queryResultMaxRows = 5000; + mocks.loadedTtl = -1; mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw))); mocks.redisSetString.mockResolvedValue(undefined); @@ -673,6 +703,80 @@ describe("RedisKeyBrowser scope changes", () => { }); describe("RedisKeyBrowser TTL list badges and no-expiry filter", () => { + it("refreshes one key's metadata without rebuilding the loaded tree", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [{ ...redisKeyInfo("string"), ttl: -1 }], + total_keys: 1, + }); + mountBrowser(); + await settle(); + + const keyCheckbox = requiredElement(`[data-redis-leaf="${KEY_RAW}"]`); + const keyRow = keyCheckbox.parentElement?.parentElement?.parentElement; + expect(keyRow).toBeInstanceOf(HTMLElement); + keyRow?.click(); + await settle(); + + const buildsBeforeDetailLoaded = mocks.createRedisKeyTreeIndex.mock.calls.length; + const flattensBeforeDetailLoaded = mocks.flattenVisibleRedisKeyTree.mock.calls.length; + requiredElement("[data-test-emit-key-loaded]").click(); + await settle(); + + expect(mocks.createRedisKeyTreeIndex).toHaveBeenCalledTimes(buildsBeforeDetailLoaded); + expect(mocks.flattenVisibleRedisKeyTree).toHaveBeenCalledTimes(flattensBeforeDetailLoaded); + expect(keyRow?.textContent).toContain("hash"); + expect(keyRow?.textContent).toContain("redis.noExpiry"); + }); + + it("refreshes no-expiry membership when detail metadata changes TTL", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [redisKeyInfo("string")], + total_keys: 1, + }); + mountBrowser(); + await settle(); + + requiredElement(`[data-redis-leaf="${KEY_RAW}"]`).closest(".group")?.click(); + await settle(); + requiredElement("[data-redis-no-expiry-filter]").click(); + await settle(); + expect(document.body.textContent).toContain("redis.noExpiryKeysEmpty"); + + requiredElement("[data-test-emit-key-loaded]").click(); + await settle(); + + expect(document.querySelector(`[data-redis-leaf="${KEY_RAW}"]`)).not.toBeNull(); + expect(document.body.textContent).toContain("redis.noExpiry"); + }); + + it("starts the local TTL countdown when detail metadata gains an expiry", async () => { + vi.useFakeTimers(); + try { + mocks.redisScanKeysBatch.mockResolvedValue({ + cursor: 0, + keys: [{ ...redisKeyInfo("string"), ttl: -1 }], + total_keys: 1, + }); + mountBrowser(); + await settle(); + + requiredElement(`[data-redis-leaf="${KEY_RAW}"]`).closest(".group")?.click(); + await settle(); + mocks.loadedTtl = 2; + requiredElement("[data-test-emit-key-loaded]").click(); + await settle(); + expect(document.body.textContent).toContain("redis.ttlSecond"); + + await vi.advanceTimersByTimeAsync(3000); + await settle(); + expect(document.body.textContent).toContain("redis.expired"); + } finally { + vi.useRealTimers(); + } + }); + it("renders TTL badges per row and filters rows to keys without expiry", async () => { mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 1fb9a71122..215775873f 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -37,6 +37,8 @@ import { flattenVisibleRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, + updateRedisKeyInfoMetadataByRaw, + updateRedisKeyTreeLeafMetadata, type RedisKeyTreeGroupNode, type RedisKeyTreeIndex, type RedisKeyTreeNode, @@ -99,6 +101,7 @@ const redisExpiryTransport = { const flatKeys = shallowRef([]); const treeKeys = shallowRef([]); +const flatKeyByRaw = new Map(); const loading = ref(false); const loadingMore = ref(false); const searchPending = ref(false); @@ -124,6 +127,10 @@ const expandedGroupIds = ref>(new Set()); const checkedKeys = ref>(new Set()); /** Bumped when selection or tree counts change so virtualized rows re-evaluate state. */ const selectionEpoch = ref(0); +/** Bumped when mutable metadata changes; it must never invalidate visibleRows. */ +const keyMetadataEpoch = ref(0); +/** Bumped only when a TTL refresh changes membership in the no-expiry projection. */ +const noExpiryProjectionEpoch = ref(0); const selectionAnchorRowId = ref(null); const selectedGroupLeafCounts = shallowRef>(new Map()); const deletingKeys = ref(false); @@ -252,7 +259,11 @@ const lastTotalKeys = ref(0); // TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。 const noExpiryOnly = ref(false); // 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销 -const filteredFlatKeys = computed(() => (noExpiryOnly.value ? flatKeys.value.filter((key) => key.ttl === -1) : flatKeys.value)); +const filteredFlatKeys = computed(() => { + if (!noExpiryOnly.value) return flatKeys.value; + void noExpiryProjectionEpoch.value; + return flatKeys.value.filter((key) => key.ttl === -1); +}); // 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准; // 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用 const filteredTreeKeys = computed(() => { @@ -280,7 +291,10 @@ const keyCountText = computed(() => { const count = isSearchMode.value && hasMore.value && !isFetchingAll.value ? `${displayedKeyCount.value}+` : displayedKeyCount.value; return t("redis.keys", { count }); }); -const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null); +const selectedKey = computed(() => { + void keyMetadataEpoch.value; + return selectedKeyRaw.value ? (flatKeyByRaw.get(selectedKeyRaw.value) ?? null) : null; +}); const dangerDetails = computed(() => { if (!pendingDanger.value) return ""; if (pendingDanger.value.kind === "delete-keys") { @@ -374,6 +388,19 @@ watch(activeCreateKeyTypeHelp, () => { const visibleRows = computed(() => { return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value); }); + +function redisRowKeyInfo(node: RedisKeyTreeNode): RedisKeyInfo | null { + void keyMetadataEpoch.value; + return node.kind === "leaf" ? (flatKeyByRaw.get(node.keyRaw) ?? null) : null; +} + +function redisRowKeyType(node: RedisKeyTreeNode): string { + return redisRowKeyInfo(node)?.key_type ?? ""; +} + +function redisRowTtl(node: RedisKeyTreeNode): number { + return redisRowKeyInfo(node)?.ttl ?? -2; +} // 列表行的 TTL 徽标文案:-1 表示永不过期,展示本地化文案; // 大于 0 时展示本地倒计时后的剩余时间,倒计时归零展示已过期;其余(-2 未查询)不显示 function redisTtlBadgeText(ttl: number, displayTtl: number): string | null { @@ -390,6 +417,7 @@ function redisTtlBadgeClass(ttl: number, displayTtl: number): string { // 记录每个 key 的 TTL 被观测到的时刻(毫秒);本地倒计时 = 观测时的 TTL - 已流逝时间, // 与右侧详情面板同源(computeTtlCountdownValue),不需要额外的网络请求 const ttlObservedAtByRaw = new Map(); +const positiveTtlKeyRaws = new Set(); // 驱动列表行 TTL 倒计时的当前时刻,仅在存在需要倒计时的 key 时每秒更新 const listTtlNowMs = ref(Date.now()); let listTtlTimer: ReturnType | null = null; @@ -399,11 +427,25 @@ function recordKeyTtlObservedAt(key: RedisKeyInfo) { const ttl = key.ttl ?? -2; if (ttl > 0) { ttlObservedAtByRaw.set(key.key_raw, Date.now()); + positiveTtlKeyRaws.add(key.key_raw); } else { ttlObservedAtByRaw.delete(key.key_raw); + positiveTtlKeyRaws.delete(key.key_raw); } } +function appendFlatKeyRecords(keys: readonly RedisKeyInfo[]) { + if (keys.length === 0) return; + for (const key of keys) flatKeyByRaw.set(key.key_raw, key); + flatKeys.value = [...flatKeys.value, ...keys]; +} + +function replaceFlatKeyRecords(keys: RedisKeyInfo[]) { + flatKeyByRaw.clear(); + for (const key of keys) flatKeyByRaw.set(key.key_raw, key); + flatKeys.value = keys; +} + // 列表行展示的 TTL:正 TTL 按观测时刻到当前时刻的流逝本地递减;-1/-2 原样透传 function redisRowDisplayTtl(ttl: number, keyRaw: string): number { if (ttl <= 0) return ttl; @@ -413,7 +455,7 @@ function redisRowDisplayTtl(ttl: number, keyRaw: string): number { // 按需启停倒计时定时器:只在组件激活且存在正 TTL 的 key 时运行,避免空转 function syncListTtlTimer() { - const needed = redisBrowserIsActive && flatKeys.value.some((key) => (key.ttl ?? -2) > 0); + const needed = redisBrowserIsActive && positiveTtlKeyRaws.size > 0; if (needed && !listTtlTimer) { listTtlNowMs.value = Date.now(); listTtlTimer = setInterval(() => { @@ -597,7 +639,7 @@ function rebuildTree(expandAll = false) { expandedGroupIds.value = nextExpanded; refreshSelectedGroupLeafCounts(); - if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) { + if (selectedKeyRaw.value && !flatKeyByRaw.has(selectedKeyRaw.value)) { selectedKeyRaw.value = null; } } @@ -704,7 +746,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole if (options.buffer) { for (const key of newKeys) options.buffer.push(key); } else if (newKeys.length > 0) { - flatKeys.value = [...flatKeys.value, ...newKeys]; + appendFlatKeyRecords(newKeys); } const loadedCount = flatKeys.value.length + (options.buffer?.length ?? 0); scanCursor.value = result.cursor; @@ -766,8 +808,9 @@ async function loadKeys() { loading.value = true; loadedKeyRaws.clear(); ttlObservedAtByRaw.clear(); + positiveTtlKeyRaws.clear(); subtreeFilledGroupIds.clear(); - flatKeys.value = []; + replaceFlatKeyRecords([]); treeKeys.value = []; treeIndex = null; selectedKeyRaw.value = null; @@ -918,7 +961,7 @@ async function fetchAll(): Promise { completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value; } finally { if (requestId === searchRequestId) { - if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys]; + appendFlatKeyRecords(bufferedKeys); if (changed) { if (useFlatKeySearchRows.value) { treeKeys.value = []; @@ -953,7 +996,7 @@ function shouldFillGroupSubtree(): boolean { function mergeScannedKeys(newKeys: RedisKeyInfo[]) { if (newKeys.length === 0) return; for (const key of newKeys) recordKeyTtlObservedAt(key); - flatKeys.value = [...flatKeys.value, ...newKeys]; + appendFlatKeyRecords(newKeys); mergeTree(newKeys); connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { loaded: isSearchMode.value ? undefined : flatKeys.value.length, @@ -1014,10 +1057,11 @@ function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) { } function removeKnownKey(keyRaw: string) { - if (!flatKeys.value.some((key) => key.key_raw === keyRaw)) return; + if (!flatKeyByRaw.has(keyRaw)) return; loadedKeyRaws.delete(keyRaw); ttlObservedAtByRaw.delete(keyRaw); - flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw); + positiveTtlKeyRaws.delete(keyRaw); + replaceFlatKeyRecords(flatKeys.value.filter((key) => key.key_raw !== keyRaw)); if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null; if (useFlatKeySearchRows.value) { treeKeys.value = []; @@ -1043,7 +1087,7 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin return; } - const previous = flatKeys.value.find((key) => key.key_raw === oldKeyRaw); + const previous = flatKeyByRaw.get(oldKeyRaw); if (!previous) { void loadKeys(); return; @@ -1055,7 +1099,8 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin const observedAt = ttlObservedAtByRaw.get(oldKeyRaw); ttlObservedAtByRaw.delete(oldKeyRaw); if (observedAt !== undefined) ttlObservedAtByRaw.set(newKeyRaw, observedAt); - flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key)); + if (positiveTtlKeyRaws.delete(oldKeyRaw)) positiveTtlKeyRaws.add(newKeyRaw); + replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key))); if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw; if (checkedKeys.value.has(oldKeyRaw)) { const nextCheckedKeys = new Set(checkedKeys.value); @@ -1089,19 +1134,15 @@ function onKeyLoaded(value: RedisValue) { return; } const keyInfo = redisValueToKeyInfo(value); - const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw); - if (existingIndex < 0) return; + const previousTtl = flatKeyByRaw.get(keyInfo.key_raw)?.ttl ?? -2; + if (!updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, keyInfo)) return; // 详情面板回写了最新的 TTL,同步刷新观测时刻,保证两侧倒计时一致 recordKeyTtlObservedAt(keyInfo); - flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key)); loadedKeyRaws.add(keyInfo.key_raw); - if (useFlatKeySearchRows.value) { - treeKeys.value = []; - treeIndex = null; - refreshSelectedGroupLeafCounts(); - } else { - rebuildTree(false); - } + if (treeIndex) updateRedisKeyTreeLeafMetadata(treeIndex, keyInfo); + if ((previousTtl === -1) !== (keyInfo.ttl === -1)) noExpiryProjectionEpoch.value++; + keyMetadataEpoch.value++; + syncListTtlTimer(); } function requestBatchDelete() { @@ -1202,7 +1243,8 @@ function resetLoadedKeys() { fetchAllLoadedCount.value = 0; loadedKeyRaws.clear(); ttlObservedAtByRaw.clear(); - flatKeys.value = []; + positiveTtlKeyRaws.clear(); + replaceFlatKeyRecords([]); treeKeys.value = []; treeIndex = null; selectedKeyRaw.value = null; @@ -1230,8 +1272,9 @@ async function deleteKeyRaws(keys: string[]) { for (const key of deleted) { loadedKeyRaws.delete(key); ttlObservedAtByRaw.delete(key); + positiveTtlKeyRaws.delete(key); } - flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw)); + replaceFlatKeyRecords(flatKeys.value.filter((key) => !deleted.has(key.key_raw))); if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) { selectedKeyRaw.value = null; } @@ -1456,20 +1499,20 @@ function upsertCreatedKey(value: RedisValue) { size: redisValueSize(value), value_preview: redisValuePreview(value), }; - const existingIndex = flatKeys.value.findIndex((key) => key.key_raw === keyInfo.key_raw); + const existing = flatKeyByRaw.get(keyInfo.key_raw); // 新建 key 携带的 TTL 以当前时刻为观测起点 recordKeyTtlObservedAt(keyInfo); - if (existingIndex >= 0) { - flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key)); + if (existing) { + replaceFlatKeyRecords(flatKeys.value.map((key) => (key.key_raw === keyInfo.key_raw ? keyInfo : key))); } else { - flatKeys.value = [keyInfo, ...flatKeys.value]; + replaceFlatKeyRecords([keyInfo, ...flatKeys.value]); } loadedKeyRaws.add(keyInfo.key_raw); selectedKeyRaw.value = keyInfo.key_raw; rebuildTree(isSearchMode.value); connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { loaded: isSearchMode.value ? undefined : flatKeys.value.length, - totalDelta: existingIndex >= 0 ? 0 : 1, + totalDelta: existing ? 0 : 1, }); } @@ -2435,14 +2478,14 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
- {{ row.node.keyType }} + {{ redisRowKeyType(row.node) }} {{ redisTtlBadgeText(row.node.ttl, redisRowDisplayTtl(row.node.ttl, row.node.keyRaw)) }}{{ redisTtlBadgeText(redisRowTtl(row.node), redisRowDisplayTtl(redisRowTtl(row.node), row.node.keyRaw)) }}