-
Notifications
You must be signed in to change notification settings - Fork 27.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
There's lots of situations in Next.js where we want to ensure that only one operation is in progress at a time for a given task. An example of this is our response cache. The expectation is that for multiple requests for the same page should only result in a single invocation. This isn't new behavior, but this abstracts the batching interface away so we don't duplicate it.
- Loading branch information
Showing
13 changed files
with
375 additions
and
225 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import { Batcher } from './batcher' | ||
|
||
describe('Batcher', () => { | ||
describe('batch', () => { | ||
it('should execute the work function immediately', async () => { | ||
const batcher = Batcher.create<string, number>() | ||
const workFn = jest.fn().mockResolvedValue(42) | ||
|
||
const result = await batcher.batch('key', workFn) | ||
|
||
expect(result).toBe(42) | ||
expect(workFn).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
it('should batch multiple calls to the same key', async () => { | ||
const batcher = Batcher.create<string, number>() | ||
const workFn = jest.fn().mockResolvedValue(42) | ||
|
||
const result1 = batcher.batch('key', workFn) | ||
const result2 = batcher.batch('key', workFn) | ||
|
||
expect(result1).toBeInstanceOf(Promise) | ||
expect(result2).toBeInstanceOf(Promise) | ||
expect(workFn).toHaveBeenCalledTimes(1) | ||
|
||
const [value1, value2] = await Promise.all([result1, result2]) | ||
|
||
expect(value1).toBe(42) | ||
expect(value2).toBe(42) | ||
expect(workFn).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
it('should not batch calls to different keys', async () => { | ||
const batcher = Batcher.create<string, string>() | ||
const workFn = jest.fn((key) => key) | ||
|
||
const result1 = batcher.batch('key1', workFn) | ||
const result2 = batcher.batch('key2', workFn) | ||
|
||
expect(result1).toBeInstanceOf(Promise) | ||
expect(result2).toBeInstanceOf(Promise) | ||
expect(workFn).toHaveBeenCalledTimes(2) | ||
|
||
const [value1, value2] = await Promise.all([result1, result2]) | ||
|
||
expect(value1).toBe('key1') | ||
expect(value2).toBe('key2') | ||
expect(workFn).toHaveBeenCalledTimes(2) | ||
}) | ||
|
||
it('should use the cacheKeyFn to generate cache keys', async () => { | ||
const cacheKeyFn = jest.fn().mockResolvedValue('cache-key') | ||
const batcher = Batcher.create<string, number>({ cacheKeyFn }) | ||
const workFn = jest.fn().mockResolvedValue(42) | ||
|
||
const result = await batcher.batch('key', workFn) | ||
|
||
expect(result).toBe(42) | ||
expect(cacheKeyFn).toHaveBeenCalledWith('key') | ||
expect(workFn).toHaveBeenCalledTimes(1) | ||
}) | ||
|
||
it('should use the schedulerFn to schedule work', async () => { | ||
const schedulerFn = jest.fn().mockImplementation((fn) => fn()) | ||
const batcher = Batcher.create<string, number>({ schedulerFn }) | ||
const workFn = jest.fn().mockResolvedValue(42) | ||
|
||
const results = await Promise.all([ | ||
batcher.batch('key', workFn), | ||
batcher.batch('key', workFn), | ||
batcher.batch('key', workFn), | ||
]) | ||
|
||
expect(results).toEqual([42, 42, 42]) | ||
expect(workFn).toHaveBeenCalledTimes(1) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
// This takes advantage of `Promise.withResolvers` which is polyfilled in | ||
// this imported module. | ||
import './polyfill-promise-with-resolvers' | ||
|
||
import { SchedulerFn } from '../server/lib/schedule-on-next-tick' | ||
|
||
type CacheKeyFn<K, C extends string | number | null> = ( | ||
key: K | ||
) => PromiseLike<C> | C | ||
|
||
type BatcherOptions<K, C extends string | number | null> = { | ||
cacheKeyFn?: CacheKeyFn<K, C> | ||
schedulerFn?: SchedulerFn<void> | ||
} | ||
|
||
type WorkFn<V, C> = ( | ||
key: C, | ||
resolve: (value: V | PromiseLike<V>) => void | ||
) => Promise<V> | ||
|
||
/** | ||
* A wrapper for a function that will only allow one call to the function to | ||
* execute at a time. | ||
*/ | ||
export class Batcher<K, V, C extends string | number | null> { | ||
private readonly pending = new Map<C, Promise<V>>() | ||
|
||
protected constructor( | ||
private readonly cacheKeyFn?: CacheKeyFn<K, C>, | ||
/** | ||
* A function that will be called to schedule the wrapped function to be | ||
* executed. This defaults to a function that will execute the function | ||
* immediately. | ||
*/ | ||
private readonly schedulerFn: SchedulerFn<void> = (fn) => fn() | ||
) {} | ||
|
||
/** | ||
* Creates a new instance of PendingWrapper. If the key extends a string or | ||
* number, the key will be used as the cache key. If the key is an object, a | ||
* cache key function must be provided. | ||
*/ | ||
public static create<K extends string | number | null, V>( | ||
options?: BatcherOptions<K, K> | ||
): Batcher<K, V, K> | ||
public static create<K, V, C extends string | number | null>( | ||
options: BatcherOptions<K, C> & | ||
Required<Pick<BatcherOptions<K, C>, 'cacheKeyFn'>> | ||
): Batcher<K, V, C> | ||
public static create<K, V, C extends string | number | null>( | ||
options?: BatcherOptions<K, C> | ||
): Batcher<K, V, C> { | ||
return new Batcher<K, V, C>(options?.cacheKeyFn, options?.schedulerFn) | ||
} | ||
|
||
/** | ||
* Wraps a function in a promise that will be resolved or rejected only once | ||
* for a given key. This will allow multiple calls to the function to be | ||
* made, but only one will be executed at a time. The result of the first | ||
* call will be returned to all callers. | ||
* | ||
* @param key the key to use for the cache | ||
* @param fn the function to wrap | ||
* @returns a promise that resolves to the result of the function | ||
*/ | ||
public async batch(key: K, fn: WorkFn<V, C>): Promise<V> { | ||
const cacheKey = (this.cacheKeyFn ? await this.cacheKeyFn(key) : key) as C | ||
if (cacheKey === null) { | ||
return fn(cacheKey, Promise.resolve) | ||
} | ||
|
||
const pending = this.pending.get(cacheKey) | ||
if (pending) return pending | ||
|
||
const { promise, resolve, reject } = Promise.withResolvers<V>() | ||
this.pending.set(cacheKey, promise) | ||
|
||
this.schedulerFn(async () => { | ||
try { | ||
const result = await fn(cacheKey, resolve) | ||
|
||
// Resolving a promise multiple times is a no-op, so we can safely | ||
// resolve all pending promises with the same result. | ||
resolve(result) | ||
} catch (err) { | ||
reject(err) | ||
} finally { | ||
this.pending.delete(cacheKey) | ||
} | ||
}) | ||
|
||
return promise | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
// This adds a `Promise.withResolvers` polyfill. This will soon be adopted into | ||
// the spec. | ||
// | ||
// TODO: remove this polyfill when it is adopted into the spec. | ||
// | ||
// https://tc39.es/proposal-promise-with-resolvers/ | ||
// | ||
if ( | ||
!('withResolvers' in Promise) || | ||
typeof Promise.withResolvers !== 'function' | ||
) { | ||
Promise.withResolvers = <T>() => { | ||
let resolvers: { | ||
resolve: (value: T | PromiseLike<T>) => void | ||
reject: (reason: any) => void | ||
} | ||
|
||
// Create the promise and assign the resolvers to the object. | ||
const promise = new Promise<T>((resolve, reject) => { | ||
resolvers = { resolve, reject } | ||
}) | ||
|
||
// We know that resolvers is defined because the Promise constructor runs | ||
// synchronously. | ||
return { promise, resolve: resolvers!.resolve, reject: resolvers!.reject } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.