@temporalio/common
Version:
Common library for code that's used across the Client, Worker, and/or Workflow
151 lines (139 loc) • 4.83 kB
text/typescript
import type { WalkEnv } from '@temporalio/proto/lib/payload-visitor.generated';
import { sequential, type ConcurrencyLimit } from '../concurrency/limit';
import type { Payload } from '../interfaces';
/**
* Called for each singular (or map-value) payload-bearing field. One payload in, one out.
*
* @internal
* @experimental
*/
export type PayloadTransform<Ctx> = (payload: Payload, context: Ctx, abortSignal?: AbortSignal) => Promise<Payload>;
/**
* Called for each payload-bearing field that may contain multiple payloads (e.g. Payloads or repeated fields).
* May return any number of payloads, including zero.
*
* @internal
* @experimental
*/
export type PayloadsTransform<Ctx> = (
payloads: Payload[],
context: Ctx,
abortSignal?: AbortSignal
) => Promise<Payload[]>;
/**
* Called on entering each message and returns the context for its children.
*
* @internal
* @experimental
*/
export type ContextDeriver<Ctx> = (message: object, typeName: string, context: Ctx) => Ctx;
/**
* Two transform functions are required because some fields require a single (non-null) payload while others
* are simply lists.
*
* @internal
* @experimental
*/
export interface VisitOptions<Ctx> {
transformPayload: PayloadTransform<Ctx>;
transformPayloads: PayloadsTransform<Ctx>;
deriveContext?: ContextDeriver<Ctx>;
/** Context in scope before any message is entered. */
initialContext?: Ctx;
/**
* Optional concurrency limit applied to every transform call. Share one limit across visits for a
* global cap (e.g. a payload store's total budget), or nest limits to compose a per-visit cap under
* a global one. Omit to run transforms one at a time (sequential).
*/
limit?: ConcurrencyLimit;
skipHeaders?: boolean;
skipSearchAttributes?: boolean;
/** Aborts the walk; composed with the internal cancel-on-error signal and handed to the transform. */
abortSignal?: AbortSignal;
}
/**
* Awaits every promise a walk produced, then throws the first rejection in traversal order.
* `allSettled` guarantees no in-flight transform is left running on the error path.
*/
async function drain(pending: Promise<unknown>[]): Promise<void> {
const results = await Promise.allSettled(pending);
for (const result of results) {
if (result.status === 'rejected') {
throw result.reason;
}
}
}
/**
* Runs a recursive walk from the VisitOptions. Runs each transform through the concurrency limit,
* then wraps them with the per-visit cancel-on-error signal and assigns them to {@link WalkEnv}.
*
* @internal
* @experimental
*/
async function runVisit<Ctx>(
options: VisitOptions<Ctx>,
walk: (env: WalkEnv<Ctx>, context: Ctx) => Promise<unknown>[]
): Promise<void> {
const {
transformPayload,
transformPayloads,
deriveContext,
initialContext,
limit = sequential(),
skipHeaders = false,
skipSearchAttributes = false,
abortSignal,
} = options;
const failure = new AbortController();
let removeListener: (() => void) | undefined;
if (abortSignal) {
if (abortSignal.aborted) {
failure.abort(abortSignal.reason);
} else {
const onAbort = () => failure.abort(abortSignal.reason);
abortSignal.addEventListener('abort', onAbort, { once: true });
removeListener = () => abortSignal.removeEventListener('abort', onAbort);
}
}
const runTransform = <T>(call: (signal: AbortSignal) => Promise<T>): Promise<T> => {
failure.signal.throwIfAborted();
return limit(async () => {
failure.signal.throwIfAborted();
try {
return await call(failure.signal);
} catch (reason) {
failure.abort(reason);
throw reason;
}
});
};
const env: WalkEnv<Ctx> = {
transformPayload: (payload, context) => runTransform((signal) => transformPayload(payload, context, signal)),
transformPayloads: (payloads, context) => runTransform((signal) => transformPayloads(payloads, context, signal)),
deriveContext,
skipHeaders,
skipSearchAttributes,
};
try {
await drain(walk(env, initialContext as Ctx));
} finally {
removeListener?.();
}
}
/**
* Applies the payload transforms to every {@link Payload} in `root`, mutating it in place. Pass the
* generated `walk*` function for the root's message type (all are re-exported below).
*
* @internal
* @experimental
*/
export async function visit<Root, Ctx = void>(
root: Root,
walk: (root: Root, env: WalkEnv<Ctx>, context: Ctx) => Promise<unknown>[],
options: VisitOptions<Ctx>
): Promise<void> {
return runVisit(options, (env, context) => walk(root, env, context));
}
// Re-export every generated walker so consumers pair any of them with `visit` without a deep import
// into the generated file.
export * from '@temporalio/proto/lib/payload-visitor.generated';