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
87 changes: 87 additions & 0 deletions docs/guide/durability/marten/sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,93 @@ IoC container.

See the [Saga with Marten sample project](https://github.com/JasperFx/wolverine/tree/main/src/Samples/OrderSagaSample).

## Strong-Typed Identifiers <Badge type="tip" text="5.14" />

Wolverine supports using strong-typed identifiers (e.g., `OrderId`, `InvoiceId`) as the identity of a Marten saga document. This works the same way that strong-typed identifiers are supported for Marten aggregate types. As long as Marten can resolve the identity type, Wolverine will correctly extract the saga identity from your messages.

Here's an example using the [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId) library:

<!-- snippet: sample_strong_typed_id_saga -->
<a id='snippet-sample_strong_typed_id_saga'></a>
```cs
[StronglyTypedId(Template.Guid)]
public readonly partial struct OrderSagaId;

public class OrderSagaWorkflow : Wolverine.Saga
{
public OrderSagaId Id { get; set; }

public string CustomerName { get; set; }
public bool ItemsPicked { get; set; }
public bool PaymentProcessed { get; set; }
public bool Shipped { get; set; }

public static OrderSagaWorkflow Start(StartOrderSaga command)
{
return new OrderSagaWorkflow
{
Id = command.OrderId,
CustomerName = command.CustomerName
};
}

public void Handle(PickOrderItems command)
{
ItemsPicked = true;
checkForCompletion();
}

public void Handle(ProcessOrderPayment command)
{
PaymentProcessed = true;
checkForCompletion();
}

public void Handle(ShipOrder command)
{
Shipped = true;
checkForCompletion();
}

public void Handle(CancelOrderSaga command)
{
MarkCompleted();
}

private void checkForCompletion()
{
if (ItemsPicked && PaymentProcessed && Shipped)
{
MarkCompleted();
}
}
}

// Messages using the strong-typed identifier
public record StartOrderSaga(OrderSagaId OrderId, string CustomerName);
public record PickOrderItems(OrderSagaId OrderSagaWorkflowId);
public record ProcessOrderPayment(OrderSagaId OrderSagaWorkflowId);
public record ShipOrder(OrderSagaId OrderSagaWorkflowId);
public record CancelOrderSaga(OrderSagaId OrderSagaWorkflowId);

// Message using [SagaIdentity] attribute with strong-typed ID
public class CompleteOrderStep
{
[SagaIdentity] public OrderSagaId TheOrderId { get; set; }
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs#L11-L82' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_strong_typed_id_saga' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The standard saga identity resolution conventions still apply:

1. Properties decorated with `[SagaIdentity]`
2. A property named `{SagaTypeName}Id` (e.g., `OrderSagaWorkflowId`)
3. A property named `SagaId`
4. A property named `Id`

Any strong-typed identifier type that Marten can resolve will work, including types generated by StronglyTypedId, Vogen, or hand-crafted value types.

## Optimistic Concurrency <Badge type="tip" text="3.0" />

Marten will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment
Expand Down
189 changes: 189 additions & 0 deletions src/Persistence/MartenTests/Saga/strong_typed_id_saga.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using IntegrationTests;
using JasperFx.Resources;
using Marten;
using Microsoft.Extensions.Hosting;
using Shouldly;
using StronglyTypedIds;
using Wolverine;
using Wolverine.Marten;
using Wolverine.Persistence.Sagas;
using Wolverine.Tracking;

namespace MartenTests.Saga;

#region sample_strong_typed_id_saga

[StronglyTypedId(Template.Guid)]
public readonly partial struct OrderSagaId;

public class OrderSagaWorkflow : Wolverine.Saga
{
public OrderSagaId Id { get; set; }

public string CustomerName { get; set; }
public bool ItemsPicked { get; set; }
public bool PaymentProcessed { get; set; }
public bool Shipped { get; set; }

public static OrderSagaWorkflow Start(StartOrderSaga command)
{
return new OrderSagaWorkflow
{
Id = command.OrderId,
CustomerName = command.CustomerName
};
}

public void Handle(PickOrderItems command)
{
ItemsPicked = true;
checkForCompletion();
}

public void Handle(ProcessOrderPayment command)
{
PaymentProcessed = true;
checkForCompletion();
}

public void Handle(ShipOrder command)
{
Shipped = true;
checkForCompletion();
}

public void Handle(CancelOrderSaga command)
{
MarkCompleted();
}

private void checkForCompletion()
{
if (ItemsPicked && PaymentProcessed && Shipped)
{
MarkCompleted();
}
}
}

// Messages using the strong-typed identifier
public record StartOrderSaga(OrderSagaId OrderId, string CustomerName);
public record PickOrderItems(OrderSagaId OrderSagaWorkflowId);
public record ProcessOrderPayment(OrderSagaId OrderSagaWorkflowId);
public record ShipOrder(OrderSagaId OrderSagaWorkflowId);
public record CancelOrderSaga(OrderSagaId OrderSagaWorkflowId);

// Message using [SagaIdentity] attribute with strong-typed ID
public class CompleteOrderStep
{
[SagaIdentity] public OrderSagaId TheOrderId { get; set; }
}

#endregion

public class strong_typed_id_saga : PostgresqlContext, IAsyncLifetime
{
private IHost _host;

public async Task InitializeAsync()
{
_host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Services.AddMarten(m =>
{
m.DisableNpgsqlLogging = true;
m.Connection(Servers.PostgresConnectionString);
m.DatabaseSchemaName = "strong_typed_sagas";
}).IntegrateWithWolverine();

opts.Services.AddResourceSetupOnStartup();
}).StartAsync();
}

public async Task DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
}

[Fact]
public async Task start_saga_with_strong_typed_id()
{
var orderId = OrderSagaId.New();

await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Han Solo"));

using var session = _host.DocumentStore().QuerySession();
var saga = await session.LoadAsync<OrderSagaWorkflow>(orderId);

saga.ShouldNotBeNull();
saga.Id.ShouldBe(orderId);
saga.CustomerName.ShouldBe("Han Solo");
}

[Fact]
public async Task handle_message_with_strong_typed_id_on_existing_saga()
{
var orderId = OrderSagaId.New();

await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Luke Skywalker"));
await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));

using var session = _host.DocumentStore().QuerySession();
var saga = await session.LoadAsync<OrderSagaWorkflow>(orderId);

saga.ShouldNotBeNull();
saga.ItemsPicked.ShouldBeTrue();
}

[Fact]
public async Task complete_saga_with_strong_typed_id()
{
var orderId = OrderSagaId.New();

await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Leia Organa"));
await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));
await _host.InvokeMessageAndWaitAsync(new ProcessOrderPayment(orderId));
await _host.InvokeMessageAndWaitAsync(new ShipOrder(orderId));

using var session = _host.DocumentStore().QuerySession();
var saga = await session.LoadAsync<OrderSagaWorkflow>(orderId);

// Saga should be deleted when completed
saga.ShouldBeNull();
}

[Fact]
public async Task cancel_saga_with_strong_typed_id()
{
var orderId = OrderSagaId.New();

await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Chewbacca"));
await _host.InvokeMessageAndWaitAsync(new CancelOrderSaga(orderId));

using var session = _host.DocumentStore().QuerySession();
var saga = await session.LoadAsync<OrderSagaWorkflow>(orderId);

// Saga should be deleted after cancel (MarkCompleted)
saga.ShouldBeNull();
}

[Fact]
public async Task multiple_steps_with_strong_typed_id()
{
var orderId = OrderSagaId.New();

await _host.InvokeMessageAndWaitAsync(new StartOrderSaga(orderId, "Yoda"));
await _host.InvokeMessageAndWaitAsync(new PickOrderItems(orderId));
await _host.InvokeMessageAndWaitAsync(new ProcessOrderPayment(orderId));

using var session = _host.DocumentStore().QuerySession();
var saga = await session.LoadAsync<OrderSagaWorkflow>(orderId);

saga.ShouldNotBeNull();
saga.ItemsPicked.ShouldBeTrue();
saga.PaymentProcessed.ShouldBeTrue();
saga.Shipped.ShouldBeFalse();
}
}
30 changes: 25 additions & 5 deletions src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.CodeGeneration.Model;
Expand All @@ -12,6 +12,7 @@ internal class PullSagaIdFromMessageFrame : SyncFrame
private readonly Type _messageType;
private readonly MemberInfo _sagaIdMember;
private readonly Type? _sagaIdType;
private readonly bool _isStrongTypedId;
private Variable? _envelope;
private Variable? _message;

Expand All @@ -21,20 +22,26 @@ public PullSagaIdFromMessageFrame(Type messageType, MemberInfo sagaIdMember)
_sagaIdMember = sagaIdMember;

_sagaIdType = sagaIdMember.GetMemberType();
if (!SagaChain.ValidSagaIdTypes.Contains(_sagaIdType))
if (!SagaChain.IsValidSagaIdType(_sagaIdType!))
{
throw new ArgumentOutOfRangeException(nameof(messageType),
$"SagaId must be one of {SagaChain.ValidSagaIdTypes.Select(x => x.NameInCode()).Join(", ")}, but was {_sagaIdType!.NameInCode()}");
$"SagaId must be one of {SagaChain.ValidSagaIdTypes.Select(x => x.NameInCode()).Join(", ")} or a strong-typed identifier, but was {_sagaIdType!.NameInCode()}");
}

_isStrongTypedId = !SagaChain.ValidSagaIdTypes.Contains(_sagaIdType);

SagaId = new Variable(_sagaIdType!, SagaChain.SagaIdVariableName, this);
}

public Variable SagaId { get; }

public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
{
if (_sagaIdType == typeof(string))
if (_isStrongTypedId)
{
generateStrongTypedIdCode(writer);
}
else if (_sagaIdType == typeof(string))
{
writer.Write(
$"{_sagaIdType.NameInCode()} {SagaChain.SagaIdVariableName} = {_message!.Usage}.{_sagaIdMember.Name} ?? {_envelope!.Usage}.{nameof(Envelope.SagaId)};");
Expand Down Expand Up @@ -66,6 +73,19 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
Next?.GenerateCode(method, writer);
}

private void generateStrongTypedIdCode(ISourceWriter writer)
{
var typeNameInCode = _sagaIdType!.FullNameInCode();

// For strong-typed identifiers, read directly from the message property
writer.Write(
$"var {SagaChain.SagaIdVariableName} = {_message!.Usage}.{_sagaIdMember.Name};");

// Check for default value
writer.Write(
$"if ({SagaChain.SagaIdVariableName}.Equals(default({typeNameInCode}))) throw new {typeof(IndeterminateSagaStateIdException).FullName}({_envelope!.Usage});");
}

public override IEnumerable<Variable> FindVariables(IMethodVariables chain)
{
_message = chain.FindVariable(_messageType);
Expand All @@ -74,4 +94,4 @@ public override IEnumerable<Variable> FindVariables(IMethodVariables chain)
_envelope = chain.FindVariable(typeof(Envelope));
yield return _envelope;
}
}
}
15 changes: 15 additions & 0 deletions src/Wolverine/Persistence/Sagas/SagaChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ public class SagaChain : HandlerChain
public const string SagaIdVariableName = "sagaId";
public static readonly Type[] ValidSagaIdTypes = [typeof(Guid), typeof(int), typeof(long), typeof(string)];

/// <summary>
/// Determines whether a type is a valid saga identity type. Supports the standard
/// primitive types (Guid, int, long, string) as well as strong-typed identifier types
/// (e.g., OrderId wrapping a Guid).
/// </summary>
public static bool IsValidSagaIdType(Type type)
{
if (ValidSagaIdTypes.Contains(type)) return true;

// Accept strong-typed identifiers: value types (structs) or reference types
// that are not one of the known primitives. Marten and other persistence
// providers know how to resolve their underlying storage type.
return type is { IsPrimitive: false, IsEnum: false };
}

public SagaChain(WolverineOptions options, IGrouping<Type, HandlerCall> grouping, HandlerGraph parent) : base(options, grouping, parent)
{
// After base constructor, saga handlers may have been moved to ByEndpoint (Separated mode).
Expand Down
Loading