From 7bd0358cbc071c7a6dbf5ed794117ec8239c2ed0 Mon Sep 17 00:00:00 2001 From: Vanessa Rodriguez Cristobal Date: Thu, 31 Aug 2023 13:04:26 +0100 Subject: [PATCH] [LW-7834] Adds queue structure to request handle validations (#436) * fix: lw-7834 adds queue structure to request handle validations in address book --- .../__tests__/useUpdateAddressStatus.test.ts | 71 ++++++++++++++ .../src/hooks/useUpdateAddressStatus.ts | 54 ++++++----- .../src/utils/__tests__/createQueue.test.ts | 92 +++++++++++++++++++ .../src/utils/taskQueue.ts | 67 ++++++++++++++ 4 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 apps/browser-extension-wallet/src/hooks/__tests__/useUpdateAddressStatus.test.ts create mode 100644 apps/browser-extension-wallet/src/utils/__tests__/createQueue.test.ts create mode 100644 apps/browser-extension-wallet/src/utils/taskQueue.ts diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useUpdateAddressStatus.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useUpdateAddressStatus.test.ts new file mode 100644 index 0000000000..72e8401d90 --- /dev/null +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useUpdateAddressStatus.test.ts @@ -0,0 +1,71 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useUpdateAddressStatus } from '../useUpdateAddressStatus'; +import { CustomConflictError, ensureHandleOwnerHasntChanged } from '@src/utils/validators'; +import { Asset, Cardano, HandleProvider } from '@cardano-sdk/core'; + +jest.mock('@src/utils/validators', () => ({ + ...jest.requireActual('@src/utils/validators'), + ensureHandleOwnerHasntChanged: jest.fn() +})); + +const mockHandleResolution = { + backgroundImage: Asset.Uri('ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd'), + cardanoAddress: Cardano.PaymentAddress( + 'addr_test1qzrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuql9tk0g' + ), + handle: 'bob', + hasDatum: false, + image: Asset.Uri('ipfs://c8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe'), + policyId: Cardano.PolicyId('50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb'), + profilePic: Asset.Uri('ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd1') +}; + +describe('useUpdateAddressStatus', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + const mockHandleResolver = { + resolveHandles: jest.fn(), + healthCheck: jest.fn(), + getPolicyIds: jest.fn() + } as HandleProvider; + const addressList = [ + { id: 1, name: 'one', address: 'address1', handleResolution: mockHandleResolution, network: 1 }, + { id: 2, name: 'two', address: 'address2', handleResolution: mockHandleResolution, network: 1 } + ]; + + it('sets addresses to valid when handles resolve correctly', async () => { + (ensureHandleOwnerHasntChanged as jest.Mock).mockResolvedValue(true); + const { result, waitForNextUpdate } = renderHook(() => useUpdateAddressStatus(addressList, mockHandleResolver)); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + address1: { isValid: true }, + address2: { isValid: true } + }); + }); + + it('sets an address as invalid if a handle resolves with an error', async () => { + const handleError = new CustomConflictError({ + message: 'Unexpected values', + expectedAddress: Cardano.PaymentAddress( + 'addr_test1qzrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuql9tk0g' + ), + actualAddress: Cardano.PaymentAddress( + 'addr_test1qzrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuql9tk0g' + ) + }); + + (ensureHandleOwnerHasntChanged as jest.Mock).mockResolvedValueOnce(true).mockRejectedValueOnce(handleError); + + const { result, waitForNextUpdate } = renderHook(() => useUpdateAddressStatus(addressList, mockHandleResolver)); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + address1: { isValid: true }, + address2: { isValid: false, error: handleError } + }); + }); +}); diff --git a/apps/browser-extension-wallet/src/hooks/useUpdateAddressStatus.ts b/apps/browser-extension-wallet/src/hooks/useUpdateAddressStatus.ts index ba22c496da..f9407622ca 100644 --- a/apps/browser-extension-wallet/src/hooks/useUpdateAddressStatus.ts +++ b/apps/browser-extension-wallet/src/hooks/useUpdateAddressStatus.ts @@ -2,12 +2,10 @@ import { HandleProvider, Cardano } from '@cardano-sdk/core'; import { AddressBookSchema } from '@lib/storage'; import { CustomConflictError, ensureHandleOwnerHasntChanged } from '@src/utils/validators'; import { useEffect, useState } from 'react'; +import { createQueue } from '@src/utils/taskQueue'; type updateAddressStatusType = Record; -const API_LIMIT = 5; -const API_RATE_LIMIT = 1000; - export const useUpdateAddressStatus = ( addressList: AddressBookSchema[], handleResolver: HandleProvider @@ -15,14 +13,19 @@ export const useUpdateAddressStatus = ( const [validatedAddressStatus, setValidatedAddressStatus] = useState({}); useEffect(() => { - const interval = 10_000; + const queueInterval = 10_000; + const batchTasks = 4; + const intervalBetweenBatch = 100; + + const queue = createQueue(batchTasks, intervalBetweenBatch); const updateAddressStatus = (address: string, status: { isValid: boolean; error?: CustomConflictError }) => { - setValidatedAddressStatus((currentValidatedAddressStatus) => ({ + setValidatedAddressStatus((currentValidatedAddressStatus: updateAddressStatusType) => ({ ...currentValidatedAddressStatus, [address]: status })); }; + const validateAddresses = async () => { if (!addressList) { return; @@ -30,30 +33,33 @@ export const useUpdateAddressStatus = ( const handleList = addressList.filter((item) => !Cardano.isAddress(item.address)); - for (const [i, item] of handleList?.entries()) { - try { - await ensureHandleOwnerHasntChanged({ - handleResolution: item.handleResolution, - handleResolver - }); - updateAddressStatus(item.address, { isValid: true }); - } catch (error) { - if (error instanceof CustomConflictError) { - updateAddressStatus(item.address, { isValid: false, error }); + for (const item of handleList) { + queue.enqueue(async () => { + try { + await ensureHandleOwnerHasntChanged({ + handleResolution: item.handleResolution, + handleResolver + }); + updateAddressStatus(item.address, { isValid: true }); + } catch (error) { + if (error instanceof CustomConflictError || error.message === 'Handle not found') { + updateAddressStatus(item.address, { isValid: false, error }); + } } - } - - if ((i + 1) % API_LIMIT === 0) { - // This pauses the execution of the for loop so we don't get rate limited. - // eslint-disable-next-line promise/avoid-new - await new Promise((resolve) => setTimeout(resolve, API_RATE_LIMIT)); - } + }); } }; + validateAddresses(); - const intervalId = setInterval(validateAddresses, interval); + const intervalId = setInterval(() => { + queue.stop(); + validateAddresses(); + }, queueInterval); - return () => clearInterval(intervalId); + return () => { + queue.stop(); + clearInterval(intervalId); + }; }, [addressList, handleResolver]); return validatedAddressStatus; diff --git a/apps/browser-extension-wallet/src/utils/__tests__/createQueue.test.ts b/apps/browser-extension-wallet/src/utils/__tests__/createQueue.test.ts new file mode 100644 index 0000000000..758afeaf20 --- /dev/null +++ b/apps/browser-extension-wallet/src/utils/__tests__/createQueue.test.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable promise/avoid-new */ +import { createQueue, TaskQueue } from '../taskQueue'; + +describe('createQueue', () => { + let queue: TaskQueue; + const batchTasks = 4; + const intervalBetweenBatch = 100; + beforeEach(() => { + queue = createQueue(batchTasks, intervalBetweenBatch); + }); + + it('should enqueue and dequeue tasks correctly', async () => { + const mockTask = jest.fn(); + // The first task will be picked up and executed immediately + queue.enqueue(mockTask); + queue.enqueue(mockTask); + + expect(queue.isEmpty()).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(queue.isEmpty()).toBe(true); + }); + + it('should drain the queue after stopping', () => { + const mockTask = jest.fn(); + queue.enqueue(mockTask); + queue.stop(); + expect(queue.isEmpty()).toBe(true); + }); + + it('should execute tasks with rate limiting', async () => { + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + const mockTask3 = jest.fn(); + const mockTask4 = jest.fn(); + const mockTask5 = jest.fn(); + const mockTask6 = jest.fn(); + + queue.enqueue(mockTask1); + queue.enqueue(mockTask2); + queue.enqueue(mockTask3); + queue.enqueue(mockTask4); + queue.enqueue(mockTask5); + queue.enqueue(mockTask6); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).toHaveBeenCalled(); + expect(mockTask3).toHaveBeenCalled(); + expect(mockTask4).toHaveBeenCalled(); + + expect(mockTask5).not.toHaveBeenCalled(); + expect(mockTask6).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, intervalBetweenBatch)); + + expect(mockTask5).toHaveBeenCalled(); + expect(mockTask6).toHaveBeenCalled(); + }); + + it('should handle stopping the queue', async () => { + const mockTask1 = jest.fn(); + const mockTask2 = jest.fn(); + const mockTask3 = jest.fn(); + const mockTask4 = jest.fn(); + const mockTask5 = jest.fn(); + const mockTask6 = jest.fn(); + + queue.enqueue(mockTask1); + queue.enqueue(mockTask2); + queue.enqueue(mockTask3); + queue.enqueue(mockTask4); + queue.enqueue(mockTask5); + queue.enqueue(mockTask6); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockTask1).toHaveBeenCalled(); + expect(mockTask2).toHaveBeenCalled(); + expect(mockTask3).toHaveBeenCalled(); + expect(mockTask4).toHaveBeenCalled(); + + queue.stop(); + + await new Promise((resolve) => setTimeout(resolve, intervalBetweenBatch)); + + expect(mockTask5).not.toHaveBeenCalled(); + expect(mockTask6).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/browser-extension-wallet/src/utils/taskQueue.ts b/apps/browser-extension-wallet/src/utils/taskQueue.ts new file mode 100644 index 0000000000..67bdfacf9c --- /dev/null +++ b/apps/browser-extension-wallet/src/utils/taskQueue.ts @@ -0,0 +1,67 @@ +type taskType = () => Promise; + +export interface TaskQueue { + enqueue: (task: taskType) => void; + dequeue: () => taskType; + isEmpty: () => boolean; + stop: () => void; +} + +export const createQueue = (batchTasks: number, intervalBetweenBatch: number): TaskQueue => { + let tasks: Array = []; + let isRunning = false; + let sent = 0; + + const stop = () => { + sent = 0; + isRunning = false; + tasks = []; + }; + + const isEmpty = () => tasks.length === 0; + + const dequeue = (): taskType | undefined => tasks.shift(); + + const execute = async () => { + if (!isEmpty()) { + const task = dequeue(); + + if (!task) { + return; + } + + await task(); + sent++; + + if (isRunning && !isEmpty()) { + if (sent >= batchTasks) { + // Reset the sent count + sent = 0; + // eslint-disable-next-line promise/avoid-new + await new Promise((resolve) => setTimeout(resolve, intervalBetweenBatch)); + } + execute(); + } + + if (isEmpty()) { + stop(); + } + } + }; + + const enqueue = (item: taskType) => { + tasks.push(item); + + if (!isRunning) { + isRunning = true; + execute(); + } + }; + + return { + enqueue, + dequeue, + isEmpty, + stop + }; +};