🐛 fix(redis): 修复过滤扫描空页导致 Key 搜索漏项

- 自动沿非零游标继续扫描空结果页
- 在游标结束或重复时安全终止续扫
- 增加过滤搜索跨空页命中 Key 的回归测试
This commit is contained in:
Syngnat
2026-07-27 17:19:25 +08:00
parent fac84c29fb
commit 1a25348292
2 changed files with 75 additions and 3 deletions

View File

@@ -359,6 +359,52 @@ describe('RedisViewer tree interactions', () => {
renderer!.unmount();
});
it('continues a filtered scan when the first cursor page has no matching keys', 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: [] },
})
.mockResolvedValueOnce({
success: true,
data: {
cursor: '0',
keys: [{ key: 'sub:v2:lock', type: 'string', ttl: 2400 }],
},
});
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');
expect(searchInput).toBeTruthy();
await act(async () => {
searchInput!.props.onSearch('sub:v2');
});
await flushEffects();
expect(redisBackend.RedisScanKeys).toHaveBeenCalledTimes(3);
expect(redisBackend.RedisScanKeys.mock.calls[1]?.[2]).toBe('0');
expect(redisBackend.RedisScanKeys.mock.calls[2]?.[2]).toBe('27');
expect(countLeafNodes(antdState.treeProps.treeData)).toBe(1);
expect(findFirstLeafNode(antdState.treeProps.treeData)?.rawKey).toBe('sub:v2:lock');
renderer!.unmount();
});
it('loads every key page when the load-all action is clicked', async () => {
redisBackend.RedisScanKeys.mockReset();
redisBackend.RedisScanKeys

View File

@@ -345,10 +345,36 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setLoading(true);
try {
const { scannedKeys, nextCursor } = await scanRedisKeysPage(config, normalizedPattern, fromCursor, effectiveTargetCount);
if (requestId !== latestLoadRequestIdRef.current) {
return;
let scanCursor = normalizeRedisCursor(fromCursor);
let scannedKeys: RedisKeyInfo[] = [];
let nextCursor = scanCursor;
const visitedCursors = new Set<string>();
while (true) {
if (visitedCursors.has(scanCursor)) {
nextCursor = '0';
break;
}
visitedCursors.add(scanCursor);
const page = await scanRedisKeysPage(
config,
normalizedPattern,
scanCursor,
effectiveTargetCount
);
if (requestId !== latestLoadRequestIdRef.current) {
return;
}
scannedKeys = page.scannedKeys;
nextCursor = page.nextCursor;
if (scannedKeys.length > 0 || nextCursor === '0') {
break;
}
scanCursor = nextCursor;
}
if (append) {
setKeys(prev => mergeRedisKeyInfoLists(prev, scannedKeys));
} else {