UNPKG

@cloud-copilot/job

Version:

Async job runners with defined worker pools

152 lines 4.98 kB
/** * 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