mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user