Merge pull request #588 from mirumee/fix/task-queue

Handle task failure and fix task duplication
This commit is contained in:
Dominik Żegleń 2020-07-06 19:05:15 +02:00 committed by GitHub
commit 9a7c8553e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 146 additions and 65 deletions

View file

@ -58,6 +58,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Handle untracked products - #523 by @dominik-zeglen - Handle untracked products - #523 by @dominik-zeglen
- Display correct error if there were no graphql errors - #525 by @dominik-zeglen - Display correct error if there were no graphql errors - #525 by @dominik-zeglen
- Add background task manager - #574 by @dominik-zeglen - Add background task manager - #574 by @dominik-zeglen
- Handle task failure and fix task duplication - #588 by @dominik-zeglen
## 2.0.0 ## 2.0.0

6
package-lock.json generated
View file

@ -16014,6 +16014,12 @@
} }
} }
}, },
"mock-apollo-client": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/mock-apollo-client/-/mock-apollo-client-0.4.0.tgz",
"integrity": "sha512-cHznpkX8uUClkWWJMpgdDWzEgjacM85xt69S9gPLrssM8Vahas0QmEJkFUycrRQyBkaqxvRe58Bg3a5pOvj2zA==",
"dev": true
},
"moment": { "moment": {
"version": "2.24.0", "version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",

View file

@ -131,6 +131,7 @@
"jest": "^24.8.0", "jest": "^24.8.0",
"jest-file": "^1.0.0", "jest-file": "^1.0.0",
"lint-staged": "^9.4.2", "lint-staged": "^9.4.2",
"mock-apollo-client": "^0.4.0",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.12.0", "react-test-renderer": "^16.12.0",

View file

@ -1,22 +1,34 @@
import { renderHook } from "@testing-library/react-hooks"; import { renderHook } from "@testing-library/react-hooks";
import { createMockClient } from "mock-apollo-client";
import { import {
backgroundTasksRefreshTime, backgroundTasksRefreshTime,
useBackgroundTasks useBackgroundTasks
} from "./BackgroundTasksProvider"; } from "./BackgroundTasksProvider";
import { Task, TaskData } from "./types"; import { OnCompletedTaskData, Task, TaskData, TaskStatus } from "./types";
jest.useFakeTimers(); jest.useFakeTimers();
function renderBackgroundTasks() {
const mockClient = createMockClient();
const intl = {
formatMessage: ({ defaultMessage }) => defaultMessage
};
return renderHook(() =>
useBackgroundTasks(mockClient, jest.fn(), intl as any)
);
}
describe("Background task provider", () => { describe("Background task provider", () => {
it("can queue a task", done => { it("can queue a task", done => {
const handle = jest.fn<Promise<boolean>, []>( const handle = jest.fn<Promise<TaskStatus>, []>(
() => new Promise(resolve => resolve(true)) () => new Promise(resolve => resolve(TaskStatus.SUCCESS))
); );
const onCompleted = jest.fn(); const onCompleted = jest.fn();
const onError = jest.fn(); const onError = jest.fn();
const { result } = renderHook(useBackgroundTasks); const { result } = renderBackgroundTasks();
const taskId = result.current.queue(Task.CUSTOM, { const taskId = result.current.queue(Task.CUSTOM, {
handle, handle,
@ -24,23 +36,23 @@ describe("Background task provider", () => {
onError onError
}); });
expect(taskId).toBe(1); expect(taskId).toBe(1);
expect(handle).not.toHaveBeenCalled(); expect(handle).toHaveBeenCalledTimes(0);
expect(onCompleted).not.toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalledTimes(0);
expect(onError).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledTimes(0);
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
setImmediate(() => { setImmediate(() => {
expect(handle).toHaveBeenCalled(); expect(handle).toHaveBeenCalledTimes(1);
expect(onCompleted).toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledTimes(0);
done(); done();
}); });
}); });
it("can handle task error", done => { it("can handle task error", done => {
const handle = jest.fn<Promise<boolean>, []>( const handle = jest.fn<Promise<TaskStatus>, []>(
() => () =>
new Promise(() => { new Promise(() => {
throw new Error("dummy error"); throw new Error("dummy error");
@ -49,7 +61,7 @@ describe("Background task provider", () => {
const onCompleted = jest.fn(); const onCompleted = jest.fn();
const onError = jest.fn(); const onError = jest.fn();
const { result } = renderHook(useBackgroundTasks); const { result } = renderBackgroundTasks();
result.current.queue(Task.CUSTOM, { result.current.queue(Task.CUSTOM, {
handle, handle,
@ -60,9 +72,36 @@ describe("Background task provider", () => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
setImmediate(() => { setImmediate(() => {
expect(handle).toHaveBeenCalled(); expect(handle).toHaveBeenCalledTimes(1);
expect(onCompleted).not.toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalledTimes(0);
expect(onError).toHaveBeenCalled(); expect(onError).toHaveBeenCalledTimes(1);
done();
});
});
it("can handle task failure", done => {
const handle = jest.fn<Promise<TaskStatus>, []>(
() => new Promise(resolve => resolve(TaskStatus.FAILURE))
);
const onCompleted = jest.fn((data: OnCompletedTaskData) =>
expect(data.status).toBe(TaskStatus.FAILURE)
);
const onError = jest.fn();
const { result } = renderBackgroundTasks();
result.current.queue(Task.CUSTOM, {
handle,
onCompleted,
onError
});
jest.runOnlyPendingTimers();
setImmediate(() => {
expect(handle).toHaveBeenCalledTimes(1);
expect(onCompleted).toHaveBeenCalledTimes(1);
done(); done();
}); });
@ -71,10 +110,10 @@ describe("Background task provider", () => {
it("can cancel task", done => { it("can cancel task", done => {
const onCompleted = jest.fn(); const onCompleted = jest.fn();
const { result } = renderHook(useBackgroundTasks); const { result } = renderBackgroundTasks();
const taskId = result.current.queue(Task.CUSTOM, { const taskId = result.current.queue(Task.CUSTOM, {
handle: () => new Promise(resolve => resolve(true)), handle: () => new Promise(resolve => resolve(TaskStatus.SUCCESS)),
onCompleted onCompleted
}); });
@ -85,51 +124,65 @@ describe("Background task provider", () => {
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
setImmediate(() => { setImmediate(() => {
expect(onCompleted).not.toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalledTimes(0);
done(); done();
}); });
}); });
it("can queue multiple tasks", done => { it("can queue multiple tasks", done => {
const responses: Array<Promise<boolean>> = [ let cycle = 0;
new Promise(resolve =>
setTimeout(() => resolve(true), backgroundTasksRefreshTime * 1.4) // Completed in two cycles
const shortTask = {
handle: jest.fn(() =>
Promise.resolve(cycle > 1 ? TaskStatus.SUCCESS : TaskStatus.PENDING)
), ),
new Promise(resolve =>
setTimeout(() => resolve(true), backgroundTasksRefreshTime * 2.1)
)
];
const tasks: TaskData[] = responses.map(response => ({
handle: () => response,
onCompleted: jest.fn() onCompleted: jest.fn()
})); };
const { result } = renderHook(useBackgroundTasks); // Completed in three cycles
const longTask = {
handle: jest.fn(() =>
Promise.resolve(cycle > 2 ? TaskStatus.SUCCESS : TaskStatus.PENDING)
),
onCompleted: jest.fn()
};
const tasks: TaskData[] = [shortTask, longTask];
const { result } = renderBackgroundTasks();
tasks.forEach(task => result.current.queue(Task.CUSTOM, task)); tasks.forEach(task => result.current.queue(Task.CUSTOM, task));
// Set time to backgroundTasksRefreshTime // Set time to backgroundTasksRefreshTime
cycle += 1;
jest.advanceTimersByTime(backgroundTasksRefreshTime + 100); jest.advanceTimersByTime(backgroundTasksRefreshTime + 100);
setImmediate(() => { setImmediate(() => {
expect(tasks[0].onCompleted).not.toHaveBeenCalled(); expect(shortTask.handle).toHaveBeenCalledTimes(1);
expect(tasks[1].onCompleted).not.toHaveBeenCalled(); expect(longTask.handle).toHaveBeenCalledTimes(1);
expect(shortTask.onCompleted).toHaveBeenCalledTimes(0);
expect(longTask.onCompleted).toHaveBeenCalledTimes(0);
// Set time to backgroundTasksRefreshTime * 2 // Set time to backgroundTasksRefreshTime * 2
cycle += 1;
jest.advanceTimersByTime(backgroundTasksRefreshTime); jest.advanceTimersByTime(backgroundTasksRefreshTime);
setImmediate(() => { setImmediate(() => {
expect(tasks[0].onCompleted).toHaveBeenCalled(); expect(shortTask.handle).toHaveBeenCalledTimes(2);
expect(tasks[1].onCompleted).not.toHaveBeenCalled(); expect(longTask.handle).toHaveBeenCalledTimes(2);
expect(shortTask.onCompleted).toHaveBeenCalledTimes(1);
expect(longTask.onCompleted).toHaveBeenCalledTimes(0);
// Set time to backgroundTasksRefreshTime * 3 // Set time to backgroundTasksRefreshTime * 3
cycle += 1;
jest.advanceTimersByTime(backgroundTasksRefreshTime); jest.advanceTimersByTime(backgroundTasksRefreshTime);
setImmediate(() => { setImmediate(() => {
expect(tasks[0].onCompleted).toHaveBeenCalled(); expect(shortTask.handle).toHaveBeenCalledTimes(2);
expect(tasks[1].onCompleted).toHaveBeenCalled(); expect(longTask.handle).toHaveBeenCalledTimes(3);
expect(shortTask.onCompleted).toHaveBeenCalledTimes(1);
expect(longTask.onCompleted).toHaveBeenCalledTimes(1);
done(); done();
}); });

View file

@ -1,4 +1,9 @@
import { IMessageContext } from "@saleor/components/messages";
import useNotifier from "@saleor/hooks/useNotifier";
import ApolloClient from "apollo-client";
import React from "react"; import React from "react";
import { useApolloClient } from "react-apollo";
import { IntlShape, useIntl } from "react-intl";
import BackgroundTasksContext from "./context"; import BackgroundTasksContext from "./context";
import { handleTask, queueCustom } from "./tasks"; import { handleTask, queueCustom } from "./tasks";
@ -6,31 +11,35 @@ import { QueuedTask, Task, TaskData, TaskStatus } from "./types";
export const backgroundTasksRefreshTime = 15 * 1000; export const backgroundTasksRefreshTime = 15 * 1000;
export function useBackgroundTasks() { // TODO: Remove underscores when working on #575 or similar PR
export function useBackgroundTasks(
_apolloClient: ApolloClient<any>,
_notify: IMessageContext,
_intl: IntlShape
) {
const idCounter = React.useRef(0); const idCounter = React.useRef(0);
const tasks = React.useRef<QueuedTask[]>([]); const tasks = React.useRef<QueuedTask[]>([]);
React.useEffect(() => { React.useEffect(() => {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
const queue = async () => { const queue = async () => {
tasks.current = tasks.current.filter(
task => task.status !== TaskStatus.ENDED
);
try { try {
await Promise.all( await Promise.all(
tasks.current.map(async task => { tasks.current.map(async task => {
let hasFinished: boolean; if (task.status === TaskStatus.PENDING) {
let status: TaskStatus;
try { try {
hasFinished = await handleTask(task); status = await handleTask(task);
} catch (error) { } catch (error) {
throw error; throw error;
} }
if (hasFinished) { if (status !== TaskStatus.PENDING) {
const taskIndex = tasks.current.findIndex( const taskIndex = tasks.current.findIndex(
t => t.id === task.id t => t.id === task.id
); );
tasks.current[taskIndex].status = TaskStatus.ENDED; tasks.current[taskIndex].status = status;
}
} }
}) })
); );
@ -51,10 +60,10 @@ export function useBackgroundTasks() {
function queue(type: Task, data?: TaskData) { function queue(type: Task, data?: TaskData) {
idCounter.current += 1; idCounter.current += 1;
switch (type) { switch (type) {
case Task.CUSTOM: case Task.CUSTOM:
queueCustom(idCounter.current, tasks, data); queueCustom(idCounter.current, tasks, data);
break;
} }
return idCounter.current; return idCounter.current;
@ -67,7 +76,10 @@ export function useBackgroundTasks() {
} }
const BackgroundTasksProvider: React.FC = ({ children }) => { const BackgroundTasksProvider: React.FC = ({ children }) => {
const { cancel, queue } = useBackgroundTasks(); const apolloClient = useApolloClient();
const notify = useNotifier();
const intl = useIntl();
const { cancel, queue } = useBackgroundTasks(apolloClient, notify, intl);
return ( return (
<BackgroundTasksContext.Provider <BackgroundTasksContext.Provider

View file

@ -1,17 +1,19 @@
import { QueuedTask, TaskData, TaskStatus } from "./types"; import { QueuedTask, TaskData, TaskStatus } from "./types";
export async function handleTask(task: QueuedTask): Promise<boolean> { export async function handleTask(task: QueuedTask): Promise<TaskStatus> {
let ok = false; let status = TaskStatus.PENDING;
try { try {
ok = await task.handle(); status = await task.handle();
if (ok) { if (status !== TaskStatus.PENDING) {
task.onCompleted(); task.onCompleted({
status
});
} }
} catch (error) { } catch (error) {
task.onError(error); task.onError(error);
} }
return ok; return status;
} }
export function handleError(error: Error) { export function handleError(error: Error) {
@ -28,7 +30,6 @@ export function queueCustom(
.forEach(field => { .forEach(field => {
throw new Error(`${field} is required when creating custom task`); throw new Error(`${field} is required when creating custom task`);
}); });
tasks.current = [ tasks.current = [
...tasks.current, ...tasks.current,
{ {

View file

@ -2,21 +2,28 @@ export enum Task {
CUSTOM CUSTOM
} }
export enum TaskStatus { export enum TaskStatus {
FAILURE,
PENDING, PENDING,
ENDED SUCCESS
} }
export interface OnCompletedTaskData {
status: TaskStatus;
}
export type OnCompletedTaskFn = (data: OnCompletedTaskData) => void;
export interface QueuedTask { export interface QueuedTask {
id: number; id: number;
handle: () => Promise<boolean>; handle: () => Promise<TaskStatus>;
status: TaskStatus; status: TaskStatus;
onCompleted: () => void; onCompleted: OnCompletedTaskFn;
onError: (error: Error) => void; onError: (error: Error) => void;
} }
export interface TaskData { export interface TaskData {
handle?: () => Promise<boolean>; id?: string;
onCompleted?: () => void; handle?: () => Promise<TaskStatus>;
onCompleted?: OnCompletedTaskFn;
onError?: () => void; onError?: () => void;
} }