Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions common/links/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ func Validate(links []*commonpb.Link, maxAllowedLinks, maxSize int) error {
if t.Activity.GetRunId() == "" {
return serviceerror.NewInvalidArgument("activity link must not have an empty run ID field")
}
case *commonpb.Link_Workflow_:
if t.Workflow.GetNamespace() == "" {
return serviceerror.NewInvalidArgument("workflow link must not have an empty namespace field")
}
if t.Workflow.GetWorkflowId() == "" {
return serviceerror.NewInvalidArgument("workflow link must not have an empty workflow ID field")
}
if t.Workflow.GetRunId() == "" {
return serviceerror.NewInvalidArgument("workflow link must not have an empty run ID field")
}
Comment thread
mavemuri marked this conversation as resolved.
default:
return serviceerror.NewInvalidArgument("unsupported link variant")
}
Expand Down
33 changes: 32 additions & 1 deletion common/links/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,24 @@ func TestValidate(t *testing.T) {
},
},
}
validWorkflow := &commonpb.Link{
Variant: &commonpb.Link_Workflow_{
Workflow: &commonpb.Link_Workflow{
Namespace: "ns",
WorkflowId: "wid",
RunId: "rid",
},
},
}

t.Run("HappyPath", func(t *testing.T) {
err := links.Validate([]*commonpb.Link{
validWorkflowEvent,
validBatchJob,
validNexusOperation,
validActivity,
}, maxLinks+1, maxSize)
validWorkflow,
}, maxLinks+2, maxSize)
require.NoError(t, err)
})

Expand Down Expand Up @@ -155,4 +165,25 @@ func TestValidate(t *testing.T) {
err := links.Validate([]*commonpb.Link{{}}, maxLinks, maxSize)
require.ErrorContains(t, err, "unsupported link variant")
})

t.Run("Workflow/EmptyNamespace", func(t *testing.T) {
l := proto.Clone(validWorkflow).(*commonpb.Link)
l.GetWorkflow().Namespace = ""
err := links.Validate([]*commonpb.Link{l}, maxLinks, maxSize)
require.EqualError(t, err, "workflow link must not have an empty namespace field")
})

t.Run("Workflow/EmptyWorkflowID", func(t *testing.T) {
l := proto.Clone(validWorkflow).(*commonpb.Link)
l.GetWorkflow().WorkflowId = ""
err := links.Validate([]*commonpb.Link{l}, maxLinks, maxSize)
require.EqualError(t, err, "workflow link must not have an empty workflow ID field")
})

t.Run("Workflow/EmptyRunID", func(t *testing.T) {
l := proto.Clone(validWorkflow).(*commonpb.Link)
l.GetWorkflow().RunId = ""
err := links.Validate([]*commonpb.Link{l}, maxLinks, maxSize)
require.EqualError(t, err, "workflow link must not have an empty run ID field")
})
}
17 changes: 15 additions & 2 deletions common/nexus/links.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import (

"github.com/nexus-rpc/sdk-go/nexus"
commonpb "go.temporal.io/api/common/v1"
"go.temporal.io/api/temporalnexus"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/log/tag"
)

// ConvertNexusLinksToProtoLinks converts a slice of Nexus SDK links into Temporal proto links,
// supporting Link_WorkflowEvent and Link_Activity variants. Unsupported or malformed entries
// are skipped with a warning since links are non-essential to execution.
// supporting Link_Workflow, Link_WorkflowEvent, and Link_Activity variants. Unsupported or
// malformed entries are skipped with a warning since links are non-essential to execution.
func ConvertNexusLinksToProtoLinks(nexusLinks []nexus.Link, logger log.Logger) []*commonpb.Link {
var out []*commonpb.Link
for _, nexusLink := range nexusLinks {
Expand Down Expand Up @@ -40,6 +41,18 @@ func ConvertNexusLinksToProtoLinks(nexusLinks []nexus.Link, logger log.Logger) [
out = append(out, &commonpb.Link{
Variant: &commonpb.Link_Activity_{Activity: link},
})
case string((&commonpb.Link_Workflow{}).ProtoReflect().Descriptor().FullName()):
Comment thread
mavemuri marked this conversation as resolved.
link, err := temporalnexus.ConvertNexusLinkToLinkWorkflow(nexusLink)
Comment thread
mavemuri marked this conversation as resolved.
Comment thread
mavemuri marked this conversation as resolved.
Comment thread
mavemuri marked this conversation as resolved.
if err != nil {
logger.Warn(
fmt.Sprintf("failed to parse link to %q: %s", nexusLink.Type, nexusLink.URL),
tag.Error(err),
)
continue
}
out = append(out, &commonpb.Link{
Variant: &commonpb.Link_Workflow_{Workflow: link},
})
Comment thread
mavemuri marked this conversation as resolved.
default:
logger.Warn(fmt.Sprintf("invalid link data type: %q", nexusLink.Type))
}
Expand Down
50 changes: 42 additions & 8 deletions common/nexus/links_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ import (
"go.temporal.io/server/common/testing/protorequire"
)

// TestConvertNexusLinksToProtoLinks_ActivityVariant verifies that the shared
// converter handles both WorkflowEvent and Activity link variants, drops
// unsupported types, and skips malformed entries — exercised by the Nexus task
// handler's start-response flow so a SAA invoked from a Nexus operation can
// surface its Activity link back to the caller.
func TestConvertNexusLinksToProtoLinks_ActivityVariant(t *testing.T) {
// TestConvertNexusLinksToProtoLinks verifies that the converter handles the
// Workflow, WorkflowEvent, and Activity link variants, drops unsupported types,
// and skips malformed entries.
func TestConvertNexusLinksToProtoLinks(t *testing.T) {
logger := log.NewTestLogger()

workflowEvent := nexus.Link{
Expand All @@ -36,6 +34,13 @@ func TestConvertNexusLinksToProtoLinks_ActivityVariant(t *testing.T) {
},
Type: "temporal.api.common.v1.Link.Activity",
}
workflow := nexus.Link{
URL: &url.URL{
Scheme: "temporal",
Path: "/namespaces/ns/workflows/wf-id/run-id",
},
Type: "temporal.api.common.v1.Link.Workflow",
}
unsupported := nexus.Link{
URL: &url.URL{Scheme: "temporal", Path: "/foo"},
Type: "unknown.Type",
Expand All @@ -44,9 +49,29 @@ func TestConvertNexusLinksToProtoLinks_ActivityVariant(t *testing.T) {
URL: &url.URL{Scheme: "temporal", Path: "/namespaces/ns/foo/act-id"},
Type: "temporal.api.common.v1.Link.Activity",
}
malformedWorkflows := []nexus.Link{
{
URL: &url.URL{Scheme: "temporal", Path: "/namespaces//workflows/wid/rid"}, // missing ns
Type: "temporal.api.common.v1.Link.Workflow",
},
{
URL: &url.URL{Scheme: "temporal", Path: "/namespaces/ns/workflows//rid"}, // missing wid
Type: "temporal.api.common.v1.Link.Workflow",
},
{
URL: &url.URL{Scheme: "temporal", Path: "/namespaces/ns/workflows/wid/"}, // missing rid
Type: "temporal.api.common.v1.Link.Workflow",
},
{
URL: &url.URL{Scheme: "temporal", Path: "/foo"}, // incorrect path
Type: "temporal.api.common.v1.Link.Workflow",
},
}
nexusLinks := []nexus.Link{workflowEvent, activity, workflow, unsupported, malformedActivity}
nexusLinks = append(nexusLinks, malformedWorkflows...)

out := commonnexus.ConvertNexusLinksToProtoLinks([]nexus.Link{workflowEvent, activity, unsupported, malformedActivity}, logger)
require.Len(t, out, 2, "workflow-event and activity links must round-trip; unsupported and malformed entries must be dropped")
out := commonnexus.ConvertNexusLinksToProtoLinks(nexusLinks, logger)
require.Len(t, out, 3, "workflow, workflow-event, and activity links must round-trip; unsupported and malformed entries must be dropped")

expected := []*commonpb.Link{
{
Expand All @@ -73,6 +98,15 @@ func TestConvertNexusLinksToProtoLinks_ActivityVariant(t *testing.T) {
},
},
},
{
Variant: &commonpb.Link_Workflow_{
Workflow: &commonpb.Link_Workflow{
Namespace: "ns",
WorkflowId: "wf-id",
RunId: "run-id",
},
},
},
}
protorequire.ProtoSliceEqual(t, expected, out)
}
23 changes: 22 additions & 1 deletion service/history/api/queryworkflow/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func Invoke(
workflowConsistencyChecker api.WorkflowConsistencyChecker,
rawMatchingClient matchingservice.MatchingServiceClient,
matchingClient matchingservice.MatchingServiceClient,
) (_ *historyservice.QueryWorkflowResponse, retError error) {
) (resp *historyservice.QueryWorkflowResponse, retError error) {
scope := shardContext.GetMetricsHandler().WithTags(metrics.OperationTag(metrics.HistoryQueryWorkflowScope))
namespaceID := namespace.ID(request.GetNamespaceId())
err := api.ValidateNamespaceUUID(namespaceID)
Expand Down Expand Up @@ -79,6 +79,27 @@ func Invoke(
// Note: QueryWorkflow should not alter mutable state, so it is safe to ignore error and not clear ms.
workflowLease.GetReleaseFn()(nil)
}()
defer func() {
Comment thread
mavemuri marked this conversation as resolved.
if retError != nil || resp.GetResponse() == nil {
return
}
// Add link to Workflow regardless of query status. A rejection on the query is not an RPC error,
// so it gets the same link that a processed query would get - only the reason differs.
Comment thread
mavemuri marked this conversation as resolved.
Comment thread
mavemuri marked this conversation as resolved.
Comment thread
mavemuri marked this conversation as resolved.
reason := "Query processed"
if resp.GetResponse().GetQueryRejected() != nil {
reason = "Query rejected"
}
resp.Response.Link = &commonpb.Link{
Comment thread
mavemuri marked this conversation as resolved.
Variant: &commonpb.Link_Workflow_{
Workflow: &commonpb.Link_Workflow{
Namespace: nsEntry.Name().String(),
WorkflowId: workflowKey.WorkflowID,
RunId: workflowKey.RunID,
Reason: reason,
},
},
}
}()
Comment thread
mavemuri marked this conversation as resolved.

// Context metadata is automatically set during mutable state transaction close for operations that mutate state.
// Since QueryWorkflow is readonly and never closes the transaction, we explicitly call SetContextMetadata
Expand Down
105 changes: 105 additions & 0 deletions tests/nexus_workflow_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package tests

import (
"context"

"github.com/nexus-rpc/sdk-go/nexus"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
querypb "go.temporal.io/api/query/v1"
apitemporalnexus "go.temporal.io/api/temporalnexus"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
"go.temporal.io/sdk/workflow"
"go.temporal.io/server/common/nexus/nexustest"
"go.temporal.io/server/common/payloads"
"go.temporal.io/server/tests/testcore"
)

func (s *NexusWorkflowTestSuite) TestNexusOperationBackedByQuery(chasmEnabled bool) {
env := s.newTestEnv(chasmEnabled)
ctx := s.Context()
taskQueue := testcore.RandomizeStr(s.T().Name())

queryType := "status"
signalName := "done"
handlerWorkflowID := testcore.RandomizeStr(s.T().Name() + "-handler")
handlerWf := func(ctx workflow.Context) error {
_ = workflow.SetQueryHandler(ctx, queryType, func() (string, error) {
return "handler-status", nil
})
workflow.GetSignalChannel(ctx, signalName).Receive(ctx, nil)
return nil
}

h := nexustest.Handler{
OnStartOperation: func(
ctx context.Context,
service, operation string,
input *nexus.LazyValue,
options nexus.StartOperationOptions,
) (nexus.HandlerStartOperationResult[any], error) {
resp, err := env.FrontendClient().QueryWorkflow(ctx, &workflowservice.QueryWorkflowRequest{
Namespace: env.Namespace().String(),
Execution: &commonpb.WorkflowExecution{WorkflowId: handlerWorkflowID},
Query: &querypb.WorkflowQuery{QueryType: queryType},
})
if err != nil {
return nil, err
}
// simulate the stitching that will be done by the SDKs eventually
nexus.AddHandlerLinks(ctx, apitemporalnexus.ConvertLinkWorkflowToNexusLink(resp.GetLink().GetWorkflow()))

var result string
if err := payloads.Decode(resp.GetQueryResult(), &result); err != nil {
return nil, err
}
return &nexus.HandlerStartOperationResultSync[any]{Value: result}, nil
},
}
endpointName := env.createRandomExternalNexusServer(ctx, s.T(), h)

callerWF := func(ctx workflow.Context) (string, error) {
c := workflow.NewNexusClient(endpointName, "service")
fut := c.ExecuteOperation(ctx, "operation", "input", workflow.NexusOperationOptions{})
var result string
err := fut.Get(ctx, &result)
return result, err
}

w := worker.New(env.SdkClient(), taskQueue, worker.Options{})
w.RegisterWorkflow(callerWF)
w.RegisterWorkflow(handlerWf)
s.NoError(w.Start())
defer w.Stop()

handlerRun, err := env.SdkClient().ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: handlerWorkflowID,
TaskQueue: taskQueue,
}, handlerWf)
s.NoError(err)

callerRun, err := env.SdkClient().ExecuteWorkflow(ctx, client.StartWorkflowOptions{
TaskQueue: taskQueue,
}, callerWF)
s.NoError(err)

var result string
s.NoError(callerRun.Get(ctx, &result))
s.Equal("handler-status", result)

// verify the nexus operation completed event carries a link to the handler's workflow
hist := env.GetHistory(env.Namespace().String(), &commonpb.WorkflowExecution{WorkflowId: callerRun.GetID()})
completedEvent := s.RequireHistoryEvent(hist, enumspb.EVENT_TYPE_NEXUS_OPERATION_COMPLETED)
s.Len(completedEvent.GetLinks(), 1)
workflowLink := completedEvent.GetLinks()[0].GetWorkflow()
s.NotNil(workflowLink, "completed event must carry a link of type workflow")
s.Equal(env.Namespace().String(), workflowLink.GetNamespace())
s.Equal(handlerWorkflowID, workflowLink.GetWorkflowId())
s.Equal(handlerRun.GetRunID(), workflowLink.GetRunId())
s.Equal("Query processed", workflowLink.GetReason())

s.NoError(env.SdkClient().SignalWorkflow(ctx, handlerWorkflowID, "", signalName, nil))
s.NoError(handlerRun.Get(ctx, nil))
}
Loading
Loading