fix(redis): keep automatic scan loading within shared budget

This commit is contained in:
Melody Ruch
2026-08-16 14:09:02 +08:00
committed by GitHub
parent 8996da7d9a
commit e671b3da7d
2 changed files with 675 additions and 13 deletions
@@ -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>