diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 5f6c10bfde..f387471ac6 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000), toast: vi.fn(), updateRedisDbKeyStats: vi.fn(), + redisScanPageSize: 100, })); vi.mock("@/lib/backend/api", () => ({ @@ -52,7 +53,7 @@ vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => { vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ ensureConnected: vi.fn().mockResolvedValue(undefined), - getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: 100 }), + getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }), updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, invalidateCompletionCache: vi.fn(), refreshRedisDbKeyCounts: vi.fn(), @@ -81,7 +82,7 @@ vi.mock("@/components/ui/button", async () => { }); vi.mock("@/components/ui/input", async () => { - const { defineComponent, h } = await import("vue"); + const { defineComponent, h, mergeProps } = await import("vue"); return { Input: defineComponent({ inheritAttrs: false, @@ -89,12 +90,14 @@ vi.mock("@/components/ui/input", async () => { emits: ["update:modelValue"], setup(props, { attrs, emit }) { return () => - h("input", { - ...attrs, - value: props.modelValue ?? "", - disabled: props.disabled, - onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), - }); + h( + "input", + mergeProps(attrs, { + value: props.modelValue ?? "", + disabled: props.disabled, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + ); }, }), }; @@ -342,6 +345,7 @@ function deferred() { function resetApiMocks() { vi.clearAllMocks(); + mocks.redisScanPageSize = 100; mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw))); mocks.redisSetString.mockResolvedValue(undefined); @@ -681,6 +685,199 @@ describe("RedisKeyBrowser expiry creation", () => { }); describe("RedisKeyBrowser fuzzy key hierarchy", () => { + it("preserves the cursor and finds a sparse fuzzy match after a bounded continuation", async () => { + mocks.redisScanPageSize = 1_000; + mountBrowser(); + await settle(); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const target = { key_display: "issue5012:target", key_raw: "aXNzdWU1MDEyOnRhcmdldA==", key_type: "string", ttl: -1 }; + let batch = 0; + mocks.redisScanKeysBatch.mockReset(); + mocks.redisScanKeysBatch.mockImplementation(() => { + batch += 1; + if (batch === 8) return Promise.resolve({ cursor: 801, keys: [target], total_keys: 0 }); + return Promise.resolve({ cursor: batch * 100, keys: [], total_keys: batch === 1 ? 200_000 : 0 }); + }); + + await submitKeySearch("issue5012:target"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(7)); + await settle(); + expect(document.body.textContent).not.toContain("target"); + expect(document.body.textContent).toContain("redis.noKeysInScanHint"); + + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => { + const labels = Array.from(document.querySelectorAll(".dbx-editor-font-family")).map((element) => element.textContent); + expect(labels).toContain("target"); + }); + + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(8); + expect(mocks.redisScanKeysBatch.mock.calls.every((call) => call[3] === "*issue5012:target*")).toBe(true); + }); + + it("bounds a large no-match keyspace and exposes the saved-cursor continuation", async () => { + mocks.redisScanPageSize = 1_000; + mountBrowser(); + await settle(); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + let batch = 0; + mocks.redisScanKeysBatch.mockReset(); + mocks.redisScanKeysBatch.mockImplementation(() => { + batch += 1; + return Promise.resolve({ cursor: batch, keys: [], total_keys: batch === 1 ? 5_000_000 : 0 }); + }); + + await submitKeySearch("missing-key"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(7)); + await settle(); + + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(7); + expect(mocks.redisScanKeysBatch.mock.calls[mocks.redisScanKeysBatch.mock.calls.length - 1]?.[2]).toBe(6); + expect(document.body.textContent).toContain("redis.noKeysInScanHint"); + expect(Array.from(document.querySelectorAll("button")).some((button) => button.textContent?.includes("redis.loadMoreKeys"))).toBe(true); + + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(14)); + + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(14); + expect(mocks.redisScanKeysBatch.mock.calls[7]?.[2]).toBe(7); + expect(document.body.textContent).toContain("redis.noKeysInScanHint"); + }); + + it("keeps existing matches while continuing to the next sparse fuzzy result", async () => { + mocks.redisScanPageSize = 1_000; + mountBrowser(); + await settle(); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const first = { key_display: "issue5012:first", key_raw: "aXNzdWU1MDEyOmZpcnN0", key_type: "string", ttl: -1 }; + const next = { key_display: "issue5012:next", key_raw: "aXNzdWU1MDEyOm5leHQ=", key_type: "string", ttl: -1 }; + let continuationBatch = 0; + mocks.redisScanKeysBatch.mockReset(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 1, keys: [first], total_keys: 200_000 }); + continuationBatch += 1; + if (continuationBatch === 8) return Promise.resolve({ cursor: 0, keys: [next], total_keys: 0 }); + return Promise.resolve({ cursor: continuationBatch + 1, keys: [], total_keys: 0 }); + }); + + await submitKeySearch("issue5012"); + await vi.waitFor(() => expect(document.body.textContent).toContain("first")); + + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => expect(continuationBatch).toBe(7)); + expect(document.body.textContent).toContain("first"); + expect(document.body.textContent).not.toContain("next"); + + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => expect(document.body.textContent).toContain("next")); + + expect(continuationBatch).toBe(8); + expect(document.body.textContent).toContain("first"); + }); + + it("cancels a sparse scan immediately when the search text changes", async () => { + mocks.redisScanPageSize = 1_000; + mountBrowser(); + await settle(); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const oldPage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const fresh = { key_display: "new:result", key_raw: "bmV3OnJlc3VsdA==", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockReset(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, _cursor: number, pattern: string) => { + if (pattern === "*old*") return oldPage.promise; + return Promise.resolve({ cursor: 0, keys: [fresh], total_keys: 1 }); + }); + + await submitKeySearch("old"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1)); + + const input = requiredElement("[data-redis-search-input]"); + input.value = "new"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + oldPage.resolve({ cursor: 1, keys: [], total_keys: 1_000_000 }); + await settle(); + + expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[3] === "*old*")).toHaveLength(1); + await vi.waitFor(() => expect(document.body.textContent).toContain("result")); + expect(mocks.redisScanKeysBatch.mock.calls.map((call) => call[3])).toEqual(["*old*", "*new*"]); + }); + + it("keeps a current continuation locked when an older load-more request finishes", async () => { + mocks.redisScanPageSize = 1_000; + mountBrowser(); + await settle(); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const oldContinuation = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const currentContinuation = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const oldInitial = { key_display: "old:initial", key_raw: "b2xkOmluaXRpYWw=", key_type: "string", ttl: -1 }; + const currentInitial = { key_display: "new:initial", key_raw: "bmV3OmluaXRpYWw=", key_type: "string", ttl: -1 }; + const currentFirstPage = { key_display: "new:first-page", key_raw: "bmV3OmZpcnN0LXBhZ2U=", key_type: "string", ttl: -1 }; + const currentLastPage = { key_display: "new:last-page", key_raw: "bmV3Omxhc3QtcGFnZQ==", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockReset(); + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "*old*") { + if (cursor === 0) return Promise.resolve({ cursor: 11, keys: [oldInitial], total_keys: 100 }); + return oldContinuation.promise; + } + if (pattern === "*new*") { + if (cursor === 0) return Promise.resolve({ cursor: 21, keys: [currentInitial], total_keys: 100 }); + if (cursor === 21) return Promise.resolve({ cursor: 22, keys: [currentFirstPage], total_keys: 0 }); + if (cursor === 22) return currentContinuation.promise; + } + return Promise.resolve({ cursor: 0, keys: [], total_keys: 0 }); + }); + + await submitKeySearch("old"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[2] === 0 && call[3] === "*old*")).toHaveLength(1)); + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.some((call) => call[2] === 11)).toBe(true)); + + await submitKeySearch("new"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[2] === 0 && call[3] === "*new*")).toHaveLength(1)); + const firstCurrentLoadMore = await vi.waitFor(() => { + const buttons = Array.from(document.querySelectorAll("button")).filter((candidate) => candidate.textContent?.includes("redis.loadMoreKeys")); + const button = buttons.find((candidate) => !candidate.disabled); + expect(button).toBeDefined(); + return button!; + }); + firstCurrentLoadMore.click(); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[2] === 21)).toHaveLength(1)); + const secondCurrentLoadMore = await vi.waitFor(() => { + const button = Array.from(document.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("redis.loadMoreKeys") && !candidate.disabled); + expect(button).toBeDefined(); + return button!; + }); + secondCurrentLoadMore.click(); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[2] === 22)).toHaveLength(1)); + + oldContinuation.resolve({ cursor: 12, keys: [], total_keys: 0 }); + await settle(); + + const loadMoreButtons = Array.from(document.querySelectorAll("button")).filter((button) => button.textContent?.includes("redis.loadMoreKeys")); + expect(loadMoreButtons.length).toBeGreaterThan(0); + expect(loadMoreButtons.every((button) => button.disabled)).toBe(true); + loadMoreButtons[0]!.click(); + await settle(); + expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[2] === 22)).toHaveLength(1); + + currentContinuation.resolve({ cursor: 0, keys: [currentLastPage], total_keys: 0 }); + await vi.waitFor(() => expect(Array.from(document.querySelectorAll("button")).filter((button) => button.textContent?.includes("redis.loadMoreKeys"))).toHaveLength(0)); + const labels = Array.from(document.querySelectorAll(".dbx-editor-font-family")).map((element) => element.textContent); + expect(labels).toContain("new"); + expect(labels).not.toContain("old"); + }); + it("keeps NUL-containing fuzzy groups isolated when selecting keys to delete", async () => { const firstKeyRaw = "cmF3LWZpcnN0"; const secondKeyRaw = "cmF3LXNlY29uZA=="; diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 0cda469b2b..07ae57edc5 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -141,6 +141,7 @@ const createKeyTypeHelpPanel = ref<{ element?: HTMLElement }>(); const createKeyTypeHelpOffsetTop = ref(0); let nextEntryId = 0; let searchRequestId = 0; +let loadMoreOperationId = 0; let redisBrowserIsActive = true; let reloadKeysOnActivation = false; let redisDbFlushedListenerRegistered = false; @@ -397,15 +398,28 @@ function mergeTree(newKeys: RedisKeyInfo[]) { expandedGroupIds.value = nextExpanded; } -async function fetchScanPage(requestId = searchRequestId): Promise { +function invalidateScanRequests(): number { + searchRequestId++; + loadMoreOperationId++; + loadingMore.value = false; + return searchRequestId; +} + +function isCurrentScanOperation(requestId: number, operationId?: number): boolean { + return requestId === searchRequestId && (operationId === undefined || operationId === loadMoreOperationId); +} + +async function fetchScanPage(requestId = searchRequestId, operationId?: number): Promise { const pageSize = redisScanPageSize.value; if (isValueSearchMode.value) { return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all"); } // Keep each backend call small so a changed search can cancel between calls. - // The total COUNT budget bounds Redis work while giving sparse MATCH patterns - // substantially more coverage than a fixed number of SCAN calls. + // COUNT is only a hint, so an empty batch does not mean the iteration is + // complete. Bound every user-triggered page to a cumulative COUNT budget, + // preserve the returned cursor, and let the existing "Load more" action + // 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))); @@ -414,7 +428,7 @@ async function fetchScanPage(requestId = searchRequestId): Promise { - const result = await fetchScanPage(requestId); - if (requestId !== searchRequestId) return false; +async function scanNextPage(requestId = searchRequestId, operationId?: number): Promise { + const result = await fetchScanPage(requestId, operationId); + if (!isCurrentScanOperation(requestId, operationId)) return false; appendScanResult(result); return true; } @@ -498,7 +512,7 @@ async function loadKeys() { if (searchTimer) clearTimeout(searchTimer); searchTimer = null; searchPending.value = false; - const requestId = ++searchRequestId; + const requestId = invalidateScanRequests(); isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; @@ -531,11 +545,14 @@ async function loadKeys() { async function loadMore() { if (!hasMore.value || loadingMore.value) return; const requestId = searchRequestId; + const operationId = ++loadMoreOperationId; loadingMore.value = true; try { - await scanNextPage(requestId); + await scanNextPage(requestId, operationId); } finally { - loadingMore.value = false; + if (isCurrentScanOperation(requestId, operationId)) { + loadingMore.value = false; + } } } @@ -713,7 +730,7 @@ function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openCo } function resetLoadedKeys() { - searchRequestId++; + invalidateScanRequests(); isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; @@ -733,7 +750,7 @@ async function deleteKeyRaws(keys: string[]) { if (uniqueKeys.length === 0 || deletingKeys.value) return; // Ignore a late SCAN page while an explicit mutation changes this result set. - searchRequestId++; + invalidateScanRequests(); fetchAllStopRequested.value = true; deletingKeys.value = true; try { @@ -1256,6 +1273,13 @@ let hasAutoFocusedSearch = false; function onSearchInput() { if (searchTimer) clearTimeout(searchTimer); + // Invalidate in-flight SCAN work as soon as the query changes instead of + // waiting for the debounce timer to start the replacement search. + invalidateScanRequests(); + loading.value = false; + isFetchingAll.value = false; + fetchAllStopRequested.value = true; + fetchAllLoadedCount.value = 0; searchPending.value = true; searchTimer = setTimeout(() => { void loadKeys(); @@ -1336,12 +1360,11 @@ function pauseRedisBrowserBackgroundWork() { // keys that were never rendered. const discardIncompleteFetchAll = isFetchingAll.value; redisBrowserIsActive = false; - searchRequestId++; + invalidateScanRequests(); isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; loading.value = false; - loadingMore.value = false; searchPending.value = false; if (searchTimer) clearTimeout(searchTimer); searchTimer = null;