Skip to content
Merged
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
3 changes: 1 addition & 2 deletions Sagaway.Callback.Router/ISagawayActor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Linq.Expressions;
using Dapr.Actors;
using Dapr.Actors;

namespace Sagaway.Callback.Router;

Expand Down
32 changes: 32 additions & 0 deletions Sagaway.Hosts.DaprActorHost/CallSubSagaOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Sagaway.Hosts.DaprActorHost;

/// <summary>
/// Represents the options for configuring a sub-saga invocation call.
/// </summary>
public record CallSubSagaOptions
{
/// <summary>
/// Gets the name of the callback method in the main saga that should be invoked
/// once the sub-saga completes its operation.
/// </summary>
public required string CallbackMethodName { get; init; }

/// <summary>
/// Gets any additional metadata specific to the sub-saga invocation,
/// which will be included in the Dapr binding call context.
/// </summary>
public string CustomSagawayMetadata { get; init; } = string.Empty;

/// <summary>
/// Gets the custom binding name to use for the Dapr binding operation.
/// If not specified, the default binding name is used.
/// </summary>
public string UseBindingName { get; init; } = string.Empty;

/// <summary>
/// Gets a dictionary of additional metadata to include in the binding context.
/// These values will override any defaults if the same keys are present.
/// </summary>
// ReSharper disable once CollectionNeverUpdated.Global
public Dictionary<string, string>? CustomBindingMetadata { get; init; } = [];
}
104 changes: 103 additions & 1 deletion Sagaway.Hosts.DaprActorHost/DaprActorHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,107 @@ public IDictionary<string, string> CaptureCallbackContext(string callbackMethodN
}


/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TSubSaga">The type of the interface of the sub-saga actor.</typeparam>
/// <param name="methodExpression">An expression that represents the method to invoke on the sub-saga actor.</param>
/// <param name="actorTypeName">The type name of the actor as registered in the Dapr actor runtime.</param>
/// <param name="newActorId">The unique identifier of the sub-saga actor.</param>
/// <param name="options">
/// A set of additional options for the sub-saga call, including:
/// <list type="bullet">
/// <item><description>CallbackMethodName: The method to invoke in the main saga after the sub-saga completes.</description></item>
/// <item><description>CustomSagawayMetadata: Metadata to include in the Dapr binding call context.</description></item>
/// <item><description>CustomBindingMetadata: A dictionary of additional binding metadata, which will override default values if keys overlap.</description></item>
/// <item><description>UseBindingName: Specifies an alternate binding name, defaulting to the callback binding name if not set.</description></item>
/// </list>
/// </param>
/// <returns>A task representing the asynchronous operation of invoking the sub-saga actor.</returns>
/// <example>
/// Example usage:
/// <code>
/// await CallSubSagaAsync&lt;ISubSaga&gt;(
/// saga => saga.ExecuteAsync(param1, param2),
/// "SubSagaActor",
/// "SubSaga123",
/// new CallSubSagaOptions(
/// CallbackMethodName: "MainSagaCallback",
/// CustomSagawayMetadata: "SomeMetadata",
/// CustomBindingMetadata: new Dictionary&lt;string, string&gt;
/// {
/// { "key1", "value1" },
/// { "key2", "value2" }
/// },
/// UseBindingName: "customBindingName"));
/// </code>
/// </example>
protected async Task CallSubSagaAsync<TSubSaga>(Expression<Func<TSubSaga, Task>> 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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

methodExpression.Body always have type MethodCallExpression?

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<string, string>()
{
["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(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add try-catch and log for error or throw?

bindingName,
"create", // Binding operation
invocationContext,
invokeDispatcherParameters
);

_logger.LogInformation("Sub-saga invocation dispatched successfully for method {MethodName}", methodName);
}

/// <summary>
/// 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,
Expand All @@ -548,7 +649,8 @@ public IDictionary<string, string> CaptureCallbackContext(string callbackMethodN
/// </code>
/// This will invoke the "DoSomethingAsync" method on the sub-saga and allow for a callback to the "MainSagaCallback" method on completion.
/// </example>
protected async Task CallSubSagaAsync<TSubSaga>(Expression<Func<TSubSaga, Task>> methodExpression, string actorTypeName,
[Obsolete("Use overload with options instead")]
protected async Task CallSubSagaAsync<TSubSaga>(Expression<Func<TSubSaga, Task>> methodExpression, string actorTypeName,
string newActorId, string callbackMethodName = "", string customMetadata = "")
where TSubSaga : ISagawayActor
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Sagaway.IntegrationTests.OrchestrationService.Actors;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Dapr.Actors;
using Sagaway.Callback.Router;

namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Dapr.Actors;
using Sagaway.Callback.Router;

namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Dapr.Actors.Runtime;
using Sagaway.Hosts;
using Sagaway.Hosts.DaprActorHost;

namespace Sagaway.IntegrationTests.TestSubSagaCommunicationService;

Expand Down Expand Up @@ -94,8 +95,12 @@ private async Task OnCallSubSagaAsync()
{
Logger.LogInformation("Start calling sub-saga...");

var subSagaOptions = new CallSubSagaOptions()
{
CallbackMethodName = nameof(OnAddResultAsync),
};
await CallSubSagaAsync<ISubSagaActor>(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)
Expand All @@ -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<ISubSagaActor>(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);
Expand Down