perf(redis): keep fetch-all finalization responsive

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-05 00:40:52 +08:00
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>