From be4c73de9d86830d29d53ae57b8bd460cbf2c8ec Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 13 Apr 2026 15:35:07 -0500 Subject: [PATCH] Add soft-deleted saga experiment and documentation Experiment confirms that Marten's LoadAsync does not filter soft-deleted documents, so Wolverine will still load and process completed sagas. Documentation recommends using ISoftDeleted interface and guarding handlers against the Deleted property. Co-Authored-By: Claude Opus 4.6 --- docs/guide/durability/marten/sagas.md | 80 +++++++++ .../Saga/soft_deleted_saga_experiment.cs | 159 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/Persistence/MartenTests/Saga/soft_deleted_saga_experiment.cs diff --git a/docs/guide/durability/marten/sagas.md b/docs/guide/durability/marten/sagas.md index 71b0aa9b9..a4e82f0b7 100644 --- a/docs/guide/durability/marten/sagas.md +++ b/docs/guide/durability/marten/sagas.md @@ -96,6 +96,86 @@ The standard saga identity resolution conventions still apply: Any strong-typed identifier type that Marten can resolve will work, including types generated by StronglyTypedId, Vogen, or hand-crafted value types. +## Soft-Deleted Sagas + +By default, when a saga calls `MarkCompleted()`, Wolverine hard-deletes the saga document from Marten. If you would +prefer to keep a history of completed sagas, you can configure your saga type to use Marten's +[soft-delete](https://martendb.io/documents/deletes.html#configuring-a-document-type-as-soft-deleted) feature. + +::: warning +When using soft-deleted sagas, Wolverine will still **load** soft-deleted saga documents via `IDocumentSession.LoadAsync()`, +which does **not** filter out soft-deleted documents. You must explicitly handle the case where a saga has been +marked as deleted in your handler code. +::: + +The recommended approach is to implement Marten's `ISoftDeleted` interface on your saga class. This gives your +handlers access to the `Deleted` and `DeletedAt` properties so they can check whether the saga has already been +completed: + +```csharp +[SoftDeleted] +public class SubscriptionSaga : Saga, ISoftDeleted +{ + public Guid Id { get; set; } + public string PlanName { get; set; } = string.Empty; + public bool IsActive { get; set; } + + // ISoftDeleted members — Marten populates these automatically + public bool Deleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public static SubscriptionSaga Start(StartSubscription command) + { + return new SubscriptionSaga + { + Id = command.SubscriptionSagaId, + PlanName = command.PlanName, + IsActive = true + }; + } + + public void Handle(CancelSubscription command) + { + IsActive = false; + MarkCompleted(); // Marten will soft-delete instead of hard-delete + } + + // Because soft-deleted sagas are still loaded by Wolverine, + // you must guard against processing messages for completed sagas + public void Handle(UpgradeSubscription command) + { + if (Deleted) + { + // Saga was already completed — do nothing, or log, or throw + return; + } + + PlanName = command.NewPlanName; + } +} + +public record StartSubscription(Guid SubscriptionSagaId, string PlanName); +public record CancelSubscription(Guid SubscriptionSagaId); +public record UpgradeSubscription(Guid SubscriptionSagaId, string NewPlanName); +``` + +Note that with soft-deletes, you retain the full saga document in the database after completion, and you can +query completed sagas using Marten's `MaybeDeleted()` or `IsDeleted()` LINQ filters: + +```csharp +// Query only deleted (completed) sagas +var completedSagas = await session + .Query() + .Where(x => x.IsDeleted()) + .ToListAsync(); + +// Query all sagas including deleted +var allSagas = await session + .Query() + .Where(x => x.MaybeDeleted()) + .ToListAsync(); +``` + ## Optimistic Concurrency Marten will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment diff --git a/src/Persistence/MartenTests/Saga/soft_deleted_saga_experiment.cs b/src/Persistence/MartenTests/Saga/soft_deleted_saga_experiment.cs new file mode 100644 index 000000000..c2dc3915e --- /dev/null +++ b/src/Persistence/MartenTests/Saga/soft_deleted_saga_experiment.cs @@ -0,0 +1,159 @@ +using IntegrationTests; +using Marten; +using Marten.Linq.SoftDeletes; +using Marten.Schema; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.Saga; + +public class soft_deleted_saga_experiment : IAsyncLifetime +{ + private IHost _host = null!; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.IncludeType(); + + opts.Services.AddMarten(m => + { + m.DisableNpgsqlLogging = true; + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "soft_delete_saga"; + + // Configure the saga type to use soft deletes in Marten + m.Schema.For().SoftDeleted(); + }).IntegrateWithWolverine(); + + opts.Policies.AutoApplyTransactions(); + }).StartAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + } + + [Fact] + public async Task saga_is_soft_deleted_when_completed() + { + var id = Guid.NewGuid(); + + // Start the saga + await _host.SendMessageAndWaitAsync(new StartSoftDeleteOrder(id, "Widget")); + + await using var session = _host.DocumentStore().QuerySession(); + + // Verify saga exists + var saga = await session.LoadAsync(id); + saga.ShouldNotBeNull(); + saga.ProductName.ShouldBe("Widget"); + + // Complete the saga (this calls MarkCompleted() which triggers Delete) + await _host.SendMessageAndWaitAsync(new CompleteSoftDeleteOrder(id)); + + // Normal load should NOT find the soft-deleted saga + await using var session2 = _host.DocumentStore().QuerySession(); + var afterComplete = await session2.LoadAsync(id); + afterComplete.ShouldBeNull(); + + // But with MaybeDeleted, we should still be able to find it + var includingDeleted = await session2 + .Query() + .Where(x => x.Id == id) + .Where(x => x.MaybeDeleted()) + .FirstOrDefaultAsync(); + includingDeleted.ShouldNotBeNull(); + includingDeleted.ProductName.ShouldBe("Widget"); + } + + [Fact] + public async Task send_message_to_completed_soft_deleted_saga() + { + var id = Guid.NewGuid(); + + // Start the saga + await _host.SendMessageAndWaitAsync(new StartSoftDeleteOrder(id, "Gadget")); + + // Complete the saga + await _host.SendMessageAndWaitAsync(new CompleteSoftDeleteOrder(id)); + + // Now send another message targeting the completed (soft-deleted) saga + // What happens? Does Wolverine find it or treat it as not found? + await _host.SendMessageAndWaitAsync(new PokeSoftDeleteOrder(id)); + + // Check if the saga was somehow resurrected or if it stayed deleted + await using var session = _host.DocumentStore().QuerySession(); + + // Normal load + var normalLoad = await session.LoadAsync(id); + + // Load including deleted + var withDeleted = await session + .Query() + .Where(x => x.Id == id) + .Where(x => x.MaybeDeleted()) + .FirstOrDefaultAsync(); + + // Report findings + if (normalLoad != null) + { + // Saga was resurrected - the soft-deleted document was found and updated + throw new Exception($"FINDING: Saga was RESURRECTED after sending message to soft-deleted saga. " + + $"WasHandled={withDeleted?.WasHandledAfterCompletion}"); + } + else if (withDeleted?.WasHandledAfterCompletion == true) + { + // Saga was found (soft-deleted), handler ran, but it's still soft-deleted + throw new Exception("FINDING: Handler ran on the soft-deleted saga but it stayed deleted"); + } + else + { + // Saga was NOT found - Wolverine correctly treats soft-deleted as not-found + // This is the expected/desired behavior + normalLoad.ShouldBeNull(); + } + } +} + +// Messages +public record StartSoftDeleteOrder(Guid SoftDeletedOrderSagaId, string ProductName); +public record CompleteSoftDeleteOrder(Guid SoftDeletedOrderSagaId); +public record PokeSoftDeleteOrder(Guid SoftDeletedOrderSagaId); + +// Saga with soft-delete configured via Marten +[SoftDeleted] +[WolverineIgnore] +public class SoftDeletedOrderSaga : Wolverine.Saga +{ + public Guid Id { get; set; } + public string ProductName { get; set; } = string.Empty; + public bool WasHandledAfterCompletion { get; set; } + + public static SoftDeletedOrderSaga Start(StartSoftDeleteOrder message) + { + return new SoftDeletedOrderSaga + { + Id = message.SoftDeletedOrderSagaId, + ProductName = message.ProductName + }; + } + + public void Handle(CompleteSoftDeleteOrder message) + { + MarkCompleted(); + } + + public void Handle(PokeSoftDeleteOrder message) + { + // If this handler is called on a soft-deleted saga, record it + WasHandledAfterCompletion = true; + } +}