🐛 fix: 修复 Issue #759 (#770)

## 关联 Issue

Closes #759

## 变更内容

原逻辑在 Redis 前缀匹配拿到任意非空批次后就停止,后端又可能因单次请求的目标数量或扫描时限返回非零
cursor,导致用户仍需手动加载后续匹配项。

- 非空前缀匹配自动沿真实 Redis cursor 扫描至 `cursor=0`,跨批次合并并按 Key 去重;
- 正确处理空批次、非空部分批次和超过默认 2000 条的结果,已覆盖 2001 条自动加载场景;
- 精确搜索继续使用原有 600/1000 分页策略,普通 `*` Key 浏览行为不变;
- Redis Cluster 前缀搜索扫描全部 master 至完成,统一受 3 秒 deadline 和 10000
条结果安全上限保护;超时、超限、SCAN 或元数据错误均明确失败,不再以 `cursor=0` 返回不完整结果;
- 检测重复 cursor,避免普通加载、精确分页和“加载全部”假完成或无限请求;
- 保留 requestId 竞态保护,新搜索可安全取代进行中的“加载全部”请求。

影响范围仅限 Redis Key 搜索和相关扫描完成性,不涉及数据写入、存储格式或 Wails RPC 模型变更。

## 测试结果

- `go test ./...`:通过;
- `go vet ./...`:通过;
- Redis 扫描 race 测试连续 3 轮:通过;
- `cd frontend && npm test`:通过(418 个测试文件,3404 个测试);
- RedisViewer 严格交互测试:22/22 通过,连续稳定性复测通过;
- `cd frontend && npm run build`:通过;
- `cd frontend && npm run i18n:scan`:通过;
- `git diff --check`:通过。

覆盖边界包括:空/部分 SCAN 页、2001/10001 条结果、重复 Key、重复 cursor、精确分页、异步请求竞态、Cluster 多
master、SCAN 批次超过 COUNT、统一 deadline、结果上限和元数据错误。

## 风险说明

不涉及数据变更或兼容性迁移。前缀匹配现在会自动完成扫描,远程或稀疏 Key 空间下请求时间可能比原先更长;单节点请求仍按后端分页和 3
秒扫描预算续传。为控制内存和渲染成本,自动全量结果超过 10000 条时会明确提示失败。Cluster 前缀搜索必须在统一 3 秒内完成全部
master,否则明确返回错误,避免静默漏项。

回滚时可回退提交 `880ed671` 和 `498e9b80`。
This commit is contained in:
Syngnat
2026-07-28 22:39:20 +08:00
committed by GitHub
4 changed files with 1019 additions and 19 deletions

View File

@@ -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') {
@@ -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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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<any>((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(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
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 () => {

View File

@@ -47,6 +47,7 @@ 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_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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ connectionId, redisDB }) => {
latestLoadRequestIdRef.current = requestId;
setLoading(true);
setLoadingAllKeys(false);
try {
let scanCursor = normalizeRedisCursor(fromCursor);
let scannedKeys: RedisKeyInfo[] = [];
let nextCursor = scanCursor;
const keyMap = new Map<string, RedisKeyInfo>();
const visitedCursors = new Set<string>();
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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ connectionId, redisDB }) => {
try {
let nextCursor = '0';
const keyMap = new Map<string, RedisKeyInfo>();
const visitedCursors = new Set<string>();
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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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) => {

View File

@@ -47,6 +47,7 @@ const (
redisScanMaxDuration = 12 * time.Second
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 {

View File

@@ -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,6 +819,398 @@ func TestRedisSearchScanContinuesPastEmptyMatchedPages(t *testing.T) {
}
}
func TestRedisSearchScanPaginatesMoreThanOneThousandKeys(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 {
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"
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()
firstResult, err := client.ScanKeys(searchPattern, 0, redisScanDefaultTargetCount)
if err != nil {
t.Fatalf("first ScanKeys returned error: %v", err)
}
if firstResult == nil || len(firstResult.Keys) != 1000 || firstResult.Cursor != "1" {
t.Fatalf("expected first 1000 keys with continuation cursor, got %#v", firstResult)
}
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 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)
}
}
func TestRedisSelectDBReconnectsWithSentinelConfig(t *testing.T) {
oldConnect := redisDBSwitchConnect
defer func() {