Skip to content

Commit 21cbabd

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 7cab088 commit 21cbabd

File tree

2 files changed

+162
-4
lines changed

2 files changed

+162
-4
lines changed

lib/utils.js

+87-1
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ function maybePromise(callback, wrapper) {
665665
function databaseNamespace(ns) {
666666
return ns.split('.')[0];
667667
}
668+
668669
function collectionNamespace(ns) {
669670
return ns
670671
.split('.')
@@ -915,6 +916,89 @@ function emitDeprecatedOptionWarning(options, list) {
915916
});
916917
}
917918

919+
function now() {
920+
const hrtime = process.hrtime();
921+
return Math.floor(hrtime[0] * 1000 + hrtime[1] / 1000000);
922+
}
923+
924+
/**
925+
* Creates an interval timer which is able to be woken up sooner than
926+
* the interval. The timer will also debounce multiple calls to wake
927+
* ensuring that the function is only ever called once within a minimum
928+
* interval window.
929+
*
930+
* @param {function} fn An async function to run on an interval, must accept a `callback` as its only parameter
931+
* @param {object} [options] Optional settings
932+
* @param {number} [options.interval] The interval at which to run the provided function
933+
* @param {number} [options.minInterval] The minimum time which must pass between invocations of the provided function
934+
* @param {boolean} [options.immediate] Execute the function immediately when the interval is started
935+
*/
936+
function makeInterruptableAsyncInterval(fn, options) {
937+
let timerId;
938+
let lastCallTime;
939+
let lastWakeTime;
940+
let stopped = false;
941+
942+
options = options || {};
943+
const interval = options.interval || 1000;
944+
const minInterval = options.minInterval || 500;
945+
const immediate = typeof options.immediate === 'boolean' ? options.immediate : false;
946+
947+
function wake() {
948+
const currentTime = now();
949+
const timeSinceLastWake = currentTime - lastWakeTime;
950+
const timeSinceLastCall = currentTime - lastCallTime;
951+
const timeUntilNextCall = Math.max(interval - timeSinceLastCall, 0);
952+
lastWakeTime = currentTime;
953+
954+
// debounce multiple calls to wake within the `minInterval`
955+
if (timeSinceLastWake < minInterval) {
956+
return;
957+
}
958+
959+
// reschedule a call as soon as possible, ensuring the call never happens
960+
// faster than the `minInterval`
961+
if (timeUntilNextCall > minInterval) {
962+
reschedule(minInterval);
963+
}
964+
}
965+
966+
function stop() {
967+
stopped = true;
968+
if (timerId) {
969+
clearTimeout(timerId);
970+
timerId = null;
971+
}
972+
973+
lastCallTime = 0;
974+
lastWakeTime = 0;
975+
}
976+
977+
function reschedule(ms) {
978+
if (stopped) return;
979+
clearTimeout(timerId);
980+
timerId = setTimeout(executeAndReschedule, ms || interval);
981+
}
982+
983+
function executeAndReschedule() {
984+
lastWakeTime = 0;
985+
lastCallTime = now();
986+
fn(err => {
987+
if (err) throw err;
988+
reschedule(interval);
989+
});
990+
}
991+
992+
if (immediate) {
993+
executeAndReschedule();
994+
} else {
995+
lastCallTime = now();
996+
reschedule();
997+
}
998+
999+
return { wake, stop };
1000+
}
1001+
9181002
module.exports = {
9191003
filterOptions,
9201004
mergeOptions,
@@ -957,5 +1041,7 @@ module.exports = {
9571041
errorStrictEqual,
9581042
makeStateMachine,
9591043
makeClientMetadata,
960-
noop
1044+
noop,
1045+
now,
1046+
makeInterruptableAsyncInterval
9611047
};

test/unit/utils.test.js

+75-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use strict';
2-
const { eachAsync } = require('../../lib/utils');
3-
const expect = require('chai').expect;
2+
const { eachAsync, now, makeInterruptableAsyncInterval } = require('../../lib/utils');
3+
const { expect } = require('chai');
44

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

0 commit comments

Comments
 (0)