From c982b59cb0159fcef4896be9bf45d77ffd24e583 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 29 May 2026 13:27:49 -0500 Subject: [PATCH] F# code generation Phase B: in-memory saga frames (GH-2969) Phase B of the F# code-generation audit (#2969): teach the in-memory stateful saga frame set to emit F#, and render a real saga's start + continue chains to compilable F#. A string-keyed CountingSaga (Start + Handle) drives the fixture. Saga frame F# overrides: - CreateNewSagaFrame: `let saga = T()`. - ResolveSagaFrame / SagaStoreOrDeleteFrame / ConditionalSagaInsertFrame: composites that delegate to their inner frames' F# and render the surrounding `if/else` (IsCompleted -> delete/update; not-completed -> insert). - SetSagaIdFrame / SetSagaIdFromSagaFrame: SetSagaId + a null-guarded SetTag block (no `?.`, pipe SetTag to ignore). - AssertSagaStateExistsFrame: `raise (UnknownSagaException(typeof, sagaId))`. - PullSagaIdFromMessageFrame / PullSagaIdFromEnvelopeFrame: the saga-id resolvers. The string branch uses `if isNull x then envelope.SagaId else x`; the Guid/numeric branch uses F#'s auto-tupled `match Type.TryParse(s) with | true, v -> ... | _ -> ...`, with Unchecked.defaultof as the zero/default sentinel. In-memory Load/Store/Delete are plain MethodCalls (already emit F#); the not-found null-guard comes from JasperFx's IfElseNullGuardFrame (already F#). fsharp-coverage after Phase B: 24 implemented / 2 skipped / 18 remaining of 44 Frame types in Wolverine.dll (up from 15/2/27 at Phase A). Deferred to a Phase B follow-up (not reachable from a minimal in-memory saga, so not exercised here): CreateMissingSagaFrame (reassigns the loaded-saga var; needs the load variable marked mutable), the store-specific LoadSagaOperation / SagaOperation / EnrollAndFetchSagaStorageFrame wrappers, ShouldProceedGuardFrame (ResequencerSaga), the Marten/Polecat data-requirement frames (separate assemblies / per-store fixtures), and the behavioural run-step (TypeLoadMode.Static). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Contracts.cs | 35 +++++++++++ .../Wolverine.Core.FSharpFixture/Generated.fs | 59 +++++++++++++++++++ .../FSharpCodegenSample.cs | 1 + .../Sagas/AssertSagaStateExistsFrame.cs | 7 +++ .../Sagas/ConditionalSagaInsertFrame.cs | 11 ++++ .../Persistence/Sagas/CreateNewSagaFrame.cs | 6 ++ .../Sagas/PullSagaIdFromEnvelopeFrame.cs | 28 +++++++++ .../Sagas/PullSagaIdFromMessageFrame.cs | 45 ++++++++++++++ .../Persistence/Sagas/ResolveSagaFrame.cs | 7 +++ .../Sagas/SagaStoreOrDeleteFrame.cs | 13 ++++ .../Persistence/Sagas/SetSagaIdFrame.cs | 19 ++++++ .../Sagas/SetSagaIdFromSagaFrame.cs | 17 ++++++ 12 files changed, 248 insertions(+) diff --git a/src/Testing/Wolverine.Core.FSharpContracts/Contracts.cs b/src/Testing/Wolverine.Core.FSharpContracts/Contracts.cs index e54b82b1f..40b8c1bd7 100644 --- a/src/Testing/Wolverine.Core.FSharpContracts/Contracts.cs +++ b/src/Testing/Wolverine.Core.FSharpContracts/Contracts.cs @@ -1,3 +1,4 @@ +using Wolverine; using Wolverine.Attributes; using Wolverine.Runtime; @@ -96,3 +97,37 @@ public void Handle(Gate command) { } } + +// ----------------------------------------------------------------------------- +// Phase B (issue GH-2969): a minimal in-memory stateful saga. Start creates the +// saga state; Handle continues it. Exercises the Wolverine.Persistence.Sagas +// frame set (saga-id resolution, create-new, load/assert-exists, store-or-delete) +// against the default in-memory saga store — no external persistence. +// ----------------------------------------------------------------------------- + +/// Starts a . +public record StartCount(string Id); + +/// Continues a . +public record IncrementCount(string Id); + +/// A minimal stateful saga over the in-memory store. +public class CountingSaga : Saga +{ + public string? Id { get; set; } + public int Count { get; set; } + + public static CountingSaga Start(StartCount command) + { + return new CountingSaga { Id = command.Id }; + } + + public void Handle(IncrementCount command) + { + Count++; + if (Count >= 3) + { + MarkCompleted(); + } + } +} diff --git a/src/Testing/Wolverine.Core.FSharpFixture/Generated.fs b/src/Testing/Wolverine.Core.FSharpFixture/Generated.fs index 26e4157d3..cdc79fa08 100644 --- a/src/Testing/Wolverine.Core.FSharpFixture/Generated.fs +++ b/src/Testing/Wolverine.Core.FSharpFixture/Generated.fs @@ -6,6 +6,7 @@ open Microsoft.Extensions.Logging open System open System.Threading open System.Threading.Tasks +open Wolverine.Persistence.Sagas open Wolverine.Runtime open Wolverine.Runtime.Handlers @@ -87,3 +88,61 @@ type GateHandler1696712162() = System.Threading.Tasks.Task.CompletedTask +type IncrementCountHandler540640831(inMemorySagaPersistor: Wolverine.Persistence.Sagas.InMemorySagaPersistor) = + inherit Wolverine.Runtime.Handlers.MessageHandler() + let _inMemorySagaPersistor = inMemorySagaPersistor + + override _.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task = + // The actual message body + let incrementCount = context.Envelope.Message :?> Wolverine.Core.FSharpContracts.IncrementCount + + // Application-specific Open Telemetry auditing + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("Id", incrementCount.Id) |> ignore + let sagaId = if isNull incrementCount.Id then context.Envelope.SagaId else incrementCount.Id + if System.String.IsNullOrEmpty(sagaId) then + raise (Wolverine.Persistence.Sagas.IndeterminateSagaStateIdException(context.Envelope)) + let countingSaga = _inMemorySagaPersistor.Load(sagaId) + if isNull countingSaga then + raise (Wolverine.Persistence.Sagas.UnknownSagaException(typeof, sagaId)) + else + context.SetSagaId(sagaId) + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("wolverine.saga.id", sagaId.ToString()) |> ignore + System.Diagnostics.Activity.Current.SetTag("wolverine.saga.type", "Wolverine.Core.FSharpContracts.CountingSaga") |> ignore + + // The actual message execution + countingSaga.Handle(incrementCount) + + // Delete the saga if completed, otherwise update it + if countingSaga.IsCompleted() then + _inMemorySagaPersistor.Delete(sagaId) + else + _inMemorySagaPersistor.Store(countingSaga) + // No unit of work + System.Threading.Tasks.Task.CompletedTask + +type StartCountHandler1561563330(inMemorySagaPersistor: Wolverine.Persistence.Sagas.InMemorySagaPersistor) = + inherit Wolverine.Runtime.Handlers.MessageHandler() + let _inMemorySagaPersistor = inMemorySagaPersistor + + override _.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task = + // The actual message body + let startCount = context.Envelope.Message :?> Wolverine.Core.FSharpContracts.StartCount + + // Application-specific Open Telemetry auditing + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("Id", startCount.Id) |> ignore + + // The actual message execution + let outgoing1 = Wolverine.Core.FSharpContracts.CountingSaga.Start(startCount) + + context.SetSagaId(startCount.Id) + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("wolverine.saga.id", startCount.Id.ToString()) |> ignore + System.Diagnostics.Activity.Current.SetTag("wolverine.saga.type", "Wolverine.Core.FSharpContracts.CountingSaga") |> ignore + if not (outgoing1.IsCompleted()) then + _inMemorySagaPersistor.Store(outgoing1) + // No unit of work + System.Threading.Tasks.Task.CompletedTask + diff --git a/src/Testing/Wolverine.Core.FSharpTests/FSharpCodegenSample.cs b/src/Testing/Wolverine.Core.FSharpTests/FSharpCodegenSample.cs index 9494e8888..941b8449e 100644 --- a/src/Testing/Wolverine.Core.FSharpTests/FSharpCodegenSample.cs +++ b/src/Testing/Wolverine.Core.FSharpTests/FSharpCodegenSample.cs @@ -34,6 +34,7 @@ public static string GenerateCode() opts.Discovery.IncludeType(); opts.Discovery.IncludeType(); opts.Discovery.IncludeType(); + opts.Discovery.IncludeType(); // Inserts ApplyExecutionDiagnosticTagsFrame at the head of every chain. opts.Tracking.HandlerExecutionDiagnosticsEnabled = true; diff --git a/src/Wolverine/Persistence/Sagas/AssertSagaStateExistsFrame.cs b/src/Wolverine/Persistence/Sagas/AssertSagaStateExistsFrame.cs index 7a725692c..8189a6056 100644 --- a/src/Wolverine/Persistence/Sagas/AssertSagaStateExistsFrame.cs +++ b/src/Wolverine/Persistence/Sagas/AssertSagaStateExistsFrame.cs @@ -24,4 +24,11 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) $"throw new {typeof(UnknownSagaException)}(typeof({_sagaState.VariableType.FullNameInCode()}), {_sagaId.Usage});"); Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write( + $"raise ({typeof(UnknownSagaException).FSharpName()}(typeof<{_sagaState.VariableType.FSharpName()}>, {_sagaId.Usage}))"); + Next?.GenerateFSharpCode(method, writer); + } } \ No newline at end of file diff --git a/src/Wolverine/Persistence/Sagas/ConditionalSagaInsertFrame.cs b/src/Wolverine/Persistence/Sagas/ConditionalSagaInsertFrame.cs index e38626272..ad6423126 100644 --- a/src/Wolverine/Persistence/Sagas/ConditionalSagaInsertFrame.cs +++ b/src/Wolverine/Persistence/Sagas/ConditionalSagaInsertFrame.cs @@ -30,6 +30,17 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"BLOCK:if not ({_saga.Usage}.{nameof(Saga.IsCompleted)}()) then"); + _insert.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + + _commit.GenerateFSharpCode(method, writer); + + Next?.GenerateFSharpCode(method, writer); + } + public override IEnumerable FindVariables(IMethodVariables chain) { yield return _saga; diff --git a/src/Wolverine/Persistence/Sagas/CreateNewSagaFrame.cs b/src/Wolverine/Persistence/Sagas/CreateNewSagaFrame.cs index 6dd62ba85..56e01b3d2 100644 --- a/src/Wolverine/Persistence/Sagas/CreateNewSagaFrame.cs +++ b/src/Wolverine/Persistence/Sagas/CreateNewSagaFrame.cs @@ -28,4 +28,10 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) writer.Write($"var {Saga.Usage} = new {Saga.VariableType.FullNameInCode()}();"); Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"{Saga.FSharpAssignmentUsage} = {Saga.VariableType.FSharpName()}()"); + Next?.GenerateFSharpCode(method, writer); + } } \ No newline at end of file diff --git a/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs b/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs index 79da7b151..ff955650c 100644 --- a/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs +++ b/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs @@ -37,6 +37,34 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + var id = SagaChain.SagaIdVariableName; + var ex = typeof(IndeterminateSagaStateIdException).FSharpName(); + var envSagaId = $"{_envelope!.Usage}.{nameof(Envelope.SagaId)}"; + + if (SagaId.VariableType == typeof(string)) + { + writer.Write($"let {id} = {envSagaId}"); + writer.Write($"BLOCK:if System.String.IsNullOrEmpty({id}) then"); + writer.Write($"raise ({ex}({_envelope.Usage}))"); + writer.FinishBlock(); + } + else + { + // F# auto-tuples the out-parameter TryParse; bind the id from the match, raising on failure. + var clrType = SagaId.VariableType.FullName; // System.Guid / System.Int64 — valid static call target + writer.Write($"BLOCK:let {id} ="); + writer.Write($"BLOCK:match {clrType}.TryParse({envSagaId}) with"); + writer.Write("| true, parsed -> parsed"); + writer.Write($"| _ -> raise ({ex}({_envelope.Usage}))"); + writer.FinishBlock(); + writer.FinishBlock(); + } + + Next?.GenerateFSharpCode(method, writer); + } + public override IEnumerable FindVariables(IMethodVariables chain) { _envelope = chain.FindVariable(typeof(Envelope)); diff --git a/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs b/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs index 0ed61a3db..401d7166a 100644 --- a/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs +++ b/src/Wolverine/Persistence/Sagas/PullSagaIdFromMessageFrame.cs @@ -73,6 +73,51 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + var id = SagaChain.SagaIdVariableName; + var ex = typeof(IndeterminateSagaStateIdException).FSharpName(); + var messageMember = $"{_message!.Usage}.{_sagaIdMember.Name}"; + var envelopeSagaId = $"{_envelope!.Usage}.{nameof(Envelope.SagaId)}"; + + if (_isStrongTypedId) + { + // Read straight from the message and reject the default value. + writer.Write($"let {id} = {messageMember}"); + writer.Write($"BLOCK:if {id}.Equals(Unchecked.defaultof<{_sagaIdType!.FSharpName()}>) then"); + writer.Write($"raise ({ex}({_envelope.Usage}))"); + writer.FinishBlock(); + } + else if (_sagaIdType == typeof(string)) + { + // F# has no `??`; fall back to the envelope's saga id when the message member is null. + writer.Write($"let {id} = if isNull {messageMember} then {envelopeSagaId} else {messageMember}"); + writer.Write($"BLOCK:if System.String.IsNullOrEmpty({id}) then"); + writer.Write($"raise ({ex}({_envelope.Usage}))"); + writer.FinishBlock(); + } + else + { + // Guid / numeric: read from the message, else parse the envelope's saga id. F# auto-tuples + // the out-parameter TryParse into a (bool * value) match. Unchecked.defaultof is both the + // numeric/Guid zero and the "indeterminate" sentinel. + var clrType = _sagaIdType!.FullName; // e.g. System.Guid / System.Int64 — valid for a static call + var fsharpType = _sagaIdType.FSharpName(); + writer.Write($"let mutable {id} = {messageMember}"); + writer.Write($"BLOCK:if {id} = Unchecked.defaultof<{fsharpType}> then"); + writer.Write($"BLOCK:match {clrType}.TryParse({envelopeSagaId}) with"); + writer.Write($"| true, parsed -> {id} <- parsed"); + writer.Write($"| _ -> {id} <- {messageMember}"); + writer.FinishBlock(); + writer.FinishBlock(); + writer.Write($"BLOCK:if {id} = Unchecked.defaultof<{fsharpType}> then"); + writer.Write($"raise ({ex}({_envelope.Usage}))"); + writer.FinishBlock(); + } + + Next?.GenerateFSharpCode(method, writer); + } + private void generateStrongTypedIdCode(ISourceWriter writer) { var typeNameInCode = _sagaIdType!.FullNameInCode(); diff --git a/src/Wolverine/Persistence/Sagas/ResolveSagaFrame.cs b/src/Wolverine/Persistence/Sagas/ResolveSagaFrame.cs index c1ae0915b..b7a0b76c3 100644 --- a/src/Wolverine/Persistence/Sagas/ResolveSagaFrame.cs +++ b/src/Wolverine/Persistence/Sagas/ResolveSagaFrame.cs @@ -34,4 +34,11 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) _loadFrame.GenerateCode(method, writer); Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + _findSagaIdFrame.GenerateFSharpCode(method, writer); + _loadFrame.GenerateFSharpCode(method, writer); + Next?.GenerateFSharpCode(method, writer); + } } \ No newline at end of file diff --git a/src/Wolverine/Persistence/Sagas/SagaStoreOrDeleteFrame.cs b/src/Wolverine/Persistence/Sagas/SagaStoreOrDeleteFrame.cs index 0104a929f..ad0ec8b6c 100644 --- a/src/Wolverine/Persistence/Sagas/SagaStoreOrDeleteFrame.cs +++ b/src/Wolverine/Persistence/Sagas/SagaStoreOrDeleteFrame.cs @@ -37,4 +37,17 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Delete the saga if completed, otherwise update it"); + writer.Write($"BLOCK:if {_saga.Usage}.{nameof(Saga.IsCompleted)}() then"); + _delete.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + writer.Write("BLOCK:else"); + _update.GenerateFSharpCode(method, writer); + writer.FinishBlock(); + + Next?.GenerateFSharpCode(method, writer); + } } \ No newline at end of file diff --git a/src/Wolverine/Persistence/Sagas/SetSagaIdFrame.cs b/src/Wolverine/Persistence/Sagas/SetSagaIdFrame.cs index 8af88410a..80a78f805 100644 --- a/src/Wolverine/Persistence/Sagas/SetSagaIdFrame.cs +++ b/src/Wolverine/Persistence/Sagas/SetSagaIdFrame.cs @@ -3,6 +3,7 @@ using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; using JasperFx.Core.Reflection; +using Wolverine.Configuration; using Wolverine.Runtime; namespace Wolverine.Persistence.Sagas; @@ -41,4 +42,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) } Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + var sagaId = FSharpEmitHelpers.FSharpUsage(_sagaId); + writer.Write($"{_context!.Usage}.{nameof(MessageContext.SetSagaId)}({sagaId})"); + + // F# has no null-conditional `?.`, and SetTag returns the Activity; guard once and pipe to ignore. + var current = $"{typeof(Activity).FSharpName()}.{nameof(Activity.Current)}"; + writer.Write($"BLOCK:if not (isNull {current}) then"); + writer.Write($"{current}.{nameof(Activity.SetTag)}(\"{WolverineTracing.SagaId}\", {sagaId}.ToString()) |> ignore"); + if (_sagaType != null) + { + writer.Write($"{current}.{nameof(Activity.SetTag)}(\"{WolverineTracing.SagaType}\", \"{_sagaType.FullName}\") |> ignore"); + } + + writer.FinishBlock(); + Next?.GenerateFSharpCode(method, writer); + } } diff --git a/src/Wolverine/Persistence/Sagas/SetSagaIdFromSagaFrame.cs b/src/Wolverine/Persistence/Sagas/SetSagaIdFromSagaFrame.cs index b03a1b3af..1b8a1d154 100644 --- a/src/Wolverine/Persistence/Sagas/SetSagaIdFromSagaFrame.cs +++ b/src/Wolverine/Persistence/Sagas/SetSagaIdFromSagaFrame.cs @@ -47,4 +47,21 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) } Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + var member = $"{_message.Usage}.{_sagaIdMember.Name}"; + writer.Write($"{_context!.Usage}.{nameof(MessageContext.SetSagaId)}({member})"); + + var current = $"{typeof(Activity).FSharpName()}.{nameof(Activity.Current)}"; + writer.Write($"BLOCK:if not (isNull {current}) then"); + writer.Write($"{current}.{nameof(Activity.SetTag)}(\"{WolverineTracing.SagaId}\", {member}.ToString()) |> ignore"); + if (_sagaType != null) + { + writer.Write($"{current}.{nameof(Activity.SetTag)}(\"{WolverineTracing.SagaType}\", \"{_sagaType.FullName}\") |> ignore"); + } + + writer.FinishBlock(); + Next?.GenerateFSharpCode(method, writer); + } }