UNPKG

@evanmpollack/ps-and-qs

Version:

An efficient promise pool implementation that provides control over the concurrency limit and execution order when running a series of asynchronous tasks.

501 lines (447 loc) 14.9 kB
const MESSAGE = 'Operation not allowed on queue of size 0'; class EmptyQueueError extends Error { constructor() { super(MESSAGE); } } /** * Partial implementation of a standard queue based on the Queue ADT * - Backed by singly linked list */ class Queue { #head; #tail; #size; /** * Gets the size of the queue. * * @returns {Number} */ get size() { return this.#size; } /** * Determines whether or not the queue is empty. * * @returns {Boolean} */ get empty() { return !this.size; } constructor() { this.#head = null; this.#tail = null; this.#size = 0; } /** * Creation method to build a queue from an iterable. * Maintains order of original iterable. * * @param {Iterable | AsyncIterable} iterable * @returns {Promise<Queue>} */ static async fromIterable(iterable) { const instance = new Queue(); for await (const item of iterable) { instance.enqueue(item); } return instance; } /** * Inserts an element at the rear of the queue. * * @param {any} data */ enqueue(data) { const node = { data: data, next: null }; if (this.#tail) this.#tail.next = node; this.#tail = node; this.#head = this.#head ?? this.#tail; this.#size++; } /** * Removes and returns the element from the front of the queue. * If queue is empty, an error is thrown. * * @returns {any} */ dequeue() { if (this.empty) throw new EmptyQueueError(); const { data, next } = this.#head; this.#head.next = null; if (this.#head === this.#tail) this.#tail = null; this.#head = next; this.#size--; return data; } /** * Performs traversal from the head. */ *[Symbol.iterator]() { let node = this.#head; while(node) { yield node.data; node = node.next; } } } /** * Partial implementation of a priority queue based on the Priority Queue ADT * - Backed by binary heap */ class PriorityQueue { #heap; #comparator; /** * Gets the size of the priority queue. */ get size() { return this.#heap.length; } /** * Determines whether or not the priority queue is empty. */ get empty() { return !this.size; } /** * Initializes an empty priority queue. Ordering is imposed by the * given comparator. * * @param {Function} comparator */ constructor(comparator) { this.#heap = []; this.#comparator = comparator; } /** * Creation method to build a priority queue from an iterable. * Ordering defined by comparator. * * @param {Iterable | AsyncIterable} iterable * @param {Function} comparator * @returns {Promise<PriorityQueue>} */ static async fromIterable(iterable, comparator) { const copy = []; for await (const item of iterable) { copy.push(item); } const instance = new PriorityQueue(comparator); instance.#heap = PriorityQueue.#heapify(copy, instance.#comparator); return instance; } /** * Inserts an element into the priority queue based on the given comparator. * * @param {any} data */ enqueue(data) { this.#heap.push(data); PriorityQueue.#siftUp(this.#heap, this.#comparator); } /** * Removes and returns the element with the most priority based on the given comparator. * * @returns {any} */ dequeue() { if (this.empty) throw new EmptyQueueError(); PriorityQueue.#swap(this.#heap, 0, this.#heap.length - 1); const last = this.#heap.pop(); PriorityQueue.#siftDown(this.#heap, 0, this.#comparator); return last; } /** * Applies the bottom-up heap construction algorithm to an array using the given comparator. * * @param {Array} array * @param {Function} comparator * @returns {Array} */ static #heapify(array, comparator) { let lastInternalIndex = Math.floor((array.length / 2)) - 1; for (let i=lastInternalIndex; i>=0; i--) PriorityQueue.#siftDown(array, i, comparator); return array; } /** * Moves the last element up the heap (decreases index/key) as long as it has a parent * and should be closer to the root of the heap than the parent according to the provided comparator. * * @param {Array} array * @param {Function} comparator */ static #siftUp(array, comparator) { let index = array.length - 1; const parentIndex = (index) => Math.floor((index - 1) / 2); // hasParent && currentValue > parent while(parentIndex(index) >= 0 && comparator(array[index], array[parentIndex(index)]) < 0) { this.#swap(array, index, parentIndex(index)); index = parentIndex(index); } } /** * Moves an element down the heap (increases index/key) as long as it has children * and should be further from the root of the heap than the selected child according * to the given comparator. * * @param {Array} array * @param {Number} index * @param {Function} comparator */ static #siftDown(array, index, comparator) { const leftChildIndex = (index) => (2 * index) + 1; const rightChildIndex = (index) => (2 * index) + 2; // hasLeftChild while (leftChildIndex(index) < array.length) { // nextChildIndex should always be the index of the child with the most priority based on the given comparator const nextChildIndex = (() => { // hasRightChild && rightChild > leftChild const useRightChildIndex = rightChildIndex(index) < array.length && comparator(array[rightChildIndex(index)], array[leftChildIndex(index)]) < 0; return (useRightChildIndex) ? rightChildIndex(index) : leftChildIndex(index); })(); // currentValue < nextChild if (comparator(array[index], array[nextChildIndex]) > 0) { this.#swap(array, index, nextChildIndex); index = nextChildIndex; } else { break; } } } /** * Swaps the values at the given indices in the array. * * @param {Array} array * @param {Number} i * @param {Number} j */ static #swap(array, i, j) { const temp = array[i]; array[i] = array[j]; array[j] = temp; } /** * Performs traversal using the ordering defined by the comparator. * * Note: sorting a heap is O(nlog(n)) regardless if you copy the heap and repeatedly dequeue * or if you sort the array using standard Array methods. * * Current Complexity: O(nlog(n) + O(n)) => O(nlog(n)) * * Copy approaches considered: * - Using fromIterator: * - Doesn't work without making priority queue async iterable * - Time Complexity: * - Heap Copy: O(2n) => O(n) * - Read every element: O(nlog(n)) * - Total: O(nlog(n)) * - Using Array.toSorted: * - Doesn't work with undefined, as comparator isn't applied to undefined values even if it's accounted for in comparator * - Time Complexity: * - Heap Copy: O(nlog(n)) * - Read every element: O(n) * - Total: O(nlog(n)) * - Creating an empty priority queue and enqueuing each item in array: * - Works well * - Time Complexity * - Heap Copy: O(n) * - Read every element: O(nlog(n)) * - Total: O(nlog(n)) */ *[Symbol.iterator]() { const copy = new PriorityQueue(this.#comparator); // O(n) because enqueue will be O(1), as no shifting will have to take place due to level order traversal property this.#heap.forEach(item => copy.enqueue(item)); while(!copy.empty) { yield copy.dequeue(); } } } class PoolExecutor { #queue; #concurrency; #results; /** * Checks if there are any tasks that haven't been executed yet. * * @returns {Boolean} */ get #tasksQueued() { return !this.#queue.empty; } /** * Initializes a PoolExecutor. * * @param {Queue | PriorityQueue} queue * @param {Number} concurrency */ constructor(queue, concurrency) { this.#queue = queue; this.#concurrency = concurrency; this.#results = []; } /** * Launches N tasks, where N is the minimum between the concurrency * limit and number of tasks. Each task recursively starts the next * one as long as there are tasks still in the queue. * * @returns {Promise<PromiseSettledResult[]>} */ async start() { const limit = Math.min(this.#concurrency, this.#queue.size); const executor = Array.from({ length: limit }, this.#execute.bind(this)); await Promise.all(executor); return this.#results; } /** * Dequeues, extracts, and runs a task. Appends the result to the result array. * Recursively executes until there are no more tasks in the queue. * * Note: If limit > 1, this function will be run concurrently with (limit - 1) other calls. * * @returns {Promise<void>} */ async #execute() { const next = this.#queue.dequeue(); const task = this.#getTask(next); const result = await this.#runTask(task); this.#results.push(result); if (this.#tasksQueued) return this.#execute(); } /** * Tries to get the task from the task property in the given element. * If it can't find the task property, it returns a task that will reject * when executed. If the task property is not a function, it returns a * task that will resolve when executed. * * Allows for additional control over result values/reasons when given an * invalid task. * * Note: invalid task means no task property or task property is not a function. * * @param {*} element task object * @returns {Function} task function */ #getTask(element) { let target = 'task'; let task; // Explicit check for null because typeof null === 'object' and calling in on null throws error // Uses in instead of hasOwnProperty to account for inherited task property if (element === null || typeof element !== 'object' || !(target in element)) { task = () => Promise.reject(`Cannot find ${target} property in ${JSON.stringify(element)}`); } else if (typeof element.task !== 'function') { task = () => Promise.resolve(element.task); } else { task = element.task; } return task; } /** * Runs a given task using the Promise API and returns the result. * * @param {Function} task task function * @returns {Promise<PromiseSettledResult>} task result */ async #runTask(task) { let result; try { result = await Promise.allSettled([task()]); } catch(e) { // Only runs if a synchronous task throws an error result = await Promise.allSettled([Promise.reject(e)]); } return result[0]; } } class PromisePoolError extends Error { constructor(message) { super(message); } } const DEFAULT_TASKS = []; const DEFAULT_CONCURRENCY = 100; const DEFAULT_PRIORITY = false; const DEFAULT_COMPARATOR = (taskA, taskB) => taskB.priority - taskA.priority; class PromisePool { #tasks; #concurrency; #priority; #comparator; set tasks(tasks) { const isIterable = typeof tasks?.[Symbol.iterator] === 'function' || typeof tasks?.[Symbol.asyncIterator] === 'function'; if (!isIterable) throw new PromisePoolError('Tasks must be an iterable'); this.#tasks = tasks; } get tasks() { return this.#tasks; } set concurrency(concurrency) { if (typeof concurrency !== 'number') throw new PromisePoolError('Concurrency must be a Number'); if (concurrency <= 0) throw new PromisePoolError('Concurrency limit must be greater than 0'); this.#concurrency = concurrency; } get concurrency() { return this.#concurrency; } set priority(priority) { if (typeof priority !== 'boolean') throw new PromisePoolError('Priority must be a Boolean'); this.#priority = priority; } get priority() { return this.#priority; } set comparator(comparator) { if (typeof comparator !== 'function') throw new PromisePoolError('Comparator must be a Function'); this.#comparator = comparator; } get comparator() { return this.#comparator; } constructor(tasks, { concurrency, priority, comparator }={}) { this.tasks = tasks ?? DEFAULT_TASKS; this.concurrency = concurrency ?? DEFAULT_CONCURRENCY; this.priority = priority ?? DEFAULT_PRIORITY; this.comparator = comparator ?? DEFAULT_COMPARATOR; } withTasks(tasks) { this.tasks = tasks; return this; } static withTasks(tasks) { return new this(tasks); } withConcurrency(limit) { this.concurrency = limit; return this; } static withConcurrency(limit) { return new this(undefined, { concurrency: limit }); } withPriority() { this.priority = true; return this; } static withPriority() { return new this(undefined, { priority: true }); } withComparator(comparator) { this.comparator = comparator; return this; } static withComparator(comparator) { return new this(undefined, { comparator: comparator }); } async start() { const queue = await ((this.priority) ? PriorityQueue.fromIterable(this.tasks, this.comparator) : Queue.fromIterable(this.tasks)); return await (new PoolExecutor(queue, this.concurrency)).start(); } } export { PromisePool as default };