UNPKG

prom-utils

Version:

Promise utilities: rate limiting, queueing/batching, defer, etc.

684 lines (683 loc) 24.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.raceTimeout = exports.TIMEOUT = exports.sleep = exports.waitUntil = exports.pacemaker = exports.pausable = exports.throughputLimiter = exports.getTimeframeUsingPeriod = exports.getTimeframeUsingElapsed = exports.rateLimit = exports.TimeoutError = exports.OptionsError = void 0; exports.batchQueue = batchQueue; exports.batchQueueParallel = batchQueueParallel; exports.defer = defer; const debug_1 = __importDefault(require("debug")); const lodash_1 = require("lodash"); const make_error_1 = __importDefault(require("make-error")); const obj_walker_1 = require("obj-walker"); const debugRL = (0, debug_1.default)('prom-utils:rateLimit'); const debugTL = (0, debug_1.default)('prom-utils:throughputLimiter'); const debugBQ = (0, debug_1.default)('prom-utils:batchQueue'); const debugBQP = (0, debug_1.default)('prom-utils:batchQueueParallel'); const debugPM = (0, debug_1.default)('prom-utils:pacemaker'); const debugWU = (0, debug_1.default)('prom-utils:waitUntil'); const debugP = (0, debug_1.default)('prom-utils:pausable'); // Error classes exports.OptionsError = (0, make_error_1.default)('OptionsError'); exports.TimeoutError = (0, make_error_1.default)('TimeoutError'); /** * Limit the concurrency of promises. This can be used to control * how many requests are made to a server, for example. Note: * exceptions will be swallowed in order to prevent an UnhandledPromiseRejection * from being thrown in the case where the promise rejects before the limit is * reached. Therefore, you must handle exceptions on a per promise basis. * Wrapping `rateLimit` method calls in a try/catch will not work. You can * set `limit` to Infinity to disregard the limit. * * To limit the promises for a given period of time, use the `maxItemsPerPeriod` * option. Optionally, specify a time period using the `period` option (default is 1 second). * For example, the following limits the number of concurrent requests to 5 * and ensures that the rate never exceeds 75 requests per minute. * * @example * ```typescript * const limiter = rateLimit(5, { maxItemsPerPeriod: 75, period: ms('1m') }) * for (const url of urls) { * // Will wait for one promise to finish if limit is reached * await limiter.add(fetch(url)) * } * // Wait for unresolved promises to resolve * await limiter.finish() * ``` */ const rateLimit = (limit, options = {}) => { const { maxItemsPerPeriod } = options; debugRL('init - limit %d', limit); debugRL('init - maxItemsPerPeriod %d', maxItemsPerPeriod); // Set of promises const set = new Set(); // Default period to 1 second const period = options.period || 1000; // Items/period limiter const itemsLimiter = (0, exports.throughputLimiter)(maxItemsPerPeriod ?? Infinity, { // Allow for high throughput at the start of the period getTimeframe: exports.getTimeframeUsingPeriod, // Expire items after the period expireAfter: period, period, // Ensure that the sliding window accurately captures all items for the period maxWindowLength: maxItemsPerPeriod, ...options, }); /** * Add a promise. Waits for one promise to resolve if limit is met or for * throughput to drop below threshold if `maxItemsPerPeriod` is set. * Optionally, set `bypass` to true to bypass async waiting. */ const add = async (prom, options = {}) => { // Add to set set.add(prom); debugRL('add called. set size: %d', set.size); // Create a child promise // See: https://runkit.com/dubiousdavid/handling-promise-rejections prom.then(() => { debugRL('resolved'); // Remove from the set set.delete(prom); }, // Handle the exception so we don't throw an UnhandledPromiseRejection exception () => { debugRL('rejected'); // Remove from the set set.delete(prom); }); // Bypass async waiting if (options.bypass) { return; } // Apply throughput limiter if (maxItemsPerPeriod) { // Wait for the throughput to drop below threshold for items/period await itemsLimiter.appendAndThrottle(1); } // Limit was reached if (set.size === limit) { debugRL('limit reached: %d', limit); // Wait for one item to finish await Promise.race(set); } }; /** * items/period */ const getStats = () => ({ itemsPerPeriod: itemsLimiter.getCurrentRate(), }); /** * Wait for all promises to resolve */ const finish = async () => { debugRL('finish'); await Promise.allSettled(set); }; return { add, finish, /** Number of pending promises. */ get length() { return set.size; }, getStats, }; }; exports.rateLimit = rateLimit; /** * Return the elapsed time since the first entry in the sliding window. * This evenly distributes the rate over the period. */ const getTimeframeUsingElapsed = (slidingWindow) => { const { timestamp } = slidingWindow[0]; return new Date().getTime() - timestamp; }; exports.getTimeframeUsingElapsed = getTimeframeUsingElapsed; /** * Return the elapsed time since the first entry in the sliding window or the period, * whichever is greater. This allows for high throughput at the start of the period. */ const getTimeframeUsingPeriod = (slidingWindow, { period }) => { const { timestamp } = slidingWindow[0]; const elapsedSinceStartOfWindow = new Date().getTime() - timestamp; return Math.max(period, elapsedSinceStartOfWindow); }; exports.getTimeframeUsingPeriod = getTimeframeUsingPeriod; const getTLDefaults = (maxUnitsPerPeriod, options) => { const _options = { period: 1000, minWindowLength: 1, expireAfter: Infinity, getTimeframe: exports.getTimeframeUsingElapsed, ...options, }; const minWindowLength = _options.minWindowLength; const _maxWindowLength = options.maxWindowLength || 3; // Ensure that maxWindowLength is at least minWindowLength const maxWindowLength = _maxWindowLength < minWindowLength ? minWindowLength : _maxWindowLength; return { ..._options, maxWindowLength, // Ensure the sleep time is granular enough but between 1 and 500 ms sleepTime: (0, lodash_1.clamp)(_options.period / maxUnitsPerPeriod, 1, 500), }; }; /** * * Limit throughput by sleeping until the rate (units/period) * is less than `maxUnitsPerPeriod`. Units and period are * intentionally abstract since it could represent requests/min or bytes/sec, * for example. * * @example * ```typescript * // Limit to at most 1000 items/sec * const limiter = throughputLimiter(1000) * * for(const batch of batches) { * // Will wait until the rate is < `maxUnitsPerPeriod` * await limiter.throttleAndAppend(batch.length) * console.log('Items/sec %d', limiter.getCurrentRate()) * } * ``` */ const throughputLimiter = (maxUnitsPerPeriod, options = {}) => { const slidingWindow = []; const optionsWithDefaults = getTLDefaults(maxUnitsPerPeriod, options); const { period, minWindowLength, maxWindowLength, sleepTime, expireAfter, getTimeframe, } = optionsWithDefaults; debugTL('init - maxUnitsPerPeriod %d', maxUnitsPerPeriod); debugTL('init - period %d ms', period); debugTL('init - minWindowLength %d', minWindowLength); debugTL('init - maxWindowLength %d', maxWindowLength); debugTL('init - sleepTime %d ms', sleepTime); debugTL('init - expireAfter %d ms', expireAfter); if (maxWindowLength === Infinity && expireAfter === Infinity) { throw new exports.OptionsError('maxWindowLength and expireAfter cannot both be Infinity'); } /** * Remove expired invocations from the sliding window. */ const cleanupExpired = () => { debugTL('cleanupExpired called'); // Remove expired invocations if (expireAfter !== Infinity) { // Get the current time const now = new Date().getTime(); // Remove invocations that are older than expireAfter while (now - slidingWindow[0]?.timestamp > expireAfter) { const shifted = slidingWindow.shift(); debugTL('removed expired: %o', shifted); } } }; /** * Get the current rate (units/period). The rate is determined by averaging the * values in the sliding window where the elapsed time is determined by * comparing the first entry in the window to the current time. * * @returns The current rate (units/period). Rate will be zero if the window * length is less than `minWindowLength`. */ const getCurrentRate = () => { debugTL('getCurrentRate called'); // Remove expired invocations cleanupExpired(); // Calculate the rate if (slidingWindow.length >= minWindowLength) { const numUnits = (0, lodash_1.sumBy)(slidingWindow, 'numUnits'); const timeframe = getTimeframe(slidingWindow, optionsWithDefaults); debugTL('total units %d', numUnits); debugTL('timeframe %d ms', timeframe); const rate = numUnits / (timeframe / period); debugTL('current rate %d units/period', rate); return rate; } debugTL('current rate 0'); return 0; }; /** * Call before processing a batch of units. After the first call, a subsequent * call assumes that the `numUnits` from the previous call were processed. A * call to `throttle` may sleep for a given period of time depending on * `maxUnitsPerPeriod` and the total number of units over the current window. */ const throttle = async () => { debugTL('throttle called'); // Skip check if maxUnitsPerPeriod is Infinity if (maxUnitsPerPeriod === Infinity) { debugTL('exiting throttle - maxUnitsPerPeriod is Infinity'); return; } let throttleTime = 0; // Check the rate, sleep, and repeat until the rate is less than // maxUnitsPerPeriod while (getCurrentRate() >= maxUnitsPerPeriod) { debugTL('sleeping for %d ms', sleepTime); await (0, exports.sleep)(sleepTime); throttleTime += sleepTime; } debugTL('throttled for %d ms', throttleTime); }; /** * Append the number of units to the sliding window. Throttle * must be called separately to ensure that the rate stays below * `maxUnitsPerPeriod`. */ const append = (numUnits) => { debugTL('append called with %d unit(s)', numUnits); // Get the current time const now = new Date().getTime(); // Add the current invocation to the sliding window slidingWindow.push({ timestamp: now, numUnits }); // Truncate the sliding window according to the window length if (slidingWindow.length > maxWindowLength) { const shifted = slidingWindow.shift(); debugTL('removed due to length: %o', shifted); } debugTL('slidingWindow: %o', slidingWindow); }; /** * This method is a combination of `throttle` and `append`. It will throttle * first and then append the number of units to the sliding window. */ const throttleAndAppend = async (numUnits) => { await throttle(); append(numUnits); }; /** * This method is a combination of `append` and `throttle`. It will append * the number of units to the sliding window and then throttle. */ const appendAndThrottle = async (numUnits) => { append(numUnits); await throttle(); }; return { getCurrentRate, throttle, append, throttleAndAppend, appendAndThrottle, }; }; exports.throughputLimiter = throughputLimiter; /** * Batch calls via a local queue. This can be used to batch values before * writing to a database, for example. * * Calls `fn` when either `batchSize`, `batchBytes`, or `timeout` is reached. * `batchSize` defaults to 500 and therefore will always be in effect if * no options are provided. You can pass `Infinity` to disregard `batchSize`. * If `timeout` is passed, the timer will be started when the first item is * enqueued and reset when `flush` is called explicitly or implicitly. * * Use `maxItemsPerSec` and/or `maxBytesPerSec` to limit throughput. * Call `queue.getStats()` to get the items/sec and bytes/sec rates. * * Call `queue.flush()` to flush explicitly. * * The last result of calling `fn` can be obtained by referencing `lastResult` * on the returned object. * * The cause of the last automatic queue flush can be obtained by referencing * `lastFlush` on the returned object. * * ```typescript * const writeToDatabase = async (records) => {...} * * const queue = batchQueue(writeToDatabase) * for (const record of records) { * // Will call `fn` when a threshold is met * await queue.enqueue(record) * } * // Call `fn` with remaining queued items * await queue.flush() * ``` */ function batchQueue(fn, options = {}) { const { batchSize = 500, batchBytes, timeout, maxItemsPerSec = Infinity, maxBytesPerSec = Infinity, } = options; debugBQ('options %o', options); let queue = []; let timeoutId; let prom; let bytes = 0; // Limiters const itemsLimiter = (0, exports.throughputLimiter)(maxItemsPerSec); const bytesLimiter = (0, exports.throughputLimiter)(maxBytesPerSec); /** * Call fn on queue and clear the queue. A delay may occur before fn is * called if `maxItemsPerSec` or `maxBytesPerSec` are set and one of the * rates is above the given threshold. */ const flush = async () => { debugBQ('flush called - queue length %d', queue.length); // Clear the timeout clearTimeout(timeoutId); debugBQ('clearTimeout called'); // Wait for a timeout initiated flush to complete await prom; // Queue is not empty if (queue.length) { // Wait for the throughput to drop below thresholds for items/sec // and bytes/sec limiters. await Promise.all([itemsLimiter.throttle(), bytesLimiter.throttle()]); // Call fn with queue const result = await fn(queue); debugBQ('fn called'); // Append the number of items and bytes to the limiters itemsLimiter.append(queue.length); bytesLimiter.append(bytes); // Set the last result obj.lastResult = result; // Reset the queue queue = []; // Reset the size bytes = 0; debugBQ('queue reset'); } }; /** * Enqueue an item. If the batch size is reached wait * for queue to be flushed. */ const enqueue = async (item) => { debugBQ('enqueue called'); // Wait for a timeout initiated flush to complete await prom; // Start a timer if timeout is set and the queue is empty if (timeout && queue.length === 0) { timeoutId = setTimeout(() => { debugBQ('setTimeout cb'); obj.lastFlush = { timeout }; prom = flush(); }, timeout); debugBQ('setTimeout called'); } // Add item to queue queue.push(item); // Calculate total bytes if a bytes-related option is set if (batchBytes || maxBytesPerSec < Infinity) { bytes += (0, obj_walker_1.size)(item); debugBQ('bytes %d', bytes); } // Batch size reached if (queue.length === batchSize) { debugBQ('batchSize reached %d', queue.length); obj.lastFlush = { batchSize }; // Wait for queue to be flushed await flush(); } // Batch bytes reached else if (batchBytes && bytes >= batchBytes) { debugBQ('batchBytes reached %d', bytes); obj.lastFlush = { batchBytes }; // Wait for queue to be flushed await flush(); } }; /** * Get stats for the two limiters. These will be zero if the * corresponding option is not enabled. * @returns The current items/sec and bytes/sec values. */ const getStats = () => ({ itemsPerSec: itemsLimiter.getCurrentRate(), bytesPerSec: bytesLimiter.getCurrentRate(), }); const obj = { flush, enqueue, getStats, get length() { return queue.length; }, }; return obj; } /** * Batch calls via a local queue. This can be used to batch values before * writing to a database, for example. Unlike `batchQueue`, this is safe to * be called concurrently. In particular, you can pair `rateLimit` with this. * * Calls `fn` when either `batchSize` or `batchBytes` is reached. * `batchSize` defaults to 500 and therefore will always be in effect if * no options are provided. You can pass `Infinity` to disregard `batchSize`. * * Call `queue.flush()` to flush explicitly. */ function batchQueueParallel(fn, options = {}) { const { batchSize = 500, batchBytes } = options; debugBQP('options %o', options); let queue = []; let bytes = 0; const results = []; /** * Call fn on queue and clear the queue */ const flush = () => { debugBQP('flush called - queue length %d', queue.length); // Queue is not empty if (queue.length) { // Call fn with queue results.push(fn(queue)); debugBQP('fn called'); // Reset the queue queue = []; // Reset the size bytes = 0; debugBQP('queue reset'); } }; /** * Enqueue an item. If a threshold is reached flush queue immediately. */ const enqueue = (item) => { debugBQP('enqueue called'); // Add item to queue queue.push(item); // Calculate total bytes if a bytes-related option is set if (batchBytes) { bytes += (0, obj_walker_1.size)(item); debugBQP('bytes %d', bytes); } // Batch size reached if (queue.length === batchSize) { debugBQP('batchSize reached %d', queue.length); obj.lastFlush = { batchSize }; // Flush queue flush(); } // Batch bytes reached else if (batchBytes && bytes >= batchBytes) { debugBQP('batchBytes reached %d', bytes); obj.lastFlush = { batchBytes }; // Flush queue flush(); } }; const obj = { flush, enqueue, results, get length() { return queue.length; }, }; return obj; } /** * Defer resolving a promise until `done` is called. */ function defer() { let done = () => { }; const promise = new Promise((resolve) => { // Swap original done fn with promise resolve fn done = () => resolve(); }); return { done, promise, }; } /** * Pause a loop by awaiting `maybeBlock`. When `pause` is called `maybeBlock` will * return a promise that is resolved when `resume` is called. Otherwise, * `maybeBlock` will return immediately. If `timeout` is passed, `resume` will * be called after `timeout` if it is not manually called first. * * ```typescript * const shouldProcess = pausable() * * onSomeCondition(shouldProcess.pause) * onSomeOtherCondition(shouldProcess.resume) * * for (const record of records) { * await shouldProcess.maybeBlock() * await processRecord(record) * } * ``` */ const pausable = (timeout) => { let deferred; let timeoutId; let isPaused = false; /** * Change the state to pause. If timeout is passed, that will change * the state to resume for each call to pause after the specified timeout. */ const pause = () => { debugP('pause called'); deferred = defer(); if (timeout) { timeoutId = setTimeout(() => { debugP('timeout'); resume(); }, timeout); debugP('setTimeout called'); } isPaused = true; }; /** * Change the state to resume. */ const resume = () => { debugP('resume called'); if (timeout) { clearTimeout(timeoutId); debugP('timeout cleared'); } deferred?.done(); isPaused = false; }; /** * Should be awaited in a loop. Will block when in a pause state. */ const maybeBlock = () => deferred?.promise; return { pause, resume, maybeBlock, get isPaused() { return isPaused; }, }; }; exports.pausable = pausable; /** * Call heartbeatFn every interval until promise resolves or rejects. * `interval` defaults to 1000. * @returns The value of the resolved promise. */ const pacemaker = async (heartbeatFn, promise, interval = 1000) => { const intervalId = setInterval(heartbeatFn, interval); try { return await promise; } finally { clearInterval(intervalId); debugPM('interval cleared'); } }; exports.pacemaker = pacemaker; /** * Wait until the predicate returns truthy or the timeout expires. * Will not hang like other implementations found on NPM. * Inspired by https://www.npmjs.com/package/async-wait-until * @returns A promise that resolves or rejects, accordingly. * * @example * ```typescript * let isTruthy = false * setTimeout(() => { isTruthy = true }, 250) * await waitUntil(() => isTruthy) * ``` */ const waitUntil = (pred, options = {}) => new Promise((resolve, reject) => { const checkFrequency = options.checkFrequency || 50; const timeout = options.timeout || 5000; let checkTimer; let timeoutTimer; // Start timeout timer if `timeout` is not set to Infinity if (timeout !== Infinity) { timeoutTimer = setTimeout(() => { debugWU('timeout'); clearTimeout(checkTimer); reject(new exports.TimeoutError(`Did not complete in ${timeout} ms`)); }, timeout); } /** * Check the predicate for truthiness. */ const check = async () => { debugWU('check called'); try { if (await pred()) { debugWU('pred returned truthy'); clearTimeout(checkTimer); clearTimeout(timeoutTimer); resolve(); } else { checkLater(); } } catch (e) { reject(e); } }; /** * Check the predicate after `checkFrequency`. */ const checkLater = () => { debugWU('checkLater called'); checkTimer = setTimeout(check, checkFrequency); }; check(); }); exports.waitUntil = waitUntil; /** * Sleep for `time` ms before resolving the Promise. */ const sleep = (time = 0) => new Promise((resolve) => setTimeout(resolve, time)); exports.sleep = sleep; exports.TIMEOUT = Symbol('TIMEOUT'); /** * Returns the value of the promise if the promise resolves prior to timeout. * If the timeout happens first, the exported TIMEOUT symbol is returned. * * @example * ```ts * const winner = await raceTimeout(someProm, 5) * if (winner === TIMEOUT) { * // Do something * } * ``` */ const raceTimeout = (prom, timeout) => Promise.race([ prom, new Promise((resolve) => setTimeout(() => resolve(exports.TIMEOUT), timeout)), ]); exports.raceTimeout = raceTimeout;