From 2f63c34ffa4232f351d0fa270fe17a10e0c437db Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 5 Sep 2026 14:17:51 +0800 Subject: [PATCH] fix(redis): bound subtree fill and resume on demand Closes #7918 --- .../redis/RedisKeyBrowser.subtreeFill.spec.ts | 556 ++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 75 ++- 2 files changed, 620 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts new file mode 100644 index 0000000000..f25ba747fb --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts @@ -0,0 +1,556 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RedisKeyInfo } from "@/lib/backend/api"; + +const mocks = vi.hoisted(() => ({ + redisScanKeysBatch: vi.fn(), + redisGetValue: vi.fn(), + redisSetString: vi.fn(), + redisJsonSet: vi.fn(), + redisHashSet: vi.fn(), + redisListPush: vi.fn(), + redisSetAdd: vi.fn(), + redisZadd: vi.fn(), + redisStreamAdd: vi.fn(), + redisSetTtl: vi.fn(), + redisSetExpireAt: vi.fn(), + redisCheckJsonModule: vi.fn(), + redisDeleteKey: vi.fn(), + redisDeleteKeys: vi.fn(), + redisExecuteCommand: vi.fn(), + saveHistory: vi.fn(), + toast: vi.fn(), + updateRedisDbKeyStats: vi.fn(), + listRedisCompletionCommandDocs: vi.fn(), + listRedisCompletionKeys: vi.fn(), + redisScanPageSize: 100, + infiniteScroll: true, + queryResultMaxRowsEnabled: true, + queryResultMaxRows: 5000, +})); + +vi.mock("@/lib/backend/api", () => ({ + redisScanKeysBatch: mocks.redisScanKeysBatch, + redisGetValue: mocks.redisGetValue, + redisSetString: mocks.redisSetString, + redisJsonSet: mocks.redisJsonSet, + redisHashSet: mocks.redisHashSet, + redisListPush: mocks.redisListPush, + redisSetAdd: mocks.redisSetAdd, + redisZadd: mocks.redisZadd, + redisStreamAdd: mocks.redisStreamAdd, + redisSetTtl: mocks.redisSetTtl, + redisSetExpireAt: mocks.redisSetExpireAt, + redisCheckJsonModule: mocks.redisCheckJsonModule, + redisDeleteKey: mocks.redisDeleteKey, + redisDeleteKeys: mocks.redisDeleteKeys, + redisExecuteCommand: mocks.redisExecuteCommand, + saveHistory: mocks.saveHistory, +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: vi.fn().mockResolvedValue(undefined), + getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }), + updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, + listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs, + listRedisCompletionKeys: mocks.listRedisCompletionKeys, + invalidateCompletionCache: vi.fn(), + refreshRedisDbKeyCounts: vi.fn(), + }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + infiniteScroll: mocks.infiniteScroll, + queryResultMaxRowsEnabled: mocks.queryResultMaxRowsEnabled, + queryResultMaxRows: mocks.queryResultMaxRows, + }, + }), +})); + +vi.mock("@/composables/useEditorFontFamilyStyle", () => ({ + useEditorFontFamilyStyle: () => ({}), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + props: { disabled: Boolean }, + setup(props, { attrs, slots }) { + return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h, mergeProps } = await import("vue"); + return { + Input: defineComponent({ + inheritAttrs: false, + props: { modelValue: String, disabled: Boolean }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + return () => + h( + "input", + mergeProps(attrs, { + value: props.modelValue ?? "", + disabled: props.disabled, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + ); + }, + }), + }; +}); + +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Dialog: defineComponent({ + props: { open: Boolean }, + setup(props, { slots }) { + return () => (props.open ? h("div", { "data-test-dialog": "" }, slots.default?.()) : null); + }, + }), + DialogContent: slotContainer, + DialogFooter: slotContainer, + DialogHeader: slotContainer, + DialogTitle: slotContainer, + }; +}); + +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Select: slotContainer, + SelectContent: slotContainer, + SelectItem: defineComponent({ + props: { value: String }, + setup(props, { slots }) { + return () => h("button", { type: "button", "data-test-select-value": props.value }, slots.default?.()); + }, + }), + SelectTrigger: slotContainer, + SelectValue: slotContainer, + }; +}); + +vi.mock("@/components/ui/option-help-panel", async () => { + const { defineComponent, h } = await import("vue"); + return { OptionHelpPanel: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tabs: slotContainer, TabsContent: slotContainer, TabsList: slotContainer, TabsTrigger: slotContainer }; +}); + +vi.mock("@/components/ui/switch", async () => { + const { defineComponent, h } = await import("vue"); + return { Switch: defineComponent({ setup: () => () => h("button", { type: "button" }) }) }; +}); + +vi.mock("@/components/ui/date-time-picker/DateTimePicker.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("@/components/ui/CustomContextMenu.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + setup(_, { slots }) { + return () => h("div", slots.default?.({ onContextMenu: () => undefined })); + }, + }), + }; +}); + +vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ props: { open: Boolean }, setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisValueViewer.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisPubSubPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisSlowlogPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("vue-virtual-scroller", async () => { + const { defineComponent, h } = await import("vue"); + return { + RecycleScroller: defineComponent({ + inheritAttrs: false, + props: { items: { type: Array, default: () => [] } }, + setup(props, { attrs, slots }) { + const visibleItemCount = 50; + return () => + h( + "div", + attrs, + props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })), + ); + }, + }), + }; +}); + +vi.mock("splitpanes", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Splitpanes: slotContainer, Pane: slotContainer }; +}); + +import RedisKeyBrowser from "./RedisKeyBrowser.vue"; + +const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = []; + +function mountBrowser() { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + return host; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +async function settleThoroughly(rounds = 40) { + for (let i = 0; i < rounds; i++) await settle(); +} + +function resetApiMocks() { + vi.clearAllMocks(); + mocks.redisScanPageSize = 100; + mocks.infiniteScroll = true; + mocks.queryResultMaxRowsEnabled = true; + mocks.queryResultMaxRows = 5000; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); +} + +// Overflowing viewport (see the infinite-scroll spec): rows render while the +// bounded auto-fill stays off, so subtree scans fire only from explicit group +// expansion and explicit "Load more" clicks — deterministic call counts. +let originalClientHeight: PropertyDescriptor | undefined; +let originalScrollHeight: PropertyDescriptor | undefined; + +function stubOverflowingViewport() { + originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 1000 : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("redis-key-scroller") ? 5000 : 100; + }, + }); +} + +function restoreViewportStub() { + if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + if (originalScrollHeight) Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight); +} + +function keyInfo(rawKey: string): RedisKeyInfo { + return { key_display: rawKey, key_raw: rawKey, key_type: "string", ttl: -1, size: 0, value_preview: "" }; +} + +function subtreeBatchKeys(count: number, batch: number): RedisKeyInfo[] { + return Array.from({ length: count }, (_, index) => keyInfo(`grp:batch${batch}:${index}`)); +} + +// `cursor` (3rd arg) and `pattern` (4th arg) of every subtree fill call. +function subtreeCalls(): unknown[][] { + return mocks.redisScanKeysBatch.mock.calls.filter((call: unknown[]) => call[3] === "grp:*"); +} + +// Clicks the group row itself (the checkbox consumes its own clicks; walk up to +// the row div carrying the click handler — same walk as the infinite-scroll +// spec). +function expandFirstGroupRow(host: HTMLElement) { + const checkbox = host.querySelector("[data-redis-group]"); + const row = checkbox?.parentElement?.parentElement; + expect(row?.className).toContain("cursor-pointer"); + row!.click(); +} + +function clickLoadMore(host: HTMLElement) { + const loadMore = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("redis.loadMoreKeys") && !button.disabled); + expect(loadMore, "load more button").toBeDefined(); + loadMore!.click(); +} + +function leafVisible(host: HTMLElement, keyRaw: string): boolean { + return host.querySelector(`[data-redis-leaf='${keyRaw}']`) !== null; +} + +// Mirrors `SUBTREE_FILL_MAX_NEW_KEYS` / `SUBTREE_FILL_MAX_SCAN_ITERATIONS` / +// `SUBTREE_SCAN_ITERATIONS_PER_CALL` in RedisKeyBrowser.vue: one fill pass +// merges at most 500 new keys and spends at most 50 SCAN iterations in batches +// of 8 — whichever bound hits first. At the spec's page size of 100 the key +// bound needs 5 calls; the iteration bound alone needs 7. +const SUBTREE_FILL_MAX_CALLS_BY_KEYS = 5; +const SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS = 7; + +beforeEach(() => { + resetApiMocks(); +}); + +afterEach(() => { + for (const { unmount, host } of mountedApps.splice(0)) { + unmount(); + host.remove(); + } + restoreViewportStub(); +}); + +// Issue #7918: expanding a group with a huge prefix (e.g. 10000 `xxxx::xxxx` +// keys) used to scan the subtree to exhaustion in one interaction, freezing +// the Redis cluster. A fill pass must stay bounded regardless of subtree size, +// keep its scan cursor, and resume on demand — while small groups still fill +// completely in one pass. +describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => { + beforeEach(() => { + mocks.redisScanPageSize = 100; + }); + + function mockMainScanOpen() { + // First browse page seeds one `grp` key; every continuation keeps the main + // scan open (cursor never closes), so the tree stays partial and subtree + // fills remain eligible. + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") return Promise.resolve({ cursor: 0, keys: [], 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 }); + }); + } + + it("fills a small group's subtree completely in one pass and does not refill on re-expand", async () => { + stubOverflowingViewport(); + mockMainScanOpen(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:s1"), keyInfo("grp:s2"), keyInfo("grp:s3")], 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(); + + expandFirstGroupRow(host); + await settleThoroughly(); + + // The whole subtree arrived in a single call (cursor closed immediately). + expect(subtreeCalls().length).toBe(1); + expect(leafVisible(host, "grp:s1")).toBe(true); + expect(leafVisible(host, "grp:s3")).toBe(true); + + // Fully scanned: collapse + re-expand must not rescan the subtree. + expandFirstGroupRow(host); + await settleThoroughly(); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls().length).toBe(1); + expect(leafVisible(host, "grp:s1")).toBe(true); + }); + + it("stops a huge group's fill at the per-pass key cap, saves the cursor, and scans no further", async () => { + stubOverflowingViewport(); + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + // A full page of brand-new keys every call; the cursor never closes. + return Promise.resolve({ cursor: 2000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), 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(); + expect(subtreeCalls().length).toBe(0); + + expandFirstGroupRow(host); + await settleThoroughly(); + + // 1 main-scan key + 5 × 100 subtree keys = 500 new keys merged: exactly the + // per-pass cap, reached after 5 subtree calls — never a scan to exhaustion + // no matter how huge the prefix is. + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + expect(host.textContent).toContain("(501+)"); + + // Nothing else drives the fill: settling further must not scan any more. + const callsAtCap = subtreeCalls().length; + await settleThoroughly(10); + expect(subtreeCalls().length).toBe(callsAtCap); + }); + + it("bounds a sparse subtree fill by the SCAN iteration budget even when no keys match", async () => { + stubOverflowingViewport(); + mockMainScanOpen(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + // The prefix matches almost nothing in a huge keyspace: every call comes + // back empty with a cursor that keeps advancing, so the key cap never + // fires — only the iteration budget can stop this pass. + if (pattern === "grp:*") return Promise.resolve({ cursor: cursor + 1, keys: [], 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(); + + expandFirstGroupRow(host); + await settleThoroughly(); + + // 50 iterations spent in batches of 8 = 6 full calls + 1 final call of 2. + const calls = subtreeCalls(); + expect(calls.length).toBe(SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS); + expect(calls[6][5]).toBe(2); + await settleThoroughly(10); + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS); + }); + + it("resumes a partially filled subtree from its saved cursor via Load more and completes", async () => { + stubOverflowingViewport(); + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + // 5 capped batches, then the resumed pass finds the subtree exhausted. + if (subtreeCall <= SUBTREE_FILL_MAX_CALLS_BY_KEYS) return Promise.resolve({ cursor: 2000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 }); + return Promise.resolve({ cursor: 0, keys: subtreeBatchKeys(50, subtreeCall), 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(); + + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + // "Load more" advances the main scan and, alongside it, resumes the next + // pending subtree — continuing from the cursor the capped pass saved (the + // one returned by call 5), never rescanning from 0. + clickLoadMore(host); + await settleThoroughly(); + + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1); + const resumeCall = subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS]; + expect(resumeCall[2]).toBe(2000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + // The subtree is now exhausted: re-expanding must not rescan it. + expandFirstGroupRow(host); + await settleThoroughly(); + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1); + expect(leafVisible(host, "grp:a")).toBe(true); + }); + + it("resumes a partially filled subtree when the group is re-expanded", async () => { + stubOverflowingViewport(); + let subtreeCall = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "grp:*") { + subtreeCall++; + if (subtreeCall <= SUBTREE_FILL_MAX_CALLS_BY_KEYS) return Promise.resolve({ cursor: 3000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 }); + return Promise.resolve({ cursor: 0, keys: subtreeBatchKeys(10, subtreeCall), 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(); + + expandFirstGroupRow(host); + await settleThoroughly(); + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS); + + // Collapse + re-expand the partially filled group: the fill resumes from + // the saved cursor (returned by call 5) instead of restarting at 0. + expandFirstGroupRow(host); + await settleThoroughly(); + expandFirstGroupRow(host); + await settleThoroughly(); + + expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1); + expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS][2]).toBe(3000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index eca57dcd74..282ed654e8 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -222,8 +222,14 @@ let fetchAllPublicationRollback: null | { lastTotalKeys: number; bufferedKeyRaws: Set; } = null; -// 展开分组时已定向补扫过的子树(见 fillGroupSubtree);在 loadKeys 重置。 +// 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。 const subtreeFilledGroupIds = new Set(); +// 补扫停在预算上限、尚未扫尽的分组:保存继续扫描所需的 SCAN 游标,供 +// “加载更多”/滚动按展开顺序续扫(见 resumePendingGroupSubtrees);同样在 +// loadKeys 重置。 +const subtreePendingGroupCursors = new Map(); +// 正在补扫中的分组:避免展开与续扫并发重复扫同一子树。 +const subtreeFillingGroupIds = new Set(); // 刷新前已展开分组的快照(#7173):刷新首屏重建树时,本轮尚未扫到的分组会被 // rebuildTree 从 expandedGroupIds 中裁掉;后续 load-more 页面让这些分组重新 // 出现时,用该快照恢复展开状态。SCAN 游标归零(本轮已扫尽,仍未出现的分组 @@ -937,6 +943,8 @@ async function loadKeys() { ttlObservedAtByRaw.clear(); positiveTtlKeyRaws.clear(); subtreeFilledGroupIds.clear(); + subtreePendingGroupCursors.clear(); + subtreeFillingGroupIds.clear(); replaceFlatKeyRecords([]); treeKeys.value = []; treeIndex = null; @@ -1000,6 +1008,9 @@ async function loadMore(iterationBudget?: ScanIterationBudget) { // automatic-fill check as everywhere else instead of relying on the user to // notice and click again. if (applied && isCurrentScanOperation(requestId, operationId)) { + // 主页面推进的同时,按展开顺序续扫一个停在预算上限的子树(#7918): + // 巨大分组靠“加载更多”/滚动逐段补全,而不是一次展开就扫尽。 + resumePendingGroupSubtrees(requestId); void maybeAutoLoadMoreRedisKeys(); } } @@ -1301,9 +1312,16 @@ function stopFetchAll() { // 展开分组时的定向补扫:树模式的自动加载只覆盖有界 SCAN 预算内的键, // 未扫到的分组在树里完全不存在(搜索能找到、树里看不到)。展开分组时用 -// `前缀:*` 模式以独立游标扫描该子树直至耗尽,让"展开即可见"成立;补扫 -// 结果经 loadedKeyRaws 去重后并入主树,主 SCAN 游标不受影响。 +// `前缀:*` 模式以独立游标扫描该子树,让"展开即可见"成立;补扫结果经 +// loadedKeyRaws 去重后并入主树,主 SCAN 游标不受影响。 const SUBTREE_SCAN_ITERATIONS_PER_CALL = 8; +// 单次补扫(一次展开或一次续扫)的预算上限:新并入的键数与 SCAN 迭代数 +// 各设一个,取先到者。巨大前缀(如上万键,#7918)一次展开就扫尽子树会把 +// 集群打满,改为每次交互只推进有界预算;未扫尽的分组把游标留在 +// subtreePendingGroupCursors,由“加载更多”/滚动按展开顺序续扫,折叠后 +// 重新展开也会从断点继续。小于预算的分组仍一次扫完,行为不变。 +const SUBTREE_FILL_MAX_NEW_KEYS = 500; +const SUBTREE_FILL_MAX_SCAN_ITERATIONS = 50; function shouldFillGroupSubtree(): boolean { return hasMore.value && !useFlatKeySearchRows.value && !isSearchMode.value && !isFetchingAll.value; @@ -1321,22 +1339,57 @@ function mergeScannedKeys(newKeys: RedisKeyInfo[]) { async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) { if (subtreeFilledGroupIds.has(group.id)) return; - subtreeFilledGroupIds.add(group.id); + if (subtreeFillingGroupIds.has(group.id)) return; + subtreeFillingGroupIds.add(group.id); + // 续扫从上次停在的游标继续,避免把已扫过的前缀重扫一遍 + const resumeCursor = subtreePendingGroupCursors.get(group.id) ?? 0; + subtreePendingGroupCursors.delete(group.id); const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value); - let cursor = 0; + let cursor = resumeCursor; + let mergedNewKeys = 0; + let iterationsLeft = SUBTREE_FILL_MAX_SCAN_ITERATIONS; try { - while (requestId === searchRequestId && !isFetchingAll.value) { + while (requestId === searchRequestId && !isFetchingAll.value && iterationsLeft > 0) { 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 (mergedNewKeys >= SUBTREE_FILL_MAX_NEW_KEYS) break; + const iterations = Math.min(SUBTREE_SCAN_ITERATIONS_PER_CALL, iterationsLeft); + const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, iterations, true); + iterationsLeft -= iterations; if (requestId !== searchRequestId) return; - mergeScannedKeys(collectUniqueRedisKeys(result.keys, loadedKeyRaws)); + const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws); + mergeScannedKeys(newKeys); + mergedNewKeys += newKeys.length; cursor = result.cursor; - if (cursor === 0) break; + if (cursor === 0) { + subtreeFilledGroupIds.add(group.id); + return; + } } + // 子树未扫尽(预算用尽或触到全局键数上限):保留游标等待续扫 + subtreePendingGroupCursors.set(group.id, cursor); } catch (error) { - // 失败不阻塞浏览;下次展开该分组会重试 - subtreeFilledGroupIds.delete(group.id); + // 失败不阻塞浏览;已并入的进度保留在游标里,下次展开/续扫从断点重试 + subtreePendingGroupCursors.set(group.id, cursor); toast(errorMessage(error), 5000); + } finally { + subtreeFillingGroupIds.delete(group.id); + } +} + +// “加载更多”/滚动推进主扫描的同时,按展开顺序续扫一个未扫尽的子树:巨大 +// 分组不会一次扫尽,用户每点一次“加载更多”(或滚动触底触发 loadMore)就 +// 前进一段有界预算,循环往复直至扫尽。 +function resumePendingGroupSubtrees(requestId = searchRequestId) { + if (!shouldFillGroupSubtree()) return; + for (const groupId of subtreePendingGroupCursors.keys()) { + const group = treeIndex?.groupById.get(groupId); + if (!group) { + // 分组已不存在(键被删除或树被重建),游标作废 + subtreePendingGroupCursors.delete(groupId); + continue; + } + void fillGroupSubtree(group, requestId); + return; } }