@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
100 lines (87 loc) • 3.18 kB
text/typescript
import type { CommonLogger } from './log/commonLogger.js'
import type { NumberOfMilliseconds } from './types.js'
class AsyncManagerImpl {
logger: CommonLogger = console
pendingOps = new Set<Promise<unknown>>()
private onErrorHooks: OnErrorHook[] = []
runInBackground(promise: Promise<unknown>): void {
const wrappedPromise = promise
.catch(err => this.fireOnErrorHooks(err))
.finally(() => this.pendingOps.delete(wrappedPromise))
this.pendingOps.add(wrappedPromise)
}
onError(fn: OnErrorHook): void {
this.onErrorHooks.push(fn)
}
/**
* Resolves when all pending operations settle.
* They may resolve or reject, allDone will never throw.
* Errors (rejections) are reported to onErrorHooks (instead).
*
* If timeout is specified - it resolves if timeout has reached.
*/
async allDone(timeout?: NumberOfMilliseconds): Promise<void> {
const { size } = this.pendingOps
if (!size) return
const { logger } = this
const started = Date.now()
if (timeout) {
const result = await Promise.race([
Promise.allSettled(this.pendingOps),
new Promise<'timeout'>(resolve => setTimeout(resolve, timeout, 'timeout')),
])
if (result === 'timeout') {
logger.warn(
`AsyncManager.allDone timed out after ${timeout} ms with ${this.pendingOps.size} pending op(s)`,
)
return
}
} else {
await Promise.allSettled(this.pendingOps)
}
logger.log(`AsyncManager.allDone for ${size} op(s) in ${Date.now() - started} ms`)
}
reset(): void {
this.pendingOps.clear()
this.onErrorHooks = []
}
private fireOnErrorHooks(err: any): void {
if (this.onErrorHooks.length) {
this.onErrorHooks.forEach(hook => hook(err))
} else {
this.logger.error('AsyncManager unhandled rejection:', err)
}
}
}
/**
* Singleton which keeps track of async operations - "voided promise-returning functions"
* that should run in parallel to the main request.
*
* It is an alternative to do `void doSomeAnalytics()`, which should run in parallel
* and not block the request (not slow down nor fail the request on analytics api failure).
*
* At the same time, `void doSomeAnalytics()` gets completely detached and untracked,
* nothing awaits it, its rejection becomes unhandledRejection (and may kill Node.js process).
*
* With AsyncManager, you instead register all those "voided" calls like this:
*
* AsyncManager.runInBackground(doSomeAnalytics())
*
* Then, in a few places you may be interested to ensure that all async operations have been finished.
* The places can be:
* - Graceful shutdown of a backend service
* - Before the end of runScript
* - At the end of each unit test, to make sure async ops don't leak
*
* You ensure no pending async operations like this:
*
* await AsyncManager.allDone()
*
* which never throws, but instead awaits all operations to be settled.
*
* @experimental
*/
export const AsyncManager = new AsyncManagerImpl()
// Shorthand alias
export const runInBackground = AsyncManager.runInBackground.bind(AsyncManager)
export type OnErrorHook = (err: Error) => any