Rate-limit DB writes
This commit is contained in:
parent
44ab720b1d
commit
6fb922f4ba
3 changed files with 134 additions and 2 deletions
|
|
@ -14,6 +14,7 @@ import type { FileSystemOperations } from "./file-operations/filesystem-operatio
|
||||||
import { FileOperations } from "./file-operations/file-operations";
|
import { FileOperations } from "./file-operations/file-operations";
|
||||||
import { ConnectionStatus } from "./services/connection-status";
|
import { ConnectionStatus } from "./services/connection-status";
|
||||||
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
|
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
|
||||||
|
import { rateLimit } from "./utils/rate-limit";
|
||||||
|
|
||||||
export interface NetworkConnectionStatus {
|
export interface NetworkConnectionStatus {
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
|
|
@ -22,6 +23,8 @@ export interface NetworkConnectionStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncClient {
|
export class SyncClient {
|
||||||
|
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/max-params
|
// eslint-disable-next-line @typescript-eslint/max-params
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly history: SyncHistory,
|
private readonly history: SyncHistory,
|
||||||
|
|
@ -80,12 +83,17 @@ export class SyncClient {
|
||||||
database: undefined
|
database: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rateLimitedSave = rateLimit(
|
||||||
|
persistence.save,
|
||||||
|
SyncClient.MINIMUM_SAVE_INTERVAL_MS
|
||||||
|
);
|
||||||
|
|
||||||
const database = new Database(
|
const database = new Database(
|
||||||
logger,
|
logger,
|
||||||
state.database,
|
state.database,
|
||||||
async (data): Promise<void> => {
|
async (data): Promise<void> => {
|
||||||
state = { ...state, database: data };
|
state = { ...state, database: data };
|
||||||
return persistence.save(state);
|
await rateLimitedSave(state);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -94,7 +102,7 @@ export class SyncClient {
|
||||||
state.settings,
|
state.settings,
|
||||||
async (data): Promise<void> => {
|
async (data): Promise<void> => {
|
||||||
state = { ...state, settings: data };
|
state = { ...state, settings: data };
|
||||||
return persistence.save(state);
|
await rateLimitedSave(state);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
66
frontend/sync-client/src/utils/rate-limit.test.ts
Normal file
66
frontend/sync-client/src/utils/rate-limit.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
58
frontend/sync-client/src/utils/rate-limit.ts
Normal file
58
frontend/sync-client/src/utils/rate-limit.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue