fix(redis): bound implicit key and value scanning
This commit is contained in:
4 files changed
+720
-43
No files matched your search
@@ -1851,6 +1851,50 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("RedisKeyBrowser KeepAlive scan budget (issue #7779)", () => {
|
||||||
|
it("does not replenish an exhausted automatic budget after deactivate/reactivate and resize", async () => {
|
||||||
|
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||||
|
const 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mocks.infiniteScroll = true;
|
||||||
|
mocks.redisScanPageSize = 1000;
|
||||||
|
let call = 0;
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||||
|
call++;
|
||||||
|
return Promise.resolve({ cursor: cursor + 1, keys: call === 1 ? [{ key_display: "seed", key_raw: "c2VlZA==", key_type: "string", ttl: -1 }] : [], total_keys: 5_000_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(8));
|
||||||
|
await settle();
|
||||||
|
const callsAtBudget = mocks.redisScanKeysBatch.mock.calls.length;
|
||||||
|
|
||||||
|
await browser.deactivate();
|
||||||
|
await browser.activate();
|
||||||
|
requiredElement<HTMLElement>(".redis-key-scroller").dispatchEvent(new Event("resize"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(callsAtBudget);
|
||||||
|
} finally {
|
||||||
|
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
|
||||||
|
if (originalScrollHeight) Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("RedisKeyBrowser interrupted Fetch All", () => {
|
describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||||
it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => {
|
it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => {
|
||||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { createApp, nextTick } from "vue";
|
import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
ensureConnected: vi.fn(),
|
||||||
redisScanKeysBatch: vi.fn(),
|
redisScanKeysBatch: vi.fn(),
|
||||||
|
redisScanValues: vi.fn(),
|
||||||
redisGetValue: vi.fn(),
|
redisGetValue: vi.fn(),
|
||||||
redisSetString: vi.fn(),
|
redisSetString: vi.fn(),
|
||||||
redisJsonSet: vi.fn(),
|
redisJsonSet: vi.fn(),
|
||||||
@@ -34,6 +36,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@/lib/backend/api", () => ({
|
vi.mock("@/lib/backend/api", () => ({
|
||||||
redisScanKeysBatch: mocks.redisScanKeysBatch,
|
redisScanKeysBatch: mocks.redisScanKeysBatch,
|
||||||
|
redisScanValues: mocks.redisScanValues,
|
||||||
redisGetValue: mocks.redisGetValue,
|
redisGetValue: mocks.redisGetValue,
|
||||||
redisSetString: mocks.redisSetString,
|
redisSetString: mocks.redisSetString,
|
||||||
redisJsonSet: mocks.redisJsonSet,
|
redisJsonSet: mocks.redisJsonSet,
|
||||||
@@ -53,7 +56,7 @@ vi.mock("@/lib/backend/api", () => ({
|
|||||||
|
|
||||||
vi.mock("@/stores/connectionStore", () => ({
|
vi.mock("@/stores/connectionStore", () => ({
|
||||||
useConnectionStore: () => ({
|
useConnectionStore: () => ({
|
||||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
ensureConnected: mocks.ensureConnected,
|
||||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
|
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
|
||||||
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
||||||
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
|
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
|
||||||
@@ -276,6 +279,45 @@ function mountBrowser() {
|
|||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountKeptAliveBrowser(onError?: (error: unknown) => void) {
|
||||||
|
const active = ref(true);
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () =>
|
||||||
|
h(KeepAlive, null, {
|
||||||
|
default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (onError) app.config.errorHandler = onError;
|
||||||
|
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 deactivate() {
|
||||||
|
active.value = false;
|
||||||
|
await settleThoroughly();
|
||||||
|
},
|
||||||
|
async activate() {
|
||||||
|
active.value = true;
|
||||||
|
await settleThoroughly();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmountBrowser(host: HTMLElement) {
|
||||||
|
const index = mountedApps.findIndex((mounted) => mounted.host === host);
|
||||||
|
expect(index, "mounted browser").toBeGreaterThanOrEqual(0);
|
||||||
|
const [mounted] = mountedApps.splice(index, 1);
|
||||||
|
mounted!.unmount();
|
||||||
|
host.remove();
|
||||||
|
}
|
||||||
|
|
||||||
async function settle() {
|
async function settle() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -284,13 +326,25 @@ async function settle() {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((next, fail) => {
|
||||||
|
resolve = next;
|
||||||
|
reject = fail;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function resetApiMocks() {
|
function resetApiMocks() {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||||
mocks.redisScanPageSize = 100;
|
mocks.redisScanPageSize = 100;
|
||||||
mocks.infiniteScroll = true;
|
mocks.infiniteScroll = true;
|
||||||
mocks.queryResultMaxRowsEnabled = true;
|
mocks.queryResultMaxRowsEnabled = true;
|
||||||
mocks.queryResultMaxRows = 5000;
|
mocks.queryResultMaxRows = 5000;
|
||||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||||
|
mocks.redisScanValues.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// A short, un-collapsed tree (a real DB has millions of keys folded into a
|
// A short, un-collapsed tree (a real DB has millions of keys folded into a
|
||||||
@@ -450,6 +504,308 @@ function keyInfo(rawKey: string): RedisKeyInfo {
|
|||||||
return { key_display: rawKey, key_raw: rawKey, key_type: "string", ttl: -1, size: 0, value_preview: "" };
|
return { key_display: rawKey, key_raw: rawKey, key_type: "string", ttl: -1, size: 0, value_preview: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchByValue(host: HTMLElement, mode: "value" | "all" = "value") {
|
||||||
|
const modeIndex = mode === "value" ? 1 : 2;
|
||||||
|
host.querySelectorAll<HTMLButtonElement>(".redis-search-mode-button")[modeIndex]?.click();
|
||||||
|
await settle();
|
||||||
|
const input = host.querySelector<HTMLInputElement>("[data-redis-search-input]");
|
||||||
|
expect(input, "redis search input").toBeDefined();
|
||||||
|
input!.value = "needle";
|
||||||
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
await settleThoroughly();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickLoadMore(host: HTMLElement) {
|
||||||
|
const button = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((candidate) => candidate.textContent?.includes("redis.loadMoreKeys") && !candidate.disabled);
|
||||||
|
expect(button, "load more button").toBeDefined();
|
||||||
|
button!.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickFetchAll(host: HTMLElement) {
|
||||||
|
const button = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((candidate) => candidate.textContent?.includes("redis.fetchAllKeys") && !candidate.disabled);
|
||||||
|
expect(button, "fetch all button").toBeDefined();
|
||||||
|
button!.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RedisKeyBrowser bounded value search (issue #7779)", () => {
|
||||||
|
it.each([
|
||||||
|
["value", false],
|
||||||
|
["all", true],
|
||||||
|
] as const)("loads only one initial %s page and clamps COUNT", async (mode, searchBoth) => {
|
||||||
|
mocks.redisScanPageSize = 5000;
|
||||||
|
mocks.redisScanValues.mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 }).mockResolvedValue({ cursor: 0, keys: [keyInfo("unexpected")], total_keys: 0 });
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(host, mode);
|
||||||
|
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenLastCalledWith("connection", 0, 0, "*", "needle", 100, searchBoth);
|
||||||
|
expect(host.textContent).toContain("redis.loadMoreKeys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances exactly one page per Load more or real scroll and preserves the opaque cursor", async () => {
|
||||||
|
stubNonOverflowingViewport();
|
||||||
|
mocks.redisScanPageSize = 1000;
|
||||||
|
mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 41, keys: [keyInfo("match")], total_keys: 5_000_000 });
|
||||||
|
if (cursor === 41) return Promise.resolve({ cursor: 73, keys: [keyInfo("match")], total_keys: 0 });
|
||||||
|
return Promise.resolve({ cursor: 99, keys: [keyInfo("sparse")], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(host);
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize"));
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
clickLoadMore(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.redisScanValues.mock.calls[1]?.[2]).toBe(41);
|
||||||
|
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll"));
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(3));
|
||||||
|
expect(mocks.redisScanValues.mock.calls[2]?.[2]).toBe(73);
|
||||||
|
expect(mocks.redisScanValues.mock.calls.every((call) => (call[5] as number) <= 100)).toBe(true);
|
||||||
|
expect(host.textContent).toContain("sparse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit Fetch all interruptible by clamping every value page to COUNT 100", async () => {
|
||||||
|
mocks.redisScanPageSize = 10_000;
|
||||||
|
mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 11, keys: [], total_keys: 5_000_000 });
|
||||||
|
if (cursor === 11) return Promise.resolve({ cursor: 12, keys: [], total_keys: 0 });
|
||||||
|
return Promise.resolve({ cursor: 0, keys: [keyInfo("last")], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(host);
|
||||||
|
clickFetchAll(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 11, 12]);
|
||||||
|
expect(mocks.redisScanValues.mock.calls.every((call) => call[5] === 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a rejected value continuation", async () => {
|
||||||
|
stubNonOverflowingViewport();
|
||||||
|
mocks.redisScanValues.mockResolvedValueOnce({ cursor: 31, keys: [keyInfo("match")], total_keys: 5_000_000 }).mockRejectedValue(new Error("value scan failed"));
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(host);
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll"));
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(2));
|
||||||
|
await settleThoroughly();
|
||||||
|
|
||||||
|
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||||
|
await settleThoroughly(10);
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RedisKeyBrowser KeepAlive empty scan pages (issue #7779)", () => {
|
||||||
|
it.each(["value", "all"] as const)("retains an empty %s scan cursor across tab switches", async (mode) => {
|
||||||
|
mocks.infiniteScroll = false;
|
||||||
|
mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 41, keys: [], total_keys: 5_000_000 });
|
||||||
|
if (cursor === 41) return Promise.resolve({ cursor: 73, keys: [], total_keys: 0 });
|
||||||
|
return Promise.resolve({ cursor: 0, keys: [keyInfo("match")], total_keys: 0 });
|
||||||
|
});
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(browser.host, mode);
|
||||||
|
clickLoadMore(browser.host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41]);
|
||||||
|
|
||||||
|
await browser.deactivate();
|
||||||
|
await browser.activate();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41]);
|
||||||
|
|
||||||
|
clickLoadMore(browser.host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 41, 73]);
|
||||||
|
expect(browser.host.textContent).toContain("match");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["value", "all"] as const)("starts the edited %s query from zero after pausing its debounce", async (mode) => {
|
||||||
|
mocks.infiniteScroll = false;
|
||||||
|
mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, _cursor: number, _pattern: string, query: string) => Promise.resolve(query === "needle" ? { cursor: 41, keys: [], total_keys: 5_000_000 } : { cursor: 0, keys: [keyInfo("updated-match")], total_keys: 1 }));
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(browser.host, mode);
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const input = browser.host.querySelector<HTMLInputElement>("[data-redis-search-input]")!;
|
||||||
|
input.value = "updated";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await settle();
|
||||||
|
await browser.deactivate();
|
||||||
|
await browser.activate();
|
||||||
|
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => [call[2], call[4]])).toEqual([
|
||||||
|
[0, "needle"],
|
||||||
|
[0, "updated"],
|
||||||
|
]);
|
||||||
|
expect(browser.host.textContent).toContain("updated-match");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the edited query pending when connection recovery finishes while inactive", async () => {
|
||||||
|
mocks.infiniteScroll = false;
|
||||||
|
mocks.redisScanValues.mockImplementation((_connectionId: string, _db: number, _cursor: number, _pattern: string, query: string) => Promise.resolve(query === "needle" ? { cursor: 41, keys: [], total_keys: 5_000_000 } : { cursor: 0, keys: [keyInfo("updated-match")], total_keys: 1 }));
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(browser.host);
|
||||||
|
const input = browser.host.querySelector<HTMLInputElement>("[data-redis-search-input]")!;
|
||||||
|
input.value = "updated";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await settle();
|
||||||
|
await browser.deactivate();
|
||||||
|
|
||||||
|
const connectionRecovery = deferred<void>();
|
||||||
|
mocks.ensureConnected.mockReturnValueOnce(connectionRecovery.promise);
|
||||||
|
await browser.activate();
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
await browser.deactivate();
|
||||||
|
connectionRecovery.resolve();
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await browser.activate();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => [call[2], call[4]])).toEqual([
|
||||||
|
[0, "needle"],
|
||||||
|
[0, "updated"],
|
||||||
|
]);
|
||||||
|
expect(browser.host.textContent).toContain("updated-match");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed refresh on activation even when the previous empty page had more keys", async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const failure = new Error("refresh failed");
|
||||||
|
mocks.infiniteScroll = false;
|
||||||
|
mocks.redisScanValues
|
||||||
|
.mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 })
|
||||||
|
.mockRejectedValueOnce(failure)
|
||||||
|
.mockResolvedValue({ cursor: 0, keys: [keyInfo("recovered")], total_keys: 1 });
|
||||||
|
const browser = mountKeptAliveBrowser(onError);
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(browser.host);
|
||||||
|
|
||||||
|
const refresh = Array.from(browser.host.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title"));
|
||||||
|
expect(refresh, "refresh button").toBeDefined();
|
||||||
|
refresh!.click();
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(onError).toHaveBeenCalledWith(failure, expect.anything(), expect.anything());
|
||||||
|
|
||||||
|
await browser.deactivate();
|
||||||
|
await browser.activate();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 0, 0]);
|
||||||
|
expect(browser.host.textContent).toContain("recovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries an interrupted refresh without accepting its late empty page", async () => {
|
||||||
|
const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
mocks.infiniteScroll = false;
|
||||||
|
mocks.redisScanValues
|
||||||
|
.mockResolvedValueOnce({ cursor: 41, keys: [], total_keys: 5_000_000 })
|
||||||
|
.mockReturnValueOnce(pending.promise)
|
||||||
|
.mockResolvedValue({ cursor: 0, keys: [keyInfo("recovered")], total_keys: 1 });
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(browser.host);
|
||||||
|
|
||||||
|
const refresh = Array.from(browser.host.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title"));
|
||||||
|
expect(refresh, "refresh button").toBeDefined();
|
||||||
|
refresh!.click();
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await browser.deactivate();
|
||||||
|
pending.resolve({ cursor: 91, keys: [], total_keys: 0 });
|
||||||
|
await settleThoroughly();
|
||||||
|
await browser.activate();
|
||||||
|
expect(mocks.redisScanValues.mock.calls.map((call) => call[2])).toEqual([0, 0, 0]);
|
||||||
|
expect(browser.host.textContent).toContain("recovered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RedisKeyBrowser continuation ownership (issue #7779)", () => {
|
||||||
|
it("disables refresh while the current page is loading", async () => {
|
||||||
|
const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
mocks.redisScanKeysBatch.mockReturnValue(pending.promise);
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1));
|
||||||
|
const refresh = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title"));
|
||||||
|
expect(refresh, "refresh button").toBeDefined();
|
||||||
|
expect(refresh!.disabled).toBe(true);
|
||||||
|
refresh!.click();
|
||||||
|
await settle();
|
||||||
|
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
pending.resolve({ cursor: 0, keys: [], total_keys: 0 });
|
||||||
|
await settleThoroughly();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks an old cursor while an Enter-only key pattern is unsubmitted", async () => {
|
||||||
|
stubNonOverflowingViewport();
|
||||||
|
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, pattern: string) => {
|
||||||
|
if (pattern === "*") return Promise.resolve({ cursor: 41, keys: [keyInfo("seed")], total_keys: 5_000_000 });
|
||||||
|
return Promise.resolve({ cursor: 0, keys: [keyInfo("new")], total_keys: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||||
|
const input = host.querySelector<HTMLInputElement>("[data-redis-search-input]")!;
|
||||||
|
input.value = "new";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize"));
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("scroll"));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mocks.redisScanKeysBatch.mock.calls[1]?.slice(2, 4)).toEqual([0, "new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards a late value page after unmount without scheduling a successor", async () => {
|
||||||
|
const pending = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
mocks.redisScanValues.mockReturnValue(pending.promise);
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
await searchByValue(host);
|
||||||
|
await vi.waitFor(() => expect(mocks.redisScanValues).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
mocks.updateRedisDbKeyStats.mockClear();
|
||||||
|
unmountBrowser(host);
|
||||||
|
pending.resolve({ cursor: 91, keys: [keyInfo("stale")], total_keys: 5_000_000 });
|
||||||
|
await settleThoroughly();
|
||||||
|
|
||||||
|
expect(mocks.redisScanValues).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.updateRedisDbKeyStats).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// `iterations` (aka `max_iterations`) is the 6th positional arg the frontend
|
// `iterations` (aka `max_iterations`) is the 6th positional arg the frontend
|
||||||
// sends to `redisScanKeysBatch` — the same unit the backend spends as real
|
// sends to `redisScanKeysBatch` — the same unit the backend spends as real
|
||||||
// Redis SCAN calls (see `crates/dbx-core/src/db/redis_driver.rs`).
|
// Redis SCAN calls (see `crates/dbx-core/src/db/redis_driver.rs`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
import { createApp, nextTick } from "vue";
|
import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
|
||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||||
@@ -273,6 +273,37 @@ function mountBrowser() {
|
|||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountKeptAliveBrowser() {
|
||||||
|
const active = ref(true);
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () =>
|
||||||
|
h(KeepAlive, null, {
|
||||||
|
default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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 deactivate() {
|
||||||
|
active.value = false;
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
|
async activate() {
|
||||||
|
active.value = true;
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function settle() {
|
async function settle() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -285,6 +316,16 @@ async function settleThoroughly(rounds = 40) {
|
|||||||
for (let i = 0; i < rounds; i++) await settle();
|
for (let i = 0; i < rounds; i++) await settle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((next, fail) => {
|
||||||
|
resolve = next;
|
||||||
|
reject = fail;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function resetApiMocks() {
|
function resetApiMocks() {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.redisScanPageSize = 100;
|
mocks.redisScanPageSize = 100;
|
||||||
@@ -523,6 +564,40 @@ describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => {
|
|||||||
expect(leafVisible(host, "grp:a")).toBe(true);
|
expect(leafVisible(host, "grp:a")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rotates Load more continuation between pending expanded subtrees", async () => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
const callsByGroup = { aa: 0, bb: 0 };
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "aa:*" || pattern === "bb:*") {
|
||||||
|
callsByGroup[pattern === "aa:*" ? "aa" : "bb"]++;
|
||||||
|
return Promise.resolve({ cursor: cursor + 1, keys: [], total_keys: 0 });
|
||||||
|
}
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("aa:a"), keyInfo("bb:a")], total_keys: 10_000 });
|
||||||
|
return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
host.querySelectorAll<HTMLElement>("[data-redis-group]")[0]!.parentElement!.parentElement!.click();
|
||||||
|
await settleThoroughly();
|
||||||
|
host.querySelectorAll<HTMLElement>("[data-redis-group]")[1]!.parentElement!.parentElement!.click();
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(callsByGroup).toEqual({ aa: 7, bb: 7 });
|
||||||
|
|
||||||
|
clickLoadMore(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(callsByGroup).toEqual({ aa: 14, bb: 7 });
|
||||||
|
clickLoadMore(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(callsByGroup).toEqual({ aa: 14, bb: 14 });
|
||||||
|
|
||||||
|
// Each resumed group retains its own completed pass's cursor.
|
||||||
|
for (const pattern of ["aa:*", "bb:*"]) {
|
||||||
|
const calls = mocks.redisScanKeysBatch.mock.calls.filter((call: unknown[]) => call[3] === pattern);
|
||||||
|
expect(calls[SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS][2]).toBe(SUBTREE_FILL_MAX_CALLS_BY_ITERATIONS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes a partially filled subtree when the group is re-expanded", async () => {
|
it("resumes a partially filled subtree when the group is re-expanded", async () => {
|
||||||
stubOverflowingViewport();
|
stubOverflowingViewport();
|
||||||
let subtreeCall = 0;
|
let subtreeCall = 0;
|
||||||
@@ -553,4 +628,175 @@ describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => {
|
|||||||
expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1);
|
expect(subtreeCalls().length).toBe(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1);
|
||||||
expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS][2]).toBe(3000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS][2]).toBe(3000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not resume a collapsed pending subtree from a main Load more", async () => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
let subtreeCall = 0;
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "grp:*") {
|
||||||
|
subtreeCall++;
|
||||||
|
return Promise.resolve({ cursor: 4000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 });
|
||||||
|
}
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 });
|
||||||
|
return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
clickLoadMore(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS]?.[2]).toBe(4000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not grant a pending subtree another pass from automatic main fill", async () => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
let mainCalls = 0;
|
||||||
|
let subtreeCall = 0;
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "grp:*") {
|
||||||
|
subtreeCall++;
|
||||||
|
return Promise.resolve({ cursor: 5000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 });
|
||||||
|
}
|
||||||
|
mainCalls++;
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 5_000_000 });
|
||||||
|
return Promise.resolve({ cursor: cursor + 1, keys: [], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return this.classList.contains("redis-key-scroller") ? 90 : 100;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
host.querySelector(".redis-key-scroller")?.dispatchEvent(new Event("resize"));
|
||||||
|
await settleThoroughly();
|
||||||
|
|
||||||
|
expect(mainCalls).toBeGreaterThan(1);
|
||||||
|
expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the last applied subtree cursor when KeepAlive invalidates an in-flight resume", async () => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
const inFlightResume = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
let subtreeCall = 0;
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "grp:*") {
|
||||||
|
subtreeCall++;
|
||||||
|
if (subtreeCall <= SUBTREE_FILL_MAX_CALLS_BY_KEYS) {
|
||||||
|
return Promise.resolve({ cursor: 6000 + subtreeCall, keys: subtreeBatchKeys(100, subtreeCall), total_keys: 0 });
|
||||||
|
}
|
||||||
|
if (subtreeCall === SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1) return inFlightResume.promise;
|
||||||
|
return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:resumed")], total_keys: 0 });
|
||||||
|
}
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 });
|
||||||
|
return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = mountKeptAliveBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(browser.host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
|
||||||
|
clickLoadMore(browser.host);
|
||||||
|
await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1));
|
||||||
|
expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS]?.[2]).toBe(6000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
|
||||||
|
await browser.deactivate();
|
||||||
|
inFlightResume.resolve({ cursor: 7000, keys: [keyInfo("grp:stale")], total_keys: 0 });
|
||||||
|
await settleThoroughly();
|
||||||
|
await browser.activate();
|
||||||
|
|
||||||
|
expandFirstGroupRow(browser.host);
|
||||||
|
expandFirstGroupRow(browser.host);
|
||||||
|
await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(SUBTREE_FILL_MAX_CALLS_BY_KEYS + 2));
|
||||||
|
expect(subtreeCalls()[SUBTREE_FILL_MAX_CALLS_BY_KEYS + 1]?.[2]).toBe(6000 + SUBTREE_FILL_MAX_CALLS_BY_KEYS);
|
||||||
|
expect(leafVisible(browser.host, "grp:stale")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops an active subtree after collapse and resumes from its in-flight cursor", async () => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
const firstSubtreePage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "grp:*" && cursor === 0) return firstSubtreePage.promise;
|
||||||
|
if (pattern === "grp:*") return Promise.resolve({ cursor: 0, keys: [keyInfo("grp:resumed")], total_keys: 0 });
|
||||||
|
if (cursor === 0) return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 });
|
||||||
|
return Promise.resolve({ cursor: 9, keys: [], total_keys: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(1));
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
firstSubtreePage.resolve({ cursor: 57, keys: [], total_keys: 0 });
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(1);
|
||||||
|
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(2);
|
||||||
|
expect(subtreeCalls()[1]?.[2]).toBe(57);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["resolves", "rejects"] as const)("keeps a newer same-group fill owned when a stale fill %s", async (outcome) => {
|
||||||
|
stubOverflowingViewport();
|
||||||
|
const oldFill = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
const currentFill = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
|
||||||
|
let mainCalls = 0;
|
||||||
|
let subtreeCall = 0;
|
||||||
|
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, _cursor: number, pattern: string) => {
|
||||||
|
if (pattern === "grp:*") {
|
||||||
|
subtreeCall++;
|
||||||
|
if (subtreeCall === 1) return oldFill.promise;
|
||||||
|
return currentFill.promise;
|
||||||
|
}
|
||||||
|
mainCalls++;
|
||||||
|
return Promise.resolve({ cursor: 9, keys: [keyInfo("grp:a")], total_keys: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = mountBrowser();
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(1));
|
||||||
|
|
||||||
|
const refresh = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.className.includes("h-6 w-6") && !button.hasAttribute("title"));
|
||||||
|
expect(refresh, "refresh button").toBeDefined();
|
||||||
|
refresh!.click();
|
||||||
|
await vi.waitFor(() => expect(mainCalls).toBeGreaterThanOrEqual(2));
|
||||||
|
await settleThoroughly();
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await vi.waitFor(() => expect(subtreeCalls()).toHaveLength(2));
|
||||||
|
|
||||||
|
if (outcome === "resolves") oldFill.resolve({ cursor: 88, keys: [keyInfo("grp:stale")], total_keys: 0 });
|
||||||
|
else oldFill.reject(new Error("stale subtree failure"));
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
expect(leafVisible(host, "grp:stale")).toBe(false);
|
||||||
|
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
expandFirstGroupRow(host);
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(subtreeCalls()).toHaveLength(2);
|
||||||
|
|
||||||
|
currentFill.resolve({ cursor: 0, keys: [keyInfo("grp:current")], total_keys: 0 });
|
||||||
|
await settleThoroughly();
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -196,12 +196,14 @@ interface ScanIterationBudget {
|
|||||||
// actual SCAN iterations (the same unit as the `max_iterations` sent to the
|
// actual SCAN iterations (the same unit as the `max_iterations` sent to the
|
||||||
// backend), decremented by every backend call the automatic path makes —
|
// backend), decremented by every backend call the automatic path makes —
|
||||||
// regardless of how many pages/keys those calls span — and stop deterministically
|
// 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
|
// the moment it's spent. Reset for genuine new load/search/scope operations;
|
||||||
// in `invalidateScanRequests`. An explicit "Load more" click or a real scroll
|
// KeepAlive deactivation only invalidates ownership so it cannot replenish the
|
||||||
|
// same retained operation. An explicit "Load more" click or a real scroll
|
||||||
// event is a single user-triggered request and keeps its own uncapped
|
// event is a single user-triggered request and keeps its own uncapped
|
||||||
// per-call budget (see `fetchScanPage`); only the automatic follow-up check
|
// 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.
|
||||||
const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
||||||
|
const REDIS_VALUE_SCAN_COUNT = 100;
|
||||||
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||||
let redisBrowserIsActive = true;
|
let redisBrowserIsActive = true;
|
||||||
let reloadKeysOnActivation = false;
|
let reloadKeysOnActivation = false;
|
||||||
@@ -224,12 +226,14 @@ let fetchAllPublicationRollback: null | {
|
|||||||
} = null;
|
} = null;
|
||||||
// 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。
|
// 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。
|
||||||
const subtreeFilledGroupIds = new Set<string>();
|
const subtreeFilledGroupIds = new Set<string>();
|
||||||
// 补扫停在预算上限、尚未扫尽的分组:保存继续扫描所需的 SCAN 游标,供
|
// 尚未扫尽的分组:保存已应用的最新 SCAN 游标,包括下一个请求还在飞行中时。
|
||||||
// “加载更多”/滚动按展开顺序续扫(见 resumePendingGroupSubtrees);同样在
|
// 这样预算停止、折叠或 KeepAlive 失效都能从已确认的位置续扫;“加载更多”/
|
||||||
// loadKeys 重置。
|
// 滚动按展开顺序续扫(见 resumePendingGroupSubtrees)。loadKeys 会重置该状态。
|
||||||
const subtreePendingGroupCursors = new Map<string, number>();
|
const subtreePendingGroupCursors = new Map<string, number>();
|
||||||
// 正在补扫中的分组:避免展开与续扫并发重复扫同一子树。
|
// 正在补扫中的分组及其操作所有权:避免展开与续扫并发重复扫同一子树,并防止
|
||||||
const subtreeFillingGroupIds = new Set<string>();
|
// 旧 generation 的 catch/finally 清掉或覆盖同 id 的新补扫。
|
||||||
|
const subtreeFillOperations = new Map<string, number>();
|
||||||
|
let subtreeFillOperationId = 0;
|
||||||
// 刷新前已展开分组的快照(#7173):刷新首屏重建树时,本轮尚未扫到的分组会被
|
// 刷新前已展开分组的快照(#7173):刷新首屏重建树时,本轮尚未扫到的分组会被
|
||||||
// rebuildTree 从 expandedGroupIds 中裁掉;后续 load-more 页面让这些分组重新
|
// rebuildTree 从 expandedGroupIds 中裁掉;后续 load-more 页面让这些分组重新
|
||||||
// 出现时,用该快照恢复展开状态。SCAN 游标归零(本轮已扫尽,仍未出现的分组
|
// 出现时,用该快照恢复展开状态。SCAN 游标归零(本轮已扫尽,仍未出现的分组
|
||||||
@@ -772,7 +776,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
|
|||||||
if (checkedKeys.value.size > 0) selectionEpoch.value++;
|
if (checkedKeys.value.size > 0) selectionEpoch.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateScanRequests(): number {
|
function invalidateScanRequests(resetAutoLoadBudget = true): number {
|
||||||
rollbackFetchAllPublication();
|
rollbackFetchAllPublication();
|
||||||
deactivateFetchAllVisibleRows();
|
deactivateFetchAllVisibleRows();
|
||||||
// Keep invalidation authoritative even when the old Fetch All continuation
|
// Keep invalidation authoritative even when the old Fetch All continuation
|
||||||
@@ -784,7 +788,8 @@ function invalidateScanRequests(): number {
|
|||||||
searchRequestId++;
|
searchRequestId++;
|
||||||
loadMoreOperationId++;
|
loadMoreOperationId++;
|
||||||
loadingMore.value = false;
|
loadingMore.value = false;
|
||||||
autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
subtreeFillOperations.clear();
|
||||||
|
if (resetAutoLoadBudget) autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||||
return searchRequestId;
|
return searchRequestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +804,7 @@ function isCurrentScanOperation(requestId: number, operationId?: number): boolea
|
|||||||
async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<RedisScanResult> {
|
async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<RedisScanResult> {
|
||||||
const pageSize = redisScanPageSize.value;
|
const pageSize = redisScanPageSize.value;
|
||||||
if (isValueSearchMode.value) {
|
if (isValueSearchMode.value) {
|
||||||
return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all");
|
return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, Math.min(pageSize, REDIS_VALUE_SCAN_COUNT), searchMode.value === "all");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep each backend call small so a changed search can cancel between calls.
|
// Keep each backend call small so a changed search can cancel between calls.
|
||||||
@@ -848,7 +853,7 @@ async function fetchScanBatchPage(maxIterations: number, options: { count?: numb
|
|||||||
const pageSize = options.count ?? redisScanPageSize.value;
|
const pageSize = options.count ?? redisScanPageSize.value;
|
||||||
// Value search cannot be batched because each key requires a GET.
|
// Value search cannot be batched because each key requires a GET.
|
||||||
if (isValueSearchMode.value) {
|
if (isValueSearchMode.value) {
|
||||||
return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all");
|
return api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, Math.min(pageSize, REDIS_VALUE_SCAN_COUNT), searchMode.value === "all");
|
||||||
}
|
}
|
||||||
return api.redisScanKeysBatch(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize, maxIterations, options.includeTypes ?? false);
|
return api.redisScanKeysBatch(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize, maxIterations, options.includeTypes ?? false);
|
||||||
}
|
}
|
||||||
@@ -922,15 +927,11 @@ async function scanNextPage(requestId = searchRequestId, operationId?: number, i
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamValueSearch(requestId: number) {
|
|
||||||
while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) {
|
|
||||||
const applied = await scanNextPage(requestId);
|
|
||||||
if (!applied) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
if (!redisBrowserIsActive) return;
|
if (!redisBrowserIsActive) return;
|
||||||
|
// Consume a deferred reload only once an active load actually starts; a
|
||||||
|
// connection check may finish after KeepAlive has paused the browser again.
|
||||||
|
reloadKeysOnActivation = false;
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = null;
|
searchTimer = null;
|
||||||
searchPending.value = false;
|
searchPending.value = false;
|
||||||
@@ -944,7 +945,7 @@ async function loadKeys() {
|
|||||||
positiveTtlKeyRaws.clear();
|
positiveTtlKeyRaws.clear();
|
||||||
subtreeFilledGroupIds.clear();
|
subtreeFilledGroupIds.clear();
|
||||||
subtreePendingGroupCursors.clear();
|
subtreePendingGroupCursors.clear();
|
||||||
subtreeFillingGroupIds.clear();
|
subtreeFillOperations.clear();
|
||||||
replaceFlatKeyRecords([]);
|
replaceFlatKeyRecords([]);
|
||||||
treeKeys.value = [];
|
treeKeys.value = [];
|
||||||
treeIndex = null;
|
treeIndex = null;
|
||||||
@@ -969,9 +970,6 @@ async function loadKeys() {
|
|||||||
}
|
}
|
||||||
const initialScanBudget = isFuzzyKeySearch.value ? autoLoadBudget : undefined;
|
const initialScanBudget = isFuzzyKeySearch.value ? autoLoadBudget : undefined;
|
||||||
const applied = await scanNextPage(requestId, undefined, initialScanBudget);
|
const applied = await scanNextPage(requestId, undefined, initialScanBudget);
|
||||||
if (applied && isValueSearchMode.value) {
|
|
||||||
await streamValueSearch(requestId);
|
|
||||||
}
|
|
||||||
succeeded = applied;
|
succeeded = applied;
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === searchRequestId) {
|
if (requestId === searchRequestId) {
|
||||||
@@ -1008,9 +1006,11 @@ async function loadMore(iterationBudget?: ScanIterationBudget) {
|
|||||||
// automatic-fill check as everywhere else instead of relying on the user to
|
// automatic-fill check as everywhere else instead of relying on the user to
|
||||||
// notice and click again.
|
// notice and click again.
|
||||||
if (applied && isCurrentScanOperation(requestId, operationId)) {
|
if (applied && isCurrentScanOperation(requestId, operationId)) {
|
||||||
// 主页面推进的同时,按展开顺序续扫一个停在预算上限的子树(#7918):
|
if (!iterationBudget) {
|
||||||
// 巨大分组靠“加载更多”/滚动逐段补全,而不是一次展开就扫尽。
|
// 只有用户点击“加载更多”或真实滚动时,才按展开顺序续扫一个停在预算上限
|
||||||
resumePendingGroupSubtrees(requestId);
|
// 的子树;短视口自动主补页不得额外获得一份独立子树预算。
|
||||||
|
resumePendingGroupSubtrees(requestId);
|
||||||
|
}
|
||||||
void maybeAutoLoadMoreRedisKeys();
|
void maybeAutoLoadMoreRedisKeys();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1026,9 @@ async function loadMore(iterationBudget?: ScanIterationBudget) {
|
|||||||
// scroll handler already uses.
|
// scroll handler already uses.
|
||||||
async function maybeAutoLoadMoreRedisKeys() {
|
async function maybeAutoLoadMoreRedisKeys() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
// Value/all pages load per-key metadata and values. Layout changes must not
|
||||||
|
// turn a sparse query into an implicit background scan.
|
||||||
|
if (isValueSearchMode.value) return;
|
||||||
// Unique visible/loaded keys are a poor stop condition on their own: an
|
// 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,
|
// 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
|
// so relying on it alone lets a short viewport turn an ordinary tree load
|
||||||
@@ -1339,40 +1342,56 @@ function mergeScannedKeys(newKeys: RedisKeyInfo[]) {
|
|||||||
|
|
||||||
async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) {
|
async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) {
|
||||||
if (subtreeFilledGroupIds.has(group.id)) return;
|
if (subtreeFilledGroupIds.has(group.id)) return;
|
||||||
if (subtreeFillingGroupIds.has(group.id)) return;
|
if (subtreeFillOperations.has(group.id)) return;
|
||||||
subtreeFillingGroupIds.add(group.id);
|
const operationId = ++subtreeFillOperationId;
|
||||||
|
subtreeFillOperations.set(group.id, operationId);
|
||||||
// 续扫从上次停在的游标继续,避免把已扫过的前缀重扫一遍
|
// 续扫从上次停在的游标继续,避免把已扫过的前缀重扫一遍
|
||||||
const resumeCursor = subtreePendingGroupCursors.get(group.id) ?? 0;
|
const resumeCursor = subtreePendingGroupCursors.get(group.id) ?? 0;
|
||||||
subtreePendingGroupCursors.delete(group.id);
|
|
||||||
const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value);
|
const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value);
|
||||||
let cursor = resumeCursor;
|
let cursor = resumeCursor;
|
||||||
let mergedNewKeys = 0;
|
let mergedNewKeys = 0;
|
||||||
let iterationsLeft = SUBTREE_FILL_MAX_SCAN_ITERATIONS;
|
let iterationsLeft = SUBTREE_FILL_MAX_SCAN_ITERATIONS;
|
||||||
try {
|
try {
|
||||||
while (requestId === searchRequestId && !isFetchingAll.value && iterationsLeft > 0) {
|
while (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId && expandedGroupIds.value.has(group.id) && !isFetchingAll.value && iterationsLeft > 0) {
|
||||||
if (flatKeys.value.length >= redisInfiniteScrollMaxKeys.value) break;
|
if (flatKeys.value.length >= redisInfiniteScrollMaxKeys.value) break;
|
||||||
if (mergedNewKeys >= SUBTREE_FILL_MAX_NEW_KEYS) break;
|
if (mergedNewKeys >= SUBTREE_FILL_MAX_NEW_KEYS) break;
|
||||||
const iterations = Math.min(SUBTREE_SCAN_ITERATIONS_PER_CALL, iterationsLeft);
|
const iterations = Math.min(SUBTREE_SCAN_ITERATIONS_PER_CALL, iterationsLeft);
|
||||||
const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, iterations, true);
|
const result = await api.redisScanKeysBatch(props.connectionId, props.db, cursor, pattern, redisScanPageSize.value, iterations, true);
|
||||||
iterationsLeft -= iterations;
|
iterationsLeft -= iterations;
|
||||||
if (requestId !== searchRequestId) return;
|
if (requestId !== searchRequestId || subtreeFillOperations.get(group.id) !== operationId) return;
|
||||||
const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws);
|
const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws);
|
||||||
mergeScannedKeys(newKeys);
|
mergeScannedKeys(newKeys);
|
||||||
mergedNewKeys += newKeys.length;
|
mergedNewKeys += newKeys.length;
|
||||||
cursor = result.cursor;
|
cursor = result.cursor;
|
||||||
if (cursor === 0) {
|
if (cursor === 0) {
|
||||||
|
subtreePendingGroupCursors.delete(group.id);
|
||||||
subtreeFilledGroupIds.add(group.id);
|
subtreeFilledGroupIds.add(group.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep the last applied cursor available while the next request is in
|
||||||
|
// flight. A KeepAlive pause can invalidate that request, but must not
|
||||||
|
// force the retained subtree to restart from cursor zero.
|
||||||
|
subtreePendingGroupCursors.set(group.id, cursor);
|
||||||
}
|
}
|
||||||
// 子树未扫尽(预算用尽或触到全局键数上限):保留游标等待续扫
|
// 子树未扫尽(预算用尽或触到全局键数上限):保留游标等待续扫
|
||||||
subtreePendingGroupCursors.set(group.id, cursor);
|
if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) subtreePendingGroupCursors.set(group.id, cursor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 失败不阻塞浏览;已并入的进度保留在游标里,下次展开/续扫从断点重试
|
// 失败不阻塞浏览;已并入的进度保留在游标里,下次展开/续扫从断点重试
|
||||||
subtreePendingGroupCursors.set(group.id, cursor);
|
if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) {
|
||||||
toast(errorMessage(error), 5000);
|
subtreePendingGroupCursors.set(group.id, cursor);
|
||||||
|
toast(errorMessage(error), 5000);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
subtreeFillingGroupIds.delete(group.id);
|
if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) {
|
||||||
|
// Keep confirmed progress during the pass, then let other pending groups
|
||||||
|
// take the next continuation. Completed or invalidated fills stay removed.
|
||||||
|
const pendingCursor = subtreePendingGroupCursors.get(group.id);
|
||||||
|
if (pendingCursor !== undefined) {
|
||||||
|
subtreePendingGroupCursors.delete(group.id);
|
||||||
|
subtreePendingGroupCursors.set(group.id, pendingCursor);
|
||||||
|
}
|
||||||
|
subtreeFillOperations.delete(group.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,12 +1401,14 @@ async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = search
|
|||||||
function resumePendingGroupSubtrees(requestId = searchRequestId) {
|
function resumePendingGroupSubtrees(requestId = searchRequestId) {
|
||||||
if (!shouldFillGroupSubtree()) return;
|
if (!shouldFillGroupSubtree()) return;
|
||||||
for (const groupId of subtreePendingGroupCursors.keys()) {
|
for (const groupId of subtreePendingGroupCursors.keys()) {
|
||||||
|
if (subtreeFillOperations.has(groupId)) continue;
|
||||||
const group = treeIndex?.groupById.get(groupId);
|
const group = treeIndex?.groupById.get(groupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
// 分组已不存在(键被删除或树被重建),游标作废
|
// 分组已不存在(键被删除或树被重建),游标作废
|
||||||
subtreePendingGroupCursors.delete(groupId);
|
subtreePendingGroupCursors.delete(groupId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!expandedGroupIds.value.has(groupId)) continue;
|
||||||
void fillGroupSubtree(group, requestId);
|
void fillGroupSubtree(group, requestId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2259,6 +2280,9 @@ function onSearchInput() {
|
|||||||
fetchAllLoadedCount.value = 0;
|
fetchAllLoadedCount.value = 0;
|
||||||
selectedKeyRaw.value = null;
|
selectedKeyRaw.value = null;
|
||||||
resetCheckedKeys();
|
resetCheckedKeys();
|
||||||
|
// The rendered rows may stay visible while editing, but their cursor
|
||||||
|
// belongs to the last submitted pattern and must not be continued.
|
||||||
|
hasMore.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
@@ -2528,11 +2552,16 @@ function pauseRedisBrowserBackgroundWork() {
|
|||||||
// 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。
|
// 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。
|
||||||
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
|
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
|
||||||
redisInfiniteScrollFrame = 0;
|
redisInfiniteScrollFrame = 0;
|
||||||
invalidateScanRequests();
|
// KeepAlive retains this operation's rows and cursor, so pausing invalidates
|
||||||
|
// ownership without granting the same operation another automatic budget.
|
||||||
|
invalidateScanRequests(false);
|
||||||
isFetchingAll.value = false;
|
isFetchingAll.value = false;
|
||||||
fetchAllStopRequested.value = false;
|
fetchAllStopRequested.value = false;
|
||||||
fetchAllLoadedCount.value = 0;
|
fetchAllLoadedCount.value = 0;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
// The edited value/all query has not run yet; its old cursor cannot be
|
||||||
|
// retained as progress for the new query when we cancel the debounce.
|
||||||
|
if (searchPending.value) reloadKeysOnActivation = true;
|
||||||
searchPending.value = false;
|
searchPending.value = false;
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = null;
|
searchTimer = null;
|
||||||
@@ -2640,16 +2669,18 @@ onMounted(async () => {
|
|||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
resumeRedisBrowserBackgroundWork();
|
resumeRedisBrowserBackgroundWork();
|
||||||
void autofocusSearchOnce();
|
void autofocusSearchOnce();
|
||||||
const shouldReload = reloadKeysOnActivation;
|
|
||||||
reloadKeysOnActivation = false;
|
|
||||||
// Ensure the connection is still alive after reactivation (e.g. tab switch).
|
// Ensure the connection is still alive after reactivation (e.g. tab switch).
|
||||||
// If keys failed to load previously (empty list), retry loading.
|
// Retry an empty failed/interrupted load, but retain successful sparse pages.
|
||||||
try {
|
try {
|
||||||
await connectionStore.ensureConnected(props.connectionId);
|
await connectionStore.ensureConnected(props.connectionId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||||
}
|
}
|
||||||
if ((shouldReload || flatKeys.value.length === 0) && !loading.value) {
|
// loadKeys resets the cursor before requesting its first page. A nonzero
|
||||||
|
// retained cursor therefore distinguishes an applied empty page from a
|
||||||
|
// failed/interrupted reload even if hasMore still reflects the previous load.
|
||||||
|
const hasRetainedScanCursor = hasMore.value && scanCursor.value !== 0;
|
||||||
|
if ((reloadKeysOnActivation || (flatKeys.value.length === 0 && !hasRetainedScanCursor)) && !loading.value) {
|
||||||
void loadKeys();
|
void loadKeys();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2726,7 +2757,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
|||||||
<Button v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.selectAllLoadedTitle')" data-redis-select-all @click="selectAllKeys">{{ t("redis.selectAllLoaded") }}</Button>
|
<Button v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.selectAllLoadedTitle')" data-redis-select-all @click="selectAllKeys">{{ t("redis.selectAllLoaded") }}</Button>
|
||||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
|
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
|
||||||
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @click="requestBatchDelete"><Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}</Button>
|
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @click="requestBatchDelete"><Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}</Button>
|
||||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys" @click="loadKeys">
|
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys || loading || loadingMore || isFetchingAll" @click="loadKeys">
|
||||||
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
||||||
<RefreshCw v-else class="h-3 w-3" />
|
<RefreshCw v-else class="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in new issue
Block a user