mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 16:23:27 +08:00
## 关联 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:
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user