fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)

Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206
This commit is contained in:
Classic298
2026-05-09 04:11:52 +09:00
committed by GitHub
parent 6082e1adae
commit 55d1db1f38
2 changed files with 78 additions and 24 deletions
+39 -4
View File
@@ -676,11 +676,46 @@ async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSessio
# GetChats
############################
CHAT_EXPORT_BATCH_SIZE = 100
@router.get('/all', response_model=list[ChatResponse])
async def get_user_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
result = await Chats.get_chats_by_user_id(user.id, db=db)
return [ChatResponse(**chat.model_dump()) for chat in result.items]
async def generate_chat_export_ndjson(user_id: str):
"""
Async generator that streams all user chats as NDJSON (one JSON object per line).
Uses short-lived DB sessions per batch to avoid holding locks for the
entire duration, which is critical for SQLite environments.
"""
skip = 0
while True:
result = await Chats.get_chats_by_user_id(
user_id,
skip=skip,
limit=CHAT_EXPORT_BATCH_SIZE,
db=None,
)
if not result.items:
break
for chat in result.items:
try:
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
except Exception as e:
log.exception(f'Error serializing chat {chat.id}: {e}')
if len(result.items) < CHAT_EXPORT_BATCH_SIZE:
break
skip += CHAT_EXPORT_BATCH_SIZE
@router.get('/all')
async def get_user_chats(user=Depends(get_verified_user)):
return StreamingResponse(
generate_chat_export_ndjson(user.id),
media_type='application/x-ndjson',
)
############################
+39 -20
View File
@@ -301,34 +301,53 @@ export const getSharedChatList = async (token: string = '', page: number = 1, fi
};
export const getAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: 'application/x-ndjson',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
});
if (error) {
throw error;
if (!res.ok) {
const err = await res.json();
console.error(err);
throw err;
}
return res;
const reader = res.body?.getReader();
if (!reader) {
throw new Error('Response body is not readable');
}
const decoder = new TextDecoder();
const chats: object[] = [];
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
chats.push(JSON.parse(trimmed));
}
}
}
// Process any remaining data in the buffer
const remaining = buffer.trim();
if (remaining) {
chats.push(JSON.parse(remaining));
}
return chats;
};
export const getChatListBySearchText = async (token: string, text: string, page: number = 1) => {