Skip to content

Special behaviour for temporal prefixes #1644

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/common/src/reserved.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const TEMPORAL_RESERVED_PREFIX = '__temporal_';
export const STACK_TRACE_RESERVED_NAME = '__stack_trace';
export const ENHANCED_STACK_TRACE_RESERVED_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_RESERVED_NAME || name === ENHANCED_STACK_TRACE_RESERVED_NAME) {
throw new TypeError(`Cannot use ${type} name: '${name}', which is a reserved name`);
}
}
248 changes: 246 additions & 2 deletions packages/test/src/test-integration-workflows.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,6 +29,11 @@ import {
TypedSearchAttributes,
WorkflowExecutionAlreadyStartedError,
} from '@temporalio/common';
import {
TEMPORAL_RESERVED_PREFIX,
STACK_TRACE_RESERVED_NAME,
ENHANCED_STACK_TRACE_RESERVED_NAME,
} from '@temporalio/common/lib/reserved';
import { signalSchedulingWorkflow } from './activities/helpers';
import { activityStartedSignal } from './workflows/definitions';
import * as workflows from './workflows';
Expand Down Expand Up @@ -1414,3 +1429,232 @@ test('Workflow can return root workflow', async (t) => {
t.deepEqual(result, 'empty test-root-workflow-length');
});
});

const reservedNames = [TEMPORAL_RESERVED_PREFIX, STACK_TRACE_RESERVED_NAME, ENHANCED_STACK_TRACE_RESERVED_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<HandlerError[]> {
// 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<boolean>('wf-ready');
export async function workflowWithDefaultHandlers(): Promise<void> {
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();
});
});
15 changes: 15 additions & 0 deletions packages/worker/src/worker-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -953,6 +954,20 @@ 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);
}

// Validate sink names to ensure they don't use reserved prefixes/names
if (opts.sinks) {
for (const sinkName of Object.keys(opts.sinks)) {
// Allow internal sinks used by the SDK
if (sinkName !== '__temporal_logger' && sinkName !== '__temporal_metrics') {
throwIfReservedName('sink', sinkName);
}
}
}

const tuner = asNativeTuner(opts.tuner, logger);

return {
Expand Down
5 changes: 5 additions & 0 deletions packages/worker/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Worker> {
if (!options.taskQueue) {
throw new TypeError('Task queue name is required');
}
throwIfReservedName('task queue', options.taskQueue);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point, taskQueue can be undefined. Won't that cause an NPE in throwIfServedName?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a check but the WorkerOptions interface says that taskQueue can only be string

const runtime = Runtime.instance();
const logger = LoggerWithComposedMetadata.compose(runtime.logger, {
sdkComponent: SdkComponent.worker,
Expand Down
Loading