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
TypeScript
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>;
}