@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
184 lines (162 loc) • 6.19 kB
text/typescript
import delay from "delay";
import type { DeferredPromise } from "p-defer";
import pDefer from "p-defer";
import { DefaultMap } from "../internal/DefaultMap";
import type { MaybeCallable } from "../internal/misc";
import { maybeCall, runInVoid } from "../internal/misc";
import type { QueryAnnotation } from "./QueryAnnotation";
import type { Runner } from "./Runner";
/**
* Batcher is similar to DataLoader, but with a few important differences:
* 1. It's strongly typed not only for the output, but for input too. And input
* can be arbitrary, not only strings (e.g. rows).
* 2. It does requests dedupping for all queries (including selects).
* 3. It's not limited by read-only requests like DataLoader, and thus it
* doesn't to any caching. Caching is delegated to some other layer (either
* above Batcher or in Runner).
*/
export class Batcher<TInput, TOutput> {
// key -> input
private queuedInputs = new Map<string, TInput>();
// key -> DeferredPromise[]
private queuedDefers = new DefaultMap<
string,
Array<DeferredPromise<TOutput>>
>();
// Dedupped annotations; each annotation identifies a caller of the query.
private queuedAnnotations = new Map<string, QueryAnnotation>();
protected flushQueue = async (): Promise<void> => {
if (!this.queuedInputs.size) {
return;
}
const inputs = this.queuedInputs;
const defers = this.queuedDefers;
const annotations = [...this.queuedAnnotations.values()];
this.queuedInputs = new Map();
this.queuedDefers = new DefaultMap();
this.queuedAnnotations = new Map();
let outputs = new Map<string, TOutput | undefined>();
const errors = new Map<string, unknown>();
if (inputs.size === 1 || !this.runner.runBatch || this.disableBatching) {
// Relatively rare since most of the requests come batched.
await this.runSingleForEach(inputs, annotations, outputs, errors);
} else {
// Called most of the times.
try {
outputs = await this.runner.runBatch(inputs, annotations);
} catch (e: unknown) {
// Relatively rare under heavy load (since errors are rare).
if (this.runner.shouldDebatchOnError(e)) {
await this.runSingleForEach(
inputs,
incrementAttempt(annotations),
outputs,
errors,
);
} else {
for (const key of defers.keys()) {
errors.set(key, e);
}
}
}
}
for (const [key, defersOfKey] of defers.entries()) {
const error = errors.get(key);
const output = outputs.get(key);
if (error === undefined) {
const outputOrDefault =
output === undefined ? this.runner.default : output;
for (const { resolve } of defersOfKey) {
// There are typically multiple callers waiting for the query results
// (due to e.g. same-ID queries coalescing).
resolve(outputOrDefault);
}
} else {
for (const { reject } of defersOfKey) {
reject(error);
}
}
}
};
constructor(
private runner: Runner<TInput, TOutput>,
private batchDelayMs: MaybeCallable<number>,
private disableBatching: boolean,
) {}
async run(input: TInput, annotation: QueryAnnotation): Promise<TOutput> {
const key = this.runner.key(input);
const delay = maybeCall(this.batchDelayMs);
// Queue return promise of this method.
const defer = pDefer<TOutput>();
this.queuedDefers.getOrAdd(key, Array).push(defer);
// In case of dedupping by key, prefer the last value. E.g. if 2 UPDATEs
// for the same ID have different values, then the last one will win, not
// the 1st one.
this.queuedInputs.set(key, input);
// Annotations are dedupped by their content.
this.queuedAnnotations.set(
annotation.trace +
annotation.vc +
annotation.debugStack +
annotation.whyClient,
annotation,
);
if (
this.queuedInputs.size >= this.runner.maxBatchSize ||
!this.runner.runBatch ||
this.disableBatching
) {
runInVoid(this.flushQueue);
} else if (this.queuedInputs.size === 1) {
// Defer calling of flushQueue() to the "end of the event loop's spin", to
// have a chance to collect more run() calls for it to execute. We
// actually defer twice (to the end of microtasks sub-loop and then once
// again), just in case: the original DataLoader library wraps the
// nextTick() call into a "global resolved Promise" object, so we do the
// same here blindly. See some of details here:
// https://github.com/graphql/dataloader/blob/fae38f14702e925d1e59051d7e5cb3a9a78bfde8/src/index.js#L234-L241
// https://stackoverflow.com/a/27648394
runInVoid(
Promise.resolve().then(() =>
delay > 0
? setTimeout(() => runInVoid(this.flushQueue()), delay)
: process.nextTick(this.flushQueue),
),
);
}
return defer.promise;
}
private async runSingleForEach(
inputs: Map<string, TInput>,
annotations: QueryAnnotation[],
outOutputs: Map<string, TOutput | undefined>,
outErrors: Map<string, unknown>,
): Promise<void> {
const promises: Array<Promise<unknown>> = [];
for (const [key, input] of inputs) {
promises.push(
this.runner
.runSingle(input, annotations)
.catch(async (error) => {
const retryMs = this.runner.delayForSingleQueryRetryOnError(error);
if (typeof retryMs === "number") {
await delay(retryMs);
}
if (retryMs !== "no_retry") {
return this.runner.runSingle(
input,
incrementAttempt(annotations),
);
}
throw error;
})
.then((output) => outOutputs.set(key, output))
.catch((error) => outErrors.set(key, error)),
);
}
await Promise["all"](promises);
}
}
function incrementAttempt(annotations: QueryAnnotation[]): QueryAnnotation[] {
return annotations.map((a) => ({ ...a, attempt: a.attempt + 1 }));
}