From 498e9b804bb1e57ab237e838a49c7783aff3164a Mon Sep 17 00:00:00 2001 From: mango <1711456624@qq.com> Date: Tue, 28 Jul 2026 20:00:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?Issue=20#759=20(Closes=20#759)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RedisViewer.interaction.test.tsx | 2 +- frontend/src/components/RedisViewer.tsx | 2 +- internal/redis/redis_impl.go | 2 +- internal/redis/redis_impl_test.go | 72 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/RedisViewer.interaction.test.tsx b/frontend/src/components/RedisViewer.interaction.test.tsx index 3d7b248b..505ee546 100644 --- a/frontend/src/components/RedisViewer.interaction.test.tsx +++ b/frontend/src/components/RedisViewer.interaction.test.tsx @@ -341,7 +341,7 @@ describe('RedisViewer tree interactions', () => { expect.any(Object), '[aA][pP][pP]:[uU][sS][eE][rR]*', '0', - 600, + 2000, ); groupTitleRenderer!.unmount(); diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 89e1008f..eab6b2a7 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -45,7 +45,7 @@ const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; const REDIS_KEY_LOAD_MORE_COUNT = 2000; -const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600; +const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 2000; const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000; const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000; const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200; diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 9ca65426..e195ee61 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -45,7 +45,7 @@ const ( redisScanMaxStepCount int64 = 2000 redisScanMaxRounds = 64 redisScanMaxDuration = 12 * time.Second - redisSearchMaxTargetCount int64 = 1000 + redisSearchMaxTargetCount int64 = 2000 redisSearchMaxStepCount int64 = 1000 redisSearchMaxDuration = 3 * time.Second ) diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index 8fa5aee4..e7d061c6 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -817,6 +817,78 @@ func TestRedisSearchScanContinuesPastEmptyMatchedPages(t *testing.T) { } } +func TestRedisSearchScanCollectsMoreThanOneThousandKeys(t *testing.T) { + var mu sync.Mutex + scanCalls := 0 + const searchPattern = "matched:*" + + redisScanResponse := func(cursor string, keys ...string) string { + var builder strings.Builder + builder.WriteString("*2\r\n") + builder.WriteString(redisBulkString(cursor)) + builder.WriteString(fmt.Sprintf("*%d\r\n", len(keys))) + for _, key := range keys { + builder.WriteString(redisBulkString(key)) + } + return builder.String() + } + + firstBatch := make([]string, 1000) + for i := range firstBatch { + firstBatch[i] = fmt.Sprintf("matched:%d", i) + } + + addr := startRedisProtocolTestServer(t, func(args []string) string { + command := strings.ToUpper(strings.TrimSpace(args[0])) + switch command { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + mu.Lock() + defer mu.Unlock() + scanCalls++ + if scanCalls == 1 { + return redisScanResponse("1", firstBatch...) + } + return redisScanResponse("0", "matched:1000") + case "TYPE": + return "+string\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + + rawClient := goredis.NewClient(&goredis.Options{ + Addr: addr, + Protocol: 2, + }) + client := &RedisClientImpl{ + client: rawClient, + singleClient: rawClient, + } + defer client.Close() + + result, err := client.ScanKeys(searchPattern, 0, redisScanDefaultTargetCount) + if err != nil { + t.Fatalf("ScanKeys returned error: %v", err) + } + if result == nil || len(result.Keys) != 1001 { + t.Fatalf("expected all 1001 searched keys, got %#v", result) + } + if result.Cursor != "0" { + t.Fatalf("expected completed cursor, got %q", result.Cursor) + } + + mu.Lock() + defer mu.Unlock() + if scanCalls != 2 { + t.Fatalf("expected ScanKeys to read both search batches, got %d calls", scanCalls) + } +} + func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) { oldConnect := redisDBSwitchConnect defer func() { From 880ed671c9a130a4783a77ac21301b40f580a666 Mon Sep 17 00:00:00 2001 From: mango <1711456624@qq.com> Date: Tue, 28 Jul 2026 21:35:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(redis):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=20Issue=20#759=20=E5=85=A8=E9=87=8F=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RedisViewer.interaction.test.tsx | 454 +++++++++++++++++- frontend/src/components/RedisViewer.tsx | 78 ++- internal/redis/redis_impl.go | 118 ++++- internal/redis/redis_impl_test.go | 338 ++++++++++++- 4 files changed, 958 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/RedisViewer.interaction.test.tsx b/frontend/src/components/RedisViewer.interaction.test.tsx index 505ee546..a84255fb 100644 --- a/frontend/src/components/RedisViewer.interaction.test.tsx +++ b/frontend/src/components/RedisViewer.interaction.test.tsx @@ -164,6 +164,12 @@ const findButtonByText = (renderer: ReactTestRenderer, text: string) => { return renderer.root.findAllByType('button').find((node) => collectRenderedText(node.props.children).includes(text)); }; +const createRedisKeyBatch = (start: number, count: number) => Array.from({ length: count }, (_, index) => ({ + key: `matched:${start + index}`, + type: 'string', + ttl: -1, +})); + const countLeafNodes = (nodes: any[]): number => { return nodes.reduce((total, node) => { if (!node || typeof node !== 'object') { @@ -341,7 +347,7 @@ describe('RedisViewer tree interactions', () => { expect.any(Object), '[aA][pP][pP]:[uU][sS][eE][rR]*', '0', - 2000, + 600, ); groupTitleRenderer!.unmount(); @@ -471,6 +477,343 @@ describe('RedisViewer tree interactions', () => { renderer!.unmount(); }); + it('loads and deduplicates every filtered cursor page automatically', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '27', + keys: [ + { key: 'sub:v2:first', type: 'string', ttl: -1 }, + { key: 'sub:v2:shared', type: 'string', ttl: -1 }, + ], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '31', keys: [] }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [ + { key: 'sub:v2:shared', type: 'string', ttl: -1 }, + { key: 'sub:v2:last', type: 'string', ttl: -1 }, + ], + }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('sub:v2'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(4); + expect(redisBackend.RedisScanKeys.mock.calls.slice(1).map((call) => call[2])).toEqual(['0', '27', '31']); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(3); + expect(collectRenderedText(renderer!.toJSON())).toContain('Loaded 3 Keys'); + expect(findButtonByText(renderer!, 'Load more')).toBeUndefined(); + + renderer!.unmount(); + }); + + it('loads more than two thousand filtered keys without manual paging', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '27', keys: createRedisKeyBatch(0, 1000) }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '31', keys: createRedisKeyBatch(1000, 1000) }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '0', keys: createRedisKeyBatch(2000, 1) }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('matched'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys.mock.calls.slice(1).map((call) => call[2])).toEqual(['0', '27', '31']); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(2001); + expect(collectRenderedText(renderer!.toJSON())).toContain('Loaded 2001 Keys'); + expect(findButtonByText(renderer!, 'Load more')).toBeUndefined(); + + renderer!.unmount(); + }); + + it('rejects filtered searches that exceed the result safety limit', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '27', keys: createRedisKeyBatch(0, 5000) }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '0', keys: createRedisKeyBatch(5000, 5001) }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('matched'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3); + expect(antdState.message.error).toHaveBeenCalledWith(expect.stringContaining('10000')); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(1); + + renderer!.unmount(); + }); + + it('keeps exact searches on the existing initial page size', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchModeGroup = renderer!.root.findAll( + node => node.props.buttonStyle === 'solid' && typeof node.props.onChange === 'function', + )[0]; + await act(async () => { + searchModeGroup.props.onChange({ target: { value: 'exact' } }); + }); + await flushEffects(); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('app:user'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenLastCalledWith( + expect.any(Object), + 'app:user', + '0', + 600, + ); + + renderer!.unmount(); + }); + + it('keeps exact search continuation available after the first page', async () => { + const firstPage = Array.from({ length: 600 }, (_, index) => ({ + key: `app:user:${index}`, + type: 'string', + ttl: -1, + })); + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '27', keys: firstPage }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '0', keys: [{ key: 'app:user:600', type: 'string', ttl: -1 }] }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchModeGroup = renderer!.root.findAll( + node => node.props.buttonStyle === 'solid' && typeof node.props.onChange === 'function', + )[0]; + await act(async () => { + searchModeGroup.props.onChange({ target: { value: 'exact' } }); + }); + await flushEffects(); + + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { cursor: '27', keys: firstPage }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '0', keys: [{ key: 'app:user:600', type: 'string', ttl: -1 }] }, + }); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('app:user'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys.mock.calls[0]?.slice(1)).toEqual(['app:user', '0', 600]); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(600); + const loadMoreButton = findButtonByText(renderer!, 'Load more'); + expect(loadMoreButton).toBeTruthy(); + await act(async () => { + loadMoreButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys.mock.calls[1]?.slice(1)).toEqual(['app:user', '27', 1000]); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(601); + + renderer!.unmount(); + }); + + it('rejects an exact search page that repeats its request cursor', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchModeGroup = renderer!.root.findAll( + node => node.props.buttonStyle === 'solid' && typeof node.props.onChange === 'function', + )[0]; + await act(async () => { + searchModeGroup.props.onChange({ target: { value: 'exact' } }); + }); + await flushEffects(); + + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '27', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '27', + keys: [{ key: 'app:user:2', type: 'string', ttl: -1 }], + }, + }); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('app:user'); + }); + await flushEffects(); + + const loadMoreButton = findButtonByText(renderer!, 'Load more'); + expect(loadMoreButton).toBeTruthy(); + await act(async () => { + loadMoreButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys.mock.calls.map((call) => call[2])).toEqual(['0', '27']); + expect(antdState.message.error).toHaveBeenCalledWith(expect.stringContaining('cursor')); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(1); + + renderer!.unmount(); + }); + + it('stops a filtered scan when the backend repeats a cursor', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '27', + keys: [{ key: 'sub:v2:first', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { cursor: '27', keys: [] }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('sub:v2'); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3); + expect(antdState.message.error).toHaveBeenCalledWith(expect.stringContaining('cursor')); + + renderer!.unmount(); + }); + it('loads every key page when the load-all action is clicked', async () => { redisBackend.RedisScanKeys.mockReset(); redisBackend.RedisScanKeys @@ -530,6 +873,115 @@ describe('RedisViewer tree interactions', () => { renderer!.unmount(); }); + it('stops load-all when the backend repeats a cursor', async () => { + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [{ key: 'app:user:2', type: 'string', ttl: -1 }], + }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const loadAllButton = findButtonByText(renderer!, 'Load all'); + await act(async () => { + loadAllButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3); + expect(redisBackend.RedisScanKeys.mock.calls.slice(1).map((call) => call[2])).toEqual(['0', '1']); + expect(antdState.message.error).toHaveBeenCalledWith(expect.stringContaining('cursor')); + expect(findButtonByText(renderer!, 'Load all')?.props.loading).toBe(false); + + renderer!.unmount(); + }); + + it('keeps a newer search when it supersedes a pending load-all request', async () => { + let resolveLoadAll!: (value: any) => void; + const pendingLoadAll = new Promise((resolve) => { + resolveLoadAll = resolve; + }); + redisBackend.RedisScanKeys.mockReset(); + redisBackend.RedisScanKeys + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '1', + keys: [{ key: 'app:user:1', type: 'string', ttl: -1 }], + }, + }) + .mockReturnValueOnce(pendingLoadAll) + .mockResolvedValueOnce({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'new:result', type: 'string', ttl: -1 }], + }, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + await act(async () => { + findButtonByText(renderer!, 'Load all')!.props.onClick?.(); + }); + await flushEffects(); + expect(findButtonByText(renderer!, 'Load all')?.props.loading).toBe(true); + + const searchInput = renderer!.root.findAllByType('input') + .find((node) => typeof node.props.onSearch === 'function'); + await act(async () => { + searchInput!.props.onSearch('new'); + }); + await flushEffects(); + + expect(findButtonByText(renderer!, 'Load all')?.props.loading).toBe(false); + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(1); + expect(findFirstLeafNode(antdState.treeProps.treeData)?.rawKey).toBe('new:result'); + + await act(async () => { + resolveLoadAll({ + success: true, + data: { + cursor: '0', + keys: [{ key: 'stale:result', type: 'string', ttl: -1 }], + }, + }); + await pendingLoadAll; + }); + await flushEffects(); + + expect(countLeafNodes(antdState.treeProps.treeData)).toBe(1); + expect(findFirstLeafNode(antdState.treeProps.treeData)?.rawKey).toBe('new:result'); + + renderer!.unmount(); + }); + it('exports the current filtered key set when the export-all action is clicked', async () => { let renderer: ReactTestRenderer; await act(async () => { diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index eab6b2a7..ebdde7af 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -45,8 +45,9 @@ const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; const REDIS_KEY_LOAD_MORE_COUNT = 2000; -const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 2000; +const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600; const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000; +const REDIS_KEY_SEARCH_MAX_RESULT_COUNT = 10000; const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000; const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200; const REDIS_KEY_GONE_MESSAGE = 'Redis Key 不存在或已过期'; // i18n-scan: allow-raw backend sentinel @@ -420,7 +421,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { pattern: string = '*', fromCursor: string = '0', append: boolean = false, - targetCount?: number + targetCount?: number, + scanToCompletion: boolean = false ) => { const config = getConfig(); if (!config) return; @@ -431,16 +433,17 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { latestLoadRequestIdRef.current = requestId; setLoading(true); + setLoadingAllKeys(false); try { let scanCursor = normalizeRedisCursor(fromCursor); let scannedKeys: RedisKeyInfo[] = []; let nextCursor = scanCursor; + const keyMap = new Map(); const visitedCursors = new Set(); while (true) { if (visitedCursors.has(scanCursor)) { - nextCursor = '0'; - break; + throw new Error(`Redis scan cursor repeated: ${scanCursor}`); } visitedCursors.add(scanCursor); @@ -456,16 +459,26 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { scannedKeys = page.scannedKeys; nextCursor = page.nextCursor; - if (scannedKeys.length > 0 || nextCursor === '0') { + if (nextCursor !== '0' && nextCursor === scanCursor) { + throw new Error(`Redis scan cursor repeated: ${nextCursor}`); + } + if (scanToCompletion) { + scannedKeys.forEach((item) => keyMap.set(item.key, item)); + if (keyMap.size > REDIS_KEY_SEARCH_MAX_RESULT_COUNT) { + throw new Error(`Redis search exceeded ${REDIS_KEY_SEARCH_MAX_RESULT_COUNT} Keys`); + } + } + if (nextCursor === '0' || (!scanToCompletion && scannedKeys.length > 0)) { break; } scanCursor = nextCursor; } + const loadedKeys = scanToCompletion ? Array.from(keyMap.values()) : scannedKeys; if (append) { - setKeys(prev => mergeRedisKeyInfoLists(prev, scannedKeys)); + setKeys(prev => mergeRedisKeyInfoLists(prev, loadedKeys)); } else { - setKeys(scannedKeys); + setKeys(loadedKeys); } setCursor(nextCursor); setHasMore(nextCursor !== '0'); @@ -482,7 +495,13 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [getConfig, scanRedisKeysPage, tr]); useEffect(() => { - loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); + loadKeys( + searchPattern, + '0', + false, + getRedisScanLoadCount(searchPattern, false), + searchMode === 'prefix' && searchPattern !== '*' + ); }, [loadKeys, redisDB]); const executeSearch = useCallback((value: string, mode: RedisSearchMode = searchMode) => { @@ -490,7 +509,13 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setSearchInput(normalized.keyword); setSearchPattern(normalized.pattern); setCursor('0'); - loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); + loadKeys( + normalized.pattern, + '0', + false, + getRedisScanLoadCount(normalized.pattern, false), + mode === 'prefix' && normalized.keyword !== '' + ); }, [loadKeys, searchMode]); const handleSearch = (value: string) => { @@ -505,7 +530,13 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } setSearchPattern(normalized.pattern); setCursor('0'); - loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); + loadKeys( + normalized.pattern, + '0', + false, + getRedisScanLoadCount(normalized.pattern, false), + searchMode === 'prefix' && normalized.keyword !== '' + ); }; const handleSearchModeChange = useCallback((event: RadioChangeEvent) => { @@ -537,8 +568,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { try { let nextCursor = '0'; const keyMap = new Map(); + const visitedCursors = new Set(); do { + if (visitedCursors.has(nextCursor)) { + throw new Error(`Redis scan cursor repeated: ${nextCursor}`); + } + visitedCursors.add(nextCursor); + const { scannedKeys, nextCursor: scannedCursor } = await scanRedisKeysPage( config, normalizedPattern, @@ -549,6 +586,9 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return; } scannedKeys.forEach((item) => keyMap.set(item.key, item)); + if (scannedCursor !== '0' && scannedCursor === nextCursor) { + throw new Error(`Redis scan cursor repeated: ${scannedCursor}`); + } nextCursor = scannedCursor; } while (nextCursor !== '0'); @@ -570,7 +610,13 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const handleRefresh = () => { setCursor('0'); - loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); + loadKeys( + searchPattern, + '0', + false, + getRedisScanLoadCount(searchPattern, false), + searchMode === 'prefix' && searchPattern !== '*' + ); }; const handleSelectAllLoadedKeys = useCallback(() => { @@ -705,7 +751,13 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setSelectedKey(null); setKeyValue(null); setCursor('0'); - loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); + loadKeys( + searchPattern, + '0', + false, + getRedisScanLoadCount(searchPattern, false), + searchMode === 'prefix' && searchPattern !== '*' + ); return; } if (String(res?.message || '').trim() === '已取消') { @@ -717,7 +769,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } finally { setImportingKeys(false); } - }, [getConfig, importConflictMode, importPreview, importSelectedKeys, loadKeys, resetImportModalState, searchPattern, tr]); + }, [getConfig, importConflictMode, importPreview, importSelectedKeys, loadKeys, resetImportModalState, searchMode, searchPattern, tr]); const importSelectedKeySet = useMemo(() => new Set(importSelectedKeys), [importSelectedKeys]); const handleToggleImportPreviewKey = useCallback((key: string, checked: boolean) => { diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index e195ee61..ee1a25d1 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -45,8 +45,9 @@ const ( redisScanMaxStepCount int64 = 2000 redisScanMaxRounds = 64 redisScanMaxDuration = 12 * time.Second - redisSearchMaxTargetCount int64 = 2000 + redisSearchMaxTargetCount int64 = 1000 redisSearchMaxStepCount int64 = 1000 + redisSearchMaxResultCount = 10000 redisSearchMaxDuration = 3 * time.Second ) @@ -391,13 +392,14 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) (err error tlsConfig = cfg } opts := &redis.ClusterOptions{ - Addrs: seedAddrs, - Username: strings.TrimSpace(attempt.User), - Password: attempt.Password, - DialTimeout: timeout, - ReadTimeout: timeout, - WriteTimeout: timeout, - TLSConfig: tlsConfig, + Addrs: seedAddrs, + Username: strings.TrimSpace(attempt.User), + Password: attempt.Password, + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, + ContextTimeoutEnabled: true, + TLSConfig: tlsConfig, } clusterClient := redis.NewClusterClient(opts) ctx, cancel := context.WithTimeout(context.Background(), timeout) @@ -529,7 +531,9 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( pattern = "*" } exactPhysicalKey := "" + isExactPattern := false if literalKey, ok := redisGlobPatternLiteralKey(pattern); ok { + isExactPattern = true exactKey, namespacePattern := redisExactSearchPattern(literalKey) exactPhysicalKey = r.toPhysicalKey(exactKey) if exactPhysicalKey == "" { @@ -562,6 +566,65 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( // 集群模式:逐 master 节点 SCAN 后合并去重 if r.isCluster && r.clusterClient != nil { + if isSearchPattern && !isExactPattern { + searchCtx, searchCancel := context.WithTimeout(context.Background(), maxDuration) + defer searchCancel() + + keys := make([]string, 0, int(targetCount)) + seen := make(map[string]struct{}, int(targetCount)) + var mu sync.Mutex + if exactPhysicalKey != "" { + exists, err := r.client.Exists(searchCtx, exactPhysicalKey).Result() + if err != nil { + return nil, fmt.Errorf("Redis 集群搜索检查精确 Key 失败: %w", err) + } + if exists > 0 { + keys = append(keys, exactPhysicalKey) + seen[exactPhysicalKey] = struct{}{} + } + } + + err := r.clusterClient.ForEachMaster(searchCtx, func(nodeCtx context.Context, node *redis.Client) error { + var nodeCursor uint64 + for { + batch, nextCursor, err := node.Scan(nodeCtx, nodeCursor, physicalPattern, scanStepCount).Result() + if err != nil { + searchCancel() + return err + } + + mu.Lock() + for _, key := range batch { + if _, ok := seen[key]; ok { + continue + } + if len(keys) >= redisSearchMaxResultCount { + mu.Unlock() + searchCancel() + return fmt.Errorf("Redis 集群搜索结果超过安全上限 %d", redisSearchMaxResultCount) + } + seen[key] = struct{}{} + keys = append(keys, key) + } + mu.Unlock() + + nodeCursor = nextCursor + if nodeCursor == 0 { + return nil + } + } + }) + if err != nil { + return nil, fmt.Errorf("Redis 集群搜索未完成: %w", err) + } + + keyInfos, err := r.loadRedisKeyInfosStrict(searchCtx, keys) + if err != nil { + return nil, fmt.Errorf("Redis 集群搜索读取 Key 元数据失败: %w", err) + } + return &RedisScanResult{Keys: keyInfos, Cursor: "0"}, nil + } + keys := make([]string, 0, int(targetCount)) seen := make(map[string]struct{}, int(targetCount)) var mu sync.Mutex @@ -685,6 +748,45 @@ func normalizeRedisScanStepCount(targetCount int64) int64 { return targetCount } +func (r *RedisClientImpl) loadRedisKeyInfosStrict(ctx context.Context, keys []string) ([]RedisKeyInfo, error) { + result := make([]RedisKeyInfo, 0, len(keys)) + if len(keys) == 0 { + return result, nil + } + + pipe := r.client.Pipeline() + typeResults := make([]*redis.StatusCmd, len(keys)) + ttlResults := make([]*redis.DurationCmd, len(keys)) + for i, key := range keys { + typeResults[i] = pipe.Type(ctx, key) + ttlResults[i] = pipe.TTL(ctx, key) + } + if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil { + return nil, err + } + + for i, key := range keys { + keyType, err := typeResults[i].Result() + if err != nil && err != redis.Nil { + return nil, err + } + ttlValue, err := ttlResults[i].Result() + if err != nil && err != redis.Nil { + return nil, err + } + ttlSeconds := toRedisTTLSeconds(ttlValue) + if isRedisKeyGone(keyType, ttlSeconds) { + continue + } + result = append(result, RedisKeyInfo{ + Key: r.toDisplayKey(key), + Type: keyType, + TTL: ttlSeconds, + }) + } + return result, nil +} + func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo { result := make([]RedisKeyInfo, 0, len(keys)) if len(keys) == 0 { diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index e7d061c6..e527c6bc 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -4,6 +4,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/shared/i18n" "bufio" + "context" "encoding/json" "errors" "fmt" @@ -16,6 +17,7 @@ import ( "strings" "sync" "testing" + "time" goredis "github.com/redis/go-redis/v9" ) @@ -817,7 +819,7 @@ func TestRedisSearchScanContinuesPastEmptyMatchedPages(t *testing.T) { } } -func TestRedisSearchScanCollectsMoreThanOneThousandKeys(t *testing.T) { +func TestRedisSearchScanPaginatesMoreThanOneThousandKeys(t *testing.T) { var mu sync.Mutex scanCalls := 0 const searchPattern = "matched:*" @@ -850,8 +852,14 @@ func TestRedisSearchScanCollectsMoreThanOneThousandKeys(t *testing.T) { defer mu.Unlock() scanCalls++ if scanCalls == 1 { + if args[1] != "0" { + t.Fatalf("expected initial cursor 0, got %q", args[1]) + } return redisScanResponse("1", firstBatch...) } + if args[1] != "1" { + t.Fatalf("expected continuation cursor 1, got %q", args[1]) + } return redisScanResponse("0", "matched:1000") case "TYPE": return "+string\r\n" @@ -871,21 +879,335 @@ func TestRedisSearchScanCollectsMoreThanOneThousandKeys(t *testing.T) { } defer client.Close() - result, err := client.ScanKeys(searchPattern, 0, redisScanDefaultTargetCount) + firstResult, err := client.ScanKeys(searchPattern, 0, redisScanDefaultTargetCount) if err != nil { - t.Fatalf("ScanKeys returned error: %v", err) + t.Fatalf("first ScanKeys returned error: %v", err) } - if result == nil || len(result.Keys) != 1001 { - t.Fatalf("expected all 1001 searched keys, got %#v", result) + if firstResult == nil || len(firstResult.Keys) != 1000 || firstResult.Cursor != "1" { + t.Fatalf("expected first 1000 keys with continuation cursor, got %#v", firstResult) } - if result.Cursor != "0" { - t.Fatalf("expected completed cursor, got %q", result.Cursor) + + secondResult, err := client.ScanKeys(searchPattern, 1, redisScanDefaultTargetCount) + if err != nil { + t.Fatalf("second ScanKeys returned error: %v", err) + } + if secondResult == nil || len(secondResult.Keys) != 1 || secondResult.Cursor != "0" { + t.Fatalf("expected final searched key with completed cursor, got %#v", secondResult) } mu.Lock() defer mu.Unlock() if scanCalls != 2 { - t.Fatalf("expected ScanKeys to read both search batches, got %d calls", scanCalls) + t.Fatalf("expected exactly two paged SCAN calls, got %d", scanCalls) + } +} + +func newRedisProtocolClusterClient(t *testing.T, addrs ...string) *goredis.ClusterClient { + t.Helper() + slots := make([]goredis.ClusterSlot, 0, len(addrs)) + for index, addr := range addrs { + start := index * 16384 / len(addrs) + end := (index+1)*16384/len(addrs) - 1 + slots = append(slots, goredis.ClusterSlot{ + Start: start, + End: end, + Nodes: []goredis.ClusterNode{{Addr: addr}}, + }) + } + client := goredis.NewClusterClient(&goredis.ClusterOptions{ + ClusterSlots: func(context.Context) ([]goredis.ClusterSlot, error) { + return slots, nil + }, + Protocol: 2, + ContextTimeoutEnabled: true, + }) + t.Cleanup(func() { + _ = client.Close() + }) + return client +} + +func TestRedisClusterConnectEnablesContextTimeouts(t *testing.T) { + var addr string + addr = startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "CLUSTER": + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split cluster test address: %v", err) + } + return fmt.Sprintf("*1\r\n*3\r\n:0\r\n:16383\r\n*3\r\n%s%s%s", redisBulkString(host), redisBulkString(port), redisBulkString("node-1")) + case "PING": + return "+PONG\r\n" + } + return "+OK\r\n" + }) + host, portText, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split cluster test address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse cluster test port: %v", err) + } + + client := &RedisClientImpl{} + if err := client.Connect(connection.ConnectionConfig{ + Type: "redis", + Host: host, + Port: port, + Topology: "cluster", + Timeout: 2, + }); err != nil { + t.Fatalf("Connect returned error: %v", err) + } + defer client.Close() + + if client.clusterClient == nil || !client.clusterClient.Options().ContextTimeoutEnabled { + t.Fatalf("expected production cluster client to enable context timeouts") + } +} + +func TestRedisClusterSearchScansEveryMasterToCompletion(t *testing.T) { + var mu sync.Mutex + scanCalls := map[string]int{} + redisScanResponse := func(cursor string, keys ...string) string { + var builder strings.Builder + builder.WriteString("*2\r\n") + builder.WriteString(redisBulkString(cursor)) + builder.WriteString(fmt.Sprintf("*%d\r\n", len(keys))) + for _, key := range keys { + builder.WriteString(redisBulkString(key)) + } + return builder.String() + } + startNode := func(name string, pages []string) string { + return startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + mu.Lock() + page := scanCalls[name] + scanCalls[name]++ + mu.Unlock() + if page >= len(pages) { + t.Fatalf("unexpected extra SCAN on %s", name) + } + if page+1 < len(pages) { + return redisScanResponse(strconv.Itoa(page+1), pages[page]) + } + return redisScanResponse("0", pages[page]) + case "TYPE": + return "+string\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + } + + firstAddr := startNode("first", []string{"matched:first:1", "matched:first:2"}) + secondAddr := startNode("second", []string{"matched:second:1", "matched:second:2"}) + clusterClient := newRedisProtocolClusterClient(t, firstAddr, secondAddr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + result, err := client.ScanKeys("matched:*", 0, 1) + if err != nil { + t.Fatalf("ScanKeys returned error: %v", err) + } + if result == nil || len(result.Keys) != 4 { + t.Fatalf("expected all four cluster search results, got %#v", result) + } + if result.Cursor != "0" { + t.Fatalf("expected completed cluster cursor, got %q", result.Cursor) + } + + mu.Lock() + defer mu.Unlock() + if scanCalls["first"] != 2 || scanCalls["second"] != 2 { + t.Fatalf("expected every master to reach cursor 0, got %#v", scanCalls) + } +} + +func TestRedisClusterExactSearchKeepsTargetCountLimit(t *testing.T) { + keys := make([]string, redisSearchMaxResultCount+1) + for i := range keys { + keys[i] = fmt.Sprintf("folder:item:%d", i) + } + var response strings.Builder + response.WriteString("*2\r\n$1\r\n0\r\n") + response.WriteString(fmt.Sprintf("*%d\r\n", len(keys))) + for _, key := range keys { + response.WriteString(redisBulkString(key)) + } + + addr := startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + return response.String() + case "TYPE": + return "+string\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + clusterClient := newRedisProtocolClusterClient(t, addr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + result, err := client.ScanKeys("folder", 0, 2) + if err != nil { + t.Fatalf("ScanKeys returned error: %v", err) + } + if result == nil || len(result.Keys) != 2 { + t.Fatalf("expected exact search to keep the target count, got %#v", result) + } +} + +func TestRedisClusterSearchKeepsEntireScanBatch(t *testing.T) { + addr := startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + return "*2\r\n$1\r\n0\r\n*2\r\n$15\r\nmatched:first:1\r\n$15\r\nmatched:first:2\r\n" + case "TYPE": + return "+string\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + clusterClient := newRedisProtocolClusterClient(t, addr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + result, err := client.ScanKeys("matched:*", 0, 1) + if err != nil { + t.Fatalf("ScanKeys returned error: %v", err) + } + if result == nil || len(result.Keys) != 2 { + t.Fatalf("expected the entire Redis SCAN batch, got %#v", result) + } +} + +func TestRedisClusterSearchRejectsResultsOverSafetyLimit(t *testing.T) { + keys := make([]string, redisSearchMaxResultCount+1) + for i := range keys { + keys[i] = fmt.Sprintf("matched:%d", i) + } + var response strings.Builder + response.WriteString("*2\r\n$1\r\n0\r\n") + response.WriteString(fmt.Sprintf("*%d\r\n", len(keys))) + for _, key := range keys { + response.WriteString(redisBulkString(key)) + } + + addr := startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + return response.String() + } + return "+OK\r\n" + }) + clusterClient := newRedisProtocolClusterClient(t, addr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + result, err := client.ScanKeys("matched:*", 0, redisScanDefaultTargetCount) + if err == nil || !strings.Contains(err.Error(), strconv.Itoa(redisSearchMaxResultCount)) { + t.Fatalf("expected safety-limit error, got result=%#v err=%v", result, err) + } +} + +func TestRedisClusterSearchHonorsSharedDeadline(t *testing.T) { + addr := startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + time.Sleep(redisSearchMaxDuration + 2*time.Second) + return "*2\r\n$1\r\n0\r\n*0\r\n" + } + return "+OK\r\n" + }) + clusterClient := newRedisProtocolClusterClient(t, addr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + startedAt := time.Now() + result, err := client.ScanKeys("matched:*", 0, 10) + elapsed := time.Since(startedAt) + if err == nil { + t.Fatalf("expected cluster search deadline error, got result %#v", result) + } + if elapsed >= redisSearchMaxDuration+time.Second { + t.Fatalf("expected shared %s deadline, elapsed %s", redisSearchMaxDuration, elapsed) + } +} + +func TestRedisClusterSearchReturnsMetadataErrors(t *testing.T) { + addr := startRedisProtocolTestServer(t, func(args []string) string { + switch strings.ToUpper(strings.TrimSpace(args[0])) { + case "HELLO": + return "-ERR unknown command 'HELLO'\r\n" + case "CLIENT": + return "-ERR unknown subcommand\r\n" + case "SCAN": + return "*2\r\n$1\r\n0\r\n*1\r\n$13\r\nmatched:first\r\n" + case "TYPE": + return "-ERR TYPE forbidden\r\n" + case "TTL": + return ":-1\r\n" + } + return "+OK\r\n" + }) + clusterClient := newRedisProtocolClusterClient(t, addr) + client := &RedisClientImpl{ + client: clusterClient, + clusterClient: clusterClient, + isCluster: true, + } + + result, err := client.ScanKeys("matched:*", 0, 10) + if err == nil { + t.Fatalf("expected metadata error, got result %#v", result) } }