UNPKG

zero-backpressure-semaphore-typescript

Version:

A modern Promise-semaphore for Node.js projects, enabling users to limit the number of concurrently executing promises. Offering backpressure control for enhanced efficiency, utilizing a communicative API that signals availability, promoting a just-in-tim

218 lines (217 loc) 13.3 kB
export type SemaphoreJob<T> = () => Promise<T>; /** * The `ZeroBackpressureSemaphore` class implements a semaphore for Node.js projects, allowing users * to limit the number of concurrently executing jobs. This implementation does not queue pending * jobs, thereby eliminating backpressure. As a result, users have better control over memory * footprint, which enhances performance by reducing garbage-collector overhead. * * The design addresses the two primary semaphore use cases in Node.js: * 1. **Single Job Execution**: A sub-procedure for which the caller must wait before proceeding with * its work. In this case, the job's completion time is crucial to know. * 2. **Multiple Jobs Execution**: In this case, the start time of a given job is crucial. Since a * pending job cannot start its execution until the semaphore allows, there is no reason to add * additional jobs that cannot start either. * Once all the jobs are completed, some post-processing logic may be required. The API provides a * designated method to wait until there are no currently-executing jobs. * * ### Modern API Design * Traditional semaphore APIs require explicit acquire and release steps, adding overhead and * responsibility on the user. * In contrast, `ZeroBackpressureSemaphore` manages job execution, abstracting away these details and * reducing user responsibility. The acquire and release steps are handled implicitly by the execution * methods, reminiscent of the RAII idiom in C++. * Method names are chosen to clearly convey their functionality. * * ### Graceful Teardown * All the job execution promises are tracked by the semaphore instance, ensuring no dangling promises. * This enables graceful termination via the `waitForAllExecutingJobsToComplete` method, in scenarios * where it is essential to ensure that all the currently executing or pending jobs are fully processed * before proceeding. * Examples include application shutdowns (e.g., `onModuleDestroy` in Nest.js applications) or * maintaining a clear state between unit-tests. * Should graceful teardown be a concern for your component, consider how its termination method (e.g., * `stop`, `terminate`, `onModuleDestroy`, etc) aligns with this capability. * * ### Error Handling for Background Jobs * Background jobs triggered by `startExecution` may throw errors. Unlike the `waitForCompletion` case, * the caller has no reference to the corresponding job promise which executes in the background. * Therefore, errors from background jobs are captured by the semaphore and can be extracted using * the `extractUncaughtErrors` method. The number of accumulated uncaught errors can be obtained via * the `amountOfUncaughtErrors` getter method. This can be useful, for example, if the user wants to * handle uncaught errors only after a certain threshold is reached. */ export declare class ZeroBackpressureSemaphore<T, UncaughtErrorType = Error> { private readonly _availableSlotsStack; private readonly _slots; private _waitForAvailableSlot?; private _notifyAvailableSlotExists?; private _uncaughtErrors; /** * Initializes the semaphore with the specified maximum number of concurrently * executing jobs. This sets up the internal structures to enforce the concurrency * limit for job execution. * * @param maxConcurrentJobs The maximum number of jobs that can execute concurrently. * @throws Error if `maxConcurrentJobs` is not a natural number (i.e., a positive integer). */ constructor(maxConcurrentJobs: number); /** * @returns The maximum number of concurrent jobs as specified in the constructor. */ get maxConcurrentJobs(): number; /** * @returns True if there is an available job slot, otherwise false. */ get isAvailable(): boolean; /** * @returns The number of jobs currently being executed by the semaphore. */ get amountOfCurrentlyExecutingJobs(): number; /** * Indicates the number of uncaught errors from background jobs triggered by `startExecution`, * that are currently stored by the instance. * These errors have not yet been extracted using `extractUncaughtErrors`. * * Knowing the number of uncaught errors allows users to decide whether to process them immediately * or wait for further accumulation. * * @returns The number of uncaught errors from background jobs. */ get amountOfUncaughtErrors(): number; /** * Resolves once the given job has *started* its execution, indicating that the semaphore has * become available (i.e., allotted a slot for the job). * Users can leverage this to prevent backpressure of pending jobs: * If the semaphore is too busy to start a given job `X`, there is no reason to create another * job `Y` until `X` has started. * * This method is particularly useful for executing multiple or background jobs, where no return * value is expected. It promotes a just-in-time approach, on which each job is pending execution * only when no other job is, thereby eliminating backpressure and reducing memory footprint. * * ### Graceful Teardown * Method `waitForAllExecutingJobsToComplete` complements the typical use-cases of `startExecution`. * It can be used to perform post-processing, after all the currently-executing jobs have completed. * * ### Error Handling * If the job throws an error, it is captured by the semaphore and can be accessed via the * `extractUncaughtError` method. Users are encouraged to specify a custom `UncaughtErrorType` * generic parameter to the class if jobs may throw errors. * * @param backgroundJob The job to be executed once the semaphore is available. * @returns A promise that resolves when the job starts execution. */ startExecution(backgroundJob: SemaphoreJob<T>): Promise<void>; /** * Executes the given job in a controlled manner, once the semaphore is available. * It resolves or rejects when the job finishes execution, returning the job's value or * propagating any error it may throw. * * This method is useful when the flow depends on a job's execution to proceed, such as * needing its return value or handling any errors it may throw. * * ### Example Use Case * Suppose you have a route handler that needs to perform a specific code block with limited * concurrency (e.g., database access) due to external constraints, such as throttling limits. * This method allows you to execute the job with controlled concurrency. Once the job resolves * or rejects, you can continue the route handler's flow based on the result. * * @param job The job to be executed once the semaphore is available. * @throws Error thrown by the job itself. * @returns A promise that resolves with the job's return value or rejects with its error. */ waitForCompletion(job: SemaphoreJob<T>): Promise<T>; /** * Waits for all **currently executing jobs** to finish, ensuring that all active promises * have either resolved or rejected before proceeding. This enables graceful termination in * scenarios such as: * - Application shutdowns (e.g., `onModuleDestroy` in Nest.js applications). * - Ensuring a clean state between unit tests. * * ### Considering Backpressure from Pending Jobs * By default, this method only waits for jobs that are already **executing** at the time of * invocation. In other words, the default behavior does **not** consider potential jobs that * are still queued (pending execution). * A backpressure of pending jobs may happen when multiple different callers share the same * semaphore instance, each being unaware of the others. * To extend the waiting behavior to include **potentially pending jobs** which account for * backpressure, use the optional `considerPendingJobsBackpressure` parameter set to `true`. * When this flag is enabled, the method will account for both existing and future backpressure, * even if the backpressure arises after the method is invoked. * * @param considerPendingJobsBackpressure A boolean indicating whether this method should also wait * for the resolution of all potentially queued jobs (i.e., * those not yet executed when the method was invoked). * This is especially relevant when multiple different callers * share the same semaphore instance, each being unaware of * the others. * @returns A promise that resolves once all currently executing jobs have completed. * If `considerPendingJobsBackpressure` is `true`, the promise will additionally * wait until all queued jobs have been executed, ensuring no pending job backpressure remains. */ waitForAllExecutingJobsToComplete(considerPendingJobsBackpressure?: boolean): Promise<void>; /** * This method resolves once at least one slot is available for job execution. * In other words, it resolves when the semaphore is available to trigger a new job immediately. * * ### Example Use Case * Consider a scenario where we read messages from a message queue (e.g., RabbitMQ, Kafka). * Each message contains job-specific metadata, meaning for each message, we want to create a * corresponding semaphore job. We aim to start processing a message immediately once it is * consumed, as message queues typically involve *acknowledgements*, which have *timeout* * mechanisms. Therefore, immediate processing is crucial to ensure efficient and reliable * handling of messages. Backpressure on the semaphore may cause messages to wait too long * before their corresponding job starts, increasing the chances of their timeout being exceeded. * To prevent such potential backpressure, users can utilize the `waitForAvailability` method * before consuming the next message. * * ### Design Choice * This method can be useful when the system is experiencing high load (as indicated by CPU * and/or memory usage metrics), and you want to pause further async operations until an available * job slot opens up. * However, the same effect can be achieved with `startExecution` alone if the async logic * (intended to be delayed until availability) is handled within the job itself rather than as * a preliminary step. Therefore, `waitForAvailability` serves as a design choice rather than a * strict necessity. * * @returns A promise that resolves once at least one slot is available. */ waitForAvailability(): Promise<void>; /** * This method returns an array of uncaught errors, captured by the semaphore while executing * background jobs added by `startExecution`. The term `extract` implies that the semaphore * instance will no longer hold these error references once extracted, unlike `get`. In other * words, ownership of these uncaught errors shifts to the caller, while the semaphore clears * its list of uncaught errors. * * Even if the user does not intend to perform error-handling with these uncaught errors, it is * important to periodically call this method when using `startExecution` to prevent the * accumulation of errors in memory. * However, there are a few exceptional cases where the user can safely avoid extracting * uncaught errors: * - The number of jobs is relatively small and the process is short-lived. * - The jobs never throw errors, thus no uncaught errors are possible. * * @returns An array of uncaught errors from background jobs triggered by `startExecution`. */ extractUncaughtErrors(): UncaughtErrorType[]; private _getAvailableSlot; /** * This method manages the execution of a given job in a controlled manner. It ensures that * the job is executed within the constraints of the semaphore and handles updating the * internal state once the job has completed. * * ### Behavior * - Waits for the job to either return a value or throw an error. * - Updates the internal state to make the allotted slot available again once the job is finished. * * @param job The job to be executed in the given slot. * @param allottedSlot The slot number in which the job should be executed. * @param isBackgroundJob A flag indicating whether the caller expects a return value to proceed * with its work. If `true`, no return value is expected, and any error * thrown by the job should not be propagated. * @returns A promise that resolves with the job's return value or rejects with its error. * Rejection occurs only if triggered by `waitForCompletion`. */ _handleJobExecution(job: SemaphoreJob<T>, allottedSlot: number, isBackgroundJob: boolean): Promise<T>; }