diff --git a/CHANGELOG.md b/CHANGELOG.md index d426a29297..b93088d53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ to docs, or any other relevant information. - Workflow tasks taking longer than 5 seconds now log a `[TMPRL1104]` warning reporting the task duration and the external storage downloads and uploads that contributed to it. The threshold is configurable with the `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` environment variable. +- Nexus links of type `Link.Callback` are now converted to and from their Nexus wire form, so they are carried + through Nexus requests and responses instead of being dropped with an "unsupported nexus link type" warning. ### Changed - Moved the Spring AI integration to [temporalio/ai-integrations](https://github.com/temporalio/ai-integrations/tree/main/java/spring-ai). diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java index bbdc0b926e..5de80b1062 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java @@ -4,8 +4,11 @@ import static io.temporal.internal.common.ProtoEnumNameUtils.simplifiedToUniqueName; import static io.temporal.internal.common.ProtoEnumNameUtils.uniqueToSimplifiedName; +import com.google.common.collect.ImmutableBiMap; +import io.temporal.api.common.v1.Execution; import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.ExecutionType; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; @@ -24,13 +27,14 @@ * Converts between {@link Link} (used on history events and RPCs) and {@link * io.temporal.api.nexus.v1.Link} (the Nexus wire form: a URL plus a type string). * - *

Four link types are supported, each with a fixed URL shape: + *

The following link types are supported, each with a fixed URL shape: * *

  *   WorkflowEvent    temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}/history
  *   Workflow         temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}
  *   NexusOperation   temporal:///namespaces/{ns}/nexus-operations/{operationId}/{runId}/details
  *   Activity         temporal:///namespaces/{ns}/activities/{activityId}/{runId}/details
+ *   Callback         temporal:///namespaces/{ns}/{executionType}/{businessId}/{runId}/callbacks/{requestId}
  * 
* *

Whoever decodes one of these URLs — the receiving server, or this class when a link arrives @@ -54,6 +58,18 @@ public class LinkConverter { private static final String EVENT_TYPE_KEY = "eventType"; private static final String REQUEST_ID_KEY = "requestID"; private static final String REASON_KEY = "reason"; + private static final String COMPONENT_PATH_KEY = "componentPath"; + + /** + * The path keyword for each execution type a callback can be attached to, in both directions: + * encoding looks up the keyword, decoding validates it and maps it back. Matches the keywords of + * the other link types, so every link addresses an execution the same way. + */ + private static final ImmutableBiMap EXECUTION_TYPE_SEGMENTS = + ImmutableBiMap.of( + ExecutionType.EXECUTION_TYPE_WORKFLOW, "workflows", + ExecutionType.EXECUTION_TYPE_ACTIVITY, "activities", + ExecutionType.EXECUTION_TYPE_NEXUS_OPERATION, "nexus-operations"); private static final String EVENT_REFERENCE_TYPE = Link.WorkflowEvent.EventReference.getDescriptor().getName(); @@ -61,35 +77,44 @@ public class LinkConverter { Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); /** - * The four link types, as a table of (path keyword, path tail, proto type name). + * The link types, as a table of (path keyword, path tail, tail ID, proto type name). * - *

The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}]}, so a link has 5 - * segments when {@link #tail} is null and 6 otherwise. Matching the count exactly is what keeps a - * Workflow link distinguishable from a WorkflowEvent link, since they share a keyword. + *

The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}[/{tailId}]]}, so a + * link has 5 segments with no tail, 6 with a tail, and 7 with a tail ID. Matching the count + * exactly is what keeps a Workflow link distinguishable from a WorkflowEvent link, since they + * share a keyword. */ private enum LinkType { - WORKFLOW_EVENT("workflows", "history", Link.WorkflowEvent.getDescriptor().getFullName()), + WORKFLOW_EVENT("workflows", "history", false, Link.WorkflowEvent.getDescriptor().getFullName()), /** A workflow execution as a whole, for when there is no history event to point at. */ - WORKFLOW("workflows", null, Link.Workflow.getDescriptor().getFullName()), + WORKFLOW("workflows", null, false, Link.Workflow.getDescriptor().getFullName()), NEXUS_OPERATION( - "nexus-operations", "details", Link.NexusOperation.getDescriptor().getFullName()), + "nexus-operations", "details", false, Link.NexusOperation.getDescriptor().getFullName()), - ACTIVITY("activities", "details", Link.Activity.getDescriptor().getFullName()); + ACTIVITY("activities", "details", false, Link.Activity.getDescriptor().getFullName()), - private final String keyword; + /** + * A callback attached to an execution. The keyword names the execution's type, so it varies and + * is any value of {@link #EXECUTION_TYPE_SEGMENTS}. The tail ID is the callback's request ID. + */ + CALLBACK(null, "callbacks", true, Link.Callback.getDescriptor().getFullName()); + + @Nullable private final String keyword; @Nullable private final String tail; + private final boolean hasTailId; private final String type; - LinkType(String keyword, @Nullable String tail, String type) { + LinkType(@Nullable String keyword, @Nullable String tail, boolean hasTailId, String type) { this.keyword = keyword; this.tail = tail; + this.hasTailId = hasTailId; this.type = type; } int segmentCount() { - return tail == null ? 5 : 6; + return 5 + (tail == null ? 0 : 1) + (hasTailId ? 1 : 0); } } @@ -112,6 +137,9 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { if (commonLink.hasActivity()) { return activityToNexusLink(commonLink.getActivity()); } + if (commonLink.hasCallback()) { + return callbackToNexusLink(commonLink.getCallback()); + } return null; } @@ -156,6 +184,30 @@ public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity ac Collections.emptyList()); } + @Nullable + public static io.temporal.api.nexus.v1.Link callbackToNexusLink(Link.Callback cb) { + Execution execution = cb.getExecution(); + String executionType = EXECUTION_TYPE_SEGMENTS.get(execution.getType()); + if (executionType == null) { + log.error( + "Failed to convert Callback link to Nexus link: unsupported execution type: {}", + execution.getType()); + return null; + } + List> query = new ArrayList<>(); + for (String segment : cb.getComponentPathList()) { + query.add(param(COMPONENT_PATH_KEY, segment)); + } + return encode( + LinkType.CALLBACK, + executionType, + cb.getNamespace(), + execution.getBusinessId(), + execution.getRunId(), + cb.getRequestId(), + query); + } + // =============================================================================================== // Decode: nexus.v1.Link -> Link. // =============================================================================================== @@ -176,6 +228,9 @@ public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { if (LinkType.ACTIVITY.type.equals(type)) { return nexusLinkToActivity(nexusLink); } + if (LinkType.CALLBACK.type.equals(type)) { + return nexusLinkToCallback(nexusLink); + } log.warn("ignoring unsupported nexus link type: {}", type); return null; } @@ -245,12 +300,46 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) .build(); } + /** Inverse of {@link #callbackToNexusLink}. A missing component path decodes to an empty one. */ + @Nullable + public static Link nexusLinkToCallback(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.CALLBACK, nexusLink); + if (decoded == null) { + return null; + } + ExecutionType executionType = EXECUTION_TYPE_SEGMENTS.inverse().get(decoded.keyword); + return Link.newBuilder() + .setCallback( + Link.Callback.newBuilder() + .setNamespace(decoded.namespace) + .setExecution( + Execution.newBuilder() + .setType(executionType) + .setBusinessId(decoded.id) + .setRunId(decoded.runId)) + .setRequestId(decoded.tailId) + .addAllComponentPath(decoded.componentPath)) + .build(); + } + // =============================================================================================== // Shared encode/decode. // =============================================================================================== + /** Builds a Nexus link URL for {@code linkType}, which must have a fixed keyword. */ + @Nullable + private static io.temporal.api.nexus.v1.Link encode( + LinkType linkType, + String namespace, + String id, + String runId, + List> queryParams) { + return encode(linkType, linkType.keyword, namespace, id, runId, null, queryParams); + } + /** - * Builds a Nexus link URL for {@code linkType}. + * Builds a Nexus link URL for {@code linkType}, with {@code keyword} in place of the type's own + * for a type whose keyword varies, and {@code tailId} after the tail for a type that has one. * *

Concatenated rather than built with {@link URI}, because the segments are already * percent-encoded and {@link URI} would escape the escapes. @@ -258,9 +347,11 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) @Nullable private static io.temporal.api.nexus.v1.Link encode( LinkType linkType, + String keyword, String namespace, String id, String runId, + @Nullable String tailId, List> queryParams) { try { StringBuilder url = @@ -270,7 +361,7 @@ private static io.temporal.api.nexus.v1.Link encode( .append('/') .append(encodePathSegment(namespace)) .append('/') - .append(linkType.keyword) + .append(keyword) .append('/') .append(encodePathSegment(id)) .append('/') @@ -278,6 +369,9 @@ private static io.temporal.api.nexus.v1.Link encode( if (linkType.tail != null) { url.append('/').append(linkType.tail); } + if (tailId != null) { + url.append('/').append(encodePathSegment(tailId)); + } if (!queryParams.isEmpty()) { url.append('?').append(encodeQuery(queryParams)); } @@ -291,18 +385,31 @@ private static io.temporal.api.nexus.v1.Link encode( } } - /** The three path IDs plus the decoded query. */ + /** The path IDs, the execution keyword and the tail ID if any, plus the query. */ private static final class Decoded { final String namespace; + final String keyword; final String id; final String runId; + @Nullable final String tailId; final Map query; - - Decoded(String namespace, String id, String runId, Map query) { + final List componentPath; + + Decoded( + String namespace, + String keyword, + String id, + String runId, + @Nullable String tailId, + @Nullable String rawQuery) + throws UnsupportedEncodingException { this.namespace = namespace; + this.keyword = keyword; this.id = id; this.runId = runId; - this.query = query; + this.tailId = tailId; + this.query = decodeQuery(rawQuery); + this.componentPath = decodeQueryValues(rawQuery); } } @@ -339,21 +446,29 @@ private static Decoded decode(LinkType linkType, io.temporal.api.nexus.v1.Link n if (segments.length != linkType.segmentCount() || !NAMESPACES_SEGMENT.equals(segments[0]) - || !linkType.keyword.equals(segments[2]) + || (linkType.keyword != null && !linkType.keyword.equals(segments[2])) || (linkType.tail != null && !linkType.tail.equals(segments[5]))) { log.error("Failed to parse Nexus link URL: invalid path: {}", rawPath); return null; } - if (segments[1].isEmpty() || segments[3].isEmpty() || segments[4].isEmpty()) { - log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath); + for (String segment : segments) { + if (segment.isEmpty()) { + log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath); + return null; + } + } + if (linkType.keyword == null && !EXECUTION_TYPE_SEGMENTS.containsValue(segments[2])) { + log.error("Failed to parse Nexus link URL: unsupported execution type: {}", segments[2]); return null; } return new Decoded( decodePathSegment(segments[1]), + segments[2], decodePathSegment(segments[3]), decodePathSegment(segments[4]), - decodeQuery(uri.getRawQuery())); + linkType.hasTailId ? decodePathSegment(segments[6]) : null, + uri.getRawQuery()); } catch (Exception e) { // Swallow un-parsable links since they are not critical to processing. log.error("Failed to parse Nexus link URL", e); @@ -435,6 +550,26 @@ private static Map decodeQuery(@Nullable String rawQuery) return params; } + /** + * Form-decodes every {@code componentPath} value in a raw query, in order. {@link #decodeQuery} + * keeps only the first value of a repeated key, so this repeated param needs its own pass. A key + * with no value yields an empty string, matching api-go's {@code Query()}. + */ + private static List decodeQueryValues(@Nullable String rawQuery) + throws UnsupportedEncodingException { + List values = new ArrayList<>(); + if (rawQuery == null || rawQuery.isEmpty()) { + return values; + } + for (String pair : rawQuery.split("&")) { + String[] kv = pair.split("=", 2); + if (!pair.isEmpty() && COMPONENT_PATH_KEY.equals(URLDecoder.decode(kv[0], UTF_8))) { + values.add(kv.length == 2 ? URLDecoder.decode(kv[1], UTF_8) : ""); + } + } + return values; + } + // =============================================================================================== // The WorkflowEvent "reference": WHICH event in the workflow's history the link points at. // diff --git a/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java index 8086b5c9a5..db000a1aed 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java @@ -2,6 +2,7 @@ import static io.temporal.internal.common.LinkConverter.linkToNexusLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToActivity; +import static io.temporal.internal.common.LinkConverter.nexusLinkToCallback; import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; @@ -11,8 +12,11 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; +import io.temporal.api.common.v1.Execution; import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.ExecutionType; +import java.util.Arrays; import org.junit.Test; /** @@ -32,6 +36,7 @@ public class LinkConverterTest { private static final String WORKFLOW = Link.Workflow.getDescriptor().getFullName(); private static final String NEXUS_OPERATION = Link.NexusOperation.getDescriptor().getFullName(); private static final String ACTIVITY = Link.Activity.getDescriptor().getFullName(); + private static final String CALLBACK = Link.Callback.getDescriptor().getFullName(); // =============================================================================================== // Encode. @@ -174,6 +179,63 @@ public void encodesActivityLink() { ACTIVITY, activity("ns", "act/id", "run-id")); } + /** The path keyword names the execution type. */ + @Test + public void encodesCallbackForEachExecutionType() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id", + CALLBACK, + callback("ns", ExecutionType.EXECUTION_TYPE_WORKFLOW, "wf-id", "run-id", "req-id")); + assertEncodes( + "temporal:///namespaces/ns/activities/act-id/run-id/callbacks/req-id", + CALLBACK, + callback("ns", ExecutionType.EXECUTION_TYPE_ACTIVITY, "act-id", "run-id", "req-id")); + assertEncodes( + "temporal:///namespaces/ns/nexus-operations/op-id/run-id/callbacks/req-id", + CALLBACK, + callback("ns", ExecutionType.EXECUTION_TYPE_NEXUS_OPERATION, "op-id", "run-id", "req-id")); + } + + /** + * One {@code componentPath} param per segment, in the path's order rather than sorted, with each + * segment form-encoded. + */ + @Test + public void encodesComponentPathAsRepeatedParamInOrder() { + String url = "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id"; + ExecutionType wf = ExecutionType.EXECUTION_TYPE_WORKFLOW; + assertEncodes( + url + "?componentPath=A%3AB&componentPath=C%2FD&componentPath=E_F", + CALLBACK, + callback("ns", wf, "wf-id", "run-id", "req-id", "A:B", "C/D", "E_F")); + assertEncodes( + url + "?componentPath=Updates&componentPath=id%2Cwith%2Ccommas", + CALLBACK, + callback("ns", wf, "wf-id", "run-id", "req-id", "Updates", "id,with,commas")); + assertEncodes( + url + "?componentPath=zulu&componentPath=alpha", + CALLBACK, + callback("ns", wf, "wf-id", "run-id", "req-id", "zulu", "alpha")); + } + + /** + * An encoded {@code /callbacks/} inside the business ID must not read as the tail, and the + * request ID is escaped like any other segment. + */ + @Test + public void encodesReservedCharactersInCallbackPath() { + assertEncodes( + "temporal:///namespaces/ns%2Fwith-slash/workflows/wf-id%2Fcallbacks%2Ffake/run-id" + + "/callbacks/request%20id%3Fx%3D1", + CALLBACK, + callback( + "ns/with-slash", + ExecutionType.EXECUTION_TYPE_WORKFLOW, + "wf-id/callbacks/fake", + "run-id", + "request id?x=1")); + } + /** The event type goes on the wire in the short PascalCase form. */ @Test public void encodesEventTypeInPascalCase() { @@ -314,6 +376,7 @@ public void rejectsWrongScheme() { assertRejected(WORKFLOW_EVENT, "https:///namespaces/ns/workflows/wf-id/run-id/history"); assertRejected(WORKFLOW, "https:///namespaces/ns/workflows/wf-id/run-id"); assertRejected(NEXUS_OPERATION, "https:///namespaces/ns/nexus-operations/op/run-id/details"); + assertRejected(CALLBACK, "https:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id"); } /** A workflow link ends at the run ID; a workflow-event link ends at {@code /history}. */ @@ -328,9 +391,12 @@ public void rejectsTrailingPathSegment() { assertRejected( WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/extra"); assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/extra"); + assertRejected( + CALLBACK, "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id/extra"); // A trailing slash is an empty extra segment, not a no-op. assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/"); assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/"); + assertRejected(CALLBACK, "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id/"); } /** A repeated key takes its first occurrence, as api-go does. No encoder emits one. */ @@ -347,17 +413,24 @@ public void rejectsMissingPathSegment() { assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id"); assertRejected(NEXUS_OPERATION, "temporal:///namespaces/ns/nexus-operations/op-id/run-id"); assertRejected(ACTIVITY, "temporal:///namespaces/ns/activities/act-id/run-id"); + assertRejected(CALLBACK, "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks"); } @Test public void rejectsEmptyPathSegment() { assertRejected(WORKFLOW_EVENT, "temporal:///namespaces//workflows/wf-id/run-id/history"); assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows//run-id/history"); + assertRejected(CALLBACK, "temporal:///namespaces/ns/workflows//run-id/callbacks/req-id"); + assertRejected(CALLBACK, "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/"); } @Test public void rejectsWrongKindSegment() { assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/activities/wf-id/run-id/history"); + // A callback's keyword varies, but only over the execution types. + assertRejected( + CALLBACK, "temporal:///namespaces/ns/schedules/sched-id/run-id/callbacks/req-id"); + assertRejected(CALLBACK, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/req-id"); } /** The declared type is authoritative on every decoder, not just the dispatcher. */ @@ -369,6 +442,9 @@ public void rejectsTypeThatDoesNotMatchThePath() { assertNull(nexusLinkToWorkflowLink(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); assertNull(nexusLinkToActivity(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); assertNull(nexusLinkToNexusOperation(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); + String callbackUrl = "temporal:///namespaces/ns/workflows/wf-id/run-id/callbacks/req-id"; + assertRejected(ACTIVITY, callbackUrl); + assertNull(nexusLinkToCallback(nexusLink(ACTIVITY, callbackUrl))); } @Test @@ -421,11 +497,20 @@ public void rejectsMalformedUrlsWithoutThrowing() { // =============================================================================================== @Test - public void dispatchCoversAllFourLinkTypes() { + public void dispatchCoversAllLinkTypes() { assertNotNull(linkToNexusLink(eventRef("ns", "w", "r", 1, EventType.EVENT_TYPE_TIMER_STARTED))); assertNotNull(linkToNexusLink(workflow("ns", "w", "r", ""))); assertNotNull(linkToNexusLink(nexusOperation("ns", "o", "r"))); assertNotNull(linkToNexusLink(activity("ns", "a", "r"))); + assertNotNull( + linkToNexusLink(callback("ns", ExecutionType.EXECUTION_TYPE_WORKFLOW, "w", "r", "q"))); + } + + /** A callback on an execution type with no path keyword is dropped rather than misencoded. */ + @Test + public void unsupportedCallbackExecutionTypeEncodesToNull() { + assertNull( + linkToNexusLink(callback("ns", ExecutionType.EXECUTION_TYPE_UNSPECIFIED, "w", "r", "q"))); } /** @@ -553,4 +638,22 @@ private static Link activity(String ns, String actId, String runId) { Link.Activity.newBuilder().setNamespace(ns).setActivityId(actId).setRunId(runId)) .build(); } + + private static Link callback( + String ns, + ExecutionType type, + String businessId, + String runId, + String requestId, + String... componentPath) { + return Link.newBuilder() + .setCallback( + Link.Callback.newBuilder() + .setNamespace(ns) + .setExecution( + Execution.newBuilder().setType(type).setBusinessId(businessId).setRunId(runId)) + .setRequestId(requestId) + .addAllComponentPath(Arrays.asList(componentPath))) + .build(); + } }