mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Often closely related to util.debounce use cases, this adds an implementation of throttle(fn) .. returns a function that limits the number of concurrent invocations of fn. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65899 Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
371 lines
8.5 KiB
JavaScript
371 lines
8.5 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayPrototypePush,
|
|
ArrayPrototypeSlice,
|
|
ObjectDefineProperties,
|
|
PromisePrototypeThen,
|
|
PromiseReject,
|
|
PromiseResolve,
|
|
PromiseWithResolvers,
|
|
ReflectApply,
|
|
} = primordials;
|
|
|
|
const {
|
|
AbortError,
|
|
codes: {
|
|
ERR_THROTTLED,
|
|
},
|
|
} = require('internal/errors');
|
|
const {
|
|
validateAbortSignal,
|
|
validateBoolean,
|
|
validateFunction,
|
|
validateInteger,
|
|
validateObject,
|
|
validateOneOf,
|
|
} = require('internal/validators');
|
|
const { addAbortListener } = require('internal/events/abort_listener');
|
|
const { kEmptyObject } = require('internal/util');
|
|
const { TIMEOUT_MAX } = require('internal/timers');
|
|
const { clearTimeout, setTimeout } = require('timers');
|
|
|
|
const timersBinding = internalBinding('timers');
|
|
const { markPromiseAsHandled } = internalBinding('util');
|
|
|
|
/**
|
|
* @typedef {object} ThrottleOptions
|
|
* @property {number} [concurrency] The maximum number of unsettled invocations.
|
|
* @property {number} [maxPending] The maximum number of queued calls.
|
|
* @property {'queue'|'drop'} [overflow] How calls exceeding the limit are handled.
|
|
* @property {AbortSignal} [signal] An AbortSignal that cancels pending and future calls.
|
|
* @property {boolean} [strict] Whether to enforce the limit over a rolling interval.
|
|
*/
|
|
|
|
/**
|
|
* Creates a function that invokes `fn` at most `limit` times per `interval`.
|
|
* @param {Function} fn
|
|
* @param {number} limit
|
|
* @param {number} interval
|
|
* @param {ThrottleOptions} [options]
|
|
* @returns {Function}
|
|
*/
|
|
function throttle(fn, limit, interval, options = kEmptyObject) {
|
|
validateFunction(fn, 'fn');
|
|
validateInteger(limit, 'limit', 1);
|
|
validateInteger(interval, 'interval', 0, TIMEOUT_MAX);
|
|
validateObject(options, 'options');
|
|
|
|
const {
|
|
concurrency = Infinity,
|
|
maxPending = Infinity,
|
|
overflow = 'queue',
|
|
signal,
|
|
strict = false,
|
|
} = options;
|
|
|
|
if (concurrency !== Infinity) {
|
|
validateInteger(concurrency, 'options.concurrency', 1);
|
|
}
|
|
if (maxPending !== Infinity) {
|
|
validateInteger(maxPending, 'options.maxPending', 0);
|
|
}
|
|
validateOneOf(overflow, 'options.overflow', ['queue', 'drop']);
|
|
validateAbortSignal(signal, 'options.signal');
|
|
validateBoolean(strict, 'options.strict');
|
|
|
|
if (signal?.aborted) {
|
|
throw new AbortError(undefined, { __proto__: null, cause: signal.reason });
|
|
}
|
|
|
|
let abortError;
|
|
let activeCount = 0;
|
|
let pendingCalls = [];
|
|
let pendingIndex = 0;
|
|
let refed = true;
|
|
let strictIndex = 0;
|
|
let strictTicks = [];
|
|
let timeout;
|
|
let windowCount = 0;
|
|
let windowStart;
|
|
|
|
function pendingCount() {
|
|
return pendingCalls.length - pendingIndex;
|
|
}
|
|
|
|
function dequeue() {
|
|
const call = pendingCalls[pendingIndex];
|
|
pendingCalls[pendingIndex++] = undefined;
|
|
|
|
if (pendingIndex === pendingCalls.length) {
|
|
pendingCalls = [];
|
|
pendingIndex = 0;
|
|
} else if (pendingIndex > 1024 && pendingIndex * 2 >= pendingCalls.length) {
|
|
pendingCalls = ArrayPrototypeSlice(pendingCalls, pendingIndex);
|
|
pendingIndex = 0;
|
|
}
|
|
|
|
return call;
|
|
}
|
|
|
|
function rejectPending(error) {
|
|
const calls = pendingCalls;
|
|
const start = pendingIndex;
|
|
pendingCalls = [];
|
|
pendingIndex = 0;
|
|
for (let i = start; i < calls.length; i++) {
|
|
calls[i].reject(error);
|
|
}
|
|
}
|
|
|
|
function resetLimiter() {
|
|
strictIndex = 0;
|
|
strictTicks = [];
|
|
windowCount = 0;
|
|
windowStart = undefined;
|
|
}
|
|
|
|
function cancelWithError(error) {
|
|
if (timeout !== undefined) {
|
|
clearTimeout(timeout);
|
|
timeout = undefined;
|
|
}
|
|
resetLimiter();
|
|
rejectPending(error);
|
|
}
|
|
|
|
function cancel(reason) {
|
|
const error = reason === undefined ?
|
|
new AbortError() :
|
|
new AbortError(undefined, { __proto__: null, cause: reason });
|
|
cancelWithError(error);
|
|
}
|
|
|
|
function abort() {
|
|
if (abortError === undefined) {
|
|
abortError = signal.reason === undefined ?
|
|
new AbortError() :
|
|
new AbortError(undefined, { __proto__: null, cause: signal.reason });
|
|
}
|
|
cancelWithError(abortError);
|
|
}
|
|
|
|
function ref() {
|
|
refed = true;
|
|
timeout?.ref();
|
|
return throttled;
|
|
}
|
|
|
|
function unref() {
|
|
refed = false;
|
|
timeout?.unref();
|
|
return throttled;
|
|
}
|
|
|
|
function hasImmediateCapacity() {
|
|
if (signal?.aborted ||
|
|
pendingCount() !== 0 ||
|
|
activeCount >= concurrency) {
|
|
return false;
|
|
}
|
|
|
|
const now = timersBinding.getLibuvNow();
|
|
if (strict) {
|
|
let index = strictIndex;
|
|
while (index < strictTicks.length &&
|
|
now - strictTicks[index] >= interval) {
|
|
index++;
|
|
}
|
|
return strictTicks.length - index < limit;
|
|
}
|
|
|
|
return windowStart === undefined ||
|
|
now - windowStart >= interval ||
|
|
windowCount < limit;
|
|
}
|
|
|
|
function pruneStrictTicks(now) {
|
|
while (strictIndex < strictTicks.length &&
|
|
now - strictTicks[strictIndex] >= interval) {
|
|
strictIndex++;
|
|
}
|
|
|
|
if (strictIndex === strictTicks.length) {
|
|
strictIndex = 0;
|
|
strictTicks = [];
|
|
} else if (strictIndex > 1024 && strictIndex * 2 >= strictTicks.length) {
|
|
strictTicks = ArrayPrototypeSlice(strictTicks, strictIndex);
|
|
strictIndex = 0;
|
|
}
|
|
}
|
|
|
|
function hasCapacity(now) {
|
|
if (strict) {
|
|
pruneStrictTicks(now);
|
|
return strictTicks.length - strictIndex < limit;
|
|
}
|
|
|
|
if (windowStart === undefined || now - windowStart >= interval) {
|
|
windowCount = 0;
|
|
windowStart = now;
|
|
}
|
|
return windowCount < limit;
|
|
}
|
|
|
|
function reserve(now) {
|
|
if (strict) {
|
|
ArrayPrototypePush(strictTicks, now);
|
|
} else {
|
|
windowCount++;
|
|
}
|
|
}
|
|
|
|
function onSettled() {
|
|
activeCount--;
|
|
drain();
|
|
}
|
|
|
|
function invoke(call) {
|
|
activeCount++;
|
|
let resultPromise;
|
|
try {
|
|
resultPromise = PromiseResolve(ReflectApply(fn, throttled, call.args));
|
|
} catch (error) {
|
|
resultPromise = PromiseReject(error);
|
|
}
|
|
call.resolve(resultPromise);
|
|
PromisePrototypeThen(resultPromise, onSettled, onSettled);
|
|
}
|
|
|
|
function drop() {
|
|
const promise = PromiseReject(new ERR_THROTTLED());
|
|
markPromiseAsHandled(promise);
|
|
return promise;
|
|
}
|
|
|
|
function schedule() {
|
|
if (timeout !== undefined ||
|
|
pendingCount() === 0 ||
|
|
activeCount >= concurrency) {
|
|
return;
|
|
}
|
|
|
|
const now = timersBinding.getLibuvNow();
|
|
if (hasCapacity(now)) {
|
|
drain();
|
|
return;
|
|
}
|
|
|
|
const delay = strict ?
|
|
strictTicks[strictIndex] + interval - now :
|
|
windowStart + interval - now;
|
|
timeout = setTimeout(onTimeout, delay);
|
|
if (!refed) timeout.unref();
|
|
}
|
|
|
|
function drain() {
|
|
if (signal?.aborted) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
while (pendingCount() !== 0 && activeCount < concurrency) {
|
|
const now = timersBinding.getLibuvNow();
|
|
if (!hasCapacity(now)) break;
|
|
const call = dequeue();
|
|
reserve(now);
|
|
invoke(call);
|
|
}
|
|
|
|
schedule();
|
|
}
|
|
|
|
function onTimeout() {
|
|
timeout = undefined;
|
|
drain();
|
|
}
|
|
|
|
function throttled(...callArgs) {
|
|
if (signal?.aborted) abort();
|
|
if (abortError !== undefined) return PromiseReject(abortError);
|
|
|
|
const now = timersBinding.getLibuvNow();
|
|
const canInvoke = pendingCount() === 0 &&
|
|
activeCount < concurrency &&
|
|
hasCapacity(now);
|
|
if (!canInvoke &&
|
|
(overflow === 'drop' || pendingCount() >= maxPending)) {
|
|
return drop();
|
|
}
|
|
|
|
const call = PromiseWithResolvers();
|
|
call.args = callArgs;
|
|
|
|
if (canInvoke) {
|
|
reserve(now);
|
|
invoke(call);
|
|
} else {
|
|
ArrayPrototypePush(pendingCalls, call);
|
|
schedule();
|
|
}
|
|
|
|
return call.promise;
|
|
}
|
|
|
|
function createFn(value) {
|
|
return {
|
|
__proto__: null,
|
|
configurable: true,
|
|
enumerable: true,
|
|
writable: true,
|
|
value,
|
|
};
|
|
}
|
|
|
|
ObjectDefineProperties(throttled, {
|
|
__proto__: null,
|
|
cancel: createFn(cancel),
|
|
hasImmediateCapacity: createFn(hasImmediateCapacity),
|
|
ref: createFn(ref),
|
|
unref: createFn(unref),
|
|
pending: {
|
|
__proto__: null,
|
|
enumerable: false,
|
|
get() {
|
|
return pendingCount() === 0 ? null :
|
|
pendingCalls[pendingCalls.length - 1].promise;
|
|
},
|
|
},
|
|
pendingCount: {
|
|
__proto__: null,
|
|
enumerable: false,
|
|
get: pendingCount,
|
|
},
|
|
activeCount: {
|
|
__proto__: null,
|
|
enumerable: false,
|
|
get() {
|
|
return activeCount;
|
|
},
|
|
},
|
|
length: {
|
|
__proto__: null,
|
|
configurable: true,
|
|
value: fn.length,
|
|
},
|
|
name: {
|
|
__proto__: null,
|
|
configurable: true,
|
|
value: fn.name,
|
|
},
|
|
});
|
|
|
|
if (signal !== undefined) {
|
|
addAbortListener(signal, abort);
|
|
}
|
|
|
|
return throttled;
|
|
}
|
|
|
|
module.exports = throttle;
|