UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

170 lines (159 loc) 6.07 kB
import { Memoize } from "fast-typescript-memoize"; import type { DeferredPromise } from "p-defer"; import pDefer from "p-defer"; import type { MaybeCallable } from "./misc"; import { maybeCall, runInVoid } from "./misc"; export interface CachedRefreshedValueOptions<TValue> { /** Delay between calling resolver. */ delayMs: MaybeCallable<number>; /** Log a timeout Error if a resolver takes more than X ms to complete. */ warningTimeoutMs: MaybeCallable<number>; /** The handler deps.handler() is called every deps.delayMs; if it returns a * different value than previously, then waiting for the next delayMs is * interrupted prematurely, and the value gets refreshed. */ deps: { delayMs: MaybeCallable<number>; handler: () => string; }; /** A resolver function that returns the value. It's assumed that this * function would eventually either resolve or throw. */ resolverFn: () => Promise<TValue>; /** An error handler. */ onError: (error: unknown, elapsed: number) => void; /** A custom delay implementation. */ delay: (ms: number) => Promise<void>; } /** * Utility class to provide a caching layer for a resolverFn with the following * assumptions: * - The value is stable and does not change frequently. * - The resolverFn can throw or take more time to resolve (e.g. outage). In * that case, the cached value is still valid, unless a fresh value is * requested with waitRefresh(). * * The implementation is as follows: * - Once value is accessed, we schedule an endless loop of calling resolver to * get latest value. * - The result is cached, so next calls will return it immediately in most of * the cases. * - Once every delayMs we call resolverFn to get latest value. All calls during * this time will get previous value (if available). */ export class CachedRefreshedValue<TValue> { /** Latest value pulled from the cache. */ private latestValue: TValue | null = null; /** Deferred promise containing the next value. Fulfilled promises are * replaced right away. */ private nextValue: DeferredPromise<TValue> = pDefer<TValue>(); /** Each time before resolverFn() is called, this value is increased. */ private resolverFnCallCount: number = 0; /** Whether the instance is destroyed or not. Used to prevent memory leaks in * unit tests. */ private destroyedError: Error | null = null; /** A callback to skip the current delay() call. */ private skipDelay: (() => void) | null = null; /** A never throwing version of options.onError(). */ private onErrorNothrow: CachedRefreshedValueOptions<TValue>["onError"]; /** * Initializes the instance. */ constructor(public readonly options: CachedRefreshedValueOptions<TValue>) { this.onErrorNothrow = (...args) => { try { options.onError(...args); } catch { // noop } }; } /** * Returns latest cached value. */ async cached(): Promise<TValue> { runInVoid(this.refreshLoop()); return this.latestValue ?? this.nextValue.promise; } /** * Triggers the call to resolverFn() ASAP (i.e. sooner than the next interval * specified in delayMs) and waits for the next successful cache refresh. */ async waitRefresh(): Promise<void> { runInVoid(this.refreshLoop()); // To unfreeze, we want a completely new resolverFn() call to finish within // refreshLoop(). I.e. the call to resolverFn() must start strictly AFTER we // entered waitRefresh(); thus, `while` loop below may spin twice. const startCallCount = this.resolverFnCallCount; while (this.resolverFnCallCount <= startCallCount) { // Skip waiting between loops. this.skipDelay?.(); // After await resolves here, it's guaranteed that this.nextValue will be // reassigned with a new pDefer (see refreshLoop() body). await this.nextValue.promise; } } /** * Destroys the instance. Stops refreshing the value and any call to it will * result in an error. */ destroy(): void { this.destroyedError = Error( `${this.constructor.name}: This instance is destroyed`, ); } @Memoize() private async refreshLoop(): Promise<void> { while (!this.destroyedError) { const warningDelayMs = maybeCall(this.options.warningTimeoutMs); const depsDelayMs = maybeCall(this.options.deps.delayMs); const depsPrev = this.options.deps.handler(); const startTime = performance.now(); const warningTimeout = setTimeout( () => this.onErrorNothrow( Error( `${this.constructor.name}.refreshLoop: Warning: ` + `resolverFn did not complete in ${warningDelayMs} ms!`, ), Math.round(performance.now() - startTime), ), warningDelayMs, ).unref(); try { this.resolverFnCallCount++; this.latestValue = await this.options.resolverFn(); const oldNextValue = this.nextValue; this.nextValue = pDefer(); oldNextValue.resolve(this.latestValue); } catch (e: unknown) { this.onErrorNothrow(e, Math.round(performance.now() - startTime)); } finally { clearTimeout(warningTimeout); } // Wait for delayMs with ability to skip it. const delayDefer = pDefer<void>(); this.skipDelay = () => delayDefer.resolve(); const depsInterval = setInterval(() => { if (this.options.deps.handler() !== depsPrev) { delayDefer.resolve(); } }, depsDelayMs).unref(); try { this.options .delay(maybeCall(this.options.delayMs)) .finally(() => delayDefer.resolve()) .catch(() => {}); await delayDefer.promise; } finally { clearInterval(depsInterval); } } // Mark current instance as destroyed. this.latestValue = null; runInVoid( this.nextValue.promise.catch(() => { // Stops unhandled promise rejection errors. }), ); this.nextValue.reject(this.destroyedError); } }