fix(redis): let fuzzy search cover keyspace aware scan budget

This commit is contained in:
弈秋
2026-10-16 17:40:57 +08:00
committed by GitHub
parent 46280b7e87
commit 4e449037e6
2 changed files with 62 additions and 25 deletions
@@ -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<HTMLElement>(".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<HTMLElement>(".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"));
@@ -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));
}