UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

272 lines (238 loc) 7.6 kB
import { $atom, $hook, $inject, $state, Alepha, type Static, type TSchema, z, } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $consumer } from "../primitives/$consumer.ts"; import { $queue, type QueueMessage, type QueuePrimitive, } from "../primitives/$queue.ts"; import { QueueProvider } from "./QueueProvider.ts"; // --------------------------------------------------------------------------------------------------------------------- /** * Queue worker configuration atom. */ export const queueWorkerOptions = $atom({ name: "alepha.queue.worker.options", schema: z.object({ interval: z .integer() .describe( "Interval in milliseconds to wait before checking for new messages.", ) .default(1000), maxInterval: z .integer() .describe( "Maximum interval in milliseconds to wait before checking for new messages.", ) .default(32000), concurrency: z .integer() .describe( "Number of workers to run concurrently. Useful only if you are doing a lot of I/O.", ) .default(1), }), default: { interval: 1000, maxInterval: 32000, concurrency: 1, }, }); export type QueueWorkerOptions = Static<typeof queueWorkerOptions.schema>; declare module "alepha" { interface State { [queueWorkerOptions.key]: QueueWorkerOptions; } } // --------------------------------------------------------------------------------------------------------------------- export class WorkerProvider { protected readonly log = $logger(); protected readonly options = $state(queueWorkerOptions); protected readonly alepha = $inject(Alepha); protected readonly queueProvider = $inject(QueueProvider); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected workerPromises: Array<Promise<void>> = []; protected workersRunning = 0; protected abortController: AbortController | undefined; protected workerIntervals: Record<number, number> = {}; protected consumers: Array<Consumer> = []; protected nextConsumerIndex = 0; public get isRunning(): boolean { return this.workersRunning > 0; } protected readonly start = $hook({ on: "start", priority: "last", handler: () => { for (const queue of this.alepha.primitives($queue)) { const handler = queue.options.handler; if (handler) { this.consumers.push({ handler, queue, }); } } for (const consumer of this.alepha.primitives($consumer)) { this.consumers.push({ queue: consumer.options.queue, handler: (msg) => consumer.handler.run(msg), }); } if (this.consumers.length > 0) { this.startWorkers(); this.log.debug( `Watching for ${this.consumers.length} queue${this.consumers.length > 1 ? "s" : ""} with ${this.options.concurrency} worker${ this.options.concurrency > 1 ? "s" : "" }.`, ); } }, }); // ------------------------------------------------------------------------------------------------------------------- // Engine part - this is the part that will run the workers and process the messages /** * Start the workers. * This method will create an endless loop that will check for new messages! */ protected startWorkers(): void { this.abortController ??= new AbortController(); const workerToStart = this.options.concurrency - this.workersRunning; for (let i = 0; i < workerToStart; i++) { this.workersRunning += 1; this.log.debug(`Starting worker n-${i}`); const workerLoop = async () => { while (this.workersRunning > 0) { this.log.trace(`Worker n-${i} is checking for new messages`); const next = await this.getNextMessage(); if (next) { this.workerIntervals[i] = 0; await this.processMessage(next); } else { await this.waitForNextMessage(i); } } this.log.info(`Worker n-${i} has stopped`); // Only decrement if we're not already at 0 (shutdown case) if (this.workersRunning > 0) { this.workersRunning -= 1; } }; this.workerPromises.push( workerLoop().catch((e) => { this.log.error(`Worker n-${i} has crashed`, e); // Always decrement on crash, regardless of shutdown state this.workersRunning = Math.max(0, this.workersRunning - 1); }), ); } } protected readonly stop = $hook({ on: "stop", handler: async () => { if (this.consumers.length > 0) { await this.stopWorkers(); } }, }); /** * Wait for the next message, where `n` is the worker number. * * This method will wait for a certain amount of time, increasing the wait time again if no message is found. */ protected async waitForNextMessage(n: number): Promise<void> { const intervals = this.workerIntervals; const milliseconds = intervals[n] || this.options.interval; this.log.trace(`Worker n-${n} is waiting for ${milliseconds}ms.`); if (this.abortController?.signal.aborted) { this.log.warn(`Worker n-${n} aborted.`); return; } await this.dateTimeProvider.wait(milliseconds, { signal: this.abortController?.signal, }); if (intervals[n]) { if (intervals[n] < this.options.maxInterval) { intervals[n] = intervals[n] * 2; } } else { intervals[n] = milliseconds; } } /** * Get the next message. */ protected async getNextMessage(): Promise<undefined | NextMessage> { const len = this.consumers.length; for (let i = 0; i < len; i++) { const idx = (this.nextConsumerIndex + i) % len; const consumer = this.consumers[idx]; const provider = consumer.queue.provider; const message = await provider.pop(consumer.queue.name); if (message) { this.nextConsumerIndex = (idx + 1) % len; return { message, consumer }; } } } /** * Process a message from a queue. */ protected async processMessage(response: { message: any; consumer: Consumer; }) { const { message, consumer } = response; try { const json = JSON.parse(message); const payload = this.alepha.codec.decode( consumer.queue.options.schema, json.payload, ); await this.alepha.context.run(() => consumer.handler({ payload })); } catch (e) { this.log.error("Failed to process message", e); } } /** * Stop the workers. * * This method will stop the workers and wait for them to finish processing. */ protected async stopWorkers() { this.workersRunning = 0; this.log.trace("Stopping workers..."); this.abortController?.abort(); this.log.trace("Waiting for workers to finish..."); await Promise.all(this.workerPromises); } /** * Force the workers to get back to work. */ public wakeUp(): void { this.log.debug("Waking up workers..."); this.abortController?.abort(); this.abortController = new AbortController(); // if no workers are running, start them, (should not happen, but just in case) this.startWorkers(); } } export interface Consumer<T extends TSchema = TSchema> { queue: QueuePrimitive<T>; handler: (message: QueueMessage<T>) => Promise<void>; } export interface NextMessage { consumer: Consumer; message: string; }