Files
tasks/chasm/lib/workflow/validator_test.go
7fec4a9a3f Implement SignalWithStart as a system nexus endpoint (#9833)
## What changed?
This PR adds `SignalWithStartWorkflowExecution` as a synchronous Nexus
operation exposed via `__temporal_system endpoint`, allowing workflows
to signal-with-start other workflows through the CHASM Nexus operation
framework.

Key changes:
- `chasm/lib/workflow/nexus_service.go`: `workflowServiceNexusHandler`
implements the `SignalWithStartWorkflowExecution` Nexus sync operation
by resolving the namespace and delegating to the History service. A
`SignalWithStartOperationProcessor` handles input enrichment (namespace,
request ID, links) and routing via CHASM's
`NexusOperationProcessorResult`.
- `chasm/lib/workflow/library.go` — library now holds the
`workflowServiceNexusHandler`, the workflow `Config`, the SA mapper
provider, and the SA validator. `newLibrary` (used by fx) takes those
dependencies, while the public `NewLibrary` keeps its old signature for
external callers. Adds `NexusServices()` so the library registers its
Nexus service via CHASM.
- `chasm/lib/workflow/validator.go`: `RequestValidator` consolidates the
`SignalWithStartWorkflowExecution` validation logic (previously inlined
in `WorkflowHandler`) into a reusable, injectable struct. This same
validator is used by both the frontend handler and the new CHASM
processor.
- common/dynamicconfig/constants.go — adds
`EnableSignalWithStartFromWorkflow` (namespace-scoped, default false).
- `service/frontend/workflow_handler.go`: Removed the `SignalWithStart`
validation block
- `service/history/fx.go`: Provides a `HistoryServiceServerProvider` so
the CHASM workflow library can call the history handler directly.
- `temporal/fx.go`: Removes the now-redundant `ChasmLibraryOptions`
grouping; each service module registers its own CHASM libraries.
- `components/nexusoperations/workflow/commands.go`: `NotFound` and
`InvalidArgument` errors during Nexus command handling are now surfaced
as workflow task failures instead of being treated as transient handler
errors.
- `common/payloads`: Adds `EncodeSingle`, `MustEncodeSingle`, and
`MustEncode` helpers used in tests.
- `cmd/tools/getproto`: Adds support for nexus-proto-annotations proto
imports.
- `tests/signal_with_start_from_workflow_test.go`: Functional test suite
covering the happy path, duplicate detection, conflict policies, and
validation rejection for the new Nexus operation.


## Why?
This functionality is one of our most requested GitHub issues.

## How did you test it?
- [X] built
- [X] run locally and tested manually
- [ ] covered by existing tests
- [X] added new unit test(s)
- [X] added new functional test(s)

## Potential risks
The history service now directly exposes a `HistoryServiceServer`
interface via `fx` for injection into the CHASM workflow library. This
tight coupling between the CHASM workflow library and the history
handler could complicate future layering — callers outside the history
service should not adopt this pattern. The feature is gated by
`history.enableSignalWithStartFromWorkflow` for rollout.

---------

Co-authored-by: Roey Berman <[email protected]>
2026-05-20 21:49:35 +00:00

157 lines
5.6 KiB
Go

package workflow
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/retrypolicy"
"go.temporal.io/server/common/searchattribute"
"google.golang.org/protobuf/types/known/durationpb"
)
const testMaxIDLen = 1000
func newTestValidator() *RequestValidator {
saValidator := searchattribute.NewValidator(
searchattribute.NewTestProvider(),
searchattribute.NewTestMapperProvider(nil),
dynamicconfig.GetIntPropertyFnFilteredByNamespace(100),
dynamicconfig.GetIntPropertyFnFilteredByNamespace(1024),
dynamicconfig.GetIntPropertyFnFilteredByNamespace(4096),
nil, // visibility manager not needed when SA is nil
dynamicconfig.GetBoolPropertyFnFilteredByNamespace(true),
dynamicconfig.GetBoolPropertyFnFilteredByNamespace(false),
)
return NewValidator(
Config{
maxIDLengthLimit: func() int { return testMaxIDLen },
defaultWorkflowRetrySettings: func(ns string) retrypolicy.DefaultRetrySettings {
return retrypolicy.DefaultDefaultRetrySettings
},
maxLinksPerRequest: func(ns string) int { return 10 },
linkMaxSize: func(ns string) int { return 1024 },
},
searchattribute.NewTestMapperProvider(nil),
saValidator,
)
}
func validSWSRequest() *workflowservice.SignalWithStartWorkflowExecutionRequest {
return &workflowservice.SignalWithStartWorkflowExecutionRequest{
WorkflowId: "test-workflow-id",
SignalName: "test-signal",
WorkflowType: &commonpb.WorkflowType{Name: "test-workflow-type"},
TaskQueue: &taskqueuepb.TaskQueue{Name: "test-task-queue"},
}
}
func TestValidateSignalWithStartRequest(t *testing.T) {
v := newTestValidator()
t.Run("HappyPath", func(t *testing.T) {
req := validSWSRequest()
err := v.ValidateSignalWithStartRequest(req)
require.NoError(t, err)
})
t.Run("NilRequest", func(t *testing.T) {
err := v.ValidateSignalWithStartRequest(nil)
require.ErrorContains(t, err, "request is empty")
})
t.Run("EmptyWorkflowID", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowId = ""
err := v.ValidateSignalWithStartRequest(req)
require.ErrorIs(t, err, ErrWorkflowIDNotSet)
})
t.Run("WorkflowIDTooLong", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowId = strings.Repeat("a", testMaxIDLen+1)
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "WorkflowId exceeds maximum allowed length")
})
t.Run("EmptySignalName", func(t *testing.T) {
req := validSWSRequest()
req.SignalName = ""
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "signal not set")
})
t.Run("SignalNameTooLong", func(t *testing.T) {
req := validSWSRequest()
req.SignalName = strings.Repeat("s", testMaxIDLen+1)
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "signal name exceeds maximum allowed length")
})
t.Run("EmptyWorkflowType", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowType = &commonpb.WorkflowType{Name: ""}
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "workflow type not set")
})
t.Run("WorkflowTypeTooLong", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowType = &commonpb.WorkflowType{Name: strings.Repeat("t", testMaxIDLen+1)}
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "workflow type name exceeds maximum allowed length")
})
t.Run("AutoGeneratesRequestID", func(t *testing.T) {
req := validSWSRequest()
req.RequestId = ""
err := v.ValidateSignalWithStartRequest(req)
require.NoError(t, err)
require.NotEmpty(t, req.RequestId, "empty RequestId should be auto-populated")
})
t.Run("RequestIDTooLong", func(t *testing.T) {
req := validSWSRequest()
req.RequestId = strings.Repeat("r", testMaxIDLen+1)
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "Request ID exceeds maximum allowed length")
})
t.Run("ConflictPolicyFailNotSupported", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowIdConflictPolicy = enumspb.WORKFLOW_ID_CONFLICT_POLICY_FAIL
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "WORKFLOW_ID_CONFLICT_POLICY_FAIL is not supported")
})
t.Run("IncompatibleTerminateIfRunningWithConflictPolicy", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowIdReusePolicy = enumspb.WORKFLOW_ID_REUSE_POLICY_TERMINATE_IF_RUNNING //nolint:staticcheck // SA1019: testing backwards-compatible validation path
req.WorkflowIdConflictPolicy = enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "WORKFLOW_ID_REUSE_POLICY_TERMINATE_IF_RUNNING cannot be used together with a WorkflowIDConflictPolicy")
})
t.Run("IncompatibleRejectDuplicateWithTerminateExisting", func(t *testing.T) {
req := validSWSRequest()
req.WorkflowIdReusePolicy = enumspb.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE
req.WorkflowIdConflictPolicy = enumspb.WORKFLOW_ID_CONFLICT_POLICY_TERMINATE_EXISTING
err := v.ValidateSignalWithStartRequest(req)
require.ErrorContains(t, err, "WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE cannot be used together with WorkflowIdConflictPolicy WORKFLOW_ID_CONFLICT_POLICY_TERMINATE_EXISTING")
})
t.Run("CronAndStartDelaySetTogether", func(t *testing.T) {
req := validSWSRequest()
req.CronSchedule = "0 * * * *"
req.WorkflowStartDelay = durationpb.New(5 * time.Second)
err := v.ValidateSignalWithStartRequest(req)
require.ErrorIs(t, err, ErrCronAndStartDelaySet)
})
}