@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
215 lines (201 loc) • 9.05 kB
JavaScript
const { debug } = require('./logging/log')
const EventEmitter = require('events')
/**
* An interval timer with jitter
* @example
* // Basic example - every 1s ± 100ms
* const j1 = new IntervalJitter()
* j1.start({interval: 1000, jitter: 100}, (t) => { console.log(`hello j1, this was delayed ${t}ms`) })
* setTimeout(() => { j1.stop() }, 5000)
* @example
* // Custom timings example - at 1s ± 100ms, 5s ± 200ms, then every 30s ± 10ms
* const j1 = new IntervalJitter()
* const retryTiming = [1000, 5000, 30000] // retry at 1s, 5s, 30s, [30s, 30s, 30s..]
* const jitterTiming = [100, 200, 10] // jitter at 100ms, 200ms, 10ms, [10ms, 10ms, 10ms..]
* j1.start({interval: retryTiming, jitter: jitterTiming}, (t) => { console.log(`hello j1, this was delayed ${t}ms`) })
* setTimeout(() => { j1.stop() }, 30000)
* @example
* const j2 = new IntervalJitter()
* j2.on('interval', (t) => { console.log(`hello j2, this was delayed ${t}ms`) })
* j2.on('started', () => { console.log('j2 started') })
* j2.on('stopped', () => { console.log('j2 stopped') })
* // Start with first "interval" firing at somewhere between 0 ~ 500ms
* // After that, fire somewhere between 900 ~ 1100ms
* j2.start({interval: 1000, jitter: 200, firstInterval: 0, firstJitter: 500})
* setTimeout(() => { j2.stop() }, 5000)
*/
const DEFAULT_INTERVAL = 1000 // 1 sec
const DEFAULT_JITTER = 100 // 100 ms
class IntervalJitter extends EventEmitter {
constructor () {
super()
this.interval = DEFAULT_INTERVAL
this.jitter = DEFAULT_JITTER
this.awaitCallback = true // if callback is async, await it before scheduleing next interval
this.intervalArray = []
this.jitterArray = []
this.firstInterval = this.interval
this.firstJitter = this.jitter
this.callback = null
this.#init()
}
#stopped = true
#executing = false
#counter = 0
#initTimer
#intervalTimer
#init () {
const self = this
self.on('internal:start1', (interval, jitter) => {
self.emit('started')
const variance = IntervalJitter.calculateJitter(jitter)
const firstDelay = interval + variance
self.#initTimer = setTimeout(async () => {
if (self.#stopped) { return }
const now = Date.now()
try {
self.#executing = true
self.#counter++
const msSinceLastCall = now - self.lastTime
debug('Interval Timer Executing')
if (typeof self.callback === 'function') {
if (self.awaitCallback && self.callback.constructor.name === 'AsyncFunction') {
await self.callback.call(this, msSinceLastCall, self.counter)
} else {
self.callback.call(this, msSinceLastCall, self.counter)
}
}
self.emit('interval', msSinceLastCall, self.counter)
} finally {
self.#executing = false
self.lastTime = now
}
if (self.#stopped) { return }
self.emit('internal:start2')
}, firstDelay)
})
self.on('internal:start2', () => {
if (self.#stopped) { return }
const intervalTimer = async function () {
const variance = IntervalJitter.calculateJitter(self.#nextJitter)
let interval = self.#nextInterval
interval += variance
interval = interval < 0 ? 0 : interval
self.#intervalTimer = setTimeout(async () => {
if (self.#stopped) { return }
const now = Date.now()
try {
self.#executing = true
self.#counter++
const msSinceLastCall = now - self.lastTime
debug('Interval Timer Executing')
if (typeof self.callback === 'function') {
if (self.awaitCallback && self.callback.constructor.name === 'AsyncFunction') {
await self.callback.call(this, msSinceLastCall, self.counter)
} else {
self.callback.call(this, msSinceLastCall, self.counter)
}
}
self.emit('interval', msSinceLastCall, self.counter)
} finally {
self.#executing = false
self.lastTime = now
}
if (self.#stopped) { return }
intervalTimer()
}, interval)
}
intervalTimer()
})
}
get #nextInterval () {
if (this.intervalArray && this.intervalArray.length) {
this.interval = this.intervalArray.shift()
}
if (typeof this.interval !== 'number') {
this.interval = DEFAULT_INTERVAL
}
if (this.interval < 0) { this.interval = 0 }
return this.interval
}
get #nextJitter () {
if (this.jitterArray && this.jitterArray.length) {
this.jitter = this.jitterArray.shift()
}
if (typeof this.jitter !== 'number') {
this.jitter = DEFAULT_JITTER
}
if (this.jitter < 0) { this.jitter = 0 }
return this.jitter
}
/** Calculate a jitter value between `0` ~ `jitter` */
static calculateJitter = (jitter) => {
if (typeof jitter !== 'number' || isNaN(jitter) || jitter < 0) { return 0 }
return Math.ceil(Math.random() * jitter)
}
get isRunning () {
return this.#stopped === false
}
get isExecuting () {
return !!this.#executing
}
get counter () {
return this.#counter
}
/**
* Start the interval timer
* @param {object} options
* @param {number|Array<number>} options.interval - base/minimum delay. If interval is an array, it will use up all elements per execution until the last element which will become the base time for remaining executions. This is useful for generating a specific retry schedule e.g. 1s, 5s, 20s, 5m, [5m, 5m,...]
* @param {number|Array<number>} options.jitter - jitter to apply to `interval`. If jitter is an array, it will use up all elements per execution until the last element which will become the base jitter for remaining executions. This is useful for generating a specific retry schedule e.g. 1s±5ms, 5s±25ms, 20s±2s, 5m±30s, [5m±30s, 5m±30s,...]
* @param {number} [options.firstInterval] - base delay for first interval (optional)
* @param {number} [options.firstJitter] - jitter to apply to `firstInterval` (optional)
* @param {Boolean} [options.awaitCallback] - flag to instruct the timer to await callback before scheduling next interval
* @param {(timeSinceLastExecution: number, callCount: number ) => {}} [callback] - the function to call upon timeout (optional, can use `on('interval')`)
*/
start ({ interval, jitter, firstInterval, firstJitter, awaitCallback } = {}, callback) {
debug('Interval Timer Start requested')
const self = this
if (!self.#stopped) { return }
self.awaitCallback = typeof awaitCallback === 'boolean' ? awaitCallback : self.awaitCallback
// setup
if (typeof interval === 'number') {
self.intervalArray = [interval]
} else if (Array.isArray(interval) && interval.length) {
self.intervalArray = [...interval]
} else {
self.intervalArray = [DEFAULT_INTERVAL]
}
if (typeof jitter === 'number') {
self.jitterArray = [jitter]
} else if (Array.isArray(jitter) && jitter.length) {
self.jitterArray = [...jitter]
} else {
self.jitterArray = [DEFAULT_JITTER]
}
if (typeof firstInterval === 'number') {
self.intervalArray.unshift(firstInterval)
}
if (typeof firstJitter === 'number') {
self.jitterArray.unshift(firstJitter)
}
// some defaults
self.interval = self.#nextInterval
self.firstInterval = self.interval
self.jitter = self.#nextJitter
self.firstJitter = self.jitter
self.#stopped = false
self.#counter = 0
self.lastTime = Date.now()
if (typeof callback === 'function') { self.callback = callback }
self.emit('internal:start1', self.firstInterval, self.firstJitter)
}
/** Stop the interval timer */
stop () {
this.#stopped = true
debug('Interval Timer Stop requested')
clearInterval(this.#intervalTimer)
clearInterval(this.#initTimer)
this.emit('stopped')
}
}
module.exports.IntervalJitter = IntervalJitter