@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
191 lines (161 loc) • 6.17 kB
text/typescript
import {callInNextEventLoop, nextEventLoop} from "../../util/eventLoop.js";
import {LinkedList} from "../array.js";
import {QueueError, QueueErrorCode} from "./errors.js";
import {JobQueueOpts, QueueMetrics, QueueType, defaultQueueOpts} from "./options.js";
/**
* JobQueue that stores arguments in the job array instead of closures.
* Supports a single itemProcessor, for arbitrary functions use the JobFnQueue
*/
// biome-ignore lint/suspicious/noExplicitAny: We need to use `any` type here
export class JobItemQueue<Args extends any[], R> {
private readonly opts: Required<JobQueueOpts>;
/**
* We choose to use LinkedList instead of regular array to improve shift() / push() / pop() performance.
* See the LinkedList benchmark for more details.
* */
private readonly jobs: LinkedList<{
args: Args;
addedTimeMs: number;
resolve: (result: R | PromiseLike<R>) => void;
reject: (error?: Error) => void;
}> = new LinkedList();
private readonly metrics?: QueueMetrics;
private runningJobs = 0;
private lastYield = 0;
/** Resolvers waiting for space in the queue */
private spaceWaiters: (() => void)[] = [];
constructor(
private readonly itemProcessor: (...args: Args) => Promise<R>,
opts: JobQueueOpts,
metrics?: QueueMetrics
) {
this.opts = {...defaultQueueOpts, ...opts};
this.opts.signal.addEventListener("abort", this.abortAllJobs, {once: true});
if (metrics) {
this.metrics = metrics;
metrics.length.addCollect(() => {
metrics.length.set(this.jobs.length);
metrics.concurrency.set(this.runningJobs);
});
}
}
get jobLen(): number {
return this.jobs.length;
}
push(...args: Args): Promise<R> {
if (this.opts.signal.aborted) {
throw new QueueError({code: QueueErrorCode.QUEUE_ABORTED});
}
if (this.jobs.length + 1 > this.opts.maxLength) {
this.metrics?.droppedJobs.inc();
if (this.opts.type === QueueType.LIFO) {
// In LIFO queues keep the latest job and drop the oldest
this.jobs.shift();
} else {
// In FIFO queues drop the latest job
throw new QueueError({code: QueueErrorCode.QUEUE_MAX_LENGTH});
}
}
return new Promise<R>((resolve, reject) => {
this.jobs.push({args, resolve, reject, addedTimeMs: Date.now()});
if (this.jobs.length === 1 && this.opts.noYieldIfOneItem) {
void this.runJob();
} else if (this.runningJobs < this.opts.maxConcurrency) {
callInNextEventLoop(this.runJob);
}
});
}
/**
* Returns a promise that resolves when there is space in the queue.
* If the queue already has space, resolves immediately (noop).
* Use this to apply backpressure when the caller should wait rather than
* have push() throw QUEUE_MAX_LENGTH.
*/
async waitForSpace(): Promise<void> {
if (this.opts.signal.aborted) {
throw new QueueError({code: QueueErrorCode.QUEUE_ABORTED});
}
if (this.jobs.length < this.opts.maxLength) {
return;
}
return new Promise<void>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) return;
settled = true;
const index = this.spaceWaiters.indexOf(wrappedResolve);
if (index >= 0) {
this.spaceWaiters.splice(index, 1);
}
reject(new QueueError({code: QueueErrorCode.QUEUE_ABORTED}));
};
const wrappedResolve = (): void => {
if (settled) return;
settled = true;
this.opts.signal.removeEventListener("abort", onAbort);
resolve();
};
this.spaceWaiters.push(wrappedResolve);
this.opts.signal.addEventListener("abort", onAbort, {once: true});
// Re-check after attaching listener to close the race window where
// signal.abort() fires between the initial check and addEventListener
if (this.opts.signal.aborted) onAbort();
});
}
getItems(): {args: Args; addedTimeMs: number}[] {
return this.jobs.map((job) => ({args: job.args, addedTimeMs: job.addedTimeMs}));
}
dropAllJobs = (): void => {
this.jobs.clear();
this.notifySpaceWaiters();
};
private runJob = async (): Promise<void> => {
if (this.opts.signal.aborted || this.runningJobs >= this.opts.maxConcurrency) {
return;
}
// Default to FIFO. LIFO -> pop() remove last item, FIFO -> shift() remove first item
const job = this.opts.type === QueueType.LIFO ? this.jobs.pop() : this.jobs.shift();
if (!job) {
return;
}
this.runningJobs++;
// If the job, metrics or any code below throws: the job will reject never going stale.
// Only downside is the job promise may be resolved twice, but that's not an issue
try {
const timer = this.metrics?.jobTime.startTimer();
this.metrics?.jobWaitTime.observe((Date.now() - job.addedTimeMs) / 1000);
const result = await this.itemProcessor(...job.args);
job.resolve(result);
if (timer) timer();
// Yield to the macro queue
if (Date.now() - this.lastYield > this.opts.yieldEveryMs) {
this.lastYield = Date.now();
await nextEventLoop();
}
} catch (e) {
job.reject(e as Error);
}
this.runningJobs = Math.max(0, this.runningJobs - 1);
// Notify any waiters that space is available
this.notifySpaceWaiters();
// Potentially run a new job
void this.runJob();
};
private notifySpaceWaiters(): void {
// Compute available slots once to avoid thundering herd: resolved waiters
// won't push() until the next microtask, so jobs.length doesn't change
// inside this loop. Without the cap we'd wake ALL waiters on a single slot.
let available = this.opts.maxLength - this.jobs.length;
while (available > 0 && this.spaceWaiters.length > 0) {
const resolve = this.spaceWaiters.shift();
if (resolve) resolve();
available--;
}
}
private abortAllJobs = (): void => {
while (this.jobs.length > 0) {
const job = this.jobs.pop();
if (job) job.reject(new QueueError({code: QueueErrorCode.QUEUE_ABORTED}));
}
};
}