UNPKG

concurrent-promise-queue

Version:

Allows promises to be queued up and executed at a maximum rate defined by time or max concurrency

273 lines (239 loc) 11.1 kB
import { v4 as uuid } from 'uuid'; export type PromiseSupplier<T> = () => Promise<T>; type PromiseExecutionListener<T> = (result:FinishedPromiseResult<T>) => void; interface FinishedPromiseResult<T> { isSuccess:boolean, result:T, error:any, } interface QueuedPromise<T> { id:string, promiseSupplier:PromiseSupplier<T> promise:Promise<T>|null, result:FinishedPromiseResult<T>|null, listener:PromiseExecutionListener<T> dependencies:Set<string>, } export interface QueueOptions { /** * The maximum concurrency factor for the queue. * This will throttle the number of promises that can be processed at one time. * Defaults to 1000 if not specified. */ maxNumberOfConcurrentPromises?:number, /** * The unit of time for rate limiting, in milliseconds. * This will decide how large the time window is that promises are throttled in. * Defaults to 100ms if not specified. */ unitOfTimeMillis?:number, /** * The maximum number of promises to process in the rate limiting time window. * Defaults to 1000 if not specified. */ maxThroughputPerUnitTime?:number, /** * When true, the queue will resolve promises currently running before moving on to more promises. * This guarantees that all promises will resolve in the order that they were added to the queue, * however, in order to fulfil this guarantee, the queue delays resolving promises that finished earlier. * This will mean that the queue will hold references to objects that could have been resolved earlier. * * The queue will still execute promises according to the capacity configured for the queue, but the resolution of the promise returned from the queue will change * * By default, this option is false, */ resolveInOrder?:boolean, } export class ConcurrentPromiseQueue { private readonly maxNumberOfConcurrentPromises:number; private readonly unitOfTimeMillis:number; private readonly maxThroughputPerUnitTime:number; private readonly resolveInOrder:boolean; private promisesToExecute:Array<QueuedPromise<any>>; private promisesBeingExecuted:{[id:string]: QueuedPromise<any>}; private promisesWaitingToResolve:{[id:string]: QueuedPromise<any>}; private promiseExecutedCallbacks:{[id:string]:PromiseExecutionListener<any>}; private promiseCompletedTimesLog:Date[]; private reattemptTimeoutId:NodeJS.Timeout|null; private previousPromiseId:string|null = null; constructor(options:QueueOptions) { this.maxNumberOfConcurrentPromises = options.maxNumberOfConcurrentPromises ?? 1000; this.unitOfTimeMillis = options.unitOfTimeMillis ?? 100; this.maxThroughputPerUnitTime = options.maxThroughputPerUnitTime ?? 1000; this.resolveInOrder = options.resolveInOrder ?? false; this.promisesToExecute = []; this.promisesBeingExecuted = {}; this.promisesWaitingToResolve = {}; this.promiseExecutedCallbacks = {}; this.promiseCompletedTimesLog = []; this.reattemptTimeoutId = null; } numberOfQueuedPromises():number { return this.promisesToExecute.length } numberOfExecutingPromises():number { return Object.keys(this.promisesBeingExecuted).length; } /** * The queue takes a function that returns a promise. * This function will be called at the point where the promise is going to be executed. * * @param promiseSupplier - A function that returns a promise. */ addPromise<T>(promiseSupplier:PromiseSupplier<T>):Promise<T> { // return a promise that will complete when the promise from the promise supplier has been run. return new Promise(((resolve, reject) => { // add the promise to list of promises to be executed and also register a callback with the same id // so that when this promise has been executed, we can call the callback and resolve the promise to return to the caller const id = uuid(); const listener = (result:FinishedPromiseResult<T>) => { delete this.promisesWaitingToResolve[id] if (result.isSuccess) { resolve(result.result); } else { reject(result.error); } }; const dependencies = new Set<string>() if (this.resolveInOrder) { // if we need to resolve in the order promises were added // check to see if the previously added promise ID still matches a promise that is running or going to run // if so, add a dependency so this promise can only resolve once the previous one has resolved const isPreviousPromiseStillActive = !!this.previousPromiseId && ( this.promisesToExecute.some(pr => pr.id === this.previousPromiseId) || !!this.promisesBeingExecuted[this.previousPromiseId] ) if (isPreviousPromiseStillActive && this.previousPromiseId) { dependencies.add(this.previousPromiseId) } } this.promisesToExecute.push({ id, promiseSupplier, listener, dependencies, promise: null, result: null, }); this.promiseExecutedCallbacks[id] = listener this.previousPromiseId = id; // call execute to kick off the processing of promises if it hasn't already started. this.execute(); })); } private onPromiseFinished(id:string) { // get the current state of this promise const thisPromise = this.promisesBeingExecuted[id] ?? this.promisesWaitingToResolve[id]; // if this promise does not exist or is not ready to resolve yet, end // this promise will be resolved when one of its dependencies finishes if (!thisPromise || thisPromise.dependencies.size > 0 || !thisPromise.result) { return } // otherwise, resolve this promise and go on to remove it as a dependency from all other promises thisPromise.listener(thisPromise.result); const promisesThatDependOnThisPromise = this.promisesToExecute .concat(Object.values(this.promisesBeingExecuted)) .concat(Object.values(this.promisesWaitingToResolve)) .filter(pr => pr.dependencies.has(id)) promisesThatDependOnThisPromise.forEach(otherPromise => { // remove the promise that has just resolved from the dependencies // the other promise no longer depends on it because it is now done otherPromise.dependencies.delete(id) // if the other promise has finished, call this function to retry calling its listeners and the listeners that depend on it if (otherPromise.result) { this.onPromiseFinished(otherPromise.id) } }) } execute():void { // check to see if we have anything to execute if (this.promisesToExecute.length === 0) { return; } // check to see how many promises have been run in the last unit of time const now:Date = new Date(); const startOfTimeUnit:Date = new Date(now.getTime() - this.unitOfTimeMillis); const promisesFinishedInLastUnitTime:Array<Date> = this.promiseCompletedTimesLog.filter(time => { return time.getTime() >= startOfTimeUnit.getTime() }); const numberOfPromisesFinishedInLastUnitTime:number = promisesFinishedInLastUnitTime.length; const numberOfPromisesBeingExecuted:number = Object.keys(this.promisesBeingExecuted).length; const numberOfPromisesLeftInConcurrencyLimit:number = this.maxNumberOfConcurrentPromises - numberOfPromisesBeingExecuted; const numberOfPromisesLeftInRateLimit:number = this.maxThroughputPerUnitTime - numberOfPromisesFinishedInLastUnitTime - numberOfPromisesBeingExecuted; const numberOfPromisesToStart:number = Math.min(numberOfPromisesLeftInConcurrencyLimit, numberOfPromisesLeftInRateLimit); if (numberOfPromisesToStart <= 0) { // if we are not starting any more promises, we should check to see if we are going to start more later if (!this.reattemptTimeoutId) { // given we are in the situation where no more promises are being started, we need to decide how long to wait const periodToWaitToReattemptPromisesMillis:number = numberOfPromisesFinishedInLastUnitTime > 0 ? now.getTime() - promisesFinishedInLastUnitTime[0].getTime() : this.unitOfTimeMillis; this.reattemptTimeoutId = setTimeout(() => { this.reattemptTimeoutId = null; this.execute(); }, periodToWaitToReattemptPromisesMillis); } return; } // if we can run more promises, run more promises until we hit the max or run out of promises for (let count:number = 0; count < numberOfPromisesToStart; count++) { const nextPromiseToStart:QueuedPromise<any>|undefined = this.promisesToExecute.shift(); if (!nextPromiseToStart) { return; } const id = nextPromiseToStart.id; const promiseExecutionListener = this.promiseExecutedCallbacks[id]; if (!promiseExecutionListener) { continue; } // run the promise and pass the result back to the callback associated with this promise const newlyStartedPromise = nextPromiseToStart.promiseSupplier() this.promisesBeingExecuted[id] = { id, promiseSupplier: nextPromiseToStart.promiseSupplier, promise: newlyStartedPromise, dependencies: nextPromiseToStart.dependencies, result: null, listener: promiseExecutionListener, } newlyStartedPromise .then(res => { return { isSuccess: true, result: res, error: null, } }) .catch(err => { return { isSuccess: false, result: null, error: err, } }) .then((res:FinishedPromiseResult<any>) => { // get the current state of the resolved promise const thisPromise = this.promisesBeingExecuted[id] thisPromise.result = res; // this promise has been executed, so remove it from the callbacks and currently executing promises // they are removed from executing promises before they are resolved to allow more work to be done even if their final resolution has not done yet delete this.promiseExecutedCallbacks[id]; delete this.promisesBeingExecuted[id]; this.promisesWaitingToResolve[id] = thisPromise; this.onPromiseFinished(id) }) .finally(() => { // eslint-disable-next-line no-shadow const now:Date = new Date(); // eslint-disable-next-line no-shadow const startOfTimeUnit:Date = new Date(now.getTime() - this.unitOfTimeMillis); this.promiseCompletedTimesLog.push(now); this.promiseCompletedTimesLog = this.promiseCompletedTimesLog.filter(time => { return time.getTime() >= startOfTimeUnit.getTime() }); this.execute(); }); } } }