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