1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
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;
});
|