From e671b3da7dfb841b7fcf17f4d141f5425755147d Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Sun, 16 Aug 2026 14:09:02 +0800 Subject: [PATCH] fix(redis): keep automatic scan loading within shared budget --- .../RedisKeyBrowser.infiniteScroll.spec.ts | 576 ++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 112 +++- 2 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts new file mode 100644 index 0000000000..0ea86016ca --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index a36ec3881e..f1a2810124 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -103,6 +103,7 @@ const fetchAllStopRequested = ref(false); const fetchAllLoadedCount = ref(0); const rootRef = ref(); const keyPaneRef = ref(); +const redisKeyScrollerRef = ref | null>(null); const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null); const commandTerminalRef = ref(); 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 { +async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise { 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 { - const result = await fetchScanPage(requestId, operationId); +async function scanNextPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise { + 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 });