Skip to content

Propagate Nexus links and request IDs to all activity starts in Nexus handlers - #3048

Open
tekkaya wants to merge 2 commits into
mainfrom
nexus-saa-bypass-links-dedup
Open

Propagate Nexus links and request IDs to all activity starts in Nexus handlers#3048
tekkaya wants to merge 2 commits into
mainfrom
nexus-saa-bypass-links-dedup

Conversation

@tekkaya

@tekkaya tekkaya commented Aug 28, 2026

Copy link
Copy Markdown

What was changed

RootActivityClientInvoker.startActivity now propagates the inbound Nexus task’s links and request ID to every activity start made on the operation-handler thread, including starts made through a raw ActivityClient.

NexusOperationMetadata remains narrowly scoped to the guarded backing start: it is the only mechanism that can attach a completion callback, because only that start is allowed to complete the Nexus operation. When present, its request ID takes precedence over the ambient inbound request ID.

Why?

TemporalNexusClient permits only one guarded primitive activity start per operation invocation. A synchronous handler that starts another activity must use a raw ActivityClient; previously, those additional activity starts received neither inbound links nor redelivery-safe request-ID reuse.

Sharing the inbound request ID across all starts is deliberate. A handler that reuses an activity ID within one invocation can resolve a later start to the earlier run; deriving per-start IDs would require assuming the handler repeats starts in the same order after redelivery.

Restricting request-ID reuse to only the guarded start preserves the previous behavior, but does not address additional starts made through a raw ActivityClient. This approach aligns with the approved sdk-go fix #2633.

Checklist

  1. Closes the multiple-activity-start gap identified during Java SDK testing of Nexus + Standalone Activities.

  2. Tested with:

    • RootActivityClientInvokerTest (unit)
    • ActivityOperationLinkingTest (functional; requires a real server and is gated by SDKTestWorkflowRule.useExternalService)
  3. Documentation updates needed: No.

@tekkaya
tekkaya force-pushed the nexus-saa-bypass-links-dedup branch 2 times, most recently from eddaa54 to 507ea44 Compare August 28, 2026 20:10
@tekkaya
tekkaya marked this pull request as ready for review August 28, 2026 20:46
@tekkaya
tekkaya requested a review from a team as a code owner August 28, 2026 20:46
@tekkaya
tekkaya force-pushed the nexus-saa-bypass-links-dedup branch from 507ea44 to 9377346 Compare August 29, 2026 04:55
@tekkaya tekkaya changed the title Give bypass-path Nexus SAA starts links and request-ID dedup Give bypass-path Nexus SAA starts links, matching sdk-python's request-ID scoping Aug 29, 2026
@tekkaya tekkaya changed the title Give bypass-path Nexus SAA starts links, matching sdk-python's request-ID scoping Support starting multiple activities from a synchronous Nexus operation handler Aug 29, 2026
@tekkaya
tekkaya force-pushed the nexus-saa-bypass-links-dedup branch from 9377346 to 0d6c817 Compare August 29, 2026 05:02
@tekkaya tekkaya changed the title Support starting multiple activities from a synchronous Nexus operation handler Links and request id dedup in activity starts from a synchronous Nexus operation handler Aug 31, 2026
@tekkaya
tekkaya force-pushed the nexus-saa-bypass-links-dedup branch from 0d6c817 to 030ecb7 Compare August 31, 2026 22:24
@tekkaya tekkaya changed the title Links and request id dedup in activity starts from a synchronous Nexus operation handler Support starting multiple activities from a synchronous Nexus operation handler Aug 31, 2026
@tekkaya tekkaya changed the title Support starting multiple activities from a synchronous Nexus operation handler Propagate Nexus links and request IDs to all activity starts in Nexus handlers Aug 31, 2026
…on handler

TemporalNexusClient guards its own activity/workflow/update start to at most
one per operation invocation, so a synchronous Nexus operation handler that
starts two or more activities inline has to bypass it and use a raw
ActivityClient obtained from Nexus.getOperationContext() instead. That bypass
path got no request links, because RootActivityClientInvoker.startActivity
derived link attachment, on-conflict dedup, and completion-callback
attachment all from the same NexusOperationMetadata, which only the guarded
TemporalNexusClient call ever sets.

Every activity start made during the invocation now reuses the inbound Nexus
task's request ID and gets its inbound links, regardless of which client
object issued it. NexusOperationMetadata keeps its narrow, one-shot scope and
remains the only thing that can attach a completion callback, since only the
guarded start should complete the Nexus operation. When metadata is present
its own request ID still takes precedence, so the guarded start's identity
never depends on the ambient value also having been set.

Reusing one ambient ID across every start in an invocation is a deliberate,
known tradeoff: a handler that starts a fresh run under an activity ID it
already used earlier in the same invocation can have that start incorrectly
resolve to the stale run instead of creating a new one. Two narrower,
redelivery-aware alternatives were tried and dropped -- raw ambient-ID reuse
scoped to only the guarded call, then a per-call ID derived from each start's
ordinal position -- because both require assuming the handler reissues an
identical sequence of calls on every retry, an assumption the SDK has no way
to verify. This change instead matches sdk-go's approved fix
(temporalnexus/temporal_operation.go, PR #2633) and sdk-python's current
behavior (temporalio/nexus/_operation_context.py): every activity start in a
Nexus context reuses the ambient request ID unconditionally. sdk-python's own
attempt at the narrower, backing-call-only scoping (PR #1722) was closed
without merging for the same reason.

ActivityOperationLinkingTest (functional, requires a real server) drives a
synchronous handler that starts two bypass-path activities and asserts both
the forward link (each activity's own ActivityExecutionInfo) and the
backward links (both activities' completions landing on the caller's single
NexusOperationCompleted event), the same way SignalOperationLinkingTest
already does for signals.

RootActivityClientInvokerTest covers metadata's request ID taking precedence
over the ambient one, the ambient-links-and-request-ID-without-metadata case,
the outside-Nexus-context case, and two bypass-path starts in one invocation
sharing the ambient request ID.
@tekkaya
tekkaya force-pushed the nexus-saa-bypass-links-dedup branch from 030ecb7 to 8fdbb33 Compare August 31, 2026 22:36
The server rejects a StartActivityExecutionRequest whose OnConflictOptions
sets attach_request_id when the request carries neither a link nor a
completion callback (chasm/lib/activity/validator.go's
validateOnConflictOptions: "attach_request_id requires at least one
completion callback or link"). The previous code set attach_request_id and
attach_links unconditionally whenever any Nexus context existed, regardless
of whether the inbound task actually had links -- a bypass-path activity
start issued during an invocation whose inbound Nexus task carries no links
would send exactly that invalid combination and get rejected. This wasn't
caught before because the existing unit tests mock the client and never
exercise real server-side validation.

OnConflictOptions is now only set when there's something to attach, and each
flag reflects what the request actually carries, matching sdk-go's identical
gating in its own fix (temporalnexus/temporal_operation.go, PR #2633). This
also fixes the guarded (metadata-backed) path: it previously set
attach_completion_callbacks based on whether metadata was present rather
than whether a callback URL was actually set, so a guarded start with an
empty callback URL and no links would hit the same rejection.

RootActivityClientInvokerTest: flipped the assertion in
nexusMetadataWithEmptyCallbackUrlOmitsCompletionCallback (attach_completion_
callbacks now correctly reflects the absence of a real callback), rewrote
nexusContextWithoutAmbientStateStartsOrdinaryActivity to assert
OnConflictOptions is entirely absent, and added
metadataWithEmptyCallbackUrlAndNoLinksOmitsOnConflictOptions covering the
previously-untested guarded-call variant of the same bug.
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.

1 participant