UNPKG

@temporalio/client

Version:
726 lines (672 loc) 26.8 kB
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>>;