From 55d1db1f3818bd5513d4a8d6a571eb7207856adb Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 8 May 2026 21:11:52 +0200 Subject: [PATCH] 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 --- backend/open_webui/routers/chats.py | 43 +++++++++++++++++++-- src/lib/apis/chats/index.ts | 59 +++++++++++++++++++---------- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 15a2876b16..9862b1edf5 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -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', + ) ############################ diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 028371386c..48f1a4812d 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -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) => {