fix(redis): bound subtree fill and resume on demand

Closes #7918
This commit is contained in:
t8y2
2026-09-05 14:38:57 +08:00
parent 51d340eb9b
commit 2f63c34ffa
2 changed files with 620 additions and 11 deletions
@@ -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<HTMLElement>("[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<HTMLButtonElement>("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);
});
});
@@ -222,8 +222,14 @@ let fetchAllPublicationRollback: null | {
lastTotalKeys: number;
bufferedKeyRaws: Set<string>;
} = null;
// 展开分组时已定向补扫过的子树(见 fillGroupSubtree);在 loadKeys 重置。
// 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。
const subtreeFilledGroupIds = new Set<string>();
// 补扫停在预算上限、尚未扫尽的分组:保存继续扫描所需的 SCAN 游标,供
// “加载更多”/滚动按展开顺序续扫(见 resumePendingGroupSubtrees);同样在
// loadKeys 重置。
const subtreePendingGroupCursors = new Map<string, number>();
// 正在补扫中的分组:避免展开与续扫并发重复扫同一子树。
const subtreeFillingGroupIds = new Set<string>();
// 刷新前已展开分组的快照(#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;
}
}