zero-backpressure-fixed-window-promise-rate-limiter
Version:
A modern Promise rate-limiter for Node.js projects, implementing a fixed-window throttling policy. It restricts the number of tasks that can *start* execution within distinct, non-overlapping time windows. The API provides backpressure control by signalin
599 lines (514 loc) • 31.5 kB
text/typescript
/**
* Copyright 2024 Ori Cohen https://github.com/ori88c
*
* 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.
*/
import { FixedWindowRateLimiter } from './zero-backpressure-fixed-window-promise-rate-limiter';
type PromiseResolveCallbackType = (value?: unknown) => void;
type PromiseRejectCallbackType = (reason?: Error) => void;
interface CustomTaskError extends Error {
taskID: number;
}
/**
* 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); };
const MOCK_WINDOW_DURATION_MS = 15 * 1000; // Can be long, as we use Jest's fake timers.
const MOCK_MAX_STARTS_PER_WINDOW = 37;
const createTestLimiter = () => new FixedWindowRateLimiter<void>(MOCK_WINDOW_DURATION_MS, MOCK_MAX_STARTS_PER_WINDOW);
describe('FixedWindowRateLimiter tests', () => {
let setTimeoutSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
setTimeoutSpy = jest.spyOn(global, 'setTimeout');
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
const triggerEndingOfCurrentWindow = (): void => {
// The 1st task of each window sets a new setTimeout timer. The timer's callback is executed
// once the window ends, updating the rate-limiter's internal state.
jest.runOnlyPendingTimers();
};
describe('Happy path tests', () => {
test('validate initial state following instantiation', async () => {
const rateLimiter = createTestLimiter();
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
expect(setTimeoutSpy).toHaveBeenCalledTimes(0);
});
test('when the window capacity is exhausted, the Rate Limiter should stop executing tasks utill a new window opens up', async () => {
const rateLimiter = createTestLimiter();
let windowsCounter = 1;
const finishTaskCallbacks: PromiseResolveCallbackType[] = [];
const createTask = () => new Promise<void>(res => finishTaskCallbacks.push(res));
for (let ithTask = 1; ithTask <= MOCK_MAX_STARTS_PER_WINDOW; ++ithTask) {
await rateLimiter.startExecution(createTask);
expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter); // setTimeout is triggered by the 1st window task.
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(ithTask);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(ithTask);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
// Now, we push an excessive task which cannot be started during the current window.
const outOfFirstWindowStartExecutionPromise = rateLimiter.startExecution(createTask);
await Promise.race([outOfFirstWindowStartExecutionPromise, resolveFast()]);
expect(rateLimiter.isCurrentWindowAvailable).toBe(false);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
// Now, we resolve all the 1st window tasks. The excessive task still cannot be executed,
// as the fixed time-window did not end.
for (let ithTask = 1; ithTask <= MOCK_MAX_STARTS_PER_WINDOW; ++ithTask) {
finishTaskCallbacks[ithTask - 1]();
await Promise.race([outOfFirstWindowStartExecutionPromise, resolveFast()]);
expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter);
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(false);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(MOCK_MAX_STARTS_PER_WINDOW - ithTask);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
// Now, we simulate the ending of current window, allowing the pending task to begin.
triggerEndingOfCurrentWindow();
++windowsCounter;
await outOfFirstWindowStartExecutionPromise;
expect(finishTaskCallbacks.length).toBe(MOCK_MAX_STARTS_PER_WINDOW + 1);
expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter);
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(1);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(1);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
// Finish the out-of-first-window task.
finishTaskCallbacks.pop()();
await rateLimiter.waitForAllExecutingTasksToComplete();
expect(rateLimiter.isCurrentWindowAvailable).toBe(true);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(1);
});
test(
'waitForAllExecutingTasksToComplete should resolve once all executing tasks have completed: ' +
'setup with insufficient initial slots, triggering dynamic slot allocation. ' +
'Tasks are resolved in FIFO order in this test', async () => {
// This test deliberately creates backpressure by simulating a burst of tasks
// that spans `amountOfWindows` time windows. As a result, each window (except the final one)
// is unable to process all the pending tasks.
const amountOfWindows = 12;
const amountOfTasks = amountOfWindows * MOCK_MAX_STARTS_PER_WINDOW; // Each window is fully utilized.
const rateLimiter = createTestLimiter();
// From the Rate Limiter's perspective, a task is considered complete upon either
// success or failure (i.e., when the Promise is resolved or rejected).
// To simulate real-world scenarios, this test includes tasks that both succeed and fail.
const taskCompletionCallbacks: (PromiseResolveCallbackType | PromiseRejectCallbackType)[] = [];
const createResolvingTask = () => new Promise<void>(res => {
taskCompletionCallbacks.push(res);
// This promise will remain unsettled until we manually invoke the 'res' callback,
// simulating an ongoing task that is about to complete successfully.
});
const createRejectingTask = () => new Promise<void>((_, rej) => {
taskCompletionCallbacks.push(rej);
// This promise will remain unsettled until we manually invoke the 'rej' callback,
// simulating an ongoing task that is about to fail.
});
const waitForCompletionPromises: Promise<void>[] = [];
for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) {
const shouldTaskSucceed = ithTask % 2 === 0; // Odd-numbered tasks fail, while even-numbered tasks succeed.
waitForCompletionPromises.push(
// Tasks will *start* execution in the order in which they were registered.
rateLimiter.waitForCompletion(shouldTaskSucceed ? createResolvingTask : createRejectingTask)
);
// Trigger the event loop.
// This may activate the rate limiter's dynamic slot allocation for this task,
// if the window's capacity hasn't been fully utilized yet.
// Most tasks should receive a new slot, as the rate limiter initially allocates
// slightly more slots than the window's capacity. However, in this test, there are
// `amountOfWindows` windows, which is significantly higher.
await Promise.race([
waitForCompletionPromises[waitForCompletionPromises.length - 1],
resolveFast()
]);
}
// Trigger the end of all windows, while none of the tasks have settled yet.
// We expect the Rate Limiter to retain references to all still-executing tasks,
// including those from earlier windows that have already ended.
for (let ithWindow = 1; ithWindow <= amountOfWindows; ++ithWindow) {
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(
ithWindow * MOCK_MAX_STARTS_PER_WINDOW // Reminder: tasks from previous windows are still executing.
);
expect(rateLimiter.isCurrentWindowAvailable).toBe(false);
// End the current window and trigger the event loop, allowing the next batch
// of the highest-priority tasks (first in Node.js's microtasks queue) to start
// execution.
triggerEndingOfCurrentWindow();
await Promise.race([waitForCompletionPromises[0], resolveFast()]);
}
let allTasksCompleted = false;
const waitForAllExecutingTasksToComplete: Promise<void> = (async () => {
await rateLimiter.waitForAllExecutingTasksToComplete();
allTasksCompleted = true;
})();
await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]);
expect(allTasksCompleted).toBe(false);
// Complete all tasks one by one, in FIFO order.
let expectedAmountOfCurrentlyExecutingTasks = amountOfTasks;
for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) {
let thrownError: Error;
const shouldTaskSucceed = ithTask % 2 === 0;
if (shouldTaskSucceed) {
taskCompletionCallbacks[ithTask -1](); // Invoking the task's Promise-resolve callback.
} else {
thrownError = new Error(`mock error message: ${ithTask}`);
taskCompletionCallbacks[ithTask - 1](thrownError); // Invoking the task's Promise-reject callback.
}
--expectedAmountOfCurrentlyExecutingTasks;
// Trigger the event loop, we expect the current task promise to be settled.
if (shouldTaskSucceed) {
await waitForCompletionPromises[ithTask - 1];
} else {
// The current task rejects.
try {
await waitForCompletionPromises[ithTask - 1];
expect(true).toBe(false); // The flow should not reach this point.
} catch (err) {
expect(err.message).toEqual(thrownError.message);
}
}
// Trigger the event loop.
if (ithTask === amountOfTasks) {
await waitForAllExecutingTasksToComplete; // We have just completed the last task.
} else {
await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]);
}
expect(allTasksCompleted).toBe(ithTask === amountOfTasks);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
// Currently we are in the (amountOfWindows +1)th window. We don't add any
// tasks to it, so its metrics are expected to remain unchanged.
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
expect(allTasksCompleted).toBe(true);
});
test(
'waitForAllExecutingTasksToComplete should resolve once all executing tasks have completed: ' +
'setup with insufficient initial slots, triggering dynamic slot allocation. ' +
'Tasks are resolved in FILO order in this test', async () => {
// FILO order for task completion times is unlikely in real life, but it’s a good edge case to test.
// It ensures the rate limiter can maintain a reference to an old task, even if its execution time exceeds
// all others.
// This test deliberately creates backpressure by simulating a burst of tasks
// that spans `amountOfWindows` time windows. As a result, each window (except the final one)
// is unable to process all the pending tasks.
const amountOfWindows = 10;
const amountOfTasks = amountOfWindows * MOCK_MAX_STARTS_PER_WINDOW; // Each window is fully utilized.
const rateLimiter = createTestLimiter();
// From the Rate Limiter's perspective, a task is considered complete upon either
// success or failure (i.e., when the Promise is resolved or rejected).
// To simulate real-world scenarios, this test includes tasks that both succeed and fail.
const taskCompletionCallbacks: (PromiseResolveCallbackType | PromiseRejectCallbackType)[] = [];
const createResolvingTask = () => new Promise<void>(res => {
taskCompletionCallbacks.push(res);
// This promise will remain unsettled until we manually invoke the 'res' callback,
// simulating an ongoing task that is about to complete successfully.
});
const createRejectingTask = () => new Promise<void>((_, rej) => {
taskCompletionCallbacks.push(rej);
// This promise will remain unsettled until we manually invoke the 'rej' callback,
// simulating an ongoing task that is about to fail.
});
const waitForCompletionPromises: Promise<void>[] = [];
for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) {
const shouldTaskSucceed = ithTask % 2 === 0; // Odd-numbered tasks fail, while even-numbered tasks succeed.
waitForCompletionPromises.push(
// Tasks will *start* execution in the order in which they were registered.
rateLimiter.waitForCompletion(shouldTaskSucceed ? createResolvingTask : createRejectingTask)
);
// Trigger the event loop.
// This may activate the rate limiter's dynamic slot allocation for this task,
// if the window's capacity hasn't been fully utilized yet.
// Most tasks should receive a new slot, as the rate limiter initially allocates
// slightly more slots than the window's capacity. However, in this test, there are
// `amountOfWindows` windows, which is significantly higher.
await Promise.race([
waitForCompletionPromises[waitForCompletionPromises.length - 1],
resolveFast()
]);
}
// Trigger the end of all windows, while none of the tasks have settled yet.
// We expect the Rate Limiter to retain references to all still-executing tasks,
// including those from earlier windows that have already ended.
for (let ithWindow = 1; ithWindow <= amountOfWindows; ++ithWindow) {
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(
ithWindow * MOCK_MAX_STARTS_PER_WINDOW // Reminder: tasks from previous windows are still executing.
);
expect(rateLimiter.isCurrentWindowAvailable).toBe(false);
// End the current window and trigger the event loop, allowing the next batch
// of the highest-priority tasks (first in Node.js's microtasks queue) to start
// execution.
triggerEndingOfCurrentWindow();
await Promise.race([waitForCompletionPromises[0], resolveFast()]);
}
let allTasksCompleted = false;
const waitForAllExecutingTasksToComplete: Promise<void> = (async () => {
await rateLimiter.waitForAllExecutingTasksToComplete();
allTasksCompleted = true;
})();
await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]);
expect(allTasksCompleted).toBe(false);
// Complete all tasks one by one, in FILO order.
let expectedAmountOfCurrentlyExecutingTasks = amountOfTasks;
for (let ithTask = amountOfTasks; ithTask >= 1; --ithTask) {
let thrownError: Error;
const shouldTaskSucceed = ithTask % 2 === 0;
if (shouldTaskSucceed) {
taskCompletionCallbacks.pop()(); // Invoking the task's Promise-resolve callback.
} else {
thrownError = new Error(`mock error message: ${ithTask}`);
taskCompletionCallbacks.pop()(thrownError); // Invoking the task's Promise-reject callback.
}
--expectedAmountOfCurrentlyExecutingTasks;
// Trigger the event loop, we expect the current task promise to be settled.
if (shouldTaskSucceed) {
await waitForCompletionPromises.pop();
} else {
// The current task rejects.
try {
await waitForCompletionPromises.pop();
expect(true).toBe(false); // The flow should not reach this point.
} catch (err) {
expect(err.message).toEqual(thrownError.message);
}
}
// Trigger the event loop.
if (ithTask === 1) {
await waitForAllExecutingTasksToComplete; // We have just completed the last task, the oldest one.
} else {
await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]);
}
expect(allTasksCompleted).toBe(ithTask === 1);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
// Currently we are in the (amountOfWindows +1)th window. We don't add any
// tasks to it, so its metrics are expected to remain unchanged.
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
expect(allTasksCompleted).toBe(true);
});
test('startExecution: when backpressure is induced, each window should honor its capacity', async () => {
const rateLimiter = createTestLimiter();
const totalAmountOfWindows = 9;
const amountOfLastWindowTasks = Math.floor(3 * MOCK_MAX_STARTS_PER_WINDOW / 4); // The last window won't utilize all its capacity.
const totalAmountOfTasks =
(totalAmountOfWindows - 1) * MOCK_MAX_STARTS_PER_WINDOW +
amountOfLastWindowTasks;
const startExecutionPromises = new Array<Promise<void>>(totalAmountOfTasks).fill(undefined);
const taskCompletionCallbacks = new Array<PromiseResolveCallbackType>(totalAmountOfTasks).fill(undefined);
// We push all tasks at once, inducing backpressure deliberately.
// Note: this is not a mindful / wise use of the rate-limiter's capabilities, as the `startExecution`
// method helps to avoid backpressure by promoting a just-in-time approach.
// However, the rate-limiter guarantees validity under any settings, including under backpressure.
for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) {
// We create unresolved promises, simulating an async work in progress.
// They will be resolved later, once we want to simulate completion of the async work.
const taskIndex = ithTask - 1;
const createTask = () => new Promise<void>(res => taskCompletionCallbacks[taskIndex] = res);
startExecutionPromises[taskIndex] = rateLimiter.startExecution(createTask);
// Trigger the event loop.
await Promise.race([
startExecutionPromises[taskIndex],
resolveFast()
]);
expect(setTimeoutSpy).toHaveBeenCalledTimes(1); // setTimeout is triggered by the 1st window task.
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW);
// Only 1st window tasks will begin execution.
const amountOfAlreadyAddedFirstWindowTasks = Math.min(ithTask, MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(amountOfAlreadyAddedFirstWindowTasks);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfAlreadyAddedFirstWindowTasks);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
// Each main iteration begins when the current window (denoted by windowsCounter) is open, thus we
// expect the rate-limiter to trigger (begin) all its tasks.
// At the end of each main loop, we trigger the next window by advancing the system clock to the timestamp
// when the current window ends, using fake timers.
for (let ithWindow = 1; ithWindow <= totalAmountOfWindows; ++ithWindow) {
// The last window is not fully occupied, while all the others are.
const isLastWindow = ithWindow === totalAmountOfWindows;
const expectedAmountOfCurrentlyExecutingTasks = isLastWindow ?
totalAmountOfTasks : (ithWindow * MOCK_MAX_STARTS_PER_WINDOW);
const amountOfCurrentWindowTasks = isLastWindow ? amountOfLastWindowTasks : MOCK_MAX_STARTS_PER_WINDOW;
let currTaskIndex = (ithWindow - 1) * MOCK_MAX_STARTS_PER_WINDOW;
for (let ithCurrWindowTask = 1; ithCurrWindowTask <= amountOfCurrentWindowTasks; ++ithCurrWindowTask, ++currTaskIndex) {
await startExecutionPromises[currTaskIndex];
expect(setTimeoutSpy).toHaveBeenCalledTimes(ithWindow);
expect(rateLimiter.isCurrentWindowAvailable).toBe(isLastWindow);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfCurrentWindowTasks);
}
if (!isLastWindow) {
triggerEndingOfCurrentWindow();
// Trigger the event loop. All the next-window tasks will begin execution.
const nextWindowLastTaskIndex = Math.min(
totalAmountOfTasks - 1,
(ithWindow + 1) * MOCK_MAX_STARTS_PER_WINDOW - 1
);
await startExecutionPromises[nextWindowLastTaskIndex];
}
}
// Now, we finish tasks one by one. The order of completion does not matter for validating metrics.
// We will use a FILO order, meaning a task that started later will be finished sooner.
let expectedAmountOfCurrentlyExecutingTasks = totalAmountOfTasks;
do {
const completeCurrentTask: PromiseResolveCallbackType = taskCompletionCallbacks.pop();
completeCurrentTask();
// Trigger the event loop, to update the rate-limiter's internal state.
await resolveFast();
--expectedAmountOfCurrentlyExecutingTasks;
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
// We are still within the last window.
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfLastWindowTasks);
expect(setTimeoutSpy).toHaveBeenCalledTimes(totalAmountOfWindows);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true); // Last window is not fully occupied.
} while (expectedAmountOfCurrentlyExecutingTasks > 0);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
});
test('waitForCompletion: when backpressure is induced, each window should honor its capacity', async () => {
const rateLimiter = createTestLimiter();
const totalAmountOfWindows = 7;
const amountOfLastWindowTasks = Math.floor(7 * MOCK_MAX_STARTS_PER_WINDOW / 9); // The last window won't utilize all its capacity.
const totalAmountOfTasks =
(totalAmountOfWindows - 1) * MOCK_MAX_STARTS_PER_WINDOW +
amountOfLastWindowTasks;
const waitForCompletionPromises = new Array<Promise<void>>(totalAmountOfTasks).fill(undefined);
const taskCompletionCallbacks = new Array<PromiseResolveCallbackType>(totalAmountOfTasks).fill(undefined);
// We push all tasks at once, inducing backpressure deliberately.
// Such a scenario can be unavoidable, for example if there's a spike in requests
// to a specific route handler, which uses a rate-limiter to comply with a third-party
// API that has throttling limits.
for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) {
// We create unresolved promises, simulating an async work in progress.
// They will be resolved later, once we want to simulate completion of the async work.
const taskIndex = ithTask - 1;
const createTask = () => new Promise<void>(
res => taskCompletionCallbacks[taskIndex] = res
);
waitForCompletionPromises[taskIndex] = rateLimiter.waitForCompletion(createTask);;
// Trigger the event loop.
await Promise.race([
waitForCompletionPromises[taskIndex],
resolveFast()
]);
expect(setTimeoutSpy).toHaveBeenCalledTimes(1); // setTimeout is triggered by the 1st window task.
expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS);
expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW);
// Only 1st window tasks will begin execution.
const amountOfAlreadyAddedFirstWindowTasks = Math.min(ithTask, MOCK_MAX_STARTS_PER_WINDOW);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(amountOfAlreadyAddedFirstWindowTasks);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfAlreadyAddedFirstWindowTasks);
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
}
// Each main iteration begins when the current window (denoted by windowsCounter) is open, thus we
// expect the rate-limiter to trigger (begin) all its tasks.
// At the end of each main loop, we trigger the next window by advancing the system clock to the timestamp
// when the current window ends, using fake timers.
for (let currentWindowNo = 1; currentWindowNo <= totalAmountOfWindows; ++currentWindowNo) {
// The last window is not fully occupied, while all the others are.
const isLastWindow = currentWindowNo === totalAmountOfWindows;
const expectedAmountOfCurrentlyExecutingTasks = isLastWindow ?
totalAmountOfTasks : (currentWindowNo * MOCK_MAX_STARTS_PER_WINDOW);
const amountOfCurrentWindowTasks = isLastWindow ? amountOfLastWindowTasks : MOCK_MAX_STARTS_PER_WINDOW;
expect(setTimeoutSpy).toHaveBeenCalledTimes(currentWindowNo);
expect(rateLimiter.isCurrentWindowAvailable).toBe(isLastWindow);
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfCurrentWindowTasks);
if (!isLastWindow) {
triggerEndingOfCurrentWindow();
// Trigger the event loop. All next-window tasks will begin execution.
await Promise.race([
waitForCompletionPromises,
resolveFast()
]);
}
}
// Now, we finish tasks one by one. The order of completion does not matter for validating metrics.
// In this test, tasks will be completed in a FIFO order.
let expectedAmountOfCurrentlyExecutingTasks = totalAmountOfTasks;
for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) {
const completeOldestExecutingTask: PromiseResolveCallbackType = taskCompletionCallbacks[ithTask - 1];
completeOldestExecutingTask();
await waitForCompletionPromises[ithTask - 1]; // This wait-for-completion promise corresponds the just-completed task.
--expectedAmountOfCurrentlyExecutingTasks;
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks);
// We are still within the last window.
expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfLastWindowTasks);
expect(setTimeoutSpy).toHaveBeenCalledTimes(totalAmountOfWindows);
expect(rateLimiter.isCurrentWindowAvailable).toBe(true); // Last window is not fully occupied.
}
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
});
});
describe('Negative path tests', () => {
test('should throw if window duration is less than 15ms or non-natural number', async () => {
const invalidWindowDurations = [-16, -0.29, 0.0001, 10, 14.9999, 15.54, 16.8989, 9348.4433];
for (const windowDurationMs of invalidWindowDurations) {
expect(() => new FixedWindowRateLimiter(windowDurationMs, MOCK_MAX_STARTS_PER_WINDOW)).toThrow();
}
});
test('should throw if max starts per window is a non-natural number', async () => {
const invalidMaxStartsPerWindow = [-16, -0.29, 0.0001, 10.0000001, 14.9999, 15.54, 16.8989, 9348.4433];
for (const maxStartsPerWindow of invalidMaxStartsPerWindow) {
expect(() => new FixedWindowRateLimiter(MOCK_WINDOW_DURATION_MS, maxStartsPerWindow)).toThrow();
}
});
test('should capture uncaught errors from background tasks triggered by startExecution', async () => {
// In this test, we simulate a single window in which all tasks throw an error.
const rateLimiter = createTestLimiter();
const amountOfTasks = MOCK_MAX_STARTS_PER_WINDOW;
const expectedTaskErrors: CustomTaskError[] = [];
const createError = (taskID: number): CustomTaskError => ({
name: "CustomTaskError",
message: `Task no. ${taskID} has failed`,
taskID
});
for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) {
expectedTaskErrors.push(createError(ithTask));
// We deliberately create a new error instance with the exact same fields to validate deep equality.
await rateLimiter.startExecution(async () => { throw createError(ithTask); });
}
await rateLimiter.waitForAllExecutingTasksToComplete();
expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0);
expect(rateLimiter.amountOfUncaughtErrors).toBe(amountOfTasks);
expect(rateLimiter.extractUncaughtErrors()).toEqual(expectedTaskErrors);
// Following extraction, the rate-limiter no longer holds the error references.
expect(rateLimiter.amountOfUncaughtErrors).toBe(0);
});
});
});