From 4b4b4ac2dd15b0a908bceaf5b27fcbe72c02614a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 26 Aug 2026 17:14:12 +0800 Subject: [PATCH] fix(redis): fill expanded group subtree via targeted scan Closes #7162 --- .../RedisKeyBrowser.infiniteScroll.spec.ts | 91 +++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 55 ++++++++++- .../src/lib/redis/redisKeyPattern.spec.ts | 20 ++++ apps/desktop/src/lib/redis/redisKeyPattern.ts | 10 ++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/lib/redis/redisKeyPattern.spec.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts index 767af10a9a..2ca97e7cc9 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -611,3 +611,94 @@ describe("RedisKeyBrowser group key counts reflect incomplete loading (issue #63 expect(countBadge?.getAttribute("title")).toBeFalsy(); }); }); + +describe("RedisKeyBrowser group subtree fill on expand (issue #7162)", () => { + beforeEach(() => { + mocks.redisScanPageSize = 100; + }); + + function scanCallsWithPattern(pattern: string): number { + return mocks.redisScanKeysBatch.mock.calls.filter((call: unknown[]) => call[3] === pattern).length; + } + + // Clicks the group row itself. `[data-redis-group]` marks the group's + // selection checkbox: it carries `cursor-pointer` itself and consumes the + // click, so a `closest()` from it can never reach the expanding row — walk + // up to the checkbox's row container (the div with the row click handler) + // instead. + function expandFirstGroupRow(host: HTMLElement) { + const checkbox = host.querySelector("[data-redis-group]"); + const row = checkbox?.parentElement?.parentElement; + expect(row?.className).toContain("cursor-pointer"); + row!.click(); + } + + // Overflowing viewport: rows render (RecycleScroller buffer 600) while the + // bounded auto-fill stays off, reproducing the stranded state from the + // issue — the main scan is still open (hasMore), so the tree is silently + // partial and a key that search finds may not exist in the tree at all. + function stubOverflowingViewport() { + stubNonOverflowingViewport(); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 5000 : 100; + }, + }); + } + + it("fills the expanded group's subtree via a dedicated prefix scan without advancing the main scan", async () => { + stubOverflowingViewport(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:hidden"), keyInfo("grp:sub:x")], total_keys: 0 }); + } + if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 }); + return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + + // Only the first browse page ran: the tree holds `grp:a` alone and the + // main scan is still open. + expect(scanCallsWithPattern("*")).toBe(1); + expect(scanCallsWithPattern("grp:*")).toBe(0); + expect(host.textContent).toContain("grp"); + + expandFirstGroupRow(host); + await settleThoroughly(); + + // Expanding the group scanned its subtree with a fresh cursor; the main + // browse cursor was not advanced (no extra `*` pages). + expect(scanCallsWithPattern("grp:*")).toBe(1); + expect(scanCallsWithPattern("*")).toBe(1); + expect(host.textContent).toContain("hidden"); + expect(host.textContent).toContain("sub"); + + // Re-collapse + re-expand must not rescan the already-completed subtree. + expandFirstGroupRow(host); + await settleThoroughly(); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(scanCallsWithPattern("grp:*")).toBe(1); + }); + + it("skips the subtree scan when the main scan already completed", async () => { + stubOverflowingViewport(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + expect(pattern).toBe("*"); + if (cursor === 0) return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:a"), keyInfo("grp:b")], total_keys: 2 }); + return Promise.resolve({ cursor: 0, keys: [], total_keys: 0 }); + }); + + const host = mountBrowser(); + await settleThoroughly(); + + expandFirstGroupRow(host); + await settleThoroughly(); + + expect(scanCallsWithPattern("grp:*")).toBe(0); + expect(host.textContent).toContain("b"); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 92ee9b5ef1..b2a7cd7246 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -51,7 +51,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { copyToClipboard } from "@/lib/common/clipboard"; import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle"; import { useToast } from "@/composables/useToast"; -import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern"; +import { redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern"; import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch"; @@ -198,6 +198,8 @@ let redisDbFlushedListenerRegistered = false; let redisInfiniteScrollFrame = 0; const loadedKeyRaws = new Set(); let treeIndex: RedisKeyTreeIndex | null = null; +// 展开分组时已定向补扫过的子树(见 fillGroupSubtree);在 loadKeys 重置。 +const subtreeFilledGroupIds = new Set(); const REDIS_COMMAND_COMPLETION_MENU_LIMIT = 12; let commandCompletionRequestId = 0; let commandDocumentationConnectionId: string | null = null; @@ -745,6 +747,7 @@ async function loadKeys() { loading.value = true; loadedKeyRaws.clear(); ttlObservedAtByRaw.clear(); + subtreeFilledGroupIds.clear(); flatKeys.value = []; treeKeys.value = []; treeIndex = null; @@ -913,12 +916,58 @@ function stopFetchAll() { fetchAllStopRequested.value = true; } +// 展开分组时的定向补扫:树模式的自动加载只覆盖有界 SCAN 预算内的键, +// 未扫到的分组在树里完全不存在(搜索能找到、树里看不到)。展开分组时用 +// `前缀:*` 模式以独立游标扫描该子树直至耗尽,让"展开即可见"成立;补扫 +// 结果经 loadedKeyRaws 去重后并入主树,主 SCAN 游标不受影响。 +const SUBTREE_SCAN_ITERATIONS_PER_CALL = 8; + +function shouldFillGroupSubtree(): boolean { + return hasMore.value && !useFlatKeySearchRows.value && !isSearchMode.value && !isFetchingAll.value; +} + +function mergeScannedKeys(newKeys: RedisKeyInfo[]) { + if (newKeys.length === 0) return; + for (const key of newKeys) recordKeyTtlObservedAt(key); + flatKeys.value = [...flatKeys.value, ...newKeys]; + mergeTree(newKeys); + connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { + loaded: isSearchMode.value ? undefined : flatKeys.value.length, + }); +} + +async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) { + if (subtreeFilledGroupIds.has(group.id)) return; + subtreeFilledGroupIds.add(group.id); + const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value); + let cursor = 0; + try { + while (requestId === searchRequestId && !isFetchingAll.value) { + if (flatKeys.value.length >= redisInfiniteScrollMaxKeys.value) break; + const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, SUBTREE_SCAN_ITERATIONS_PER_CALL, true); + if (requestId !== searchRequestId) return; + mergeScannedKeys(collectUniqueRedisKeys(result.keys, loadedKeyRaws)); + cursor = result.cursor; + if (cursor === 0) break; + } + } catch (error) { + // 失败不阻塞浏览;下次展开该分组会重试 + subtreeFilledGroupIds.delete(group.id); + toast(errorMessage(error), 5000); + } +} + function toggleGroup(groupId: string) { const next = new Set(expandedGroupIds.value); - if (next.has(groupId)) next.delete(groupId); - else next.add(groupId); + const expanding = !next.has(groupId); + if (expanding) next.add(groupId); + else next.delete(groupId); expandedGroupIds.value = next; void maybeAutoLoadMoreRedisKeys(); + if (expanding && shouldFillGroupSubtree()) { + const group = treeIndex?.groupById.get(groupId); + if (group) void fillGroupSubtree(group); + } } function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) { diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts new file mode 100644 index 0000000000..ce894349b0 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern"; + +describe("redisGroupSubtreePattern", () => { + it("builds a prefix pattern for single and nested groups", () => { + expect(redisGroupSubtreePattern(["grp"])).toBe("grp:*"); + expect(redisGroupSubtreePattern(["grp", "sub"])).toBe("grp:sub:*"); + }); + + it("supports custom separators and preserves empty segments", () => { + expect(redisGroupSubtreePattern(["a", "b"], "/")).toBe("a/b/*"); + expect(redisGroupSubtreePattern(["a", "", "c"])).toBe("a::c:*"); + }); + + it("escapes glob metacharacters in every segment", () => { + expect(redisGroupSubtreePattern(["a*b"])).toBe("a\\*b:*"); + expect(redisGroupSubtreePattern(["order?]", "x[y"])).toBe("order\\?\\]:x\\[y:*"); + expect(redisGroupSubtreePattern(["back\\slash"])).toBe("back\\\\slash:*"); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.ts b/apps/desktop/src/lib/redis/redisKeyPattern.ts index 32060f7f44..37439ac8c1 100644 --- a/apps/desktop/src/lib/redis/redisKeyPattern.ts +++ b/apps/desktop/src/lib/redis/redisKeyPattern.ts @@ -11,6 +11,16 @@ export function redisKeySearchPattern(value: string, fuzzy: boolean): string { return fuzzy ? `*${escapeRedisGlobText(pattern, fuzzy)}*` : pattern; } +/** + * SCAN MATCH pattern covering exactly the subtree of a tree group: every + * segment is glob-escaped so keys containing `*`/`?`/`[`/`]` match literally, + * and the trailing `*` only widens to the group's descendants. + */ +export function redisGroupSubtreePattern(pathSegments: readonly string[], separator = ":"): string { + const prefix = pathSegments.map((segment) => escapeRedisGlobText(segment)).join(separator); + return `${prefix}${separator}*`; +} + // Redis scan page size (COUNT parameter per SCAN round-trip) — shared defaults // and validation bounds used by the connection form and key browser. export const REDIS_SCAN_PAGE_SIZE_DEFAULT = 1000;