From db7656f05eae2c4c662f49345d94377bc19b6ec9 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 14 Aug 2026 14:52:12 +0400 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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, From c3d4cc3ad53cc64cb6d8493418e67f356b86a8a4 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Thu, 10 Sep 2026 23:04:30 +0400 Subject: [PATCH 5/8] fix: register Java feature, drop dead version gate, format Register child_workflow_payloads_default_id in PreparedFeature.ALL. Drop the go minVersion gate: features.go imports every feature, so it never guarded compilation. Drop the Go local_activity_payloads until sdk-go#2562 is released. --- features/features.go | 2 - features/serialization_context/README.md | 4 +- .../activity_payloads/config.json | 5 - .../activity_payloads/feature.java | 8 +- .../async_activity_completion/config.json | 5 - .../async_activity_completion/feature.py | 4 +- .../child_workflow_payloads/config.json | 5 - .../child_workflow_payloads/feature.py | 11 ++- .../config.json | 5 - .../feature.py | 14 +-- .../continue_as_new/config.json | 5 - .../continue_as_new/feature.py | 5 +- .../external_signal/config.json | 5 - .../external_signal/feature.java | 3 +- .../external_signal/feature.py | 12 ++- .../serialization_context/failure/config.json | 5 - .../failure/feature.java | 4 +- .../local_activity_payloads/README.md | 13 +-- .../local_activity_payloads/config.json | 5 - .../local_activity_payloads/feature.go | 95 ------------------- .../sercontext/SerContext.java | 3 +- .../workflow_payloads/config.json | 5 - .../workflow_payloads/feature.java | 3 +- .../workflow_payloads/feature.py | 14 ++- .../temporal/sdkfeatures/PreparedFeature.java | 1 + 25 files changed, 58 insertions(+), 183 deletions(-) delete mode 100644 features/serialization_context/activity_payloads/config.json delete mode 100644 features/serialization_context/async_activity_completion/config.json delete mode 100644 features/serialization_context/child_workflow_payloads/config.json delete mode 100644 features/serialization_context/child_workflow_payloads_default_id/config.json delete mode 100644 features/serialization_context/continue_as_new/config.json delete mode 100644 features/serialization_context/external_signal/config.json delete mode 100644 features/serialization_context/failure/config.json delete mode 100644 features/serialization_context/local_activity_payloads/config.json delete mode 100644 features/serialization_context/local_activity_payloads/feature.go delete mode 100644 features/serialization_context/workflow_payloads/config.json diff --git a/features/features.go b/features/features.go index 9463664a..c4d5cb94 100644 --- a/features/features.go +++ b/features/features.go @@ -52,7 +52,6 @@ import ( 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" @@ -124,7 +123,6 @@ func init() { 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, diff --git a/features/serialization_context/README.md b/features/serialization_context/README.md index 54ddd7a9..775d486b 100644 --- a/features/serialization_context/README.md +++ b/features/serialization_context/README.md @@ -31,8 +31,8 @@ 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 +- **Go** — no `local_activity_payloads`: the SDK encoded the local activity + result with the plain worker converter and decoded 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 diff --git a/features/serialization_context/activity_payloads/config.json b/features/serialization_context/activity_payloads/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/activity_payloads/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/activity_payloads/feature.java b/features/serialization_context/activity_payloads/feature.java index 5eb3bf95..c1d5370c 100644 --- a/features/serialization_context/activity_payloads/feature.java +++ b/features/serialization_context/activity_payloads/feature.java @@ -96,7 +96,9 @@ public void checkResult(Runner runner, Run run) throws Exception { .getWorkflowExecutionStartedEventAttributes(); var scheduled = SerContext.findEvent( - history, "ActivityTaskScheduled", e -> e.hasActivityTaskScheduledEventAttributes()) + history, + "ActivityTaskScheduled", + e -> e.hasActivityTaskScheduledEventAttributes()) .getActivityTaskScheduledEventAttributes(); var expected = @@ -111,7 +113,9 @@ public void checkResult(Runner runner, Run run) throws Exception { var completed = SerContext.findEvent( - history, "ActivityTaskCompleted", e -> e.hasActivityTaskCompletedEventAttributes()) + history, + "ActivityTaskCompleted", + e -> e.hasActivityTaskCompletedEventAttributes()) .getActivityTaskCompletedEventAttributes(); assertEquals(expected, SerContext.firstSignature(completed.getResult())); diff --git a/features/serialization_context/async_activity_completion/config.json b/features/serialization_context/async_activity_completion/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/async_activity_completion/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/async_activity_completion/feature.py b/features/serialization_context/async_activity_completion/feature.py index 41daa127..82dad823 100644 --- a/features/serialization_context/async_activity_completion/feature.py +++ b/features/serialization_context/async_activity_completion/feature.py @@ -89,7 +89,9 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: "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( + assert sercontext.first_signature( + completed.result + ) == sercontext.activity_signature( runner.namespace, info.workflow_id, info.workflow_type, diff --git a/features/serialization_context/child_workflow_payloads/config.json b/features/serialization_context/child_workflow_payloads/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/child_workflow_payloads/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/child_workflow_payloads/feature.py b/features/serialization_context/child_workflow_payloads/feature.py index f2c5dde9..dec1b549 100644 --- a/features/serialization_context/child_workflow_payloads/feature.py +++ b/features/serialization_context/child_workflow_payloads/feature.py @@ -56,16 +56,19 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: initiated = sercontext.find_event( parent_events, "StartChildWorkflowExecutionInitiated", - lambda e: e.event_type - == EventType.EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED, + 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, + 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 diff --git a/features/serialization_context/child_workflow_payloads_default_id/config.json b/features/serialization_context/child_workflow_payloads_default_id/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/child_workflow_payloads_default_id/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/child_workflow_payloads_default_id/feature.py b/features/serialization_context/child_workflow_payloads_default_id/feature.py index 1ff6d4b9..056f41b0 100644 --- a/features/serialization_context/child_workflow_payloads_default_id/feature.py +++ b/features/serialization_context/child_workflow_payloads_default_id/feature.py @@ -53,8 +53,7 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: child_started_in_parent = sercontext.find_event( parent_events, "ChildWorkflowExecutionStarted", - lambda e: e.event_type - == EventType.EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_STARTED, + 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 @@ -67,16 +66,19 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: initiated = sercontext.find_event( parent_events, "StartChildWorkflowExecutionInitiated", - lambda e: e.event_type - == EventType.EVENT_TYPE_START_CHILD_WORKFLOW_EXECUTION_INITIATED, + 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, + 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 diff --git a/features/serialization_context/continue_as_new/config.json b/features/serialization_context/continue_as_new/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/continue_as_new/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/continue_as_new/feature.py b/features/serialization_context/continue_as_new/feature.py index 5f43c1bc..a7efeae5 100644 --- a/features/serialization_context/continue_as_new/feature.py +++ b/features/serialization_context/continue_as_new/feature.py @@ -32,8 +32,9 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: continued = sercontext.find_event( first_run_events, "WorkflowExecutionContinuedAsNew", - lambda e: e.event_type - == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CONTINUED_AS_NEW, + 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 diff --git a/features/serialization_context/external_signal/config.json b/features/serialization_context/external_signal/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/external_signal/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/external_signal/feature.java b/features/serialization_context/external_signal/feature.java index 11f27427..eccf6358 100644 --- a/features/serialization_context/external_signal/feature.java +++ b/features/serialization_context/external_signal/feature.java @@ -90,8 +90,7 @@ public Run execute(Runner runner) throws Exception { var run = runner.executeSingleWorkflow(null, receiverId(runner)); - var receiverStub = - runner.client.newUntypedWorkflowStub(receiverExecution, Optional.empty()); + var receiverStub = runner.client.newUntypedWorkflowStub(receiverExecution, Optional.empty()); assertEquals(SIGNAL_DATA, receiverStub.getResult(String.class)); return run; } diff --git a/features/serialization_context/external_signal/feature.py b/features/serialization_context/external_signal/feature.py index 9a8ab58f..cf7ea322 100644 --- a/features/serialization_context/external_signal/feature.py +++ b/features/serialization_context/external_signal/feature.py @@ -37,9 +37,9 @@ class Workflow: @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) + await workflow.get_external_workflow_handle_for(Receiver.run, target_id).signal( + Receiver.external, SIGNAL_DATA + ) return target_id @@ -77,8 +77,10 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: initiated = sercontext.find_event( sender_events, "SignalExternalWorkflowExecutionInitiated", - lambda e: e.event_type - == EventType.EVENT_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_INITIATED, + 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 diff --git a/features/serialization_context/failure/config.json b/features/serialization_context/failure/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/failure/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/failure/feature.java b/features/serialization_context/failure/feature.java index 6c490a3e..761fa2ef 100644 --- a/features/serialization_context/failure/feature.java +++ b/features/serialization_context/failure/feature.java @@ -87,7 +87,9 @@ public void checkResult(Runner runner, Run run) throws Exception { .getWorkflowExecutionStartedEventAttributes(); var scheduled = SerContext.findEvent( - history, "ActivityTaskScheduled", e -> e.hasActivityTaskScheduledEventAttributes()) + history, + "ActivityTaskScheduled", + e -> e.hasActivityTaskScheduledEventAttributes()) .getActivityTaskScheduledEventAttributes(); var activityFailed = diff --git a/features/serialization_context/local_activity_payloads/README.md b/features/serialization_context/local_activity_payloads/README.md index 2705fe5d..52a164a1 100644 --- a/features/serialization_context/local_activity_payloads/README.md +++ b/features/serialization_context/local_activity_payloads/README.md @@ -17,11 +17,8 @@ Steps: 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. +Not implemented for Go: the SDK built the local activity environment from the +worker's plain data converter, so the result was encoded without any context and +then decoded as workflow scoped. temporalio/sdk-go#2562 fixes this. Add +`feature.go` back, together with its entry in `features/features.go`, once that +fix is in a release. diff --git a/features/serialization_context/local_activity_payloads/config.json b/features/serialization_context/local_activity_payloads/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/local_activity_payloads/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 4f6961c6..00000000 --- a/features/serialization_context/local_activity_payloads/feature.go +++ /dev/null @@ -1,95 +0,0 @@ -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.java b/features/serialization_context/sercontext/SerContext.java index 3dc0f128..a9c88a1b 100644 --- a/features/serialization_context/sercontext/SerContext.java +++ b/features/serialization_context/sercontext/SerContext.java @@ -80,7 +80,8 @@ public static String signature(SerializationContext context) { public static DataConverter dataConverter() { return new CodecDataConverter( - DefaultDataConverter.newDefaultInstance().withFailureConverter(new SigningFailureConverter()), + DefaultDataConverter.newDefaultInstance() + .withFailureConverter(new SigningFailureConverter()), Collections.singletonList(new SigningCodec())); } diff --git a/features/serialization_context/workflow_payloads/config.json b/features/serialization_context/workflow_payloads/config.json deleted file mode 100644 index 9e538bce..00000000 --- a/features/serialization_context/workflow_payloads/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "go": { - "minVersion": "v1.42.0" - } -} diff --git a/features/serialization_context/workflow_payloads/feature.java b/features/serialization_context/workflow_payloads/feature.java index b5e36afa..46fd6d0d 100644 --- a/features/serialization_context/workflow_payloads/feature.java +++ b/features/serialization_context/workflow_payloads/feature.java @@ -142,8 +142,7 @@ public void checkResult(Runner runner, Run run) throws Exception { "WorkflowExecutionUpdateCompleted", e -> e.hasWorkflowExecutionUpdateCompletedEventAttributes()) .getWorkflowExecutionUpdateCompletedEventAttributes(); - assertEquals( - expected, SerContext.firstSignature(updateCompleted.getOutcome().getSuccess())); + assertEquals(expected, SerContext.firstSignature(updateCompleted.getOutcome().getSuccess())); } @Override diff --git a/features/serialization_context/workflow_payloads/feature.py b/features/serialization_context/workflow_payloads/feature.py index cc0f2ce3..abc689fd 100644 --- a/features/serialization_context/workflow_payloads/feature.py +++ b/features/serialization_context/workflow_payloads/feature.py @@ -56,7 +56,9 @@ async def start(runner: Runner) -> WorkflowHandle: memo={MEMO_KEY: MEMO_VALUE}, ) - assert await handle.query(Workflow.prefixed, QUERY_ARG) == QUERY_ARG + WORKFLOW_INPUT + 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 @@ -96,16 +98,18 @@ async def check_result(runner: Runner, handle: WorkflowHandle) -> None: accepted = sercontext.find_event( events, "WorkflowExecutionUpdateAccepted", - lambda e: e.event_type - == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED, + 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, + 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 diff --git a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java index 890f330b..5d847dcf 100644 --- a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java +++ b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java @@ -37,6 +37,7 @@ public class PreparedFeature { 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.child_workflow_payloads_default_id.feature.Impl.class, serialization_context.continue_as_new.feature.Impl.class, serialization_context.external_signal.feature.Impl.class, serialization_context.failure.feature.Impl.class, From fdbfcf6e17d127b62f02f698b2ac7051995df8bc Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Thu, 10 Sep 2026 23:57:55 +0400 Subject: [PATCH 6/8] ci: pin the PHP SDK to the serialization context branch The marshaller drops repeated fields coming from the protobuf C extension, which the runtime image loads, so schedule describe fails on the released SDK. Pin the branch that carries the fix and stop passing an explicit PHP version, which would override the pin. --- .github/workflows/ci.yaml | 6 ++---- harness/php/composer.json | 10 ++++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f750e010..3a536353 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -99,11 +99,9 @@ jobs: fi echo "java_latest=$java_latest" >> $GITHUB_OUTPUT + # Temporary: harness/php/composer.json pins the SDK to a branch, so an + # empty version keeps that pin. Restore the lookup once the fix ships. php_latest="$INPUT_PHP_SDK_VERSION" - if [ -z "$php_latest" ]; then - php_latest=$(./temporal-features latest-sdk-version --lang php) - echo "Derived latest PHP SDK release version: $php_latest" - fi echo "php_latest=$php_latest" >> $GITHUB_OUTPUT python_latest="$INPUT_PYTHON_SDK_VERSION" diff --git a/harness/php/composer.json b/harness/php/composer.json index f2ddb5a7..faa360cd 100644 --- a/harness/php/composer.json +++ b/harness/php/composer.json @@ -9,7 +9,7 @@ "buggregator/trap": "^1.9", "spiral/core": "^3.13", "symfony/process": ">=6.4", - "temporal/sdk": "^2.16.0", + "temporal/sdk": "dev-feature/serialization-context2 as 2.18.0", "webmozart/assert": "^1.11" }, "autoload": { @@ -21,5 +21,11 @@ "rr-get": "rr get" }, "prefer-stable": true, - "minimum-stability": "dev" + "minimum-stability": "dev", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/temporalio/sdk-php" + } + ] } From d52812fc58d3ec20134b77f4b1bc0c0a5a606e14 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 11 Sep 2026 23:34:37 +0400 Subject: [PATCH 7/8] ci: point the PHP SDK pin at the pushed branch The branch carrying the marshaller and serialization context fixes is serialization-context2, not feature/serialization-context2. --- harness/php/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/php/composer.json b/harness/php/composer.json index faa360cd..9bdabad9 100644 --- a/harness/php/composer.json +++ b/harness/php/composer.json @@ -9,7 +9,7 @@ "buggregator/trap": "^1.9", "spiral/core": "^3.13", "symfony/process": ">=6.4", - "temporal/sdk": "dev-feature/serialization-context2 as 2.18.0", + "temporal/sdk": "dev-serialization-context2 as 2.18.0", "webmozart/assert": "^1.11" }, "autoload": { From 0f4e65b134d91da1b1e89b2b86c40ebde56ae012 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sat, 12 Sep 2026 10:38:59 +0400 Subject: [PATCH 8/8] ci: skip the PHP docker image while the SDK is pinned to a branch php-ver was built as 'v' + the version, so the empty version the pin needs produced "v" and build-image rejected it as invalid semver. The job takes no repo ref, so there is no release to build an image from until the fix ships. --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a536353..000233d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -328,7 +328,7 @@ jobs: go-ver: 'v${{ needs.build-go.outputs.go_latest }}' ts-ver: 'v${{ needs.build-go.outputs.typescript_latest }}' java-ver: 'v${{ needs.build-go.outputs.java_latest }}' - php-ver: 'v${{ needs.build-go.outputs.php_latest }}' + php-ver: ${{ needs.build-go.outputs.php_latest && format('v{0}', needs.build-go.outputs.php_latest) || '' }} py-ver: 'v${{ needs.build-go.outputs.python_latest }}' cs-ver: 'v${{ needs.build-go.outputs.csharp_latest }}' rb-ver: 'v${{ needs.build-go.outputs.ruby_latest }}'