@temporalio/workflow
Version:
Temporal.io SDK Workflow sub-package
276 lines (250 loc) • 10.8 kB
text/typescript
import type { AsyncLocalStorage as ALS } from 'node:async_hooks';
import { CancelledFailure, Duration, IllegalStateError } from '@temporalio/common';
import { msOptionalToNumber } from '@temporalio/common/lib/time';
import { untrackPromise } from './stack-helpers';
import { getActivator } from './global-attributes';
import { SdkFlags } from './flags';
// AsyncLocalStorage is injected via vm module into global scope.
// In case Workflow code is imported in Node.js context, replace with an empty class.
export const AsyncLocalStorage: new <T>() => ALS<T> = (globalThis as any).AsyncLocalStorage ?? class {};
/** Magic symbol used to create the root scope - intentionally not exported */
const NO_PARENT = Symbol('NO_PARENT');
/**
* Option for constructing a CancellationScope
*/
export interface CancellationScopeOptions {
/**
* Time in milliseconds before the scope cancellation is automatically requested
*/
timeout?: Duration;
/**
* If false, prevent outer cancellation from propagating to inner scopes, Activities, timers, and Triggers, defaults to true.
* (Scope still propagates CancelledFailure thrown from within).
*/
cancellable: boolean;
/**
* An optional CancellationScope (useful for running background tasks).
* The `NO_PARENT` symbol is reserved for the root scope.
*/
parent?: CancellationScope | typeof NO_PARENT;
}
/**
* Cancellation Scopes provide the mechanic by which a Workflow may gracefully handle incoming requests for cancellation
* (e.g. in response to {@link WorkflowHandle.cancel} or through the UI or CLI), as well as request cancelation of
* cancellable operations it owns (e.g. Activities, Timers, Child Workflows, etc).
*
* Cancellation Scopes form a tree, with the Workflow's main function running in the root scope of that tree.
* By default, cancellation propagates down from a parent scope to its children and its cancellable operations.
* A non-cancellable scope can receive cancellation requests, but is never effectively considered as cancelled,
* thus shieldding its children and cancellable operations from propagation of cancellation requests it receives.
*
* Scopes are created using the `CancellationScope` constructor or the static helper methods {@link cancellable},
* {@link nonCancellable} and {@link withTimeout}. `withTimeout` creates a scope that automatically cancels itself after
* some duration.
*
* Cancellation of a cancellable scope results in all operations created directly in that scope to throw a
* {@link CancelledFailure} (either directly, or as the `cause` of an {@link ActivityFailure} or a
* {@link ChildWorkflowFailure}). Further attempt to create new cancellable scopes or cancellable operations within a
* scope that has already been cancelled will also immediately throw a {@link CancelledFailure} exception. It is however
* possible to create a non-cancellable scope at that point; this is often used to execute rollback or cleanup
* operations. For example:
*
* ```ts
* async function myWorkflow(...): Promise<void> {
* try {
* // This activity runs in the root cancellation scope. Therefore, a cancelation request on
* // the Workflow execution (e.g. through the UI or CLI) automatically propagates to this
* // activity. Assuming that the activity properly handle the cancellation request, then the
* // call below will throw an `ActivityFailure` exception, with `cause` sets to an
* // instance of `CancelledFailure`.
* await someActivity();
* } catch (e) {
* if (isCancellation(e)) {
* // Run cleanup activity in a non-cancellable scope
* await CancellationScope.nonCancellable(async () => {
* await cleanupActivity();
* }
* } else {
* throw e;
* }
* }
* }
* ```
*
* A cancellable scope may be programatically cancelled by calling {@link cancel|`scope.cancel()`}`. This may be used,
* for example, to explicitly request cancellation of an Activity or Child Workflow:
*
* ```ts
* const cancellableActivityScope = new CancellationScope();
* const activityPromise = cancellableActivityScope.run(() => someActivity());
* cancellableActivityScope.cancel(); // Cancels the activity
* await activityPromise; // Throws `ActivityFailure` with `cause` set to `CancelledFailure`
* ```
*/
export class CancellationScope {
/**
* Time in milliseconds before the scope cancellation is automatically requested
*/
protected readonly timeout?: number;
/**
* If false, then this scope will never be considered cancelled, even if a cancellation request is received (either
* directly by calling `scope.cancel()` or indirectly by cancelling a cancellable parent scope). This effectively
* shields the scope's children and cancellable operations from propagation of cancellation requests made on the
* non-cancellable scope.
*
* Note that the Promise returned by the `run` function of non-cancellable scope may still throw a `CancelledFailure`
* if such an exception is thrown from within that scope (e.g. by directly cancelling a cancellable child scope).
*/
public readonly cancellable: boolean;
/**
* An optional CancellationScope (useful for running background tasks), defaults to {@link CancellationScope.current}()
*/
public readonly parent?: CancellationScope;
/**
* A Promise that throws when a cancellable scope receives a cancellation request, either directly
* (i.e. `scope.cancel()`), or indirectly (by cancelling a cancellable parent scope).
*
* Note that a non-cancellable scope may receive cancellation requests, resulting in the `cancelRequested` promise for
* that scope to throw, though the scope will not effectively get cancelled (i.e. `consideredCancelled` will still
* return `false`, and cancellation will not be propagated to child scopes and contained operations).
*/
public readonly cancelRequested: Promise<never>;
#cancelRequested = false;
// Typescript does not understand that the Promise executor runs synchronously in the constructor
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
protected readonly reject: (reason?: any) => void;
constructor(options?: CancellationScopeOptions) {
this.timeout = msOptionalToNumber(options?.timeout);
this.cancellable = options?.cancellable ?? true;
this.cancelRequested = new Promise((_, reject) => {
// @ts-expect-error TSC doesn't understand that the Promise executor runs synchronously
this.reject = (err) => {
this.#cancelRequested = true;
reject(err);
};
});
untrackPromise(this.cancelRequested);
// Avoid unhandled rejections
untrackPromise(this.cancelRequested.catch(() => undefined));
if (options?.parent !== NO_PARENT) {
this.parent = options?.parent || CancellationScope.current();
if (
this.parent.cancellable ||
(this.parent.#cancelRequested &&
!getActivator().hasFlag(SdkFlags.NonCancellableScopesAreShieldedFromPropagation))
) {
this.#cancelRequested = this.parent.#cancelRequested;
untrackPromise(
this.parent.cancelRequested.catch((err) => {
this.reject(err);
})
);
} else {
untrackPromise(
this.parent.cancelRequested.catch((err) => {
if (!getActivator().hasFlag(SdkFlags.NonCancellableScopesAreShieldedFromPropagation)) {
this.reject(err);
}
})
);
}
}
}
/**
* Whether the scope was effectively cancelled. A non-cancellable scope can never be considered cancelled.
*/
public get consideredCancelled(): boolean {
return this.#cancelRequested && this.cancellable;
}
/**
* Activate the scope as current and run `fn`
*
* Any timers, Activities, Triggers and CancellationScopes created in the body of `fn`
* automatically link their cancellation to this scope.
*
* @return the result of `fn`
*/
run<T>(fn: () => Promise<T>): Promise<T> {
return storage.run(this, this.runInContext.bind(this, fn) as () => Promise<T>);
}
/**
* Method that runs a function in AsyncLocalStorage context.
*
* Could have been written as anonymous function, made into a method for improved stack traces.
*/
protected async runInContext<T>(fn: () => Promise<T>): Promise<T> {
let timerScope: CancellationScope | undefined;
if (this.timeout) {
timerScope = new CancellationScope();
untrackPromise(
timerScope
.run(() => sleep(this.timeout as number))
.then(
() => this.cancel(),
() => {
// scope was already cancelled, ignore
}
)
);
}
try {
return await fn();
} finally {
if (
timerScope &&
!timerScope.consideredCancelled &&
getActivator().hasFlag(SdkFlags.NonCancellableScopesAreShieldedFromPropagation)
) {
timerScope.cancel();
}
}
}
/**
* Request to cancel the scope and linked children
*/
cancel(): void {
this.reject(new CancelledFailure('Cancellation scope cancelled'));
}
/**
* Get the current "active" scope
*/
static current(): CancellationScope {
// Using globals directly instead of a helper function to avoid circular import
return storage.getStore() ?? (globalThis as any).__TEMPORAL_ACTIVATOR__.rootScope;
}
/** Alias to `new CancellationScope({ cancellable: true }).run(fn)` */
static cancellable<T>(fn: () => Promise<T>): Promise<T> {
return new this({ cancellable: true }).run(fn);
}
/** Alias to `new CancellationScope({ cancellable: false }).run(fn)` */
static nonCancellable<T>(fn: () => Promise<T>): Promise<T> {
return new this({ cancellable: false }).run(fn);
}
/** Alias to `new CancellationScope({ cancellable: true, timeout }).run(fn)` */
static withTimeout<T>(timeout: Duration, fn: () => Promise<T>): Promise<T> {
return new this({ cancellable: true, timeout }).run(fn);
}
}
const storage = new AsyncLocalStorage<CancellationScope>();
/**
* Avoid exposing the storage directly so it doesn't get frozen
*/
export function disableStorage(): void {
storage.disable();
}
export class RootCancellationScope extends CancellationScope {
constructor() {
super({ cancellable: true, parent: NO_PARENT });
}
cancel(): void {
this.reject(new CancelledFailure('Workflow cancelled'));
}
}
/** This function is here to avoid a circular dependency between this module and workflow.ts */
let sleep = (_: Duration): Promise<void> => {
throw new IllegalStateError('Workflow has not been properly initialized');
};
export function registerSleepImplementation(fn: typeof sleep): void {
sleep = fn;
}