From 97612148600d207bf28294f074336b88a86c0657 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 28 Mar 2025 08:11:02 +0530 Subject: [PATCH] feat: Add debounce for meta query (#11195) This PR combines the approaches in https://github.com/chatwoot/chatwoot/pull/11190 and https://github.com/chatwoot/chatwoot/pull/11187 to debounce the meta request with a max wait time of 2.5 seconds With 500 concurrent users, the theoretical limit with this is 720K requests per minute, if all of them continuously receive websocket events. The max wait of 2.5 seconds is still very generous, and we can easily make it 2 seconds for smaller accounts and 5 seconds for larger accounts. ```js const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 200); const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000); export const actions = { get: async ({ commit, state: $state }, params) => { if ($state.allCount > 100) { longDebouncedFetchMetaData(commit, params); } else { debouncedFetchMetaData(commit, params); } }, set({ commit }, meta) { commit(types.SET_CONV_TAB_META, meta); }, }; ``` Related Utils PR: https://github.com/chatwoot/utils/pull/49 Here's the debounce in action image --------- Co-authored-by: Pranav --- .../helper/ConversationMetaThrottleManager.js | 29 ------------ .../ConversationMetaThrottleManager.spec.js | 34 -------------- .../store/modules/conversationStats.js | 47 +++++++------------ .../specs/conversationStats/actions.spec.js | 27 +++++++++-- .../specs/conversationStats/helper.spec.js | 37 --------------- package.json | 2 +- pnpm-lock.yaml | 10 ++-- 7 files changed, 45 insertions(+), 141 deletions(-) delete mode 100644 app/javascript/dashboard/helper/ConversationMetaThrottleManager.js delete mode 100644 app/javascript/dashboard/helper/specs/ConversationMetaThrottleManager.spec.js delete mode 100644 app/javascript/dashboard/store/modules/specs/conversationStats/helper.spec.js diff --git a/app/javascript/dashboard/helper/ConversationMetaThrottleManager.js b/app/javascript/dashboard/helper/ConversationMetaThrottleManager.js deleted file mode 100644 index 4f7c7a610..000000000 --- a/app/javascript/dashboard/helper/ConversationMetaThrottleManager.js +++ /dev/null @@ -1,29 +0,0 @@ -class ConversationMetaThrottleManager { - constructor() { - this.lastUpdatedTime = null; - } - - shouldThrottle(threshold = 10000) { - if (!this.lastUpdatedTime) { - return false; - } - - const currentTime = new Date().getTime(); - const lastUpdatedTime = new Date(this.lastUpdatedTime).getTime(); - - if (currentTime - lastUpdatedTime < threshold) { - return true; - } - return false; - } - - markUpdate() { - this.lastUpdatedTime = new Date(); - } - - reset() { - this.lastUpdatedTime = null; - } -} - -export default new ConversationMetaThrottleManager(); diff --git a/app/javascript/dashboard/helper/specs/ConversationMetaThrottleManager.spec.js b/app/javascript/dashboard/helper/specs/ConversationMetaThrottleManager.spec.js deleted file mode 100644 index b0c67ffaa..000000000 --- a/app/javascript/dashboard/helper/specs/ConversationMetaThrottleManager.spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import ConversationMetaThrottleManager from '../ConversationMetaThrottleManager'; - -describe('ConversationMetaThrottleManager', () => { - beforeEach(() => { - // Reset the lastUpdatedTime before each test - ConversationMetaThrottleManager.lastUpdatedTime = null; - }); - - describe('shouldThrottle', () => { - it('returns false when lastUpdatedTime is not set', () => { - expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(false); - }); - - it('returns true when time difference is less than threshold', () => { - ConversationMetaThrottleManager.markUpdate(); - expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(true); - }); - - it('returns false when time difference is more than threshold', () => { - ConversationMetaThrottleManager.lastUpdatedTime = new Date( - Date.now() - 11000 - ); - expect(ConversationMetaThrottleManager.shouldThrottle()).toBe(false); - }); - - it('respects custom threshold value', () => { - ConversationMetaThrottleManager.lastUpdatedTime = new Date( - Date.now() - 5000 - ); - expect(ConversationMetaThrottleManager.shouldThrottle(3000)).toBe(false); - expect(ConversationMetaThrottleManager.shouldThrottle(6000)).toBe(true); - }); - }); -}); diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js index 8de20370b..ef70500ef 100644 --- a/app/javascript/dashboard/store/modules/conversationStats.js +++ b/app/javascript/dashboard/store/modules/conversationStats.js @@ -1,7 +1,6 @@ import types from '../mutation-types'; import ConversationApi from '../../api/inbox/conversation'; - -import ConversationMetaThrottleManager from 'dashboard/helper/ConversationMetaThrottleManager'; +import { debounce } from '@chatwoot/utils'; const state = { mineCount: 0, @@ -13,38 +12,24 @@ export const getters = { getStats: $state => $state, }; -export const shouldThrottle = conversationCount => { - // The threshold for throttling is different for normal users and large accounts - // Normal users: 2 seconds - // Large accounts: 10 seconds - // We would only update the conversation stats based on the threshold above. - // This is done to reduce the number of /meta request made to the server. - const NORMAL_USER_THRESHOLD = 2000; - const LARGE_ACCOUNT_THRESHOLD = 10000; - - const threshold = - conversationCount > 100 ? LARGE_ACCOUNT_THRESHOLD : NORMAL_USER_THRESHOLD; - return ConversationMetaThrottleManager.shouldThrottle(threshold); +// Create a debounced version of the actual API call function +const fetchMetaData = async (commit, params) => { + try { + const response = await ConversationApi.meta(params); + const { + data: { meta }, + } = response; + commit(types.SET_CONV_TAB_META, meta); + } catch (error) { + // ignore + } }; -export const actions = { - get: async ({ commit, state: $state }, params) => { - if (shouldThrottle($state.allCount)) { - // eslint-disable-next-line no-console - console.warn('Throttle /meta fetch, will resume after threshold'); - return; - } - ConversationMetaThrottleManager.markUpdate(); +const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 2500); - try { - const response = await ConversationApi.meta(params); - const { - data: { meta }, - } = response; - commit(types.SET_CONV_TAB_META, meta); - } catch (error) { - // Ignore error - } +export const actions = { + get: async ({ commit }, params) => { + debouncedFetchMetaData(commit, params); }, set({ commit }, meta) { commit(types.SET_CONV_TAB_META, meta); diff --git a/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js index 4572387ac..43f0efb3a 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js @@ -6,22 +6,41 @@ const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +vi.mock('@chatwoot/utils', () => ({ + debounce: vi.fn(fn => { + return fn; + }), +})); + describe('#actions', () => { + beforeEach(() => { + vi.useFakeTimers(); // Set up fake timers + commit.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); // Reset to real timers after each test + }); + describe('#get', () => { it('sends correct mutations if API is success', async () => { axios.get.mockResolvedValue({ data: { meta: { mine_count: 1 } } }); - await actions.get( - { commit, state: { updatedOn: null } }, + actions.get( + { commit, state: { allCount: 0 } }, { inboxId: 1, assigneeTpe: 'me', status: 'open' } ); + + await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(commit).toHaveBeenCalled()); + expect(commit.mock.calls).toEqual([ [types.default.SET_CONV_TAB_META, { mine_count: 1 }], ]); }); it('sends correct actions if API is error', async () => { axios.get.mockRejectedValue({ message: 'Incorrect header' }); - await actions.get( - { commit, state: { updatedOn: null } }, + actions.get( + { commit, state: { allCount: 0 } }, { inboxId: 1, assigneeTpe: 'me', status: 'open' } ); expect(commit.mock.calls).toEqual([]); diff --git a/app/javascript/dashboard/store/modules/specs/conversationStats/helper.spec.js b/app/javascript/dashboard/store/modules/specs/conversationStats/helper.spec.js deleted file mode 100644 index eb07fd1c8..000000000 --- a/app/javascript/dashboard/store/modules/specs/conversationStats/helper.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import ConversationMetaThrottleManager from 'dashboard/helper/ConversationMetaThrottleManager'; -import { shouldThrottle } from '../../conversationStats'; - -vi.mock('dashboard/helper/ConversationMetaThrottleManager', () => ({ - default: { - shouldThrottle: vi.fn(), - }, -})); - -describe('shouldThrottle', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('uses normal threshold for accounts with 100 or fewer conversations', () => { - shouldThrottle(100); - expect(ConversationMetaThrottleManager.shouldThrottle).toHaveBeenCalledWith( - 2000 - ); - }); - - it('uses large account threshold for accounts with more than 100 conversations', () => { - shouldThrottle(101); - expect(ConversationMetaThrottleManager.shouldThrottle).toHaveBeenCalledWith( - 10000 - ); - }); - - it('returns the throttle value from ConversationMetaThrottleManager', () => { - ConversationMetaThrottleManager.shouldThrottle.mockReturnValue(true); - expect(shouldThrottle(50)).toBe(true); - - ConversationMetaThrottleManager.shouldThrottle.mockReturnValue(false); - expect(shouldThrottle(150)).toBe(false); - }); -}); diff --git a/package.json b/package.json index 9e5bab631..6d7300667 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.1.1-next", - "@chatwoot/utils": "^0.0.41", + "@chatwoot/utils": "^0.0.42", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6749103a8..b32dc7521 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.1.1-next version: 1.1.1-next '@chatwoot/utils': - specifier: ^0.0.41 - version: 0.0.41 + specifier: ^0.0.42 + version: 0.0.42 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -406,8 +406,8 @@ packages: '@chatwoot/prosemirror-schema@1.1.1-next': resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==} - '@chatwoot/utils@0.0.41': - resolution: {integrity: sha512-f0D+XArVYbc9m9M7KZpCaVJ+EUVzobX+D9P5Vt/h2jUipg706GoBhGwsP8kjfWhUdNdcS+H+OB4ZCKGF1NIkTQ==} + '@chatwoot/utils@0.0.42': + resolution: {integrity: sha512-TrEywcG1zjgBScVrQla7GMJwXsbLyc5u/verm/LbLrGxizU2NcNoJecRvJOUgL65kYVEtcO9//+gIDswwqnt6g==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5250,7 +5250,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.41': + '@chatwoot/utils@0.0.42': dependencies: date-fns: 2.30.0