fix(redis): isolate sparse scan continuation state
This commit is contained in:
1 parent
043ecfeafa
commit
3e3e6d04b8
2 files changed
+242
-22
No files matched your search
@@ -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<T>() {
|
||||
|
||||
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<HTMLElement>(".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<HTMLInputElement>("[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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("button")).filter((button) => button.textContent?.includes("redis.loadMoreKeys"))).toHaveLength(0));
|
||||
const labels = Array.from(document.querySelectorAll<HTMLElement>(".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==";
|
||||
|
||||
@@ -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<RedisScanResult> {
|
||||
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<RedisScanResult> {
|
||||
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<RedisScanResu
|
||||
let totalKeys = 0;
|
||||
|
||||
while (completedIterations < maxIterations) {
|
||||
if (requestId !== searchRequestId) {
|
||||
if (!isCurrentScanOperation(requestId, operationId)) {
|
||||
return { cursor, keys: [], total_keys: totalKeys };
|
||||
}
|
||||
const iterations = Math.min(iterationsPerCall, maxIterations - completedIterations);
|
||||
@@ -479,9 +493,9 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
|
||||
return newKeys.length;
|
||||
}
|
||||
|
||||
async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
|
||||
const result = await fetchScanPage(requestId);
|
||||
if (requestId !== searchRequestId) return false;
|
||||
async function scanNextPage(requestId = searchRequestId, operationId?: number): Promise<boolean> {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user