@merchise/action-queue
Version:
A coordinated queue of actions
443 lines (442 loc) • 12.6 kB
JavaScript
class ActionQueue {
constructor(options) {
if (typeof options !== "undefined") {
this._options = {
createPromises: typeof options.createPromises == "boolean" ? options.createPromises : true,
rejectCanceled: typeof options.rejectCanceled == "boolean" ? options.rejectCanceled : true,
workers: typeof options.workers == "number" && options.workers > 1 ? options.workers : 1
};
} else {
this._options = {
createPromises: true,
workers: 1,
rejectCanceled: true
};
}
this._thens = [];
this._catchs = [];
this._finallys = [];
this._cancels = [];
this._paused = false;
this._queue = [];
this._rolling = null;
this._workers = {};
this._idle = /* @__PURE__ */ new Set();
[...Array(this._options.workers).keys()].map((x) => this._idle.add(x));
}
/**
* Register an new callback to call when any of the actions in the queue
* is completed and not canceled.
*
* @param {function} callback
*/
then(callback) {
this._thens.push(callback);
}
/**
* Register a new callback to call when any of the actions in the queue
* is rejected without being cancelled.
*
* @param {function} callback
*/
catch(callback) {
this._catchs.push(callback);
}
/**
* Register a new callback which is going to be called when any of the
* actions in the queue is either completed, rejected or cancelled.
*
* These callbacks are always called after the other (more specific)
* callbacks.
*
* @param {function} callback
*/
finally(callback) {
this._finallys.push(callback);
}
/**
* Register a new callback which is going to be called when any of the
* actions in the queue is cancelled.
*
* @param {function} callback
*/
oncancel(callback) {
this._cancels.push(callback);
}
/**
* Insert the action at the beginning of the queue. If a current action
* is already running don't cancel it, instead queue the given action to
* be run later but before any other action in the queue.
*
* @param {function} fn The action to perform
* @param {...any} extra Extra arguments to pass to callbacks
*
* If the option `createPromises` is true, return a promise that is
* equivalent to the one that would be returned by the action after it
* starts running. This promise will resolve only if/when the action
* runs and resolves, and it will reject when the action rejects or is
* cancelled.
*
* If `createPromises` is false, return undefined.
*/
prepend(fn, ...extra) {
let item = this._build_action(fn, extra);
this._queue.splice(0, 0, item);
this._run();
return item.external_promise;
}
/**
* Insert the action at the end of the queue.
*
* @param {function} fn The action to perform
* @param {...any} extra Extra arguments to pass to callbacks
*
* If the option `createPromises` is true, return a promise that is
* equivalent to the one that would be returned by the action after it
* starts running. This promise will resolve only if/when the action
* runs and resolves, and it will reject when the action rejects or is
* cancelled.
*
* If `createPromises` is false, return undefined.
*/
append(fn, ...extra) {
let item = this._build_action(fn, extra);
this._queue.push(item);
this._run();
return item.external_promise;
}
_build_action(fn, extra) {
let connectors = { resolve: () => {
}, reject: () => {
} };
let promise;
if (this._options.createPromises) {
promise = new Promise(function(resolve, reject) {
connectors.resolve = resolve;
connectors.reject = reject;
});
} else {
promise = void 0;
}
let item = {
fn,
connectors,
extra,
external_promise: promise,
inner_promise: void 0,
cancelled: false,
cancel: () => {
this._cancel_action(item);
}
};
return item;
}
/**
* Replaces the entire queue with the given action. Cancel pending and
* running actions.
*
* @param {function} fn The action to perform
* @param {...any} extra Extra arguments to pass to callbacks
*
* Returns a promise that is equivalent to the one that would be
* returned by the action after it starts running. This promise will
* resolve only if/when the action runs and resolves, and it will
* reject when the action rejects or is cancelled.
*/
replace(fn, ...extra) {
this.clear();
return this.append(fn, ...extra);
}
/**
* Replaces the entire queue with the given action. Without canceling
* running actions.
*
* @param {function} fn The action to perform
* @param {...any} extra Extra arguments to pass to callbacks
*/
replace_pending(fn, ...extra) {
let pending = this._queue.concat();
this._queue.splice(0, this._queue.length);
while (pending.length > 0) {
let action = pending.shift();
this._cancel_action(action);
}
this.append(fn, ...extra);
}
/**
* Clear the entire queue. Cancel pending and running actions.
*/
clear() {
let pending = this._queue.concat();
this._queue.splice(0, this._queue.length);
this._cancel_running();
while (pending.length > 0) {
let action = pending.shift();
this._cancel_action(action);
}
}
/**
* Return the length of the queue
*/
length() {
return this._queue.length + this.running();
}
/**
* Return the amount of tasks currently running.
*/
running() {
return this._options.workers - this._idle.size;
}
/**
* Return True if the queue is busy, either running or with waiting
* actions.
*/
busy() {
return this.length() > 0;
}
/**
* Return a promise that resolves/rejects just as soon as the first
* pending action resolves/rejects.
*
* Cancelations don't affect the promise. If the running action gets
* cancelled midway, this promise will take over on the next action.
* If no action is scheduled to be next, we wait.
*
* The only way this promise is rejected, is if the running action is
* rejected. The only way this promise is resolved, is when the
* running action is resolved.
*
* When the same queue is used several times, calls to promise may
* return different promises.
*/
promise() {
if (this._rolling === null)
this._setup_rolling_promise();
return this._rolling.promise;
}
/**
* Return true if the queue is paused.
*/
paused() {
return this._paused;
}
/**
* Pause the queue. No tasks are going to be run.
*/
pause() {
this._paused = true;
}
/**
* Resume the queue.
*/
resume() {
this._paused = false;
this._run();
}
/**
* Return an object with the running and pending jobs in the queue.
*
* The result is an object with two properties: 'running' and 'pending'.
* Each is an array of objects the properties:
*
* - `args`; which is an array (possibly empty) with the extra arguments
* passed to `append`, `prepend` or `replace`.
*
* - `cancel`; a function that allows to cancel this particular action
*
* - `promise`; the promise attached to this action (undefined if
* `createPromises` is false).
*
*/
info() {
let map = function(d) {
let { extra, cancel, external_promise } = d;
return { args: extra, cancel, promise: external_promise };
};
const workers = Object.values(this._workers);
const queue = [].concat(this._queue);
return {
running: workers.map(map),
pending: queue.map(map)
};
}
_setup_rolling_promise() {
let self = this;
self._rolling = {
promise: null,
resolve: null,
reject: null
};
self._rolling.promise = new Promise(function(resolve, reject) {
self._rolling.resolve = resolve;
self._rolling.reject = reject;
});
}
/**
* Run the next action in the queue.
*
* If there's an action running already or if the queue is empty, ignore
* the request.
*
* After the action is finished, request to run the next one.
*/
_run() {
if (!this.paused() && this._idle.size > 0 && this._queue.length > 0) {
let running = this._queue.shift();
let { fn, connectors, cancelled } = running;
if (cancelled) {
this._run();
return;
}
let inner_promise = fn();
running.inner_promise = inner_promise;
let index = this._acquire(running);
let self = this;
inner_promise.then(function(...result) {
self._release(index);
try {
connectors.resolve(...result);
} catch (e) {
console.error(e);
}
if (result.length == 1 && typeof result[0] === "undefined") {
result = [];
}
let extra = running.extra || [];
if (self._rolling !== null) {
let rolling_resolve = self._rolling.resolve;
if (typeof rolling_resolve != "undefined" && rolling_resolve !== null) {
try {
rolling_resolve.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
}
self._rolling = null;
}
self._thens.forEach(function(fn2) {
try {
fn2.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
});
self._finallys.forEach(function(fn2) {
try {
fn2.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
});
self._run();
}).catch(function(...result) {
self._release(index);
try {
connectors.reject(...result);
} catch (e) {
console.error(e);
}
if (result.length == 1 && typeof result[0] === "undefined") {
result = [];
}
let extra = running.extra || [];
if (self._rolling !== null) {
let rolling_reject = self._rolling.reject;
if (typeof rolling_reject != "undefined" && rolling_reject !== null) {
try {
rolling_reject.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
}
self._rolling = null;
}
self._catchs.forEach(function(fn2) {
try {
fn2.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
});
self._finallys.forEach(function(fn2) {
try {
fn2.apply(self, result.concat(extra));
} catch (e) {
console.error(e);
}
});
self._run();
});
self._run();
}
}
/**
* Cancel all running actions if any.
*
* If the underlying promise has a `cancel` function, call it. Fallback
* to `abort`.
*
* Call registered callbacks (oncancel and finally).
*/
_cancel_running() {
for (const action of Object.values(this._workers)) {
let promise = action.inner_promise;
try {
if (typeof promise.cancel === "function") {
promise.cancel();
} else if (typeof promise.abort === "function") {
promise.abort();
}
} catch (e) {
console.error(e);
}
this._cancel_action(action);
}
this._workers = {};
this._idle = /* @__PURE__ */ new Set();
[...Array(this._options.workers).keys()].map((x) => this._idle.add(x));
}
/**
* Call the cancelled and finally callbacks for the action.
*/
_cancel_action(action) {
action.cancelled = true;
let extra = action.extra;
let self = this;
this._cancels.forEach(function(fn) {
try {
fn.apply(self, extra);
} catch (e) {
console.error(e);
}
});
if (this._options.rejectCanceled) {
try {
action.connectors.reject(new Error("Action was cancelled"));
} catch (e) {
console.error(e);
}
}
this._finallys.forEach(function(fn) {
try {
fn.apply(self, extra);
} catch (e) {
console.error(e);
}
});
}
_acquire(item) {
let values = this._idle.values();
let result = values.next().value;
this._idle.delete(result);
this._workers[result] = item;
return result;
}
_release(index) {
delete this._workers[index];
this._idle.add(index);
}
}
export {
ActionQueue as default
};