From db7656f05eae2c4c662f49345d94377bc19b6ec9 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 14 Aug 2026 14:52:12 +0400 Subject: [PATCH 1/4] Add serialization context feature tests for Go --- features/features.go | 18 ++ features/serialization_context/README.md | 42 +++++ .../activity_payloads/README.md | 22 +++ .../activity_payloads/config.json | 5 + .../activity_payloads/feature.go | 111 +++++++++++ .../async_activity_completion/README.md | 25 +++ .../async_activity_completion/config.json | 5 + .../async_activity_completion/feature.go | 155 ++++++++++++++++ .../child_workflow_payloads/README.md | 17 ++ .../child_workflow_payloads/config.json | 5 + .../child_workflow_payloads/feature.go | 92 ++++++++++ .../README.md | 20 ++ .../config.json | 5 + .../feature.go | 102 +++++++++++ .../continue_as_new/README.md | 16 ++ .../continue_as_new/config.json | 5 + .../continue_as_new/feature.go | 79 ++++++++ .../external_signal/README.md | 15 ++ .../external_signal/config.json | 5 + .../external_signal/feature.go | 104 +++++++++++ .../serialization_context/failure/README.md | 17 ++ .../serialization_context/failure/config.json | 5 + .../serialization_context/failure/feature.go | 100 ++++++++++ .../local_activity_payloads/README.md | 27 +++ .../local_activity_payloads/config.json | 5 + .../local_activity_payloads/feature.go | 95 ++++++++++ .../sercontext/sercontext.go | 167 +++++++++++++++++ .../workflow_payloads/README.md | 20 ++ .../workflow_payloads/config.json | 5 + .../workflow_payloads/feature.go | 172 ++++++++++++++++++ 30 files changed, 1461 insertions(+) create mode 100644 features/serialization_context/README.md create mode 100644 features/serialization_context/activity_payloads/README.md create mode 100644 features/serialization_context/activity_payloads/config.json create mode 100644 features/serialization_context/activity_payloads/feature.go create mode 100644 features/serialization_context/async_activity_completion/README.md create mode 100644 features/serialization_context/async_activity_completion/config.json create mode 100644 features/serialization_context/async_activity_completion/feature.go create mode 100644 features/serialization_context/child_workflow_payloads/README.md create mode 100644 features/serialization_context/child_workflow_payloads/config.json create mode 100644 features/serialization_context/child_workflow_payloads/feature.go create mode 100644 features/serialization_context/child_workflow_payloads_default_id/README.md create mode 100644 features/serialization_context/child_workflow_payloads_default_id/config.json create mode 100644 features/serialization_context/child_workflow_payloads_default_id/feature.go create mode 100644 features/serialization_context/continue_as_new/README.md create mode 100644 features/serialization_context/continue_as_new/config.json create mode 100644 features/serialization_context/continue_as_new/feature.go create mode 100644 features/serialization_context/external_signal/README.md create mode 100644 features/serialization_context/external_signal/config.json create mode 100644 features/serialization_context/external_signal/feature.go create mode 100644 features/serialization_context/failure/README.md create mode 100644 features/serialization_context/failure/config.json create mode 100644 features/serialization_context/failure/feature.go create mode 100644 features/serialization_context/local_activity_payloads/README.md create mode 100644 features/serialization_context/local_activity_payloads/config.json create mode 100644 features/serialization_context/local_activity_payloads/feature.go create mode 100644 features/serialization_context/sercontext/sercontext.go create mode 100644 features/serialization_context/workflow_payloads/README.md create mode 100644 features/serialization_context/workflow_payloads/config.json create mode 100644 features/serialization_context/workflow_payloads/feature.go diff --git a/features/features.go b/features/features.go index bb8c976f..9463664a 100644 --- a/features/features.go +++ b/features/features.go @@ -45,6 +45,15 @@ import ( schedule_duplicate_error "github.com/temporalio/features/features/schedule/duplicate_error" schedule_pause "github.com/temporalio/features/features/schedule/pause" schedule_trigger "github.com/temporalio/features/features/schedule/trigger" + serialization_context_activity_payloads "github.com/temporalio/features/features/serialization_context/activity_payloads" + serialization_context_async_activity_completion "github.com/temporalio/features/features/serialization_context/async_activity_completion" + serialization_context_child_workflow_payloads "github.com/temporalio/features/features/serialization_context/child_workflow_payloads" + serialization_context_child_workflow_payloads_default_id "github.com/temporalio/features/features/serialization_context/child_workflow_payloads_default_id" + serialization_context_continue_as_new "github.com/temporalio/features/features/serialization_context/continue_as_new" + serialization_context_external_signal "github.com/temporalio/features/features/serialization_context/external_signal" + serialization_context_failure "github.com/temporalio/features/features/serialization_context/failure" + serialization_context_local_activity_payloads "github.com/temporalio/features/features/serialization_context/local_activity_payloads" + serialization_context_workflow_payloads "github.com/temporalio/features/features/serialization_context/workflow_payloads" signal_external "github.com/temporalio/features/features/signal/external" telemetry_metrics "github.com/temporalio/features/features/telemetry/metrics" update_activities "github.com/temporalio/features/features/update/activities" @@ -108,6 +117,15 @@ func init() { schedule_duplicate_error.Feature, schedule_pause.Feature, schedule_trigger.Feature, + serialization_context_activity_payloads.Feature, + serialization_context_async_activity_completion.Feature, + serialization_context_child_workflow_payloads.Feature, + serialization_context_child_workflow_payloads_default_id.Feature, + serialization_context_continue_as_new.Feature, + serialization_context_external_signal.Feature, + serialization_context_failure.Feature, + serialization_context_local_activity_payloads.Feature, + serialization_context_workflow_payloads.Feature, signal_external.Feature, telemetry_metrics.Feature, update_activities.Feature, diff --git a/features/serialization_context/README.md b/features/serialization_context/README.md new file mode 100644 index 00000000..54ddd7a9 --- /dev/null +++ b/features/serialization_context/README.md @@ -0,0 +1,42 @@ +# Serialization context + +A `DataConverter`, `PayloadCodec` or `FailureConverter` can opt into receiving +the context a payload is being converted in, so that it can, for example, derive +an encryption key from the namespace or use the workflow ID as associated data. + +The features in this directory share a `sercontext` helper per language, which +provides: + +- a payload codec that stamps the signature of its serialization context onto + every payload it encodes and refuses to decode a payload encoded under a + different context, so any asymmetry between the encoding and the decoding side + fails the feature wherever it happens +- a failure converter that records the signature of its serialization context in + `Failure.source` + +Each feature then asserts the exact signature recorded in history, which pins +down the context values themselves rather than only their symmetry. Contexts +that never reach history are asserted against the set of signatures the codec was +actually asked to convert with. + +The signature format is per language, because the SDKs expose different context +fields. Signatures are only ever compared within a single run, so they do not +need to agree across languages. + +## History replay + +Go and Java disable the harness history check. The replayer runs histories under +a placeholder namespace and workflow ID, so payloads recorded by a real execution +can never decode under a context derived from them. + +## Language notes + +- **Go** — `local_activity_payloads` fails: the SDK encodes the local activity + result with the plain worker converter and decodes it with the workflow + context. See that feature's README. +- **Python** — the workflow side of an activity context only carries an activity + ID when the workflow sets one explicitly, so the features that schedule + activities pass an explicit `activity_id`. +- **TypeScript** — no `local_activity_payloads`: the SDK has no local + activities. The activity context carries no workflow or activity type. +- **Java** — the activity context carries no activity ID. diff --git a/features/serialization_context/activity_payloads/README.md b/features/serialization_context/activity_payloads/README.md new file mode 100644 index 00000000..b7b22a59 --- /dev/null +++ b/features/serialization_context/activity_payloads/README.md @@ -0,0 +1,22 @@ +# Serialization context: activity payloads + +Activity payloads are converted with an `ActivitySerializationContext` carrying +the namespace, workflow ID, workflow type, activity type, task queue, and +`IsLocal = false`. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run an activity that heartbeats and fails its first attempt, so the second + attempt has to decode the heartbeat details recorded by the first one +- verify the client result +- verify that the `ActivityTaskScheduled` input payload and the + `ActivityTaskCompleted` result payload carry the activity signature +- verify that the `WorkflowExecutionCompleted` result payload carries the + workflow signature, not the activity one + +Python only puts an activity ID in the workflow side context when the +workflow sets one explicitly, so the workflow schedules the activity with an +explicit activity ID. diff --git a/features/serialization_context/activity_payloads/config.json b/features/serialization_context/activity_payloads/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/activity_payloads/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/activity_payloads/feature.go b/features/serialization_context/activity_payloads/feature.go new file mode 100644 index 00000000..0d42ed59 --- /dev/null +++ b/features/serialization_context/activity_payloads/feature.go @@ -0,0 +1,111 @@ +package activity_payloads + +import ( + "context" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +const ( + workflowInput = "hello" + heartbeatData = "beat" +) + +var Feature = harness.Feature{ + Workflows: Workflow, + Activities: Activity, + ClientOptions: sercontext.ClientOptions(), + Execute: harness.ExecuteWithArgs(Workflow, workflowInput), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context, input string) (string, error) { + opts := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + HeartbeatTimeout: 5 * time.Second, + RetryPolicy: &temporal.RetryPolicy{InitialInterval: time.Millisecond, MaximumAttempts: 2}, + } + var result string + err := workflow.ExecuteActivity(workflow.WithActivityOptions(ctx, opts), Activity, input).Get(ctx, &result) + return result, err +} + +// Activity heartbeats and fails on its first attempt so that its second attempt +// has to decode the heartbeat details recorded by the first one. +func Activity(ctx context.Context, input string) (string, error) { + if activity.GetInfo(ctx).Attempt == 1 { + activity.RecordHeartbeat(ctx, heartbeatData) + return "", harness.AppErrorf("retrying to read back heartbeat details") + } + var details string + if err := activity.GetHeartbeatDetails(ctx, &details); err != nil { + return "", err + } + return input + "|" + details, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(workflowInput+"|"+heartbeatData, result) + + events, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + + started, err := sercontext.FindEvent(events, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + scheduled, err := sercontext.FindEvent(events, "ActivityTaskScheduled", func(e *historypb.HistoryEvent) bool { + return e.GetActivityTaskScheduledEventAttributes() != nil + }) + if err != nil { + return err + } + scheduledAttrs := scheduled.GetActivityTaskScheduledEventAttributes() + expected := sercontext.ActivitySignature( + runner.Namespace, + run.GetID(), + started.GetWorkflowExecutionStartedEventAttributes().GetWorkflowType().GetName(), + scheduledAttrs.GetActivityType().GetName(), + scheduledAttrs.GetTaskQueue().GetName(), + false, + ) + runner.Require.Equal(expected, sercontext.FirstSignature(scheduledAttrs.GetInput())) + + completed, err := sercontext.FindEvent(events, "ActivityTaskCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetActivityTaskCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(completed.GetActivityTaskCompletedEventAttributes().GetResult())) + + workflowCompleted, err := sercontext.FindEvent(events, "WorkflowExecutionCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal( + sercontext.WorkflowSignature(runner.Namespace, run.GetID()), + sercontext.FirstSignature(workflowCompleted.GetWorkflowExecutionCompletedEventAttributes().GetResult()), + ) + + return nil +} diff --git a/features/serialization_context/async_activity_completion/README.md b/features/serialization_context/async_activity_completion/README.md new file mode 100644 index 00000000..5a894c80 --- /dev/null +++ b/features/serialization_context/async_activity_completion/README.md @@ -0,0 +1,25 @@ +# Serialization context: async activity completion + +A client completing an activity out of band has no task to derive the activity +context from, so it has to be given one through the `*WithOptions` client calls. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run an activity that returns `ErrResultPending` after publishing its identity +- heartbeat and complete it with `RecordActivityHeartbeatByIDWithOptions` and + `CompleteActivityByIDWithOptions`, passing workflow ID, workflow type, + activity type and task queue +- verify the client result, which requires the workflow to decode the result + under the very same activity context +- verify that the `ActivityTaskCompleted` result payload carries the activity + signature + +The plain `CompleteActivityByID` has no activity metadata to build a context +from, so a context aware codec must use the `*WithOptions` variants. + +Python only puts an activity ID in the workflow side context when the +workflow sets one explicitly, so the workflow schedules the activity with an +explicit activity ID. diff --git a/features/serialization_context/async_activity_completion/config.json b/features/serialization_context/async_activity_completion/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/async_activity_completion/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/async_activity_completion/feature.go b/features/serialization_context/async_activity_completion/feature.go new file mode 100644 index 00000000..9ba3eff2 --- /dev/null +++ b/features/serialization_context/async_activity_completion/feature.go @@ -0,0 +1,155 @@ +package async_activity_completion + +import ( + "context" + "sync" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + activityResult = "completed-out-of-band" + heartbeatData = "beat" +) + +// scheduledActivity is what the activity worker saw, used by the completing +// client to reconstruct the same activity serialization context. +type scheduledActivity struct { + workflowID string + runID string + activityID string + activityType string + workflowType string + taskQueue string +} + +var ( + scheduledLock sync.Mutex + scheduled *scheduledActivity +) + +var Feature = harness.Feature{ + Workflows: Workflow, + Activities: Activity, + ClientOptions: sercontext.ClientOptions(), + Execute: Execute, + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context) (string, error) { + opts := workflow.ActivityOptions{ + StartToCloseTimeout: time.Minute, + HeartbeatTimeout: 30 * time.Second, + } + var result string + err := workflow.ExecuteActivity(workflow.WithActivityOptions(ctx, opts), Activity).Get(ctx, &result) + return result, err +} + +// Activity hands its identity to the test and lets the client complete it. +func Activity(ctx context.Context) (string, error) { + info := activity.GetInfo(ctx) + scheduledLock.Lock() + scheduled = &scheduledActivity{ + workflowID: info.WorkflowExecution.ID, + runID: info.WorkflowExecution.RunID, + activityID: info.ActivityID, + activityType: info.ActivityType.Name, + workflowType: info.WorkflowType.Name, + taskQueue: info.TaskQueue, + } + scheduledLock.Unlock() + return "", activity.ErrResultPending +} + +func Execute(ctx context.Context, runner *harness.Runner) (client.WorkflowRun, error) { + run, err := runner.ExecuteDefault(ctx) + if err != nil { + return nil, err + } + + var pending *scheduledActivity + err = runner.DoUntilEventually(ctx, 100*time.Millisecond, 30*time.Second, func() bool { + scheduledLock.Lock() + defer scheduledLock.Unlock() + pending = scheduled + return pending != nil + }) + if err != nil { + return nil, err + } + + // Without the *WithOptions variants the client has no workflow ID or activity + // type to build an activity serialization context from. + err = runner.Client.RecordActivityHeartbeatByIDWithOptions(ctx, client.RecordActivityHeartbeatByIDOptions{ + Namespace: runner.Namespace, + WorkflowID: pending.workflowID, + RunID: pending.runID, + ActivityID: pending.activityID, + ActivityType: pending.activityType, + WorkflowType: pending.workflowType, + TaskQueue: pending.taskQueue, + Details: []interface{}{heartbeatData}, + }) + if err != nil { + return nil, err + } + + err = runner.Client.CompleteActivityByIDWithOptions(ctx, client.CompleteActivityByIDOptions{ + Namespace: runner.Namespace, + WorkflowID: pending.workflowID, + RunID: pending.runID, + ActivityID: pending.activityID, + ActivityType: pending.activityType, + WorkflowType: pending.workflowType, + TaskQueue: pending.taskQueue, + Result: activityResult, + }) + if err != nil { + return nil, err + } + return run, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(activityResult, result) + + scheduledLock.Lock() + pending := scheduled + scheduledLock.Unlock() + + events, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + completed, err := sercontext.FindEvent(events, "ActivityTaskCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetActivityTaskCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal( + sercontext.ActivitySignature( + runner.Namespace, + pending.workflowID, + pending.workflowType, + pending.activityType, + pending.taskQueue, + false, + ), + sercontext.FirstSignature(completed.GetActivityTaskCompletedEventAttributes().GetResult()), + ) + + return nil +} diff --git a/features/serialization_context/child_workflow_payloads/README.md b/features/serialization_context/child_workflow_payloads/README.md new file mode 100644 index 00000000..be13013d --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/README.md @@ -0,0 +1,17 @@ +# Serialization context: child workflow payloads + +Child workflow payloads are converted with the *child's* workflow ID, not the +parent's. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run a workflow that starts a child workflow with an explicit workflow ID +- verify the client result +- verify that the parent's `StartChildWorkflowExecutionInitiated` input payload + and `ChildWorkflowExecutionCompleted` result payload carry the child's + signature +- verify that the child's `WorkflowExecutionStarted` input payload carries the + same signature diff --git a/features/serialization_context/child_workflow_payloads/config.json b/features/serialization_context/child_workflow_payloads/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/child_workflow_payloads/feature.go b/features/serialization_context/child_workflow_payloads/feature.go new file mode 100644 index 00000000..6f288568 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/feature.go @@ -0,0 +1,92 @@ +package child_workflow_payloads + +import ( + "context" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + workflowInput = "hello" + childIDSuffix = "_child" + childResultTag = "|child" +) + +var Feature = harness.Feature{ + Workflows: []interface{}{Workflow, ChildWorkflow}, + ClientOptions: sercontext.ClientOptions(), + Execute: harness.ExecuteWithArgs(Workflow, workflowInput), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context, input string) (string, error) { + opts := workflow.ChildWorkflowOptions{ + WorkflowID: workflow.GetInfo(ctx).WorkflowExecution.ID + childIDSuffix, + WorkflowRunTimeout: time.Minute, + } + var result string + err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions(ctx, opts), ChildWorkflow, input).Get(ctx, &result) + return result, err +} + +func ChildWorkflow(ctx workflow.Context, input string) (string, error) { + return input + childResultTag, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(workflowInput+childResultTag, result) + + childID := run.GetID() + childIDSuffix + // The child's payloads carry the child's own workflow ID, not the parent's. + expected := sercontext.WorkflowSignature(runner.Namespace, childID) + runner.Require.NotEqual(sercontext.WorkflowSignature(runner.Namespace, run.GetID()), expected) + + parentEvents, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + + initiated, err := sercontext.FindEvent(parentEvents, "StartChildWorkflowExecutionInitiated", func(e *historypb.HistoryEvent) bool { + return e.GetStartChildWorkflowExecutionInitiatedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(initiated.GetStartChildWorkflowExecutionInitiatedEventAttributes().GetInput())) + + childCompleted, err := sercontext.FindEvent(parentEvents, "ChildWorkflowExecutionCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetChildWorkflowExecutionCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(childCompleted.GetChildWorkflowExecutionCompletedEventAttributes().GetResult())) + + childEvents, err := sercontext.Events(ctx, runner.Client, childID, "") + if err != nil { + return err + } + childStarted, err := sercontext.FindEvent(childEvents, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(childStarted.GetWorkflowExecutionStartedEventAttributes().GetInput())) + + return nil +} diff --git a/features/serialization_context/child_workflow_payloads_default_id/README.md b/features/serialization_context/child_workflow_payloads_default_id/README.md new file mode 100644 index 00000000..9875d80c --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/README.md @@ -0,0 +1,20 @@ +# Serialization context: child workflow payloads (generated ID) + +When a child workflow is started **without** an explicit workflow ID, the SDK +assigns one deterministically. The child's payloads must be converted with that +generated child ID, not with the parent's ID. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run a workflow that starts a child workflow **without** a workflow ID +- verify the client result +- discover the generated child workflow ID from the parent's + `ChildWorkflowExecutionStarted` event and confirm it differs from the parent's +- verify that the parent's `StartChildWorkflowExecutionInitiated` input payload + and `ChildWorkflowExecutionCompleted` result payload carry the child's + signature +- verify that the child's `WorkflowExecutionStarted` input payload carries the + same signature diff --git a/features/serialization_context/child_workflow_payloads_default_id/config.json b/features/serialization_context/child_workflow_payloads_default_id/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/child_workflow_payloads_default_id/feature.go b/features/serialization_context/child_workflow_payloads_default_id/feature.go new file mode 100644 index 00000000..1dedffb9 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/feature.go @@ -0,0 +1,102 @@ +package child_workflow_payloads_default_id + +import ( + "context" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + workflowInput = "hello" + childResultTag = "|child" +) + +var Feature = harness.Feature{ + Workflows: []interface{}{Workflow, ChildWorkflow}, + ClientOptions: sercontext.ClientOptions(), + Execute: harness.ExecuteWithArgs(Workflow, workflowInput), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context, input string) (string, error) { + // No explicit workflow ID: the SDK assigns a deterministic one, and the + // child's payloads must still be converted with that generated ID. + opts := workflow.ChildWorkflowOptions{ + WorkflowRunTimeout: time.Minute, + } + var result string + err := workflow.ExecuteChildWorkflow( + workflow.WithChildOptions(ctx, opts), ChildWorkflow, input).Get(ctx, &result) + return result, err +} + +func ChildWorkflow(ctx workflow.Context, input string) (string, error) { + return input + childResultTag, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(workflowInput+childResultTag, result) + + parentEvents, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + + // The child ID is generated by the SDK, so discover it from the parent history. + childStarted, err := sercontext.FindEvent(parentEvents, "ChildWorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetChildWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + childID := childStarted.GetChildWorkflowExecutionStartedEventAttributes().GetWorkflowExecution().GetWorkflowId() + + // The generated child ID differs from the parent's, and its payloads carry the child's own signature. + runner.Require.NotEqual(run.GetID(), childID) + runner.Require.NotEmpty(childID) + expected := sercontext.WorkflowSignature(runner.Namespace, childID) + runner.Require.NotEqual(sercontext.WorkflowSignature(runner.Namespace, run.GetID()), expected) + + initiated, err := sercontext.FindEvent(parentEvents, "StartChildWorkflowExecutionInitiated", func(e *historypb.HistoryEvent) bool { + return e.GetStartChildWorkflowExecutionInitiatedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(initiated.GetStartChildWorkflowExecutionInitiatedEventAttributes().GetInput())) + + childCompleted, err := sercontext.FindEvent(parentEvents, "ChildWorkflowExecutionCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetChildWorkflowExecutionCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(childCompleted.GetChildWorkflowExecutionCompletedEventAttributes().GetResult())) + + childEvents, err := sercontext.Events(ctx, runner.Client, childID, "") + if err != nil { + return err + } + childWfStarted, err := sercontext.FindEvent(childEvents, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(childWfStarted.GetWorkflowExecutionStartedEventAttributes().GetInput())) + + return nil +} diff --git a/features/serialization_context/continue_as_new/README.md b/features/serialization_context/continue_as_new/README.md new file mode 100644 index 00000000..cf74a3a0 --- /dev/null +++ b/features/serialization_context/continue_as_new/README.md @@ -0,0 +1,16 @@ +# Serialization context: continue-as-new payloads + +Continue-as-new keeps the workflow ID, so the arguments handed to the next run +are converted with the same `WorkflowSerializationContext`. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run a workflow that continues as new once +- verify the client result +- verify that the first run's `WorkflowExecutionContinuedAsNew` input payload, + the last run's `WorkflowExecutionStarted` input payload and its + `WorkflowExecutionCompleted` result payload all carry the same workflow + signature diff --git a/features/serialization_context/continue_as_new/config.json b/features/serialization_context/continue_as_new/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/continue_as_new/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/continue_as_new/feature.go b/features/serialization_context/continue_as_new/feature.go new file mode 100644 index 00000000..0643453c --- /dev/null +++ b/features/serialization_context/continue_as_new/feature.go @@ -0,0 +1,79 @@ +package continue_as_new + +import ( + "context" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const finalResult = "done" + +var Feature = harness.Feature{ + Workflows: Workflow, + ClientOptions: sercontext.ClientOptions(), + Execute: harness.ExecuteWithArgs(Workflow, 1), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context, remaining int) (string, error) { + if remaining > 0 { + return "", workflow.NewContinueAsNewError(ctx, Workflow, remaining-1) + } + return finalResult, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + // GetRunID follows continue-as-new once the run is awaited. + firstRunID := run.GetRunID() + + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(finalResult, result) + + // Continue-as-new keeps the workflow ID, so both runs share the context. + expected := sercontext.WorkflowSignature(runner.Namespace, run.GetID()) + + firstRunEvents, err := sercontext.Events(ctx, runner.Client, run.GetID(), firstRunID) + if err != nil { + return err + } + continued, err := sercontext.FindEvent(firstRunEvents, "WorkflowExecutionContinuedAsNew", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionContinuedAsNewEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(continued.GetWorkflowExecutionContinuedAsNewEventAttributes().GetInput())) + + lastRunEvents, err := sercontext.Events(ctx, runner.Client, run.GetID(), "") + if err != nil { + return err + } + started, err := sercontext.FindEvent(lastRunEvents, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(started.GetWorkflowExecutionStartedEventAttributes().GetInput())) + + completed, err := sercontext.FindEvent(lastRunEvents, "WorkflowExecutionCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(completed.GetWorkflowExecutionCompletedEventAttributes().GetResult())) + + return nil +} diff --git a/features/serialization_context/external_signal/README.md b/features/serialization_context/external_signal/README.md new file mode 100644 index 00000000..dfa42476 --- /dev/null +++ b/features/serialization_context/external_signal/README.md @@ -0,0 +1,15 @@ +# Serialization context: external signal payloads + +A signal sent to another workflow is converted with the *target's* workflow ID. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- start a receiver workflow that waits for a signal +- run a workflow that signals the receiver through + `SignalExternalWorkflow` +- verify that the sender's `SignalExternalWorkflowExecutionInitiated` input + payload and the receiver's `WorkflowExecutionSignaled` input payload carry the + receiver's signature diff --git a/features/serialization_context/external_signal/config.json b/features/serialization_context/external_signal/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/external_signal/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/external_signal/feature.go b/features/serialization_context/external_signal/feature.go new file mode 100644 index 00000000..0758685b --- /dev/null +++ b/features/serialization_context/external_signal/feature.go @@ -0,0 +1,104 @@ +package external_signal + +import ( + "context" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + signalName = "external" + signalData = "signaled" +) + +var Feature = harness.Feature{ + Workflows: []interface{}{Workflow, Receiver}, + ClientOptions: sercontext.ClientOptions(), + Execute: Execute, + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +// Workflow signals another running workflow. The signal payload is serialized +// with the target's workflow ID, not this workflow's own ID. +func Workflow(ctx workflow.Context, targetID string) (string, error) { + err := workflow.SignalExternalWorkflow(ctx, targetID, "", signalName, signalData).Get(ctx, nil) + return targetID, err +} + +func Receiver(ctx workflow.Context) (string, error) { + var received string + workflow.GetSignalChannel(ctx, signalName).Receive(ctx, &received) + return received, nil +} + +func receiverID(runner *harness.Runner) string { + return runner.TaskQueue + "_receiver" +} + +func Execute(ctx context.Context, runner *harness.Runner) (client.WorkflowRun, error) { + receiver, err := runner.Client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ + ID: receiverID(runner), + TaskQueue: runner.TaskQueue, + WorkflowExecutionTimeout: time.Minute, + }, Receiver) + if err != nil { + return nil, err + } + + run, err := harness.ExecuteWithArgs(Workflow, receiver.GetID())(ctx, runner) + if err != nil { + return nil, err + } + + var received string + if err := receiver.Get(ctx, &received); err != nil { + return nil, err + } + runner.Require.Equal(signalData, received) + return run, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(receiverID(runner), result) + + expected := sercontext.WorkflowSignature(runner.Namespace, receiverID(runner)) + runner.Require.NotEqual(sercontext.WorkflowSignature(runner.Namespace, run.GetID()), expected) + + senderEvents, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + initiated, err := sercontext.FindEvent(senderEvents, "SignalExternalWorkflowExecutionInitiated", func(e *historypb.HistoryEvent) bool { + return e.GetSignalExternalWorkflowExecutionInitiatedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(initiated.GetSignalExternalWorkflowExecutionInitiatedEventAttributes().GetInput())) + + receiverEvents, err := sercontext.Events(ctx, runner.Client, receiverID(runner), "") + if err != nil { + return err + } + signaled, err := sercontext.FindEvent(receiverEvents, "WorkflowExecutionSignaled", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionSignaledEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(signaled.GetWorkflowExecutionSignaledEventAttributes().GetInput())) + + return nil +} diff --git a/features/serialization_context/failure/README.md b/features/serialization_context/failure/README.md new file mode 100644 index 00000000..6381f0bd --- /dev/null +++ b/features/serialization_context/failure/README.md @@ -0,0 +1,17 @@ +# Serialization context: failure conversion + +Failures are converted with a serialization context too: activity failures with +the activity context, workflow failures with the workflow context. + +Steps: + +- register a failure converter that records the signature of its serialization + context in `Failure.Source` +- run a workflow whose activity fails, and which then fails itself +- verify the client sees the workflow error +- verify that `ActivityTaskFailed` carries the activity signature +- verify that `WorkflowExecutionFailed` carries the workflow signature + +Python only puts an activity ID in the workflow side context when the +workflow sets one explicitly, so the workflow schedules the activity with an +explicit activity ID. diff --git a/features/serialization_context/failure/config.json b/features/serialization_context/failure/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/failure/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/failure/feature.go b/features/serialization_context/failure/feature.go new file mode 100644 index 00000000..dd66bb48 --- /dev/null +++ b/features/serialization_context/failure/feature.go @@ -0,0 +1,100 @@ +package failure + +import ( + "context" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +const ( + activityErrorMessage = "activity failed" + workflowErrorMessage = "workflow failed" +) + +var Feature = harness.Feature{ + Workflows: Workflow, + Activities: Activity, + ClientOptions: sercontext.ClientOptions(), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +// Workflow lets an activity fail and then fails itself, so that both an +// activity scoped and a workflow scoped failure conversion are recorded. +func Workflow(ctx workflow.Context) error { + opts := workflow.ActivityOptions{ + StartToCloseTimeout: 10 * time.Second, + RetryPolicy: harness.RetryDisabled, + } + err := workflow.ExecuteActivity(workflow.WithActivityOptions(ctx, opts), Activity).Get(ctx, nil) + if err == nil { + return harness.AppErrorf("expected the activity to fail") + } + return temporal.NewApplicationError(workflowErrorMessage, "WorkflowError") +} + +func Activity(ctx context.Context) error { + return temporal.NewNonRetryableApplicationError(activityErrorMessage, "ActivityError", nil) +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + err := run.Get(ctx, nil) + runner.Require.Error(err) + runner.Require.Contains(err.Error(), workflowErrorMessage) + + events, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + + started, err := sercontext.FindEvent(events, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + scheduled, err := sercontext.FindEvent(events, "ActivityTaskScheduled", func(e *historypb.HistoryEvent) bool { + return e.GetActivityTaskScheduledEventAttributes() != nil + }) + if err != nil { + return err + } + scheduledAttrs := scheduled.GetActivityTaskScheduledEventAttributes() + + activityFailed, err := sercontext.FindEvent(events, "ActivityTaskFailed", func(e *historypb.HistoryEvent) bool { + return e.GetActivityTaskFailedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal( + sercontext.ActivitySignature( + runner.Namespace, + run.GetID(), + started.GetWorkflowExecutionStartedEventAttributes().GetWorkflowType().GetName(), + scheduledAttrs.GetActivityType().GetName(), + scheduledAttrs.GetTaskQueue().GetName(), + false, + ), + activityFailed.GetActivityTaskFailedEventAttributes().GetFailure().GetSource(), + ) + + workflowFailed, err := sercontext.FindEvent(events, "WorkflowExecutionFailed", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionFailedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal( + sercontext.WorkflowSignature(runner.Namespace, run.GetID()), + workflowFailed.GetWorkflowExecutionFailedEventAttributes().GetFailure().GetSource(), + ) + + return nil +} diff --git a/features/serialization_context/local_activity_payloads/README.md b/features/serialization_context/local_activity_payloads/README.md new file mode 100644 index 00000000..2705fe5d --- /dev/null +++ b/features/serialization_context/local_activity_payloads/README.md @@ -0,0 +1,27 @@ +# Serialization context: local activity payloads + +A local activity gets an `ActivitySerializationContext` with `IsLocal = true`, +while the marker bookkeeping around it stays workflow scoped. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- run a local activity +- verify the client result +- verify that the `LocalActivity` marker `data` payload carries the workflow + signature +- verify that the `LocalActivity` marker `result` payload carries the activity + signature with `IsLocal = true` + +Not implemented for TypeScript: the SDK has no local activities. + +## Known Go SDK gap + +This feature currently fails on the Go SDK. `WithLocalActivityTask` builds the +local activity environment from the worker's plain data converter instead of +`ExecuteLocalActivityParams.DataConverter`, so the result is encoded without any +context, while `ExecuteLocalActivity` leaves the future on the workflow context, +so the same payload is decoded as workflow scoped. Any context aware converter +therefore breaks local activities. diff --git a/features/serialization_context/local_activity_payloads/config.json b/features/serialization_context/local_activity_payloads/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/local_activity_payloads/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/local_activity_payloads/feature.go b/features/serialization_context/local_activity_payloads/feature.go new file mode 100644 index 00000000..4f6961c6 --- /dev/null +++ b/features/serialization_context/local_activity_payloads/feature.go @@ -0,0 +1,95 @@ +package local_activity_payloads + +import ( + "context" + "encoding/json" + "time" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/harness/go/harness" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const workflowInput = "hello" + +var Feature = harness.Feature{ + Workflows: Workflow, + Activities: LocalActivity, + ClientOptions: sercontext.ClientOptions(), + Execute: harness.ExecuteWithArgs(Workflow, workflowInput), + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +func Workflow(ctx workflow.Context, input string) (string, error) { + opts := workflow.LocalActivityOptions{StartToCloseTimeout: 10 * time.Second} + var result string + err := workflow.ExecuteLocalActivity( + workflow.WithLocalActivityOptions(ctx, opts), LocalActivity, input).Get(ctx, &result) + return result, err +} + +func LocalActivity(ctx context.Context, input string) (string, error) { + return input + "|local", nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(workflowInput+"|local", result) + + events, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + + started, err := sercontext.FindEvent(events, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + startedAttrs := started.GetWorkflowExecutionStartedEventAttributes() + + marker, err := sercontext.FindEvent(events, "LocalActivity marker", func(e *historypb.HistoryEvent) bool { + return e.GetMarkerRecordedEventAttributes().GetMarkerName() == "LocalActivity" + }) + if err != nil { + return err + } + details := marker.GetMarkerRecordedEventAttributes().GetDetails() + + // The marker bookkeeping itself belongs to the workflow, its payload carries + // the workflow context. + markerData := details["data"].GetPayloads()[0] + runner.Require.Equal( + sercontext.WorkflowSignature(runner.Namespace, run.GetID()), + sercontext.SignatureOf(markerData), + ) + + var decodedMarker struct { + ActivityType string + } + if err := json.Unmarshal(markerData.GetData(), &decodedMarker); err != nil { + return err + } + + // The local activity result carries the activity context with IsLocal set. + runner.Require.Equal( + sercontext.ActivitySignature( + runner.Namespace, + run.GetID(), + startedAttrs.GetWorkflowType().GetName(), + decodedMarker.ActivityType, + startedAttrs.GetTaskQueue().GetName(), + true, + ), + sercontext.FirstSignature(details["result"]), + ) + + return nil +} diff --git a/features/serialization_context/sercontext/sercontext.go b/features/serialization_context/sercontext/sercontext.go new file mode 100644 index 00000000..185e28ab --- /dev/null +++ b/features/serialization_context/sercontext/sercontext.go @@ -0,0 +1,167 @@ +// Package sercontext provides a payload codec and failure converter that are +// aware of the serialization context the SDK hands them, plus history helpers +// used by the serialization_context features to assert on the recorded context. +package sercontext + +import ( + "context" + "fmt" + + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + failurepb "go.temporal.io/api/failure/v1" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/converter" + "go.temporal.io/sdk/temporal" + "google.golang.org/protobuf/proto" +) + +// MetadataKey is the payload metadata entry Codec stamps with the signature of +// the serialization context it was created with. +const MetadataKey = "ctx-signature" + +// NoContext is the signature used when a converter is used without any +// serialization context. +const NoContext = "none" + +// DefaultFailureSource is what the default failure converter puts in +// Failure.Source. FailureConverter only overwrites its own fresh failures. +const DefaultFailureSource = "GoSDK" + +func WorkflowSignature(namespace, workflowID string) string { + return fmt.Sprintf("wf|%s|%s", namespace, workflowID) +} + +func ActivitySignature(namespace, workflowID, workflowType, activityType, taskQueue string, isLocal bool) string { + return fmt.Sprintf("act|%s|%s|%s|%s|%s|%t", + namespace, workflowID, workflowType, activityType, taskQueue, isLocal) +} + +// Signature renders a serialization context as a comparable string. +func Signature(ctx converter.SerializationContext) string { + switch sc := ctx.(type) { + case converter.WorkflowSerializationContext: + return WorkflowSignature(sc.Namespace, sc.WorkflowID) + case converter.ActivitySerializationContext: + return ActivitySignature(sc.Namespace, sc.WorkflowID, sc.WorkflowType, sc.ActivityType, sc.TaskQueue, sc.IsLocal) + } + return NoContext +} + +// ClientOptions returns client options wired with the context aware converters. +func ClientOptions() client.Options { + return client.Options{ + DataConverter: converter.NewCodecDataConverter(converter.GetDefaultDataConverter(), NewCodec()), + FailureConverter: NewFailureConverter(), + } +} + +// Codec stamps every payload it encodes with the signature of its serialization +// context and rejects any payload that was encoded under a different one. +type Codec struct { + signature string +} + +func NewCodec() *Codec { return &Codec{signature: NoContext} } + +func (c *Codec) WithSerializationContext(ctx converter.SerializationContext) converter.PayloadCodec { + return &Codec{signature: Signature(ctx)} +} + +func (c *Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + clone := proto.Clone(p).(*commonpb.Payload) + if clone.Metadata == nil { + clone.Metadata = map[string][]byte{} + } + clone.Metadata[MetadataKey] = []byte(c.signature) + result[i] = clone + } + return result, nil +} + +func (c *Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) { + result := make([]*commonpb.Payload, len(payloads)) + for i, p := range payloads { + if encoded := SignatureOf(p); encoded != c.signature { + return nil, fmt.Errorf( + "serialization context mismatch: payload encoded as %q, decoded as %q", encoded, c.signature) + } + clone := proto.Clone(p).(*commonpb.Payload) + delete(clone.Metadata, MetadataKey) + result[i] = clone + } + return result, nil +} + +// FailureConverter records the signature of its serialization context in +// Failure.Source of the failures it creates. +type FailureConverter struct { + parent converter.FailureConverter + signature string +} + +func NewFailureConverter() *FailureConverter { + return &FailureConverter{parent: temporal.GetDefaultFailureConverter(), signature: NoContext} +} + +func (f *FailureConverter) WithSerializationContext(ctx converter.SerializationContext) converter.FailureConverter { + return &FailureConverter{parent: f.parent, signature: Signature(ctx)} +} + +func (f *FailureConverter) ErrorToFailure(err error) *failurepb.Failure { + failure := f.parent.ErrorToFailure(err) + // A failure that already travelled the wire is returned as-is by the default + // converter, and its source must not be overwritten. + if failure != nil && failure.Source == DefaultFailureSource { + failure.Source = f.signature + } + return failure +} + +func (f *FailureConverter) FailureToError(failure *failurepb.Failure) error { + return f.parent.FailureToError(failure) +} + +// SignatureOf returns the context signature a payload was encoded with. +func SignatureOf(payload *commonpb.Payload) string { + return string(payload.GetMetadata()[MetadataKey]) +} + +// FirstSignature returns the context signature of the first payload. +func FirstSignature(payloads *commonpb.Payloads) string { + if len(payloads.GetPayloads()) == 0 { + return "" + } + return SignatureOf(payloads.GetPayloads()[0]) +} + +// Events collects the full history of a workflow execution. +func Events(ctx context.Context, c client.Client, workflowID, runID string) ([]*historypb.HistoryEvent, error) { + iter := c.GetWorkflowHistory(ctx, workflowID, runID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT) + var events []*historypb.HistoryEvent + for iter.HasNext() { + event, err := iter.Next() + if err != nil { + return nil, err + } + events = append(events, event) + } + return events, nil +} + +// FindEvent returns the first event matching cond, or an error if there is none. +func FindEvent( + events []*historypb.HistoryEvent, + name string, + cond func(*historypb.HistoryEvent) bool, +) (*historypb.HistoryEvent, error) { + for _, event := range events { + if cond(event) { + return event, nil + } + } + return nil, fmt.Errorf("no %v event in history", name) +} diff --git a/features/serialization_context/workflow_payloads/README.md b/features/serialization_context/workflow_payloads/README.md new file mode 100644 index 00000000..180c710b --- /dev/null +++ b/features/serialization_context/workflow_payloads/README.md @@ -0,0 +1,20 @@ +# Serialization context: workflow payloads + +Every workflow scoped payload is converted with a `WorkflowSerializationContext` +carrying the namespace and the workflow ID. + +Steps: + +- register a payload codec that stamps the signature of its serialization + context onto every payload it encodes, and rejects payloads that were encoded + under a different context +- start a workflow with an input, a memo, a side effect, a signal, a query and + an update +- verify the client result +- verify that the `WorkflowExecutionStarted` input and memo payloads, the + `WorkflowExecutionCompleted` result payload, the `WorkflowExecutionSignaled` + input payload, the `SideEffect` marker data payload, the accepted update input + payloads and the update outcome payload all carry the workflow signature + +Query input and result never reach history; they are covered by the codec, which +fails the query if it was encoded under a different context. diff --git a/features/serialization_context/workflow_payloads/config.json b/features/serialization_context/workflow_payloads/config.json new file mode 100644 index 00000000..9e538bce --- /dev/null +++ b/features/serialization_context/workflow_payloads/config.json @@ -0,0 +1,5 @@ +{ + "go": { + "minVersion": "v1.42.0" + } +} diff --git a/features/serialization_context/workflow_payloads/feature.go b/features/serialization_context/workflow_payloads/feature.go new file mode 100644 index 00000000..f0cc4e08 --- /dev/null +++ b/features/serialization_context/workflow_payloads/feature.go @@ -0,0 +1,172 @@ +package workflow_payloads + +import ( + "context" + + "github.com/temporalio/features/features/serialization_context/sercontext" + "github.com/temporalio/features/features/update/updateutil" + "github.com/temporalio/features/harness/go/harness" + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +const ( + signalName = "append" + queryName = "prefixed" + updateName = "suffixed" + memoKey = "ser-ctx-memo" + + workflowInput = "input" + memoValue = "memo" + queryArg = "query-" + updateArg = "-update" + signalData = "signal" + sideEffectValue = "side" +) + +var Feature = harness.Feature{ + Workflows: Workflow, + ClientOptions: sercontext.ClientOptions(), + StartWorkflowOptionsMutator: func(opts *client.StartWorkflowOptions) { + opts.Memo = map[string]interface{}{memoKey: memoValue} + }, + Execute: Execute, + CheckResult: CheckResult, + CheckHistory: harness.NoHistoryCheck, +} + +// Workflow exercises every workflow scoped payload: its own input and result, a +// side effect, a signal, a query and an update. +func Workflow(ctx workflow.Context, input string) (string, error) { + err := workflow.SetQueryHandler(ctx, queryName, func(prefix string) (string, error) { + return prefix + input, nil + }) + if err != nil { + return "", err + } + + err = workflow.SetUpdateHandler(ctx, updateName, func(ctx workflow.Context, suffix string) (string, error) { + return input + suffix, nil + }) + if err != nil { + return "", err + } + + var sideEffect string + if err := workflow.SideEffect(ctx, func(workflow.Context) interface{} { + return sideEffectValue + }).Get(&sideEffect); err != nil { + return "", err + } + + var signaled string + workflow.GetSignalChannel(ctx, signalName).Receive(ctx, &signaled) + + return input + "|" + sideEffect + "|" + signaled, nil +} + +func Execute(ctx context.Context, runner *harness.Runner) (client.WorkflowRun, error) { + if reason := updateutil.CheckServerSupportsUpdate(ctx, runner.Client); reason != "" { + return nil, runner.Skip(reason) + } + + run, err := harness.ExecuteWithArgs(Workflow, workflowInput)(ctx, runner) + if err != nil { + return nil, err + } + + queryValue, err := runner.Client.QueryWorkflow(ctx, run.GetID(), run.GetRunID(), queryName, queryArg) + runner.Require.NoError(err) + var queryResult string + runner.Require.NoError(queryValue.Get(&queryResult)) + runner.Require.Equal(queryArg+workflowInput, queryResult) + + handle, err := runner.Client.UpdateWorkflow(ctx, client.UpdateWorkflowOptions{ + WorkflowID: run.GetID(), + RunID: run.GetRunID(), + UpdateName: updateName, + Args: []interface{}{updateArg}, + WaitForStage: client.WorkflowUpdateStageCompleted, + }) + runner.Require.NoError(err) + var updateResult string + runner.Require.NoError(handle.Get(ctx, &updateResult)) + runner.Require.Equal(workflowInput+updateArg, updateResult) + + runner.Require.NoError(runner.Client.SignalWorkflow(ctx, run.GetID(), run.GetRunID(), signalName, signalData)) + return run, nil +} + +func CheckResult(ctx context.Context, runner *harness.Runner, run client.WorkflowRun) error { + var result string + if err := run.Get(ctx, &result); err != nil { + return err + } + runner.Require.Equal(workflowInput+"|"+sideEffectValue+"|"+signalData, result) + + events, err := sercontext.Events(ctx, runner.Client, run.GetID(), run.GetRunID()) + if err != nil { + return err + } + expected := sercontext.WorkflowSignature(runner.Namespace, run.GetID()) + + started, err := sercontext.FindEvent(events, "WorkflowExecutionStarted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionStartedEventAttributes() != nil + }) + if err != nil { + return err + } + startedAttrs := started.GetWorkflowExecutionStartedEventAttributes() + runner.Require.Equal(expected, sercontext.FirstSignature(startedAttrs.GetInput())) + runner.Require.Equal(expected, sercontext.SignatureOf(startedAttrs.GetMemo().GetFields()[memoKey])) + + completed, err := sercontext.FindEvent(events, "WorkflowExecutionCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionCompletedEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(completed.GetWorkflowExecutionCompletedEventAttributes().GetResult())) + + signaled, err := sercontext.FindEvent(events, "WorkflowExecutionSignaled", func(e *historypb.HistoryEvent) bool { + return e.GetWorkflowExecutionSignaledEventAttributes() != nil + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(signaled.GetWorkflowExecutionSignaledEventAttributes().GetInput())) + + sideEffect, err := sercontext.FindEvent(events, "SideEffect marker", func(e *historypb.HistoryEvent) bool { + return e.GetMarkerRecordedEventAttributes().GetMarkerName() == "SideEffect" + }) + if err != nil { + return err + } + runner.Require.Equal(expected, + sercontext.FirstSignature(sideEffect.GetMarkerRecordedEventAttributes().GetDetails()["data"])) + + accepted, err := sercontext.FindEvent(events, "WorkflowExecutionUpdateAccepted", func(e *historypb.HistoryEvent) bool { + return e.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED + }) + if err != nil { + return err + } + acceptedAttrs := accepted.GetWorkflowExecutionUpdateAcceptedEventAttributes() + runner.Require.Equal(expected, + sercontext.FirstSignature(acceptedAttrs.GetAcceptedRequest().GetInput().GetArgs())) + + updateCompleted, err := sercontext.FindEvent(events, "WorkflowExecutionUpdateCompleted", func(e *historypb.HistoryEvent) bool { + return e.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED + }) + if err != nil { + return err + } + runner.Require.Equal(expected, sercontext.FirstSignature( + updateCompleted.GetWorkflowExecutionUpdateCompletedEventAttributes().GetOutcome().GetSuccess())) + + return nil +} From 63632a4bd46a1a6f627278bdbee2c239abeb02be Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 14 Aug 2026 14:52:20 +0400 Subject: [PATCH 2/4] Add serialization context feature tests for Python --- features/serialization_context/__init__.py | 0 .../activity_payloads/__init__.py | 0 .../activity_payloads/feature.py | 97 +++++++++++ .../async_activity_completion/__init__.py | 0 .../async_activity_completion/feature.py | 109 ++++++++++++ .../child_workflow_payloads/__init__.py | 0 .../child_workflow_payloads/feature.py | 86 ++++++++++ .../__init__.py | 0 .../feature.py | 97 +++++++++++ .../continue_as_new/__init__.py | 0 .../continue_as_new/feature.py | 63 +++++++ .../external_signal/__init__.py | 0 .../external_signal/feature.py | 101 ++++++++++++ .../serialization_context/failure/__init__.py | 0 .../serialization_context/failure/feature.py | 94 +++++++++++ .../local_activity_payloads/__init__.py | 0 .../local_activity_payloads/feature.py | 68 ++++++++ .../sercontext/__init__.py | 0 .../sercontext/sercontext.py | 155 ++++++++++++++++++ .../workflow_payloads/__init__.py | 0 .../workflow_payloads/feature.py | 118 +++++++++++++ 21 files changed, 988 insertions(+) create mode 100644 features/serialization_context/__init__.py create mode 100644 features/serialization_context/activity_payloads/__init__.py create mode 100644 features/serialization_context/activity_payloads/feature.py create mode 100644 features/serialization_context/async_activity_completion/__init__.py create mode 100644 features/serialization_context/async_activity_completion/feature.py create mode 100644 features/serialization_context/child_workflow_payloads/__init__.py create mode 100644 features/serialization_context/child_workflow_payloads/feature.py create mode 100644 features/serialization_context/child_workflow_payloads_default_id/__init__.py create mode 100644 features/serialization_context/child_workflow_payloads_default_id/feature.py create mode 100644 features/serialization_context/continue_as_new/__init__.py create mode 100644 features/serialization_context/continue_as_new/feature.py create mode 100644 features/serialization_context/external_signal/__init__.py create mode 100644 features/serialization_context/external_signal/feature.py create mode 100644 features/serialization_context/failure/__init__.py create mode 100644 features/serialization_context/failure/feature.py create mode 100644 features/serialization_context/local_activity_payloads/__init__.py create mode 100644 features/serialization_context/local_activity_payloads/feature.py create mode 100644 features/serialization_context/sercontext/__init__.py create mode 100644 features/serialization_context/sercontext/sercontext.py create mode 100644 features/serialization_context/workflow_payloads/__init__.py create mode 100644 features/serialization_context/workflow_payloads/feature.py diff --git a/features/serialization_context/__init__.py b/features/serialization_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/activity_payloads/__init__.py b/features/serialization_context/activity_payloads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/activity_payloads/feature.py b/features/serialization_context/activity_payloads/feature.py new file mode 100644 index 00000000..7045e3a1 --- /dev/null +++ b/features/serialization_context/activity_payloads/feature.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import activity, workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "hello" +HEARTBEAT_DATA = "beat" +# Python only puts an activity ID in the workflow side context when the +# workflow sets one explicitly. +ACTIVITY_ID = "ser-ctx-activity" + + +@activity.defn +async def activity_with_heartbeat(input: str) -> str: + """Fails its first attempt so the second one has to decode the heartbeat + details recorded by the first.""" + if activity.info().attempt == 1: + activity.heartbeat(HEARTBEAT_DATA) + raise ApplicationError("retrying to read back heartbeat details") + return f"{input}|{activity.info().heartbeat_details[0]}" + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self, input: str) -> str: + return await workflow.execute_activity( + activity_with_heartbeat, + input, + activity_id=ACTIVITY_ID, + start_to_close_timeout=timedelta(seconds=10), + heartbeat_timeout=timedelta(seconds=5), + retry_policy=RetryPolicy( + initial_interval=timedelta(milliseconds=1), maximum_attempts=2 + ), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == f"{WORKFLOW_INPUT}|{HEARTBEAT_DATA}" + + events = await sercontext.events(handle) + + started = sercontext.find_event( + events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + scheduled = sercontext.find_event( + events, + "ActivityTaskScheduled", + lambda e: e.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED, + ).activity_task_scheduled_event_attributes + + expected = sercontext.activity_signature( + runner.namespace, + handle.id, + started.workflow_type.name, + scheduled.activity_type.name, + scheduled.activity_id, + scheduled.task_queue.name, + False, + ) + assert sercontext.first_signature(scheduled.input) == expected + + completed = sercontext.find_event( + events, + "ActivityTaskCompleted", + lambda e: e.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_COMPLETED, + ).activity_task_completed_event_attributes + assert sercontext.first_signature(completed.result) == expected + + workflow_completed = sercontext.find_event( + events, + "WorkflowExecutionCompleted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED, + ).workflow_execution_completed_event_attributes + assert sercontext.first_signature( + workflow_completed.result + ) == sercontext.workflow_signature(runner.namespace, handle.id) + + +register_feature( + workflows=[Workflow], + activities=[activity_with_heartbeat], + start_options={"arg": WORKFLOW_INPUT}, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/async_activity_completion/__init__.py b/features/serialization_context/async_activity_completion/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/async_activity_completion/feature.py b/features/serialization_context/async_activity_completion/feature.py new file mode 100644 index 00000000..41daa127 --- /dev/null +++ b/features/serialization_context/async_activity_completion/feature.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import Optional + +from temporalio import activity, workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle +from temporalio.converter import ActivitySerializationContext + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +ACTIVITY_RESULT = "completed-out-of-band" +HEARTBEAT_DATA = "beat" +# Python only puts an activity ID in the workflow side context when the +# workflow sets one explicitly. +ACTIVITY_ID = "ser-ctx-activity" + + +class Scheduled: + """What the activity worker saw, used by the completing client to + reconstruct the same activity serialization context.""" + + info: Optional[activity.Info] = None + + +@activity.defn +async def pending_activity() -> str: + Scheduled.info = activity.info() + activity.raise_complete_async() + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self) -> str: + return await workflow.execute_activity( + pending_activity, + activity_id=ACTIVITY_ID, + start_to_close_timeout=timedelta(minutes=1), + heartbeat_timeout=timedelta(seconds=30), + ) + + +async def start(runner: Runner) -> WorkflowHandle: + handle = await runner.start_single_parameterless_workflow() + + for _ in range(300): + if Scheduled.info is not None: + break + await asyncio.sleep(0.1) + info = Scheduled.info + assert info is not None, "activity was never started" + assert info.workflow_id is not None + + # Without an explicit context the client has no workflow ID or activity type + # to build an activity serialization context from. + async_handle = runner.client.get_async_activity_handle( + workflow_id=info.workflow_id, + run_id=info.workflow_run_id, + activity_id=info.activity_id, + ).with_context( + ActivitySerializationContext( + namespace=runner.namespace, + workflow_id=info.workflow_id, + workflow_type=info.workflow_type, + activity_type=info.activity_type, + activity_id=info.activity_id, + activity_task_queue=runner.task_queue, + is_local=False, + ) + ) + await async_handle.heartbeat(HEARTBEAT_DATA) + await async_handle.complete(ACTIVITY_RESULT) + return handle + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == ACTIVITY_RESULT + + info = Scheduled.info + assert info is not None + + events = await sercontext.events(handle) + completed = sercontext.find_event( + events, + "ActivityTaskCompleted", + lambda e: e.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_COMPLETED, + ).activity_task_completed_event_attributes + assert sercontext.first_signature(completed.result) == sercontext.activity_signature( + runner.namespace, + info.workflow_id, + info.workflow_type, + info.activity_type, + info.activity_id, + runner.task_queue, + False, + ) + + +register_feature( + workflows=[Workflow], + activities=[pending_activity], + start=start, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/child_workflow_payloads/__init__.py b/features/serialization_context/child_workflow_payloads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/child_workflow_payloads/feature.py b/features/serialization_context/child_workflow_payloads/feature.py new file mode 100644 index 00000000..f2c5dde9 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/feature.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import uuid +from datetime import timedelta + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "hello" +CHILD_ID_SUFFIX = "_child" +CHILD_RESULT_TAG = "|child" + + +@workflow.defn +class ChildWorkflow: + @workflow.run + async def run(self, input: str) -> str: + return input + CHILD_RESULT_TAG + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self, input: str) -> str: + return await workflow.execute_child_workflow( + ChildWorkflow.run, + input, + id=workflow.info().workflow_id + CHILD_ID_SUFFIX, + run_timeout=timedelta(minutes=1), + ) + + +async def start(runner: Runner) -> WorkflowHandle: + return await runner.client.start_workflow( + Workflow.run, + WORKFLOW_INPUT, + id=f"{runner.feature.rel_dir}-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == WORKFLOW_INPUT + CHILD_RESULT_TAG + + child_id = handle.id + CHILD_ID_SUFFIX + # The child's payloads carry the child's own workflow ID, not the parent's. + expected = sercontext.workflow_signature(runner.namespace, child_id) + assert expected != sercontext.workflow_signature(runner.namespace, handle.id) + + parent_events = await sercontext.events(handle) + initiated = sercontext.find_event( + parent_events, + "StartChildWorkflowExecutionInitiated", + lambda e: e.event_type + == EventType.EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED, + ).start_child_workflow_execution_initiated_event_attributes + assert sercontext.first_signature(initiated.input) == expected + + child_completed = sercontext.find_event( + parent_events, + "ChildWorkflowExecutionCompleted", + lambda e: e.event_type + == EventType.EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_COMPLETED, + ).child_workflow_execution_completed_event_attributes + assert sercontext.first_signature(child_completed.result) == expected + + child_events = await sercontext.events(runner.client.get_workflow_handle(child_id)) + child_started = sercontext.find_event( + child_events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + assert sercontext.first_signature(child_started.input) == expected + + +register_feature( + workflows=[Workflow, ChildWorkflow], + start=start, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/child_workflow_payloads_default_id/__init__.py b/features/serialization_context/child_workflow_payloads_default_id/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/child_workflow_payloads_default_id/feature.py b/features/serialization_context/child_workflow_payloads_default_id/feature.py new file mode 100644 index 00000000..1ff6d4b9 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/feature.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import uuid +from datetime import timedelta + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "hello" +CHILD_RESULT_TAG = "|child" + + +@workflow.defn +class ChildWorkflow: + @workflow.run + async def run(self, input: str) -> str: + return input + CHILD_RESULT_TAG + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self, input: str) -> str: + # No explicit id: the SDK assigns one, and the child's payloads must + # still be converted with that generated ID. + return await workflow.execute_child_workflow( + ChildWorkflow.run, + input, + run_timeout=timedelta(minutes=1), + ) + + +async def start(runner: Runner) -> WorkflowHandle: + return await runner.client.start_workflow( + Workflow.run, + WORKFLOW_INPUT, + id=f"{runner.feature.rel_dir}-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == WORKFLOW_INPUT + CHILD_RESULT_TAG + + parent_events = await sercontext.events(handle) + + # The child ID is generated by the SDK, so discover it from the parent history. + child_started_in_parent = sercontext.find_event( + parent_events, + "ChildWorkflowExecutionStarted", + lambda e: e.event_type + == EventType.EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_STARTED, + ).child_workflow_execution_started_event_attributes + child_id = child_started_in_parent.workflow_execution.workflow_id + + # The generated child ID differs from the parent's, and carries the child's own signature. + assert child_id != handle.id + assert child_id != "" + expected = sercontext.workflow_signature(runner.namespace, child_id) + assert expected != sercontext.workflow_signature(runner.namespace, handle.id) + + initiated = sercontext.find_event( + parent_events, + "StartChildWorkflowExecutionInitiated", + lambda e: e.event_type + == EventType.EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED, + ).start_child_workflow_execution_initiated_event_attributes + assert sercontext.first_signature(initiated.input) == expected + + child_completed = sercontext.find_event( + parent_events, + "ChildWorkflowExecutionCompleted", + lambda e: e.event_type + == EventType.EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_COMPLETED, + ).child_workflow_execution_completed_event_attributes + assert sercontext.first_signature(child_completed.result) == expected + + child_events = await sercontext.events(runner.client.get_workflow_handle(child_id)) + child_started = sercontext.find_event( + child_events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + assert sercontext.first_signature(child_started.input) == expected + + +register_feature( + workflows=[Workflow, ChildWorkflow], + start=start, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/continue_as_new/__init__.py b/features/serialization_context/continue_as_new/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/continue_as_new/feature.py b/features/serialization_context/continue_as_new/feature.py new file mode 100644 index 00000000..5f43c1bc --- /dev/null +++ b/features/serialization_context/continue_as_new/feature.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +FINAL_RESULT = "done" + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self, remaining: int) -> str: + if remaining > 0: + workflow.continue_as_new(remaining - 1) + return FINAL_RESULT + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + first_run_id = handle.first_execution_run_id + assert await handle.result() == FINAL_RESULT + + # Continue-as-new keeps the workflow ID, so both runs share the context. + expected = sercontext.workflow_signature(runner.namespace, handle.id) + + first_run_events = await sercontext.events( + runner.client.get_workflow_handle(handle.id, run_id=first_run_id) + ) + continued = sercontext.find_event( + first_run_events, + "WorkflowExecutionContinuedAsNew", + lambda e: e.event_type + == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW, + ).workflow_execution_continued_as_new_event_attributes + assert sercontext.first_signature(continued.input) == expected + + last_run_events = await sercontext.events( + runner.client.get_workflow_handle(handle.id) + ) + started = sercontext.find_event( + last_run_events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + assert sercontext.first_signature(started.input) == expected + + completed = sercontext.find_event( + last_run_events, + "WorkflowExecutionCompleted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED, + ).workflow_execution_completed_event_attributes + assert sercontext.first_signature(completed.result) == expected + + +register_feature( + workflows=[Workflow], + start_options={"arg": 1}, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/external_signal/__init__.py b/features/serialization_context/external_signal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/external_signal/feature.py b/features/serialization_context/external_signal/feature.py new file mode 100644 index 00000000..9a8ab58f --- /dev/null +++ b/features/serialization_context/external_signal/feature.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import uuid +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +SIGNAL_DATA = "signaled" + + +@workflow.defn +class Receiver: + def __init__(self) -> None: + self._received: Optional[str] = None + + @workflow.run + async def run(self) -> str: + await workflow.wait_condition(lambda: self._received is not None) + assert self._received is not None + return self._received + + @workflow.signal + def external(self, data: str) -> None: + self._received = data + + +@workflow.defn +class Workflow: + """Signals another running workflow. The payload is serialized with the + target's workflow ID, not this workflow's own ID.""" + + @workflow.run + async def run(self, target_id: str) -> str: + await workflow.get_external_workflow_handle_for( + Receiver.run, target_id + ).signal(Receiver.external, SIGNAL_DATA) + return target_id + + +def receiver_id(runner: Runner) -> str: + return f"{runner.task_queue}_receiver" + + +async def start(runner: Runner) -> WorkflowHandle: + receiver = await runner.client.start_workflow( + Receiver.run, + id=receiver_id(runner), + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + handle = await runner.client.start_workflow( + Workflow.run, + receiver.id, + id=f"{runner.feature.rel_dir}-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + ) + + assert await receiver.result() == SIGNAL_DATA + return handle + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == receiver_id(runner) + + expected = sercontext.workflow_signature(runner.namespace, receiver_id(runner)) + assert expected != sercontext.workflow_signature(runner.namespace, handle.id) + + sender_events = await sercontext.events(handle) + initiated = sercontext.find_event( + sender_events, + "SignalExternalWorkflowExecutionInitiated", + lambda e: e.event_type + == EventType.EVENT_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_INITIATED, + ).signal_external_workflow_execution_initiated_event_attributes + assert sercontext.first_signature(initiated.input) == expected + + receiver_events = await sercontext.events( + runner.client.get_workflow_handle(receiver_id(runner)) + ) + signaled = sercontext.find_event( + receiver_events, + "WorkflowExecutionSignaled", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED, + ).workflow_execution_signaled_event_attributes + assert sercontext.first_signature(signaled.input) == expected + + +register_feature( + workflows=[Workflow, Receiver], + start=start, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/failure/__init__.py b/features/serialization_context/failure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/failure/feature.py b/features/serialization_context/failure/feature.py new file mode 100644 index 00000000..d4ac7fb4 --- /dev/null +++ b/features/serialization_context/failure/feature.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import activity, workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowFailureError, WorkflowHandle +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +ACTIVITY_ERROR_MESSAGE = "activity failed" +WORKFLOW_ERROR_MESSAGE = "workflow failed" +# Python only puts an activity ID in the workflow side context when the +# workflow sets one explicitly. +ACTIVITY_ID = "ser-ctx-activity" + + +@activity.defn +async def failing_activity() -> None: + raise ApplicationError(ACTIVITY_ERROR_MESSAGE, type="ActivityError") + + +@workflow.defn +class Workflow: + """Lets an activity fail and then fails itself, so that both an activity + scoped and a workflow scoped failure conversion are recorded.""" + + @workflow.run + async def run(self) -> None: + try: + await workflow.execute_activity( + failing_activity, + activity_id=ACTIVITY_ID, + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy(maximum_attempts=1), + ) + except Exception: + raise ApplicationError(WORKFLOW_ERROR_MESSAGE, type="WorkflowError") + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + try: + await handle.result() + raise AssertionError("expected the workflow to fail") + except WorkflowFailureError as err: + assert WORKFLOW_ERROR_MESSAGE in str(err.cause) + + events = await sercontext.events(handle) + + started = sercontext.find_event( + events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + scheduled = sercontext.find_event( + events, + "ActivityTaskScheduled", + lambda e: e.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED, + ).activity_task_scheduled_event_attributes + + activity_failed = sercontext.find_event( + events, + "ActivityTaskFailed", + lambda e: e.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_FAILED, + ).activity_task_failed_event_attributes + assert activity_failed.failure.source == sercontext.activity_signature( + runner.namespace, + handle.id, + started.workflow_type.name, + scheduled.activity_type.name, + scheduled.activity_id, + scheduled.task_queue.name, + False, + ) + + workflow_failed = sercontext.find_event( + events, + "WorkflowExecutionFailed", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_FAILED, + ).workflow_execution_failed_event_attributes + assert workflow_failed.failure.source == sercontext.workflow_signature( + runner.namespace, handle.id + ) + + +register_feature( + workflows=[Workflow], + activities=[failing_activity], + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/local_activity_payloads/__init__.py b/features/serialization_context/local_activity_payloads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/local_activity_payloads/feature.py b/features/serialization_context/local_activity_payloads/feature.py new file mode 100644 index 00000000..81de8384 --- /dev/null +++ b/features/serialization_context/local_activity_payloads/feature.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import timedelta + +from temporalio import activity, workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "hello" +ACTIVITY_NAME = "local_activity" +# Python only puts an activity ID in the workflow side context when the +# workflow sets one explicitly. +ACTIVITY_ID = "ser-ctx-local-activity" + + +@activity.defn +async def local_activity(input: str) -> str: + return f"{input}|local" + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self, input: str) -> str: + return await workflow.execute_local_activity( + local_activity, + input, + activity_id=ACTIVITY_ID, + start_to_close_timeout=timedelta(seconds=10), + ) + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == f"{WORKFLOW_INPUT}|local" + + events = await sercontext.events(handle) + started = sercontext.find_event( + events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + + # Local activity payloads never reach history, so the context is asserted on + # what the codec was actually asked to convert with. + prefix = ( + f"act|{runner.namespace}|{handle.id}|{started.workflow_type.name}" + f"|{ACTIVITY_NAME}|{ACTIVITY_ID}" + ) + suffix = f"|{started.task_queue.name}|True" + assert any( + s.startswith(prefix) and s.endswith(suffix) + for s in sercontext.observed_signatures + ), ( + f"no local activity context observed, wanted {prefix}...{suffix}, " + f"got {sorted(sercontext.observed_signatures)}" + ) + + +register_feature( + workflows=[Workflow], + activities=[local_activity], + start_options={"arg": WORKFLOW_INPUT}, + check_result=check_result, + data_converter=sercontext.data_converter(), +) diff --git a/features/serialization_context/sercontext/__init__.py b/features/serialization_context/sercontext/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/sercontext/sercontext.py b/features/serialization_context/sercontext/sercontext.py new file mode 100644 index 00000000..38e5e116 --- /dev/null +++ b/features/serialization_context/sercontext/sercontext.py @@ -0,0 +1,155 @@ +"""Context aware converters shared by the serialization_context features. + +The codec stamps the signature of its serialization context onto every payload it +encodes and refuses to decode a payload encoded under a different context, so any +asymmetry between the encoding and the decoding side fails the feature wherever +it happens. Each feature additionally asserts the exact signature recorded in +history. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, List, Optional, Sequence + +from temporalio.api.common.v1 import Payload +from temporalio.api.failure.v1 import Failure +from temporalio.client import WorkflowHandle +from temporalio.converter import ( + ActivitySerializationContext, + DataConverter, + DefaultFailureConverter, + PayloadCodec, + PayloadConverter, + SerializationContext, + WithSerializationContext, + WorkflowSerializationContext, +) + +METADATA_KEY = "ctx-signature" +NO_CONTEXT = "none" + +observed_signatures: set[str] = set() +"""Every signature the codec has been asked to encode or decode with. + +Worker and client share a process here, so this is how a feature asserts on a +context whose payload never shows up in history. +""" + + +def workflow_signature(namespace: str, workflow_id: str) -> str: + return f"wf|{namespace}|{workflow_id}" + + +def activity_signature( + namespace: Optional[str], + workflow_id: Optional[str], + workflow_type: Optional[str], + activity_type: Optional[str], + activity_id: Optional[str], + activity_task_queue: Optional[str], + is_local: bool, +) -> str: + return ( + f"act|{namespace}|{workflow_id}|{workflow_type}|{activity_type}" + f"|{activity_id}|{activity_task_queue}|{is_local}" + ) + + +def signature(context: Optional[SerializationContext]) -> str: + if isinstance(context, WorkflowSerializationContext): + return workflow_signature(context.namespace, context.workflow_id) + if isinstance(context, ActivitySerializationContext): + return activity_signature( + context.namespace, + context.workflow_id, + context.workflow_type, + context.activity_type, + context.activity_id, + context.activity_task_queue, + context.is_local, + ) + return NO_CONTEXT + + +class SigningCodec(PayloadCodec, WithSerializationContext): + def __init__(self, sig: str = NO_CONTEXT) -> None: + super().__init__() + self.signature = sig + + def with_context(self, context: SerializationContext) -> SigningCodec: + return SigningCodec(signature(context)) + + async def encode(self, payloads: Sequence[Payload]) -> List[Payload]: + observed_signatures.add(self.signature) + result: List[Payload] = [] + for p in payloads: + clone = Payload() + clone.CopyFrom(p) + clone.metadata[METADATA_KEY] = self.signature.encode() + result.append(clone) + return result + + async def decode(self, payloads: Sequence[Payload]) -> List[Payload]: + observed_signatures.add(self.signature) + result: List[Payload] = [] + for p in payloads: + encoded = signature_of(p) + if encoded != self.signature: + raise ValueError( + f"serialization context mismatch: payload encoded as {encoded!r}, " + f"decoded as {self.signature!r}" + ) + clone = Payload() + clone.CopyFrom(p) + del clone.metadata[METADATA_KEY] + result.append(clone) + return result + + +class SigningFailureConverter(DefaultFailureConverter, WithSerializationContext): + def __init__(self, sig: str = NO_CONTEXT) -> None: + super().__init__() + self.signature = sig + + def with_context(self, context: SerializationContext) -> SigningFailureConverter: + return SigningFailureConverter(signature(context)) + + def to_failure( + self, + exception: BaseException, + payload_converter: PayloadConverter, + failure: Failure, + ) -> None: + super().to_failure(exception, payload_converter, failure) + # A failure that already travelled the wire is copied as-is by the + # default converter, and its source must not be overwritten. + if not failure.source: + failure.source = self.signature + + +def data_converter() -> DataConverter: + return dataclasses.replace( + DataConverter.default, + payload_codec=SigningCodec(), + failure_converter_class=SigningFailureConverter, + ) + + +def signature_of(payload: Payload) -> str: + return payload.metadata.get(METADATA_KEY, b"").decode() + + +def first_signature(payloads: Any) -> str: + return signature_of(payloads.payloads[0]) + + +async def events(handle: WorkflowHandle) -> List[Any]: + return [e async for e in handle.fetch_history_events()] + + +def find_event(events: List[Any], name: str, predicate) -> Any: + for event in events: + if predicate(event): + return event + raise AssertionError(f"no {name} event in history") diff --git a/features/serialization_context/workflow_payloads/__init__.py b/features/serialization_context/workflow_payloads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/serialization_context/workflow_payloads/feature.py b/features/serialization_context/workflow_payloads/feature.py new file mode 100644 index 00000000..cc0f2ce3 --- /dev/null +++ b/features/serialization_context/workflow_payloads/feature.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import uuid +from datetime import timedelta +from typing import Optional + +from temporalio import workflow +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + +from features.serialization_context.sercontext import sercontext +from harness.python.feature import Runner, register_feature + +WORKFLOW_INPUT = "input" +MEMO_KEY = "ser-ctx-memo" +MEMO_VALUE = "memo" +QUERY_ARG = "query-" +UPDATE_ARG = "-update" +SIGNAL_DATA = "signal" + + +@workflow.defn +class Workflow: + def __init__(self) -> None: + self._input = "" + self._signaled: Optional[str] = None + + @workflow.run + async def run(self, input: str) -> str: + self._input = input + await workflow.wait_condition(lambda: self._signaled is not None) + return f"{input}|{self._signaled}" + + @workflow.signal + def append(self, data: str) -> None: + self._signaled = data + + @workflow.query + def prefixed(self, prefix: str) -> str: + return prefix + self._input + + @workflow.update + def suffixed(self, suffix: str) -> str: + return self._input + suffix + + +async def start(runner: Runner) -> WorkflowHandle: + await runner.skip_if_update_unsupported() + + handle = await runner.client.start_workflow( + Workflow.run, + WORKFLOW_INPUT, + id=f"{runner.feature.rel_dir}-{uuid.uuid4()}", + task_queue=runner.task_queue, + execution_timeout=timedelta(minutes=1), + memo={MEMO_KEY: MEMO_VALUE}, + ) + + assert await handle.query(Workflow.prefixed, QUERY_ARG) == QUERY_ARG + WORKFLOW_INPUT + assert ( + await handle.execute_update(Workflow.suffixed, UPDATE_ARG) + == WORKFLOW_INPUT + UPDATE_ARG + ) + await handle.signal(Workflow.append, SIGNAL_DATA) + return handle + + +async def check_result(runner: Runner, handle: WorkflowHandle) -> None: + assert await handle.result() == f"{WORKFLOW_INPUT}|{SIGNAL_DATA}" + + events = await sercontext.events(handle) + expected = sercontext.workflow_signature(runner.namespace, handle.id) + + started = sercontext.find_event( + events, + "WorkflowExecutionStarted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + ).workflow_execution_started_event_attributes + assert sercontext.first_signature(started.input) == expected + assert sercontext.signature_of(started.memo.fields[MEMO_KEY]) == expected + + completed = sercontext.find_event( + events, + "WorkflowExecutionCompleted", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED, + ).workflow_execution_completed_event_attributes + assert sercontext.first_signature(completed.result) == expected + + signaled = sercontext.find_event( + events, + "WorkflowExecutionSignaled", + lambda e: e.event_type == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED, + ).workflow_execution_signaled_event_attributes + assert sercontext.first_signature(signaled.input) == expected + + accepted = sercontext.find_event( + events, + "WorkflowExecutionUpdateAccepted", + lambda e: e.event_type + == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED, + ).workflow_execution_update_accepted_event_attributes + assert sercontext.first_signature(accepted.accepted_request.input.args) == expected + + update_completed = sercontext.find_event( + events, + "WorkflowExecutionUpdateCompleted", + lambda e: e.event_type + == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED, + ).workflow_execution_update_completed_event_attributes + assert sercontext.first_signature(update_completed.outcome.success) == expected + + +register_feature( + workflows=[Workflow], + start=start, + check_result=check_result, + data_converter=sercontext.data_converter(), +) From 23b623b20a608fe7679301110cc943c0eb66041a Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 14 Aug 2026 14:52:27 +0400 Subject: [PATCH 3/4] Add serialization context feature tests for TypeScript --- .../activity_payloads/feature.ts | 71 +++++++++++++++ .../async_activity_completion/feature.ts | 75 ++++++++++++++++ .../child_workflow_payloads/feature.ts | 56 ++++++++++++ .../feature.ts | 66 ++++++++++++++ .../continue_as_new/feature.ts | 72 +++++++++++++++ .../external_signal/feature.ts | 73 +++++++++++++++ .../serialization_context/failure/feature.ts | 70 +++++++++++++++ .../sercontext/failure_converter.ts | 31 +++++++ .../sercontext/sercontext.ts | 84 +++++++++++++++++ .../workflow_payloads/feature.ts | 90 +++++++++++++++++++ 10 files changed, 688 insertions(+) create mode 100644 features/serialization_context/activity_payloads/feature.ts create mode 100644 features/serialization_context/async_activity_completion/feature.ts create mode 100644 features/serialization_context/child_workflow_payloads/feature.ts create mode 100644 features/serialization_context/child_workflow_payloads_default_id/feature.ts create mode 100644 features/serialization_context/continue_as_new/feature.ts create mode 100644 features/serialization_context/external_signal/feature.ts create mode 100644 features/serialization_context/failure/feature.ts create mode 100644 features/serialization_context/sercontext/failure_converter.ts create mode 100644 features/serialization_context/sercontext/sercontext.ts create mode 100644 features/serialization_context/workflow_payloads/feature.ts diff --git a/features/serialization_context/activity_payloads/feature.ts b/features/serialization_context/activity_payloads/feature.ts new file mode 100644 index 00000000..ec35bf4e --- /dev/null +++ b/features/serialization_context/activity_payloads/feature.ts @@ -0,0 +1,71 @@ +import * as assert from 'assert'; +import { Context } from '@temporalio/activity'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const WORKFLOW_INPUT = 'hello'; +const HEARTBEAT_DATA = 'beat'; +const ACTIVITY_ID = 'ser-ctx-activity'; + +const activities = wf.proxyActivities({ + activityId: ACTIVITY_ID, + startToCloseTimeout: '10 seconds', + heartbeatTimeout: '5 seconds', + retry: { initialInterval: '1 millisecond', maximumAttempts: 2 }, +}); + +export async function workflow(input: string): Promise { + return activities.activityWithHeartbeat(input); +} + +const activitiesImpl = { + // Fails its first attempt so the second one has to decode the heartbeat + // details recorded by the first. + async activityWithHeartbeat(input: string): Promise { + const ctx = Context.current(); + if (ctx.info.attempt === 1) { + ctx.heartbeat(HEARTBEAT_DATA); + throw new Error('retrying to read back heartbeat details'); + } + return `${input}|${ctx.info.heartbeatDetails}`; + }, +}; + +export const feature = new Feature({ + workflow, + activities: activitiesImpl, + workflowStartOptions: { args: [WORKFLOW_INPUT] }, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), `${WORKFLOW_INPUT}|${HEARTBEAT_DATA}`); + + const events = await runner.getHistoryEvents(handle); + const expected = sercontext.activitySignature(runner.options.namespace, handle.workflowId, ACTIVITY_ID, false); + + const scheduled = sercontext.findEvent( + events, + 'ActivityTaskScheduled', + (e) => !!e.activityTaskScheduledEventAttributes, + ).activityTaskScheduledEventAttributes; + assert.equal(scheduled?.activityId, ACTIVITY_ID); + assert.equal(sercontext.firstSignature(scheduled?.input), expected); + + const completed = sercontext.findEvent( + events, + 'ActivityTaskCompleted', + (e) => !!e.activityTaskCompletedEventAttributes, + ).activityTaskCompletedEventAttributes; + assert.equal(sercontext.firstSignature(completed?.result), expected); + + const workflowCompleted = sercontext.findEvent( + events, + 'WorkflowExecutionCompleted', + (e) => !!e.workflowExecutionCompletedEventAttributes, + ).workflowExecutionCompletedEventAttributes; + assert.equal( + sercontext.firstSignature(workflowCompleted?.result), + sercontext.workflowSignature(runner.options.namespace, handle.workflowId), + ); + }, +}); diff --git a/features/serialization_context/async_activity_completion/feature.ts b/features/serialization_context/async_activity_completion/feature.ts new file mode 100644 index 00000000..93bd8b08 --- /dev/null +++ b/features/serialization_context/async_activity_completion/feature.ts @@ -0,0 +1,75 @@ +import * as assert from 'assert'; +import { CompleteAsyncError, Context } from '@temporalio/activity'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const ACTIVITY_RESULT = 'completed-out-of-band'; +const HEARTBEAT_DATA = 'beat'; +const ACTIVITY_ID = 'ser-ctx-activity'; + +const activities = wf.proxyActivities({ + activityId: ACTIVITY_ID, + startToCloseTimeout: '1 minute', + heartbeatTimeout: '30 seconds', +}); + +export async function workflow(): Promise { + return activities.pendingActivity(); +} + +// What the activity worker saw, used by the completing client to reconstruct +// the same activity serialization context. +let taskToken: Uint8Array | undefined; +let workflowId: string | undefined; + +const activitiesImpl = { + async pendingActivity(): Promise { + const info = Context.current().info; + taskToken = info.taskToken; + workflowId = info.workflowExecution?.workflowId; + throw new CompleteAsyncError(); + }, +}; + +export const feature = new Feature({ + workflow, + activities: activitiesImpl, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + execute: async (runner) => { + const handle = await runner.executeSingleParameterlessWorkflow(); + + for (let i = 0; i < 300 && taskToken === undefined; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(taskToken, 'activity was never started'); + assert.ok(workflowId); + + // A task token carries no activity metadata, so the context has to be + // supplied. By-ID operations infer it from the IDs instead. + const serializationContext = { + type: 'activity' as const, + namespace: runner.options.namespace, + workflowId, + activityId: ACTIVITY_ID, + isLocal: false, + }; + await runner.client.activity.heartbeat(taskToken, HEARTBEAT_DATA, { serializationContext }); + await runner.client.activity.complete(taskToken, ACTIVITY_RESULT, { serializationContext }); + return handle; + }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), ACTIVITY_RESULT); + + const events = await runner.getHistoryEvents(handle); + const completed = sercontext.findEvent( + events, + 'ActivityTaskCompleted', + (e) => !!e.activityTaskCompletedEventAttributes, + ).activityTaskCompletedEventAttributes; + assert.equal( + sercontext.firstSignature(completed?.result), + sercontext.activitySignature(runner.options.namespace, handle.workflowId, ACTIVITY_ID, false), + ); + }, +}); diff --git a/features/serialization_context/child_workflow_payloads/feature.ts b/features/serialization_context/child_workflow_payloads/feature.ts new file mode 100644 index 00000000..2e091e63 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/feature.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const WORKFLOW_INPUT = 'hello'; +const CHILD_ID_SUFFIX = '_child'; +const CHILD_RESULT_TAG = '|child'; + +export async function workflow(input: string): Promise { + return wf.executeChild(childWorkflow, { + workflowId: wf.workflowInfo().workflowId + CHILD_ID_SUFFIX, + args: [input], + }); +} + +export async function childWorkflow(input: string): Promise { + return input + CHILD_RESULT_TAG; +} + +export const feature = new Feature({ + workflow, + workflowStartOptions: { args: [WORKFLOW_INPUT] }, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), WORKFLOW_INPUT + CHILD_RESULT_TAG); + + const childId = handle.workflowId + CHILD_ID_SUFFIX; + // The child's payloads carry the child's own workflow ID, not the parent's. + const expected = sercontext.workflowSignature(runner.options.namespace, childId); + assert.notEqual(expected, sercontext.workflowSignature(runner.options.namespace, handle.workflowId)); + + const parentEvents = await runner.getHistoryEvents(handle); + const initiated = sercontext.findEvent( + parentEvents, + 'StartChildWorkflowExecutionInitiated', + (e) => !!e.startChildWorkflowExecutionInitiatedEventAttributes, + ).startChildWorkflowExecutionInitiatedEventAttributes; + assert.equal(sercontext.firstSignature(initiated?.input), expected); + + const childCompleted = sercontext.findEvent( + parentEvents, + 'ChildWorkflowExecutionCompleted', + (e) => !!e.childWorkflowExecutionCompletedEventAttributes, + ).childWorkflowExecutionCompletedEventAttributes; + assert.equal(sercontext.firstSignature(childCompleted?.result), expected); + + const childEvents = await runner.getHistoryEvents(runner.client.workflow.getHandle(childId)); + const childStarted = sercontext.findEvent( + childEvents, + 'WorkflowExecutionStarted', + (e) => !!e.workflowExecutionStartedEventAttributes, + ).workflowExecutionStartedEventAttributes; + assert.equal(sercontext.firstSignature(childStarted?.input), expected); + }, +}); diff --git a/features/serialization_context/child_workflow_payloads_default_id/feature.ts b/features/serialization_context/child_workflow_payloads_default_id/feature.ts new file mode 100644 index 00000000..b27136d0 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/feature.ts @@ -0,0 +1,66 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const WORKFLOW_INPUT = 'hello'; +const CHILD_RESULT_TAG = '|child'; + +export async function workflow(input: string): Promise { + // No explicit workflowId: the SDK assigns one, and the child's payloads must + // still be converted with that generated ID. + return wf.executeChild(childWorkflow, { + args: [input], + }); +} + +export async function childWorkflow(input: string): Promise { + return input + CHILD_RESULT_TAG; +} + +export const feature = new Feature({ + workflow, + workflowStartOptions: { args: [WORKFLOW_INPUT] }, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), WORKFLOW_INPUT + CHILD_RESULT_TAG); + + const parentEvents = await runner.getHistoryEvents(handle); + + // The child ID is generated by the SDK, so discover it from the parent history. + const childStartedInParent = sercontext.findEvent( + parentEvents, + 'ChildWorkflowExecutionStarted', + (e) => !!e.childWorkflowExecutionStartedEventAttributes, + ).childWorkflowExecutionStartedEventAttributes; + const childId = childStartedInParent?.workflowExecution?.workflowId ?? ''; + + // The generated child ID differs from the parent's, and carries the child's own signature. + assert.notEqual(childId, handle.workflowId); + assert.notEqual(childId, ''); + const expected = sercontext.workflowSignature(runner.options.namespace, childId); + assert.notEqual(expected, sercontext.workflowSignature(runner.options.namespace, handle.workflowId)); + + const initiated = sercontext.findEvent( + parentEvents, + 'StartChildWorkflowExecutionInitiated', + (e) => !!e.startChildWorkflowExecutionInitiatedEventAttributes, + ).startChildWorkflowExecutionInitiatedEventAttributes; + assert.equal(sercontext.firstSignature(initiated?.input), expected); + + const childCompleted = sercontext.findEvent( + parentEvents, + 'ChildWorkflowExecutionCompleted', + (e) => !!e.childWorkflowExecutionCompletedEventAttributes, + ).childWorkflowExecutionCompletedEventAttributes; + assert.equal(sercontext.firstSignature(childCompleted?.result), expected); + + const childEvents = await runner.getHistoryEvents(runner.client.workflow.getHandle(childId)); + const childStarted = sercontext.findEvent( + childEvents, + 'WorkflowExecutionStarted', + (e) => !!e.workflowExecutionStartedEventAttributes, + ).workflowExecutionStartedEventAttributes; + assert.equal(sercontext.firstSignature(childStarted?.input), expected); + }, +}); diff --git a/features/serialization_context/continue_as_new/feature.ts b/features/serialization_context/continue_as_new/feature.ts new file mode 100644 index 00000000..f1049b0b --- /dev/null +++ b/features/serialization_context/continue_as_new/feature.ts @@ -0,0 +1,72 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import * as proto from '@temporalio/proto'; +import { Feature, Runner } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const FINAL_RESULT = 'done'; + +export async function workflow(remaining: number): Promise { + if (remaining > 0) { + await wf.continueAsNew(remaining - 1); + } + return FINAL_RESULT; +} + +// The harness helper always reads the latest run, and this feature needs a +// specific one. +async function runHistoryEvents( + runner: Runner, + workflowId: string, + runId?: string, +): Promise { + const events = Array(); + let nextPageToken: Uint8Array | undefined = undefined; + for (;;) { + const response: proto.temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryResponse = + await runner.client.connection.workflowService.getWorkflowExecutionHistory({ + nextPageToken, + namespace: runner.options.namespace, + execution: { workflowId, runId }, + }); + events.push(...(response.history?.events ?? [])); + if (response.nextPageToken == null || response.nextPageToken.length === 0) break; + nextPageToken = response.nextPageToken; + } + return events; +} + +export const feature = new Feature({ + workflow, + workflowStartOptions: { args: [1] }, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), FINAL_RESULT); + + // Continue-as-new keeps the workflow ID, so both runs share the context. + const expected = sercontext.workflowSignature(runner.options.namespace, handle.workflowId); + + const firstRunEvents = await runHistoryEvents(runner, handle.workflowId, handle.firstExecutionRunId); + const continued = sercontext.findEvent( + firstRunEvents, + 'WorkflowExecutionContinuedAsNew', + (e) => !!e.workflowExecutionContinuedAsNewEventAttributes, + ).workflowExecutionContinuedAsNewEventAttributes; + assert.equal(sercontext.firstSignature(continued?.input), expected); + + const lastRunEvents = await runHistoryEvents(runner, handle.workflowId); + const started = sercontext.findEvent( + lastRunEvents, + 'WorkflowExecutionStarted', + (e) => !!e.workflowExecutionStartedEventAttributes, + ).workflowExecutionStartedEventAttributes; + assert.equal(sercontext.firstSignature(started?.input), expected); + + const completed = sercontext.findEvent( + lastRunEvents, + 'WorkflowExecutionCompleted', + (e) => !!e.workflowExecutionCompletedEventAttributes, + ).workflowExecutionCompletedEventAttributes; + assert.equal(sercontext.firstSignature(completed?.result), expected); + }, +}); diff --git a/features/serialization_context/external_signal/feature.ts b/features/serialization_context/external_signal/feature.ts new file mode 100644 index 00000000..92095c23 --- /dev/null +++ b/features/serialization_context/external_signal/feature.ts @@ -0,0 +1,73 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const SIGNAL_DATA = 'signaled'; + +const externalSignal = wf.defineSignal<[string]>('external'); + +// Signals another running workflow. The payload is serialized with the target's +// workflow ID, not this workflow's own ID. +export async function workflow(targetId: string): Promise { + await wf.getExternalWorkflowHandle(targetId).signal(externalSignal, SIGNAL_DATA); + return targetId; +} + +export async function receiver(): Promise { + let received: string | undefined; + wf.setHandler(externalSignal, (data) => { + received = data; + }); + await wf.condition(() => received !== undefined); + return received as string; +} + +function receiverId(taskQueue: string): string { + return `${taskQueue}-receiver`; +} + +export const feature = new Feature({ + workflow, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + execute: async (runner) => { + const receiverHandle = await runner.client.workflow.start(receiver, { + taskQueue: runner.options.taskQueue, + workflowId: receiverId(runner.options.taskQueue), + workflowExecutionTimeout: 60000, + }); + + const handle = await runner.client.workflow.start(workflow, { + taskQueue: runner.options.taskQueue, + workflowId: `${runner.options.taskQueue}-wf`, + workflowExecutionTimeout: 60000, + args: [receiverHandle.workflowId], + }); + + assert.equal(await receiverHandle.result(), SIGNAL_DATA); + return handle; + }, + checkResult: async (runner, handle) => { + const target = receiverId(runner.options.taskQueue); + assert.equal(await handle.result(), target); + + const expected = sercontext.workflowSignature(runner.options.namespace, target); + assert.notEqual(expected, sercontext.workflowSignature(runner.options.namespace, handle.workflowId)); + + const senderEvents = await runner.getHistoryEvents(handle); + const initiated = sercontext.findEvent( + senderEvents, + 'SignalExternalWorkflowExecutionInitiated', + (e) => !!e.signalExternalWorkflowExecutionInitiatedEventAttributes, + ).signalExternalWorkflowExecutionInitiatedEventAttributes; + assert.equal(sercontext.firstSignature(initiated?.input), expected); + + const receiverEvents = await runner.getHistoryEvents(runner.client.workflow.getHandle(target)); + const signaled = sercontext.findEvent( + receiverEvents, + 'WorkflowExecutionSignaled', + (e) => !!e.workflowExecutionSignaledEventAttributes, + ).workflowExecutionSignaledEventAttributes; + assert.equal(sercontext.firstSignature(signaled?.input), expected); + }, +}); diff --git a/features/serialization_context/failure/feature.ts b/features/serialization_context/failure/feature.ts new file mode 100644 index 00000000..8bdc3f39 --- /dev/null +++ b/features/serialization_context/failure/feature.ts @@ -0,0 +1,70 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import { ApplicationFailure } from '@temporalio/common'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const ACTIVITY_ERROR_MESSAGE = 'activity failed'; +const WORKFLOW_ERROR_MESSAGE = 'workflow failed'; +const ACTIVITY_ID = 'ser-ctx-activity'; + +const activities = wf.proxyActivities({ + activityId: ACTIVITY_ID, + startToCloseTimeout: '10 seconds', + retry: { maximumAttempts: 1 }, +}); + +// Lets an activity fail and then fails itself, so that both an activity scoped +// and a workflow scoped failure conversion are recorded. +export async function workflow(): Promise { + try { + await activities.failingActivity(); + } catch { + throw ApplicationFailure.create({ message: WORKFLOW_ERROR_MESSAGE, type: 'WorkflowError' }); + } + throw ApplicationFailure.create({ message: 'expected the activity to fail' }); +} + +const activitiesImpl = { + async failingActivity(): Promise { + throw ApplicationFailure.create({ + message: ACTIVITY_ERROR_MESSAGE, + type: 'ActivityError', + nonRetryable: true, + }); + }, +}; + +export const feature = new Feature({ + workflow, + activities: activitiesImpl, + dataConverter: { + payloadCodecs: [new sercontext.SigningCodec()], + failureConverterPath: require.resolve('../sercontext/failure_converter'), + }, + checkResult: async (runner, handle) => { + await assert.rejects(handle.result()); + + const events = await runner.getHistoryEvents(handle); + + const activityFailed = sercontext.findEvent( + events, + 'ActivityTaskFailed', + (e) => !!e.activityTaskFailedEventAttributes, + ).activityTaskFailedEventAttributes; + assert.equal( + activityFailed?.failure?.source, + sercontext.activitySignature(runner.options.namespace, handle.workflowId, ACTIVITY_ID, false), + ); + + const workflowFailed = sercontext.findEvent( + events, + 'WorkflowExecutionFailed', + (e) => !!e.workflowExecutionFailedEventAttributes, + ).workflowExecutionFailedEventAttributes; + assert.equal( + workflowFailed?.failure?.source, + sercontext.workflowSignature(runner.options.namespace, handle.workflowId), + ); + }, +}); diff --git a/features/serialization_context/sercontext/failure_converter.ts b/features/serialization_context/sercontext/failure_converter.ts new file mode 100644 index 00000000..c4024a3a --- /dev/null +++ b/features/serialization_context/sercontext/failure_converter.ts @@ -0,0 +1,31 @@ +import { + DefaultFailureConverter, + FailureConverter, + PayloadConverter, + ProtoFailure, + SerializationContext, +} from '@temporalio/common'; +import { signatureOf } from './sercontext'; + +const DEFAULT_FAILURE_SOURCE = 'TypeScriptSDK'; + +/** Records the signature of its serialization context in `Failure.source`. */ +class SigningFailureConverter implements FailureConverter { + private readonly parent = new DefaultFailureConverter(); + + errorToFailure(err: unknown, payloadConverter: PayloadConverter, context?: SerializationContext): ProtoFailure { + const failure = this.parent.errorToFailure(err, payloadConverter, context); + // A failure that already travelled the wire keeps the source it was created + // with, so only a freshly built one is stamped. + if (failure.source === DEFAULT_FAILURE_SOURCE) { + failure.source = signatureOf(context); + } + return failure; + } + + failureToError(failure: ProtoFailure, payloadConverter: PayloadConverter, context?: SerializationContext) { + return this.parent.failureToError(failure, payloadConverter, context); + } +} + +export const failureConverter = new SigningFailureConverter(); diff --git a/features/serialization_context/sercontext/sercontext.ts b/features/serialization_context/sercontext/sercontext.ts new file mode 100644 index 00000000..504fac03 --- /dev/null +++ b/features/serialization_context/sercontext/sercontext.ts @@ -0,0 +1,84 @@ +import { Payload, PayloadCodec, SerializationContext } from '@temporalio/common'; +import { decode, encode } from '@temporalio/common/lib/encoding'; + +export const METADATA_KEY = 'ctx-signature'; +export const NO_CONTEXT = 'none'; + +/** + * Every signature the codec has been asked to encode or decode with. Worker and + * client share a process here, so this is how a feature asserts on a context + * whose payload never shows up in history. + */ +export const observedSignatures = new Set(); + +export function workflowSignature(namespace: string, workflowId: string): string { + return `wf|${namespace}|${workflowId}`; +} + +export function activitySignature( + namespace: string, + workflowId: string | undefined, + activityId: string | undefined, + isLocal: boolean, +): string { + return `act|${namespace}|${workflowId}|${activityId}|${isLocal}`; +} + +export function signatureOf(context?: SerializationContext): string { + switch (context?.type) { + case 'workflow': + return workflowSignature(context.namespace, context.workflowId); + case 'activity': + return activitySignature(context.namespace, context.workflowId, context.activityId, context.isLocal); + default: + return NO_CONTEXT; + } +} + +/** + * Stamps the signature of its serialization context onto every payload it + * encodes and refuses to decode a payload encoded under a different context. + */ +export class SigningCodec implements PayloadCodec { + async encode(payloads: Payload[], context?: SerializationContext): Promise { + const signature = signatureOf(context); + observedSignatures.add(signature); + return payloads.map((payload) => ({ + ...payload, + metadata: { ...payload.metadata, [METADATA_KEY]: encode(signature) }, + })); + } + + async decode(payloads: Payload[], context?: SerializationContext): Promise { + const signature = signatureOf(context); + observedSignatures.add(signature); + return payloads.map((payload) => { + const encoded = payloadSignature(payload); + if (encoded !== signature) { + throw new Error(`serialization context mismatch: payload encoded as '${encoded}', decoded as '${signature}'`); + } + const metadata = { ...payload.metadata }; + delete metadata[METADATA_KEY]; + return { ...payload, metadata }; + }); + } +} + +export function payloadSignature(payload?: { metadata?: Record | null } | null): string { + const raw = payload?.metadata?.[METADATA_KEY]; + return raw ? decode(raw) : ''; +} + +export function firstSignature( + payloads?: { payloads?: { metadata?: Record | null }[] | null } | null, +): string { + return payloadSignature(payloads?.payloads?.[0]); +} + +export function findEvent(events: T[], name: string, predicate: (event: T) => boolean): T { + const event = events.find(predicate); + if (!event) { + throw new Error(`no ${name} event in history`); + } + return event; +} diff --git a/features/serialization_context/workflow_payloads/feature.ts b/features/serialization_context/workflow_payloads/feature.ts new file mode 100644 index 00000000..2cbb665f --- /dev/null +++ b/features/serialization_context/workflow_payloads/feature.ts @@ -0,0 +1,90 @@ +import * as assert from 'assert'; +import * as wf from '@temporalio/workflow'; +import { Feature } from '@temporalio/harness'; +import * as sercontext from '../sercontext/sercontext'; + +const WORKFLOW_INPUT = 'input'; +const MEMO_KEY = 'ser-ctx-memo'; +const MEMO_VALUE = 'memo'; +const QUERY_ARG = 'query-'; +const UPDATE_ARG = '-update'; +const SIGNAL_DATA = 'signal'; + +const appendSignal = wf.defineSignal<[string]>('append'); +const prefixedQuery = wf.defineQuery('prefixed'); +const suffixedUpdate = wf.defineUpdate('suffixed'); + +// Exercises every workflow scoped payload: input and result, a memo, a signal, +// a query and an update. +export async function workflow(input: string): Promise { + let signaled: string | undefined; + wf.setHandler(appendSignal, (data) => { + signaled = data; + }); + wf.setHandler(prefixedQuery, (prefix) => prefix + input); + wf.setHandler(suffixedUpdate, (suffix) => input + suffix); + + await wf.condition(() => signaled !== undefined); + return `${input}|${signaled}`; +} + +export const feature = new Feature({ + workflow, + dataConverter: { payloadCodecs: [new sercontext.SigningCodec()] }, + execute: async (runner) => { + const handle = await runner.client.workflow.start(workflow, { + taskQueue: runner.options.taskQueue, + workflowId: `${runner.options.taskQueue}-wf`, + workflowExecutionTimeout: 60000, + args: [WORKFLOW_INPUT], + memo: { [MEMO_KEY]: MEMO_VALUE }, + }); + + assert.equal(await handle.query(prefixedQuery, QUERY_ARG), QUERY_ARG + WORKFLOW_INPUT); + assert.equal(await handle.executeUpdate(suffixedUpdate, { args: [UPDATE_ARG] }), WORKFLOW_INPUT + UPDATE_ARG); + await handle.signal(appendSignal, SIGNAL_DATA); + return handle; + }, + checkResult: async (runner, handle) => { + assert.equal(await handle.result(), `${WORKFLOW_INPUT}|${SIGNAL_DATA}`); + + const events = await runner.getHistoryEvents(handle); + const expected = sercontext.workflowSignature(runner.options.namespace, handle.workflowId); + + const started = sercontext.findEvent( + events, + 'WorkflowExecutionStarted', + (e) => !!e.workflowExecutionStartedEventAttributes, + ).workflowExecutionStartedEventAttributes; + assert.equal(sercontext.firstSignature(started?.input), expected); + assert.equal(sercontext.payloadSignature(started?.memo?.fields?.[MEMO_KEY]), expected); + + const completed = sercontext.findEvent( + events, + 'WorkflowExecutionCompleted', + (e) => !!e.workflowExecutionCompletedEventAttributes, + ).workflowExecutionCompletedEventAttributes; + assert.equal(sercontext.firstSignature(completed?.result), expected); + + const signaled = sercontext.findEvent( + events, + 'WorkflowExecutionSignaled', + (e) => !!e.workflowExecutionSignaledEventAttributes, + ).workflowExecutionSignaledEventAttributes; + assert.equal(sercontext.firstSignature(signaled?.input), expected); + + const accepted = sercontext.findEvent( + events, + 'WorkflowExecutionUpdateAccepted', + (e) => !!e.workflowExecutionUpdateAcceptedEventAttributes, + ).workflowExecutionUpdateAcceptedEventAttributes; + assert.equal(sercontext.firstSignature(accepted?.acceptedRequest?.input?.args), expected); + + const updateCompleted = sercontext.findEvent( + events, + 'WorkflowExecutionUpdateCompleted', + (e) => !!e.workflowExecutionUpdateCompletedEventAttributes, + ).workflowExecutionUpdateCompletedEventAttributes; + assert.equal(sercontext.firstSignature(updateCompleted?.outcome?.success), expected); + }, +}); From 69da1abdf9dfe57b1122dc137c406e6b38472e54 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 14 Aug 2026 14:52:38 +0400 Subject: [PATCH 4/4] Add serialization context feature tests for Java --- .../activity_payloads/feature.java | 135 +++++++++++++++ .../async_activity_completion/feature.java | 128 +++++++++++++++ .../child_workflow_payloads/feature.java | 116 +++++++++++++ .../feature.java | 126 ++++++++++++++ .../continue_as_new/feature.java | 88 ++++++++++ .../external_signal/feature.java | 134 +++++++++++++++ .../failure/feature.java | 124 ++++++++++++++ .../local_activity_payloads/feature.java | 106 ++++++++++++ .../sercontext/SerContext.java | 103 ++++++++++++ .../sercontext/SigningCodec.java | 66 ++++++++ .../sercontext/SigningFailureConverter.java | 49 ++++++ .../workflow_payloads/feature.java | 155 ++++++++++++++++++ .../temporal/sdkfeatures/PreparedFeature.java | 8 + 13 files changed, 1338 insertions(+) create mode 100644 features/serialization_context/activity_payloads/feature.java create mode 100644 features/serialization_context/async_activity_completion/feature.java create mode 100644 features/serialization_context/child_workflow_payloads/feature.java create mode 100644 features/serialization_context/child_workflow_payloads_default_id/feature.java create mode 100644 features/serialization_context/continue_as_new/feature.java create mode 100644 features/serialization_context/external_signal/feature.java create mode 100644 features/serialization_context/failure/feature.java create mode 100644 features/serialization_context/local_activity_payloads/feature.java create mode 100644 features/serialization_context/sercontext/SerContext.java create mode 100644 features/serialization_context/sercontext/SigningCodec.java create mode 100644 features/serialization_context/sercontext/SigningFailureConverter.java create mode 100644 features/serialization_context/workflow_payloads/feature.java diff --git a/features/serialization_context/activity_payloads/feature.java b/features/serialization_context/activity_payloads/feature.java new file mode 100644 index 00000000..5eb3bf95 --- /dev/null +++ b/features/serialization_context/activity_payloads/feature.java @@ -0,0 +1,135 @@ +package serialization_context.activity_payloads; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String WORKFLOW_INPUT = "hello"; + String HEARTBEAT_DATA = "beat"; + + @WorkflowMethod + String workflow(String input); + + @ActivityInterface + interface Activities { + @ActivityMethod + String activityWithHeartbeat(String input); + + /** Fails its first attempt so the second one has to decode the heartbeat details. */ + class Impl implements Activities { + @Override + public String activityWithHeartbeat(String input) { + var context = Activity.getExecutionContext(); + if (context.getInfo().getAttempt() == 1) { + context.heartbeat(HEARTBEAT_DATA); + throw ApplicationFailure.newFailure( + "retrying to read back heartbeat details", "RetryError"); + } + return input + "|" + context.getHeartbeatDetails(String.class).orElse(""); + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerActivitiesImplementations(new Activities.Impl()); + } + + @Override + public String workflow(String input) { + var activities = + Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setHeartbeatTimeout(Duration.ofSeconds(5)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(1)) + .setMaximumAttempts(2) + .build()) + .build()); + return activities.activityWithHeartbeat(input); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + return runner.executeSingleWorkflow(null, WORKFLOW_INPUT); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals( + WORKFLOW_INPUT + "|" + HEARTBEAT_DATA, runner.waitForRunResult(run, String.class)); + + var history = runner.getWorkflowHistory(run); + var started = + SerContext.findEvent( + history, + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + var scheduled = + SerContext.findEvent( + history, "ActivityTaskScheduled", e -> e.hasActivityTaskScheduledEventAttributes()) + .getActivityTaskScheduledEventAttributes(); + + var expected = + SerContext.activitySignature( + runner.config.namespace, + run.execution.getWorkflowId(), + started.getWorkflowType().getName(), + scheduled.getActivityType().getName(), + scheduled.getTaskQueue().getName(), + false); + assertEquals(expected, SerContext.firstSignature(scheduled.getInput())); + + var completed = + SerContext.findEvent( + history, "ActivityTaskCompleted", e -> e.hasActivityTaskCompletedEventAttributes()) + .getActivityTaskCompletedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(completed.getResult())); + + var workflowCompleted = + SerContext.findEvent( + history, + "WorkflowExecutionCompleted", + e -> e.hasWorkflowExecutionCompletedEventAttributes()) + .getWorkflowExecutionCompletedEventAttributes(); + assertEquals( + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()), + SerContext.firstSignature(workflowCompleted.getResult())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/async_activity_completion/feature.java b/features/serialization_context/async_activity_completion/feature.java new file mode 100644 index 00000000..7dd593eb --- /dev/null +++ b/features/serialization_context/async_activity_completion/feature.java @@ -0,0 +1,128 @@ +package serialization_context.async_activity_completion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInfo; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String ACTIVITY_RESULT = "completed-out-of-band"; + String HEARTBEAT_DATA = "beat"; + + /** What the activity worker saw, used by the completing client to rebuild the same context. */ + AtomicReference SCHEDULED = new AtomicReference<>(); + + @WorkflowMethod + String workflow(); + + @ActivityInterface + interface Activities { + @ActivityMethod + String pendingActivity(); + + class Impl implements Activities { + @Override + public String pendingActivity() { + var context = Activity.getExecutionContext(); + SCHEDULED.set(context.getInfo()); + context.doNotCompleteOnReturn(); + return null; + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerActivitiesImplementations(new Activities.Impl()); + } + + @Override + public String workflow() { + var activities = + Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMinutes(1)) + .setHeartbeatTimeout(Duration.ofSeconds(30)) + .build()); + return activities.pendingActivity(); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + var run = runner.executeSingleParameterlessWorkflow(); + + ActivityInfo info = null; + for (int i = 0; i < 300 && info == null; i++) { + info = SCHEDULED.get(); + if (info == null) { + Thread.sleep(100); + } + } + assertNotNull(info, "activity was never started"); + + // A task token carries no activity metadata, so the context has to be supplied. + var completionClient = + runner + .client + .newActivityCompletionClient() + .withContext(new ActivitySerializationContext(info)); + completionClient.heartbeat(info.getTaskToken(), HEARTBEAT_DATA); + completionClient.complete(info.getTaskToken(), ACTIVITY_RESULT); + return run; + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(ACTIVITY_RESULT, runner.waitForRunResult(run, String.class)); + + var info = SCHEDULED.get(); + var completed = + SerContext.findEvent( + runner.getWorkflowHistory(run), + "ActivityTaskCompleted", + e -> e.hasActivityTaskCompletedEventAttributes()) + .getActivityTaskCompletedEventAttributes(); + assertEquals( + SerContext.activitySignature( + info.getNamespace(), + info.getWorkflowId(), + info.getWorkflowType(), + info.getActivityType(), + info.getActivityTaskQueue(), + false), + SerContext.firstSignature(completed.getResult())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/child_workflow_payloads/feature.java b/features/serialization_context/child_workflow_payloads/feature.java new file mode 100644 index 00000000..13ea4f83 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads/feature.java @@ -0,0 +1,116 @@ +package serialization_context.child_workflow_payloads; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.ChildWorkflowOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String WORKFLOW_INPUT = "hello"; + String CHILD_ID_SUFFIX = "_child"; + String CHILD_RESULT_TAG = "|child"; + + @WorkflowMethod + String workflow(String input); + + @WorkflowInterface + interface ChildWorkflow { + @WorkflowMethod + String execute(String input); + + class Impl implements ChildWorkflow { + @Override + public String execute(String input) { + return input + CHILD_RESULT_TAG; + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerWorkflowImplementationTypes(ChildWorkflow.Impl.class); + } + + @Override + public String workflow(String input) { + var child = + Workflow.newChildWorkflowStub( + ChildWorkflow.class, + ChildWorkflowOptions.newBuilder() + .setWorkflowId(Workflow.getInfo().getWorkflowId() + CHILD_ID_SUFFIX) + .setWorkflowRunTimeout(Duration.ofMinutes(1)) + .build()); + return child.execute(input); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + return runner.executeSingleWorkflow(null, WORKFLOW_INPUT); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(WORKFLOW_INPUT + CHILD_RESULT_TAG, runner.waitForRunResult(run, String.class)); + + var childId = run.execution.getWorkflowId() + CHILD_ID_SUFFIX; + // The child's payloads carry the child's own workflow ID, not the parent's. + var expected = SerContext.workflowSignature(runner.config.namespace, childId); + assertNotEquals( + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()), + expected); + + var parentHistory = runner.getWorkflowHistory(run); + var initiated = + SerContext.findEvent( + parentHistory, + "StartChildWorkflowExecutionInitiated", + e -> e.hasStartChildWorkflowExecutionInitiatedEventAttributes()) + .getStartChildWorkflowExecutionInitiatedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(initiated.getInput())); + + var childCompleted = + SerContext.findEvent( + parentHistory, + "ChildWorkflowExecutionCompleted", + e -> e.hasChildWorkflowExecutionCompletedEventAttributes()) + .getChildWorkflowExecutionCompletedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(childCompleted.getResult())); + + var childRun = + new Run(run.method, WorkflowExecution.newBuilder().setWorkflowId(childId).build()); + var childStarted = + SerContext.findEvent( + runner.getWorkflowHistory(childRun), + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(childStarted.getInput())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/child_workflow_payloads_default_id/feature.java b/features/serialization_context/child_workflow_payloads_default_id/feature.java new file mode 100644 index 00000000..fc8eeab0 --- /dev/null +++ b/features/serialization_context/child_workflow_payloads_default_id/feature.java @@ -0,0 +1,126 @@ +package serialization_context.child_workflow_payloads_default_id; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.ChildWorkflowOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String WORKFLOW_INPUT = "hello"; + String CHILD_RESULT_TAG = "|child"; + + @WorkflowMethod + String workflow(String input); + + @WorkflowInterface + interface ChildWorkflow { + @WorkflowMethod + String execute(String input); + + class Impl implements ChildWorkflow { + @Override + public String execute(String input) { + return input + CHILD_RESULT_TAG; + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerWorkflowImplementationTypes(ChildWorkflow.Impl.class); + } + + @Override + public String workflow(String input) { + // No explicit workflow ID: the SDK assigns one, and the child's payloads + // must still be converted with that generated ID. + var child = + Workflow.newChildWorkflowStub( + ChildWorkflow.class, + ChildWorkflowOptions.newBuilder() + .setWorkflowRunTimeout(Duration.ofMinutes(1)) + .build()); + return child.execute(input); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + return runner.executeSingleWorkflow(null, WORKFLOW_INPUT); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(WORKFLOW_INPUT + CHILD_RESULT_TAG, runner.waitForRunResult(run, String.class)); + + var parentHistory = runner.getWorkflowHistory(run); + + // The child ID is generated by the SDK, so discover it from the parent history. + var childStartedInParent = + SerContext.findEvent( + parentHistory, + "ChildWorkflowExecutionStarted", + e -> e.hasChildWorkflowExecutionStartedEventAttributes()) + .getChildWorkflowExecutionStartedEventAttributes(); + var childId = childStartedInParent.getWorkflowExecution().getWorkflowId(); + + // The generated child ID differs from the parent's, and carries the child's own signature. + assertNotEquals(run.execution.getWorkflowId(), childId); + var expected = SerContext.workflowSignature(runner.config.namespace, childId); + assertNotEquals( + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()), + expected); + + var initiated = + SerContext.findEvent( + parentHistory, + "StartChildWorkflowExecutionInitiated", + e -> e.hasStartChildWorkflowExecutionInitiatedEventAttributes()) + .getStartChildWorkflowExecutionInitiatedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(initiated.getInput())); + + var childCompleted = + SerContext.findEvent( + parentHistory, + "ChildWorkflowExecutionCompleted", + e -> e.hasChildWorkflowExecutionCompletedEventAttributes()) + .getChildWorkflowExecutionCompletedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(childCompleted.getResult())); + + var childRun = + new Run(run.method, WorkflowExecution.newBuilder().setWorkflowId(childId).build()); + var childStarted = + SerContext.findEvent( + runner.getWorkflowHistory(childRun), + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(childStarted.getInput())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/continue_as_new/feature.java b/features/serialization_context/continue_as_new/feature.java new file mode 100644 index 00000000..c4d80b0b --- /dev/null +++ b/features/serialization_context/continue_as_new/feature.java @@ -0,0 +1,88 @@ +package serialization_context.continue_as_new; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String FINAL_RESULT = "done"; + + @WorkflowMethod + String workflow(int remaining); + + class Impl implements feature { + + @Override + public String workflow(int remaining) { + if (remaining > 0) { + Workflow.continueAsNew(remaining - 1); + } + return FINAL_RESULT; + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + return runner.executeSingleWorkflow(null, 1); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(FINAL_RESULT, runner.waitForRunResult(run, String.class)); + + // Continue-as-new keeps the workflow ID, so both runs share the context. + var expected = + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()); + + var continued = + SerContext.findEvent( + runner.getWorkflowHistory(run), + "WorkflowExecutionContinuedAsNew", + e -> e.hasWorkflowExecutionContinuedAsNewEventAttributes()) + .getWorkflowExecutionContinuedAsNewEventAttributes(); + assertEquals(expected, SerContext.firstSignature(continued.getInput())); + + var lastRun = + new Run( + run.method, + WorkflowExecution.newBuilder().setWorkflowId(run.execution.getWorkflowId()).build()); + var lastRunHistory = runner.getWorkflowHistory(lastRun); + + var started = + SerContext.findEvent( + lastRunHistory, + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(started.getInput())); + + var completed = + SerContext.findEvent( + lastRunHistory, + "WorkflowExecutionCompleted", + e -> e.hasWorkflowExecutionCompletedEventAttributes()) + .getWorkflowExecutionCompletedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(completed.getResult())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/external_signal/feature.java b/features/serialization_context/external_signal/feature.java new file mode 100644 index 00000000..11f27427 --- /dev/null +++ b/features/serialization_context/external_signal/feature.java @@ -0,0 +1,134 @@ +package serialization_context.external_signal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.Optional; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String SIGNAL_DATA = "signaled"; + + @WorkflowMethod + String workflow(String targetId); + + @WorkflowInterface + interface Receiver { + @WorkflowMethod + String execute(); + + @SignalMethod(name = "external") + void external(String data); + + class Impl implements Receiver { + private String received; + + @Override + public String execute() { + Workflow.await(() -> received != null); + return received; + } + + @Override + public void external(String data) { + this.received = data; + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerWorkflowImplementationTypes(Receiver.Impl.class); + } + + /** + * Signals another running workflow. The payload is serialized with the target's workflow ID, + * not this workflow's own ID. + */ + @Override + public String workflow(String targetId) { + Workflow.newExternalWorkflowStub(Receiver.class, targetId).external(SIGNAL_DATA); + return targetId; + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + private static String receiverId(Runner runner) { + return runner.config.taskQueue + "-receiver"; + } + + @Override + public Run execute(Runner runner) throws Exception { + var receiverOptions = + WorkflowOptions.newBuilder() + .setWorkflowId(receiverId(runner)) + .setTaskQueue(runner.config.taskQueue) + .setWorkflowExecutionTimeout(Duration.ofMinutes(1)) + .build(); + var receiver = runner.client.newWorkflowStub(Receiver.class, receiverOptions); + var receiverExecution = WorkflowClient.start(receiver::execute); + + var run = runner.executeSingleWorkflow(null, receiverId(runner)); + + var receiverStub = + runner.client.newUntypedWorkflowStub(receiverExecution, Optional.empty()); + assertEquals(SIGNAL_DATA, receiverStub.getResult(String.class)); + return run; + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + var target = receiverId(runner); + assertEquals(target, runner.waitForRunResult(run, String.class)); + + var expected = SerContext.workflowSignature(runner.config.namespace, target); + assertNotEquals( + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()), + expected); + + var initiated = + SerContext.findEvent( + runner.getWorkflowHistory(run), + "SignalExternalWorkflowExecutionInitiated", + e -> e.hasSignalExternalWorkflowExecutionInitiatedEventAttributes()) + .getSignalExternalWorkflowExecutionInitiatedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(initiated.getInput())); + + var receiverRun = + new Run(run.method, WorkflowExecution.newBuilder().setWorkflowId(target).build()); + var signaled = + SerContext.findEvent( + runner.getWorkflowHistory(receiverRun), + "WorkflowExecutionSignaled", + e -> e.hasWorkflowExecutionSignaledEventAttributes()) + .getWorkflowExecutionSignaledEventAttributes(); + assertEquals(expected, SerContext.firstSignature(signaled.getInput())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/failure/feature.java b/features/serialization_context/failure/feature.java new file mode 100644 index 00000000..6c490a3e --- /dev/null +++ b/features/serialization_context/failure/feature.java @@ -0,0 +1,124 @@ +package serialization_context.failure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.sdkfeatures.SimpleWorkflow; +import io.temporal.worker.Worker; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import serialization_context.sercontext.SerContext; + +public interface feature extends Feature, SimpleWorkflow { + + String ACTIVITY_ERROR_MESSAGE = "activity failed"; + String WORKFLOW_ERROR_MESSAGE = "workflow failed"; + + @ActivityInterface + interface Activities { + @ActivityMethod + void failingActivity(); + + class Impl implements Activities { + @Override + public void failingActivity() { + throw ApplicationFailure.newNonRetryableFailure(ACTIVITY_ERROR_MESSAGE, "ActivityError"); + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerActivitiesImplementations(new Activities.Impl()); + } + + /** + * Lets an activity fail and then fails itself, so that both an activity scoped and a workflow + * scoped failure conversion are recorded. + */ + @Override + public void workflow() { + var activities = + Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build()); + try { + activities.failingActivity(); + } catch (Exception e) { + throw ApplicationFailure.newFailure(WORKFLOW_ERROR_MESSAGE, "WorkflowError"); + } + throw ApplicationFailure.newFailure("expected the activity to fail", "WorkflowError"); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + try { + runner.waitForRunResult(run); + fail("expected the workflow to fail"); + } catch (Exception e) { + // Expected. + } + + var history = runner.getWorkflowHistory(run); + var started = + SerContext.findEvent( + history, + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + var scheduled = + SerContext.findEvent( + history, "ActivityTaskScheduled", e -> e.hasActivityTaskScheduledEventAttributes()) + .getActivityTaskScheduledEventAttributes(); + + var activityFailed = + SerContext.findEvent( + history, "ActivityTaskFailed", e -> e.hasActivityTaskFailedEventAttributes()) + .getActivityTaskFailedEventAttributes(); + assertEquals( + SerContext.activitySignature( + runner.config.namespace, + run.execution.getWorkflowId(), + started.getWorkflowType().getName(), + scheduled.getActivityType().getName(), + scheduled.getTaskQueue().getName(), + false), + activityFailed.getFailure().getSource()); + + var workflowFailed = + SerContext.findEvent( + history, + "WorkflowExecutionFailed", + e -> e.hasWorkflowExecutionFailedEventAttributes()) + .getWorkflowExecutionFailedEventAttributes(); + assertEquals( + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()), + workflowFailed.getFailure().getSource()); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/local_activity_payloads/feature.java b/features/serialization_context/local_activity_payloads/feature.java new file mode 100644 index 00000000..95307b2a --- /dev/null +++ b/features/serialization_context/local_activity_payloads/feature.java @@ -0,0 +1,106 @@ +package serialization_context.local_activity_payloads; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.LocalActivityOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.worker.Worker; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String WORKFLOW_INPUT = "hello"; + String ACTIVITY_TYPE = "SerCtxLocalActivity"; + + @WorkflowMethod + String workflow(String input); + + @ActivityInterface + interface Activities { + @ActivityMethod(name = ACTIVITY_TYPE) + String localActivity(String input); + + class Impl implements Activities { + @Override + public String localActivity(String input) { + return input + "|local"; + } + } + } + + class Impl implements feature { + + @Override + public void prepareWorker(Worker worker) { + worker.registerActivitiesImplementations(new Activities.Impl()); + } + + @Override + public String workflow(String input) { + var activities = + Workflow.newLocalActivityStub( + Activities.class, + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build()); + return activities.localActivity(input); + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + return runner.executeSingleWorkflow(null, WORKFLOW_INPUT); + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(WORKFLOW_INPUT + "|local", runner.waitForRunResult(run, String.class)); + + var history = runner.getWorkflowHistory(run); + var started = + SerContext.findEvent( + history, + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + + // Local activity payloads never reach history, so the context is asserted on what the codec + // was actually asked to convert with. + var expected = + SerContext.activitySignature( + runner.config.namespace, + run.execution.getWorkflowId(), + started.getWorkflowType().getName(), + ACTIVITY_TYPE, + started.getTaskQueue().getName(), + true); + assertTrue( + SerContext.OBSERVED_SIGNATURES.contains(expected), + "no local activity context observed, wanted " + + expected + + ", got " + + SerContext.OBSERVED_SIGNATURES); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/features/serialization_context/sercontext/SerContext.java b/features/serialization_context/sercontext/SerContext.java new file mode 100644 index 00000000..3dc0f128 --- /dev/null +++ b/features/serialization_context/sercontext/SerContext.java @@ -0,0 +1,103 @@ +package serialization_context.sercontext; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.payload.context.WorkflowSerializationContext; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +/** + * Context aware converters shared by the serialization_context features, plus history helpers used + * to assert on the recorded context. + */ +public final class SerContext { + + public static final String METADATA_KEY = "ctx-signature"; + public static final String NO_CONTEXT = "none"; + public static final String DEFAULT_FAILURE_SOURCE = "JavaSDK"; + + /** + * Every signature the codec has been asked to encode or decode with. Worker and client share a + * process here, so this is how a feature asserts on a context whose payload never shows up in + * history. + */ + public static final Set OBSERVED_SIGNATURES = ConcurrentHashMap.newKeySet(); + + private SerContext() {} + + public static String workflowSignature(String namespace, String workflowId) { + return "wf|" + namespace + "|" + workflowId; + } + + public static String activitySignature( + String namespace, + String workflowId, + String workflowType, + String activityType, + String activityTaskQueue, + boolean local) { + return "act|" + + namespace + + "|" + + workflowId + + "|" + + workflowType + + "|" + + activityType + + "|" + + activityTaskQueue + + "|" + + local; + } + + public static String signature(SerializationContext context) { + if (context instanceof WorkflowSerializationContext) { + WorkflowSerializationContext workflow = (WorkflowSerializationContext) context; + return workflowSignature(workflow.getNamespace(), workflow.getWorkflowId()); + } + if (context instanceof ActivitySerializationContext) { + ActivitySerializationContext activity = (ActivitySerializationContext) context; + return activitySignature( + activity.getNamespace(), + activity.getWorkflowId(), + activity.getWorkflowType(), + activity.getActivityType(), + activity.getActivityTaskQueue(), + activity.isLocal()); + } + return NO_CONTEXT; + } + + public static DataConverter dataConverter() { + return new CodecDataConverter( + DefaultDataConverter.newDefaultInstance().withFailureConverter(new SigningFailureConverter()), + Collections.singletonList(new SigningCodec())); + } + + public static String signatureOf(Payload payload) { + ByteString signature = payload.getMetadataMap().get(METADATA_KEY); + return signature == null ? "" : signature.toStringUtf8(); + } + + public static String firstSignature(Payloads payloads) { + return signatureOf(payloads.getPayloads(0)); + } + + public static HistoryEvent findEvent( + History history, String name, Predicate predicate) { + return history.getEventsList().stream() + .filter(predicate) + .findFirst() + .orElseThrow(() -> new AssertionError("no " + name + " event in history")); + } +} diff --git a/features/serialization_context/sercontext/SigningCodec.java b/features/serialization_context/sercontext/SigningCodec.java new file mode 100644 index 00000000..c8d168ce --- /dev/null +++ b/features/serialization_context/sercontext/SigningCodec.java @@ -0,0 +1,66 @@ +package serialization_context.sercontext; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.payload.context.SerializationContext; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; + +/** + * Stamps the signature of its serialization context onto every payload it encodes and refuses to + * decode a payload encoded under a different context. + */ +public class SigningCodec implements PayloadCodec { + + private final String signature; + + public SigningCodec() { + this(SerContext.NO_CONTEXT); + } + + private SigningCodec(String signature) { + this.signature = signature; + } + + @Override + public PayloadCodec withContext(@Nonnull SerializationContext context) { + return new SigningCodec(SerContext.signature(context)); + } + + @Nonnull + @Override + public List encode(@Nonnull List payloads) { + SerContext.OBSERVED_SIGNATURES.add(signature); + List result = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + result.add( + payload.toBuilder() + .putMetadata(SerContext.METADATA_KEY, ByteString.copyFromUtf8(signature)) + .build()); + } + return result; + } + + @Nonnull + @Override + public List decode(@Nonnull List payloads) { + SerContext.OBSERVED_SIGNATURES.add(signature); + List result = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + String encoded = SerContext.signatureOf(payload); + if (!encoded.equals(signature)) { + throw new PayloadCodecException( + "serialization context mismatch: payload encoded as '" + + encoded + + "', decoded as '" + + signature + + "'"); + } + result.add(payload.toBuilder().removeMetadata(SerContext.METADATA_KEY).build()); + } + return result; + } +} diff --git a/features/serialization_context/sercontext/SigningFailureConverter.java b/features/serialization_context/sercontext/SigningFailureConverter.java new file mode 100644 index 00000000..a2539226 --- /dev/null +++ b/features/serialization_context/sercontext/SigningFailureConverter.java @@ -0,0 +1,49 @@ +package serialization_context.sercontext; + +import io.temporal.api.failure.v1.Failure; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.FailureConverter; +import io.temporal.failure.DefaultFailureConverter; +import io.temporal.payload.context.SerializationContext; +import javax.annotation.Nonnull; + +/** Records the signature of its serialization context in {@code Failure.source}. */ +public class SigningFailureConverter implements FailureConverter { + + private final DefaultFailureConverter parent = new DefaultFailureConverter(); + private final String signature; + + public SigningFailureConverter() { + this(SerContext.NO_CONTEXT); + } + + private SigningFailureConverter(String signature) { + this.signature = signature; + } + + @Nonnull + @Override + public FailureConverter withContext(@Nonnull SerializationContext context) { + return new SigningFailureConverter(SerContext.signature(context)); + } + + @Nonnull + @Override + public RuntimeException failureToException( + @Nonnull Failure failure, @Nonnull DataConverter dataConverter) { + return parent.failureToException(failure, dataConverter); + } + + @Nonnull + @Override + public Failure exceptionToFailure( + @Nonnull Throwable throwable, @Nonnull DataConverter dataConverter) { + Failure failure = parent.exceptionToFailure(throwable, dataConverter); + // A failure that already travelled the wire keeps the source it was created with, so only a + // freshly built one is stamped. + if (SerContext.DEFAULT_FAILURE_SOURCE.equals(failure.getSource())) { + return failure.toBuilder().setSource(signature).build(); + } + return failure; + } +} diff --git a/features/serialization_context/workflow_payloads/feature.java b/features/serialization_context/workflow_payloads/feature.java new file mode 100644 index 00000000..b5e36afa --- /dev/null +++ b/features/serialization_context/workflow_payloads/feature.java @@ -0,0 +1,155 @@ +package serialization_context.workflow_payloads; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import serialization_context.sercontext.SerContext; + +@WorkflowInterface +public interface feature extends Feature { + + String WORKFLOW_INPUT = "input"; + String MEMO_KEY = "ser-ctx-memo"; + String MEMO_VALUE = "memo"; + String QUERY_ARG = "query-"; + String UPDATE_ARG = "-update"; + String SIGNAL_DATA = "signal"; + + @WorkflowMethod + String workflow(String input); + + @SignalMethod(name = "append") + void append(String data); + + @QueryMethod(name = "prefixed") + String prefixed(String prefix); + + @UpdateMethod(name = "suffixed") + String suffixed(String suffix); + + /** + * Exercises every workflow scoped payload: input and result, a memo, a signal, a query and an + * update. + */ + class Impl implements feature { + + private String input = ""; + private String signaled; + + @Override + public String workflow(String input) { + this.input = input; + Workflow.await(() -> signaled != null); + return input + "|" + signaled; + } + + @Override + public void append(String data) { + this.signaled = data; + } + + @Override + public String prefixed(String prefix) { + return prefix + input; + } + + @Override + public String suffixed(String suffix) { + return input + suffix; + } + + @Override + public void workflowClientOptions(WorkflowClientOptions.Builder builder) { + builder.setDataConverter(SerContext.dataConverter()); + } + + @Override + public Run execute(Runner runner) throws Exception { + var options = + WorkflowOptions.newBuilder() + .setTaskQueue(runner.config.taskQueue) + .setWorkflowExecutionTimeout(Duration.ofMinutes(1)) + .setMemo(Collections.singletonMap(MEMO_KEY, MEMO_VALUE)) + .build(); + var run = runner.executeSingleWorkflow(options, WORKFLOW_INPUT); + + var stub = runner.client.newUntypedWorkflowStub(run.execution, Optional.empty()); + assertEquals(QUERY_ARG + WORKFLOW_INPUT, stub.query("prefixed", String.class, QUERY_ARG)); + assertEquals(WORKFLOW_INPUT + UPDATE_ARG, stub.update("suffixed", String.class, UPDATE_ARG)); + stub.signal("append", SIGNAL_DATA); + return run; + } + + @Override + public void checkResult(Runner runner, Run run) throws Exception { + assertEquals(WORKFLOW_INPUT + "|" + SIGNAL_DATA, runner.waitForRunResult(run, String.class)); + + var history = runner.getWorkflowHistory(run); + var expected = + SerContext.workflowSignature(runner.config.namespace, run.execution.getWorkflowId()); + + var started = + SerContext.findEvent( + history, + "WorkflowExecutionStarted", + e -> e.hasWorkflowExecutionStartedEventAttributes()) + .getWorkflowExecutionStartedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(started.getInput())); + assertEquals( + expected, SerContext.signatureOf(started.getMemo().getFieldsMap().get(MEMO_KEY))); + + var completed = + SerContext.findEvent( + history, + "WorkflowExecutionCompleted", + e -> e.hasWorkflowExecutionCompletedEventAttributes()) + .getWorkflowExecutionCompletedEventAttributes(); + assertEquals(expected, SerContext.firstSignature(completed.getResult())); + + var signaled = + SerContext.findEvent( + history, + "WorkflowExecutionSignaled", + e -> e.hasWorkflowExecutionSignaledEventAttributes()) + .getWorkflowExecutionSignaledEventAttributes(); + assertEquals(expected, SerContext.firstSignature(signaled.getInput())); + + var accepted = + SerContext.findEvent( + history, + "WorkflowExecutionUpdateAccepted", + e -> e.hasWorkflowExecutionUpdateAcceptedEventAttributes()) + .getWorkflowExecutionUpdateAcceptedEventAttributes(); + assertEquals( + expected, SerContext.firstSignature(accepted.getAcceptedRequest().getInput().getArgs())); + + var updateCompleted = + SerContext.findEvent( + history, + "WorkflowExecutionUpdateCompleted", + e -> e.hasWorkflowExecutionUpdateCompletedEventAttributes()) + .getWorkflowExecutionUpdateCompletedEventAttributes(); + assertEquals( + expected, SerContext.firstSignature(updateCompleted.getOutcome().getSuccess())); + } + + @Override + public void checkHistory(Runner runner, Run run) { + // The replayer runs histories under a placeholder namespace and workflow ID, which a context + // derived signature can never match. + } + } +} diff --git a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java index 93066a73..890f330b 100644 --- a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java +++ b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java @@ -34,6 +34,14 @@ public class PreparedFeature { schedule.duplicate_error.feature.Impl.class, schedule.pause.feature.Impl.class, schedule.trigger.feature.Impl.class, + serialization_context.activity_payloads.feature.Impl.class, + serialization_context.async_activity_completion.feature.Impl.class, + serialization_context.child_workflow_payloads.feature.Impl.class, + serialization_context.continue_as_new.feature.Impl.class, + serialization_context.external_signal.feature.Impl.class, + serialization_context.failure.feature.Impl.class, + serialization_context.local_activity_payloads.feature.Impl.class, + serialization_context.workflow_payloads.feature.Impl.class, signal.external.feature.Impl.class, update.activities.feature.Impl.class, update.async_accepted.feature.Impl.class,