@barchart/common-js
Version:
Library of common JavaScript utilities
284 lines (230 loc) • 8.6 kB
JavaScript
import * as assert from './../lang/assert.js';
import * as is from './../lang/is.js';
import * as object from './../lang/object.js';
import * as promise from './../lang/promise.js';
import Disposable from './../lang/Disposable.js';
/**
* An object that wraps asynchronous delays (i.e. timeout and interval).
*
* @public
* @extends {Disposable}
*/
export default class Scheduler extends Disposable {
#timeoutBindings;
#intervalBindings;
constructor() {
super();
this.#timeoutBindings = {};
this.#intervalBindings = {};
}
/**
* Schedules an action to execute in the future, returning a Promise.
*
* @public
* @async
* @param {Function} actionToSchedule - The action to execute.
* @param {number} millisecondDelay - Milliseconds before the action can be started.
* @param {string=} actionDescription - A description of the action, used for logging purposes.
* @returns {Promise}
*/
async schedule(actionToSchedule, millisecondDelay, actionDescription) {
assert.argumentIsRequired(actionToSchedule, 'actionToSchedule', Function);
assert.argumentIsRequired(millisecondDelay, 'millisecondDelay', Number);
assert.argumentIsOptional(actionDescription, 'actionDescription', String);
if (this.disposed) {
throw new Error('The Scheduler has been disposed.');
}
let token;
const schedulePromise = promise.build((resolveCallback, rejectCallback) => {
const wrappedAction = () => {
const disposable = this.#timeoutBindings[token];
// 2021/05/18, BRI. Invoking dispose cases the clearTimeout function to run.
// Running clearTimeout should not be necessary because the timer has elapsed
// and the callback is being invoked. However, failing to call clearTimeout in
// a Node.js environment (after version 10) leads to a memory leak. Notice that
// this function has a reference to the Scheduler instance (via closure). In my
// view, this is breaking change between versions 10 and 12 of Node.js. I have
// been unable to locate any documentation regarding this change; however, a changes
// to did occur (which becomes obvious when inspecting the data structure returned by
// the setTimeout function).
if (disposable) {
disposable.dispose();
}
try {
resolveCallback(actionToSchedule());
} catch (e) {
rejectCallback(e);
}
};
token = setTimeout(wrappedAction, millisecondDelay);
this.#timeoutBindings[token] = Disposable.fromAction(() => {
clearTimeout(token);
delete this.#timeoutBindings[token];
});
});
return schedulePromise;
}
/**
* @public
* @param {Function} actionToRepeat
* @param {number} millisecondInterval
* @param {string=} actionDescription
* @returns {Disposable}
*/
repeat(actionToRepeat, millisecondInterval, actionDescription) {
assert.argumentIsRequired(actionToRepeat, 'actionToRepeat', Function);
assert.argumentIsRequired(millisecondInterval, 'millisecondInterval', Number);
assert.argumentIsOptional(actionDescription, 'actionDescription', String);
if (this.disposed) {
throw new Error('The Scheduler has been disposed.');
}
const wrappedAction = () => {
try {
actionToRepeat();
} catch {
}
};
const token = setInterval(wrappedAction, millisecondInterval);
this.#intervalBindings[token] = Disposable.fromAction(() => {
clearInterval(token);
delete this.#intervalBindings[token];
});
return this.#intervalBindings[token];
}
/**
* Attempts an action, repeating if necessary, using an exponential backoff.
*
* @public
* @async
* @param {Function} actionToBackoff - The action to attempt. If it fails -- because an error is thrown, a promise is rejected, or the function returns a falsey value -- the action will be invoked again.
* @param {number=} millisecondDelay - The amount of time to wait to execute the action. Subsequent failures are multiply this value by 2 ^ [number of failures]. So, a 1000 millisecond backoff would schedule attempts using the following delays: 0, 1000, 2000, 4000, 8000, etc. If not specified, the first attempt will execute immediately, then a value of 1000 will be used.
* @param {string=} actionDescription - Description of the action to attempt, used for logging purposes.
* @param {number=} maximumAttempts - The number of attempts to before giving up.
* @param {Function=} failureCallback - If provided, will be invoked if a function is considered to be failing.
* @param {object=} failureValue - If provided, will consider the result to have failed, if this value is returned (a deep equality check is used). If not provided, an undefined value will trigger a retry.
* @param {number=} maximumDelay - The maximum delay that can be used for the backoff. If not provided, the delay will continue to double until the maximum number of attempts is reached.
* @returns {Promise}
*/
async backoff(actionToBackoff, millisecondDelay, actionDescription, maximumAttempts, failureCallback, failureValue, maximumDelay) {
assert.argumentIsRequired(actionToBackoff, 'actionToBackoff', Function);
assert.argumentIsOptional(millisecondDelay, 'millisecondDelay', Number);
assert.argumentIsOptional(actionDescription, 'actionDescription', String);
assert.argumentIsOptional(maximumAttempts, 'maximumAttempts', Number);
assert.argumentIsOptional(failureCallback, 'failureCallback', Function);
assert.argumentIsOptional(maximumDelay, 'maximumDelay', Number);
if (this.disposed) {
throw new Error('The Scheduler has been disposed.');
}
const processAction = async (attempts) => {
let delay;
if (attempts === 0) {
delay = 0;
} else {
delay = (millisecondDelay || 1000) * Math.pow(2, attempts - 1);
if (maximumDelay && delay > maximumDelay) {
delay = maximumDelay;
}
}
try {
let result;
if (delay === 0) {
result = await actionToBackoff();
} else {
result = await this.schedule(actionToBackoff, delay, `Attempt [ ${attempts} ] for [ ${(actionDescription || 'unnamed action')} ]`);
}
if (!is.undef(failureValue) && object.equals(result, failureValue)) {
throw `Attempt [ ${attempts} ] for [ ${(actionDescription || 'unnamed action')} ] failed due to invalid result`;
}
return result;
} catch (e) {
if (is.fn(failureCallback)) {
failureCallback(attempts);
}
throw e;
}
};
let attempts = 0;
const processActionRecursive = async () => {
try {
const result = await processAction(attempts++);
return result;
} catch (e) {
if (maximumAttempts > 0 && attempts === maximumAttempts) {
const message = `Maximum failures reached for ${(actionDescription || 'unnamed action')}`;
if (is.object(e)) {
e.backoff = message;
throw e;
}
throw message;
}
return processActionRecursive();
}
};
return processActionRecursive();
}
/**
* @protected
* @override
*/
_onDispose() {
object.keys(this.#timeoutBindings).forEach((key) => {
this.#timeoutBindings[key].dispose();
});
object.keys(this.#intervalBindings).forEach((key) => {
this.#intervalBindings[key].dispose();
});
this.#timeoutBindings = null;
this.#intervalBindings = null;
}
/**
* @public
* @static
* @async
* @param {Function} actionToSchedule
* @param {number} millisecondDelay
* @param {string=} actionDescription
* @returns {Promise}
*/
static async schedule(actionToSchedule, millisecondDelay, actionDescription) {
const scheduler = new Scheduler();
let result;
try {
result = await scheduler.schedule(actionToSchedule, millisecondDelay, actionDescription);
} finally {
scheduler.dispose();
}
return result;
}
/**
* @public
* @static
* @async
* @param {Function} actionToBackoff
* @param {number} millisecondDelay
* @param {string=} actionDescription
* @param {number=} maximumAttempts
* @param {Function=} failureCallback
* @param {object=} failureValue
* @param {number=} maximumDelay
* @returns {Promise}
*/
static async backoff(actionToBackoff, millisecondDelay, actionDescription, maximumAttempts, failureCallback, failureValue, maximumDelay) {
const scheduler = new Scheduler();
let result;
try {
result = await scheduler.backoff(actionToBackoff, millisecondDelay, actionDescription, maximumAttempts, failureCallback, failureValue, maximumDelay);
} finally {
scheduler.dispose();
}
return result;
}
/**
* Returns a string representation.
*
* @public
* @returns {string}
*/
toString() {
return '[Scheduler]';
}
}