Skip to content

Commit

Permalink
[LW-7834] Adds queue structure to request handle validations (#436)
Browse files Browse the repository at this point in the history
* fix: lw-7834 adds queue structure to request handle validations in address book
  • Loading branch information
VanessaPC authored Aug 31, 2023
1 parent 559e762 commit 7bd0358
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -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<any>('@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 }
});
});
});
54 changes: 30 additions & 24 deletions apps/browser-extension-wallet/src/hooks/useUpdateAddressStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,64 @@ 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<string, { isValid: boolean; error?: CustomConflictError }>;

const API_LIMIT = 5;
const API_RATE_LIMIT = 1000;

export const useUpdateAddressStatus = (
addressList: AddressBookSchema[],
handleResolver: HandleProvider
): updateAddressStatusType => {
const [validatedAddressStatus, setValidatedAddressStatus] = useState<updateAddressStatusType>({});

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;
}

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
67 changes: 67 additions & 0 deletions apps/browser-extension-wallet/src/utils/taskQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
type taskType = () => Promise<unknown>;

export interface TaskQueue {
enqueue: (task: taskType) => void;
dequeue: () => taskType;
isEmpty: () => boolean;
stop: () => void;
}

export const createQueue = (batchTasks: number, intervalBetweenBatch: number): TaskQueue => {
let tasks: Array<taskType> = [];
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
};
};

0 comments on commit 7bd0358

Please sign in to comment.