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
91 changes: 91 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,94 @@ func setup(w Worker) {
require.Len(t, stubs, 1)
require.Len(t, registers, 2)
}

// --- Outbound signal sends / query calls ----------------------------
//
// A workflow (or a service holding a Temporal client) can signal or
// query an ALREADY-RUNNING workflow by name. These are the consumer
// side of the signal/query namespaces — distinct from the in-workflow
// handler declarations. We surface them as EdgeCalls edges tagged
// `via=temporal.signal-send` / `temporal.query-call` carrying the
// signal/query name (the 4th positional argument, a string literal).
//
// APIs (the name is always the 4th positional arg, after ctx +
// workflowID + runID):
//
// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // workflow -> workflow
// client.SignalWorkflow(ctx, wid, rid, "name", arg) // service -> workflow
// client.QueryWorkflow(ctx, wid, rid, "name", args...) // service -> workflow
//
// (Note: there is no workflow.QueryWorkflow — querying is a client-side
// operation; and SignalExternalWorkflow already returns a Future, so
// there is no ...Async variant.)

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

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

func Orchestrator(ctx workflow.Context) error {
return workflow.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil)
}
`)
edges := temporalEdgesByVia(fix, "temporal.signal-send")
require.Len(t, edges, 1)
assert.Equal(t, "signal", edges[0].Meta["temporal_kind"])
assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_ClientSignalWorkflow(t *testing.T) {
// Receiver is an arbitrary client variable, so detection is by
// method name (like the Register* helpers), gated on a string-literal
// name in the 4th position.
fix := runGoExtract(t, `package svc

func Cancel(c Client) error {
return c.SignalWorkflow(ctx, "order-123", "", "cancel-request", nil)
}
`)
edges := temporalEdgesByVia(fix, "temporal.signal-send")
require.Len(t, edges, 1)
assert.Equal(t, "signal", edges[0].Meta["temporal_kind"])
assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"])
}

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

func Status(c Client) {
c.QueryWorkflow(ctx, "order-123", "", "get-status")
}
`)
edges := temporalEdgesByVia(fix, "temporal.query-call")
require.Len(t, edges, 1)
assert.Equal(t, "query", edges[0].Meta["temporal_kind"])
assert.Equal(t, "get-status", edges[0].Meta["temporal_name"])
}

func TestGoTemporal_OutboundNonLiteralNameUndetected(t *testing.T) {
// Signal/query names are matched by string at runtime; a non-literal
// name can't be pinned, so no outbound edge is emitted.
fix := runGoExtract(t, `package svc

func Cancel(c Client, name string) error {
return c.SignalWorkflow(ctx, "order-123", "", name, nil)
}
`)
assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send"))
}

func TestGoTemporal_SignalExternalAliasedNotDetected(t *testing.T) {
// SignalExternalWorkflow is gated on the canonical "workflow"
// receiver (it is a workflow-package function); an aliased import
// is intentionally missed, consistent with the dispatch detector.
fix := runGoExtract(t, `package wf

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

func Orchestrator(ctx wf.Context) error {
return wf.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil)
}
`)
assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send"))
}
17 changes: 17 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
// tempOutKind is "signal" / "query" when this call is an outbound
// signal-send / query-call against a running workflow
// (SignalExternalWorkflow / SignalWorkflow / QueryWorkflow); tempName
// then carries the signal/query name. `via=temporal.signal-send` /
// `temporal.query-call` meta is stamped on the emitted edge below.
tempOutKind string
}

type goDeferredTypeRef struct {
Expand Down Expand Up @@ -346,6 +352,14 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
dc.tempKind = "register_" + kind
dc.tempName = name
}
} else if okind, namePos, ok := goTemporalSignalQueryOutKind(receiver, method); ok {
// Outbound signal-send / query-call against a running
// workflow: SignalExternalWorkflow / SignalWorkflow /
// QueryWorkflow. The name is the 4th positional literal.
if name := goTemporalNthStringLiteralArg(expr.Node, namePos, src); name != "" {
dc.tempOutKind = okind
dc.tempName = name
}
}
calls = append(calls, dc)
if name, ok := detectGoLogEvent(expr.Node, method, src); ok {
Expand Down Expand Up @@ -668,6 +682,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
}
applyGoGRPCRegisterMeta(edge, c, src, tenv)
applyGoTemporalRegisterMeta(edge, c)
applyGoTemporalSignalQueryMeta(edge, c)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand All @@ -680,6 +695,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
}
applyGoGRPCRegisterMeta(edge, c, src, tenv)
applyGoTemporalRegisterMeta(edge, c)
applyGoTemporalSignalQueryMeta(edge, c)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand Down Expand Up @@ -729,6 +745,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
}
applyGoGRPCRegisterMeta(edge, c, src, tenv)
applyGoTemporalRegisterMeta(edge, c)
applyGoTemporalSignalQueryMeta(edge, c)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
}
Expand Down
93 changes: 93 additions & 0 deletions internal/parser/languages/golang_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,36 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) {
return "", false, false
}

// goTemporalSignalQueryOutKind reports whether (receiver, method) names
// an OUTBOUND signal-send or query-call against an already-running
// workflow and, if so, returns the kind ("signal" / "query") plus the
// 1-based position of the signal/query-name argument.
//
// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // wf -> wf
// client.SignalWorkflow(ctx, wid, rid, "name", arg) // svc -> wf
// client.QueryWorkflow(ctx, wid, rid, "name", args...) // svc -> wf
//
// SignalExternalWorkflow is gated on the canonical "workflow" receiver
// (it is a workflow-package function). SignalWorkflow / QueryWorkflow
// live on the client and are called on an arbitrary client variable, so
// — like the Register* helpers — they are matched by method name alone;
// the string-literal name gate below keeps that high-precision. There is
// deliberately no workflow.QueryWorkflow (querying is client-side) and no
// SignalExternalWorkflowAsync (SignalExternalWorkflow returns a Future).
func goTemporalSignalQueryOutKind(receiver, method string) (kind string, namePos int, ok bool) {
switch method {
case "SignalExternalWorkflow":
if receiver == "workflow" {
return "signal", 4, true
}
case "SignalWorkflow":
return "signal", 4, true
case "QueryWorkflow":
return "query", 4, true
}
return "", 0, false
}

// 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 @@ -115,6 +145,38 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string {
return ""
}

// goTemporalNthStringLiteralArg returns the unquoted value of the n-th
// (1-based) positional argument of a call when that argument is a string
// literal, else "". Used to extract the signal/query name from an
// outbound send/call — names are matched by string at runtime, so only a
// literal can be pinned here (a variable / constant is left undetected,
// keeping the detector high-precision).
func goTemporalNthStringLiteralArg(callNode *sitter.Node, n int, 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 == n {
switch c.Type() {
case "interpreted_string_literal", "raw_string_literal":
return goTemporalNameFromExpr(c, src)
}
return ""
}
}
return ""
}

// goTemporalRegisterName extracts the registered function name from a
// `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call —
// the first positional argument, which is the function reference.
Expand Down Expand Up @@ -168,6 +230,37 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) {
edge.Meta["temporal_name"] = c.tempName
}

// applyGoTemporalSignalQueryMeta stamps the outbound signal-send /
// query-call meta onto an EdgeCalls edge derived from
// `SignalExternalWorkflow` / `SignalWorkflow` / `QueryWorkflow`:
// `via=temporal.signal-send` or `temporal.query-call`, plus
// `temporal_kind` (signal / query) and `temporal_name` (the literal
// signal/query name). No-op when c.tempOutKind / c.tempName are unset.
//
// These are the consumer side of the signal/query namespaces; the
// provider side is the in-workflow handler (GetSignalChannel /
// SetQueryHandler), tagged via=temporal.handler.
func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) {
if edge == nil || c.tempOutKind == "" || c.tempName == "" {
return
}
var via string
switch c.tempOutKind {
case "signal":
via = "temporal.signal-send"
case "query":
via = "temporal.query-call"
default:
return
}
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["via"] = via
edge.Meta["temporal_kind"] = c.tempOutKind
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