modern-async
Version:
A modern tooling library for asynchronous operations using async/await, promises and async generators
234 lines (216 loc) • 7.51 kB
JavaScript
import asyncSleepCancellable from './asyncSleepCancellable.mjs'
import Queue from './Queue.mjs'
import assert from 'nanoassert'
import asyncWrap from './asyncWrap.mjs'
import CancelledError from './CancelledError.mjs'
/**
* A class implementing a scheduler.
*
* It fills the same purpose than setInterval() but its behavior is more adapted to asynchronous
* tasks. Notably it can limit the concurrency of asynchronous tasks running in parallel.
*
* @example
* import { Scheduler, asyncSleep } from 'modern-async'
*
* let i = 0
* const scheduler = new Scheduler(async () => {
* const taskNbr = i
* i += 1
* console.log(`Starting task ${taskNbr}`)
* await asyncSleep(10) // waits 10ms
* console.log(`Ending task ${taskNbr}`)
* }, 100) // a scheduler that triggers every 100ms
* // the default configuration uses a maximum concurrency of 1 and doesn't allow pending
* // tasks, which mean that if a task takes more time to complete than the delay it will be skipped
*
* scheduler.start() // starts the scheduler
*
* await asyncSleep(1000) // waits 1s, during that time the task should trigger ~ 9 times
*
* scheduler.stop() // stops the scheduler
* console.log('Scheduler stopped')
* // no "Starting task" console message may appear from here but you could still see a
* // "Stopping task" as there could have a task that started before we stopped the
* // scheduler
*/
class Scheduler {
/**
* Constructs a Scheduler.
*
* @param {Function} fct The asynchronous function to call when the scheduler is triggered.
* @param {number} delay The delay between two triggering of the scheduler, in ms.
* @param {{startImmediate: boolean, concurrency: number, maxPending: number}} [options] An object that can contain additional options:
*
* * `startImmediate`: If true a new task will be triggered as soon as the start() method is called.
* Defaults to ´false`.
* * `concurrency`: The maximum number of concurrent tasks. See the `concurrency` attribute. Defaults to 1.
* * `maxPending`: The maximum number of pending tasks. See the `maxPending` attribute. Defaults to 0.
*/
constructor (fct, delay, options = null) {
options = options || {}
this._asyncFct = asyncWrap(fct)
this._delay = delay
assert(typeof this._delay === 'number', 'delay must be a number')
assert(this._delay >= 0, 'delay must be greater or equal than 0')
this._startImmediate = options.startImmediate || false
assert(typeof this._startImmediate === 'boolean',
'startImmediate must be a boolean')
this._maxPending = options.maxPending || 0
assert(Number.isInteger(this._maxPending) || this._maxPending === Number.POSITIVE_INFINITY,
'maxPending must be an integer or positive infinity')
assert(this._maxPending >= 0, 'maxPending must be greater or equal than 0')
this._queue = new Queue(options.concurrency || 1)
this._started = false
this._initialTime = null
this._nbrTriggering = null
this._cancelSleep = null
}
/**
* (Read-only) The delay between two triggering of the scheduler, in milliseconds.
*
* @member {number}
* @returns {number} ignore
*/
get delay () {
return this._delay
}
/**
* (Read-only) Whether or not a triggering of the task should occur immediately when calling `start()` or not.
*
* Defaults to false.
*
* @member {boolean}
* @returns {boolean} ignore
*/
get startImmediate () {
return this._startImmediate
}
/**
* (Read-only) The maximum number of asynchronous tasks that can run in parallel.
*
* This parameter only matters in the event where some tasks may take more time to execute
* than the delay. If the concurrency allows it the new task will be run concurrently. If not
* it may be scheduled to be executed depending on the configuration of the `maxPending` parameter.
*
* Defaults to 1.
*
* @member {number}
* @returns {number} ignore
*/
get concurrency () {
return this._queue.concurrency
}
/**
* (Read-only) The maximum number of tasks that can be pending.
*
* In the event where one of the tasks triggered by the scheduler takes more time to execute than
* the delay the next task may or may not be run concurrently depending on the configuration of
* the `concurrency` parameter. If the maximum concurrency was already reached the new task can
* be scheduled to be executed as soon as the previous task finished.
*
* This parameter indicates the maximum amount of tasks that can be pending at any time. If a
* task should be scheduled and the maximum amount of pending tasks is already reached
* that new task will be skipped.
*
* This behavior helps to prevent cases that would lead to a infinite amount of tasks to be
* pending. This could happen in extreme cases where the tasks would take systematically more
* time to execute than the delay.
*
* Defaults to 0.
*
* @member {number}
* @returns {number} ignore
*/
get maxPending () {
return this._maxPending
}
/**
* (Read-only) Whether or not the scheduler is actually started.
*
* @member {boolean}
* @returns {boolean} ignore
*/
get started () {
return this._started
}
/**
* Starts the scheduler.
*
* Calling this method can trigger a task immediately depending on the configuration
* of the `startImmediate` parameter.
*
* If this method is called while the scheduler is already started it will have no effect.
*/
start () {
if (this.started) {
return
}
assert(this._queue.pending === 0)
this._started = true
this._initialTime = new Date().getTime()
this._nbrTriggering = 0
if (this.startImmediate) {
this._triggerTask()
}
this._scheduleSleep()
}
/**
* Stops the scheduler.
*
* If, for any reason, there were pending tasks in the scheduler they will be cancelled. On the other
* hand if they are still one or more tasks that are running they will continue to run until they
* terminate.
*
* This method is safe to call in a task if necessary.
*
* If this method is called while the scheduler is already stopped it will have no effect.
*/
stop () {
if (!this.started) {
return
}
assert(!!this._cancelSleep)
this._cancelSleep()
this._cancelSleep = null
this._queue.cancelAllPending()
assert(this._queue.pending === 0)
this._started = false
this._initialTime = null
this._nbrTriggering = null
}
/**
* @ignore
*/
_scheduleSleep () {
this._nbrTriggering += 1
const nextTime = this._initialTime + (this.delay * this._nbrTriggering)
const currentTime = new Date().getTime()
const [promise, cancel] = asyncSleepCancellable(nextTime - currentTime)
this._cancelSleep = cancel
promise.then(() => {
this._triggerTask()
this._scheduleSleep()
}, () => {
// ignore cancelled asyncSleep
})
}
/**
* @ignore
*/
_triggerTask () {
const reachedMaxConcurrency = this._queue.running === this._queue.concurrency
const forecastPending = reachedMaxConcurrency ? this._queue.pending + 1 : 0
if (forecastPending <= this.maxPending) {
this._queue.exec(this._asyncFct).catch(exceptionHandler)
}
}
}
export default Scheduler
const exceptionHandler = (e) => {
if (e instanceof CancelledError) {
// ignore
} else {
throw e
}
}
export { exceptionHandler }