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
34 changes: 34 additions & 0 deletions internal/indexer/temporal_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,37 @@ func setup(w Worker) {
assert.Equal(t, child.ID, stubCall.To)
assert.Equal(t, "workflow", stubCall.Meta["temporal_kind"])
}

// TestTemporalE2E_GoQueryHandler exercises in-workflow handler detection:
// a workflow.SetQueryHandler call must surface as a via=temporal.handler
// edge from the enclosing workflow carrying its kind + name.
func TestTemporalE2E_GoQueryHandler(t *testing.T) {
dir := t.TempDir()

writeFile(t, filepath.Join(dir, "workflow.go"), `package wf

import "go.temporal.io/sdk/workflow"

func StatusWorkflow(ctx workflow.Context) error {
workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil })
return nil
}
`)

g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

wf := g.FindNodesByName("StatusWorkflow")[0]
var handler *graph.Edge
for _, e := range g.GetOutEdges(wf.ID) {
if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.handler" {
handler = e
break
}
}
require.NotNil(t, handler, "workflow must have an outbound temporal.handler edge")
assert.Equal(t, "query", handler.Meta["temporal_kind"])
assert.Equal(t, "status", handler.Meta["temporal_name"])
}
143 changes: 143 additions & 0 deletions internal/parser/languages/go_temporal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,146 @@ func setup(w Worker) {
require.Len(t, stubs, 1)
require.Len(t, registers, 2)
}

// --- In-workflow handler declarations (query / signal / update) -----
//
// These mirror the Java SDK's @QueryMethod / @SignalMethod /
// @UpdateMethod annotations: from inside a workflow body the Go SDK
// declares the named query / signal / update channels the workflow
// serves. We surface each as a `via=temporal.handler` EdgeCalls edge
// carrying temporal_kind + temporal_name so the graph records, per
// workflow, the named handlers it exposes — symmetric with the Java
// side's per-method annotation edges.

func TestGoTemporal_SetQueryHandler(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil })
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
e := edges[0]
assert.Equal(t, "query", e.Meta["temporal_kind"])
assert.Equal(t, "status", e.Meta["temporal_name"])
assert.Equal(t, "pkg/foo.go::OrderWorkflow", e.From,
"handler edge must originate from the enclosing workflow function")
}

func TestGoTemporal_GetSignalChannel(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
ch := workflow.GetSignalChannel(ctx, "cancel")
_ = ch
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
assert.Equal(t, "signal", edges[0].Meta["temporal_kind"])
assert.Equal(t, "cancel", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_SetUpdateHandler(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
workflow.SetUpdateHandler(ctx, "retry", func() error { return nil })
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
assert.Equal(t, "update", edges[0].Meta["temporal_kind"])
assert.Equal(t, "retry", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_SetUpdateHandlerWithOptions(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
workflow.SetUpdateHandlerWithOptions(ctx, "retry", func() error { return nil }, workflow.UpdateHandlerOptions{})
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
assert.Equal(t, "update", edges[0].Meta["temporal_kind"])
assert.Equal(t, "retry", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_SetQueryHandlerWithOptions(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
workflow.SetQueryHandlerWithOptions(ctx, "status", func() (string, error) { return "ok", nil }, workflow.QueryHandlerOptions{})
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
assert.Equal(t, "query", edges[0].Meta["temporal_kind"])
assert.Equal(t, "status", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_GetSignalChannelWithOptions(t *testing.T) {
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context) error {
ch := workflow.GetSignalChannelWithOptions(ctx, "cancel", workflow.SignalChannelOptions{})
_ = ch
return nil
}
`)
edges := temporalEdgesByVia(fix, "temporal.handler")
require.Len(t, edges, 1)
assert.Equal(t, "signal", edges[0].Meta["temporal_kind"])
assert.Equal(t, "cancel", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_HandlerNonLiteralNameUndetected(t *testing.T) {
// Query / signal / update names are matched by string at runtime;
// a non-literal name (variable / selector) can't be pinned here, so
// no handler edge is emitted — high-precision, no guessing.
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx workflow.Context, q string) error {
workflow.SetQueryHandler(ctx, q, func() (string, error) { return "ok", nil })
return nil
}
`)
assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler"),
"non-literal handler name must not be detected")
}

func TestGoTemporal_HandlerAliasedImportNotDetected(t *testing.T) {
// Consistent with the dispatch detector: only the canonical
// "workflow" receiver alias is recognised.
fix := runGoExtract(t, `package wf

import wf "go.temporal.io/sdk/workflow"

func OrderWorkflow(ctx wf.Context) error {
wf.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil })
return nil
}
`)
assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler"))
}
15 changes: 15 additions & 0 deletions internal/parser/languages/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ type goDeferredCall struct {
tempKind string
tempName string
tempLocal bool
// tempHandlerKind is "query" / "signal" / "update" when this call
// is a `workflow.SetQueryHandler` / `GetSignalChannel` /
// `SetUpdateHandler` in-workflow handler declaration; tempName then
// carries the handler's string name. `via=temporal.handler` meta is
// stamped on the emitted edge in the call post-pass below.
tempHandlerKind string
}

type goDeferredTypeRef struct {
Expand Down Expand Up @@ -346,6 +352,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
dc.tempKind = "register_" + kind
dc.tempName = name
}
} else if hkind, ok := goTemporalHandlerKind(receiver, method); ok {
// Temporal in-workflow handler declaration:
// `workflow.SetQueryHandler(ctx, "name", fn)` etc.
if name := goTemporalHandlerName(expr.Node, src); name != "" {
dc.tempHandlerKind = hkind

@zzet zzet Jun 12, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@avfirsov I need to think about it. My main concern for now: why not do the same as with the Register kind?

dc.tempKind = "register_" + kind`

In such a case, it could be

dc.tempKind = "handle_" + kind`

without exploding the schema.

I'll take a look at other PRs and return to this point later. There might be benefits for resolution purposes, so I'm not sure atm.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question — and you're right that the goDeferredCall field is the part worth trimming. Let me separate the two axes, because I think they answer your "benefits for resolution?" doubt:

The via value is the real schema; the struct field is just a parser-internal carrier. The reason I didn't fold this into tempKind is that handler declarations sit on a different axis than dispatch/register:

  • temporal.stub (ExecuteActivity) and temporal.register are about the activity/workflow namespace, and temporal.stub edges get rewritten by ResolveTemporalCalls (placeholder → real function). The tempKind values (activity / workflow / register_activity / register_workflow) all live on that one axis and feed that one resolver pass.
  • temporal.handler is the query/signal/update namespace and is not rewritten — it's pure provider metadata (“this workflow serves query status”), the Go-side counterpart of Java's @QueryMethod/@SignalMethod/@UpdateMethod annotation edges. A distinct via lets a consumer ask "all handlers" (via == "temporal.handler") without decoding a handle_* prefix out of tempKind, and keeps the dispatch-intercept condition (tempKind == "activity" || "workflow") untouched.

So I'd keep the distinct via=temporal.handler (different namespace, different resolver treatment), but I fully agree about not adding the field. I can drop tempHandlerKind and carry it as tempKind = "handle_" + kind, with applyGoTemporalHandlerMeta routing on that prefix — exactly mirroring register_. Same for the tempOutKind field in #81 (signalout_/querycall_). That removes the schema growth you're flagging while preserving the semantic/via distinction.

No rush — happy to push that refactor whenever you've settled on the direction (or leave it as-is if you decide the separate field reads cleaner). Thanks for taking the time across the three PRs.

dc.tempName = name
}
}
calls = append(calls, dc)
if name, ok := detectGoLogEvent(expr.Node, method, src); ok {
Expand Down Expand Up @@ -668,6 +681,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
}
applyGoGRPCRegisterMeta(edge, c, src, tenv)
applyGoTemporalRegisterMeta(edge, c)
applyGoTemporalHandlerMeta(edge, c)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand All @@ -680,6 +694,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
}
applyGoGRPCRegisterMeta(edge, c, src, tenv)
applyGoTemporalRegisterMeta(edge, c)
applyGoTemporalHandlerMeta(edge, c)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand Down
86 changes: 86 additions & 0 deletions internal/parser/languages/golang_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,70 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) {
return "", false, false
}

// goTemporalHandlerKind reports whether (receiver, method) names one of
// the Temporal in-workflow handler-declaration helpers and, if so,
// returns the canonical kind ("query" / "signal" / "update").
//
// workflow.SetQueryHandler(ctx, "name", fn)
// workflow.SetQueryHandlerWithOptions(ctx, "name", fn, opts)
// workflow.GetSignalChannel(ctx, "name")
// workflow.GetSignalChannelWithOptions(ctx, "name", opts)
// workflow.SetUpdateHandler(ctx, "name", fn)
// workflow.SetUpdateHandlerWithOptions(ctx, "name", fn, opts)
//
// These mirror the Java SDK's `@QueryMethod` / `@SignalMethod` /
// `@UpdateMethod` annotations: a workflow declares, from inside its
// body, the named query / signal / update channels it serves. As with
// the dispatch helpers we require the receiver text to be exactly the
// canonical "workflow" alias.
func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) {
if receiver != "workflow" {
return "", false
}
switch method {
case "SetQueryHandler", "SetQueryHandlerWithOptions":
return "query", true
case "GetSignalChannel", "GetSignalChannelWithOptions":
return "signal", true
case "SetUpdateHandler", "SetUpdateHandlerWithOptions":
return "update", true
}
return "", false
}

// goTemporalHandlerName extracts the query / signal / update name from a
// handler-declaration call — the second positional argument (after the
// workflow.Context). Unlike dispatch names we accept ONLY a string
// literal: handler names are matched by string at runtime, so a
// non-literal (variable / selector) can't be pinned to a name here and
// is left undetected, keeping the detector high-precision. Returns ""
// when the second argument is missing or is not a string literal.
func goTemporalHandlerName(callNode *sitter.Node, src []byte) string {
if callNode == nil || callNode.Type() != "call_expression" {
return ""
}
args := callNode.ChildByFieldName("arguments")
if args == nil {
return ""
}
count := 0
for i := 0; i < int(args.NamedChildCount()); i++ {
c := args.NamedChild(i)
if c == nil {
continue
}
count++
if count == 2 {
switch c.Type() {
case "interpreted_string_literal", "raw_string_literal":
return goTemporalNameFromExpr(c, src)
}
return ""
}
}
return ""
}

// goTemporalDispatchName extracts the activity (or child-workflow)
// name from a `workflow.ExecuteActivity(ctx, X, args...)` call. X is
// the second positional argument and is either:
Expand Down Expand Up @@ -168,6 +232,28 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) {
edge.Meta["temporal_name"] = c.tempName
}

// applyGoTemporalHandlerMeta stamps `via=temporal.handler` plus
// `temporal_kind` (query / signal / update) and `temporal_name` (the
// handler's string name) onto the EdgeCalls edge derived from a
// `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler`
// call. No-op when c.tempHandlerKind / c.tempName are unset.
//
// The edge originates from the enclosing workflow function, so the
// graph records — per workflow — the named query / signal / update
// handlers it exposes, symmetric with the Java side's per-method
// `@QueryMethod` / `@SignalMethod` / `@UpdateMethod` annotation edges.
func applyGoTemporalHandlerMeta(edge *graph.Edge, c goDeferredCall) {
if edge == nil || c.tempHandlerKind == "" || c.tempName == "" {
return
}
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["via"] = "temporal.handler"
edge.Meta["temporal_kind"] = c.tempHandlerKind
edge.Meta["temporal_name"] = c.tempName
}

// goTemporalNameFromExpr reduces a single argument expression to the
// trailing identifier that names the activity / workflow. Handles
// string literals (`"MyActivity"` and the Go raw-string variant),
Expand Down
Loading