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)
}
}