77import com .uber .m3 .tally .RootScopeBuilder ;
88import com .uber .m3 .tally .Scope ;
99import io .temporal .api .common .v1 .Link ;
10+ import io .temporal .api .common .v1 .Payloads ;
1011import io .temporal .api .common .v1 .WorkflowExecution ;
1112import io .temporal .api .enums .v1 .EventType ;
1213import io .temporal .api .enums .v1 .UpdateWorkflowExecutionLifecycleStage ;
14+ import io .temporal .api .enums .v1 .WorkflowExecutionStatus ;
15+ import io .temporal .api .query .v1 .QueryRejected ;
1316import io .temporal .api .update .v1 .UpdateRef ;
17+ import io .temporal .api .workflowservice .v1 .QueryWorkflowRequest ;
18+ import io .temporal .api .workflowservice .v1 .QueryWorkflowResponse ;
1419import io .temporal .api .workflowservice .v1 .SignalWithStartWorkflowExecutionRequest ;
1520import io .temporal .api .workflowservice .v1 .SignalWithStartWorkflowExecutionResponse ;
1621import io .temporal .api .workflowservice .v1 .SignalWorkflowExecutionRequest ;
2328import io .temporal .client .WorkflowClientOptions ;
2429import io .temporal .client .WorkflowOptions ;
2530import io .temporal .client .WorkflowUpdateStage ;
31+ import io .temporal .common .converter .DefaultDataConverter ;
2632import io .temporal .common .interceptors .Header ;
33+ import io .temporal .common .interceptors .WorkflowClientCallsInterceptor ;
2734import io .temporal .common .interceptors .WorkflowClientCallsInterceptor .StartUpdateInput ;
2835import io .temporal .common .interceptors .WorkflowClientCallsInterceptor .WorkflowSignalInput ;
2936import io .temporal .common .interceptors .WorkflowClientCallsInterceptor .WorkflowSignalWithStartInput ;
@@ -348,6 +355,116 @@ private static StartUpdateInput<String> newStartUpdateInput() {
348355 .build ());
349356 }
350357
358+ /**
359+ * A query never writes to history, so the server answers with a {@code Link.Workflow} naming the
360+ * execution that processed it instead of a {@code Link.WorkflowEvent}. That link has to reach the
361+ * operation context so the caller's Nexus operation event points back at the queried workflow.
362+ */
363+ @ Test
364+ public void queryCapturesWorkflowResponseLink () {
365+ Link responseLink = workflowLink (WORKFLOW_ID , "target-run" , "Query processed" );
366+ when (genericClient .query (any (QueryWorkflowRequest .class )))
367+ .thenReturn (
368+ QueryWorkflowResponse .newBuilder ()
369+ .setLink (responseLink )
370+ .setQueryResult (queryResult ("answer" ))
371+ .build ());
372+
373+ WorkflowClientCallsInterceptor .QueryOutput <String > output = invoker .query (newQueryInput ());
374+
375+ List <Link > captured = nexusCtx .getResponseLinks ();
376+ Assert .assertEquals ("expected one captured response link" , 1 , captured .size ());
377+ Assert .assertEquals (responseLink , captured .get (0 ));
378+
379+ // Capturing the link must not disturb the query's own result.
380+ Assert .assertFalse (output .isQueryRejected ());
381+ Assert .assertEquals ("answer" , output .getResult ());
382+ }
383+
384+ /**
385+ * Two queries in a row each contribute a response link; both must accumulate in call order on the
386+ * shared list, exactly as the signal path does.
387+ */
388+ @ Test
389+ public void multipleQueriesAccumulateAllResponseLinks () {
390+ Link firstResponseLink = workflowLink ("callee-a" , "run-a" , "Query processed" );
391+ Link secondResponseLink = workflowLink ("callee-b" , "run-b" , "Query processed" );
392+ when (genericClient .query (any (QueryWorkflowRequest .class )))
393+ .thenReturn (QueryWorkflowResponse .newBuilder ().setLink (firstResponseLink ).build ())
394+ .thenReturn (QueryWorkflowResponse .newBuilder ().setLink (secondResponseLink ).build ());
395+
396+ invoker .query (newQueryInput ());
397+ invoker .query (newQueryInput ());
398+
399+ Assert .assertEquals (
400+ "expected one response link per query call, in call order" ,
401+ Arrays .asList (firstResponseLink , secondResponseLink ),
402+ nexusCtx .getResponseLinks ());
403+ }
404+
405+ /**
406+ * A rejected query still carries a link to the workflow that rejected it, and the link is
407+ * captured before the rejection is surfaced. This matches sdk-go, where the link is recorded
408+ * ahead of the QueryRejected branch. Pins the ordering so it is not "fixed" into the wrong
409+ * behavior later.
410+ */
411+ @ Test
412+ public void rejectedQueryStillCapturesResponseLink () {
413+ Link responseLink = workflowLink (WORKFLOW_ID , "target-run" , "Query processed" );
414+ when (genericClient .query (any (QueryWorkflowRequest .class )))
415+ .thenReturn (
416+ QueryWorkflowResponse .newBuilder ()
417+ .setLink (responseLink )
418+ .setQueryRejected (
419+ QueryRejected .newBuilder ()
420+ .setStatus (WorkflowExecutionStatus .WORKFLOW_EXECUTION_STATUS_COMPLETED ))
421+ .build ());
422+
423+ WorkflowClientCallsInterceptor .QueryOutput <String > output = invoker .query (newQueryInput ());
424+
425+ Assert .assertTrue ("expected the query to be reported as rejected" , output .isQueryRejected ());
426+ Assert .assertEquals (
427+ "expected the response link to be captured even for a rejected query" ,
428+ Collections .singletonList (responseLink ),
429+ nexusCtx .getResponseLinks ());
430+ }
431+
432+ /**
433+ * Older-server compatibility: {@code QueryWorkflowResponse.link} is unset, so nothing is captured
434+ * and the query itself still succeeds.
435+ */
436+ @ Test
437+ public void queryAgainstOlderServerCapturesNoResponseLink () {
438+ when (genericClient .query (any (QueryWorkflowRequest .class )))
439+ .thenReturn (QueryWorkflowResponse .getDefaultInstance ());
440+
441+ invoker .query (newQueryInput ());
442+
443+ Assert .assertTrue (
444+ "expected no captured response link when server returned no link" ,
445+ nexusCtx .getResponseLinks ().isEmpty ());
446+ }
447+
448+ /**
449+ * A query issued outside a Nexus operation handler must not touch the operation context at all.
450+ * Guards against the propagation being reached without a context, which would throw.
451+ */
452+ @ Test
453+ public void queryOutsideNexusContextIgnoresResponseLink () {
454+ CurrentNexusOperationContext .unset ();
455+ when (genericClient .query (any (QueryWorkflowRequest .class )))
456+ .thenReturn (
457+ QueryWorkflowResponse .newBuilder ()
458+ .setLink (workflowLink (WORKFLOW_ID , "target-run" , "Query processed" ))
459+ .build ());
460+
461+ invoker .query (newQueryInput ());
462+
463+ Assert .assertTrue (
464+ "a query outside a Nexus context must not record response links" ,
465+ nexusCtx .getResponseLinks ().isEmpty ());
466+ }
467+
351468 // ── helpers ──────────────────────────────────────────────────────────────────────────────
352469
353470 private static WorkflowSignalInput newSignalInput () {
@@ -374,6 +491,33 @@ private static WorkflowSignalWithStartInput newSignalWithStartInput() {
374491 startInput , "test-signal" , new Object [] {"signal-payload" });
375492 }
376493
494+ private static WorkflowClientCallsInterceptor .QueryInput <String > newQueryInput () {
495+ return new WorkflowClientCallsInterceptor .QueryInput <>(
496+ WorkflowExecution .newBuilder ().setWorkflowId (WORKFLOW_ID ).build (),
497+ "test-query" ,
498+ Header .empty (),
499+ new Object [] {},
500+ String .class ,
501+ String .class );
502+ }
503+
504+ private static Payloads queryResult (String value ) {
505+ return DefaultDataConverter .STANDARD_INSTANCE
506+ .toPayloads (value )
507+ .orElseThrow (() -> new IllegalStateException ("expected payloads" ));
508+ }
509+
510+ private static Link workflowLink (String workflowId , String runId , String reason ) {
511+ return Link .newBuilder ()
512+ .setWorkflow (
513+ Link .Workflow .newBuilder ()
514+ .setNamespace (NAMESPACE )
515+ .setWorkflowId (workflowId )
516+ .setRunId (runId )
517+ .setReason (reason ))
518+ .build ();
519+ }
520+
377521 private static Link workflowEventLink (String workflowId , String runId , EventType eventType ) {
378522 return Link .newBuilder ()
379523 .setWorkflowEvent (
0 commit comments