import { BatchLookupError } from '@/models/BatchLookupError' import { MaybePromise } from '@/types' export type BatchCallbackLookup = (value: V) => MaybePromise export type BatchCallback = (values: V[]) => MaybePromise | BatchCallbackLookup> export type BatchOptions = { maxBatchSize?: number, maxWaitMilliseconds?: number, } type Timer = ReturnType type Resolve = (value: V) => void type Reject = (reason?: unknown) => void type BatchQueueValue = { response: Promise, resolve: Resolve, reject: Reject, } type BatchQueue = Map> export class BatchProcessor { private readonly callback: BatchCallback private readonly options: BatchOptions private readonly queue: BatchQueue = new Map() private timeout: Timer | undefined = undefined private waitingSince: number | null = null public constructor(callback: BatchCallback, options: BatchOptions = {}) { this.callback = callback this.options = options } public batch(value: V): Promise { if (this.queue.has(value)) { const { response } = this.queue.get(value)! return response } let resolve!: Resolve let reject!: Reject const response = new Promise((...args) => { [resolve, reject] = args }) this.queue.set(value, { response, resolve, reject, }) this.requestProcessQueue() return response } public process(): void { this.processQueue() } private requestProcessQueue(): void { if (this.shouldProcessNow()) { this.processQueue() return } this.waitToProcessQueue() } private waitToProcessQueue(): void { if (this.waitingSince === null) { this.waitingSince = Date.now() } clearTimeout(this.timeout) this.timeout = setTimeout(() => this.processQueue()) } private shouldProcessNow(): boolean { return this.maxBatchSizeReached() || this.maxWaitReached() } private maxBatchSizeReached(): boolean { const { maxBatchSize = Infinity } = this.options return this.queue.size >= maxBatchSize } private maxWaitReached(): boolean { const { maxWaitMilliseconds = Infinity } = this.options const now = Date.now() const since = this.waitingSince ?? 0 return now - since >= maxWaitMilliseconds } private getBatchToProcess(): BatchQueue { const batch = new Map(this.queue) this.queue.clear() this.waitingSince = null clearTimeout(this.timeout) return batch } private async processQueue(): Promise { const batch = this.getBatchToProcess() const values = Array.from(batch.keys()) try { const response = await this.callback(values) if (this.isBatchCallbackLookup(response)) { return this.resolveBatchUsingLookup(batch, response) } return this.resolveBatchUsingMap(batch, response) } catch (error) { this.rejectBatch(batch, error) } } private resolveBatchUsingMap(batch: BatchQueue, map: Map): void { batch.forEach(({ resolve, reject }, id) => { const value = map.get(id) if (value === undefined) { reject(new BatchLookupError(id)) return } resolve(value) }) } private resolveBatchUsingLookup(batch: BatchQueue, lookup: BatchCallbackLookup): void { batch.forEach(async ({ resolve }, id) => { resolve(await lookup(id)) }) } private rejectBatch(batch: BatchQueue, error: unknown): void { batch.forEach(({ reject }) => reject(error)) } private isBatchCallbackLookup(value: ReturnType>): value is BatchCallbackLookup { return typeof value === 'function' } }