taskon
Version:
A simple JavaScript/TypeScript tasks queue that supports dynamic concurrency control
308 lines • 13.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.TaskQueueCore = void 0;
const logger_1 = require("./logger");
const type_1 = require("./type");
/**
* Core abstract class for task queue, which is only responsible for serialized
* tasks execution and concurrency dynamic adjustment.
*/
class TaskQueueCore {
constructor({ logger = logger_1.logger, verbose = false, concurrency = 1, returnError = false, onTaskStatusUpdate, } = {}) {
/** @internal */
this.currentQueueId = 0;
/** @internal */
this.promiseQueues = [];
/** @internal */
this.onTaskStatusUpdate = undefined;
this.logger = logger;
this.verbose = verbose;
this.concurrency = concurrency;
this.returnError = returnError;
this.onTaskStatusUpdate = onTaskStatusUpdate;
this.promiseQueues = this._initPromiseQueues(concurrency);
}
/** @internal */
_initPromiseQueues(concurrency) {
if (concurrency < 1) {
throw new Error('Concurrency must be at least 1');
}
return new Array(concurrency).fill(null).map(() => ({
queueId: ++this.currentQueueId,
promise: Promise.resolve(),
length: 0,
taskIds: [],
}));
}
/**
* Adjust the concurrency.
* @param newConcurrency The new concurrency
* @param taskBalancingStrategy The existing running task balancing strategy
*/
adjustConcurrency(newConcurrency, taskBalancingStrategy = 'round-robin') {
if (newConcurrency !== this.concurrency) {
const oldConcurrency = this.concurrency;
this.concurrency = newConcurrency;
this._log({ level: 'info' }, `Adjusting concurrency from ${oldConcurrency} to ${newConcurrency}`);
const newPromiseQueue = this._initPromiseQueues(newConcurrency);
// Lookup from new queue index to old queues
let newQueueIndexToOldQueuesLookup = {};
if (taskBalancingStrategy === 'round-robin') {
this.promiseQueues.forEach((queue, index) => {
const newQueueIndex = index % newConcurrency;
if (!newQueueIndexToOldQueuesLookup[newQueueIndex]) {
newQueueIndexToOldQueuesLookup[newQueueIndex] = [queue];
}
else {
newQueueIndexToOldQueuesLookup[newQueueIndex].push(queue);
}
});
}
else {
throw new Error(`Unknown task balancing strategy ${taskBalancingStrategy}`);
}
// Based on the lookup from new queue index to old queues, re-assign the
// tasks from old queues to new queues
newPromiseQueue.forEach((queue, index) => {
var _a;
// Update the task ids of the new queue from all the tasks from the
// corresponding old queues
queue.taskIds = newQueueIndexToOldQueuesLookup[index]
? newQueueIndexToOldQueuesLookup[index].reduce((total, current) => {
return [...total, ...current.taskIds];
}, [])
: [];
// Update the task lengh of the new queue
queue.length = queue.taskIds.length;
// Update the promise of the new queue
queue.promise = newQueueIndexToOldQueuesLookup[index]
? Promise.all(newQueueIndexToOldQueuesLookup[index].map((oldQueue) => oldQueue.promise))
: Promise.resolve();
// Let the new queue be the parent queue of all old queues
(_a = newQueueIndexToOldQueuesLookup[index]) === null || _a === void 0 ? void 0 : _a.forEach((oldQueue) => (oldQueue.parentQueue = queue));
});
// Update the promise queues
this.promiseQueues = newPromiseQueue;
// Wait for appending tasks to waiting queue
Promise.resolve().then(() => {
// If the new concurrency is greater than the old one, add new tasks to
// fully leverage the whole concurrency capacity
if (oldConcurrency < newConcurrency && !this._shouldStop()) {
for (let i = 0; i < newConcurrency - oldConcurrency; i++) {
const task = this._getNextTask(false);
if (task) {
this._log({ level: 'info' }, `Adding task ${task.taskId} to the queue due to extra
concurrency`);
this._addTask(task);
}
}
}
});
this._log({ level: 'info' }, `Adjusted concurrency from ${oldConcurrency} to ${newConcurrency}`);
}
}
/**
* Get the current concurrency of the queue
*/
getConcurrency() {
return this.concurrency;
}
/** @internal */
_log(config, ...messages) {
if (this.verbose) {
if (config.taskId !== undefined) {
this.logger[config.level](`Task ${config.taskId} |`, ...messages);
}
else {
this.logger[config.level](...messages);
}
}
}
/** @internal */
_updateTaskStatus(task, status) {
var _a, _b;
task.status = status;
(_a = task.onStatusUpdate) === null || _a === void 0 ? void 0 : _a.call(task, status, task);
(_b = this.onTaskStatusUpdate) === null || _b === void 0 ? void 0 : _b.call(this, status, task);
}
/** @internal */
async _runTask(task, resolve, reject) {
try {
if (task.status === 'idle') {
task.runAt = new Date().getTime();
this._updateTaskStatus(task, 'running');
const result = await task.callback();
task.result = result;
task.finishedAt = new Date().getTime();
this._updateTaskStatus(task, 'success');
resolve(result);
}
else {
throw Error(`Task ${task.taskId} is already triggered`);
}
}
catch (error) {
task.error = error;
this._updateTaskStatus(task, 'error');
if (this.returnError) {
resolve(error);
}
else {
reject(error);
}
}
}
/** @internal */
_cleanTask(task) {
task.status = 'idle';
task.queueId = undefined;
task.result = undefined;
task.error = undefined;
task.runAt = undefined;
task.finishedAt = undefined;
return task;
}
/** @internal */
_addTask(task, withTaskClean = true) {
if (withTaskClean) {
this._cleanTask(task);
}
this._log({
level: 'info',
taskId: task.taskId,
}, 'Processing task:', task);
const _isWaitedTask = (0, type_1.isWaitedTask)(task);
this._log({
level: 'info',
taskId: task.taskId,
}, `Task ${task.taskId} is a ${_isWaitedTask ? 'waited task' : 'normal task'}`);
// The waited task already has the resolve function
let _resolve;
let _reject;
if (_isWaitedTask) {
_resolve = task.resolve;
_reject = task.reject;
}
// The waited task already has the promise
const _promise = _isWaitedTask
? task.promise
: new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
const promiseQueue = this._getAvailablePromiseQueue();
this._log({
level: 'info',
taskId: task.taskId,
}, 'Get available promise queue:', promiseQueue);
// If there is no available promise queue or we should stop the execution,
// append the task to the waiting queue
if (!promiseQueue || this._shouldStop(task)) {
Promise.resolve().then(() => {
// Directly modify the existing task instance to keep the task reference
// (for the inherited class)
task.resolve = _resolve;
task.reject = _reject;
task.promise = _promise;
this._pushTaskToWaitingQueue(task);
});
}
// If there is an available promise queue, append the task to it
else {
promiseQueue.length += 1;
promiseQueue.taskIds.push(task.taskId);
task.queueId = promiseQueue.queueId;
const resolveTask = (resultType, result, resolve) => {
promiseQueue.length -= 1;
promiseQueue.taskIds.shift();
// Update the length and task ids for all the parent queues
let currentParentQueue = promiseQueue.parentQueue;
let farestParentQueue = promiseQueue.parentQueue;
while (currentParentQueue) {
currentParentQueue.length -= 1;
currentParentQueue.taskIds.shift();
currentParentQueue = currentParentQueue.parentQueue;
if (currentParentQueue) {
farestParentQueue = currentParentQueue;
}
}
resolve(result);
this._log({
level: resultType === 'result' ? 'info' : 'error',
taskId: task.taskId,
}, resultType === 'result'
? `Resolved task ${task.taskId} with result ${result}`
: `Rejected task ${task.taskId} with error ${result}`);
// If the current queue has a parent queue with tasks assigned,
// then this queue should stop and let the parent queue continue
// execution
//
// Cases that the above conditions are met:
// 1. Multiple queues are assigned to the parent queue due to
// concurrency adjustment (this queue should stop)
//
// ----------------------------------------------------------------
//
// If the current queue has a parent queue without task assigned,
// then this queue should continue
//
// Cases that the above conditions are met:
// 1. Only this queue is assigned to the parent queue due to
// concurrency adjustment, where the parent queue also rely
// on this queue to continue execution (this queue should continue)
if (farestParentQueue && farestParentQueue.taskIds.length > 0) {
// TODO: Might need to clean up all deprecated queues to have good
// states
return;
}
// If stopped, directly return to skip the execution of next task
if (this._shouldStop(task)) {
return;
}
// Get and re-add the first task from the waiting queue after the
// previous "Promise.resolve().then()" finishes appending tasks to
// the waiting queue
Promise.resolve().then(() => {
const nextTask = this._getNextTask(true);
if (nextTask) {
this._log({
level: 'info',
taskId: task.taskId,
}, `Adding the next task ${nextTask.taskId} to continue execution`);
this._addTask(nextTask);
}
else {
this._log({
level: 'info',
taskId: task.taskId,
}, `No more task to continue execution`);
}
});
};
const runTask = () => {
this._log({
level: 'info',
taskId: task.taskId,
}, `Running task ${task.taskId}`);
return this._runTask(task, (result) => {
resolveTask('result', result, _resolve);
}, (error) => {
resolveTask('error', error, _reject);
});
};
// Keep running tasks no matter the previous task is resolved or rejected
// NOTE: You do not need to change this for most cases
promiseQueue.promise.then(runTask).catch(runTask);
// Append the promise to the promise queue to serialize async task
// executions
// NOTE: You do not need to change this for most cases
promiseQueue.promise = _promise;
}
// Return the result from the original callback function of the task
// NOTE: You do not need to change this for most cases
return _promise;
}
}
exports.TaskQueueCore = TaskQueueCore;
exports.default = TaskQueueCore;
//# sourceMappingURL=core.js.map