From e365165ee14c264ee96077ea382480d3486ea7b2 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 11 Aug 2026 17:17:49 -0700 Subject: [PATCH 01/17] Adding Nexus Query links --- CHANGELOG.md | 14 + .../Client/TemporalClient.Workflow.cs | 8 + src/Temporalio/Nexus/ProtoLinkExtensions.cs | 79 ++++- src/Temporalio/Worker/NexusWorker.cs | 13 + .../Nexus/NexusWorkflowUpdateHandleTests.cs | 8 +- .../Nexus/ProtoLinkExtensionsTests.cs | 302 ++++++++++++++++++ .../Nexus/QueryResponseLinkTests.cs | 246 ++++++++++++++ .../Worker/NexusQueryOperationTests.cs | 237 ++++++++++++++ 8 files changed, 887 insertions(+), 20 deletions(-) create mode 100644 tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs create mode 100644 tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd4d47e..be4d7f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,10 @@ to docs, or any other relevant information. pollers are left unchanged. - Workers now log a [TMPRL1104] warning when a workflow task takes longer than 5 seconds. Set `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. +- Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler + now propagates the link the server returns for the workflow that processed it, so the caller's + Nexus operation event points back at the queried workflow. Requires a server that populates + `QueryWorkflowResponse.link`; older servers leave it unset and nothing is propagated. ### Changed @@ -103,6 +107,16 @@ to docs, or any other relevant information. (codec) or `BadRequest` (converter) handler exception. This matches the existing pass-through behavior for `ApplicationFailureException` and lets codecs and converters control the resulting Nexus error type and retry behavior. +- A `common.v1.Link.Workflow` now serializes to the workflow path + `temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param, + rather than reusing the workflow-event path with a `/history` suffix and dropping `reason`. The + previous form was indistinguishable from a workflow-event link except by its type, and did not + match the other SDKs. Inbound workflow links are now parsed as well, and a link with a trailing + path segment is rejected. +- A Nexus operation backed by a workflow query now fails when the query fails or is rejected, rather + than being retried until the operation times out. `WorkflowQueryFailedException` and + `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither + outcome can change on a retry. ### Fixed diff --git a/src/Temporalio/Client/TemporalClient.Workflow.cs b/src/Temporalio/Client/TemporalClient.Workflow.cs index 30ddcf09..b3c5fc15 100644 --- a/src/Temporalio/Client/TemporalClient.Workflow.cs +++ b/src/Temporalio/Client/TemporalClient.Workflow.cs @@ -437,6 +437,14 @@ public override async Task QueryWorkflowAsync(QueryWorkflowInp throw new WorkflowQueryFailedException(e.Message); } + // A query writes nothing to history, so the server returns a link to the workflow + // execution that processed it rather than to an event. When the query is issued from + // inside a Nexus operation handler, propagate that link so the caller's Nexus + // operation event points at the queried workflow. Captured before the rejection + // check below so a rejected query still records its link. Older servers leave it + // unset, which is a no-op. + CaptureNexusResponseLink(resp.Link); + // Throw rejection if rejected if (resp.QueryRejected != null) { diff --git a/src/Temporalio/Nexus/ProtoLinkExtensions.cs b/src/Temporalio/Nexus/ProtoLinkExtensions.cs index d8e6ba5f..09794d9f 100644 --- a/src/Temporalio/Nexus/ProtoLinkExtensions.cs +++ b/src/Temporalio/Nexus/ProtoLinkExtensions.cs @@ -137,6 +137,8 @@ public static NexusLink ToNexusLink(this Api.Common.V1.Link.Types.Activity act) new Api.Common.V1.Link { NexusOperation = link.ToNexusOperation() }, var t when t == Api.Common.V1.Link.Types.Activity.Descriptor.FullName => new Api.Common.V1.Link { Activity = link.ToActivity() }, + var t when t == Api.Common.V1.Link.Types.Workflow.Descriptor.FullName => + new Api.Common.V1.Link { Workflow = link.ToWorkflow() }, _ => throw new ArgumentException($"Unknown link type: {link.Type}"), }; @@ -158,20 +160,53 @@ public static Api.Common.V1.Link.Types.NexusOperation ToNexusOperation(this Nexu } /// - /// Convert a workflow link to a Nexus link. Unlike a workflow-event link, this points at a - /// workflow execution without referencing a particular history event, which is used when - /// there is no event to link to (e.g. a rejected update). + /// Convert a workflow link to a Nexus link. A workflow link addresses a workflow execution + /// as a whole rather than one event within it, so the URL carries no event path suffix and + /// no reference query params. It is used when there is no history event to point at, for + /// example a query or a rejected update. The optional reason explaining why the link exists + /// is carried as a query param. /// /// Workflow link to convert. /// Nexus link. public static NexusLink ToNexusLink(this Api.Common.V1.Link.Types.Workflow workflow) { + // Build URI with empty authority so there is no host. UriBuilder cannot be used + // here because even with Host explicitly set to "", it emits "temporal:/path" + // (single slash) rather than the canonical "temporal:///path" form other SDKs use. var uriStr = "temporal:///namespaces/" + Uri.EscapeDataString(workflow.Namespace) + "/workflows/" + Uri.EscapeDataString(workflow.WorkflowId) + "/" + - Uri.EscapeDataString(workflow.RunId) + "/history"; + Uri.EscapeDataString(workflow.RunId); + if (workflow.Reason.Length > 0) + { + uriStr += "?reason=" + Uri.EscapeDataString(workflow.Reason); + } return new(new Uri(uriStr), Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); } + /// + /// Convert a Nexus link to a workflow link. The run ID ends a workflow link, so anything + /// trailing is rejected. In particular this rejects the workflow-event form, which ends in + /// "history" and is otherwise identical. + /// + /// Nexus link. + /// Workflow link. + /// If the link is invalid. + public static Api.Common.V1.Link.Types.Workflow ToWorkflow(this NexusLink link) + { + var pathPieces = ParseTemporalLinkPath(link, "workflows", null); + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = Uri.UnescapeDataString(pathPieces[1]), + WorkflowId = Uri.UnescapeDataString(pathPieces[3]), + RunId = Uri.UnescapeDataString(pathPieces[4]), + }; + if (ParseQueryParams(link.Uri).TryGetValue("reason", out var reason)) + { + workflow.Reason = reason; + } + return workflow; + } + /// /// Convert a Nexus link to an activity link. /// @@ -205,14 +240,7 @@ public static Api.Common.V1.Link.Types.WorkflowEvent ToWorkflowEvent(this NexusL RunId = Uri.UnescapeDataString(pathPieces[4]), }; - // Simple query param parser because .NET stdlib doesn't have one in all versions - var query = link.Uri.Query. - TrimStart('?'). - Split(QuerySeparator, StringSplitOptions.RemoveEmptyEntries). - Select(v => v.Split(QueryValueSeparator, 2)). - ToDictionary( - kv => Uri.UnescapeDataString(kv[0]), - kv => kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty); + var query = ParseQueryParams(link.Uri); if (!query.TryGetValue("referenceType", out var refType)) { @@ -271,9 +299,26 @@ public static Api.Common.V1.Link.Types.WorkflowEvent ToWorkflowEvent(this NexusL return evt; } - // Validate a Temporal-shaped link URI and return its path segments. Expected path shape - // is /namespaces/{namespace}/{kind}/{id}/{run}/{tail}. - private static string[] ParseTemporalLinkPath(NexusLink link, string expectedKind, string expectedTail) + // Simple query param parser because .NET stdlib doesn't have one in all versions. Values are + // form decoded, i.e. "+" becomes a space before percent decoding, because other SDKs write + // these params with form encoding. Doing it in the other order would corrupt a literal "+", + // which form encoding writes as "%2B". + private static Dictionary ParseQueryParams(Uri uri) => + uri.Query. + TrimStart('?'). + Split(QuerySeparator, StringSplitOptions.RemoveEmptyEntries). + Select(v => v.Split(QueryValueSeparator, 2)). + ToDictionary( + kv => Uri.UnescapeDataString(kv[0]), + kv => kv.Length > 1 ? + Uri.UnescapeDataString(kv[1].Replace("+", " ")) : string.Empty); + + // Validate a Temporal-shaped link URI and return its path segments. Expected path shape is + // /namespaces/{namespace}/{kind}/{id}/{run}/{tail}, or /namespaces/{namespace}/{kind}/{id}/{run} + // when expectedTail is null. The length is matched exactly, so a link with a trailing segment + // is rejected when none is expected and vice versa. + private static string[] ParseTemporalLinkPath( + NexusLink link, string expectedKind, string? expectedTail) { if (link.Uri.Scheme != "temporal") { @@ -284,10 +329,10 @@ private static string[] ParseTemporalLinkPath(NexusLink link, string expectedKin throw new ArgumentException("Unexpected host"); } var pathPieces = link.Uri.AbsolutePath.TrimStart('/').Split('/'); - if (pathPieces.Length != 6 || + if (pathPieces.Length != (expectedTail == null ? 5 : 6) || pathPieces[0] != "namespaces" || pathPieces[2] != expectedKind || - pathPieces[5] != expectedTail) + (expectedTail != null && pathPieces[5] != expectedTail)) { throw new ArgumentException("Invalid path"); } diff --git a/src/Temporalio/Worker/NexusWorker.cs b/src/Temporalio/Worker/NexusWorker.cs index dfa39644..7ae4eb0f 100644 --- a/src/Temporalio/Worker/NexusWorker.cs +++ b/src/Temporalio/Worker/NexusWorker.cs @@ -387,6 +387,19 @@ private HandlerException ConvertToHandlerException(Exception exc) { return new(HandlerErrorType.BadRequest, "Workflow failed", exc); } + else if (exc is WorkflowQueryFailedException) + { + // A query handler that threw will throw again on every attempt, so this must be a + // non-retryable type. Falling through to the Internal default below would make the + // server retry the operation until it times out instead of failing it. + return new(HandlerErrorType.BadRequest, "Workflow query failed", exc); + } + else if (exc is WorkflowQueryRejectedException) + { + // Rejection is a property of the workflow's state against the reject condition, so + // retrying cannot change the outcome either. + return new(HandlerErrorType.BadRequest, "Workflow query rejected", exc); + } else if (exc is ApplicationFailureException appExc && appExc.NonRetryable) { return new( diff --git a/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs b/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs index 4abfdd13..c9e43c1b 100644 --- a/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs +++ b/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs @@ -219,8 +219,10 @@ public void CommonLink_ToNexusLink_UnsetOneof_ReturnsNull() } [Fact] - public void WorkflowLink_ToNexusLink_BuildsHistoryUri() + public void WorkflowLink_ToNexusLink_BuildsWorkflowUri() { + // A workflow link addresses the execution itself, so there is no "/history" suffix. The + // suffix belongs to the workflow-event form, and its absence is what distinguishes the two. var workflow = new Link.Types.Workflow { Namespace = "ns", @@ -230,7 +232,7 @@ public void WorkflowLink_ToNexusLink_BuildsHistoryUri() var link = workflow.ToNexusLink(); Assert.Equal("temporal", link.Uri.Scheme); - Assert.Equal("/namespaces/ns/workflows/wid/rid/history", link.Uri.AbsolutePath); + Assert.Equal("/namespaces/ns/workflows/wid/rid", link.Uri.AbsolutePath); Assert.Equal(Link.Types.Workflow.Descriptor.FullName, link.Type); } @@ -270,6 +272,6 @@ public void CommonLink_ToNexusLink_FallsBackToWorkflow() Assert.NotNull(link); Assert.Equal(Link.Types.Workflow.Descriptor.FullName, link.Type); - Assert.Equal("/namespaces/ns/workflows/wid/rid/history", link.Uri.AbsolutePath); + Assert.Equal("/namespaces/ns/workflows/wid/rid", link.Uri.AbsolutePath); } } diff --git a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs index 49f039cc..1cf679b9 100644 --- a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs +++ b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs @@ -224,4 +224,306 @@ public void ToActivity_RejectsBadPath() Api.Common.V1.Link.Types.Activity.Descriptor.FullName); Assert.Throws(() => link.ToActivity()); } + + [Fact] + public void WorkflowLink_ToNexusLink_BuildsExpectedUri() + { + // A workflow link addresses the execution itself, so unlike a workflow-event link there is + // no "/history" suffix. That absence is the only thing distinguishing the two paths. + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns", + WorkflowId = "wf-id", + RunId = "run-id", + }; + var nexusLink = workflow.ToNexusLink(); + + Assert.Equal("temporal", nexusLink.Uri.Scheme); + Assert.Equal(Api.Common.V1.Link.Types.Workflow.Descriptor.FullName, nexusLink.Type); + Assert.Equal("/namespaces/ns/workflows/wf-id/run-id", nexusLink.Uri.AbsolutePath); + Assert.Equal(string.Empty, nexusLink.Uri.Query); + } + + [Fact] + public void WorkflowLink_ToNexusLink_EncodesReasonAsQueryParam() + { + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns", + WorkflowId = "wf-id", + RunId = "run-id", + Reason = "rejected update", + }; + var nexusLink = workflow.ToNexusLink(); + + Assert.Equal("/namespaces/ns/workflows/wf-id/run-id", nexusLink.Uri.AbsolutePath); + Assert.Equal("?reason=rejected%20update", nexusLink.Uri.Query); + } + + [Fact] + public void WorkflowLink_ToNexusLink_EscapesPathSegments() + { + // A slash and a space in the path must be percent escaped, otherwise the link resolves to a + // different workflow. + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns", + WorkflowId = "wf/id with space", + RunId = "run-id", + }; + var nexusLink = workflow.ToNexusLink(); + + Assert.Equal( + "/namespaces/ns/workflows/wf%2Fid%20with%20space/run-id", + nexusLink.Uri.AbsolutePath); + } + + [Fact] + public void ToWorkflow_ParsesUri() + { + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + var workflow = link.ToWorkflow(); + Assert.Equal("ns", workflow.Namespace); + Assert.Equal("wf-id", workflow.WorkflowId); + Assert.Equal("run-id", workflow.RunId); + Assert.Equal(string.Empty, workflow.Reason); + } + + [Fact] + public void ToWorkflow_ParsesReason() + { + // Other SDKs form encode this param, so a "+" has to decode back to a space. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal("rejected update", link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_ParsesPercentEncodedReason() + { + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected%20update"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal("rejected update", link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_RejectsTrailingSegment() + { + // The workflow-event form addresses an event inside the workflow, so it must not be accepted + // as a workflow link even when the type says otherwise. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id/history"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + Assert.Throws(() => link.ToWorkflow()); + } + + [Fact] + public void ToWorkflow_RejectsMissingRunId() + { + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + Assert.Throws(() => link.ToWorkflow()); + } + + [Fact] + public void ToWorkflow_RejectsNonTemporalScheme() + { + var link = new NexusLink( + new Uri("https://example/namespaces/ns/workflows/wf-id/run-id"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + Assert.Throws(() => link.ToWorkflow()); + } + + [Fact] + public void ToWorkflow_FindsReasonByKeyNotPosition() + { + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?foo=bar&reason=Query+processed"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal("Query processed", link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_EmptyReasonValue() + { + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason="), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal(string.Empty, link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_BareReasonKey() + { + // A key with no "=" must not blow up on the missing value. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal(string.Empty, link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_ReasonPrefixKeyIgnored() + { + // "reasonx" must not be treated as "reason". + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reasonx=nope"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal(string.Empty, link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_LiteralPlusInPathIsPreserved() + { + // A "+" in a path segment is a literal "+", not a space. Path segments are percent decoded + // only; form decoding applies to query values. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/a+b/run-id"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal("a+b", link.ToWorkflow().WorkflowId); + } + + [Fact] + public void WorkflowLink_RoundTrips() + { + // Reserved characters in every field at once: path segments are percent escaped and the + // reason is a query value, so a reason containing "=" and "&" must not be split as syntax. + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns/with/slash", + WorkflowId = "wf id with space", + RunId = "run-id", + Reason = "reason with = and &", + }; + + var roundTripped = workflow.ToNexusLink().ToWorkflow(); + Assert.Equal(workflow, roundTripped); + } + + [Fact] + public void ToProtoLink_WorkflowShape_RoundTrips() + { + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns", + WorkflowId = "wf", + RunId = "run-id", + Reason = "Query processed", + }; + var protoLink = workflow.ToNexusLink().ToProtoLink(); + Assert.Equal(workflow, protoLink.Workflow); + } + + [Fact] + public void ProtoToNexusLink_WorkflowVariant_Dispatches() + { + var protoLink = new Api.Common.V1.Link + { + Workflow = new() { Namespace = "ns", WorkflowId = "wf", RunId = "run" }, + }; + var nexusLink = protoLink.ToNexusLink(); + Assert.NotNull(nexusLink); + Assert.Equal( + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName, + nexusLink.Type); + Assert.Equal(protoLink.Workflow, nexusLink.ToWorkflow()); + } + + [Fact] + public void ToWorkflowEvent_RejectsSuffixlessWorkflowPath() + { + // The inverse of ToWorkflow_RejectsTrailingSegment: a workflow link must not be readable as + // a workflow event. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id"), + Api.Common.V1.Link.Types.WorkflowEvent.Descriptor.FullName); + Assert.Throws(() => link.ToWorkflowEvent()); + } + + [Fact] + public void WorkflowLink_LiteralPlusInReason_RoundTrips() + { + // Form encoding writes a space as "+" and a literal "+" as "%2B", so the reader has to + // replace "+" with a space before percent decoding. Doing it in the other order would turn + // this reason into "a b". + var workflow = new Api.Common.V1.Link.Types.Workflow + { + Namespace = "ns", + WorkflowId = "wf-id", + RunId = "run-id", + Reason = "a+b", + }; + var nexusLink = workflow.ToNexusLink(); + + Assert.Equal("?reason=a%2Bb", nexusLink.Uri.Query); + Assert.Equal("a+b", nexusLink.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_FormEncodedLiteralPlusInReason() + { + // The same case as written by an SDK that form encodes the whole query string. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=a%2Bb"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal("a+b", link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflowEvent_FormDecodesQueryValues() + { + // Query values are form decoded, so a "+" is a space. This matches Go's URL.Query(), which + // form decodes every param. Request IDs are UUIDs in practice, so this is about cross-SDK + // consistency rather than a case that arises today. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=RequestIdReference&requestID=a+b" + + "&eventType=WorkflowExecutionStarted"), + Api.Common.V1.Link.Types.WorkflowEvent.Descriptor.FullName); + + Assert.Equal("a b", link.ToWorkflowEvent().RequestIdRef.RequestId); + } + + [Fact] + public void ToWorkflow_AcceptsEmptyRunId() + { + // Characterization, not a statement of intent. A trailing slash still yields the expected + // segment count, so the run ID comes back empty rather than being rejected. Go's parser + // matches "[^/]+" per segment and rejects this. The same leniency exists in the + // workflow-event, activity, and nexus-operation converters, so tightening it belongs with a + // consolidation of all four rather than with this change. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal(string.Empty, link.ToWorkflow().RunId); + } + + [Fact] + public void ToWorkflow_AcceptsEmptyNamespace() + { + // Characterization; see ToWorkflow_AcceptsEmptyRunId. + var link = new NexusLink( + new Uri("temporal:///namespaces//workflows/wf-id/run-id"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + var workflow = link.ToWorkflow(); + Assert.Equal(string.Empty, workflow.Namespace); + Assert.Equal("wf-id", workflow.WorkflowId); + Assert.Equal("run-id", workflow.RunId); + } } diff --git a/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs new file mode 100644 index 00000000..e103b0a8 --- /dev/null +++ b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs @@ -0,0 +1,246 @@ +namespace Temporalio.Tests.Nexus; + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Google.Protobuf; +using Microsoft.Extensions.Logging.Abstractions; +using NexusRpc.Handlers; +using Temporalio.Api.Common.V1; +using Temporalio.Api.Enums.V1; +using Temporalio.Api.Query.V1; +using Temporalio.Api.WorkflowService.V1; +using Temporalio.Client; +using Temporalio.Common; +using Temporalio.Exceptions; +using Temporalio.Nexus; +using Xunit; + +/// +/// Unit tests for query response link propagation in and out of the Nexus operation context. These +/// run against a fake workflow service, covering behavior the end-to-end tests cannot reach until +/// the server populates QueryWorkflowResponse.Link. +/// +public class QueryResponseLinkTests +{ + [Fact] + public async Task QueryAsync_CapturesWorkflowResponseLink() + { + // A query never writes to history, so the server answers with a Link.Workflow naming the + // execution that processed it instead of a Link.WorkflowEvent. That link has to reach the + // operation context so the caller's Nexus operation event points back at the queried + // workflow. + var responseLink = WorkflowLink("wf-target", "target-run", "Query processed"); + var client = NewClient(new QueryWorkflowResponse + { + Link = responseLink, + QueryResult = await ToPayloadsAsync("answer"), + }); + var context = NewContext(); + + var result = await WithContextAsync( + context, () => QueryAsync(client)); + + // Capturing the link must not disturb the query's own result. + Assert.Equal("answer", result); + Assert.Equal(new[] { responseLink }, context.ResponseLinks); + } + + [Fact] + public async Task QueryAsync_AgainstOlderServerCapturesNoResponseLink() + { + // Older servers leave the field unset, so nothing is captured and the query still succeeds. + var client = NewClient(new QueryWorkflowResponse + { + QueryResult = await ToPayloadsAsync("answer"), + }); + var context = NewContext(); + + var result = await WithContextAsync( + context, () => QueryAsync(client)); + + Assert.Equal("answer", result); + Assert.Empty(context.ResponseLinks); + } + + [Fact] + public async Task QueryAsync_OutsideNexusContextIgnoresResponseLink() + { + // A query issued outside a Nexus operation handler must not touch the operation context at + // all. Guards against the propagation being reached without a context, which would throw. + var client = NewClient(new QueryWorkflowResponse + { + Link = WorkflowLink("wf-target", "target-run", "Query processed"), + QueryResult = await ToPayloadsAsync("answer"), + }); + var context = NewContext(); + + // Deliberately not inside WithContextAsync. + var result = await QueryAsync(client); + + Assert.Equal("answer", result); + Assert.Empty(context.ResponseLinks); + } + + [Fact] + public async Task QueryAsync_MultipleQueriesAccumulateAllResponseLinks() + { + // Two queries in a row each contribute a response link; both must accumulate in call order + // on the shared list, exactly as the signal path does. + var first = WorkflowLink("callee-a", "run-a", "Query processed"); + var second = WorkflowLink("callee-b", "run-b", "Query processed"); + var payloads = await ToPayloadsAsync("answer"); + var client = NewClient( + new QueryWorkflowResponse { Link = first, QueryResult = payloads }, + new QueryWorkflowResponse { Link = second, QueryResult = payloads }); + var context = NewContext(); + + await WithContextAsync(context, async () => + { + await QueryAsync(client); + await QueryAsync(client); + return 0; + }); + + Assert.Equal(new[] { first, second }, context.ResponseLinks); + } + + [Fact] + public async Task QueryAsync_RejectedQueryStillCapturesResponseLink() + { + // A rejected query still carries a link to the workflow that rejected it, and the link is + // captured before the rejection is surfaced. Pins the ordering so it is not "fixed" into the + // wrong behavior later. + var responseLink = WorkflowLink("wf-target", "target-run", "Query processed"); + var client = NewClient(new QueryWorkflowResponse + { + Link = responseLink, + QueryRejected = new() { Status = WorkflowExecutionStatus.Completed }, + }); + var context = NewContext(); + + await Assert.ThrowsAsync( + () => WithContextAsync(context, () => QueryAsync(client))); + + Assert.Equal(new[] { responseLink }, context.ResponseLinks); + } + + private static Task QueryAsync(TemporalClient client) => + client.GetWorkflowHandle("wf-target").QueryAsync("test-query", Array.Empty()); + + private static async Task WithContextAsync( + NexusOperationExecutionContext context, Func> func) + { + NexusOperationExecutionContext.AsyncLocalCurrent.Value = context; + try + { + return await func().ConfigureAwait(false); + } + finally + { + NexusOperationExecutionContext.AsyncLocalCurrent.Value = null; + } + } + + private static Task ToPayloadsAsync(object value) => + Task.FromResult(new Payloads + { + Payloads_ = { Temporalio.Converters.DataConverter.Default.PayloadConverter.ToPayload(value) }, + }); + + private static TemporalClient NewClient(params QueryWorkflowResponse[] responses) => + new TemporalClient( + new FakeConnection(new FakeWorkflowService(responses)), + new TemporalClientOptions { Namespace = "test-namespace" }); + + private static NexusOperationExecutionContext NewContext() + { + var handlerContext = new OperationStartContext( + Service: "svc", + Operation: "op", + CancellationToken: CancellationToken.None, + RequestId: Guid.NewGuid().ToString()); + return new NexusOperationExecutionContext( + handlerContext: handlerContext, + info: new("test-namespace", "tq", "endpoint"), + logger: NullLogger.Instance, + runtimeMetricMeter: new Lazy( + () => throw new InvalidOperationException("metric meter not expected in test")), + temporalClient: null); + } + + private static Link WorkflowLink(string workflowId, string runId, string reason) => + new() + { + Workflow = new() + { + Namespace = "test-namespace", + WorkflowId = workflowId, + RunId = runId, + Reason = reason, + }, + }; + + /// Workflow service that replays canned responses in order. + private class FakeWorkflowService : WorkflowService + { + private readonly Queue responses; + + public FakeWorkflowService(IEnumerable responses) => + this.responses = new(responses); + + internal override Bridge.Interop.TemporalCoreRpcService Service => + Bridge.Interop.TemporalCoreRpcService.Workflow; + + internal override string FullName => "temporal.api.workflowservice.v1.WorkflowService"; + + protected override Task InvokeRpcAsync( + string rpc, IMessage req, MessageParser resp, RpcOptions? options = null) + { + if (rpc != "QueryWorkflow") + { + throw new NotSupportedException($"Unexpected RPC: {rpc}"); + } + return Task.FromResult((T)(object)responses.Dequeue()); + } + } + + /// Connection that exposes only the fake workflow service. + private class FakeConnection : ITemporalConnection + { + private readonly WorkflowService workflowService; + + public FakeConnection(WorkflowService workflowService) => + this.workflowService = workflowService; + + public string? ApiKey { get; set; } + + public IReadOnlyCollection> RpcMetadata { get; set; } = + Array.Empty>(); + + public IReadOnlyCollection> RpcBinaryMetadata { get; set; } = + Array.Empty>(); + + public WorkflowService WorkflowService => workflowService; + + public OperatorService OperatorService => throw new NotSupportedException(); + + public CloudService CloudService => throw new NotSupportedException(); + + public TestService TestService => throw new NotSupportedException(); + + public TemporalConnectionOptions Options => new(); + + public bool IsConnected => true; + + public SafeHandle? BridgeClient => null; + + public Task CheckHealthAsync( + RpcService? service = null, RpcOptions? options = null) => + throw new NotSupportedException(); + + public Task ConnectAsync() => throw new NotSupportedException(); + } +} diff --git a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs new file mode 100644 index 00000000..fc0522ba --- /dev/null +++ b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs @@ -0,0 +1,237 @@ +namespace Temporalio.Tests.Worker; + +using NexusRpc; +using NexusRpc.Handlers; +using Temporalio.Client; +using Temporalio.Exceptions; +using Temporalio.Nexus; +using Temporalio.Worker; +using Temporalio.Workflows; +using Xunit; +using Xunit.Abstractions; + +/// +/// End-to-end tests for Query-backed Nexus operations. A Query is always synchronous and writes +/// nothing to history, so the handler simply queries and returns the result; there is no operation +/// token and no completion callback. +/// +/// +/// The response link the server attaches to QueryWorkflowResponse.Link is verified in +/// Temporalio.Tests.Nexus.QueryResponseLinkTests instead, since the server does not populate +/// that field yet. +/// +public class NexusQueryOperationTests : WorkflowEnvironmentTestBase +{ + public NexusQueryOperationTests(ITestOutputHelper output, WorkflowEnvironment env) + : base(output, env) + { + } + + [NexusService] + public interface ICounterQueryService + { + [NexusOperation] + int GetCount(QueryInput input); + } + + /// Backs the operation with a workflow query. + [NexusServiceHandler(typeof(ICounterQueryService))] + public class CounterQueryServiceHandler + { + [NexusOperationHandler] + public IOperationHandler GetCount() => + OperationHandler.Sync(async (ctx, input) => + { + // A Query resolves immediately, so this is a plain synchronous operation: no + // operation token, no completion callback, nothing to cancel. + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(input.WorkflowId, input.RunId); + return await handle.QueryAsync( + "GetCount", + new object?[] { input.Fail }, + input.RejectNotOpen ? + new() { RejectCondition = Api.Enums.V1.QueryRejectCondition.NotOpen } : + null); + }); + } + + /// Counter workflow whose state a query reads. + [Workflow] + public class CounterWorkflow + { + private int counter; + private bool done; + + [WorkflowRun] + public async Task RunAsync() + { + await Workflow.WaitConditionAsync(() => done); + return counter; + } + + [WorkflowQuery] + public int GetCount(bool fail) + { + if (fail) + { + // A query handler that throws makes the server answer with a query failure, which + // the handler surfaces to the caller as a failed operation. + throw new InvalidOperationException("query failed (for testing)"); + } + return counter; + } + + [WorkflowSignal] + public async Task BumpAsync() => counter++; + + [WorkflowSignal] + public async Task DoneAsync() => done = true; + } + + [Workflow] + public class CounterQueryCallerWorkflow + { + [WorkflowRun] + public async Task RunAsync(CallerInput input) => + // Bounded so a regression that makes a terminal failure retryable surfaces as a timeout + // rather than hanging the test. + await Workflow.CreateNexusWorkflowClient(input.Endpoint). + ExecuteNexusOperationAsync( + svc => svc.GetCount(input.Query), + new() { ScheduleToCloseTimeout = TimeSpan.FromSeconds(20) }); + } + + public record QueryInput( + string WorkflowId, + string? RunId = null, + bool Fail = false, + bool RejectNotOpen = false); + + public record CallerInput(string Endpoint, QueryInput Query); + + [Fact] + public async Task QueryOperation_ReturnsResult() + { + await RunWithCounterAsync(async (endpoint, taskQueue, counter) => + { + await counter.SignalAsync(wf => wf.BumpAsync()); + await counter.SignalAsync(wf => wf.BumpAsync()); + + var caller = await RunCallerAsync(taskQueue, endpoint, new(counter.Id)); + Assert.Equal(2, await caller.GetResultAsync()); + }); + } + + [Fact] + public async Task QueryOperation_UnknownWorkflow_FailsOperation() + { + await RunWithCounterAsync(async (endpoint, taskQueue, counter) => + { + var caller = await RunCallerAsync( + taskQueue, endpoint, new($"unknown-wid-{Guid.NewGuid()}")); + await AssertOperationFailedWithAsync(HandlerErrorType.NotFound, caller); + }); + } + + [Fact] + public async Task QueryOperation_UnknownRun_FailsOperation() + { + await RunWithCounterAsync(async (endpoint, taskQueue, counter) => + { + var caller = await RunCallerAsync( + taskQueue, endpoint, new(counter.Id, RunId: Guid.NewGuid().ToString())); + await AssertOperationFailedWithAsync(HandlerErrorType.NotFound, caller); + }); + } + + [Fact] + public async Task QueryOperation_FailedQuery_FailsOperation() + { + await RunWithCounterAsync(async (endpoint, taskQueue, counter) => + { + var caller = await RunCallerAsync( + taskQueue, endpoint, new(counter.Id, Fail: true)); + await AssertOperationFailedWithAsync(HandlerErrorType.BadRequest, caller); + }); + } + + [Fact] + public async Task QueryOperation_RejectedQuery_FailsOperation() + { + // The reject condition is NotOpen, so querying a workflow that has already closed is + // rejected and must surface as an operation failure. + var taskQueue = $"tq-{Guid.NewGuid()}"; + var workerOptions = new TemporalWorkerOptions(taskQueue). + AddNexusService(new CounterQueryServiceHandler()). + AddWorkflow(). + AddWorkflow(); + var endpointName = $"nexus-endpoint-{taskQueue}"; + await Env.TestEnv.CreateNexusEndpointAsync(endpointName, taskQueue); + + using var worker = new TemporalWorker(Client, workerOptions); + await worker.ExecuteAsync(async () => + { + var counter = await Client.StartWorkflowAsync( + (CounterWorkflow wf) => wf.RunAsync(), + new($"counter-{Guid.NewGuid()}", taskQueue)); + // Close the workflow before querying so NotOpen rejects. + await counter.SignalAsync(wf => wf.DoneAsync()); + await counter.GetResultAsync(); + + var caller = await RunCallerAsync( + taskQueue, endpointName, new(counter.Id, RejectNotOpen: true)); + await AssertOperationFailedWithAsync(HandlerErrorType.BadRequest, caller); + }); + } + + /// + /// Asserts the caller's operation failed with the specific handler error type the SDK is supposed + /// to derive from what the handler threw. Asserting only NexusOperationFailureException would + /// also pass for a schedule-to-close timeout, which is what a wrongly-retryable failure looks + /// like, so the classification is pinned here. + /// + private static async Task AssertOperationFailedWithAsync( + HandlerErrorType expected, WorkflowHandle caller) + { + var exc = await Assert.ThrowsAsync( + () => caller.GetResultAsync()); + var nexusExc = Assert.IsType(exc.InnerException); + var handlerExc = Assert.IsType(nexusExc.InnerException); + Assert.Equal(expected, handlerExc.ErrorType); + Assert.False(handlerExc.IsRetryable); + } + + private async Task RunWithCounterAsync( + Func, Task> body) + { + var taskQueue = $"tq-{Guid.NewGuid()}"; + var workerOptions = new TemporalWorkerOptions(taskQueue). + AddNexusService(new CounterQueryServiceHandler()). + AddWorkflow(). + AddWorkflow(); + var endpointName = $"nexus-endpoint-{taskQueue}"; + await Env.TestEnv.CreateNexusEndpointAsync(endpointName, taskQueue); + + using var worker = new TemporalWorker(Client, workerOptions); + await worker.ExecuteAsync(async () => + { + var counter = await Client.StartWorkflowAsync( + (CounterWorkflow wf) => wf.RunAsync(), + new($"counter-{Guid.NewGuid()}", taskQueue)); + try + { + await body(endpointName, taskQueue, counter); + } + finally + { + await counter.SignalAsync(wf => wf.DoneAsync()); + } + }); + } + + private async Task> RunCallerAsync( + string taskQueue, string endpoint, QueryInput query) => + await Client.StartWorkflowAsync( + (CounterQueryCallerWorkflow wf) => wf.RunAsync(new(endpoint, query)), + new($"caller-{Guid.NewGuid()}", taskQueue)); +} From 3a16dce566430d7def93adf816111451a64ee522 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 11 Aug 2026 17:24:17 -0700 Subject: [PATCH 02/17] Ran format --- tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs index e103b0a8..cc8bfe9a 100644 --- a/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs +++ b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs @@ -10,7 +10,6 @@ namespace Temporalio.Tests.Nexus; using NexusRpc.Handlers; using Temporalio.Api.Common.V1; using Temporalio.Api.Enums.V1; -using Temporalio.Api.Query.V1; using Temporalio.Api.WorkflowService.V1; using Temporalio.Client; using Temporalio.Common; From 4ee265703ef7390980057242ffb1be24d738a216 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 11 Aug 2026 17:39:13 -0700 Subject: [PATCH 03/17] Some comment fixes --- src/Temporalio/Client/TemporalClient.Workflow.cs | 6 ------ src/Temporalio/Nexus/ProtoLinkExtensions.cs | 5 +---- tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs | 5 ++--- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/Temporalio/Client/TemporalClient.Workflow.cs b/src/Temporalio/Client/TemporalClient.Workflow.cs index b3c5fc15..15049778 100644 --- a/src/Temporalio/Client/TemporalClient.Workflow.cs +++ b/src/Temporalio/Client/TemporalClient.Workflow.cs @@ -437,12 +437,6 @@ public override async Task QueryWorkflowAsync(QueryWorkflowInp throw new WorkflowQueryFailedException(e.Message); } - // A query writes nothing to history, so the server returns a link to the workflow - // execution that processed it rather than to an event. When the query is issued from - // inside a Nexus operation handler, propagate that link so the caller's Nexus - // operation event points at the queried workflow. Captured before the rejection - // check below so a rejected query still records its link. Older servers leave it - // unset, which is a no-op. CaptureNexusResponseLink(resp.Link); // Throw rejection if rejected diff --git a/src/Temporalio/Nexus/ProtoLinkExtensions.cs b/src/Temporalio/Nexus/ProtoLinkExtensions.cs index 09794d9f..268ea84a 100644 --- a/src/Temporalio/Nexus/ProtoLinkExtensions.cs +++ b/src/Temporalio/Nexus/ProtoLinkExtensions.cs @@ -299,10 +299,7 @@ public static Api.Common.V1.Link.Types.WorkflowEvent ToWorkflowEvent(this NexusL return evt; } - // Simple query param parser because .NET stdlib doesn't have one in all versions. Values are - // form decoded, i.e. "+" becomes a space before percent decoding, because other SDKs write - // these params with form encoding. Doing it in the other order would corrupt a literal "+", - // which form encoding writes as "%2B". + // Simple query param parser because .NET stdlib doesn't have one in all versions. private static Dictionary ParseQueryParams(Uri uri) => uri.Query. TrimStart('?'). diff --git a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs index 1cf679b9..1e2a24f1 100644 --- a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs +++ b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs @@ -486,9 +486,8 @@ public void ToWorkflow_FormEncodedLiteralPlusInReason() [Fact] public void ToWorkflowEvent_FormDecodesQueryValues() { - // Query values are form decoded, so a "+" is a space. This matches Go's URL.Query(), which - // form decodes every param. Request IDs are UUIDs in practice, so this is about cross-SDK - // consistency rather than a case that arises today. + // Query values are form decoded. Request IDs are UUIDs in practice, + // so this is about cross-SDK consistency rather than a case that arises today. var link = new NexusLink( new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id/history" + "?referenceType=RequestIdReference&requestID=a+b" + From 601336624c98087cbd120d4c86873874685022b0 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 11 Aug 2026 17:46:25 -0700 Subject: [PATCH 04/17] Fixed comment --- tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs index 1e2a24f1..6b00721d 100644 --- a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs +++ b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs @@ -501,10 +501,10 @@ public void ToWorkflowEvent_FormDecodesQueryValues() public void ToWorkflow_AcceptsEmptyRunId() { // Characterization, not a statement of intent. A trailing slash still yields the expected - // segment count, so the run ID comes back empty rather than being rejected. Go's parser - // matches "[^/]+" per segment and rejects this. The same leniency exists in the - // workflow-event, activity, and nexus-operation converters, so tightening it belongs with a - // consolidation of all four rather than with this change. + // segment count, so the run ID comes back empty rather than being rejected. The same + // leniency exists in the workflow-event, activity, and nexus-operation converters, since + // they share this path parsing, so tightening it is a decision about all four rather than + // about this converter. var link = new NexusLink( new Uri("temporal:///namespaces/ns/workflows/wf-id/"), Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); From 9825c206c53b3d16fc0b8ecff505244787d30bd1 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 17 Aug 2026 13:58:23 -0700 Subject: [PATCH 05/17] Responding to PR comments --- src/Temporalio/Nexus/ProtoLinkExtensions.cs | 16 ++++- src/Temporalio/Worker/NexusWorker.cs | 21 ++++-- .../Nexus/NexusWorkflowUpdateHandleTests.cs | 68 ------------------- .../Nexus/ProtoLinkExtensionsTests.cs | 40 +++++++++-- .../Worker/NexusQueryOperationTests.cs | 6 +- 5 files changed, 67 insertions(+), 84 deletions(-) diff --git a/src/Temporalio/Nexus/ProtoLinkExtensions.cs b/src/Temporalio/Nexus/ProtoLinkExtensions.cs index 268ea84a..53c96bee 100644 --- a/src/Temporalio/Nexus/ProtoLinkExtensions.cs +++ b/src/Temporalio/Nexus/ProtoLinkExtensions.cs @@ -299,7 +299,21 @@ public static Api.Common.V1.Link.Types.WorkflowEvent ToWorkflowEvent(this NexusL return evt; } - // Simple query param parser because .NET stdlib doesn't have one in all versions. + /// + /// Parse a URI query string into its parameters. Simple hand-rolled parser because the .NET + /// stdlib does not have one in all versions we target. + /// + /// URI whose query string to parse. + /// + /// The query parameters, with both keys and values percent-decoded. Values are additionally + /// form-decoded, i.e. + becomes a space, since that is how a query value is encoded on + /// the way out; the replacement is safe to do before percent-decoding because a literal + /// + is encoded as %2B. A parameter present with no = maps to an empty + /// string, so callers cannot distinguish ?reason from ?reason=. Keys are looked + /// up with the dictionary's default ordinal comparer, so lookups are case-sensitive and + /// reason and Reason are different parameters. A query repeating a key throws, + /// which callers treat as an invalid link like any other malformed URI. + /// private static Dictionary ParseQueryParams(Uri uri) => uri.Query. TrimStart('?'). diff --git a/src/Temporalio/Worker/NexusWorker.cs b/src/Temporalio/Worker/NexusWorker.cs index 7ae4eb0f..b18ec6b9 100644 --- a/src/Temporalio/Worker/NexusWorker.cs +++ b/src/Temporalio/Worker/NexusWorker.cs @@ -389,16 +389,23 @@ private HandlerException ConvertToHandlerException(Exception exc) } else if (exc is WorkflowQueryFailedException) { - // A query handler that threw will throw again on every attempt, so this must be a - // non-retryable type. Falling through to the Internal default below would make the - // server retry the operation until it times out instead of failing it. - return new(HandlerErrorType.BadRequest, "Workflow query failed", exc); + // The query handler faulted rather than the request being bad, and it will fault the + // same way on every attempt, so Internal must be marked non-retryable explicitly. + return new( + HandlerErrorType.Internal, + "Workflow query failed", + exc, + HandlerErrorRetryBehavior.NonRetryable); } else if (exc is WorkflowQueryRejectedException) { - // Rejection is a property of the workflow's state against the reject condition, so - // retrying cannot change the outcome either. - return new(HandlerErrorType.BadRequest, "Workflow query rejected", exc); + // Rejection follows from the queried workflow's status, so as above this is not a + // caller error and retrying cannot change the outcome. + return new( + HandlerErrorType.Internal, + "Workflow query rejected", + exc, + HandlerErrorRetryBehavior.NonRetryable); } else if (exc is ApplicationFailureException appExc && appExc.NonRetryable) { diff --git a/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs b/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs index c9e43c1b..53d37c85 100644 --- a/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs +++ b/tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs @@ -3,7 +3,6 @@ namespace Temporalio.Tests.Nexus; using System.Linq; using System.Text; using System.Text.Json; -using Temporalio.Api.Common.V1; using Temporalio.Nexus; using Xunit; @@ -207,71 +206,4 @@ public void FromToken_ToleratesAbsentRid() Assert.Equal(string.Empty, handle.RunId); Assert.Equal("u", handle.UpdateId); } - - [Fact] - public void CommonLink_ToNexusLink_UnsetOneof_ReturnsNull() - { - // A link whose oneof is unset (neither workflow-event nor workflow) must not dereference a - // null variant; it returns null so callers can skip it. - var link = new Link().ToNexusLink(); - - Assert.Null(link); - } - - [Fact] - public void WorkflowLink_ToNexusLink_BuildsWorkflowUri() - { - // A workflow link addresses the execution itself, so there is no "/history" suffix. The - // suffix belongs to the workflow-event form, and its absence is what distinguishes the two. - var workflow = new Link.Types.Workflow - { - Namespace = "ns", - WorkflowId = "wid", - RunId = "rid", - }; - var link = workflow.ToNexusLink(); - - Assert.Equal("temporal", link.Uri.Scheme); - Assert.Equal("/namespaces/ns/workflows/wid/rid", link.Uri.AbsolutePath); - Assert.Equal(Link.Types.Workflow.Descriptor.FullName, link.Type); - } - - [Fact] - public void CommonLink_ToNexusLink_PrefersWorkflowEvent() - { - var common = new Link - { - WorkflowEvent = new() - { - Namespace = "ns", - WorkflowId = "wid", - RunId = "rid", - EventRef = new() { EventId = 1 }, - }, - }; - var link = common.ToNexusLink(); - - Assert.NotNull(link); - Assert.Equal(Link.Types.WorkflowEvent.Descriptor.FullName, link.Type); - } - - [Fact] - public void CommonLink_ToNexusLink_FallsBackToWorkflow() - { - // No history event (e.g. a rejected update) — falls back to the workflow link. - var common = new Link - { - Workflow = new() - { - Namespace = "ns", - WorkflowId = "wid", - RunId = "rid", - }, - }; - var link = common.ToNexusLink(); - - Assert.NotNull(link); - Assert.Equal(Link.Types.Workflow.Descriptor.FullName, link.Type); - Assert.Equal("/namespaces/ns/workflows/wid/rid", link.Uri.AbsolutePath); - } } diff --git a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs index 6b00721d..ed375ec7 100644 --- a/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs +++ b/tests/Temporalio.Tests/Nexus/ProtoLinkExtensionsTests.cs @@ -384,6 +384,31 @@ public void ToWorkflow_ReasonPrefixKeyIgnored() Assert.Equal(string.Empty, link.ToWorkflow().Reason); } + [Fact] + public void ToWorkflow_ReasonKeyLookupIsCaseSensitive() + { + // Query params are held in a dictionary with the default ordinal comparer, so "Reason" is a + // different param than "reason" and does not populate the field. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?Reason=nope"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Equal(string.Empty, link.ToWorkflow().Reason); + } + + [Fact] + public void ToWorkflow_RepeatedReasonKey_Throws() + { + // A repeated key cannot go into the dictionary, so it surfaces as the same ArgumentException + // as any other malformed link. Callers converting links catch that and drop the link with a + // warning rather than failing the operation. + var link = new NexusLink( + new Uri("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=a&reason=b"), + Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); + + Assert.Throws(() => link.ToWorkflow()); + } + [Fact] public void ToWorkflow_LiteralPlusInPathIsPreserved() { @@ -430,6 +455,8 @@ public void ToProtoLink_WorkflowShape_RoundTrips() [Fact] public void ProtoToNexusLink_WorkflowVariant_Dispatches() { + // The workflow variant is what a link carries when there is no history event to point at, + // e.g. a Query or a rejected update. var protoLink = new Api.Common.V1.Link { Workflow = new() { Namespace = "ns", WorkflowId = "wf", RunId = "run" }, @@ -500,11 +527,12 @@ public void ToWorkflowEvent_FormDecodesQueryValues() [Fact] public void ToWorkflow_AcceptsEmptyRunId() { - // Characterization, not a statement of intent. A trailing slash still yields the expected - // segment count, so the run ID comes back empty rather than being rejected. The same - // leniency exists in the workflow-event, activity, and nexus-operation converters, since - // they share this path parsing, so tightening it is a decision about all four rather than - // about this converter. + // A trailing slash still yields the expected segment count, so the run ID comes back empty + // rather than being rejected. The workflow-event, activity, and nexus-operation converters + // are equally lenient because all four share this path parsing, so tightening it is a + // decision about all four and belongs with that change rather than this one. This test + // exists so that whichever way it is decided, the behavior changes visibly instead of + // silently. var link = new NexusLink( new Uri("temporal:///namespaces/ns/workflows/wf-id/"), Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); @@ -515,7 +543,7 @@ public void ToWorkflow_AcceptsEmptyRunId() [Fact] public void ToWorkflow_AcceptsEmptyNamespace() { - // Characterization; see ToWorkflow_AcceptsEmptyRunId. + // The same shared-path-parsing leniency as ToWorkflow_AcceptsEmptyRunId; see that test. var link = new NexusLink( new Uri("temporal:///namespaces//workflows/wf-id/run-id"), Api.Common.V1.Link.Types.Workflow.Descriptor.FullName); diff --git a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs index fc0522ba..09c1fd0a 100644 --- a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs @@ -151,7 +151,9 @@ await RunWithCounterAsync(async (endpoint, taskQueue, counter) => { var caller = await RunCallerAsync( taskQueue, endpoint, new(counter.Id, Fail: true)); - await AssertOperationFailedWithAsync(HandlerErrorType.BadRequest, caller); + // The query handler faulted, not the Nexus request, so this is Internal and explicitly + // non-retryable rather than BadRequest. + await AssertOperationFailedWithAsync(HandlerErrorType.Internal, caller); }); } @@ -180,7 +182,7 @@ await worker.ExecuteAsync(async () => var caller = await RunCallerAsync( taskQueue, endpointName, new(counter.Id, RejectNotOpen: true)); - await AssertOperationFailedWithAsync(HandlerErrorType.BadRequest, caller); + await AssertOperationFailedWithAsync(HandlerErrorType.Internal, caller); }); } From 2dc6bf953b21670273662c1811f48bf6b6de0cae Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 18 Aug 2026 10:49:33 -0700 Subject: [PATCH 06/17] Moved release note --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4d7f03..be05e5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ to docs, or any other relevant information. # Changelog ## [Unreleased] +- Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler + now propagates the link the server returns for the workflow that processed it, so the caller's + Nexus operation event points back at the queried workflow. Requires a server that populates + `QueryWorkflowResponse.link`; older servers leave it unset and nothing is propagated. ### Added @@ -87,10 +91,6 @@ to docs, or any other relevant information. pollers are left unchanged. - Workers now log a [TMPRL1104] warning when a workflow task takes longer than 5 seconds. Set `TEMPORAL_WORKFLOW_TASK_DURATION_WARN_SECONDS` to change the threshold. -- Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler - now propagates the link the server returns for the workflow that processed it, so the caller's - Nexus operation event points back at the queried workflow. Requires a server that populates - `QueryWorkflowResponse.link`; older servers leave it unset and nothing is propagated. ### Changed From 90d2a110eb0fbea27ac66b62bec8552c4f2efd1c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 18 Aug 2026 10:53:21 -0700 Subject: [PATCH 07/17] Fixed changelog --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be05e5c1..680b5eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ to docs, or any other relevant information. # Changelog ## [Unreleased] + +### Added — new features - Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler now propagates the link the server returns for the workflow that processed it, so the caller's Nexus operation event points back at the queried workflow. Requires a server that populates @@ -27,6 +29,17 @@ to docs, or any other relevant information. - Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to report invalid Nexus operation input with structured details. +### Changed — changes in existing functionality + +### Deprecated — soon-to-be-removed features + +### :boom: Breaking Changes — removed or backwards-incompatible features + +### Fixed — notable bug fixes + +### Security — notable security fixes + + ### Changed From fea8c5e48238bba7b723976e5c82b2cf1aa33181 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 18 Aug 2026 10:56:49 -0700 Subject: [PATCH 08/17] Moved more entries --- CHANGELOG.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680b5eb9..a95b96db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ to docs, or any other relevant information. - Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to report invalid Nexus operation input with structured details. ### Changed — changes in existing functionality +- A `common.v1.Link.Workflow` now serializes to the workflow path + `temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param, + rather than reusing the workflow-event path with a `/history` suffix and dropping `reason`. The + previous form was indistinguishable from a workflow-event link except by its type, and did not + match the other SDKs. Inbound workflow links are now parsed as well, and a link with a trailing + path segment is rejected. +- A Nexus operation backed by a workflow query now fails when the query fails or is rejected, rather + than being retried until the operation times out. `WorkflowQueryFailedException` and + `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither + outcome can change on a retry. ### Deprecated — soon-to-be-removed features @@ -120,16 +130,6 @@ to docs, or any other relevant information. (codec) or `BadRequest` (converter) handler exception. This matches the existing pass-through behavior for `ApplicationFailureException` and lets codecs and converters control the resulting Nexus error type and retry behavior. -- A `common.v1.Link.Workflow` now serializes to the workflow path - `temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param, - rather than reusing the workflow-event path with a `/history` suffix and dropping `reason`. The - previous form was indistinguishable from a workflow-event link except by its type, and did not - match the other SDKs. Inbound workflow links are now parsed as well, and a link with a trailing - path segment is rejected. -- A Nexus operation backed by a workflow query now fails when the query fails or is rejected, rather - than being retried until the operation times out. `WorkflowQueryFailedException` and - `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither - outcome can change on a retry. ### Fixed From bbd7d4eaad0b56e026a4635f01c36d2b5135cc42 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 18 Aug 2026 10:57:57 -0700 Subject: [PATCH 09/17] Removed empty entries --- CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95b96db..38764828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,16 +41,6 @@ to docs, or any other relevant information. `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither outcome can change on a retry. -### Deprecated — soon-to-be-removed features - -### :boom: Breaking Changes — removed or backwards-incompatible features - -### Fixed — notable bug fixes - -### Security — notable security fixes - - - ### Changed - A non-retryable `ApplicationFailureException` with error type `PayloadValidationError` thrown by a From 7cf3edabcd841fc7824bf82a07495578f37c715a Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 18 Aug 2026 11:00:07 -0700 Subject: [PATCH 10/17] merged change sections --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38764828..1b90b7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,6 @@ to docs, or any other relevant information. than being retried until the operation times out. `WorkflowQueryFailedException` and `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither outcome can change on a retry. - -### Changed - - A non-retryable `ApplicationFailureException` with error type `PayloadValidationError` thrown by a payload codec or payload converter while decoding Nexus operation input is now reported as a non-retryable `BadRequest` handler exception (with the application failure as its cause) instead of From 0e17f73f251f09cf34e337e4b4571782dff8d78b Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 21 Aug 2026 12:05:20 -0700 Subject: [PATCH 11/17] Fixed changelog --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b90b7ab..02bc4259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,9 @@ to docs, or any other relevant information. now propagates the link the server returns for the workflow that processed it, so the caller's Nexus operation event points back at the queried workflow. Requires a server that populates `QueryWorkflowResponse.link`; older servers leave it unset and nothing is propagated. - -### Added - - Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to report invalid Nexus operation input with structured details. +- ### Changed — changes in existing functionality - A `common.v1.Link.Workflow` now serializes to the workflow path `temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param, From d3420763477fc73bfb811c2d01514be5a4b5636e Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 21 Aug 2026 13:07:24 -0700 Subject: [PATCH 12/17] Removed unused context --- tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs index cc8bfe9a..dfdb7dff 100644 --- a/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs +++ b/tests/Temporalio.Tests/Nexus/QueryResponseLinkTests.cs @@ -68,19 +68,19 @@ public async Task QueryAsync_AgainstOlderServerCapturesNoResponseLink() public async Task QueryAsync_OutsideNexusContextIgnoresResponseLink() { // A query issued outside a Nexus operation handler must not touch the operation context at - // all. Guards against the propagation being reached without a context, which would throw. + // all. No context is installed here, so if the propagation dereferenced the current context + // without checking for one first, this query would throw instead of returning. The response + // still carries a link so the propagation has something to try to attach. var client = NewClient(new QueryWorkflowResponse { Link = WorkflowLink("wf-target", "target-run", "Query processed"), QueryResult = await ToPayloadsAsync("answer"), }); - var context = NewContext(); // Deliberately not inside WithContextAsync. var result = await QueryAsync(client); Assert.Equal("answer", result); - Assert.Empty(context.ResponseLinks); } [Fact] From 5160db8f947e5b7c26c96278db1d95445d477c4e Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 21 Aug 2026 15:54:06 -0700 Subject: [PATCH 13/17] Added a end to end test for backlink --- .../Worker/NexusQueryOperationTests.cs | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs index 09c1fd0a..428c76c0 100644 --- a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs @@ -2,6 +2,7 @@ namespace Temporalio.Tests.Worker; using NexusRpc; using NexusRpc.Handlers; +using Temporalio.Api.Enums.V1; using Temporalio.Client; using Temporalio.Exceptions; using Temporalio.Nexus; @@ -15,11 +16,6 @@ namespace Temporalio.Tests.Worker; /// nothing to history, so the handler simply queries and returns the result; there is no operation /// token and no completion callback. /// -/// -/// The response link the server attaches to QueryWorkflowResponse.Link is verified in -/// Temporalio.Tests.Nexus.QueryResponseLinkTests instead, since the server does not populate -/// that field yet. -/// public class NexusQueryOperationTests : WorkflowEnvironmentTestBase { public NexusQueryOperationTests(ITestOutputHelper output, WorkflowEnvironment env) @@ -122,6 +118,30 @@ await RunWithCounterAsync(async (endpoint, taskQueue, counter) => }); } + [Fact] + public async Task QueryOperation_CapturesResponseLink() + { + // End-to-end response link check: the server attaches a link to QueryWorkflowResponse, the + // client hands it to the Nexus operation context, and the SDK puts it on the caller's + // NexusOperationCompleted event. + // + // Only the response direction is asserted. A Query writes nothing to the queried workflow's + // history, so there is no event on the callee side to carry a forward link, unlike signal. + await RunWithCounterAsync(async (endpoint, taskQueue, counter) => + { + await counter.SignalAsync(wf => wf.BumpAsync()); + await counter.SignalAsync(wf => wf.BumpAsync()); + + var caller = await RunCallerAsync(taskQueue, endpoint, new(counter.Id)); + Assert.Equal(2, await caller.GetResultAsync()); + + var completed = Assert.Single( + (await caller.FetchHistoryAsync()).Events, + e => e.EventType == EventType.NexusOperationCompleted); + AssertQueryResponseLink(completed, counter.Id); + }); + } + [Fact] public async Task QueryOperation_UnknownWorkflow_FailsOperation() { @@ -203,6 +223,26 @@ private static async Task AssertOperationFailedWithAsync( Assert.False(handlerExc.IsRetryable); } + /// + /// Assert that a caller-side event carries a response link naming the queried workflow. A Query + /// produces no history event, so the server answers with a Link.Workflow identifying the + /// execution that processed the Query rather than the Link.WorkflowEvent the signal and + /// update paths use. + /// + private static void AssertQueryResponseLink( + Api.History.V1.HistoryEvent evt, string queriedWorkflowId) + { + Assert.NotEmpty(evt.Links); + var link = evt.Links[0]; + // A Query link must use the Workflow variant, not WorkflowEvent, because a Query writes + // nothing to history. + Assert.Equal( + Api.Common.V1.Link.VariantOneofCase.Workflow, link.VariantCase); + Assert.Equal(queriedWorkflowId, link.Workflow.WorkflowId); + // The link should name the run that processed the Query. + Assert.NotEqual(string.Empty, link.Workflow.RunId); + } + private async Task RunWithCounterAsync( Func, Task> body) { From 25d3696e5728e1cae84a7ba6eb2df0be1a6f4f35 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 24 Aug 2026 15:21:34 -0700 Subject: [PATCH 14/17] Updated server version --- CHANGELOG.md | 7 ++----- tests/Temporalio.Tests/Worker/NexusWorkerTests.cs | 7 ++++++- tests/Temporalio.Tests/WorkflowEnvironment.cs | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bc4259..cf92ddf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,7 @@ to docs, or any other relevant information. ### Added — new features - Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler now propagates the link the server returns for the workflow that processed it, so the caller's - Nexus operation event points back at the queried workflow. Requires a server that populates - `QueryWorkflowResponse.link`; older servers leave it unset and nothing is propagated. + Nexus operation event points back at the queried workflow. - Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to report invalid Nexus operation input with structured details. - @@ -35,9 +34,7 @@ to docs, or any other relevant information. match the other SDKs. Inbound workflow links are now parsed as well, and a link with a trailing path segment is rejected. - A Nexus operation backed by a workflow query now fails when the query fails or is rejected, rather - than being retried until the operation times out. `WorkflowQueryFailedException` and - `WorkflowQueryRejectedException` map to a non-retryable `BadRequest` handler error, since neither - outcome can change on a retry. + than being retried until the operation times out. - A non-retryable `ApplicationFailureException` with error type `PayloadValidationError` thrown by a payload codec or payload converter while decoding Nexus operation input is now reported as a non-retryable `BadRequest` handler exception (with the application failure as its cause) instead of diff --git a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs index 7295375e..bfa2d61f 100644 --- a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs @@ -1450,7 +1450,12 @@ public async Task ExecuteNexusOperationAsync_CancelWaitRequested_ProperlyFails() var exc2 = Assert.IsType(exc.InnerException); var exc3 = Assert.IsType(exc2.InnerException); Assert.Equal(HandlerErrorType.NotImplemented, exc3.ErrorType); - Assert.Equal("Intentional failure", exc3.Message); + var chainMessages = new List(); + for (Exception? cause = exc; cause != null; cause = cause.InnerException) + { + chainMessages.Add(cause.Message); + } + Assert.Contains("Intentional failure", chainMessages); } [Fact] diff --git a/tests/Temporalio.Tests/WorkflowEnvironment.cs b/tests/Temporalio.Tests/WorkflowEnvironment.cs index 65af7a7c..a2f709e7 100644 --- a/tests/Temporalio.Tests/WorkflowEnvironment.cs +++ b/tests/Temporalio.Tests/WorkflowEnvironment.cs @@ -71,7 +71,7 @@ public async Task InitializeAsync() { DevServerOptions = new() { - DownloadVersion = "v1.7.2-standalone-nexus-operations", + DownloadVersion = "v1.8.3-server-1.32.0-162.0", ExtraArgs = new List { // Disable search attribute cache @@ -99,6 +99,7 @@ public async Task InitializeAsync() "--dynamic-config-value", "history.enableChasm=true", "--dynamic-config-value", "history.enableTransitionHistory=true", "--dynamic-config-value", "activity.startDelayEnabled=true", + "--dynamic-config-value", "activity.enableCallbacks=true", // Enable standalone Nexus operations "--dynamic-config-value", "callback.allowedAddresses=[{\"Pattern\":\"*\",\"AllowInsecure\":true}]", // SDK tests use arbitrary callback URLs, permit that on the server "--dynamic-config-value", "nexusoperation.enableStandalone=true", From 399e97cfb93d31fb9266eaeca176a6955c31d9ae Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 24 Aug 2026 15:31:00 -0700 Subject: [PATCH 15/17] Updated dev server version --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf92ddf6..31c1d7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ to docs, or any other relevant information. exception is reported as `Invalid operation input`, which is distinct from the `failed to decode Nexus operation input` message used when decoding itself fails. Application failures of any other error type, and retryable `PayloadValidationError` failures, keep their existing behavior. +- The test suite dev server is now `v1.8.3-server-1.32.0-162.0`. ## [1.18.0] - 2026-08-13 From df2299d136a955d47d6542eb238b9a148c6d6be9 Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:33:11 -0700 Subject: [PATCH 16/17] fix(tests): exclude from cloud tests --- tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs index 428c76c0..f1b92dc9 100644 --- a/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusQueryOperationTests.cs @@ -16,6 +16,9 @@ namespace Temporalio.Tests.Worker; /// nothing to history, so the handler simply queries and returns the result; there is no operation /// token and no completion callback. /// +[CloudTestExclusion( + CloudTestExclusionReason.NeedsCloudAdaptation, + "Requires Cloud Nexus endpoint setup and cleanup.")] public class NexusQueryOperationTests : WorkflowEnvironmentTestBase { public NexusQueryOperationTests(ITestOutputHelper output, WorkflowEnvironment env) From ba356bbebb61cc4be94750c59b919aa657a0b55b Mon Sep 17 00:00:00 2001 From: jmaeagle99 <44687433+jmaeagle99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:40:15 -0700 Subject: [PATCH 17/17] fix: cleanup change log --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c1d7ad..e8e3003f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,16 @@ to docs, or any other relevant information. ## [Unreleased] -### Added — new features +### Added + - Support Workflow Queries as Nexus operations. A query issued from inside a Nexus operation handler now propagates the link the server returns for the workflow that processed it, so the caller's - Nexus operation event points back at the queried workflow. + Nexus operation event points back at the queried workflow. - Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to report invalid Nexus operation input with structured details. -- -### Changed — changes in existing functionality + +### Changed + - A `common.v1.Link.Workflow` now serializes to the workflow path `temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param, rather than reusing the workflow-event path with a `/history` suffix and dropping `reason`. The @@ -42,7 +44,6 @@ to docs, or any other relevant information. exception is reported as `Invalid operation input`, which is distinct from the `failed to decode Nexus operation input` message used when decoding itself fails. Application failures of any other error type, and retryable `PayloadValidationError` failures, keep their existing behavior. -- The test suite dev server is now `v1.8.3-server-1.32.0-162.0`. ## [1.18.0] - 2026-08-13