From 50fe26bc8d3e1c47b3a37ebb0579c989142828b2 Mon Sep 17 00:00:00 2001 From: Anton Liljeberg Date: Sun, 14 Dec 2025 12:33:28 +0100 Subject: [PATCH 1/2] feat: saga identity from attribute --- .../Sagas/saga_id_member_determination.cs | 6 ++- src/Wolverine/Persistence/Sagas/SagaChain.cs | 43 +++++++++++-------- .../Sagas/SagaIdentityFromAttribute.cs | 10 +++++ 3 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 src/Wolverine/Persistence/Sagas/SagaIdentityFromAttribute.cs diff --git a/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs b/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs index f0494295a..3b6e5df24 100644 --- a/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs +++ b/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs @@ -10,17 +10,19 @@ public class saga_id_member_determination [InlineData(typeof(SomeSagaMessage2), nameof(SomeSagaMessage2.SagaId))] [InlineData(typeof(SomeSagaMessage3), nameof(SomeSagaMessage3.SomeSagaId))] [InlineData(typeof(SomeSagaMessage4), nameof(SomeSagaMessage4.Id))] + [InlineData(typeof(SomeSagaMessage5), nameof(SomeSagaMessage5.SomeId))] public void determine_the_member(Type messageType, string expectedMemberName) { - SagaChain.DetermineSagaIdMember(messageType, typeof(SomeSaga)).Name + SagaChain.DetermineSagaIdMember(messageType, typeof(SomeSaga))?.Name .ShouldBe(expectedMemberName); } } -public record SomeSagaMessage1(Guid Id, [property: SagaIdentity]Guid RandomName); +public record SomeSagaMessage1(Guid Id, [property: SagaIdentity] Guid RandomName); public record SomeSagaMessage2(Guid SagaId, Guid Id); public record SomeSagaMessage3(Guid Id, Guid SomeSagaId, Guid SagaId); public record SomeSagaMessage4(Guid Id); +public record SomeSagaMessage5(Guid SomeId); public class SomeSaga diff --git a/src/Wolverine/Persistence/Sagas/SagaChain.cs b/src/Wolverine/Persistence/Sagas/SagaChain.cs index 4e03fd3e2..b7cb88294 100644 --- a/src/Wolverine/Persistence/Sagas/SagaChain.cs +++ b/src/Wolverine/Persistence/Sagas/SagaChain.cs @@ -1,12 +1,11 @@ -using System.Reflection; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; using JasperFx.Core; using JasperFx.Core.Reflection; +using System.Reflection; using Wolverine.Logging; -using Wolverine.Runtime; using Wolverine.Runtime.Handlers; namespace Wolverine.Persistence.Sagas; @@ -29,8 +28,17 @@ public SagaChain(WolverineOptions options, IGrouping grouping { try { - SagaType = grouping.Where(x => x.HandlerType.CanBeCastTo()).Select(x => x.HandlerType) - .Distinct().Single(); + var saga = grouping.Where(x => x.HandlerType.CanBeCastTo()).DistinctBy(x => x.HandlerType).Single(); + SagaType = saga.HandlerType; + SagaMethodInfo = saga.Method; + + SagaIdMember = DetermineSagaIdMember(MessageType, SagaType, saga.Method); + + // Automatically audit the saga id + if (SagaIdMember != null && AuditedMembers.All(x => x.Member != SagaIdMember)) + { + AuditedMembers.Add(new AuditedMember(SagaIdMember, SagaIdMember.Name, SagaIdMember.Name)); + } } catch (Exception e) { @@ -41,14 +49,6 @@ public SagaChain(WolverineOptions options, IGrouping grouping $"Command types cannot be handled by multiple saga types. Message {MessageType.FullNameInCode()} is handled by sagas {handlerTypes}", e); } - - SagaIdMember = DetermineSagaIdMember(MessageType, SagaType); - - // Automatically audit the saga id - if (SagaIdMember != null && AuditedMembers.All(x => x.Member != SagaIdMember)) - { - AuditedMembers.Add(new AuditedMember(SagaIdMember, SagaIdMember.Name, SagaIdMember.Name)); - } } public override bool TryInferMessageIdentity(out PropertyInfo? property) @@ -69,6 +69,8 @@ protected override void tryAssignStickyEndpoints(HandlerCall handlerCall, Wolver public Type SagaType { get; } + public MethodInfo? SagaMethodInfo { get; set; } + public MemberInfo? SagaIdMember { get; set; } public MethodCall[] ExistingCalls { get; set; } = []; @@ -77,13 +79,18 @@ protected override void tryAssignStickyEndpoints(HandlerCall handlerCall, Wolver public MethodCall[] NotFoundCalls { get; set; } = []; - public static MemberInfo? DetermineSagaIdMember(Type messageType, Type sagaType) + public static MemberInfo? DetermineSagaIdMember(Type messageType, Type sagaType, MethodInfo? sagaHandlerMethod = null) { var expectedSagaIdName = $"{sagaType.Name}Id"; + var specifiedSagaIdMemberName = sagaHandlerMethod?.GetParameters() + .Select(x => x.GetCustomAttribute()) + .FirstOrDefault(a => a != null)?.PropertyName; + var members = messageType.GetFields().OfType().Concat(messageType.GetProperties()).ToArray(); return members.FirstOrDefault(x => x.HasAttribute()) - ?? members.FirstOrDefault(x => x.Name == expectedSagaIdName) + ?? members.FirstOrDefault(x => x.Name == (specifiedSagaIdMemberName ?? expectedSagaIdName)) + ?? members.FirstOrDefault(x => x.Name == expectedSagaIdName.Replace("Saga", "", StringComparison.InvariantCultureIgnoreCase)) ?? members.FirstOrDefault(x => x.Name == SagaIdMemberName) ?? members.FirstOrDefault(x => x.Name.EqualsIgnoreCase("Id")); } @@ -97,14 +104,14 @@ internal override List DetermineFrames(GenerationRules rules, IServiceCon MessageVariable messageVariable) { applyCustomizations(rules, container); - + if (AuditedMembers.Count != 0) { Middleware.Insert(0, new AuditToActivityFrame(this)); } var frameProvider = rules.GetPersistenceProviders(this, container); - + frameProvider.ApplyTransactionSupport(this, container); NotFoundCalls = findByNames(NotFound); @@ -133,7 +140,7 @@ internal override List DetermineFrames(GenerationRules rules, IServiceCon generateCodeForMaybeExisting(container, frameProvider, list); } -// .Concat(handlerReturnValueFrames) + // .Concat(handlerReturnValueFrames) return Middleware.Concat(container.TryCreateConstructorFrames(Handlers)).Concat(list).Concat(Postprocessors).ToList(); } @@ -185,7 +192,7 @@ private void generateForOnlyStartingSaga(IServiceContainer container, IPersisten { return; } - + var ifNotCompleted = buildFrameForConditionalInsert(sagaVariable, frameProvider, container); frames.Add(ifNotCompleted); } diff --git a/src/Wolverine/Persistence/Sagas/SagaIdentityFromAttribute.cs b/src/Wolverine/Persistence/Sagas/SagaIdentityFromAttribute.cs new file mode 100644 index 000000000..8575d5ec4 --- /dev/null +++ b/src/Wolverine/Persistence/Sagas/SagaIdentityFromAttribute.cs @@ -0,0 +1,10 @@ +namespace Wolverine.Persistence.Sagas; + +/// +/// Marks a public property on a message type handler parameter as the saga state identity +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class SagaIdentityFromAttribute(string propertyName) : Attribute +{ + public string PropertyName { get => propertyName; } +} \ No newline at end of file From cc276601c4dd33afe19d2f6eaad5ceb4d511e3e5 Mon Sep 17 00:00:00 2001 From: Anton Liljeberg Date: Sun, 14 Dec 2025 18:00:58 +0100 Subject: [PATCH 2/2] test --- .../Sagas/saga_id_member_determination.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs b/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs index 3b6e5df24..51a5c14d7 100644 --- a/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs +++ b/src/Testing/CoreTests/Persistence/Sagas/saga_id_member_determination.cs @@ -10,22 +10,31 @@ public class saga_id_member_determination [InlineData(typeof(SomeSagaMessage2), nameof(SomeSagaMessage2.SagaId))] [InlineData(typeof(SomeSagaMessage3), nameof(SomeSagaMessage3.SomeSagaId))] [InlineData(typeof(SomeSagaMessage4), nameof(SomeSagaMessage4.Id))] - [InlineData(typeof(SomeSagaMessage5), nameof(SomeSagaMessage5.SomeId))] public void determine_the_member(Type messageType, string expectedMemberName) { SagaChain.DetermineSagaIdMember(messageType, typeof(SomeSaga))?.Name .ShouldBe(expectedMemberName); } + + [Fact] + public void member_is_determined_by_attribute() + { + var method = typeof(SomeSaga).GetMethod(nameof(SomeSaga.Handle)); + + SagaChain.DetermineSagaIdMember(typeof(SomeSagaMessage5), typeof(SomeSaga), method) + !.Name.ShouldBe(nameof(SomeSagaMessage5.Hello)); + } } public record SomeSagaMessage1(Guid Id, [property: SagaIdentity] Guid RandomName); public record SomeSagaMessage2(Guid SagaId, Guid Id); public record SomeSagaMessage3(Guid Id, Guid SomeSagaId, Guid SagaId); public record SomeSagaMessage4(Guid Id); -public record SomeSagaMessage5(Guid SomeId); - +public record SomeSagaMessage5(Guid Hello, Guid Id, Guid SagaId, Guid SomeSagaId); public class SomeSaga { public Guid Id { get; set; } + + public void Handle([SagaIdentityFrom(nameof(SomeSagaMessage5.Hello))] SomeSagaMessage5 message) { } } \ No newline at end of file