mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(redis): complete collection searches and rank exact hash fields
This commit is contained in:
+376
-18
@@ -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") }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "要素を検索",
|
||||
|
||||
@@ -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: "항목 검색",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Поиск элементов",
|
||||
|
||||
@@ -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: "搜索元素",
|
||||
|
||||
@@ -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: "搜尋元素",
|
||||
|
||||
+316
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user