From 6fb922f4bacd7a5bbdf8258b36dfc9b0e1cf448f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 29 Mar 2025 11:02:42 +0000 Subject: [PATCH] Rate-limit DB writes --- frontend/sync-client/src/sync-client.ts | 12 +++- .../sync-client/src/utils/rate-limit.test.ts | 66 +++++++++++++++++++ frontend/sync-client/src/utils/rate-limit.ts | 58 ++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 frontend/sync-client/src/utils/rate-limit.test.ts create mode 100644 frontend/sync-client/src/utils/rate-limit.ts diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 27aa2172..0153148c 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -14,6 +14,7 @@ import type { FileSystemOperations } from "./file-operations/filesystem-operatio import { FileOperations } from "./file-operations/file-operations"; import { ConnectionStatus } from "./services/connection-status"; import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; +import { rateLimit } from "./utils/rate-limit"; export interface NetworkConnectionStatus { isSuccessful: boolean; @@ -22,6 +23,8 @@ export interface NetworkConnectionStatus { } export class SyncClient { + private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; + // eslint-disable-next-line @typescript-eslint/max-params private constructor( private readonly history: SyncHistory, @@ -80,12 +83,17 @@ export class SyncClient { database: undefined }; + const rateLimitedSave = rateLimit( + persistence.save, + SyncClient.MINIMUM_SAVE_INTERVAL_MS + ); + const database = new Database( logger, state.database, async (data): Promise => { state = { ...state, database: data }; - return persistence.save(state); + await rateLimitedSave(state); } ); @@ -94,7 +102,7 @@ export class SyncClient { state.settings, async (data): Promise => { state = { ...state, settings: data }; - return persistence.save(state); + await rateLimitedSave(state); } ); diff --git a/frontend/sync-client/src/utils/rate-limit.test.ts b/frontend/sync-client/src/utils/rate-limit.test.ts new file mode 100644 index 00000000..577783f7 --- /dev/null +++ b/frontend/sync-client/src/utils/rate-limit.test.ts @@ -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>() + .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>() + .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>() + .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"); + }); +}); diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts new file mode 100644 index 00000000..4de89ae8 --- /dev/null +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -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) => ReturnType | Promise} 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 +>( + fn: T, + minIntervalMs: number +): (...args: Parameters) => Promise { + let newArgs: Parameters | undefined = undefined; + let running: Promise | undefined = undefined; + + const decoratedFn = async ( + ...args: Parameters + ): Promise => { + 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; +}