diff --git a/Sagaway.Callback.Router/ISagawayActor.cs b/Sagaway.Callback.Router/ISagawayActor.cs index 29c887f..8f9d50e 100644 --- a/Sagaway.Callback.Router/ISagawayActor.cs +++ b/Sagaway.Callback.Router/ISagawayActor.cs @@ -1,5 +1,4 @@ -using System.Linq.Expressions; -using Dapr.Actors; +using Dapr.Actors; namespace Sagaway.Callback.Router; diff --git a/Sagaway.Hosts.DaprActorHost/CallSubSagaOptions.cs b/Sagaway.Hosts.DaprActorHost/CallSubSagaOptions.cs new file mode 100644 index 0000000..cc7ce19 --- /dev/null +++ b/Sagaway.Hosts.DaprActorHost/CallSubSagaOptions.cs @@ -0,0 +1,32 @@ +namespace Sagaway.Hosts.DaprActorHost; + +/// +/// Represents the options for configuring a sub-saga invocation call. +/// +public record CallSubSagaOptions +{ + /// + /// Gets the name of the callback method in the main saga that should be invoked + /// once the sub-saga completes its operation. + /// + public required string CallbackMethodName { get; init; } + + /// + /// Gets any additional metadata specific to the sub-saga invocation, + /// which will be included in the Dapr binding call context. + /// + public string CustomSagawayMetadata { get; init; } = string.Empty; + + /// + /// Gets the custom binding name to use for the Dapr binding operation. + /// If not specified, the default binding name is used. + /// + public string UseBindingName { get; init; } = string.Empty; + + /// + /// Gets a dictionary of additional metadata to include in the binding context. + /// These values will override any defaults if the same keys are present. + /// + // ReSharper disable once CollectionNeverUpdated.Global + public Dictionary? CustomBindingMetadata { get; init; } = []; +} \ No newline at end of file diff --git a/Sagaway.Hosts.DaprActorHost/DaprActorHost.cs b/Sagaway.Hosts.DaprActorHost/DaprActorHost.cs index c7948a4..bcfa406 100644 --- a/Sagaway.Hosts.DaprActorHost/DaprActorHost.cs +++ b/Sagaway.Hosts.DaprActorHost/DaprActorHost.cs @@ -529,6 +529,107 @@ public IDictionary CaptureCallbackContext(string callbackMethodN } + /// + /// Invokes a method on a sub-saga actor using an expression to capture the method call. + /// Extracts the method name and parameters from the provided expression and sends them via Dapr binding. + /// Includes support for custom callback contexts, metadata, and binding options. + /// + /// The type of the interface of the sub-saga actor. + /// An expression that represents the method to invoke on the sub-saga actor. + /// The type name of the actor as registered in the Dapr actor runtime. + /// The unique identifier of the sub-saga actor. + /// + /// A set of additional options for the sub-saga call, including: + /// + /// CallbackMethodName: The method to invoke in the main saga after the sub-saga completes. + /// CustomSagawayMetadata: Metadata to include in the Dapr binding call context. + /// CustomBindingMetadata: A dictionary of additional binding metadata, which will override default values if keys overlap. + /// UseBindingName: Specifies an alternate binding name, defaulting to the callback binding name if not set. + /// + /// + /// A task representing the asynchronous operation of invoking the sub-saga actor. + /// + /// Example usage: + /// + /// await CallSubSagaAsync<ISubSaga>( + /// saga => saga.ExecuteAsync(param1, param2), + /// "SubSagaActor", + /// "SubSaga123", + /// new CallSubSagaOptions( + /// CallbackMethodName: "MainSagaCallback", + /// CustomSagawayMetadata: "SomeMetadata", + /// CustomBindingMetadata: new Dictionary<string, string> + /// { + /// { "key1", "value1" }, + /// { "key2", "value2" } + /// }, + /// UseBindingName: "customBindingName")); + /// + /// + protected async Task CallSubSagaAsync(Expression> methodExpression, string actorTypeName, + string newActorId, CallSubSagaOptions options) + where TSubSaga : ISagawayActor + { + _logger.LogInformation("Starting sub-saga with actor id {NewActorId} using method {CallbackMethodName}", newActorId, options.CallbackMethodName); + + // Use the method name of StartSubSagaWithContextAsync to handle the sub-saga dispatch + var callbackContext = CaptureCallbackContext(options.CallbackMethodName); + + // Extract method name and parameters from the expression + var methodCall = (MethodCallExpression)methodExpression.Body; + var methodName = methodCall.Method.Name; + var arguments = methodCall.Arguments.Select(a => Expression.Lambda(a).Compile().DynamicInvoke()).ToArray(); + + _logger.LogDebug("Extracted method {MethodName} with arguments for sub-saga", methodName); + + // Prepare the SubSagaInvocationContext object + var invocationContext = new SubSagaInvocationContext + { + MethodName = methodName, // The target method to invoke in the sub-saga + CallbackContext = callbackContext, + ParametersJson = JsonSerializer.Serialize(arguments, GetJsonSerializerOptions()) + }; + + var invokeDispatcherParameters = new Dictionary() + { + ["x-sagaway-dapr-callback-method-name"] = nameof(ProcessASubSagaCallAsync), + ["x-sagaway-dapr-actor-id"] = newActorId, + ["x-sagaway-dapr-actor-type"] = actorTypeName, + ["x-sagaway-dapr-message-dispatch-time"] = DateTime.UtcNow.ToString("o"), // ISO 8601 format + ["x-sagaway-dapr-custom-metadata"] = options.CustomSagawayMetadata, + }; + + LogDebugContext("Sub Saga call context", invokeDispatcherParameters); + + if (options.CustomBindingMetadata is not null) + { + foreach (var (key, value) in options.CustomBindingMetadata) + { + invokeDispatcherParameters[key] = value; + } + } + + _logger.LogInformation("Dispatching sub-saga invocation for method {MethodName}", methodName); + + // Create a new DaprClient for the sub-saga invocation, so it will not use the preconfigured HttpClient with the default headers + var daprClientBuilder = new DaprClientBuilder(); + var subSagaDaprClient = daprClientBuilder.Build(); // No custom headers for sub-saga + + var bindingName = string.IsNullOrWhiteSpace(options.UseBindingName) + ? GetCallbackBindingName() + : options.UseBindingName; + + // Dispatch the sub-saga invocation with a single parameter (invocationContext) + await subSagaDaprClient.InvokeBindingAsync( + bindingName, + "create", // Binding operation + invocationContext, + invokeDispatcherParameters + ); + + _logger.LogInformation("Sub-saga invocation dispatched successfully for method {MethodName}", methodName); + } + /// /// Call or start a sub-saga by invoking a method on a sub-saga actor using an expression to capture the method call. /// The method extracts the function name and parameters from the provided expression, and sends them via Dapr binding, @@ -548,7 +649,8 @@ public IDictionary CaptureCallbackContext(string callbackMethodN /// /// This will invoke the "DoSomethingAsync" method on the sub-saga and allow for a callback to the "MainSagaCallback" method on completion. /// - protected async Task CallSubSagaAsync(Expression> methodExpression, string actorTypeName, + [Obsolete("Use overload with options instead")] + protected async Task CallSubSagaAsync(Expression> methodExpression, string actorTypeName, string newActorId, string callbackMethodName = "", string customMetadata = "") where TSubSaga : ISagawayActor { diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Actors/Argument.cs b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Actors/Argument.cs index c173a48..747a669 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Actors/Argument.cs +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.OrchestrationService/Actors/Argument.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Nodes; namespace Sagaway.IntegrationTests.OrchestrationService.Actors; diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/IMainSagaActor.cs b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/IMainSagaActor.cs index 76551b0..d09e942 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/IMainSagaActor.cs +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/IMainSagaActor.cs @@ -1,4 +1,3 @@ -using Dapr.Actors; using Sagaway.Callback.Router; namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService; diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/ISubSagaActor.cs b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/ISubSagaActor.cs index 7e0f736..2be84b9 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/ISubSagaActor.cs +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/ISubSagaActor.cs @@ -1,4 +1,3 @@ -using Dapr.Actors; using Sagaway.Callback.Router; namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService; diff --git a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/MainSagaActor.cs b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/MainSagaActor.cs index 19e65c2..1e1d247 100644 --- a/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/MainSagaActor.cs +++ b/Sagaway.IntegrationTests/Sagaway.IntegrationTests.TestSubSagaCommunicationService/MainSagaActor.cs @@ -1,5 +1,6 @@ using Dapr.Actors.Runtime; using Sagaway.Hosts; +using Sagaway.Hosts.DaprActorHost; namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService; @@ -94,8 +95,12 @@ private async Task OnCallSubSagaAsync() { Logger.LogInformation("Start calling sub-saga..."); + var subSagaOptions = new CallSubSagaOptions() + { + CallbackMethodName = nameof(OnAddResultAsync), + }; await CallSubSagaAsync(subSaga => subSaga.AddAsync(38, 4, TimeSpan.FromSeconds(5)), - "SubSagaActor","Sub" + ActorHost.Id, nameof(OnAddResultAsync)); + "SubSagaActor", "Sub" + ActorHost.Id, subSagaOptions); } private async Task OnAddResultAsync(AddResult addResult) @@ -105,8 +110,12 @@ private async Task OnAddResultAsync(AddResult addResult) if (addResult.Result == 42) { Logger.LogInformation("Telling SubSagaActor to complete..."); + var subSagaOptions = new CallSubSagaOptions() + { + CallbackMethodName = nameof(OnSubSagaEndAsync), + }; await CallSubSagaAsync(subSaga => subSaga.DoneAsync(), - "SubSagaActor", "Sub" + ActorHost.Id, nameof(OnSubSagaEndAsync)); + "SubSagaActor", "Sub" + ActorHost.Id, subSagaOptions); // Wait for the sub-saga to be fully done before marking the operation complete. await ReportCompleteOperationOutcomeAsync(MainSagaActorOperations.CallSubSaga, true);