@cloud-copilot/job
Version:
Async job runners with defined worker pools
150 lines • 5.29 kB
JavaScript
/**
* A queue that will run jobs concurrently using promises up to a specified limit.
* If no work is being done, it will wait for jobs to be added and run them up to the
* maximum concurrency.
*
* Results are not stored in this queue, but are processed immediately via the `onComplete` callback.
*
*/
export class StreamingJobQueue {
/**
* Create a new runner with the specified concurrency.
*
* @param concurrency - The maximum number of jobs to run concurrently.
* @param logger - Logger instance for logging long-running jobs.
* @param onComplete - Callback to handle job completion, receives the job result.
*/
constructor(concurrency, logger, onComplete) {
this.concurrency = concurrency;
this.logger = logger;
this.onComplete = onComplete;
this.queue = [];
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 })
await this.onComplete({ status: 'fulfilled', value, properties: job.properties });
}
catch (reason) {
// this.results.push({ status: 'rejected', reason, properties: job.properties })
await this.onComplete({ 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() {
return new Promise((resolve) => {
if (this.activeJobs === 0 && this.queue.length === 0) {
resolve();
}
else {
this.waitingResolvers.push(resolve);
}
});
}
/**
* 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
* the onComplete callback has been called for each job.
*/
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=StreamingJobQueue.js.map