Skip to content

Temporal cluster: merge #78/#79/#81 + cross-language Java→Go join (#77) + const retention (#80)#82

Merged
zzet merged 21 commits into
mainfrom
feat/temporal-cluster
Jun 12, 2026
Merged

Temporal cluster: merge #78/#79/#81 + cross-language Java→Go join (#77) + const retention (#80)#82
zzet merged 21 commits into
mainfrom
feat/temporal-cluster

Conversation

@zzet

@zzet zzet commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Integrates PRs #78, #79, #81 and implements the design work from issues #77 and #80, end-to-end. Instead of merging the three PRs as-is, they were brought onto one branch, their defects fixed, and the cross-cutting Temporal intelligence built on top — including the cross-language Java→Go workflow join (#77) and constant-value retention (#80 Q1).

All packages build (incl. CGO binary), go vet is clean, and go test -race passes for internal/graph, internal/graph/store_sqlite, internal/resolver, internal/parser/languages, and internal/indexer.

Closes the work tracked in #77 and #80; supersedes #78, #79, #81 (their commits are included).

What's in here

Merged PRs (clean-integrated)

Fixes to the merged PRs

  • feat(temporal): resolve activity/workflow dispatch names from env-var-with-default vars #79 env-default data-flow (fix(temporal): correct env-default name resolution data-flow): cmp.Or was modelled backwards (returned the last string literal; cmp.Or returns the first non-zero) and accepted any callee mixing an env read with a literal. Now returns the first literal, gated on the cmp.Or callee. The lexical walk took the first match and descended into nested closures; now replays writes in source order (last live write wins) and never descends into nested func literals.

Correctness / performance (independent of the PRs)

  • Language gate on stub resolutionidx.lookup had no language filter, so a Go temporal.stub could resolve onto a Java method node on a name collision. Now gated by caller language; the intentional cross-language join is a separate, explicit path.
  • ResolveTemporalCalls perf — it runs on every incremental edit and scanned the largest edge class (EdgeCalls) three times per pass plus an unconditional AddNode per role. Now: one EdgeCalls sweep, an early-out before any work when the graph has no Temporal edges, and conditional role write-back.

Completed Go surface

  • RegisterActivityWithOptions{Name} override honored (the canonical dispatch name).
  • RegisterActivities(&Struct{}) struct registration — promotes every exported method to an activity.
  • Workflow-start familyclient.ExecuteWorkflow (workflow @ arg 3) and SignalWithStartWorkflow (workflow @ arg 6) → via=temporal.start, resolved to the registered workflow ("who starts this workflow").
  • Aliased import wf "…/workflow" — the receiver-gated detectors now canonicalise the file's workflow alias.
  • Docsvia=temporal.* taxonomy in docs/contracts.md + the gortex://schema MCP resource.

#80 Q1 — constant-value retention + dereference

The dominant real-world shape (const ChargeCardActivity = "ChargeCard"; ExecuteActivity(ctx, ChargeCardActivity, …)) now resolves. Per the design steer, the literal is kept in a queryable constant_values sidecar (a new optional ConstantValue capability on both the in-memory and sqlite backends, mirroring clone_shingles / ref_facts) — not the gob Meta blob. The resolver builds a name→value deref map and retries unresolved dispatch names under the const value (ambiguous names dropped, never a wrong guess).

#77 — cross-language Java→Go join

  • G2 — the resolver computes each Java method's canonical Temporal name (explicit @XxxMethod(name=) > activity Capitalize(method) + @ActivityInterface(namePrefix=) > workflow = interface simple name) so the strings line up with the Go side.
  • G1 — the Java extractor detects client.newWorkflowStub(OrderWorkflow.class, …) / newUntypedWorkflowStub("…") and emits a via=temporal.start consumer edge; the resolver's lookupCrossLang matches a unique other-language candidate by canonical name and lands the edge at the speculative tier (OriginSpeculative / MetaSpeculative / temporal_cross_lang). get_callers / impact on a Go workflow now surface the Java services that start it.
  • Java consumer signal/query — symmetric with feat(temporal): detect outbound signal-send / query-call against running workflows #81, gated on the receiver's inferred WorkflowStub type to stay precise.

#80 Q2 — wrapper detection (bounded)

A function forwarding a parameter as the dispatch name (executeActivity(ctx, ao, name, …){ ExecuteActivity(ctx, name, …) }) is now recognised as a dispatch wrapper: the unresolvable parameter-named stub is suppressed (it was pure noise) and the function is marked (temporal_wrapper_*). Documented blind spot: cross-file wrapper-following (propagating a caller's literal/const through the wrapper) needs call-argument flow and is deliberately deferred — the const-deref already covers the const-naming half.

Design decisions (re: #77 / #80)

  • Edge model: reuse EdgeCalls + via=temporal.* (no new edge kinds) so blast-radius / impact traverse generically.
  • Cross-language tier: speculative, not ast_resolved — a by-name match across a type-system boundary, hidden from default queries.
  • Const storage: queryable sidecar table, not Meta (unindexable gob blob, decoded on every node load) and not extra graph nodes (size).

avfirsov and others added 21 commits June 12, 2026 08:42
…larations

Surface workflow.SetQueryHandler / GetSignalChannel / SetUpdateHandler[WithOptions]
calls as via=temporal.handler EdgeCalls edges carrying temporal_kind
(query/signal/update) + temporal_name, originating from the enclosing
workflow function. This mirrors the Java side's per-method @QueryMethod /
@SignalMethod / @UpdateMethod annotation edges, giving the graph a
symmetric, queryable record of the named handlers each Go workflow exposes.

High-precision: only the canonical "workflow" receiver alias is matched and
the handler name must be a string literal (runtime-matched names can't be
pinned from a variable), consistent with the existing dispatch detector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ault vars

When a workflow names its activity/child-workflow through a local variable
read from an env var with a literal fallback, resolve the dispatch to that
literal default instead of leaving it unresolved:

    actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard")
    workflow.ExecuteActivity(ctx, actName, id)   // -> activity "ChargeCard"

    name := os.Getenv("K"); if name == "" { name = "ChargeCard" }

The parser does a narrow, intra-procedural lookup anchored on a literal
os.Getenv / os.LookupEnv read (so the value is provably env-sourced, not a
general data-flow guess) and tags the stub edge temporal_name_origin=
env_default. The resolver then lands the edge at the speculative tier
(OriginSpeculative, confidence 0.4, MetaSpeculative=true) rather than
ast_resolved — the runtime env override may name a different handler than
the literal default, so the edge is present but hidden from default queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nnelWithOptions

Complete the in-workflow handler set: the detector already handled
SetUpdateHandlerWithOptions but missed the WithOptions variants of the
query and signal handlers. Note: the Go SDK has no SetSignalHandler —
signals are received via GetSignalChannel[WithOptions].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing workflows

Surface the consumer side of the Temporal signal/query namespaces:

    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

Each emits an EdgeCalls edge tagged via=temporal.signal-send /
temporal.query-call carrying temporal_kind + temporal_name (the signal /
query name = the 4th positional argument, a string literal). These pair
with the in-workflow handler edges (via=temporal.handler) as the
sender/handler two sides of the signal/query contract.

SignalExternalWorkflow is gated on the canonical "workflow" receiver;
SignalWorkflow / QueryWorkflow live on the client and are matched by
method name (like the Register* helpers), kept high-precision by the
string-literal name gate. No new edge kinds — consistent with the
existing EdgeCalls + via=temporal.* convention.

Note on the Go SDK surface: there is no workflow.SetSignalHandler
(signals are received via GetSignalChannel), no workflow.QueryWorkflow
(querying is client-side), and no SignalExternalWorkflowAsync
(SignalExternalWorkflow already returns a Future).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* pr79-review:
  feat(temporal): resolve activity/workflow names from env-var-with-default vars
* pr78-review:
  feat(temporal): also detect SetQueryHandlerWithOptions / GetSignalChannelWithOptions
  feat(temporal): detect Go in-workflow query/signal/update handler declarations

# Conflicts:
#	internal/indexer/temporal_e2e_test.go
#	internal/parser/languages/go_temporal_test.go
#	internal/parser/languages/golang.go
#	internal/parser/languages/golang_temporal.go
* pr81-review:
  feat(temporal): detect outbound signal-send / query-call against running workflows

# Conflicts:
#	internal/parser/languages/go_temporal_test.go
#	internal/parser/languages/golang.go
#	internal/parser/languages/golang_temporal.go
goCallEnvDefaultLiteral modelled cmp.Or backwards (returned the last
string literal; cmp.Or returns the FIRST non-zero argument) and accepted
any callee that mixed an env read with a literal, so an arbitrary user
wrapper like combine(os.Getenv("K"), "Suffix") was misread as an
env-or-default. It now returns the first literal and is gated on the
stdlib cmp.Or callee.

goTemporalEnvDefaultName took the first matching assignment in tree order
and descended into nested closures, so a later live reassignment was
ignored and a shadowing same-named variable in an inner func literal
could mis-flag the outer dispatch. It now replays the writes in source
order (last live write wins) and never descends into nested func
literals.

Adds regression tests: cmp.Or multi-literal first-wins, non-cmp.Or callee
rejected, env-default overwritten by a later reassignment, and a
shadowing closure variable not matched.
PR #81 shipped only parser-level tests; this exercises SignalExternalWorkflow
and client.QueryWorkflow through the full indexer pipeline, asserting the
via=temporal.signal-send / temporal.query-call edges originate from the
sender function and carry the correct temporal_kind + temporal_name.
idx.lookup keyed only on <kind>::<name> with no language filter, and the
candidate set co-mingles Go register targets with Java annotation-tagged
methods. A Go workflow.ExecuteActivity stub could therefore resolve onto
a Java method node when names collided and the Java entry was the unique
overall candidate (pickGoTemporalTarget gates language only on the Go
register-indexing path, not the stub-resolution path). lookup now filters
candidates to the caller's language; the intentional Java->Go join is a
separate cross-language pass. Adds a unit test for the gate.
…e-back

ResolveTemporalCalls runs on every incremental single-file reindex and
scanned the largest edge class (EdgeCalls) three times per pass (once for
register edges in buildTemporalIndex, once for the EdgeAnnotated probe,
once for stub edges) plus an unconditional AddNode for every temporal-role
node — a whole-workspace recompute paid even by repos with no Temporal
code.

- Collect temporal.register and temporal.stub edges in a single EdgeCalls
  sweep and pass them into buildTemporalIndex (no second scan).
- Probe EdgeAnnotated once and early-out before any node fetch / index
  build / Java propagation when the graph has zero temporal edges.
- stampTemporalRole now skips the store write-back when the node already
  carries the same role + name, eliminating the per-pass re-write storm.
goTemporalRegisterName returned only the function-reference identifier, so
RegisterActivityWithOptions(MyActivity, activity.RegisterOptions{Name:
"Override"}) indexed the activity under "MyActivity" and a dispatch of
ExecuteActivity(ctx, "Override", ...) never resolved.

The parser now extracts the RegisterOptions{Name: "..."} literal into
temporal_registered_name; the resolver locates the node by the func-ref
name but stamps + indexes it under the registered name, so the dispatch
matches. Covers RegisterActivityWithOptions and RegisterWorkflowWithOptions
(by-value or &-pointer options literal).
goTemporalRegisterKind flagged RegisterActivities as plural and its doc
promised the resolver would promote each method of the struct, but the
parser discarded the flag and the resolver had no plural handling, so
w.RegisterActivities(&MyActivities{}) registered nothing resolvable.

The parser now extracts the struct TYPE name from the &T{} / T{} argument
and tags the register edge temporal_register_plural. The resolver locates
the Go type node and promotes every exported method (via its EdgeMemberOf
members) to an activity keyed by the method name, so a dispatch of
ExecuteActivity(ctx, "ChargeCard", ...) resolves to (*Activities).ChargeCard.
Unexported methods are skipped. Adds an indexer e2e test.
…mily

Adds detection of client.ExecuteWorkflow(ctx, opts, workflow, args...)
(workflow at position 3) and client.SignalWithStartWorkflow(ctx, wfID,
sig, arg, opts, workflow, ...) (workflow at position 6 — distinct from the
4th-position signal-name family). Each emits a via=temporal.start EdgeCalls
edge carrying temporal_kind=workflow + temporal_name (the workflow func
ref, selector, or string type name).

ResolveTemporalCalls now consumes temporal.start edges alongside
temporal.stub and rewrites them to the registered workflow node, so
get_callers / impact on a Go workflow surfaces the services that start it —
the 'who starts this workflow' relationship. Parser + indexer e2e tests.
The dispatch / handler / SignalExternalWorkflow detectors gated on the
receiver text being literally "workflow", so a file using
import wf "go.temporal.io/sdk/workflow" lost dispatch AND handler AND
signal-send detection at once.

The extractor now resolves the workflow package's local alias from the
file's import table and canonicalises a matching receiver to "workflow"
before the receiver-gated detectors run. A non-workflow receiver that
merely shares a name is unaffected. The three *AliasedNotDetected tests
become *Detected, plus a negative test that a non-workflow receiver is
still ignored when workflow is aliased.
The new provider/consumer edge tags (temporal.handler / signal-send /
query-call / start) and the register variants had no discoverable
documentation. Adds a Temporal edge-taxonomy table to docs/contracts.md
(via, direction, source call, temporal_kind, resolved?) plus the extra
Meta (temporal_registered_name, temporal_register_plural,
temporal_name_origin) and roles, and lists the temporal via values + the
temporal_role Meta field in the gortex://schema MCP resource.
…atch

Real codebases name activities almost exclusively through string consts
(const ChargeCardActivity = "ChargeCard"; ExecuteActivity(ctx,
ChargeCardActivity, ...)), but emitConst dropped the literal so the
dispatch resolved against the identifier and never matched the registered
activity.

Adds a queryable constant_values sidecar (node_id, repo_prefix, file_path,
value) — an optional ConstantValueWriter/Reader capability implemented by
both the in-memory and sqlite backends, mirroring the clone_shingles /
ref_facts pattern. Kept out of the gob Meta blob (unindexable, decoded on
every node load) per the design steer. The Go extractor records each
KindConstant's string/numeric literal; the indexer persists them per file
(repo-prefix-aware, file-scoped eviction on reindex). ResolveTemporalCalls
builds a name→value deref map from the sidecar and, when a dispatch name
doesn't resolve directly, retries under the const's literal value,
recording temporal_const_deref on the edge. Ambiguous const names (same
name, different values) are dropped so a deref is never a wrong guess.
Cross-file e2e + sqlite capability unit tests.
The Java side indexed methods under their bare identifier, while the Go
side registers under the function / type name and the real Temporal wire
name follows the SDK defaults — so the two never lined up for a
cross-language join.

The resolver now computes each Java method's canonical Temporal name and
indexes / stamps it under that string: an explicit @XxxMethod(name=) wins
(parsed from the EdgeAnnotated args); an activity defaults to its method
name capitalized, prefixed by @ActivityInterface(namePrefix=); a workflow's
type is the interface simple name; signal/query/update keep the method
name. This is the prerequisite that makes a name-based Java<->Go match
correct. Adds helper unit tests and canonical-name assertions to the Java
propagation tests.
Closes the polyglot gap: a Java service that starts a workflow implemented
in a Go repo is now linked to the Go workflow node.

Java side: the extractor detects client.newWorkflowStub(OrderWorkflow.class,
...) / newUntypedWorkflowStub("OrderWorkflow") and emits a via=temporal.start
consumer edge keyed by the workflow's canonical type name (the class
literal's simple name, matching the Java SDK default and the name a Go
RegisterWorkflow uses).

Resolver side: when a consumer (temporal.start / stub) has no same-language
handler, lookupCrossLang matches a unique candidate in a DIFFERENT language
by canonical name and lands the edge at the speculative tier
(OriginSpeculative, conf 0.4, MetaSpeculative + temporal_cross_lang) — a
by-name match across a type-system boundary, hidden from default queries.
Builds on the G2 canonical names so the strings line up. get_callers /
impact on a Go workflow now surface the Java services that start it.
Parser, resolver, and Go+Java e2e tests.
Mirrors the Go outbound signal/query detection (#81) on the Java side so
the polyglot estate is symmetric: an outbound stub.signal("name", …) /
stub.query("name", …) on an untyped WorkflowStub emits a
via=temporal.signal-send / temporal.query-call edge carrying the
signal/query name (the first string-literal argument).

Because "signal" / "query" are ordinary method names, detection is gated
on the receiver's inferred type being WorkflowStub (via the extractor's
type environment), so it never false-matches arbitrary code. Parser tests
cover the WorkflowStub case and a negative for a non-stub receiver.
A function that forwards one of its parameters as the dispatch name —
executeActivity(ctx, ao, name, …) { workflow.ExecuteActivity(ctx, name, …) }
— is a dispatch wrapper. The parameter is not a real activity name, so the
stub it produced could never resolve and was pure noise.

The extractor now recognises this shape (the dispatch name is one of the
enclosing function's parameters), suppresses the unresolvable stub, and
stamps temporal_wrapper_kind / temporal_wrapper_param on the function node
so a future interprocedural pass can propagate the caller's argument.
Needed a full param-name set per function (paramsByFunc keeps only
non-builtin-typed params), threaded as paramNamesByFunc.

NOTE: cross-file wrapper-FOLLOWING — propagating a caller's literal/const
through the wrapper to the real handler — requires call-argument flow and
remains a documented blind spot (the const-deref already covers the
const-naming half of the problem). Adds a wrapper-suppression test.
@zzet zzet merged commit 6ce81d4 into main Jun 12, 2026
10 checks passed
@zzet zzet deleted the feat/temporal-cluster branch June 12, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants