Skip to content

Commit 7c79ef4

Browse files
committed
Add Nexus link converter for Link_Callback
1 parent c424910 commit 7c79ef4

3 files changed

Lines changed: 263 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ to docs, or any other relevant information.
4949
- Workflow tasks taking longer than 5 seconds now log a `[TMPRL1104]` warning reporting the task duration and the
5050
external storage downloads and uploads that contributed to it. The threshold is configurable with the
5151
`TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` environment variable.
52+
- Nexus links of type `Link.Callback` are now converted to and from their Nexus wire form, so they are carried
53+
through Nexus requests and responses instead of being dropped with an "unsupported nexus link type" warning.
5254

5355
### Changed
5456
- Moved the Spring AI integration to [temporalio/ai-integrations](https://github.com/temporalio/ai-integrations/tree/main/java/spring-ai).

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

Lines changed: 157 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
import static io.temporal.internal.common.ProtoEnumNameUtils.simplifiedToUniqueName;
55
import static io.temporal.internal.common.ProtoEnumNameUtils.uniqueToSimplifiedName;
66

7+
import com.google.common.collect.ImmutableBiMap;
8+
import io.temporal.api.common.v1.Execution;
79
import io.temporal.api.common.v1.Link;
810
import io.temporal.api.enums.v1.EventType;
11+
import io.temporal.api.enums.v1.ExecutionType;
912
import java.io.UnsupportedEncodingException;
1013
import java.net.URI;
1114
import java.net.URLDecoder;
@@ -24,13 +27,14 @@
2427
* Converts between {@link Link} (used on history events and RPCs) and {@link
2528
* io.temporal.api.nexus.v1.Link} (the Nexus wire form: a URL plus a type string).
2629
*
27-
* <p>Four link types are supported, each with a fixed URL shape:
30+
* <p>The following link types are supported, each with a fixed URL shape:
2831
*
2932
* <pre>
3033
* WorkflowEvent temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}/history
3134
* Workflow temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}
3235
* NexusOperation temporal:///namespaces/{ns}/nexus-operations/{operationId}/{runId}/details
3336
* Activity temporal:///namespaces/{ns}/activities/{activityId}/{runId}/details
37+
* Callback temporal:///namespaces/{ns}/{executionType}/{businessId}/{runId}/callbacks/{requestId}
3438
* </pre>
3539
*
3640
* <p>Whoever decodes one of these URLs — the receiving server, or this class when a link arrives
@@ -54,42 +58,63 @@ public class LinkConverter {
5458
private static final String EVENT_TYPE_KEY = "eventType";
5559
private static final String REQUEST_ID_KEY = "requestID";
5660
private static final String REASON_KEY = "reason";
61+
private static final String COMPONENT_PATH_KEY = "componentPath";
62+
63+
/**
64+
* The path keyword for each execution type a callback can be attached to, in both directions:
65+
* encoding looks up the keyword, decoding validates it and maps it back. Matches the keywords of
66+
* the other link types, so every link addresses an execution the same way.
67+
*/
68+
private static final ImmutableBiMap<ExecutionType, String> EXECUTION_TYPE_SEGMENTS =
69+
ImmutableBiMap.of(
70+
ExecutionType.EXECUTION_TYPE_WORKFLOW, "workflows",
71+
ExecutionType.EXECUTION_TYPE_ACTIVITY, "activities",
72+
ExecutionType.EXECUTION_TYPE_NEXUS_OPERATION, "nexus-operations");
5773

5874
private static final String EVENT_REFERENCE_TYPE =
5975
Link.WorkflowEvent.EventReference.getDescriptor().getName();
6076
private static final String REQUEST_ID_REFERENCE_TYPE =
6177
Link.WorkflowEvent.RequestIdReference.getDescriptor().getName();
6278

6379
/**
64-
* The four link types, as a table of (path keyword, path tail, proto type name).
80+
* The link types, as a table of (path keyword, path tail, tail ID, proto type name).
6581
*
66-
* <p>The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}]}, so a link has 5
67-
* segments when {@link #tail} is null and 6 otherwise. Matching the count exactly is what keeps a
68-
* Workflow link distinguishable from a WorkflowEvent link, since they share a keyword.
82+
* <p>The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}[/{tailId}]]}, so a
83+
* link has 5 segments with no tail, 6 with a tail, and 7 with a tail ID. Matching the count
84+
* exactly is what keeps a Workflow link distinguishable from a WorkflowEvent link, since they
85+
* share a keyword.
6986
*/
7087
private enum LinkType {
71-
WORKFLOW_EVENT("workflows", "history", Link.WorkflowEvent.getDescriptor().getFullName()),
88+
WORKFLOW_EVENT("workflows", "history", false, Link.WorkflowEvent.getDescriptor().getFullName()),
7289

7390
/** A workflow execution as a whole, for when there is no history event to point at. */
74-
WORKFLOW("workflows", null, Link.Workflow.getDescriptor().getFullName()),
91+
WORKFLOW("workflows", null, false, Link.Workflow.getDescriptor().getFullName()),
7592

7693
NEXUS_OPERATION(
77-
"nexus-operations", "details", Link.NexusOperation.getDescriptor().getFullName()),
94+
"nexus-operations", "details", false, Link.NexusOperation.getDescriptor().getFullName()),
7895

79-
ACTIVITY("activities", "details", Link.Activity.getDescriptor().getFullName());
96+
ACTIVITY("activities", "details", false, Link.Activity.getDescriptor().getFullName()),
8097

81-
private final String keyword;
98+
/**
99+
* A callback attached to an execution. The keyword names the execution's type, so it varies and
100+
* is any value of {@link #EXECUTION_TYPE_SEGMENTS}. The tail ID is the callback's request ID.
101+
*/
102+
CALLBACK(null, "callbacks", true, Link.Callback.getDescriptor().getFullName());
103+
104+
@Nullable private final String keyword;
82105
@Nullable private final String tail;
106+
private final boolean hasTailId;
83107
private final String type;
84108

85-
LinkType(String keyword, @Nullable String tail, String type) {
109+
LinkType(@Nullable String keyword, @Nullable String tail, boolean hasTailId, String type) {
86110
this.keyword = keyword;
87111
this.tail = tail;
112+
this.hasTailId = hasTailId;
88113
this.type = type;
89114
}
90115

91116
int segmentCount() {
92-
return tail == null ? 5 : 6;
117+
return 5 + (tail == null ? 0 : 1) + (hasTailId ? 1 : 0);
93118
}
94119
}
95120

@@ -112,6 +137,9 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) {
112137
if (commonLink.hasActivity()) {
113138
return activityToNexusLink(commonLink.getActivity());
114139
}
140+
if (commonLink.hasCallback()) {
141+
return callbackToNexusLink(commonLink.getCallback());
142+
}
115143
return null;
116144
}
117145

@@ -156,6 +184,30 @@ public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity ac
156184
Collections.emptyList());
157185
}
158186

187+
@Nullable
188+
public static io.temporal.api.nexus.v1.Link callbackToNexusLink(Link.Callback cb) {
189+
Execution execution = cb.getExecution();
190+
String executionType = EXECUTION_TYPE_SEGMENTS.get(execution.getType());
191+
if (executionType == null) {
192+
log.error(
193+
"Failed to convert Callback link to Nexus link: unsupported execution type: {}",
194+
execution.getType());
195+
return null;
196+
}
197+
List<Map.Entry<String, String>> query = new ArrayList<>();
198+
for (String segment : cb.getComponentPathList()) {
199+
query.add(param(COMPONENT_PATH_KEY, segment));
200+
}
201+
return encode(
202+
LinkType.CALLBACK,
203+
executionType,
204+
cb.getNamespace(),
205+
execution.getBusinessId(),
206+
execution.getRunId(),
207+
cb.getRequestId(),
208+
query);
209+
}
210+
159211
// ===============================================================================================
160212
// Decode: nexus.v1.Link -> Link.
161213
// ===============================================================================================
@@ -176,6 +228,9 @@ public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) {
176228
if (LinkType.ACTIVITY.type.equals(type)) {
177229
return nexusLinkToActivity(nexusLink);
178230
}
231+
if (LinkType.CALLBACK.type.equals(type)) {
232+
return nexusLinkToCallback(nexusLink);
233+
}
179234
log.warn("ignoring unsupported nexus link type: {}", type);
180235
return null;
181236
}
@@ -245,22 +300,58 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink)
245300
.build();
246301
}
247302

303+
/** Inverse of {@link #callbackToNexusLink}. A missing component path decodes to an empty one. */
304+
@Nullable
305+
public static Link nexusLinkToCallback(io.temporal.api.nexus.v1.Link nexusLink) {
306+
Decoded decoded = decode(LinkType.CALLBACK, nexusLink);
307+
if (decoded == null) {
308+
return null;
309+
}
310+
ExecutionType executionType = EXECUTION_TYPE_SEGMENTS.inverse().get(decoded.keyword);
311+
return Link.newBuilder()
312+
.setCallback(
313+
Link.Callback.newBuilder()
314+
.setNamespace(decoded.namespace)
315+
.setExecution(
316+
Execution.newBuilder()
317+
.setType(executionType)
318+
.setBusinessId(decoded.id)
319+
.setRunId(decoded.runId))
320+
.setRequestId(decoded.tailId)
321+
.addAllComponentPath(decoded.componentPath))
322+
.build();
323+
}
324+
248325
// ===============================================================================================
249326
// Shared encode/decode.
250327
// ===============================================================================================
251328

329+
/** Builds a Nexus link URL for {@code linkType}, which must have a fixed keyword. */
330+
@Nullable
331+
private static io.temporal.api.nexus.v1.Link encode(
332+
LinkType linkType,
333+
String namespace,
334+
String id,
335+
String runId,
336+
List<Map.Entry<String, String>> queryParams) {
337+
return encode(linkType, linkType.keyword, namespace, id, runId, null, queryParams);
338+
}
339+
252340
/**
253-
* Builds a Nexus link URL for {@code linkType}.
341+
* Builds a Nexus link URL for {@code linkType}, with {@code keyword} in place of the type's own
342+
* for a type whose keyword varies, and {@code tailId} after the tail for a type that has one.
254343
*
255344
* <p>Concatenated rather than built with {@link URI}, because the segments are already
256345
* percent-encoded and {@link URI} would escape the escapes.
257346
*/
258347
@Nullable
259348
private static io.temporal.api.nexus.v1.Link encode(
260349
LinkType linkType,
350+
String keyword,
261351
String namespace,
262352
String id,
263353
String runId,
354+
@Nullable String tailId,
264355
List<Map.Entry<String, String>> queryParams) {
265356
try {
266357
StringBuilder url =
@@ -270,14 +361,17 @@ private static io.temporal.api.nexus.v1.Link encode(
270361
.append('/')
271362
.append(encodePathSegment(namespace))
272363
.append('/')
273-
.append(linkType.keyword)
364+
.append(keyword)
274365
.append('/')
275366
.append(encodePathSegment(id))
276367
.append('/')
277368
.append(encodePathSegment(runId));
278369
if (linkType.tail != null) {
279370
url.append('/').append(linkType.tail);
280371
}
372+
if (tailId != null) {
373+
url.append('/').append(encodePathSegment(tailId));
374+
}
281375
if (!queryParams.isEmpty()) {
282376
url.append('?').append(encodeQuery(queryParams));
283377
}
@@ -291,18 +385,31 @@ private static io.temporal.api.nexus.v1.Link encode(
291385
}
292386
}
293387

294-
/** The three path IDs plus the decoded query. */
388+
/** The path IDs, the execution keyword and the tail ID if any, plus the query. */
295389
private static final class Decoded {
296390
final String namespace;
391+
final String keyword;
297392
final String id;
298393
final String runId;
394+
@Nullable final String tailId;
299395
final Map<String, String> query;
300-
301-
Decoded(String namespace, String id, String runId, Map<String, String> query) {
396+
final List<String> componentPath;
397+
398+
Decoded(
399+
String namespace,
400+
String keyword,
401+
String id,
402+
String runId,
403+
@Nullable String tailId,
404+
@Nullable String rawQuery)
405+
throws UnsupportedEncodingException {
302406
this.namespace = namespace;
407+
this.keyword = keyword;
303408
this.id = id;
304409
this.runId = runId;
305-
this.query = query;
410+
this.tailId = tailId;
411+
this.query = decodeQuery(rawQuery);
412+
this.componentPath = decodeQueryValues(rawQuery);
306413
}
307414
}
308415

@@ -339,21 +446,29 @@ private static Decoded decode(LinkType linkType, io.temporal.api.nexus.v1.Link n
339446

340447
if (segments.length != linkType.segmentCount()
341448
|| !NAMESPACES_SEGMENT.equals(segments[0])
342-
|| !linkType.keyword.equals(segments[2])
449+
|| (linkType.keyword != null && !linkType.keyword.equals(segments[2]))
343450
|| (linkType.tail != null && !linkType.tail.equals(segments[5]))) {
344451
log.error("Failed to parse Nexus link URL: invalid path: {}", rawPath);
345452
return null;
346453
}
347-
if (segments[1].isEmpty() || segments[3].isEmpty() || segments[4].isEmpty()) {
348-
log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath);
454+
for (String segment : segments) {
455+
if (segment.isEmpty()) {
456+
log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath);
457+
return null;
458+
}
459+
}
460+
if (linkType.keyword == null && !EXECUTION_TYPE_SEGMENTS.containsValue(segments[2])) {
461+
log.error("Failed to parse Nexus link URL: unsupported execution type: {}", segments[2]);
349462
return null;
350463
}
351464

352465
return new Decoded(
353466
decodePathSegment(segments[1]),
467+
segments[2],
354468
decodePathSegment(segments[3]),
355469
decodePathSegment(segments[4]),
356-
decodeQuery(uri.getRawQuery()));
470+
linkType.hasTailId ? decodePathSegment(segments[6]) : null,
471+
uri.getRawQuery());
357472
} catch (Exception e) {
358473
// Swallow un-parsable links since they are not critical to processing.
359474
log.error("Failed to parse Nexus link URL", e);
@@ -435,6 +550,26 @@ private static Map<String, String> decodeQuery(@Nullable String rawQuery)
435550
return params;
436551
}
437552

553+
/**
554+
* Form-decodes every {@code componentPath} value in a raw query, in order. {@link #decodeQuery}
555+
* keeps only the first value of a repeated key, so this repeated param needs its own pass. A key
556+
* with no value yields an empty string, matching api-go's {@code Query()}.
557+
*/
558+
private static List<String> decodeQueryValues(@Nullable String rawQuery)
559+
throws UnsupportedEncodingException {
560+
List<String> values = new ArrayList<>();
561+
if (rawQuery == null || rawQuery.isEmpty()) {
562+
return values;
563+
}
564+
for (String pair : rawQuery.split("&")) {
565+
String[] kv = pair.split("=", 2);
566+
if (!pair.isEmpty() && COMPONENT_PATH_KEY.equals(URLDecoder.decode(kv[0], UTF_8))) {
567+
values.add(kv.length == 2 ? URLDecoder.decode(kv[1], UTF_8) : "");
568+
}
569+
}
570+
return values;
571+
}
572+
438573
// ===============================================================================================
439574
// The WorkflowEvent "reference": WHICH event in the workflow's history the link points at.
440575
//

0 commit comments

Comments
 (0)