From cc73e2c7ab3028a07d73887fba86502d95d41fd7 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 13:52:00 -0700 Subject: [PATCH 01/35] Wait for iteration callbacks before completing run --- loadgen/generic_executor.go | 37 +++++++++++++++++++------------- loadgen/generic_executor_test.go | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 3e656c2b..cd5b35b1 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -156,22 +156,29 @@ func (g *genericRun) Run(ctx context.Context) error { select { case <-ctx.Done(): - case doneCh <- err: - switch { - case stopping: - g.logger.Debugf("Iteration %v abandoned: run is stopping", run.Iteration) - case iterErr == nil: - run.Duration = elapsed - g.completed.Add(1) - if g.config.OnCompletion != nil { - g.config.OnCompletion(ctx, run) - } - default: - g.failed.Add(1) - if g.config.OnIterationFailure != nil { - g.config.OnIterationFailure(ctx, run, iterErr) - } + return + default: + } + + switch { + case stopping: + g.logger.Debugf("Iteration %v abandoned: run is stopping", run.Iteration) + case iterErr == nil: + run.Duration = elapsed + g.completed.Add(1) + if g.config.OnCompletion != nil { + g.config.OnCompletion(ctx, run) } + default: + g.failed.Add(1) + if g.config.OnIterationFailure != nil { + g.config.OnIterationFailure(ctx, run, iterErr) + } + } + + select { + case <-ctx.Done(): + case doneCh <- err: } }() diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index 1c2fae4f..c8ac2593 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -62,6 +62,43 @@ func TestRunHappyPathIterations(t *testing.T) { }) } +func TestRunWaitsForOnCompletion(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + callbackStarted := make(chan struct{}) + releaseCallback := make(chan struct{}) + runDone := make(chan error, 1) + + go func() { + runDone <- execute(&GenericExecutor{ + Execute: func(ctx context.Context, run *Run) error { + return nil + }}, + RunConfiguration{ + Iterations: 1, + OnCompletion: func(ctx context.Context, run *Run) { + close(callbackStarted) + <-releaseCallback + }, + }, + ) + }() + + <-callbackStarted + synctest.Wait() + + returned := false + select { + case <-runDone: + returned = true + default: + } + close(releaseCallback) + + require.False(t, returned, "executor returned before OnCompletion finished") + require.NoError(t, <-runDone) + }) +} + func TestRunFailIterations(t *testing.T) { synctest.Test(t, func(t *testing.T) { tracker := newIterationTracker() From 17592951c3e678f3dc199e016a9b64be587c7720 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 14:28:31 -0700 Subject: [PATCH 02/35] Document iteration completion ordering --- loadgen/generic_executor.go | 3 +++ loadgen/generic_executor_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index cd5b35b1..4e5f9aca 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -154,6 +154,9 @@ func (g *genericRun) Run(ctx context.Context) error { // cancellation while the run is healthy. stopping := iterErr != nil && ctx.Err() != nil && errors.Is(iterErr, context.Canceled) + // Finish outcome handling before notifying the waiter because callers may read + // callback-updated state as soon as Run returns. Check cancellation on both + // sides because a callback may outlive the run context. select { case <-ctx.Done(): return diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index c8ac2593..e2d4e923 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -84,6 +84,7 @@ func TestRunWaitsForOnCompletion(t *testing.T) { }() <-callbackStarted + // Let every goroutine reach a durable blocking point before checking whether Run returned. synctest.Wait() returned := false From ae807430d1e19453ddb18803fa18c82f4e90656c Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 14:34:48 -0700 Subject: [PATCH 03/35] Clarify iteration completion comments --- loadgen/generic_executor.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 4e5f9aca..1ac90df2 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -154,9 +154,7 @@ func (g *genericRun) Run(ctx context.Context) error { // cancellation while the run is healthy. stopping := iterErr != nil && ctx.Err() != nil && errors.Is(iterErr, context.Canceled) - // Finish outcome handling before notifying the waiter because callers may read - // callback-updated state as soon as Run returns. Check cancellation on both - // sides because a callback may outlive the run context. + // Do not start outcome handling after the run has been canceled. select { case <-ctx.Done(): return @@ -179,6 +177,8 @@ func (g *genericRun) Run(ctx context.Context) error { } } + // Notify the waiter only after callbacks finish because callers may read + // callback-updated state as soon as Run returns. select { case <-ctx.Done(): case doneCh <- err: From d70ec3ed2e1fc81321251a4a248cb4ec852b674e Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 14:38:14 -0700 Subject: [PATCH 04/35] Update generic_executor.go --- loadgen/generic_executor.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 1ac90df2..07651477 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -154,7 +154,7 @@ func (g *genericRun) Run(ctx context.Context) error { // cancellation while the run is healthy. stopping := iterErr != nil && ctx.Err() != nil && errors.Is(iterErr, context.Canceled) - // Do not start outcome handling after the run has been canceled. + // Skip outcome handling if the run has been canceled. select { case <-ctx.Done(): return @@ -177,8 +177,7 @@ func (g *genericRun) Run(ctx context.Context) error { } } - // Notify the waiter only after callbacks finish because callers may read - // callback-updated state as soon as Run returns. + // Notify the waiter after callbacks finish so Run cannot return before they update state. select { case <-ctx.Done(): case doneCh <- err: From 4e344af7d16a93830b303b27d1faf9549cbf317f Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 14:38:31 -0700 Subject: [PATCH 05/35] Update generic_executor.go --- loadgen/generic_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 07651477..6ea906ba 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -177,7 +177,7 @@ func (g *genericRun) Run(ctx context.Context) error { } } - // Notify the waiter after callbacks finish so Run cannot return before they update state. + // Notify the waiter after callbacks finish so method cannot return before they update state. select { case <-ctx.Done(): case doneCh <- err: From 8e19c1a9e01d3984549f984721c655b067bf2bfb Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 14:44:22 -0700 Subject: [PATCH 06/35] Preserve failure reporting when runs are canceled --- loadgen/generic_executor.go | 7 ------ loadgen/generic_executor_test.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 6ea906ba..14c0d6e4 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -154,13 +154,6 @@ func (g *genericRun) Run(ctx context.Context) error { // cancellation while the run is healthy. stopping := iterErr != nil && ctx.Err() != nil && errors.Is(iterErr, context.Canceled) - // Skip outcome handling if the run has been canceled. - select { - case <-ctx.Done(): - return - default: - } - switch { case stopping: g.logger.Debugf("Iteration %v abandoned: run is stopping", run.Iteration) diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index e2d4e923..f634bc12 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -316,6 +316,45 @@ func TestRunContinueOnIterationFailure(t *testing.T) { }) } +func TestRunCanceledWithNonCancellationErrorIsReportedAsFailure(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + failureReported := make(chan struct{}, 1) + executor := &GenericExecutor{ + Execute: func(ctx context.Context, run *Run) error { + cancel() + return errors.New("deliberate fail from test") + }, + } + + logger := zap.Must(zap.NewDevelopment()) + defer logger.Sync() + err := executor.Run(ctx, ScenarioInfo{ + MetricsHandler: client.MetricsNopHandler, + Logger: logger.Sugar(), + Configuration: RunConfiguration{ + Iterations: 1, + ContinueOnIterationFailure: true, + OnIterationFailure: func(ctx context.Context, run *Run, err error) { + failureReported <- struct{}{} + }, + }, + }) + require.Error(t, err) + + synctest.Wait() + reported := false + select { + case <-failureReported: + reported = true + default: + } + require.True(t, reported, "non-cancellation error should be reported as a failure") + }) +} + // TestRunStoppedIterationsAreNotCountedAsFailures pins that iterations abandoned // by a caller stopping the run are left out of the tallies, so a clean stop is // not reported as a burst of failures. From 3f0b0518f699d05e67b8db70ff1b2602a2b36f3e Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 15:00:44 -0700 Subject: [PATCH 07/35] Reuse executor setup in cancellation tests --- loadgen/generic_executor_test.go | 62 +++++++++++++------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index f634bc12..8589b069 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -37,6 +37,10 @@ func (i *iterationTracker) assertSeen(t *testing.T, iterations int) { } func execute(executor *GenericExecutor, runConfig RunConfiguration) error { + return executeContext(context.Background(), executor, runConfig) +} + +func executeContext(ctx context.Context, executor *GenericExecutor, runConfig RunConfiguration) error { logger := zap.Must(zap.NewDevelopment()) defer logger.Sync() info := ScenarioInfo{ @@ -44,7 +48,7 @@ func execute(executor *GenericExecutor, runConfig RunConfiguration) error { Logger: logger.Sugar(), Configuration: runConfig, } - return executor.Run(context.Background(), info) + return executor.Run(ctx, info) } func TestRunHappyPathIterations(t *testing.T) { @@ -322,24 +326,16 @@ func TestRunCanceledWithNonCancellationErrorIsReportedAsFailure(t *testing.T) { defer cancel() failureReported := make(chan struct{}, 1) - executor := &GenericExecutor{ + err := executeContext(ctx, &GenericExecutor{ Execute: func(ctx context.Context, run *Run) error { cancel() return errors.New("deliberate fail from test") }, - } - - logger := zap.Must(zap.NewDevelopment()) - defer logger.Sync() - err := executor.Run(ctx, ScenarioInfo{ - MetricsHandler: client.MetricsNopHandler, - Logger: logger.Sugar(), - Configuration: RunConfiguration{ - Iterations: 1, - ContinueOnIterationFailure: true, - OnIterationFailure: func(ctx context.Context, run *Run, err error) { - failureReported <- struct{}{} - }, + }, RunConfiguration{ + Iterations: 1, + ContinueOnIterationFailure: true, + OnIterationFailure: func(ctx context.Context, run *Run, err error) { + failureReported <- struct{}{} }, }) require.Error(t, err) @@ -368,7 +364,7 @@ func TestRunStoppedIterationsAreNotCountedAsFailures(t *testing.T) { defer cancel() var inFlight int - executor := &GenericExecutor{ + err := executeContext(ctx, &GenericExecutor{ Execute: func(ctx context.Context, run *Run) error { mu.Lock() inFlight++ @@ -383,27 +379,19 @@ func TestRunStoppedIterationsAreNotCountedAsFailures(t *testing.T) { <-ctx.Done() return ctx.Err() }, - } - - logger := zap.Must(zap.NewDevelopment()) - defer logger.Sync() - err := executor.Run(ctx, ScenarioInfo{ - MetricsHandler: client.MetricsNopHandler, - Logger: logger.Sugar(), - Configuration: RunConfiguration{ - Iterations: 100, - MaxConcurrent: concurrent, - ContinueOnIterationFailure: true, - OnCompletion: func(ctx context.Context, run *Run) { - mu.Lock() - defer mu.Unlock() - completed = append(completed, run.Iteration) - }, - OnIterationFailure: func(ctx context.Context, run *Run, err error) { - mu.Lock() - defer mu.Unlock() - failed = append(failed, run.Iteration) - }, + }, RunConfiguration{ + Iterations: 100, + MaxConcurrent: concurrent, + ContinueOnIterationFailure: true, + OnCompletion: func(ctx context.Context, run *Run) { + mu.Lock() + defer mu.Unlock() + completed = append(completed, run.Iteration) + }, + OnIterationFailure: func(ctx context.Context, run *Run, err error) { + mu.Lock() + defer mu.Unlock() + failed = append(failed, run.Iteration) }, }) From 66323cbc4464517080ea025a5395e6eee1ed4b96 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 15:05:16 -0700 Subject: [PATCH 08/35] Update generic_executor_test.go --- loadgen/generic_executor_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index 8589b069..c2d1bc4e 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -88,7 +88,7 @@ func TestRunWaitsForOnCompletion(t *testing.T) { }() <-callbackStarted - // Let every goroutine reach a durable blocking point before checking whether Run returned. + synctest.Wait() returned := false @@ -341,6 +341,7 @@ func TestRunCanceledWithNonCancellationErrorIsReportedAsFailure(t *testing.T) { require.Error(t, err) synctest.Wait() + reported := false select { case <-failureReported: From 64302efe9e412b8faced2e21d3295a430969cb89 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 15:07:46 -0700 Subject: [PATCH 09/35] Clarify cancellation test name --- loadgen/generic_executor_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index c2d1bc4e..5f5ddd07 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -320,7 +320,7 @@ func TestRunContinueOnIterationFailure(t *testing.T) { }) } -func TestRunCanceledWithNonCancellationErrorIsReportedAsFailure(t *testing.T) { +func TestRunReportsNonCancellationFailureAfterCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() From 3aefefa48da84cc9930d1537ab8f5ad995aa95b8 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 15:10:01 -0700 Subject: [PATCH 10/35] Update generic_executor.go --- loadgen/generic_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 14c0d6e4..6a641596 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -170,7 +170,7 @@ func (g *genericRun) Run(ctx context.Context) error { } } - // Notify the waiter after callbacks finish so method cannot return before they update state. + // Notify the waiter after callbacks finish so Run cannot return before they do. select { case <-ctx.Done(): case doneCh <- err: From 165a7b3d945c77257deaa86159bfca394d98a579 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Fri, 4 Sep 2026 18:54:04 -0700 Subject: [PATCH 11/35] Exercise Nexus workflow messaging in throughput stress --- docs/throughput-stress.md | 27 ++++--- scenarios/throughput_stress.go | 89 ++++++++++++++++++++++ scenarios/throughput_stress_test.go | 110 ++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index 33e32cb8..7c3080b7 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -90,15 +90,24 @@ is any. Asking for `include-standalone-nexus=true` while Nexus is off is a contradiction, and fails the run. -## Nexus operation with a standalone activity +## Nexus operation actions -`include-nexus-standalone-activity` adds a standalone activity backed Nexus operation. -It is driven two ways each iteration: As an in-workflow Nexus operation, and — when standalone Nexus is part of the run — -as a standalone Nexus operation. +The following opt-in options exercise actions from the Nexus handler: -This is **opt-in and off by default**; pass `--option include-nexus-standalone-activity=true`. -It requires `nexus-enabled` and also needs server support for standalone activities and activity -completion callbacks (dynamic config `activity.enableStandalone` and `activity.enableCallbacks`) and a -Nexus callback URL; if those are off the operation fails clearly rather than being skipped. +- `include-nexus-standalone-activity` +- `include-nexus-signal` +- `include-nexus-signal-with-start` +- `include-nexus-update` -Currently only supported and run by Go workers. +The standalone activity action is driven two ways each iteration: as an in-workflow Nexus operation +and, when standalone Nexus is part of the run, as a standalone Nexus operation. It also needs server +support for standalone activities and activity completion callbacks (dynamic config +`activity.enableStandalone` and `activity.enableCallbacks`) and a Nexus callback URL; if those are +off, the operation fails clearly rather than being skipped. + +The workflow actions use one target kitchenSink workflow per iteration for all selected actions. +With signal-with-start enabled, exactly one signal-with-start request is made: it either creates the +target or messages a target created by a regular Nexus workflow start. + +All four options are off by default, require `nexus-enabled`, and are currently supported by Go +workers. diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index fbff4ffd..ab7e409e 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -67,6 +67,15 @@ const ( // Opt-in and off by default (only the Go worker implements the operation); requires Nexus load // (nexus-enabled) and server support for standalone activities + activity completion callbacks. IncludeNexusStandaloneActivityFlag = "include-nexus-standalone-activity" + // IncludeNexusSignalFlag enables a Nexus operation that signals a workflow. + // Opt-in and off by default; requires Nexus load (nexus-enabled). + IncludeNexusSignalFlag = "include-nexus-signal" + // IncludeNexusSignalWithStartFlag enables a Nexus operation that signals a workflow, + // starting it first if needed. Opt-in and off by default; requires Nexus load (nexus-enabled). + IncludeNexusSignalWithStartFlag = "include-nexus-signal-with-start" + // IncludeNexusUpdateFlag enables a Nexus operation that updates a workflow. + // Opt-in and off by default; requires Nexus load (nexus-enabled). + IncludeNexusUpdateFlag = "include-nexus-update" // PayloadDistributionJsonFlag is a JSON string (or @file) configuring a weighted // activity payload-size distribution. See loadgen.PayloadConfig for details. PayloadDistributionJsonFlag = "payload-distribution-json" @@ -109,6 +118,9 @@ type tpsConfig struct { IncludeStandaloneActivity bool IncludeStandaloneActivityOperatorCommands bool IncludeNexusStandaloneActivity bool + IncludeNexusSignal bool + IncludeNexusSignalWithStart bool + IncludeNexusUpdate bool Payload *loadgen.PayloadConfig } @@ -148,6 +160,9 @@ func init() { return c.Namespace.GetStandaloneActivityOperatorCommands() }) o.Bool(IncludeNexusStandaloneActivityFlag, false, "Include a Nexus operation that starts a standalone activity (Go worker only).") + o.Bool(IncludeNexusSignalFlag, false, "Include a Nexus operation that signals a workflow (Go worker only).") + o.Bool(IncludeNexusSignalWithStartFlag, false, "Include a Nexus operation that signals a workflow, starting it if needed (Go worker only).") + o.Bool(IncludeNexusUpdateFlag, false, "Include a Nexus operation that updates a workflow (Go worker only).") o.String(PayloadDistributionJsonFlag, "", "JSON payload-size distribution; use @ to read from a file.") }, ExecutorFn: func() loadgen.Executor { return newThroughputStressExecutor() }, @@ -248,6 +263,19 @@ func (t *tpsExecutor) Configure(info loadgen.ScenarioInfo) error { if config.IncludeNexusStandaloneActivity && !config.NexusEnabled { return fmt.Errorf("%s requires %s", IncludeNexusStandaloneActivityFlag, NexusEnabledFlag) } + for _, nexusWorkflowAction := range []struct { + option string + enabled *bool + }{ + {IncludeNexusSignalFlag, &config.IncludeNexusSignal}, + {IncludeNexusSignalWithStartFlag, &config.IncludeNexusSignalWithStart}, + {IncludeNexusUpdateFlag, &config.IncludeNexusUpdate}, + } { + *nexusWorkflowAction.enabled = info.OptionBool(nexusWorkflowAction.option) + if *nexusWorkflowAction.enabled && !config.NexusEnabled { + return fmt.Errorf("%s requires %s", nexusWorkflowAction.option, NexusEnabledFlag) + } + } if payloadStr := info.OptionString(PayloadDistributionJsonFlag); payloadStr != "" { config.Payload, err = loadgen.ParseAndValidatePayloadConfig(payloadStr) @@ -287,6 +315,9 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error // Standalone operations are part of Nexus load, so they go with it. t.config.IncludeStandaloneNexus = false t.config.IncludeNexusStandaloneActivity = false + t.config.IncludeNexusSignal = false + t.config.IncludeNexusSignalWithStart = false + t.config.IncludeNexusUpdate = false } else { info.Logger.Infof("Using nexus endpoint %q", nexus.Endpoint) } @@ -521,6 +552,9 @@ func (t *tpsExecutor) createActionsChunk( // Create actions for the current chunk for i := 0; i < itersPerChunk; i++ { + nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", + run.DefaultStartWorkflowOptions().ID, + t.internalIterationIndex(run, remainingInternalIters, i)) syncActions := []*Action{ PayloadActivity(t.samplePayloadSize(rng), t.samplePayloadSize(rng), DefaultLocalActivity), PayloadActivity(0, t.samplePayloadSize(rng), DefaultLocalActivity), @@ -616,6 +650,9 @@ func (t *tpsExecutor) createActionsChunk( ) } } + if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { + asyncActions = append(asyncActions, t.createNexusWorkflowTargetSequence(nexusWorkflowID, rng)) + } } // Add standalone activities, if configured. @@ -932,6 +969,58 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { }) } +func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng *rand.Rand) *Action { + var startAction *Action + var targetActions []*Action + if t.config.IncludeNexusSignalWithStart && rng.Intn(2) == 0 { + startAction = t.createNexusSignalWithStartAction(workflowID) + } else if t.config.IncludeNexusSignalWithStart { + targetActions = append(targetActions, t.createNexusSignalWithStartAction(workflowID)) + } + if t.config.IncludeNexusSignal { + targetActions = append(targetActions, t.createNexusSignalAction(workflowID)) + } + if t.config.IncludeNexusUpdate { + targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) + } + return NewNexusWorkflowTargetSequence(t.config.NexusEndpoint, workflowID, startAction, targetActions...) +} + +func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { + return NewNexusOperationAction( + t.config.NexusEndpoint, + NexusSignalWorkflowRequest(workflowID, "", &DoSignal{}, nil), + ConvertToPayload(workflowID), + WaitFinishChoice(), + ) +} + +func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Action { + return NewNexusOperationAction( + t.config.NexusEndpoint, + NexusSignalWorkflowRequest(workflowID, "", &DoSignal{WithStart: true}, &NexusWorkflowStartOptions{ + WorkflowInput: &WorkflowInput{}, + }), + ConvertToPayload(workflowID), + WaitFinishChoice(), + ) +} + +func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { + return NewNexusOperationAction( + t.config.NexusEndpoint, + NexusUpdateWorkflowRequest(workflowID, "", &DoUpdate{ + Variant: &DoUpdate_DoActions{DoActions: &DoActionsUpdate{ + Variant: &DoActionsUpdate_DoActions{DoActions: SingleActionSet( + NewNexusUpdateResultAction(workflowID), + )}, + }}, + }), + ConvertToPayload(workflowID), + WaitFinishChoice(), + ) +} + func (t *tpsExecutor) createStandaloneNexusOperationAction(input *NexusOperationRequest) *Action { return ClientActivity(ClientActions(&ClientAction{ Variant: &ClientAction_DoStandaloneNexusOperation{ diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index a7f0b0c7..45091a5d 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -3,6 +3,7 @@ package scenarios import ( "fmt" "math/rand" + "strings" "testing" "time" @@ -249,6 +250,115 @@ func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { require.NotNil(t, actions[2].GetAwaitPendingActions()) } +func TestThroughputStressNexusWorkflowTargetSequence(t *testing.T) { + t.Parallel() + + executor := newThroughputStressExecutor() + require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ + RunID: "nexus-target-sequence", + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + NexusEnabledFlag: "true", + NexusEndpointFlag: "test-endpoint", + IncludeNexusSignalFlag: "true", + IncludeNexusSignalWithStartFlag: "true", + IncludeNexusUpdateFlag: "true", + }), + })) + + const workflowID = "nexus-target" + seenSignalWithStartCreator := map[bool]bool{} + for seed := int64(0); seed < 100; seed++ { + actions := executor.createNexusWorkflowTargetSequence( + workflowID, rand.New(rand.NewSource(seed))).GetNestedActionSet().GetActions() + + startedWithSignalWithStart := actions[0].GetNexusOperation().GetInput(). + GetWorkflowAction().GetSignal().GetWithStart() + seenSignalWithStartCreator[startedWithSignalWithStart] = true + + var starts, signals, signalWithStarts, updates int + for _, action := range actions { + operation := action.GetNexusOperation() + if operation == nil { + continue + } + require.Equal(t, "test-endpoint", operation.GetEndpoint()) + require.Equal(t, ks.KitchenSinkNexusOperationName, operation.GetOperation()) + workflowAction := operation.GetInput().GetWorkflowAction() + require.Equal(t, workflowID, workflowAction.GetWorkflowId()) + switch { + case workflowAction.GetStart() != nil: + starts++ + case workflowAction.GetSignal() != nil: + signals++ + if workflowAction.GetSignal().GetWithStart() { + signalWithStarts++ + } + case workflowAction.GetUpdate() != nil: + updates++ + } + } + + require.Equal(t, 2, signals) + require.Equal(t, 1, signalWithStarts) + require.Equal(t, 1, updates) + if startedWithSignalWithStart { + require.Zero(t, starts) + } else { + require.Equal(t, 1, starts) + } + } + require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) +} + +func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossChunks(t *testing.T) { + t.Parallel() + + executor := newThroughputStressExecutor() + require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ + RunID: "nexus-target-ids", + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "3", + ContinueAsNewAfterIterFlag: "2", + NexusEnabledFlag: "true", + NexusEndpointFlag: "test-endpoint", + IncludeNexusSignalFlag: "true", + IncludeNexusSignalWithStartFlag: "true", + IncludeNexusUpdateFlag: "true", + }), + })) + run := (&loadgen.ScenarioInfo{ + RunID: "nexus-target-ids", + Logger: zap.NewNop().Sugar(), + }).NewRun(1) + + workflowIDPrefix := run.DefaultStartWorkflowOptions().ID + "-nexus-target-" + workflowIDs := map[string]bool{} + collectWorkflowIDs := func(actions []*ks.Action) { + var walk func([]*ks.Action) + walk = func(actions []*ks.Action) { + for _, action := range actions { + operation := action.GetNexusOperation() + if workflowAction := operation.GetInput().GetWorkflowAction(); operation.GetOperation() == ks.KitchenSinkNexusOperationName && + strings.HasPrefix(workflowAction.GetWorkflowId(), workflowIDPrefix) { + workflowIDs[workflowAction.GetWorkflowId()] = true + } + if nested := action.GetNestedActionSet(); nested != nil { + walk(nested.GetActions()) + } + } + } + walk(actions) + } + collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 3)) + collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(2)), 0, 1, 1)) + + require.Equal(t, map[string]bool{ + workflowIDPrefix + "0": true, + workflowIDPrefix + "1": true, + workflowIDPrefix + "2": true, + }, workflowIDs) +} + func TestThroughputStressConfigurePayload(t *testing.T) { t.Parallel() From 69b6fe8c95519c1d54677479c6c3cb690c1fb9ac Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Mon, 7 Sep 2026 14:35:44 -0700 Subject: [PATCH 12/35] Address Nexus throughput review feedback --- scenarios/throughput_stress.go | 23 +++++---- scenarios/throughput_stress_test.go | 72 ++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index ab7e409e..472247d5 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -263,16 +263,18 @@ func (t *tpsExecutor) Configure(info loadgen.ScenarioInfo) error { if config.IncludeNexusStandaloneActivity && !config.NexusEnabled { return fmt.Errorf("%s requires %s", IncludeNexusStandaloneActivityFlag, NexusEnabledFlag) } + config.IncludeNexusSignal = info.OptionBool(IncludeNexusSignalFlag) + config.IncludeNexusSignalWithStart = info.OptionBool(IncludeNexusSignalWithStartFlag) + config.IncludeNexusUpdate = info.OptionBool(IncludeNexusUpdateFlag) for _, nexusWorkflowAction := range []struct { option string - enabled *bool + enabled bool }{ - {IncludeNexusSignalFlag, &config.IncludeNexusSignal}, - {IncludeNexusSignalWithStartFlag, &config.IncludeNexusSignalWithStart}, - {IncludeNexusUpdateFlag, &config.IncludeNexusUpdate}, + {IncludeNexusSignalFlag, config.IncludeNexusSignal}, + {IncludeNexusSignalWithStartFlag, config.IncludeNexusSignalWithStart}, + {IncludeNexusUpdateFlag, config.IncludeNexusUpdate}, } { - *nexusWorkflowAction.enabled = info.OptionBool(nexusWorkflowAction.option) - if *nexusWorkflowAction.enabled && !config.NexusEnabled { + if nexusWorkflowAction.enabled && !config.NexusEnabled { return fmt.Errorf("%s requires %s", nexusWorkflowAction.option, NexusEnabledFlag) } } @@ -552,9 +554,6 @@ func (t *tpsExecutor) createActionsChunk( // Create actions for the current chunk for i := 0; i < itersPerChunk; i++ { - nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", - run.DefaultStartWorkflowOptions().ID, - t.internalIterationIndex(run, remainingInternalIters, i)) syncActions := []*Action{ PayloadActivity(t.samplePayloadSize(rng), t.samplePayloadSize(rng), DefaultLocalActivity), PayloadActivity(0, t.samplePayloadSize(rng), DefaultLocalActivity), @@ -651,7 +650,11 @@ func (t *tpsExecutor) createActionsChunk( } } if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { - asyncActions = append(asyncActions, t.createNexusWorkflowTargetSequence(nexusWorkflowID, rng)) + nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d-%s", + run.DefaultStartWorkflowOptions().ID, + t.internalIterationIndex(run, remainingInternalIters, i), + uuid.NewString()) + syncActions = append(syncActions, t.createNexusWorkflowTargetSequence(nexusWorkflowID, rng)) } } diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 45091a5d..d047e517 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -308,9 +308,28 @@ func TestThroughputStressNexusWorkflowTargetSequence(t *testing.T) { } } require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) + + run := (&loadgen.ScenarioInfo{ + RunID: "nexus-target-sequence", + Logger: zap.NewNop().Sugar(), + }).NewRun(1) + var concurrentPendingActions int + var countPendingActions func([]*ks.Action, bool) + countPendingActions = func(actions []*ks.Action, concurrent bool) { + for _, action := range actions { + if action.GetAwaitPendingActions() != nil && concurrent { + concurrentPendingActions++ + } + if nested := action.GetNestedActionSet(); nested != nil { + countPendingActions(nested.GetActions(), concurrent || nested.GetConcurrent()) + } + } + } + countPendingActions(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 1), false) + require.Equal(t, 1, concurrentPendingActions) } -func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossChunks(t *testing.T) { +func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossAttemptsAndChunks(t *testing.T) { t.Parallel() executor := newThroughputStressExecutor() @@ -350,13 +369,10 @@ func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossChunks(t *testing. walk(actions) } collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 3)) + collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 3)) collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(2)), 0, 1, 1)) - require.Equal(t, map[string]bool{ - workflowIDPrefix + "0": true, - workflowIDPrefix + "1": true, - workflowIDPrefix + "2": true, - }, workflowIDs) + require.Len(t, workflowIDs, 5) } func TestThroughputStressConfigurePayload(t *testing.T) { @@ -471,6 +487,50 @@ func TestThroughputStressConfigureExplicitStandaloneNexusRequiresNexusEnabled(t require.Contains(t, err.Error(), NexusEnabledFlag) } +func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + flag string + enabled [3]bool + }{ + {name: "signal", flag: IncludeNexusSignalFlag, enabled: [3]bool{true, false, false}}, + {name: "signal with start", flag: IncludeNexusSignalWithStartFlag, enabled: [3]bool{false, true, false}}, + {name: "update", flag: IncludeNexusUpdateFlag, enabled: [3]bool{false, false, true}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := newThroughputStressExecutor().Configure(loadgen.ScenarioInfo{ + RunID: "tps-nexus-workflow-action", + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + tc.flag: "true", + NexusEnabledFlag: "false", + }), + }) + + require.Error(t, err) + require.Contains(t, err.Error(), tc.flag) + require.Contains(t, err.Error(), NexusEnabledFlag) + + executor := newThroughputStressExecutor() + require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ + RunID: "tps-nexus-workflow-action", + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + tc.flag: "true", + NexusEnabledFlag: "true", + }), + })) + require.Equal(t, tc.enabled, [3]bool{ + executor.config.IncludeNexusSignal, + executor.config.IncludeNexusSignalWithStart, + executor.config.IncludeNexusUpdate, + }) + }) + } +} + func TestThroughputStressConfigureInvalidPayload(t *testing.T) { t.Parallel() From 45ae3a7cc0da71139075554224d2df7edd4f7b7f Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Mon, 7 Sep 2026 18:51:35 -0700 Subject: [PATCH 13/35] Simplify Nexus workflow action configuration test --- scenarios/throughput_stress_test.go | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index d047e517..32a7123b 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -491,13 +491,12 @@ func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *tes t.Parallel() for _, tc := range []struct { - name string - flag string - enabled [3]bool + name string + flag string }{ - {name: "signal", flag: IncludeNexusSignalFlag, enabled: [3]bool{true, false, false}}, - {name: "signal with start", flag: IncludeNexusSignalWithStartFlag, enabled: [3]bool{false, true, false}}, - {name: "update", flag: IncludeNexusUpdateFlag, enabled: [3]bool{false, false, true}}, + {name: "signal", flag: IncludeNexusSignalFlag}, + {name: "signal with start", flag: IncludeNexusSignalWithStartFlag}, + {name: "update", flag: IncludeNexusUpdateFlag}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -513,20 +512,6 @@ func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *tes require.Error(t, err) require.Contains(t, err.Error(), tc.flag) require.Contains(t, err.Error(), NexusEnabledFlag) - - executor := newThroughputStressExecutor() - require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ - RunID: "tps-nexus-workflow-action", - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - tc.flag: "true", - NexusEnabledFlag: "true", - }), - })) - require.Equal(t, tc.enabled, [3]bool{ - executor.config.IncludeNexusSignal, - executor.config.IncludeNexusSignalWithStart, - executor.config.IncludeNexusUpdate, - }) }) } } From cf1700cffc5c0acdd267f9bf3be04a4d44e6d661 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Mon, 7 Sep 2026 19:17:19 -0700 Subject: [PATCH 14/35] Address remaining Nexus throughput review feedback --- docs/throughput-stress.md | 7 -- scenarios/throughput_stress.go | 29 +++++- scenarios/throughput_stress_test.go | 134 +++++++++++++++++----------- 3 files changed, 105 insertions(+), 65 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index 7c3080b7..9f88d2b5 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -104,10 +104,3 @@ and, when standalone Nexus is part of the run, as a standalone Nexus operation. support for standalone activities and activity completion callbacks (dynamic config `activity.enableStandalone` and `activity.enableCallbacks`) and a Nexus callback URL; if those are off, the operation fails clearly rather than being skipped. - -The workflow actions use one target kitchenSink workflow per iteration for all selected actions. -With signal-with-start enabled, exactly one signal-with-start request is made: it either creates the -target or messages a target created by a regular Nexus workflow start. - -All four options are off by default, require `nexus-enabled`, and are currently supported by Go -workers. diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 472247d5..b5f4ec5f 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -650,10 +650,9 @@ func (t *tpsExecutor) createActionsChunk( } } if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { - nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d-%s", + nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, - t.internalIterationIndex(run, remainingInternalIters, i), - uuid.NewString()) + t.internalIterationIndex(run, remainingInternalIters, i)) syncActions = append(syncActions, t.createNexusWorkflowTargetSequence(nexusWorkflowID, rng)) } } @@ -986,7 +985,29 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * if t.config.IncludeNexusUpdate { targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) } - return NewNexusWorkflowTargetSequence(t.config.NexusEndpoint, workflowID, startAction, targetActions...) + if startAction == nil { + startAction = NewNexusOperationAction(t.config.NexusEndpoint, + &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: workflowID, + StartOptions: &NexusWorkflowStartOptions{ + WorkflowInput: &WorkflowInput{InitialActions: ListActionSet( + NewAwaitWorkflowStateAction("status", "done"), + NewEmptyReturnResultAction(), + )}, + }, + Action: &NexusWorkflowAction_Start{Start: &emptypb.Empty{}}, + }}, + }, + nil, + &AwaitableChoice{Condition: &AwaitableChoice_WaitStarted{WaitStarted: &emptypb.Empty{}}}, + ) + } + actions := append([]*Action{startAction}, targetActions...) + actions = append(actions, &Action{ + Variant: &Action_AwaitPendingActions{AwaitPendingActions: &AwaitPendingActions{}}, + }) + return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: actions}}} } func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 32a7123b..b7254219 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -250,33 +250,58 @@ func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { require.NotNil(t, actions[2].GetAwaitPendingActions()) } -func TestThroughputStressNexusWorkflowTargetSequence(t *testing.T) { +func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Parallel() - executor := newThroughputStressExecutor() - require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ - RunID: "nexus-target-sequence", - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - NexusEnabledFlag: "true", - NexusEndpointFlag: "test-endpoint", - IncludeNexusSignalFlag: "true", - IncludeNexusSignalWithStartFlag: "true", - IncludeNexusUpdateFlag: "true", - }), - })) + exec := newThroughputStressExecutor() + exec.config = &tpsConfig{ + InternalIterations: 1, + NexusEnabled: true, + NexusEndpoint: "test-endpoint", + IncludeNexusSignal: true, + IncludeNexusSignalWithStart: true, + IncludeNexusUpdate: true, + SleepTime: time.Millisecond, + RngSeed: 1, + } + exec.rng = rand.New(rand.NewSource(1)) + info := &loadgen.ScenarioInfo{ + RunID: "nexus-workflow-actions", + ExecutionID: "exec", + Logger: zap.NewNop().Sugar(), + } - const workflowID = "nexus-target" seenSignalWithStartCreator := map[bool]bool{} - for seed := int64(0); seed < 100; seed++ { - actions := executor.createNexusWorkflowTargetSequence( - workflowID, rand.New(rand.NewSource(seed))).GetNestedActionSet().GetActions() + for iteration := 1; iteration <= 100; iteration++ { + run := info.NewRun(iteration) + workflowID := fmt.Sprintf("%s-nexus-target-%d", + run.DefaultStartWorkflowOptions().ID, iteration-1) + var targetActions []*ks.Action + var walk func([]*ks.Action) + walk = func(actions []*ks.Action) { + for _, action := range actions { + if nested := action.GetNestedActionSet(); nested != nil { + for _, nestedAction := range nested.GetActions() { + if nestedAction.GetNexusOperation().GetInput().GetWorkflowAction().GetWorkflowId() == workflowID { + targetActions = nested.GetActions() + break + } + } + walk(nested.GetActions()) + } + } + } + for _, actionSet := range exec.createActions(run) { + walk(actionSet.GetActions()) + } - startedWithSignalWithStart := actions[0].GetNexusOperation().GetInput(). + require.NotEmpty(t, targetActions) + startedWithSignalWithStart := targetActions[0].GetNexusOperation().GetInput(). GetWorkflowAction().GetSignal().GetWithStart() seenSignalWithStartCreator[startedWithSignalWithStart] = true var starts, signals, signalWithStarts, updates int - for _, action := range actions { + for _, action := range targetActions { operation := action.GetNexusOperation() if operation == nil { continue @@ -306,30 +331,12 @@ func TestThroughputStressNexusWorkflowTargetSequence(t *testing.T) { } else { require.Equal(t, 1, starts) } + require.NotNil(t, targetActions[len(targetActions)-1].GetAwaitPendingActions()) } require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) - - run := (&loadgen.ScenarioInfo{ - RunID: "nexus-target-sequence", - Logger: zap.NewNop().Sugar(), - }).NewRun(1) - var concurrentPendingActions int - var countPendingActions func([]*ks.Action, bool) - countPendingActions = func(actions []*ks.Action, concurrent bool) { - for _, action := range actions { - if action.GetAwaitPendingActions() != nil && concurrent { - concurrentPendingActions++ - } - if nested := action.GetNestedActionSet(); nested != nil { - countPendingActions(nested.GetActions(), concurrent || nested.GetConcurrent()) - } - } - } - countPendingActions(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 1), false) - require.Equal(t, 1, concurrentPendingActions) } -func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossAttemptsAndChunks(t *testing.T) { +func TestThroughputStressNexusWorkflowTargetIDsAreStable(t *testing.T) { t.Parallel() executor := newThroughputStressExecutor() @@ -337,7 +344,6 @@ func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossAttemptsAndChunks( RunID: "nexus-target-ids", Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ IterFlag: "3", - ContinueAsNewAfterIterFlag: "2", NexusEnabledFlag: "true", NexusEndpointFlag: "test-endpoint", IncludeNexusSignalFlag: "true", @@ -345,34 +351,54 @@ func TestThroughputStressNexusWorkflowTargetsAreDistinctAcrossAttemptsAndChunks( IncludeNexusUpdateFlag: "true", }), })) - run := (&loadgen.ScenarioInfo{ - RunID: "nexus-target-ids", - Logger: zap.NewNop().Sugar(), - }).NewRun(1) - - workflowIDPrefix := run.DefaultStartWorkflowOptions().ID + "-nexus-target-" - workflowIDs := map[string]bool{} - collectWorkflowIDs := func(actions []*ks.Action) { + info := &loadgen.ScenarioInfo{ + RunID: "nexus-target-ids", + ExecutionID: "exec", + Logger: zap.NewNop().Sugar(), + } + collectWorkflowIDs := func(actionSets []*ks.ActionSet, workflowIDPrefix string) []string { + var workflowIDs []string + seen := map[string]bool{} var walk func([]*ks.Action) walk = func(actions []*ks.Action) { for _, action := range actions { operation := action.GetNexusOperation() if workflowAction := operation.GetInput().GetWorkflowAction(); operation.GetOperation() == ks.KitchenSinkNexusOperationName && - strings.HasPrefix(workflowAction.GetWorkflowId(), workflowIDPrefix) { - workflowIDs[workflowAction.GetWorkflowId()] = true + strings.HasPrefix(workflowAction.GetWorkflowId(), workflowIDPrefix) && + !seen[workflowAction.GetWorkflowId()] { + seen[workflowAction.GetWorkflowId()] = true + workflowIDs = append(workflowIDs, workflowAction.GetWorkflowId()) } if nested := action.GetNestedActionSet(); nested != nil { walk(nested.GetActions()) } } } - walk(actions) + for _, actionSet := range actionSets { + walk(actionSet.GetActions()) + } + return workflowIDs } - collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 3)) - collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(1)), 0, 0, 3)) - collectWorkflowIDs(executor.createActionsChunk(run, rand.New(rand.NewSource(2)), 0, 1, 1)) - require.Len(t, workflowIDs, 5) + for iteration, expectedWorkflowIDs := range [][]string{ + { + "w-nexus-target-ids-exec-1-nexus-target-0", + "w-nexus-target-ids-exec-1-nexus-target-1", + "w-nexus-target-ids-exec-1-nexus-target-2", + }, + { + "w-nexus-target-ids-exec-2-nexus-target-3", + "w-nexus-target-ids-exec-2-nexus-target-4", + "w-nexus-target-ids-exec-2-nexus-target-5", + }, + } { + run := info.NewRun(iteration + 1) + workflowIDPrefix := run.DefaultStartWorkflowOptions().ID + "-nexus-target-" + require.Equal(t, expectedWorkflowIDs, + collectWorkflowIDs(executor.createActions(run), workflowIDPrefix)) + require.Equal(t, expectedWorkflowIDs, + collectWorkflowIDs(executor.createActions(run), workflowIDPrefix)) + } } func TestThroughputStressConfigurePayload(t *testing.T) { From f8575796c2d4a92ecf5e91442f01be6d0d58cfda Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Mon, 7 Sep 2026 19:51:54 -0700 Subject: [PATCH 15/35] Inline Nexus throughput action setup --- scenarios/throughput_stress.go | 82 +++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index b5f4ec5f..ac518933 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -986,8 +986,10 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) } if startAction == nil { - startAction = NewNexusOperationAction(t.config.NexusEndpoint, - &NexusOperationRequest{ + startAction = &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Operation: KitchenSinkNexusOperationName, + Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, StartOptions: &NexusWorkflowStartOptions{ @@ -999,9 +1001,8 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * Action: &NexusWorkflowAction_Start{Start: &emptypb.Empty{}}, }}, }, - nil, - &AwaitableChoice{Condition: &AwaitableChoice_WaitStarted{WaitStarted: &emptypb.Empty{}}}, - ) + AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitStarted{WaitStarted: &emptypb.Empty{}}}, + }}} } actions := append([]*Action{startAction}, targetActions...) actions = append(actions, &Action{ @@ -1011,38 +1012,59 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * } func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { - return NewNexusOperationAction( - t.config.NexusEndpoint, - NexusSignalWorkflowRequest(workflowID, "", &DoSignal{}, nil), - ConvertToPayload(workflowID), - WaitFinishChoice(), - ) + return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Operation: KitchenSinkNexusOperationName, + ExpectedOutput: ConvertToPayload(workflowID), + AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + Input: &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: workflowID, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{}}, + }}, + }, + }}} } func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Action { - return NewNexusOperationAction( - t.config.NexusEndpoint, - NexusSignalWorkflowRequest(workflowID, "", &DoSignal{WithStart: true}, &NexusWorkflowStartOptions{ - WorkflowInput: &WorkflowInput{}, - }), - ConvertToPayload(workflowID), - WaitFinishChoice(), - ) + return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Operation: KitchenSinkNexusOperationName, + ExpectedOutput: ConvertToPayload(workflowID), + AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + Input: &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: workflowID, + StartOptions: &NexusWorkflowStartOptions{WorkflowInput: &WorkflowInput{}}, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{WithStart: true}}, + }}, + }, + }}} } func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { - return NewNexusOperationAction( - t.config.NexusEndpoint, - NexusUpdateWorkflowRequest(workflowID, "", &DoUpdate{ - Variant: &DoUpdate_DoActions{DoActions: &DoActionsUpdate{ - Variant: &DoActionsUpdate_DoActions{DoActions: SingleActionSet( - NewNexusUpdateResultAction(workflowID), - )}, + return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Operation: KitchenSinkNexusOperationName, + ExpectedOutput: ConvertToPayload(workflowID), + AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + Input: &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: workflowID, + Action: &NexusWorkflowAction_Update{Update: &DoUpdate{ + Variant: &DoUpdate_DoActions{DoActions: &DoActionsUpdate{ + Variant: &DoActionsUpdate_DoActions{DoActions: SingleActionSet( + // The update handler's return value is itself encoded by the data converter + // before the server forwards it to the Nexus completion callback, so the value + // is wrapped twice here: the caller decodes the outer layer and compares the + // inner Payload against ExecuteNexusOperation.expected_output. + NewReturnResultAction(ConvertToPayload(ConvertToPayload(workflowID))), + )}, + }}, + }}, }}, - }), - ConvertToPayload(workflowID), - WaitFinishChoice(), - ) + }, + }}} } func (t *tpsExecutor) createStandaloneNexusOperationAction(input *NexusOperationRequest) *Action { From 0087dcad008a3cbc6a685da37301377e2dcecb46 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 08:48:53 -0700 Subject: [PATCH 16/35] Update throughput_stress.go --- scenarios/throughput_stress.go | 37 ++++++++++++++-------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index ac518933..893ecd71 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -986,9 +986,8 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) } if startAction == nil { - startAction = &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Operation: KitchenSinkNexusOperationName, + startAction = NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, @@ -1002,7 +1001,7 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * }}, }, AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitStarted{WaitStarted: &emptypb.Empty{}}}, - }}} + }) } actions := append([]*Action{startAction}, targetActions...) actions = append(actions, &Action{ @@ -1012,26 +1011,21 @@ func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng * } func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { - return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Operation: KitchenSinkNexusOperationName, - ExpectedOutput: ConvertToPayload(workflowID), - AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + return NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{}}, }}, }, - }}} + ExpectedOutput: ConvertToPayload(workflowID), + }) } func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Action { - return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Operation: KitchenSinkNexusOperationName, - ExpectedOutput: ConvertToPayload(workflowID), - AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + return NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, @@ -1039,15 +1033,13 @@ func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Actio Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{WithStart: true}}, }}, }, - }}} + ExpectedOutput: ConvertToPayload(workflowID), + }) } func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { - return &Action{Variant: &Action_NexusOperation{NexusOperation: &ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Operation: KitchenSinkNexusOperationName, - ExpectedOutput: ConvertToPayload(workflowID), - AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + return NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, @@ -1064,7 +1056,8 @@ func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { }}, }}, }, - }}} + ExpectedOutput: ConvertToPayload(workflowID), + }) } func (t *tpsExecutor) createStandaloneNexusOperationAction(input *NexusOperationRequest) *Action { From c8dcd9ee756db238c2d7e8618e7dd26756516eaa Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 10:54:59 -0700 Subject: [PATCH 17/35] Make Nexus throughput signals explicit --- scenarios/throughput_stress.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 893ecd71..00a588fc 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -1016,7 +1016,11 @@ func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, - Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{}}, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ + Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ + Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet()}, + }}, + }}, }}, }, ExpectedOutput: ConvertToPayload(workflowID), @@ -1030,7 +1034,12 @@ func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Actio Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, StartOptions: &NexusWorkflowStartOptions{WorkflowInput: &WorkflowInput{}}, - Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{WithStart: true}}, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ + Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ + Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet()}, + }}, + WithStart: true, + }}, }}, }, ExpectedOutput: ConvertToPayload(workflowID), From 730d7f7591e73d015572c7fb513119bf739a14b9 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 13:09:26 -0700 Subject: [PATCH 18/35] Simplify Nexus throughput action tests --- scenarios/throughput_stress.go | 5 ++- scenarios/throughput_stress_test.go | 66 ----------------------------- 2 files changed, 3 insertions(+), 68 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 00a588fc..95cd116c 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -653,7 +653,7 @@ func (t *tpsExecutor) createActionsChunk( nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, t.internalIterationIndex(run, remainingInternalIters, i)) - syncActions = append(syncActions, t.createNexusWorkflowTargetSequence(nexusWorkflowID, rng)) + syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID, rng)) } } @@ -971,7 +971,8 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { }) } -func (t *tpsExecutor) createNexusWorkflowTargetSequence(workflowID string, rng *rand.Rand) *Action { +// createNexusWorkflowActionSequence starts a workflow, sends the configured actions, and waits for completion. +func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng *rand.Rand) *Action { var startAction *Action var targetActions []*Action if t.config.IncludeNexusSignalWithStart && rng.Intn(2) == 0 { diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index b7254219..ea705426 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -3,7 +3,6 @@ package scenarios import ( "fmt" "math/rand" - "strings" "testing" "time" @@ -336,71 +335,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) } -func TestThroughputStressNexusWorkflowTargetIDsAreStable(t *testing.T) { - t.Parallel() - - executor := newThroughputStressExecutor() - require.NoError(t, executor.Configure(loadgen.ScenarioInfo{ - RunID: "nexus-target-ids", - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "3", - NexusEnabledFlag: "true", - NexusEndpointFlag: "test-endpoint", - IncludeNexusSignalFlag: "true", - IncludeNexusSignalWithStartFlag: "true", - IncludeNexusUpdateFlag: "true", - }), - })) - info := &loadgen.ScenarioInfo{ - RunID: "nexus-target-ids", - ExecutionID: "exec", - Logger: zap.NewNop().Sugar(), - } - collectWorkflowIDs := func(actionSets []*ks.ActionSet, workflowIDPrefix string) []string { - var workflowIDs []string - seen := map[string]bool{} - var walk func([]*ks.Action) - walk = func(actions []*ks.Action) { - for _, action := range actions { - operation := action.GetNexusOperation() - if workflowAction := operation.GetInput().GetWorkflowAction(); operation.GetOperation() == ks.KitchenSinkNexusOperationName && - strings.HasPrefix(workflowAction.GetWorkflowId(), workflowIDPrefix) && - !seen[workflowAction.GetWorkflowId()] { - seen[workflowAction.GetWorkflowId()] = true - workflowIDs = append(workflowIDs, workflowAction.GetWorkflowId()) - } - if nested := action.GetNestedActionSet(); nested != nil { - walk(nested.GetActions()) - } - } - } - for _, actionSet := range actionSets { - walk(actionSet.GetActions()) - } - return workflowIDs - } - - for iteration, expectedWorkflowIDs := range [][]string{ - { - "w-nexus-target-ids-exec-1-nexus-target-0", - "w-nexus-target-ids-exec-1-nexus-target-1", - "w-nexus-target-ids-exec-1-nexus-target-2", - }, - { - "w-nexus-target-ids-exec-2-nexus-target-3", - "w-nexus-target-ids-exec-2-nexus-target-4", - "w-nexus-target-ids-exec-2-nexus-target-5", - }, - } { - run := info.NewRun(iteration + 1) - workflowIDPrefix := run.DefaultStartWorkflowOptions().ID + "-nexus-target-" - require.Equal(t, expectedWorkflowIDs, - collectWorkflowIDs(executor.createActions(run), workflowIDPrefix)) - require.Equal(t, expectedWorkflowIDs, - collectWorkflowIDs(executor.createActions(run), workflowIDPrefix)) - } -} - func TestThroughputStressConfigurePayload(t *testing.T) { t.Parallel() From fe65d63157140c1a46dd422f2de98eaf4aafbb2c Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 13:34:01 -0700 Subject: [PATCH 19/35] Complete Nexus throughput workflow targets --- scenarios/throughput_stress.go | 53 +++++++++++++++++------------ scenarios/throughput_stress_test.go | 14 ++++++++ 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 95cd116c..eae8ed4b 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -266,17 +266,14 @@ func (t *tpsExecutor) Configure(info loadgen.ScenarioInfo) error { config.IncludeNexusSignal = info.OptionBool(IncludeNexusSignalFlag) config.IncludeNexusSignalWithStart = info.OptionBool(IncludeNexusSignalWithStartFlag) config.IncludeNexusUpdate = info.OptionBool(IncludeNexusUpdateFlag) - for _, nexusWorkflowAction := range []struct { - option string - enabled bool - }{ - {IncludeNexusSignalFlag, config.IncludeNexusSignal}, - {IncludeNexusSignalWithStartFlag, config.IncludeNexusSignalWithStart}, - {IncludeNexusUpdateFlag, config.IncludeNexusUpdate}, - } { - if nexusWorkflowAction.enabled && !config.NexusEnabled { - return fmt.Errorf("%s requires %s", nexusWorkflowAction.option, NexusEnabledFlag) - } + if config.IncludeNexusSignal && !config.NexusEnabled { + return fmt.Errorf("%s requires %s", IncludeNexusSignalFlag, NexusEnabledFlag) + } + if config.IncludeNexusSignalWithStart && !config.NexusEnabled { + return fmt.Errorf("%s requires %s", IncludeNexusSignalWithStartFlag, NexusEnabledFlag) + } + if config.IncludeNexusUpdate && !config.NexusEnabled { + return fmt.Errorf("%s requires %s", IncludeNexusUpdateFlag, NexusEnabledFlag) } if payloadStr := info.OptionString(PayloadDistributionJsonFlag); payloadStr != "" { @@ -975,10 +972,13 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng *rand.Rand) *Action { var startAction *Action var targetActions []*Action - if t.config.IncludeNexusSignalWithStart && rng.Intn(2) == 0 { - startAction = t.createNexusSignalWithStartAction(workflowID) - } else if t.config.IncludeNexusSignalWithStart { - targetActions = append(targetActions, t.createNexusSignalWithStartAction(workflowID)) + if t.config.IncludeNexusSignalWithStart { + signalWithStart := t.createNexusSignalWithStartAction(workflowID) + if rng.Intn(2) == 0 { + startAction = signalWithStart + } else { + targetActions = append(targetActions, signalWithStart) + } } if t.config.IncludeNexusSignal { targetActions = append(targetActions, t.createNexusSignalAction(workflowID)) @@ -993,10 +993,8 @@ func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng * Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, StartOptions: &NexusWorkflowStartOptions{ - WorkflowInput: &WorkflowInput{InitialActions: ListActionSet( - NewAwaitWorkflowStateAction("status", "done"), - NewEmptyReturnResultAction(), - )}, + WorkflowIdConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, + WorkflowInput: &WorkflowInput{}, }, Action: &NexusWorkflowAction_Start{Start: &emptypb.Empty{}}, }}, @@ -1005,9 +1003,20 @@ func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng * }) } actions := append([]*Action{startAction}, targetActions...) - actions = append(actions, &Action{ - Variant: &Action_AwaitPendingActions{AwaitPendingActions: &AwaitPendingActions{}}, - }) + actions = append(actions, + // Complete the target so the wait_started start operation can finish. + &Action{Variant: &Action_SendSignal{SendSignal: &SendSignalAction{ + WorkflowId: workflowID, + SignalName: "do_actions_signal", + Args: []*common.Payload{ConvertToPayload(&DoSignal_DoSignalActions{ + Variant: &DoSignal_DoSignalActions_DoActions{ + DoActions: SingleActionSet(NewEmptyReturnResultAction()), + }, + })}, + AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, + }}}, + &Action{Variant: &Action_AwaitPendingActions{AwaitPendingActions: &AwaitPendingActions{}}}, + ) return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: actions}}} } diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index ea705426..67a4053c 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -11,6 +11,7 @@ import ( "github.com/temporalio/omes/internal/workertest" "github.com/temporalio/omes/loadgen" ks "github.com/temporalio/omes/loadgen/kitchensink" + enumspb "go.temporal.io/api/enums/v1" namespacev1 "go.temporal.io/api/namespace/v1" "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/converter" @@ -312,6 +313,8 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { switch { case workflowAction.GetStart() != nil: starts++ + require.Equal(t, enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, + workflowAction.GetStartOptions().GetWorkflowIdConflictPolicy()) case workflowAction.GetSignal() != nil: signals++ if workflowAction.GetSignal().GetWithStart() { @@ -330,6 +333,17 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { } else { require.Equal(t, 1, starts) } + completeTarget := targetActions[len(targetActions)-2].GetSendSignal() + require.NotNil(t, completeTarget) + require.Equal(t, workflowID, completeTarget.GetWorkflowId()) + require.Equal(t, "do_actions_signal", completeTarget.GetSignalName()) + require.NotNil(t, completeTarget.GetAwaitableChoice().GetWaitFinish()) + require.Len(t, completeTarget.GetArgs(), 1) + var signalAction ks.DoSignal_DoSignalActions + require.NoError(t, converter.GetDefaultDataConverter().FromPayload(completeTarget.GetArgs()[0], &signalAction)) + completingActions := signalAction.GetDoActions().GetActions() + require.Len(t, completingActions, 1) + require.NotNil(t, completingActions[0].GetReturnResult()) require.NotNil(t, targetActions[len(targetActions)-1].GetAwaitPendingActions()) } require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) From dff3e8cb29d69d7b16b49b00a03b6aacfa9a6a37 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 13:55:04 -0700 Subject: [PATCH 20/35] Document Nexus throughput action ordering --- scenarios/throughput_stress.go | 1 + 1 file changed, 1 insertion(+) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index eae8ed4b..18ac3738 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -650,6 +650,7 @@ func (t *tpsExecutor) createActionsChunk( nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, t.internalIterationIndex(run, remainingInternalIters, i)) + // Keep this sequence sequential because AwaitPendingActions drains workflow-global pending actions. syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID, rng)) } } From a53002e6cecbd5e94eb6b22e4ccf7ad548077928 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 15:09:09 -0700 Subject: [PATCH 21/35] Exercise Nexus throughput workflow actions --- scenarios/throughput_stress.go | 79 ++++++++++++-------- scenarios/throughput_stress_test.go | 112 +++++++++++++++------------- 2 files changed, 106 insertions(+), 85 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 18ac3738..e2343549 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -125,12 +125,13 @@ type tpsConfig struct { } type tpsExecutor struct { - lock sync.Mutex - state *tpsState - config *tpsConfig - isResuming bool - runID string - rng *rand.Rand + lock sync.Mutex + state *tpsState + config *tpsConfig + isResuming bool + runID string + rng *rand.Rand + onActionsCreated func(*loadgen.Run, []*ActionSet) } var _ loadgen.Resumable = (*tpsExecutor)(nil) @@ -394,7 +395,11 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error // // NOTE: No client actions (e.g. Signal) are defined; however, client action activities are. // That means these client actions are sent from the activity worker instead of Omes. - options.Params.WorkflowInput.InitialActions = t.createActions(run) + actions := t.createActions(run) + options.Params.WorkflowInput.InitialActions = actions + if t.onActionsCreated != nil { + t.onActionsCreated(run, actions) + } return nil }, @@ -933,22 +938,26 @@ func (t *tpsExecutor) createNexusAttachCallbacksAction() *Action { {Variant: &Action_NestedActionSet{ NestedActionSet: &ActionSet{Concurrent: true, Actions: fanout}, }}, - {Variant: &Action_SendSignal{ - SendSignal: &SendSignalAction{ - WorkflowId: handlerWfID, - SignalName: "do_actions_signal", - Args: []*common.Payload{ConvertToPayload(&DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{ - DoActions: SingleActionSet(NewEmptyReturnResultAction()), - }, - })}, - AwaitableChoice: &AwaitableChoice{ - // The operation futures below are the correctness gate. Do not fail if an - // active/passive transition replays this after the handler has completed. - Condition: &AwaitableChoice_Abandon{Abandon: &emptypb.Empty{}}, - }, + NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Input: &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: handlerWfID, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ + Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ + Variant: &DoSignal_DoSignalActions_DoActions{ + DoActions: SingleActionSet(NewEmptyReturnResultAction()), + }, + }}, + }}, + }}, }, - }}, + AwaitableChoice: &AwaitableChoice{ + // The operation futures below are the correctness gate. Do not fail if an + // active/passive transition replays this after the handler has completed. + Condition: &AwaitableChoice_Abandon{Abandon: &emptypb.Empty{}}, + }, + }), {Variant: &Action_AwaitPendingActions{ AwaitPendingActions: &AwaitPendingActions{}, }}, @@ -1006,16 +1015,22 @@ func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng * actions := append([]*Action{startAction}, targetActions...) actions = append(actions, // Complete the target so the wait_started start operation can finish. - &Action{Variant: &Action_SendSignal{SendSignal: &SendSignalAction{ - WorkflowId: workflowID, - SignalName: "do_actions_signal", - Args: []*common.Payload{ConvertToPayload(&DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{ - DoActions: SingleActionSet(NewEmptyReturnResultAction()), - }, - })}, - AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitFinish{WaitFinish: &emptypb.Empty{}}}, - }}}, + NexusOperation(&ExecuteNexusOperation{ + Endpoint: t.config.NexusEndpoint, + Input: &NexusOperationRequest{ + Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ + WorkflowId: workflowID, + Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ + Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ + Variant: &DoSignal_DoSignalActions_DoActions{ + DoActions: SingleActionSet(NewEmptyReturnResultAction()), + }, + }}, + }}, + }}, + }, + ExpectedOutput: ConvertToPayload(workflowID), + }), &Action{Variant: &Action_AwaitPendingActions{AwaitPendingActions: &AwaitPendingActions{}}}, ) return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: actions}}} diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 67a4053c..caf53e14 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -211,9 +211,8 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { }).NewRun(0) var inWorkflow, standalone bool - var walk func(actions []*ks.Action) - walk = func(actions []*ks.Action) { - for _, a := range actions { + for _, set := range exec.createActions(run) { + walkActions(set.GetActions(), func(a *ks.Action) { if op := a.GetNexusOperation(); op.GetInput().GetStartActivity() != nil { inWorkflow = true } @@ -227,13 +226,7 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { } } } - if nested := a.GetNestedActionSet(); nested != nil { - walk(nested.GetActions()) - } - } - } - for _, set := range exec.createActions(run) { - walk(set.GetActions()) + }) } require.True(t, inWorkflow, @@ -246,40 +239,53 @@ func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { actions := (&tpsExecutor{config: &tpsConfig{NexusEndpoint: "test-endpoint"}}). createNexusAttachCallbacksAction().GetNestedActionSet().GetActions() - require.NotNil(t, actions[1].GetSendSignal().GetAwaitableChoice().GetAbandon()) + require.NotNil(t, actions[1].GetNexusOperation().GetAwaitableChoice().GetAbandon()) require.NotNil(t, actions[2].GetAwaitPendingActions()) } func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Parallel() - exec := newThroughputStressExecutor() - exec.config = &tpsConfig{ - InternalIterations: 1, - NexusEnabled: true, - NexusEndpoint: "test-endpoint", - IncludeNexusSignal: true, - IncludeNexusSignalWithStart: true, - IncludeNexusUpdate: true, - SleepTime: time.Millisecond, - RngSeed: 1, - } - exec.rng = rand.New(rand.NewSource(1)) - info := &loadgen.ScenarioInfo{ - RunID: "nexus-workflow-actions", - ExecutionID: "exec", - Logger: zap.NewNop().Sugar(), - } + // Each run creates an endpoint targeting its own task queue. + env := workertest.SetupTestEnvironment(t, workertest.WithExecutorTimeout(time.Minute)) + + for _, tc := range []struct { + runID string + startedWithSignalWithStart bool + }{ + {runID: "nexus-workflow-actions-plain-start"}, + {runID: "nexus-workflow-actions-signal-with-start-2", startedWithSignalWithStart: true}, + } { + scenarioInfo := loadgen.ScenarioInfo{ + RunID: tc.runID, + Configuration: loadgen.RunConfiguration{ + Iterations: 1, + }, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + NexusEnabledFlag: "true", + IncludeNexusSignalFlag: "true", + IncludeNexusSignalWithStartFlag: "true", + IncludeNexusUpdateFlag: "true", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + }), + } + exec := newThroughputStressExecutor() + var workflowID string + var observedActions []*ks.ActionSet + exec.onActionsCreated = func(run *loadgen.Run, actions []*ks.ActionSet) { + workflowID = run.DefaultStartWorkflowOptions().ID + observedActions = actions + } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err, tc.runID) - seenSignalWithStartCreator := map[bool]bool{} - for iteration := 1; iteration <= 100; iteration++ { - run := info.NewRun(iteration) - workflowID := fmt.Sprintf("%s-nexus-target-%d", - run.DefaultStartWorkflowOptions().ID, iteration-1) + workflowID = fmt.Sprintf("%s-nexus-target-%d", workflowID, 0) var targetActions []*ks.Action - var walk func([]*ks.Action) - walk = func(actions []*ks.Action) { - for _, action := range actions { + for _, actionSet := range observedActions { + walkActions(actionSet.GetActions(), func(action *ks.Action) { if nested := action.GetNestedActionSet(); nested != nil { for _, nestedAction := range nested.GetActions() { if nestedAction.GetNexusOperation().GetInput().GetWorkflowAction().GetWorkflowId() == workflowID { @@ -287,18 +293,14 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { break } } - walk(nested.GetActions()) } - } - } - for _, actionSet := range exec.createActions(run) { - walk(actionSet.GetActions()) + }) } require.NotEmpty(t, targetActions) startedWithSignalWithStart := targetActions[0].GetNexusOperation().GetInput(). GetWorkflowAction().GetSignal().GetWithStart() - seenSignalWithStartCreator[startedWithSignalWithStart] = true + require.Equal(t, tc.startedWithSignalWithStart, startedWithSignalWithStart) var starts, signals, signalWithStarts, updates int for _, action := range targetActions { @@ -306,7 +308,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { if operation == nil { continue } - require.Equal(t, "test-endpoint", operation.GetEndpoint()) + require.Equal(t, exec.config.NexusEndpoint, operation.GetEndpoint()) require.Equal(t, ks.KitchenSinkNexusOperationName, operation.GetOperation()) workflowAction := operation.GetInput().GetWorkflowAction() require.Equal(t, workflowID, workflowAction.GetWorkflowId()) @@ -325,7 +327,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { } } - require.Equal(t, 2, signals) + require.Equal(t, 3, signals) require.Equal(t, 1, signalWithStarts) require.Equal(t, 1, updates) if startedWithSignalWithStart { @@ -333,20 +335,15 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { } else { require.Equal(t, 1, starts) } - completeTarget := targetActions[len(targetActions)-2].GetSendSignal() + completeTarget := targetActions[len(targetActions)-2].GetNexusOperation() require.NotNil(t, completeTarget) - require.Equal(t, workflowID, completeTarget.GetWorkflowId()) - require.Equal(t, "do_actions_signal", completeTarget.GetSignalName()) - require.NotNil(t, completeTarget.GetAwaitableChoice().GetWaitFinish()) - require.Len(t, completeTarget.GetArgs(), 1) - var signalAction ks.DoSignal_DoSignalActions - require.NoError(t, converter.GetDefaultDataConverter().FromPayload(completeTarget.GetArgs()[0], &signalAction)) - completingActions := signalAction.GetDoActions().GetActions() + completeSignal := completeTarget.GetInput().GetWorkflowAction().GetSignal() + require.NotNil(t, completeSignal) + completingActions := completeSignal.GetDoSignalActions().GetDoActions().GetActions() require.Len(t, completingActions, 1) require.NotNil(t, completingActions[0].GetReturnResult()) require.NotNil(t, targetActions[len(targetActions)-1].GetAwaitPendingActions()) } - require.Equal(t, map[bool]bool{false: true, true: true}, seenSignalWithStartCreator) } func TestThroughputStressConfigurePayload(t *testing.T) { @@ -597,6 +594,15 @@ func TestThroughputStressOperatorCommandsAcrossRunsAndContinueAsNew(t *testing.T }, commandTypes) } +func walkActions(actions []*ks.Action, visit func(*ks.Action)) { + for _, action := range actions { + visit(action) + if nested := action.GetNestedActionSet(); nested != nil { + walkActions(nested.GetActions(), visit) + } + } +} + func standaloneActivityOperatorCommandsInConcurrentGroups(actions []*ks.Action) ( commands []*ks.DoStandaloneActivityOperatorCommands, ) { From 1e764e4dc3db8dbdfe79df3da3161f4283991928 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 17:25:10 -0700 Subject: [PATCH 22/35] fix --- scenarios/throughput_stress.go | 9 +- scenarios/throughput_stress_test.go | 308 +++++++++++++--------------- 2 files changed, 148 insertions(+), 169 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index e2343549..5896fddc 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -171,7 +171,10 @@ func init() { } func newThroughputStressExecutor() *tpsExecutor { - return &tpsExecutor{state: &tpsState{}} + return &tpsExecutor{ + state: &tpsState{}, + onActionsCreated: func(*loadgen.Run, []*ActionSet) {}, + } } // Snapshot returns a snapshot of the current state. @@ -397,9 +400,7 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error // That means these client actions are sent from the activity worker instead of Omes. actions := t.createActions(run) options.Params.WorkflowInput.InitialActions = actions - if t.onActionsCreated != nil { - t.onActionsCreated(run, actions) - } + t.onActionsCreated(run, actions) return nil }, diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index caf53e14..c0240a8a 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -11,7 +11,6 @@ import ( "github.com/temporalio/omes/internal/workertest" "github.com/temporalio/omes/loadgen" ks "github.com/temporalio/omes/loadgen/kitchensink" - enumspb "go.temporal.io/api/enums/v1" namespacev1 "go.temporal.io/api/namespace/v1" "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/converter" @@ -148,11 +147,9 @@ func TestThroughputStressFeatureAutoEnable(t *testing.T) { require.Equal(t, 1, executor.Snapshot().(tpsState).CompletedIterations) } -func TestThroughputStressNexusStandaloneActivity(t *testing.T) { +func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { t.Parallel() - runID := fmt.Sprintf("tps-nsa-%d", time.Now().Unix()) - // Enable the activity-backed operation and standalone-Nexus completion path. server := workertest.StartDevServer(t, workertest.WithDynamicConfig(map[string]any{ "activity.enableStandalone": true, @@ -161,78 +158,12 @@ func TestThroughputStressNexusStandaloneActivity(t *testing.T) { "history.enableCHASMCallbacks": true, })) env := workertest.SetupTestEnvironment(t, - workertest.WithExecutorTimeout(1*time.Minute), + workertest.WithExecutorTimeout(time.Minute), workertest.WithDevServer(server)) - scenarioInfo := loadgen.ScenarioInfo{ - RunID: runID, - Configuration: loadgen.RunConfiguration{ - Iterations: 1, - }, - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "1", - ContinueAsNewAfterIterFlag: "1", - SleepTimeFlag: "1ms", - VisibilityVerificationTimeoutFlag: "10s", - NexusEnabledFlag: "true", - IncludeNexusStandaloneActivityFlag: "true", - }), - } - - executor := newThroughputStressExecutor() - _, err := env.RunExecutorTest(t, executor, scenarioInfo, clioptions.LangGo) - require.NoError(t, err, "Executor should complete successfully with nexus standalone activity enabled") - - require.True(t, executor.config.IncludeNexusStandaloneActivity, - "nexus standalone activity should be enabled") - require.Equal(t, 1, executor.Snapshot().(tpsState).CompletedIterations) -} - -func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { - t.Parallel() - - exec := newThroughputStressExecutor() - exec.config = &tpsConfig{ - InternalIterations: 1, - ContinueAsNewAfterIter: 0, - NexusEnabled: true, - NexusEndpoint: "test-endpoint", - IncludeStandaloneNexus: true, - IncludeNexusStandaloneActivity: true, - SleepTime: time.Millisecond, - RngSeed: 1, - } - exec.rng = rand.New(rand.NewSource(1)) - - run := (&loadgen.ScenarioInfo{ - RunID: "tps-nsa-actions", - ExecutionID: "exec", - Logger: zap.NewNop().Sugar(), - }).NewRun(0) - - var inWorkflow, standalone bool - for _, set := range exec.createActions(run) { - walkActions(set.GetActions(), func(a *ks.Action) { - if op := a.GetNexusOperation(); op.GetInput().GetStartActivity() != nil { - inWorkflow = true - } - // Find the nested standalone-Nexus client action. - if seq := a.GetExecActivity().GetClient().GetClientSequence(); seq != nil { - for _, set := range seq.GetActionSets() { - for _, ca := range set.GetActions() { - if sn := ca.GetDoStandaloneNexusOperation().GetOperation(); sn.GetInput().GetStartActivity() != nil { - standalone = true - } - } - } - } - }) - } - - require.True(t, inWorkflow, - `expected an in-workflow Nexus start-activity action`) - require.True(t, standalone, - `expected a standalone Nexus start-activity client action`) + require.Equal(t, + nexusStandaloneActivityActionCounts{inWorkflow: 1, standalone: 1}, + runThroughputStressNexusStandaloneActivityActions(t, env)) } func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { @@ -250,99 +181,24 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { env := workertest.SetupTestEnvironment(t, workertest.WithExecutorTimeout(time.Minute)) for _, tc := range []struct { - runID string - startedWithSignalWithStart bool + name string + runID string + want nexusWorkflowActionCounts }{ - {runID: "nexus-workflow-actions-plain-start"}, - {runID: "nexus-workflow-actions-signal-with-start-2", startedWithSignalWithStart: true}, + { + name: "Start", + runID: "nexus-workflow-actions-plain-start", + want: nexusWorkflowActionCounts{starts: 1, signals: 3, signalWithStarts: 1, updates: 1}, + }, + { + name: "SignalWithStart", + runID: "nexus-workflow-actions-signal-with-start-2", + want: nexusWorkflowActionCounts{signals: 3, signalWithStarts: 1, updates: 1}, + }, } { - scenarioInfo := loadgen.ScenarioInfo{ - RunID: tc.runID, - Configuration: loadgen.RunConfiguration{ - Iterations: 1, - }, - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "1", - NexusEnabledFlag: "true", - IncludeNexusSignalFlag: "true", - IncludeNexusSignalWithStartFlag: "true", - IncludeNexusUpdateFlag: "true", - SleepTimeFlag: "1ms", - VisibilityVerificationTimeoutFlag: "10s", - }), - } - exec := newThroughputStressExecutor() - var workflowID string - var observedActions []*ks.ActionSet - exec.onActionsCreated = func(run *loadgen.Run, actions []*ks.ActionSet) { - workflowID = run.DefaultStartWorkflowOptions().ID - observedActions = actions - } - - _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) - require.NoError(t, err, tc.runID) - - workflowID = fmt.Sprintf("%s-nexus-target-%d", workflowID, 0) - var targetActions []*ks.Action - for _, actionSet := range observedActions { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - if nested := action.GetNestedActionSet(); nested != nil { - for _, nestedAction := range nested.GetActions() { - if nestedAction.GetNexusOperation().GetInput().GetWorkflowAction().GetWorkflowId() == workflowID { - targetActions = nested.GetActions() - break - } - } - } - }) - } - - require.NotEmpty(t, targetActions) - startedWithSignalWithStart := targetActions[0].GetNexusOperation().GetInput(). - GetWorkflowAction().GetSignal().GetWithStart() - require.Equal(t, tc.startedWithSignalWithStart, startedWithSignalWithStart) - - var starts, signals, signalWithStarts, updates int - for _, action := range targetActions { - operation := action.GetNexusOperation() - if operation == nil { - continue - } - require.Equal(t, exec.config.NexusEndpoint, operation.GetEndpoint()) - require.Equal(t, ks.KitchenSinkNexusOperationName, operation.GetOperation()) - workflowAction := operation.GetInput().GetWorkflowAction() - require.Equal(t, workflowID, workflowAction.GetWorkflowId()) - switch { - case workflowAction.GetStart() != nil: - starts++ - require.Equal(t, enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, - workflowAction.GetStartOptions().GetWorkflowIdConflictPolicy()) - case workflowAction.GetSignal() != nil: - signals++ - if workflowAction.GetSignal().GetWithStart() { - signalWithStarts++ - } - case workflowAction.GetUpdate() != nil: - updates++ - } - } - - require.Equal(t, 3, signals) - require.Equal(t, 1, signalWithStarts) - require.Equal(t, 1, updates) - if startedWithSignalWithStart { - require.Zero(t, starts) - } else { - require.Equal(t, 1, starts) - } - completeTarget := targetActions[len(targetActions)-2].GetNexusOperation() - require.NotNil(t, completeTarget) - completeSignal := completeTarget.GetInput().GetWorkflowAction().GetSignal() - require.NotNil(t, completeSignal) - completingActions := completeSignal.GetDoSignalActions().GetDoActions().GetActions() - require.Len(t, completingActions, 1) - require.NotNil(t, completingActions[0].GetReturnResult()) - require.NotNil(t, targetActions[len(targetActions)-1].GetAwaitPendingActions()) + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, runThroughputStressNexusWorkflowActions(t, env, tc.runID)) + }) } } @@ -603,6 +459,128 @@ func walkActions(actions []*ks.Action, visit func(*ks.Action)) { } } +type nexusStandaloneActivityActionCounts struct { + inWorkflow int + standalone int +} + +func runThroughputStressNexusStandaloneActivityActions( + t *testing.T, + env *workertest.TestEnvironment, +) (counts nexusStandaloneActivityActionCounts) { + t.Helper() + + exec := newThroughputStressExecutor() + exec.onActionsCreated = func(_ *loadgen.Run, actions []*ks.ActionSet) { + counts = countNexusStandaloneActivityActions(actions) + } + scenarioInfo := loadgen.ScenarioInfo{ + RunID: "tps-nsa-actions", + Configuration: loadgen.RunConfiguration{Iterations: 1}, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + ContinueAsNewAfterIterFlag: "1", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + NexusEnabledFlag: "true", + IncludeStandaloneNexusFlag: "true", + IncludeNexusStandaloneActivityFlag: "true", + }), + } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err) + return counts +} + +func countNexusStandaloneActivityActions( + actionSets []*ks.ActionSet, +) (counts nexusStandaloneActivityActionCounts) { + for _, actionSet := range actionSets { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + if action.GetNexusOperation().GetInput().GetStartActivity() != nil { + counts.inWorkflow++ + } + // Find the nested standalone-Nexus client action. + if sequence := action.GetExecActivity().GetClient().GetClientSequence(); sequence != nil { + for _, clientActionSet := range sequence.GetActionSets() { + for _, clientAction := range clientActionSet.GetActions() { + operation := clientAction.GetDoStandaloneNexusOperation().GetOperation() + if operation.GetInput().GetStartActivity() != nil { + counts.standalone++ + } + } + } + } + }) + } + return counts +} + +type nexusWorkflowActionCounts struct { + starts int + signals int + signalWithStarts int + updates int +} + +func runThroughputStressNexusWorkflowActions( + t *testing.T, + env *workertest.TestEnvironment, + runID string, +) (counts nexusWorkflowActionCounts) { + t.Helper() + + exec := newThroughputStressExecutor() + exec.onActionsCreated = func(run *loadgen.Run, actions []*ks.ActionSet) { + targetWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, 0) + counts = countNexusWorkflowActions(actions, targetWorkflowID) + } + scenarioInfo := loadgen.ScenarioInfo{ + RunID: runID, + Configuration: loadgen.RunConfiguration{Iterations: 1}, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + NexusEnabledFlag: "true", + IncludeNexusSignalFlag: "true", + IncludeNexusSignalWithStartFlag: "true", + IncludeNexusUpdateFlag: "true", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + }), + } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err, runID) + return counts +} + +func countNexusWorkflowActions( + actionSets []*ks.ActionSet, + targetWorkflowID string, +) (counts nexusWorkflowActionCounts) { + for _, actionSet := range actionSets { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() + if workflowAction.GetWorkflowId() != targetWorkflowID { + return + } + switch { + case workflowAction.GetStart() != nil: + counts.starts++ + case workflowAction.GetSignal() != nil: + counts.signals++ + if workflowAction.GetSignal().GetWithStart() { + counts.signalWithStarts++ + } + case workflowAction.GetUpdate() != nil: + counts.updates++ + } + }) + } + return counts +} + func standaloneActivityOperatorCommandsInConcurrentGroups(actions []*ks.Action) ( commands []*ks.DoStandaloneActivityOperatorCommands, ) { From 3acf594989bc1c1101a3c4af6aa9c7051576fb56 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 18:11:52 -0700 Subject: [PATCH 23/35] Inline Nexus action test setup --- scenarios/throughput_stress_test.go | 222 ++++++++++++---------------- 1 file changed, 93 insertions(+), 129 deletions(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index c0240a8a..fa1531c9 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -161,9 +161,49 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { workertest.WithExecutorTimeout(time.Minute), workertest.WithDevServer(server)) - require.Equal(t, - nexusStandaloneActivityActionCounts{inWorkflow: 1, standalone: 1}, - runThroughputStressNexusStandaloneActivityActions(t, env)) + type actionCounts struct { + inWorkflow int + standalone int + } + var counts actionCounts + exec := newThroughputStressExecutor() + exec.onActionsCreated = func(_ *loadgen.Run, actionSets []*ks.ActionSet) { + for _, actionSet := range actionSets { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + if action.GetNexusOperation().GetInput().GetStartActivity() != nil { + counts.inWorkflow++ + } + // Find the nested standalone-Nexus client action. + if sequence := action.GetExecActivity().GetClient().GetClientSequence(); sequence != nil { + for _, clientActionSet := range sequence.GetActionSets() { + for _, clientAction := range clientActionSet.GetActions() { + operation := clientAction.GetDoStandaloneNexusOperation().GetOperation() + if operation.GetInput().GetStartActivity() != nil { + counts.standalone++ + } + } + } + } + }) + } + } + scenarioInfo := loadgen.ScenarioInfo{ + RunID: "tps-nsa-actions", + Configuration: loadgen.RunConfiguration{Iterations: 1}, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + ContinueAsNewAfterIterFlag: "1", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + NexusEnabledFlag: "true", + IncludeStandaloneNexusFlag: "true", + IncludeNexusStandaloneActivityFlag: "true", + }), + } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err) + require.Equal(t, actionCounts{inWorkflow: 1, standalone: 1}, counts) } func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { @@ -180,24 +220,70 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { // Each run creates an endpoint targeting its own task queue. env := workertest.SetupTestEnvironment(t, workertest.WithExecutorTimeout(time.Minute)) + type actionCounts struct { + starts int + signals int + signalWithStarts int + updates int + } for _, tc := range []struct { name string runID string - want nexusWorkflowActionCounts + want actionCounts }{ { name: "Start", runID: "nexus-workflow-actions-plain-start", - want: nexusWorkflowActionCounts{starts: 1, signals: 3, signalWithStarts: 1, updates: 1}, + want: actionCounts{starts: 1, signals: 3, signalWithStarts: 1, updates: 1}, }, { name: "SignalWithStart", runID: "nexus-workflow-actions-signal-with-start-2", - want: nexusWorkflowActionCounts{signals: 3, signalWithStarts: 1, updates: 1}, + want: actionCounts{signals: 3, signalWithStarts: 1, updates: 1}, }, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, runThroughputStressNexusWorkflowActions(t, env, tc.runID)) + var counts actionCounts + exec := newThroughputStressExecutor() + exec.onActionsCreated = func(run *loadgen.Run, actionSets []*ks.ActionSet) { + targetWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, 0) + for _, actionSet := range actionSets { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() + if workflowAction.GetWorkflowId() != targetWorkflowID { + return + } + switch { + case workflowAction.GetStart() != nil: + counts.starts++ + case workflowAction.GetSignal() != nil: + counts.signals++ + if workflowAction.GetSignal().GetWithStart() { + counts.signalWithStarts++ + } + case workflowAction.GetUpdate() != nil: + counts.updates++ + } + }) + } + } + scenarioInfo := loadgen.ScenarioInfo{ + RunID: tc.runID, + Configuration: loadgen.RunConfiguration{Iterations: 1}, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + NexusEnabledFlag: "true", + IncludeNexusSignalFlag: "true", + IncludeNexusSignalWithStartFlag: "true", + IncludeNexusUpdateFlag: "true", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + }), + } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err, tc.runID) + require.Equal(t, tc.want, counts) }) } } @@ -459,128 +545,6 @@ func walkActions(actions []*ks.Action, visit func(*ks.Action)) { } } -type nexusStandaloneActivityActionCounts struct { - inWorkflow int - standalone int -} - -func runThroughputStressNexusStandaloneActivityActions( - t *testing.T, - env *workertest.TestEnvironment, -) (counts nexusStandaloneActivityActionCounts) { - t.Helper() - - exec := newThroughputStressExecutor() - exec.onActionsCreated = func(_ *loadgen.Run, actions []*ks.ActionSet) { - counts = countNexusStandaloneActivityActions(actions) - } - scenarioInfo := loadgen.ScenarioInfo{ - RunID: "tps-nsa-actions", - Configuration: loadgen.RunConfiguration{Iterations: 1}, - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "1", - ContinueAsNewAfterIterFlag: "1", - SleepTimeFlag: "1ms", - VisibilityVerificationTimeoutFlag: "10s", - NexusEnabledFlag: "true", - IncludeStandaloneNexusFlag: "true", - IncludeNexusStandaloneActivityFlag: "true", - }), - } - - _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) - require.NoError(t, err) - return counts -} - -func countNexusStandaloneActivityActions( - actionSets []*ks.ActionSet, -) (counts nexusStandaloneActivityActionCounts) { - for _, actionSet := range actionSets { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - if action.GetNexusOperation().GetInput().GetStartActivity() != nil { - counts.inWorkflow++ - } - // Find the nested standalone-Nexus client action. - if sequence := action.GetExecActivity().GetClient().GetClientSequence(); sequence != nil { - for _, clientActionSet := range sequence.GetActionSets() { - for _, clientAction := range clientActionSet.GetActions() { - operation := clientAction.GetDoStandaloneNexusOperation().GetOperation() - if operation.GetInput().GetStartActivity() != nil { - counts.standalone++ - } - } - } - } - }) - } - return counts -} - -type nexusWorkflowActionCounts struct { - starts int - signals int - signalWithStarts int - updates int -} - -func runThroughputStressNexusWorkflowActions( - t *testing.T, - env *workertest.TestEnvironment, - runID string, -) (counts nexusWorkflowActionCounts) { - t.Helper() - - exec := newThroughputStressExecutor() - exec.onActionsCreated = func(run *loadgen.Run, actions []*ks.ActionSet) { - targetWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, 0) - counts = countNexusWorkflowActions(actions, targetWorkflowID) - } - scenarioInfo := loadgen.ScenarioInfo{ - RunID: runID, - Configuration: loadgen.RunConfiguration{Iterations: 1}, - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "1", - NexusEnabledFlag: "true", - IncludeNexusSignalFlag: "true", - IncludeNexusSignalWithStartFlag: "true", - IncludeNexusUpdateFlag: "true", - SleepTimeFlag: "1ms", - VisibilityVerificationTimeoutFlag: "10s", - }), - } - - _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) - require.NoError(t, err, runID) - return counts -} - -func countNexusWorkflowActions( - actionSets []*ks.ActionSet, - targetWorkflowID string, -) (counts nexusWorkflowActionCounts) { - for _, actionSet := range actionSets { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() - if workflowAction.GetWorkflowId() != targetWorkflowID { - return - } - switch { - case workflowAction.GetStart() != nil: - counts.starts++ - case workflowAction.GetSignal() != nil: - counts.signals++ - if workflowAction.GetSignal().GetWithStart() { - counts.signalWithStarts++ - } - case workflowAction.GetUpdate() != nil: - counts.updates++ - } - }) - } - return counts -} - func standaloneActivityOperatorCommandsInConcurrentGroups(actions []*ks.Action) ( commands []*ks.DoStandaloneActivityOperatorCommands, ) { From 1395c077b4d352f997e17f7bfaccc2d09bbe1fb6 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 18:22:58 -0700 Subject: [PATCH 24/35] simpler --- scenarios/throughput_stress.go | 15 ++++++--------- scenarios/throughput_stress_test.go | 11 ++++++----- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 5896fddc..dd769053 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -131,7 +131,7 @@ type tpsExecutor struct { isResuming bool runID string rng *rand.Rand - onActionsCreated func(*loadgen.Run, []*ActionSet) + onActionsCreated func([]*ActionSet) } var _ loadgen.Resumable = (*tpsExecutor)(nil) @@ -173,7 +173,7 @@ func init() { func newThroughputStressExecutor() *tpsExecutor { return &tpsExecutor{ state: &tpsState{}, - onActionsCreated: func(*loadgen.Run, []*ActionSet) {}, + onActionsCreated: func([]*ActionSet) {}, } } @@ -400,7 +400,7 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error // That means these client actions are sent from the activity worker instead of Omes. actions := t.createActions(run) options.Params.WorkflowInput.InitialActions = actions - t.onActionsCreated(run, actions) + t.onActionsCreated(actions) return nil }, @@ -557,6 +557,7 @@ func (t *tpsExecutor) createActionsChunk( // Create actions for the current chunk for i := 0; i < itersPerChunk; i++ { + iterationIndex := t.internalIterationIndex(run, remainingInternalIters, i) syncActions := []*Action{ PayloadActivity(t.samplePayloadSize(rng), t.samplePayloadSize(rng), DefaultLocalActivity), PayloadActivity(0, t.samplePayloadSize(rng), DefaultLocalActivity), @@ -653,10 +654,7 @@ func (t *tpsExecutor) createActionsChunk( } } if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { - nexusWorkflowID := fmt.Sprintf("%s-nexus-target-%d", - run.DefaultStartWorkflowOptions().ID, - t.internalIterationIndex(run, remainingInternalIters, i)) - // Keep this sequence sequential because AwaitPendingActions drains workflow-global pending actions. + nexusWorkflowID := fmt.Sprintf("%s/nexus-workflow-%d", run.DefaultStartWorkflowOptions().ID, iterationIndex) syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID, rng)) } } @@ -666,11 +664,10 @@ func (t *tpsExecutor) createActionsChunk( asyncActions = append(asyncActions, t.createStandaloneActivityAction(loadgen.TaskQueueForRun(run.RunID), rng)) } if t.config.IncludeStandaloneActivityOperatorCommands { - commandOrdinal := t.internalIterationIndex(run, remainingInternalIters, i) asyncActions = append(asyncActions, t.createStandaloneActivityOperatorCommandsAction( loadgen.TaskQueueForRun(run.RunID), - commandOrdinal, + iterationIndex, ), ) } diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index fa1531c9..21390892 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -3,6 +3,7 @@ package scenarios import ( "fmt" "math/rand" + "strings" "testing" "time" @@ -167,7 +168,7 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { } var counts actionCounts exec := newThroughputStressExecutor() - exec.onActionsCreated = func(_ *loadgen.Run, actionSets []*ks.ActionSet) { + exec.onActionsCreated = func(actionSets []*ks.ActionSet) { for _, actionSet := range actionSets { walkActions(actionSet.GetActions(), func(action *ks.Action) { if action.GetNexusOperation().GetInput().GetStartActivity() != nil { @@ -218,7 +219,8 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Parallel() // Each run creates an endpoint targeting its own task queue. - env := workertest.SetupTestEnvironment(t, workertest.WithExecutorTimeout(time.Minute)) + env := workertest.SetupTestEnvironment(t, + workertest.WithExecutorTimeout(time.Minute)) type actionCounts struct { starts int @@ -245,12 +247,11 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var counts actionCounts exec := newThroughputStressExecutor() - exec.onActionsCreated = func(run *loadgen.Run, actionSets []*ks.ActionSet) { - targetWorkflowID := fmt.Sprintf("%s-nexus-target-%d", run.DefaultStartWorkflowOptions().ID, 0) + exec.onActionsCreated = func(actionSets []*ks.ActionSet) { for _, actionSet := range actionSets { walkActions(actionSet.GetActions(), func(action *ks.Action) { workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() - if workflowAction.GetWorkflowId() != targetWorkflowID { + if !strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { return } switch { From b27e90fcab2aadf5d04242b3cbef3d8a77c44f8f Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 18:28:17 -0700 Subject: [PATCH 25/35] Restore Nexus action ordering comment --- scenarios/throughput_stress.go | 1 + 1 file changed, 1 insertion(+) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index dd769053..7748548f 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -655,6 +655,7 @@ func (t *tpsExecutor) createActionsChunk( } if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { nexusWorkflowID := fmt.Sprintf("%s/nexus-workflow-%d", run.DefaultStartWorkflowOptions().ID, iterationIndex) + // Keep this sequence sequential because AwaitPendingActions drains workflow-global pending actions. syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID, rng)) } } From 388f6598f5bb0d3085d3771110e5afeb10d2d3a8 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 18:30:46 -0700 Subject: [PATCH 26/35] Update throughput_stress_test.go --- scenarios/throughput_stress_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 21390892..250eb66a 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -218,7 +218,6 @@ func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Parallel() - // Each run creates an endpoint targeting its own task queue. env := workertest.SetupTestEnvironment(t, workertest.WithExecutorTimeout(time.Minute)) From 143ea02fb4ae4634e6b735485c333a4cdd0c5b88 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 18:53:36 -0700 Subject: [PATCH 27/35] Exercise Nexus workflow action callbacks --- scenarios/throughput_stress.go | 2 +- scenarios/throughput_stress_test.go | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 7748548f..8756986d 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -1090,7 +1090,7 @@ func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { }}, }}, }, - ExpectedOutput: ConvertToPayload(workflowID), + ExpectedOutput: ConvertToPayload(ConvertToPayload(workflowID)), }) } diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 250eb66a..aaf4590c 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -218,8 +218,15 @@ func TestThroughputStressNexusAttachSignalIsFireAndForget(t *testing.T) { func TestThroughputStressNexusWorkflowActions(t *testing.T) { t.Parallel() + server := workertest.StartDevServer(t, workertest.WithDynamicConfig(map[string]any{ + "history.enableChasm": true, + "history.enableCHASMCallbacks": true, + "history.enableCHASMSignalBacklinks": true, + "history.enableUpdateCallbacks": true, + })) env := workertest.SetupTestEnvironment(t, - workertest.WithExecutorTimeout(time.Minute)) + workertest.WithExecutorTimeout(time.Minute), + workertest.WithDevServer(server)) type actionCounts struct { starts int From b4e0f1ba362fca584c257f9ef37ecd64c4185119 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 19:46:35 -0700 Subject: [PATCH 28/35] Simplify Nexus workflow action sequencing --- docs/throughput-stress.md | 4 +- scenarios/throughput_stress.go | 107 +++++++++-------------- scenarios/throughput_stress_test.go | 129 ++++++++++++++++------------ 3 files changed, 118 insertions(+), 122 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index 9f88d2b5..9163e3d3 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -96,9 +96,11 @@ The following opt-in options exercise actions from the Nexus handler: - `include-nexus-standalone-activity` - `include-nexus-signal` -- `include-nexus-signal-with-start` - `include-nexus-update` +The signal and update actions start their target workflow with signal-with-start. Enabling +`include-nexus-signal` also sends an ordinary signal to that target. + The standalone activity action is driven two ways each iteration: as an in-workflow Nexus operation and, when standalone Nexus is part of the run, as a standalone Nexus operation. It also needs server support for standalone activities and activity completion callbacks (dynamic config diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 8756986d..e1d1fa84 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -70,9 +70,6 @@ const ( // IncludeNexusSignalFlag enables a Nexus operation that signals a workflow. // Opt-in and off by default; requires Nexus load (nexus-enabled). IncludeNexusSignalFlag = "include-nexus-signal" - // IncludeNexusSignalWithStartFlag enables a Nexus operation that signals a workflow, - // starting it first if needed. Opt-in and off by default; requires Nexus load (nexus-enabled). - IncludeNexusSignalWithStartFlag = "include-nexus-signal-with-start" // IncludeNexusUpdateFlag enables a Nexus operation that updates a workflow. // Opt-in and off by default; requires Nexus load (nexus-enabled). IncludeNexusUpdateFlag = "include-nexus-update" @@ -81,6 +78,13 @@ const ( PayloadDistributionJsonFlag = "payload-distribution-json" ) +const ( + nexusSignalStateKey = "nexus-signal" + nexusSignalWithStartStateKey = "nexus-signal-with-start" + nexusUpdateStateKey = "nexus-update" + nexusActionComplete = "complete" +) + type tpsState struct { // CompletedIterations is the number of iteration that have been completed. CompletedIterations int `json:"completedIterations"` @@ -119,7 +123,6 @@ type tpsConfig struct { IncludeStandaloneActivityOperatorCommands bool IncludeNexusStandaloneActivity bool IncludeNexusSignal bool - IncludeNexusSignalWithStart bool IncludeNexusUpdate bool Payload *loadgen.PayloadConfig } @@ -162,7 +165,6 @@ func init() { }) o.Bool(IncludeNexusStandaloneActivityFlag, false, "Include a Nexus operation that starts a standalone activity (Go worker only).") o.Bool(IncludeNexusSignalFlag, false, "Include a Nexus operation that signals a workflow (Go worker only).") - o.Bool(IncludeNexusSignalWithStartFlag, false, "Include a Nexus operation that signals a workflow, starting it if needed (Go worker only).") o.Bool(IncludeNexusUpdateFlag, false, "Include a Nexus operation that updates a workflow (Go worker only).") o.String(PayloadDistributionJsonFlag, "", "JSON payload-size distribution; use @ to read from a file.") }, @@ -268,14 +270,10 @@ func (t *tpsExecutor) Configure(info loadgen.ScenarioInfo) error { return fmt.Errorf("%s requires %s", IncludeNexusStandaloneActivityFlag, NexusEnabledFlag) } config.IncludeNexusSignal = info.OptionBool(IncludeNexusSignalFlag) - config.IncludeNexusSignalWithStart = info.OptionBool(IncludeNexusSignalWithStartFlag) config.IncludeNexusUpdate = info.OptionBool(IncludeNexusUpdateFlag) if config.IncludeNexusSignal && !config.NexusEnabled { return fmt.Errorf("%s requires %s", IncludeNexusSignalFlag, NexusEnabledFlag) } - if config.IncludeNexusSignalWithStart && !config.NexusEnabled { - return fmt.Errorf("%s requires %s", IncludeNexusSignalWithStartFlag, NexusEnabledFlag) - } if config.IncludeNexusUpdate && !config.NexusEnabled { return fmt.Errorf("%s requires %s", IncludeNexusUpdateFlag, NexusEnabledFlag) } @@ -319,7 +317,6 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error t.config.IncludeStandaloneNexus = false t.config.IncludeNexusStandaloneActivity = false t.config.IncludeNexusSignal = false - t.config.IncludeNexusSignalWithStart = false t.config.IncludeNexusUpdate = false } else { info.Logger.Infof("Using nexus endpoint %q", nexus.Endpoint) @@ -653,10 +650,10 @@ func (t *tpsExecutor) createActionsChunk( ) } } - if t.config.IncludeNexusSignal || t.config.IncludeNexusSignalWithStart || t.config.IncludeNexusUpdate { + if t.config.IncludeNexusSignal || t.config.IncludeNexusUpdate { nexusWorkflowID := fmt.Sprintf("%s/nexus-workflow-%d", run.DefaultStartWorkflowOptions().ID, iterationIndex) - // Keep this sequence sequential because AwaitPendingActions drains workflow-global pending actions. - syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID, rng)) + // Keep this sequence sequential so signal-with-start creates the target before the remaining actions. + syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID)) } } @@ -977,62 +974,33 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { }) } -// createNexusWorkflowActionSequence starts a workflow, sends the configured actions, and waits for completion. -func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string, rng *rand.Rand) *Action { - var startAction *Action - var targetActions []*Action - if t.config.IncludeNexusSignalWithStart { - signalWithStart := t.createNexusSignalWithStartAction(workflowID) - if rng.Intn(2) == 0 { - startAction = signalWithStart - } else { - targetActions = append(targetActions, signalWithStart) - } +// createNexusWorkflowActionSequence starts a workflow that waits for the configured actions before completing. +func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string) *Action { + targetWorkflowActions := []*Action{ + NewAwaitWorkflowStateAction(nexusSignalWithStartStateKey, nexusActionComplete), + } + if t.config.IncludeNexusSignal { + targetWorkflowActions = append(targetWorkflowActions, + NewAwaitWorkflowStateAction(nexusSignalStateKey, nexusActionComplete)) + } + if t.config.IncludeNexusUpdate { + targetWorkflowActions = append(targetWorkflowActions, + NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete)) } + // Yield a workflow task so update completion is recorded before the target closes. + targetWorkflowActions = append(targetWorkflowActions, NewTimerAction(time.Millisecond)) + // Complete the target after every configured action has marked itself complete. + targetWorkflowActions = append(targetWorkflowActions, NewEmptyReturnResultAction()) + targetWorkflowInput := &WorkflowInput{InitialActions: ListActionSet(targetWorkflowActions...)} + + targetActions := []*Action{t.createNexusSignalWithStartAction(workflowID, targetWorkflowInput)} if t.config.IncludeNexusSignal { targetActions = append(targetActions, t.createNexusSignalAction(workflowID)) } if t.config.IncludeNexusUpdate { targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) } - if startAction == nil { - startAction = NexusOperation(&ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Input: &NexusOperationRequest{ - Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ - WorkflowId: workflowID, - StartOptions: &NexusWorkflowStartOptions{ - WorkflowIdConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, - WorkflowInput: &WorkflowInput{}, - }, - Action: &NexusWorkflowAction_Start{Start: &emptypb.Empty{}}, - }}, - }, - AwaitableChoice: &AwaitableChoice{Condition: &AwaitableChoice_WaitStarted{WaitStarted: &emptypb.Empty{}}}, - }) - } - actions := append([]*Action{startAction}, targetActions...) - actions = append(actions, - // Complete the target so the wait_started start operation can finish. - NexusOperation(&ExecuteNexusOperation{ - Endpoint: t.config.NexusEndpoint, - Input: &NexusOperationRequest{ - Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ - WorkflowId: workflowID, - Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ - Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{ - DoActions: SingleActionSet(NewEmptyReturnResultAction()), - }, - }}, - }}, - }}, - }, - ExpectedOutput: ConvertToPayload(workflowID), - }), - &Action{Variant: &Action_AwaitPendingActions{AwaitPendingActions: &AwaitPendingActions{}}}, - ) - return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: actions}}} + return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: targetActions}}} } func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { @@ -1043,7 +1011,11 @@ func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { WorkflowId: workflowID, Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet()}, + Variant: &DoSignal_DoSignalActions_DoActions{ + DoActions: SingleActionSet( + NewSetWorkflowStateAction(nexusSignalStateKey, nexusActionComplete), + ), + }, }}, }}, }}, @@ -1052,16 +1024,18 @@ func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { }) } -func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string) *Action { +func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string, workflowInput *WorkflowInput) *Action { return NexusOperation(&ExecuteNexusOperation{ Endpoint: t.config.NexusEndpoint, Input: &NexusOperationRequest{ Action: &NexusOperationRequest_WorkflowAction{WorkflowAction: &NexusWorkflowAction{ WorkflowId: workflowID, - StartOptions: &NexusWorkflowStartOptions{WorkflowInput: &WorkflowInput{}}, + StartOptions: &NexusWorkflowStartOptions{WorkflowInput: workflowInput}, Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet()}, + Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet( + NewSetWorkflowStateAction(nexusSignalWithStartStateKey, nexusActionComplete), + )}, }}, WithStart: true, }}, @@ -1080,6 +1054,7 @@ func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { Action: &NexusWorkflowAction_Update{Update: &DoUpdate{ Variant: &DoUpdate_DoActions{DoActions: &DoActionsUpdate{ Variant: &DoActionsUpdate_DoActions{DoActions: SingleActionSet( + NewSetWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), // The update handler's return value is itself encoded by the data converter // before the server forwards it to the Nexus completion callback, so the value // is wrapped twice here: the caller decodes the outer layer and compares the diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index aaf4590c..3f762247 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -234,65 +234,85 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { signalWithStarts int updates int } - for _, tc := range []struct { - name string - runID string - want actionCounts - }{ - { - name: "Start", - runID: "nexus-workflow-actions-plain-start", - want: actionCounts{starts: 1, signals: 3, signalWithStarts: 1, updates: 1}, - }, - { - name: "SignalWithStart", - runID: "nexus-workflow-actions-signal-with-start-2", - want: actionCounts{signals: 3, signalWithStarts: 1, updates: 1}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - var counts actionCounts - exec := newThroughputStressExecutor() - exec.onActionsCreated = func(actionSets []*ks.ActionSet) { - for _, actionSet := range actionSets { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() - if !strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { - return - } - switch { - case workflowAction.GetStart() != nil: - counts.starts++ - case workflowAction.GetSignal() != nil: - counts.signals++ - if workflowAction.GetSignal().GetWithStart() { - counts.signalWithStarts++ + var counts actionCounts + markers := make(map[string]string) + startInputs := 0 + wantMarkers := map[string]string{ + "nexus-signal": "complete", + "nexus-signal-with-start": "complete", + "nexus-update": "complete", + } + exec := newThroughputStressExecutor() + exec.onActionsCreated = func(actionSets []*ks.ActionSet) { + for _, actionSet := range actionSets { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() + if !strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { + return + } + if input := workflowAction.GetStartOptions().GetWorkflowInput(); input != nil { + startInputs++ + waiters := make(map[string]string) + timers := 0 + returns := 0 + for _, initialActions := range input.GetInitialActions() { + walkActions(initialActions.GetActions(), func(action *ks.Action) { + if await := action.GetAwaitWorkflowState(); await != nil { + waiters[await.GetKey()] = await.GetValue() } - case workflowAction.GetUpdate() != nil: - counts.updates++ + if action.GetTimer() != nil { + timers++ + } + if action.GetReturnResult() != nil { + returns++ + } + }) + } + require.Equal(t, wantMarkers, waiters) + require.Equal(t, 1, timers) + require.Equal(t, 1, returns) + } + recordMarkers := func(actions *ks.ActionSet) { + walkActions(actions.GetActions(), func(action *ks.Action) { + for key, value := range action.GetSetWorkflowState().GetKvs() { + markers[key] = value } }) } - } - scenarioInfo := loadgen.ScenarioInfo{ - RunID: tc.runID, - Configuration: loadgen.RunConfiguration{Iterations: 1}, - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - IterFlag: "1", - NexusEnabledFlag: "true", - IncludeNexusSignalFlag: "true", - IncludeNexusSignalWithStartFlag: "true", - IncludeNexusUpdateFlag: "true", - SleepTimeFlag: "1ms", - VisibilityVerificationTimeoutFlag: "10s", - }), - } - - _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) - require.NoError(t, err, tc.runID) - require.Equal(t, tc.want, counts) - }) + switch { + case workflowAction.GetStart() != nil: + counts.starts++ + case workflowAction.GetSignal() != nil: + counts.signals++ + recordMarkers(workflowAction.GetSignal().GetDoSignalActions().GetDoActions()) + if workflowAction.GetSignal().GetWithStart() { + counts.signalWithStarts++ + } + case workflowAction.GetUpdate() != nil: + counts.updates++ + recordMarkers(workflowAction.GetUpdate().GetDoActions().GetDoActions()) + } + }) + } + } + scenarioInfo := loadgen.ScenarioInfo{ + RunID: "nexus-workflow-actions-signal-with-start", + Configuration: loadgen.RunConfiguration{Iterations: 1}, + Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ + IterFlag: "1", + NexusEnabledFlag: "true", + IncludeNexusSignalFlag: "true", + IncludeNexusUpdateFlag: "true", + SleepTimeFlag: "1ms", + VisibilityVerificationTimeoutFlag: "10s", + }), } + + _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) + require.NoError(t, err, scenarioInfo.RunID) + require.Equal(t, actionCounts{signals: 2, signalWithStarts: 1, updates: 1}, counts) + require.Equal(t, 1, startInputs) + require.Equal(t, wantMarkers, markers) } func TestThroughputStressConfigurePayload(t *testing.T) { @@ -415,7 +435,6 @@ func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *tes flag string }{ {name: "signal", flag: IncludeNexusSignalFlag}, - {name: "signal with start", flag: IncludeNexusSignalWithStartFlag}, {name: "update", flag: IncludeNexusUpdateFlag}, } { t.Run(tc.name, func(t *testing.T) { From 581ed81e3645ff7d90d164d296b82e6f99757918 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 19:49:28 -0700 Subject: [PATCH 29/35] Simplify Nexus workflow action test --- scenarios/throughput_stress_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 3f762247..7253aee7 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -235,7 +235,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { updates int } var counts actionCounts - markers := make(map[string]string) startInputs := 0 wantMarkers := map[string]string{ "nexus-signal": "complete", @@ -272,25 +271,16 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { require.Equal(t, 1, timers) require.Equal(t, 1, returns) } - recordMarkers := func(actions *ks.ActionSet) { - walkActions(actions.GetActions(), func(action *ks.Action) { - for key, value := range action.GetSetWorkflowState().GetKvs() { - markers[key] = value - } - }) - } switch { case workflowAction.GetStart() != nil: counts.starts++ case workflowAction.GetSignal() != nil: counts.signals++ - recordMarkers(workflowAction.GetSignal().GetDoSignalActions().GetDoActions()) if workflowAction.GetSignal().GetWithStart() { counts.signalWithStarts++ } case workflowAction.GetUpdate() != nil: counts.updates++ - recordMarkers(workflowAction.GetUpdate().GetDoActions().GetDoActions()) } }) } @@ -312,7 +302,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { require.NoError(t, err, scenarioInfo.RunID) require.Equal(t, actionCounts{signals: 2, signalWithStarts: 1, updates: 1}, counts) require.Equal(t, 1, startInputs) - require.Equal(t, wantMarkers, markers) } func TestThroughputStressConfigurePayload(t *testing.T) { From 54b57dc16f874ed88b7517a71d83f8af6acc7736 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 19:54:40 -0700 Subject: [PATCH 30/35] Update throughput_stress_test.go --- scenarios/throughput_stress_test.go | 54 +++++++++++++---------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 7253aee7..447408bd 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -235,12 +235,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { updates int } var counts actionCounts - startInputs := 0 - wantMarkers := map[string]string{ - "nexus-signal": "complete", - "nexus-signal-with-start": "complete", - "nexus-update": "complete", - } exec := newThroughputStressExecutor() exec.onActionsCreated = func(actionSets []*ks.ActionSet) { for _, actionSet := range actionSets { @@ -249,28 +243,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { if !strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { return } - if input := workflowAction.GetStartOptions().GetWorkflowInput(); input != nil { - startInputs++ - waiters := make(map[string]string) - timers := 0 - returns := 0 - for _, initialActions := range input.GetInitialActions() { - walkActions(initialActions.GetActions(), func(action *ks.Action) { - if await := action.GetAwaitWorkflowState(); await != nil { - waiters[await.GetKey()] = await.GetValue() - } - if action.GetTimer() != nil { - timers++ - } - if action.GetReturnResult() != nil { - returns++ - } - }) - } - require.Equal(t, wantMarkers, waiters) - require.Equal(t, 1, timers) - require.Equal(t, 1, returns) - } switch { case workflowAction.GetStart() != nil: counts.starts++ @@ -278,6 +250,31 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { counts.signals++ if workflowAction.GetSignal().GetWithStart() { counts.signalWithStarts++ + input := workflowAction.GetStartOptions().GetWorkflowInput() + require.NotNil(t, input) + waiters := make(map[string]string) + timers := 0 + returns := 0 + for _, initialActions := range input.GetInitialActions() { + walkActions(initialActions.GetActions(), func(action *ks.Action) { + if await := action.GetAwaitWorkflowState(); await != nil { + waiters[await.GetKey()] = await.GetValue() + } + if action.GetTimer() != nil { + timers++ + } + if action.GetReturnResult() != nil { + returns++ + } + }) + } + require.Equal(t, map[string]string{ + "nexus-signal": "complete", + "nexus-signal-with-start": "complete", + "nexus-update": "complete", + }, waiters) + require.Equal(t, 1, timers) + require.Equal(t, 1, returns) } case workflowAction.GetUpdate() != nil: counts.updates++ @@ -301,7 +298,6 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err, scenarioInfo.RunID) require.Equal(t, actionCounts{signals: 2, signalWithStarts: 1, updates: 1}, counts) - require.Equal(t, 1, startInputs) } func TestThroughputStressConfigurePayload(t *testing.T) { From b48e064a861a9125c2ff4643765ed4f462520fad Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 19:57:27 -0700 Subject: [PATCH 31/35] Simplify Nexus target action assertions --- scenarios/throughput_stress_test.go | 30 +++++++---------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 447408bd..dc4bd2a4 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -252,29 +252,13 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { counts.signalWithStarts++ input := workflowAction.GetStartOptions().GetWorkflowInput() require.NotNil(t, input) - waiters := make(map[string]string) - timers := 0 - returns := 0 - for _, initialActions := range input.GetInitialActions() { - walkActions(initialActions.GetActions(), func(action *ks.Action) { - if await := action.GetAwaitWorkflowState(); await != nil { - waiters[await.GetKey()] = await.GetValue() - } - if action.GetTimer() != nil { - timers++ - } - if action.GetReturnResult() != nil { - returns++ - } - }) - } - require.Equal(t, map[string]string{ - "nexus-signal": "complete", - "nexus-signal-with-start": "complete", - "nexus-update": "complete", - }, waiters) - require.Equal(t, 1, timers) - require.Equal(t, 1, returns) + require.Equal(t, ks.ListActionSet( + ks.NewAwaitWorkflowStateAction("nexus-signal-with-start", "complete"), + ks.NewAwaitWorkflowStateAction("nexus-signal", "complete"), + ks.NewAwaitWorkflowStateAction("nexus-update", "complete"), + ks.NewTimerAction(time.Millisecond), + ks.NewEmptyReturnResultAction(), + ), input.GetInitialActions()) } case workflowAction.GetUpdate() != nil: counts.updates++ From 3232d0617e21468e70c071b456e07d1e102a4dc7 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 20:05:05 -0700 Subject: [PATCH 32/35] Combine Nexus workflow action options --- docs/throughput-stress.md | 7 ++-- scenarios/throughput_stress.go | 56 ++++++++++------------------- scenarios/throughput_stress_test.go | 37 ++++++++----------- 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index 9163e3d3..ddc1efe9 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -95,11 +95,10 @@ Asking for `include-standalone-nexus=true` while Nexus is off is a contradiction The following opt-in options exercise actions from the Nexus handler: - `include-nexus-standalone-activity` -- `include-nexus-signal` -- `include-nexus-update` +- `include-nexus-workflow-actions` -The signal and update actions start their target workflow with signal-with-start. Enabling -`include-nexus-signal` also sends an ordinary signal to that target. +The workflow actions start their target workflow with signal-with-start, then send an ordinary +signal and an update to that target. The standalone activity action is driven two ways each iteration: as an in-workflow Nexus operation and, when standalone Nexus is part of the run, as a standalone Nexus operation. It also needs server diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index e1d1fa84..21e97b28 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -67,12 +67,9 @@ const ( // Opt-in and off by default (only the Go worker implements the operation); requires Nexus load // (nexus-enabled) and server support for standalone activities + activity completion callbacks. IncludeNexusStandaloneActivityFlag = "include-nexus-standalone-activity" - // IncludeNexusSignalFlag enables a Nexus operation that signals a workflow. + // IncludeNexusWorkflowActionsFlag enables Nexus operations that signal and update a workflow. // Opt-in and off by default; requires Nexus load (nexus-enabled). - IncludeNexusSignalFlag = "include-nexus-signal" - // IncludeNexusUpdateFlag enables a Nexus operation that updates a workflow. - // Opt-in and off by default; requires Nexus load (nexus-enabled). - IncludeNexusUpdateFlag = "include-nexus-update" + IncludeNexusWorkflowActionsFlag = "include-nexus-workflow-actions" // PayloadDistributionJsonFlag is a JSON string (or @file) configuring a weighted // activity payload-size distribution. See loadgen.PayloadConfig for details. PayloadDistributionJsonFlag = "payload-distribution-json" @@ -122,8 +119,7 @@ type tpsConfig struct { IncludeStandaloneActivity bool IncludeStandaloneActivityOperatorCommands bool IncludeNexusStandaloneActivity bool - IncludeNexusSignal bool - IncludeNexusUpdate bool + IncludeNexusWorkflowActions bool Payload *loadgen.PayloadConfig } @@ -164,8 +160,7 @@ func init() { return c.Namespace.GetStandaloneActivityOperatorCommands() }) o.Bool(IncludeNexusStandaloneActivityFlag, false, "Include a Nexus operation that starts a standalone activity (Go worker only).") - o.Bool(IncludeNexusSignalFlag, false, "Include a Nexus operation that signals a workflow (Go worker only).") - o.Bool(IncludeNexusUpdateFlag, false, "Include a Nexus operation that updates a workflow (Go worker only).") + o.Bool(IncludeNexusWorkflowActionsFlag, false, "Include Nexus operations that signal and update a workflow (Go worker only).") o.String(PayloadDistributionJsonFlag, "", "JSON payload-size distribution; use @ to read from a file.") }, ExecutorFn: func() loadgen.Executor { return newThroughputStressExecutor() }, @@ -269,13 +264,9 @@ func (t *tpsExecutor) Configure(info loadgen.ScenarioInfo) error { if config.IncludeNexusStandaloneActivity && !config.NexusEnabled { return fmt.Errorf("%s requires %s", IncludeNexusStandaloneActivityFlag, NexusEnabledFlag) } - config.IncludeNexusSignal = info.OptionBool(IncludeNexusSignalFlag) - config.IncludeNexusUpdate = info.OptionBool(IncludeNexusUpdateFlag) - if config.IncludeNexusSignal && !config.NexusEnabled { - return fmt.Errorf("%s requires %s", IncludeNexusSignalFlag, NexusEnabledFlag) - } - if config.IncludeNexusUpdate && !config.NexusEnabled { - return fmt.Errorf("%s requires %s", IncludeNexusUpdateFlag, NexusEnabledFlag) + config.IncludeNexusWorkflowActions = info.OptionBool(IncludeNexusWorkflowActionsFlag) + if config.IncludeNexusWorkflowActions && !config.NexusEnabled { + return fmt.Errorf("%s requires %s", IncludeNexusWorkflowActionsFlag, NexusEnabledFlag) } if payloadStr := info.OptionString(PayloadDistributionJsonFlag); payloadStr != "" { @@ -316,8 +307,7 @@ func (t *tpsExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error // Standalone operations are part of Nexus load, so they go with it. t.config.IncludeStandaloneNexus = false t.config.IncludeNexusStandaloneActivity = false - t.config.IncludeNexusSignal = false - t.config.IncludeNexusUpdate = false + t.config.IncludeNexusWorkflowActions = false } else { info.Logger.Infof("Using nexus endpoint %q", nexus.Endpoint) } @@ -650,7 +640,7 @@ func (t *tpsExecutor) createActionsChunk( ) } } - if t.config.IncludeNexusSignal || t.config.IncludeNexusUpdate { + if t.config.IncludeNexusWorkflowActions { nexusWorkflowID := fmt.Sprintf("%s/nexus-workflow-%d", run.DefaultStartWorkflowOptions().ID, iterationIndex) // Keep this sequence sequential so signal-with-start creates the target before the remaining actions. syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID)) @@ -978,27 +968,19 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string) *Action { targetWorkflowActions := []*Action{ NewAwaitWorkflowStateAction(nexusSignalWithStartStateKey, nexusActionComplete), + NewAwaitWorkflowStateAction(nexusSignalStateKey, nexusActionComplete), + NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), + // Yield a workflow task so update completion is recorded before the target closes. + NewTimerAction(time.Millisecond), + // Complete the target after every configured action has marked itself complete. + NewEmptyReturnResultAction(), } - if t.config.IncludeNexusSignal { - targetWorkflowActions = append(targetWorkflowActions, - NewAwaitWorkflowStateAction(nexusSignalStateKey, nexusActionComplete)) - } - if t.config.IncludeNexusUpdate { - targetWorkflowActions = append(targetWorkflowActions, - NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete)) - } - // Yield a workflow task so update completion is recorded before the target closes. - targetWorkflowActions = append(targetWorkflowActions, NewTimerAction(time.Millisecond)) - // Complete the target after every configured action has marked itself complete. - targetWorkflowActions = append(targetWorkflowActions, NewEmptyReturnResultAction()) targetWorkflowInput := &WorkflowInput{InitialActions: ListActionSet(targetWorkflowActions...)} - targetActions := []*Action{t.createNexusSignalWithStartAction(workflowID, targetWorkflowInput)} - if t.config.IncludeNexusSignal { - targetActions = append(targetActions, t.createNexusSignalAction(workflowID)) - } - if t.config.IncludeNexusUpdate { - targetActions = append(targetActions, t.createNexusUpdateAction(workflowID)) + targetActions := []*Action{ + t.createNexusSignalWithStartAction(workflowID, targetWorkflowInput), + t.createNexusSignalAction(workflowID), + t.createNexusUpdateAction(workflowID), } return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: targetActions}}} } diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index dc4bd2a4..f992d873 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -272,8 +272,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ IterFlag: "1", NexusEnabledFlag: "true", - IncludeNexusSignalFlag: "true", - IncludeNexusUpdateFlag: "true", + "include-nexus-workflow-actions": "true", SleepTimeFlag: "1ms", VisibilityVerificationTimeoutFlag: "10s", }), @@ -399,29 +398,21 @@ func TestThroughputStressConfigureExplicitStandaloneNexusRequiresNexusEnabled(t func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *testing.T) { t.Parallel() - for _, tc := range []struct { - name string - flag string - }{ - {name: "signal", flag: IncludeNexusSignalFlag}, - {name: "update", flag: IncludeNexusUpdateFlag}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + const flag = "include-nexus-workflow-actions" + options, err := loadgen.GetScenario("throughput_stress").ResolveOptions(map[string]string{ + flag: "true", + NexusEnabledFlag: "false", + }) + require.NoError(t, err) - err := newThroughputStressExecutor().Configure(loadgen.ScenarioInfo{ - RunID: "tps-nexus-workflow-action", - Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ - tc.flag: "true", - NexusEnabledFlag: "false", - }), - }) + err = newThroughputStressExecutor().Configure(loadgen.ScenarioInfo{ + RunID: "tps-nexus-workflow-action", + Options: options, + }) - require.Error(t, err) - require.Contains(t, err.Error(), tc.flag) - require.Contains(t, err.Error(), NexusEnabledFlag) - }) - } + require.Error(t, err) + require.Contains(t, err.Error(), flag) + require.Contains(t, err.Error(), NexusEnabledFlag) } func TestThroughputStressConfigureInvalidPayload(t *testing.T) { From ca4a8e137d8c7fc0e6a7de0b79be199f34cde47f Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 21:24:54 -0700 Subject: [PATCH 33/35] Address Nexus throughput review feedback --- docs/throughput-stress.md | 5 +++- loadgen/generic_executor.go | 34 ++++++++++++++------------ loadgen/generic_executor_test.go | 35 ++++++++++++++++++++++++++ scenarios/throughput_stress.go | 37 ++++++++++++---------------- scenarios/throughput_stress_test.go | 38 ++++++++++++++++++----------- 5 files changed, 98 insertions(+), 51 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index ddc1efe9..caffe53c 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -98,7 +98,10 @@ The following opt-in options exercise actions from the Nexus handler: - `include-nexus-workflow-actions` The workflow actions start their target workflow with signal-with-start, then send an ordinary -signal and an update to that target. +signal and an update to that target. The update completes asynchronously through a completion +callback, so this needs server support for CHASM callbacks and update callbacks (dynamic config +`history.enableChasm`, `history.enableCHASMCallbacks`, `history.enableCHASMSignalBacklinks` and +`history.enableUpdateCallbacks`). The standalone activity action is driven two ways each iteration: as an in-workflow Nexus operation and, when standalone Nexus is part of the run, as a standalone Nexus operation. It also needs server diff --git a/loadgen/generic_executor.go b/loadgen/generic_executor.go index 3e656c2b..2a02436a 100644 --- a/loadgen/generic_executor.go +++ b/loadgen/generic_executor.go @@ -154,24 +154,28 @@ func (g *genericRun) Run(ctx context.Context) error { // cancellation while the run is healthy. stopping := iterErr != nil && ctx.Err() != nil && errors.Is(iterErr, context.Canceled) + if stopping { + g.logger.Debugf("Iteration %v abandoned: run is stopping", run.Iteration) + return + } + if ctx.Err() != nil { + return + } + if iterErr == nil { + run.Duration = elapsed + g.completed.Add(1) + if g.config.OnCompletion != nil { + g.config.OnCompletion(ctx, run) + } + } else { + g.failed.Add(1) + if g.config.OnIterationFailure != nil { + g.config.OnIterationFailure(ctx, run, iterErr) + } + } select { case <-ctx.Done(): case doneCh <- err: - switch { - case stopping: - g.logger.Debugf("Iteration %v abandoned: run is stopping", run.Iteration) - case iterErr == nil: - run.Duration = elapsed - g.completed.Add(1) - if g.config.OnCompletion != nil { - g.config.OnCompletion(ctx, run) - } - default: - g.failed.Add(1) - if g.config.OnIterationFailure != nil { - g.config.OnIterationFailure(ctx, run, iterErr) - } - } } }() diff --git a/loadgen/generic_executor_test.go b/loadgen/generic_executor_test.go index 1c2fae4f..15a9efd3 100644 --- a/loadgen/generic_executor_test.go +++ b/loadgen/generic_executor_test.go @@ -62,6 +62,41 @@ func TestRunHappyPathIterations(t *testing.T) { }) } +func TestRunWaitsForCompletionCallback(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + callbackStarted := make(chan struct{}) + releaseCallback := make(chan struct{}) + runDone := make(chan error, 1) + go func() { + runDone <- execute(&GenericExecutor{ + Execute: func(context.Context, *Run) error { return nil }, + }, RunConfiguration{ + Iterations: 1, + MaxConcurrent: 1, + OnCompletion: func(context.Context, *Run) { + close(callbackStarted) + <-releaseCallback + }, + }) + }() + + <-callbackStarted + synctest.Wait() + var earlyErr error + returnedEarly := false + select { + case earlyErr = <-runDone: + returnedEarly = true + default: + } + close(releaseCallback) + if returnedEarly { + require.Failf(t, "run returned before completion callback", "error: %v", earlyErr) + } + require.NoError(t, <-runDone) + }) +} + func TestRunFailIterations(t *testing.T) { synctest.Test(t, func(t *testing.T) { tracker := newIterationTracker() diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index 21e97b28..ea02c8d8 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -76,10 +76,8 @@ const ( ) const ( - nexusSignalStateKey = "nexus-signal" - nexusSignalWithStartStateKey = "nexus-signal-with-start" - nexusUpdateStateKey = "nexus-update" - nexusActionComplete = "complete" + nexusUpdateStateKey = "nexus-update" + nexusActionComplete = "complete" ) type tpsState struct { @@ -124,12 +122,13 @@ type tpsConfig struct { } type tpsExecutor struct { - lock sync.Mutex - state *tpsState - config *tpsConfig - isResuming bool - runID string - rng *rand.Rand + lock sync.Mutex + state *tpsState + config *tpsConfig + isResuming bool + runID string + rng *rand.Rand + // onActionsCreated observes generated actions and may be called concurrently by iteration goroutines. onActionsCreated func([]*ActionSet) } @@ -642,8 +641,7 @@ func (t *tpsExecutor) createActionsChunk( } if t.config.IncludeNexusWorkflowActions { nexusWorkflowID := fmt.Sprintf("%s/nexus-workflow-%d", run.DefaultStartWorkflowOptions().ID, iterationIndex) - // Keep this sequence sequential so signal-with-start creates the target before the remaining actions. - syncActions = append(syncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID)) + asyncActions = append(asyncActions, t.createNexusWorkflowActionSequence(nexusWorkflowID)) } } @@ -967,12 +965,11 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { // createNexusWorkflowActionSequence starts a workflow that waits for the configured actions before completing. func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string) *Action { targetWorkflowActions := []*Action{ - NewAwaitWorkflowStateAction(nexusSignalWithStartStateKey, nexusActionComplete), - NewAwaitWorkflowStateAction(nexusSignalStateKey, nexusActionComplete), + // Only the update writes state because SetWorkflowState replaces the entire map. NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), // Yield a workflow task so update completion is recorded before the target closes. NewTimerAction(time.Millisecond), - // Complete the target after every configured action has marked itself complete. + // Complete the target after the final update has marked the sequence complete. NewEmptyReturnResultAction(), } targetWorkflowInput := &WorkflowInput{InitialActions: ListActionSet(targetWorkflowActions...)} @@ -994,9 +991,7 @@ func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ Variant: &DoSignal_DoSignalActions_DoActions{ - DoActions: SingleActionSet( - NewSetWorkflowStateAction(nexusSignalStateKey, nexusActionComplete), - ), + DoActions: SingleActionSet(NewTimerAction(time.Millisecond)), }, }}, }}, @@ -1015,9 +1010,9 @@ func (t *tpsExecutor) createNexusSignalWithStartAction(workflowID string, workfl StartOptions: &NexusWorkflowStartOptions{WorkflowInput: workflowInput}, Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{DoActions: SingleActionSet( - NewSetWorkflowStateAction(nexusSignalWithStartStateKey, nexusActionComplete), - )}, + Variant: &DoSignal_DoSignalActions_DoActions{ + DoActions: SingleActionSet(NewTimerAction(time.Millisecond)), + }, }}, WithStart: true, }}, diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index f992d873..eb351c23 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "strings" + "sync" "testing" "time" @@ -16,6 +17,7 @@ import ( "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/converter" "go.uber.org/zap" + "google.golang.org/protobuf/proto" ) func TestThroughputStress(t *testing.T) { @@ -166,9 +168,12 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { inWorkflow int standalone int } + var countsMu sync.Mutex var counts actionCounts exec := newThroughputStressExecutor() exec.onActionsCreated = func(actionSets []*ks.ActionSet) { + countsMu.Lock() + defer countsMu.Unlock() for _, actionSet := range actionSets { walkActions(actionSet.GetActions(), func(action *ks.Action) { if action.GetNexusOperation().GetInput().GetStartActivity() != nil { @@ -204,6 +209,8 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err) + countsMu.Lock() + defer countsMu.Unlock() require.Equal(t, actionCounts{inWorkflow: 1, standalone: 1}, counts) } @@ -234,9 +241,13 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { signalWithStarts int updates int } + var countsMu sync.Mutex var counts actionCounts + var targetWorkflowInput *ks.WorkflowInput exec := newThroughputStressExecutor() exec.onActionsCreated = func(actionSets []*ks.ActionSet) { + countsMu.Lock() + defer countsMu.Unlock() for _, actionSet := range actionSets { walkActions(actionSet.GetActions(), func(action *ks.Action) { workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() @@ -250,15 +261,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { counts.signals++ if workflowAction.GetSignal().GetWithStart() { counts.signalWithStarts++ - input := workflowAction.GetStartOptions().GetWorkflowInput() - require.NotNil(t, input) - require.Equal(t, ks.ListActionSet( - ks.NewAwaitWorkflowStateAction("nexus-signal-with-start", "complete"), - ks.NewAwaitWorkflowStateAction("nexus-signal", "complete"), - ks.NewAwaitWorkflowStateAction("nexus-update", "complete"), - ks.NewTimerAction(time.Millisecond), - ks.NewEmptyReturnResultAction(), - ), input.GetInitialActions()) + targetWorkflowInput = workflowAction.GetStartOptions().GetWorkflowInput() } case workflowAction.GetUpdate() != nil: counts.updates++ @@ -272,7 +275,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { Options: loadgen.MustResolveScenarioOptions("throughput_stress", map[string]string{ IterFlag: "1", NexusEnabledFlag: "true", - "include-nexus-workflow-actions": "true", + IncludeNexusWorkflowActionsFlag: "true", SleepTimeFlag: "1ms", VisibilityVerificationTimeoutFlag: "10s", }), @@ -280,7 +283,15 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err, scenarioInfo.RunID) + countsMu.Lock() + defer countsMu.Unlock() require.Equal(t, actionCounts{signals: 2, signalWithStarts: 1, updates: 1}, counts) + require.NotNil(t, targetWorkflowInput) + require.True(t, proto.Equal(&ks.WorkflowInput{InitialActions: ks.ListActionSet( + ks.NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), + ks.NewTimerAction(time.Millisecond), + ks.NewEmptyReturnResultAction(), + )}, targetWorkflowInput)) } func TestThroughputStressConfigurePayload(t *testing.T) { @@ -398,10 +409,9 @@ func TestThroughputStressConfigureExplicitStandaloneNexusRequiresNexusEnabled(t func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *testing.T) { t.Parallel() - const flag = "include-nexus-workflow-actions" options, err := loadgen.GetScenario("throughput_stress").ResolveOptions(map[string]string{ - flag: "true", - NexusEnabledFlag: "false", + IncludeNexusWorkflowActionsFlag: "true", + NexusEnabledFlag: "false", }) require.NoError(t, err) @@ -411,7 +421,7 @@ func TestThroughputStressConfigureNexusWorkflowActionsRequireNexusEnabled(t *tes }) require.Error(t, err) - require.Contains(t, err.Error(), flag) + require.Contains(t, err.Error(), IncludeNexusWorkflowActionsFlag) require.Contains(t, err.Error(), NexusEnabledFlag) } From fd9740007fdf1640cc44ead55210f5333d8b14dc Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 23:36:02 -0700 Subject: [PATCH 34/35] tweak --- docs/throughput-stress.md | 18 ++--- scenarios/throughput_stress.go | 31 ++------ scenarios/throughput_stress_test.go | 117 ++++++++++++---------------- 3 files changed, 63 insertions(+), 103 deletions(-) diff --git a/docs/throughput-stress.md b/docs/throughput-stress.md index caffe53c..1659f20f 100644 --- a/docs/throughput-stress.md +++ b/docs/throughput-stress.md @@ -92,19 +92,13 @@ Asking for `include-standalone-nexus=true` while Nexus is off is a contradiction ## Nexus operation actions -The following opt-in options exercise actions from the Nexus handler: +### `include-nexus-workflow-actions` -- `include-nexus-standalone-activity` -- `include-nexus-workflow-actions` +The workflow actions start their target workflow with signal-with-start, update it, then send an +ordinary signal that completes it. This requires Nexus update callback support. -The workflow actions start their target workflow with signal-with-start, then send an ordinary -signal and an update to that target. The update completes asynchronously through a completion -callback, so this needs server support for CHASM callbacks and update callbacks (dynamic config -`history.enableChasm`, `history.enableCHASMCallbacks`, `history.enableCHASMSignalBacklinks` and -`history.enableUpdateCallbacks`). +### `include-nexus-standalone-activity` The standalone activity action is driven two ways each iteration: as an in-workflow Nexus operation -and, when standalone Nexus is part of the run, as a standalone Nexus operation. It also needs server -support for standalone activities and activity completion callbacks (dynamic config -`activity.enableStandalone` and `activity.enableCallbacks`) and a Nexus callback URL; if those are -off, the operation fails clearly rather than being skipped. +and, when standalone Nexus is part of the run, as a standalone Nexus operation. This requires +standalone activity and callback support. diff --git a/scenarios/throughput_stress.go b/scenarios/throughput_stress.go index ea02c8d8..c6cb7be4 100644 --- a/scenarios/throughput_stress.go +++ b/scenarios/throughput_stress.go @@ -75,11 +75,6 @@ const ( PayloadDistributionJsonFlag = "payload-distribution-json" ) -const ( - nexusUpdateStateKey = "nexus-update" - nexusActionComplete = "complete" -) - type tpsState struct { // CompletedIterations is the number of iteration that have been completed. CompletedIterations int `json:"completedIterations"` @@ -962,24 +957,13 @@ func (t *tpsExecutor) createNexusStandaloneActivityAction() *Action { }) } -// createNexusWorkflowActionSequence starts a workflow that waits for the configured actions before completing. +// createNexusWorkflowActionSequence exercises workflow messaging through one Nexus target. func (t *tpsExecutor) createNexusWorkflowActionSequence(workflowID string) *Action { - targetWorkflowActions := []*Action{ - // Only the update writes state because SetWorkflowState replaces the entire map. - NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), - // Yield a workflow task so update completion is recorded before the target closes. - NewTimerAction(time.Millisecond), - // Complete the target after the final update has marked the sequence complete. - NewEmptyReturnResultAction(), - } - targetWorkflowInput := &WorkflowInput{InitialActions: ListActionSet(targetWorkflowActions...)} - - targetActions := []*Action{ - t.createNexusSignalWithStartAction(workflowID, targetWorkflowInput), - t.createNexusSignalAction(workflowID), + return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: []*Action{ + t.createNexusSignalWithStartAction(workflowID, &WorkflowInput{}), t.createNexusUpdateAction(workflowID), - } - return &Action{Variant: &Action_NestedActionSet{NestedActionSet: &ActionSet{Actions: targetActions}}} + t.createNexusSignalAction(workflowID), + }}}} } func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { @@ -990,8 +974,8 @@ func (t *tpsExecutor) createNexusSignalAction(workflowID string) *Action { WorkflowId: workflowID, Action: &NexusWorkflowAction_Signal{Signal: &DoSignal{ Variant: &DoSignal_DoSignalActions_{DoSignalActions: &DoSignal_DoSignalActions{ - Variant: &DoSignal_DoSignalActions_DoActions{ - DoActions: SingleActionSet(NewTimerAction(time.Millisecond)), + Variant: &DoSignal_DoSignalActions_DoActionsInMain{ + DoActionsInMain: SingleActionSet(NewEmptyReturnResultAction()), }, }}, }}, @@ -1031,7 +1015,6 @@ func (t *tpsExecutor) createNexusUpdateAction(workflowID string) *Action { Action: &NexusWorkflowAction_Update{Update: &DoUpdate{ Variant: &DoUpdate_DoActions{DoActions: &DoActionsUpdate{ Variant: &DoActionsUpdate_DoActions{DoActions: SingleActionSet( - NewSetWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), // The update handler's return value is itself encoded by the data converter // before the server forwards it to the Nexus completion callback, so the value // is wrapped twice here: the caller decodes the outer layer and compares the diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index eb351c23..20893168 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -4,7 +4,6 @@ import ( "fmt" "math/rand" "strings" - "sync" "testing" "time" @@ -168,30 +167,10 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { inWorkflow int standalone int } - var countsMu sync.Mutex - var counts actionCounts + actionsCreated := make(chan []*ks.ActionSet, 1) exec := newThroughputStressExecutor() - exec.onActionsCreated = func(actionSets []*ks.ActionSet) { - countsMu.Lock() - defer countsMu.Unlock() - for _, actionSet := range actionSets { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - if action.GetNexusOperation().GetInput().GetStartActivity() != nil { - counts.inWorkflow++ - } - // Find the nested standalone-Nexus client action. - if sequence := action.GetExecActivity().GetClient().GetClientSequence(); sequence != nil { - for _, clientActionSet := range sequence.GetActionSets() { - for _, clientAction := range clientActionSet.GetActions() { - operation := clientAction.GetDoStandaloneNexusOperation().GetOperation() - if operation.GetInput().GetStartActivity() != nil { - counts.standalone++ - } - } - } - } - }) - } + exec.onActionsCreated = func(actions []*ks.ActionSet) { + actionsCreated <- actions } scenarioInfo := loadgen.ScenarioInfo{ RunID: "tps-nsa-actions", @@ -209,8 +188,25 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err) - countsMu.Lock() - defer countsMu.Unlock() + var counts actionCounts + for _, actionSet := range <-actionsCreated { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + if action.GetNexusOperation().GetInput().GetStartActivity() != nil { + counts.inWorkflow++ + } + // Find the nested standalone-Nexus client action. + if sequence := action.GetExecActivity().GetClient().GetClientSequence(); sequence != nil { + for _, clientActionSet := range sequence.GetActionSets() { + for _, clientAction := range clientActionSet.GetActions() { + operation := clientAction.GetDoStandaloneNexusOperation().GetOperation() + if operation.GetInput().GetStartActivity() != nil { + counts.standalone++ + } + } + } + } + }) + } require.Equal(t, actionCounts{inWorkflow: 1, standalone: 1}, counts) } @@ -235,39 +231,10 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { workertest.WithExecutorTimeout(time.Minute), workertest.WithDevServer(server)) - type actionCounts struct { - starts int - signals int - signalWithStarts int - updates int - } - var countsMu sync.Mutex - var counts actionCounts - var targetWorkflowInput *ks.WorkflowInput + actionsCreated := make(chan []*ks.ActionSet, 1) exec := newThroughputStressExecutor() - exec.onActionsCreated = func(actionSets []*ks.ActionSet) { - countsMu.Lock() - defer countsMu.Unlock() - for _, actionSet := range actionSets { - walkActions(actionSet.GetActions(), func(action *ks.Action) { - workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() - if !strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { - return - } - switch { - case workflowAction.GetStart() != nil: - counts.starts++ - case workflowAction.GetSignal() != nil: - counts.signals++ - if workflowAction.GetSignal().GetWithStart() { - counts.signalWithStarts++ - targetWorkflowInput = workflowAction.GetStartOptions().GetWorkflowInput() - } - case workflowAction.GetUpdate() != nil: - counts.updates++ - } - }) - } + exec.onActionsCreated = func(actions []*ks.ActionSet) { + actionsCreated <- actions } scenarioInfo := loadgen.ScenarioInfo{ RunID: "nexus-workflow-actions-signal-with-start", @@ -283,15 +250,31 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err, scenarioInfo.RunID) - countsMu.Lock() - defer countsMu.Unlock() - require.Equal(t, actionCounts{signals: 2, signalWithStarts: 1, updates: 1}, counts) - require.NotNil(t, targetWorkflowInput) - require.True(t, proto.Equal(&ks.WorkflowInput{InitialActions: ks.ListActionSet( - ks.NewAwaitWorkflowStateAction(nexusUpdateStateKey, nexusActionComplete), - ks.NewTimerAction(time.Millisecond), - ks.NewEmptyReturnResultAction(), - )}, targetWorkflowInput)) + var workflowActions []*ks.NexusWorkflowAction + for _, actionSet := range <-actionsCreated { + walkActions(actionSet.GetActions(), func(action *ks.Action) { + workflowAction := action.GetNexusOperation().GetInput().GetWorkflowAction() + if strings.Contains(workflowAction.GetWorkflowId(), "/nexus-workflow-") { + workflowActions = append(workflowActions, workflowAction) + } + }) + } + require.Len(t, workflowActions, 3) + + signalWithStartAction := workflowActions[0] + require.True(t, signalWithStartAction.GetSignal().GetWithStart()) + require.True(t, proto.Equal(&ks.WorkflowInput{}, signalWithStartAction.GetStartOptions().GetWorkflowInput())) + + updateAction := workflowActions[1] + require.NotNil(t, updateAction.GetUpdate()) + + signalAction := workflowActions[2] + require.NotNil(t, signalAction.GetSignal()) + require.False(t, signalAction.GetSignal().GetWithStart()) + require.True(t, proto.Equal( + ks.SingleActionSet(ks.NewEmptyReturnResultAction()), + signalAction.GetSignal().GetDoSignalActions().GetDoActionsInMain(), + )) } func TestThroughputStressConfigurePayload(t *testing.T) { From 4c8e104529f051287c63277b6b02bb87a6b67301 Mon Sep 17 00:00:00 2001 From: Stephan Behnke Date: Tue, 8 Sep 2026 23:36:17 -0700 Subject: [PATCH 35/35] Update throughput_stress_test.go --- scenarios/throughput_stress_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scenarios/throughput_stress_test.go b/scenarios/throughput_stress_test.go index 20893168..2485d1b3 100644 --- a/scenarios/throughput_stress_test.go +++ b/scenarios/throughput_stress_test.go @@ -188,6 +188,7 @@ func TestThroughputStressNexusStandaloneActivityActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err) + var counts actionCounts for _, actionSet := range <-actionsCreated { walkActions(actionSet.GetActions(), func(action *ks.Action) { @@ -250,6 +251,7 @@ func TestThroughputStressNexusWorkflowActions(t *testing.T) { _, err := env.RunExecutorTest(t, exec, scenarioInfo, clioptions.LangGo) require.NoError(t, err, scenarioInfo.RunID) + var workflowActions []*ks.NexusWorkflowAction for _, actionSet := range <-actionsCreated { walkActions(actionSet.GetActions(), func(action *ks.Action) {