diff --git a/packages/common/src/reserved.ts b/packages/common/src/reserved.ts new file mode 100644 index 000000000..8e43be3cc --- /dev/null +++ b/packages/common/src/reserved.ts @@ -0,0 +1,26 @@ +export const TEMPORAL_RESERVED_PREFIX = '__temporal_'; +export const STACK_TRACE_QUERY_NAME = '__stack_trace'; +export const ENHANCED_STACK_TRACE_QUERY_NAME = '__enhanced_stack_trace'; + +/** + * Valid entity types that can be checked for reserved name violations + */ +export type ReservedNameEntityType = 'query' | 'signal' | 'update' | 'activity' | 'task queue' | 'sink' | 'workflow'; + +/** + * Validates if the provided name contains any reserved prefixes or matches any reserved names. + * Throws a TypeError if validation fails, with a specific message indicating whether the issue + * is with a reserved prefix or an exact match to a reserved name. + * + * @param type The entity type being checked + * @param name The name to check against reserved prefixes/names + */ +export function throwIfReservedName(type: ReservedNameEntityType, name: string): void { + if (name.startsWith(TEMPORAL_RESERVED_PREFIX)) { + throw new TypeError(`Cannot use ${type} name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); + } + + if (name === STACK_TRACE_QUERY_NAME || name === ENHANCED_STACK_TRACE_QUERY_NAME) { + throw new TypeError(`Cannot use ${type} name: '${name}', which is a reserved name`); + } +} diff --git a/packages/test/src/helpers.ts b/packages/test/src/helpers.ts index 218be7169..28a2a5812 100644 --- a/packages/test/src/helpers.ts +++ b/packages/test/src/helpers.ts @@ -293,21 +293,6 @@ export async function getRandomPort(fn = (_port: number) => Promise.resolve()): }); } -export function asSdkLoggerSink( - fn: (info: WorkflowInfo, message: string, attrs?: Record) => Promise, - opts?: Omit, 'fn'> -): worker.InjectedSinks { - return { - __temporal_logger: { - trace: { fn, ...opts }, - debug: { fn, ...opts }, - info: { fn, ...opts }, - warn: { fn, ...opts }, - error: { fn, ...opts }, - }, - }; -} - export async function loadHistory(fname: string): Promise { const isJson = fname.endsWith('json'); const fpath = path.resolve(__dirname, `../history_files/${fname}`); diff --git a/packages/test/src/test-integration-workflows.ts b/packages/test/src/test-integration-workflows.ts index cef4061e4..0878572b2 100644 --- a/packages/test/src/test-integration-workflows.ts +++ b/packages/test/src/test-integration-workflows.ts @@ -1,14 +1,24 @@ import { setTimeout as setTimeoutPromise } from 'timers/promises'; import { randomUUID } from 'crypto'; +import asyncRetry from 'async-retry'; import { ExecutionContext } from 'ava'; import { firstValueFrom, Subject } from 'rxjs'; -import { WorkflowFailedError } from '@temporalio/client'; +import { WorkflowFailedError, WorkflowHandle } from '@temporalio/client'; import * as activity from '@temporalio/activity'; import { msToNumber, tsToMs } from '@temporalio/common/lib/time'; import { TestWorkflowEnvironment } from '@temporalio/testing'; import { CancelReason } from '@temporalio/worker/lib/activity'; import * as workflow from '@temporalio/workflow'; -import { defineQuery, defineSignal } from '@temporalio/workflow'; +import { + condition, + defineQuery, + defineSignal, + defineUpdate, + setDefaultQueryHandler, + setDefaultSignalHandler, + setDefaultUpdateHandler, + setHandler, +} from '@temporalio/workflow'; import { SdkFlags } from '@temporalio/workflow/lib/flags'; import { ActivityCancellationType, @@ -20,12 +30,17 @@ import { TypedSearchAttributes, WorkflowExecutionAlreadyStartedError, } from '@temporalio/common'; +import { + TEMPORAL_RESERVED_PREFIX, + STACK_TRACE_QUERY_NAME, + ENHANCED_STACK_TRACE_QUERY_NAME, +} from '@temporalio/common/lib/reserved'; import { signalSchedulingWorkflow } from './activities/helpers'; import { activityStartedSignal } from './workflows/definitions'; import * as workflows from './workflows'; import { Context, createLocalTestEnvironment, helpers, makeTestFunction } from './helpers-integration'; import { overrideSdkInternalFlag } from './mock-internal-flags'; -import { asSdkLoggerSink, loadHistory, RUN_TIME_SKIPPING_TESTS, waitUntil } from './helpers'; +import { loadHistory, RUN_TIME_SKIPPING_TESTS, waitUntil } from './helpers'; const test = makeTestFunction({ workflowsPath: __filename, @@ -1126,53 +1141,6 @@ test('Workflow can upsert memo', async (t) => { }); }); -test('Sink functions contains upserted memo', async (t) => { - const { createWorker, executeWorkflow } = helpers(t); - const recordedMessages = Array<{ message: string; memo: Record | undefined }>(); - const sinks = asSdkLoggerSink(async (info, message, _attrs) => { - recordedMessages.push({ - message, - memo: info.memo, - }); - }); - const worker = await createWorker({ sinks }); - await worker.runUntil(async () => { - await executeWorkflow(upsertAndReadMemo, { - memo: { - note1: 'aaa', - note2: 'bbb', - note4: 'eee', - }, - args: [ - { - note2: 'ccc', - note3: 'ddd', - note4: null, - }, - ], - }); - }); - - t.deepEqual(recordedMessages, [ - { - message: 'Workflow started', - memo: { - note1: 'aaa', - note2: 'bbb', - note4: 'eee', - }, - }, - { - message: 'Workflow completed', - memo: { - note1: 'aaa', - note2: 'ccc', - note3: 'ddd', - }, - }, - ]); -}); - export async function langFlagsReplayCorrectly(): Promise { const { noopActivity } = workflow.proxyActivities({ scheduleToCloseTimeout: '10s' }); await workflow.CancellationScope.withTimeout('10s', async () => { @@ -1440,3 +1408,232 @@ test('Workflow can return root workflow', async (t) => { t.deepEqual(result, 'empty test-root-workflow-length'); }); }); + +const reservedNames = [TEMPORAL_RESERVED_PREFIX, STACK_TRACE_QUERY_NAME, ENHANCED_STACK_TRACE_QUERY_NAME]; + +test('Cannot register activities using reserved prefixes', async (t) => { + const { createWorker } = helpers(t); + + for (const name of reservedNames) { + const activityName = name === TEMPORAL_RESERVED_PREFIX ? name + '_test' : name; + await t.throwsAsync( + createWorker({ + activities: { [activityName]: () => {} }, + }), + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use activity name: '${activityName}', with reserved prefix: '${name}'` + : `Cannot use activity name: '${activityName}', which is a reserved name`, + } + ); + } +}); + +test('Cannot register task queues using reserved prefixes', async (t) => { + const { createWorker } = helpers(t); + + for (const name of reservedNames) { + const taskQueue = name === TEMPORAL_RESERVED_PREFIX ? name + '_test' : name; + + await t.throwsAsync( + createWorker({ + taskQueue, + }), + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use task queue name: '${taskQueue}', with reserved prefix: '${name}'` + : `Cannot use task queue name: '${taskQueue}', which is a reserved name`, + } + ); + } +}); + +test('Cannot register sinks using reserved prefixes', async (t) => { + const { createWorker } = helpers(t); + + for (const name of reservedNames) { + const sinkName = name === TEMPORAL_RESERVED_PREFIX ? name + '_test' : name; + await t.throwsAsync( + createWorker({ + sinks: { + [sinkName]: { + test: { + fn: () => {}, + }, + }, + }, + }), + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use sink name: '${sinkName}', with reserved prefix: '${name}'` + : `Cannot use sink name: '${sinkName}', which is a reserved name`, + } + ); + } +}); + +interface HandlerError { + name: string; + message: string; +} + +export async function workflowReservedNameHandler(name: string): Promise { + // Re-package errors, default payload converter has trouble converting native errors (no 'data' field). + const expectedErrors: HandlerError[] = []; + try { + setHandler(defineSignal(name === TEMPORAL_RESERVED_PREFIX ? name + '_signal' : name), () => {}); + } catch (e) { + if (e instanceof Error) { + expectedErrors.push({ name: e.name, message: e.message }); + } + } + try { + setHandler(defineUpdate(name === TEMPORAL_RESERVED_PREFIX ? name + '_update' : name), () => {}); + } catch (e) { + if (e instanceof Error) { + expectedErrors.push({ name: e.name, message: e.message }); + } + } + try { + setHandler(defineQuery(name === TEMPORAL_RESERVED_PREFIX ? name + '_query' : name), () => {}); + } catch (e) { + if (e instanceof Error) { + expectedErrors.push({ name: e.name, message: e.message }); + } + } + return expectedErrors; +} + +test('Workflow failure if define signals/updates/queries with reserved prefixes', async (t) => { + const { createWorker, executeWorkflow } = helpers(t); + const worker = await createWorker(); + await worker.runUntil(async () => { + for (const name of reservedNames) { + const result = await executeWorkflow(workflowReservedNameHandler, { + args: [name], + }); + t.deepEqual(result, [ + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use signal name: '${name}_signal', with reserved prefix: '${name}'` + : `Cannot use signal name: '${name}', which is a reserved name`, + }, + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use update name: '${name}_update', with reserved prefix: '${name}'` + : `Cannot use update name: '${name}', which is a reserved name`, + }, + { + name: 'TypeError', + message: + name === TEMPORAL_RESERVED_PREFIX + ? `Cannot use query name: '${name}_query', with reserved prefix: '${name}'` + : `Cannot use query name: '${name}', which is a reserved name`, + }, + ]); + } + }); +}); + +export const wfReadyQuery = defineQuery('wf-ready'); +export async function workflowWithDefaultHandlers(): Promise { + let unblocked = false; + setHandler(defineSignal('unblock'), () => { + unblocked = true; + }); + + setDefaultQueryHandler(() => {}); + setDefaultSignalHandler(() => {}); + setDefaultUpdateHandler(() => {}); + setHandler(wfReadyQuery, () => true); + + await condition(() => unblocked); +} + +test('Default handlers fail given reserved prefix', async (t) => { + const { createWorker, startWorkflow } = helpers(t); + const worker = await createWorker(); + + const assertWftFailure = async (handle: WorkflowHandle, errMsg: string) => { + await asyncRetry( + async () => { + const history = await handle.fetchHistory(); + const wftFailedEvent = history.events?.findLast((ev) => ev.workflowTaskFailedEventAttributes); + if (wftFailedEvent === undefined) { + throw new Error('No WFT failed event found'); + } + const { failure } = wftFailedEvent.workflowTaskFailedEventAttributes ?? {}; + if (!failure) { + return t.fail('Expected failure in workflowTaskFailedEventAttributes'); + } + t.is(failure.message, errMsg); + }, + { minTimeout: 300, factor: 1, retries: 10 } + ); + }; + + await worker.runUntil(async () => { + // Reserved query + let handle = await startWorkflow(workflowWithDefaultHandlers); + await asyncRetry(async () => { + if (!(await handle.query(wfReadyQuery))) { + throw new Error('Workflow not ready yet'); + } + }); + const queryName = `${TEMPORAL_RESERVED_PREFIX}_query`; + await t.throwsAsync( + handle.query(queryName), + { + // TypeError transforms to a QueryNotRegisteredError on the way back from server + name: 'QueryNotRegisteredError', + message: `Cannot use query name: '${queryName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`, + }, + `Query ${queryName} should fail` + ); + await handle.terminate(); + + // Reserved signal + handle = await startWorkflow(workflowWithDefaultHandlers); + await asyncRetry(async () => { + if (!(await handle.query(wfReadyQuery))) { + throw new Error('Workflow not ready yet'); + } + }); + const signalName = `${TEMPORAL_RESERVED_PREFIX}_signal`; + await handle.signal(signalName); + await assertWftFailure( + handle, + `Cannot use signal name: '${signalName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'` + ); + await handle.terminate(); + + // Reserved update + handle = await startWorkflow(workflowWithDefaultHandlers); + await asyncRetry(async () => { + if (!(await handle.query(wfReadyQuery))) { + throw new Error('Workflow not ready yet'); + } + }); + const updateName = `${TEMPORAL_RESERVED_PREFIX}_update`; + handle.executeUpdate(updateName).catch(() => { + // Expect failure. The error caught here is a WorkflowNotFound because + // the workflow will have already failed, so the update cannot go through. + // We assert on the expected failure below. + }); + await assertWftFailure( + handle, + `Cannot use update name: '${updateName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'` + ); + await handle.terminate(); + }); +}); diff --git a/packages/test/src/test-sinks.ts b/packages/test/src/test-sinks.ts index ebe358da4..138b4d01c 100644 --- a/packages/test/src/test-sinks.ts +++ b/packages/test/src/test-sinks.ts @@ -6,7 +6,7 @@ import { DefaultLogger, InjectedSinks, Runtime, WorkerOptions, LogEntry, NativeC import { SearchAttributes, WorkflowInfo } from '@temporalio/workflow'; import { UnsafeWorkflowInfo } from '@temporalio/workflow/lib/interfaces'; import { SdkComponent, TypedSearchAttributes } from '@temporalio/common'; -import { RUN_INTEGRATION_TESTS, Worker, asSdkLoggerSink, registerDefaultCustomSearchAttributes } from './helpers'; +import { RUN_INTEGRATION_TESTS, Worker, registerDefaultCustomSearchAttributes } from './helpers'; import { defaultOptions } from './mock-native-worker'; import * as workflows from './workflows'; @@ -388,12 +388,19 @@ if (RUN_INTEGRATION_TESTS) { const taskQueue = `${__filename}-${t.title}`; const recordedMessages = Array<{ message: string; searchAttributes: SearchAttributes }>(); // eslint-disable-line deprecation/deprecation - const sinks = asSdkLoggerSink(async (info, message, _attrs) => { - recordedMessages.push({ - message, - searchAttributes: info.searchAttributes, // eslint-disable-line deprecation/deprecation - }); - }); + const sinks: InjectedSinks = { + customLogger: { + info: { + fn: async (info, message) => { + recordedMessages.push({ + message, + searchAttributes: info.searchAttributes, // eslint-disable-line deprecation/deprecation + }); + }, + callDuringReplay: false, + }, + }, + }; const client = new WorkflowClient(); const date = new Date(); @@ -413,11 +420,11 @@ if (RUN_INTEGRATION_TESTS) { t.deepEqual(recordedMessages, [ { - message: 'Workflow started', + message: 'Before upsert', searchAttributes: {}, }, { - message: 'Workflow completed', + message: 'After upsert', searchAttributes: { CustomBoolField: [true], CustomKeywordField: ['durable code'], @@ -429,6 +436,70 @@ if (RUN_INTEGRATION_TESTS) { ]); }); + test('Sink functions contains upserted memo', async (t) => { + const taskQueue = `${__filename}-${t.title}`; + const client = new WorkflowClient(); + + const recordedMessages = Array<{ message: string; memo: Record | undefined }>(); + const sinks: InjectedSinks = { + customLogger: { + info: { + fn: async (info, message) => { + recordedMessages.push({ + message, + memo: info.memo, + }); + }, + callDuringReplay: false, + }, + }, + }; + + const worker = await Worker.create({ + ...defaultOptions, + taskQueue, + sinks, + }); + + await worker.runUntil( + client.execute(workflows.upsertAndReadMemo, { + taskQueue, + workflowId: uuid4(), + memo: { + note1: 'aaa', + note2: 'bbb', + note4: 'eee', + }, + args: [ + { + note2: 'ccc', + note3: 'ddd', + note4: null, + }, + ], + }) + ); + + t.deepEqual(recordedMessages, [ + { + message: 'Before upsert memo', + memo: { + note1: 'aaa', + note2: 'bbb', + note4: 'eee', + }, + }, + { + message: 'After upsert memo', + memo: { + note1: 'aaa', + note2: 'ccc', + note3: 'ddd', + }, + }, + ]); + }); + test('Core issue 589', async (t) => { const taskQueue = `${__filename}-${t.title}`; diff --git a/packages/test/src/workflows/upsert-and-read-memo.ts b/packages/test/src/workflows/upsert-and-read-memo.ts index e33676638..d50cd4213 100644 --- a/packages/test/src/workflows/upsert-and-read-memo.ts +++ b/packages/test/src/workflows/upsert-and-read-memo.ts @@ -1,6 +1,11 @@ -import { upsertMemo, workflowInfo } from '@temporalio/workflow'; +import { upsertMemo, workflowInfo, proxySinks } from '@temporalio/workflow'; +import { CustomLoggerSinks } from './log-sink-tester'; + +const { customLogger } = proxySinks(); export async function upsertAndReadMemo(memo: Record): Promise | undefined> { + customLogger.info('Before upsert memo'); upsertMemo(memo); + customLogger.info('After upsert memo'); return workflowInfo().memo; } diff --git a/packages/test/src/workflows/upsert-and-read-search-attributes.ts b/packages/test/src/workflows/upsert-and-read-search-attributes.ts index cf598cdb8..0d989cb75 100644 --- a/packages/test/src/workflows/upsert-and-read-search-attributes.ts +++ b/packages/test/src/workflows/upsert-and-read-search-attributes.ts @@ -1,7 +1,11 @@ -import { SearchAttributes, upsertSearchAttributes, workflowInfo } from '@temporalio/workflow'; +import { SearchAttributes, upsertSearchAttributes, workflowInfo, proxySinks } from '@temporalio/workflow'; +import { CustomLoggerSinks } from './log-sink-tester'; + +const { customLogger } = proxySinks(); // eslint-disable-next-line deprecation/deprecation export async function upsertAndReadSearchAttributes(msSinceEpoch: number): Promise { + customLogger.info('Before upsert'); upsertSearchAttributes({ CustomIntField: [123], CustomBoolField: [true], @@ -13,5 +17,6 @@ export async function upsertAndReadSearchAttributes(msSinceEpoch: number): Promi CustomDatetimeField: [new Date(msSinceEpoch)], CustomDoubleField: [3.14], }); + customLogger.info('After upsert'); return workflowInfo().searchAttributes; // eslint-disable-line deprecation/deprecation } diff --git a/packages/worker/src/worker-options.ts b/packages/worker/src/worker-options.ts index 6b27f0be0..4614eac6b 100644 --- a/packages/worker/src/worker-options.ts +++ b/packages/worker/src/worker-options.ts @@ -14,6 +14,7 @@ import { loadDataConverter } from '@temporalio/common/lib/internal-non-workflow' import { LoggerSinks } from '@temporalio/workflow'; import { Context } from '@temporalio/activity'; import { native } from '@temporalio/core-bridge'; +import { throwIfReservedName } from '@temporalio/common/lib/reserved'; import { ActivityInboundLogInterceptor } from './activity-log-interceptor'; import { NativeConnection } from './connection'; import { CompiledWorkerInterceptors, WorkerInterceptors } from './interceptors'; @@ -697,8 +698,9 @@ export function defaultSinks(logger?: Logger): InjectedSinks { // eslint-disable-next-line deprecation/deprecation if (!logger) return {} as InjectedSinks; - // eslint-disable-next-line deprecation/deprecation - return initLoggerSink(logger) as unknown as InjectedSinks; + // Register the logger sink with its historical name + const { __temporal_logger: defaultWorkerLogger } = initLoggerSink(logger); + return { defaultWorkerLogger } satisfies InjectedSinks; // eslint-disable-line deprecation/deprecation } /** @@ -931,6 +933,13 @@ export function compileWorkerOptions( logger: Logger, metricMeter: MetricMeter ): CompiledWorkerOptions { + // Validate sink names to ensure they don't use reserved prefixes/names + if (rawOpts.sinks) { + for (const sinkName of Object.keys(rawOpts.sinks)) { + throwIfReservedName('sink', sinkName); + } + } + const opts = addDefaultWorkerOptions(rawOpts, logger, metricMeter); if (opts.maxCachedWorkflows !== 0 && opts.maxCachedWorkflows < 2) { logger.warn('maxCachedWorkflows must be either 0 (ie. cache is disabled) or greater than 1. Defaulting to 2.'); @@ -953,6 +962,10 @@ export function compileWorkerOptions( } const activities = new Map(Object.entries(opts.activities ?? {}).filter(([_, v]) => typeof v === 'function')); + for (const activityName of activities.keys()) { + throwIfReservedName('activity', activityName); + } + const tuner = asNativeTuner(opts.tuner, logger); return { diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 7a1487d72..cb83f391c 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -57,6 +57,7 @@ import { workflowLogAttributes } from '@temporalio/workflow/lib/logs'; import { native } from '@temporalio/core-bridge'; import { coresdk, temporal } from '@temporalio/proto'; import { type SinkCall, type WorkflowInfo } from '@temporalio/workflow'; +import { throwIfReservedName } from '@temporalio/common/lib/reserved'; import { Activity, CancelReason, activityLogAttributes } from './activity'; import { extractNativeClient, extractReferenceHolders, InternalNativeConnection, NativeConnection } from './connection'; import { ActivityExecuteInput } from './interceptors'; @@ -467,6 +468,10 @@ export class Worker { * This method initiates a connection to the server and will throw (asynchronously) on connection failure. */ public static async create(options: WorkerOptions): Promise { + if (!options.taskQueue) { + throw new TypeError('Task queue name is required'); + } + throwIfReservedName('task queue', options.taskQueue); const runtime = Runtime.instance(); const logger = LoggerWithComposedMetadata.compose(runtime.logger, { sdkComponent: SdkComponent.worker, diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index 231560375..e1e23c537 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -32,6 +32,11 @@ import { import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow'; import type { coresdk, temporal } from '@temporalio/proto'; +import { + TEMPORAL_RESERVED_PREFIX, + STACK_TRACE_QUERY_NAME, + ENHANCED_STACK_TRACE_QUERY_NAME, +} from '@temporalio/common/lib/reserved'; import { alea, RNG } from './alea'; import { RootCancellationScope } from './cancellation-scope'; import { UpdateScope } from './update-scope'; @@ -261,7 +266,7 @@ export class Activator implements ActivationHandler { */ public readonly queryHandlers = new Map([ [ - '__stack_trace', + STACK_TRACE_QUERY_NAME, { handler: () => { return new RawValue( @@ -274,7 +279,7 @@ export class Activator implements ActivationHandler { }, ], [ - '__enhanced_stack_trace', + ENHANCED_STACK_TRACE_QUERY_NAME, { handler: (): RawValue => { const { sourceMap } = this; @@ -682,11 +687,18 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing query activation attributes'); } - const execute = composeInterceptors( - this.interceptors.inbound, - 'handleQuery', - this.queryWorkflowNextHandler.bind(this) - ); + // If query has __temporal_ prefix but no handler exists, throw error + if (queryType.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.queryHandlers.has(queryType)) { + throw new TypeError(`Cannot use query name: '${queryType}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); + } + + // Skip interceptors if it's an internal query. + const isInternalQuery = + queryType.startsWith(TEMPORAL_RESERVED_PREFIX) || + queryType === STACK_TRACE_QUERY_NAME || + queryType === ENHANCED_STACK_TRACE_QUERY_NAME; + const interceptors = isInternalQuery ? [] : this.interceptors.inbound; + const execute = composeInterceptors(interceptors, 'handleQuery', this.queryWorkflowNextHandler.bind(this)); execute({ queryName: queryType, args: arrayFromPayloads(this.payloadConverter, activation.arguments), @@ -710,6 +722,18 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing activation update protocolInstanceId'); } + // If update has __temporal_ prefix but no handler exists, throw error + if (name.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.updateHandlers.get(name)) { + throw new TypeError(`Cannot use update name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`); + } + + // Skip interceptors if it's an internal update. + const isInternalUpdate = + name.startsWith(TEMPORAL_RESERVED_PREFIX) || + name === STACK_TRACE_QUERY_NAME || + name === ENHANCED_STACK_TRACE_QUERY_NAME; + const interceptors = isInternalUpdate ? [] : this.interceptors.inbound; + const entry = this.updateHandlers.get(name) ?? (this.defaultUpdateHandler @@ -766,7 +790,7 @@ export class Activator implements ActivationHandler { try { if (runValidator && entry.validator) { const validate = composeInterceptors( - this.interceptors.inbound, + interceptors, 'validateUpdate', this.validateUpdateNextHandler.bind(this, entry.validator) ); @@ -779,7 +803,7 @@ export class Activator implements ActivationHandler { } this.acceptUpdate(protocolInstanceId); const execute = composeInterceptors( - this.interceptors.inbound, + interceptors, 'handleUpdate', this.updateNextHandler.bind(this, entry.handler) ); @@ -862,6 +886,20 @@ export class Activator implements ActivationHandler { throw new TypeError('Missing activation signalName'); } + // If signal has __temporal_ prefix but no handler exists, throw error + if (signalName.startsWith(TEMPORAL_RESERVED_PREFIX) && !this.signalHandlers.has(signalName)) { + throw new TypeError( + `Cannot use signal name: '${signalName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'` + ); + } + + // Skip interceptors if it's an internal signal. + const isInternalSignal = + signalName.startsWith(TEMPORAL_RESERVED_PREFIX) || + signalName === STACK_TRACE_QUERY_NAME || + signalName === ENHANCED_STACK_TRACE_QUERY_NAME; + const interceptors = isInternalSignal ? [] : this.interceptors.inbound; + if (!this.signalHandlers.has(signalName) && !this.defaultSignalHandler) { this.bufferedSignals.push(activation); return; @@ -875,11 +913,7 @@ export class Activator implements ActivationHandler { const signalExecutionNum = this.signalHandlerExecutionSeq++; this.inProgressSignals.set(signalExecutionNum, { name: signalName, unfinishedPolicy }); - const execute = composeInterceptors( - this.interceptors.inbound, - 'handleSignal', - this.signalWorkflowNextHandler.bind(this) - ); + const execute = composeInterceptors(interceptors, 'handleSignal', this.signalWorkflowNextHandler.bind(this)); execute({ args: arrayFromPayloads(this.payloadConverter, activation.input), signalName, diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 2d5898865..a9024cd04 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -33,6 +33,7 @@ import { versioningIntentToProto } from '@temporalio/common/lib/versioning-inten import { Duration, msOptionalToTs, msToNumber, msToTs, requiredTsToMs } from '@temporalio/common/lib/time'; import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { temporal } from '@temporalio/proto'; +import { throwIfReservedName } from '@temporalio/common/lib/reserved'; import { CancellationScope, registerSleepImplementation } from './cancellation-scope'; import { UpdateScope } from './update-scope'; import { @@ -1272,6 +1273,8 @@ export function setHandler< options?: QueryHandlerOptions | SignalHandlerOptions | UpdateHandlerOptions ): void { const activator = assertInWorkflowContext('Workflow.setHandler(...) may only be used from a Workflow Execution.'); + // Cannot register handler for reserved names + throwIfReservedName(def.type, def.name); const description = options?.description; if (def.type === 'update') { if (typeof handler === 'function') {