perf(redis): keep fetch-all finalization responsive
This commit is contained in:
1 parent
b8becde86a
commit
6ac2b68553
4 files changed
+843
-81
No files matched your search
@@ -25,8 +25,11 @@ const mocks = vi.hoisted(() => ({
|
||||
redisExecuteCommand: vi.fn(),
|
||||
saveHistory: vi.fn(),
|
||||
canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000),
|
||||
buildRedisKeySnapshotCooperatively: vi.fn(),
|
||||
createRedisKeyTreeIndex: vi.fn(),
|
||||
flattenVisibleRedisKeyTree: vi.fn(),
|
||||
scrollerUpdateVisibleItems: vi.fn(),
|
||||
scrollerItems: [] as unknown[][],
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
listRedisCompletionCommandDocs: vi.fn(),
|
||||
@@ -61,9 +64,11 @@ vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/redis/redisKeyTree")>();
|
||||
mocks.createRedisKeyTreeIndex.mockImplementation(actual.createRedisKeyTreeIndex);
|
||||
mocks.flattenVisibleRedisKeyTree.mockImplementation(actual.flattenVisibleRedisKeyTree);
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockImplementation(actual.buildRedisKeySnapshotCooperatively);
|
||||
return {
|
||||
...actual,
|
||||
canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree,
|
||||
buildRedisKeySnapshotCooperatively: mocks.buildRedisKeySnapshotCooperatively,
|
||||
createRedisKeyTreeIndex: mocks.createRedisKeyTreeIndex,
|
||||
flattenVisibleRedisKeyTree: mocks.flattenVisibleRedisKeyTree,
|
||||
};
|
||||
@@ -330,21 +335,36 @@ vi.mock("./RedisSlowlogPanel.vue", async () => {
|
||||
});
|
||||
|
||||
vi.mock("vue-virtual-scroller", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const { defineComponent, h, shallowRef, watch } = await import("vue");
|
||||
return {
|
||||
RecycleScroller: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
setup(props, { attrs, slots }) {
|
||||
setup(props, { attrs, slots, expose }) {
|
||||
// Mirror the real scroller: interaction tests should render a viewport,
|
||||
// not every row in a deliberately large result set.
|
||||
const visibleItemCount = 50;
|
||||
return () =>
|
||||
h(
|
||||
const renderedItems = shallowRef<unknown[]>([]);
|
||||
const bindVisibleItems = () => {
|
||||
renderedItems.value = props.items.slice(0, visibleItemCount);
|
||||
};
|
||||
watch(() => props.items, bindVisibleItems, { immediate: true });
|
||||
expose({
|
||||
updateVisibleItems: (itemsChanged?: boolean) => {
|
||||
mocks.scrollerUpdateVisibleItems(itemsChanged);
|
||||
// The real scroller keeps a keyed view pool when false. Rebind only
|
||||
// when callers identify in-place item changes explicitly.
|
||||
if (itemsChanged) bindVisibleItems();
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
mocks.scrollerItems.push(props.items as unknown[]);
|
||||
return h(
|
||||
"div",
|
||||
attrs,
|
||||
props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })),
|
||||
renderedItems.value.map((item) => slots.default?.({ item })),
|
||||
);
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -409,6 +429,7 @@ function resetApiMocks() {
|
||||
mocks.queryResultMaxRowsEnabled = true;
|
||||
mocks.queryResultMaxRows = 5000;
|
||||
mocks.loadedTtl = -1;
|
||||
mocks.scrollerItems.length = 0;
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
|
||||
mocks.redisSetString.mockResolvedValue(undefined);
|
||||
@@ -1773,6 +1794,188 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
|
||||
});
|
||||
|
||||
describe("RedisKeyBrowser interrupted Fetch All", () => {
|
||||
it("publishes Fetch All rows through the existing scroller array and explicitly refreshes its viewport", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
const itemsBeforeFetchAll = mocks.scrollerItems[mocks.scrollerItems.length - 1];
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("buffered"));
|
||||
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(itemsBeforeFetchAll);
|
||||
expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledWith(true);
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("redis.stopFetchAll");
|
||||
});
|
||||
|
||||
it("rebinds rendered row labels after multi-chunk in-place publication", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
const visibleRows = Array.from({ length: 25_001 }, (_, index) => {
|
||||
const label = `next-${String(index).padStart(5, "0")}`;
|
||||
return {
|
||||
id: `leaf:0:${label}`,
|
||||
depth: 0,
|
||||
node: {
|
||||
kind: "leaf" as const,
|
||||
id: `leaf:0:${label}`,
|
||||
label,
|
||||
fullKeyDisplay: label,
|
||||
keyRaw: label,
|
||||
db: 0,
|
||||
keyType: "string",
|
||||
ttl: -1,
|
||||
size: 0,
|
||||
valuePreview: "",
|
||||
pathSegments: [label],
|
||||
},
|
||||
};
|
||||
});
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
|
||||
flatKeys: [initial, buffered],
|
||||
flatKeyByRaw: new Map([
|
||||
[initial.key_raw, initial],
|
||||
[buffered.key_raw, buffered],
|
||||
]),
|
||||
loadedKeyRaws: new Set([initial.key_raw, buffered.key_raw]),
|
||||
filteredKeyCount: 2,
|
||||
treeIndex: null,
|
||||
expandedGroupIds: new Set(),
|
||||
visibleRows,
|
||||
});
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("next-00000"));
|
||||
await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll"));
|
||||
|
||||
expect(mocks.scrollerUpdateVisibleItems.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(mocks.scrollerUpdateVisibleItems.mock.calls.every(([itemsChanged]) => itemsChanged === true)).toBe(true);
|
||||
expect(document.body.textContent).toContain("next-00049");
|
||||
expect(document.body.textContent).not.toContain("initial");
|
||||
});
|
||||
|
||||
it("keeps stable Fetch All rows for metadata-only detail updates and exits them for no-expiry membership changes", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
requiredElement<HTMLButtonElement>("[data-redis-no-expiry-filter]").click();
|
||||
await settle();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull());
|
||||
const stableItems = mocks.scrollerItems[mocks.scrollerItems.length - 1];
|
||||
requiredElement<HTMLElement>(`[data-redis-leaf="${initial.key_raw}"]`).closest<HTMLElement>(".group")?.click();
|
||||
await settle();
|
||||
|
||||
mocks.loadedTtl = -1;
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toBe(stableItems);
|
||||
|
||||
mocks.loadedTtl = 60;
|
||||
requiredElement<HTMLButtonElement>("[data-test-emit-key-loaded]").click();
|
||||
await settle();
|
||||
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).not.toBe(stableItems);
|
||||
expect(document.querySelector(`[data-redis-leaf="${initial.key_raw}"]`)).toBeNull();
|
||||
expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the previously published rows when Fetch All is stopped during cooperative preparation", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
const buildStarted = deferred<void>();
|
||||
const releaseBuild = deferred<void>();
|
||||
const buildSnapshot = mocks.buildRedisKeySnapshotCooperatively.getMockImplementation();
|
||||
expect(buildSnapshot).toBeDefined();
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockImplementationOnce(async (...args) => {
|
||||
buildStarted.resolve();
|
||||
await releaseBuild.promise;
|
||||
return buildSnapshot!(...args);
|
||||
});
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await buildStarted.promise;
|
||||
clickButtonWithText("redis.stopFetchAll");
|
||||
releaseBuild.resolve();
|
||||
await settle();
|
||||
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("buffered");
|
||||
expect(document.body.textContent).toContain("redis.fetchAllKeys");
|
||||
expect(mocks.scrollerUpdateVisibleItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back a partially published stable row buffer when Fetch All is stopped", async () => {
|
||||
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
|
||||
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
|
||||
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
|
||||
mountBrowser();
|
||||
await settle();
|
||||
|
||||
const row = {
|
||||
id: "leaf:0:buffered",
|
||||
depth: 0,
|
||||
node: {
|
||||
kind: "leaf" as const,
|
||||
id: "leaf:0:buffered",
|
||||
label: "buffered",
|
||||
fullKeyDisplay: "buffered",
|
||||
keyRaw: buffered.key_raw,
|
||||
db: 0,
|
||||
keyType: "string",
|
||||
ttl: -1,
|
||||
size: 0,
|
||||
valuePreview: "",
|
||||
pathSegments: ["buffered"],
|
||||
},
|
||||
};
|
||||
mocks.buildRedisKeySnapshotCooperatively.mockResolvedValueOnce({
|
||||
flatKeys: [initial, buffered],
|
||||
flatKeyByRaw: new Map([
|
||||
[initial.key_raw, initial],
|
||||
[buffered.key_raw, buffered],
|
||||
]),
|
||||
loadedKeyRaws: new Set([initial.key_raw, buffered.key_raw]),
|
||||
filteredKeyCount: 2,
|
||||
treeIndex: null,
|
||||
expandedGroupIds: new Set(),
|
||||
visibleRows: Array.from({ length: 25_001 }, () => row),
|
||||
});
|
||||
const pendingFrames: FrameRequestCallback[] = [];
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
|
||||
pendingFrames.push(callback);
|
||||
return pendingFrames.length;
|
||||
};
|
||||
|
||||
try {
|
||||
clickButtonWithText("redis.fetchAllKeys");
|
||||
await vi.waitFor(() => expect(mocks.scrollerUpdateVisibleItems).toHaveBeenCalledTimes(1));
|
||||
clickButtonWithText("redis.stopFetchAll");
|
||||
expect(pendingFrames).toHaveLength(1);
|
||||
pendingFrames[0](0);
|
||||
await settle();
|
||||
|
||||
expect(document.body.textContent).toContain("initial");
|
||||
expect(document.body.textContent).not.toContain("buffered");
|
||||
expect(document.body.textContent).toContain("redis.fetchAllKeys");
|
||||
} finally {
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads instead of advancing past an uncommitted buffered page after reactivation", async () => {
|
||||
const bufferedPage = deferred<{ cursor: number; keys: Array<{ key_display: string; key_raw: string; key_type: string; ttl: number }>; total_keys: number }>();
|
||||
let returnFreshPage = false;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { continuousQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit
|
||||
import {
|
||||
appendRedisKeysToTreeIndex,
|
||||
buildRedisKeyTree,
|
||||
buildRedisKeySnapshotCooperatively,
|
||||
canBuildRedisFuzzyTree,
|
||||
collectExpandedGroupIds,
|
||||
collectRedisGroupKeyRaws,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
type RedisKeyTreeGroupNode,
|
||||
type RedisKeyTreeIndex,
|
||||
type RedisKeyTreeNode,
|
||||
type RedisKeyTreeRow,
|
||||
} from "@/lib/redis/redisKeyTree";
|
||||
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
|
||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||
@@ -101,7 +103,7 @@ const redisExpiryTransport = {
|
||||
|
||||
const flatKeys = shallowRef<RedisKeyInfo[]>([]);
|
||||
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
|
||||
const flatKeyByRaw = new Map<string, RedisKeyInfo>();
|
||||
let flatKeyByRaw = new Map<string, RedisKeyInfo>();
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const searchPending = ref(false);
|
||||
@@ -201,10 +203,23 @@ const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
|
||||
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
|
||||
let redisBrowserIsActive = true;
|
||||
let reloadKeysOnActivation = false;
|
||||
let fetchAllPreparingSnapshot = false;
|
||||
let redisDbFlushedListenerRegistered = false;
|
||||
let redisInfiniteScrollFrame = 0;
|
||||
const loadedKeyRaws = new Set<string>();
|
||||
let loadedKeyRaws = new Set<string>();
|
||||
let treeIndex: RedisKeyTreeIndex | null = null;
|
||||
let fetchAllPublicationRollback: null | {
|
||||
flatKeys: RedisKeyInfo[];
|
||||
flatKeyByRaw: Map<string, RedisKeyInfo>;
|
||||
loadedKeyRaws: Set<string>;
|
||||
treeKeys: RedisKeyTreeNode[];
|
||||
treeIndex: RedisKeyTreeIndex | null;
|
||||
expandedGroupIds: Set<string>;
|
||||
scanCursor: number;
|
||||
hasMore: boolean;
|
||||
lastTotalKeys: number;
|
||||
bufferedKeyRaws: Set<string>;
|
||||
} = null;
|
||||
// 展开分组时已定向补扫过的子树(见 fillGroupSubtree);在 loadKeys 重置。
|
||||
const subtreeFilledGroupIds = new Set<string>();
|
||||
// 刷新前已展开分组的快照(#7173):刷新首屏重建树时,本轮尚未扫到的分组会被
|
||||
@@ -246,6 +261,7 @@ const redisInfiniteScrollEnabled = computed(() => settingsStore.editorSettings.i
|
||||
const redisInfiniteScrollMaxKeys = computed(() => continuousQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows));
|
||||
watch(redisKeySeparator, () => {
|
||||
if (flatKeys.value.length === 0) return;
|
||||
invalidateScanRequests();
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
@@ -258,6 +274,7 @@ const lastTotalKeys = ref(0);
|
||||
// “仅看无过期”过滤开关:开启后只保留 TTL 为 -1(永不过期)的已加载 key。
|
||||
// TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。
|
||||
const noExpiryOnly = ref(false);
|
||||
const fetchAllFilteredKeyCount = ref<number | null>(null);
|
||||
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
|
||||
const filteredFlatKeys = computed(() => {
|
||||
if (!noExpiryOnly.value) return flatKeys.value;
|
||||
@@ -273,7 +290,7 @@ const filteredTreeKeys = computed(() => {
|
||||
const displayedKeyCount = computed(() => {
|
||||
if (isFetchingAll.value) return fetchAllLoadedCount.value;
|
||||
// 过滤时展示匹配数量,便于确认“无过期”key 的规模
|
||||
if (noExpiryOnly.value) return filteredFlatKeys.value.length;
|
||||
if (noExpiryOnly.value) return fetchAllFilteredKeyCount.value ?? filteredFlatKeys.value.length;
|
||||
return flatKeys.value.length;
|
||||
});
|
||||
const fetchAllProgressText = computed(() => {
|
||||
@@ -385,9 +402,51 @@ async function updateCreateKeyTypeHelpOffset() {
|
||||
watch(activeCreateKeyTypeHelp, () => {
|
||||
void updateCreateKeyTypeHelpOffset();
|
||||
});
|
||||
const visibleRows = computed(() => {
|
||||
const regularVisibleRows = computed(() => {
|
||||
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
|
||||
});
|
||||
// Fetch All keeps this raw array identity bound to RecycleScroller. Replacing
|
||||
// the prop array makes vue-virtual-scroller slice and inspect every row key;
|
||||
// bounded in-place publication plus updateVisibleItems avoids that O(N) turn.
|
||||
const fetchAllVisibleRows = shallowRef<RedisKeyTreeRow[]>([]);
|
||||
const fetchAllVisibleRowsActive = ref(false);
|
||||
const visibleRows = computed(() => (fetchAllVisibleRowsActive.value ? fetchAllVisibleRows.value : regularVisibleRows.value));
|
||||
|
||||
function activateFetchAllVisibleRows() {
|
||||
if (fetchAllVisibleRowsActive.value) return;
|
||||
// Return the exact currently-bound identity when the override is enabled, so
|
||||
// entering Fetch All does not itself trigger the scroller's items watcher.
|
||||
fetchAllVisibleRows.value = regularVisibleRows.value;
|
||||
fetchAllVisibleRowsActive.value = true;
|
||||
}
|
||||
|
||||
function deactivateFetchAllVisibleRows() {
|
||||
if (!fetchAllVisibleRowsActive.value) return;
|
||||
fetchAllVisibleRowsActive.value = false;
|
||||
fetchAllVisibleRows.value = [];
|
||||
fetchAllFilteredKeyCount.value = null;
|
||||
}
|
||||
|
||||
function rollbackFetchAllPublication() {
|
||||
const rollback = fetchAllPublicationRollback;
|
||||
if (!rollback) return;
|
||||
flatKeys.value = rollback.flatKeys;
|
||||
flatKeyByRaw = rollback.flatKeyByRaw;
|
||||
loadedKeyRaws = rollback.loadedKeyRaws;
|
||||
treeKeys.value = rollback.treeKeys;
|
||||
treeIndex = rollback.treeIndex;
|
||||
expandedGroupIds.value = rollback.expandedGroupIds;
|
||||
scanCursor.value = rollback.scanCursor;
|
||||
hasMore.value = rollback.hasMore;
|
||||
lastTotalKeys.value = rollback.lastTotalKeys;
|
||||
for (const keyRaw of rollback.bufferedKeyRaws) {
|
||||
ttlObservedAtByRaw.delete(keyRaw);
|
||||
positiveTtlKeyRaws.delete(keyRaw);
|
||||
}
|
||||
fetchAllPublicationRollback = null;
|
||||
if (selectedKeyRaw.value && !flatKeyByRaw.has(selectedKeyRaw.value)) selectedKeyRaw.value = null;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
}
|
||||
|
||||
function redisRowKeyInfo(node: RedisKeyTreeNode): RedisKeyInfo | null {
|
||||
void keyMetadataEpoch.value;
|
||||
@@ -616,6 +675,7 @@ function onKeyPaneKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function rebuildTree(expandAll = false) {
|
||||
deactivateFetchAllVisibleRows();
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
@@ -646,6 +706,7 @@ function rebuildTree(expandAll = false) {
|
||||
|
||||
function mergeTree(newKeys: RedisKeyInfo[]) {
|
||||
if (newKeys.length === 0) return;
|
||||
deactivateFetchAllVisibleRows();
|
||||
if (!treeIndex) {
|
||||
rebuildTree(isSearchMode.value);
|
||||
return;
|
||||
@@ -671,6 +732,14 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
|
||||
}
|
||||
|
||||
function invalidateScanRequests(): number {
|
||||
rollbackFetchAllPublication();
|
||||
deactivateFetchAllVisibleRows();
|
||||
// Keep invalidation authoritative even when the old Fetch All continuation
|
||||
// observes the generation mismatch and therefore skips its own finalizer.
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = true;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
fetchAllPreparingSnapshot = false;
|
||||
searchRequestId++;
|
||||
loadMoreOperationId++;
|
||||
loadingMore.value = false;
|
||||
@@ -678,6 +747,10 @@ function invalidateScanRequests(): number {
|
||||
return searchRequestId;
|
||||
}
|
||||
|
||||
function invalidateFetchAllForStructuralMutation() {
|
||||
if (isFetchingAll.value || fetchAllPublicationRollback) invalidateScanRequests();
|
||||
}
|
||||
|
||||
function isCurrentScanOperation(requestId: number, operationId?: number): boolean {
|
||||
return requestId === searchRequestId && (operationId === undefined || operationId === loadMoreOperationId);
|
||||
}
|
||||
@@ -782,6 +855,25 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole
|
||||
return newKeys.length;
|
||||
}
|
||||
|
||||
function bufferFetchAllScanResult(result: RedisScanResult, buffer: RedisKeyInfo[], bufferedKeyRaws: Set<string>): number {
|
||||
let added = 0;
|
||||
for (const key of result.keys) {
|
||||
if (loadedKeyRaws.has(key.key_raw) || bufferedKeyRaws.has(key.key_raw)) continue;
|
||||
bufferedKeyRaws.add(key.key_raw);
|
||||
buffer.push(key);
|
||||
recordKeyTtlObservedAt(key);
|
||||
added++;
|
||||
}
|
||||
scanCursor.value = result.cursor;
|
||||
hasMore.value = result.cursor !== 0;
|
||||
if (result.total_keys > 0 || (result.cursor === 0 && result.keys.length === 0)) lastTotalKeys.value = result.total_keys;
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : flatKeys.value.length + buffer.length,
|
||||
total: result.total_keys > 0 || (result.cursor === 0 && result.keys.length === 0) ? result.total_keys : undefined,
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
async function scanNextPage(requestId = searchRequestId, operationId?: number, iterationBudget?: ScanIterationBudget): Promise<boolean> {
|
||||
const result = await fetchScanPage(requestId, operationId, iterationBudget);
|
||||
if (!isCurrentScanOperation(requestId, operationId)) return false;
|
||||
@@ -938,16 +1030,81 @@ function onRedisKeyScroll(event: Event) {
|
||||
// end; per-page tree sorting dominates runtime on million-key pattern scans.
|
||||
const FETCH_ALL_SCAN_COUNT = 50000;
|
||||
const FETCH_ALL_BATCH_ITERATIONS = 8;
|
||||
const FETCH_ALL_PUBLISH_CHUNK_SIZE = 25_000;
|
||||
|
||||
function yieldForRedisKeyBrowserPaint(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function refreshRedisKeyScroller() {
|
||||
const scroller = redisKeyScrollerRef.value as unknown as { updateVisibleItems?: (force?: boolean) => unknown } | null;
|
||||
// Entries changed in place while the array identity stayed stable. Tell the
|
||||
// scroller to invalidate its keyed view pool for the currently rendered
|
||||
// range; `false` preserves old key/view mappings and can combine row labels.
|
||||
scroller?.updateVisibleItems?.(true);
|
||||
}
|
||||
|
||||
async function publishFetchAllVisibleRows(rows: readonly RedisKeyTreeRow[], requestId: number): Promise<boolean> {
|
||||
const target = fetchAllVisibleRows.value;
|
||||
if (rows.length === 0) {
|
||||
target.length = 0;
|
||||
refreshRedisKeyScroller();
|
||||
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < rows.length; offset += FETCH_ALL_PUBLISH_CHUNK_SIZE) {
|
||||
if (requestId !== searchRequestId || !redisBrowserIsActive || fetchAllStopRequested.value) return false;
|
||||
const end = Math.min(offset + FETCH_ALL_PUBLISH_CHUNK_SIZE, rows.length);
|
||||
for (let index = offset; index < end; index++) target[index] = rows[index];
|
||||
if (end === rows.length) target.length = rows.length;
|
||||
refreshRedisKeyScroller();
|
||||
// Yield only when another bounded publication unit remains.
|
||||
if (end < rows.length) await yieldForRedisKeyBrowserPaint();
|
||||
}
|
||||
return requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value;
|
||||
}
|
||||
|
||||
async function fetchAll(): Promise<boolean> {
|
||||
if (!hasMore.value || isFetchingAll.value) return false;
|
||||
const requestId = searchRequestId;
|
||||
const bufferedKeys: RedisKeyInfo[] = [];
|
||||
const bufferedKeyRaws = new Set<string>();
|
||||
const initialFlatKeys = flatKeys.value;
|
||||
const initialFlatKeyByRaw = flatKeyByRaw;
|
||||
const initialLoadedKeyRaws = loadedKeyRaws;
|
||||
const initialTreeKeys = treeKeys.value;
|
||||
const initialTreeIndex = treeIndex;
|
||||
const initialExpandedGroupIds = expandedGroupIds.value;
|
||||
const initialScanCursor = scanCursor.value;
|
||||
const initialHasMore = hasMore.value;
|
||||
const initialLastTotalKeys = lastTotalKeys.value;
|
||||
const snapshotConfig = {
|
||||
db: props.db,
|
||||
separator: redisKeySeparator.value,
|
||||
flatRows: useFlatKeySearchRows.value,
|
||||
expandAll: isSearchMode.value,
|
||||
expandedGroupIds: expandedGroupIds.value,
|
||||
noExpiryOnly: noExpiryOnly.value,
|
||||
};
|
||||
fetchAllPublicationRollback = {
|
||||
flatKeys: initialFlatKeys,
|
||||
flatKeyByRaw: initialFlatKeyByRaw,
|
||||
loadedKeyRaws: initialLoadedKeyRaws,
|
||||
treeKeys: initialTreeKeys,
|
||||
treeIndex: initialTreeIndex,
|
||||
expandedGroupIds: initialExpandedGroupIds,
|
||||
scanCursor: initialScanCursor,
|
||||
hasMore: initialHasMore,
|
||||
lastTotalKeys: initialLastTotalKeys,
|
||||
bufferedKeyRaws,
|
||||
};
|
||||
activateFetchAllVisibleRows();
|
||||
isFetchingAll.value = true;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = flatKeys.value.length;
|
||||
let changed = false;
|
||||
let completed = false;
|
||||
let publicationSucceeded = true;
|
||||
try {
|
||||
while (requestId === searchRequestId && !fetchAllStopRequested.value && hasMore.value) {
|
||||
const result = await fetchScanBatchPage(FETCH_ALL_BATCH_ITERATIONS, {
|
||||
@@ -955,28 +1112,69 @@ async function fetchAll(): Promise<boolean> {
|
||||
includeTypes: false,
|
||||
});
|
||||
if (requestId !== searchRequestId) break;
|
||||
changed = appendScanResult(result, { updateTree: false, buffer: bufferedKeys }) > 0 || changed;
|
||||
changed = bufferFetchAllScanResult(result, bufferedKeys, bufferedKeyRaws) > 0 || changed;
|
||||
fetchAllLoadedCount.value = flatKeys.value.length + bufferedKeys.length;
|
||||
}
|
||||
completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value;
|
||||
} finally {
|
||||
if (requestId === searchRequestId) {
|
||||
appendFlatKeyRecords(bufferedKeys);
|
||||
if (changed) {
|
||||
if (useFlatKeySearchRows.value) {
|
||||
treeKeys.value = [];
|
||||
treeIndex = null;
|
||||
expandedGroupIds.value = new Set();
|
||||
} else {
|
||||
rebuildTree(isSearchMode.value);
|
||||
let published = !changed && !fetchAllStopRequested.value;
|
||||
try {
|
||||
if (changed) {
|
||||
snapshotConfig.flatRows = (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || (isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(initialFlatKeys.length + bufferedKeys.length));
|
||||
snapshotConfig.expandAll = isSearchMode.value;
|
||||
snapshotConfig.expandedGroupIds = expandedGroupIds.value;
|
||||
snapshotConfig.noExpiryOnly = noExpiryOnly.value;
|
||||
activateFetchAllVisibleRows();
|
||||
fetchAllPreparingSnapshot = true;
|
||||
const snapshot = await buildRedisKeySnapshotCooperatively([initialFlatKeys, bufferedKeys], snapshotConfig, {
|
||||
shouldContinue: () => requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value,
|
||||
});
|
||||
if (snapshot && requestId === searchRequestId && redisBrowserIsActive) {
|
||||
flatKeys.value = snapshot.flatKeys;
|
||||
flatKeyByRaw = snapshot.flatKeyByRaw;
|
||||
loadedKeyRaws = snapshot.loadedKeyRaws;
|
||||
treeIndex = snapshot.treeIndex;
|
||||
treeKeys.value = snapshot.treeIndex?.root ?? [];
|
||||
expandedGroupIds.value = snapshot.expandedGroupIds;
|
||||
fetchAllFilteredKeyCount.value = snapshotConfig.noExpiryOnly ? snapshot.filteredKeyCount : null;
|
||||
refreshSelectedGroupLeafCounts();
|
||||
published = await publishFetchAllVisibleRows(snapshot.visibleRows, requestId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fetchAllPreparingSnapshot = false;
|
||||
publicationSucceeded = published;
|
||||
if (published) {
|
||||
fetchAllPublicationRollback = null;
|
||||
if (!hasMore.value) refreshExpandedGroupIds.clear();
|
||||
}
|
||||
if (!published) {
|
||||
rollbackFetchAllPublication();
|
||||
deactivateFetchAllVisibleRows();
|
||||
if (requestId === searchRequestId) {
|
||||
scanCursor.value = initialScanCursor;
|
||||
hasMore.value = initialHasMore;
|
||||
lastTotalKeys.value = initialLastTotalKeys;
|
||||
for (const keyRaw of bufferedKeyRaws) {
|
||||
ttlObservedAtByRaw.delete(keyRaw);
|
||||
positiveTtlKeyRaws.delete(keyRaw);
|
||||
}
|
||||
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
|
||||
loaded: isSearchMode.value ? undefined : initialFlatKeys.length,
|
||||
total: initialLastTotalKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
}
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
} else {
|
||||
publicationSucceeded = false;
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
return completed && publicationSucceeded;
|
||||
}
|
||||
|
||||
function stopFetchAll() {
|
||||
@@ -1025,6 +1223,13 @@ async function fillGroupSubtree(group: RedisKeyTreeGroupNode, requestId = search
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
if (fetchAllPreparingSnapshot) {
|
||||
invalidateScanRequests();
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
fetchAllLoadedCount.value = 0;
|
||||
}
|
||||
deactivateFetchAllVisibleRows();
|
||||
const next = new Set(expandedGroupIds.value);
|
||||
const expanding = !next.has(groupId);
|
||||
if (expanding) next.add(groupId);
|
||||
@@ -1058,6 +1263,7 @@ function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
|
||||
|
||||
function removeKnownKey(keyRaw: string) {
|
||||
if (!flatKeyByRaw.has(keyRaw)) return;
|
||||
invalidateFetchAllForStructuralMutation();
|
||||
loadedKeyRaws.delete(keyRaw);
|
||||
ttlObservedAtByRaw.delete(keyRaw);
|
||||
positiveTtlKeyRaws.delete(keyRaw);
|
||||
@@ -1087,6 +1293,7 @@ function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: strin
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateFetchAllForStructuralMutation();
|
||||
const previous = flatKeyByRaw.get(oldKeyRaw);
|
||||
if (!previous) {
|
||||
void loadKeys();
|
||||
@@ -1135,12 +1342,19 @@ function onKeyLoaded(value: RedisValue) {
|
||||
}
|
||||
const keyInfo = redisValueToKeyInfo(value);
|
||||
const previousTtl = flatKeyByRaw.get(keyInfo.key_raw)?.ttl ?? -2;
|
||||
const noExpiryMembershipChanged = (previousTtl === -1) !== (keyInfo.ttl === -1);
|
||||
if (!updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, keyInfo)) return;
|
||||
// 详情面板回写了最新的 TTL,同步刷新观测时刻,保证两侧倒计时一致
|
||||
recordKeyTtlObservedAt(keyInfo);
|
||||
loadedKeyRaws.add(keyInfo.key_raw);
|
||||
if (treeIndex) updateRedisKeyTreeLeafMetadata(treeIndex, keyInfo);
|
||||
if ((previousTtl === -1) !== (keyInfo.ttl === -1)) noExpiryProjectionEpoch.value++;
|
||||
if (noExpiryMembershipChanged) {
|
||||
noExpiryProjectionEpoch.value++;
|
||||
// A Fetch All stable row snapshot intentionally ignores ordinary metadata
|
||||
// writes. Filter membership is structural, though, so leave that snapshot
|
||||
// and let the canonical no-expiry projection include/exclude the key.
|
||||
if (noExpiryOnly.value) deactivateFetchAllVisibleRows();
|
||||
}
|
||||
keyMetadataEpoch.value++;
|
||||
syncListTtlTimer();
|
||||
}
|
||||
@@ -1499,6 +1713,7 @@ function upsertCreatedKey(value: RedisValue) {
|
||||
size: redisValueSize(value),
|
||||
value_preview: redisValuePreview(value),
|
||||
};
|
||||
invalidateFetchAllForStructuralMutation();
|
||||
const existing = flatKeyByRaw.get(keyInfo.key_raw);
|
||||
// 新建 key 携带的 TTL 以当前时刻为观测起点
|
||||
recordKeyTtlObservedAt(keyInfo);
|
||||
@@ -1901,6 +2116,12 @@ function toggleFuzzyKeySearch() {
|
||||
if (searchMode.value === "key") void loadKeys();
|
||||
}
|
||||
|
||||
function toggleNoExpiryOnly() {
|
||||
if (isFetchingAll.value) return;
|
||||
deactivateFetchAllVisibleRows();
|
||||
noExpiryOnly.value = !noExpiryOnly.value;
|
||||
}
|
||||
|
||||
function getSearchInput(): HTMLInputElement | null {
|
||||
return rootRef.value?.querySelector<HTMLInputElement>("[data-redis-search-input]") ?? null;
|
||||
}
|
||||
@@ -2399,8 +2620,9 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
:class="noExpiryOnly ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'"
|
||||
:title="t('redis.noExpiryOnlyTitle')"
|
||||
:aria-pressed="noExpiryOnly"
|
||||
:disabled="isFetchingAll"
|
||||
data-redis-no-expiry-filter
|
||||
@click="noExpiryOnly = !noExpiryOnly"
|
||||
@click="toggleNoExpiryOnly"
|
||||
>
|
||||
<Clock class="h-3 w-3 mr-1" />
|
||||
<span>{{ t("redis.noExpiryOnly") }}</span>
|
||||
|
||||
Reference in new issue
Block a user