implemented throttle util method

main
Inga 🏳‍🌈 7 months ago
parent 1c3ef056b2
commit e38fb2408a
  1. 5
      src/utils/eventLoop.ts
  2. 290
      src/utils/throttle.spec.ts
  3. 48
      src/utils/throttle.ts

@ -0,0 +1,5 @@
export const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
export const nextTick = () =>
new Promise<void>((resolve) => setImmediate(resolve));

@ -0,0 +1,290 @@
import { nextTick, sleep } from './eventLoop';
import { throttle } from './throttle';
const createDeferred = async () => {
let promise: Promise<void> | undefined = undefined;
const { settle } = await new Promise<{ settle: () => void }>((resolve) => {
promise = new Promise((settle) => {
resolve({ settle });
});
});
return {
// `promise` is guaranteed to be defined here, because we only reach this code after `resolve({ settle })` was called,
// which cannot happen in earlier event loop iteration than the one where `promise` is assigned.
// ESLint correctly complains here because this is an anti-pattern that makes it hard to automatically reason about the code.
// We only do async crimes here to write deterministic tests for low-level async `throttle` function.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
promise: promise!,
settle,
};
};
describe('accepts arguments and returns results for sequential calls with partially repeated arguments', () => {
it('calls sync function and returns value', async () => {
const calledWith: unknown[] = [];
const f = throttle(
(...args: unknown[]) =>
new Promise((resolve) => {
calledWith.push(args);
resolve(args);
}),
);
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(5)).toEqual([5]);
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(calledWith).toEqual([
[1, 2, 3],
[1, 2, 4],
[1, 2, 4],
[5],
[1, 2, 3],
]);
});
it('calls async function with nextTick and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await nextTick();
return args;
});
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(5)).toEqual([5]);
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(calledWith).toEqual([
[1, 2, 3],
[1, 2, 4],
[1, 2, 4],
[5],
[1, 2, 3],
]);
});
it('calls async function with small sleep and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await sleep(50);
return args;
});
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(1, 2, 4)).toEqual([1, 2, 4]);
expect(await f(5)).toEqual([5]);
expect(await f(1, 2, 3)).toEqual([1, 2, 3]);
expect(calledWith).toEqual([
[1, 2, 3],
[1, 2, 4],
[1, 2, 4],
[5],
[1, 2, 3],
]);
});
});
describe('accepts arguments and returns results for concurrent calls with different arguments', () => {
it('calls sync function and returns value', async () => {
const calledWith: unknown[] = [];
const f = throttle(
(...args: unknown[]) =>
new Promise((resolve) => {
calledWith.push(args);
resolve(args);
}),
);
expect(await Promise.all([f(1, 2, 3), f(1, 2, 4), f(5)])).toEqual([
[1, 2, 3],
[1, 2, 4],
[5],
]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
it('calls async function with nextTick and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await nextTick();
return args;
});
expect(await Promise.all([f(1, 2, 3), f(1, 2, 4), f(5)])).toEqual([
[1, 2, 3],
[1, 2, 4],
[5],
]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
it('calls async function with small sleep and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await sleep(50);
return args;
});
expect(await Promise.all([f(1, 2, 3), f(1, 2, 4), f(5)])).toEqual([
[1, 2, 3],
[1, 2, 4],
[5],
]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
});
describe('accepts arguments and returns results for concurrent calls with partially repeated arguments', () => {
it('calls sync function and returns value', async () => {
const calledWith: unknown[] = [];
const f = throttle(
(...args: unknown[]) =>
new Promise((resolve) => {
calledWith.push(args);
resolve(args);
}),
);
expect(
await Promise.all([
f(1, 2, 3),
f(1, 2, 4),
f(1, 2, 4),
f(5),
f(1, 2, 3),
]),
).toEqual([[1, 2, 3], [1, 2, 4], [1, 2, 4], [5], [1, 2, 3]]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
it('calls async function with nextTick and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await nextTick();
return args;
});
expect(
await Promise.all([
f(1, 2, 3),
f(1, 2, 4),
f(1, 2, 4),
f(5),
f(1, 2, 3),
]),
).toEqual([[1, 2, 3], [1, 2, 4], [1, 2, 4], [5], [1, 2, 3]]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
it('calls async function with small sleep and returns results', async () => {
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
await sleep(50);
return args;
});
expect(
await Promise.all([
f(1, 2, 3),
f(1, 2, 4),
f(1, 2, 4),
f(5),
f(1, 2, 3),
]),
).toEqual([[1, 2, 3], [1, 2, 4], [1, 2, 4], [5], [1, 2, 3]]);
expect(calledWith).toEqual([[1, 2, 3], [1, 2, 4], [5]]);
});
});
describe('waits for the wrapped function to resolve prior to calling it again', () => {
it('for the same arguments', async () => {
let deferred = await createDeferred();
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
const callNumber = calledWith.length;
await deferred.promise;
return { args, callNumber };
});
const promises = [];
promises.push(f(1, 2, 3));
promises.push(f(1, 2, 3));
promises.push(f(1, 2, 3));
deferred.settle();
await Promise.all(promises);
deferred = await createDeferred();
promises.push(f(1, 2, 3));
promises.push(f(1, 2, 3));
deferred.settle();
expect(await Promise.all(promises)).toEqual([
{ args: [1, 2, 3], callNumber: 1 },
{ args: [1, 2, 3], callNumber: 1 },
{ args: [1, 2, 3], callNumber: 1 },
{ args: [1, 2, 3], callNumber: 2 },
{ args: [1, 2, 3], callNumber: 2 },
]);
expect(calledWith).toEqual([
[1, 2, 3],
[1, 2, 3],
]);
});
it('for different arguments', async () => {
let deferred = await createDeferred();
const calledWith: unknown[] = [];
const f = throttle(async (...args: unknown[]) => {
calledWith.push(args);
const callNumber = calledWith.length;
await deferred.promise;
return { args, callNumber };
});
const promises = [];
promises.push(f(1, 2, 3));
promises.push(f(1, 2, 4));
promises.push(f(1, 2, 3));
deferred.settle();
await Promise.all(promises);
deferred = await createDeferred();
promises.push(f(1, 2, 4));
promises.push(f(1, 2, 3));
deferred.settle();
expect(await Promise.all(promises)).toEqual([
{ args: [1, 2, 3], callNumber: 1 },
{ args: [1, 2, 4], callNumber: 2 },
{ args: [1, 2, 3], callNumber: 1 },
{ args: [1, 2, 4], callNumber: 3 },
{ args: [1, 2, 3], callNumber: 4 },
]);
expect(calledWith).toEqual([
[1, 2, 3],
[1, 2, 4],
[1, 2, 4],
[1, 2, 3],
]);
});
});

@ -0,0 +1,48 @@
import { nextTick } from './eventLoop';
/**
* Function like this probably already exists on npm, or maybe it can be constructed from lodash tools etc.
* But I cannot think of any good keywords to find it now, easier to implement it myself.
*
* The purpose is: wrap an async function accepting any number of arguments so that even if the wrapped function
* is called more than once with the same arguments in quick succession, the underlying function will only be
* executed at most once at a time for every given argument set.
*
* This is useful for complex async functions that cannot really be executed concurrently for the same argument set,
* because of assumptions they make about the outside world not changing (in a way that matter) while they're executing.
* Which can be useful for e.g. data retrieval from remote api with caching.
* Another benefit of throttling is reducing the number of times underlying function is executed, which might bring
* additional performance benefits.
*
* Of course this will only work when only one copy of the application is running at the time.
*
* @param f Async function to be wrapped
* @returns Wrapped function, such that if it is called with the same arguments as some of the previous calls
* that did not yet resolve, it will return the previous promise rather than invoke `f` again.
*/
export const throttle = <TArgs extends unknown[], TResult>(
f: (...args: TArgs) => Promise<TResult>,
) => {
const promises = new Map<string, Promise<TResult>>();
return (...args: TArgs) => {
const promiseKey = JSON.stringify(args);
if (!promises.has(promiseKey)) {
promises.set(
promiseKey,
(async () => {
try {
return await f(...args);
} finally {
await nextTick();
promises.delete(promiseKey);
}
})(),
);
}
// `promises` is guaranteed to have this key here, because we just set it if it wasn't set before,
// and the body of `finally` is deferred to another event loop iteration.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return promises.get(promiseKey)!;
};
};
Loading…
Cancel
Save