Groups and concurrency
Run a list of tasks with groupAsCancelablePromise, limit how many run at once, follow their progress and cancel all of them with one call.
One promise for many tasks
groupAsCancelablePromise takes a list of tasks and returns one CancelablePromise that resolves with all the
results. A task is a function that returns a promise, or a promise that is already running.
import { CancelablePromise, groupAsCancelablePromise } from 'easy-cancelable-promise';
const fetchUser = (id: number) =>
new CancelablePromise<string>((resolve, _reject, { onCancel }) => {
const timer = setTimeout(() => resolve(`user ${id}`), 10);
onCancel(() => clearTimeout(timer));
});
export async function basic() {
// functions are called when their turn arrives; promises are already running
const group = groupAsCancelablePromise<string[]>(
[() => fetchUser(1), () => fetchUser(2), () => fetchUser(3), () => fetchUser(4), () => fetchUser(5)],
{ maxConcurrent: 2 }, // only 2 at a time
);
// an empty list returns null
if (!group) return [];
group.onProgress((percentage) => console.log(`${percentage}% complete`)); // 20, 40, 60, 80, 100
return group; // ['user 1', 'user 2', 'user 3', 'user 4', 'user 5'], in the order of the tasks
}- A function is called when its turn arrives. That is what makes the concurrency limit work.
- The results keep the position of their tasks, no matter which one finishes first.
- The group reports its progress: the percentage of tasks completed.
Cancel the group
One call cancels the tasks that are running and leaves the tasks in the queue unstarted.
import { CancelablePromise, groupAsCancelablePromise } from 'easy-cancelable-promise';
export async function cancelGroup() {
const started: number[] = [];
const stopped: number[] = [];
const task = (id: number) => () => {
started.push(id);
return new CancelablePromise<number>((resolve, _reject, { onCancel }) => {
const timer = setTimeout(() => resolve(id), 5000);
onCancel(() => {
clearTimeout(timer);
stopped.push(id);
});
});
};
const group = groupAsCancelablePromise<number[]>([task(1), task(2), task(3), task(4)], { maxConcurrent: 2 })!;
// one call: the running tasks are canceled, the tasks in the queue never start
group.cancel('User navigated away');
const reason = await group.catch((error) => error);
return { started, stopped, reason, status: group.status };
// { started: [1, 2], stopped: [1, 2], reason: 'User navigated away', status: 'canceled' }
}It also works the other way: if one task is canceled, the group is canceled, and that cancels the rest.
When a task fails
If a task is rejected, the group is rejected with the same reason and no more tasks are started. Tasks that are already running are not canceled.
import { groupAsCancelablePromise } from 'easy-cancelable-promise';
export async function errors() {
const started: string[] = [];
const task = (name: string, fails = false) => () => {
started.push(name);
return fails ? Promise.reject(new Error(`${name} failed`)) : Promise.resolve(name);
};
const group = groupAsCancelablePromise<string[]>([task('a'), task('b', true), task('c'), task('d')], {
maxConcurrent: 2,
})!;
// the group rejects with the reason of the first task that failed
const reason = await group.catch((error) => (error as Error).message);
return { reason, started, status: group.status };
// { reason: 'b failed', started: ['a', 'b'], status: 'rejected' }: the tasks in the queue never started
}Configuration
import { groupAsCancelablePromise } from 'easy-cancelable-promise';
export async function config() {
const events: string[] = [];
const step = (name: string) => () => Promise.resolve(name);
const results = await groupAsCancelablePromise<string[]>([step('a'), step('b'), step('c')], {
// how many tasks run at the same time (default: 8)
maxConcurrent: 2,
// start each task only after the previous one finished (default: false)
executeInOrder: false,
// called before each task starts, with the position of the task
beforeEachCallback: (index) => events.push(`starting ${index}`),
// called after each task that succeeded, with its result and its position
afterEachCallback: (result, index) => events.push(`finished ${index}: ${String(result)}`),
// called once, when every task succeeded, with all the results
onQueueEmptyCallback: (all) => events.push(`done: ${String(all)}`),
});
return { results, events };
// results: ['a', 'b', 'c']
// events: ['starting 0', 'starting 1', 'finished 0: a', 'finished 1: b', 'starting 2', 'finished 2: c', 'done: a,b,c']
}| Option | Default | Purpose |
|---|---|---|
maxConcurrent | 8 | How many tasks run at the same time. Values below 1 behave as 1 |
executeInOrder | false | Starts each task only after the previous one finished |
beforeEachCallback | none | Called before each task starts, with the position of the task |
afterEachCallback | none | Called after each task that succeeded, with its result and position |
onQueueEmptyCallback | none | Called once, when every task succeeded, with all the results |
Tasks run in batches of maxConcurrent: the next batch starts when every task of the current one finished.
One at a time, in order
import { groupAsCancelablePromise } from 'easy-cancelable-promise';
export async function sequential() {
const order: string[] = [];
const step = (name: string, milliseconds: number) => () =>
new Promise<string>((resolve) => {
order.push(`start ${name}`);
setTimeout(() => {
order.push(`end ${name}`);
resolve(name);
}, milliseconds);
});
// each task starts only after the previous one finished
const results = await groupAsCancelablePromise<string[]>([step('one', 20), step('two', 5), step('three', 1)], {
executeInOrder: true,
});
return { results, order };
// results: ['one', 'two', 'three']
// order: ['start one', 'end one', 'start two', 'end two', 'start three', 'end three']
}Next
- Cancel a group: the live example of this page.
- Static helpers:
CancelablePromise.allwhen you do not need a concurrency limit.