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
410 lines (355 loc) • 17.9 kB
text/typescript
/**
* 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.
*/
/**
* General note on testing concurrency in unit tests:
* While ideal tests follow a strict Arrange-Act-Assert structure, rigorously testing
* concurrency-oriented components often requires validating intermediate states.
* Incorrect intermediate states can compromise the entire component's correctness,
* making their verification essential.
*
* As with everything in engineering, this comes at a cost: verbosity.
* Given that resilience is the primary goal, this is a small price to pay.
*/
import { ZeroOverheadLock } from './zero-overhead-promise-lock';
type PromiseResolveCallbackType = (value?: unknown) => void;
type PromiseRejectCallbackType = (err?: Error) => void;
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
/**
* resolveFast
*
* The one-and-only purpose of this function, is triggerring an event-loop iteration.
* It is relevant whenever a test needs to simulate tasks from the Node.js' micro-tasks queue.
*/
const resolveFast = async () => {
expect(14).toBeGreaterThan(3);
};
describe('ZeroOverheadLock tests', () => {
describe('Happy path tests', () => {
test('executeExclusive: should return the expected value when succeeds', async () => {
const lock = new ZeroOverheadLock<number>();
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
const expectedValue = -295;
const task = async (): Promise<number> => { return expectedValue; };
const actualValue = await lock.executeExclusive(task);
expect(actualValue).toBe(expectedValue);
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
});
test('waitForAllExistingTasksToComplete: should resolve immediately if no task currently executes', async () => {
const lock = new ZeroOverheadLock<number>();
await lock.waitForAllExistingTasksToComplete();
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
});
test(
'executeExclusive: should process only one task at a time, and waitForAllExistingTasksToComplete ' +
'should resolve only after *all* the currently pending and processed tasks are completed', async () => {
const lock = new ZeroOverheadLock<void>();
const numberOfTasks = 225;
const taskCompletionCallbacks: PromiseResolveCallbackType[] = [];
const executeExclusivePromises: Promise<void>[] = [];
let expectedBackpressure: number;
// Create a burst of tasks, inducing backpressure on the lock.
for (let ithTask = 0; ithTask < numberOfTasks; ++ithTask) {
const jobPromise = new Promise<void>(res => taskCompletionCallbacks[ithTask] = res);
const job = () => jobPromise;
// Tasks will be executed in the order in which they were registered.
executeExclusivePromises[ithTask] = lock.executeExclusive(job);
// Trigger the event loop, allowing the lock to evaluate if the current task can begin execution.
// Based on this test's configuration, only the first task will be allowed to start.
await Promise.race([
executeExclusivePromises[ithTask],
resolveFast()
]);
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
// Only the first task does not induce backpressure, as it can start immediately.
expectedBackpressure = ithTask;
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
}
let allTasksCompleted = false;
const waitForCompletionOfAllTasksPromise = (async () => {
await lock.waitForAllExistingTasksToComplete();
allTasksCompleted = true;
})();
const lastTaskIndex = numberOfTasks - 1;
expectedBackpressure = lastTaskIndex;
for (let ithTask = 0; ithTask < numberOfTasks; ++ithTask) {
// At this stage, all tasks are pending for execution, except one which has started
// (the ithTask).
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
expect(allTasksCompleted).toBe(false);
// Complete the current task.
// Note: the order in which tasks start execution corresponds to the order in which
// `executeExclusive` was invoked.
const finishCurrentTask = taskCompletionCallbacks[ithTask];
finishCurrentTask();
const isLastTask = ithTask === lastTaskIndex;
if (isLastTask) {
await Promise.all([executeExclusivePromises[ithTask], waitForCompletionOfAllTasksPromise]);
} else {
await Promise.race([executeExclusivePromises[ithTask], waitForCompletionOfAllTasksPromise]);
--expectedBackpressure;
}
}
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
expect(allTasksCompleted).toBe(true);
});
test('currentExecution getter should return the active task promise during execution', async () => {
type ResultType = Record<string, number>;
const mockResult: ResultType = { a: 1, b: 2 };
let resolveTask: PromiseResolveCallbackType;
const lock = new ZeroOverheadLock<ResultType>();
// Pre-constructed task promise to verify identity.
const taskPromise = new Promise<ResultType>(res => resolveTask = res);
const executeExclusivePromise = lock.executeExclusive(() => taskPromise);
const validationRounds = 24;
for (let attempt = 0; attempt < validationRounds; ++attempt) {
expect(lock.isAvailable).toBe(false);
// currentExecution should return the exact Promise instance.
expect(lock.currentExecution).toBe(taskPromise);
expect(lock.pendingTasksCount).toBe(0);
}
resolveTask(mockResult);
const result = await executeExclusivePromise;
expect(result).toBe(mockResult);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.isAvailable).toBe(true);
expect(lock.currentExecution).toBe(undefined);
});
/**
* Note: While fundamentally different, the technique of multiple promises awaiting
* the same shared promise instance is somewhat reminiscent of `std::condition_variable` in C++.
* Instead of coordinating multiple threads, this approach coordinates multiple promise instances.
*/
test(
'when _currentlyExecutingTask resolves, its awaiters should be executed according to their ' +
'order in the Node.js microtasks queue', async () => {
// This test does not directly assess the lock component. Instead, it verifies the
// correctness of the **shared promise instance** acquire mechanism, ensuring it honors
// the FIFO order of callers requesting the (one and only) execution slot.
// In JavaScript, it is common for a caller to create a promise (as the **sole owner** of
// this promise instance) and await its resolution. It is less common for multiple promises
// to await concurrently on the same shared promise instance. In that scenario, a pertinent
// question arises:
// In which *order* will the multiple awaiters be executed?
// Short answer: according to their order in the Node.js microtasks queue.
// Long answer:
// When a promise is resolved, the callbacks attached to it (other promises awaiting
// its resolution) are *queued* as microtasks. Therefore, if multiple awaiters are waiting on
// the same shared promise instance, and the awaiters were created in a *specific* order, the
// first awaiter will be executed first once the shared promise is resolved. This is because
// adding a microtask (such as an async function awaiting a promise) ensures its position in
// the microtasks queue, guaranteeing its execution before subsequent microtasks in the queue.
// This holds true for any position, i.e., it can be generalized.
// In the following test, a relatively large number of awaiters is chosen. The motive is
// to observe statistical errors, which should *not* exist regardless of the input size.
const numberOfAwaiters = 384;
const actualExecutionOrderOfAwaiters: number[] = [];
// This specific usage of one promise instance being awaited by multiple other promises
// may remind those with a C++ background of a condition_variable.
let notifyAvailability: PromiseResolveCallbackType;
const waitForAvailability = new Promise(res => notifyAvailability = res);
const awaiterAskingToAcquireLock = async (awaiterID: number): Promise<void> => {
await waitForAvailability;
actualExecutionOrderOfAwaiters.push(awaiterID);
// Other awaiters in the microtasks queue will now be notified about the
// fulfillment of 'waitForAvailability'.
}
const expectedExecutionOrder: number[] = [];
const awaiterPromises: Promise<void>[] = [];
for (let awaiterID = 0; awaiterID < numberOfAwaiters; ++awaiterID) {
expectedExecutionOrder.push(awaiterID);
awaiterPromises.push(awaiterAskingToAcquireLock(awaiterID));
}
// Initially, no awaiter should be able to make progress.
await Promise.race([...awaiterPromises, resolveFast()]);
expect(actualExecutionOrderOfAwaiters.length).toBe(0);
// Notify that an "execution slot" is available, triggering the awaiters *in order*.
notifyAvailability();
await Promise.all(awaiterPromises);
// The execution order should match the expected order.
expect(actualExecutionOrderOfAwaiters).toEqual(expectedExecutionOrder);
});
});
describe('Negative path tests', () => {
test('executeExclusive: should return the expected error when throws', async () => {
const lock = new ZeroOverheadLock<string>();
const expectedError = new Error('mock error');
const task = async (): Promise<string> => { throw expectedError; };
expect.assertions(4);
try {
await lock.executeExclusive(task);
} catch (err) {
expect(err).toBe(expectedError);
}
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
});
test(
'executeExclusive: should process only one task at a time, and waitForAllExistingTasksToComplete ' +
'should resolve only after *all* the currently pending and processed tasks are completed, ' +
'with all tasks rejecting', async () => {
const lock = new ZeroOverheadLock<void>();
const numberOfTasks = 90;
const taskRejectCallbacks: PromiseRejectCallbackType[] = [];
const executeExclusivePromises: Promise<void>[] = [];
let expectedBackpressure: number;
// Create a burst of tasks, inducing backpressure on the lock.
for (let ithTask = 0; ithTask < numberOfTasks; ++ithTask) {
const taskPromise = new Promise<void>((_, rej) => taskRejectCallbacks[ithTask] = rej);
const createTask = () => taskPromise;
// Tasks will be executed in the order in which they were registered.
executeExclusivePromises[ithTask] = lock.executeExclusive(createTask);
// Trigger the event loop, allowing the lock to evaluate if the current task can begin execution.
// Based on this test's configuration, only the first task will be allowed to start.
await Promise.race([
executeExclusivePromises[ithTask],
resolveFast()
]);
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
// Only the first task does not induce backpressure, as it can start immediately.
expectedBackpressure = ithTask;
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
}
let allTasksCompleted = false;
const waitForCompletionOfAllTasksPromise = (async () => {
await lock.waitForAllExistingTasksToComplete();
allTasksCompleted = true;
})();
const lastTaskIndex = numberOfTasks - 1;
expectedBackpressure = lastTaskIndex;
for (let ithTask = 0; ithTask < numberOfTasks; ++ithTask) {
// At this stage, all tasks are pending for execution, except one which has started
// (the ithTask).
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
expect(allTasksCompleted).toBe(false);
// Complete the current task.
// Note: the order in which tasks start execution corresponds to the order in which
// `executeExclusive` was invoked.
const rejectCurrentTask = taskRejectCallbacks[ithTask];
const currError = new Error(`${ithTask}`);
rejectCurrentTask(currError);
const isLastTask = ithTask === lastTaskIndex;
try {
if (isLastTask) {
await Promise.all([executeExclusivePromises[ithTask], waitForCompletionOfAllTasksPromise]);
} else {
await Promise.race([executeExclusivePromises[ithTask], waitForCompletionOfAllTasksPromise]);
}
expect(true).toBe(false); // The flow should not reach this point.
} catch (err) {
expect(err).toBe(currError);
}
if (!isLastTask) {
--expectedBackpressure;
}
}
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
expect(allTasksCompleted).toBe(true);
});
});
describe('Resilience tests: mixed scenarios', () => {
test(
'executeExclusive: should enforce exclusivity, and waitForAllExistingTasksToComplete should ' +
'resolve only after all pending and executing tasks (both successful and failing) finish', async () => {
// Arrange.
jest.useFakeTimers();
const lock = new ZeroOverheadLock<number>();
const taskDurationMs = 15 * 1000;
const tasksCount = 231 ;
const createErrorMessage = (taskNumber: number) => `Task no. ${taskNumber} has failed`;
const createFailingTask = async (taskNumber: number): Promise<number> => {
await sleep(taskDurationMs);
throw new Error(createErrorMessage(taskNumber));
};
const createSuccessfulTask = async (taskNumber: number): Promise<number> => {
await sleep(taskDurationMs);
return taskNumber;
}
// Act.
const executeExclusivePromises: Promise<number>[] = [];
for (let taskNumber = 0; taskNumber < tasksCount; ++taskNumber) {
// Even attempts (0-indexed) will fail, while odd attempts will succeed.
const createTask = taskNumber % 2 === 0 ?
() => createFailingTask(taskNumber) :
() => createSuccessfulTask(taskNumber);
executeExclusivePromises[taskNumber] = lock.executeExclusive(createTask);
expect(lock.pendingTasksCount).toBe(taskNumber);
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
}
let allTasksCompleted = false;
const waitForCompletionOfAllTasksPromise = (async () => {
await lock.waitForAllExistingTasksToComplete();
allTasksCompleted = true;
})();
// Assert.
let expectedBackpressure = tasksCount - 1;
for (let taskNumber = 0; taskNumber < tasksCount; ++taskNumber) {
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
expect(allTasksCompleted).toBe(false);
expect(lock.isAvailable).toBe(false);
expect(lock.currentExecution).toBeDefined();
const shouldSucceed = taskNumber % 2 === 1;
await Promise.allSettled([
jest.advanceTimersByTimeAsync(taskDurationMs),
executeExclusivePromises[taskNumber]
]);
const isLastTask = (taskNumber + 1) === tasksCount;
if (!isLastTask) {
--expectedBackpressure;
}
expect(lock.pendingTasksCount).toBe(expectedBackpressure);
// Validate the resolved value or thrown error. Each task should produce
// a distinct outcome.
if (shouldSucceed) {
const result = await executeExclusivePromises[taskNumber];
expect(result).toBe(taskNumber);
} else {
try {
await executeExclusivePromises[taskNumber];
expect(true).toBe(false); // The flow should not reach this point.
} catch (err) {
const expectedMessage = createErrorMessage(taskNumber);
expect(err.message).toEqual(expectedMessage);
}
}
}
await waitForCompletionOfAllTasksPromise;
expect(allTasksCompleted).toBe(true);
expect(lock.isAvailable).toBe(true);
expect(lock.pendingTasksCount).toBe(0);
expect(lock.currentExecution).toBe(undefined);
jest.useRealTimers();
});
});
});