fix(redis): bound implicit key and value scanning

This commit is contained in:
Zzz
2026-09-05 22:18:04 +08:00
committed by GitHub
parent 4bef571fc4
commit bc2d32f20f
4 changed files with 720 additions and 43 deletions
@@ -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>