mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## 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]>
357 lines
14 KiB
Go
357 lines
14 KiB
Go
package workflow
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nexus-rpc/sdk-go/nexus"
|
|
commandpb "go.temporal.io/api/command/v1"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
historypb "go.temporal.io/api/history/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/server/chasm"
|
|
chasmworkflow "go.temporal.io/server/chasm/lib/workflow"
|
|
commonnexus "go.temporal.io/server/common/nexus"
|
|
"go.temporal.io/server/common/primitives/timestamp"
|
|
"go.temporal.io/server/components/nexusoperations"
|
|
"go.temporal.io/server/service/history/hsm"
|
|
historyi "go.temporal.io/server/service/history/interfaces"
|
|
"go.temporal.io/server/service/history/workflow"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
)
|
|
|
|
type commandHandler struct {
|
|
config *nexusoperations.Config
|
|
endpointRegistry commonnexus.EndpointRegistry
|
|
nexusProcessor *chasm.NexusEndpointProcessor
|
|
}
|
|
|
|
func (ch *commandHandler) HandleScheduleCommand(
|
|
ctx context.Context,
|
|
ms historyi.MutableState,
|
|
validator chasmworkflow.Validator,
|
|
workflowTaskCompletedEventID int64,
|
|
command *commandpb.Command,
|
|
) error {
|
|
ns := ms.GetNamespaceEntry()
|
|
nsName := ms.GetNamespaceEntry().Name().String()
|
|
|
|
attrs := command.GetScheduleNexusOperationCommandAttributes()
|
|
if attrs == nil {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: "empty ScheduleNexusOperationCommandAttributes",
|
|
}
|
|
}
|
|
|
|
requestID := uuid.NewString()
|
|
var endpointID string
|
|
// Skip endpoint registry lookup for __temporal_system endpoint
|
|
if attrs.Endpoint == commonnexus.SystemEndpoint {
|
|
if len(attrs.NexusHeader) > 0 {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("ScheduleNexusOperationCommandAttributes.NexusHeader must be empty when using %s endpoint", commonnexus.SystemEndpoint),
|
|
}
|
|
}
|
|
// Run ProcessInput for validation.
|
|
_, err := ch.nexusProcessor.ProcessInput(chasm.NexusOperationProcessorContext{
|
|
Namespace: ns,
|
|
RequestID: requestID,
|
|
// Links are not needed for validation.
|
|
}, attrs.Service, attrs.Operation, attrs.Input)
|
|
if err != nil {
|
|
_, isNotFound := errors.AsType[*serviceerror.NotFound](err)
|
|
_, isInvalidArgument := errors.AsType[*serviceerror.InvalidArgument](err)
|
|
if isNotFound || isInvalidArgument {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: err.Error(),
|
|
}
|
|
}
|
|
handlerErr, isHandlerError := errors.AsType[*nexus.HandlerError](err)
|
|
if isHandlerError {
|
|
// nolint:exhaustive
|
|
switch handlerErr.Type {
|
|
case nexus.HandlerErrorTypeNotFound, nexus.HandlerErrorTypeBadRequest:
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: handlerErr.Message,
|
|
}
|
|
}
|
|
|
|
}
|
|
return err
|
|
}
|
|
} else {
|
|
endpoint, err := ch.endpointRegistry.GetByName(ctx, ns.ID(), attrs.Endpoint)
|
|
if err != nil {
|
|
if _, ok := errors.AsType[*serviceerror.NotFound](err); ok {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("endpoint %q not found", attrs.Endpoint),
|
|
}
|
|
} else if _, ok := errors.AsType[*serviceerror.PermissionDenied](err); ok {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("caller namespace %q unauthorized for %q", ns.Name(), attrs.Endpoint),
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
endpointID = endpoint.Id
|
|
}
|
|
|
|
if len(attrs.Service) > ch.config.MaxServiceNameLength(nsName) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf(
|
|
"ScheduleNexusOperationCommandAttributes.Service exceeds length limit of %d",
|
|
ch.config.MaxServiceNameLength(nsName),
|
|
),
|
|
}
|
|
}
|
|
|
|
if len(attrs.Operation) > ch.config.MaxOperationNameLength(nsName) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf(
|
|
"ScheduleNexusOperationCommandAttributes.Operation exceeds length limit of %d",
|
|
ch.config.MaxOperationNameLength(nsName),
|
|
),
|
|
}
|
|
}
|
|
|
|
if err := timestamp.ValidateAndCapProtoDuration(attrs.ScheduleToCloseTimeout); err != nil {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf(
|
|
"ScheduleNexusOperationCommandAttributes.ScheduleToCloseTimeout is invalid: %v", err),
|
|
}
|
|
}
|
|
|
|
if err := timestamp.ValidateAndCapProtoDuration(attrs.ScheduleToStartTimeout); err != nil {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf(
|
|
"ScheduleNexusOperationCommandAttributes.ScheduleToStartTimeout is invalid: %v", err),
|
|
}
|
|
}
|
|
|
|
if err := timestamp.ValidateAndCapProtoDuration(attrs.StartToCloseTimeout); err != nil {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf(
|
|
"ScheduleNexusOperationCommandAttributes.StartToCloseTimeout is invalid: %v", err),
|
|
}
|
|
}
|
|
|
|
if attrs.Endpoint != commonnexus.SystemEndpoint && !validator.IsValidPayloadSize(attrs.Input.Size()) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: "ScheduleNexusOperationCommandAttributes.Input exceeds size limit",
|
|
TerminateWorkflow: true,
|
|
}
|
|
}
|
|
|
|
headerLength := 0
|
|
lowerCaseHeader := make(map[string]string, len(attrs.NexusHeader))
|
|
for k, v := range attrs.NexusHeader {
|
|
lowerK := strings.ToLower(k)
|
|
lowerCaseHeader[lowerK] = v
|
|
headerLength += len(lowerK) + len(v)
|
|
if slices.Contains(ch.config.DisallowedOperationHeaders(), lowerK) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("ScheduleNexusOperationCommandAttributes.NexusHeader contains a disallowed header key: %q", k),
|
|
}
|
|
}
|
|
}
|
|
|
|
if headerLength > ch.config.MaxOperationHeaderSize(nsName) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_SCHEDULE_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: "ScheduleNexusOperationCommandAttributes.NexusHeader exceeds size limit",
|
|
}
|
|
}
|
|
|
|
root := ms.HSM()
|
|
coll := nexusoperations.MachineCollection(root)
|
|
maxPendingOperations := ch.config.MaxConcurrentOperations(nsName)
|
|
if coll.Size() >= ch.config.MaxConcurrentOperations(nsName) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_PENDING_NEXUS_OPERATIONS_LIMIT_EXCEEDED,
|
|
Message: fmt.Sprintf("workflow has reached the pending nexus operation limit of %d for this namespace", maxPendingOperations),
|
|
}
|
|
}
|
|
|
|
// Trim timeout to workflow run timeout.
|
|
runTimeout := ms.GetExecutionInfo().WorkflowRunTimeout.AsDuration()
|
|
opTimeout := attrs.ScheduleToCloseTimeout.AsDuration()
|
|
if runTimeout > 0 && (opTimeout == 0 || opTimeout > runTimeout) {
|
|
attrs.ScheduleToCloseTimeout = ms.GetExecutionInfo().WorkflowRunTimeout
|
|
opTimeout = attrs.ScheduleToCloseTimeout.AsDuration()
|
|
}
|
|
|
|
// Trim timeout to max allowed timeout.
|
|
if maxTimeout := ch.config.MaxOperationScheduleToCloseTimeout(nsName); maxTimeout > 0 && opTimeout > maxTimeout {
|
|
attrs.ScheduleToCloseTimeout = durationpb.New(maxTimeout)
|
|
}
|
|
|
|
// Trim secondary timeouts to the primary timeout.
|
|
scheduleToCloseTimeout := attrs.ScheduleToCloseTimeout.AsDuration()
|
|
scheduleToStartTimeout := attrs.ScheduleToStartTimeout.AsDuration()
|
|
startToCloseTimeout := attrs.StartToCloseTimeout.AsDuration()
|
|
|
|
if scheduleToCloseTimeout > 0 {
|
|
if scheduleToStartTimeout > scheduleToCloseTimeout {
|
|
attrs.ScheduleToStartTimeout = attrs.ScheduleToCloseTimeout
|
|
}
|
|
if startToCloseTimeout > scheduleToCloseTimeout {
|
|
attrs.StartToCloseTimeout = attrs.ScheduleToCloseTimeout
|
|
}
|
|
}
|
|
|
|
event := ms.AddHistoryEvent(enumspb.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, func(he *historypb.HistoryEvent) {
|
|
he.Attributes = &historypb.HistoryEvent_NexusOperationScheduledEventAttributes{
|
|
NexusOperationScheduledEventAttributes: &historypb.NexusOperationScheduledEventAttributes{
|
|
Endpoint: attrs.Endpoint,
|
|
EndpointId: endpointID,
|
|
Service: attrs.Service,
|
|
Operation: attrs.Operation,
|
|
Input: attrs.Input,
|
|
ScheduleToCloseTimeout: attrs.ScheduleToCloseTimeout,
|
|
ScheduleToStartTimeout: attrs.ScheduleToStartTimeout,
|
|
StartToCloseTimeout: attrs.StartToCloseTimeout,
|
|
NexusHeader: lowerCaseHeader,
|
|
RequestId: requestID,
|
|
WorkflowTaskCompletedEventId: workflowTaskCompletedEventID,
|
|
},
|
|
}
|
|
he.UserMetadata = command.UserMetadata
|
|
})
|
|
|
|
return nexusoperations.ScheduledEventDefinition{}.Apply(root, event)
|
|
}
|
|
|
|
func (ch *commandHandler) HandleCancelCommand(
|
|
ctx context.Context,
|
|
ms historyi.MutableState,
|
|
validator chasmworkflow.Validator,
|
|
workflowTaskCompletedEventID int64,
|
|
command *commandpb.Command,
|
|
) error {
|
|
attrs := command.GetRequestCancelNexusOperationCommandAttributes()
|
|
if attrs == nil {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: "empty CancelNexusOperationCommandAttributes",
|
|
}
|
|
}
|
|
|
|
coll := nexusoperations.MachineCollection(ms.HSM())
|
|
nodeID := strconv.FormatInt(attrs.ScheduledEventId, 10)
|
|
node, err := coll.Node(nodeID)
|
|
hasBufferedEvent := ms.HasAnyBufferedEvent(makeNexusOperationTerminalEventFilter(attrs.ScheduledEventId))
|
|
if err != nil {
|
|
if errors.Is(err, hsm.ErrStateMachineNotFound) {
|
|
if !hasBufferedEvent {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("requested cancelation for a non-existing or already completed operation with scheduled event ID of %d", attrs.ScheduledEventId),
|
|
}
|
|
}
|
|
// Fallthrough and apply the event, there's special logic that will handle state machine not found below.
|
|
} else {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if node != nil {
|
|
// TODO(bergundy): Remove this when operation auto-deletes itself on terminal state.
|
|
// Operation may already be in a terminal state because it doesn't yet delete itself. We don't want to accept
|
|
// cancelation in this case.
|
|
op, err := hsm.MachineData[nexusoperations.Operation](node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The operation is already in a terminal state and the terminal NexusOperation event has not just been buffered.
|
|
// We allow the workflow to request canceling an operation that has just completed while a workflow task is in
|
|
// flight since it cannot know about the state of the operation.
|
|
if !nexusoperations.TransitionCanceled.Possible(op) && !hasBufferedEvent {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("requested cancelation for an already complete operation with scheduled event ID of %d", attrs.ScheduledEventId),
|
|
}
|
|
}
|
|
// END TODO
|
|
}
|
|
|
|
// Always create the event even if there's a buffered completion to avoid breaking replay in the SDK.
|
|
// The event will be applied before the completion since buffered events are reordered and put at the end of the
|
|
// batch, after command events from the workflow task.
|
|
event := ms.AddHistoryEvent(enumspb.EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED, func(he *historypb.HistoryEvent) {
|
|
he.Attributes = &historypb.HistoryEvent_NexusOperationCancelRequestedEventAttributes{
|
|
NexusOperationCancelRequestedEventAttributes: &historypb.NexusOperationCancelRequestedEventAttributes{
|
|
ScheduledEventId: attrs.ScheduledEventId,
|
|
WorkflowTaskCompletedEventId: workflowTaskCompletedEventID,
|
|
},
|
|
}
|
|
he.UserMetadata = command.UserMetadata
|
|
})
|
|
|
|
err = nexusoperations.CancelRequestedEventDefinition{}.Apply(ms.HSM(), event)
|
|
|
|
// Cancel spawns a child Cancelation machine, if that machine already exists we got a duplicate cancelation request.
|
|
if errors.Is(err, hsm.ErrStateMachineAlreadyExists) {
|
|
return chasmworkflow.FailWorkflowTaskError{
|
|
Cause: enumspb.WORKFLOW_TASK_FAILED_CAUSE_BAD_REQUEST_CANCEL_NEXUS_OPERATION_ATTRIBUTES,
|
|
Message: fmt.Sprintf("cancelation was already requested for an operation with scheduled event ID %d", attrs.ScheduledEventId),
|
|
}
|
|
} else if errors.Is(err, hsm.ErrStateMachineNotFound) {
|
|
// This may happen if there's a buffered completion. Ignore.
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func RegisterCommandHandlers(
|
|
reg *workflow.CommandHandlerRegistry,
|
|
chasmRegistry *chasm.Registry,
|
|
endpointRegistry commonnexus.EndpointRegistry,
|
|
config *nexusoperations.Config,
|
|
) error {
|
|
h := commandHandler{
|
|
config: config,
|
|
endpointRegistry: endpointRegistry,
|
|
nexusProcessor: chasmRegistry.NexusEndpointProcessor,
|
|
}
|
|
if err := reg.Register(enumspb.COMMAND_TYPE_SCHEDULE_NEXUS_OPERATION, h.HandleScheduleCommand); err != nil {
|
|
return err
|
|
}
|
|
return reg.Register(enumspb.COMMAND_TYPE_REQUEST_CANCEL_NEXUS_OPERATION, h.HandleCancelCommand)
|
|
}
|
|
|
|
func makeNexusOperationTerminalEventFilter(scheduledEventID int64) func(event *historypb.HistoryEvent) bool {
|
|
return func(event *historypb.HistoryEvent) bool {
|
|
switch event.EventType {
|
|
case enumspb.EVENT_TYPE_NEXUS_OPERATION_COMPLETED:
|
|
return event.GetNexusOperationCompletedEventAttributes().GetScheduledEventId() == scheduledEventID
|
|
case enumspb.EVENT_TYPE_NEXUS_OPERATION_FAILED:
|
|
return event.GetNexusOperationFailedEventAttributes().GetScheduledEventId() == scheduledEventID
|
|
case enumspb.EVENT_TYPE_NEXUS_OPERATION_CANCELED:
|
|
return event.GetNexusOperationCanceledEventAttributes().GetScheduledEventId() == scheduledEventID
|
|
case enumspb.EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT:
|
|
return event.GetNexusOperationTimedOutEventAttributes().GetScheduledEventId() == scheduledEventID
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|