diff --git a/README.md b/README.md index e3c7ce1..bb5ec16 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,11 @@ Prerequisites: * [NexusCancellation](src/NexusCancellation) - Demonstrates how to cancel a running Nexus operation from a caller workflow. * [NexusContextPropagation](src/NexusContextPropagation) - Context propagation through Nexus services. * [NexusDependencyInjection](src/NexusDependencyInjection) - How to configure a Nexus service handler for dependency injection using a generic host worker. -* [Nexus Messaging](src/NexusMessaging): Demonstrates how send signal, update and query messages through Nexus. +* [Nexus Messaging](src/NexusMessaging): Demonstrates how to send signal, update and query messages through Nexus. This contains two samples, one sending messages to an existing workflow and a second that creates a workflow through Nexus and sends messages to it. +* [Nexus Messaging Temporal Operation](src/NexusMessagingTemporalOperation): Demonstrates how to send signal, update and query messages through Nexus. + This version uses the `[TemporalOperation]` attribute to declare a Temporal-backed Nexus operation start handler directly on a method within a `[NexusServiceHandler]` class. * [NexusMultiArg](src/NexusMultiArg) - Nexus service implementation calling a workflow with multiple arguments. * [NexusSimple](src/NexusSimple) - Simple Nexus service implementation. * [NexusStandaloneActivity](src/NexusStandaloneActivity) - Nexus Operation backed by a Standalone Activity. diff --git a/TemporalioSamples.slnx b/TemporalioSamples.slnx index 92d2dd4..9d5ea2a 100644 --- a/TemporalioSamples.slnx +++ b/TemporalioSamples.slnx @@ -11,7 +11,6 @@ - @@ -60,6 +59,12 @@ + + + + + + diff --git a/src/NexusMessaging/CallerPattern/Handler/NexusGreetingService.cs b/src/NexusMessaging/CallerPattern/Handler/NexusGreetingService.cs index d0038ac..4c7aa4e 100644 --- a/src/NexusMessaging/CallerPattern/Handler/NexusGreetingService.cs +++ b/src/NexusMessaging/CallerPattern/Handler/NexusGreetingService.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.NexusMessaging.CallerPattern.Handler; using NexusRpc.Handlers; -using Temporalio.Client; using Temporalio.Nexus; using TemporalioSamples.NexusMessaging.CallerPattern; using TemporalioSamples.NexusMessaging.Common; @@ -12,63 +11,55 @@ namespace TemporalioSamples.NexusMessaging.CallerPattern.Handler; [NexusServiceHandler(typeof(INexusGreetingService))] public class NexusGreetingService { -#pragma warning disable VSTHRD200 // Names must match the INexusGreetingService operations, which can't take the Async suffix + // OperationHandler.Sync means the result is returned inline to the Nexus caller + // (as opposed to WorkflowRunOperationHandler, which returns an async operation token). + // The lambda may still be async internally. // Query: read-only, no state mutation — uses workflow query - [TemporalOperation] - public async Task> GetLanguages( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusGreetingService.GetLanguagesInput input) - { - // Access the Temporal client from the Nexus client passed to the handler - var handle = client.TemporalClient.GetWorkflowHandle( - WorkflowIdForUser(input.UserId)); - return TemporalOperationResult.SyncResult( - await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported))); - } + [NexusOperationHandler] + public IOperationHandler GetLanguages() => + OperationHandler.Sync( + async (ctx, input) => + { + // Access the Temporal client from the Nexus operation context + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(WorkflowIdForUser(input.UserId)); + return await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported)); + }); // Query: read-only — returns the workflow's current language - [TemporalOperation] - public async Task> GetLanguage( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusGreetingService.GetLanguageInput input) - { - var handle = client.TemporalClient.GetWorkflowHandle( - WorkflowIdForUser(input.UserId)); - return TemporalOperationResult.SyncResult( - await handle.QueryAsync(wf => wf.QueryLanguage())); - } + [NexusOperationHandler] + public IOperationHandler GetLanguage() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(WorkflowIdForUser(input.UserId)); + return await handle.QueryAsync(wf => wf.QueryLanguage()); + }); // Update: mutates state and returns the previous value — uses workflow update - [TemporalOperation] - public Task> SetLanguage( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusGreetingService.SetLanguageInput input) => - client.StartWorkflowUpdateAsync( - WorkflowIdForUser(input.UserId), - wf => wf.SetLanguageAsync(input.Language), - // An Update-backed Operation must wait for the Accepted stage. Any other stage is - // rejected with "nexus op workflow updates only support WorkflowUpdateStageAccepted - // for async updates". - new(WorkflowUpdateStage.Accepted)); + [NexusOperationHandler] + public IOperationHandler SetLanguage() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(WorkflowIdForUser(input.UserId)); + return await handle.ExecuteUpdateAsync(wf => wf.SetLanguageAsync(input.Language)); + }); // Signal: fire-and-forget, no return value needed — uses workflow signal - [TemporalOperation] - public async Task> Approve( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusGreetingService.ApproveInput input) - { - var handle = client.TemporalClient.GetWorkflowHandle( - WorkflowIdForUser(input.UserId)); - await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); - return TemporalOperationResult.SyncResult(default); - } - -#pragma warning restore VSTHRD200 + [NexusOperationHandler] + public IOperationHandler Approve() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(WorkflowIdForUser(input.UserId)); + await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); + return default; + }); private static string WorkflowIdForUser(string userId) => $"GreetingWorkflow_for_{userId}"; } diff --git a/src/NexusMessaging/CallerPattern/README.md b/src/NexusMessaging/CallerPattern/README.md index 54ff2a1..083e8ab 100644 --- a/src/NexusMessaging/CallerPattern/README.md +++ b/src/NexusMessaging/CallerPattern/README.md @@ -14,24 +14,19 @@ The caller Workflow: ### Running -This sample requires a Temporal dev server build that supports Workflow Update callbacks. Download -the compatible binary from the [Temporal CLI pre-release instructions](https://docs.temporal.io/standalone-nexus-operation#temporal-cli-support). - -Start the Temporal dev server with the required namespaces pre-created and Workflow Update -callbacks enabled: +Start a Temporal server: ```bash -./temporal server start-dev \ - --dynamic-config-value history.enableUpdateCallbacks=true \ - --dynamic-config-value history.enableCHASMSignalBacklinks=true \ - --namespace nexus-messaging-handler-namespace \ - --namespace nexus-messaging-caller-namespace +temporal server start-dev ``` -Create the Nexus endpoint: +Create the namespaces and Nexus endpoint: ```bash -./temporal operator nexus endpoint create \ +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ --name nexus-messaging-caller-pattern-endpoint \ --target-namespace nexus-messaging-handler-namespace \ --target-task-queue nexus-messaging-handler-sample @@ -54,12 +49,3 @@ In a third terminal, run the following command to start the example: ```bash dotnet run --project src/NexusMessaging -- caller-workflow ``` - -Expected output: - -``` -Supported languages: Chinese, English -Current language: English -Set language from English to Chinese -Approved workflow -``` diff --git a/src/NexusMessaging/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs b/src/NexusMessaging/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs index 4e97f6e..39d8cd7 100644 --- a/src/NexusMessaging/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs +++ b/src/NexusMessaging/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs @@ -18,17 +18,6 @@ public async Task RunAsync() var userIdOne = "user-one"; var userIdTwo = "user-two"; - // Attach information before the Workflow exists. Since AttachApprovalContext is backed - // by Signal-with-Start on the handler, this call creates the Workflow and delivers the - // note to it. - await client.ExecuteNexusOperationAsync( - svc => svc.AttachApprovalContext(new INexusRemoteGreetingService.AttachApprovalContextInput( - "queued for localization review by the nightly batch", userIdOne))); - log.Add($"Attached approval context before the workflow existed: {userIdOne}"); - - // The Workflow for this user is already running due to AttachApprovalContext. The handler - // sets the conflict policy to UseExisting, so this call attaches the Operation's - // completion callback to the running execution. var handleOne = await client.StartNexusOperationAsync( svc => svc.RunFromRemote(new INexusRemoteGreetingService.RunFromRemoteInput(userIdOne))); log.Add($"Started remote workflow for user: {userIdOne}"); @@ -37,13 +26,6 @@ await client.ExecuteNexusOperationAsync( svc => svc.RunFromRemote(new INexusRemoteGreetingService.RunFromRemoteInput(userIdTwo))); log.Add($"Started remote workflow for user: {userIdTwo}"); - // This user's Workflow was created by RunFromRemote just above, so here Signal-with-Start - // skips the start and only delivers the Signal. - await client.ExecuteNexusOperationAsync( - svc => svc.AttachApprovalContext(new INexusRemoteGreetingService.AttachApprovalContextInput( - "translation approved by the localization team", userIdTwo))); - log.Add($"Attached approval context to the running workflow: {userIdTwo}"); - // Interact with workflow one: get languages, set language, approve var languagesOne = await client.ExecuteNexusOperationAsync( svc => svc.GetLanguages(new INexusRemoteGreetingService.GetLanguagesInput(false, userIdOne))); diff --git a/src/NexusMessaging/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs b/src/NexusMessaging/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs index e9c9c1d..f3042d7 100644 --- a/src/NexusMessaging/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs +++ b/src/NexusMessaging/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs @@ -17,7 +17,6 @@ public class GreetingWorkflow private Language currentLanguage = Language.English; private bool approved; private string approvedBy = string.Empty; - private string? approvalContext; [WorkflowRun] public async Task RunAsync(string userId) @@ -80,11 +79,4 @@ public Task ApproveAsync(string name) approvedBy = name; return Task.CompletedTask; } - - [WorkflowSignal] - public Task AttachApprovalContextAsync(string note) - { - approvalContext = note; - return Task.CompletedTask; - } } diff --git a/src/NexusMessaging/OnDemandPattern/Handler/NexusRemoteGreetingService.cs b/src/NexusMessaging/OnDemandPattern/Handler/NexusRemoteGreetingService.cs index f27e111..52b5c46 100644 --- a/src/NexusMessaging/OnDemandPattern/Handler/NexusRemoteGreetingService.cs +++ b/src/NexusMessaging/OnDemandPattern/Handler/NexusRemoteGreetingService.cs @@ -1,8 +1,6 @@ namespace TemporalioSamples.NexusMessaging.OnDemandPattern.Handler; using NexusRpc.Handlers; -using Temporalio.Api.Enums.V1; -using Temporalio.Client; using Temporalio.Nexus; using TemporalioSamples.NexusMessaging.Common; using TemporalioSamples.NexusMessaging.OnDemandPattern; @@ -13,100 +11,66 @@ namespace TemporalioSamples.NexusMessaging.OnDemandPattern.Handler; [NexusServiceHandler(typeof(INexusRemoteGreetingService))] public class NexusRemoteGreetingService { -#pragma warning disable VSTHRD200 // Names must match the INexusRemoteGreetingService operations, which can't take the Async suffix + // WorkflowRunOperationHandler starts a backing workflow and returns its handle to the + // Nexus infrastructure. The caller receives an async operation token and can poll for + // the workflow result later via GetResultAsync. + [NexusOperationHandler] + public IOperationHandler RunFromRemote() => + WorkflowRunOperationHandler.FromHandleFactory( + (WorkflowRunOperationContext context, INexusRemoteGreetingService.RunFromRemoteInput input) => + context.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(input.UserId), + new() { Id = GetWorkflowId(input.UserId) })); - // Starts the GreetingWorkflow for the given user, or attaches to one already running. - // StartWorkflowAsync returns an async result, which attaches a completion callback, - // so the Operation completes when the Workflow returns. - [TemporalOperation] - public Task> RunFromRemote( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.RunFromRemoteInput input) => - client.StartWorkflowAsync( - (GreetingWorkflow wf) => wf.RunAsync(input.UserId), - new() - { - Id = GetWorkflowId(input.UserId), - - // By default, starting a Workflow whose ID is already running fails the Operation. - // Since AttachApprovalContext below can create the GreetingWorkflow first, this - // Operation needs to attach to the running execution rather than fail. - IdConflictPolicy = WorkflowIdConflictPolicy.UseExisting, - }); + // OperationHandler.Sync means the result is returned inline to the Nexus caller + // (as opposed to WorkflowRunOperationHandler, which returns an async operation token). + // The lambda may still be async internally. // Query: read-only, no state mutation — uses workflow query - [TemporalOperation] - public async Task> GetLanguages( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.GetLanguagesInput input) - { - // Access the Temporal client from the Nexus client passed to the handler - var handle = client.TemporalClient.GetWorkflowHandle( - GetWorkflowId(input.UserId)); - return TemporalOperationResult.SyncResult( - await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported))); - } + [NexusOperationHandler] + public IOperationHandler GetLanguages() => + OperationHandler.Sync( + async (ctx, input) => + { + // Access the Temporal client from the Nexus operation context + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(GetWorkflowId(input.UserId)); + return await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported)); + }); // Query: read-only — returns the workflow's current language - [TemporalOperation] - public async Task> GetLanguage( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.GetLanguageInput input) - { - var handle = client.TemporalClient.GetWorkflowHandle( - GetWorkflowId(input.UserId)); - return TemporalOperationResult.SyncResult( - await handle.QueryAsync(wf => wf.QueryLanguage())); - } + [NexusOperationHandler] + public IOperationHandler GetLanguage() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(GetWorkflowId(input.UserId)); + return await handle.QueryAsync(wf => wf.QueryLanguage()); + }); // Update: mutates state and returns the previous value — uses workflow update - [TemporalOperation] - public Task> SetLanguage( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.SetLanguageInput input) => - client.StartWorkflowUpdateAsync( - GetWorkflowId(input.UserId), - wf => wf.SetLanguageAsync(input.Language), - // An Update-backed Operation must wait for the Accepted stage. Any other stage is - // rejected with "nexus op workflow updates only support WorkflowUpdateStageAccepted - // for async updates". - new(WorkflowUpdateStage.Accepted)); + [NexusOperationHandler] + public IOperationHandler SetLanguage() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(GetWorkflowId(input.UserId)); + return await handle.ExecuteUpdateAsync(wf => wf.SetLanguageAsync(input.Language)); + }); // Signal: fire-and-forget, no return value needed — uses workflow signal - [TemporalOperation] - public async Task> Approve( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.ApproveInput input) - { - var handle = client.TemporalClient.GetWorkflowHandle( - GetWorkflowId(input.UserId)); - await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); - return TemporalOperationResult.SyncResult(default); - } - - // Signal-with-Start: starts the Workflow first if it is not already running. - // When the Workflow already exists, only the Signal is delivered. - [TemporalOperation] - public async Task> AttachApprovalContext( - TemporalOperationStartContext ctx, - ITemporalNexusClient client, - INexusRemoteGreetingService.AttachApprovalContextInput input) - { - var options = new WorkflowOptions( - id: GetWorkflowId(input.UserId), - taskQueue: NexusOperationExecutionContext.Current.Info.TaskQueue); - options.SignalWithStart((GreetingWorkflow wf) => wf.AttachApprovalContextAsync(input.Note)); - await client.TemporalClient.StartWorkflowAsync( - (GreetingWorkflow wf) => wf.RunAsync(input.UserId), options); - return TemporalOperationResult.SyncResult(default); - } - -#pragma warning restore VSTHRD200 + [NexusOperationHandler] + public IOperationHandler Approve() => + OperationHandler.Sync( + async (ctx, input) => + { + var client = NexusOperationExecutionContext.Current.TemporalClient; + var handle = client.GetWorkflowHandle(GetWorkflowId(input.UserId)); + await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); + return default; + }); private static string GetWorkflowId(string userId) => $"GreetingWorkflow_for_{userId}"; } diff --git a/src/NexusMessaging/OnDemandPattern/INexusRemoteGreetingService.cs b/src/NexusMessaging/OnDemandPattern/INexusRemoteGreetingService.cs index 5e3a6cf..d0b8121 100644 --- a/src/NexusMessaging/OnDemandPattern/INexusRemoteGreetingService.cs +++ b/src/NexusMessaging/OnDemandPattern/INexusRemoteGreetingService.cs @@ -21,9 +21,6 @@ public interface INexusRemoteGreetingService [NexusOperation] void Approve(ApproveInput input); - [NexusOperation] - void AttachApprovalContext(AttachApprovalContextInput input); - public record RunFromRemoteInput(string UserId); public record GetLanguagesInput(bool IncludeUnsupported, string UserId); @@ -35,6 +32,4 @@ public record GetLanguageInput(string UserId); public record SetLanguageInput(Language Language, string UserId); public record ApproveInput(string Name, string UserId); - - public record AttachApprovalContextInput(string Note, string UserId); } diff --git a/src/NexusMessaging/OnDemandPattern/README.md b/src/NexusMessaging/OnDemandPattern/README.md index 0643fb9..ef99357 100644 --- a/src/NexusMessaging/OnDemandPattern/README.md +++ b/src/NexusMessaging/OnDemandPattern/README.md @@ -1,41 +1,31 @@ ## On-demand pattern No Workflow is pre-started. The caller creates and controls Workflow instances through Nexus -operations. `NexusRemoteGreetingService` adds a `RunFromRemote` operation that starts a -`GreetingWorkflow`, and every operation includes a `UserId` so the handler can derive +operations. `NexusRemoteGreetingService` adds a `RunFromRemote` operation that starts a new +`GreetingWorkflow`, and every other operation includes a `UserId` so the handler can derive the target Workflow ID. The caller Workflow: -1. Attaches approval context for the first user via `AttachApprovalContext`, before anything has - started that user's Workflow -2. Starts two remote `GreetingWorkflow` instances via `RunFromRemote` (backed by a Workflow started - through `ITemporalNexusClient.StartWorkflowAsync`) -3. Attaches approval context for the second user, whose Workflow now already exists -4. Workflow one: queries supported languages, changes to Spanish, and approves -5. Workflow two: queries the current language, changes to French, and approves -6. Waits for each to complete and returns their results +1. Starts two remote `GreetingWorkflow` instances via `RunFromRemote` (backed by `WorkflowRunOperationHandler`) +2. Workflow one: queries supported languages, changes to Spanish, and approves +3. Workflow two: queries the current language, changes to French, and approves +4. Waits for each to complete and returns their results ### Running -This sample requires a Temporal dev server build that supports Workflow Update callbacks. Download -the compatible binary from the [Temporal CLI pre-release instructions](https://docs.temporal.io/standalone-nexus-operation#temporal-cli-support). - -Start the Temporal dev server with the required namespaces pre-created, and Workflow Update -callbacks and signal-with-start from a Workflow enabled: +Start a Temporal server: ```bash -./temporal server start-dev \ - --dynamic-config-value history.enableUpdateCallbacks=true \ - --dynamic-config-value history.enableCHASMSignalBacklinks=true \ - --dynamic-config-value history.enableSignalWithStartFromWorkflow=true \ - --namespace nexus-messaging-handler-namespace \ - --namespace nexus-messaging-caller-namespace +temporal server start-dev ``` -Create the Nexus endpoint: +Create the namespaces and Nexus endpoint: ```bash -./temporal operator nexus endpoint create \ +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ --name nexus-messaging-on-demand-pattern-endpoint \ --target-namespace nexus-messaging-handler-namespace \ --target-task-queue nexus-messaging-handler-sample @@ -58,20 +48,3 @@ In a third terminal, run the following command to start the example: ```bash dotnet run --project src/NexusMessaging -- remote-caller-workflow ``` - -Expected output: - -``` -Attached approval context before the workflow existed: user-one -Started remote workflow for user: user-one -Started remote workflow for user: user-two -Attached approval context to the running workflow: user-two -[One] Supported languages: Chinese, English -[One] Set language from English to Spanish -[One] Approved -[Two] Current language: English -[Two] Set language from English to French -[Two] Approved -[One] Result: Hola, mundo (approved by CallerRemoteWorkflow) -[Two] Result: Bonjour, monde (approved by CallerRemoteWorkflow) -``` diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/Caller/CallerWorkflow.workflow.cs b/src/NexusMessagingTemporalOperation/CallerPattern/Caller/CallerWorkflow.workflow.cs new file mode 100644 index 0000000..e37c179 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/Caller/CallerWorkflow.workflow.cs @@ -0,0 +1,39 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Caller; + +using Temporalio.Workflows; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; + +[Workflow] +public class CallerWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string userId) + { + var log = new List(); + var client = Workflow.CreateNexusWorkflowClient( + NexusEndpoints.GreetingService); + + // GetLanguages - query entity workflow for supported languages + var languagesOutput = await client.ExecuteNexusOperationAsync( + svc => svc.GetLanguages(new INexusGreetingService.GetLanguagesInput(false, userId))); + log.Add($"Supported languages: {string.Join(", ", languagesOutput.Languages)}"); + + // GetLanguage - query entity workflow for current language + var currentLanguage = await client.ExecuteNexusOperationAsync( + svc => svc.GetLanguage(new INexusGreetingService.GetLanguageInput(userId))); + log.Add($"Current language: {currentLanguage}"); + + // SetLanguage - update entity workflow to change language + var previousLanguage = await client.ExecuteNexusOperationAsync( + svc => svc.SetLanguage(new INexusGreetingService.SetLanguageInput(Language.Chinese, userId))); + log.Add($"Set language from {previousLanguage} to {Language.Chinese}"); + + // Approve - signal entity workflow to complete + await client.ExecuteNexusOperationAsync( + svc => svc.Approve(new INexusGreetingService.ApproveInput("CallerWorkflow", userId))); + log.Add("Approved workflow"); + + return log.ToArray(); + } +} diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/Caller/NexusEndpoints.cs b/src/NexusMessagingTemporalOperation/CallerPattern/Caller/NexusEndpoints.cs new file mode 100644 index 0000000..c8a3b13 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/Caller/NexusEndpoints.cs @@ -0,0 +1,6 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Caller; + +public static class NexusEndpoints +{ + public const string GreetingService = "nexus-messaging-caller-pattern-endpoint"; +} diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/Handler/GreetingWorkflow.workflow.cs b/src/NexusMessagingTemporalOperation/CallerPattern/Handler/GreetingWorkflow.workflow.cs new file mode 100644 index 0000000..01db39c --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/Handler/GreetingWorkflow.workflow.cs @@ -0,0 +1,82 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Handler; + +using Temporalio.Exceptions; +using Temporalio.Workflows; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; + +[Workflow] +public class GreetingWorkflow +{ + private readonly Dictionary greetings = new() + { + [Language.Chinese] = "你好,世界", + [Language.English] = "Hello, world", + }; + + private Language currentLanguage = Language.English; + private bool approved; + private string approvedBy = string.Empty; + + [WorkflowRun] + public async Task RunAsync(string userId) + { + // Wait for approve signal and all handlers to finish + await Workflow.WaitConditionAsync(() => approved && Workflow.AllHandlersFinished); + + var greeting = greetings.TryGetValue(currentLanguage, out var g) ? g : "Hello, world"; + return $"{greeting} (approved by {approvedBy})"; + } + + [WorkflowQuery] + public INexusGreetingService.GetLanguagesOutput QueryLanguages(bool includeUnsupported) + { + if (includeUnsupported) + { + return new(Enum.GetValues()); + } + + return new(greetings.Keys.ToArray()); + } + + [WorkflowQuery] + public Language QueryLanguage() => currentLanguage; + + [WorkflowUpdate] + public async Task SetLanguageAsync(Language newLanguage) + { + var prev = currentLanguage; + + // If language not yet in our greetings map, fetch from activity + if (!greetings.ContainsKey(newLanguage)) + { + var allGreetings = await Workflow.ExecuteActivityAsync( + (GreetingActivities a) => a.GetAllGreetingsAsync(), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + foreach (var kvp in allGreetings) + { + greetings[kvp.Key] = kvp.Value; + } + } + + currentLanguage = newLanguage; + return prev; + } + + [WorkflowUpdateValidator(nameof(SetLanguageAsync))] + public void ValidateSetLanguage(Language newLanguage) + { + if (!Enum.IsDefined(newLanguage)) + { + throw new ApplicationFailureException($"Unsupported language: {newLanguage}"); + } + } + + [WorkflowSignal] + public Task ApproveAsync(string name) + { + approved = true; + approvedBy = name; + return Task.CompletedTask; + } +} diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/Handler/NexusGreetingService.cs b/src/NexusMessagingTemporalOperation/CallerPattern/Handler/NexusGreetingService.cs new file mode 100644 index 0000000..504d41c --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/Handler/NexusGreetingService.cs @@ -0,0 +1,74 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Handler; + +using NexusRpc.Handlers; +using Temporalio.Client; +using Temporalio.Nexus; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; + +// Entity pattern: the handler worker pre-starts a GreetingWorkflow per user at boot time. +// This service routes each Nexus operation to that existing workflow by deriving the +// workflow ID from the caller-supplied UserId. +[NexusServiceHandler(typeof(INexusGreetingService))] +public class NexusGreetingService +{ +#pragma warning disable VSTHRD200 // Names must match the INexusGreetingService operations, which can't take the Async suffix + + // Query: read-only, no state mutation — uses workflow query + [TemporalOperation] + public async Task> GetLanguages( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusGreetingService.GetLanguagesInput input) + { + // Access the Temporal client from the Nexus client passed to the handler + var handle = client.TemporalClient.GetWorkflowHandle( + WorkflowIdForUser(input.UserId)); + return TemporalOperationResult.SyncResult( + await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported))); + } + + // Query: read-only — returns the workflow's current language + [TemporalOperation] + public async Task> GetLanguage( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusGreetingService.GetLanguageInput input) + { + var handle = client.TemporalClient.GetWorkflowHandle( + WorkflowIdForUser(input.UserId)); + return TemporalOperationResult.SyncResult( + await handle.QueryAsync(wf => wf.QueryLanguage())); + } + + // Update: mutates state and returns the previous value — uses workflow update + [TemporalOperation] + public Task> SetLanguage( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusGreetingService.SetLanguageInput input) => + client.StartWorkflowUpdateAsync( + WorkflowIdForUser(input.UserId), + wf => wf.SetLanguageAsync(input.Language), + // An Update-backed Operation must wait for the Accepted stage. Any other stage is + // rejected with "nexus op workflow updates only support WorkflowUpdateStageAccepted + // for async updates". + new(WorkflowUpdateStage.Accepted)); + + // Signal: fire-and-forget, no return value needed — uses workflow signal + [TemporalOperation] + public async Task> Approve( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusGreetingService.ApproveInput input) + { + var handle = client.TemporalClient.GetWorkflowHandle( + WorkflowIdForUser(input.UserId)); + await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); + return TemporalOperationResult.SyncResult(default); + } + +#pragma warning restore VSTHRD200 + + private static string WorkflowIdForUser(string userId) => $"GreetingWorkflow_for_{userId}"; +} diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/INexusGreetingService.cs b/src/NexusMessagingTemporalOperation/CallerPattern/INexusGreetingService.cs new file mode 100644 index 0000000..53db39b --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/INexusGreetingService.cs @@ -0,0 +1,30 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern; + +using NexusRpc; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; + +[NexusService] +public interface INexusGreetingService +{ + [NexusOperation] + GetLanguagesOutput GetLanguages(GetLanguagesInput input); + + [NexusOperation] + Language GetLanguage(GetLanguageInput input); + + [NexusOperation] + Language SetLanguage(SetLanguageInput input); + + [NexusOperation] + void Approve(ApproveInput input); + + public record GetLanguagesInput(bool IncludeUnsupported, string UserId); + + public record GetLanguagesOutput(IReadOnlyList Languages); + + public record GetLanguageInput(string UserId); + + public record SetLanguageInput(Language Language, string UserId); + + public record ApproveInput(string Name, string UserId); +} diff --git a/src/NexusMessagingTemporalOperation/CallerPattern/README.md b/src/NexusMessagingTemporalOperation/CallerPattern/README.md new file mode 100644 index 0000000..7549f18 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/CallerPattern/README.md @@ -0,0 +1,65 @@ +## Entity pattern + +The handler worker starts a `GreetingWorkflow` for a User ID at boot time. +`NexusGreetingService` routes every Nexus operation to that existing Workflow by deriving +the Workflow ID from the caller-supplied `UserId` (see the `WorkflowIdForUser` call). +The caller passes a `UserId`, not a Workflow ID -- the handler is responsible for the +ID mapping. + +The caller Workflow: +1. Queries for supported languages (`GetLanguages` -- backed by a Workflow query) +2. Queries the current language (`GetLanguage` -- backed by a Workflow query) +3. Changes the language to Chinese (`SetLanguage` -- backed by a Workflow update that calls an activity) +4. Approves the Workflow (`Approve` -- backed by a Workflow signal) + +### Running + +This sample requires a Temporal dev server build that supports Workflow Update callbacks. Download +the compatible binary from the [Temporal CLI pre-release instructions](https://docs.temporal.io/standalone-nexus-operation#temporal-cli-support). + +Start the Temporal dev server with the required namespaces pre-created and Workflow Update +callbacks enabled: + +```bash +./temporal server start-dev \ + --dynamic-config-value history.enableUpdateCallbacks=true \ + --dynamic-config-value history.enableCHASMSignalBacklinks=true \ + --namespace nexus-messaging-handler-namespace \ + --namespace nexus-messaging-caller-namespace +``` + +Create the Nexus endpoint: + +```bash +./temporal operator nexus endpoint create \ + --name nexus-messaging-caller-pattern-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-sample +``` + +In one terminal, start the handler worker: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- handler-worker +``` + +In a second terminal, start the caller worker: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- caller-worker +``` + +In a third terminal, run the following command to start the example: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- caller-workflow +``` + +Expected output: + +``` +Supported languages: Chinese, English +Current language: English +Set language from English to Chinese +Approved workflow +``` diff --git a/src/NexusMessagingTemporalOperation/Common/GreetingActivities.cs b/src/NexusMessagingTemporalOperation/Common/GreetingActivities.cs new file mode 100644 index 0000000..d205ef1 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/Common/GreetingActivities.cs @@ -0,0 +1,19 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.Common; + +using Temporalio.Activities; + +public class GreetingActivities +{ + [Activity] + public Task> GetAllGreetingsAsync() => + Task.FromResult(new Dictionary + { + [Language.Arabic] = "مرحبا بالعالم", + [Language.Chinese] = "你好,世界", + [Language.English] = "Hello, world", + [Language.French] = "Bonjour, monde", + [Language.Hindi] = "नमस्ते दुनिया", + [Language.Portuguese] = "Olá, mundo", + [Language.Spanish] = "Hola, mundo", + }); +} diff --git a/src/NexusMessagingTemporalOperation/Common/Language.cs b/src/NexusMessagingTemporalOperation/Common/Language.cs new file mode 100644 index 0000000..6c8d1c4 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/Common/Language.cs @@ -0,0 +1,12 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.Common; + +public enum Language +{ + Arabic, + Chinese, + English, + French, + Hindi, + Portuguese, + Spanish, +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs b/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs new file mode 100644 index 0000000..9786758 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/CallerRemoteWorkflow.workflow.cs @@ -0,0 +1,82 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Caller; + +using Temporalio.Workflows; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern; + +[Workflow] +public class CallerRemoteWorkflow +{ + [WorkflowRun] + public async Task RunAsync() + { + var log = new List(); + var client = Workflow.CreateNexusWorkflowClient( + NexusEndpoints.RemoteGreetingService); + + // Start two remote greeting workflows on demand + var userIdOne = "user-one"; + var userIdTwo = "user-two"; + + // Attach information before the Workflow exists. Since AttachApprovalContext is backed + // by Signal-with-Start on the handler, this call creates the Workflow and delivers the + // note to it. + await client.ExecuteNexusOperationAsync( + svc => svc.AttachApprovalContext(new INexusRemoteGreetingService.AttachApprovalContextInput( + "queued for localization review by the nightly batch", userIdOne))); + log.Add($"Attached approval context before the workflow existed: {userIdOne}"); + + // The Workflow for this user is already running due to AttachApprovalContext. The handler + // sets the conflict policy to UseExisting, so this call attaches the Operation's + // completion callback to the running execution. + var handleOne = await client.StartNexusOperationAsync( + svc => svc.RunFromRemote(new INexusRemoteGreetingService.RunFromRemoteInput(userIdOne))); + log.Add($"Started remote workflow for user: {userIdOne}"); + + var handleTwo = await client.StartNexusOperationAsync( + svc => svc.RunFromRemote(new INexusRemoteGreetingService.RunFromRemoteInput(userIdTwo))); + log.Add($"Started remote workflow for user: {userIdTwo}"); + + // This user's Workflow was created by RunFromRemote just above, so here Signal-with-Start + // skips the start and only delivers the Signal. + await client.ExecuteNexusOperationAsync( + svc => svc.AttachApprovalContext(new INexusRemoteGreetingService.AttachApprovalContextInput( + "translation approved by the localization team", userIdTwo))); + log.Add($"Attached approval context to the running workflow: {userIdTwo}"); + + // Interact with workflow one: get languages, set language, approve + var languagesOne = await client.ExecuteNexusOperationAsync( + svc => svc.GetLanguages(new INexusRemoteGreetingService.GetLanguagesInput(false, userIdOne))); + log.Add($"[One] Supported languages: {string.Join(", ", languagesOne.Languages)}"); + + var prevLangOne = await client.ExecuteNexusOperationAsync( + svc => svc.SetLanguage(new INexusRemoteGreetingService.SetLanguageInput(Language.Spanish, userIdOne))); + log.Add($"[One] Set language from {prevLangOne} to {Language.Spanish}"); + + await client.ExecuteNexusOperationAsync( + svc => svc.Approve(new INexusRemoteGreetingService.ApproveInput("CallerRemoteWorkflow", userIdOne))); + log.Add("[One] Approved"); + + // Interact with workflow two: get language, set language, approve + var currentLangTwo = await client.ExecuteNexusOperationAsync( + svc => svc.GetLanguage(new INexusRemoteGreetingService.GetLanguageInput(userIdTwo))); + log.Add($"[Two] Current language: {currentLangTwo}"); + + var prevLangTwo = await client.ExecuteNexusOperationAsync( + svc => svc.SetLanguage(new INexusRemoteGreetingService.SetLanguageInput(Language.French, userIdTwo))); + log.Add($"[Two] Set language from {prevLangTwo} to {Language.French}"); + + await client.ExecuteNexusOperationAsync( + svc => svc.Approve(new INexusRemoteGreetingService.ApproveInput("CallerRemoteWorkflow", userIdTwo))); + log.Add("[Two] Approved"); + + // Wait for both remote workflows to complete + var resultOne = await handleOne.GetResultAsync(); + log.Add($"[One] Result: {resultOne}"); + + var resultTwo = await handleTwo.GetResultAsync(); + log.Add($"[Two] Result: {resultTwo}"); + + return log.ToArray(); + } +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/NexusEndpoints.cs b/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/NexusEndpoints.cs new file mode 100644 index 0000000..d98bfa5 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/Caller/NexusEndpoints.cs @@ -0,0 +1,6 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Caller; + +public static class NexusEndpoints +{ + public const string RemoteGreetingService = "nexus-messaging-on-demand-pattern-endpoint"; +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs b/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs new file mode 100644 index 0000000..45a51bd --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/GreetingWorkflow.workflow.cs @@ -0,0 +1,90 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Handler; + +using Temporalio.Exceptions; +using Temporalio.Workflows; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern; + +[Workflow] +public class GreetingWorkflow +{ + private readonly Dictionary greetings = new() + { + [Language.Chinese] = "你好,世界", + [Language.English] = "Hello, world", + }; + + private Language currentLanguage = Language.English; + private bool approved; + private string approvedBy = string.Empty; + private string? approvalContext; + + [WorkflowRun] + public async Task RunAsync(string userId) + { + // Wait for approve signal and all handlers to finish + await Workflow.WaitConditionAsync(() => approved && Workflow.AllHandlersFinished); + + var greeting = greetings.TryGetValue(currentLanguage, out var g) ? g : "Hello, world"; + return $"{greeting} (approved by {approvedBy})"; + } + + [WorkflowQuery] + public INexusRemoteGreetingService.GetLanguagesOutput QueryLanguages(bool includeUnsupported) + { + if (includeUnsupported) + { + return new(Enum.GetValues()); + } + + return new(greetings.Keys.ToArray()); + } + + [WorkflowQuery] + public Language QueryLanguage() => currentLanguage; + + [WorkflowUpdate] + public async Task SetLanguageAsync(Language newLanguage) + { + var prev = currentLanguage; + + // If language not yet in our greetings map, fetch from activity + if (!greetings.ContainsKey(newLanguage)) + { + var allGreetings = await Workflow.ExecuteActivityAsync( + (GreetingActivities a) => a.GetAllGreetingsAsync(), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + foreach (var kvp in allGreetings) + { + greetings[kvp.Key] = kvp.Value; + } + } + + currentLanguage = newLanguage; + return prev; + } + + [WorkflowUpdateValidator(nameof(SetLanguageAsync))] + public void ValidateSetLanguage(Language newLanguage) + { + if (!Enum.IsDefined(newLanguage)) + { + throw new ApplicationFailureException($"Unsupported language: {newLanguage}"); + } + } + + [WorkflowSignal] + public Task ApproveAsync(string name) + { + approved = true; + approvedBy = name; + return Task.CompletedTask; + } + + [WorkflowSignal] + public Task AttachApprovalContextAsync(string note) + { + approvalContext = note; + return Task.CompletedTask; + } +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/NexusRemoteGreetingService.cs b/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/NexusRemoteGreetingService.cs new file mode 100644 index 0000000..097eed9 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/Handler/NexusRemoteGreetingService.cs @@ -0,0 +1,112 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Handler; + +using NexusRpc.Handlers; +using Temporalio.Api.Enums.V1; +using Temporalio.Client; +using Temporalio.Nexus; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern; + +// On-demand pattern: no workflow is pre-started. The caller creates workflow instances +// through Nexus operations. Each operation includes a UserId so the handler can derive +// the target workflow ID. +[NexusServiceHandler(typeof(INexusRemoteGreetingService))] +public class NexusRemoteGreetingService +{ +#pragma warning disable VSTHRD200 // Names must match the INexusRemoteGreetingService operations, which can't take the Async suffix + + // Starts the GreetingWorkflow for the given user, or attaches to one already running. + // StartWorkflowAsync returns an async result, which attaches a completion callback, + // so the Operation completes when the Workflow returns. + [TemporalOperation] + public Task> RunFromRemote( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.RunFromRemoteInput input) => + client.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(input.UserId), + new() + { + Id = GetWorkflowId(input.UserId), + + // By default, starting a Workflow whose ID is already running fails the Operation. + // Since AttachApprovalContext below can create the GreetingWorkflow first, this + // Operation needs to attach to the running execution rather than fail. + IdConflictPolicy = WorkflowIdConflictPolicy.UseExisting, + }); + + // Query: read-only, no state mutation — uses workflow query + [TemporalOperation] + public async Task> GetLanguages( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.GetLanguagesInput input) + { + // Access the Temporal client from the Nexus client passed to the handler + var handle = client.TemporalClient.GetWorkflowHandle( + GetWorkflowId(input.UserId)); + return TemporalOperationResult.SyncResult( + await handle.QueryAsync(wf => wf.QueryLanguages(input.IncludeUnsupported))); + } + + // Query: read-only — returns the workflow's current language + [TemporalOperation] + public async Task> GetLanguage( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.GetLanguageInput input) + { + var handle = client.TemporalClient.GetWorkflowHandle( + GetWorkflowId(input.UserId)); + return TemporalOperationResult.SyncResult( + await handle.QueryAsync(wf => wf.QueryLanguage())); + } + + // Update: mutates state and returns the previous value — uses workflow update + [TemporalOperation] + public Task> SetLanguage( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.SetLanguageInput input) => + client.StartWorkflowUpdateAsync( + GetWorkflowId(input.UserId), + wf => wf.SetLanguageAsync(input.Language), + // An Update-backed Operation must wait for the Accepted stage. Any other stage is + // rejected with "nexus op workflow updates only support WorkflowUpdateStageAccepted + // for async updates". + new(WorkflowUpdateStage.Accepted)); + + // Signal: fire-and-forget, no return value needed — uses workflow signal + [TemporalOperation] + public async Task> Approve( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.ApproveInput input) + { + var handle = client.TemporalClient.GetWorkflowHandle( + GetWorkflowId(input.UserId)); + await handle.SignalAsync(wf => wf.ApproveAsync(input.Name)); + return TemporalOperationResult.SyncResult(default); + } + + // Signal-with-Start: starts the Workflow first if it is not already running. + // When the Workflow already exists, only the Signal is delivered. + [TemporalOperation] + public async Task> AttachApprovalContext( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + INexusRemoteGreetingService.AttachApprovalContextInput input) + { + var options = new WorkflowOptions( + id: GetWorkflowId(input.UserId), + taskQueue: NexusOperationExecutionContext.Current.Info.TaskQueue); + options.SignalWithStart((GreetingWorkflow wf) => wf.AttachApprovalContextAsync(input.Note)); + await client.TemporalClient.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(input.UserId), options); + return TemporalOperationResult.SyncResult(default); + } + +#pragma warning restore VSTHRD200 + + private static string GetWorkflowId(string userId) => $"GreetingWorkflow_for_{userId}"; +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/INexusRemoteGreetingService.cs b/src/NexusMessagingTemporalOperation/OnDemandPattern/INexusRemoteGreetingService.cs new file mode 100644 index 0000000..02cf3c6 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/INexusRemoteGreetingService.cs @@ -0,0 +1,40 @@ +namespace TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern; + +using NexusRpc; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; + +[NexusService] +public interface INexusRemoteGreetingService +{ + [NexusOperation] + string RunFromRemote(RunFromRemoteInput input); + + [NexusOperation] + GetLanguagesOutput GetLanguages(GetLanguagesInput input); + + [NexusOperation] + Language GetLanguage(GetLanguageInput input); + + [NexusOperation] + Language SetLanguage(SetLanguageInput input); + + [NexusOperation] + void Approve(ApproveInput input); + + [NexusOperation] + void AttachApprovalContext(AttachApprovalContextInput input); + + public record RunFromRemoteInput(string UserId); + + public record GetLanguagesInput(bool IncludeUnsupported, string UserId); + + public record GetLanguagesOutput(IReadOnlyList Languages); + + public record GetLanguageInput(string UserId); + + public record SetLanguageInput(Language Language, string UserId); + + public record ApproveInput(string Name, string UserId); + + public record AttachApprovalContextInput(string Note, string UserId); +} diff --git a/src/NexusMessagingTemporalOperation/OnDemandPattern/README.md b/src/NexusMessagingTemporalOperation/OnDemandPattern/README.md new file mode 100644 index 0000000..444c477 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/OnDemandPattern/README.md @@ -0,0 +1,77 @@ +## On-demand pattern + +No Workflow is pre-started. The caller creates and controls Workflow instances through Nexus +operations. `NexusRemoteGreetingService` adds a `RunFromRemote` operation that starts a +`GreetingWorkflow`, and every operation includes a `UserId` so the handler can derive +the target Workflow ID. + +The caller Workflow: +1. Attaches approval context for the first user via `AttachApprovalContext`, before anything has + started that user's Workflow +2. Starts two remote `GreetingWorkflow` instances via `RunFromRemote` (backed by a Workflow started + through `ITemporalNexusClient.StartWorkflowAsync`) +3. Attaches approval context for the second user, whose Workflow now already exists +4. Workflow one: queries supported languages, changes to Spanish, and approves +5. Workflow two: queries the current language, changes to French, and approves +6. Waits for each to complete and returns their results + +### Running + +This sample requires a Temporal dev server build that supports Workflow Update callbacks. Download +the compatible binary from the [Temporal CLI pre-release instructions](https://docs.temporal.io/standalone-nexus-operation#temporal-cli-support). + +Start the Temporal dev server with the required namespaces pre-created, and Workflow Update +callbacks and signal-with-start from a Workflow enabled: + +```bash +./temporal server start-dev \ + --dynamic-config-value history.enableUpdateCallbacks=true \ + --dynamic-config-value history.enableCHASMSignalBacklinks=true \ + --dynamic-config-value history.enableSignalWithStartFromWorkflow=true \ + --namespace nexus-messaging-handler-namespace \ + --namespace nexus-messaging-caller-namespace +``` + +Create the Nexus endpoint: + +```bash +./temporal operator nexus endpoint create \ + --name nexus-messaging-on-demand-pattern-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-sample +``` + +In one terminal, start the handler worker: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- remote-handler-worker +``` + +In a second terminal, start the caller worker: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- remote-caller-worker +``` + +In a third terminal, run the following command to start the example: + +```bash +dotnet run --project src/NexusMessagingTemporalOperation -- remote-caller-workflow +``` + +Expected output: + +``` +Attached approval context before the workflow existed: user-one +Started remote workflow for user: user-one +Started remote workflow for user: user-two +Attached approval context to the running workflow: user-two +[One] Supported languages: Chinese, English +[One] Set language from English to Spanish +[One] Approved +[Two] Current language: English +[Two] Set language from English to French +[Two] Approved +[One] Result: Hola, mundo (approved by CallerRemoteWorkflow) +[Two] Result: Bonjour, monde (approved by CallerRemoteWorkflow) +``` diff --git a/src/NexusMessagingTemporalOperation/Program.cs b/src/NexusMessagingTemporalOperation/Program.cs new file mode 100644 index 0000000..3932e3d --- /dev/null +++ b/src/NexusMessagingTemporalOperation/Program.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Common.EnvConfig; +using Temporalio.Worker; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Caller; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Handler; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Caller; +using CallerGreetingWorkflow = TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Handler.GreetingWorkflow; +using OnDemandGreetingWorkflow = TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Handler.GreetingWorkflow; +using OnDemandNexusService = TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Handler.NexusRemoteGreetingService; + +using var loggerFactory = LoggerFactory.Create(builder => + builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var logger = loggerFactory.CreateLogger(); + +// Cancellation token cancelled on ctrl+c +using var tokenSource = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + tokenSource.Cancel(); + eventArgs.Cancel = true; +}; + +const string HandlerNamespace = "nexus-messaging-handler-namespace"; +const string CallerNamespace = "nexus-messaging-caller-namespace"; +const string HandlerTaskQueue = "nexus-messaging-handler-sample"; +const string CallerTaskQueue = "nexus-messaging-caller-sample"; + +Task ConnectClientAsync(string temporalNamespace) +{ + var connectOptions = ClientEnvConfig.LoadClientConnectOptions(); + connectOptions.TargetHost ??= "localhost:7233"; + connectOptions.Namespace = temporalNamespace; + connectOptions.LoggerFactory = loggerFactory; + return TemporalClient.ConnectAsync(connectOptions); +} + +async Task RunHandlerWorkerAsync() +{ + logger.LogInformation("Running handler worker"); + var client = await ConnectClientAsync(HandlerNamespace); + + // Start entity workflow with UseExisting policy (entity pattern: pre-start at boot) + var workflowId = "GreetingWorkflow_for_default-user"; + try + { + await client.StartWorkflowAsync( + (CallerGreetingWorkflow wf) => wf.RunAsync("default-user"), + new(id: workflowId, taskQueue: HandlerTaskQueue) + { + IdConflictPolicy = Temporalio.Api.Enums.V1.WorkflowIdConflictPolicy.UseExisting, + }); + logger.LogInformation("Started entity workflow {WorkflowId}", workflowId); + } + catch (Temporalio.Exceptions.RpcException ex) + { + logger.LogWarning(ex, "Could not start entity workflow"); + } + + using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(HandlerTaskQueue). + AddNexusService(new NexusGreetingService()). + AddWorkflow(). + AddAllActivities(new GreetingActivities())); + try + { + await worker.ExecuteAsync(tokenSource.Token); + } + catch (OperationCanceledException) + { + logger.LogInformation("Handler worker cancelled"); + } +} + +async Task RunCallerWorkerAsync() +{ + logger.LogInformation("Running caller worker"); + using var worker = new TemporalWorker( + await ConnectClientAsync(CallerNamespace), + new TemporalWorkerOptions(CallerTaskQueue). + AddWorkflow()); + try + { + await worker.ExecuteAsync(tokenSource.Token); + } + catch (OperationCanceledException) + { + logger.LogInformation("Caller worker cancelled"); + } +} + +async Task ExecuteCallerWorkflowAsync() +{ + logger.LogInformation("Executing caller workflow"); + var client = await ConnectClientAsync(CallerNamespace); + var result = await client.ExecuteWorkflowAsync( + (CallerWorkflow wf) => wf.RunAsync("default-user"), + new(id: "nexus-messaging-caller-workflow-id", taskQueue: CallerTaskQueue)); + logger.LogInformation("Caller workflow result:"); + foreach (var entry in result) + { + logger.LogInformation(" {Entry}", entry); + } +} + +async Task RunRemoteHandlerWorkerAsync() +{ + logger.LogInformation("Running remote handler worker (on-demand pattern)"); + using var worker = new TemporalWorker( + await ConnectClientAsync(HandlerNamespace), + new TemporalWorkerOptions(HandlerTaskQueue). + AddNexusService(new OnDemandNexusService()). + AddWorkflow(). + AddAllActivities(new GreetingActivities())); + try + { + await worker.ExecuteAsync(tokenSource.Token); + } + catch (OperationCanceledException) + { + logger.LogInformation("Remote handler worker cancelled"); + } +} + +async Task RunRemoteCallerWorkerAsync() +{ + logger.LogInformation("Running remote caller worker (on-demand pattern)"); + using var worker = new TemporalWorker( + await ConnectClientAsync(CallerNamespace), + new TemporalWorkerOptions(CallerTaskQueue). + AddWorkflow()); + try + { + await worker.ExecuteAsync(tokenSource.Token); + } + catch (OperationCanceledException) + { + logger.LogInformation("Remote caller worker cancelled"); + } +} + +async Task ExecuteRemoteCallerWorkflowAsync() +{ + logger.LogInformation("Executing remote caller workflow (on-demand pattern)"); + var client = await ConnectClientAsync(CallerNamespace); + var result = await client.ExecuteWorkflowAsync( + (CallerRemoteWorkflow wf) => wf.RunAsync(), + new(id: "nexus-messaging-remote-caller-workflow-id", taskQueue: CallerTaskQueue)); + logger.LogInformation("Remote caller workflow result:"); + foreach (var entry in result) + { + logger.LogInformation(" {Entry}", entry); + } +} + +switch (args.ElementAtOrDefault(0)) +{ + case "handler-worker": + await RunHandlerWorkerAsync(); + break; + case "caller-worker": + await RunCallerWorkerAsync(); + break; + case "caller-workflow": + await ExecuteCallerWorkflowAsync(); + break; + case "remote-handler-worker": + await RunRemoteHandlerWorkerAsync(); + break; + case "remote-caller-worker": + await RunRemoteCallerWorkerAsync(); + break; + case "remote-caller-workflow": + await ExecuteRemoteCallerWorkflowAsync(); + break; + default: + throw new ArgumentException( + "Must pass 'handler-worker', 'caller-worker', 'caller-workflow', " + + "'remote-handler-worker', 'remote-caller-worker', or 'remote-caller-workflow' as the single argument"); +} diff --git a/src/NexusMessagingTemporalOperation/README.md b/src/NexusMessagingTemporalOperation/README.md new file mode 100644 index 0000000..aa2b3ea --- /dev/null +++ b/src/NexusMessagingTemporalOperation/README.md @@ -0,0 +1,22 @@ +This sample shows how to expose a long-running Workflow's queries, updates, and signals as Nexus operations. This +version uses the experimental `[TemporalOperation]` attribute to declare a Temporal-backed Nexus operation start handler +directly on a method within a `[NexusServiceHandler]` class, instead of returning an `IOperationHandler` from +`[NexusOperationHandler]`. + +There are two self-contained examples, each in its own directory: + +| | `CallerPattern/` | `OnDemandPattern/` | +|--------------------------------|------------------------------------------------|-----------------------------------------------------------------| +| **Pattern** | Query, update, and signal an existing Workflow | Create and run Workflows on demand, and send signals to them | +| **Who creates the Workflow?** | The handler worker starts it on boot | The caller starts it via a Nexus operation | +| **Who knows the Workflow ID?** | Only the handler | The caller passes a UserId; the handler derives the Workflow ID | +| **Nexus service** | `NexusGreetingService` | `NexusRemoteGreetingService` | + +The `GreetingActivities` and `Language` enum are shared between the two patterns (in the `Common/` +directory). Each pattern has its own `GreetingWorkflow`, Nexus service interface, and handler +implementation. This highlights that the same activity logic can be exposed through Nexus in different +ways depending on whether the caller needs lifecycle control. + +See each directory's README for running instructions: +- [CallerPattern README](CallerPattern/README.md) +- [OnDemandPattern README](OnDemandPattern/README.md) diff --git a/src/NexusMessagingTemporalOperation/TemporalioSamples.NexusMessagingTemporalOperation.csproj b/src/NexusMessagingTemporalOperation/TemporalioSamples.NexusMessagingTemporalOperation.csproj new file mode 100644 index 0000000..573e2c9 --- /dev/null +++ b/src/NexusMessagingTemporalOperation/TemporalioSamples.NexusMessagingTemporalOperation.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/tests/NexusMessaging/CallerPatternTests.cs b/tests/NexusMessaging/CallerPatternTests.cs index 5ec1770..a9938b2 100644 --- a/tests/NexusMessaging/CallerPatternTests.cs +++ b/tests/NexusMessaging/CallerPatternTests.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.Tests.NexusMessaging; using Temporalio.Client; -using Temporalio.Testing; using Temporalio.Worker; using TemporalioSamples.NexusMessaging.CallerPattern.Caller; using TemporalioSamples.NexusMessaging.CallerPattern.Handler; @@ -9,40 +8,23 @@ namespace TemporalioSamples.Tests.NexusMessaging; using Xunit; using Xunit.Abstractions; -public class CallerPatternTests : TestBase +public class CallerPatternTests : WorkflowEnvironmentTestBase { - public CallerPatternTests(ITestOutputHelper output) - : base(output) + public CallerPatternTests(ITestOutputHelper output, WorkflowEnvironment env) + : base(output, env) { } [Fact] public async Task RunAsync_CallerWorkflow_Succeeds() { - // SetLanguage is backed by a Workflow Update, which needs a dev server build that supports - // update callbacks. - await using var env = await WorkflowEnvironment.StartLocalAsync(new() - { - DevServerOptions = new() - { - DownloadVersion = "v1.7.4-standalone-nexus-operations", - ExtraArgs = - [ - "--dynamic-config-value", - "history.enableUpdateCallbacks=true", - "--dynamic-config-value", - "history.enableCHASMSignalBacklinks=true", - ], - }, - }); - var handlerTaskQueue = $"tq-{Guid.NewGuid()}"; - await env.CreateNexusEndpointAsync(NexusEndpoints.GreetingService, handlerTaskQueue); + await Env.TestEnv.CreateNexusEndpointAsync(NexusEndpoints.GreetingService, handlerTaskQueue); var userId = $"user-{Guid.NewGuid()}"; var workflowId = $"GreetingWorkflow_for_{userId}"; // Start entity workflow - await env.Client.StartWorkflowAsync( + await Client.StartWorkflowAsync( (GreetingWorkflow wf) => wf.RunAsync(userId), new(id: workflowId, taskQueue: handlerTaskQueue) { @@ -51,7 +33,7 @@ await env.Client.StartWorkflowAsync( // Run handler worker using var handlerWorker = new TemporalWorker( - env.Client, + Client, new TemporalWorkerOptions(handlerTaskQueue). AddNexusService(new NexusGreetingService()). AddWorkflow(). @@ -60,12 +42,12 @@ await handlerWorker.ExecuteAsync(async () => { // Run caller worker using var callerWorker = new TemporalWorker( - env.Client, + Client, new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). AddWorkflow()); await callerWorker.ExecuteAsync(async () => { - var result = await env.Client.ExecuteWorkflowAsync( + var result = await Client.ExecuteWorkflowAsync( (CallerWorkflow wf) => wf.RunAsync(userId), new(id: $"wf-{Guid.NewGuid()}", taskQueue: callerWorker.Options.TaskQueue!)); diff --git a/tests/NexusMessaging/OnDemandPatternTests.cs b/tests/NexusMessaging/OnDemandPatternTests.cs index 4b7f7b3..61bc21c 100644 --- a/tests/NexusMessaging/OnDemandPatternTests.cs +++ b/tests/NexusMessaging/OnDemandPatternTests.cs @@ -1,7 +1,6 @@ namespace TemporalioSamples.Tests.NexusMessaging; using Temporalio.Client; -using Temporalio.Testing; using Temporalio.Worker; using TemporalioSamples.NexusMessaging.Common; using TemporalioSamples.NexusMessaging.OnDemandPattern.Caller; @@ -9,41 +8,22 @@ namespace TemporalioSamples.Tests.NexusMessaging; using Xunit; using Xunit.Abstractions; -public class OnDemandPatternTests : TestBase +public class OnDemandPatternTests : WorkflowEnvironmentTestBase { - public OnDemandPatternTests(ITestOutputHelper output) - : base(output) + public OnDemandPatternTests(ITestOutputHelper output, WorkflowEnvironment env) + : base(output, env) { } [Fact] public async Task RunAsync_CallerRemoteWorkflow_Succeeds() { - // SetLanguage is backed by a Workflow Update and AttachApprovalContext by - // Signal-with-Start, both of which need a dev server build that supports them. - await using var env = await WorkflowEnvironment.StartLocalAsync(new() - { - DevServerOptions = new() - { - DownloadVersion = "v1.7.4-standalone-nexus-operations", - ExtraArgs = - [ - "--dynamic-config-value", - "history.enableUpdateCallbacks=true", - "--dynamic-config-value", - "history.enableCHASMSignalBacklinks=true", - "--dynamic-config-value", - "history.enableSignalWithStartFromWorkflow=true", - ], - }, - }); - var handlerTaskQueue = $"tq-{Guid.NewGuid()}"; - await env.CreateNexusEndpointAsync(NexusEndpoints.RemoteGreetingService, handlerTaskQueue); + await Env.TestEnv.CreateNexusEndpointAsync(NexusEndpoints.RemoteGreetingService, handlerTaskQueue); // Run handler worker using var handlerWorker = new TemporalWorker( - env.Client, + Client, new TemporalWorkerOptions(handlerTaskQueue). AddNexusService(new NexusRemoteGreetingService()). AddWorkflow(). @@ -52,27 +32,25 @@ await handlerWorker.ExecuteAsync(async () => { // Run caller worker using var callerWorker = new TemporalWorker( - env.Client, + Client, new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). AddWorkflow()); await callerWorker.ExecuteAsync(async () => { - var result = await env.Client.ExecuteWorkflowAsync( + var result = await Client.ExecuteWorkflowAsync( (CallerRemoteWorkflow wf) => wf.RunAsync(), new(id: $"wf-{Guid.NewGuid()}", taskQueue: callerWorker.Options.TaskQueue!)); - Assert.Contains("Attached approval context before the workflow existed: user-one", result[0]); - Assert.Contains("Started remote workflow for user: user-one", result[1]); - Assert.Contains("Started remote workflow for user: user-two", result[2]); - Assert.Contains("Attached approval context to the running workflow: user-two", result[3]); - Assert.Contains("[One] Supported languages:", result[4]); - Assert.Contains($"[One] Set language from {Language.English} to {Language.Spanish}", result[5]); - Assert.Contains("[One] Approved", result[6]); - Assert.Contains("[Two] Current language:", result[7]); - Assert.Contains($"[Two] Set language from {Language.English} to {Language.French}", result[8]); - Assert.Contains("[Two] Approved", result[9]); - Assert.Contains("[One] Result:", result[10]); - Assert.Contains("[Two] Result:", result[11]); + Assert.Contains("Started remote workflow for user: user-one", result[0]); + Assert.Contains("Started remote workflow for user: user-two", result[1]); + Assert.Contains("[One] Supported languages:", result[2]); + Assert.Contains($"[One] Set language from {Language.English} to {Language.Spanish}", result[3]); + Assert.Contains("[One] Approved", result[4]); + Assert.Contains("[Two] Current language:", result[5]); + Assert.Contains($"[Two] Set language from {Language.English} to {Language.French}", result[6]); + Assert.Contains("[Two] Approved", result[7]); + Assert.Contains("[One] Result:", result[8]); + Assert.Contains("[Two] Result:", result[9]); }); }); } diff --git a/tests/NexusMessagingTemporalOperation/CallerPatternTests.cs b/tests/NexusMessagingTemporalOperation/CallerPatternTests.cs new file mode 100644 index 0000000..98a29b3 --- /dev/null +++ b/tests/NexusMessagingTemporalOperation/CallerPatternTests.cs @@ -0,0 +1,80 @@ +namespace TemporalioSamples.Tests.NexusMessagingTemporalOperation; + +using Temporalio.Client; +using Temporalio.Testing; +using Temporalio.Worker; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Caller; +using TemporalioSamples.NexusMessagingTemporalOperation.CallerPattern.Handler; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using Xunit; +using Xunit.Abstractions; + +public class CallerPatternTests : TestBase +{ + public CallerPatternTests(ITestOutputHelper output) + : base(output) + { + } + + [Fact] + public async Task RunAsync_CallerWorkflow_Succeeds() + { + // SetLanguage is backed by a Workflow Update, which needs a dev server build that supports + // update callbacks. + await using var env = await WorkflowEnvironment.StartLocalAsync(new() + { + DevServerOptions = new() + { + DownloadVersion = "v1.7.4-standalone-nexus-operations", + ExtraArgs = + [ + "--dynamic-config-value", + "history.enableUpdateCallbacks=true", + "--dynamic-config-value", + "history.enableCHASMSignalBacklinks=true", + ], + }, + }); + + var handlerTaskQueue = $"tq-{Guid.NewGuid()}"; + await env.CreateNexusEndpointAsync(NexusEndpoints.GreetingService, handlerTaskQueue); + var userId = $"user-{Guid.NewGuid()}"; + var workflowId = $"GreetingWorkflow_for_{userId}"; + + // Start entity workflow + await env.Client.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(userId), + new(id: workflowId, taskQueue: handlerTaskQueue) + { + IdConflictPolicy = Temporalio.Api.Enums.V1.WorkflowIdConflictPolicy.UseExisting, + }); + + // Run handler worker + using var handlerWorker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions(handlerTaskQueue). + AddNexusService(new NexusGreetingService()). + AddWorkflow(). + AddAllActivities(new GreetingActivities())); + await handlerWorker.ExecuteAsync(async () => + { + // Run caller worker + using var callerWorker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddWorkflow()); + await callerWorker.ExecuteAsync(async () => + { + var result = await env.Client.ExecuteWorkflowAsync( + (CallerWorkflow wf) => wf.RunAsync(userId), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: callerWorker.Options.TaskQueue!)); + + Assert.Equal(4, result.Length); + Assert.Contains("Supported languages:", result[0]); + Assert.Contains("Current language:", result[1]); + Assert.Contains($"Set language from {Language.English} to {Language.Chinese}", result[2]); + Assert.Equal("Approved workflow", result[3]); + }); + }); + } +} diff --git a/tests/NexusMessagingTemporalOperation/OnDemandPatternTests.cs b/tests/NexusMessagingTemporalOperation/OnDemandPatternTests.cs new file mode 100644 index 0000000..e467bc6 --- /dev/null +++ b/tests/NexusMessagingTemporalOperation/OnDemandPatternTests.cs @@ -0,0 +1,79 @@ +namespace TemporalioSamples.Tests.NexusMessagingTemporalOperation; + +using Temporalio.Client; +using Temporalio.Testing; +using Temporalio.Worker; +using TemporalioSamples.NexusMessagingTemporalOperation.Common; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Caller; +using TemporalioSamples.NexusMessagingTemporalOperation.OnDemandPattern.Handler; +using Xunit; +using Xunit.Abstractions; + +public class OnDemandPatternTests : TestBase +{ + public OnDemandPatternTests(ITestOutputHelper output) + : base(output) + { + } + + [Fact] + public async Task RunAsync_CallerRemoteWorkflow_Succeeds() + { + // SetLanguage is backed by a Workflow Update and AttachApprovalContext by + // Signal-with-Start, both of which need a dev server build that supports them. + await using var env = await WorkflowEnvironment.StartLocalAsync(new() + { + DevServerOptions = new() + { + DownloadVersion = "v1.7.4-standalone-nexus-operations", + ExtraArgs = + [ + "--dynamic-config-value", + "history.enableUpdateCallbacks=true", + "--dynamic-config-value", + "history.enableCHASMSignalBacklinks=true", + "--dynamic-config-value", + "history.enableSignalWithStartFromWorkflow=true", + ], + }, + }); + + var handlerTaskQueue = $"tq-{Guid.NewGuid()}"; + await env.CreateNexusEndpointAsync(NexusEndpoints.RemoteGreetingService, handlerTaskQueue); + + // Run handler worker + using var handlerWorker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions(handlerTaskQueue). + AddNexusService(new NexusRemoteGreetingService()). + AddWorkflow(). + AddAllActivities(new GreetingActivities())); + await handlerWorker.ExecuteAsync(async () => + { + // Run caller worker + using var callerWorker = new TemporalWorker( + env.Client, + new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddWorkflow()); + await callerWorker.ExecuteAsync(async () => + { + var result = await env.Client.ExecuteWorkflowAsync( + (CallerRemoteWorkflow wf) => wf.RunAsync(), + new(id: $"wf-{Guid.NewGuid()}", taskQueue: callerWorker.Options.TaskQueue!)); + + Assert.Contains("Attached approval context before the workflow existed: user-one", result[0]); + Assert.Contains("Started remote workflow for user: user-one", result[1]); + Assert.Contains("Started remote workflow for user: user-two", result[2]); + Assert.Contains("Attached approval context to the running workflow: user-two", result[3]); + Assert.Contains("[One] Supported languages:", result[4]); + Assert.Contains($"[One] Set language from {Language.English} to {Language.Spanish}", result[5]); + Assert.Contains("[One] Approved", result[6]); + Assert.Contains("[Two] Current language:", result[7]); + Assert.Contains($"[Two] Set language from {Language.English} to {Language.French}", result[8]); + Assert.Contains("[Two] Approved", result[9]); + Assert.Contains("[One] Result:", result[10]); + Assert.Contains("[Two] Result:", result[11]); + }); + }); + } +} diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj index 4ecadfd..bfbaa27 100644 --- a/tests/TemporalioSamples.Tests.csproj +++ b/tests/TemporalioSamples.Tests.csproj @@ -52,6 +52,7 @@ +