Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ to docs, or any other relevant information.

### 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.
- Added `PayloadValidationError.CreateException`, which payload converters and codecs can use to
report invalid Nexus operation input with structured details.

### 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
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.
- 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
Expand Down
2 changes: 2 additions & 0 deletions src/Temporalio/Client/TemporalClient.Workflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ public override async Task<TResult> QueryWorkflowAsync<TResult>(QueryWorkflowInp
throw new WorkflowQueryFailedException(e.Message);
}

CaptureNexusResponseLink(resp.Link);

// Throw rejection if rejected
if (resp.QueryRejected != null)
{
Expand Down
90 changes: 73 additions & 17 deletions src/Temporalio/Nexus/ProtoLinkExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
};

Expand All @@ -158,20 +160,53 @@ public static Api.Common.V1.Link.Types.NexusOperation ToNexusOperation(this Nexu
}

/// <summary>
/// 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.
/// </summary>
/// <param name="workflow">Workflow link to convert.</param>
/// <returns>Nexus link.</returns>
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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="link">Nexus link.</param>
/// <returns>Workflow link.</returns>
/// <exception cref="ArgumentException">If the link is invalid.</exception>
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;
}

/// <summary>
/// Convert a Nexus link to an activity link.
/// </summary>
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -271,9 +299,37 @@ 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)
/// <summary>
/// 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.
/// </summary>
/// <param name="uri">URI whose query string to parse.</param>
/// <returns>
/// The query parameters, with both keys and values percent-decoded. Values are additionally
/// form-decoded, i.e. <c>+</c> 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
/// <c>+</c> is encoded as <c>%2B</c>. A parameter present with no <c>=</c> maps to an empty
/// string, so callers cannot distinguish <c>?reason</c> from <c>?reason=</c>. Keys are looked
/// up with the dictionary's default ordinal comparer, so lookups are case-sensitive and
/// <c>reason</c> and <c>Reason</c> are different parameters. A query repeating a key throws,
/// which callers treat as an invalid link like any other malformed URI.
/// </returns>
private static Dictionary<string, string> ParseQueryParams(Uri uri) =>
uri.Query.
TrimStart('?').
Split(QuerySeparator, StringSplitOptions.RemoveEmptyEntries).
Select(v => v.Split(QueryValueSeparator, 2)).
ToDictionary(
Comment thread
jmaeagle99 marked this conversation as resolved.
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")
{
Expand All @@ -284,10 +340,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");
}
Expand Down
20 changes: 20 additions & 0 deletions src/Temporalio/Worker/NexusWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,26 @@ private HandlerException ConvertToHandlerException(Exception exc)
{
return new(HandlerErrorType.BadRequest, "Workflow failed", exc);
}
else if (exc is WorkflowQueryFailedException)
{
// 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 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)
{
return new(
Expand Down
66 changes: 0 additions & 66 deletions tests/Temporalio.Tests/Nexus/NexusWorkflowUpdateHandleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -207,69 +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_BuildsHistoryUri()
{
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/history", 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/history", link.Uri.AbsolutePath);
}
}
Loading
Loading