Rate-limit DB writes

This commit is contained in:
Andras Schmelczer 2025-03-29 11:02:42 +00:00
parent 44ab720b1d
commit 6fb922f4ba
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
3 changed files with 134 additions and 2 deletions

View file

@ -0,0 +1,66 @@
import { rateLimit } from "./rate-limit";
import { jest } from "@jest/globals";
describe("rateLimit", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("should call the function immediately on first invocation", async () => {
const mockFn = jest
.fn<() => Promise<string>>()
.mockResolvedValue("result");
const rateLimited = rateLimit(mockFn, 100);
const promise = rateLimited();
expect(mockFn).toHaveBeenCalledTimes(1);
await promise;
});
it("should call the function again after the interval has passed", async () => {
const mockFn = jest
.fn<(value: number) => Promise<string>>()
.mockResolvedValue("result");
const rateLimited = rateLimit(mockFn, 100);
const promise1 = rateLimited(1);
await promise1;
jest.advanceTimersByTime(200);
const promise2 = rateLimited(2);
await promise2;
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith(2);
});
it("should use the most recent arguments if multiple calls are made within interval", async () => {
const mockFn = jest
.fn<(value: string) => Promise<string>>()
.mockImplementation(async (val) => `${val}-result`);
const rateLimited = rateLimit(mockFn, 100);
const promise1 = rateLimited("first");
jest.advanceTimersByTime(10);
const promise2 = rateLimited("second");
jest.advanceTimersByTime(10);
const promise3 = rateLimited("third");
jest.advanceTimersByTime(1000);
expect(await promise1).toEqual("first-result");
expect(await promise2).toEqual("third-result");
expect(await promise3).toBeUndefined();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenNthCalledWith(1, "first");
expect(mockFn).toHaveBeenNthCalledWith(2, "third");
});
});

View file

@ -0,0 +1,58 @@
import { createPromise } from "./create-promise";
import { sleep } from "./sleep";
/**
* Creates a rate-limited version of a given asynchronous function.
* Ensures that the function is not called more frequently than specified by `minIntervalMs`.
* If the function is called while a previous call is still within the rate limit window,
* it will queue up the most recent arguments and execute them after the rate limit expires.
* Only the most recent call is preserved in the queue.
*
* @template T - Type of the function to be rate limited
* @param {T} fn - The asynchronous function to rate limit
* @param {number} minIntervalMs - The minimum interval in milliseconds between function calls
* @returns {(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>} A decorated function that respects the rate limit.
* Returns the original function's return type when executed, or undefined if the call was superseded by a newer one.
*/
export function rateLimit<
R,
T extends (
...args: any // eslint-disable-line @typescript-eslint/no-explicit-any
) => Promise<R>
>(
fn: T,
minIntervalMs: number
): (...args: Parameters<T>) => Promise<R | undefined> {
let newArgs: Parameters<T> | undefined = undefined;
let running: Promise<unknown> | undefined = undefined;
const decoratedFn = async (
...args: Parameters<T>
): Promise<R | undefined> => {
if (running !== undefined) {
newArgs = args;
await running;
// args might have changed while we were waiting
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (newArgs === undefined) {
// we weren't the first one to wake up, that means a newer
// invocation is running now, we can just bail
return;
}
args = newArgs;
newArgs = undefined;
}
const [promise, resolve] = createPromise();
running = promise;
sleep(minIntervalMs)
.then(resolve)
.catch(() => {
// sleep cannot fail
});
return fn(...args);
};
return decoratedFn;
}