@grammyjs/runner
Version:
Scale grammY bots that use long polling
263 lines (262 loc) • 9.97 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DecayingDeque = void 0;
// Maximal valid value that can be passed to `setTimeout`
const MAX_TIMEOUT_VALUE = ~(1 << 31); // equals (2 ** 31 - 1)
/**
* A _decaying deque_ is a special kind of doubly linked list that serves as a
* queue for a special kind of nodes, called _drifts_.
*
* A decaying deque has a worker function that spawns a task for each element
* that is added to the queue. This task then gets wrapped into a drift. The
* drifts are then the actual elements (aka. links) in the queue.
*
* In addition, the decaying deque runs a timer that purges old elements from
* the queue. This period of time is determined by the `taskTimeout`.
*
* When a task completes or exceeds its timeout, the corresponding drift is
* removed from the queue. As a result, only drifts with pending tasks are
* contained in the queue at all times.
*
* When a tasks completes with failure (`reject`s or exceeds the timeout), the
* respective handler (`catchError` or `catchTimeout`) is called.
*
* The decaying deque has its name from the observation that new elements are
* appended to the tail, and the old elements are removed at arbitrary positions
* in the queue whenever a task completes, hence, the queue seems to _decay_.
*/
class DecayingDeque {
/**
* Creates a new decaying queue with the given parameters.
*
* @param taskTimeout Max period of time for a task
* @param worker Task generator
* @param concurrency `add` will return only after the number of pending tasks fell below `concurrency`. `false` means `1`, `true` means `Infinity`, numbers below `1` mean `1`
* @param catchError Error handler, receives the error and the source element
* @param catchTimeout Timeout handler, receives the source element and the promise of the task
*/
constructor(taskTimeout, worker, concurrency, catchError, catchTimeout) {
this.taskTimeout = taskTimeout;
this.worker = worker;
this.catchError = catchError;
this.catchTimeout = catchTimeout;
/**
* Number of drifts in the queue. Equivalent to the number of currently
* pending tasks.
*/
this.len = 0;
/** Head element (oldest), `null` iff the queue is empty */
this.head = null;
/** Tail element (newest), `null` iff the queue is empty */
this.tail = null;
/**
* List of subscribers that wait for the queue to have capacity again. All
* functions in this array will be called as soon as new capacity is
* available, i.e. the number of pending tasks falls below `concurrency`.
*/
this.subscribers = [];
this.emptySubscribers = [];
if (concurrency === false)
this.concurrency = 1;
else if (concurrency === true)
this.concurrency = Infinity;
else
this.concurrency = concurrency < 1 ? 1 : concurrency;
}
/**
* Adds the provided elements to the queue and starts tasks for all of them
* immediately. Returns a `Promise` that resolves with `concurrency - length`
* once this value becomes positive.
* @param elems Elements to be added
* @returns `this.capacity()`
*/
add(elems) {
const len = elems.length;
this.len += len;
if (len > 0) {
let i = 0;
const now = Date.now();
// emptyness check
if (this.head === null) {
this.head = this.tail = this.toDrift(elems[i++], now);
// start timer because head element changed
this.startTimer();
}
let prev = this.tail;
while (i < len) {
// create drift from source element
const node = this.toDrift(elems[i++], now);
// link it to previous element (append operation)
prev.next = node;
node.prev = prev;
prev = node;
}
this.tail = prev;
}
return this.capacity();
}
empty() {
return new Promise((resolve) => {
if (this.len === 0)
resolve();
else
this.emptySubscribers.push(resolve);
});
}
/**
* Returns a `Promise` that resolves with `concurrency - length` once this
* value becomes positive. Use `await queue.capacity()` to wait until the
* queue has free space again.
*
* @returns `concurrency - length` once positive
*/
capacity() {
return new Promise((resolve) => {
const capacity = this.concurrency - this.len;
if (capacity > 0)
resolve(capacity);
else
this.subscribers.push(resolve);
});
}
/**
* Called when a node completed its lifecycle and should be removed from the
* queue. Effectively wraps the `remove` call and takes care of the timer.
*
* @param node Drift to decay
*/
decay(node) {
var _a;
// We only need to restart the timer if we decay the head element of the
// queue, however, if the next element has the same date as `node`, we can
// skip this step, too.
if (this.head === node && node.date !== ((_a = node.next) === null || _a === void 0 ? void 0 : _a.date)) {
// Clear previous timeout
if (this.timer !== undefined)
clearTimeout(this.timer);
// Emptyness check (do not start if queue is now empty)
if (node.next === null)
this.timer = undefined;
// Reschedule timer for the next node's timeout
else {
this.startTimer(node.next.date + this.taskTimeout - Date.now());
}
}
this.remove(node);
}
/**
* Removes an element from the queue. Calls subscribers if there is capacity
* after performing this operation.
*
* @param node Drift to remove
*/
remove(node) {
// Connecting the links of `prev` and `next` removes `node`
if (this.head === node)
this.head = node.next;
else
node.prev.next = node.next;
if (this.tail === node)
this.tail = node.prev;
else
node.next.prev = node.prev;
// Mark this drift as no longer contained
node.date = -1;
// Notify subscribers if there is capacity by now
const capacity = this.concurrency - --this.len;
if (capacity > 0) {
this.subscribers.forEach((resolve) => resolve(capacity));
this.subscribers = [];
}
// Notify subscribers if the queue is empty now
if (this.len === 0) {
this.emptySubscribers.forEach((resolve) => resolve());
this.emptySubscribers = [];
}
}
/**
* Takes a source element and starts the task for it by calling the worker
* function. Then wraps this task into a drift. Also makes sure that the drift
* removes itself from the queue once it completes, and that the error handler
* is invoked if it fails (rejects).
*
* @param elem Source element
* @param date Date when this drift is created
* @returns The created drift
*/
toDrift(elem, date) {
const node = {
prev: null,
task: this.worker(elem)
.catch(async (err) => {
// Rethrow iff the drift is no longer contained (timed out)
if (node.date > 0)
await this.catchError(err, elem);
else
throw err;
})
.finally(() => {
// Decay the node once the task completes (unless the drift was
// removed due to a timeout before)
if (node.date > 0)
this.decay(node);
}),
next: null,
date,
elem,
};
return node;
}
/**
* Starts a timer that fires off a timeout after the given period of time.
*
* @param ms Number of milliseconds to wait before the timeout kicks in
*/
startTimer(ms = this.taskTimeout) {
this.timer = ms > MAX_TIMEOUT_VALUE
? undefined
: setTimeout(() => this.timeout(), ms);
}
/**
* Performs a timeout event. This removes the head element as well as all
* subsequent drifts with the same date (added in the same millisecond).
*
* The timeout handler is called in sequence for every removed drift.
*/
timeout() {
var _a;
// Rare cases of the event ordering might fire a timeout even though the
// head element has just decayed.
if (this.head === null)
return;
while (this.head.date === ((_a = this.head.next) === null || _a === void 0 ? void 0 : _a.date)) {
this.catchTimeout(this.head.elem, this.head.task);
// No need to restart timer here, we'll modify head again anyway
this.remove(this.head);
}
this.catchTimeout(this.head.elem, this.head.task);
this.decay(this.head);
}
/**
* Number of pending tasks in the queue. Equivalent to
* `this.pendingTasks().length` (but much more efficient).
*/
get length() {
return this.len;
}
/**
* Creates a snapshot of the queue by computing a list of those elements that
* are currently being processed.
*/
pendingTasks() {
const len = this.len;
const snapshot = Array(len);
let drift = this.head;
for (let i = 0; i < len; i++) {
snapshot[i] = drift.elem;
drift = drift.next;
}
return snapshot;
}
}
exports.DecayingDeque = DecayingDeque;