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
80 changes: 80 additions & 0 deletions docs/guide/durability/marten/sagas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubscriptionSaga>()
.Where(x => x.IsDeleted())
.ToListAsync();

// Query all sagas including deleted
var allSagas = await session
.Query<SubscriptionSaga>()
.Where(x => x.MaybeDeleted())
.ToListAsync();
```

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

Marten will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment
Expand Down
159 changes: 159 additions & 0 deletions src/Persistence/MartenTests/Saga/soft_deleted_saga_experiment.cs
Original file line number Diff line number Diff line change
@@ -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<SoftDeletedOrderSaga>();

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<SoftDeletedOrderSaga>().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<SoftDeletedOrderSaga>(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<SoftDeletedOrderSaga>(id);
afterComplete.ShouldBeNull();

// But with MaybeDeleted, we should still be able to find it
var includingDeleted = await session2
.Query<SoftDeletedOrderSaga>()
.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<SoftDeletedOrderSaga>(id);

// Load including deleted
var withDeleted = await session
.Query<SoftDeletedOrderSaga>()
.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;
}
}
Loading