Skip to content

Commit 5a8c407

Browse files
authored
Improve /all-chats performance by replacing Redis SCAN with per-user chat index (#1618)
Fixes OPS-3001. This change will require a migration script to move all chatIds to respective user index. ## Additional Notes ### Why /all-chats was slow - Redis SCAN is O(n) and requires multiple passes. - It scans all keys, not only chat keys. - Each chat key triggered a separate GET, causing N+1 calls. ### How it’s now improved - Each user has a dedicated Redis Set storing their chat IDs. - Lookup becomes O(1) via SMEMBERS.
1 parent fc69b5c commit 5a8c407

3 files changed

Lines changed: 68 additions & 20 deletions

File tree

packages/server/api/src/app/ai/chat/ai-chat.service.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export type MCPChatContext = {
5858
model?: string;
5959
};
6060

61+
type ChatSummary = { chatId: string; chatName: string };
62+
6163
export const generateChatId = (
6264
params: MCPChatContext & {
6365
userId: string;
@@ -122,6 +124,10 @@ export const updateChatName = async (
122124
await createChatContext(chatId, userId, projectId, updatedChatContext);
123125
};
124126

127+
const userChatsIndexKey = (userId: string, projectId: string): string => {
128+
return `${projectId}:${userId}:chats`;
129+
};
130+
125131
export const createChatContext = async (
126132
chatId: string,
127133
userId: string,
@@ -136,6 +142,8 @@ export const createChatContext = async (
136142
context,
137143
chatExpireTime,
138144
);
145+
const indexKey = userChatsIndexKey(userId, projectId);
146+
await cacheWrapper.addToSet(indexKey, chatId);
139147
};
140148

141149
export const getChatContext = async (
@@ -176,28 +184,27 @@ export const getAllChats = async (
176184
userId: string,
177185
projectId: string,
178186
): Promise<{ chatId: string; chatName: string }[]> => {
179-
const pattern = `${projectId}:${userId}:*:context`;
180-
const keys = await cacheWrapper.scanKeys(pattern);
181-
const chats: { chatId: string; chatName: string }[] = [];
182-
183-
for (const key of keys) {
184-
const keyParts = key.split(':');
185-
if (keyParts.length !== 4) {
186-
continue;
187-
}
188-
const longChatId = keyParts[2];
189-
190-
const context = await cacheWrapper.getSerializedObject<MCPChatContext>(key);
191-
192-
if (context?.chatName) {
193-
chats.push({
194-
chatId: longChatId,
195-
chatName: context.chatName,
196-
});
197-
}
187+
const indexKey = userChatsIndexKey(userId, projectId);
188+
const chatIds = await cacheWrapper.getSetMembers(indexKey);
189+
190+
if (chatIds.length === 0) {
191+
return [];
198192
}
199193

200-
return chats;
194+
const chatsOrNull = await Promise.all(
195+
chatIds.map(async (chatId): Promise<ChatSummary | null> => {
196+
const context = await getChatContext(chatId, userId, projectId);
197+
if (!context?.chatName) {
198+
return null;
199+
}
200+
return {
201+
chatId,
202+
chatName: context.chatName,
203+
};
204+
}),
205+
);
206+
207+
return chatsOrNull.filter((chat): chat is ChatSummary => chat !== null);
201208
};
202209

203210
export const saveChatHistory = async (
@@ -221,10 +228,12 @@ export const deleteChatHistory = async (
221228
userId: string,
222229
projectId: string,
223230
): Promise<void> => {
231+
const indexKey = userChatsIndexKey(userId, projectId);
224232
await Promise.all([
225233
cacheWrapper.deleteKey(chatHistoryKey(chatId, userId, projectId)),
226234
cacheWrapper.deleteKey(chatToolsKey(chatId, userId, projectId)),
227235
cacheWrapper.deleteKey(chatContextKey(chatId, userId, projectId)),
236+
cacheWrapper.removeFromSet(indexKey, chatId),
228237
]);
229238
};
230239

packages/server/shared/src/lib/cache/memory-wrapper.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ const getBufferAndDelete = async (key: string): Promise<Buffer | null> => {
7373
throw new Error('Not implemented');
7474
};
7575

76+
const addToSet = async (key: string, member: string): Promise<void> => {
77+
const existing = (await getSerializedObject<string[]>(key)) ?? [];
78+
if (!existing.includes(member)) {
79+
existing.push(member);
80+
}
81+
await setSerializedObject(key, existing);
82+
};
83+
84+
const removeFromSet = async (key: string, member: string): Promise<void> => {
85+
const existing = (await getSerializedObject<string[]>(key)) ?? [];
86+
const updated = existing.filter((id) => id !== member);
87+
await setSerializedObject(key, updated);
88+
};
89+
90+
const getSetMembers = async (key: string): Promise<string[]> => {
91+
return (await getSerializedObject<string[]>(key)) ?? [];
92+
};
93+
7694
export const memoryWrapper = {
7795
setKey,
7896
getKey,
@@ -84,4 +102,7 @@ export const memoryWrapper = {
84102
setSerializedObject,
85103
getSerializedObject,
86104
scanKeys,
105+
addToSet,
106+
removeFromSet,
107+
getSetMembers,
87108
};

packages/server/shared/src/lib/cache/redis-wrapper.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ async function setSerializedObject<T>(
5959
await setKey(key, JSON.stringify(obj), expireInSeconds);
6060
}
6161

62+
const addToSet = async (key: string, member: string): Promise<void> => {
63+
const redis = getRedisClient();
64+
await redis.sadd(key, member);
65+
};
66+
67+
const removeFromSet = async (key: string, member: string): Promise<void> => {
68+
const redis = getRedisClient();
69+
await redis.srem(key, member);
70+
};
71+
72+
const getSetMembers = async (key: string): Promise<string[]> => {
73+
const redis = getRedisClient();
74+
return redis.smembers(key);
75+
};
76+
6277
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6378
async function getOrAdd<T, Args extends any[]>(
6479
key: string,
@@ -134,4 +149,7 @@ export const redisWrapper = {
134149
setSerializedObject,
135150
getSerializedObject,
136151
scanKeys,
152+
addToSet,
153+
removeFromSet,
154+
getSetMembers,
137155
};

0 commit comments

Comments
 (0)