From 5ac2c4b749032429ae3e6a4542198f2897b266c9 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Tue, 1 Apr 2025 13:45:05 -0700 Subject: [PATCH] Add RawValue support for non-converted Payloads --- .../common/src/converter/payload-converter.ts | 65 ++++++++++++++++++- packages/common/src/converter/types.ts | 6 ++ .../test/src/test-integration-workflows.ts | 45 ++++++++++++- packages/workflow/src/internals.ts | 23 ++++--- packages/workflow/src/workflow.ts | 4 +- 5 files changed, 129 insertions(+), 14 deletions(-) diff --git a/packages/common/src/converter/payload-converter.ts b/packages/common/src/converter/payload-converter.ts index 662eb1a99..d0db7662c 100644 --- a/packages/common/src/converter/payload-converter.ts +++ b/packages/common/src/converter/payload-converter.ts @@ -1,7 +1,7 @@ import { decode, encode } from '../encoding'; import { PayloadConverterError, ValueError } from '../errors'; import { Payload } from '../interfaces'; -import { encodingKeys, encodingTypes, METADATA_ENCODING_KEY } from './types'; +import { encodingKeys, encodingTypes, METADATA_ENCODING_KEY, METADATA_RAW_VALUE_KEY } from './types'; /** * Used by the framework to serialize/deserialize data like parameters and return values. @@ -100,6 +100,60 @@ export function mapFromPayloads( ) as Record; } +/** + * RawValue is a wrapper over a payload. + * A payload that belongs to a RawValue is special in that it bypasses normal payload conversion, + * but still undergoes codec conversion. + */ +export class RawValue { + private readonly _payload: Payload; + + /** + * Receives an incoming Payload and returns a RawValue wrapping it. + * + * Notably, this method strips any raw value metadata if it exists. + * This allows users to convert the payload back to its native representation. + * + * @param payload the incoming Payload + * @returns an instance of RawValue + */ + static receive(payload: Payload): RawValue { + if (payload.metadata == null) { + throw new ValueError('Missing payload metadata'); + } + // Remove the raw value identifier key (if it exists). + delete payload.metadata[METADATA_RAW_VALUE_KEY]; + return new RawValue(payload); + } + + constructor(payload: Payload) { + this._payload = payload; + } + + get payload(): Payload { + return this._payload; + } + + /** + * Sends the Payload from the RawValue. + * + * Notably, this method add raw value metadata to identify that the Payload + * belongs to a RawValue when we {@link receive}. + * + * @param payload the incoming Payload + * @returns an instance of RawValue + */ + send(): Payload { + return { + metadata: { + ...this.payload.metadata, + [METADATA_RAW_VALUE_KEY]: encode('true'), + }, + data: this.payload.data, + }; + } +} + export interface PayloadConverterWithEncoding { /** * Converts a value to a {@link Payload}. @@ -143,6 +197,9 @@ export class CompositePayloadConverter implements PayloadConverter { * Returns the first successful result, throws {@link ValueError} if there is no converter that can handle the value. */ public toPayload(value: T): Payload { + if (value instanceof RawValue) { + return value.send(); + } for (const converter of this.converters) { const result = converter.toPayload(value); if (result !== undefined) { @@ -160,6 +217,12 @@ export class CompositePayloadConverter implements PayloadConverter { if (payload.metadata === undefined || payload.metadata === null) { throw new ValueError('Missing payload metadata'); } + // Payload is intended to be a RawValue. + // Avoid payload conversion, return payload wrapped as RawValue. + if (payload.metadata[METADATA_RAW_VALUE_KEY]) { + return RawValue.receive(payload) as T; + } + const encoding = decode(payload.metadata[METADATA_ENCODING_KEY]); const converter = this.converterByEncoding.get(encoding); if (converter === undefined) { diff --git a/packages/common/src/converter/types.ts b/packages/common/src/converter/types.ts index 413c359a3..ba9bede27 100644 --- a/packages/common/src/converter/types.ts +++ b/packages/common/src/converter/types.ts @@ -19,3 +19,9 @@ export const encodingKeys = { } as const; export const METADATA_MESSAGE_TYPE_KEY = 'messageType'; + +/** + * Metadata key used to identify a RawValue payload. + * A RawValue payload is a payload intended to bypass normal payload conversion. + */ +export const METADATA_RAW_VALUE_KEY = 'rawValue'; diff --git a/packages/test/src/test-integration-workflows.ts b/packages/test/src/test-integration-workflows.ts index 9d55c87ef..03fb9de82 100644 --- a/packages/test/src/test-integration-workflows.ts +++ b/packages/test/src/test-integration-workflows.ts @@ -14,11 +14,14 @@ import { ActivityCancellationType, ApplicationFailure, defineSearchAttributeKey, + METADATA_ENCODING_KEY, + RawValue, SearchAttributePair, SearchAttributeType, TypedSearchAttributes, WorkflowExecutionAlreadyStartedError, } from '@temporalio/common'; +import { temporal } from '@temporalio/proto'; import { signalSchedulingWorkflow } from './activities/helpers'; import { activityStartedSignal } from './workflows/definitions'; import * as workflows from './workflows'; @@ -384,7 +387,10 @@ test('Query workflow metadata returns handler descriptions', async (t) => { await worker.runUntil(async () => { const handle = await startWorkflow(queryWorkflowMetadata); - const meta = await handle.query(workflow.workflowMetadataQuery); + const rawValue = await handle.query(workflow.workflowMetadataQuery); + const meta = workflow.defaultPayloadConverter.fromPayload( + rawValue.payload + ) as temporal.api.sdk.v1.IWorkflowMetadata; t.is(meta.definition?.type, 'queryWorkflowMetadata'); const queryDefinitions = meta.definition?.queryDefinitions; // Three built-in ones plus dummyQuery1 and dummyQuery2 @@ -1337,3 +1343,40 @@ test('can register search attributes to dev server', async (t) => { t.deepEqual(desc.searchAttributes, { 'new-search-attr': [12] }); // eslint-disable-line deprecation/deprecation await env.teardown(); }); + +export async function rawValueWorkflow(rawValue: RawValue): Promise { + const { rawValueActivity } = workflow.proxyActivities({ startToCloseTimeout: '10s' }); + return await rawValueActivity(rawValue); +} + +test('workflow and activity can receive/return RawValue', async (t) => { + const { executeWorkflow, createWorker } = helpers(t); + const worker = await createWorker({ + activities: { + async rawValueActivity(rawValue: RawValue): Promise { + return rawValue; + }, + }, + }); + + await worker.runUntil(async () => { + const testValue = 'test'; + const rawValueWithKey: RawValue = new RawValue(workflow.defaultPayloadConverter.toPayload(testValue)); + const res = await executeWorkflow(rawValueWorkflow, { + args: [rawValueWithKey], + }); + // Compare payloads. Explicitly convert to Uint8Array because the actual + // returned payload has Buffer types. + console.log('RETURNED', res); + const actualMetadata = res.payload.metadata![METADATA_ENCODING_KEY]; + const expectedMetadata = rawValueWithKey.payload.metadata![METADATA_ENCODING_KEY]; + t.deepEqual(new Uint8Array(actualMetadata), new Uint8Array(expectedMetadata)); + const actualData = res.payload.data!; + const expectedData = rawValueWithKey.payload.data!; + t.deepEqual(new Uint8Array(actualData), new Uint8Array(expectedData)); + + // Compare value from wrapped payload. + const resValue = workflow.defaultPayloadConverter.fromPayload(res.payload); + t.deepEqual(resValue, testValue); + }); +}); diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index acc49985b..182a2d073 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -20,6 +20,7 @@ import { WorkflowUpdateValidatorType, mapFromPayloads, fromPayloadsAtIndex, + RawValue, } from '@temporalio/common'; import { decodeSearchAttributes, @@ -27,7 +28,7 @@ import { } from '@temporalio/common/lib/converter/payload-search-attributes'; import { composeInterceptors } from '@temporalio/common/lib/interceptors'; import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow'; -import type { coresdk, temporal } from '@temporalio/proto'; +import type { coresdk } from '@temporalio/proto'; import { alea, RNG } from './alea'; import { RootCancellationScope } from './cancellation-scope'; import { UpdateScope } from './update-scope'; @@ -292,7 +293,7 @@ export class Activator implements ActivationHandler { [ '__temporal_workflow_metadata', { - handler: (): temporal.api.sdk.v1.IWorkflowMetadata => { + handler: (): RawValue => { const workflowType = this.info.workflowType; const queryDefinitions = Array.from(this.queryHandlers.entries()).map(([name, value]) => ({ name, @@ -306,14 +307,16 @@ export class Activator implements ActivationHandler { name, description: value.description, })); - return { - definition: { - type: workflowType, - queryDefinitions, - signalDefinitions, - updateDefinitions, - }, - }; + return new RawValue( + this.payloadConverter.toPayload({ + definition: { + type: workflowType, + queryDefinitions, + signalDefinitions, + updateDefinitions, + }, + }) + ); }, description: 'Returns metadata associated with this workflow.', }, diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 711b71b04..0b44f1895 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -22,6 +22,7 @@ import { WorkflowReturnType, WorkflowUpdateValidatorType, SearchAttributeUpdatePair, + RawValue, } from '@temporalio/common'; import { encodeUnifiedSearchAttributes, @@ -30,7 +31,6 @@ import { import { versioningIntentToProto } from '@temporalio/common/lib/versioning-intent-enum'; import { Duration, msOptionalToTs, msToNumber, msToTs, requiredTsToMs } from '@temporalio/common/lib/time'; import { composeInterceptors } from '@temporalio/common/lib/interceptors'; -import { temporal } from '@temporalio/proto'; import { CancellationScope, registerSleepImplementation } from './cancellation-scope'; import { UpdateScope } from './update-scope'; import { @@ -1589,4 +1589,4 @@ export function allHandlersFinished(): boolean { export const stackTraceQuery = defineQuery('__stack_trace'); export const enhancedStackTraceQuery = defineQuery('__enhanced_stack_trace'); -export const workflowMetadataQuery = defineQuery('__temporal_workflow_metadata'); +export const workflowMetadataQuery = defineQuery('__temporal_workflow_metadata');