diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 787faeffaf..88a16b37fd 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -1638,7 +1638,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { expect(mocks.toast).toHaveBeenCalledWith("deleted 3001", 3000); }); - it("preserves the cursor and finds a sparse fuzzy match after a bounded continuation", async () => { + it("finds a sparse fuzzy match within the keyspace-aware budget without manual load-more (#12412)", async () => { mocks.redisScanPageSize = 1_000; mountBrowser(); await settle(); @@ -1654,15 +1654,17 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { return Promise.resolve({ cursor: batch * 100, keys: [], total_keys: batch === 1 ? 200_000 : 0 }); }); + // DBSIZE 200k → 200 SCAN iterations of keyspace-aware budget: the + // automatic first page keeps scanning (8 backend calls) until the sparse + // match at iteration 64 instead of stopping after the old 50-iteration + // shared-budget slice (7 calls), so the match surfaces without a manual + // load-more click (#12412). 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(() => expect(groupRowOrUndefined("issue5012")).toBeDefined()); - expect(Array.from(document.querySelectorAll(".dbx-editor-font-family")).map((element) => element.textContent)).not.toContain("target"); + await settle(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(8); + expect(document.body.textContent).not.toContain("target"); + groupRow("issue5012").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); await settle(); await vi.waitFor(() => { @@ -1670,7 +1672,14 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { expect(labels).toContain("target"); }); - expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(8); + // The cursor handed back with the matching page is preserved: the manual + // continuation resumes from cursor 801 and scans its own keyspace-aware + // page (200 iterations = 25 backend calls) past the end of the mock. + clickButtonWithText("redis.loadMoreKeys"); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(33)); + await settle(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(33); + expect(mocks.redisScanKeysBatch.mock.calls[8]?.[2]).toBe(801); expect(mocks.redisScanKeysBatch.mock.calls.every((call) => call[3] === "*issue5012:target*")).toBe(true); }); @@ -1688,20 +1697,27 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { return Promise.resolve({ cursor: batch, keys: [], total_keys: batch === 1 ? 5_000_000 : 0 }); }); + // DBSIZE 5M → keyspace-aware budget = 1M COUNT = 1000 SCAN iterations, so + // the automatic first page spends up to 1000 iterations (125 backend calls + // at 8 iterations each) instead of the old shared 50-iteration slice (7 + // calls), then stops with the cursor saved (#12412). await submitKeySearch("missing-key"); - await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(7)); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(125)); await settle(); - expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(7); - expect(mocks.redisScanKeysBatch.mock.calls[mocks.redisScanKeysBatch.mock.calls.length - 1]?.[2]).toBe(6); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(125); + expect(mocks.redisScanKeysBatch.mock.calls[124]?.[2]).toBe(124); expect(document.body.textContent).toContain("redis.noKeysInScanHint"); expect(Array.from(document.querySelectorAll("button")).some((button) => button.textContent?.includes("redis.loadMoreKeys"))).toBe(true); + // The manual continuation resumes from the saved cursor with the same + // keyspace-aware per-page budget (another 1000 iterations) instead of + // falling back to the old 50k COUNT floor. clickButtonWithText("redis.loadMoreKeys"); - await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(14)); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(250)); - expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(14); - expect(mocks.redisScanKeysBatch.mock.calls[7]?.[2]).toBe(7); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(250); + expect(mocks.redisScanKeysBatch.mock.calls[125]?.[2]).toBe(125); expect(document.body.textContent).toContain("redis.noKeysInScanHint"); }); @@ -1731,11 +1747,10 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { await vi.waitFor(() => expect(document.body.textContent).toContain("first")); expect(requiredElement(".redis-key-count").textContent).toBe("1+ keys"); - clickButtonWithText("redis.loadMoreKeys"); - await vi.waitFor(() => expect(continuationBatch).toBe(7)); - expect(document.body.textContent).toContain("first"); - expect(document.body.textContent).not.toContain("next"); - + // One continuation click now scans the keyspace-aware budget (200k keys → + // 200 iterations) and reaches the next sparse match while keeping the + // already found key; previously the same click was cut off at the old + // 50-iteration slice just short of the match and needed a second click (#12412). clickButtonWithText("redis.loadMoreKeys"); await vi.waitFor(() => expect(document.body.textContent).toContain("next")); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 90b678b73c..ea856fdf58 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -246,6 +246,10 @@ let loadMoreOperationId = 0; // backend calls, without changing its return type. interface ScanIterationBudget { remaining: number; + // Current chain allowance. A fuzzy key search raises both fields together + // once DBSIZE is known (see `fetchScanPage`), so later pages observe an + // already-expanded budget instead of re-inflating it from scratch (#12412). + total: number; } // Automatic continuation (see `maybeAutoLoadMoreRedisKeys`) has no natural stop // condition when a search is sparse: unique visible keys barely grow, so the @@ -262,9 +266,12 @@ interface ScanIterationBudget { // same retained operation. 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. +// they hand off to afterward is constrained by this shared budget. A fuzzy +// key search raises this allowance to its keyspace-aware budget once DBSIZE +// is observed (see `fetchScanPage`); ordinary loads keep the conservative +// default below. const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50; -let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; +let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS, total: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; let redisBrowserIsActive = true; let reloadKeysOnActivation = false; let redisDbFlushedListenerRegistered = false; @@ -1002,7 +1009,7 @@ function invalidateScanRequests(resetAutoLoadBudget = true): number { loadMoreOperationId++; loadingMore.value = false; subtreeFillOperations.clear(); - if (resetAutoLoadBudget) autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; + if (resetAutoLoadBudget) autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS, total: AUTO_LOAD_TOTAL_SCAN_ITERATIONS }; return searchRequestId; } @@ -1027,7 +1034,11 @@ async function fetchScanPage(requestId = searchRequestId, operationId?: number, // preserve the returned cursor, and let the existing "Load more" action // continue sparse searches without turning one request into a full scan. const isFuzzyKeySearch = searchMode.value === "key" && fuzzyKeySearch.value; - let scanCountBudget = redisFuzzySearchScanBudget(0); + // DBSIZE only travels with the cursor==0 batch; carry the last observed + // keyspace across continuation pages so their COUNT budget stays + // keyspace-aware instead of collapsing back to the 50k floor (#12412). + let totalKeys = isFuzzyKeySearch ? lastTotalKeys.value : 0; + let scanCountBudget = redisFuzzySearchScanBudget(totalKeys); const iterationsPerCall = 8; 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 @@ -1037,7 +1048,6 @@ async function fetchScanPage(requestId = searchRequestId, operationId?: number, let 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)) break; @@ -1049,6 +1059,18 @@ async function fetchScanPage(requestId = searchRequestId, operationId?: number, if (isFuzzyKeySearch && totalKeys > 0) { scanCountBudget = redisFuzzySearchScanBudget(totalKeys); const fuzzyMaxIterations = Math.ceil(scanCountBudget / Math.max(1, pageSize)); + if (iterationBudget) { + // The shared automatic-fill budget starts at the conservative + // AUTO_LOAD_TOTAL_SCAN_ITERATIONS default, which suits ordinary loads + // but clamps a fuzzy search to a handful of SCAN pages no matter how + // large the keyspace is (#12412). Raise the chain allowance to the + // keyspace-aware total (never shrink it) so the `availableIterations` + // below can hand the grown budget to this page; iterations already + // spent still count against the raised total. + const expandedTotal = Math.max(iterationBudget.total, fuzzyMaxIterations); + iterationBudget.remaining += expandedTotal - iterationBudget.total; + iterationBudget.total = expandedTotal; + } const availableIterations = iterationBudget ? completedIterations + iterationBudget.remaining : fuzzyMaxIterations; maxIterations = Math.min(fuzzyMaxIterations, Math.max(maxIterations, availableIterations)); }