Skip to content

Commit e10a4ca

Browse files
jchui-wdclaude
andauthored
bugfix: message panel crashes via large chat volume (#6191)
* fix: replace full message load in stats endpoint with SQL COUNT queries * fix: replace full message load in stats endpoint with SQL COUNT queries Loading all messages into memory to compute stats caused OOM crashes with large chat volumes. Replaced with parallel SQL COUNT queries that never load message rows. The feedbackTypes filter now uses a correlated subquery to avoid hitting PostgreSQL's parameter limit at high feedback volumes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feedback filters were incorrectly filtering messages with feedback of the WHOLE session, instead of just the feedback + previous message. Added code to count preceding messages as count to mimic previous functionality * fix: consolidate stats queries from 4 round-trips into 1 combined query Replace 4 separate COUNT QueryBuilders (totalMessages, totalSessions, totalFeedback, positiveFeedback) with a single query using addSelect and CASE WHEN for the positive feedback breakdown. When a feedbackTypes filter is active, reduce from 5 round-trips down to 2 (combined + preceding). Update tests to match the new single getRawOne call and aliased column names. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 42b9157 commit e10a4ca

3 files changed

Lines changed: 354 additions & 31 deletions

File tree

packages/server/src/controllers/stats/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const getChatflowStats = async (req: Request, res: Response, next: NextFunction)
99
if (typeof req.params === 'undefined' || !req.params.id) {
1010
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: statsController.getChatflowStats - id not provided!`)
1111
}
12+
const activeWorkspaceId = req.user?.activeWorkspaceId
13+
if (!activeWorkspaceId) {
14+
throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: statsController.getChatflowStats - unauthorized!`)
15+
}
1216
const chatflowid = req.params.id
1317
const _chatTypes = req.query?.chatType as string | undefined
1418
let chatTypes: ChatType[] | undefined
@@ -47,13 +51,11 @@ const getChatflowStats = async (req: Request, res: Response, next: NextFunction)
4751
}
4852
const apiResponse = await statsService.getChatflowStats(
4953
chatflowid,
54+
activeWorkspaceId,
5055
chatTypes,
5156
startDate,
5257
endDate,
53-
'',
54-
true,
55-
feedbackTypeFilters,
56-
req.user?.activeWorkspaceId
58+
feedbackTypeFilters
5759
)
5860
return res.json(apiResponse)
5961
} catch (error) {
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, it, expect, jest, beforeEach } from '@jest/globals'
2+
import { StatusCodes } from 'http-status-codes'
3+
import { ChatMessageRatingType, ChatType } from '../../Interface'
4+
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
5+
6+
jest.mock('../../utils/getRunningExpressApp', () => ({
7+
getRunningExpressApp: jest.fn()
8+
}))
9+
10+
import statsService from '.'
11+
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
12+
import { ChatFlow } from '../../database/entities/ChatFlow'
13+
14+
const mockQb: any = {
15+
select: jest.fn().mockReturnThis(),
16+
addSelect: jest.fn().mockReturnThis(),
17+
from: jest.fn().mockReturnThis(),
18+
innerJoin: jest.fn().mockReturnThis(),
19+
leftJoin: jest.fn().mockReturnThis(),
20+
where: jest.fn().mockReturnThis(),
21+
andWhere: jest.fn().mockReturnThis(),
22+
setParameter: jest.fn().mockReturnThis(),
23+
setParameters: jest.fn().mockReturnThis(),
24+
subQuery: jest.fn().mockReturnThis(),
25+
getQuery: jest.fn().mockReturnValue('(SELECT DISTINCT cm2.sessionId FROM chat_message cm2)'),
26+
getParameters: jest.fn().mockReturnValue({}),
27+
getRawOne: jest.fn(),
28+
getRawMany: jest.fn()
29+
}
30+
31+
const mockMessageRepo: any = {
32+
createQueryBuilder: jest.fn().mockReturnValue(mockQb)
33+
}
34+
35+
const mockChatFlowRepo: any = {
36+
findOneBy: jest.fn()
37+
}
38+
39+
const CHATFLOW_ID = 'cf-abc-123'
40+
const WORKSPACE_ID = 'ws-xyz-456'
41+
42+
describe('statsService.getChatflowStats', () => {
43+
beforeEach(() => {
44+
jest.clearAllMocks()
45+
;(getRunningExpressApp as jest.Mock).mockReturnValue({
46+
AppDataSource: {
47+
getRepository: jest.fn((entity: unknown) => {
48+
if (entity === ChatFlow) return mockChatFlowRepo
49+
return mockMessageRepo
50+
})
51+
}
52+
})
53+
54+
mockChatFlowRepo.findOneBy.mockResolvedValue({ id: CHATFLOW_ID } as any)
55+
mockQb.getRawOne.mockResolvedValue({ count: '0' })
56+
mockQb.getRawMany.mockResolvedValue([])
57+
mockQb.select.mockReturnThis()
58+
mockQb.addSelect.mockReturnThis()
59+
mockQb.from.mockReturnThis()
60+
mockQb.innerJoin.mockReturnThis()
61+
mockQb.leftJoin.mockReturnThis()
62+
mockQb.where.mockReturnThis()
63+
mockQb.andWhere.mockReturnThis()
64+
mockQb.setParameter.mockReturnThis()
65+
mockQb.setParameters.mockReturnThis()
66+
mockQb.subQuery.mockReturnThis()
67+
mockQb.getQuery.mockReturnValue('(SELECT DISTINCT cm2.sessionId FROM chat_message cm2)')
68+
mockQb.getParameters.mockReturnValue({})
69+
mockMessageRepo.createQueryBuilder.mockReturnValue(mockQb)
70+
})
71+
72+
describe('workspace authorization', () => {
73+
it('throws when activeWorkspaceId is not provided', async () => {
74+
await expect(
75+
statsService.getChatflowStats(CHATFLOW_ID, undefined as any, undefined, undefined, undefined, undefined)
76+
).rejects.toBeInstanceOf(InternalFlowiseError)
77+
78+
expect(mockChatFlowRepo.findOneBy).not.toHaveBeenCalled()
79+
})
80+
81+
it('throws when chatflow is not found in the workspace', async () => {
82+
mockChatFlowRepo.findOneBy.mockResolvedValue(null)
83+
84+
await expect(
85+
statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
86+
).rejects.toBeInstanceOf(InternalFlowiseError)
87+
88+
expect(mockMessageRepo.createQueryBuilder).not.toHaveBeenCalled()
89+
})
90+
91+
it('looks up chatflow with the correct workspaceId', async () => {
92+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
93+
94+
expect(mockChatFlowRepo.findOneBy).toHaveBeenCalledWith({
95+
id: CHATFLOW_ID,
96+
workspaceId: WORKSPACE_ID
97+
})
98+
})
99+
})
100+
101+
describe('no filters', () => {
102+
it('returns the correct shape with parsed integers', async () => {
103+
mockQb.getRawOne.mockResolvedValueOnce({
104+
totalMessages: '157',
105+
totalSessions: '42',
106+
totalFeedback: '10',
107+
positiveFeedback: '7'
108+
})
109+
110+
const result = await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
111+
112+
expect(result).toEqual({
113+
totalMessages: 157,
114+
totalSessions: 42,
115+
totalFeedback: 10,
116+
positiveFeedback: 7
117+
})
118+
})
119+
120+
it('defaults to 0 when getRawOne returns undefined', async () => {
121+
mockQb.getRawOne.mockResolvedValue(undefined)
122+
123+
const result = await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
124+
125+
expect(result.totalMessages).toBe(0)
126+
expect(result.totalSessions).toBe(0)
127+
expect(result.totalFeedback).toBe(0)
128+
expect(result.positiveFeedback).toBe(0)
129+
})
130+
131+
it('runs 1 QueryBuilder and getRawOne 1 time when no feedbackTypes filter is set', async () => {
132+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
133+
134+
expect(mockMessageRepo.createQueryBuilder).toHaveBeenCalledTimes(1)
135+
expect(mockQb.getRawOne).toHaveBeenCalledTimes(1)
136+
})
137+
})
138+
139+
describe('chatTypes filter', () => {
140+
it('uses In operator with the provided chatTypes', async () => {
141+
const chatTypes: ChatType[] = [ChatType.INTERNAL]
142+
143+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, chatTypes, undefined, undefined, undefined)
144+
145+
const whereArg = mockQb.where.mock.calls[0][0]
146+
expect(whereArg.chatType.type).toBe('in')
147+
expect(whereArg.chatType.value).toEqual(chatTypes)
148+
})
149+
})
150+
151+
describe('date range filter', () => {
152+
it('uses Between when both startDate and endDate are provided', async () => {
153+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, '2024-01-01', '2024-12-31', undefined)
154+
155+
const whereArg = mockQb.where.mock.calls[0][0]
156+
expect(whereArg.createdDate.type).toBe('between')
157+
})
158+
159+
it('uses MoreThanOrEqual when only startDate is provided', async () => {
160+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, '2024-01-01', undefined, undefined)
161+
162+
const whereArg = mockQb.where.mock.calls[0][0]
163+
expect(whereArg.createdDate.type).toBe('moreThanOrEqual')
164+
})
165+
166+
it('uses LessThanOrEqual when only endDate is provided', async () => {
167+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, '2024-12-31', undefined)
168+
169+
const whereArg = mockQb.where.mock.calls[0][0]
170+
expect(whereArg.createdDate.type).toBe('lessThanOrEqual')
171+
})
172+
})
173+
174+
describe('feedbackTypes filter', () => {
175+
it('returns all zeros when no sessions have qualifying feedback', async () => {
176+
mockQb.getRawOne.mockResolvedValue({ count: '0' })
177+
178+
const result = await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, [
179+
ChatMessageRatingType.THUMBS_UP
180+
])
181+
182+
expect(result).toEqual({ totalMessages: 0, totalSessions: 0, totalFeedback: 0, positiveFeedback: 0 })
183+
// 1 combinedQb + 1 precedingCountQb = 2
184+
expect(mockQb.getRawOne).toHaveBeenCalledTimes(2)
185+
})
186+
187+
it('computes totalMessages as totalFeedback + precedingCount when feedbackTypes is set', async () => {
188+
mockQb.getRawOne
189+
.mockResolvedValueOnce({ totalSessions: '42', totalFeedback: '67', positiveFeedback: '60' }) // combinedQb
190+
.mockResolvedValueOnce({ count: '57' }) // precedingCountQb
191+
192+
const result = await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, [
193+
ChatMessageRatingType.THUMBS_UP
194+
])
195+
196+
// totalMessages = totalFeedback(67) + precedingCount(57) = 124
197+
expect(result.totalMessages).toBe(124)
198+
expect(result.totalSessions).toBe(42)
199+
expect(result.totalFeedback).toBe(67)
200+
expect(result.positiveFeedback).toBe(60)
201+
// 1 combinedQb + 1 precedingCountQb = 2
202+
expect(mockQb.getRawOne).toHaveBeenCalledTimes(2)
203+
})
204+
205+
it('passes the feedbackTypes to the session subquery', async () => {
206+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, [
207+
ChatMessageRatingType.THUMBS_DOWN
208+
])
209+
210+
const feedbackCall = mockQb.andWhere.mock.calls.find((call: string[]) => call[0].includes('feedbackTypes'))
211+
expect(feedbackCall).toBeDefined()
212+
expect(feedbackCall![1]).toEqual(expect.objectContaining({ feedbackTypes: [ChatMessageRatingType.THUMBS_DOWN] }))
213+
})
214+
215+
it('appends the session subquery condition to the combined count query', async () => {
216+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, [
217+
ChatMessageRatingType.THUMBS_UP
218+
])
219+
220+
const sessionIdCalls = mockQb.andWhere.mock.calls.filter((call: string[]) => call[0].includes('cm.sessionId IN'))
221+
expect(sessionIdCalls.length).toBe(1)
222+
})
223+
})
224+
225+
describe('error handling', () => {
226+
it('wraps unexpected errors as InternalFlowiseError with 500 status', async () => {
227+
mockQb.getRawOne.mockRejectedValue(new Error('DB connection lost'))
228+
229+
let caught: any
230+
try {
231+
await statsService.getChatflowStats(CHATFLOW_ID, WORKSPACE_ID, undefined, undefined, undefined, undefined)
232+
} catch (e) {
233+
caught = e
234+
}
235+
236+
expect(caught).toBeInstanceOf(InternalFlowiseError)
237+
expect(caught.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR)
238+
expect(caught.message).toContain('statsService.getChatflowStats')
239+
})
240+
})
241+
})

0 commit comments

Comments
 (0)