@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
170 lines (159 loc) • 6.07 kB
text/typescript
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`,
);
}
()
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);
}
}