Skip to content

Commit accc671

Browse files
committed
Query commit
1 parent af2f853 commit accc671

5 files changed

Lines changed: 851 additions & 14 deletions

File tree

temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,14 @@ public <R> QueryOutput<R> query(QueryInput<R> input) {
434434
QueryWorkflowResponse result;
435435
result = genericClient.query(request);
436436

437+
// A query writes nothing to history, so the server returns a link to the workflow execution
438+
// that processed it rather than to an event. When the query is issued from inside a Nexus
439+
// operation handler, propagate that link so the caller's Nexus operation event points at the
440+
// queried workflow. Older servers leave it unset.
441+
if (CurrentNexusOperationContext.isNexusContext() && result.hasLink()) {
442+
CurrentNexusOperationContext.get().addResponseLink(result.getLink());
443+
}
444+
437445
boolean queryRejected = result.hasQueryRejected();
438446
WorkflowExecutionStatus rejectStatus =
439447
queryRejected ? result.getQueryRejected().getStatus() : null;

temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ public class LinkConverter {
2525
"temporal:///namespaces/%s/nexus-operations/%s/%s/details";
2626
private static final String activityLinkPathFormat =
2727
"temporal:///namespaces/%s/activities/%s/%s/details";
28+
private static final String workflowLinkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s";
2829
private static final String linkReferenceTypeKey = "referenceType";
2930
private static final String linkEventIDKey = "eventID";
3031
private static final String linkEventTypeKey = "eventType";
3132
private static final String linkRequestIDKey = "requestID";
33+
private static final String linkReasonKey = "reason";
3234

3335
private static final String eventReferenceType =
3436
Link.WorkflowEvent.EventReference.getDescriptor().getName();
@@ -98,14 +100,28 @@ public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.Workfl
98100
return null;
99101
}
100102

103+
/**
104+
* Converts a {@link Link.Workflow} to a Nexus link. A workflow link addresses a workflow
105+
* execution as a whole rather than one event within it, so the URL uses the workflow path and
106+
* carries no event path suffix and no reference query params. It is used when there is no history
107+
* event to point at, for example a Query or a rejected Update. The optional {@code reason}
108+
* explaining why the link exists is carried as a query param.
109+
*/
101110
public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) {
102111
try {
103-
String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString());
104-
String workflowId =
105-
URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString())
106-
.replace("+", "%20"); // handle workflowIds supporting spaces
107-
String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString());
108-
String url = String.format(linkPathFormat, namespace, workflowId, runId);
112+
String url =
113+
String.format(
114+
workflowLinkPathFormat,
115+
encodePathSegment(w.getNamespace()),
116+
encodePathSegment(w.getWorkflowId()),
117+
encodePathSegment(w.getRunId()));
118+
if (!w.getReason().isEmpty()) {
119+
url +=
120+
"?"
121+
+ linkReasonKey
122+
+ "="
123+
+ URLEncoder.encode(w.getReason(), StandardCharsets.UTF_8.toString());
124+
}
109125
return io.temporal.api.nexus.v1.Link.newBuilder()
110126
.setUrl(url)
111127
.setType(workflowLinkType)
@@ -190,16 +206,25 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL
190206
}
191207

192208
public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) {
209+
if (!workflowLinkType.equals(nexusLink.getType())) {
210+
log.error(
211+
"Failed to parse Nexus link URL: cannot parse link type {} to {}",
212+
nexusLink.getType(),
213+
workflowLinkType);
214+
return null;
215+
}
193216
Link.Builder link = Link.newBuilder();
194217
try {
195218
URI uri = new URI(nexusLink.getUrl());
196-
log.debug("Parsing nexus link URL: {}", uri.getRawPath());
197-
if (!uri.getScheme().equals(temporalUrlScheme)) {
219+
220+
// Compared in this order so a URL with no scheme at all reports the invalid scheme rather
221+
// than throwing.
222+
if (!temporalUrlScheme.equals(uri.getScheme())) {
198223
log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
199224
return null;
200225
}
226+
201227
StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
202-
// maybe add constants for "namespaces", "workflows" too
203228
if (!st.nextToken().equals("namespaces")) {
204229
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
205230
return null;
@@ -210,18 +235,28 @@ public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLi
210235
return null;
211236
}
212237
String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
213-
if (!st.hasMoreTokens()) {
238+
String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
239+
// The run ID ends a workflow link, so anything trailing means this is a different link
240+
// shape. In particular this rejects the workflow-event form, which ends in "/history".
241+
if (st.hasMoreTokens()) {
214242
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
215243
return null;
216244
}
217-
String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
218-
link.setWorkflow(
245+
246+
Link.Workflow.Builder w =
219247
Link.Workflow.newBuilder()
220248
.setNamespace(namespace)
221249
.setWorkflowId(workflowID)
222-
.setRunId(runID));
250+
.setRunId(runID);
251+
String reason = rawQueryParam(uri, linkReasonKey);
252+
if (reason != null) {
253+
w.setReason(reason);
254+
}
255+
256+
link.setWorkflow(w);
223257
} catch (Exception e) {
224-
log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, e);
258+
// Swallow un-parsable links since they are not critical to processing.
259+
log.error("Failed to parse Nexus link URL", e);
225260
return null;
226261
}
227262
return link.build();
@@ -406,6 +441,33 @@ public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexus
406441
return link.build();
407442
}
408443

444+
/**
445+
* Percent-encodes a single URL path segment. {@link URLEncoder} targets form encoding, where a
446+
* space becomes '+', so rewrite it to "%20" as required for a path.
447+
*/
448+
private static String encodePathSegment(String value) throws UnsupportedEncodingException {
449+
return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20");
450+
}
451+
452+
/**
453+
* Reads a single param out of the raw, still-encoded query string, or returns null when the param
454+
* is absent. Unlike {@link #parseQueryParams} the value is decoded exactly once, so values that
455+
* themselves contain '=' or '&' survive the round trip.
456+
*/
457+
private static String rawQueryParam(URI uri, String key) throws UnsupportedEncodingException {
458+
final String rawQuery = uri.getRawQuery();
459+
if (rawQuery == null || rawQuery.isEmpty()) {
460+
return null;
461+
}
462+
for (String pair : rawQuery.split("&")) {
463+
final String[] kv = pair.split("=", 2);
464+
if (kv[0].equals(key)) {
465+
return kv.length == 2 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) : "";
466+
}
467+
}
468+
return null;
469+
}
470+
409471
private static Map<String, String> parseQueryParams(URI uri) throws UnsupportedEncodingException {
410472
final String query = uri.getQuery();
411473
if (query == null || query.isEmpty()) {

temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
import com.uber.m3.tally.RootScopeBuilder;
88
import com.uber.m3.tally.Scope;
99
import io.temporal.api.common.v1.Link;
10+
import io.temporal.api.common.v1.Payloads;
1011
import io.temporal.api.common.v1.WorkflowExecution;
1112
import io.temporal.api.enums.v1.EventType;
1213
import io.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage;
14+
import io.temporal.api.enums.v1.WorkflowExecutionStatus;
15+
import io.temporal.api.query.v1.QueryRejected;
1316
import io.temporal.api.update.v1.UpdateRef;
17+
import io.temporal.api.workflowservice.v1.QueryWorkflowRequest;
18+
import io.temporal.api.workflowservice.v1.QueryWorkflowResponse;
1419
import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest;
1520
import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse;
1621
import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest;
@@ -23,7 +28,9 @@
2328
import io.temporal.client.WorkflowClientOptions;
2429
import io.temporal.client.WorkflowOptions;
2530
import io.temporal.client.WorkflowUpdateStage;
31+
import io.temporal.common.converter.DefaultDataConverter;
2632
import io.temporal.common.interceptors.Header;
33+
import io.temporal.common.interceptors.WorkflowClientCallsInterceptor;
2734
import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.StartUpdateInput;
2835
import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput;
2936
import 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

Comments
 (0)