diff options
Diffstat (limited to 'addons/mail/static/src/utils/throttle')
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle.js | 382 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle_tests.js | 407 |
2 files changed, 789 insertions, 0 deletions
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js new file mode 100644 index 00000000..6b9ff008 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle.js @@ -0,0 +1,382 @@ +odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +/** + * This module define an utility function that enables throttling calls on a + * provided function. Such throttled calls can be canceled, flushed and/or + * cleared: + * + * - cancel: Canceling a throttle function call means that if a function call is + * pending invocation, cancel removes this pending call invocation. It however + * preserves the internal timer of the cooling down phase of this throttle + * function, meaning that any following throttle function call will be pending + * and has to wait for the remaining time of the cooling down phase before + * being invoked. + * + * - flush: Flushing a throttle function call means that if a function call is + * pending invocation, flush immediately terminates the cooling down phase and + * the pending function call is immediately invoked. Flush also works without + * any pending function call: it just terminates the cooling down phase, so + * that a following function call is guaranteed to be immediately called. + * + * - clear: Clearing a throttle function combines canceling and flushing + * together. + */ + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of internal and external Throttle errors. + * Internal errors are prefixed with `_`. + */ + + /** + * Error when throttle function has been canceled with `.cancel()`. Used to + * let the caller know of throttle function that the call has been canceled, + * which means the inner function will not be called. Usually caller should + * just accept it and kindly treat this error as a polite warning. + */ +class ThrottleCanceledError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleCanceledError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been reinvoked again. Used to let know + * caller of throttle function that the call has been canceled and replaced with + * another one, which means the (potentially) following inner function will be + * in the context of another call. Same as for `ThrottleCanceledError`, usually + * caller should just accept it and kindly treat this error as a polite + * warning. + */ +class ThrottleReinvokedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleReinvokedError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been flushed with `.flush()`. Used + * internally to immediately invoke pending inner functions, since a flush means + * the termination of cooling down phase. + * + * @private + */ +class _ThrottleFlushedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = '_ThrottleFlushedError'; + this.throttleId = throttleId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class models the behaviour of the cancelable, flushable and clearable + * throttle version of a provided function. See definitions at the top of this + * file. + */ +class Throttle { + + /** + * @param {Object} env the OWL env + * @param {function} func provided function for making throttled version. + * @param {integer} duration duration of the 'cool down' phase, i.e. + * the minimum duration between the most recent function call that has + * been made and the following function call (of course, assuming no flush + * in-between). + */ + constructor(env, func, duration) { + /** + * Reference to the OWL envirionment. Useful to fine-tune control of + * time flow in tests. + * @see mail/static/src/utils/test_utils.js:start.hasTimeControl + */ + this.env = env; + /** + * Unique id of this throttle function. Useful for the ThrottleError + * management, in order to determine whether these errors come from + * this throttle or from another one (e.g. inner function makes use of + * another throttle). + */ + this.id = _.uniqueId('throttle_'); + /** + * Deferred of current cooling down phase in progress. Defined only when + * there is a cooling down phase in progress. Resolved when cooling down + * phase terminates from timeout, and rejected if flushed. + * + * @see _ThrottleFlushedError for rejection of this deferred. + */ + this._coolingDownDeferred = undefined; + /** + * Duration, in milliseconds, of the cool down phase. + */ + this._duration = duration; + /** + * Inner function to be invoked and throttled. + */ + this._function = func; + /** + * Determines whether the throttle function is currently in cool down + * phase. Cool down phase happens just after inner function has been + * invoked, and during this time any following function call are pending + * and will be invoked only after the end of the cool down phase (except + * if canceled). + */ + this._isCoolingDown = false; + /** + * Deferred of a currently pending invocation to inner function. Defined + * only during a cooling down phase and just after when throttle + * function has been called during this cooling down phase. It is kept + * until cooling down phase ends (either from timeout or flushed + * throttle) or until throttle is canceled (i.e. removes pending invoke + * while keeping cooling down phase live on). + */ + this._pendingInvokeDeferred = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Cancel any buffered function call while keeping the cooldown phase + * running. + */ + cancel() { + if (!this._isCoolingDown) { + return; + } + if (!this._pendingInvokeDeferred) { + return; + } + this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id)); + } + + /** + * Clear any buffered function call and immediately terminates any cooling + * down phase in progress. + */ + clear() { + this.cancel(); + this.flush(); + } + + /** + * Called when there is a call to the function. This function is throttled, + * so the time it is called depends on whether the "cooldown stage" occurs + * or not: + * + * - no cooldown stage: function is called immediately, and it starts + * the cooldown stage when successful. + * - in cooldown stage: function is called when the cooldown stage has + * ended from timeout. + * + * Note that after the cooldown stage, only the last attempted function + * call will be considered. + * + * @param {...any} args + * @throws {ThrottleReinvokedError|ThrottleCanceledError} + * @returns {any} result of called function, if it's called. + */ + async do(...args) { + if (!this._isCoolingDown) { + return this._invokeFunction(...args); + } + if (this._pendingInvokeDeferred) { + this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id)); + } + try { + this._pendingInvokeDeferred = makeDeferred(); + await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]); + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + throw error; + } + } finally { + this._pendingInvokeDeferred = undefined; + } + return this._invokeFunction(...args); + } + + /** + * Flush the internal throttle timer, so that the following function call + * is immediate. For instance, if there is a cooldown stage, it is aborted. + */ + flush() { + if (!this._isCoolingDown) { + return; + } + const coolingDownDeferred = this._coolingDownDeferred; + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + coolingDownDeferred.reject(new _ThrottleFlushedError(this.id)); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Invoke the inner function of this throttle and starts cooling down phase + * immediately after. + * + * @private + * @param {...any} args + */ + _invokeFunction(...args) { + const res = this._function(...args); + this._startCoolingDown(); + return res; + } + + /** + * Called just when the inner function is being called. Starts the cooling + * down phase, which turn any call to this throttle function as pending + * inner function calls. This will be called after the end of cooling down + * phase (except if canceled). + */ + async _startCoolingDown() { + if (this._coolingDownDeferred) { + throw new Error("Cannot start cooling down if there's already a cooling down in progress."); + } + // Keep local reference of cooling down deferred, because the one stored + // on `this` could be overwritten by another call to this throttle. + const coolingDownDeferred = makeDeferred(); + this._coolingDownDeferred = coolingDownDeferred; + this._isCoolingDown = true; + const cooldownTimeoutId = this.env.browser.setTimeout( + () => coolingDownDeferred.resolve(), + this._duration + ); + let unexpectedError; + try { + await coolingDownDeferred; + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + // This branching should never happen. + // Still defined in case of programming error. + unexpectedError = error; + } + } finally { + this.env.browser.clearTimeout(cooldownTimeoutId); + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + } + if (unexpectedError) { + throw unexpectedError; + } + } + +} + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +/** + * A function that creates a cancelable, flushable and clearable throttle + * version of a provided function. See definitions at the top of this file. + * + * This throttle mechanism allows calling a function at most once during a + * certain period: + * + * - When a function call is made, it enters a 'cooldown' phase, in which any + * attempt to call the function is buffered until the cooldown phase ends. + * - At most 1 function call can be buffered during the cooldown phase, and the + * latest one in this phase will be considered at its end. + * - When a cooldown phase ends, any buffered function call will be performed + * and another cooldown phase will follow up. + * + * @param {Object} env the OWL env + * @param {function} func the function to throttle. + * @param {integer} duration duration, in milliseconds, of the cooling down + * phase of the throttling. + * @param {Object} [param2={}] + * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller + * of throttle function will observe some errors that come from current + * throttle call that has been canceled, such as when throttle function has + * been explicitly canceled with `.cancel()` or when another new throttle call + * has been registered. + * @see ThrottleCanceledError for when a call has been canceled from explicit + * call. + * @see ThrottleReinvokedError for when a call has been canceled from another + * new throttle call has been registered. + * @returns {function} the cancelable, flushable and clearable throttle version + * of the provided function. + */ +function throttle( + env, + func, + duration, + { silentCancelationErrors = true } = {} +) { + const throttleObj = new Throttle(env, func, duration); + const callable = async (...args) => { + try { + // await is important, otherwise errors are not intercepted. + return await throttleObj.do(...args); + } catch (error) { + const isSelfReinvokedError = ( + error instanceof ThrottleReinvokedError && + error.throttleId === throttleObj.id + ); + const isSelfCanceledError = ( + error instanceof ThrottleCanceledError && + error.throttleId === throttleObj.id + ); + + if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) { + // Silently ignore cancelation errors. + // Promise is indefinitely pending for async functions. + return new Promise(() => {}); + } else { + throw error; + } + } + }; + Object.assign(callable, { + cancel: () => throttleObj.cancel(), + clear: () => throttleObj.clear(), + flush: () => throttleObj.flush(), + }); + return callable; +} + +/** + * Make external throttle errors accessible from throttle function. + */ +Object.assign(throttle, { + ThrottleReinvokedError, + ThrottleCanceledError, +}); + + +return throttle; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js new file mode 100644 index 00000000..d3e6ad66 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle_tests.js @@ -0,0 +1,407 @@ +odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { ThrottleReinvokedError, ThrottleCanceledError } = throttle; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('throttle', {}, function () { +QUnit.module('throttle_tests.js', { + beforeEach() { + beforeEach(this); + this.throttles = []; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + for (const t of this.throttles) { + t.clear(); + } + afterEach(this); + }, +}); + +QUnit.test('single call', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasInvokedFunc = false; + const throttledFunc = throttle( + this.env, + () => { + hasInvokedFunc = true; + return 'func_result'; + }, + 0 + ); + this.throttles.push(throttledFunc); + + assert.notOk( + hasInvokedFunc, + "func should not have been invoked on immediate throttle initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasInvokedFunc, + "func should not have been invoked from throttle initialization after 0ms" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke'); + assert.strictEqual( + res, + 'func_result', + "throttle call return should forward result of inner func" + ); + }); + await nextTick(); + assert.ok( + hasInvokedFunc, + "func should have been immediately invoked on first throttle call" + ); + assert.verifySteps( + ['throttle_observed_invoke'], + "throttle should have observed invoked on first throttle call" + ); +}); + +QUnit.test('2nd (throttled) call', async function (assert) { + assert.expect(8); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000 + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + res, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('throttled call reinvocation', async function (assert) { + assert.expect(11); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleReinvokedError, + "Should generate a Throttle reinvoked error (from another throttle function call)" + ); + assert.step('throttle_reinvoked_1'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc() + .then(result => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + result, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_reinvoked_1'], + "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)" + ); +}); + +QUnit.test('flush throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_2')); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(10); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.flush(); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked from 2nd call after flush" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('cancel throttled call', async function (assert) { + assert.expect(10); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.cancel()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.cancel(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + [], + "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)" + ); +}); + +QUnit.test('clear throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.clear()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.clear(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled (from `.clear()`)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)" + ); +}); + +}); +}); +}); + +}); |
