@cloud-copilot/job
Version:
Async job runners with defined worker pools
152 lines • 4.98 kB
JavaScript
/**
* Creates a queue that runs jobs concurrently up to a specified limit.
* This will wait for jobs to be added to it and run them up the
* maximum concurrency.
*
* Results are available via `getResults()`.
*/
export class ConcurrentJobQueue {
/**
* Create a new runner with the specified concurrency.
*
* @param concurrency - The maximum number of jobs to run concurrently.
*/
constructor(concurrency, logger) {
this.concurrency = concurrency;
this.logger = logger;
this.queue = [];
this.results = [];
this.activeJobs = 0;
this.waitingResolvers = [];
this.workers = [];
this.isAcceptingWork = true;
this.workAvailablePromise = null;
this.resolveWorkAvailable = null;
}
async worker(workerId) {
while (this.isAcceptingWork || this.queue.length > 0) {
const job = this.queue.shift();
if (!job) {
if (!this.isAcceptingWork) {
// No longer accepting work and no jobs left, exit immediately
return;
}
// No work available, wait for new work to be added
await this.waitForWorkAvailable();
continue;
}
this.activeJobs++;
const context = { workerId };
const startTime = Date.now();
const interval = setInterval(() => {
this.logger.warn(`Long-running job detected.`, { minutes: Math.floor((Date.now() - startTime) / 60000) }, { ...context, ...job.properties });
}, 60000);
try {
const value = await job.execute({ ...context, properties: job.properties });
this.results.push({ status: 'fulfilled', value, properties: job.properties });
}
catch (reason) {
this.results.push({ status: 'rejected', reason, properties: job.properties });
}
finally {
clearInterval(interval);
this.activeJobs--;
this.checkIfIdle();
}
}
}
waitForWorkAvailable() {
if (!this.workAvailablePromise) {
this.workAvailablePromise = new Promise((resolve) => {
this.resolveWorkAvailable = resolve;
});
}
return this.workAvailablePromise;
}
ensureWorkers() {
if (this.workers.length === 0 && this.isAcceptingWork) {
for (let i = 0; i < this.concurrency; i++) {
this.workers.push(this.worker(i + 1));
}
}
}
notifyWorkersOfNewWork() {
// Wake up waiting workers
if (this.resolveWorkAvailable) {
this.resolveWorkAvailable();
this.workAvailablePromise = null;
this.resolveWorkAvailable = null;
}
}
checkIfIdle() {
if (this.activeJobs === 0 && this.queue.length === 0) {
// Notify all waiting resolvers
this.waitingResolvers.forEach((resolve) => resolve());
this.waitingResolvers = [];
}
}
/**
* Add a job to the queue
*/
enqueue(job) {
if (!this.isAcceptingWork) {
throw new Error('Cannot enqueue jobs after shutdown');
}
this.queue.push(job);
this.ensureWorkers();
this.notifyWorkersOfNewWork();
}
/**
* Add multiple jobs to the queue
*/
enqueueAll(jobs) {
jobs.forEach((job) => this.enqueue(job));
}
/**
* Returns a promise that resolves when all queued work is complete
*/
waitForIdle() {
// log.debug('waitForIdle called', this.activeJobs, this.queue.length)
return new Promise((resolve) => {
if (this.activeJobs === 0 && this.queue.length === 0) {
resolve();
}
else {
this.waitingResolvers.push(resolve);
}
});
}
/**
* Get all results accumulated so far
*/
getResults() {
return this.results;
}
/**
* Shutdown the queue - no new jobs will be accepted, but existing jobs will complete.
*
* Returns when a promise that resolves when all jobs have been processed and
* are available in `getResults()`.
*/
async finishAllWork() {
this.isAcceptingWork = false;
// Wake up any sleeping workers so they can process remaining jobs or exit
this.notifyWorkersOfNewWork();
// Check if we're already idle and notify any waiting resolvers
await Promise.all(this.workers);
this.workers = [];
}
/**
* Get the current queue length
*/
get queueLength() {
return this.queue.length;
}
/**
* Get the number of currently active jobs
*/
get activeJobCount() {
return this.activeJobs;
}
}
//# sourceMappingURL=ConcurrentJobQueue.js.map