UNPKG

zero-overhead-promise-lock

Version:

An efficient Promise lock for Node.js projects, ensuring mutually exclusive execution of asynchronous tasks. Key features include a backpressure indicator, access to the currently executing task promise for smart reuse (useful when launching a duplicate t

219 lines (206 loc) 10.2 kB
/** * Copyright 2024 Ori Cohen https://github.com/ori88c * https://github.com/ori88c/zero-overhead-promise-lock * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export type AsyncTask<T> = () => Promise<T>; /** * The `ZeroOverheadLock` class implements a Promise-lock for Node.js projects, enabling users * to ensure the mutually exclusive execution of specified tasks. * * ### Race Conditions: How Are They Possible in Single-Threaded JavaScript? * In Node.js, synchronous code blocks - those that do *not* contain an `await` keyword - are * guaranteed to execute within a single event-loop iteration. These blocks inherently do not * require synchronization, as their execution is mutually exclusive by definition and cannot * overlap. * In contrast, asynchronous tasks that include at least one `await`, necessarily span across * multiple event-loop iterations. Such tasks may require synchronization, when overlapping * executions could result in an inconsistent or invalid state. * In this regard, JavaScript's single-threaded nature differs inherently from that of * single-threaded C code, for example. * * ### Modern API Design * Traditional lock APIs require explicit acquire and release steps, adding overhead and * responsibility on the user. * In contrast, `ZeroOverheadLock` manages task execution, abstracting away these details and * reducing user responsibility. The acquire and release steps are handled implicitly by the * execution method, reminiscent of the RAII idiom in C++. * * ### Graceful Teardown * Task execution promises are tracked by the lock instance, ensuring no dangling promises. * This enables graceful teardown via the `waitForAllExistingTasksToComplete` method, in * scenarios where it is essential to ensure that all tasks - whether already executing or queued - * are fully processed before proceeding. * Examples include application shutdowns (e.g., `onModuleDestroy` in Nest.js applications) * or maintaining a clear state between unit tests. */ export class ZeroOverheadLock<T> { private _pendingTasksCount = 0; private _currentExecution?: Promise<T>; /** * Availability indicator: * A pending `_waitForAvailability` promise signifies that the lock is currently held. * Its resolve function is used to notify all awaiters of a state change. This approach * has similarities with a condition_variable in C++. */ private _waitForAvailablity?: Promise<void>; private _notifyTaskCompletion?: (value: void) => void; // Resolving the above. /** * Indicates whether the lock is currently available to immediately begin executing a new task. * * ### Check-and-Abort Friendly * This property is particularly useful in "check and abort" scenarios, where an operation * should be skipped or aborted if the lock is currently held by another task. * * @returns `true` if no task is currently executing; otherwise, `false`. */ public get isAvailable(): boolean { return this._waitForAvailablity === undefined; } /** * Exposes the currently executing task's promise, if one is active. * * ### Smart Reuse * This property is useful in scenarios where launching a duplicate task is wasteful. * Instead of scheduling a new task, consumers can await the ongoing execution to avoid * redundant operations. * * ### Usage Example * Suppose a route handler allows clients to fetch an aggregated usage summary * from a third-party service. Since this summary does not change frequently * and the request is expensive, it’s ideal to avoid triggering multiple * simultaneous fetches. Instead, reuse the ongoing execution: * ```ts * async function fetchSummary(): Promise<Summary> { * const ongoing = summaryFetchLock.getCurrentExecution(); * if (ongoing) { * return await ongoing; * } else { * return await summaryFetchLock.executeExclusive(fetchUsageSummary); * } * } * ``` * * @returns The currently executing task’s promise, or `undefined` if the lock is available. */ public get currentExecution(): Promise<T> | undefined { return this._currentExecution; } /** * Returns the number of tasks that are currently pending execution due to the lock being held. * These tasks are waiting for the lock to become available before they can proceed. * * ### Monitoring Backpressure * This property is useful for monitoring backpressure and making informed decisions, such as * dynamically adjusting task submission rates or triggering alerts if the backpressure grows * too large. Additionally, this metric can aid in internal resource management within a * containerized environment. * * ### Real-World Example: A Keyed Lock for Batch Processing of Kafka Messages * Suppose you are consuming a batch of Kafka messages from the same partition concurrently, but * need to ensure sequential processing for messages associated with the same key. For example, * each message may represent an action on a user account, where processing multiple actions * concurrently could lead to race conditions. Kafka experts might suggest increasing the number * of partitions to ensure sequential processing per partition. However, in practice, this approach * can be costly. As a result, it is not uncommon to prefer batch-processing messages from the same * partition rather than increasing the partition count. * To prevent concurrent processing of same-key messages during batch processing, you can use this * lock as a building block for a Keyed Lock, where each **key** is mapped to its own lock instance. * In this case, the key could be the UserID, ensuring that actions on the same user account are * processed sequentially. * When multiple locks exist - each associated with a unique key - the `pendingTasksCount` metric * can help optimize resource usage. Specifically, if a lock’s backpressure reaches 0, it may indicate * that the lock is no longer needed and can be **removed** from the Keyed Lock to free up resources. * * @returns The number of tasks currently waiting for execution. */ public get pendingTasksCount(): number { return this._pendingTasksCount; } /** * This method executes the given task in a controlled manner, once the lock is available. * It resolves or rejects when the task finishes execution, returning the task's value or * propagating any error it may throw. * * @param criticalTask The asynchronous task to execute exclusively, ensuring it does not * overlap with any other execution managed by this lock instance. * @throws Error thrown by the task itself. * @returns A promise that resolves with the task's return value or rejects with its error. */ public async executeExclusive(criticalTask: AsyncTask<T>): Promise<T> { ++this._pendingTasksCount; while (this._waitForAvailablity) { await this._waitForAvailablity; } this._waitForAvailablity = new Promise<void>( res => this._notifyTaskCompletion = res ); --this._pendingTasksCount; return this._handleTaskExecution(criticalTask); } /** * Waits for the completion of all tasks that are *currently* pending or executing. * * This method is particularly useful in scenarios where it is essential to ensure that * all tasks - whether already executing or queued - are fully processed before proceeding. * Examples include application shutdowns (e.g., `onModuleDestroy` in Nest.js applications) * or maintaining a clear state between unit tests. * This need is especially relevant in Kubernetes ReplicaSet deployments. When an HPA controller * scales down, pods begin shutting down gracefully. * * ### Graceful Shutdown * The returned promise only accounts for tasks registered at the time this method is called. * If this method is being used as part of a graceful shutdown process, the **caller must ensure** * that no additional tasks are registered after this method is called. * If there is any uncertainty about new tasks being registered, consider using the following pattern: * ```ts * while (!lock.isAvailable) { * await lock.waitForAllExistingTasksToComplete() * } * ``` * * @returns A promise that resolves once all tasks that were pending or executing at the time * of invocation are completed. */ public async waitForAllExistingTasksToComplete(): Promise<void> { // Pending tasks are more prioritized in the Node.js microtasks queue. while (this._waitForAvailablity) { await this._waitForAvailablity; } } /** * This method manages the execution of a given task in a controlled manner, i.e., * updating the internal state on completion. * * ### Behavior * - Waits for the task to either return a value or throw an error. * - Updates the internal state to denote availability once the task is finished. * * @param criticalTask The asynchronous task to execute exclusively, ensuring it does not * overlap with any other execution managed by this lock instance. * @returns A promise that resolves with the task's return value or rejects with its error. */ public async _handleTaskExecution(criticalTask: AsyncTask<T>): Promise<T> { try { this._currentExecution = criticalTask(); const result = await this._currentExecution; return result; } finally { this._notifyTaskCompletion(); this._waitForAvailablity = undefined; this._notifyTaskCompletion = undefined; this._currentExecution = undefined; } } }