Skip to content
Open
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 @@ -57,6 +57,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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two sibling cases use this repo's own converters (ConvertNexusLinkToLinkWorkflowEvent, ConvertNexusLinkToLinkActivity in common/nexus/link_converter.go), while this one reaches into go.temporal.io/api/temporalnexus. That's the only use of go.temporal.io/api/temporalnexus anywhere in the repo, and it is the sole reason for the api-go fork pin in go.mod.

common/nexus/link_converter.go opens with an explicit policy for exactly this situation:

This file is duplicated in sdk-go/temporalnexus/link_converter.go. Any changes here or there must be replicated. This is temporary until the temporal repo updates to the most recent SDK version.

Adding a local ConvertNexusLinkToLinkWorkflow next to the existing converters would keep all three URL path formats defined in one place, keep this switch internally consistent, and drop the need for the replace directive entirely. It's a small function and the file already owns the urlPath*Template/regex conventions it would follow.

@mavemuri mavemuri Jul 29, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rejected
real fix is to eventually point all the converters to the helper from api-go for consistency
This pr intentionally addresses one to keep it scoped while avoid additional duplication. Cleaning up all the duplicates will be a different, more scoped pr

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common/links.Validate has no *commonpb.Link_Workflow_ case — it falls into default: and returns InvalidArgument: "unsupported link variant" (common/links/validator.go:60-61).

That makes the link this PR returns a dead end for the main thing links are for: taking a link you got back and attaching it to a follow-up request. A client that does

qResp, _ := c.WorkflowService().QueryWorkflow(ctx, ...)
c.WorkflowService().StartWorkflowExecution(ctx, &workflowservice.StartWorkflowExecutionRequest{
    ..., Links: []*commonpb.Link{qResp.GetLink()},
})

gets InvalidArgument: unsupported link variant. Same for SignalWorkflowExecution, SignalWithStart, and UpdateWorkflowExecution (service/frontend/workflow_handler.go:693, 2241, 2286, 2350, 2445) plus the CHASM validators (chasm/lib/workflow/validator.go:220, chasm/lib/activity/link_validator.go:34).

Strictly this gap predates the PR — updateworkflow already emits Link_Workflow for rejected updates — but this change makes the variant reachable from every query response, so it's worth closing here. Adding the case alongside the Link_Activity_ one is a few lines:

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")
	}

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
55 changes: 55 additions & 0 deletions tests/query_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,61 @@ func (s *QueryWorkflowSuite) TestQueryWorkflow_Consistent_PiggybackQuery() {
s.Equal("pauseabc", queryResultStr)
}

func (s *QueryWorkflowSuite) TestQueryWorkflowResult_ContainsWorkflowLink() {
env := testcore.NewEnv(s.T())
workflowFn := func(ctx workflow.Context) error {
_ = workflow.SetQueryHandler(ctx, "test", func() (string, error) {
return "", nil
})
workflow.GetSignalChannel(ctx, "test").Receive(ctx, nil)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

env.SdkWorker().RegisterWorkflow(workflowFn)

wid := "test-query-result-link-wid"
run, err := env.SdkClient().ExecuteWorkflow(ctx,
sdkclient.StartWorkflowOptions{ID: wid, TaskQueue: env.WorkerTaskQueue()}, workflowFn)
s.NoError(err)

// use frontend client to query workflow so that the resp can be
// inspected directly instead of using the sdkclient
resp, err := env.FrontendClient().QueryWorkflow(ctx, &workflowservice.QueryWorkflowRequest{
Namespace: env.Namespace().String(),
Execution: &commonpb.WorkflowExecution{WorkflowId: wid},
Query: &querypb.WorkflowQuery{QueryType: "test"},
QueryRejectCondition: enumspb.QUERY_REJECT_CONDITION_NOT_OPEN, // set here too so the second query below differs only by the workflow being closed
})
s.NoError(err)

link := resp.GetLink().GetWorkflow()
s.NotNil(link, "query must carry a link of type workflow")
s.Equal(env.Namespace().String(), link.GetNamespace())
s.Equal(wid, link.GetWorkflowId())
s.Equal(run.GetRunID(), link.GetRunId())
s.Equal("Query processed", link.GetReason())
Comment thread
mavemuri marked this conversation as resolved.

s.NoError(env.SdkClient().SignalWorkflow(ctx, wid, "", "test", ""))
s.NoError(run.Get(ctx, nil))
// run same after workflow closed, should reject due to QueryRejectCondition
resp, err = env.FrontendClient().QueryWorkflow(ctx, &workflowservice.QueryWorkflowRequest{
Namespace: env.Namespace().String(),
Execution: &commonpb.WorkflowExecution{WorkflowId: wid},
Query: &querypb.WorkflowQuery{QueryType: "test"},
QueryRejectCondition: enumspb.QUERY_REJECT_CONDITION_NOT_OPEN,
})
s.NoError(err)

link = resp.GetLink().GetWorkflow()
s.NotNil(link, "query must carry a link of type workflow")
s.Equal(env.Namespace().String(), link.GetNamespace())
s.Equal(wid, link.GetWorkflowId())
s.Equal(run.GetRunID(), link.GetRunId())
s.Equal("Query rejected", link.GetReason())
Comment thread
mavemuri marked this conversation as resolved.
}

func (s *QueryWorkflowSuite) TestQueryWorkflow_QueryWhileBackoff() {
env := testcore.NewEnv(s.T())
tv := testvars.New(s.T())
Expand Down
Loading