@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
JavaScript
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 };