fix(redis): complete collection searches and rank exact hash fields

This commit is contained in:
Rendegou
2026-10-11 14:21:35 +08:00
committed by GitHub
parent 56ec883fb7
commit 3edafe58dd
16 changed files with 1547 additions and 119 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, 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";
@@ -67,10 +67,12 @@ afterEach(() => {
host.remove();
}
vi.unstubAllGlobals();
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllMocks();
mocks.redisLoadMore.mockReset();
vi.stubGlobal("localStorage", createLocalStorage());
});
@@ -135,46 +137,402 @@ const testI18nMessages = {
loadedMembers: "{loaded} / {total} members loaded",
loadedItems: "{loaded} / {total} items loaded",
loadMoreKeys: "Load more",
fields: "{count} fields",
ttlSecond: "{count}s",
collectionSearching: "Searching",
collectionSearchSubmit: "Search",
collectionSearchSubmitHint: "Click Search or press Enter",
collectionSearchStop: "Stop search",
collectionSearchCancel: "Cancel search",
collectionSearchStopped: "Search stopped; results and progress kept",
collectionSearchPartial: "Partial results; search not complete",
collectionSearchComplete: "Search complete",
collectionSearchEmpty: "No matches found",
collectionSearchPaused: "Search paused; not complete",
collectionSearchStalled: "Search did not advance",
collectionSearchFailed: "Search failed: {error}",
collectionSearchContinue: "Continue search",
collectionSearchRetry: "Retry search",
stopFetchAll: "Stop",
},
},
};
function mountViewer() {
function mountViewer(keepAlive = false, keyRaw = ref("key")) {
const visible = ref(true);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" });
const viewer = () => (visible.value ? h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: keyRaw.value, keyRaw: keyRaw.value }) : null);
return () => (keepAlive ? h(KeepAlive, null, { default: viewer }) : viewer());
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
return visible;
}
function searchInput(): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>("input[placeholder='Search members'], input[placeholder='Search items']");
return document.querySelector<HTMLInputElement>("input[placeholder='Search fields and values'], input[placeholder='Search members'], input[placeholder='Search items']");
}
function loadMoreButton(): HTMLButtonElement {
const buttons = [...document.querySelectorAll<HTMLButtonElement>("button")];
return buttons.find((button) => button.textContent?.includes("Load more"))!;
return buttons.find((button) => button.textContent?.includes("Load more") || button.textContent?.includes("Continue search"))!;
}
async function typeSearch(query: string) {
async function typeDraft(query: string) {
const input = searchInput()!;
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true }));
// The Input wrapper syncs its v-model through a passive watcher, so the query only
// reaches the viewer on the next tick — press Enter after it lands, like real typing.
// Let the Input wrapper's passive v-model watcher settle before submission.
await nextTick();
}
async function typeSearch(query: string) {
await typeDraft(query);
const input = searchInput()!;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
}
describe("Redis collection member search", () => {
it("waits for the Search button and does not scan while typing", async () => {
vi.useFakeTimers();
mocks.redisGetValue.mockResolvedValue(setValue());
mocks.redisLoadMore.mockResolvedValue({ kind: "set", items: [{ member: blob("needle") }] });
mountViewer();
await settle();
expect(document.querySelector<HTMLButtonElement>("[data-redis-search-submit]")!.disabled).toBe(true);
await typeDraft("needle");
await vi.advanceTimersByTimeAsync(1000);
expect(mocks.redisLoadMore).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("alpha");
document.querySelector<HTMLButtonElement>("[data-redis-search-submit]")!.click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "set", 0, 200, "needle", undefined);
expect(document.body.textContent).toContain("needle");
});
it("cancels an in-flight search, restores browsing, and ignores the old response", async () => {
const browsing = { ...setValue(), data: { kind: "set", items: [{ member: blob("alpha") }], total: 100, scan_cursor: 10 } };
mocks.redisGetValue.mockResolvedValue(browsing);
let finish!: (result: unknown) => void;
mocks.redisLoadMore
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce({ kind: "set", items: [{ member: blob("beta") }] });
mountViewer();
await settle();
await typeSearch("old");
document.querySelector<HTMLButtonElement>("[data-redis-search-cancel]")!.click();
await settle();
expect(searchInput()?.value).toBe("");
expect(document.body.textContent).toContain("alpha");
expect(document.querySelector("[data-redis-search-stop]")).toBeNull();
loadMoreButton().click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
finish({ kind: "set", items: [{ member: blob("obsolete") }], scan_cursor: 99 });
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore.mock.calls[1][4]).toBe(10);
expect(mocks.redisLoadMore.mock.calls[1][6]).toBeUndefined();
expect(document.body.textContent).toContain("alpha");
expect(document.body.textContent).toContain("beta");
expect(document.body.textContent).not.toContain("obsolete");
});
it("discards a submitted search on editing, and Escape cancels the new draft", async () => {
mocks.redisGetValue.mockResolvedValue(setValue());
mocks.redisLoadMore.mockResolvedValue({ kind: "set", items: [{ member: blob("found") }] });
mountViewer();
await settle();
await typeSearch("found");
await typeDraft("new keyword");
await settle();
expect(document.body.textContent).toContain("alpha");
expect(document.body.textContent).not.toContain("found");
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
searchInput()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await settle();
expect(searchInput()?.value).toBe("");
expect(document.querySelector("[data-redis-search-cancel]")).toBeNull();
});
it("does not submit Enter while an IME composition is active", async () => {
mocks.redisGetValue.mockResolvedValue(setValue());
mountViewer();
await settle();
await typeDraft("keyword");
searchInput()!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, isComposing: true }));
await settle();
expect(mocks.redisLoadMore).not.toHaveBeenCalled();
});
it("preserves an unsubmitted draft during a ZSet sort reload", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: zsetValue().data.items, scan_cursor: 2 });
mountViewer();
await settle();
await typeDraft("draft");
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Score"))!.click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
expect(mocks.redisLoadMore.mock.calls[0][6]).toBeUndefined();
expect(searchInput()?.value).toBe("draft");
});
it.each(["cancel", "edit"])("preserves a queued %s before the ZSet loading view renders", async (intent) => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockImplementation((_connection, _db, _key, _kind, _cursor, _count, query) => Promise.resolve({ kind: "zset", items: zsetValue().data.items, scan_cursor: query ? undefined : 2 }));
mountViewer();
await settle();
await typeSearch("A");
mocks.redisLoadMore.mockClear();
let finish!: (value: unknown) => void;
const pending = new Promise((resolve) => {
finish = resolve;
});
mocks.redisGetValue.mockReturnValueOnce(pending);
const input = searchInput()!;
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Score"))!.click();
// Both handlers run before Vue replaces the toolbar with the loading view.
if (intent === "cancel") document.querySelector<HTMLButtonElement>("[data-redis-search-cancel]")!.click();
else await typeDraft("B");
await settle();
// Enter during a reload must obey the same disabled state as the button.
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
finish(zsetValue());
for (let i = 0; i < 4; i++) await settle();
expect(searchInput()?.value).toBe(intent === "cancel" ? "" : "B");
expect(mocks.redisLoadMore.mock.calls.every((call) => call[6] == null)).toBe(true);
expect(mocks.redisLoadMore).not.toHaveBeenCalled();
mocks.redisLoadMore.mockClear();
loadMoreButton().click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 2, 200, undefined, "asc");
});
it("updates duplicate Hash values and TTLs without replacing an edit draft or explicit sorting", async () => {
let finish!: (result: unknown) => void;
mocks.redisGetValue.mockResolvedValue({ key_display: "hash", key_raw: "hash", ttl: -1, redis_type: "hash", data: { kind: "hash", items: [], total: 25000, scan_cursor: 10 } });
mocks.redisLoadMore.mockResolvedValueOnce({ kind: "hash", items: [{ field: blob("b-field"), value: blob("old"), field_ttl: 100 }], scan_cursor: 10 }).mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
mountViewer();
await settle();
document.querySelector<HTMLButtonElement>("[role='columnheader'] button")!.click();
await settle();
await typeSearch("field");
document.querySelector<HTMLElement>("[data-redis-value-row]")!.click();
await settle();
document.querySelector<HTMLElement>("[data-redis-member-utf8-viewer]")!.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
await settle();
const editor = document.querySelector<HTMLTextAreaElement>("[data-redis-member-utf8-editor]")!;
editor.value = "retained draft";
editor.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
finish({
kind: "hash",
items: [
{ field: blob("b-field"), value: blob("new"), field_ttl: 50 },
{ field: blob("a-field"), value: blob("other"), field_ttl: 80 },
],
});
await settle();
expect(editor.value).toBe("retained draft");
expect(document.querySelector("[role='columnheader']")?.getAttribute("aria-sort")).toBe("ascending");
const rows = [...document.querySelectorAll<HTMLElement>("[data-redis-value-row]")];
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain("a-field");
expect(rows[1].textContent).toContain("new");
expect(rows[1].textContent).toContain("50s");
});
it("retries an initial load that settled while the viewer was inactive", async () => {
let finish!: (value: unknown) => void;
mocks.redisGetValue
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce(setValue());
const visible = mountViewer(true);
await settle();
visible.value = false;
await settle();
finish(setValue());
await settle();
visible.value = true;
await settle();
expect(mocks.redisGetValue).toHaveBeenCalledTimes(2);
expect(document.body.textContent).toContain("alpha");
expect(searchInput()).not.toBeNull();
});
it("does not let a stale search overwrite a refreshed collection", async () => {
mocks.redisGetValue.mockResolvedValueOnce(setValue()).mockResolvedValueOnce({ ...setValue(), data: { kind: "set", items: [{ member: blob("fresh") }], total: 1 } });
let finish!: (page: unknown) => void;
mocks.redisLoadMore.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
mountViewer();
await settle();
await typeSearch("old");
document.querySelector<HTMLButtonElement>("[data-redis-value-refresh]")!.click();
await settle();
finish({ kind: "set", items: [{ member: blob("obsolete-result") }], scan_cursor: 10 });
await settle();
expect(document.body.textContent).toContain("fresh");
expect(document.body.textContent).not.toContain("obsolete-result");
expect(searchInput()?.value).toBe("");
});
it("uses the new key after scope invalidation while the old request is still in flight", async () => {
mocks.redisGetValue.mockResolvedValue(setValue());
let finish!: (page: unknown) => void;
mocks.redisLoadMore
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce({ kind: "set", items: [{ member: blob("new-result") }] });
const key = ref("key");
mountViewer(false, key);
await settle();
await typeSearch("old");
key.value = "new-key";
await settle();
await typeSearch("new");
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
finish({ kind: "set", items: [{ member: blob("obsolete-result") }] });
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore.mock.calls[1][2]).toBe("new-key");
expect(document.body.textContent).toContain("new-result");
expect(document.body.textContent).not.toContain("obsolete-result");
});
it("allows ordinary paging to resume after deactivation without consuming it twice", async () => {
mocks.redisGetValue.mockResolvedValue({ ...setValue(), data: { ...setValue().data, total: 100, scan_cursor: 10 } });
let finish!: (page: unknown) => void;
mocks.redisLoadMore.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const visible = mountViewer(true);
await settle();
loadMoreButton().click();
await settle();
visible.value = false;
await settle();
finish({ kind: "set", items: [{ member: blob("resumed") }], scan_cursor: undefined });
await settle();
visible.value = true;
await settle();
loadMoreButton().click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("resumed");
});
it("rolls back a descending reload canceled by deactivation and keeps ascending pagination", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
const visible = mountViewer(true);
await settle();
let finish!: (value: unknown) => void;
mocks.redisGetValue.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Score"))!.click();
await settle();
visible.value = false;
await settle();
finish(zsetValue());
await settle();
visible.value = true;
await settle();
mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined });
loadMoreButton().click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 2, 200, undefined, "asc");
});
it("automatically reaches a hash field after consecutive empty scan slices", async () => {
mocks.redisGetValue.mockResolvedValue({ key_display: "hash", key_raw: "hash", ttl: -1, redis_type: "hash", data: { kind: "hash", items: [], total: 25271, scan_cursor: 10 } });
mocks.redisLoadMore
.mockResolvedValueOnce({ kind: "hash", items: [], scan_cursor: 20 })
.mockResolvedValueOnce({ kind: "hash", items: [], scan_cursor: 30 })
.mockResolvedValueOnce({ kind: "hash", items: [{ field: blob("EC00100051EB02DD"), value: blob("exists") }], scan_cursor: 40 })
.mockResolvedValueOnce({ kind: "hash", items: [] });
mountViewer();
await settle();
await typeSearch("EC00100051EB02DD");
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore.mock.calls.map((call) => call[4])).toEqual([0, 20, 30, 40]);
expect(document.body.textContent).toContain("1 fields");
expect(document.body.textContent).toContain("Search complete");
});
it("does not enqueue intermediate keywords behind a delayed search", async () => {
let finish!: (result: unknown) => void;
mocks.redisGetValue.mockResolvedValue(setValue());
mocks.redisLoadMore
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce({ kind: "set", items: [{ member: blob("C") }], scan_cursor: undefined });
mountViewer();
await settle();
await typeSearch("A");
await typeSearch("B");
await typeSearch("C");
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
finish({ kind: "set", items: [{ member: blob("A") }], scan_cursor: 10 });
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore.mock.calls.map((call) => call[6])).toEqual(["A", "C"]);
expect(document.body.textContent).toContain("C");
expect(document.body.textContent).toContain("Search complete");
});
it("shows stopping as incomplete, and can continue from an unstarted first cursor", async () => {
let finish!: (result: unknown) => void;
mocks.redisGetValue.mockResolvedValue(setValue());
mocks.redisLoadMore
.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
)
.mockResolvedValueOnce({ kind: "set", items: [], scan_cursor: undefined });
mountViewer();
await settle();
await typeSearch("missing");
document.querySelector<HTMLButtonElement>("[data-redis-search-stop]")!.click();
await settle();
expect(document.body.textContent).toContain("Search stopped; results and progress kept");
expect(document.body.textContent).not.toContain("No matches found");
document.querySelector<HTMLButtonElement>("[data-redis-search-continue]")!.click();
await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledTimes(1);
finish({ kind: "set", items: [], scan_cursor: 50 });
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore.mock.calls[1][4]).toBe(50);
expect(document.body.textContent).toContain("No matches found");
});
it("offers a member search box for zset keys", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mountViewer();
@@ -217,23 +575,23 @@ describe("Redis collection member search", () => {
expect(document.body.textContent).not.toContain("2314099896:11842851748888");
});
it("carries the active query into load-more paging", async () => {
it("carries the active query into automatic continuation", async () => {
mocks.redisGetValue.mockResolvedValue(zsetValue());
mocks.redisLoadMore.mockResolvedValue({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: 200,
});
mocks.redisLoadMore
.mockResolvedValueOnce({
kind: "zset",
items: [{ score: "2", member: blob("2314101855:22903060973580") }],
scan_cursor: 200,
})
.mockResolvedValueOnce({ kind: "zset", items: [], scan_cursor: undefined });
mountViewer();
await settle();
await typeSearch("22903060");
mocks.redisLoadMore.mockClear();
mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined });
loadMoreButton().click();
await settle();
for (let i = 0; i < 4; i++) await settle();
expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 200, 200, "22903060", "asc");
expect(document.body.textContent).toContain("Search complete");
});
it("keeps the query when the zset sort direction is toggled", async () => {
@@ -5,7 +5,7 @@ import type { CalendarDateTime } from "@internationalized/date";
import { useI18n } from "vue-i18n";
import { onClickOutside } from "@vueuse/core";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, X, FileArchive, Download } from "@lucide/vue";
import { Check, ChevronDown, Copy, ClipboardCopy, Eye, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText, ArrowUp, ArrowDown, ArrowUpDown, Search, Pause, X, FileArchive, Download } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -40,7 +40,6 @@ import {
parseRedisJsonDetail,
REDIS_VALUE_FORMAT_DISPLAY_ORDER,
redisBlobText,
redisCollectionPageItems,
redisJsonValueText,
normalizeRedisJsonDraft,
redisClipboardSafeText,
@@ -73,6 +72,7 @@ import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, va
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
import { formatBytes } from "@/lib/database/serverMetrics";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { emptyRedisCollectionSearchState, RedisCollectionSearchController, type RedisCollectionScope } from "@/lib/redis/redisCollectionSearchController";
const { t, locale } = useI18n();
const { toast } = useToast();
@@ -412,6 +412,9 @@ const valueSearchHasNavigated = ref(false);
const collectionSearchQuery = ref("");
const activeCollectionSearchQuery = ref("");
const searchLoading = ref(false);
const collectionSearchState = shallowRef(emptyRedisCollectionSearchState());
let collectionBrowseSnapshot: { items: RedisCollectionItem[]; cursor: number | undefined } | undefined;
let collectionSearchIntentRevision = 0;
const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null);
type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean };
const stringJsonEditorRef = ref<JsonEditorHandle | null>(null);
@@ -422,6 +425,7 @@ const memberTextareaRef = ref<HTMLTextAreaElement | null>(null);
const valueViewerSearchActive = ref(false);
function toggleHashSort(column: "field" | "value") {
stopCollectionSearch();
if (hashSortBy.value === column && hashSortDir.value === "desc") {
hashSortBy.value = null;
} else if (hashSortBy.value === column) {
@@ -437,7 +441,7 @@ async function toggleZsetSort() {
const previousDirection = zsetSortDir.value;
zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc";
try {
await reloadPreservingCollectionSearch({ notifyParent: false });
if (!(await reloadPreservingCollectionSearch({ notifyParent: false }))) zsetSortDir.value = previousDirection;
} catch (error) {
zsetSortDir.value = previousDirection;
toast(errorMessage(error), 3000);
@@ -863,8 +867,6 @@ const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolea
const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value);
const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value);
let collectionSearchTimer: ReturnType<typeof setTimeout> | null = null;
let collectionSearchRequestId = 0;
let hashResizeStartX = 0;
let hashResizeStartWidth = 0;
let zsetResizeStartX = 0;
@@ -1194,53 +1196,103 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb
/** True for the collection kinds whose toolbar offers a server-filtered member search. */
const collectionSearchSupported = computed(() => redisKind.value === "hash" || redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "zset");
function onCollectionSearchInput() {
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = setTimeout(() => void onCollectionSearch(), 400);
const collectionSearchController = new RedisCollectionSearchController({
fetchPage: ({ scope, cursor, query }) => api.redisLoadMore(scope.connectionId, scope.db, scope.keyRaw, scope.kind, cursor, 200, query || undefined, scope.sortDirection),
changed: (state) => {
collectionSearchState.value = state;
activeCollectionSearchQuery.value = state.query;
searchLoading.value = Boolean(state.query) && state.status === "searching";
loadingMore.value = !state.query && state.status === "searching";
if (state.status !== "idle") {
collectionItems.value = state.items;
scanCursor.value = state.cursor ?? undefined;
}
},
});
function collectionScope(): RedisCollectionScope | undefined {
const kind = redisKind.value;
if (kind !== "hash" && kind !== "list" && kind !== "set" && kind !== "zset") return;
return { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw, kind, sortDirection: kind === "zset" ? zsetSortDir.value : undefined };
}
const collectionSearchStatusText = computed(() => {
const state = collectionSearchState.value;
if (state.status === "failed") return t("redis.collectionSearchFailed", { error: errorMessage(state.error) });
if (state.pauseReason === "stalled") return t("redis.collectionSearchStalled");
if (state.status === "complete") return t(state.items.length ? "redis.collectionSearchComplete" : "redis.collectionSearchEmpty");
if (state.status === "partial") return t("redis.collectionSearchPartial");
if (state.pauseReason === "stopped") return t("redis.collectionSearchStopped");
if (state.status === "paused") return t("redis.collectionSearchPaused");
return t("redis.collectionSearching");
});
const collectionSearchPlaceholder = computed(() => t(redisKind.value === "hash" ? "redis.searchFields" : redisKind.value === "list" ? "redis.searchItems" : "redis.searchMembers"));
const canContinueCollectionSearch = computed(() => Boolean(activeCollectionSearchQuery.value) && collectionSearchState.value.cursor != null && !searchLoading.value);
function resetCollectionSearchResults() {
collectionSearchController.cancelSearch();
if (collectionBrowseSnapshot) {
collectionItems.value = collectionBrowseSnapshot.items;
scanCursor.value = collectionBrowseSnapshot.cursor;
collectionBrowseSnapshot = undefined;
}
if (!hasRetainedMemberDraft.value) clearSelectedMember();
}
function onCollectionSearchInput(event: Event) {
// Editing is a draft, not a submitted search. Invalidate any old request now.
// Capture before the Input wrapper's passive v-model watcher or a reload unmount.
collectionSearchQuery.value = (event.target as HTMLInputElement).value;
collectionSearchIntentRevision++;
resetCollectionSearchResults();
}
function cancelCollectionSearch() {
collectionSearchIntentRevision++;
collectionSearchQuery.value = "";
resetCollectionSearchResults();
}
function stopCollectionSearch() {
collectionSearchController.stop();
}
function onCollectionSearchKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === "Enter") {
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
void onCollectionSearch();
event.preventDefault();
if (!loading.value && !searchLoading.value) void (canContinueCollectionSearch.value ? collectionSearchController.start() : onCollectionSearch());
return;
}
if (event.key === "Escape") {
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
collectionSearchQuery.value = "";
void onCollectionSearch();
event.preventDefault();
cancelCollectionSearch();
}
}
async function onCollectionSearch() {
const scope = collectionScope();
const query = collectionSearchQuery.value.trim();
const keyType = redisKind.value;
if (!collectionSearchSupported.value) return;
const requestId = ++collectionSearchRequestId;
searchLoading.value = true;
try {
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, 0, 200, query || undefined, sortDirection);
if (requestId !== collectionSearchRequestId || result.kind !== keyType) return;
activeCollectionSearchQuery.value = query;
collectionItems.value = result.items;
scanCursor.value = result.scan_cursor ?? undefined;
if (!hasRetainedMemberDraft.value) clearSelectedMember();
} finally {
if (requestId === collectionSearchRequestId) searchLoading.value = false;
}
if (!scope || loading.value || !query) return;
collectionSearchIntentRevision++;
if (!activeCollectionSearchQuery.value) collectionBrowseSnapshot = { items: collectionItems.value, cursor: scanCursor.value };
collectionSearchController.prepare(scope, query);
if (!hasRetainedMemberDraft.value) clearSelectedMember();
await collectionSearchController.start();
}
/** `load()` clears the search box, so anything that reloads mid-search has to re-apply the query. */
async function reloadPreservingCollectionSearch(options: { notifyParent?: boolean } = {}) {
const query = activeCollectionSearchQuery.value || collectionSearchQuery.value.trim();
await load({ ...options, selectDefaultMember: false });
const query = activeCollectionSearchQuery.value;
const draft = collectionSearchQuery.value;
const revision = collectionSearchIntentRevision;
if (!(await load({ ...options, selectDefaultMember: false, shouldApply: () => revision === collectionSearchIntentRevision }))) return false;
collectionSearchQuery.value = draft;
if (query) {
collectionSearchQuery.value = query;
await onCollectionSearch();
}
return true;
}
function readRedisJsonWordWrap(): boolean {
@@ -1602,11 +1654,15 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
const notifyParent = options.notifyParent ?? true;
const shouldSelectDefaultMember = options.selectDefaultMember ?? true;
const requestId = ++loadRequestId;
if (!background) stopCollectionSearch();
const loadScope = { connectionId: props.connectionId, db: props.db, keyRaw: props.keyRaw };
if (!background) loading.value = true;
try {
let loadedValue = await api.redisGetValue(props.connectionId, props.db, props.keyRaw);
let loadedValue = await api.redisGetValue(loadScope.connectionId, loadScope.db, loadScope.keyRaw);
if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false;
if (loadedValue.data.kind === "zset" && zsetSortDir.value === "desc") {
const sortedPage = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "zset", 0, 200, undefined, "desc");
const sortedPage = await collectionSearchController.readPage({ ...loadScope, kind: "zset", sortDirection: "desc" }, 0, "", () => requestId === loadRequestId && (!options.shouldApply || options.shouldApply()));
if (!sortedPage) return false;
if (sortedPage.kind === "zset") {
loadedValue = {
...loadedValue,
@@ -1618,7 +1674,7 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
};
}
}
if (requestId !== loadRequestId || (options.shouldApply && !options.shouldApply())) return false;
if (requestId !== loadRequestId || !redisValueViewerIsActive || (options.shouldApply && !options.shouldApply())) return false;
// Redis reports a key that expired between refreshes as a `none` value.
// Tell the browser to remove it instead of rendering a stale detail shell.
@@ -1649,9 +1705,8 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
return false;
}
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
collectionSearchTimer = null;
collectionSearchRequestId++;
collectionBrowseSnapshot = undefined;
collectionSearchController.reset();
collectionSearchQuery.value = "";
activeCollectionSearchQuery.value = "";
searchLoading.value = false;
@@ -1721,20 +1776,10 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
async function loadMore() {
if (!data.value || !hasMore.value || loadingMore.value || searchLoading.value) return;
if (!collectionSearchSupported.value) return;
const keyType = redisKind.value;
const filter = activeCollectionSearchQuery.value || undefined;
const requestId = collectionSearchRequestId;
loadingMore.value = true;
try {
const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined;
const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, filter, sortDirection);
if (requestId !== collectionSearchRequestId) return;
const newItems = redisCollectionPageItems(result);
collectionItems.value = [...collectionItems.value, ...newItems];
scanCursor.value = result.scan_cursor ?? undefined;
} finally {
loadingMore.value = false;
}
const scope = collectionScope();
if (!scope) return;
if (activeCollectionSearchQuery.value) await collectionSearchController.start();
else await collectionSearchController.browse(scope, collectionItems.value, scanCursor.value!);
}
async function saveString() {
@@ -2758,6 +2803,10 @@ watch(contentSearchText, () => {
watch(
() => [props.connectionId, props.db, props.keyRaw],
() => {
loadRequestId++;
collectionBrowseSnapshot = undefined;
collectionSearchController.reset();
collectionSearchQuery.value = "";
resetValueSearch();
valueViewerSearchActive.value = false;
resetStreamEntries();
@@ -2806,20 +2855,24 @@ onMounted(() => {
});
onActivated(() => {
redisValueViewerIsActive = true;
collectionSearchController.setActive(true);
if (!data.value && !loading.value) void load();
startRefreshTimers();
});
onDeactivated(() => {
redisValueViewerIsActive = false;
collectionSearchController.setActive(false);
stopRefreshTimers();
});
onBeforeUnmount(() => {
loadRequestId++;
collectionSearchController.dispose();
window.removeEventListener("pointerdown", handleValueViewerPointerDown, true);
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
redisValueViewerIsActive = false;
stopRefreshTimers();
stopResizeHashColumns();
stopResizeZsetColumns();
if (collectionSearchTimer) clearTimeout(collectionSearchTimer);
});
defineExpose({ focusSearch });
@@ -2923,6 +2976,33 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
</div>
</div>
<div v-if="collectionSearchSupported" data-redis-collection-search class="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2 text-xs">
<div class="relative min-w-40 max-w-sm flex-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input v-model="collectionSearchQuery" class="h-7 w-full pl-6 text-xs" :placeholder="collectionSearchPlaceholder" :aria-label="collectionSearchPlaceholder" :title="t('redis.collectionSearchSubmitHint')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<Button
v-if="searchLoading"
data-redis-search-stop
variant="outline"
size="sm"
class="h-7 shrink-0 border-amber-600 bg-amber-50 text-xs text-amber-800 hover:bg-amber-100 hover:text-amber-900 active:bg-amber-200 active:text-amber-900 dark:border-amber-400 dark:bg-amber-950 dark:text-amber-300 dark:hover:bg-amber-900 dark:hover:text-amber-200 dark:active:bg-amber-800 dark:active:text-amber-100"
@click="stopCollectionSearch"
><Pause class="h-3.5 w-3.5" aria-hidden="true" />{{ t("redis.collectionSearchStop") }}</Button
>
<Button v-else-if="canContinueCollectionSearch" data-redis-search-continue size="sm" class="h-7 shrink-0 text-xs" :disabled="loading" @click="collectionSearchController.start()">{{
t(collectionSearchState.status === "failed" ? "redis.collectionSearchRetry" : "redis.collectionSearchContinue")
}}</Button>
<Button v-else data-redis-search-submit size="sm" class="h-7 shrink-0 text-xs" :disabled="loading || !collectionSearchQuery.trim()" @click="onCollectionSearch">{{ t("redis.collectionSearchSubmit") }}</Button>
<Button v-if="collectionSearchQuery || activeCollectionSearchQuery" data-redis-search-cancel variant="ghost" size="sm" class="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground" @click="cancelCollectionSearch"
><X class="h-3.5 w-3.5" aria-hidden="true" />{{ t("redis.collectionSearchCancel") }}</Button
>
<div v-if="activeCollectionSearchQuery" class="flex basis-full items-center gap-1.5 text-muted-foreground">
<Loader2 v-if="searchLoading" class="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
<span role="status" aria-live="polite">{{ collectionSearchStatusText }}</span>
</div>
</div>
<!-- String / kvrocks Bitmap(位图同样按字节预览,但只读) -->
<div v-if="(isStringLikeKind || isBitmapKind) && stringValueDetail" class="flex-1 flex flex-col overflow-hidden">
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
@@ -3100,10 +3180,6 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
<div v-else-if="redisKind === 'list'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchItems')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" :placeholder="t('redis.valuePlaceholder')" @keydown.enter="listPush" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />{{ t("redis.pushAction") }}</Button>
@@ -3134,7 +3210,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<div v-if="hasMore && !activeCollectionSearchQuery" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
@@ -3148,10 +3224,6 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
<div v-else-if="redisKind === 'set'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", setRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" :placeholder="t('redis.memberPlaceholder')" @keydown.enter="setAdd" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />{{ t("redis.addAction") }}</Button>
@@ -3185,7 +3257,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<div v-if="hasMore && !activeCollectionSearchQuery" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
@@ -3199,10 +3271,6 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
<div v-else-if="redisKind === 'hash'" ref="hashTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newField" class="h-6 w-24 text-xs" :placeholder="t('redis.fieldPlaceholder')" />
<Input v-model="newValue" class="h-6 w-32 text-xs" :placeholder="t('redis.valuePlaceholder')" @keydown.enter="hashSet" />
@@ -3278,7 +3346,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<div v-if="hasMore && !activeCollectionSearchQuery" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
@@ -3292,10 +3360,6 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
<div v-else-if="redisKind === 'zset'" ref="zsetTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("members", zsetRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newScore" class="h-6 w-20 text-xs" :placeholder="t('redis.scorePlaceholder')" />
<Input v-model="newValue" class="h-6 w-32 text-xs" :placeholder="t('redis.memberPlaceholder')" @keydown.enter="zsetAdd" />
@@ -3357,7 +3421,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<div v-if="hasMore && !activeCollectionSearchQuery" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
+14
View File
@@ -6232,6 +6232,20 @@ export default {
searchByKey: "Key",
searchByValue: "Value",
searchByAll: "All",
collectionSearching: "Searching…",
collectionSearchSubmit: "Search",
collectionSearchSubmitHint: "Click Search or press Enter",
collectionSearchStop: "Stop search",
collectionSearchCancel: "Cancel search",
collectionSearchStopped: "Stopped; results and progress kept",
collectionSearchPartial: "More data remains to search",
collectionSearchComplete: "Search complete",
collectionSearchEmpty: "No matches found",
collectionSearchPaused: "Search paused",
collectionSearchStalled: "Search did not advance; you can retry",
collectionSearchFailed: "Search failed: {error}",
collectionSearchContinue: "Continue search",
collectionSearchRetry: "Retry search",
searchFields: "Search fields and values",
searchMembers: "Search members",
searchItems: "Search items",
+14
View File
@@ -5691,6 +5691,20 @@ export default withEnglishFallback({
searchByKey: "Clave",
searchByValue: "Valor",
searchByAll: "Todo",
collectionSearching: "Buscando…",
collectionSearchSubmit: "Buscar",
collectionSearchSubmitHint: "Haz clic en Buscar o pulsa Intro",
collectionSearchStop: "Detener búsqueda",
collectionSearchCancel: "Cancelar búsqueda",
collectionSearchStopped: "Detenida; se conservan resultados y progreso",
collectionSearchPartial: "Quedan datos por buscar",
collectionSearchComplete: "Búsqueda completada",
collectionSearchEmpty: "No se encontraron coincidencias",
collectionSearchPaused: "Búsqueda en pausa",
collectionSearchStalled: "La búsqueda no avanzó; puedes reintentar",
collectionSearchFailed: "Error de búsqueda: {error}",
collectionSearchContinue: "Continuar búsqueda",
collectionSearchRetry: "Reintentar búsqueda",
searchFields: "Buscar campos y valores",
searchMembers: "Buscar miembros",
searchItems: "Buscar elementos",
+14
View File
@@ -5505,6 +5505,20 @@ export default withEnglishFallback({
searchByKey: "Chiave",
searchByValue: "Valore",
searchByAll: "Tutto",
collectionSearching: "Ricerca in corso…",
collectionSearchSubmit: "Cerca",
collectionSearchSubmitHint: "Fai clic su Cerca o premi Invio",
collectionSearchStop: "Interrompi ricerca",
collectionSearchCancel: "Annulla ricerca",
collectionSearchStopped: "Interrotta; risultati e avanzamento conservati",
collectionSearchPartial: "Restano dati da cercare",
collectionSearchComplete: "Ricerca completata",
collectionSearchEmpty: "Nessuna corrispondenza trovata",
collectionSearchPaused: "Ricerca in pausa",
collectionSearchStalled: "La ricerca non è avanzata; puoi riprovare",
collectionSearchFailed: "Ricerca non riuscita: {error}",
collectionSearchContinue: "Continua ricerca",
collectionSearchRetry: "Riprova ricerca",
searchFields: "Cerca campi e valori",
searchMembers: "Cerca membri",
searchItems: "Cerca elementi",
+14
View File
@@ -5530,6 +5530,20 @@ export default withEnglishFallback({
searchByKey: "キー",
searchByValue: "値",
searchByAll: "すべて",
collectionSearching: "検索中…",
collectionSearchSubmit: "検索",
collectionSearchSubmitHint: "検索をクリックするか Enter を押してください",
collectionSearchStop: "検索を停止",
collectionSearchCancel: "検索をキャンセル",
collectionSearchStopped: "停止しました。結果と進行状況は保持されています",
collectionSearchPartial: "検索されていないデータが残っています",
collectionSearchComplete: "検索完了",
collectionSearchEmpty: "一致する結果は見つかりませんでした",
collectionSearchPaused: "検索は一時停止中です",
collectionSearchStalled: "検索が進みませんでした。再試行できます",
collectionSearchFailed: "検索に失敗しました: {error}",
collectionSearchContinue: "検索を続行",
collectionSearchRetry: "検索を再試行",
searchFields: "フィールドまたは値を検索",
searchMembers: "メンバーを検索",
searchItems: "要素を検索",
+14
View File
@@ -5219,6 +5219,20 @@ export default withEnglishFallback({
searchByKey: "키",
searchByValue: "값",
searchByAll: "전체",
collectionSearching: "검색 중…",
collectionSearchSubmit: "검색",
collectionSearchSubmitHint: "검색을 클릭하거나 Enter를 누르세요",
collectionSearchStop: "검색 중지",
collectionSearchCancel: "검색 취소",
collectionSearchStopped: "중지됨. 결과와 진행 상태는 유지됩니다",
collectionSearchPartial: "검색하지 않은 데이터가 남아 있습니다",
collectionSearchComplete: "검색 완료",
collectionSearchEmpty: "일치하는 결과가 없습니다",
collectionSearchPaused: "검색 일시 중지됨",
collectionSearchStalled: "검색이 진행되지 않았습니다. 다시 시도할 수 있습니다",
collectionSearchFailed: "검색 실패: {error}",
collectionSearchContinue: "검색 계속",
collectionSearchRetry: "검색 다시 시도",
searchFields: "필드와 값 검색",
searchMembers: "멤버 검색",
searchItems: "항목 검색",
+14
View File
@@ -5505,6 +5505,20 @@ export default withEnglishFallback({
searchByKey: "Chave",
searchByValue: "Valor",
searchByAll: "Tudo",
collectionSearching: "Pesquisando…",
collectionSearchSubmit: "Pesquisar",
collectionSearchSubmitHint: "Clique em Pesquisar ou pressione Enter",
collectionSearchStop: "Parar pesquisa",
collectionSearchCancel: "Cancelar pesquisa",
collectionSearchStopped: "Interrompida; resultados e progresso mantidos",
collectionSearchPartial: "Ainda há dados a pesquisar",
collectionSearchComplete: "Pesquisa concluída",
collectionSearchEmpty: "Nenhuma correspondência encontrada",
collectionSearchPaused: "Pesquisa pausada",
collectionSearchStalled: "A pesquisa não avançou; você pode tentar novamente",
collectionSearchFailed: "Falha na pesquisa: {error}",
collectionSearchContinue: "Continuar pesquisa",
collectionSearchRetry: "Repetir pesquisa",
searchFields: "Pesquisar campos e valores",
searchMembers: "Pesquisar membros",
searchItems: "Pesquisar itens",
+14
View File
@@ -6542,6 +6542,20 @@ export default withEnglishFallback({
searchByKey: "Ключ",
searchByValue: "Значение",
searchByAll: "Все",
collectionSearching: "Поиск…",
collectionSearchSubmit: "Найти",
collectionSearchSubmitHint: "Нажмите «Найти» или Enter",
collectionSearchStop: "Остановить поиск",
collectionSearchCancel: "Отменить поиск",
collectionSearchStopped: "Остановлено; результаты и прогресс сохранены",
collectionSearchPartial: "Остались непросмотренные данные",
collectionSearchComplete: "Поиск завершён",
collectionSearchEmpty: "Совпадений не найдено",
collectionSearchPaused: "Поиск приостановлен",
collectionSearchStalled: "Поиск не продвинулся; можно повторить",
collectionSearchFailed: "Ошибка поиска: {error}",
collectionSearchContinue: "Продолжить поиск",
collectionSearchRetry: "Повторить поиск",
searchFields: "Поиск по полям и значениям",
searchMembers: "Поиск элементов множества",
searchItems: "Поиск элементов",
+14
View File
@@ -6223,6 +6223,20 @@ export default withEnglishFallback({
searchByKey: "键",
searchByValue: "值",
searchByAll: "全部",
collectionSearching: "正在搜索…",
collectionSearchSubmit: "搜索",
collectionSearchSubmitHint: "点击搜索或按 Enter",
collectionSearchStop: "停止搜索",
collectionSearchCancel: "取消搜索",
collectionSearchStopped: "已停止,结果和进度已保留",
collectionSearchPartial: "还有数据未搜索",
collectionSearchComplete: "搜索完成",
collectionSearchEmpty: "未找到匹配结果",
collectionSearchPaused: "搜索已暂停",
collectionSearchStalled: "搜索未能继续推进,可重试",
collectionSearchFailed: "搜索失败:{error}",
collectionSearchContinue: "继续搜索",
collectionSearchRetry: "重试搜索",
searchFields: "搜索字段或值",
searchMembers: "搜索成员",
searchItems: "搜索元素",
+14
View File
@@ -4846,6 +4846,20 @@ export default withEnglishFallback({
searchByKey: "鍵",
searchByValue: "值",
searchByAll: "全部",
collectionSearching: "正在搜尋…",
collectionSearchSubmit: "搜尋",
collectionSearchSubmitHint: "點擊搜尋或按 Enter",
collectionSearchStop: "停止搜尋",
collectionSearchCancel: "取消搜尋",
collectionSearchStopped: "已停止,結果與進度已保留",
collectionSearchPartial: "還有資料未搜尋",
collectionSearchComplete: "搜尋完成",
collectionSearchEmpty: "未找到符合的結果",
collectionSearchPaused: "搜尋已暫停",
collectionSearchStalled: "搜尋未能繼續推進,可重試",
collectionSearchFailed: "搜尋失敗:{error}",
collectionSearchContinue: "繼續搜尋",
collectionSearchRetry: "重試搜尋",
searchFields: "搜尋欄位或值",
searchMembers: "搜尋成員",
searchItems: "搜尋元素",
@@ -0,0 +1,316 @@
import { describe, expect, it, vi } from "vitest";
import type { RedisCollectionPage } from "@/lib/backend/api";
import { emptyRedisCollectionSearchState, mergeRedisCollectionItems, RedisCollectionSearchController, type RedisCollectionSearchState } from "../redisCollectionSearchController";
const scope = { connectionId: "connection", db: 0, keyRaw: "hash", kind: "hash" as const };
const blob = (text: string) => ({ raw_base64: btoa(text), encoding: "utf8" as const });
const entry = (field = "wanted", value = "value") => ({ field: blob(field), value: blob(value) });
const page = (cursor?: number, items = [] as ReturnType<typeof entry>[]): RedisCollectionPage => ({ kind: "hash", items, scan_cursor: cursor });
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
async function settle() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
function setup(fetchPage = vi.fn().mockResolvedValue(page())) {
let state: RedisCollectionSearchState = emptyRedisCollectionSearchState();
const controller = new RedisCollectionSearchController({
fetchPage,
changed: (value) => {
state = value;
},
});
return {
controller,
fetchPage,
state: () => state,
};
}
describe("Redis progressive collection search", () => {
it("restores a consumed browsing overflow page after canceling a search", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(page(undefined, [entry("match")])),
);
const browsing = s.controller.browse(scope, [entry("original")], 17);
await settle();
s.controller.cancelSearch();
s.controller.prepare(scope, "match");
const search = s.controller.start();
old.resolve(page(17, [entry("buffered-browse")]));
await browsing;
await search;
s.controller.cancelSearch();
await s.controller.browse(scope, [entry("original")], 17);
expect(s.fetchPage).toHaveBeenCalledTimes(2);
expect(s.state().items).toEqual([entry("original"), entry("buffered-browse")]);
expect(s.state().query).toBe("");
});
it("discards a retained browsing continuation when the key changes", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(page(undefined, [entry("new-key")])),
);
const browsing = s.controller.browse(scope, [entry("original")], 17);
await settle();
s.controller.prepare({ ...scope, keyRaw: "new-key" }, "match");
s.controller.cancelSearch();
old.resolve(page(17, [entry("obsolete")]));
await browsing;
await s.controller.browse({ ...scope, keyRaw: "new-key" }, [], 17);
expect(s.fetchPage).toHaveBeenCalledTimes(2);
expect(s.state().items).toEqual([entry("new-key")]);
});
it("presents results progressively and keeps scanning after a nonempty page", async () => {
const last = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockResolvedValueOnce(page(10))
.mockResolvedValueOnce(page(30))
.mockResolvedValueOnce(page(50, [entry()]))
.mockReturnValueOnce(last.promise),
);
s.controller.prepare(scope, "wanted");
const searching = s.controller.start();
for (let i = 0; i < 4; i++) await settle();
expect(s.fetchPage.mock.calls.map(([request]) => request.cursor)).toEqual([0, 10, 30, 50]);
expect(s.state()).toMatchObject({ status: "searching", cursor: 50, items: [entry()] });
last.resolve(page(undefined, [entry("later")]));
await searching;
expect(s.state()).toMatchObject({ status: "complete", items: [entry(), entry("later")] });
});
it("only marks an exhausted walk as complete", async () => {
const s = setup(vi.fn().mockResolvedValueOnce(page(10)).mockResolvedValueOnce(page()));
s.controller.prepare(scope, "missing");
await s.controller.start();
expect(s.state()).toMatchObject({ status: "complete", cursor: null, items: [] });
});
it("keeps displayed results unchanged across empty continuation pages", async () => {
const empty = deferred<RedisCollectionPage>();
const last = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockResolvedValueOnce(page(10, [entry()]))
.mockReturnValueOnce(empty.promise)
.mockReturnValueOnce(last.promise),
);
s.controller.prepare(scope, "wanted");
const searching = s.controller.start();
await settle();
const displayed = s.state().items;
empty.resolve(page(20));
await settle();
expect(s.state()).toMatchObject({ status: "searching", cursor: 20 });
expect(s.state().items).toBe(displayed);
last.resolve(page());
await searching;
expect(s.state()).toMatchObject({ status: "complete", cursor: null });
expect(s.state().items).toBe(displayed);
});
it("keeps scanning when a page takes longer than five seconds", async () => {
vi.useFakeTimers();
try {
const s = setup(
vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(page(12)), 60_000)))
.mockResolvedValueOnce(page(undefined, [entry()])),
);
s.controller.prepare(scope, "wanted");
const searching = s.controller.start();
await settle();
await vi.advanceTimersByTimeAsync(60_000);
await searching;
expect(s.fetchPage.mock.calls.map(([request]) => request.cursor)).toEqual([0, 12]);
expect(s.state()).toMatchObject({ status: "complete", items: [entry()] });
} finally {
vi.useRealTimers();
}
});
it("keeps only the latest search while an obsolete request is in flight", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(page(undefined, [entry("C")])),
);
s.controller.prepare(scope, "A");
const a = s.controller.start();
await settle();
s.controller.prepare(scope, "B");
const b = s.controller.start();
s.controller.prepare(scope, "C");
const c = s.controller.start();
expect(s.fetchPage).toHaveBeenCalledTimes(1);
old.resolve(page(100, [entry("A")]));
await Promise.all([a, b, c]);
expect(s.fetchPage.mock.calls.map(([request]) => request.query)).toEqual(["A", "C"]);
expect(s.state()).toMatchObject({ query: "C", items: [entry("C")] });
});
it("does not dispatch a new draft until explicitly submitted", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(page()));
s.controller.prepare(scope, "A");
const a = s.controller.start();
await settle();
s.controller.prepare(scope, "B");
old.resolve(page(100));
await a;
expect(s.fetchPage).toHaveBeenCalledTimes(1);
await s.controller.start();
expect(s.fetchPage).toHaveBeenCalledTimes(2);
});
it("keeps scanning a replacement search after waiting for an older request", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(page(10))
.mockResolvedValueOnce(page(undefined, [entry("C")])),
);
s.controller.prepare(scope, "A");
const a = s.controller.start();
await settle();
s.controller.prepare(scope, "C");
const c = s.controller.start();
old.resolve(page(20));
await Promise.all([a, c]);
expect(s.fetchPage).toHaveBeenCalledTimes(3);
expect(s.state()).toMatchObject({ status: "complete", items: [entry("C")] });
});
it("can stop and resume a first request without releasing its physical gate early", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockReturnValueOnce(old.promise)
.mockResolvedValueOnce(page(undefined, [entry()])),
);
s.controller.prepare(scope, "wanted");
const first = s.controller.start();
await settle();
s.controller.stop();
expect(s.state()).toMatchObject({ status: "paused", cursor: 0 });
const resumed = s.controller.start();
expect(s.fetchPage).toHaveBeenCalledTimes(1);
old.resolve(page(99));
await Promise.all([first, resumed]);
expect(s.fetchPage.mock.calls[1][0].cursor).toBe(99);
expect(s.state().status).toBe("complete");
});
it("keeps successful progress on failure and retries that cursor", async () => {
const s = setup(vi.fn().mockResolvedValueOnce(page(10)).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce(page()));
s.controller.prepare(scope, "wanted");
await s.controller.start();
expect(s.state()).toMatchObject({ status: "failed", cursor: 10 });
await s.controller.start();
expect(s.fetchPage.mock.calls[2][0].cursor).toBe(10);
});
it("retains a consumed overflow page privately while stopped and resumes without requesting it twice", async () => {
const overflow = 2 ** 52;
const pending = deferred<RedisCollectionPage>();
const s = setup(
vi
.fn()
.mockResolvedValueOnce(page(overflow, [entry("first")]))
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce(page(undefined, [entry("third")])),
);
s.controller.prepare(scope, "wanted");
const reading = s.controller.start();
await settle();
s.controller.stop();
pending.resolve(page(overflow, [entry("second")]));
await reading;
expect(s.state()).toMatchObject({ status: "paused", items: [entry("first")] });
await s.controller.start();
expect(s.fetchPage).toHaveBeenCalledTimes(3);
expect(s.state()).toMatchObject({ status: "complete", items: [entry("first"), entry("second"), entry("third")] });
});
it("rejects stale errors and reset results without affecting a replacement search", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(page()));
s.controller.prepare(scope, "A");
const a = s.controller.start();
await settle();
s.controller.reset();
s.controller.prepare({ ...scope, db: 2 }, "B");
const b = s.controller.start();
old.reject(new Error("old failure"));
await Promise.all([a, b]);
expect(s.state()).toMatchObject({ query: "B", status: "complete", error: undefined });
expect(s.fetchPage.mock.calls[1][0].scope.db).toBe(2);
});
it("pauses repeated empty cursors, but accepts repeated nonempty overflow cursors", async () => {
const s = setup(
vi
.fn()
.mockResolvedValueOnce(page(10))
.mockResolvedValueOnce(page(10))
.mockResolvedValueOnce(page(10, [entry()]))
.mockResolvedValueOnce(page(undefined, [entry("last")])),
);
s.controller.prepare(scope, "wanted");
await s.controller.start();
expect(s.state()).toMatchObject({ status: "paused", pauseReason: "stalled" });
await s.controller.start();
expect(s.state()).toMatchObject({ status: "complete", items: [entry(), entry("last")] });
});
it("stops on deactivation, requires manual resume on activation, and ignores disposed responses", async () => {
const old = deferred<RedisCollectionPage>();
const s = setup(vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(page()));
s.controller.prepare(scope, "wanted");
const a = s.controller.start();
await settle();
s.controller.setActive(false);
old.resolve(page(10));
await a;
s.controller.setActive(true);
await settle();
expect(s.fetchPage).toHaveBeenCalledTimes(1);
await s.controller.start();
expect(s.state().status).toBe("complete");
const last = deferred<RedisCollectionPage>();
s.fetchPage.mockReturnValueOnce(last.promise);
s.controller.prepare(scope, "last");
const b = s.controller.start();
await settle();
s.controller.dispose();
last.resolve(page(50));
await b;
expect(s.fetchPage).toHaveBeenCalledTimes(3);
expect(s.state().status).toBe("paused");
});
it("ordinary browsing requests a single page even when that page is empty", async () => {
const s = setup(vi.fn().mockResolvedValue(page(20)));
await s.controller.browse(scope, [entry()], 10);
expect(s.fetchPage).toHaveBeenCalledTimes(1);
expect(s.state()).toMatchObject({ status: "partial", items: [entry()], cursor: 20 });
});
it("merges newer values, preserves duplicate List values at distinct indices, and updates ZSet scores", () => {
expect(mergeRedisCollectionItems("hash", [{ ...entry("field", "old"), field_ttl: 100 }], [{ ...entry("field", "new"), field_ttl: 50 }])).toEqual([{ ...entry("field", "new"), field_ttl: 50 }]);
const value = blob("same");
expect(mergeRedisCollectionItems("list", [{ index: 0, value }], [{ index: 1, value }])).toHaveLength(2);
expect(mergeRedisCollectionItems("set", [{ member: value }], [{ member: value }])).toHaveLength(1);
expect(mergeRedisCollectionItems("zset", [{ member: value, score: "1" }], [{ member: value, score: "2" }])).toEqual([{ member: value, score: "2" }]);
});
});
@@ -0,0 +1,201 @@
import type { RedisCollectionPage } from "@/lib/backend/api";
import type { RedisCollectionItem } from "./redisValuePresentation";
export type RedisCollectionScope = {
connectionId: string;
db: number;
keyRaw: string;
kind: RedisCollectionPage["kind"];
sortDirection?: "asc" | "desc";
};
export type RedisCollectionSearchState = {
status: "idle" | "searching" | "partial" | "complete" | "paused" | "failed";
query: string;
items: RedisCollectionItem[];
// Zero means the first request is still pending; null means the walk is complete.
cursor: number | null;
error?: unknown;
pauseReason?: "stopped" | "inactive" | "stalled";
};
export const emptyRedisCollectionSearchState = (): RedisCollectionSearchState => ({ status: "idle", query: "", items: [], cursor: null });
export function mergeRedisCollectionItems(kind: RedisCollectionScope["kind"], previous: RedisCollectionItem[], incoming: RedisCollectionItem[]): RedisCollectionItem[] {
if (incoming.length === 0) return previous;
const identity = (item: RedisCollectionItem): string => {
if (kind === "list" && "index" in item) return String(item.index);
if ("field" in item) return item.field.raw_base64;
if ("member" in item) return item.member.raw_base64;
throw new Error("Unexpected Redis collection item");
};
const merged = new Map<string, RedisCollectionItem>();
for (const item of [...previous, ...incoming]) merged.set(identity(item), item);
return [...merged.values()];
}
type Request = { scope: RedisCollectionScope; cursor: number; query: string };
type Session = { scope: RedisCollectionScope; state: RedisCollectionSearchState; buffered?: { cursor: number; page: RedisCollectionPage } };
type Operation = { session: Session; revision: number; resolve: () => void };
/** Serializes real page requests, while replacing pending searches with the latest intent. */
export class RedisCollectionSearchController {
private session?: Session;
private browsingSession?: Session;
private revision = 0;
private pending?: Operation;
private pumping = false;
private active = true;
private disposed = false;
private gate: Promise<void> = Promise.resolve();
constructor(
private readonly options: {
fetchPage: (request: Request) => Promise<RedisCollectionPage>;
changed: (state: RedisCollectionSearchState) => void;
},
) {}
private publish(state: RedisCollectionSearchState) {
if (this.session) this.session.state = state;
if (!this.disposed) this.options.changed(state);
}
private invalidate() {
this.revision++;
this.pending?.resolve();
this.pending = undefined;
}
prepare(scope: RedisCollectionScope, query: string) {
this.invalidate();
if (query && this.session && !this.session.state.query && this.sameScope(this.session.scope, scope)) this.browsingSession = this.session;
if (this.browsingSession && (!query || !this.sameScope(this.browsingSession.scope, scope))) this.browsingSession = undefined;
this.session = { scope: { ...scope }, state: { status: "searching", query, items: [], cursor: 0 } };
this.publish(this.session.state);
}
reset() {
this.invalidate();
this.session = undefined;
this.browsingSession = undefined;
this.publish(emptyRedisCollectionSearchState());
}
/** Keep a browsing page that may already have consumed a server overflow cursor. */
cancelSearch() {
this.invalidate();
const browsing = this.browsingSession ?? (this.session && !this.session.state.query ? this.session : undefined);
this.browsingSession = undefined;
this.session = browsing;
this.publish(browsing ? { ...browsing.state, status: "idle", error: undefined, pauseReason: undefined } : emptyRedisCollectionSearchState());
}
private sameScope(left: RedisCollectionScope, right: RedisCollectionScope) {
return left.connectionId === right.connectionId && left.db === right.db && left.keyRaw === right.keyRaw && left.kind === right.kind && left.sortDirection === right.sortDirection;
}
stop(reason: "stopped" | "inactive" = "stopped") {
this.invalidate();
if (this.session?.state.cursor != null) this.publish({ ...this.session.state, status: "paused", pauseReason: reason, error: undefined });
}
setActive(active: boolean) {
this.active = active;
if (!active) this.stop("inactive");
}
dispose() {
this.stop("inactive");
this.disposed = true;
this.active = false;
}
/** Ordinary load-more pages use the same request gate, without automatic empty-page scanning. */
browse(scope: RedisCollectionScope, items: RedisCollectionItem[], cursor: number) {
const current = this.session;
const sameScope = current && this.sameScope(current.scope, scope);
if (!sameScope || current.state.query || current.state.cursor !== cursor) this.prepare(scope, "");
this.session!.state = { status: "searching", query: "", items, cursor };
return this.start();
}
start(): Promise<void> {
if (!this.session || this.session.state.cursor == null || !this.active || this.disposed) return Promise.resolve();
this.invalidate();
const session = this.session;
this.publish({ ...session.state, status: "searching", pauseReason: undefined, error: undefined });
const completion = new Promise<void>((resolve) => {
this.pending = { session, revision: this.revision, resolve };
});
void this.pump();
return completion;
}
/** Also gates the descending ZSet initial page used during a value reload. */
async readPage(scope: RedisCollectionScope, cursor: number, query: string, valid: () => boolean = () => true): Promise<RedisCollectionPage | undefined> {
const previous = this.gate;
let release!: () => void;
this.gate = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
if (this.disposed || !this.active || !valid()) return undefined;
return await this.options.fetchPage({ scope, cursor, query });
} finally {
release();
}
}
private current(operation: Operation) {
return this.active && !this.disposed && this.session === operation.session && this.revision === operation.revision;
}
private async pump() {
if (this.pumping) return;
this.pumping = true;
try {
while (this.pending && this.active && !this.disposed) {
const operation = this.pending;
this.pending = undefined;
try {
await this.scan(operation);
} catch (error) {
if (this.current(operation)) this.publish({ ...operation.session.state, status: "failed", error });
} finally {
operation.resolve();
}
}
} finally {
this.pumping = false;
}
}
private async scan(operation: Operation) {
while (this.current(operation)) {
const { scope, state } = operation.session;
if (state.cursor == null) return;
const cursor = state.cursor;
const buffered = operation.session.buffered;
operation.session.buffered = undefined;
const page = buffered?.cursor === cursor ? buffered.page : await this.readPage(scope, cursor, state.query, () => this.current(operation));
if (!page) return;
if (!this.current(operation)) {
// Overflow cursors consume their page on the server. Keep the settled page
// private until this same search is resumed, without changing paused UI.
if ((this.session === operation.session || this.browsingSession === operation.session) && !this.disposed) operation.session.buffered = { cursor, page };
return;
}
if (page.kind !== scope.kind) throw new Error("Unexpected Redis collection page type");
const next = page.scan_cursor != null && page.scan_cursor > 0 ? page.scan_cursor : null;
const items = mergeRedisCollectionItems(scope.kind, state.items, page.items);
if (next == null || !state.query) {
this.publish({ ...state, items, cursor: next, status: next == null ? "complete" : "partial" });
return;
}
// Nonempty overflow pages may legitimately keep the same cursor.
const reason = page.items.length === 0 && next === cursor ? "stalled" : undefined;
this.publish({ ...state, items, cursor: next, status: reason ? "paused" : "searching", pauseReason: reason });
if (reason) return;
}
}
}
+390 -27
View File
@@ -4080,18 +4080,15 @@ async fn hscan_page_raw<C>(
key: &[u8],
cursor: u64,
count: usize,
match_pattern: Option<&str>,
lowered_query: Option<&str>,
) -> Result<(u64, Vec<RedisHashItem>), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let mut cmd = redis::cmd("HSCAN");
cmd.arg(key).arg(cursor).arg("COUNT").arg(count);
if let Some(pattern) = match_pattern {
cmd.arg("MATCH").arg(pattern);
}
let raw: RedisRawValue = cmd.query_async(con).await.map_err(|e| e.to_string())?;
parse_scan_hash_entries(raw)
parse_scan_hash_entries(raw, lowered_query)
}
async fn hscan_filtered_page_raw<C>(
@@ -4109,13 +4106,39 @@ where
let target = count.max(1);
let lowered = query.to_lowercase();
// A full field name should not have to wait for its hash-table scan bucket.
// Still scan one batch to obtain a real continuation for the fuzzy search.
if cursor == 0 {
let exact: Option<Vec<u8>> = match redis::cmd("HGET").arg(key).arg(query.as_bytes()).query_async(con).await {
Ok(value) => value,
Err(error) if is_redis_command_denied_error(&error.to_string()) => None,
Err(error) => return Err(error.to_string()),
};
if let Some(value) = exact {
items.push(RedisHashItem {
field: redis_blob_from_bytes(query.as_bytes()),
value: redis_blob_from_bytes(&value),
field_ttl: None,
});
}
}
let exact_match = !items.is_empty();
let mut positions: HashMap<String, usize> =
items.iter().enumerate().map(|(index, item)| (item.field.raw_base64.clone(), index)).collect();
for _ in 0..COLLECTION_FILTER_SCAN_MAX_ITERATIONS {
let (next, page) = hscan_page_raw(con, key, cur, target, None).await?;
items.extend(page.into_iter().filter(|item| hash_entry_matches_query(item, &lowered)));
let (next, page) = hscan_page_raw(con, key, cur, target, Some(&lowered)).await?;
for item in page {
if let Some(&index) = positions.get(&item.field.raw_base64) {
items[index] = item;
} else {
positions.insert(item.field.raw_base64.clone(), items.len());
items.push(item);
}
}
cur = next;
// HSCAN MATCH only checks field names, so value search has to filter returned pairs client-side.
// Keep a hard scan bound so sparse value matches cannot turn one UI search into a full hash walk.
if cur == 0 || items.len() >= target {
if exact_match || cur == 0 || items.len() >= target {
break;
}
}
@@ -4534,8 +4557,12 @@ fn blob_matches_query(blob: &RedisBlob, lowered_query: &str) -> bool {
redis_blob_display_text(blob).to_lowercase().contains(lowered_query)
}
fn hash_entry_matches_query(item: &RedisHashItem, lowered_query: &str) -> bool {
blob_matches_query(&item.field, lowered_query) || blob_matches_query(&item.value, lowered_query)
fn bytes_match_query(bytes: &[u8], lowered_query: &str) -> bool {
// Match the same text as redis_blob_display_text, including binary escapes.
match std::str::from_utf8(bytes) {
Ok(text) => text.to_lowercase().contains(lowered_query),
Err(_) => redis_bytes_to_display(bytes).to_lowercase().contains(lowered_query),
}
}
async fn sscan_page_raw<C>(
@@ -4596,33 +4623,38 @@ where
Ok(items)
}
fn parse_scan_hash_entries(raw: RedisRawValue) -> Result<(u64, Vec<RedisHashItem>), String> {
let RedisRawValue::Array(parts) = raw else {
fn parse_scan_hash_entries(
raw: RedisRawValue,
lowered_query: Option<&str>,
) -> Result<(u64, Vec<RedisHashItem>), String> {
let RedisRawValue::Array(mut parts) = raw else {
return Err("Invalid SCAN response".to_string());
};
if parts.len() != 2 {
return Err("Invalid SCAN response".to_string());
}
let cursor = redis_value_to_string(parts[0].clone())
let entries = parts.pop().unwrap();
let cursor = redis_value_to_string(parts.pop().unwrap())
.ok_or("Invalid cursor")?
.parse::<u64>()
.map_err(|_| "Invalid cursor".to_string())?;
let RedisRawValue::Array(entries) = &parts[1] else {
let RedisRawValue::Array(entries) = entries else {
return Err("Invalid SCAN entries".to_string());
};
let mut items = Vec::new();
let mut iter = entries.iter();
let mut iter = entries.into_iter();
while let Some(field) = iter.next() {
let Some(value) = iter.next() else { break };
let field = redis_value_to_bytes(field.clone())
.map(|bytes| redis_blob_from_bytes(&bytes))
.ok_or_else(|| "Invalid hash field payload".to_string())?;
let value = redis_value_to_bytes(value.clone())
.map(|bytes| redis_blob_from_bytes(&bytes))
.ok_or_else(|| "Invalid hash value payload".to_string())?;
let field = redis_value_to_bytes(field).ok_or_else(|| "Invalid hash field payload".to_string())?;
let value = redis_value_to_bytes(value).ok_or_else(|| "Invalid hash value payload".to_string())?;
if lowered_query.is_some_and(|query| !bytes_match_query(&field, query) && !bytes_match_query(&value, query)) {
continue;
}
let field = redis_blob_from_bytes(&field);
let value = redis_blob_from_bytes(&value);
items.push(RedisHashItem { field, value, field_ttl: None });
}
@@ -6579,9 +6611,335 @@ mod tests {
assert!(sessions.entries.keys().all(|cursor| *cursor > 0 && *cursor <= super::MAX_SAFE_INTEGER_CURSOR));
}
// Keep the pre-optimization path as an independent semantic/benchmark oracle.
fn hash_scan_before_filter(raw: RedisRawValue, query: Option<&str>) -> Result<(u64, Vec<RedisHashItem>), String> {
let RedisRawValue::Array(parts) = raw else { return Err("Invalid SCAN response".to_string()) };
if parts.len() != 2 {
return Err("Invalid SCAN response".to_string());
}
let cursor = super::redis_value_to_string(parts[0].clone())
.ok_or("Invalid cursor")?
.parse::<u64>()
.map_err(|_| "Invalid cursor".to_string())?;
let RedisRawValue::Array(entries) = &parts[1] else { return Err("Invalid SCAN entries".to_string()) };
let mut items = Vec::new();
let mut iter = entries.iter();
while let Some(field) = iter.next() {
let Some(value) = iter.next() else { break };
let field = redis_value_to_bytes(field.clone())
.map(|bytes| redis_blob_from_bytes(&bytes))
.ok_or_else(|| "Invalid hash field payload".to_string())?;
let value = redis_value_to_bytes(value.clone())
.map(|bytes| redis_blob_from_bytes(&bytes))
.ok_or_else(|| "Invalid hash value payload".to_string())?;
items.push(RedisHashItem { field, value, field_ttl: None });
}
if let Some(query) = query {
items.retain(|item| {
super::blob_matches_query(&item.field, query) || super::blob_matches_query(&item.value, query)
});
}
Ok((cursor, items))
}
#[test]
fn hash_raw_filter_preserves_unicode_binary_and_value_matching() {
let mut pairs = vec![
(b"Exact".to_vec(), b"".to_vec()),
(b"other".to_vec(), b"contains eXaCt".to_vec()),
("用户:İΣ".as_bytes().to_vec(), "Straße Σσς".as_bytes().to_vec()),
(b"slash\\field".to_vec(), b"utf8\\value".to_vec()),
(b"binary\\\xff".to_vec(), b"\0\x80".to_vec()),
];
pairs.extend((0..=255).map(|byte| (vec![b'f', byte], vec![byte, b'V'])));
let entries = pairs
.into_iter()
.flat_map(|(field, value)| [RedisRawValue::BulkString(field), RedisRawValue::BulkString(value)])
.collect();
let raw = RedisRawValue::Array(vec![bulk("512"), RedisRawValue::Array(entries)]);
for query in [
None,
Some(""),
Some("exact"),
Some("用户"),
Some("i\u{307}"),
Some("σ"),
Some("ς"),
Some("straße"),
Some("\\"),
Some("\\\\"),
Some("\\xff"),
Some("\\x00"),
Some("v"),
Some("absent"),
] {
assert_eq!(
super::parse_scan_hash_entries(raw.clone(), query),
hash_scan_before_filter(raw.clone(), query),
"query {query:?}"
);
}
}
#[test]
fn hash_raw_filter_preserves_payload_validation() {
for raw in [
RedisRawValue::Nil,
RedisRawValue::Array(vec![]),
RedisRawValue::Array(vec![bulk("bad-cursor"), RedisRawValue::Array(vec![])]),
RedisRawValue::Array(vec![bulk("0"), RedisRawValue::Nil]),
RedisRawValue::Array(vec![bulk("0"), RedisRawValue::Array(vec![RedisRawValue::Nil, bulk("value")])]),
RedisRawValue::Array(vec![bulk("0"), RedisRawValue::Array(vec![bulk("unmatched"), RedisRawValue::Nil])]),
RedisRawValue::Array(vec![
RedisRawValue::Int(17),
RedisRawValue::Array(vec![RedisRawValue::Int(42), RedisRawValue::Boolean(true), bulk("unpaired")]),
]),
] {
for query in [None, Some("absent"), Some("42")] {
assert_eq!(
super::parse_scan_hash_entries(raw.clone(), query),
hash_scan_before_filter(raw.clone(), query)
);
}
}
}
#[test]
#[ignore = "manual CPU benchmark; run with --ignored --nocapture using an optimized test profile"]
fn hash_raw_filter_benchmark() {
use std::{hint::black_box, time::Instant};
let payload = "payload:".to_string() + &"x".repeat(1016);
let raw = hscan_response_owned(
"0",
(0..25_000)
.map(|index| {
(if index < 4 { format!("needle:{index}") } else { format!("field:{index}") }, payload.clone())
})
.collect(),
);
for query in ["absent", "needle", "payload"] {
let mut before = Vec::new();
let mut after = Vec::new();
for _ in 0..7 {
let input = raw.clone();
let start = Instant::now();
let expected = black_box(hash_scan_before_filter(black_box(input), Some(query)).unwrap());
before.push(start.elapsed().as_secs_f64() * 1000.0);
let input = raw.clone();
let start = Instant::now();
let actual = black_box(super::parse_scan_hash_entries(black_box(input), Some(query)).unwrap());
after.push(start.elapsed().as_secs_f64() * 1000.0);
assert_eq!(actual, expected);
}
before.sort_by(f64::total_cmp);
after.sort_by(f64::total_cmp);
eprintln!("Hash CPU query={query} fields=25000 value_bytes=1024 median_before_ms={:.3} median_after_ms={:.3} speedup={:.2}", before[3], after[3], before[3] / after[3]);
}
}
#[tokio::test]
async fn hash_search_prioritizes_exact_fields_and_keeps_fuzzy_continuation() {
let mut con = FakeRedisConnection::new(vec![
bulk("old"),
hscan_response("512", vec![("Exact", "new"), ("exact", "case variant"), ("other", "contains Exact")]),
httl_response(3, -1),
hscan_response("0", vec![("prefix:Exact", "tail")]),
httl_response(1, -1),
]);
let page = super::load_more_collection(&mut con, b"hash", "hash", 0, 200, Some("Exact"), None).await.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = page else { panic!("expected hash") };
assert_eq!(scan_cursor, Some(512));
assert_eq!(items.len(), 3);
assert_eq!(items[0].field, text_blob("Exact"));
assert_eq!(items[0].value, text_blob("new"));
assert_eq!(con.command_count("HGET"), 1);
assert_eq!(con.command_count("HSCAN"), 1);
assert!(con.commands[0].contains("\r\nExact\r\n"));
let page = super::load_more_collection(&mut con, b"hash", "hash", 512, 200, Some("Exact"), None).await.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = page else { panic!("expected hash") };
assert_eq!(scan_cursor, None);
assert_eq!(items[0].field, text_blob("prefix:Exact"));
assert_eq!(con.command_count("HGET"), 1);
}
#[tokio::test]
async fn hash_search_distinguishes_empty_exact_values_from_missing_fields() {
let mut con = FakeRedisConnection::new(vec![bulk(""), hscan_response("100", vec![])]);
let (cursor, items) = super::hscan_filtered_page_raw(&mut con, b"hash", 0, 200, "field").await.unwrap();
assert_eq!(cursor, 100);
assert_eq!(items.len(), 1);
assert_eq!(items[0].value, text_blob(""));
assert_eq!(con.command_count("HSCAN"), 1);
}
#[tokio::test]
async fn hash_search_exact_seed_keeps_overflow_pages_and_refreshes_ttls() {
let mut con = FakeRedisConnection::new(vec![
bulk("old"),
hscan_response("0", vec![("wanted", "new"), ("wanted:2", "two"), ("wanted:3", "three")]),
httl_response(1, 100),
httl_response(1, 90),
httl_response(1, 80),
]);
let first = super::load_more_collection(&mut con, b"exact-overflow-hash", "hash", 0, 1, Some("wanted"), None)
.await
.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = first else { panic!("expected Hash") };
let cursor = scan_cursor.unwrap();
assert!(cursor >= super::COLLECTION_OVERFLOW_CURSOR_START);
assert_eq!(items[0].value, text_blob("new"));
assert_eq!(items[0].field_ttl, Some(100));
let second =
super::load_more_collection(&mut con, b"exact-overflow-hash", "hash", cursor, 1, Some("wanted"), None)
.await
.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = second else { panic!("expected Hash") };
assert_eq!(scan_cursor, Some(cursor));
assert_eq!(items[0].field, text_blob("wanted:2"));
assert_eq!(items[0].field_ttl, Some(90));
let third =
super::load_more_collection(&mut con, b"exact-overflow-hash", "hash", cursor, 1, Some("wanted"), None)
.await
.unwrap();
let RedisCollectionPage::Hash { items, scan_cursor } = third else { panic!("expected Hash") };
assert_eq!(scan_cursor, None);
assert_eq!(items[0].field, text_blob("wanted:3"));
assert_eq!(items[0].field_ttl, Some(80));
assert_eq!(con.command_count("HGET"), 1);
assert_eq!(con.command_count("HSCAN"), 1);
}
#[tokio::test]
async fn hash_search_optional_lookup_errors_fall_back_without_hiding_real_failures() {
for error in [
noperm("hget"),
redis::RedisError::from((redis::ErrorKind::ResponseError, "ERR", "unknown command 'HGET'".to_string())),
] {
let mut con =
FakeRedisConnection::with_results(vec![Err(error), Ok(hscan_response("0", vec![("field", "wanted")]))]);
let (_, items) = super::hscan_filtered_page_raw(&mut con, b"hash", 0, 200, "wanted").await.unwrap();
assert_eq!(items.len(), 1);
}
for error in [
redis::RedisError::from((redis::ErrorKind::IoError, "disconnected")),
redis::RedisError::from((redis::ErrorKind::AuthenticationFailed, "WRONGPASS")),
redis::RedisError::from((redis::ErrorKind::TypeError, "WRONGTYPE")),
] {
let mut con = FakeRedisConnection::with_results(vec![Err(error)]);
assert!(super::hscan_filtered_page_raw(&mut con, b"hash", 0, 200, "wanted").await.is_err());
assert_eq!(con.command_count("HSCAN"), 0);
}
}
#[tokio::test]
async fn hash_search_exact_lookup_keeps_binary_keys_and_unicode_queries() {
let mut con = FakeRedisConnection::new(vec![bulk("value"), hscan_response("0", vec![])]);
let (cursor, items) = super::hscan_filtered_page_raw(&mut con, b"hash\xff", 0, 200, "用户").await.unwrap();
assert_eq!(cursor, 0);
assert_eq!(items[0].field, text_blob("用户"));
}
// Exercise the production page/TTL/overflow path against a disposable server.
async fn verify_large_hash_search_live<C>(con: &mut C) -> Result<(), String>
where
C: ConnectionLike + Send + Sync + Unpin,
{
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let key = format!("dbx-11491-{}-{nonce}", std::process::id());
let query = "EC00100051EB02DD";
let expected = [query.to_string(), query.to_lowercase(), format!("prefix:{query}"), "value-only".to_string()];
let result = async {
for start in (0..25_000).step_by(500) {
let mut command = redis::cmd("HSET");
command.arg(&key);
for index in start..start + 500 {
command.arg(format!("filler:{index:05}")).arg("unrelated payload");
}
command.query_async::<u64>(con).await.map_err(|error| error.to_string())?;
// Ensure even interrupted manual runs leave no persistent test data.
redis::cmd("EXPIRE")
.arg(&key)
.arg(600)
.query_async::<bool>(con)
.await
.map_err(|error| error.to_string())?;
}
let mut command = redis::cmd("HSET");
command.arg(&key);
for field in &expected {
command.arg(field).arg(if field == "value-only" { "contains Ec00100051eb02dd" } else { "" });
}
command.query_async::<u64>(con).await.map_err(|error| error.to_string())?;
for search in [query, "ec00100051eb02dd", "00100051", "absent-11491"] {
let mut cursor = 0;
let mut found = HashMap::new();
let mut requests = 0;
loop {
let page =
super::load_more_collection(con, key.as_bytes(), "hash", cursor, 200, Some(search), None)
.await?;
let RedisCollectionPage::Hash { items, scan_cursor } = page else {
return Err("expected Hash page".to_string());
};
requests += 1;
if search == query && requests == 1 {
if items.first().map(|item| &item.field) != Some(&text_blob(query)) || scan_cursor.is_none() {
return Err("exact field was not prioritized with a continuation".to_string());
}
if items[0].value != text_blob("") {
return Err("empty value was lost".to_string());
}
}
for item in items {
found.insert(item.field.raw_base64.clone(), item);
}
let Some(next) = scan_cursor else { break };
cursor = next;
if requests > 200 {
return Err("scan did not finish".to_string());
}
}
let wanted: std::collections::HashSet<_> = if search == "absent-11491" {
Default::default()
} else {
expected.iter().map(|field| text_blob(field).raw_base64).collect()
};
if found.keys().cloned().collect::<std::collections::HashSet<_>>() != wanted {
return Err(format!("incomplete search for {search}: {} matches", found.len()));
}
eprintln!("large Hash search {search}: {requests} pages, {} unique matches", found.len());
}
Ok(())
}
.await;
let cleanup = redis::cmd("DEL").arg(&key).query_async::<u64>(con).await.map_err(|error| error.to_string());
result.and(cleanup.map(|_| ()))
}
#[tokio::test]
#[ignore = "requires disposable Redis via DBX_TEST_REDIS_URL"]
async fn hash_search_large_hash_live() {
let url = std::env::var("DBX_TEST_REDIS_URL").expect("DBX_TEST_REDIS_URL");
let mut con = super::connect(&url, std::time::Duration::from_secs(5)).await.unwrap();
verify_large_hash_search_live(&mut con).await.unwrap();
}
#[tokio::test]
#[ignore = "requires disposable Cluster via DBX_TEST_REDIS_CLUSTER_PORT"]
async fn hash_search_large_hash_cluster_live() {
let mut config = redis_test_connection_config();
config.host = "127.0.0.1".to_string();
config.port =
std::env::var("DBX_TEST_REDIS_CLUSTER_PORT").expect("DBX_TEST_REDIS_CLUSTER_PORT").parse().unwrap();
let pool = super::connect_cluster(&config).await.unwrap();
let mut con = super::cluster_key_connection(&pool, b"dbx-11491").await.unwrap();
verify_large_hash_search_live(&mut con).await.unwrap();
}
#[tokio::test]
async fn filtered_hash_load_more_matches_fields_and_keeps_scan_cursor() {
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Nil,
hscan_response("512", vec![("user:1", "Ada")]),
hscan_response("0", vec![("user:2", "Bob")]),
]);
@@ -6595,13 +6953,15 @@ mod tests {
assert_eq!(scan_cursor, Some(512));
assert_eq!(items, vec![RedisHashItem { field: text_blob("user:1"), value: text_blob("Ada"), field_ttl: None }]);
assert_eq!(con.command_count("HSCAN"), 1);
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
assert!(!con.commands.iter().any(|command| command.contains("\r\nMATCH\r\n")));
}
#[tokio::test]
async fn filtered_hash_load_more_matches_values() {
let mut con =
FakeRedisConnection::new(vec![hscan_response("0", vec![("status", "Ada Lovelace"), ("name", "Bob")])]);
let mut con = FakeRedisConnection::new(vec![
RedisRawValue::Nil,
hscan_response("0", vec![("status", "Ada Lovelace"), ("name", "Bob")]),
]);
let result =
super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, Some("lovelace"), None).await.unwrap();
@@ -6615,7 +6975,7 @@ mod tests {
vec![RedisHashItem { field: text_blob("status"), value: text_blob("Ada Lovelace"), field_ttl: None }]
);
assert_eq!(con.command_count("HSCAN"), 1);
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
assert!(!con.commands.iter().any(|command| command.contains("\r\nMATCH\r\n")));
}
#[tokio::test]
@@ -6632,7 +6992,7 @@ mod tests {
assert_eq!(items, vec![RedisSetItem { member: text_blob("Ada Lovelace") }]);
assert_eq!(con.command_count("SSCAN"), 1);
// Substring search cannot be expressed as an SSCAN glob, so no MATCH is sent.
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
assert!(!con.commands.iter().any(|command| command.contains("\r\nMATCH\r\n")));
}
#[tokio::test]
@@ -7141,6 +7501,7 @@ mod tests {
.collect::<Vec<_>>();
*responses.last_mut().unwrap() = Ok(hscan_response("10", vec![("field", "wanted")]));
responses.push(Err(legacy_hash_expiry_error()));
responses.insert(0, Ok(RedisRawValue::Nil));
let mut con = FakeRedisConnection::with_results(responses);
let page =
super::load_more_collection(&mut con, b"legacy-sparse", "hash", 0, 20, Some("wanted"), None).await.unwrap();
@@ -7502,7 +7863,9 @@ mod tests {
async fn filtered_hash_load_more_caps_sparse_scan_iterations() {
let responses = (1..=super::COLLECTION_FILTER_SCAN_MAX_ITERATIONS + 1)
.map(|cursor| hscan_response(&cursor.to_string(), vec![]))
.collect();
.collect::<Vec<_>>();
let mut responses = responses;
responses.insert(0, RedisRawValue::Nil);
let mut con = FakeRedisConnection::new(responses);
let result =
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB