@temporalio/client
Version:
Temporal.io SDK Client sub-package
726 lines (672 loc) • 26.8 kB
text/typescript
import { status as grpcStatus } from '@grpc/grpc-js';
import { v4 as uuid4 } from 'uuid';
import type {
ActivityFunction,
LoadedDataConverter,
Next,
Priority,
RetryPolicy,
SearchAttributePair,
TypedSearchAttributes,
} from '@temporalio/common';
import {
compilePriority,
compileRetryPolicy,
convertDeploymentVersion,
decodePriority,
decompileRetryPolicy,
} from '@temporalio/common';
import type { Duration } from '@temporalio/common/lib/time';
import { msOptionalToTs, optionalTsToDate, optionalTsToMs } from '@temporalio/common/lib/time';
import { composeInterceptors } from '@temporalio/common/lib/interceptors';
import {
decodeTypedSearchAttributes,
encodeUnifiedSearchAttributes,
searchAttributePayloadConverter,
} from '@temporalio/common/lib/converter/payload-search-attributes';
import {
decodeArrayFromPayloads,
decodeFromPayloadsAtIndex,
decodeOptionalFailureToOptionalError,
encodeToPayloads,
encodeUserMetadata,
} from '@temporalio/common/lib/internal-non-workflow';
import { temporal } from '@temporalio/proto';
import type { Replace } from '@temporalio/common/lib/type-helpers';
import type {
ActivityCancelInput,
ActivityClientInterceptor,
ActivityCountInput,
ActivityDescribeInput,
ActivityGetResultInput,
ActivityListInput,
ActivityStartInput,
ActivityTerminateInput,
} from './interceptors';
import type { AsyncCompletionClientOptions } from './async-completion-client';
import { AsyncCompletionClient } from './async-completion-client';
import type {
ActivityExecutionDescription,
ActivityExecutionInfo,
ActivityIdConflictPolicy,
ActivityIdReusePolicy,
CountActivityExecutions,
} from './types';
import {
decodeActivityExecutionStatus,
decodePendingActivityState,
encodeActivityIdConflictPolicy,
encodeActivityIdReusePolicy,
} from './types';
import type { ErrorDetailsName } from './helpers';
import { rethrowKnownErrorTypes, trimGrpcTypeUrl, getGrpcStatusDetails } from './helpers';
import {
isGrpcServiceError,
ServiceError,
ActivityNotFoundError,
ActivityExecutionFailedError,
ActivityExecutionAlreadyStartedError,
} from './errors';
/**
* Options used to configure {@link ActivityClient}
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export interface ActivityClientOptions extends AsyncCompletionClientOptions {
interceptors?: ActivityClientInterceptor[];
}
/**
* Client for starting and managing Activities, and for asynchronous completion and heartbeating of Activities.
* Includes all functionality of {@link AsyncCompletionClient}.
*
* Typically this client should not be instantiated directly, instead create the high level {@link Client} and use
* {@link Client.activity} to interact with Activities.
*/
export class ActivityClient extends AsyncCompletionClient implements TypedActivityClient<any> {
private readonly interceptedHandlers: {
[K in keyof Required<ActivityClientInterceptor>]: Next<ActivityClientInterceptor, K>;
};
constructor(options?: ActivityClientOptions) {
super(options);
const interceptors = options?.interceptors ?? [];
this.interceptedHandlers = {
start: composeInterceptors(interceptors, 'start', this.startHandler.bind(this)),
getResult: composeInterceptors(interceptors, 'getResult', this.getResultHandler.bind(this)),
describe: composeInterceptors(interceptors, 'describe', this.describeHandler.bind(this)),
cancel: composeInterceptors(interceptors, 'cancel', this.cancelHandler.bind(this)),
terminate: composeInterceptors(interceptors, 'terminate', this.terminateHandler.bind(this)),
list: composeInterceptors(interceptors, 'list', this.listHandler.bind(this)),
count: composeInterceptors(interceptors, 'count', this.countHandler.bind(this)),
};
}
/**
* Returns this client as a {@link TypedActivityClient}. It enables strong type checking of Activity name, arguments
* and result based on the provided Activity interface. Note that no new client object is created - this method only
* affects type annotations.
* @template T Activity interface to use for type checking. The returned client can only start activities present in
* this interface.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
typed<T>(): TypedActivityClient<T> {
return this;
}
/**
* Starts new Standalone Activity execution.
*
* @param activity Name of the activity to start.
* @param options Options controlling the start and execution of the activity.
* @returns Handle to the started activity. The handle's `runId` property will be set to the started run.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
async start<R = any>(activity: string, options: ActivityOptions): Promise<ActivityHandle<R>> {
return this.interceptedHandlers.start({
activityType: activity,
options,
headers: {},
});
}
/**
* Executes a Standalone Activity until completion and returns the result.
* @param activity Name of the activity to start.
* @param options Options controlling the activity execution.
* @returns Result of the activity.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
async execute<R = any>(activity: string, options: ActivityOptions): Promise<R> {
const handle = await this.start(activity, options);
return handle.result();
}
/**
* Creates an Activity handle from ID and optionally from run ID. If `runId` is not set, the handle will refer to the
* newest Activity run with the given Activity ID.
*
* Note 1: this function always succeeds. If the provided ID is invalid, an error will only be thrown when calling
* the handle's methods.
*
* Note 2: if `runID` is not set when calling `getHandle`, then `runId` property of the returned handle will always
* remain unset, even after method calls are performed. To get the run ID of the targeted activity execution, call
* {@link ActivityHandle.describe} and read the `activityRunId` field of the returned {@link ActivityExecutionDescription}.
*
* @param activityId ID of the Activity.
* @param runId Optional run ID of the specific Activity execution.
* @returns Handle to the specified activity execution.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
getHandle<R = any>(activityId: string, runId?: string): ActivityHandle<R> {
return this.createHandle(activityId, runId);
}
/**
* Return a list of Activity executions matching the given `query`.
*
* Note that the list of Activity executions returned is approximate and eventually consistent.
*
* More info on the concept of "visibility" and the query syntax on the Temporal documentation site:
* https://docs.temporal.io/visibility
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
list(query: string): AsyncIterable<ActivityExecutionInfo> {
return this.interceptedHandlers.list({
query,
headers: {},
});
}
/**
* Return the number of Activity executions matching the given `query`.
*
* Note that the number of Activity executions returned is approximate and eventually consistent.
*
* More info on the concept of "visibility" and the query syntax on the Temporal documentation site:
* https://docs.temporal.io/visibility
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
async count(query: string): Promise<CountActivityExecutions> {
return await this.interceptedHandlers.count({
query,
headers: {},
});
}
protected createHandle<R>(activityId: string, runId?: string): ActivityHandle<R> {
if (!activityId) {
throw new TypeError('activityId is required');
}
const handle = {
client: this,
activityId,
runId,
async result(): Promise<R> {
return await this.client.interceptedHandlers.getResult({
activityId: this.activityId,
activityRunId: this.runId ?? '',
headers: {},
});
},
async describe(): Promise<ActivityExecutionDescription> {
return await this.client.interceptedHandlers.describe({
activityId: this.activityId,
activityRunId: this.runId ?? '',
headers: {},
});
},
async cancel(reason: string): Promise<void> {
return await this.client.interceptedHandlers.cancel({
activityId: this.activityId,
activityRunId: this.runId ?? '',
reason,
headers: {},
});
},
async terminate(reason: string): Promise<void> {
return await this.client.interceptedHandlers.terminate({
activityId: this.activityId,
activityRunId: this.runId ?? '',
reason,
headers: {},
});
},
};
return handle;
}
protected async startHandler(input: ActivityStartInput): Promise<ActivityHandle> {
if (!input.activityType) {
throw new TypeError('activityType is required');
}
validateActivityOptions(input.options);
try {
const resp = await this.workflowService.startActivityExecution(
await this.buildStartActivityExecutionRequest(input)
);
return this.createHandle(input.options.id, resp.runId);
} catch (err) {
if (isGrpcServiceError(err) && err.code === grpcStatus.ALREADY_EXISTS) {
for (const entry of getGrpcStatusDetails(err) ?? []) {
if (!entry.type_url || !entry.value) continue;
if (
(trimGrpcTypeUrl(entry.type_url) as ErrorDetailsName) ===
'temporal.api.errordetails.v1.ActivityExecutionAlreadyStartedFailure'
) {
const details = temporal.api.errordetails.v1.ActivityExecutionAlreadyStartedFailure.decode(entry.value);
throw new ActivityExecutionAlreadyStartedError(
'Activity execution already started',
input.options.id,
details.runId
);
}
}
}
this.rethrowGrpcError(err, 'Failed to start activity');
}
}
protected async buildStartActivityExecutionRequest(
input: ActivityStartInput
): Promise<temporal.api.workflowservice.v1.IStartActivityExecutionRequest> {
const searchAttributes = input.options.typedSearchAttributes
? { indexedFields: encodeUnifiedSearchAttributes(undefined, input.options.typedSearchAttributes) }
: undefined;
return {
namespace: this.options.namespace,
identity: this.options.identity,
requestId: uuid4(),
activityId: input.options.id,
activityType: { name: input.activityType },
taskQueue: { name: input.options.taskQueue },
scheduleToCloseTimeout: msOptionalToTs(input.options.scheduleToCloseTimeout),
scheduleToStartTimeout: msOptionalToTs(input.options.scheduleToStartTimeout),
startToCloseTimeout: msOptionalToTs(input.options.startToCloseTimeout),
heartbeatTimeout: msOptionalToTs(input.options.heartbeatTimeout),
retryPolicy: input.options.retry ? compileRetryPolicy(input.options.retry) : undefined,
input: { payloads: await encodeToPayloads(this.dataConverter, ...(input.options.args || [])) },
idReusePolicy: encodeActivityIdReusePolicy(input.options.idReusePolicy),
idConflictPolicy: encodeActivityIdConflictPolicy(input.options.idConflictPolicy),
searchAttributes,
header: { fields: input.headers },
userMetadata: await encodeUserMetadata(this.dataConverter, input.options.summary, undefined),
priority: input.options.priority ? compilePriority(input.options.priority) : undefined,
};
}
protected async getResultHandler(input: ActivityGetResultInput): Promise<any> {
if (!input.activityId) {
throw new TypeError('activityId is required');
}
const req: temporal.api.workflowservice.v1.IPollActivityExecutionRequest = {
namespace: this.options.namespace,
activityId: input.activityId,
runId: input.activityRunId || undefined,
};
for (;;) {
let failedErr;
try {
const resp = await this.workflowService.pollActivityExecution(req);
if (resp.outcome?.result) {
const [result] = await decodeArrayFromPayloads(this.dataConverter, resp.outcome.result.payloads ?? []);
return result;
} else if (resp.outcome?.failure) {
// If error conversion throws an exception, we want it to be caught and handled by rethrowGrpcError().
// If it succeeds, we want to throw the ActivityExecutionFailedError directly, so outside of try/catch.
failedErr = new ActivityExecutionFailedError(
'Activity execution failed',
await decodeOptionalFailureToOptionalError(this.dataConverter, resp.outcome.failure),
input.activityId,
resp.runId || input.activityRunId || undefined
);
}
} catch (err) {
this.rethrowGrpcError(err, 'Failed to get activity result');
}
if (failedErr) {
throw failedErr;
}
}
}
protected async describeHandler(input: ActivityDescribeInput): Promise<ActivityExecutionDescription> {
if (!input.activityId) {
throw new TypeError('activityId is required');
}
try {
const resp = await this.workflowService.describeActivityExecution({
namespace: this.options.namespace,
activityId: input.activityId,
runId: input.activityRunId || undefined,
});
return buildActivityDescription(resp.info!, this.dataConverter);
} catch (err) {
this.rethrowGrpcError(err, 'Failed to describe activity');
}
}
protected async cancelHandler(input: ActivityCancelInput): Promise<void> {
if (!input.activityId) {
throw new TypeError('activityId is required');
}
try {
await this.workflowService.requestCancelActivityExecution({
namespace: this.options.namespace,
activityId: input.activityId,
runId: input.activityRunId || undefined,
identity: this.options.identity,
requestId: uuid4(),
reason: input.reason || undefined,
});
} catch (err) {
this.rethrowGrpcError(err, 'Failed to request activity cancellation');
}
}
protected async terminateHandler(input: ActivityTerminateInput): Promise<void> {
if (!input.activityId) {
throw new TypeError('activityId is required');
}
try {
await this.workflowService.terminateActivityExecution({
namespace: this.options.namespace,
activityId: input.activityId,
runId: input.activityRunId || undefined,
identity: this.options.identity,
requestId: uuid4(),
reason: input.reason || undefined,
});
} catch (err) {
this.rethrowGrpcError(err, 'Failed to terminate activity');
}
}
protected async *listHandler(input: ActivityListInput): AsyncIterable<ActivityExecutionInfo> {
let nextPageToken: Uint8Array | null | undefined = undefined;
do {
try {
const resp: temporal.api.workflowservice.v1.IListActivityExecutionsResponse =
await this.workflowService.listActivityExecutions({
namespace: this.options.namespace,
query: input.query,
nextPageToken,
});
for (const info of resp.executions ?? []) {
yield buildActivityExecutionInfo(info);
}
nextPageToken = resp.nextPageToken;
} catch (e) {
this.rethrowGrpcError(e, 'Failed to list activities');
}
} while (nextPageToken && nextPageToken.length > 0);
}
protected async countHandler(input: ActivityCountInput): Promise<CountActivityExecutions> {
try {
const resp = await this.workflowService.countActivityExecutions({
namespace: this.options.namespace,
query: input.query,
});
return {
count: resp.count?.toNumber() ?? 0,
groups: resp.groups?.map((g) => ({
count: g.count?.toNumber() ?? 0,
groupValues: g.groupValues?.map((v) => searchAttributePayloadConverter.fromPayload(v)),
})),
};
} catch (err) {
this.rethrowGrpcError(err, 'Failed to count activities');
}
}
protected rethrowGrpcError(err: unknown, fallbackMessage: string): never {
if (isGrpcServiceError(err)) {
rethrowKnownErrorTypes(err);
if (err.code === grpcStatus.NOT_FOUND) {
throw new ActivityNotFoundError(err.details ?? 'Activity not found');
}
throw new ServiceError(fallbackMessage, { cause: err });
}
throw new ServiceError('Unexpected error while making gRPC request');
}
}
/**
* Handle that can be used to perform operations on the associated Activity.
* Can be obtained by calling {@link ActivityClient.start} or {@link ActivityClient.getHandle}.
* @template R Result type of the activity. Use {@link ActivityClient.typed} to start activities in a type-safe way.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export interface ActivityHandle<R = any> {
/**
* ID of the Activity this handle refers to.
*/
readonly activityId: string;
/**
* Run ID of the specific Activity execution this handle refers to. If empty, this handle refers to the latest
* execution of the Activity with given ID.
*/
readonly runId?: string;
/**
* Waits until the activity completes. If the activity is successful, returns the result of the activity.
* If the activity was not successful, throws {@link ActivityExecutionFailedError}. The activity failure is stored in
* the `cause` field.
*/
result(): Promise<R>;
/**
* Returns information about the Activity execution.
*/
describe(): Promise<ActivityExecutionDescription>;
/**
* Requests cancellation of the Activity execution. Note that cancellations are cooperative and not guaranteed to happen.
*/
cancel(reason: string): Promise<void>;
/**
* Terminates the Activity execution. Note that the worker is not immediately notified of termination and may continue running the activity.
*/
terminate(reason: string): Promise<void>;
}
/**
* Options used by {@link ActivityClient.start}.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export interface ActivityOptions {
/**
* Activity ID of the started activity. It's recommended to use a meaningful business ID.
*/
id: string;
/**
* Task queue to run this activity on.
*/
taskQueue: string;
/**
* Input arguments to pass to the activity.
*/
args?: any[] | Readonly<any[]>;
/**
* If set, specifies maximum time between successful heartbeats.
*/
heartbeatTimeout?: Duration;
/**
* Controls how Activity is retried. If not set, the server will assign default retry policy.
*/
retry?: RetryPolicy;
/**
* Is set, specifies total time the activity is allowed to run, including retries.
*
* Note: it is required to set at least one of {@link startToCloseTimeout} and {@link scheduleToCloseTimeout}.
*/
startToCloseTimeout?: Duration;
/**
* If set, specifies maximum time the activity can wait in the task queue before being picked up by a worker.
* This timeout is non-retryable.
*/
scheduleToStartTimeout?: Duration;
/**
* If set, specifies maximum time for a single execution attempt. This timeout is retryable.
*
* Note: it is required to set at least one of {@link startToCloseTimeout} and {@link scheduleToCloseTimeout}.
*/
scheduleToCloseTimeout?: Duration;
/**
* A single-line fixed summary for this activity execution that may appear in UI/CLI.
* This can be in single-line Temporal markdown format.
*/
summary?: string;
/**
* Priority to use when starting this activity.
*/
priority?: Priority;
/**
* Specifies behavior if there's a *closed* activity with the same ID.
*/
idReusePolicy?: ActivityIdReusePolicy;
/**
* Specifies behavior if there's a *running* activity with the same ID. Note that there can only be one running
* Activity for each Activity ID.
*/
idConflictPolicy?: ActivityIdConflictPolicy;
/**
* Search attributes for the activity.
*/
typedSearchAttributes?: SearchAttributePair[] | TypedSearchAttributes;
}
function validateActivityOptions(options: ActivityOptions): void {
if (!options.id) {
throw new TypeError('id is required');
}
if (!options.taskQueue) {
throw new TypeError('taskQueue is required');
}
if (!options.scheduleToCloseTimeout && !options.startToCloseTimeout) {
throw new TypeError('Either scheduleToCloseTimeout or startToCloseTimeout is required');
}
}
function buildActivityExecutionInfoCommonPart(
info: temporal.api.activity.v1.IActivityExecutionListInfo | temporal.api.activity.v1.IActivityExecutionInfo
): ActivityExecutionInfo {
return {
activityId: info.activityId!,
activityRunId: info.runId!,
activityType: info.activityType!.name!,
scheduleTime: optionalTsToDate(info.scheduleTime),
closeTime: optionalTsToDate(info.closeTime),
status: decodeActivityExecutionStatus(info.status)!,
typedSearchAttributes: decodeTypedSearchAttributes(info.searchAttributes?.indexedFields),
taskQueue: info.taskQueue!,
executionDurationMs: optionalTsToMs(info.executionDuration),
};
}
function buildActivityExecutionInfo(info: temporal.api.activity.v1.IActivityExecutionListInfo): ActivityExecutionInfo {
return {
...buildActivityExecutionInfoCommonPart(info),
rawListInfo: info,
};
}
function buildActivityDescription(
info: temporal.api.activity.v1.IActivityExecutionInfo,
dataConverter: LoadedDataConverter
): ActivityExecutionDescription {
const getHeartbeatDetails: <T>() => Promise<T | undefined> = async <T>() => {
const payloads = info.heartbeatDetails?.payloads;
if (payloads && payloads.length > 0) {
return await decodeFromPayloadsAtIndex<T>(dataConverter, 0, info.heartbeatDetails?.payloads);
} else {
return undefined;
}
};
const getLastFailure: () => Promise<Error | undefined> = async () => {
return await decodeOptionalFailureToOptionalError(dataConverter, info.lastFailure);
};
return {
...buildActivityExecutionInfoCommonPart(info),
rawInfo: info,
runState: decodePendingActivityState(info.runState),
scheduleToCloseTimeoutMs: optionalTsToMs(info.scheduleToCloseTimeout),
scheduleToStartTimeoutMs: optionalTsToMs(info.scheduleToStartTimeout),
startToCloseTimeoutMs: optionalTsToMs(info.startToCloseTimeout),
heartbeatTimeoutMs: optionalTsToMs(info.heartbeatTimeout),
retryPolicy: decompileRetryPolicy(info.retryPolicy)!,
lastHeartbeatTime: optionalTsToDate(info.lastHeartbeatTime),
lastStartedTime: optionalTsToDate(info.lastStartedTime),
attempt: info.attempt!,
expirationTime: optionalTsToDate(info.expirationTime),
lastWorkerIdentity: info.lastWorkerIdentity || undefined,
currentRetryIntervalMs: optionalTsToMs(info.currentRetryInterval),
lastAttemptCompleteTime: optionalTsToDate(info.lastAttemptCompleteTime),
nextAttemptScheduleTime: optionalTsToDate(info.nextAttemptScheduleTime),
lastDeploymentVersion: convertDeploymentVersion(info.lastDeploymentVersion),
priority: decodePriority(info.priority),
canceledReason: info.canceledReason || undefined,
getHeartbeatDetails,
getLastFailure,
};
}
/**
* Sub-interface of {@link ActivityClient} that provides a strongly-typed interface for executing Activities.
* Argument types in the provided options must match the argument types of the specified Activity as defined in provided
* interface
* @template T Activity interface
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export interface TypedActivityClient<T> {
start<N extends ActivityName<T>>(
activity: N,
options: ActivityOptionsFor<T, N>
): Promise<ActivityHandle<ActivityResult<T, N>>>;
execute<N extends ActivityName<T>>(activity: N, options: ActivityOptionsFor<T, N>): Promise<ActivityResult<T, N>>;
}
/**
* Utility type to support strong typing in {@link TypedActivityClient}.
* Contains names of activities extracted from the specified activity interface.
* @template T Activity interface
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export type ActivityName<T> = {
[N in keyof T & string]: T[N] extends ActivityFunction<any, any> ? N : never;
}[keyof T & string];
/**
* Utility type to support strong typing in {@link TypedActivityClient}.
* Extracts argument types of an activity.
* @template T Activity interface
* @template N Activity name
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export type ActivityArgs<T, N extends ActivityName<T>> = T[N] extends ActivityFunction<infer P, any> ? P : never;
/**
* Utility type to support strong typing in {@link TypedActivityClient}.
* Extracts result type of an activity.
* @template T Activity interface
* @template N Activity name
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export type ActivityResult<T, N extends ActivityName<T>> = T[N] extends ActivityFunction<any, infer R> ? R : never;
/**
* Utility type to support strong typing in {@link TypedActivityClient}.
* Represents {@link ActivityOptions} with strongly typed arguments.
* @template Args Types of activity arguments as an array type.
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export type ActivityOptionsWithArgs<Args extends any[]> = Args extends [any, ...any]
? Replace<
ActivityOptions,
{
/**
* Arguments to pass to the Activity
*/
args: Args | Readonly<Args>;
}
>
: Replace<
ActivityOptions,
{
/**
* Arguments to pass to the Activity
*/
args?: Args | Readonly<Args>;
}
>;
/**
* Utility type to support strong typing in {@link TypedActivityClient}.
* Represents {@link ActivityOptions} with strongly typed arguments matching specified Activity in specified interface.
* @template T Activity interface
* @template N Activity name
*
* @experimental Standalone Activities are experimental. APIs may be subject to change.
*/
export type ActivityOptionsFor<T, N extends ActivityName<T>> = ActivityOptionsWithArgs<ActivityArgs<T, N>>;