diff --git a/src/utils/eventLoop.ts b/src/utils/eventLoop.ts new file mode 100644 index 0000000..30c01f1 --- /dev/null +++ b/src/utils/eventLoop.ts @@ -0,0 +1,5 @@ +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const nextTick = () => + new Promise((resolve) => setImmediate(resolve)); diff --git a/src/utils/throttle.spec.ts b/src/utils/throttle.spec.ts new file mode 100644 index 0000000..92cc2d7 --- /dev/null +++ b/src/utils/throttle.spec.ts @@ -0,0 +1,290 @@ +import { nextTick, sleep } from './eventLoop'; +import { throttle } from './throttle'; + +const createDeferred = async () => { + let promise: Promise | 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], + ]); + }); +}); diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..5d7a80a --- /dev/null +++ b/src/utils/throttle.ts @@ -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 = ( + f: (...args: TArgs) => Promise, +) => { + const promises = new Map>(); + 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)!; + }; +};