mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 07:23:15 +08:00
fix(redis): bound implicit key and value scanning
This commit is contained in:
@@ -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", () => {
|
||||
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 };
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn(),
|
||||
redisScanKeysBatch: vi.fn(),
|
||||
redisScanValues: vi.fn(),
|
||||
redisGetValue: vi.fn(),
|
||||
redisSetString: vi.fn(),
|
||||
redisJsonSet: vi.fn(),
|
||||
@@ -34,6 +36,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
redisScanKeysBatch: mocks.redisScanKeysBatch,
|
||||
redisScanValues: mocks.redisScanValues,
|
||||
redisGetValue: mocks.redisGetValue,
|
||||
redisSetString: mocks.redisSetString,
|
||||
redisJsonSet: mocks.redisJsonSet,
|
||||
@@ -53,7 +56,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
|
||||
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
|
||||
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
|
||||
@@ -276,6 +279,45 @@ function mountBrowser() {
|
||||
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() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
@@ -284,13 +326,25 @@ async function settle() {
|
||||
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() {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.redisScanPageSize = 100;
|
||||
mocks.infiniteScroll = true;
|
||||
mocks.queryResultMaxRowsEnabled = true;
|
||||
mocks.queryResultMaxRows = 5000;
|
||||
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
|
||||
@@ -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: "" };
|
||||
}
|
||||
|
||||
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
|
||||
// sends to `redisScanKeysBatch` — the same unit the backend spends as real
|
||||
// Redis SCAN calls (see `crates/dbx-core/src/db/redis_driver.rs`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RedisKeyInfo } from "@/lib/backend/api";
|
||||
@@ -273,6 +273,37 @@ function mountBrowser() {
|
||||
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() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
@@ -285,6 +316,16 @@ async function settleThoroughly(rounds = 40) {
|
||||
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() {
|
||||
vi.clearAllMocks();
|
||||
mocks.redisScanPageSize = 100;
|
||||
@@ -523,6 +564,40 @@ describe("RedisKeyBrowser bounded group subtree fill (issue #7918)", () => {
|
||||
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 () => {
|
||||
stubOverflowingViewport();
|
||||
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()[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
|
||||
// backend), decremented by every backend call the automatic path makes —
|
||||
// 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
|
||||
// in `invalidateScanRequests`. An explicit "Load more" click or a real scroll
|
||||
// the moment it's spent. Reset for genuine new load/search/scope operations;
|
||||
// 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
|
||||
// per-call budget (see `fetchScanPage`); only the automatic follow-up check
|
||||
// they hand off to afterward is constrained by this shared budget.
|
||||
const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
||||
const REDIS_VALUE_SCAN_COUNT = 100;
|
||||
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
let redisBrowserIsActive = true;
|
||||
let reloadKeysOnActivation = false;
|
||||
@@ -224,12 +226,14 @@ let fetchAllPublicationRollback: null | {
|
||||
} = null;
|
||||
// 展开分组时已定向补扫且已扫尽的子树(见 fillGroupSubtree);在 loadKeys 重置。
|
||||
const subtreeFilledGroupIds = new Set<string>();
|
||||
// 补扫停在预算上限、尚未扫尽的分组:保存继续扫描所需的 SCAN 游标,供
|
||||
// “加载更多”/滚动按展开顺序续扫(见 resumePendingGroupSubtrees);同样在
|
||||
// loadKeys 重置。
|
||||
// 尚未扫尽的分组:保存已应用的最新 SCAN 游标,包括下一个请求还在飞行中时。
|
||||
// 这样预算停止、折叠或 KeepAlive 失效都能从已确认的位置续扫;“加载更多”/
|
||||
// 滚动按展开顺序续扫(见 resumePendingGroupSubtrees)。loadKeys 会重置该状态。
|
||||
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):刷新首屏重建树时,本轮尚未扫到的分组会被
|
||||
// rebuildTree 从 expandedGroupIds 中裁掉;后续 load-more 页面让这些分组重新
|
||||
// 出现时,用该快照恢复展开状态。SCAN 游标归零(本轮已扫尽,仍未出现的分组
|
||||
@@ -772,7 +776,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
|
||||
if (checkedKeys.value.size > 0) selectionEpoch.value++;
|
||||
}
|
||||
|
||||
function invalidateScanRequests(): number {
|
||||
function invalidateScanRequests(resetAutoLoadBudget = true): number {
|
||||
rollbackFetchAllPublication();
|
||||
deactivateFetchAllVisibleRows();
|
||||
// Keep invalidation authoritative even when the old Fetch All continuation
|
||||
@@ -784,7 +788,8 @@ function invalidateScanRequests(): number {
|
||||
searchRequestId++;
|
||||
loadMoreOperationId++;
|
||||
loadingMore.value = false;
|
||||
autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
subtreeFillOperations.clear();
|
||||
if (resetAutoLoadBudget) autoLoadBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
return searchRequestId;
|
||||
}
|
||||
|
||||
@@ -799,7 +804,7 @@ function isCurrentScanOperation(requestId: number, operationId?: number): boolea
|
||||
async function fetchScanPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<RedisScanResult> {
|
||||
const pageSize = redisScanPageSize.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.
|
||||
@@ -848,7 +853,7 @@ async function fetchScanBatchPage(maxIterations: number, options: { count?: numb
|
||||
const pageSize = options.count ?? redisScanPageSize.value;
|
||||
// Value search cannot be batched because each key requires a GET.
|
||||
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);
|
||||
}
|
||||
@@ -922,15 +927,11 @@ async function scanNextPage(requestId = searchRequestId, operationId?: number, i
|
||||
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() {
|
||||
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);
|
||||
searchTimer = null;
|
||||
searchPending.value = false;
|
||||
@@ -944,7 +945,7 @@ async function loadKeys() {
|
||||
positiveTtlKeyRaws.clear();
|
||||
subtreeFilledGroupIds.clear();
|
||||
subtreePendingGroupCursors.clear();
|
||||
subtreeFillingGroupIds.clear();
|
||||
subtreeFillOperations.clear();
|
||||
replaceFlatKeyRecords([]);
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
@@ -969,9 +970,6 @@ async function loadKeys() {
|
||||
}
|
||||
const initialScanBudget = isFuzzyKeySearch.value ? autoLoadBudget : undefined;
|
||||
const applied = await scanNextPage(requestId, undefined, initialScanBudget);
|
||||
if (applied && isValueSearchMode.value) {
|
||||
await streamValueSearch(requestId);
|
||||
}
|
||||
succeeded = applied;
|
||||
} finally {
|
||||
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
|
||||
// notice and click again.
|
||||
if (applied && isCurrentScanOperation(requestId, operationId)) {
|
||||
// 主页面推进的同时,按展开顺序续扫一个停在预算上限的子树(#7918):
|
||||
// 巨大分组靠“加载更多”/滚动逐段补全,而不是一次展开就扫尽。
|
||||
resumePendingGroupSubtrees(requestId);
|
||||
if (!iterationBudget) {
|
||||
// 只有用户点击“加载更多”或真实滚动时,才按展开顺序续扫一个停在预算上限
|
||||
// 的子树;短视口自动主补页不得额外获得一份独立子树预算。
|
||||
resumePendingGroupSubtrees(requestId);
|
||||
}
|
||||
void maybeAutoLoadMoreRedisKeys();
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1026,9 @@ async function loadMore(iterationBudget?: ScanIterationBudget) {
|
||||
// scroll handler already uses.
|
||||
async function maybeAutoLoadMoreRedisKeys() {
|
||||
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
|
||||
// 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
|
||||
@@ -1339,40 +1342,56 @@ function mergeScannedKeys(newKeys: RedisKeyInfo[]) {
|
||||
|
||||
async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = searchRequestId) {
|
||||
if (subtreeFilledGroupIds.has(group.id)) return;
|
||||
if (subtreeFillingGroupIds.has(group.id)) return;
|
||||
subtreeFillingGroupIds.add(group.id);
|
||||
if (subtreeFillOperations.has(group.id)) return;
|
||||
const operationId = ++subtreeFillOperationId;
|
||||
subtreeFillOperations.set(group.id, operationId);
|
||||
// 续扫从上次停在的游标继续,避免把已扫过的前缀重扫一遍
|
||||
const resumeCursor = subtreePendingGroupCursors.get(group.id) ?? 0;
|
||||
subtreePendingGroupCursors.delete(group.id);
|
||||
const pattern = redisGroupSubtreePattern(group.pathSegments, redisKeySeparator.value);
|
||||
let cursor = resumeCursor;
|
||||
let mergedNewKeys = 0;
|
||||
let iterationsLeft = SUBTREE_FILL_MAX_SCAN_ITERATIONS;
|
||||
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 (mergedNewKeys >= SUBTREE_FILL_MAX_NEW_KEYS) break;
|
||||
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);
|
||||
iterationsLeft -= iterations;
|
||||
if (requestId !== searchRequestId) return;
|
||||
if (requestId !== searchRequestId || subtreeFillOperations.get(group.id) !== operationId) return;
|
||||
const newKeys = collectUniqueRedisKeys(result.keys, loadedKeyRaws);
|
||||
mergeScannedKeys(newKeys);
|
||||
mergedNewKeys += newKeys.length;
|
||||
cursor = result.cursor;
|
||||
if (cursor === 0) {
|
||||
subtreePendingGroupCursors.delete(group.id);
|
||||
subtreeFilledGroupIds.add(group.id);
|
||||
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) {
|
||||
// 失败不阻塞浏览;已并入的进度保留在游标里,下次展开/续扫从断点重试
|
||||
subtreePendingGroupCursors.set(group.id, cursor);
|
||||
toast(errorMessage(error), 5000);
|
||||
if (requestId === searchRequestId && subtreeFillOperations.get(group.id) === operationId) {
|
||||
subtreePendingGroupCursors.set(group.id, cursor);
|
||||
toast(errorMessage(error), 5000);
|
||||
}
|
||||
} 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) {
|
||||
if (!shouldFillGroupSubtree()) return;
|
||||
for (const groupId of subtreePendingGroupCursors.keys()) {
|
||||
if (subtreeFillOperations.has(groupId)) continue;
|
||||
const group = treeIndex?.groupById.get(groupId);
|
||||
if (!group) {
|
||||
// 分组已不存在(键被删除或树被重建),游标作废
|
||||
subtreePendingGroupCursors.delete(groupId);
|
||||
continue;
|
||||
}
|
||||
if (!expandedGroupIds.value.has(groupId)) continue;
|
||||
void fillGroupSubtree(group, requestId);
|
||||
return;
|
||||
}
|
||||
@@ -2259,6 +2280,9 @@ function onSearchInput() {
|
||||
fetchAllLoadedCount.value = 0;
|
||||
selectedKeyRaw.value = null;
|
||||
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;
|
||||
}
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
@@ -2528,11 +2552,16 @@ function pauseRedisBrowserBackgroundWork() {
|
||||
// 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。
|
||||
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
|
||||
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;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
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;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
@@ -2640,16 +2669,18 @@ onMounted(async () => {
|
||||
onActivated(async () => {
|
||||
resumeRedisBrowserBackgroundWork();
|
||||
void autofocusSearchOnce();
|
||||
const shouldReload = reloadKeysOnActivation;
|
||||
reloadKeysOnActivation = false;
|
||||
// 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 {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
} catch (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();
|
||||
}
|
||||
});
|
||||
@@ -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="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 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" />
|
||||
<RefreshCw v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user