mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(redis): keep automatic scan loading within shared budget
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
// @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 }) {
|
||||
// Real RecycleScroller only mounts as many rows as fit the viewport;
|
||||
// rendering everything here would defeat the point of this test, so
|
||||
// cap it the same way the expiry spec's mock does.
|
||||
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();
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// A short, un-collapsed tree (a real DB has millions of keys folded into a
|
||||
// handful of visible top-level groups) never overflows the scroller's
|
||||
// viewport, so it never emits a native `scroll` event. Simulate that real
|
||||
// layout fact — happy-dom always reports 0 for clientHeight/scrollHeight —
|
||||
// by stubbing both getters for the specific scroller element under test.
|
||||
let originalClientHeight: PropertyDescriptor | undefined;
|
||||
let originalScrollHeight: PropertyDescriptor | undefined;
|
||||
|
||||
function stubNonOverflowingViewport() {
|
||||
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") ? 90 : 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreViewportStub() {
|
||||
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
|
||||
if (originalScrollHeight) Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetApiMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mountedApps.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
restoreViewportStub();
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser infinite scroll auto-continue (issue #6022)", () => {
|
||||
it("keeps fetching pages on its own when the loaded rows don't overflow the viewport, without any scroll event", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
|
||||
const firstPage: RedisKeyInfo[] = [{ key_display: "id:acct:1:mt5:live01:59995072:x", key_raw: "a", key_type: "string", ttl: -1, size: 0, value_preview: "" }];
|
||||
const secondPage: RedisKeyInfo[] = [{ key_display: "id:acct:1:mt5:live01:59995129:x", key_raw: "b", key_type: "string", ttl: -1, size: 0, value_preview: "" }];
|
||||
const thirdPage: RedisKeyInfo[] = [
|
||||
{ key_display: "id:acct:1:mt5:live01:59995904:x", key_raw: "c", key_type: "string", ttl: -1, size: 0, value_preview: "" },
|
||||
{ key_display: "id:acct:1:mt4:live04:40001:y", key_raw: "d", key_type: "string", ttl: -1, size: 0, value_preview: "" },
|
||||
];
|
||||
// A large real keyspace: cursor keeps moving and only closes on the
|
||||
// third round-trip, same shape as scanning a few hundred-thousand keys.
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||
if (cursor === 0) return Promise.resolve({ cursor: 7, keys: firstPage, total_keys: 200_004 });
|
||||
if (cursor === 7) return Promise.resolve({ cursor: 91, keys: secondPage, total_keys: 0 });
|
||||
return Promise.resolve({ cursor: 0, keys: thirdPage, total_keys: 0 });
|
||||
});
|
||||
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
// Nobody dispatched a `scroll` event — a container shorter than its
|
||||
// viewport never fires one in a real browser — yet all three pages must
|
||||
// have been pulled automatically. All 4 keys share the "id" top-level
|
||||
// segment, so the (collapsed-by-default) root group's own badge is
|
||||
// enough to prove every page made it into the tree, without needing to
|
||||
// expand each nested folder just to read the leaf labels.
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(3);
|
||||
expect(host.textContent).toContain("id");
|
||||
expect(host.textContent).toContain("(4)");
|
||||
});
|
||||
|
||||
it("stops auto-fetching once the loaded rows actually overflow the viewport", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
// This time the scroller reports real overflow after the first page, so
|
||||
// the existing scroll-driven `loadMore` is expected to take over instead.
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("redis-key-scroller") ? 5000 : 0;
|
||||
},
|
||||
});
|
||||
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||
if (cursor === 0) return Promise.resolve({ cursor: 7, keys: [{ key_display: "k1", key_raw: "a", key_type: "string", ttl: -1, size: 0, value_preview: "" }], total_keys: 200_004 });
|
||||
return Promise.resolve({ cursor: 0, keys: [{ key_display: "k2", key_raw: "b", key_type: "string", ttl: -1, size: 0, value_preview: "" }], total_keys: 0 });
|
||||
});
|
||||
|
||||
mountBrowser();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rechecks bounded auto-loading when the viewport grows", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
let overflow = true;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("redis-key-scroller") ? (overflow ? 5000 : 90) : 0;
|
||||
},
|
||||
});
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||
if (cursor === 0) return Promise.resolve({ cursor: 7, keys: [keyInfo("a")], total_keys: 200_004 });
|
||||
return Promise.resolve({ cursor: 0, keys: [keyInfo("b")], total_keys: 0 });
|
||||
});
|
||||
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
overflow = false;
|
||||
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize"));
|
||||
await settleThoroughly();
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Use the real production default page size (not an artificial single-call
|
||||
// size) so these tests actually exercise the amplification the review
|
||||
// flagged: with the default 1,000-row page, a single logical "page" can
|
||||
// still internally retry across many backend calls while it comes back
|
||||
// empty (COUNT is only a hint), so a page-count cap alone doesn't bound the
|
||||
// real work. See `AUTO_LOAD_TOTAL_SCAN_ITERATIONS` in RedisKeyBrowser.vue —
|
||||
// the whole automatic-fill operation shares ONE SCAN-iteration budget across
|
||||
// every backend call it makes, however many logical pages that spans.
|
||||
const PRODUCTION_PAGE_SIZE = 1000;
|
||||
// Mirrors the constants in RedisKeyBrowser.vue: `iterations` per backend
|
||||
// call is capped at 8, and the automatic-fill operation gets a total budget
|
||||
// of 50 SCAN iterations. Supported page sizes split that budget differently:
|
||||
// 200/1,000-row pages need at most 7 backend calls, while 5,000/10,000-row
|
||||
// pages can need at most 10. The shared iteration limit remains the invariant.
|
||||
const ITERATIONS_PER_CALL = 8;
|
||||
const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
||||
const AUTO_LOAD_MAX_CALLS = Math.ceil(AUTO_LOAD_TOTAL_SCAN_ITERATIONS / ITERATIONS_PER_CALL);
|
||||
const AUTO_LOAD_PAGE_SIZE_CASES = [
|
||||
{ pageSize: 200, maxCalls: 7 },
|
||||
{ pageSize: 1000, maxCalls: 7 },
|
||||
{ pageSize: 5000, maxCalls: 10 },
|
||||
{ pageSize: 10000, maxCalls: 10 },
|
||||
] as const;
|
||||
|
||||
async function settleThoroughly(rounds = 40) {
|
||||
for (let i = 0; i < rounds; i++) await settle();
|
||||
}
|
||||
|
||||
function keyInfo(rawKey: string): RedisKeyInfo {
|
||||
return { key_display: rawKey, key_raw: rawKey, key_type: "string", ttl: -1, size: 0, value_preview: "" };
|
||||
}
|
||||
|
||||
// `iterations` (aka `max_iterations`) is the 6th positional arg the frontend
|
||||
// sends to `redisScanKeysBatch` — the same unit the backend spends as real
|
||||
// Redis SCAN calls (see `crates/dbx-core/src/db/redis_driver.rs`).
|
||||
function totalIterationsRequested(): number {
|
||||
return mocks.redisScanKeysBatch.mock.calls.reduce((sum: number, call: unknown[]) => sum + (call[5] as number), 0);
|
||||
}
|
||||
|
||||
describe("RedisKeyBrowser automatic continuation request budget (PR #6313 review)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.redisScanPageSize = PRODUCTION_PAGE_SIZE;
|
||||
});
|
||||
|
||||
it.each(AUTO_LOAD_PAGE_SIZE_CASES)("stops after at most $maxCalls automatic backend calls for page size $pageSize", async ({ pageSize, maxCalls }) => {
|
||||
stubNonOverflowingViewport();
|
||||
mocks.redisScanPageSize = pageSize;
|
||||
let call = 0;
|
||||
// The tree-vs-empty-state view only mounts the scroller once at least one
|
||||
// key has loaded, so seed exactly one key on the first page — every page
|
||||
// after that comes back empty with a cursor that always advances and
|
||||
// never hits 0. An unbounded loop would spin until the (huge) keyspace is
|
||||
// exhausted or the process runs out of time.
|
||||
mocks.redisScanKeysBatch.mockImplementation((_c: string, _d: number, cursor: number) => {
|
||||
call++;
|
||||
return Promise.resolve({ cursor: cursor + 1, keys: call === 1 ? [keyInfo("seed")] : [], total_keys: 5_000_000 });
|
||||
});
|
||||
|
||||
mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
// 1 initial page + the automatic-fill operation's total iteration budget,
|
||||
// never the ~147-call amplification a page-count-only cap would allow at
|
||||
// this page size.
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1 + maxCalls);
|
||||
expect(totalIterationsRequested()).toBeLessThanOrEqual(ITERATIONS_PER_CALL + AUTO_LOAD_TOTAL_SCAN_ITERATIONS);
|
||||
const callsAtBound = mocks.redisScanKeysBatch.mock.calls.length;
|
||||
await settleThoroughly(10);
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(callsAtBound);
|
||||
});
|
||||
|
||||
it("stops after a bounded total of backend calls when every page comes back with only already-loaded keys, at the production page size", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
// Every page repeats the same key, so unique loaded/visible keys stay at 1
|
||||
// forever — a stop condition based on unique-key growth alone never fires.
|
||||
mocks.redisScanKeysBatch.mockImplementation((_c: string, _d: number, cursor: number) => Promise.resolve({ cursor: cursor + 1, keys: [keyInfo("dup")], total_keys: 5_000_000 }));
|
||||
|
||||
mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1 + AUTO_LOAD_MAX_CALLS);
|
||||
expect(totalIterationsRequested()).toBeLessThanOrEqual(ITERATIONS_PER_CALL + AUTO_LOAD_TOTAL_SCAN_ITERATIONS);
|
||||
});
|
||||
|
||||
it("stops after a bounded total of backend calls against a sparse filter that only rarely yields a new key, at the production page size", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
let call = 0;
|
||||
mocks.redisScanKeysBatch.mockImplementation((_c: string, _d: number, cursor: number) => {
|
||||
call++;
|
||||
// A new unique key surfaces on the first page (so the scroller mounts)
|
||||
// and then only every 3rd call after that — same shape as a narrow
|
||||
// pattern against a huge, mostly-non-matching keyspace.
|
||||
const keys = call === 1 || call % 3 === 0 ? [keyInfo(`sparse-${call}`)] : [];
|
||||
return Promise.resolve({ cursor: cursor + 1, keys, total_keys: 5_000_000 });
|
||||
});
|
||||
|
||||
mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1 + AUTO_LOAD_MAX_CALLS);
|
||||
expect(totalIterationsRequested()).toBeLessThanOrEqual(ITERATIONS_PER_CALL + AUTO_LOAD_TOTAL_SCAN_ITERATIONS);
|
||||
});
|
||||
|
||||
it("still stops on reaching the configured max-key limit, well under the iteration budget", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
mocks.queryResultMaxRows = 3;
|
||||
let call = 0;
|
||||
mocks.redisScanKeysBatch.mockImplementation((_c: string, _d: number, cursor: number) => {
|
||||
call++;
|
||||
return Promise.resolve({ cursor: cursor + 1, keys: [keyInfo(`k${call}`)], total_keys: 5_000_000 });
|
||||
});
|
||||
|
||||
mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
// 1 initial page + 2 automatic pages reaches the 3-key limit, well before
|
||||
// the total iteration budget would otherwise cut it off.
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser loadMore failure handling (PR #6313 review)", () => {
|
||||
it("does not automatically retry after a page request fails", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 7, keys: [keyInfo("a")], total_keys: 200_004 }).mockRejectedValue(new Error("backend unavailable"));
|
||||
|
||||
mountBrowser();
|
||||
await settleThoroughly();
|
||||
|
||||
// One successful initial page, one failed automatic follow-up — and then
|
||||
// nothing further, even after settling repeatedly.
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
|
||||
await settleThoroughly(10);
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("charges a failed automatic request to the shared iteration budget", async () => {
|
||||
stubNonOverflowingViewport();
|
||||
mocks.redisScanKeysBatch
|
||||
.mockResolvedValueOnce({ cursor: 7, keys: [keyInfo("a")], total_keys: 200_004 })
|
||||
.mockRejectedValueOnce(new Error("backend unavailable"))
|
||||
.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve({ cursor: cursor + 1, keys: [], total_keys: 0 }));
|
||||
|
||||
const host = mountBrowser();
|
||||
await settleThoroughly();
|
||||
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
|
||||
|
||||
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize"));
|
||||
await settleThoroughly();
|
||||
|
||||
expect(totalIterationsRequested()).toBeLessThanOrEqual(ITERATIONS_PER_CALL + AUTO_LOAD_TOTAL_SCAN_ITERATIONS);
|
||||
expect(mocks.redisScanKeysBatch.mock.calls.length).toBeLessThanOrEqual(1 + AUTO_LOAD_MAX_CALLS);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,7 @@ const fetchAllStopRequested = ref(false);
|
||||
const fetchAllLoadedCount = ref(0);
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const keyPaneRef = ref<HTMLElement>();
|
||||
const redisKeyScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
|
||||
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
||||
const commandTerminalRef = ref<HTMLElement>();
|
||||
const searchPattern = ref("");
|
||||
@@ -161,6 +162,28 @@ const createKeyTypeHelpOffsetTop = ref(0);
|
||||
let nextEntryId = 0;
|
||||
let searchRequestId = 0;
|
||||
let loadMoreOperationId = 0;
|
||||
// Mutable so `fetchScanPage` can decrement it in place as it consumes real
|
||||
// backend calls, without changing its return type.
|
||||
interface ScanIterationBudget {
|
||||
remaining: number;
|
||||
}
|
||||
// Automatic continuation (see `maybeAutoLoadMoreRedisKeys`) has no natural stop
|
||||
// condition when a search is sparse: unique visible keys barely grow, so the
|
||||
// scroller keeps reporting a short viewport forever. A *page count* budget is
|
||||
// not enough on its own: each page's `fetchScanPage` already retries within
|
||||
// its own cumulative COUNT budget while a page comes back empty, so a single
|
||||
// automatic "page" can still cost dozens of backend calls and SCAN
|
||||
// iterations. Give the whole automatic-fill operation ONE shared budget of
|
||||
// actual SCAN iterations (the same unit as the `max_iterations` sent to the
|
||||
// backend), decremented by every backend call the automatic path makes —
|
||||
// regardless of how many pages/keys those calls span — and stop deterministically
|
||||
// the moment it's spent. Reset alongside the rest of the per-operation state
|
||||
// in `invalidateScanRequests`. An explicit "Load more" click or a real scroll
|
||||
// event is a single user-triggered request and keeps its own uncapped
|
||||
// per-call budget (see `fetchScanPage`); only the automatic follow-up check
|
||||
// they hand off to afterward is constrained by this shared budget.
|
||||
const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
||||
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
let redisBrowserIsActive = true;
|
||||
let reloadKeysOnActivation = false;
|
||||
let redisDbFlushedListenerRegistered = false;
|
||||
@@ -514,6 +537,7 @@ function invalidateScanRequests(): number {
|
||||
searchRequestId++;
|
||||
loadMoreOperationId++;
|
||||
loadingMore.value = false;
|
||||
autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
return searchRequestId;
|
||||
}
|
||||
|
||||
@@ -521,7 +545,7 @@ function isCurrentScanOperation(requestId: number, operationId?: number): boolea
|
||||
return requestId === searchRequestId && (operationId === undefined || operationId === loadMoreOperationId);
|
||||
}
|
||||
|
||||
async function fetchScanPage(requestId = searchRequestId, operationId?: number): Promise<RedisScanResult> {
|
||||
async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<RedisScanResult> {
|
||||
const pageSize = redisScanPageSize.value;
|
||||
if (isValueSearchMode.value) {
|
||||
return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all");
|
||||
@@ -534,23 +558,27 @@ async function fetchScanPage(requestId = searchRequestId, operationId?: number):
|
||||
// continue sparse searches without turning one request into a full scan.
|
||||
const scanCountBudget = 50_000;
|
||||
const iterationsPerCall = 8;
|
||||
const maxIterations = Math.max(1, Math.ceil(scanCountBudget / Math.max(1, pageSize)));
|
||||
const perCallMaxIterations = Math.max(1, Math.ceil(scanCountBudget / Math.max(1, pageSize)));
|
||||
// When part of the automatic-fill chain, also cap this call to whatever is
|
||||
// left of the shared iteration budget — this is what actually bounds the
|
||||
// total backend work across every page that chain triggers, not just this
|
||||
// one call's own per-call cap.
|
||||
const maxIterations = iterationBudget ? Math.max(0, Math.min(perCallMaxIterations, iterationBudget.remaining)) : perCallMaxIterations;
|
||||
let completedIterations = 0;
|
||||
let cursor = scanCursor.value;
|
||||
let totalKeys = 0;
|
||||
|
||||
while (completedIterations < maxIterations) {
|
||||
if (!isCurrentScanOperation(requestId, operationId)) {
|
||||
return { cursor, keys: [], total_keys: totalKeys };
|
||||
}
|
||||
if (!isCurrentScanOperation(requestId, operationId)) break;
|
||||
const iterations = Math.min(iterationsPerCall, maxIterations - completedIterations);
|
||||
if (iterationBudget) iterationBudget.remaining -= iterations;
|
||||
const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, effectivePattern.value, pageSize, iterations, true);
|
||||
completedIterations += iterations;
|
||||
if (totalKeys === 0) totalKeys = result.total_keys;
|
||||
if (result.keys.length > 0 || result.cursor === 0) {
|
||||
return { ...result, total_keys: totalKeys };
|
||||
}
|
||||
cursor = result.cursor;
|
||||
completedIterations += iterations;
|
||||
}
|
||||
|
||||
return { cursor, keys: [], total_keys: totalKeys };
|
||||
@@ -605,8 +633,8 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
|
||||
return newKeys.length;
|
||||
}
|
||||
|
||||
async function scanNextPage(requestId = searchRequestId, operationId?: number): Promise<boolean> {
|
||||
const result = await fetchScanPage(requestId, operationId);
|
||||
async function scanNextPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<boolean> {
|
||||
const result = await fetchScanPage(requestId, operationId, iterationBudget);
|
||||
if (!isCurrentScanOperation(requestId, operationId)) return false;
|
||||
appendScanResult(result);
|
||||
return true;
|
||||
@@ -638,6 +666,11 @@ async function loadKeys() {
|
||||
expandedGroupIds.value = new Set();
|
||||
scanCursor.value = 0;
|
||||
lastTotalKeys.value = 0;
|
||||
// Only chain the automatic continuation after a page actually applied. A
|
||||
// throw (network/backend failure) must not schedule another attempt — the
|
||||
// `finally` block below always runs on failure too, so success is tracked
|
||||
// separately and checked once we're clear of it.
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (isValueSearchMode.value && !valueQuery.value) {
|
||||
hasMore.value = false;
|
||||
@@ -647,14 +680,18 @@ async function loadKeys() {
|
||||
if (applied && isValueSearchMode.value) {
|
||||
await streamValueSearch(requestId);
|
||||
}
|
||||
succeeded = applied;
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
if (succeeded && requestId === searchRequestId) {
|
||||
void maybeAutoLoadMoreRedisKeys();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
async function loadMore(iterationBudget?: ScanIterationBudget) {
|
||||
// 与 loadKeys 对称:组件被 keep-alive 包裹且停用后,挂起的 rAF 仍可能触发本函数,
|
||||
// 守卫掉停用态避免对隐藏组件跑一次冗余 SCAN。
|
||||
if (!redisBrowserIsActive) return;
|
||||
@@ -662,13 +699,61 @@ async function loadMore() {
|
||||
const requestId = searchRequestId;
|
||||
const operationId = ++loadMoreOperationId;
|
||||
loadingMore.value = true;
|
||||
// Same reasoning as `loadKeys`: a failed page must not trigger another
|
||||
// automatic attempt from `finally`, or a persistent failure retries forever
|
||||
// (bounded only by hasMore/viewport state, neither of which a failure changes).
|
||||
let applied = false;
|
||||
try {
|
||||
await scanNextPage(requestId, operationId);
|
||||
applied = await scanNextPage(requestId, operationId, iterationBudget);
|
||||
} finally {
|
||||
if (isCurrentScanOperation(requestId, operationId)) {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
// A manual "Load more" click or scroll-driven page is one user-triggered
|
||||
// request, uncapped by the shared budget (see `iterationBudget` above); but
|
||||
// if the viewport is still short afterward, hand off to the same bounded
|
||||
// automatic-fill check as everywhere else instead of relying on the user to
|
||||
// notice and click again.
|
||||
if (applied && isCurrentScanOperation(requestId, operationId)) {
|
||||
void maybeAutoLoadMoreRedisKeys();
|
||||
}
|
||||
}
|
||||
|
||||
// Tree mode collapses most rows by default, so the loaded key count and the
|
||||
// rendered row count can diverge wildly (e.g. 1000 loaded keys folded into a
|
||||
// handful of visible top-level groups). When that happens the scroller never
|
||||
// overflows its viewport, so it never emits a native `scroll` event and
|
||||
// `onRedisKeyScroll` — the only other caller of `loadMore` — never runs,
|
||||
// silently stranding the browser on the first sparse SCAN page forever. Keep
|
||||
// pulling pages after any load until the view is either actually scrollable
|
||||
// or genuinely out of keys/budget, mirroring the same threshold logic the
|
||||
// scroll handler already uses.
|
||||
async function maybeAutoLoadMoreRedisKeys() {
|
||||
await nextTick();
|
||||
// Unique visible/loaded keys are a poor stop condition on their own: an
|
||||
// empty, all-duplicate, or sparsely-matching page grows that count by ~0,
|
||||
// so relying on it alone lets a short viewport turn an ordinary tree load
|
||||
// into an unbounded chain of SCAN pages. Stop deterministically — with zero
|
||||
// further backend calls — the instant the shared iteration budget for this
|
||||
// operation is spent, independent of how many (if any) new keys prior calls
|
||||
// yielded.
|
||||
if (autoLoadBudget.remaining <= 0) return;
|
||||
const scroller = redisKeyScrollerRef.value?.$el as HTMLElement | undefined;
|
||||
if (!scroller) return;
|
||||
const shouldLoad = shouldLoadMoreRedisKeys({
|
||||
enabled: redisInfiniteScrollEnabled.value,
|
||||
hasMore: hasMore.value,
|
||||
busy: loading.value || loadingMore.value || searchPending.value || deletingKeys.value || isFetchingAll.value,
|
||||
loadedKeys: flatKeys.value.length,
|
||||
maxKeys: redisInfiniteScrollMaxKeys.value,
|
||||
scrollTop: scroller.scrollTop,
|
||||
clientHeight: scroller.clientHeight,
|
||||
scrollHeight: scroller.scrollHeight,
|
||||
});
|
||||
if (shouldLoad) {
|
||||
await loadMore(autoLoadBudget).catch((error) => toast(errorMessage(error), 5000));
|
||||
}
|
||||
}
|
||||
|
||||
function onRedisKeyScroll(event: Event) {
|
||||
@@ -746,6 +831,7 @@ function toggleGroup(groupId: string) {
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
expandedGroupIds.value = next;
|
||||
void maybeAutoLoadMoreRedisKeys();
|
||||
}
|
||||
|
||||
function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
|
||||
@@ -1886,7 +1972,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
|
||||
<template v-if="hasMore">
|
||||
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore()">
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
@@ -1899,7 +1985,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ loadingEmptyText }}</span>
|
||||
</div>
|
||||
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll">
|
||||
<RecycleScroller v-else ref="redisKeyScrollerRef" class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll" @resize="maybeAutoLoadMoreRedisKeys">
|
||||
<template #default="{ item: row }">
|
||||
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
|
||||
<div
|
||||
@@ -1959,7 +2045,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
{{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }}
|
||||
</div>
|
||||
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || deletingKeys" @click="loadMore">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || deletingKeys" @click="loadMore()">
|
||||
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
|
||||
{{ t("redis.loadMoreKeys") }}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user