Skip to content

Commit 9e12cd5

Browse files
committed
feat: introduce an interruptable async interval timer
This timer calls an async function on an interval, optionally allowing the interval to be interrupted and execution to occur sooner. Calls to interrupt the interval are debounced such that only the first call to wake the timer is honored.
1 parent 7baa85e commit 9e12cd5

File tree

2 files changed

+161
-2
lines changed

2 files changed

+161
-2
lines changed

lib/utils.js

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,89 @@ function maybePromise(parent, callback, fn) {
734734
return result;
735735
}
736736

737+
function now() {
738+
const hrtime = process.hrtime();
739+
return Math.floor(hrtime[0] * 1000 + hrtime[1] / 1000000);
740+
}
741+
742+
/**
743+
* Creates an interval timer which is able to be woken up sooner than
744+
* the interval. The timer will also debounce multiple calls to wake
745+
* ensuring that the function is only ever called once within a minimum
746+
* interval window.
747+
*
748+
* @param {function} fn An async function to run on an interval, must accept a `callback` as its only parameter
749+
* @param {object} [options] Optional settings
750+
* @param {number} [options.interval] The interval at which to run the provided function
751+
* @param {number} [options.minInterval] The minimum time which must pass between invocations of the provided function
752+
* @param {boolean} [options.immediate] Execute the function immediately when the interval is started
753+
*/
754+
function makeInterruptableAsyncInterval(fn, options) {
755+
let timerId;
756+
let lastCallTime;
757+
let lastWakeTime;
758+
let stopped = false;
759+
760+
options = options || {};
761+
const interval = options.interval || 1000;
762+
const minInterval = options.minInterval || 500;
763+
const immediate = typeof options.immediate === 'boolean' ? options.immediate : false;
764+
765+
function wake() {
766+
const currentTime = now();
767+
const timeSinceLastWake = currentTime - lastWakeTime;
768+
const timeSinceLastCall = currentTime - lastCallTime;
769+
const timeUntilNextCall = Math.max(interval - timeSinceLastCall, 0);
770+
lastWakeTime = currentTime;
771+
772+
// debounce multiple calls to wake within the `minInterval`
773+
if (timeSinceLastWake < minInterval) {
774+
return;
775+
}
776+
777+
// reschedule a call as soon as possible, ensuring the call never happens
778+
// faster than the `minInterval`
779+
if (timeUntilNextCall > minInterval) {
780+
reschedule(minInterval);
781+
}
782+
}
783+
784+
function stop() {
785+
stopped = true;
786+
if (timerId) {
787+
clearTimeout(timerId);
788+
timerId = null;
789+
}
790+
791+
lastCallTime = 0;
792+
lastWakeTime = 0;
793+
}
794+
795+
function reschedule(ms) {
796+
if (stopped) return;
797+
clearTimeout(timerId);
798+
timerId = setTimeout(executeAndReschedule, ms || interval);
799+
}
800+
801+
function executeAndReschedule() {
802+
lastWakeTime = 0;
803+
lastCallTime = now();
804+
fn(err => {
805+
if (err) throw err;
806+
reschedule(interval);
807+
});
808+
}
809+
810+
if (immediate) {
811+
executeAndReschedule();
812+
} else {
813+
lastCallTime = now();
814+
reschedule();
815+
}
816+
817+
return { wake, stop };
818+
}
819+
737820
module.exports = {
738821
filterOptions,
739822
mergeOptions,
@@ -764,5 +847,7 @@ module.exports = {
764847
resolveReadPreference,
765848
emitDeprecationWarning,
766849
makeCounter,
767-
maybePromise
850+
maybePromise,
851+
now,
852+
makeInterruptableAsyncInterval
768853
};

test/unit/utils.test.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use strict';
22
const eachAsync = require('../../lib/core/utils').eachAsync;
3+
const makeInterruptableAsyncInterval = require('../../lib/utils').makeInterruptableAsyncInterval;
4+
const now = require('../../lib/utils').now;
35
const expect = require('chai').expect;
46

57
describe('utils', function() {
6-
describe('eachAsync', function() {
8+
context('eachAsync', function() {
79
it('should callback with an error', function(done) {
810
eachAsync(
911
[{ error: false }, { error: true }],
@@ -33,4 +35,76 @@ describe('utils', function() {
3335
done();
3436
});
3537
});
38+
39+
context('makeInterruptableAsyncInterval', function() {
40+
const roundToNearestMultipleOfTen = x => Math.floor(x / 10) * 10;
41+
42+
it('should execute a method in an repeating interval', function(done) {
43+
let lastTime = now();
44+
const marks = [];
45+
const executor = makeInterruptableAsyncInterval(
46+
callback => {
47+
marks.push(now() - lastTime);
48+
lastTime = now();
49+
callback();
50+
},
51+
{ interval: 10 }
52+
);
53+
54+
setTimeout(() => {
55+
const roundedMarks = marks.map(roundToNearestMultipleOfTen);
56+
expect(roundedMarks.every(mark => roundedMarks[0] === mark)).to.be.true;
57+
executor.stop();
58+
done();
59+
}, 50);
60+
});
61+
62+
it('should schedule execution sooner if requested within min interval threshold', function(done) {
63+
let lastTime = now();
64+
const marks = [];
65+
const executor = makeInterruptableAsyncInterval(
66+
callback => {
67+
marks.push(now() - lastTime);
68+
lastTime = now();
69+
callback();
70+
},
71+
{ interval: 50, minInterval: 10 }
72+
);
73+
74+
// immediately schedule execution
75+
executor.wake();
76+
77+
setTimeout(() => {
78+
const roundedMarks = marks.map(roundToNearestMultipleOfTen);
79+
expect(roundedMarks[0]).to.equal(10);
80+
executor.stop();
81+
done();
82+
}, 50);
83+
});
84+
85+
it('should debounce multiple requests to wake the interval sooner', function(done) {
86+
let lastTime = now();
87+
const marks = [];
88+
const executor = makeInterruptableAsyncInterval(
89+
callback => {
90+
marks.push(now() - lastTime);
91+
lastTime = now();
92+
callback();
93+
},
94+
{ interval: 50, minInterval: 10 }
95+
);
96+
97+
for (let i = 0; i < 100; ++i) {
98+
executor.wake();
99+
}
100+
101+
setTimeout(() => {
102+
const roundedMarks = marks.map(roundToNearestMultipleOfTen);
103+
expect(roundedMarks[0]).to.equal(10);
104+
expect(roundedMarks.slice(1).every(mark => mark === 50)).to.be.true;
105+
executor.stop();
106+
done();
107+
}, 250);
108+
});
109+
});
36110
});

0 commit comments

Comments
 (0)