parent
1c3ef056b2
commit
e38fb2408a
@ -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…
Reference in new issue