Summary: Fixes some bugs involving intervals, and updates RandomizedTimer to support both fixed and randomized delays, and to better handle async callbacks. * Fixed a bug where Throttle would queue up many pidusage calls due to the use of setInterval, and the async nature of the calls. * Fixed a but where RandomizedTimer (now just Interval) would not be disabled in ActiveDoc on doc shutdown if initialization had not yet settled. Test Plan: Tested manually. Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3604pull/19/head
parent
af77824618
commit
2cb783ea7b
@ -0,0 +1,105 @@
|
||||
export interface IntervalOptions {
|
||||
/**
|
||||
* Handler for errors that are thrown from the callback.
|
||||
*/
|
||||
onError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export interface IntervalDelay {
|
||||
// The base delay in milliseconds.
|
||||
delayMs: number;
|
||||
// If set, randomizes the base delay (per interval) by this amount of milliseconds.
|
||||
varianceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval takes a function to execute, and calls it on an interval based on
|
||||
* the provided delay.
|
||||
*
|
||||
* Supports both fixed and randomized delays between intervals.
|
||||
*/
|
||||
export class Interval {
|
||||
private _timeout?: NodeJS.Timeout | null;
|
||||
private _lastPendingCall?: Promise<unknown> | unknown;
|
||||
private _timeoutDelay?: number;
|
||||
private _stopped: boolean = true;
|
||||
|
||||
constructor(
|
||||
private _callback: () => Promise<unknown> | unknown,
|
||||
private _delay: IntervalDelay,
|
||||
private _options: IntervalOptions
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sets the timeout and schedules the callback to be called on interval.
|
||||
*/
|
||||
public enable(): void {
|
||||
this._stopped = false;
|
||||
this._setTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timeout and prevents the next call from being scheduled.
|
||||
*
|
||||
* This method does not currently cancel any pending calls. See `disableAndFinish`
|
||||
* for an async version of this method that supports waiting for the last pending
|
||||
* call to finish.
|
||||
*/
|
||||
public disable(): void {
|
||||
this._stopped = true;
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `disable`, but also waits for the last pending call to finish.
|
||||
*/
|
||||
public async disableAndFinish(): Promise<void> {
|
||||
this.disable();
|
||||
await this._lastPendingCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the delay in milliseconds of the next scheduled call.
|
||||
*
|
||||
* Primarily useful for tests.
|
||||
*/
|
||||
public getDelayMs(): number | undefined {
|
||||
return this._timeoutDelay;
|
||||
}
|
||||
|
||||
private _clearTimeout() {
|
||||
if (!this._timeout) { return; }
|
||||
|
||||
clearTimeout(this._timeout);
|
||||
this._timeout = null;
|
||||
}
|
||||
|
||||
private _setTimeout() {
|
||||
this._clearTimeout();
|
||||
this._timeoutDelay = this._computeDelayMs();
|
||||
this._timeout = setTimeout(() => this._onTimeoutTriggered(), this._timeoutDelay);
|
||||
}
|
||||
|
||||
private _computeDelayMs() {
|
||||
const {delayMs, varianceMs} = this._delay;
|
||||
if (varianceMs !== undefined) {
|
||||
// Randomize the delay by the specified amount of variance.
|
||||
const [min, max] = [delayMs - varianceMs, delayMs + varianceMs];
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
} else {
|
||||
return delayMs;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onTimeoutTriggered() {
|
||||
this._clearTimeout();
|
||||
try {
|
||||
await (this._lastPendingCall = this._callback());
|
||||
} catch (e: unknown) {
|
||||
this._options.onError(e);
|
||||
}
|
||||
if (!this._stopped) {
|
||||
this._setTimeout();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* RandomizedTimer takes a function to execute, and calls it on a randomized interval
|
||||
* between the minimum and maximum delay. The interval delay is randomized between
|
||||
* each scheduled call.
|
||||
*/
|
||||
export class RandomizedTimer {
|
||||
private _timeout?: NodeJS.Timeout | null;
|
||||
|
||||
constructor(
|
||||
private _callback: () => void,
|
||||
private _minDelayMs: number,
|
||||
private _maxDelayMs: number,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sets the timeout and schedules the callback to be called.
|
||||
*/
|
||||
public enable(): void {
|
||||
this._setTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timeout and prevents the callback from being called.
|
||||
*/
|
||||
public disable(): void {
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
private _clearTimeout() {
|
||||
if (!this._timeout) { return; }
|
||||
|
||||
clearTimeout(this._timeout);
|
||||
this._timeout = null;
|
||||
}
|
||||
|
||||
private _setTimeout() {
|
||||
this._clearTimeout();
|
||||
const [min, max] = [this._minDelayMs, this._maxDelayMs];
|
||||
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
this._timeout = setTimeout(() => this._onTimeoutTriggered(), delay);
|
||||
}
|
||||
|
||||
private _onTimeoutTriggered() {
|
||||
this._clearTimeout();
|
||||
this._callback();
|
||||
this._setTimeout();
|
||||
}
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
import {Interval} from 'app/common/Interval';
|
||||
import {delay} from 'bluebird';
|
||||
import {assert} from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
describe('Interval', function() {
|
||||
const delayMs = 100;
|
||||
const varianceMs = 50;
|
||||
const promiseDelayMs = 200;
|
||||
const delayBufferMs = 20;
|
||||
|
||||
let interval: Interval;
|
||||
let spy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
spy = sinon.spy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (interval) {
|
||||
await interval.disableAndFinish();
|
||||
}
|
||||
});
|
||||
|
||||
it('is not enabled by default', async function() {
|
||||
interval = new Interval(spy, {delayMs}, {onError: () => { /* do nothing */ }});
|
||||
assert.equal(spy.callCount, 0);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 0);
|
||||
});
|
||||
|
||||
it('can be disabled', async function() {
|
||||
interval = new Interval(spy, {delayMs}, {onError: () => { /* do nothing */ }});
|
||||
interval.enable();
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 1);
|
||||
|
||||
// Disable the interval, and check that the calls stop.
|
||||
interval.disable();
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 1);
|
||||
|
||||
// Enable the interval again, and check that the calls resume.
|
||||
interval.enable();
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 2);
|
||||
spy.resetHistory();
|
||||
});
|
||||
|
||||
it('calls onError if callback throws an error', async function() {
|
||||
const callback = () => { throw new Error('Something bad happened.'); };
|
||||
const onErrorSpy = sinon.spy();
|
||||
interval = new Interval(callback, {delayMs}, {onError: onErrorSpy});
|
||||
interval.enable();
|
||||
|
||||
// Check that onError is called when the callback throws.
|
||||
assert.equal(onErrorSpy.callCount, 0);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(onErrorSpy.callCount, 1);
|
||||
|
||||
// Check that the interval didn't stop (since the onError spy silenced the error).
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(onErrorSpy.callCount, 2);
|
||||
});
|
||||
|
||||
describe('with a fixed delay', function() {
|
||||
beforeEach(() => {
|
||||
interval = new Interval(spy, {delayMs}, {onError: () => { /* do nothing */ }});
|
||||
interval.enable();
|
||||
});
|
||||
|
||||
it('calls the callback on a fixed interval', async function() {
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 1);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(spy.callCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a randomized delay', function() {
|
||||
beforeEach(() => {
|
||||
interval = new Interval(spy, {delayMs, varianceMs}, {
|
||||
onError: () => { /* do nothing */ }
|
||||
});
|
||||
interval.enable();
|
||||
});
|
||||
|
||||
it('calls the callback on a randomized interval', async function() {
|
||||
const delays: number[] = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
// Get the current delay and check that it's within the expected range.
|
||||
const currentDelayMs = interval.getDelayMs();
|
||||
delays.push(currentDelayMs!);
|
||||
assert.isDefined(currentDelayMs);
|
||||
assert.isAtMost(currentDelayMs!, delayMs + varianceMs);
|
||||
assert.isAtLeast(currentDelayMs!, delayMs - varianceMs);
|
||||
|
||||
// Wait for the delay, and check that the spy was called.
|
||||
await delay(currentDelayMs!);
|
||||
assert.equal(spy.callCount, i);
|
||||
}
|
||||
|
||||
// Check that we didn't use the same delay all 10 times.
|
||||
assert.notEqual([...new Set(delays)].length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a promise-based callback', function() {
|
||||
let promiseSpy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
const promise = () => delay(promiseDelayMs);
|
||||
promiseSpy = sinon.spy(promise);
|
||||
interval = new Interval(promiseSpy, {delayMs}, {onError: () => { /* do nothing */ }});
|
||||
interval.enable();
|
||||
});
|
||||
|
||||
it('waits for promises to settle before scheduling the next call', async function() {
|
||||
assert.equal(promiseSpy.callCount, 0);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 1);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 1); // Still 1, because the first promise hasn't settled yet.
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 1); // Promise now settled, but there's still a 100ms delay.
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 2); // Now we finally call the callback again.
|
||||
});
|
||||
|
||||
it('can wait for last promise to settle when disabling', async function() {
|
||||
assert.equal(promiseSpy.callCount, 0);
|
||||
await delay(delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 1);
|
||||
await interval.disableAndFinish();
|
||||
|
||||
// Check that once disabled, no more calls are scheduled.
|
||||
await delay(promiseDelayMs + delayMs + delayBufferMs);
|
||||
assert.equal(promiseSpy.callCount, 1);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in new issue