diff --git a/Directory.Packages.props b/Directory.Packages.props index 269957cc3..cce54041e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,13 +21,14 @@ - - + + - + + - + @@ -79,13 +80,13 @@ - - - - - - - + + + + + + + diff --git a/build/build.cs b/build/build.cs index 2488d6750..f2a5bd349 100644 --- a/build/build.cs +++ b/build/build.cs @@ -212,7 +212,7 @@ class Build : NukeBuild .Executes(() => { DotNetTest(c => c - .SetProjectFile(Solution.Persistence.SqliteTests) + .SetProjectFile(Solution.Persistence.Sqlite.SqliteTests) .SetConfiguration(Configuration) .EnableNoBuild() .EnableNoRestore() @@ -329,13 +329,13 @@ class Build : NukeBuild Solution.Transports.Pulsar.Wolverine_Pulsar, Solution.Transports.GCP.Wolverine_Pubsub, Solution.Persistence.Wolverine_RDBMS, - Solution.Persistence.Wolverine_Postgresql, - Solution.Persistence.Wolverine_Marten, - Solution.Persistence.Wolverine_RavenDb, - Solution.Persistence.Wolverine_SqlServer, - Solution.Persistence.Wolverine_MySql, - Solution.Persistence.Wolverine_Oracle, - Solution.Persistence.Wolverine_Sqlite, + Solution.Persistence.PostgreSQL.Wolverine_Postgresql, + Solution.Persistence.Marten.Wolverine_Marten, + Solution.Persistence.RavenDb.Wolverine_RavenDb, + Solution.Persistence.SqlServer.Wolverine_SqlServer, + Solution.Persistence.MySql.Wolverine_MySql, + Solution.Persistence.Oracle.Wolverine_Oracle, + Solution.Persistence.Sqlite.Wolverine_Sqlite, Solution.Persistence.CosmosDb.Wolverine_CosmosDb, Solution.Extensions.Wolverine_FluentValidation, Solution.Extensions.Wolverine_MemoryPack, @@ -478,10 +478,10 @@ private IEnumerable nugetReferences() { yield return new(Solution.Wolverine, ["JasperFx", "JasperFx.RuntimeCompiler", "JasperFx.Events"]); - yield return new(Solution.Persistence.Wolverine_Postgresql, ["Weasel.Postgresql"]); + yield return new(Solution.Persistence.PostgreSQL.Wolverine_Postgresql, ["Weasel.Postgresql"]); yield return new(Solution.Persistence.Wolverine_RDBMS, ["Weasel.Core"]); - yield return new(Solution.Persistence.Wolverine_SqlServer, ["Weasel.SqlServer"]); - yield return new(Solution.Persistence.Wolverine_Marten, ["Marten"]); + yield return new(Solution.Persistence.SqlServer.Wolverine_SqlServer, ["Weasel.SqlServer"]); + yield return new(Solution.Persistence.Marten.Wolverine_Marten, ["Marten"]); } Target Attach => _ => _.Executes(() => diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 96c2dad3e..4dc58abb9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -81,6 +81,7 @@ const config: UserConfig = { {text: 'Vertical Slice Architecture', link: '/tutorials/vertical-slice-architecture'}, {text: 'Modular Monoliths', link: '/tutorials/modular-monolith'}, {text: 'Event Sourcing and CQRS with Marten', link: '/tutorials/cqrs-with-marten'}, + {text: 'Event Sourcing and CQRS with Polecat', link: '/tutorials/cqrs-with-polecat'}, {text: 'Railway Programming with Wolverine', link: '/tutorials/railway-programming'}, {text: 'Interoperability with Non-Wolverine Systems', link: '/tutorials/interop'}, {text: 'Leader Election and Agents', link: '/tutorials/leader-election'}, @@ -232,6 +233,7 @@ const config: UserConfig = { {text: 'Uploading Files', link: '/guide/http/files'}, {text: 'Integration with Sagas', link: '/guide/http/sagas'}, {text: 'Integration with Marten', link: '/guide/http/marten'}, + {text: 'Integration with Polecat', link: '/guide/http/polecat'}, {text: 'Validation', link: '/guide/http/validation'}, {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, @@ -259,6 +261,18 @@ const config: UserConfig = { {text: 'Multi-Tenancy and Marten', link: '/guide/durability/marten/multi-tenancy'}, {text: 'Ancillary Marten Stores', link: '/guide/durability/marten/ancillary-stores'}, ]}, + {text: 'Polecat Integration', link: '/guide/durability/polecat/', collapsed: true, items: [ + {text: 'Transactional Middleware', link: '/guide/durability/polecat/transactional-middleware'}, + {text: 'Transactional Outbox Support', link: '/guide/durability/polecat/outbox'}, + {text: 'Transactional Inbox Support', link: '/guide/durability/polecat/inbox'}, + {text: 'Operation Side Effects', link: '/guide/durability/polecat/operations'}, + {text: 'Aggregate Handlers and Event Sourcing', link: '/guide/durability/polecat/event-sourcing'}, + {text: 'Event Forwarding to Wolverine', link: '/guide/durability/polecat/event-forwarding'}, + {text: 'Event Subscriptions', link: '/guide/durability/polecat/subscriptions'}, + {text: 'Subscription/Projection Distribution', link: '/guide/durability/polecat/distribution'}, + {text: 'Sagas', link: '/guide/durability/polecat/sagas'}, + {text: 'Multi-Tenancy and Polecat', link: '/guide/durability/polecat/multi-tenancy'}, + ]}, {text: 'Sql Server Integration', link: '/guide/durability/sqlserver'}, {text: 'PostgreSQL Integration', link: '/guide/durability/postgresql'}, {text: 'MySQL Integration', link: '/guide/durability/mysql'}, diff --git a/docs/guide/durability/marten/event-sourcing.md b/docs/guide/durability/marten/event-sourcing.md index 5cd8607d1..402576906 100644 --- a/docs/guide/durability/marten/event-sourcing.md +++ b/docs/guide/durability/marten/event-sourcing.md @@ -905,6 +905,202 @@ public class when_transfering_money snippet source | anchor +### Finer-Grained Optimistic Concurrency in Multi-Stream Operations + +When a handler uses multiple `[WriteAggregate]` parameters, Wolverine automatically applies version discovery only +to the **first** aggregate parameter. Secondary aggregate parameters will **not** automatically look for a `Version` +variable, preventing them from accidentally sharing the same version source. + +To opt a secondary stream into optimistic concurrency checking, use the `VersionSource` property to explicitly point +it at a different member: + +```cs +public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, + long FromVersion, long ToVersion); + +public static class TransferMoneyHandler +{ + public static void Handle( + TransferMoney command, + + // First parameter: discovers "Version" automatically, or use + // VersionSource for an explicit member name + [WriteAggregate(nameof(TransferMoney.FromId), + VersionSource = nameof(TransferMoney.FromVersion))] + IEventStream fromAccount, + + // Secondary parameter: only gets version checking if VersionSource is set + [WriteAggregate(nameof(TransferMoney.ToId), + VersionSource = nameof(TransferMoney.ToVersion))] + IEventStream toAccount) + { + if (fromAccount.Aggregate.Amount >= command.Amount) + { + fromAccount.AppendOne(new Withdrawn(command.Amount)); + toAccount.AppendOne(new Debited(command.Amount)); + } + } +} +``` + +You can also use `AlwaysEnforceConsistency` on individual streams within a multi-stream operation to ensure a +concurrency check even when no events are appended to that stream: + +```cs +public static void Handle( + TransferMoney command, + [WriteAggregate(nameof(TransferMoney.FromId), + AlwaysEnforceConsistency = true)] + IEventStream fromAccount, + [WriteAggregate(nameof(TransferMoney.ToId))] + IEventStream toAccount) +{ + // Even if insufficient funds cause no events to be appended + // to fromAccount, Marten will still verify its version hasn't changed + if (fromAccount.Aggregate.Amount >= command.Amount) + { + fromAccount.AppendOne(new Withdrawn(command.Amount)); + toAccount.AppendOne(new Debited(command.Amount)); + } +} +``` + +## Enforcing Consistency Without New Events + +In some cases, your command handler may decide not to emit any new events after evaluating the current aggregate state. +By default, Marten will silently succeed in this case without checking whether the stream has been modified since it was fetched. +This can be problematic for cross-stream operations where you need to guarantee that all referenced aggregates are still in the +state you expect at commit time. + +The `AlwaysEnforceConsistency` option tells Marten to perform an optimistic concurrency check on the stream even if no events +are appended. If another session has written to the stream between your `FetchForWriting()` and `SaveChangesAsync()`, Marten +will throw a `ConcurrencyException`. + +### Using the property on `[AggregateHandler]` + +You can set `AlwaysEnforceConsistency = true` on the `[AggregateHandler]` attribute: + +```cs +[AggregateHandler(AlwaysEnforceConsistency = true)] +public static class MyAggregateHandler +{ + public static void Handle(DoSomething command, IEventStream stream) + { + // Even if no events are appended, Marten will verify + // the stream version hasn't changed since it was fetched + } +} +``` + +### Using `[ConsistentAggregateHandler]` + +For convenience, there is a `[ConsistentAggregateHandler]` attribute that automatically sets `AlwaysEnforceConsistency = true`: + +```cs +[ConsistentAggregateHandler] +public static class MyAggregateHandler +{ + public static void Handle(DoSomething command, IEventStream stream) + { + // AlwaysEnforceConsistency is automatically true + } +} +``` + +### Parameter-level usage with `[ConsistentAggregate]` + +When using parameter-level attributes (the `[Aggregate]` pattern), you can use `[ConsistentAggregate]` instead: + +```cs +public static class MyHandler +{ + public static void Handle(DoSomething command, + [ConsistentAggregate] IEventStream stream) + { + // AlwaysEnforceConsistency is automatically true + } +} +``` + +Or set the property directly on `[Aggregate]`: + +```cs +public static class MyHandler +{ + public static void Handle(DoSomething command, + [Aggregate(AlwaysEnforceConsistency = true)] IEventStream stream) + { + // Explicitly opt into consistency enforcement + } +} +``` + +## Overriding Version Discovery + +By default, Wolverine discovers a version member on your command type by looking for a property or field named `Version` +of type `int` or `long`. In multi-stream operations where each stream needs its own version source, this convention +breaks down because you can't have multiple properties all named "Version". + +The `VersionSource` property lets you explicitly specify which member supplies the expected stream version for +optimistic concurrency checks. + +### On `[AggregateHandler]` + +```cs +public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, long FromVersion); + +[AggregateHandler(VersionSource = nameof(TransferMoney.FromVersion))] +public static class TransferMoneyHandler +{ + public static IEnumerable Handle(TransferMoney command, Account account) + { + // FromVersion will be checked against the "from" account's stream version + yield return new Withdrawn(command.Amount); + } +} +``` + +### On `[WriteAggregate]` / `[Aggregate]` + +This is particularly useful for multi-stream operations where each stream needs independent version tracking: + +```cs +public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, + long FromVersion, long ToVersion); + +public static class TransferMoneyHandler +{ + public static void Handle( + TransferMoney command, + [WriteAggregate(nameof(TransferMoney.FromId), + VersionSource = nameof(TransferMoney.FromVersion))] + IEventStream fromAccount, + [WriteAggregate(nameof(TransferMoney.ToId), + VersionSource = nameof(TransferMoney.ToVersion))] + IEventStream toAccount) + { + if (fromAccount.Aggregate.Amount >= command.Amount) + { + fromAccount.AppendOne(new Withdrawn(command.Amount)); + toAccount.AppendOne(new Debited(command.Amount)); + } + } +} +``` + +For HTTP endpoints, `VersionSource` can resolve from route arguments, query string parameters, or request body members: + +```cs +[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrder command, + [Aggregate(VersionSource = "expectedVersion")] Order order) +{ + return new OrderShipped(); +} +``` + ## Strong Typed Identifiers If you're so inclined, you can use strong typed identifiers from tools like [Vogen](https://github.com/SteveDunn/Vogen) and [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId) @@ -1024,4 +1220,33 @@ public static StrongLetterAggregate Handle( tools do this for you, and value types generated by these tools are -legal route argument variables for Wolverine.HTTP now. +legal route argument variables for Wolverine.HTTP now. + +## Natural Keys + +Marten supports [natural keys](/events/natural-keys) on aggregates, allowing you to look up event streams by a domain-meaningful identifier (like an order number) instead of the internal stream id. Wolverine's aggregate handler workflow fully supports natural keys, letting you route commands to the correct aggregate using a business identifier. + +### Defining the Aggregate with a Natural Key + +First, define your aggregate with a `[NaturalKey]` property and mark the methods that set the key with `[NaturalKeySource]`: + + + + +### Using Natural Keys in Command Handlers + +When your command carries the natural key value instead of a stream id, Wolverine can resolve it automatically. The command property should match the aggregate's natural key type: + + + + +Wolverine uses the natural key type on the command property to call `FetchForWriting()` under the covers, resolving the stream by the natural key in a single database round-trip. + +### Handler Examples + +Here are the handlers that process those commands, using `[WriteAggregate]` and `IEventStream`: + + + + +For more details on how natural keys work at the Marten level, see the [Marten natural keys documentation](https://martendb.io/events/natural-keys). diff --git a/docs/guide/durability/polecat/distribution.md b/docs/guide/durability/polecat/distribution.md new file mode 100644 index 000000000..936b53b43 --- /dev/null +++ b/docs/guide/durability/polecat/distribution.md @@ -0,0 +1,66 @@ +# Projection/Subscription Distribution + +When Wolverine is combined with Polecat and you're using +asynchronous projections or any event subscriptions with Polecat, you can achieve potentially greater +scalability for your system by letting Wolverine distribute the load evenly across a running cluster: + +```cs +opts.Services.AddPolecat(m => + { + m.Connection(connectionString); + + m.Projections.Add(ProjectionLifecycle.Async); + m.Projections.Add(ProjectionLifecycle.Async); + m.Projections.Add(ProjectionLifecycle.Async); + }) + .IntegrateWithWolverine(m => + { + // This makes Wolverine distribute the registered projections + // and event subscriptions evenly across a running application + // cluster + m.UseWolverineManagedEventSubscriptionDistribution = true; + }); +``` + +::: tip +This option replaces the Polecat `AddAsyncDaemon(HotCold)` option and should not be used in combination +with Polecat's own load distribution. +::: + +With this option, Wolverine is going to ensure that every single known asynchronous event projection and every event +subscription is running on exactly one running node within your application cluster. Moreover, Wolverine will purposely stop and +restart projections or subscriptions to spread the running load across your entire cluster of running nodes. + +If a node is taken offline, Wolverine will detect that the node is no longer accessible and try to start the missing +projection/subscription agents on another active node. + +_If you run your application on only a single server, Wolverine will of course run all projections and subscriptions +on just that one server._ + +Some other facts about this integration: + +* Wolverine's agent distribution works with per-tenant database multi-tenancy +* Wolverine does automatic health checking at the running node level +* Wolverine can detect when new nodes come online and redistribute work +* Wolverine is able to support blue/green deployment +* This capability depends on Wolverine's built-in leadership election + +## Uri Structure + +The `Uri` structure for event subscriptions or projections is: + +``` +event-subscriptions://[event store type]/[event store name]/[database server].[database name]/[relative path of the shard] +``` + +For example: `event-subscriptions://polecat/main/localhost.mydb/day/all` + +## Requirements + +This functionality requires Wolverine to both track running nodes and to send messages between running nodes within your +clustered Wolverine service. Wolverine will utilize a "database control queue" for this internal messaging if you are using the `AddPolecat().IntegrateWithWolverine()` integration. + +Other requirements: + +* You cannot disable external transports with `StubAllExternalTransports()` +* `WolverineOptions.Durability.Mode` must be `Balanced` diff --git a/docs/guide/durability/polecat/event-forwarding.md b/docs/guide/durability/polecat/event-forwarding.md new file mode 100644 index 000000000..fddbfec37 --- /dev/null +++ b/docs/guide/durability/polecat/event-forwarding.md @@ -0,0 +1,57 @@ +# Event Forwarding + +::: tip +As of Wolverine 2.2, you can use `IEvent` as the message type in a handler as part of the event forwarding when you +need to utilize Polecat metadata +::: + +::: warning +The Wolverine team recommends against combining this functionality with **also** using events as either a handler response +or cascaded messages as the behavior can easily become confusing. Instead, prefer using custom types for handler responses or HTTP response bodies +instead of the raw event types when using the event forwarding. +::: + +The "Event Forwarding" feature immediately pushes any event captured by Polecat through Wolverine's persistent +outbox where there is a known subscriber (either a local message handler or a known subscriber rule to that event type). +The "Event Forwarding" publishes the new events as soon as the containing transaction is successfully committed. This is +different from the [Event Subscriptions](./subscriptions) in that there is no ordering guarantee, and does require you to +use the Wolverine transactional middleware for Polecat. + +::: tip +The strong recommendation is to use either subscriptions or event forwarding, but not both in the same application. +::: + +To be clear, this will work for: + +* Any event type where the Wolverine application has a message handler for either the event type itself, or `IEvent` where `T` is the event type +* Any event type where there is a known message subscription for that event type or its wrapping `IEvent` to an external transport + +Timing wise, the "event forwarding" happens at the time of committing the transaction for the original message that spawned the +new events, and the resulting event messages go out as cascading messages only after the original transaction succeeds -- just +like any other outbox usage. **There is no guarantee about ordering in this case.** + +To opt into this feature, chain the `AddPolecat().EventForwardingToWolverine()` call as +shown below: + +```cs +builder.Services.AddPolecat(opts => + { + opts.Connection(connectionString); + }) + .IntegrateWithWolverine() + .EventForwardingToWolverine(); +``` + +This does need to be paired with Wolverine configuration to add +subscriptions to event types like so: + +```cs +builder.Host.UseWolverine(opts => +{ + opts.PublishMessage() + .ToLocalQueue("charting") + .UseDurableInbox(); + + opts.Policies.AutoApplyTransactions(); +}); +``` diff --git a/docs/guide/durability/polecat/event-sourcing.md b/docs/guide/durability/polecat/event-sourcing.md new file mode 100644 index 000000000..d9b30d332 --- /dev/null +++ b/docs/guide/durability/polecat/event-sourcing.md @@ -0,0 +1,537 @@ +# Aggregate Handlers and Event Sourcing + +::: tip +Only use the "aggregate handler workflow" if you are wanting to potentially write new events to an existing event stream. If all you +need in a message handler or HTTP endpoint is a read-only copy of an event streamed aggregate from Polecat, use the `[ReadAggregate]` attribute +instead that has a little bit lighter weight runtime within Polecat. +::: + +The Wolverine + Polecat combination is optimized for efficient and productive development using a [CQRS architecture style](https://martinfowler.com/bliki/CQRS.html) with Polecat's event sourcing support. +Specifically, let's dive into the responsibilities of a typical command handler in a CQRS with event sourcing architecture: + +1. Fetch any current state of the system that's necessary to evaluate or validate the incoming event +2. *Decide* what events should be emitted and captured in response to an incoming event +3. Manage concurrent access to system state +4. Safely commit the new events +5. Selectively publish some of the events based on system needs to other parts of your system or even external systems +6. Instrument all of the above + +And then lastly, you're going to want some resiliency and selective retry capabilities for concurrent access violations or just normal infrastructure hiccups. + +Let's jump right into an example order management system. I'm going to model the order workflow with this aggregate model: + +```cs +public class Item +{ + public string Name { get; set; } + public bool Ready { get; set; } +} + +public class Order +{ + public Order(OrderCreated created) + { + foreach (var item in created.Items) Items[item.Name] = item; + } + + // This would be the stream id + public Guid Id { get; set; } + + // This is important, by convention this would + // be the version + public int Version { get; set; } + + public DateTimeOffset? Shipped { get; private set; } + + public Dictionary Items { get; set; } = new(); + + // These methods are used by Polecat to update the aggregate + // from the raw events + public void Apply(IEvent shipped) + { + Shipped = shipped.Timestamp; + } + + public void Apply(ItemReady ready) + { + Items[ready.Name].Ready = true; + } + + public bool IsReadyToShip() + { + return Shipped == null && Items.Values.All(x => x.Ready); + } +} +``` + +At a minimum, we're going to want a command handler for this command message that marks an order item as ready to ship: + +```cs +// OrderId refers to the identity of the Order aggregate +public record MarkItemReady(Guid OrderId, string ItemName, int Version); +``` + +Wolverine supports the [Decider](https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider) +pattern with Polecat using the `[AggregateHandler]` middleware. +Using that middleware, we get this slim code: + +```cs +[AggregateHandler] +public static IEnumerable Handle(MarkItemReady command, Order order) +{ + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + + // Mark that the this item is ready + yield return new ItemReady(command.ItemName); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + // If the order is ready to ship, also emit an OrderReady event + if (order.IsReadyToShip()) + { + yield return new OrderReady(); + } +} +``` + +In the case above, Wolverine is wrapping middleware around our basic command handler to: + +1. Fetch the appropriate `Order` aggregate matching the command +2. Append any new events returned from the handle method to the Polecat event stream for this `Order` +3. Saves any outstanding changes and commits the Polecat unit of work + +::: warning +There are some open imperfections with Wolverine's code generation against the `[WriteAggregate]` and `[ReadAggregate]` +usage. For best results, only use these attributes on a parameter within the main HTTP endpoint method and not in `Validate/Before/Load` methods. +::: + +::: info +The `[Aggregate]` and `[WriteAggregate]` attributes _require the requested stream and aggregate to be found by default_, meaning that the handler or HTTP +endpoint will be stopped if the requested data is not found. You can explicitly mark individual attributes as `Required=false`. +::: + +Alternatively, there is also the newer `[WriteAggregate]` usage: + +```cs +public static IEnumerable Handle( + MarkItemReady command, + [WriteAggregate] Order order) +{ + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + yield return new ItemReady(command.ItemName); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + if (order.IsReadyToShip()) + { + yield return new OrderReady(); + } +} +``` + +The `[WriteAggregate]` attribute also opts into the "aggregate handler workflow", but is placed at the parameter level +instead of the class level. This was added to extend the "aggregate handler workflow" to operations that involve multiple +event streams in one transaction. + +::: tip +`[WriteAggregate]` works equally on message handlers as it does on HTTP endpoints. +::: + +## Validation on Stream Existence + +By default, the "aggregate handler workflow" does no validation on whether or not the identified event stream actually +exists at runtime. You can protect against missing streams: + +```cs +public static class ValidatedMarkItemReadyHandler +{ + public static IEnumerable Handle( + MarkItemReady command, + + // In HTTP this will return a 404 status code and stop + // In message handlers, this will log and discard the message + [WriteAggregate(Required = true)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle2( + MarkItemReady command, + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle3( + MarkItemReady command, + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle4( + MarkItemReady command, + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404, MissingMessage = "Cannot find Order {0}")] Order order) => []; +} +``` + +### Handler Method Signatures + +The aggregate workflow command handler method signature needs to follow these rules: + +* Either explicitly use the `[AggregateHandler]` attribute on the handler method **or use the `AggregateHandler` suffix** on the message handler type +* The first argument should be the command type +* The 2nd argument should be the aggregate -- either the aggregate itself (`Order`) or wrapped in the `IEventStream` type (`IEventStream`): + +```cs +[AggregateHandler] +public static void Handle(MarkItemReady command, IEventStream stream) +{ + var order = stream.Aggregate; + + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + stream.AppendOne(new ItemReady(command.ItemName)); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + if (order.IsReadyToShip()) + { + stream.AppendOne(new OrderReady()); + } +} +``` + +As for the return values from these handler methods, you can use: + +* It's legal to have **no** return values if you are directly using `IEventStream` to append events +* `IEnumerable` or `object[]` to denote events to append to the current event stream +* `IAsyncEnumerable` will also be treated as events to append +* `Events` to denote a list of events +* `OutgoingMessages` to refer to additional command messages to be published that should *not* be captured as events +* `ISideEffect` objects +* Any other type would be considered to be a separate event type + +Here's an alternative using `Events`: + +```cs +[AggregateHandler] +public static async Task<(Events, OutgoingMessages)> HandleAsync(MarkItemReady command, Order order, ISomeService service) +{ + var data = await service.FindDataAsync(); + + var messages = new OutgoingMessages(); + var events = new Events(); + + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + events += new ItemReady(command.ItemName); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + if (order.IsReadyToShip()) + { + events += new OrderReady(); + messages.Add(new ShipOrder(order.Id)); + } + + return (events, messages); +} +``` + +### Determining the Aggregate Identity + +Wolverine is trying to determine a public member on the command type that refers to the identity +of the aggregate type. You've got two options, either use the implied naming convention +where the `OrderId` property is assumed to be the identity of the `Order` aggregate: + +```cs +// OrderId refers to the identity of the Order aggregate +public record MarkItemReady(Guid OrderId, string ItemName, int Version); +``` + +Or decorate a public member on the command class with the `[Identity]` attribute: + +```cs +public class MarkItemReady +{ + [Identity] public Guid Id { get; init; } + public string ItemName { get; init; } +} +``` + +## Forwarding Events + +See [Event Forwarding](./event-forwarding) for more information. + +## Returning the Updated Aggregate + +A common use case has been to respond with the now updated state of the projected +aggregate that has just been updated by appending new events. + +Wolverine.Polecat has a special response type for message handlers or HTTP endpoints we can use as a directive to tell Wolverine +to respond with the latest state of a projected aggregate as part of the command execution: + +```cs +[AggregateHandler] +public static (UpdatedAggregate, Events) Handle(MarkItemReady command, Order order) +{ + var events = new Events(); + + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + events.Add(new ItemReady(command.ItemName)); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + if (order.IsReadyToShip()) + { + events.Add(new OrderReady()); + } + + return (new UpdatedAggregate(), events); +} +``` + +The `UpdatedAggregate` type is just a directive to Wolverine to generate the necessary code to call `FetchLatest` and respond with that: + +```cs +public static Task update_and_get_latest(IMessageBus bus, MarkItemReady command) +{ + return bus.InvokeAsync(command); +} +``` + +You can also use `UpdatedAggregate` as the response body of an HTTP endpoint with Wolverine.HTTP [as shown here](/guide/http/polecat#responding-with-the-updated-aggregate). + +### Passing the Aggregate to Before/Validate/Load Methods + +The "[compound handler](/guide/handlers/#compound-handlers)" feature is fully supported within the aggregate handler workflow. You can pass the aggregate type as an argument to any `Before` / `LoadAsync` / `Validate` method: + +```cs +public record RaiseIfValidated(Guid LetterAggregateId); + +public static class RaiseIfValidatedHandler +{ + public static HandlerContinuation Validate(LetterAggregate aggregate) => + aggregate.ACount == 0 ? HandlerContinuation.Continue : HandlerContinuation.Stop; + + [AggregateHandler] + public static IEnumerable Handle(RaiseIfValidated command, LetterAggregate aggregate) + { + yield return new BEvent(); + } +} +``` + +## Reading the Latest Version of an Aggregate + +If you want to inject the current state of an event sourced aggregate as a parameter into +a message handler method strictly for information and don't need the heavier "aggregate handler workflow," use the `[ReadAggregate]` attribute: + +```cs +public record FindAggregate(Guid Id); + +public static class FindLettersHandler +{ + public static LetterAggregateEnvelope Handle(FindAggregate command, [ReadAggregate] LetterAggregate aggregate) + { + return new LetterAggregateEnvelope(aggregate); + } +} +``` + +If the aggregate doesn't exist, the HTTP request will stop with a 404 status code. +The aggregate/stream identity is found with these rules: + +1. You can specify a particular request body property name or route argument +2. Look for a request body property or route argument named "EntityTypeId" +3. Look for a request body property or route argument named "Id" or "id" + +## Targeting Multiple Streams at Once + +It's possible to use the "aggregate handler workflow" while needing to append events to more than one event stream at a time. + +::: tip +You can use read only views of event streams through `[ReadAggregate]` at will, and that will use +Polecat's `FetchLatest()` API underneath. For appending to multiple streams, use `IEventStream` directly. +::: + +```cs +public record TransferMoney(Guid FromId, Guid ToId, double Amount); + +public static class TransferMoneyHandler +{ + [WolverinePost("/accounts/transfer")] + public static void Handle( + TransferMoney command, + + [WriteAggregate(nameof(TransferMoney.FromId))] IEventStream fromAccount, + + [WriteAggregate(nameof(TransferMoney.ToId))] IEventStream toAccount) + { + if (fromAccount.Aggregate.Amount >= command.Amount) + { + fromAccount.AppendOne(new Withdrawn(command.Amount)); + toAccount.AppendOne(new Debited(command.Amount)); + } + } +} +``` + +### Finer-Grained Optimistic Concurrency in Multi-Stream Operations + +When a handler uses multiple `[WriteAggregate]` parameters, Wolverine automatically applies version discovery only +to the **first** aggregate parameter. To opt a secondary stream into optimistic concurrency checking, use `VersionSource`: + +```cs +public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, + long FromVersion, long ToVersion); + +public static class TransferMoneyHandler +{ + public static void Handle( + TransferMoney command, + + [WriteAggregate(nameof(TransferMoney.FromId), + VersionSource = nameof(TransferMoney.FromVersion))] + IEventStream fromAccount, + + [WriteAggregate(nameof(TransferMoney.ToId), + VersionSource = nameof(TransferMoney.ToVersion))] + IEventStream toAccount) + { + if (fromAccount.Aggregate.Amount >= command.Amount) + { + fromAccount.AppendOne(new Withdrawn(command.Amount)); + toAccount.AppendOne(new Debited(command.Amount)); + } + } +} +``` + +## Enforcing Consistency Without New Events + +The `AlwaysEnforceConsistency` option tells Polecat to perform an optimistic concurrency check on the stream even if no events +are appended: + +```cs +[AggregateHandler(AlwaysEnforceConsistency = true)] +public static class MyAggregateHandler +{ + public static void Handle(DoSomething command, IEventStream stream) + { + // Even if no events are appended, Polecat will verify + // the stream version hasn't changed since it was fetched + } +} +``` + +For convenience, there is a `[ConsistentAggregateHandler]` attribute that automatically sets `AlwaysEnforceConsistency = true`. + +### Parameter-level usage with `[ConsistentAggregate]` + +```cs +public static class MyHandler +{ + public static void Handle(DoSomething command, + [ConsistentAggregate] IEventStream stream) + { + // AlwaysEnforceConsistency is automatically true + } +} +``` + +## Overriding Version Discovery + +By default, Wolverine discovers a version member on your command type by looking for a property or field named `Version` +of type `int` or `long`. The `VersionSource` property lets you explicitly specify which member supplies the expected stream version: + +```cs +public record TransferMoney(Guid FromId, Guid ToId, decimal Amount, long FromVersion); + +[AggregateHandler(VersionSource = nameof(TransferMoney.FromVersion))] +public static class TransferMoneyHandler +{ + public static IEnumerable Handle(TransferMoney command, Account account) + { + yield return new Withdrawn(command.Amount); + } +} +``` + +For HTTP endpoints, `VersionSource` can resolve from route arguments, query string parameters, or request body members: + +```cs +[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrder command, + [Aggregate(VersionSource = "expectedVersion")] Order order) +{ + return new OrderShipped(); +} +``` + +## Strong Typed Identifiers + +You can use strong typed identifiers from tools like [Vogen](https://github.com/SteveDunn/Vogen) and [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId) +within the "Aggregate Handler Workflow." You can also use hand rolled value types that wrap either `Guid` or `string` +as long as they conform to Polecat's rules about value type identifiers. + +```cs +public record IncrementStrongA(LetterId Id); + +public static class StrongLetterHandler +{ + public static AEvent Handle(IncrementStrongA command, [WriteAggregate] StrongLetterAggregate aggregate) + { + return new(); + } +} +``` + +## Natural Keys + +Polecat supports [natural keys](/events/natural-keys) on aggregates, allowing you to look up event streams by a domain-meaningful identifier (like an order number) instead of the internal stream id. Wolverine's aggregate handler workflow fully supports natural keys, letting you route commands to the correct aggregate using a business identifier. + +### Defining the Aggregate with a Natural Key + +First, define your aggregate with a `[NaturalKey]` property and mark the methods that set the key with `[NaturalKeySource]`: + + + + +### Using Natural Keys in Command Handlers + +When your command carries the natural key value instead of a stream id, Wolverine can resolve it automatically. The command property should match the aggregate's natural key type: + + + + +Wolverine uses the natural key type on the command property to call `FetchForWriting()` under the covers, resolving the stream by the natural key in a single database round-trip. + +### Handler Examples + +Here are the handlers that process those commands, using `[WriteAggregate]` and `IEventStream`: + + + + +For more details on how natural keys work at the Polecat level, see the [Polecat natural keys documentation](/events/natural-keys). diff --git a/docs/guide/durability/polecat/inbox.md b/docs/guide/durability/polecat/inbox.md new file mode 100644 index 000000000..e7960c130 --- /dev/null +++ b/docs/guide/durability/polecat/inbox.md @@ -0,0 +1,39 @@ +# Polecat as Inbox + +On the flip side of using Wolverine's "outbox" support for outgoing messages, you can also choose to use the same message persistence for incoming messages such that +incoming messages are first persisted to the application's underlying SQL Server database before being processed. While +you *could* use this with external message brokers like Rabbit MQ, it's more likely this will be valuable for Wolverine's [local queues](/guide/messaging/transports/local). + +Back to the sample Polecat + Wolverine integration: + +```cs +var builder = WebApplication.CreateBuilder(args); +builder.Host.ApplyJasperFxExtensions(); + +builder.Services.AddPolecat(opts => + { + opts.Connection(connectionString); + }) + .IntegrateWithWolverine(); + +builder.Host.UseWolverine(opts => +{ + // I've added persistent inbox + // behavior to the "important" + // local queue + opts.LocalQueue("important") + .UseDurableInbox(); +}); +``` + +By marking this local queue as persistent, any messages sent to this queue +in memory are first persisted to the underlying SQL Server database, and deleted when the message is successfully processed. This allows Wolverine to grant a stronger +delivery guarantee to local messages and even allow messages to be processed if the current application node fails before the message is processed. + +Or finally, it's less code to opt into Wolverine's outbox by delegating to the [command bus](/guide/in-memory-bus) functionality: + +```cs +// Delegate directly to Wolverine commands +app.MapPost("/orders/create2", (CreateOrder command, IMessageBus bus) + => bus.InvokeAsync(command)); +``` diff --git a/docs/guide/durability/polecat/index.md b/docs/guide/durability/polecat/index.md new file mode 100644 index 000000000..48044dd35 --- /dev/null +++ b/docs/guide/durability/polecat/index.md @@ -0,0 +1,57 @@ +# Polecat Integration + +::: info +There is also some HTTP specific integration for Polecat with Wolverine. See [Integration with Polecat](/guide/http/polecat) for more information. +::: + +[Polecat](https://github.com/JasperFx/polecat) and Wolverine are sibling projects under the [JasperFx organization](https://github.com/JasperFx), and as such, have quite a bit of synergy when used together. Adding the `WolverineFx.Polecat` Nuget dependency to your application adds the capability to combine Polecat and Wolverine to: + +* Simplify persistent handler coding with transactional middleware +* Use Polecat and SQL Server as a persistent inbox or outbox with Wolverine messaging +* Support persistent sagas within Wolverine applications +* Effectively use Wolverine and Polecat together for a [Decider](https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider) function workflow with event sourcing +* Selectively publish events captured by Polecat through Wolverine messaging +* Process events captured by Polecat through Wolverine message handlers through either [subscriptions](./subscriptions) or the older [event forwarding](./event-forwarding). + +## Getting Started + +To use the Wolverine integration with Polecat, install the `WolverineFx.Polecat` Nuget into your application. Assuming that you've configured Polecat in your application (and Wolverine itself!), you next need to add the Wolverine integration to Polecat as shown in this sample application bootstrapping: + +```cs +var builder = WebApplication.CreateBuilder(args); +builder.Host.ApplyJasperFxExtensions(); + +builder.Services.AddPolecat(opts => + { + opts.Connection(connectionString); + }) + .IntegrateWithWolverine(); + +builder.Host.UseWolverine(opts => +{ + opts.Policies.AutoApplyTransactions(); +}); +``` + +Using the `IntegrateWithWolverine()` extension method behind your call to `AddPolecat()` will: + +* Register the necessary [inbox and outbox](/guide/durability/) database tables with Polecat's database schema management +* Adds Wolverine's "DurabilityAgent" to your .NET application for the inbox and outbox +* Makes Polecat the active [saga storage](/guide/durability/sagas) for Wolverine +* Adds transactional middleware using Polecat to your Wolverine application + +## Transactional Middleware + +See the [Transactional Middleware](./transactional-middleware) page. + +## Polecat as Outbox + +See the [Polecat as Outbox](./outbox) page. + +## Polecat as Inbox + +See the [Polecat as Inbox](./inbox) page. + +## Saga Storage + +See the [Polecat as Saga Storage](./sagas) page. diff --git a/docs/guide/durability/polecat/multi-tenancy.md b/docs/guide/durability/polecat/multi-tenancy.md new file mode 100644 index 000000000..6e1b43963 --- /dev/null +++ b/docs/guide/durability/polecat/multi-tenancy.md @@ -0,0 +1,99 @@ +# Multi-Tenancy and Polecat + +Wolverine.Polecat fully supports Polecat multi-tenancy features, including both conjoined multi-tenanted documents and full blown +multi-tenancy through separate databases. + +Some important facts to know: + +* Wolverine.Polecat's transactional middleware is able to respect the [tenant id from Wolverine](/guide/handlers/multi-tenancy) in resolving an `IDocumentSession` +* If using a database per tenant(s) strategy with Polecat, Wolverine.Polecat is able to create separate message storage tables in each tenant SQL Server database +* With the strategy above, you'll need a "master" SQL Server database for tenant neutral operations as well +* The durability agent is able to work against both the master and all of the tenant databases for reliable messaging + +## Database per Tenant + +To get started using Wolverine with Polecat's database per tenant strategy, configure Polecat multi-tenancy as you normally +would, but you also need to specify a "master" database connection string for Wolverine: + +```cs +builder.Services.AddPolecat(m => + { + m.MultiTenantedDatabases(tenancy => + { + tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant1;...", "tenant1"); + tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant2;...", "tenant2"); + tenancy.AddSingleTenantDatabase("Server=localhost;Database=tenant3;...", "tenant3"); + }); + }) + .IntegrateWithWolverine(x => x.MainDatabaseConnectionString = connectionString); +``` + +And you'll probably want this as well to make sure the message storage is in all the databases upfront: + +```cs +builder.Services.AddResourceSetupOnStartup(); +``` + +Lastly, this is the Wolverine set up: + +```cs +builder.Host.UseWolverine(opts => +{ + opts.Policies.AutoApplyTransactions(); + opts.Policies.UseDurableLocalQueues(); +}); +``` + +From there, you should be ready to use Polecat + Wolverine with usages like: + +```cs +[WolverineDelete("/todoitems/{tenant}")] +public static void Delete( + DeleteTodo command, IDocumentSession session) +{ + session.Delete(command.Id); +} +``` + +## Conjoined Multi-Tenancy + +For "conjoined" multi-tenancy where there's still just one database: + +```cs +public class TenantedDocument +{ + public Guid Id { get; init; } + public string TenantId { get; set; } + public string Location { get; set; } +} + +public record CreateTenantDocument(Guid Id, string Location); + +public static class CreateTenantDocumentHandler +{ + public static IPolecatOp Handle(CreateTenantDocument command) + { + return PolecatOps.Insert(new TenantedDocument{Id = command.Id, Location = command.Location}); + } +} +``` + +Bootstrapping: + +```cs +_host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddPolecat(connectionString) + .IntegrateWithWolverine(); + + opts.Policies.AutoApplyTransactions(); + }).StartAsync(); +``` + +And then the calls to `InvokeForTenantAsync()` just work: + +```cs +await bus.InvokeForTenantAsync("one", new CreateTenantDocument(id, "Andor")); +await bus.InvokeForTenantAsync("two", new CreateTenantDocument(id, "Tear")); +``` diff --git a/docs/guide/durability/polecat/operations.md b/docs/guide/durability/polecat/operations.md new file mode 100644 index 000000000..3e2d88830 --- /dev/null +++ b/docs/guide/durability/polecat/operations.md @@ -0,0 +1,82 @@ +# Polecat Operation Side Effects + +::: tip +You can certainly write your own `IPolecatOp` implementations and use them as return values in your Wolverine +handlers +::: + +::: info +This integration also includes full support for the [storage action side effects](/guide/handlers/side-effects.html#storage-side-effects) +model when using Polecat with Wolverine. +::: + +The `Wolverine.Polecat` library includes some helpers for Wolverine [side effects](/guide/handlers/side-effects) using +Polecat with the `IPolecatOp` interface: + +```cs +/// +/// Interface for any kind of Polecat related side effect +/// +public interface IPolecatOp : ISideEffect +{ + void Execute(IDocumentSession session); +} +``` + +The built in side effects can all be used from the `PolecatOps` static class like this HTTP endpoint example: + +```cs +[WolverinePost("/invoices/{invoiceId}/pay")] +public static IPolecatOp Pay([Entity] Invoice invoice) +{ + invoice.Paid = true; + return PolecatOps.Store(invoice); +} +``` + +There are existing Polecat ops for storing, inserting, updating, and deleting a document. There's also a specific +helper for starting a new event stream as shown below: + +```cs +public static class TodoListEndpoint +{ + [WolverinePost("/api/todo-lists")] + public static (TodoCreationResponse, IStartStream) CreateTodoList( + CreateTodoListRequest request + ) + { + var listId = CombGuidIdGeneration.NewGuid(); + var result = new TodoListCreated(listId, request.Title); + var startStream = PolecatOps.StartStream(listId, result); + + return (new TodoCreationResponse(listId), startStream); + } +} +``` + +The major advantage of using a Polecat side effect is to help keep your Wolverine handlers or HTTP endpoints +be a pure function that can be easily unit tested through measuring the expected return values. Using `IPolecatOp` also +helps you utilize synchronous methods for your logic, even though at runtime Wolverine itself will be wrapping asynchronous +code about your simpler, synchronous code. + +## Returning Multiple Polecat Side Effects + +Wolverine lets you return zero to many `IPolecatOp` operations as side effects +from a message handler or HTTP endpoint method like so: + +```cs +public static IEnumerable Handle(AppendManyNamedDocuments command) +{ + var number = 1; + foreach (var name in command.Names) + { + yield return PolecatOps.Store(new NamedDocument{Id = name, Number = number++}); + } +} +``` + +Wolverine will pick up on any return type that can be cast to `IEnumerable`, so for example: + +* `IEnumerable` +* `IPolecatOp[]` +* `List` diff --git a/docs/guide/durability/polecat/outbox.md b/docs/guide/durability/polecat/outbox.md new file mode 100644 index 000000000..17c447e21 --- /dev/null +++ b/docs/guide/durability/polecat/outbox.md @@ -0,0 +1,107 @@ +# Polecat as Transactional Outbox + +::: tip +Wolverine's outbox will help you order all outgoing messages until after the database transaction succeeds, but only messages being delivered +to endpoints explicitly configured to be persistent will be stored in the database. +::: + +One of the most important features in all of Wolverine is the [persistent outbox](https://microservices.io/patterns/data/transactional-outbox.html) support and its easy integration into Polecat. + +Here's a common problem when using any kind of messaging strategy. Inside the handling for a single web request, you need to make some immediate writes to +the backing database for the application, then send a corresponding message out through your asynchronous messaging infrastructure. Easy enough, but here's a few ways +that could go wrong: + +* The message is received and processed before the initial database writes are committed +* The database transaction fails, but the message was still sent out +* The database transaction succeeds, but the message infrastructure fails + +This is where the "outbox" pattern comes into play to guarantee +that the outgoing message and database transaction both succeed or fail, and that the message is only sent out after the database transaction has succeeded. + +Imagine a simple example where a Wolverine handler is receiving a `CreateOrder` command that will create a new Polecat `Order` document and also publish +an `OrderCreated` event through Wolverine messaging. Using the outbox, that handler **in explicit, long hand form** is this: + +```cs +public static async Task Handle( + CreateOrder command, + IDocumentSession session, + IMessageBus bus, + CancellationToken cancellation) +{ + var order = new Order + { + Description = command.Description + }; + + // Register the new document with Polecat + session.Store(order); + + // Hold on though, this message isn't actually sent + // until the Polecat session is committed + await bus.SendAsync(new OrderCreated(order.Id)); + + // This makes the database commits, *then* flushed the + // previously registered messages to Wolverine's sending + // agents + await session.SaveChangesAsync(cancellation); +} +``` + +When `IDocumentSession.SaveChangesAsync()` is called, Polecat is persisting the new `Order` document **and** creating database records for the outgoing `OrderCreated` message +in the same transaction. After the database transaction succeeds, the pending messages are automatically sent to Wolverine's sending agents. + +Now, let's play "what if:" + +* What if the messaging broker is down? As long as the messages are persisted, Wolverine will continue trying to send the persisted outgoing messages until the messaging broker is back up. +* What if the application dies after the database transaction but before the messages are sent? Wolverine will still be able to send these persisted messages from either another running application node or after the application is restarted. + +In the section below on transactional middleware we'll see a shorthand way to simplify the code sample above. + +## Outbox with ASP.Net Core + +The Wolverine outbox is also usable from within ASP.Net Core controller or Minimal API handler code. Within an MVC controller: + +```cs +public class CreateOrderController : ControllerBase +{ + [HttpPost("/orders/create2")] + public async Task Create( + [FromBody] CreateOrder command, + [FromServices] IDocumentSession session, + [FromServices] IMessageBus bus) + { + var order = new Order + { + Description = command.Description + }; + + // Register the new document with Polecat + session.Store(order); + + // Don't worry, this message doesn't go out until + // after the Polecat transaction succeeds + await bus.PublishAsync(new OrderCreated(order.Id)); + + // Commit the Polecat transaction + await session.SaveChangesAsync(); + } +} +``` + +From a Minimal API: + +```cs +app.MapPost("/orders/create3", async (CreateOrder command, IDocumentSession session, IMessageBus bus) => +{ + var order = new Order + { + Description = command.Description + }; + + session.Store(order); + + await bus.PublishAsync(new OrderCreated(order.Id)); + + await session.SaveChangesAsync(); +}); +``` diff --git a/docs/guide/durability/polecat/sagas.md b/docs/guide/durability/polecat/sagas.md new file mode 100644 index 000000000..9855ef86a --- /dev/null +++ b/docs/guide/durability/polecat/sagas.md @@ -0,0 +1,13 @@ +# Polecat as Saga Storage + +Polecat is an easy option for [persistent sagas](/guide/durability/sagas) with Wolverine. To opt into using Polecat as your saga storage mechanism in Wolverine, you +just need to add the `IntegrateWithWolverine()` option to your Polecat configuration as shown in the Polecat Integration [Getting Started](/guide/durability/polecat/#getting-started) section. + +When using the Wolverine + Polecat integration, your stateful saga classes should be valid Polecat document types that inherit from Wolverine's `Saga` type, which generally means being a public class with a valid +identity member. Remember that your handler methods in Wolverine can accept "method injected" dependencies from your underlying +IoC container. + +## Optimistic Concurrency + +Polecat will automatically apply numeric revisioning to Wolverine `Saga` storage, and will increment +the `Version` while handling `Saga` commands to use Polecat's native optimistic concurrency protection. diff --git a/docs/guide/durability/polecat/subscriptions.md b/docs/guide/durability/polecat/subscriptions.md new file mode 100644 index 000000000..94308e116 --- /dev/null +++ b/docs/guide/durability/polecat/subscriptions.md @@ -0,0 +1,178 @@ +# Event Subscriptions + +::: tip +The older [Event Forwarding](./event-forwarding) feature is a subset of subscriptions that relies on the Polecat transactional middleware in message handlers or HTTP endpoints, but happens at the time of event +capture whereas the event subscriptions are processed in strict order in a background process through Polecat's async daemon +subsystem **and do not require you to use the Polecat transactional middleware for every operation**. The **strong suggestion from the Wolverine team is to use one or the other approach, but not both in the same system**. +::: + +Wolverine has the ability to extend Polecat's event subscription functionality to carry out message processing by Wolverine on +the events being captured by Polecat in strict order. This functionality works through Polecat's async daemon. + +There are easy recipes for processing events through Wolverine message handlers, and also for just publishing events +through Wolverine's normal message publishing to be processed locally or by being propagated through asynchronous messaging +to other systems. + +::: info +Note that Polecat itself will guarantee that each subscription is only running on one active node at a time. +::: + +## Publish Events as Messages + +::: tip +Unless you really want to publish every single event captured by Polecat, set up event type filters to make the subscription +do less work at runtime. +::: + +The simplest recipe is to just ask Polecat to publish events -- in strict order -- to Wolverine subscribers: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services + .AddPolecat(o => + { + o.Connection(connectionString); + }) + .IntegrateWithWolverine() + .AddAsyncDaemon(DaemonMode.HotCold) + + // This would attempt to publish every non-archived event + // from Polecat to Wolverine subscribers + .PublishEventsToWolverine("Everything") + + // Or with filtering + .PublishEventsToWolverine("Orders", relay => + { + relay.FilterIncomingEventsOnStreamType(typeof(Order)); + relay.Options.SubscribeFromPresent(); + }); + }).StartAsync(); +``` + +First off, what's a "subscriber?" *That* would mean any event that Wolverine recognizes as having: + +* A local message handler in the application for the specific event type +* A local message handler in the application for the specific `IEvent` type +* Any event type where Wolverine can discover subscribers through routing rules + +## Process Events as Messages in Strict Order + +In some cases you may want the events to be executed by Wolverine message handlers in strict order: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services + .AddPolecat(o => + { + o.Connection(connectionString); + o.Projections.Errors.SkipApplyErrors = true; + }) + .IntegrateWithWolverine() + .AddAsyncDaemon(DaemonMode.HotCold) + .ProcessEventsWithWolverineHandlersInStrictOrder("Orders", o => + { + o.IncludeType(); + o.Options.SubscribeFromTime(new DateTimeOffset(new DateTime(2023, 12, 1))); + }); + }).StartAsync(); +``` + +In this recipe, Polecat & Wolverine are working together to call `IMessageBus.InvokeAsync()` on each event in order. + +In the case of exceptions from processing the event with Wolverine: + +1. Any built in "retry" error handling will kick in to retry the event processing inline +2. If the retries are exhausted, and `SkipApplyErrors = true`, Wolverine will persist the event to its SQL Server backed dead letter queue and proceed to the next event +3. If the retries are exhausted, and `SkipApplyErrors = false`, Wolverine will direct Polecat to pause the subscription + +## Custom Subscriptions + +The base type for all Wolverine subscriptions is the `Wolverine.Polecat.Subscriptions.BatchSubscription` class. If you need +to do something completely custom, or just to take action on a batch of events at one time, subclass that type: + +```cs +public record CompanyActivated(string Name); +public record CompanyDeactivated; +public record NewCompany(Guid Id, string Name); + +public class CompanyActivations +{ + public List Additions { get; set; } = new(); + public List Removals { get; set; } = new(); +} + +public class CompanyTransferSubscription : BatchSubscription +{ + public CompanyTransferSubscription() : base("CompanyTransfer") + { + IncludeType(); + IncludeType(); + } + + public override async Task ProcessEventsAsync(EventRange page, ISubscriptionController controller, + IDocumentOperations operations, + IMessageBus bus, CancellationToken cancellationToken) + { + var activations = new CompanyActivations(); + foreach (var e in page.Events) + { + switch (e) + { + case IEvent activated: + activations.Additions.Add(new NewCompany(activated.StreamId, activated.Data.Name)); + break; + case IEvent deactivated: + activations.Removals.Add(deactivated.StreamId); + break; + } + } + + await bus.PublishAsync(activations); + } +} +``` + +And the related code to register this subscription: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseRabbitMq(); + + opts.PublishMessage() + .ToRabbitExchange("activations"); + + opts.Services + .AddPolecat(o => + { + o.Connection(connectionString); + }) + .IntegrateWithWolverine() + .AddAsyncDaemon(DaemonMode.HotCold) + .SubscribeToEvents(new CompanyTransferSubscription()); + }).StartAsync(); +``` + +## Using IoC Services in Subscriptions + +To use IoC services in your subscription, use constructor injection and the `SubscribeToEventsWithServices()` API: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services + .AddPolecat(o => + { + o.Connection(connectionString); + }) + .IntegrateWithWolverine() + .AddAsyncDaemon(DaemonMode.HotCold) + .SubscribeToEventsWithServices(ServiceLifetime.Scoped); + }).StartAsync(); +``` diff --git a/docs/guide/durability/polecat/transactional-middleware.md b/docs/guide/durability/polecat/transactional-middleware.md new file mode 100644 index 000000000..8af813df4 --- /dev/null +++ b/docs/guide/durability/polecat/transactional-middleware.md @@ -0,0 +1,151 @@ +# Transactional Middleware + +::: warning +When using the transactional middleware with Polecat, Wolverine is assuming that there will be a single, +atomic transaction for the entire message handler. Because of the integration with Wolverine's outbox and +the Polecat `IDocumentSession`, it is **very strongly** recommended that you do not call `IDocumentSession.SaveChangesAsync()` +yourself as that may result in unexpected behavior in terms of outgoing messages. +::: + +::: tip +You will need to make the `IServiceCollection.AddPolecat(...).IntegrateWithWolverine()` call to add this middleware to a Wolverine application. +::: + +It is no longer necessary to mark a handler method with `[Transactional]` if you choose to use the `AutoApplyTransactions()` option as shown below: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddPolecat("some connection string") + .IntegrateWithWolverine(); + + // Opt into using "auto" transaction middleware + opts.Policies.AutoApplyTransactions(); + }).StartAsync(); +``` + +With this enabled, Wolverine will automatically use the Polecat +transactional middleware for handlers that have a dependency on `IDocumentSession` (meaning the method takes in `IDocumentSession` or has +some dependency that itself depends on `IDocumentSession`) as long as the `IntegrateWithWolverine()` call was used in application bootstrapping. + +### Opting Out with [NonTransactional] + +When using `AutoApplyTransactions()`, there may be specific handlers or HTTP endpoints where you want to explicitly opt out of +transactional middleware even though they use `IDocumentSession`. You can do this with the `[NonTransactional]` attribute: + +```cs +using Wolverine.Attributes; + +public static class MySpecialHandler +{ + // This handler will NOT have transactional middleware applied + // even when AutoApplyTransactions() is enabled + [NonTransactional] + public static void Handle(MyCommand command, IDocumentSession session) + { + // You're managing the session yourself here + } +} +``` + +The `[NonTransactional]` attribute can be placed on individual handler methods or on the handler class itself to opt out all methods. + +In the previous section we saw an example of incorporating Wolverine's outbox with Polecat transactions. Using Wolverine's transactional middleware support for Polecat, the long hand handler can become this equivalent: + +```cs +// Note that we're able to avoid doing any kind of asynchronous +// code in this handler +[Transactional] +public static OrderCreated Handle(CreateOrder command, IDocumentSession session) +{ + var order = new Order + { + Description = command.Description + }; + + // Register the new document with Polecat + session.Store(order); + + // Utilizing Wolverine's "cascading messages" functionality + // to have this message sent through Wolverine + return new OrderCreated(order.Id); +} +``` + +Or if you need to take more control over how the outgoing `OrderCreated` message is sent, you can use this slightly different alternative: + +```cs +[Transactional] +public static ValueTask Handle( + CreateOrder command, + IDocumentSession session, + IMessageBus bus) +{ + var order = new Order + { + Description = command.Description + }; + + // Register the new document with Polecat + session.Store(order); + + // Utilizing Wolverine's "cascading messages" functionality + return bus.SendAsync( + new OrderCreated(order.Id), + new DeliveryOptions { DeliverWithin = 5.Minutes() }); +} +``` + +In both cases Wolverine's transactional middleware for Polecat is taking care of registering the Polecat session with Wolverine's outbox before you call into the message handler, and +also calling Polecat's `IDocumentSession.SaveChangesAsync()` afterward. + +::: tip +This [Transactional] attribute can appear on either the handler class that will apply to all the actions on that class, or on a specific action method. +::: + +If so desired, you *can* also use a policy to apply the Polecat transaction semantics with a policy: + +```cs +public class CommandsAreTransactional : IHandlerPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + chains + .Where(chain => chain.MessageType.Name.EndsWith("Command")) + .Each(chain => chain.Middleware.Add(new CreateDocumentSessionFrame(chain))); + } +} +``` + +Then add the policy to your application like this: + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + // And actually use the policy + opts.Policies.Add(); + }).StartAsync(); +``` + +## Using IDocumentOperations + +When using the transactional middleware with Polecat, it's best to **not** directly call `IDocumentSession.SaveChangesAsync()` +yourself because that negates the transactional middleware's ability to mark the transaction boundary and can cause +unexpected problems with the outbox. As a way of preventing this problem, you can choose to directly use +Polecat's `IDocumentOperations` as an argument to your handler or endpoint methods, which is effectively `IDocumentSession` minus +the ability to commit the ongoing unit of work with a `SaveChangesAsync` API. + +```cs +public class CreateDocCommand2Handler +{ + [Transactional] + public void Handle( + CreateDocCommand2 message, + IDocumentOperations operations) + { + operations.Store(new FakeDoc { Id = message.Id }); + } +} +``` diff --git a/docs/guide/http/marten.md b/docs/guide/http/marten.md index 7d5f59444..3e434cd55 100644 --- a/docs/guide/http/marten.md +++ b/docs/guide/http/marten.md @@ -386,6 +386,39 @@ Wolverine can't (yet) handle a signature with multiple event streams of the same `UpdatedAggregate`. ::: +## Overriding Version Discovery + +By default, Wolverine looks for a variable named `version` (from route arguments, query string, or request body) for +optimistic concurrency checks. In multi-stream scenarios, you can use the `VersionSource` property on `[Aggregate]` or +`[WriteAggregate]` to specify a different source: + +```cs +// Version from route argument +[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrder command, + [Aggregate(VersionSource = "expectedVersion")] Order order) +{ + return new OrderShipped(); +} + +// Version from request body member +public record ShipOrderWithVersion(Guid OrderId, long ExpectedVersion); + +[WolverinePost("/orders/ship-versioned")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrderWithVersion command, + [Aggregate(VersionSource = nameof(ShipOrderWithVersion.ExpectedVersion))] Order order) +{ + return new OrderShipped(); +} +``` + +See [Overriding Version Discovery](/guide/durability/marten/event-sourcing.html#overriding-version-discovery) in the +aggregate handler workflow documentation for more details and multi-stream examples. + ## Reading the Latest Version of an Aggregate ::: info diff --git a/docs/guide/http/polecat.md b/docs/guide/http/polecat.md new file mode 100644 index 000000000..e17ae09b8 --- /dev/null +++ b/docs/guide/http/polecat.md @@ -0,0 +1,231 @@ +# Integration with Polecat + +The `Wolverine.Http.Polecat` library adds the ability to more deeply integrate Polecat +into Wolverine.HTTP by utilizing information from route arguments. + +To install that library, use: + +```bash +dotnet add package WolverineFx.Http.Polecat +``` + +## Passing Polecat Documents to Endpoint Parameters + +::: tip +The `[Entity]` attribute is supported by both message handlers and HTTP endpoints for loading documents by identity. +::: + +Consider this very common use case: you have an HTTP endpoint that needs to work on a Polecat document that will +be loaded using the value of one of the route arguments as that document's identity. In a long hand way, that could +look like this: + +```cs +[WolverineGet("/invoices/longhand/{id}")] +[ProducesResponseType(404)] +[ProducesResponseType(200, Type = typeof(Invoice))] +public static async Task GetInvoice( + Guid id, + IQuerySession session, + CancellationToken cancellationToken) +{ + var invoice = await session.LoadAsync(id, cancellationToken); + if (invoice == null) return Results.NotFound(); + + return Results.Ok(invoice); +} +``` + +Using the `[Entity]` attribute, this becomes much simpler: + +```cs +[WolverineGet("/invoices/{id}")] +public static Invoice Get([Entity] Invoice invoice) +{ + return invoice; +} +``` + +Notice that the `[Entity]` attribute was able to use the "id" route parameter. By default, Wolverine is looking first +for a route variable named "invoiceId" (the document type name + "Id"), then falling back to looking for "id". You can +explicitly override the matching of route argument like so: + +```cs +[WolverinePost("/invoices/{number}/approve")] +public static IPolecatOp Approve([Entity("number")] Invoice invoice) +{ + invoice.Approved = true; + return PolecatOps.Store(invoice); +} +``` + +In the code above, if the `Invoice` document does not exist, the route will stop and return a status code 404 for Not Found. + +If you want your handler executed even if the document does not exist, set `Required` to `false`. + +## Polecat Aggregate Workflow + +The HTTP endpoints can play inside the full Wolverine + Polecat combination with Wolverine's [specific +support for Event Sourcing and CQRS](/guide/durability/polecat/event-sourcing). + +### Using Route Arguments + +::: tip +The `[Aggregate]` attribute was originally meant for the "aggregate handler workflow" where Wolverine is interacting with +Polecat with the assumption that it will be appending events to streams and getting you ready for versioning assertions. + +If all you need is a read only copy of aggregate data, the `[ReadAggregate]` is a lighter weight option. + +Also, the `[WriteAggregate]` attribute has the exact same behavior as the older `[Aggregate]`, but is available in both +message handlers and HTTP endpoints. +::: + +To opt into the Wolverine + Polecat "aggregate workflow" using data from route arguments for the aggregate id, +use the `[Aggregate]` attribute on endpoint method parameters: + +```cs +[WolverinePost("/orders/{orderId}/ship2"), EmptyResponse] +public static OrderShipped Ship(ShipOrder2 command, [Aggregate] Order order) +{ + if (order.HasShipped) + throw new InvalidOperationException("This has already shipped!"); + + return new OrderShipped(); +} +``` + +Using this version, you no longer have to supply a command in the request body: + +```cs +[WolverinePost("/orders/{orderId}/ship3"), EmptyResponse] +public static OrderShipped Ship3([Aggregate] Order order) +{ + return new OrderShipped(); +} +``` + +A couple notes: + +* The return value handling for events follows the same rules as shown in the event sourcing section +* The endpoints will return a 404 response code if the aggregate does not exist +* The aggregate id can be set explicitly like `[Aggregate("number")]` +* This usage will automatically apply the transactional middleware + +### Using Request Body + +::: tip +This usage only requires Wolverine.Polecat and does not require the Wolverine.Http.Polecat library +::: + +For context, let's say we have these events and aggregate to model an `Order` workflow: + +```cs +public record MarkItemReady(Guid OrderId, string ItemName, int Version); +public record OrderShipped; +public record OrderCreated(Item[] Items); +public record OrderReady; +public record ItemReady(string Name); + +public class Order +{ + public Guid Id { get; set; } + public int Version { get; set; } + public Dictionary Items { get; set; } = new(); + public bool HasShipped { get; set; } + + public void Apply(ItemReady ready) + { + Items[ready.Name].Ready = true; + } + + public bool IsReadyToShip() + { + return Items.Values.All(x => x.Ready); + } +} +``` + +To append a single event to an event stream from an HTTP endpoint: + +```cs +[AggregateHandler] +[WolverinePost("/orders/ship"), EmptyResponse] +public static OrderShipped Ship(ShipOrder command, Order order) +{ + return new OrderShipped(); +} +``` + +Or potentially append multiple events using the `Events` type: + +```cs +[AggregateHandler] +[WolverinePost("/orders/itemready")] +public static (OrderStatus, Events) Post(MarkItemReady command, Order order) +{ + var events = new Events(); + + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + item.Ready = true; + events += new ItemReady(command.ItemName); + } + else + { + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + if (order.IsReadyToShip()) + { + events += new OrderReady(); + } + + return (new OrderStatus(order.Id, order.IsReadyToShip()), events); +} +``` + +### Responding with the Updated Aggregate + +See the documentation from the message handlers on using [UpdatedAggregate](/guide/durability/polecat/event-sourcing#returning-the-updated-aggregate) for more background on this topic. + +To return the updated state of a projected aggregate from Polecat as the HTTP response: + +```cs +[AggregateHandler] +[WolverinePost("/orders/{id}/confirm2")] +public static (UpdatedAggregate, Events) ConfirmDifferent(ConfirmOrder command, Order order) +{ + return ( + new UpdatedAggregate(), + [new OrderConfirmed()] + ); +} +``` + +## Overriding Version Discovery + +By default, Wolverine looks for a variable named `version` for optimistic concurrency checks. Use `VersionSource` to specify a different source: + +```cs +[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrder command, + [Aggregate(VersionSource = "expectedVersion")] Order order) +{ + return new OrderShipped(); +} +``` + +See [Overriding Version Discovery](/guide/durability/polecat/event-sourcing#overriding-version-discovery) for more details. + +## Reading the Latest Version of an Aggregate + +If you want to inject the current state of an event sourced aggregate as a parameter into +an HTTP endpoint method, use the `[ReadAggregate]` attribute: + +```cs +[WolverineGet("/orders/latest/{id}")] +public static Order GetLatest(Guid id, [ReadAggregate] Order order) => order; +``` + +If the aggregate doesn't exist, the HTTP request will stop with a 404 status code. diff --git a/docs/tutorials/cqrs-with-polecat.md b/docs/tutorials/cqrs-with-polecat.md new file mode 100644 index 000000000..4b047d43b --- /dev/null +++ b/docs/tutorials/cqrs-with-polecat.md @@ -0,0 +1,174 @@ +# Event Sourcing and CQRS with Polecat + +::: tip +This guide assumes some familiarity with Event Sourcing nomenclature. +::: + +Let's get the entire Wolverine + [Polecat](https://github.com/JasperFx/polecat) combination assembled and build a system using CQRS with Event Sourcing! + +We're going to start with a simple, headless ASP.Net Core project: + +```bash +dotnet new webapi +``` + +Next, add the `WolverineFx.Http.Polecat` Nuget to get Polecat, Wolverine itself, and the full Wolverine + Polecat integration +including the HTTP integration. Inside the bootstrapping in the `Program` file: + +```csharp +builder.Services.AddPolecat(opts => +{ + var connectionString = builder.Configuration.GetConnectionString("Polecat"); + opts.Connection(connectionString); +}) +.IntegrateWithWolverine(); +``` + +For Wolverine itself: + +```csharp +builder.Host.UseWolverine(opts => +{ + opts.Policies.AutoApplyTransactions(); +}); + +builder.Services.AddWolverineHttp(); +``` + +Next, add support for Wolverine.HTTP endpoints: + +```csharp +app.MapWolverineEndpoints(); +``` + +And lastly, add the extended command line support through Oakton: + +```csharp +return await app.RunOaktonCommands(args); +``` + +## Event Types and a Projected Aggregate + +In Polecat, a "Projection" is the mechanism of taking raw events and "projecting" them +into some kind of view, which could be a .NET object persisted to the database as JSON. + +```cs +public class Incident +{ + public Guid Id { get; set; } + public int Version { get; set; } + public IncidentStatus Status { get; set; } = IncidentStatus.Pending; + public IncidentCategory? Category { get; set; } + public bool HasOutstandingResponseToCustomer { get; set; } = false; + + public Incident() { } + + public void Apply(IncidentLogged _) { } + public void Apply(AgentRespondedToIncident _) => HasOutstandingResponseToCustomer = false; + public void Apply(CustomerRespondedToIncident _) => HasOutstandingResponseToCustomer = true; + public void Apply(IncidentResolved _) => Status = IncidentStatus.Resolved; + public void Apply(IncidentClosed _) => Status = IncidentStatus.Closed; +} +``` + +And some event types: + +```cs +public record IncidentLogged(Guid CustomerId, Contact Contact, string Description, Guid LoggedBy); +public record IncidentCategorised(Guid IncidentId, IncidentCategory Category, Guid CategorisedBy); +public record IncidentClosed(Guid ClosedBy); +``` + +## Start a New Stream + +Here's the HTTP endpoint that will log a new incident by starting a new event stream: + +```cs +public record LogIncident(Guid CustomerId, Contact Contact, string Description, Guid LoggedBy); + +public static class LogIncidentEndpoint +{ + [WolverinePost("/api/incidents")] + public static (CreationResponse, IStartStream) Post(LogIncident command) + { + var (customerId, contact, description, loggedBy) = command; + + var logged = new IncidentLogged(customerId, contact, description, loggedBy); + var start = PolecatOps.StartStream(logged); + + var response = new CreationResponse("/api/incidents/" + start.StreamId, start.StreamId); + + return (response, start); + } +} +``` + +The `IStartStream` interface is a [Polecat specific "side effect"](/guide/durability/polecat/operations) type. `PolecatOps.StartStream()` assigns a new sequential `Guid` value for the new incident. + +One of the biggest advantages of Wolverine is that it allows you to use pure functions for many handlers, and the unit test becomes just: + +```cs +[Fact] +public void unit_test() +{ + var contact = new Contact(ContactChannel.Email); + var command = new LogIncident(Guid.NewGuid(), contact, "It's broken", Guid.NewGuid()); + + var (response, startStream) = LogIncidentEndpoint.Post(command); + + startStream.Events.ShouldBe([ + new IncidentLogged(command.CustomerId, command.Contact, command.Description, command.LoggedBy) + ]); +} +``` + +## Appending Events to an Existing Stream + +Let's write an HTTP endpoint to accept a `CategoriseIncident` command using the [aggregate handler workflow](/guide/durability/polecat/event-sourcing): + +```cs +public record CategoriseIncident(IncidentCategory Category, Guid CategorisedBy, int Version); + +public static class CategoriseIncidentEndpoint +{ + public static ProblemDetails Validate(Incident incident) + { + return incident.Status == IncidentStatus.Closed + ? new ProblemDetails { Detail = "Incident is already closed" } + : WolverineContinue.NoProblems; + } + + [EmptyResponse] + [WolverinePost("/api/incidents/{incidentId:guid}/category")] + public static IncidentCategorised Post( + CategoriseIncident command, + [Aggregate("incidentId")] Incident incident) + { + return new IncidentCategorised(incident.Id, command.Category, command.CategorisedBy); + } +} +``` + +Behind the scenes, Wolverine is using Polecat's `FetchForWriting` API which sets up optimistic concurrency checks. + +## Publishing or Handling Events + +The Wolverine + Polecat combination comes with two main ways to process events: + +[Event Forwarding](/guide/durability/polecat/event-forwarding) is a lightweight way to immediately publish events through Wolverine's messaging infrastructure. Note that event forwarding comes with **no ordering guarantees**. + +[Event Subscriptions](/guide/durability/polecat/subscriptions) utilizes a **strictly ordered mechanism** to read in and process event data from the Polecat event store. Wolverine supports three modes: + +1. Executing each event with a Wolverine message handler in strict order +2. Publishing the events as messages through Wolverine in strict order +3. User defined operations on a batch of events at a time + +## Scaling Polecat Projections + +Wolverine has the ability to distribute the asynchronous projections and subscriptions to Polecat events evenly across +an application cluster for better scalability. See [Projection/Subscription Distribution](/guide/durability/polecat/distribution) for more information. + +## Observability + +Both Polecat and Wolverine have strong support for OpenTelemetry tracing as well as emitting performance +metrics. See [Wolverine's Otel and Metrics](/guide/logging#open-telemetry) support for more information. diff --git a/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs b/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs new file mode 100644 index 000000000..bc82c3f47 --- /dev/null +++ b/src/Http/Wolverine.Http.Marten/ConsistentAggregateAttribute.cs @@ -0,0 +1,20 @@ +using Wolverine.Marten; + +namespace Wolverine.Http.Marten; + +/// +/// Marks a parameter to an HTTP endpoint as being part of the Marten event sourcing +/// "aggregate handler" workflow with set to true, +/// meaning Marten will enforce an optimistic concurrency check on referenced streams even if no events are appended. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class ConsistentAggregateAttribute : Wolverine.Marten.ConsistentAggregateAttribute +{ + public ConsistentAggregateAttribute() + { + } + + public ConsistentAggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) + { + } +} diff --git a/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs b/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs new file mode 100644 index 000000000..ba632cd57 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Marten/using_version_source_override.cs @@ -0,0 +1,84 @@ +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using WolverineWebApi.Marten; + +namespace Wolverine.Http.Tests.Marten; + +public class using_version_source_override(AppFixture fixture) : IntegrationContext(fixture) +{ + private async Task CreateOrder() + { + var result = await Scenario(x => + { + x.Post.Json(new StartOrder(["Socks", "Shoes", "Shirt"])).ToUrl("/orders/create"); + }); + + var status = result.ReadAsJson(); + return status.OrderId; + } + + [Fact] + public async Task happy_path_with_version_source_from_route_argument() + { + var orderId = await CreateOrder(); + + // version 1 matches the stream after creation + await Scenario(x => + { + x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 1)) + .ToUrl($"/orders/{orderId}/ship-with-expected-version/1"); + x.StatusCodeShouldBe(204); + }); + + await using var session = Store.LightweightSession(); + var order = await session.Events.AggregateStreamAsync(orderId); + order.ShouldNotBeNull(); + order.Shipped.HasValue.ShouldBeTrue(); + } + + [Fact] + public async Task wrong_version_from_route_argument_returns_500() + { + var orderId = await CreateOrder(); + + // version 99 does not match - should fail + await Scenario(x => + { + x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 99)) + .ToUrl($"/orders/{orderId}/ship-with-expected-version/99"); + x.StatusCodeShouldBe(500); + }); + } + + [Fact] + public async Task happy_path_with_version_source_from_request_body() + { + var orderId = await CreateOrder(); + + await Scenario(x => + { + x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 1)) + .ToUrl("/orders/ship-with-body-version"); + x.StatusCodeShouldBe(204); + }); + + await using var session = Store.LightweightSession(); + var order = await session.Events.AggregateStreamAsync(orderId); + order.ShouldNotBeNull(); + order.Shipped.HasValue.ShouldBeTrue(); + } + + [Fact] + public async Task wrong_version_from_request_body_returns_500() + { + var orderId = await CreateOrder(); + + await Scenario(x => + { + x.Post.Json(new ShipOrderWithExpectedVersion(orderId, 99)) + .ToUrl("/orders/ship-with-body-version"); + x.StatusCodeShouldBe(500); + }); + } +} diff --git a/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs b/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs new file mode 100644 index 000000000..f47e8bca9 --- /dev/null +++ b/src/Http/WolverineWebApi/Marten/VersionSourceEndpoints.cs @@ -0,0 +1,31 @@ +using Wolverine.Http; +using Wolverine.Http.Marten; +using Wolverine.Marten; +using WolverineWebApi.Marten; + +namespace WolverineWebApi.Marten; + +public record ShipOrderWithExpectedVersion(Guid OrderId, long ExpectedVersion); + +public static class VersionSourceEndpoints +{ + // Route argument version with custom name + [WolverinePost("/orders/{orderId}/ship-with-expected-version/{expectedVersion}")] + [EmptyResponse] + public static OrderShipped ShipWithRouteVersion( + ShipOrderWithExpectedVersion command, + [Aggregate(VersionSource = "expectedVersion")] Order order) + { + return new OrderShipped(); + } + + // Request body member version with custom name + [WolverinePost("/orders/ship-with-body-version")] + [EmptyResponse] + public static OrderShipped ShipWithBodyVersion( + ShipOrderWithExpectedVersion command, + [Aggregate(VersionSource = nameof(ShipOrderWithExpectedVersion.ExpectedVersion))] Order order) + { + return new OrderShipped(); + } +} diff --git a/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs b/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs index 8995721c8..c461c9f96 100644 --- a/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs +++ b/src/Persistence/EfCoreTests/Bug_252_codegen_issue.cs @@ -2,15 +2,12 @@ using JasperFx; using JasperFx.Core; using JasperFx.Core.Reflection; -using Microsoft.Data.SqlClient; +using JasperFx.Resources; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SharedPersistenceModels.Items; using Shouldly; -using Weasel.Core; -using Weasel.SqlServer; -using Weasel.SqlServer.Tables; using Wolverine; using Wolverine.Attributes; using Wolverine.EntityFrameworkCore; @@ -51,21 +48,12 @@ public async Task use_the_saga_type_to_determine_the_correct_DbContext_type() opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); opt.UseEntityFrameworkCoreTransactions(); + opt.UseEntityFrameworkCoreWolverineManagedMigrations(); + opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState); opt.Policies.UseDurableLocalQueues(); opt.Policies.AutoApplyTransactions(); }).StartAsync(); - var table = new Table("OrderSagas"); - table.AddColumn("id").AsPrimaryKey(); - table.AddColumn("version"); - await using var conn = new SqlConnection(Servers.SqlServerConnectionString); - await conn.OpenAsync(); - - var migration = await SchemaMigration.DetermineAsync(conn, table); - await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All); - - await conn.CloseAsync(); - await host.InvokeMessageAndWaitAsync(new OrderCreated(Guid.NewGuid())); } @@ -79,7 +67,7 @@ public async Task bug_256_message_bus_should_be_in_outbox_transaction() { o.UseSqlServer(Servers.SqlServerConnectionString); }); - + opt.Services.AddDbContextWithWolverineIntegration(o => { o.UseSqlServer(Servers.SqlServerConnectionString); @@ -89,20 +77,12 @@ public async Task bug_256_message_bus_should_be_in_outbox_transaction() opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); opt.UseEntityFrameworkCoreTransactions(); + opt.UseEntityFrameworkCoreWolverineManagedMigrations(); + opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState); opt.Policies.UseDurableLocalQueues(); opt.Policies.AutoApplyTransactions(); }).StartAsync(); - var table = new Table("OrderSagas"); - table.AddColumn("id").AsPrimaryKey(); - await using var conn = new SqlConnection(Servers.SqlServerConnectionString); - await conn.OpenAsync(); - - var migration = await SchemaMigration.DetermineAsync(conn, table); - await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All); - - await conn.CloseAsync(); - var chain = host.Services.GetRequiredService().HandlerFor().As().Chain; var lines = chain.SourceCode.ReadLines(); diff --git a/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs b/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs index b985ddce2..3a239de4e 100644 --- a/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs +++ b/src/Persistence/EfCoreTests/Optimistic_concurrency_with_ef_core.cs @@ -2,15 +2,12 @@ using JasperFx; using JasperFx.Core; using JasperFx.Core.Reflection; -using Microsoft.Data.SqlClient; +using JasperFx.Resources; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SharedPersistenceModels.Items; using Shouldly; -using Weasel.Core; -using Weasel.SqlServer; -using Weasel.SqlServer.Tables; using Wolverine; using Wolverine.Attributes; using Wolverine.ComplianceTests; @@ -47,22 +44,12 @@ public async Task detect_concurrency_exception_as_SagaConcurrencyException() opt.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); opt.UseEntityFrameworkCoreTransactions(); + opt.UseEntityFrameworkCoreWolverineManagedMigrations(); + opt.Services.AddResourceSetupOnStartup(StartupAction.ResetState); opt.Policies.UseDurableLocalQueues(); opt.Policies.AutoApplyTransactions(); }).StartAsync(); - var table = new Table("ConcurrencyTestSagas"); - table.AddColumn("id").AsPrimaryKey(); - table.AddColumn("value"); - table.AddColumn("version"); - await using var conn = new SqlConnection(Servers.SqlServerConnectionString); - await conn.OpenAsync(); - - var migration = await SchemaMigration.DetermineAsync(conn, table); - await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All); - - await conn.CloseAsync(); - using var scope = host.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs b/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs index 67a597247..7d4b1c217 100644 --- a/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs +++ b/src/Persistence/EfCoreTests/Sagas/EfCoreSagaHost.cs @@ -1,14 +1,11 @@ using IntegrationTests; using JasperFx; -using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using JasperFx.Resources; using Wolverine.ComplianceTests; using Wolverine.ComplianceTests.Sagas; -using Weasel.Core; -using Weasel.SqlServer; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.SqlServer; @@ -27,15 +24,15 @@ public IHost BuildHost() opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); - opts.Services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString)); + opts.Services.AddDbContextWithWolverineIntegration(x => x.UseSqlServer(Servers.SqlServerConnectionString)); opts.UseEntityFrameworkCoreTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); opts.PublishAllMessages().Locally(); }); - // Watch if this hangs, might have to get fancier - Initialize().GetAwaiter().GetResult(); + _host.ResetResourceState().GetAwaiter().GetResult(); return _host; } @@ -72,24 +69,4 @@ public async Task LoadState(string id) where T : Saga return await session.FindAsync(id); } - public async Task Initialize() - { - var tables = new ISchemaObject[] - { - new WorkflowStateTable("GuidWorkflowState"), - new WorkflowStateTable("IntWorkflowState"), - new WorkflowStateTable("LongWorkflowState"), - new WorkflowStateTable("StringWorkflowState") - }; - - await using var conn = new SqlConnection(Servers.SqlServerConnectionString); - await conn.OpenAsync(); - - var migration = await SchemaMigration.DetermineAsync(conn, tables); - await new SqlServerMigrator().ApplyAllAsync(conn, migration, AutoCreate.All); - - await conn.CloseAsync(); - - await _host.ResetResourceState(); - } } \ No newline at end of file diff --git a/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs b/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs index 166602199..5696c0f45 100644 --- a/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs +++ b/src/Persistence/EfCoreTests/Sagas/SagaDbContext.cs @@ -22,6 +22,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) map.Property(x => x.TwoCompleted).HasColumnName("two"); map.Property(x => x.ThreeCompleted).HasColumnName("three"); map.Property(x => x.FourCompleted).HasColumnName("four"); + map.Property(x => x.Name).HasColumnName("Name"); }); modelBuilder.Entity(map => @@ -32,6 +33,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) map.Property(x => x.TwoCompleted).HasColumnName("two"); map.Property(x => x.ThreeCompleted).HasColumnName("three"); map.Property(x => x.FourCompleted).HasColumnName("four"); + map.Property(x => x.Name).HasColumnName("Name"); }); modelBuilder.Entity(map => @@ -42,6 +44,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) map.Property(x => x.TwoCompleted).HasColumnName("two"); map.Property(x => x.ThreeCompleted).HasColumnName("three"); map.Property(x => x.FourCompleted).HasColumnName("four"); + map.Property(x => x.Name).HasColumnName("Name"); }); modelBuilder.Entity(map => @@ -52,6 +55,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) map.Property(x => x.TwoCompleted).HasColumnName("two"); map.Property(x => x.ThreeCompleted).HasColumnName("three"); map.Property(x => x.FourCompleted).HasColumnName("four"); + map.Property(x => x.Name).HasColumnName("Name"); }); } } \ No newline at end of file diff --git a/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs b/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs deleted file mode 100644 index 19e6ad56c..000000000 --- a/src/Persistence/EfCoreTests/Sagas/WorkflowStateTable.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Weasel.SqlServer.Tables; - -namespace EfCoreTests.Sagas; - -internal class WorkflowStateTable : Table -{ - public WorkflowStateTable(string tableName) : base(tableName) - { - AddColumn("Id").AsPrimaryKey(); - AddColumn("one"); - AddColumn("two"); - AddColumn("three"); - AddColumn("four"); - AddColumn("Name"); - AddColumn("version"); - } -} \ No newline at end of file diff --git a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs index d4429cbb4..2d2980624 100644 --- a/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs +++ b/src/Persistence/EfCoreTests/idempotency_with_inline_or_buffered_endpoints_end_to_end.cs @@ -9,9 +9,6 @@ using Microsoft.Extensions.Hosting; using SharedPersistenceModels.Items; using Shouldly; -using Weasel.Core; -using Weasel.SqlServer; -using Weasel.SqlServer.Tables; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.Persistence; @@ -23,29 +20,9 @@ namespace EfCoreTests; public class idempotency_with_inline_or_buffered_endpoints_end_to_end : IAsyncLifetime { - public async Task InitializeAsync() + public Task InitializeAsync() { - await buildSqlServer(); - } - - private static async Task buildSqlServer() - { - var itemsTable = new Table(new DbObjectName("dbo", "items")); - itemsTable.AddColumn("Id").AsPrimaryKey(); - itemsTable.AddColumn("Name"); - itemsTable.AddColumn("Approved"); - - await using var conn = new SqlConnection(Servers.SqlServerConnectionString); - await conn.OpenAsync(); - var migration = await SchemaMigration.DetermineAsync(conn, itemsTable); - if (migration.Difference != SchemaPatchDifference.None) - { - var sqlServerMigrator = new SqlServerMigrator(); - - await sqlServerMigrator.ApplyAllAsync(conn, migration, AutoCreate.CreateOrUpdate); - } - - await conn.CloseAsync(); + return Task.CompletedTask; } public Task DisposeAsync() @@ -81,6 +58,7 @@ public async Task happy_and_sad_path(IdempotencyStyle idempotency, bool isWolver opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency"); opts.UseEntityFrameworkCoreTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); }).StartAsync(); var messageId = Guid.NewGuid(); @@ -131,6 +109,7 @@ public async Task happy_and_sad_path_with_message_and_destination_tracking(Idemp opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency"); opts.UseEntityFrameworkCoreTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); }).StartAsync(); var messageId = Guid.NewGuid(); @@ -173,6 +152,7 @@ public async Task apply_idempotency_to_non_transactional_handler() opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "idempotency"); opts.UseEntityFrameworkCoreTransactions(); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); // THIS RIGHT HERE opts.Policies.AutoApplyIdempotencyOnNonTransactionalHandlers(); diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs index f78be6215..5505dfc50 100644 --- a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs @@ -1,9 +1,11 @@ using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Model; +using JasperFx.Events.Aggregation; using Marten.Schema; using NSubstitute; using Shouldly; +using StronglyTypedIds; using Wolverine.Configuration; using Wolverine.Marten; using Wolverine.Runtime; @@ -97,6 +99,43 @@ public void cannot_determine_aggregate_id() AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(BadCommand)); }); } + + [Fact] + public void determine_aggregate_id_by_strong_typed_id_on_aggregate_id_property() + { + // CourseAggregate has CourseId Id property (strong typed ID) + // ChangeCourseCapacity has a single CourseId property + AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(ChangeCourseCapacity)) + .Name.ShouldBe(nameof(ChangeCourseCapacity.CourseId)); + } + + [Fact] + public void determine_aggregate_id_by_identified_by_interface() + { + // CourseAggregateWithInterface implements IdentifiedBy + // but uses Guid Id property. The IdentifiedBy signals the strong typed ID. + AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregateWithInterface), typeof(ChangeCourseCapacityForInterface)) + .Name.ShouldBe(nameof(ChangeCourseCapacityForInterface.CourseId)); + } + + [Fact] + public void strong_typed_id_matching_requires_single_property() + { + // If the command has multiple properties of the same strong typed ID type, + // the fallback should NOT match and should throw + Should.Throw(() => + { + AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(TransferBetweenCourses)); + }); + } + + [Fact] + public void strong_typed_id_not_used_when_conventional_name_matches() + { + // Even with a strong typed ID, if the conventional name "Id" matches, use that + AggregateHandling.DetermineAggregateIdMember(typeof(CourseAggregate), typeof(UpdateCourseWithConventionalId)) + .Name.ShouldBe(nameof(UpdateCourseWithConventionalId.Id)); + } } public class Invoice @@ -148,4 +187,43 @@ public Task Handle(Invalid2 command, Invoice invoice) public record Invalid1(Guid InvoiceId); -public record Invalid2(Guid InvoiceId); \ No newline at end of file +public record Invalid2(Guid InvoiceId); + +// Strong typed ID using StronglyTypedIds package +[StronglyTypedId(Template.Guid)] +public readonly partial struct CourseId; + +// Aggregate with a strong typed ID property +public class CourseAggregate +{ + public CourseId Id { get; set; } + public int Capacity { get; set; } + public int Version { get; set; } + + public void Apply(CourseCapacityChanged e) + { + Capacity = e.NewCapacity; + } +} + +// Aggregate that uses IdentifiedBy to declare its strong typed identity +public class CourseAggregateWithInterface : IdentifiedBy +{ + public Guid Id { get; set; } + public int Capacity { get; set; } + public int Version { get; set; } +} + +public record CourseCapacityChanged(int NewCapacity); + +// Command with a single CourseId property (non-conventional name) +public record ChangeCourseCapacity(CourseId CourseId, int NewCapacity); + +// Command for the IdentifiedBy aggregate +public record ChangeCourseCapacityForInterface(CourseId CourseId, int NewCapacity); + +// Command with multiple CourseId properties — should NOT match +public record TransferBetweenCourses(CourseId SourceCourseId, CourseId TargetCourseId); + +// Command with conventional "Id" name — should use conventional matching, not fallback +public record UpdateCourseWithConventionalId(CourseId Id, string Name); \ No newline at end of file diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs new file mode 100644 index 000000000..7e3afaead --- /dev/null +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/always_enforce_consistency_workflow.cs @@ -0,0 +1,263 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.AggregateHandlerWorkflow; + +public class always_enforce_consistency_workflow : PostgresqlContext, IAsyncLifetime +{ + private IHost theHost; + private IDocumentStore theStore; + private Guid theStreamId; + + public async Task InitializeAsync() + { + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.Projections.Snapshot(SnapshotLifecycle.Inline); + m.DisableNpgsqlLogging = true; + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto; + }).StartAsync(); + + theStore = theHost.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + private async Task GivenAggregate() + { + await using var session = theStore.LightweightSession(); + var action = session.Events.StartStream(new ConsistencyStarted()); + await session.SaveChangesAsync(); + + theStreamId = action.Id; + } + + private async Task LoadAggregate() + { + await using var session = theStore.LightweightSession(); + return await session.LoadAsync(theStreamId); + } + + [Fact] + public async Task happy_path_with_events_using_attribute_property() + { + await GivenAggregate(); + await theHost.InvokeMessageAndWaitAsync(new ConsistentIncrementA(theStreamId)); + + var aggregate = await LoadAggregate(); + aggregate.ACount.ShouldBe(1); + } + + [Fact] + public async Task happy_path_no_events_emitted_using_attribute_property() + { + await GivenAggregate(); + + // This should succeed even though no events are emitted, + // because no one else has modified the stream + await theHost.InvokeMessageAndWaitAsync(new ConsistentDoNothing(theStreamId)); + + var aggregate = await LoadAggregate(); + aggregate.ACount.ShouldBe(0); + } + + [Fact] + public async Task concurrency_violation_no_events_emitted_using_attribute_property() + { + await GivenAggregate(); + + // The handler will sneakily modify the stream mid-flight using a separate session, + // simulating a concurrent writer between FetchForWriting and SaveChangesAsync + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new ConsistentDoNothingWithConcurrentModification(theStreamId))); + } + + [Fact] + public async Task happy_path_with_events_using_consistent_aggregate_handler_attribute() + { + await GivenAggregate(); + await theHost.InvokeMessageAndWaitAsync(new ConsistentHandlerIncrementA(theStreamId)); + + var aggregate = await LoadAggregate(); + aggregate.ACount.ShouldBe(1); + } + + [Fact] + public async Task happy_path_no_events_using_consistent_aggregate_handler_attribute() + { + await GivenAggregate(); + + await theHost.InvokeMessageAndWaitAsync(new ConsistentHandlerDoNothing(theStreamId)); + + var aggregate = await LoadAggregate(); + aggregate.ACount.ShouldBe(0); + } + + [Fact] + public async Task concurrency_violation_no_events_using_consistent_aggregate_handler_attribute() + { + await GivenAggregate(); + + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new ConsistentHandlerDoNothingWithConcurrentModification(theStreamId))); + } + + [Fact] + public async Task happy_path_using_parameter_level_consistent_aggregate_attribute() + { + await GivenAggregate(); + await theHost.InvokeMessageAndWaitAsync(new ConsistentParamIncrementA(theStreamId)); + + var aggregate = await LoadAggregate(); + aggregate.ACount.ShouldBe(1); + } + + [Fact] + public async Task concurrency_violation_using_parameter_level_consistent_aggregate_attribute() + { + await GivenAggregate(); + + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new ConsistentParamDoNothingWithConcurrentModification(theStreamId))); + } +} + +#region Aggregate and Events + +public class ConsistencyAggregate +{ + public ConsistencyAggregate() + { + } + + public ConsistencyAggregate(ConsistencyStarted started) + { + } + + public Guid Id { get; set; } + public int ACount { get; set; } + + public void Apply(ConsistencyAEvent e) => ACount++; +} + +public record ConsistencyStarted; +public record ConsistencyAEvent; + +#endregion + +#region Commands + +// Happy path commands +public record ConsistentIncrementA(Guid ConsistencyAggregateId); +public record ConsistentDoNothing(Guid ConsistencyAggregateId); +public record ConsistentHandlerIncrementA(Guid ConsistencyAggregateId); +public record ConsistentHandlerDoNothing(Guid ConsistencyAggregateId); +public record ConsistentParamIncrementA(Guid ConsistencyAggregateId); + +// Concurrency violation commands - handlers will sneakily modify the stream mid-flight +public record ConsistentDoNothingWithConcurrentModification(Guid ConsistencyAggregateId); +public record ConsistentHandlerDoNothingWithConcurrentModification(Guid ConsistencyAggregateId); +public record ConsistentParamDoNothingWithConcurrentModification(Guid ConsistencyAggregateId); + +#endregion + +#region Handlers using AggregateHandler with AlwaysEnforceConsistency property + +[AggregateHandler(AlwaysEnforceConsistency = true)] +public static class ConsistentPropertyHandler +{ + public static ConsistencyAEvent Handle(ConsistentIncrementA command, ConsistencyAggregate aggregate) + { + return new ConsistencyAEvent(); + } + + public static void Handle(ConsistentDoNothing command, IEventStream stream) + { + // Intentionally do not append any events + } + + public static async Task Handle(ConsistentDoNothingWithConcurrentModification command, + IEventStream stream, IDocumentStore store) + { + // Simulate a concurrent writer modifying the stream between FetchForWriting and SaveChangesAsync + await using var sneakySession = store.LightweightSession(); + sneakySession.Events.Append(command.ConsistencyAggregateId, new ConsistencyAEvent()); + await sneakySession.SaveChangesAsync(); + } +} + +#endregion + +#region Handlers using ConsistentAggregateHandler attribute + +[ConsistentAggregateHandler] +public static class ConsistentAggregateHandlerUsage +{ + public static ConsistencyAEvent Handle(ConsistentHandlerIncrementA command, ConsistencyAggregate aggregate) + { + return new ConsistencyAEvent(); + } + + public static void Handle(ConsistentHandlerDoNothing command, IEventStream stream) + { + // Intentionally do not append any events + } + + public static async Task Handle(ConsistentHandlerDoNothingWithConcurrentModification command, + IEventStream stream, IDocumentStore store) + { + await using var sneakySession = store.LightweightSession(); + sneakySession.Events.Append(command.ConsistencyAggregateId, new ConsistencyAEvent()); + await sneakySession.SaveChangesAsync(); + } +} + +#endregion + +#region Handlers using parameter-level ConsistentAggregate attribute + +public static class ConsistentParamHandler +{ + public static ConsistencyAEvent Handle(ConsistentParamIncrementA command, + [ConsistentAggregate] ConsistencyAggregate aggregate) + { + return new ConsistencyAEvent(); + } + + public static async Task Handle(ConsistentParamDoNothingWithConcurrentModification command, + [ConsistentAggregate] IEventStream stream, IDocumentStore store) + { + await using var sneakySession = store.LightweightSession(); + sneakySession.Events.Append(stream.Id, new ConsistencyAEvent()); + await sneakySession.SaveChangesAsync(); + } +} + +#endregion diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs new file mode 100644 index 000000000..dc993b910 --- /dev/null +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/multi_stream_version_and_consistency.cs @@ -0,0 +1,278 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.AggregateHandlerWorkflow; + +public class multi_stream_version_and_consistency : PostgresqlContext, IAsyncLifetime +{ + private IHost theHost; + private IDocumentStore theStore; + private Guid fromAccountId; + private Guid toAccountId; + + public async Task InitializeAsync() + { + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "multi_stream_tests"; + m.Projections.Snapshot(SnapshotLifecycle.Inline); + m.DisableNpgsqlLogging = true; + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto; + }).StartAsync(); + + theStore = theHost.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + private async Task GivenAccounts(decimal fromBalance = 1000, decimal toBalance = 500) + { + await using var session = theStore.LightweightSession(); + fromAccountId = session.Events.StartStream(new BankAccountOpened(fromBalance)).Id; + toAccountId = session.Events.StartStream(new BankAccountOpened(toBalance)).Id; + await session.SaveChangesAsync(); + } + + private async Task LoadAccount(Guid id) + { + await using var session = theStore.LightweightSession(); + return await session.LoadAsync(id); + } + + [Fact] + public async Task only_first_stream_gets_version_check_by_default() + { + await GivenAccounts(); + + // The command has a "Version" property. Only the first [WriteAggregate] + // (fromAccount) should pick it up. The second (toAccount) should NOT. + // Version = 1 matches the "from" account after the open event. + await theHost.InvokeMessageAndWaitAsync( + new TransferFunds(fromAccountId, toAccountId, 100, Version: 1)); + + var from = await LoadAccount(fromAccountId); + var to = await LoadAccount(toAccountId); + from.Balance.ShouldBe(900); + to.Balance.ShouldBe(600); + } + + [Fact] + public async Task wrong_version_on_first_stream_causes_concurrency_error() + { + await GivenAccounts(); + + // Version = 99 doesn't match the "from" account + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new TransferFunds(fromAccountId, toAccountId, 100, Version: 99))); + } + + [Fact] + public async Task explicit_version_source_on_secondary_stream() + { + await GivenAccounts(); + + // Both FromVersion and ToVersion = 1 match their respective streams + await theHost.InvokeMessageAndWaitAsync( + new TransferFundsWithDualVersion(fromAccountId, toAccountId, 100, + FromVersion: 1, ToVersion: 1)); + + var from = await LoadAccount(fromAccountId); + var to = await LoadAccount(toAccountId); + from.Balance.ShouldBe(900); + to.Balance.ShouldBe(600); + } + + [Fact] + public async Task wrong_version_on_explicitly_sourced_secondary_stream() + { + await GivenAccounts(); + + // FromVersion is correct, but ToVersion is wrong + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new TransferFundsWithDualVersion(fromAccountId, toAccountId, 100, + FromVersion: 1, ToVersion: 99))); + } + + [Fact] + public async Task always_enforce_consistency_on_secondary_stream_with_no_events() + { + await GivenAccounts(fromBalance: 0); + + // Insufficient funds: "from" stream gets no events, but has AlwaysEnforceConsistency. + // A concurrent modification sneaks in during handling. + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new TransferWithConsistencyCheck(fromAccountId, toAccountId, 100))); + } + + [Fact] + public async Task always_enforce_consistency_happy_path_no_events() + { + await GivenAccounts(fromBalance: 0); + + // No concurrent modification, so this should succeed even though + // no events are appended to the "from" stream + await theHost.InvokeMessageAndWaitAsync( + new TransferWithConsistencyCheckNoConcurrentModification(fromAccountId, toAccountId, 100)); + + // Balances should be unchanged since insufficient funds + var from = await LoadAccount(fromAccountId); + var to = await LoadAccount(toAccountId); + from.Balance.ShouldBe(0); + to.Balance.ShouldBe(500); + } +} + +#region Aggregate and Events + +public class BankAccount +{ + public Guid Id { get; set; } + public decimal Balance { get; set; } + + public static BankAccount Create(BankAccountOpened opened) => + new() { Balance = opened.InitialBalance }; + + public void Apply(FundsWithdrawn e) => Balance -= e.Amount; + public void Apply(FundsDeposited e) => Balance += e.Amount; +} + +public record BankAccountOpened(decimal InitialBalance); +public record FundsWithdrawn(decimal Amount); +public record FundsDeposited(decimal Amount); + +#endregion + +#region Commands + +// Default version convention: only the first [WriteAggregate] picks up "Version" +public record TransferFunds(Guid BankAccountId, Guid ToAccountId, decimal Amount, long Version); + +// Explicit VersionSource on both streams +public record TransferFundsWithDualVersion( + Guid FromAccountId, Guid ToAccountId, decimal Amount, + long FromVersion, long ToVersion); + +// Multi-stream with AlwaysEnforceConsistency and concurrent modification +public record TransferWithConsistencyCheck(Guid FromAccountId, Guid ToAccountId, decimal Amount); + +// Multi-stream with AlwaysEnforceConsistency, no concurrent modification +public record TransferWithConsistencyCheckNoConcurrentModification( + Guid FromAccountId, Guid ToAccountId, decimal Amount); + +#endregion + +#region Handlers + +// Default behavior: first [WriteAggregate] picks up "Version", second does not +public static class TransferFundsHandler +{ + public static void Handle( + TransferFunds command, + [WriteAggregate] IEventStream fromAccount, + [WriteAggregate(nameof(TransferFunds.ToAccountId))] IEventStream toAccount) + { + if (fromAccount.Aggregate.Balance >= command.Amount) + { + fromAccount.AppendOne(new FundsWithdrawn(command.Amount)); + toAccount.AppendOne(new FundsDeposited(command.Amount)); + } + } +} + +// Explicit VersionSource on both streams +public static class TransferFundsWithDualVersionHandler +{ + public static void Handle( + TransferFundsWithDualVersion command, + [WriteAggregate(nameof(TransferFundsWithDualVersion.FromAccountId), + VersionSource = nameof(TransferFundsWithDualVersion.FromVersion))] + IEventStream fromAccount, + [WriteAggregate(nameof(TransferFundsWithDualVersion.ToAccountId), + VersionSource = nameof(TransferFundsWithDualVersion.ToVersion))] + IEventStream toAccount) + { + if (fromAccount.Aggregate.Balance >= command.Amount) + { + fromAccount.AppendOne(new FundsWithdrawn(command.Amount)); + toAccount.AppendOne(new FundsDeposited(command.Amount)); + } + } +} + +// AlwaysEnforceConsistency on "from" stream, with sneaky concurrent modification +public static class TransferWithConsistencyCheckHandler +{ + public static async Task Handle( + TransferWithConsistencyCheck command, + [WriteAggregate(nameof(TransferWithConsistencyCheck.FromAccountId), + AlwaysEnforceConsistency = true)] + IEventStream fromAccount, + [WriteAggregate(nameof(TransferWithConsistencyCheck.ToAccountId))] + IEventStream toAccount, + IDocumentStore store) + { + // Sneaky concurrent modification on the "from" account + await using var sneakySession = store.LightweightSession(); + sneakySession.Events.Append(command.FromAccountId, new FundsDeposited(1)); + await sneakySession.SaveChangesAsync(); + + // Insufficient funds: don't append any events to "from" + if (fromAccount.Aggregate.Balance >= command.Amount) + { + fromAccount.AppendOne(new FundsWithdrawn(command.Amount)); + toAccount.AppendOne(new FundsDeposited(command.Amount)); + } + // Even though no events appended to "from", AlwaysEnforceConsistency + // should detect the concurrent modification and throw + } +} + +// AlwaysEnforceConsistency on "from" stream, NO concurrent modification +public static class TransferWithConsistencyCheckNoConcurrentModificationHandler +{ + public static void Handle( + TransferWithConsistencyCheckNoConcurrentModification command, + [WriteAggregate(nameof(TransferWithConsistencyCheckNoConcurrentModification.FromAccountId), + AlwaysEnforceConsistency = true)] + IEventStream fromAccount, + [WriteAggregate(nameof(TransferWithConsistencyCheckNoConcurrentModification.ToAccountId))] + IEventStream toAccount) + { + // Insufficient funds: don't append any events to either stream + if (fromAccount.Aggregate.Balance >= command.Amount) + { + fromAccount.AppendOne(new FundsWithdrawn(command.Amount)); + toAccount.AppendOne(new FundsDeposited(command.Amount)); + } + // No concurrent modification, so AlwaysEnforceConsistency should pass + } +} + +#endregion diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs new file mode 100644 index 000000000..c024743a6 --- /dev/null +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/natural_key_aggregate_handler_workflow.cs @@ -0,0 +1,191 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Events.Aggregation; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.ComplianceTests; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.AggregateHandlerWorkflow; + +public class natural_key_aggregate_handler_workflow : PostgresqlContext, IAsyncDisposable +{ + private readonly IHost _host; + private readonly IDocumentStore _store; + + public natural_key_aggregate_handler_workflow() + { + _host = WolverineHost.For(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "nk_handler"; + m.Projections.Snapshot(SnapshotLifecycle.Inline); + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto; + }); + + _store = _host.Services.GetRequiredService(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public async Task handle_command_with_natural_key_returning_single_event() + { + var streamId = Guid.NewGuid(); + var orderNumber = new NkHandlerOrderNumber("ORD-WOL-001"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new NkHandlerOrderCreated(orderNumber, "Alice")); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new AddNkOrderItem(orderNumber, "Widget", 9.99m)); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.TotalAmount.ShouldBe(9.99m); + aggregate.CustomerName.ShouldBe("Alice"); + } + + [Fact] + public async Task handle_command_with_natural_key_returning_multiple_events() + { + var streamId = Guid.NewGuid(); + var orderNumber = new NkHandlerOrderNumber("ORD-WOL-002"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new NkHandlerOrderCreated(orderNumber, "Bob")); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new AddNkOrderItems(orderNumber, + [("Gadget", 19.99m), ("Doohickey", 5.50m)])); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.TotalAmount.ShouldBe(25.49m); + } + + [Fact] + public async Task handle_command_with_natural_key_using_event_stream() + { + var streamId = Guid.NewGuid(); + var orderNumber = new NkHandlerOrderNumber("ORD-WOL-003"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new NkHandlerOrderCreated(orderNumber, "Charlie"), + new NkHandlerItemAdded("Widget", 10.00m)); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new CompleteNkOrder(orderNumber)); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.IsComplete.ShouldBeTrue(); + aggregate.TotalAmount.ShouldBe(10.00m); + } +} + +#region sample_wolverine_marten_natural_key_aggregate + +public record NkHandlerOrderNumber(string Value); + +public class NkOrderAggregate +{ + public Guid Id { get; set; } + + [NaturalKey] + public NkHandlerOrderNumber OrderNum { get; set; } = null!; + + public decimal TotalAmount { get; set; } + public string CustomerName { get; set; } = string.Empty; + public bool IsComplete { get; set; } + + [NaturalKeySource] + public void Apply(NkHandlerOrderCreated e) + { + OrderNum = e.OrderNumber; + CustomerName = e.CustomerName; + } + + public void Apply(NkHandlerItemAdded e) + { + TotalAmount += e.Price; + } + + public void Apply(NkHandlerOrderCompleted e) + { + IsComplete = true; + } +} + +public record NkHandlerOrderCreated(NkHandlerOrderNumber OrderNumber, string CustomerName); +public record NkHandlerItemAdded(string ItemName, decimal Price); +public record NkHandlerOrderCompleted; + +#endregion + +#region sample_wolverine_marten_natural_key_commands + +public record AddNkOrderItem(NkHandlerOrderNumber OrderNum, string ItemName, decimal Price); +public record AddNkOrderItems(NkHandlerOrderNumber OrderNum, (string Name, decimal Price)[] Items); +public record CompleteNkOrder(NkHandlerOrderNumber OrderNum); + +#endregion + +#region sample_wolverine_marten_natural_key_handlers + +public static class NkOrderHandler +{ + public static NkHandlerItemAdded Handle(AddNkOrderItem command, + [WriteAggregate] NkOrderAggregate aggregate) + { + return new NkHandlerItemAdded(command.ItemName, command.Price); + } + + public static IEnumerable Handle(AddNkOrderItems command, + [WriteAggregate] NkOrderAggregate aggregate) + { + foreach (var (name, price) in command.Items) + { + yield return new NkHandlerItemAdded(name, price); + } + } + + public static void Handle(CompleteNkOrder command, + [WriteAggregate] IEventStream stream) + { + stream.AppendOne(new NkHandlerOrderCompleted()); + } +} + +#endregion diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs new file mode 100644 index 000000000..1e2d8de9b --- /dev/null +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/version_source_override.cs @@ -0,0 +1,167 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.AggregateHandlerWorkflow; + +public class version_source_override : PostgresqlContext, IAsyncLifetime +{ + private IHost theHost; + private IDocumentStore theStore; + private Guid theStreamId; + + public async Task InitializeAsync() + { + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.Projections.Snapshot(SnapshotLifecycle.Inline); + m.DisableNpgsqlLogging = true; + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto; + }).StartAsync(); + + theStore = theHost.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + private async Task GivenAggregate() + { + await using var session = theStore.LightweightSession(); + var action = session.Events.StartStream(new VersionSourceStarted()); + await session.SaveChangesAsync(); + + theStreamId = action.Id; + } + + private async Task LoadAggregate() + { + await using var session = theStore.LightweightSession(); + return await session.LoadAsync(theStreamId); + } + + [Fact] + public async Task happy_path_with_custom_version_source_on_aggregate_handler() + { + await GivenAggregate(); + + // ExpectedVersion = 1 matches the stream version after the start event + await theHost.InvokeMessageAndWaitAsync( + new IncrementWithCustomVersion(theStreamId, ExpectedVersion: 1)); + + var aggregate = await LoadAggregate(); + aggregate.Count.ShouldBe(1); + } + + [Fact] + public async Task wrong_version_with_custom_version_source_on_aggregate_handler() + { + await GivenAggregate(); + + // ExpectedVersion = 99 does not match the actual stream version of 1 + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new IncrementWithCustomVersion(theStreamId, ExpectedVersion: 99))); + } + + [Fact] + public async Task happy_path_with_custom_version_source_on_write_aggregate() + { + await GivenAggregate(); + + await theHost.InvokeMessageAndWaitAsync( + new IncrementWithParamVersionSource(theStreamId, MyVersion: 1)); + + var aggregate = await LoadAggregate(); + aggregate.Count.ShouldBe(1); + } + + [Fact] + public async Task wrong_version_with_custom_version_source_on_write_aggregate() + { + await GivenAggregate(); + + await Should.ThrowAsync( + theHost.InvokeMessageAndWaitAsync( + new IncrementWithParamVersionSource(theStreamId, MyVersion: 99))); + } +} + +#region Types + +public class VersionSourceAggregate +{ + public VersionSourceAggregate() + { + } + + public VersionSourceAggregate(VersionSourceStarted started) + { + } + + public Guid Id { get; set; } + public int Count { get; set; } + + public void Apply(VersionSourceIncremented e) => Count++; +} + +public record VersionSourceStarted; +public record VersionSourceIncremented; + +#endregion + +#region Commands + +// Command with a non-standard version property name for AggregateHandler usage +public record IncrementWithCustomVersion(Guid VersionSourceAggregateId, long ExpectedVersion); + +// Command with a non-standard version property name for WriteAggregate parameter usage +public record IncrementWithParamVersionSource(Guid VersionSourceAggregateId, long MyVersion); + +#endregion + +#region Handlers + +[AggregateHandler(VersionSource = nameof(IncrementWithCustomVersion.ExpectedVersion))] +public static class CustomVersionSourceHandler +{ + public static VersionSourceIncremented Handle(IncrementWithCustomVersion command, + VersionSourceAggregate aggregate) + { + return new VersionSourceIncremented(); + } +} + +public static class ParamVersionSourceHandler +{ + public static VersionSourceIncremented Handle(IncrementWithParamVersionSource command, + [WriteAggregate(VersionSource = nameof(IncrementWithParamVersionSource.MyVersion))] + VersionSourceAggregate aggregate) + { + return new VersionSourceIncremented(); + } +} + +#endregion diff --git a/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs b/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs new file mode 100644 index 000000000..75dd51725 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/AllCoursesFullyBookedState.cs @@ -0,0 +1,58 @@ +namespace MartenTests.Dcb.University; + +/// +/// State for the "all courses fully booked" automation. +/// Tracks all courses and their capacity/subscription counts. +/// Built from events tagged with FacultyId. +/// +public class AllCoursesFullyBookedState +{ + public Dictionary Courses { get; } = new(); + public bool Notified { get; private set; } + + public bool AllCoursesFullyBooked => + Courses.Count > 0 && Courses.Values.All(c => c.IsFullyBooked); + + public void Apply(CourseCreated e) + { + Courses[e.CourseId] = new CourseStats(e.Capacity, 0); + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(CourseCapacityChanged e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Capacity = e.Capacity }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(StudentSubscribedToCourse e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Students = stats.Students + 1 }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Students = stats.Students - 1 }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(AllCoursesFullyBookedNotificationSent e) + { + Notified = true; + } + + private void ResetNotifiedIfNotAllBooked() + { + if (!AllCoursesFullyBooked) + Notified = false; + } + + public record CourseStats(int Capacity, int Students) + { + public bool IsFullyBooked => Students >= Capacity; + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs b/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs new file mode 100644 index 000000000..2b23f2105 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/BoundaryModelSubscribeStudentToCourse.cs @@ -0,0 +1,43 @@ +using JasperFx.Events.Tags; +using Wolverine.Marten; + +namespace MartenTests.Dcb.University; + +public record BoundaryModelSubscribeStudentToCourse(StudentId StudentId, CourseId CourseId); + +#region sample_wolverine_dcb_boundary_model_handler +public static class BoundaryModelSubscribeStudentHandler +{ + public const int MaxCoursesPerStudent = 3; + + public static EventTagQuery Load(BoundaryModelSubscribeStudentToCourse command) + => EventTagQuery + .For(command.CourseId) + .AndEventsOfType() + .Or(command.StudentId) + .AndEventsOfType(); + + public static StudentSubscribedToCourse Handle( + BoundaryModelSubscribeStudentToCourse command, + [BoundaryModel] + SubscriptionState state) + { + if (state.StudentId == null) + throw new InvalidOperationException("Student with given id never enrolled the faculty"); + + if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent) + throw new InvalidOperationException("Student subscribed to too many courses"); + + if (state.CourseId == null) + throw new InvalidOperationException("Course with given id does not exist"); + + if (state.StudentsSubscribedToCourse >= state.CourseCapacity) + throw new InvalidOperationException("Course is fully booked"); + + if (state.AlreadySubscribed) + throw new InvalidOperationException("Student already subscribed to this course"); + + return new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId); + } +} +#endregion diff --git a/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs new file mode 100644 index 000000000..d919a9fea --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs @@ -0,0 +1,137 @@ +using Castle.Components.DictionaryAdapter.Xml; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; +using Microsoft.Extensions.Logging; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Marten; + +namespace MartenTests.Dcb.University; + +public record ChangeCourseCapacity(CourseId CourseId, int Capacity); + +/// +/// Changes a course's capacity. Validates the course exists and capacity differs. +/// Uses DCB to query by CourseId tag. +/// +[WolverineIgnore] +public static class ChangeCourseCapacityHandler +{ + public static async Task Handle(ChangeCourseCapacity command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is not { Created: true }) + throw new InvalidOperationException("Course with given id does not exist"); + + if (command.Capacity == state.Capacity) + return; // No change needed + + var @event = new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity); + boundary.AppendOne(@event); + } +} + +public static class WithDcbChangeCourseCapacityHandler +{ + public class State + { + public bool Created { get; private set; } + public int Capacity { get; private set; } + + public void Apply(CourseCreated e) + { + Created = true; + Capacity = e.Capacity; + } + + public void Apply(CourseCapacityChanged e) + { + Capacity = e.Capacity; + } + } + + public static EventTagQuery Load(ChangeCourseCapacity command) + => new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + + public static HandlerContinuation Validate( + ChangeCourseCapacity command, + State state, + ILogger logger) + { + if (state is not { Created: true }) + { + logger.LogDebug("Course with given id {CourseId} does not exist", command.CourseId); + return HandlerContinuation.Stop; + } + + return HandlerContinuation.Continue; + } + + public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, State state) + { + return command.Capacity != state.Capacity + ? new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity) + : null; + } +} + +public static class AggregateHandlerChangeCourseCapacityHandler +{ + public class Course + { + public bool Created { get; private set; } + public int Capacity { get; private set; } + + public void Apply(CourseCreated e) + { + Created = true; + Capacity = e.Capacity; + } + + public void Apply(CourseCapacityChanged e) + { + Capacity = e.Capacity; + } + } + + public static EventTagQuery Load(ChangeCourseCapacity command) + => new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + + public static HandlerContinuation Validate( + ChangeCourseCapacity command, + + + Course state, + ILogger logger) + { + if (state is not { Created: true }) + { + logger.LogDebug("Course with given id {CourseId} does not exist", command.CourseId); + return HandlerContinuation.Stop; + } + + return HandlerContinuation.Continue; + } + + public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, + + // TODO -- see if we could auto-register this with Marten? + [WriteAggregate] + Course state) + { + return command.Capacity != state.Capacity + ? new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity) + : null; + } +} + diff --git a/src/Persistence/MartenTests/Dcb/University/CourseState.cs b/src/Persistence/MartenTests/Dcb/University/CourseState.cs new file mode 100644 index 000000000..95aeba212 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/CourseState.cs @@ -0,0 +1,29 @@ +namespace MartenTests.Dcb.University; + +/// +/// Aggregate state for a single course, built from events tagged with CourseId. +/// Used by CreateCourse, RenameCourse, and ChangeCourseCapacity handlers. +/// +public class CourseState +{ + public bool Created { get; private set; } + public string? Name { get; private set; } + public int Capacity { get; private set; } + + public void Apply(CourseCreated e) + { + Created = true; + Name = e.Name; + Capacity = e.Capacity; + } + + public void Apply(CourseRenamed e) + { + Name = e.Name; + } + + public void Apply(CourseCapacityChanged e) + { + Capacity = e.Capacity; + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs b/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs new file mode 100644 index 000000000..253170e9f --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/CourseStatsProjection.cs @@ -0,0 +1,37 @@ +namespace MartenTests.Dcb.University; + +/// +/// Read model for course statistics. Ported from the Axon CoursesStatsProjection. +/// In Marten, this would be an inline or async projection. +/// +public class CourseStatsReadModel +{ + public CourseId CourseId { get; set; } = default!; + public string Name { get; set; } = string.Empty; + public int Capacity { get; set; } + public int SubscribedStudents { get; set; } +} + +public static class CourseStatsProjection +{ + public static CourseStatsReadModel Create(CourseCreated e) => + new() + { + CourseId = e.CourseId, + Name = e.Name, + Capacity = e.Capacity, + SubscribedStudents = 0 + }; + + public static void Apply(CourseRenamed e, CourseStatsReadModel model) => + model.Name = e.Name; + + public static void Apply(CourseCapacityChanged e, CourseStatsReadModel model) => + model.Capacity = e.Capacity; + + public static void Apply(StudentSubscribedToCourse e, CourseStatsReadModel model) => + model.SubscribedStudents++; + + public static void Apply(StudentUnsubscribedFromCourse e, CourseStatsReadModel model) => + model.SubscribedStudents--; +} diff --git a/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs b/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs new file mode 100644 index 000000000..1706322ea --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/CreateCourse.cs @@ -0,0 +1,26 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record CreateCourse(CourseId CourseId, string Name, int Capacity); + +/// +/// Creates a course if it doesn't already exist. +/// Uses DCB to query by CourseId tag to check for prior creation. +/// +public static class CreateCourseHandler +{ + public static async Task Handle(CreateCourse command, IDocumentSession session) + { + var query = new EventTagQuery().Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is { Created: true }) + return; // Already created, idempotent + + var @event = new CourseCreated(FacultyId.Default, command.CourseId, command.Name, command.Capacity); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs b/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs new file mode 100644 index 000000000..2aba9a10b --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/EnrollStudentInFaculty.cs @@ -0,0 +1,27 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record EnrollStudentInFaculty(StudentId StudentId, string FirstName, string LastName); + +/// +/// Enrolls a student in the faculty (idempotent). +/// Uses DCB to query by StudentId tag. +/// +public static class EnrollStudentHandler +{ + public static async Task Handle(EnrollStudentInFaculty command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.StudentId); + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is { Exists: true }) + return; // Already enrolled, idempotent + + var @event = new StudentEnrolledInFaculty(FacultyId.Default, command.StudentId, command.FirstName, command.LastName); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs b/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs new file mode 100644 index 000000000..788f6e638 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/EnrolledStudentState.cs @@ -0,0 +1,15 @@ +namespace MartenTests.Dcb.University; + +/// +/// Aggregate state for student enrollment, built from events tagged with StudentId. +/// Used by EnrollStudentInFaculty handler. +/// +public class EnrolledStudentState +{ + public bool Exists { get; private set; } + + public void Apply(StudentEnrolledInFaculty e) + { + Exists = true; + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs b/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs new file mode 100644 index 000000000..d21998668 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/RenameCourse.cs @@ -0,0 +1,32 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record RenameCourse(CourseId CourseId, string Name); + +/// +/// Renames a course. Validates the course exists and name is different. +/// Uses DCB to query by CourseId tag. +/// +public static class RenameCourseHandler +{ + public static async Task Handle(RenameCourse command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is not { Created: true }) + throw new InvalidOperationException("Course with given id does not exist"); + + if (command.Name == state.Name) + return; // No change needed + + var @event = new CourseRenamed(FacultyId.Default, command.CourseId, command.Name); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs b/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs new file mode 100644 index 000000000..e0576bcbe --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/SendAllCoursesFullyBookedNotification.cs @@ -0,0 +1,36 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record SendAllCoursesFullyBookedNotification(FacultyId FacultyId); + +/// +/// Automation handler that sends a notification when all courses are fully booked. +/// Uses DCB to query events tagged with FacultyId to build state across all courses. +/// +/// In the Axon demo this is split into an EventHandler (reactor) and a CommandHandler. +/// Here we port both patterns as Wolverine handlers. +/// +public static class AllCoursesFullyBookedHandler +{ + public static async Task Handle(SendAllCoursesFullyBookedNotification command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is { AllCoursesFullyBooked: true, Notified: false }) + { + // In a real app, send notification via INotificationService here + boundary.AppendOne(new AllCoursesFullyBookedNotificationSent(command.FacultyId)); + } + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs b/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs new file mode 100644 index 000000000..be55e4354 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/SubscribeStudentToCourse.cs @@ -0,0 +1,63 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record SubscribeStudentToCourse(StudentId StudentId, CourseId CourseId); + +/// +/// Subscribes a student to a course. This is the most complex DCB handler — +/// it spans BOTH CourseId and StudentId tag boundaries to enforce: +/// - Student must be enrolled in faculty +/// - Student can't subscribe to more than 3 courses +/// - Course must exist +/// - Course must have vacant spots +/// - Student can't already be subscribed to this course +/// +/// Ported from the Axon demo's EventCriteria.either() pattern which OR's +/// events matching CourseId with events matching StudentId. +/// +public static class SubscribeStudentHandler +{ + public const int MaxCoursesPerStudent = 3; + + public static async Task Handle(SubscribeStudentToCourse command, IDocumentSession session) + { + // Query events tagged with CourseId OR StudentId — the DCB spans both + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.StudentId) + .Or(command.StudentId) + .Or(command.StudentId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate ?? new SubscriptionState(); + Decide(command, state); + + var @event = new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId); + boundary.AppendOne(@event); + } + + private static void Decide(SubscribeStudentToCourse command, SubscriptionState state) + { + if (state.StudentId == null) + throw new InvalidOperationException("Student with given id never enrolled the faculty"); + + if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent) + throw new InvalidOperationException("Student subscribed to too many courses"); + + if (state.CourseId == null) + throw new InvalidOperationException("Course with given id does not exist"); + + if (state.StudentsSubscribedToCourse >= state.CourseCapacity) + throw new InvalidOperationException("Course is fully booked"); + + if (state.AlreadySubscribed) + throw new InvalidOperationException("Student already subscribed to this course"); + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs b/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs new file mode 100644 index 000000000..0363e10e6 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/SubscriptionState.cs @@ -0,0 +1,55 @@ +#region sample_wolverine_dcb_subscription_state +namespace MartenTests.Dcb.University; +/// Built from events tagged with BOTH CourseId and StudentId. +/// This is the core DCB pattern — the consistency boundary spans multiple streams. +/// +/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses +/// EventCriteria.either() to load events matching CourseId OR StudentId. +/// +public class SubscriptionState +{ + public CourseId? CourseId { get; private set; } + public int CourseCapacity { get; private set; } + public int StudentsSubscribedToCourse { get; private set; } + + public StudentId? StudentId { get; private set; } + public int CoursesStudentSubscribed { get; private set; } + public bool AlreadySubscribed { get; private set; } + + public void Apply(CourseCreated e) + { + CourseId = e.CourseId; + CourseCapacity = e.Capacity; + } + + public void Apply(CourseCapacityChanged e) + { + CourseCapacity = e.Capacity; + } + + public void Apply(StudentEnrolledInFaculty e) + { + StudentId = e.StudentId; + } + + public void Apply(StudentSubscribedToCourse e) + { + if (e.CourseId == CourseId) + StudentsSubscribedToCourse++; + if (e.StudentId == StudentId) + CoursesStudentSubscribed++; + if (e.StudentId == StudentId && e.CourseId == CourseId) + AlreadySubscribed = true; + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + if (e.CourseId == CourseId) + StudentsSubscribedToCourse--; + if (e.StudentId == StudentId) + CoursesStudentSubscribed--; + if (e.StudentId == StudentId && e.CourseId == CourseId) + AlreadySubscribed = false; + } +} +#endregion diff --git a/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs b/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs new file mode 100644 index 000000000..b49d395d6 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/UniversityEvents.cs @@ -0,0 +1,17 @@ +#region sample_wolverine_dcb_university_events +namespace MartenTests.Dcb.University; + +public record CourseCreated(FacultyId FacultyId, CourseId CourseId, string Name, int Capacity); + +public record CourseRenamed(FacultyId FacultyId, CourseId CourseId, string Name); + +public record CourseCapacityChanged(FacultyId FacultyId, CourseId CourseId, int Capacity); + +public record StudentEnrolledInFaculty(FacultyId FacultyId, StudentId StudentId, string FirstName, string LastName); + +public record StudentSubscribedToCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId); + +public record StudentUnsubscribedFromCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId); + +public record AllCoursesFullyBookedNotificationSent(FacultyId FacultyId); +#endregion diff --git a/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs b/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs new file mode 100644 index 000000000..65288b2be --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/UniversityIds.cs @@ -0,0 +1,38 @@ +#region sample_wolverine_dcb_university_ids +namespace MartenTests.Dcb.University; + +/// +/// Strong-typed ID for a course. Uses string value with "Course:" prefix. +/// +public record CourseId(string Value) +{ + public static CourseId Random() => new($"Course:{Guid.NewGuid()}"); + public static CourseId Of(string raw) => new(raw.StartsWith("Course:") ? raw : $"Course:{raw}"); + public override string ToString() => Value; +} + +/// +/// Strong-typed ID for a student. Uses string value with "Student:" prefix. +/// +public record StudentId(string Value) +{ + public static StudentId Random() => new($"Student:{Guid.NewGuid()}"); + public static StudentId Of(string raw) => new(raw.StartsWith("Student:") ? raw : $"Student:{raw}"); + public override string ToString() => Value; +} + +/// +/// Strong-typed ID for the faculty. Single-instance in this demo. +/// +public record FacultyId(string Value) +{ + public static readonly FacultyId Default = new("Faculty:ONLY_FACULTY_ID"); + public static FacultyId Of(string raw) => new(raw.StartsWith("Faculty:") ? raw : $"Faculty:{raw}"); + public override string ToString() => Value; +} + +/// +/// Composite ID for a student-course subscription. +/// +public record SubscriptionId(CourseId CourseId, StudentId StudentId); +#endregion diff --git a/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs b/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs new file mode 100644 index 000000000..545e5f837 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/UnsubscribeStudentFromCourse.cs @@ -0,0 +1,31 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.Dcb.University; + +public record UnsubscribeStudentFromCourse(StudentId StudentId, CourseId CourseId); + +/// +/// Unsubscribes a student from a course. Idempotent if not subscribed. +/// Uses DCB to query by both CourseId AND StudentId tags. +/// +public static class UnsubscribeStudentHandler +{ + public static async Task Handle(UnsubscribeStudentFromCourse command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.StudentId) + .Or(command.StudentId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is not { Subscribed: true }) + return; // Not subscribed, nothing to do + + var @event = new StudentUnsubscribedFromCourse(FacultyId.Default, command.StudentId, command.CourseId); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs b/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs new file mode 100644 index 000000000..90fe95e21 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/University/UnsubscriptionState.cs @@ -0,0 +1,20 @@ +namespace MartenTests.Dcb.University; + +/// +/// State for unsubscribe — tracks whether the student is currently subscribed +/// to the course. Built from events tagged with both CourseId AND StudentId. +/// +public class UnsubscriptionState +{ + public bool Subscribed { get; private set; } + + public void Apply(StudentSubscribedToCourse e) + { + Subscribed = true; + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + Subscribed = false; + } +} diff --git a/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs b/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs new file mode 100644 index 000000000..a8acf92a4 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/boundary_model_workflow_tests.cs @@ -0,0 +1,208 @@ +using IntegrationTests; +using JasperFx.Events; +using JasperFx.Events.Tags; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using MartenTests.Dcb.University; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.Dcb; + +public class boundary_model_workflow_tests : PostgresqlContext, IAsyncLifetime +{ + private IHost theHost; + private IDocumentStore theStore; + + public async Task InitializeAsync() + { + // Drop the schema if it exists to avoid migration conflicts + await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DROP SCHEMA IF EXISTS dcb_boundary_tests CASCADE;"; + await cmd.ExecuteNonQueryAsync(); + } + + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "dcb_boundary_tests"; + + // Register tag types for DCB + m.Events.RegisterTagType("student") + .ForAggregate(); + m.Events.RegisterTagType("course") + .ForAggregate(); + m.Events.RegisterTagType("faculty"); + + // Register event types + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + + m.Events.StreamIdentity = StreamIdentity.AsString; + + m.DisableNpgsqlLogging = true; + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + }).StartAsync(); + + theStore = theHost.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + private async Task SeedCourseAndStudent(CourseId courseId, StudentId studentId, int capacity = 10) + { + await using var session = theStore.LightweightSession(); + + var courseCreated = session.Events.BuildEvent( + new CourseCreated(FacultyId.Default, courseId, "Math 101", capacity)); + courseCreated.WithTag(courseId); + var courseStreamKey = courseId.Value; + session.Events.Append(courseStreamKey, courseCreated); + + var enrolled = session.Events.BuildEvent( + new StudentEnrolledInFaculty(FacultyId.Default, studentId, "Alice", "Smith")); + enrolled.WithTag(studentId); + var studentStreamKey = studentId.Value; + session.Events.Append(studentStreamKey, enrolled); + + await session.SaveChangesAsync(); + } + + [Fact] + public async Task can_fetch_for_writing_by_tags_across_multiple_tag_types() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + await SeedCourseAndStudent(courseId, studentId); + + await using var session = theStore.LightweightSession(); + + var query = new EventTagQuery() + .Or(courseId) + .Or(studentId); + + var boundary = await session.Events.FetchForWritingByTags(query); + boundary.Events.Count.ShouldBe(2); + boundary.Aggregate.ShouldNotBeNull(); + boundary.Aggregate.CourseId.ShouldBe(courseId); + boundary.Aggregate.StudentId.ShouldBe(studentId); + } + + [Fact] + public async Task boundary_model_handler_subscribes_student_to_course() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + await SeedCourseAndStudent(courseId, studentId); + + // Invoke the [BoundaryModel] handler + await theHost.InvokeMessageAndWaitAsync( + new BoundaryModelSubscribeStudentToCourse(studentId, courseId)); + + // Verify the subscription event was appended and discoverable by tag + await using var session = theStore.LightweightSession(); + var events = await session.Events.QueryByTagsAsync( + new EventTagQuery().Or(studentId)); + + events.ShouldContain(e => e.Data is StudentSubscribedToCourse); + } + + [Fact] + public async Task boundary_model_handler_throws_when_student_not_enrolled() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + // Only seed the course, NOT the student + await using var session = theStore.LightweightSession(); + var courseCreated = session.Events.BuildEvent( + new CourseCreated(FacultyId.Default, courseId, "Math 101", 10)); + courseCreated.WithTag(courseId); + session.Events.Append(courseId.Value, courseCreated); + await session.SaveChangesAsync(); + + // The handler should throw because student is not enrolled + await Should.ThrowAsync(async () => + { + await theHost.InvokeMessageAndWaitAsync( + new BoundaryModelSubscribeStudentToCourse(studentId, courseId)); + }); + } + + [Fact] + public async Task boundary_model_handler_throws_when_course_does_not_exist() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + // Only seed the student, NOT the course + await using var session = theStore.LightweightSession(); + var enrolled = session.Events.BuildEvent( + new StudentEnrolledInFaculty(FacultyId.Default, studentId, "Alice", "Smith")); + enrolled.WithTag(studentId); + session.Events.Append(studentId.Value, enrolled); + await session.SaveChangesAsync(); + + await Should.ThrowAsync(async () => + { + await theHost.InvokeMessageAndWaitAsync( + new BoundaryModelSubscribeStudentToCourse(studentId, courseId)); + }); + } + + [Fact] + public async Task boundary_model_handler_throws_when_course_is_fully_booked() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + // Create course with capacity = 1 and fill it + await SeedCourseAndStudent(courseId, studentId, capacity: 1); + + // Subscribe a different student first to fill the course + var otherStudentId = StudentId.Random(); + await using var session = theStore.LightweightSession(); + var otherEnrolled = session.Events.BuildEvent( + new StudentEnrolledInFaculty(FacultyId.Default, otherStudentId, "Bob", "Jones")); + otherEnrolled.WithTag(otherStudentId); + session.Events.Append(otherStudentId.Value, otherEnrolled); + + var subscribed = session.Events.BuildEvent( + new StudentSubscribedToCourse(FacultyId.Default, otherStudentId, courseId)); + subscribed.WithTag(otherStudentId, courseId); + session.Events.Append(otherStudentId.Value, subscribed); + await session.SaveChangesAsync(); + + // Now try to subscribe our student — should fail because course is full + await Should.ThrowAsync(async () => + { + await theHost.InvokeMessageAndWaitAsync( + new BoundaryModelSubscribeStudentToCourse(studentId, courseId)); + }); + } +} diff --git a/src/Persistence/MartenTests/EventTypeForwarderTests.cs b/src/Persistence/MartenTests/EventTypeForwarderTests.cs index 52985af87..b9f2721e5 100644 --- a/src/Persistence/MartenTests/EventTypeForwarderTests.cs +++ b/src/Persistence/MartenTests/EventTypeForwarderTests.cs @@ -59,4 +59,7 @@ public object GetHeader(string key) public string AggregateTypeName { get; set; } public string? UserName { get; set; } public bool IsSkipped { get; set; } + public IReadOnlyList? Tags => null; + public void AddTag(TTag tag) where TTag : notnull { } + public void AddTag(JasperFx.Events.EventTag tag) { } } \ No newline at end of file diff --git a/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs b/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs new file mode 100644 index 000000000..7e16e04fe --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/AllCoursesFullyBookedState.cs @@ -0,0 +1,58 @@ +namespace MartenTests.MultiStream.University; + +/// +/// State for the "all courses fully booked" automation. +/// Tracks all courses and their capacity/subscription counts. +/// Built from events tagged with FacultyId. +/// +public class AllCoursesFullyBookedState +{ + public Dictionary Courses { get; } = new(); + public bool Notified { get; private set; } + + public bool AllCoursesFullyBooked => + Courses.Count > 0 && Courses.Values.All(c => c.IsFullyBooked); + + public void Apply(CourseCreated e) + { + Courses[e.CourseId] = new CourseStats(e.Capacity, 0); + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(CourseCapacityChanged e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Capacity = e.Capacity }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(StudentSubscribedToCourse e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Students = stats.Students + 1 }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + if (Courses.TryGetValue(e.CourseId, out var stats)) + Courses[e.CourseId] = stats with { Students = stats.Students - 1 }; + ResetNotifiedIfNotAllBooked(); + } + + public void Apply(AllCoursesFullyBookedNotificationSent e) + { + Notified = true; + } + + private void ResetNotifiedIfNotAllBooked() + { + if (!AllCoursesFullyBooked) + Notified = false; + } + + public record CourseStats(int Capacity, int Students) + { + public bool IsFullyBooked => Students >= Capacity; + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs b/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs new file mode 100644 index 000000000..7efc908e5 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/ChangeCourseCapacity.cs @@ -0,0 +1,32 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record ChangeCourseCapacity(CourseId CourseId, int Capacity); + +/// +/// Changes a course's capacity. Validates the course exists and capacity differs. +/// Uses DCB to query by CourseId tag. +/// +public static class ChangeCourseCapacityHandler +{ + public static async Task Handle(ChangeCourseCapacity command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is not { Created: true }) + throw new InvalidOperationException("Course with given id does not exist"); + + if (command.Capacity == state.Capacity) + return; // No change needed + + var @event = new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/CourseState.cs b/src/Persistence/MartenTests/MultiStream/University/CourseState.cs new file mode 100644 index 000000000..a175859a9 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/CourseState.cs @@ -0,0 +1,29 @@ +namespace MartenTests.MultiStream.University; + +/// +/// Aggregate state for a single course, built from events tagged with CourseId. +/// Used by CreateCourse, RenameCourse, and ChangeCourseCapacity handlers. +/// +public class CourseState +{ + public bool Created { get; private set; } + public string? Name { get; private set; } + public int Capacity { get; private set; } + + public void Apply(CourseCreated e) + { + Created = true; + Name = e.Name; + Capacity = e.Capacity; + } + + public void Apply(CourseRenamed e) + { + Name = e.Name; + } + + public void Apply(CourseCapacityChanged e) + { + Capacity = e.Capacity; + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs b/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs new file mode 100644 index 000000000..5f72eaa44 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/CourseStatsProjection.cs @@ -0,0 +1,37 @@ +namespace MartenTests.MultiStream.University; + +/// +/// Read model for course statistics. Ported from the Axon CoursesStatsProjection. +/// In Marten, this would be an inline or async projection. +/// +public class CourseStatsReadModel +{ + public CourseId CourseId { get; set; } = default!; + public string Name { get; set; } = string.Empty; + public int Capacity { get; set; } + public int SubscribedStudents { get; set; } +} + +public static class CourseStatsProjection +{ + public static CourseStatsReadModel Create(CourseCreated e) => + new() + { + CourseId = e.CourseId, + Name = e.Name, + Capacity = e.Capacity, + SubscribedStudents = 0 + }; + + public static void Apply(CourseRenamed e, CourseStatsReadModel model) => + model.Name = e.Name; + + public static void Apply(CourseCapacityChanged e, CourseStatsReadModel model) => + model.Capacity = e.Capacity; + + public static void Apply(StudentSubscribedToCourse e, CourseStatsReadModel model) => + model.SubscribedStudents++; + + public static void Apply(StudentUnsubscribedFromCourse e, CourseStatsReadModel model) => + model.SubscribedStudents--; +} diff --git a/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs b/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs new file mode 100644 index 000000000..31c9a309d --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/CreateCourse.cs @@ -0,0 +1,26 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record CreateCourse(CourseId CourseId, string Name, int Capacity); + +/// +/// Creates a course if it doesn't already exist. +/// Uses DCB to query by CourseId tag to check for prior creation. +/// +public static class CreateCourseHandler +{ + public static async Task Handle(CreateCourse command, IDocumentSession session) + { + var query = new EventTagQuery().Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is { Created: true }) + return; // Already created, idempotent + + var @event = new CourseCreated(FacultyId.Default, command.CourseId, command.Name, command.Capacity); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs b/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs new file mode 100644 index 000000000..46c745579 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/EnrollStudentInFaculty.cs @@ -0,0 +1,27 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record EnrollStudentInFaculty(StudentId StudentId, string FirstName, string LastName); + +/// +/// Enrolls a student in the faculty (idempotent). +/// Uses DCB to query by StudentId tag. +/// +public static class EnrollStudentHandler +{ + public static async Task Handle(EnrollStudentInFaculty command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.StudentId); + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is { Exists: true }) + return; // Already enrolled, idempotent + + var @event = new StudentEnrolledInFaculty(FacultyId.Default, command.StudentId, command.FirstName, command.LastName); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs b/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs new file mode 100644 index 000000000..c8bd91e02 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/EnrolledStudentState.cs @@ -0,0 +1,15 @@ +namespace MartenTests.MultiStream.University; + +/// +/// Aggregate state for student enrollment, built from events tagged with StudentId. +/// Used by EnrollStudentInFaculty handler. +/// +public class EnrolledStudentState +{ + public bool Exists { get; private set; } + + public void Apply(StudentEnrolledInFaculty e) + { + Exists = true; + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs b/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs new file mode 100644 index 000000000..48e1655dc --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/RenameCourse.cs @@ -0,0 +1,32 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record RenameCourse(CourseId CourseId, string Name); + +/// +/// Renames a course. Validates the course exists and name is different. +/// Uses DCB to query by CourseId tag. +/// +public static class RenameCourseHandler +{ + public static async Task Handle(RenameCourse command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is not { Created: true }) + throw new InvalidOperationException("Course with given id does not exist"); + + if (command.Name == state.Name) + return; // No change needed + + var @event = new CourseRenamed(FacultyId.Default, command.CourseId, command.Name); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs b/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs new file mode 100644 index 000000000..46706ae5e --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/SendAllCoursesFullyBookedNotification.cs @@ -0,0 +1,36 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record SendAllCoursesFullyBookedNotification(FacultyId FacultyId); + +/// +/// Automation handler that sends a notification when all courses are fully booked. +/// Uses DCB to query events tagged with FacultyId to build state across all courses. +/// +/// In the Axon demo this is split into an EventHandler (reactor) and a CommandHandler. +/// Here we port both patterns as Wolverine handlers. +/// +public static class AllCoursesFullyBookedHandler +{ + public static async Task Handle(SendAllCoursesFullyBookedNotification command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId) + .Or(command.FacultyId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate; + if (state is { AllCoursesFullyBooked: true, Notified: false }) + { + // In a real app, send notification via INotificationService here + boundary.AppendOne(new AllCoursesFullyBookedNotificationSent(command.FacultyId)); + } + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs b/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs new file mode 100644 index 000000000..1f1e821fd --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/SubscribeStudentToCourse.cs @@ -0,0 +1,63 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record SubscribeStudentToCourse(StudentId StudentId, CourseId CourseId); + +/// +/// Subscribes a student to a course. This is the most complex DCB handler — +/// it spans BOTH CourseId and StudentId tag boundaries to enforce: +/// - Student must be enrolled in faculty +/// - Student can't subscribe to more than 3 courses +/// - Course must exist +/// - Course must have vacant spots +/// - Student can't already be subscribed to this course +/// +/// Ported from the Axon demo's EventCriteria.either() pattern which OR's +/// events matching CourseId with events matching StudentId. +/// +public static class SubscribeStudentHandler +{ + public const int MaxCoursesPerStudent = 3; + + public static async Task Handle(SubscribeStudentToCourse command, IDocumentSession session) + { + // Query events tagged with CourseId OR StudentId — the DCB spans both + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.StudentId) + .Or(command.StudentId) + .Or(command.StudentId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + var state = boundary.Aggregate ?? new SubscriptionState(); + Decide(command, state); + + var @event = new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId); + boundary.AppendOne(@event); + } + + private static void Decide(SubscribeStudentToCourse command, SubscriptionState state) + { + if (state.StudentId == null) + throw new InvalidOperationException("Student with given id never enrolled the faculty"); + + if (state.CoursesStudentSubscribed >= MaxCoursesPerStudent) + throw new InvalidOperationException("Student subscribed to too many courses"); + + if (state.CourseId == null) + throw new InvalidOperationException("Course with given id does not exist"); + + if (state.StudentsSubscribedToCourse >= state.CourseCapacity) + throw new InvalidOperationException("Course is fully booked"); + + if (state.AlreadySubscribed) + throw new InvalidOperationException("Student already subscribed to this course"); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs b/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs new file mode 100644 index 000000000..7a74d796a --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/SubscriptionState.cs @@ -0,0 +1,56 @@ +namespace MartenTests.MultiStream.University; + +/// +/// Cross-stream aggregate state for a student subscribing to a course. +/// Built from events tagged with BOTH CourseId and StudentId. +/// This is the core DCB pattern — the consistency boundary spans multiple streams. +/// +/// Ported from the Axon SubscribeStudentToCourseCommandHandler.State which uses +/// EventCriteria.either() to load events matching CourseId OR StudentId. +/// +public class SubscriptionState +{ + public CourseId? CourseId { get; private set; } + public int CourseCapacity { get; private set; } + public int StudentsSubscribedToCourse { get; private set; } + + public StudentId? StudentId { get; private set; } + public int CoursesStudentSubscribed { get; private set; } + public bool AlreadySubscribed { get; private set; } + + public void Apply(CourseCreated e) + { + CourseId = e.CourseId; + CourseCapacity = e.Capacity; + } + + public void Apply(CourseCapacityChanged e) + { + CourseCapacity = e.Capacity; + } + + public void Apply(StudentEnrolledInFaculty e) + { + StudentId = e.StudentId; + } + + public void Apply(StudentSubscribedToCourse e) + { + if (e.CourseId == CourseId) + StudentsSubscribedToCourse++; + if (e.StudentId == StudentId) + CoursesStudentSubscribed++; + if (e.StudentId == StudentId && e.CourseId == CourseId) + AlreadySubscribed = true; + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + if (e.CourseId == CourseId) + StudentsSubscribedToCourse--; + if (e.StudentId == StudentId) + CoursesStudentSubscribed--; + if (e.StudentId == StudentId && e.CourseId == CourseId) + AlreadySubscribed = false; + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs b/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs new file mode 100644 index 000000000..8e2978eac --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/UniversityEvents.cs @@ -0,0 +1,18 @@ +namespace MartenTests.MultiStream.University; + +// All events carry their tag IDs as properties, matching the Axon @EventTag pattern. +// In Marten, tags are attached to events via WithTag() at append time. + +public record CourseCreated(FacultyId FacultyId, CourseId CourseId, string Name, int Capacity); + +public record CourseRenamed(FacultyId FacultyId, CourseId CourseId, string Name); + +public record CourseCapacityChanged(FacultyId FacultyId, CourseId CourseId, int Capacity); + +public record StudentEnrolledInFaculty(FacultyId FacultyId, StudentId StudentId, string FirstName, string LastName); + +public record StudentSubscribedToCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId); + +public record StudentUnsubscribedFromCourse(FacultyId FacultyId, StudentId StudentId, CourseId CourseId); + +public record AllCoursesFullyBookedNotificationSent(FacultyId FacultyId); diff --git a/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs b/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs new file mode 100644 index 000000000..74725c60e --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/UniversityIds.cs @@ -0,0 +1,36 @@ +namespace MartenTests.MultiStream.University; + +/// +/// Strong-typed ID for a course. Uses string value with "Course:" prefix. +/// +public record CourseId(string Value) +{ + public static CourseId Random() => new($"Course:{Guid.NewGuid()}"); + public static CourseId Of(string raw) => new(raw.StartsWith("Course:") ? raw : $"Course:{raw}"); + public override string ToString() => Value; +} + +/// +/// Strong-typed ID for a student. Uses string value with "Student:" prefix. +/// +public record StudentId(string Value) +{ + public static StudentId Random() => new($"Student:{Guid.NewGuid()}"); + public static StudentId Of(string raw) => new(raw.StartsWith("Student:") ? raw : $"Student:{raw}"); + public override string ToString() => Value; +} + +/// +/// Strong-typed ID for the faculty. Single-instance in this demo. +/// +public record FacultyId(string Value) +{ + public static readonly FacultyId Default = new("Faculty:ONLY_FACULTY_ID"); + public static FacultyId Of(string raw) => new(raw.StartsWith("Faculty:") ? raw : $"Faculty:{raw}"); + public override string ToString() => Value; +} + +/// +/// Composite ID for a student-course subscription. +/// +public record SubscriptionId(CourseId CourseId, StudentId StudentId); diff --git a/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs b/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs new file mode 100644 index 000000000..89bf533db --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/UnsubscribeStudentFromCourse.cs @@ -0,0 +1,31 @@ +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; + +namespace MartenTests.MultiStream.University; + +public record UnsubscribeStudentFromCourse(StudentId StudentId, CourseId CourseId); + +/// +/// Unsubscribes a student from a course. Idempotent if not subscribed. +/// Uses DCB to query by both CourseId AND StudentId tags. +/// +public static class UnsubscribeStudentHandler +{ + public static async Task Handle(UnsubscribeStudentFromCourse command, IDocumentSession session) + { + var query = new EventTagQuery() + .Or(command.CourseId) + .Or(command.CourseId) + .Or(command.StudentId) + .Or(command.StudentId); + + var boundary = await session.Events.FetchForWritingByTags(query); + + if (boundary.Aggregate is not { Subscribed: true }) + return; // Not subscribed, nothing to do + + var @event = new StudentUnsubscribedFromCourse(FacultyId.Default, command.StudentId, command.CourseId); + boundary.AppendOne(@event); + } +} diff --git a/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs b/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs new file mode 100644 index 000000000..86cbea7e2 --- /dev/null +++ b/src/Persistence/MartenTests/MultiStream/University/UnsubscriptionState.cs @@ -0,0 +1,20 @@ +namespace MartenTests.MultiStream.University; + +/// +/// State for unsubscribe — tracks whether the student is currently subscribed +/// to the course. Built from events tagged with both CourseId AND StudentId. +/// +public class UnsubscriptionState +{ + public bool Subscribed { get; private set; } + + public void Apply(StudentSubscribedToCourse e) + { + Subscribed = true; + } + + public void Apply(StudentUnsubscribedFromCourse e) + { + Subscribed = false; + } +} diff --git a/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj b/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj index 208e53771..737a3d449 100644 --- a/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj +++ b/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj @@ -12,11 +12,9 @@ - - diff --git a/src/Persistence/PolecatTests/PolecatTests.csproj b/src/Persistence/PolecatTests/PolecatTests.csproj new file mode 100644 index 000000000..de64c9cc9 --- /dev/null +++ b/src/Persistence/PolecatTests/PolecatTests.csproj @@ -0,0 +1,35 @@ + + + + false + true + net10.0 + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + Servers.cs + + + + diff --git a/src/Persistence/PolecatTests/natural_key_aggregate_handler_workflow.cs b/src/Persistence/PolecatTests/natural_key_aggregate_handler_workflow.cs new file mode 100644 index 000000000..189f89fed --- /dev/null +++ b/src/Persistence/PolecatTests/natural_key_aggregate_handler_workflow.cs @@ -0,0 +1,196 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Events.Aggregation; +using Polecat.Events; +using JasperFx.Resources; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polecat; +using Polecat.Projections; +using Shouldly; +using Wolverine; +using Wolverine.Polecat; +using Wolverine.Tracking; +using Xunit; + +namespace PolecatTests; + +public class natural_key_aggregate_handler_workflow : IAsyncLifetime +{ + private IHost _host = null!; + private IDocumentStore _store = null!; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddPolecat(m => + { + m.ConnectionString = Servers.SqlServerConnectionString; + m.DatabaseSchemaName = "nk_handler"; + m.Projections.Snapshot(SnapshotLifecycle.Inline); + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Services.AddResourceSetupOnStartup(); + opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Auto; + }).StartAsync(); + + _store = _host.Services.GetRequiredService(); + + // Ensure the Polecat event store schema is created + var store = (DocumentStore)_store; + await store.Database.ApplyAllConfiguredChangesToDatabaseAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public async Task handle_command_with_natural_key_returning_single_event() + { + var streamId = Guid.NewGuid(); + var orderNumber = new PcNkOrderNumber("ORD-PC-001"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new PcNkOrderCreated(orderNumber, "Alice")); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new AddPcNkOrderItem(orderNumber, "Widget", 9.99m)); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.TotalAmount.ShouldBe(9.99m); + aggregate.CustomerName.ShouldBe("Alice"); + } + + [Fact] + public async Task handle_command_with_natural_key_returning_multiple_events() + { + var streamId = Guid.NewGuid(); + var orderNumber = new PcNkOrderNumber("ORD-PC-002"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new PcNkOrderCreated(orderNumber, "Bob")); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new AddPcNkOrderItems(orderNumber, + [("Gadget", 19.99m), ("Doohickey", 5.50m)])); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.TotalAmount.ShouldBe(25.49m); + } + + [Fact] + public async Task handle_command_with_natural_key_using_event_stream() + { + var streamId = Guid.NewGuid(); + var orderNumber = new PcNkOrderNumber("ORD-PC-003"); + + await using var session = _store.LightweightSession(); + session.Events.StartStream(streamId, + new PcNkOrderCreated(orderNumber, "Charlie"), + new PcNkItemAdded("Widget", 10.00m)); + await session.SaveChangesAsync(); + + await _host.TrackActivity() + .SendMessageAndWaitAsync(new CompletePcNkOrder(orderNumber)); + + await using var verify = _store.LightweightSession(); + var aggregate = await verify.LoadAsync(streamId); + + aggregate.ShouldNotBeNull(); + aggregate!.IsComplete.ShouldBeTrue(); + aggregate.TotalAmount.ShouldBe(10.00m); + } +} + +#region sample_wolverine_polecat_natural_key_aggregate + +public record PcNkOrderNumber(string Value); + +public class PcNkOrderAggregate +{ + public Guid Id { get; set; } + + [NaturalKey] + public PcNkOrderNumber OrderNum { get; set; } = null!; + + public decimal TotalAmount { get; set; } + public string CustomerName { get; set; } = string.Empty; + public bool IsComplete { get; set; } + + [NaturalKeySource] + public void Apply(PcNkOrderCreated e) + { + OrderNum = e.OrderNumber; + CustomerName = e.CustomerName; + } + + public void Apply(PcNkItemAdded e) + { + TotalAmount += e.Price; + } + + public void Apply(PcNkOrderCompleted e) + { + IsComplete = true; + } +} + +public record PcNkOrderCreated(PcNkOrderNumber OrderNumber, string CustomerName); +public record PcNkItemAdded(string ItemName, decimal Price); +public record PcNkOrderCompleted; + +#endregion + +#region sample_wolverine_polecat_natural_key_commands + +public record AddPcNkOrderItem(PcNkOrderNumber OrderNum, string ItemName, decimal Price); +public record AddPcNkOrderItems(PcNkOrderNumber OrderNum, (string Name, decimal Price)[] Items); +public record CompletePcNkOrder(PcNkOrderNumber OrderNum); + +#endregion + +#region sample_wolverine_polecat_natural_key_handlers + +public static class PcNkOrderHandler +{ + public static PcNkItemAdded Handle(AddPcNkOrderItem command, + [WriteAggregate] PcNkOrderAggregate aggregate) + { + return new PcNkItemAdded(command.ItemName, command.Price); + } + + public static IEnumerable Handle(AddPcNkOrderItems command, + [WriteAggregate] PcNkOrderAggregate aggregate) + { + foreach (var (name, price) in command.Items) + { + yield return new PcNkItemAdded(name, price); + } + } + + public static void Handle(CompletePcNkOrder command, + [WriteAggregate] IEventStream stream) + { + stream.AppendOne(new PcNkOrderCompleted()); + } +} + +#endregion diff --git a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs index f41bda75a..f7ebf11fa 100644 --- a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs +++ b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs @@ -42,6 +42,22 @@ public AggregateHandlerAttribute() : this(ConcurrencyStyle.Optimistic) internal ConcurrencyStyle LoadStyle { get; } + /// + /// If true, Marten will enforce an optimistic concurrency check on this stream even if no + /// events are appended at the time of calling SaveChangesAsync(). This is useful when you want + /// to ensure the stream version has not advanced since it was fetched, even if the command + /// handler decides not to emit any new events. + /// + public bool AlwaysEnforceConsistency { get; set; } + + /// + /// Override the name of the member on the command type used to find the expected stream version + /// for optimistic concurrency checks. By default, Wolverine looks for a member named "Version" + /// of type int or long. This is useful in multi-stream operations where each stream needs + /// its own version source. + /// + public string? VersionSource { get; set; } + /// /// Override or "help" Wolverine to understand which type is the aggregate type /// @@ -74,11 +90,11 @@ public override void Modify(IChain chain, GenerationRules rules, IServiceContain AggregateType ??= AggregateHandling.DetermineAggregateType(chain); (AggregateIdMember, VersionMember) = - AggregateHandling.DetermineAggregateIdAndVersion(AggregateType, CommandType, container); + AggregateHandling.DetermineAggregateIdAndVersion(AggregateType, CommandType, container, VersionSource); var aggregateFrame = new MemberAccessFrame(CommandType, AggregateIdMember, $"{Variable.DefaultArgName(AggregateType)}_Id"); - + var versionFrame = VersionMember == null ? null : new MemberAccessFrame(CommandType,VersionMember, $"{Variable.DefaultArgName(CommandType)}_Version"); var handling = new AggregateHandling(this) @@ -86,7 +102,8 @@ public override void Modify(IChain chain, GenerationRules rules, IServiceContain AggregateType = AggregateType, AggregateId = aggregateFrame.Variable, LoadStyle = LoadStyle, - Version = versionFrame?.Variable + Version = versionFrame?.Variable, + AlwaysEnforceConsistency = AlwaysEnforceConsistency }; handling.Apply(chain, container); diff --git a/src/Persistence/Wolverine.Marten/AggregateHandling.cs b/src/Persistence/Wolverine.Marten/AggregateHandling.cs index e946861b6..54f821441 100644 --- a/src/Persistence/Wolverine.Marten/AggregateHandling.cs +++ b/src/Persistence/Wolverine.Marten/AggregateHandling.cs @@ -31,7 +31,9 @@ internal record AggregateHandling(IDataRequirement Requirement) public ConcurrencyStyle LoadStyle { get; init; } public Variable? Version { get; init; } + public bool AlwaysEnforceConsistency { get; init; } public ParameterInfo? Parameter { get; set; } + public bool IsNaturalKey { get; init; } public Variable Apply(IChain chain, IServiceContainer container) { @@ -127,7 +129,7 @@ public static bool TryLoad(IChain chain, out AggregateHandling handling) } internal static (MemberInfo, MemberInfo?) DetermineAggregateIdAndVersion(Type aggregateType, Type commandType, - IServiceContainer container) + IServiceContainer container, string? versionSource = null) { if (commandType.Closes(typeof(IEvent<>))) { @@ -147,10 +149,29 @@ internal static (MemberInfo, MemberInfo?) DetermineAggregateIdAndVersion(Type ag } var aggregateId = DetermineAggregateIdMember(aggregateType, commandType); - var version = DetermineVersionMember(commandType); + var version = versionSource != null + ? DetermineVersionMemberByName(commandType, versionSource) + : DetermineVersionMember(commandType); return (aggregateId, version); } + internal static MemberInfo? DetermineVersionMemberByName(Type commandType, string memberName) + { + var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var prop = commandType.GetProperties(bindingFlags) + .FirstOrDefault(x => x.Name.EqualsIgnoreCase(memberName) + && (x.PropertyType == typeof(int) || x.PropertyType == typeof(long))); + + if (prop != null) return prop; + + var field = commandType.GetFields(bindingFlags) + .FirstOrDefault(x => x.Name.EqualsIgnoreCase(memberName) + && (x.FieldType == typeof(int) || x.FieldType == typeof(long))); + + return field; + } + internal static void ValidateMethodSignatureForEmittedEvents(IChain chain, MethodCall firstCall, IChain handlerChain) { @@ -173,6 +194,13 @@ internal static MemberInfo DetermineAggregateIdMember(Type aggregateType, Type c ?? commandType.GetMembers().FirstOrDefault(x => x.Name.EqualsIgnoreCase(conventionalMemberName) || x.Name.EqualsIgnoreCase("Id")); + if (member == null) + { + // Fall back: if the aggregate uses a strong typed ID, look for a single + // property of that exact type on the command + member = TryFindStrongTypedIdMember(aggregateType, commandType); + } + if (member == null) { throw new InvalidOperationException( @@ -182,6 +210,31 @@ internal static MemberInfo DetermineAggregateIdMember(Type aggregateType, Type c return member; } + internal static MemberInfo? TryFindStrongTypedIdMember(Type aggregateType, Type commandType) + { + // Determine the strong typed ID type from the aggregate + var strongTypedIdType = WriteAggregateAttribute.FindIdentifiedByType(aggregateType); + + if (strongTypedIdType == null) + { + // Check the Id property on the aggregate itself + var idProp = aggregateType.GetProperty("Id"); + if (idProp != null && !WriteAggregateAttribute.IsPrimitiveIdType(idProp.PropertyType)) + { + strongTypedIdType = idProp.PropertyType; + } + } + + if (strongTypedIdType == null) return null; + + // Look for a single property of the strong typed ID type on the command + var matchingProps = commandType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + return matchingProps.Length == 1 ? matchingProps[0] : null; + } + internal static void DetermineEventCaptureHandling(IChain chain, MethodCall firstCall, Type aggregateType) { var asyncEnumerable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); diff --git a/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs b/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs new file mode 100644 index 000000000..fd6ff998e --- /dev/null +++ b/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs @@ -0,0 +1,176 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Tags; +using Marten.Events.Dcb; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Marten.Codegen; +using Wolverine.Marten.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Marten; + +/// +/// Marks a parameter to a Wolverine message handler or HTTP endpoint method as being part of the +/// Marten Dynamic Consistency Boundary (DCB) workflow. The handler must have a Load/Before method +/// that returns an . Wolverine will call +/// IDocumentSession.Events.FetchForWritingByTags<T>(query) and project the matching +/// events into the parameter type. Return values from the handler are appended via +/// . +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class BoundaryModelAttribute : WolverineParameterAttribute, IDataRequirement, IRefersToAggregate +{ + private OnMissing? _onMissing; + + public bool Required { get; set; } + public string MissingMessage { get; set; } + + public OnMissing OnMissing + { + get => _onMissing ?? OnMissing.Simple404; + set => _onMissing = value; + } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, + GenerationRules rules) + { + _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; + + var aggregateType = parameter.ParameterType; + if (aggregateType.IsNullable()) + { + aggregateType = aggregateType.GetInnerTypeFromNullable(); + } + + var isBoundaryParameter = false; + if (aggregateType.Closes(typeof(IEventBoundary<>))) + { + aggregateType = aggregateType.GetGenericArguments()[0]; + isBoundaryParameter = true; + } + + // Validate that a Load/Before method returning EventTagQuery exists on the handler type. + // The method itself will be added to the middleware chain by ApplyImpliedMiddlewareFromHandlers() + // which runs after this Modify() call. The LoadBoundaryFrame resolves the EventTagQuery + // variable lazily during FindVariables(). + var firstCall = chain.HandlerCalls().First(); + var handlerType = firstCall.HandlerType; + var loadMethodNames = new[] { "Load", "LoadAsync", "Before", "BeforeAsync" }; + + var loadMethod = handlerType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance) + .FirstOrDefault(m => loadMethodNames.Contains(m.Name) && + (m.ReturnType == typeof(EventTagQuery) || + m.ReturnType == typeof(Task) || + m.ReturnType == typeof(ValueTask))); + + if (loadMethod == null) + { + throw new InvalidOperationException( + $"[BoundaryModel] on parameter '{parameter.Name}' in {chain} requires a Load() or Before() method " + + $"that returns an EventTagQuery to define the tag query for FetchForWritingByTags<{aggregateType.Name}>()."); + } + + new MartenPersistenceFrameProvider().ApplyTransactionSupport(chain, container); + + // The EventTagQuery variable will be resolved lazily from the Load method's return value + var loader = new LoadBoundaryFrame(aggregateType); + chain.Middleware.Add(loader); + + var boundary = loader.Boundary; + + // Set up event capture: return values from the handler get appended via the boundary + DetermineEventCaptureHandling(chain, aggregateType); + + // Extract the aggregate from the boundary + var boundaryInterfaceType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); + Variable aggregateVariable = new MemberAccessVariable(boundary, + boundaryInterfaceType.GetProperty(nameof(IEventBoundary.Aggregate))!); + + if (Required) + { + var otherFrames = chain.AddStopConditionIfNull(aggregateVariable, null, this); + var block = new LoadEntityFrameBlock(aggregateVariable, otherFrames); + block.AlsoMirrorAsTheCreator(boundary); + chain.Middleware.Add(block); + aggregateVariable = block.Mirror; + } + + // If the parameter is IEventBoundary, return the boundary itself + if (isBoundaryParameter) + { + return boundary; + } + + // Relay the aggregate to the handler + if (parameter.ParameterType == aggregateType || parameter.ParameterType.IsNullable() && + parameter.ParameterType.GetInnerTypeFromNullable() == aggregateType) + { + firstCall.TrySetArgument(parameter.Name, aggregateVariable); + } + + // Store deferred assignment for middleware methods (Before/After) + AggregateHandling.StoreDeferredMiddlewareVariable(chain, parameter.Name, aggregateVariable); + + // Also do immediate relay for any middleware already present + foreach (var methodCall in chain.Middleware.OfType()) + { + if (!methodCall.TrySetArgument(parameter.Name, aggregateVariable)) + { + methodCall.TrySetArgument(aggregateVariable); + } + } + + // Store boundary handling info in chain tags for reference + chain.Tags["BoundaryHandling"] = new BoundaryHandlingTag(aggregateType, boundary); + + return aggregateVariable; + } + + internal static void DetermineEventCaptureHandling(IChain chain, Type aggregateType) + { + var firstCall = chain.HandlerCalls().First(); + + var asyncEnumerable = + firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); + if (asyncEnumerable != null) + { + asyncEnumerable.UseReturnAction(_ => + { + return typeof(ApplyBoundaryEventsFromAsyncEnumerableFrame<>).CloseAndBuildAs( + asyncEnumerable, aggregateType); + }); + return; + } + + var eventsVariable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(Events)) ?? + firstCall.Creates.FirstOrDefault(x => + x.VariableType.CanBeCastTo>() && + !x.VariableType.CanBeCastTo()); + + if (eventsVariable != null) + { + eventsVariable.UseReturnAction( + v => typeof(RegisterBoundaryEventsFrame<>) + .CloseAndBuildAs(eventsVariable, aggregateType) + .WrapIfNotNull(v), "Append events via DCB boundary"); + return; + } + + // If there's no IEventBoundary parameter, assume return values are events + if (!firstCall.Method.GetParameters() + .Any(x => x.ParameterType.Closes(typeof(IEventBoundary<>)))) + { + chain.ReturnVariableActionSource = new BoundaryEventCaptureActionSource(aggregateType); + } + } +} + +internal record BoundaryHandlingTag(Type AggregateType, Variable Boundary); diff --git a/src/Persistence/Wolverine.Marten/Codegen/BoundaryEventCapture.cs b/src/Persistence/Wolverine.Marten/Codegen/BoundaryEventCapture.cs new file mode 100644 index 000000000..1307e45f4 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Codegen/BoundaryEventCapture.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten.Events.Dcb; +using Wolverine.Configuration; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Marten.Codegen; + +/// +/// Registers a collection of events via IEventBoundary.AppendMany() for DCB workflows. +/// +internal class RegisterBoundaryEventsFrame : MethodCall where T : notnull +{ + public RegisterBoundaryEventsFrame(Variable returnVariable) : base(typeof(IEventBoundary), + FindMethod(returnVariable.VariableType)) + { + Arguments[0] = returnVariable; + CommentText = "Capturing events returned from handler and appending via DCB boundary"; + } + + internal static MethodInfo FindMethod(Type responseType) + { + return responseType.CanBeCastTo>() + ? ReflectionHelper.GetMethod>(x => x.AppendMany(new List()))! + : ReflectionHelper.GetMethod>(x => x.AppendOne(null))!; + } +} + +/// +/// Handles async enumerable return values by appending each event via IEventBoundary.AppendOne(). +/// +internal class ApplyBoundaryEventsFromAsyncEnumerableFrame : AsyncFrame where T : notnull +{ + private readonly Variable _returnValue; + private Variable? _boundary; + + public ApplyBoundaryEventsFromAsyncEnumerableFrame(Variable returnValue) + { + _returnValue = returnValue; + uses.Add(returnValue); + } + + public string Description => "Append events from async enumerable to DCB boundary for " + + typeof(T).FullNameInCode(); + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _boundary = chain.FindVariable(typeof(IEventBoundary)); + yield return _boundary; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var variableName = (typeof(T).Name + "Event").ToCamelCase(); + + writer.WriteComment(Description); + writer.Write( + $"await foreach (var {variableName} in {_returnValue.Usage}) {_boundary!.Usage}.{nameof(IEventBoundary.AppendOne)}({variableName});"); + Next?.GenerateCode(method, writer); + } +} + +/// +/// Makes each individual return value from a handler method be appended as an event +/// via IEventBoundary.AppendOne() for DCB workflows. +/// +internal class BoundaryEventCaptureActionSource : IReturnVariableActionSource +{ + private readonly Type _aggregateType; + + public BoundaryEventCaptureActionSource(Type aggregateType) + { + _aggregateType = aggregateType; + } + + public IReturnVariableAction Build(IChain chain, Variable variable) + { + return new ActionSource(_aggregateType, variable); + } + + internal class ActionSource : IReturnVariableAction + { + private readonly Type _aggregateType; + private readonly Variable _variable; + + public ActionSource(Type aggregateType, Variable variable) + { + _aggregateType = aggregateType; + _variable = variable; + } + + public string Description => + "Append event via DCB boundary for aggregate " + _aggregateType.FullNameInCode(); + + public IEnumerable Dependencies() + { + yield break; + } + + public IEnumerable Frames() + { + var boundaryType = typeof(IEventBoundary<>).MakeGenericType(_aggregateType); + + yield return new MethodCall(boundaryType, nameof(IEventBoundary.AppendOne)) + { + Arguments = + { + [0] = _variable + } + }; + } + } +} diff --git a/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs index 726c2eccb..a4bd3f1be 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs @@ -25,7 +25,7 @@ public LoadAggregateFrame(AggregateHandling att) { _att = att; _identity = _att.AggregateId; - + if (_att is { LoadStyle: ConcurrencyStyle.Optimistic, Version: not null }) { _version = _att.Version; @@ -35,17 +35,35 @@ public LoadAggregateFrame(AggregateHandling att) Stream = new Variable(_eventStreamType, this); _rawIdentity = _identity; - if (_rawIdentity.VariableType != typeof(Guid) && _rawIdentity.VariableType != typeof(string)) + // For natural keys, keep the full natural key object (don't unwrap) + if (!_att.IsNaturalKey && _rawIdentity.VariableType != typeof(Guid) && _rawIdentity.VariableType != typeof(string)) { var valueType = ValueTypeInfo.ForType(_rawIdentity.VariableType); _rawIdentity = new MemberAccessVariable(_identity, valueType.ValueProperty); } } - + public Variable Stream { get; } + public bool IsNaturalKey => _att.IsNaturalKey; + + private string NaturalKeyFetchForWriting(string targetUsage, string? cancellationTokenUsage = null) + { + var aggType = _att.AggregateType.FullNameInCode(); + var nkType = _identity.VariableType.FullNameInCode(); + var args = cancellationTokenUsage != null + ? $"{_identity.Usage}, {cancellationTokenUsage}" + : _identity.Usage; + return $"{targetUsage}.Events.FetchForWriting<{aggType}, {nkType}>({args})"; + } public void WriteCodeToEnlistInBatchQuery(GeneratedMethod method, ISourceWriter writer) { + if (_att.IsNaturalKey) + { + writer.WriteLine($"var {_batchQueryItem.Usage} = {NaturalKeyFetchForWriting(_batchQuery!.Usage)};"); + return; + } + if (_att.LoadStyle == ConcurrencyStyle.Exclusive) { writer.WriteLine($"var {_batchQueryItem.Usage} = {_batchQuery!.Usage}.Events.FetchForExclusiveWriting<{_att.AggregateType.FullNameInCode()}>({_rawIdentity.Usage});"); @@ -71,7 +89,7 @@ public override IEnumerable FindVariables(IMethodVariables chain) { yield return _identity; if (_version != null) yield return _version; - + _session = chain.FindVariable(typeof(IDocumentSession)); yield return _session; @@ -89,7 +107,11 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) writer.WriteComment("Loading Marten aggregate as part of the aggregate handler workflow"); if (_batchQueryItem == null) { - if (_att.LoadStyle == ConcurrencyStyle.Exclusive) + if (_att.IsNaturalKey) + { + writer.WriteLine($"var {Stream.Usage} = await {NaturalKeyFetchForWriting(_session!.Usage, _token!.Usage)};"); + } + else if (_att.LoadStyle == ConcurrencyStyle.Exclusive) { writer.WriteLine($"var {Stream.Usage} = await {_session!.Usage}.Events.FetchForExclusiveWriting<{_att.AggregateType.FullNameInCode()}>({_rawIdentity.Usage}, {_token.Usage});"); } @@ -105,9 +127,14 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) else { writer.Write( - $"var {Stream.Usage} = await {_batchQueryItem.Usage}.ConfigureAwait(false);"); + $"var {Stream.Usage} = await {_batchQueryItem.Usage}.ConfigureAwait(false);"); } - + + if (_att.AlwaysEnforceConsistency) + { + writer.WriteLine($"{Stream.Usage}.{nameof(IEventStream.AlwaysEnforceConsistency)} = true;"); + } + Next?.GenerateCode(method, writer); } -} \ No newline at end of file +} diff --git a/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs new file mode 100644 index 000000000..7303da80d --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs @@ -0,0 +1,49 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using JasperFx.Events.Tags; +using Marten; +using Marten.Events.Dcb; + +namespace Wolverine.Marten.Codegen; + +internal class LoadBoundaryFrame : AsyncFrame +{ + private readonly Type _aggregateType; + private Variable? _query; + private Variable? _session; + private Variable? _token; + private readonly Type _boundaryType; + + public LoadBoundaryFrame(Type aggregateType, Variable? query = null) + { + _aggregateType = aggregateType; + _query = query; + _boundaryType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); + Boundary = new Variable(_boundaryType, this); + } + + public Variable Boundary { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _query ??= chain.FindVariable(typeof(EventTagQuery)); + yield return _query; + + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _token = chain.FindVariable(typeof(CancellationToken)); + yield return _token; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Loading DCB boundary model via FetchForWritingByTags"); + writer.WriteLine( + $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{_aggregateType.FullNameInCode()}>({_query.Usage}, {_token!.Usage});"); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Marten/Codegen/MartenQueryingFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/MartenQueryingFrame.cs index 74c065535..f15d8d82f 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/MartenQueryingFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/MartenQueryingFrame.cs @@ -30,6 +30,14 @@ public void Apply(IGeneratedMethod method) } } + private static bool IsBatchable(IBatchableFrame frame) + { + // Natural key aggregate loads cannot be batched because IBatchedQuery + // does not have a FetchForWriting overload + if (frame is LoadAggregateFrame laf && laf.IsNaturalKey) return false; + return true; + } + private static (int, IReadOnlyList frames) sortThroughFrames(IGeneratedMethod method) { var list = new List(); @@ -38,7 +46,7 @@ private static (int, IReadOnlyList frames) sortThroughFrames(IG for (int i = 0; i < method.Frames.Count; i++) { var frame = method.Frames[i]; - if (frame is LoadEntityFrameBlock block && block.Creator is IBatchableFrame b) + if (frame is LoadEntityFrameBlock block && block.Creator is IBatchableFrame b && IsBatchable(b)) { list.Add(b); if (index == -1) @@ -46,7 +54,7 @@ private static (int, IReadOnlyList frames) sortThroughFrames(IG index = i; } } - else if (frame is IBatchableFrame batchable) + else if (frame is IBatchableFrame batchable && IsBatchable(batchable)) { list.Add(batchable); if (index == -1) diff --git a/src/Persistence/Wolverine.Marten/ConsistentAggregateAttribute.cs b/src/Persistence/Wolverine.Marten/ConsistentAggregateAttribute.cs new file mode 100644 index 000000000..b42df9676 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/ConsistentAggregateAttribute.cs @@ -0,0 +1,20 @@ +namespace Wolverine.Marten; + +/// +/// Marks a parameter to a Wolverine message handler as being part of the Marten event sourcing +/// "aggregate handler" workflow with set to true, +/// meaning Marten will enforce an optimistic concurrency check on referenced streams even if no events are appended. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class ConsistentAggregateAttribute : WriteAggregateAttribute +{ + public ConsistentAggregateAttribute() : base() + { + AlwaysEnforceConsistency = true; + } + + public ConsistentAggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) + { + AlwaysEnforceConsistency = true; + } +} diff --git a/src/Persistence/Wolverine.Marten/ConsistentAggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Marten/ConsistentAggregateHandlerAttribute.cs new file mode 100644 index 000000000..9804abd27 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/ConsistentAggregateHandlerAttribute.cs @@ -0,0 +1,21 @@ +namespace Wolverine.Marten; + +/// +/// Applies the aggregate handler workflow with set to true, +/// meaning Marten will enforce an optimistic concurrency check on referenced streams even if no events are appended. +/// This is useful for cross-stream operations where you want to ensure referenced aggregates have not changed +/// since they were fetched. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ConsistentAggregateHandlerAttribute : AggregateHandlerAttribute +{ + public ConsistentAggregateHandlerAttribute(ConcurrencyStyle loadStyle) : base(loadStyle) + { + AlwaysEnforceConsistency = true; + } + + public ConsistentAggregateHandlerAttribute() : base(ConcurrencyStyle.Optimistic) + { + AlwaysEnforceConsistency = true; + } +} diff --git a/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs b/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs index 93b7ebc10..9e3fc6a4a 100644 --- a/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs +++ b/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs @@ -12,6 +12,7 @@ using Wolverine.Configuration; using Wolverine.Marten.Codegen; using Wolverine.Marten.Persistence.Sagas; +using JasperFx.Events.Aggregation; using Wolverine.Persistence; using Wolverine.Runtime; @@ -20,7 +21,7 @@ namespace Wolverine.Marten; /// /// Use Marten's FetchLatest() API to retrieve the parameter value /// -public class ReadAggregateAttribute : WolverineParameterAttribute, IDataRequirement +public class ReadAggregateAttribute : WolverineParameterAttribute, IDataRequirement, IRefersToAggregate { private OnMissing? _onMissing; @@ -61,7 +62,12 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC if (!tryFindIdentityVariable(chain, parameter, idType, out var identity)) { - throw new InvalidEntityLoadUsageException(this, parameter); + // Fall back to strong typed ID matching + identity = tryFindStrongTypedIdentityVariable(chain, parameter.ParameterType, idType); + if (identity == null) + { + throw new InvalidEntityLoadUsageException(this, parameter); + } } var frame = new FetchLatestAggregateFrame(parameter.ParameterType, identity); @@ -88,6 +94,35 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC return returnVariable; } + + private Variable? tryFindStrongTypedIdentityVariable(IChain chain, Type aggregateType, Type idType) + { + var strongTypedIdType = idType; + + if (WriteAggregateAttribute.IsPrimitiveIdType(idType)) + { + strongTypedIdType = WriteAggregateAttribute.FindIdentifiedByType(aggregateType); + } + + if (strongTypedIdType == null || WriteAggregateAttribute.IsPrimitiveIdType(strongTypedIdType)) return null; + + var inputType = chain.InputType(); + if (inputType == null) return null; + + var matchingProps = inputType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + if (matchingProps.Length == 1) + { + if (chain.TryFindVariable(matchingProps[0].Name, ValueSource, strongTypedIdType, out var variable)) + { + return variable; + } + } + + return null; + } } internal class FetchLatestAggregateFrame : AsyncFrame, IBatchableFrame diff --git a/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs b/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs index 91b64371b..46e453971 100644 --- a/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs +++ b/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs @@ -7,6 +7,7 @@ using JasperFx.Core.Reflection; using Marten; using Marten.Events; +using JasperFx.Events.Aggregation; using Wolverine.Attributes; using Wolverine.Configuration; using Wolverine.Persistence; @@ -21,7 +22,7 @@ namespace Wolverine.Marten; /// "aggregate handler" workflow /// [AttributeUsage(AttributeTargets.Parameter)] -public class WriteAggregateAttribute : WolverineParameterAttribute, IDataRequirement, IMayInferMessageIdentity +public class WriteAggregateAttribute : WolverineParameterAttribute, IDataRequirement, IMayInferMessageIdentity, IRefersToAggregate { public WriteAggregateAttribute() { @@ -51,6 +52,21 @@ public OnMissing OnMissing /// public ConcurrencyStyle LoadStyle { get; set; } = ConcurrencyStyle.Optimistic; + /// + /// If true, Marten will enforce an optimistic concurrency check on this stream even if no + /// events are appended at the time of calling SaveChangesAsync(). This is useful when you want + /// to ensure the stream version has not advanced since it was fetched, even if the command + /// handler decides not to emit any new events. + /// + public bool AlwaysEnforceConsistency { get; set; } + + /// + /// Override the name of the variable or member used to find the expected stream version + /// for optimistic concurrency checks. By default, Wolverine looks for a variable named "version". + /// This is useful in multi-stream operations where each stream needs its own version source. + /// + public string? VersionSource { get; set; } + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) { _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; @@ -68,13 +84,24 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC var store = container.GetInstance(); var idType = store.Options.FindOrResolveDocumentType(aggregateType).IdType; - var identity = FindIdentity(aggregateType, idType, chain) ?? throw new InvalidOperationException( - $"Unable to determine an aggregate id for the parameter '{parameter.Name}' on method {chain.HandlerCalls().First()}"); + var identity = FindIdentity(aggregateType, idType, chain); + var isNaturalKey = false; + + // If standard identity resolution failed, check for natural key support + if (identity == null && store.Options is StoreOptions storeOptions) + { + var naturalKey = storeOptions.Projections.FindNaturalKeyDefinition(aggregateType); + if (naturalKey != null) + { + identity = FindIdentity(aggregateType, naturalKey.OuterType, chain); + if (identity != null) isNaturalKey = true; + } + } if (identity == null) { throw new InvalidOperationException( - "Cannot determine an identity variable for this aggregate from the route arguments"); + $"Unable to determine an aggregate id for the parameter '{parameter.Name}' on method {chain.HandlerCalls().First()}"); } var version = findVersionVariable(chain); @@ -87,7 +114,9 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC AggregateId = identity, LoadStyle = LoadStyle, Version = version, - Parameter = parameter + AlwaysEnforceConsistency = AlwaysEnforceConsistency, + Parameter = parameter, + IsNaturalKey = isNaturalKey }; return handling.Apply(chain, container); @@ -95,24 +124,29 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC internal Variable? findVersionVariable(IChain chain) { - if (chain.TryFindVariable("version", ValueSource.Anything, typeof(long), out var variable)) + // If no explicit VersionSource is set and another aggregate handling already + // exists on this chain, skip automatic version discovery to avoid multiple + // streams accidentally sharing the same "version" variable + if (VersionSource == null && chain.Tags.ContainsKey(nameof(AggregateHandling))) { - return variable; + return null; } - if (chain.TryFindVariable("version", ValueSource.Anything, typeof(int), out var v2)) + var name = VersionSource ?? "version"; + + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(long), out var variable)) { - return v2; + return variable; } - if (chain.TryFindVariable("version", ValueSource.Anything, typeof(uint), out var v3)) + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(int), out var v2)) { - return v3; + return v2; } - if (chain.TryFindVariable("version", ValueSource.Anything, typeof(uint), out var v4)) + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(uint), out var v3)) { - return v4; + return v3; } return null; @@ -138,9 +172,52 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC return v3; } + // Fall back to strong typed identifier matching: if the identity type is a + // strong typed ID (not a primitive like Guid/string), look for a single property + // of that exact type on the input/command type. + var strongTypedIdType = idType; + + // If idType is primitive, check if the aggregate declares IdentifiedBy + if (IsPrimitiveIdType(idType)) + { + strongTypedIdType = FindIdentifiedByType(aggregateType); + } + + if (strongTypedIdType != null && !IsPrimitiveIdType(strongTypedIdType)) + { + var inputType = chain.InputType(); + if (inputType != null) + { + var matchingProps = inputType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + if (matchingProps.Length == 1) + { + if (chain.TryFindVariable(matchingProps[0].Name, ValueSource.Anything, strongTypedIdType, out var v4)) + { + return v4; + } + } + } + } + return null; } + internal static bool IsPrimitiveIdType(Type type) + { + return type == typeof(Guid) || type == typeof(string) || type == typeof(int) || type == typeof(long); + } + + internal static Type? FindIdentifiedByType(Type aggregateType) + { + var identifiedByInterface = aggregateType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IdentifiedBy<>)); + + return identifiedByInterface?.GetGenericArguments()[0]; + } + public bool TryInferMessageIdentity(IChain chain, out PropertyInfo property) { var inputType = chain.InputType(); diff --git a/src/Persistence/Wolverine.Polecat/AggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Polecat/AggregateHandlerAttribute.cs new file mode 100644 index 000000000..8cf15f7bd --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/AggregateHandlerAttribute.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.CodeGeneration.Services; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using Polecat; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Polecat.Codegen; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; +using Wolverine.Runtime.Partitioning; +using Microsoft.Extensions.DependencyInjection; + +namespace Wolverine.Polecat; + +/// +/// Applies middleware to Wolverine message actions to apply a workflow with concurrency protections for +/// "command" messages that use a Polecat projected aggregate to "decide" what +/// on new events to persist to the aggregate stream. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class AggregateHandlerAttribute : ModifyChainAttribute, IDataRequirement, IMayInferMessageIdentity +{ + public AggregateHandlerAttribute(ConcurrencyStyle loadStyle) + { + LoadStyle = loadStyle; + } + + public AggregateHandlerAttribute() : this(ConcurrencyStyle.Optimistic) + { + } + + internal ConcurrencyStyle LoadStyle { get; } + + public bool AlwaysEnforceConsistency { get; set; } + public string? VersionSource { get; set; } + public Type? AggregateType { get; set; } + internal MemberInfo? AggregateIdMember { get; set; } + internal Type? CommandType { get; private set; } + public MemberInfo? VersionMember { get; private set; } + + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) + { + _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; + + if (chain.Tags.ContainsKey(nameof(AggregateHandlerAttribute))) + { + return; + } + + chain.Tags.Add(nameof(AggregateHandlerAttribute), "true"); + + CommandType = chain.InputType(); + if (CommandType == null) + { + throw new InvalidOperationException( + $"Cannot apply Polecat aggregate handler workflow to chain {chain} because it has no input type"); + } + + AggregateType ??= AggregateHandling.DetermineAggregateType(chain); + + (AggregateIdMember, VersionMember) = + AggregateHandling.DetermineAggregateIdAndVersion(AggregateType, CommandType, container, VersionSource); + + var aggregateFrame = new MemberAccessFrame(CommandType, AggregateIdMember, + $"{Variable.DefaultArgName(AggregateType)}_Id"); + + var versionFrame = VersionMember == null ? null : new MemberAccessFrame(CommandType, VersionMember, $"{Variable.DefaultArgName(CommandType)}_Version"); + + var handling = new AggregateHandling(this) + { + AggregateType = AggregateType, + AggregateId = aggregateFrame.Variable, + LoadStyle = LoadStyle, + Version = versionFrame?.Variable, + AlwaysEnforceConsistency = AlwaysEnforceConsistency + }; + + handling.Apply(chain, container); + } + + public bool TryInferMessageIdentity(IChain chain, out PropertyInfo property) + { + var inputType = chain.InputType(); + property = default!; + + if (inputType.Closes(typeof(IEvent<>))) + { + if (AggregateHandling.TryLoad(chain, out var handling)) + { + property = handling.AggregateId.VariableType == typeof(string) + ? inputType.GetProperty(nameof(IEvent.StreamKey)) + : inputType.GetProperty(nameof(IEvent.StreamId)); + } + + return property != null; + } + + var aggregateType = AggregateType ?? AggregateHandling.DetermineAggregateType(chain); + var idMember = AggregateHandling.DetermineAggregateIdMember(aggregateType, inputType); + property = idMember as PropertyInfo; + return property != null; + } + + private OnMissing? _onMissing; + + public bool Required { get; set; } + public string MissingMessage { get; set; } + + public OnMissing OnMissing + { + get => _onMissing ?? OnMissing.Simple404; + set => _onMissing = value; + } +} diff --git a/src/Persistence/Wolverine.Polecat/AggregateHandling.cs b/src/Persistence/Wolverine.Polecat/AggregateHandling.cs new file mode 100644 index 000000000..5d7f7d035 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/AggregateHandling.cs @@ -0,0 +1,443 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using Microsoft.Extensions.DependencyInjection; +using Polecat; +using Polecat.Events; +using Wolverine.Configuration; +using Wolverine.Polecat.Codegen; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Polecat; + +internal record AggregateHandling(IDataRequirement Requirement) +{ + private static readonly Type _versioningBaseType = typeof(AggregateVersioning<>); + + public Type AggregateType { get; init; } + public Variable AggregateId { get; init; } + + public ConcurrencyStyle LoadStyle { get; init; } + public Variable? Version { get; init; } + public bool AlwaysEnforceConsistency { get; init; } + public ParameterInfo? Parameter { get; set; } + public bool IsNaturalKey { get; init; } + + public Variable Apply(IChain chain, IServiceContainer container) + { + Store(chain); + + new PolecatPersistenceFrameProvider().ApplyTransactionSupport(chain, container); + + var loader = new LoadAggregateFrame(this); + chain.Middleware.Add(loader); + + var firstCall = chain.HandlerCalls().First(); + + var eventStream = loader.Stream!; + if (Parameter != null) + { + eventStream.OverrideName("stream_" + Parameter.Name); + } + + if (AggregateType == firstCall.HandlerType) + { + chain.Middleware.Add(new MissingAggregateCheckFrame(AggregateType, AggregateId, + eventStream)); + } + + DetermineEventCaptureHandling(chain, firstCall, AggregateType); + + ValidateMethodSignatureForEmittedEvents(chain, firstCall, chain); + var aggregate = RelayAggregateToHandlerMethod(eventStream, chain, firstCall, AggregateType); + + if (Parameter != null && Parameter.ParameterType.Closes(typeof(IEventStream<>))) + { + return eventStream; + } + + return aggregate; + } + + public void Store(IChain chain) + { + if (chain.Tags.TryGetValue(nameof(AggregateHandling), out var raw)) + { + if (raw is AggregateHandling handling) + { + if (ReferenceEquals(handling, this)) return; + chain.Tags[nameof(AggregateHandling)] = new List { handling, this }; + } + else if (raw is List list) + { + list.Add(this); + } + } + else + { + chain.Tags[nameof(AggregateHandling)] = this; + } + } + + public static bool TryLoad(IChain chain, out AggregateHandling handling) + { + if (chain.Tags.TryGetValue(nameof(AggregateHandling), out var raw)) + { + if (raw is AggregateHandling h) + { + handling = h; + return true; + } + } + + handling = default; + return false; + } + + public static bool TryLoad(IChain chain, out AggregateHandling handling) + { + if (chain.Tags.TryGetValue(nameof(AggregateHandling), out var raw)) + { + if (raw is AggregateHandling h && h.AggregateType == typeof(T)) + { + handling = h; + return true; + } + + if (raw is List list) + { + handling = list.FirstOrDefault(x => x.AggregateType == typeof(T)); + return handling != null; + } + } + + handling = default; + return false; + } + + internal static (MemberInfo, MemberInfo?) DetermineAggregateIdAndVersion(Type aggregateType, Type commandType, + IServiceContainer container, string? versionSource = null) + { + if (commandType.Closes(typeof(IEvent<>))) + { + var concreteEventType = typeof(Event<>).MakeGenericType(commandType.GetGenericArguments()[0]); + + var options = container.Services.GetRequiredService(); + var flattenHierarchy = BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy; + var member = options.Events.StreamIdentity == StreamIdentity.AsGuid + ? concreteEventType.GetProperty(nameof(IEvent.StreamId), flattenHierarchy) + : concreteEventType.GetProperty(nameof(IEvent.StreamKey), flattenHierarchy); + + return (member!, null); + } + + var aggregateId = DetermineAggregateIdMember(aggregateType, commandType); + var version = versionSource != null + ? DetermineVersionMemberByName(commandType, versionSource) + : DetermineVersionMember(commandType); + return (aggregateId, version); + } + + internal static MemberInfo? DetermineVersionMemberByName(Type commandType, string memberName) + { + var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var prop = commandType.GetProperties(bindingFlags) + .FirstOrDefault(x => x.Name.EqualsIgnoreCase(memberName) + && (x.PropertyType == typeof(int) || x.PropertyType == typeof(long))); + + if (prop != null) return prop; + + var field = commandType.GetFields(bindingFlags) + .FirstOrDefault(x => x.Name.EqualsIgnoreCase(memberName) + && (x.FieldType == typeof(int) || x.FieldType == typeof(long))); + + return field; + } + + internal static void ValidateMethodSignatureForEmittedEvents(IChain chain, MethodCall firstCall, + IChain handlerChain) + { + if (firstCall.Method.ReturnType == typeof(Task) || firstCall.Method.ReturnType == typeof(void)) + { + var parameters = chain.HandlerCalls().First().Method.GetParameters(); + var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); + if (stream == null) + { + throw new InvalidOperationException( + $"No events are emitted from handler {handlerChain} even though it is marked as an action that would emit Polecat events. Either return the events from the handler, or use the IEventStream service as an argument."); + } + } + } + + internal static MemberInfo DetermineAggregateIdMember(Type aggregateType, Type commandType) + { + var conventionalMemberName = $"{aggregateType.Name}Id"; + var member = commandType.GetMembers().FirstOrDefault(x => x.HasAttribute()) + ?? commandType.GetMembers().FirstOrDefault(x => + x.Name.EqualsIgnoreCase(conventionalMemberName) || x.Name.EqualsIgnoreCase("Id")); + + if (member == null) + { + member = TryFindStrongTypedIdMember(aggregateType, commandType); + } + + if (member == null) + { + throw new InvalidOperationException( + $"Unable to determine the aggregate id for aggregate type {aggregateType.FullNameInCode()} on command type {commandType.FullNameInCode()}. Either make a property or field named '{conventionalMemberName}', or decorate a member with the {typeof(IdentityAttribute).FullNameInCode()} attribute"); + } + + return member; + } + + internal static MemberInfo? TryFindStrongTypedIdMember(Type aggregateType, Type commandType) + { + var strongTypedIdType = WriteAggregateAttribute.FindIdentifiedByType(aggregateType); + + if (strongTypedIdType == null) + { + var idProp = aggregateType.GetProperty("Id"); + if (idProp != null && !WriteAggregateAttribute.IsPrimitiveIdType(idProp.PropertyType)) + { + strongTypedIdType = idProp.PropertyType; + } + } + + if (strongTypedIdType == null) return null; + + var matchingProps = commandType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + return matchingProps.Length == 1 ? matchingProps[0] : null; + } + + internal static void DetermineEventCaptureHandling(IChain chain, MethodCall firstCall, Type aggregateType) + { + var asyncEnumerable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); + if (asyncEnumerable != null) + { + asyncEnumerable.UseReturnAction(_ => + { + return typeof(ApplyEventsFromAsyncEnumerableFrame<>).CloseAndBuildAs(asyncEnumerable, + aggregateType); + }); + return; + } + + var eventsVariable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(Events)) ?? + firstCall.Creates.FirstOrDefault(x => + x.VariableType.CanBeCastTo>() && + !x.VariableType.CanBeCastTo()); + + if (eventsVariable != null) + { + eventsVariable.UseReturnAction( + v => typeof(RegisterEventsFrame<>).CloseAndBuildAs(eventsVariable, aggregateType) + .WrapIfNotNull(v), "Append events to the Polecat event stream"); + return; + } + + if (!firstCall.Method.GetParameters().Any(x => x.ParameterType.Closes(typeof(IEventStream<>)))) + { + chain.ReturnVariableActionSource = new EventCaptureActionSource(aggregateType); + } + } + + internal Variable RelayAggregateToHandlerMethod(Variable eventStream, IChain chain, MethodCall firstCall, + Type aggregateType) + { + Variable aggregateVariable = new MemberAccessVariable(eventStream, + typeof(IEventStream<>).MakeGenericType(aggregateType).GetProperty(nameof(IEventStream.Aggregate))); + + if (Requirement.Required) + { + var otherFrames = chain.AddStopConditionIfNull(aggregateVariable, AggregateId, Requirement); + var block = new LoadEntityFrameBlock(aggregateVariable, otherFrames); + block.AlsoMirrorAsTheCreator(eventStream); + chain.Middleware.Add(block); + aggregateVariable = block.Mirror; + } + + if (firstCall.HandlerType == aggregateType) + { + firstCall.Target = aggregateVariable; + } + else if (Parameter != null && Parameter.ParameterType.Closes(typeof(IEventStream<>))) + { + var index = Array.FindIndex(firstCall.Method.GetParameters(), x => x.Name == Parameter.Name); + firstCall.Arguments[index] = eventStream; + } + else if (Parameter != null) + { + firstCall.TrySetArgument(Parameter.Name, aggregateVariable); + } + else + { + firstCall.TrySetArgument(aggregateVariable); + } + + if (Parameter != null) + { + StoreDeferredMiddlewareVariable(chain, Parameter.Name, aggregateVariable); + } + + foreach (var methodCall in chain.Middleware.OfType()) + { + if (Parameter != null) + { + if (!methodCall.TrySetArgument(Parameter.Name, aggregateVariable)) + { + methodCall.TrySetArgument(aggregateVariable); + } + } + else + { + methodCall.TrySetArgument(aggregateVariable); + } + } + + return aggregateVariable; + } + + internal static Type DetermineAggregateType(IChain chain) + { + var firstCall = chain.HandlerCalls().First(); + var parameters = firstCall.Method.GetParameters(); + var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); + if (stream != null) + { + return stream.ParameterType.GetGenericArguments().Single(); + } + + if (parameters.Length >= 2 && (parameters[1].ParameterType.IsConcrete() || + parameters[1].ParameterType.Closes(typeof(IEvent<>)))) + { + return parameters[1].ParameterType; + } + + if (firstCall.HandlerType.HasAttribute()) + { + return firstCall.HandlerType; + } + + throw new InvalidOperationException( + $"Unable to determine a Polecat aggregate type for {chain}. You may need to explicitly specify the aggregate type in a {nameof(AggregateHandlerAttribute)} attribute"); + } + + internal static MemberInfo DetermineVersionMember(Type aggregateType) + { + var versioning = + _versioningBaseType.CloseAndBuildAs(AggregationScope.SingleStream, aggregateType); + return versioning.VersionMember; + } + + internal static void StoreDeferredMiddlewareVariable(IChain chain, string parameterName, Variable variable) + { + const string key = "DeferredMiddlewareVariables"; + if (!chain.Tags.TryGetValue(key, out var raw)) + { + raw = new List<(string Name, Variable Variable)>(); + chain.Tags[key] = raw; + } + ((List<(string Name, Variable Variable)>)raw).Add((parameterName, variable)); + } +} + +internal class ApplyEventsFromAsyncEnumerableFrame : AsyncFrame, IReturnVariableAction where T : class +{ + private readonly Variable _returnValue; + private Variable? _stream; + + public ApplyEventsFromAsyncEnumerableFrame(Variable returnValue) + { + _returnValue = returnValue; + uses.Add(_returnValue); + } + + public string Description => "Apply events to Polecat event stream"; + + public new IEnumerable Dependencies() + { + yield break; + } + + public IEnumerable Frames() + { + yield return this; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _stream = chain.FindVariable(typeof(IEventStream)); + yield return _stream; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var variableName = (typeof(T).Name + "Event").ToCamelCase(); + + writer.WriteComment(Description); + writer.Write( + $"await foreach (var {variableName} in {_returnValue.Usage}) {_stream!.Usage}.{nameof(IEventStream.AppendOne)}({variableName});"); + Next?.GenerateCode(method, writer); + } +} + +internal class EventCaptureActionSource : IReturnVariableActionSource +{ + private readonly Type _aggregateType; + + public EventCaptureActionSource(Type aggregateType) + { + _aggregateType = aggregateType; + } + + public IReturnVariableAction Build(IChain chain, Variable variable) + { + return new ActionSource(_aggregateType, variable); + } + + internal class ActionSource : IReturnVariableAction + { + private readonly Type _aggregateType; + private readonly Variable _variable; + + public ActionSource(Type aggregateType, Variable variable) + { + _aggregateType = aggregateType; + _variable = variable; + } + + public string Description => "Append event to event stream for aggregate " + _aggregateType.FullNameInCode(); + + public IEnumerable Dependencies() + { + yield break; + } + + public IEnumerable Frames() + { + var streamType = typeof(IEventStream<>).MakeGenericType(_aggregateType); + + yield return new MethodCall(streamType, nameof(IEventStream.AppendOne)) + { + Arguments = + { + [0] = _variable + } + }; + } + } +} diff --git a/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs b/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs new file mode 100644 index 000000000..90b6af158 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/AssemblyAttributes.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("PolecatTests")] +[assembly: InternalsVisibleTo("Wolverine.Http.Polecat")] diff --git a/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs b/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs new file mode 100644 index 000000000..3d4a9e434 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs @@ -0,0 +1,160 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Tags; +using Polecat; +using Polecat.Events.Dcb; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Polecat.Codegen; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Polecat; + +/// +/// Marks a parameter to a Wolverine message handler or HTTP endpoint method as being part of the +/// Polecat Dynamic Consistency Boundary (DCB) workflow. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class BoundaryModelAttribute : WolverineParameterAttribute, IDataRequirement, IRefersToAggregate +{ + private OnMissing? _onMissing; + + public bool Required { get; set; } + public string MissingMessage { get; set; } + + public OnMissing OnMissing + { + get => _onMissing ?? OnMissing.Simple404; + set => _onMissing = value; + } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, + GenerationRules rules) + { + _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; + + var aggregateType = parameter.ParameterType; + if (aggregateType.IsNullable()) + { + aggregateType = aggregateType.GetInnerTypeFromNullable(); + } + + var isBoundaryParameter = false; + if (aggregateType.Closes(typeof(IEventBoundary<>))) + { + aggregateType = aggregateType.GetGenericArguments()[0]; + isBoundaryParameter = true; + } + + var firstCall = chain.HandlerCalls().First(); + var handlerType = firstCall.HandlerType; + var loadMethodNames = new[] { "Load", "LoadAsync", "Before", "BeforeAsync" }; + + var loadMethod = handlerType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance) + .FirstOrDefault(m => loadMethodNames.Contains(m.Name) && + (m.ReturnType == typeof(EventTagQuery) || + m.ReturnType == typeof(Task) || + m.ReturnType == typeof(ValueTask))); + + if (loadMethod == null) + { + throw new InvalidOperationException( + $"[BoundaryModel] on parameter '{parameter.Name}' in {chain} requires a Load() or Before() method " + + $"that returns an EventTagQuery to define the tag query for FetchForWritingByTags<{aggregateType.Name}>()."); + } + + new PolecatPersistenceFrameProvider().ApplyTransactionSupport(chain, container); + + var loader = new LoadBoundaryFrame(aggregateType); + chain.Middleware.Add(loader); + + var boundary = loader.Boundary; + + DetermineEventCaptureHandling(chain, aggregateType); + + var boundaryInterfaceType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); + Variable aggregateVariable = new MemberAccessVariable(boundary, + boundaryInterfaceType.GetProperty(nameof(IEventBoundary.Aggregate))!); + + if (Required) + { + var otherFrames = chain.AddStopConditionIfNull(aggregateVariable, null, this); + var block = new LoadEntityFrameBlock(aggregateVariable, otherFrames); + block.AlsoMirrorAsTheCreator(boundary); + chain.Middleware.Add(block); + aggregateVariable = block.Mirror; + } + + if (isBoundaryParameter) + { + return boundary; + } + + if (parameter.ParameterType == aggregateType || parameter.ParameterType.IsNullable() && + parameter.ParameterType.GetInnerTypeFromNullable() == aggregateType) + { + firstCall.TrySetArgument(parameter.Name, aggregateVariable); + } + + AggregateHandling.StoreDeferredMiddlewareVariable(chain, parameter.Name, aggregateVariable); + + foreach (var methodCall in chain.Middleware.OfType()) + { + if (!methodCall.TrySetArgument(parameter.Name, aggregateVariable)) + { + methodCall.TrySetArgument(aggregateVariable); + } + } + + chain.Tags["BoundaryHandling"] = new BoundaryHandlingTag(aggregateType, boundary); + + return aggregateVariable; + } + + internal static void DetermineEventCaptureHandling(IChain chain, Type aggregateType) + { + var firstCall = chain.HandlerCalls().First(); + + var asyncEnumerable = + firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); + if (asyncEnumerable != null) + { + asyncEnumerable.UseReturnAction(_ => + { + return typeof(ApplyBoundaryEventsFromAsyncEnumerableFrame<>).CloseAndBuildAs( + asyncEnumerable, aggregateType); + }); + return; + } + + var eventsVariable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(Events)) ?? + firstCall.Creates.FirstOrDefault(x => + x.VariableType.CanBeCastTo>() && + !x.VariableType.CanBeCastTo()); + + if (eventsVariable != null) + { + eventsVariable.UseReturnAction( + v => typeof(RegisterBoundaryEventsFrame<>) + .CloseAndBuildAs(eventsVariable, aggregateType) + .WrapIfNotNull(v), "Append events via DCB boundary"); + return; + } + + if (!firstCall.Method.GetParameters() + .Any(x => x.ParameterType.Closes(typeof(IEventBoundary<>)))) + { + chain.ReturnVariableActionSource = new BoundaryEventCaptureActionSource(aggregateType); + } + } +} + +internal record BoundaryHandlingTag(Type AggregateType, Variable Boundary); diff --git a/src/Persistence/Wolverine.Polecat/Codegen/BoundaryEventCapture.cs b/src/Persistence/Wolverine.Polecat/Codegen/BoundaryEventCapture.cs new file mode 100644 index 000000000..212bea0ce --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/BoundaryEventCapture.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Polecat.Events.Dcb; +using Wolverine.Configuration; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Polecat.Codegen; + +/// +/// Registers a collection of events via IEventBoundary.AppendMany() for DCB workflows. +/// +internal class RegisterBoundaryEventsFrame : MethodCall where T : class +{ + public RegisterBoundaryEventsFrame(Variable returnVariable) : base(typeof(IEventBoundary), + FindMethod(returnVariable.VariableType)) + { + Arguments[0] = returnVariable; + CommentText = "Capturing events returned from handler and appending via DCB boundary"; + } + + internal static MethodInfo FindMethod(Type responseType) + { + return responseType.CanBeCastTo>() + ? ReflectionHelper.GetMethod>(x => x.AppendMany(new List()))! + : ReflectionHelper.GetMethod>(x => x.AppendOne(null))!; + } +} + +/// +/// Handles async enumerable return values by appending each event via IEventBoundary.AppendOne(). +/// +internal class ApplyBoundaryEventsFromAsyncEnumerableFrame : AsyncFrame where T : class +{ + private readonly Variable _returnValue; + private Variable? _boundary; + + public ApplyBoundaryEventsFromAsyncEnumerableFrame(Variable returnValue) + { + _returnValue = returnValue; + uses.Add(returnValue); + } + + public string Description => "Append events from async enumerable to DCB boundary for " + + typeof(T).FullNameInCode(); + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _boundary = chain.FindVariable(typeof(IEventBoundary)); + yield return _boundary; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var variableName = (typeof(T).Name + "Event").ToCamelCase(); + + writer.WriteComment(Description); + writer.Write( + $"await foreach (var {variableName} in {_returnValue.Usage}) {_boundary!.Usage}.{nameof(IEventBoundary.AppendOne)}({variableName});"); + Next?.GenerateCode(method, writer); + } +} + +/// +/// Makes each individual return value from a handler method be appended as an event +/// via IEventBoundary.AppendOne() for DCB workflows. +/// +internal class BoundaryEventCaptureActionSource : IReturnVariableActionSource +{ + private readonly Type _aggregateType; + + public BoundaryEventCaptureActionSource(Type aggregateType) + { + _aggregateType = aggregateType; + } + + public IReturnVariableAction Build(IChain chain, Variable variable) + { + return new ActionSource(_aggregateType, variable); + } + + internal class ActionSource : IReturnVariableAction + { + private readonly Type _aggregateType; + private readonly Variable _variable; + + public ActionSource(Type aggregateType, Variable variable) + { + _aggregateType = aggregateType; + _variable = variable; + } + + public string Description => + "Append event via DCB boundary for aggregate " + _aggregateType.FullNameInCode(); + + public IEnumerable Dependencies() + { + yield break; + } + + public IEnumerable Frames() + { + var boundaryType = typeof(IEventBoundary<>).MakeGenericType(_aggregateType); + + yield return new MethodCall(boundaryType, nameof(IEventBoundary.AppendOne)) + { + Arguments = + { + [0] = _variable + } + }; + } + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/CreateDocumentSessionFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/CreateDocumentSessionFrame.cs new file mode 100644 index 000000000..3c2f1e8c2 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/CreateDocumentSessionFrame.cs @@ -0,0 +1,77 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Polecat; +using Wolverine.Configuration; +using Wolverine.Polecat.Publishing; +using Wolverine.Persistence; +using Wolverine.Runtime; + +namespace Wolverine.Polecat.Codegen; + +internal class CreateDocumentSessionFrame : Frame +{ + private readonly IChain _chain; + private Variable? _cancellation; + private Variable? _context; + private bool _createsSession; + private Variable? _factory; + + public CreateDocumentSessionFrame(IChain chain) : base(true) + { + _chain = chain; + } + + public Variable? Session { get; private set; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _cancellation = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellation; + + Session = chain.TryFindVariable(typeof(IDocumentSession), VariableSource.NotServices); + if (Session == null) + { + _createsSession = true; + Session = new Variable(typeof(IDocumentSession), this); + + _factory = chain.FindVariable(typeof(OutboxedSessionFactory)); + yield return _factory; + } + + // Inside of messaging. Not sure how this is gonna work for HTTP yet + _context = chain.TryFindVariable(typeof(IMessageContext), VariableSource.NotServices); + + if (_context != null) + { + yield return _context; + } + + if (Session != null) + { + yield return Session; + } + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + if (_createsSession) + { + writer.BlankLine(); + writer.WriteComment("Open a new document session registered with the Wolverine"); + writer.WriteComment("message context to support the outbox functionality"); + writer.Write( + $"using var {Session!.Usage} = {_factory!.Usage}.{nameof(OutboxedSessionFactory.OpenSession)}({_context!.Usage});"); + } + + if (_chain.Idempotency != IdempotencyStyle.None) + { + writer.BlankLine(); + writer.WriteComment("This message handler is configured for Eager idempotency checks"); + + writer.Write($"await {_context.Usage}.{nameof(MessageContext.AssertEagerIdempotencyAsync)}({_cancellation.Usage});"); + } + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/EventStoreFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/EventStoreFrame.cs new file mode 100644 index 000000000..f8ec9e3ba --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/EventStoreFrame.cs @@ -0,0 +1,30 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Polecat; +using Polecat.Events; + +namespace Wolverine.Polecat.Codegen; + +internal class EventStoreFrame : SyncFrame +{ + private readonly Variable _eventStore; + private Variable? _session; + + public EventStoreFrame() + { + _eventStore = Create(); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine($"var {_eventStore.Usage} = {_session!.Usage}.{nameof(IDocumentSession.Events)};"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/LoadAggregateFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/LoadAggregateFrame.cs new file mode 100644 index 000000000..50207b427 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/LoadAggregateFrame.cs @@ -0,0 +1,86 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; +using Polecat.Events; + +namespace Wolverine.Polecat.Codegen; + +internal class LoadAggregateFrame : AsyncFrame +{ + private readonly AggregateHandling _att; + private Variable? _session; + private Variable? _token; + private readonly Variable _identity; + private readonly Variable? _version; + private readonly Type _eventStreamType; + private readonly Variable _rawIdentity; + + public LoadAggregateFrame(AggregateHandling att) + { + _att = att; + _identity = _att.AggregateId; + + if (_att is { LoadStyle: ConcurrencyStyle.Optimistic, Version: not null }) + { + _version = _att.Version; + } + + _eventStreamType = typeof(IEventStream<>).MakeGenericType(_att.AggregateType); + Stream = new Variable(_eventStreamType, this); + + _rawIdentity = _identity; + // For natural keys, keep the full natural key object (don't unwrap) + if (!_att.IsNaturalKey && _rawIdentity.VariableType != typeof(Guid) && _rawIdentity.VariableType != typeof(string)) + { + var valueType = ValueTypeInfo.ForType(_rawIdentity.VariableType); + _rawIdentity = new MemberAccessVariable(_identity, valueType.ValueProperty); + } + } + + public Variable Stream { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + yield return _identity; + if (_version != null) yield return _version; + + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _token = chain.FindVariable(typeof(CancellationToken)); + yield return _token; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Loading Polecat aggregate as part of the aggregate handler workflow"); + if (_att.IsNaturalKey) + { + var aggType = _att.AggregateType.FullNameInCode(); + var nkType = _identity.VariableType.FullNameInCode(); + writer.WriteLine($"var {Stream.Usage} = await {_session!.Usage}.Events.FetchForWriting<{aggType}, {nkType}>({_identity.Usage}, {_token!.Usage});"); + } + else if (_att.LoadStyle == ConcurrencyStyle.Exclusive) + { + writer.WriteLine($"var {Stream.Usage} = await {_session!.Usage}.Events.FetchForExclusiveWriting<{_att.AggregateType.FullNameInCode()}>({_rawIdentity.Usage}, {_token.Usage});"); + } + else if (_version == null) + { + writer.WriteLine($"var {Stream.Usage} = await {_session!.Usage}.Events.FetchForWriting<{_att.AggregateType.FullNameInCode()}>({_rawIdentity.Usage}, {_token.Usage});"); + } + else + { + writer.WriteLine($"var {Stream.Usage} = await {_session!.Usage}.Events.FetchForWriting<{_att.AggregateType.FullNameInCode()}>({_rawIdentity.Usage}, {_version.Usage}, {_token.Usage});"); + } + + if (_att.AlwaysEnforceConsistency) + { + writer.WriteLine($"{Stream.Usage}.{nameof(IEventStream.AlwaysEnforceConsistency)} = true;"); + } + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs new file mode 100644 index 000000000..546b66163 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs @@ -0,0 +1,49 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using JasperFx.Events.Tags; +using Polecat; +using Polecat.Events.Dcb; + +namespace Wolverine.Polecat.Codegen; + +internal class LoadBoundaryFrame : AsyncFrame +{ + private readonly Type _aggregateType; + private Variable? _query; + private Variable? _session; + private Variable? _token; + private readonly Type _boundaryType; + + public LoadBoundaryFrame(Type aggregateType, Variable? query = null) + { + _aggregateType = aggregateType; + _query = query; + _boundaryType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); + Boundary = new Variable(_boundaryType, this); + } + + public Variable Boundary { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _query ??= chain.FindVariable(typeof(EventTagQuery)); + yield return _query; + + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _token = chain.FindVariable(typeof(CancellationToken)); + yield return _token; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Loading DCB boundary model via FetchForWritingByTags"); + writer.WriteLine( + $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{_aggregateType.FullNameInCode()}>({_query.Usage}, {_token!.Usage});"); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/MissingAggregateCheckFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/MissingAggregateCheckFrame.cs new file mode 100644 index 000000000..ac50513a5 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/MissingAggregateCheckFrame.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat.Events; + +namespace Wolverine.Polecat.Codegen; + +internal class MissingAggregateCheckFrame : SyncFrame +{ + private readonly Type _aggregateType; + private readonly Variable _identity; + private readonly Variable _eventStream; + + public MissingAggregateCheckFrame(Type aggregateType, Variable identity, + Variable eventStream) + { + _aggregateType = aggregateType; + _identity = identity; + _eventStream = eventStream; + + uses.Add(identity); + uses.Add(eventStream); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + yield break; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine( + $"if ({_eventStream.Usage}.{nameof(IEventStream.Aggregate)} == null) throw new {typeof(UnknownAggregateException).FullNameInCode()}(typeof({_aggregateType.FullNameInCode()}), {_identity.Usage});"); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/OpenPolecatSessionFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/OpenPolecatSessionFrame.cs new file mode 100644 index 000000000..e6378747e --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/OpenPolecatSessionFrame.cs @@ -0,0 +1,101 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Polecat; +using Wolverine.Polecat.Publishing; +using Wolverine.Persistence; +using Wolverine.Runtime; + +namespace Wolverine.Polecat.Codegen; + +internal class OpenPolecatSessionFrame : AsyncFrame +{ + private readonly Type _sessionType; + private Variable? _context; + private Variable? _factory; + private Variable? _polecatFactory; + private Variable _tenantId; + private bool _justCast; + + public OpenPolecatSessionFrame(Type sessionType) + { + _sessionType = sessionType; + ReturnVariable = new Variable(sessionType, this); + } + + public Variable ReturnVariable { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + if (_justCast) + { + Next?.GenerateCode(method, writer); + return; + } + + var methodName = ReturnVariable.VariableType == typeof(IQuerySession) + ? nameof(OutboxedSessionFactory.QuerySession) + : nameof(OutboxedSessionFactory.OpenSession); + + if (_context == null) + { + // Just use native Polecat here. + writer.Write($"await using var {ReturnVariable.Usage} = {_polecatFactory!.Usage}.{methodName}();"); + } + else if (_tenantId == null) + { + writer.WriteComment("Building the Polecat session"); + writer.Write($"await using var {ReturnVariable.Usage} = {_factory!.Usage}.{methodName}({_context!.Usage});"); + } + else + { + writer.WriteComment("Building the Polecat session using the detected tenant id"); + writer.Write($"await using var {ReturnVariable.Usage} = {_factory!.Usage}.{methodName}({_context!.Usage}, {_tenantId.Usage});"); + } + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + if (_sessionType == typeof(IQuerySession)) + { + _justCast = true; + var documentSession = chain.TryFindVariable(typeof(IDocumentSession), VariableSource.All); + if (documentSession != null) + { + yield return documentSession; + ReturnVariable.OverrideName($"(({typeof(IQuerySession)}){documentSession.Usage})"); + yield break; + } + } + + // Honestly, this is mostly to get the ordering correct + if (chain.TryFindVariableByName(typeof(string), PersistenceConstants.TenantIdVariableName, out var tenant)) + { + _tenantId = tenant; + yield return _tenantId; + + // Mandatory in this case + _context = chain.FindVariable(typeof(MessageContext)); + } + else + { + // Do a Try/Find here + _context = chain.TryFindVariable(typeof(IMessageContext), VariableSource.NotServices) + ?? chain.TryFindVariable(typeof(IMessageBus), VariableSource.NotServices); + } + + if (_context != null) + { + yield return _context; + _factory = chain.FindVariable(typeof(OutboxedSessionFactory)); + yield return _factory; + } + else + { + _polecatFactory = chain.FindVariable(typeof(ISessionFactory)); + yield return _polecatFactory; + } + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/RegisterEventsFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/RegisterEventsFrame.cs new file mode 100644 index 000000000..a6051622c --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/RegisterEventsFrame.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat.Events; + +namespace Wolverine.Polecat.Codegen; + +internal class RegisterEventsFrame : MethodCall where T : class +{ + public RegisterEventsFrame(Variable returnVariable) : base(typeof(IEventStream), + FindMethod(returnVariable.VariableType)) + { + Arguments[0] = returnVariable; + CommentText = "Capturing any possible events returned from the command handlers"; + } + + internal static MethodInfo FindMethod(Type responseType) + { + return responseType.CanBeCastTo>() + ? ReflectionHelper.GetMethod>(x => x.AppendMany(new List()))! + : ReflectionHelper.GetMethod>(x => x.AppendOne(null))!; + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs b/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs new file mode 100644 index 000000000..3524ea492 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs @@ -0,0 +1,95 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Events; +using Polecat; +using Polecat.Events; + +namespace Wolverine.Polecat.Codegen; + +internal class SessionVariableSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(IQuerySession) || type == typeof(IDocumentSession); + } + + public Variable Create(Type type) + { + return new OpenPolecatSessionFrame(type).ReturnVariable; + } +} + +internal class DocumentOperationsSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(IDocumentOperations); + } + + public Variable Create(Type type) + { + return new DocumentOperationsFrame().Variable; + } +} + +internal class DocumentOperationsFrame : SyncFrame +{ + private Variable _session; + + public DocumentOperationsFrame() + { + Variable = new Variable(typeof(IDocumentOperations), this); + } + + public Variable Variable { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"{typeof(IDocumentOperations)} {Variable.Usage} = {_session.Usage};"); + Next?.GenerateCode(method, writer); + } +} + +internal class EventOperationsSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(IEventOperations); + } + + public Variable Create(Type type) + { + return new EventOperationsFrame().Variable; + } +} + +internal class EventOperationsFrame : SyncFrame +{ + private Variable _session; + + public EventOperationsFrame() + { + Variable = new Variable(typeof(IEventOperations), this); + } + + public Variable Variable { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"{typeof(IEventOperations)} {Variable.Usage} = {_session.Usage}.{nameof(IDocumentSession.Events)};"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/ConcurrencyStyle.cs b/src/Persistence/Wolverine.Polecat/ConcurrencyStyle.cs new file mode 100644 index 000000000..1a707fef2 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/ConcurrencyStyle.cs @@ -0,0 +1,14 @@ +namespace Wolverine.Polecat; + +public enum ConcurrencyStyle +{ + /// + /// Check for concurrency violations optimistically at the point of committing the updated data + /// + Optimistic, + + /// + /// Try to attain an exclusive lock on the data behind the current aggregate + /// + Exclusive +} diff --git a/src/Persistence/Wolverine.Polecat/ConsistentAggregateAttribute.cs b/src/Persistence/Wolverine.Polecat/ConsistentAggregateAttribute.cs new file mode 100644 index 000000000..8d2102341 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/ConsistentAggregateAttribute.cs @@ -0,0 +1,19 @@ +namespace Wolverine.Polecat; + +/// +/// Marks a parameter to a Wolverine message handler as being part of the Polecat event sourcing +/// "aggregate handler" workflow with set to true. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class ConsistentAggregateAttribute : WriteAggregateAttribute +{ + public ConsistentAggregateAttribute() : base() + { + AlwaysEnforceConsistency = true; + } + + public ConsistentAggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) + { + AlwaysEnforceConsistency = true; + } +} diff --git a/src/Persistence/Wolverine.Polecat/ConsistentAggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Polecat/ConsistentAggregateHandlerAttribute.cs new file mode 100644 index 000000000..a6d8a1586 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/ConsistentAggregateHandlerAttribute.cs @@ -0,0 +1,18 @@ +namespace Wolverine.Polecat; + +/// +/// Applies the aggregate handler workflow with set to true. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ConsistentAggregateHandlerAttribute : AggregateHandlerAttribute +{ + public ConsistentAggregateHandlerAttribute(ConcurrencyStyle loadStyle) : base(loadStyle) + { + AlwaysEnforceConsistency = true; + } + + public ConsistentAggregateHandlerAttribute() : base(ConcurrencyStyle.Optimistic) + { + AlwaysEnforceConsistency = true; + } +} diff --git a/src/Persistence/Wolverine.Polecat/Events.cs b/src/Persistence/Wolverine.Polecat/Events.cs new file mode 100644 index 000000000..8ac96c4c2 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Events.cs @@ -0,0 +1,24 @@ +using Wolverine.Configuration; + +namespace Wolverine.Polecat; + +/// +/// Tells Wolverine handlers that this value contains a +/// list of events to be appended to the current stream +/// +public class Events : List, IWolverineReturnType +{ + public Events() + { + } + + public Events(IEnumerable collection) : base(collection) + { + } + + public static Events operator +(Events events, object @event) + { + events.Add(@event); + return events; + } +} diff --git a/src/Persistence/Wolverine.Polecat/FlushOutgoingMessagesOnCommit.cs b/src/Persistence/Wolverine.Polecat/FlushOutgoingMessagesOnCommit.cs new file mode 100644 index 000000000..bd5697e2a --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/FlushOutgoingMessagesOnCommit.cs @@ -0,0 +1,122 @@ +using Microsoft.Data.SqlClient; +using Polecat; +using Wolverine.Polecat.Persistence.Operations; +using Wolverine.Persistence.Durability; +using Wolverine.RDBMS; +using Wolverine.SqlServer.Persistence; +using Wolverine.Runtime; + +namespace Wolverine.Polecat; + +/// +/// Polecat IDocumentSessionListener that marks incoming envelopes as handled +/// before save, and flushes outgoing messages after commit. +/// +internal class FlushOutgoingMessagesOnCommit : IDocumentSessionListener +{ + private readonly MessageContext _context; + private readonly SqlServerMessageStore _messageStore; + + public FlushOutgoingMessagesOnCommit(MessageContext context, SqlServerMessageStore messageStore) + { + _context = context; + _messageStore = messageStore; + } + + public Task BeforeSaveChangesAsync(IDocumentSession session, CancellationToken token) + { + // No need to do anything for HTTP requests + if (_context.Envelope == null) + { + return Task.CompletedTask; + } + + // Mark as handled! + if (_context.Envelope.Destination != null) + { + if (_context.Envelope.WasPersistedInInbox) + { + if (_context.Envelope.Store == null && _messageStore.Role == MessageStoreRole.Ancillary) + { + return Task.CompletedTask; + } + + var keepUntil = DateTimeOffset.UtcNow.Add(_context.Runtime.Options.Durability.KeepAfterMessageHandling); + // Use ITransactionParticipant to execute the SQL in the same transaction + session.AddTransactionParticipant(new MarkIncomingAsHandledParticipant( + _messageStore.IncomingFullName, _context.Envelope.Id, keepUntil)); + _context.Envelope.Status = EnvelopeStatus.Handled; + } + } + + return Task.CompletedTask; + } + + public Task AfterCommitAsync(IDocumentSession session, CancellationToken token) + { + return _context.FlushOutgoingMessagesAsync(); + } +} + +/// +/// Polecat ITransactionParticipant that flushes outgoing messages after commit. +/// Used by PolecatOutbox when listeners can't be added post-construction. +/// +internal class FlushOutgoingMessagesParticipant : ITransactionParticipant +{ + private readonly MessageContext _context; + private readonly SqlServerMessageStore _messageStore; + + public FlushOutgoingMessagesParticipant(MessageContext context, SqlServerMessageStore messageStore) + { + _context = context; + _messageStore = messageStore; + } + + public async Task BeforeCommitAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken token) + { + // Mark incoming as handled if needed + if (_context.Envelope?.Destination != null && _context.Envelope.WasPersistedInInbox) + { + if (_context.Envelope.Store == null && _messageStore.Role == MessageStoreRole.Ancillary) + { + return; + } + + var keepUntil = DateTimeOffset.UtcNow.Add(_context.Runtime.Options.Durability.KeepAfterMessageHandling); + await using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = + $"update {_messageStore.IncomingFullName} set {DatabaseConstants.Status} = '{EnvelopeStatus.Handled}', {DatabaseConstants.KeepUntil} = @keepUntil where id = @id"; + cmd.Parameters.AddWithValue("@keepUntil", keepUntil); + cmd.Parameters.AddWithValue("@id", _context.Envelope.Id); + await cmd.ExecuteNonQueryAsync(token); + _context.Envelope.Status = EnvelopeStatus.Handled; + } + } +} + +internal class MarkIncomingAsHandledParticipant : ITransactionParticipant +{ + private readonly string _incomingFullName; + private readonly Guid _envelopeId; + private readonly DateTimeOffset _keepUntil; + + public MarkIncomingAsHandledParticipant(string incomingFullName, Guid envelopeId, DateTimeOffset keepUntil) + { + _incomingFullName = incomingFullName; + _envelopeId = envelopeId; + _keepUntil = keepUntil; + } + + public async Task BeforeCommitAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken token) + { + await using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = + $"update {_incomingFullName} set {DatabaseConstants.Status} = '{EnvelopeStatus.Handled}', {DatabaseConstants.KeepUntil} = @keepUntil where id = @id"; + cmd.Parameters.AddWithValue("@keepUntil", _keepUntil); + cmd.Parameters.AddWithValue("@id", _envelopeId); + await cmd.ExecuteNonQueryAsync(token); + } +} diff --git a/src/Persistence/Wolverine.Polecat/IPolecatOp.cs b/src/Persistence/Wolverine.Polecat/IPolecatOp.cs new file mode 100644 index 000000000..13d26d1e0 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/IPolecatOp.cs @@ -0,0 +1,311 @@ +using System.Linq.Expressions; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using Polecat.Events; +using Polecat; +using Wolverine.Configuration; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Polecat; + +/// +/// Interface for any kind of Polecat related side effect +/// +public interface IPolecatOp : ISideEffect +{ + void Execute(IDocumentSession session); +} + +internal class PolecatOpPolicy : IChainPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var chain in chains) + { + var candidates = chain.ReturnVariablesOfType>().ToArray(); + if (candidates.Any()) + { + new PolecatPersistenceFrameProvider().ApplyTransactionSupport(chain, container); + } + + foreach (var collection in candidates) + { + collection.UseReturnAction(v => new ForEachPolecatOpFrame(v)); + } + } + } +} + +internal class ForEachPolecatOpFrame : SyncFrame +{ + private readonly Variable _collection; + private Variable _session; + + public ForEachPolecatOpFrame(Variable collection) + { + _collection = collection; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + yield return _collection; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Apply each Polecat op to the current document session"); + writer.Write($"foreach (var item_of_{_collection.Usage} in {_collection.Usage}) item_of_{_collection.Usage}.{nameof(IPolecatOp.Execute)}({_session.Usage});"); + Next?.GenerateCode(method, writer); + } +} + +/// +/// Access to Polecat related side effect return values from message handlers +/// +public static class PolecatOps +{ + public static StoreDoc Store(T document) where T : notnull + { + if (document == null) throw new ArgumentNullException(nameof(document)); + return new StoreDoc(document); + } + + public static StoreManyDocs StoreMany(params T[] documents) where T : notnull + { + if (documents == null) throw new ArgumentNullException(nameof(documents)); + return new StoreManyDocs(documents); + } + + public static InsertDoc Insert(T document) where T : notnull + { + if (document == null) throw new ArgumentNullException(nameof(document)); + return new InsertDoc(document); + } + + public static UpdateDoc Update(T document) where T : notnull + { + if (document == null) throw new ArgumentNullException(nameof(document)); + return new UpdateDoc(document); + } + + public static DeleteDoc Delete(T document) where T : notnull + { + if (document == null) throw new ArgumentNullException(nameof(document)); + return new DeleteDoc(document); + } + + public static DeleteDocById Delete(string id) where T : class + { + if (id == null) throw new ArgumentNullException(nameof(id)); + return new DeleteDocById(id); + } + + public static DeleteDocById Delete(Guid id) where T : class + { + return new DeleteDocById(id); + } + + public static DeleteDocById Delete(int id) where T : class + { + return new DeleteDocById(id); + } + + public static DeleteDocById Delete(long id) where T : class + { + return new DeleteDocById(id); + } + + public static DeleteDocById Delete(object id) where T : class + { + if (id == null) throw new ArgumentNullException(nameof(id)); + return new DeleteDocById(id); + } + + public static DeleteDocWhere DeleteWhere(Expression> expression) where T : class + { + if (expression == null) throw new ArgumentNullException(nameof(expression)); + return new DeleteDocWhere(expression); + } + + public static StartStream StartStream(Guid streamId, params object[] events) where T : class + { + return new StartStream(streamId, events); + } + + public static IStartStream StartStream(params object[] events) where T : class + { + var streamId = CombGuidIdGeneration.NewGuid(); + return new StartStream(streamId, events); + } + + public static IStartStream StartStream(string streamKey, params object[] events) where T : class + { + return new StartStream(streamKey, events); + } + + public static NoOp Nothing() => new NoOp(); +} + +public class NoOp : IPolecatOp +{ + public void Execute(IDocumentSession session) + { + // nothing + } +} + +public interface IStartStream : IPolecatOp +{ + string StreamKey { get; } + Guid StreamId { get; } + Type AggregateType { get; } + IReadOnlyList Events { get; } +} + +public class StartStream : IStartStream where T : class +{ + public string StreamKey { get; } = string.Empty; + public Guid StreamId { get; } + + public StartStream(Guid streamId, params object[] events) + { + StreamId = streamId; + Events.AddRange(events); + } + + public StartStream(string streamKey, params object[] events) + { + StreamKey = streamKey; + Events.AddRange(events); + } + + public StartStream(Guid streamId, IList events) + { + StreamId = streamId; + Events.AddRange(events); + } + + public StartStream(string streamKey, IList events) + { + StreamKey = streamKey; + Events.AddRange(events); + } + + public StartStream With(object[] events) + { + Events.AddRange(events); + return this; + } + + public StartStream With(object @event) + { + Events.Add(@event); + return this; + } + + public List Events { get; } = new(); + + public void Execute(IDocumentSession session) + { + if (StreamId == Guid.Empty) + { + if (StreamKey.IsNotEmpty()) + { + session.Events.StartStream(StreamKey, Events.ToArray()); + } + else + { + session.Events.StartStream(Events.ToArray()); + } + } + else + { + session.Events.StartStream(StreamId, Events.ToArray()); + } + } + + Type IStartStream.AggregateType => typeof(T); + IReadOnlyList IStartStream.Events => Events; +} + +public class StoreDoc : DocumentOp where T : notnull +{ + private readonly T _document; + public StoreDoc(T document) : base(document) { _document = document; } + public override void Execute(IDocumentSession session) { session.Store(_document); } +} + +public class StoreManyDocs : DocumentsOp where T : notnull +{ + private readonly T[] _documents; + public StoreManyDocs(params T[] documents) : base(documents.Cast().ToArray()) { _documents = documents; } + public StoreManyDocs(IList documents) : this(documents.ToArray()) { } + public override void Execute(IDocumentSession session) { session.Store(_documents); } +} + +public class InsertDoc : DocumentOp where T : notnull +{ + private readonly T _document; + public InsertDoc(T document) : base(document) { _document = document; } + public override void Execute(IDocumentSession session) { session.Insert(_document); } +} + +public class UpdateDoc : DocumentOp where T : notnull +{ + private readonly T _document; + public UpdateDoc(T document) : base(document) { _document = document; } + public override void Execute(IDocumentSession session) { session.Update(_document); } +} + +public class DeleteDoc : DocumentOp where T : notnull +{ + private readonly T _document; + public DeleteDoc(T document) : base(document) { _document = document; } + public override void Execute(IDocumentSession session) { session.Delete(_document); } +} + +public class DeleteDocById : IPolecatOp where T : class +{ + private readonly object _id; + public DeleteDocById(object id) { _id = id; } + + public void Execute(IDocumentSession session) + { + switch (_id) + { + case string idAsString: session.Delete(idAsString); break; + case Guid idAsGuid: session.Delete(idAsGuid); break; + case long idAsLong: session.Delete(idAsLong); break; + case int idAsInt: session.Delete(idAsInt); break; + default: throw new InvalidOperationException($"Cannot delete by id of type {_id.GetType()}"); break; + } + } +} + +public class DeleteDocWhere : IPolecatOp where T : class +{ + private readonly Expression> _expression; + public DeleteDocWhere(Expression> expression) { _expression = expression; } + public void Execute(IDocumentSession session) { session.DeleteWhere(_expression); } +} + +public abstract class DocumentOp : IPolecatOp +{ + public object Document { get; } + protected DocumentOp(object document) { Document = document; } + public abstract void Execute(IDocumentSession session); +} + +public abstract class DocumentsOp : IPolecatOp +{ + public object[] Documents { get; } + protected DocumentsOp(params object[] documents) { Documents = documents; } + public abstract void Execute(IDocumentSession session); +} diff --git a/src/Persistence/Wolverine.Polecat/IPolecatOutbox.cs b/src/Persistence/Wolverine.Polecat/IPolecatOutbox.cs new file mode 100644 index 000000000..c5331ed83 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/IPolecatOutbox.cs @@ -0,0 +1,42 @@ +using Polecat; +using Wolverine.Runtime; + +namespace Wolverine.Polecat; + +/// +/// Outbox-ed messaging sending with Polecat +/// +public interface IPolecatOutbox : IMessageBus +{ + /// + /// Current document session + /// + IDocumentSession? Session { get; } + + /// + /// Enroll a Polecat document session into the outbox'd sender + /// + /// + void Enroll(IDocumentSession session); +} + +public class PolecatOutbox : MessageContext, IPolecatOutbox +{ + public PolecatOutbox(IWolverineRuntime runtime, IDocumentSession session) : base(runtime) + { + Enroll(session); + } + + public void Enroll(IDocumentSession session) + { + Session = session; + var polecatEnvelopeTransaction = new PolecatEnvelopeTransaction(session, this); + Transaction = polecatEnvelopeTransaction; + + // Polecat requires listeners on SessionOptions before session creation, + // so we use ITransactionParticipant for the outbox flush + session.AddTransactionParticipant(new FlushOutgoingMessagesParticipant(this, polecatEnvelopeTransaction.Store)); + } + + public IDocumentSession? Session { get; private set; } +} diff --git a/src/Persistence/Wolverine.Polecat/IdentityAttribute.cs b/src/Persistence/Wolverine.Polecat/IdentityAttribute.cs new file mode 100644 index 000000000..6442964db --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/IdentityAttribute.cs @@ -0,0 +1,8 @@ +namespace Wolverine.Polecat; + +/// +/// Marks a property or field on a command type as the aggregate identity +/// for the Polecat aggregate handler workflow. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class IdentityAttribute : Attribute; diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Operations/PolecatStorageExtensions.cs b/src/Persistence/Wolverine.Polecat/Persistence/Operations/PolecatStorageExtensions.cs new file mode 100644 index 000000000..463ad4c77 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Persistence/Operations/PolecatStorageExtensions.cs @@ -0,0 +1,91 @@ +using Microsoft.Data.SqlClient; +using Polecat; +using Wolverine.RDBMS; +using Wolverine.Runtime.Serialization; +using Wolverine.SqlServer.Persistence; + +namespace Wolverine.Polecat.Persistence.Operations; + +internal static class PolecatStorageExtensions +{ + public static void StoreIncoming(this IDocumentSession session, SqlServerMessageStore store, Envelope envelope) + { + var participant = new StoreIncomingEnvelopeParticipant(store.IncomingFullName, envelope); + session.AddTransactionParticipant(participant); + } + + public static void StoreOutgoing(this IDocumentSession session, SqlServerMessageStore store, Envelope envelope, + int ownerId) + { + var participant = new StoreOutgoingEnvelopeParticipant(store.OutgoingFullName, envelope, ownerId); + session.AddTransactionParticipant(participant); + } +} + +internal class StoreIncomingEnvelopeParticipant : ITransactionParticipant +{ + private readonly string _incomingTable; + private readonly Envelope _envelope; + + public StoreIncomingEnvelopeParticipant(string incomingTable, Envelope envelope) + { + _incomingTable = incomingTable; + _envelope = envelope; + } + + public async Task BeforeCommitAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken token) + { + await using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = + $"insert into {_incomingTable} ({DatabaseConstants.IncomingFields}) values (@body, @id, @status, @ownerId, @scheduledTime, @attempts, @messageType, @destination, @keepUntil)"; + + cmd.Parameters.AddWithValue("@body", EnvelopeSerializer.Serialize(_envelope)); + cmd.Parameters.AddWithValue("@id", _envelope.Id); + cmd.Parameters.AddWithValue("@status", _envelope.Status.ToString()); + cmd.Parameters.AddWithValue("@ownerId", _envelope.OwnerId); + cmd.Parameters.AddWithValue("@scheduledTime", + _envelope.ScheduledTime.HasValue ? _envelope.ScheduledTime.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@attempts", _envelope.Attempts); + cmd.Parameters.AddWithValue("@messageType", _envelope.MessageType); + cmd.Parameters.AddWithValue("@destination", + (object?)_envelope.Destination?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("@keepUntil", + _envelope.KeepUntil.HasValue ? _envelope.KeepUntil.Value : DBNull.Value); + + await cmd.ExecuteNonQueryAsync(token); + } +} + +internal class StoreOutgoingEnvelopeParticipant : ITransactionParticipant +{ + private readonly string _outgoingTable; + private readonly Envelope _envelope; + private readonly int _ownerId; + + public StoreOutgoingEnvelopeParticipant(string outgoingTable, Envelope envelope, int ownerId) + { + _outgoingTable = outgoingTable; + _envelope = envelope; + _ownerId = ownerId; + } + + public async Task BeforeCommitAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken token) + { + await using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = + $"insert into {_outgoingTable} ({DatabaseConstants.OutgoingFields}) values (@body, @id, @ownerId, @destination, @deliverBy, @attempts, @messageType)"; + + cmd.Parameters.AddWithValue("@body", EnvelopeSerializer.Serialize(_envelope)); + cmd.Parameters.AddWithValue("@id", _envelope.Id); + cmd.Parameters.AddWithValue("@ownerId", _ownerId); + cmd.Parameters.AddWithValue("@destination", _envelope.Destination!.ToString()); + cmd.Parameters.AddWithValue("@deliverBy", + _envelope.DeliverBy.HasValue ? _envelope.DeliverBy.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@attempts", _envelope.Attempts); + cmd.Parameters.AddWithValue("@messageType", _envelope.MessageType); + + await cmd.ExecuteNonQueryAsync(token); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/DocumentSessionOperationFrame.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/DocumentSessionOperationFrame.cs new file mode 100644 index 000000000..5db8cf90b --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/DocumentSessionOperationFrame.cs @@ -0,0 +1,33 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Polecat; + +namespace Wolverine.Polecat.Persistence.Sagas; + +internal class DocumentSessionOperationFrame : SyncFrame +{ + private readonly string _methodName; + private readonly Variable _saga; + private Variable? _session; + + public DocumentSessionOperationFrame(Variable saga, string methodName) + { + _saga = saga; + _methodName = methodName; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine(""); + writer.WriteComment("Register the document operation with the current session"); + writer.Write($"{_session!.Usage}.{_methodName}({_saga.Usage});"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/LoadDocumentFrame.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/LoadDocumentFrame.cs new file mode 100644 index 000000000..bbd4580cc --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/LoadDocumentFrame.cs @@ -0,0 +1,46 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; + +namespace Wolverine.Polecat.Persistence.Sagas; + +internal class LoadDocumentFrame : AsyncFrame +{ + private readonly Variable _sagaId; + private Variable? _cancellation; + private Variable? _session; + + public LoadDocumentFrame(Type sagaType, Variable sagaId) + { + _sagaId = sagaId; + uses.Add(sagaId); + + var usage = $"{Variable.DefaultArgName(sagaType)}_{sagaId.Usage.Split('.').Last()}"; + Saga = new Variable(sagaType, usage, this); + } + + public Variable Saga { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + yield return _sagaId; + + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _cancellation = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellation; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine(""); + writer.WriteComment("Try to load the existing saga document"); + writer.Write( + $"var {Saga.Usage} = await {_session!.Usage}.LoadAsync<{Saga.VariableType.FullNameInCode()}>({_sagaId.Usage}, {_cancellation!.Usage}).ConfigureAwait(false);"); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs new file mode 100644 index 000000000..e3fd5f56e --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs @@ -0,0 +1,154 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; +using Polecat.Events; +using Wolverine.Configuration; +using Wolverine.Polecat.Codegen; +using Wolverine.Persistence; +using Wolverine.Persistence.Sagas; +using Wolverine.Runtime; + +namespace Wolverine.Polecat.Persistence.Sagas; + +internal class PolecatPersistenceFrameProvider : IPersistenceFrameProvider +{ + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) + { + persistenceService = typeof(IDocumentSession); + return true; + } + + public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) + { + var idProp = sagaType.GetProperty("Id", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + return idProp?.PropertyType ?? typeof(Guid); + } + + public void ApplyTransactionSupport(IChain chain, IServiceContainer container) + { + if (!chain.Middleware.OfType().Any()) + { + chain.Middleware.Add(new CreateDocumentSessionFrame(chain)); + } + + if (chain is not SagaChain) + { + if (!chain.Postprocessors.OfType().Any()) + { + chain.Postprocessors.Add(new DocumentSessionSaveChanges()); + } + + if (!chain.Postprocessors.OfType().Any()) + { + chain.Postprocessors.Add(new FlushOutgoingMessages()); + } + } + } + + public void ApplyTransactionSupport(IChain chain, IServiceContainer container, Type entityType) + { + ApplyTransactionSupport(chain, container); + } + + public bool CanApply(IChain chain, IServiceContainer container) + { + if (chain is SagaChain) + { + return true; + } + + if (chain.ReturnVariablesOfType().Any()) return true; + + var serviceDependencies = chain + .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations) }).ToArray(); + return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) || x.Closes(typeof(IEventStream<>))); + } + + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) + { + return new LoadDocumentFrame(sagaType, sagaId); + } + + public Frame DetermineInsertFrame(Variable saga, IServiceContainer container) + { + return new DocumentSessionOperationFrame(saga, nameof(IDocumentSession.Insert)); + } + + public Frame CommitUnitOfWorkFrame(Variable saga, IServiceContainer container) + { + return new DocumentSessionSaveChanges(); + } + + public Frame DetermineUpdateFrame(Variable saga, IServiceContainer container) + { + return new DocumentSessionOperationFrame(saga, nameof(IDocumentSession.Update)); + } + + public Frame DetermineDeleteFrame(Variable sagaId, Variable saga, IServiceContainer container) + { + return new DocumentSessionOperationFrame(saga, nameof(IDocumentSession.Delete)); + } + + public Frame DetermineStoreFrame(Variable saga, IServiceContainer container) + { + return new DocumentSessionOperationFrame(saga, nameof(IDocumentSession.Store)); + } + + public Frame DetermineDeleteFrame(Variable variable, IServiceContainer container) + { + return new DocumentSessionOperationFrame(variable, nameof(IDocumentSession.Delete)); + } + + public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServiceContainer container) + { + var method = typeof(PolecatStorageActionApplier).GetMethod("ApplyAction") + .MakeGenericMethod(entityType); + + var call = new MethodCall(typeof(PolecatStorageActionApplier), method); + call.Arguments[1] = action; + + return call; + } + + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) + { + // Polecat doesn't have DocumentMetadata for soft-delete check in the same way, + // so we skip this for now + return []; + } +} + +public static class PolecatStorageActionApplier +{ + public static void ApplyAction(IDocumentSession session, IStorageAction action) + { + if (action.Entity == null) return; + + switch (action.Action) + { + case StorageAction.Delete: + session.Delete(action.Entity!); + break; + case StorageAction.Insert: + session.Insert(action.Entity); + break; + case StorageAction.Store: + session.Store(action.Entity); + break; + case StorageAction.Update: + session.Update(action.Entity); + break; + } + } +} + +internal class DocumentSessionSaveChanges : MethodCall +{ + public DocumentSessionSaveChanges() : base(typeof(IDocumentSession), ReflectionHelper.GetMethod(x => x.SaveChangesAsync(default))) + { + CommentText = "Save all pending changes to this Polecat session"; + } +} diff --git a/src/Persistence/Wolverine.Polecat/PolecatAggregateHandlerStrategy.cs b/src/Persistence/Wolverine.Polecat/PolecatAggregateHandlerStrategy.cs new file mode 100644 index 000000000..ca5934ffd --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/PolecatAggregateHandlerStrategy.cs @@ -0,0 +1,27 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.Core.Reflection; +using Wolverine.Configuration; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Polecat; + +internal class PolecatAggregateHandlerStrategy : IHandlerPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var chain in chains.Where(x => + x.Handlers.Any(call => call.HandlerType.Name.EndsWith("AggregateHandler")))) + { + if (chain.HasAttribute()) + { + continue; + } + + if (chain.Handlers.SelectMany(x => x.Creates).Any(x => x.VariableType.CanBeCastTo())) continue; + + new AggregateHandlerAttribute(ConcurrencyStyle.Optimistic).Modify(chain, rules, container); + } + } +} diff --git a/src/Persistence/Wolverine.Polecat/PolecatBackedPersistenceMarker.cs b/src/Persistence/Wolverine.Polecat/PolecatBackedPersistenceMarker.cs new file mode 100644 index 000000000..37a4e5fe5 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/PolecatBackedPersistenceMarker.cs @@ -0,0 +1,17 @@ +using JasperFx.CodeGeneration.Model; +using Wolverine.SqlServer.Persistence; + +namespace Wolverine.Polecat; + +internal class PolecatBackedPersistenceMarker : IVariableSource +{ + public bool Matches(Type type) + { + return type == GetType(); + } + + public Variable Create(Type type) + { + return Variable.For(); + } +} diff --git a/src/Persistence/Wolverine.Polecat/PolecatEnvelopeTransaction.cs b/src/Persistence/Wolverine.Polecat/PolecatEnvelopeTransaction.cs new file mode 100644 index 000000000..c63d2d4d3 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/PolecatEnvelopeTransaction.cs @@ -0,0 +1,87 @@ +using Polecat; +using Wolverine.Polecat.Persistence.Operations; +using Wolverine.Persistence.Durability; +using Wolverine.SqlServer.Persistence; +using Wolverine.Runtime; +using MultiTenantedMessageStore = Wolverine.Persistence.Durability.MultiTenantedMessageStore; + +namespace Wolverine.Polecat; + +internal class PolecatEnvelopeTransaction : IEnvelopeTransaction +{ + private readonly MessageContext _context; + private readonly int _nodeId; + + public PolecatEnvelopeTransaction(IDocumentSession session, MessageContext context) + { + _context = context; + if (context.Storage is SqlServerMessageStore store) + { + Store = store; + _nodeId = store.Durability.AssignedNodeNumber; + } + else if (context.Storage is MultiTenantedMessageStore { Main: SqlServerMessageStore s }) + { + Store = s; + _nodeId = s.Durability.AssignedNodeNumber; + } + else + { + throw new InvalidOperationException( + "This Wolverine application is not using SQL Server + Polecat as the backing message persistence"); + } + + Session = session; + } + + public SqlServerMessageStore Store { get; } + + public IDocumentSession Session { get; } + + public Task PersistOutgoingAsync(Envelope envelope) + { + Session.StoreOutgoing(Store, envelope, _nodeId); + return Task.CompletedTask; + } + + public Task PersistOutgoingAsync(Envelope[] envelopes) + { + foreach (var envelope in envelopes) Session.StoreOutgoing(Store, envelope, _nodeId); + + return Task.CompletedTask; + } + + public Task PersistIncomingAsync(Envelope envelope) + { + Session.StoreIncoming(Store, envelope); + return Task.CompletedTask; + } + + public ValueTask RollbackAsync() + { + return ValueTask.CompletedTask; + } + + public async Task TryMakeEagerIdempotencyCheckAsync(Envelope envelope, DurabilitySettings settings, + CancellationToken cancellation) + { + if (envelope.WasPersistedInInbox) return true; + + try + { + // Might need to reset! + _context.MultiFlushMode = MultiFlushMode.AllowMultiples; + var copy = Envelope.ForPersistedHandled(envelope, DateTimeOffset.UtcNow, settings); + await PersistIncomingAsync(copy); + await Session.SaveChangesAsync(cancellation); + + envelope.WasPersistedInInbox = true; + envelope.Status = EnvelopeStatus.Handled; + return true; + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs new file mode 100644 index 000000000..d96ee4572 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs @@ -0,0 +1,205 @@ +using JasperFx; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using Polecat; +using Microsoft.Extensions.DependencyInjection; +using Wolverine.ErrorHandling; +using Wolverine.Polecat.Codegen; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Polecat.Publishing; +using Wolverine.Persistence.Sagas; +using Wolverine.RDBMS; +using Wolverine.Runtime; +using Wolverine.Runtime.Routing; +using Wolverine.Util; + +namespace Wolverine.Polecat; + +public class PolecatIntegration : IWolverineExtension, IEventForwarding +{ + private readonly List> _actions = []; + + /// + /// This directs the Polecat integration to try to publish events out of the enrolled outbox + /// for a Polecat session on SaveChangesAsync(). This is the "event forwarding" option. + /// There is no ordering guarantee with this option, but this will distribute event messages + /// faster than strictly ordered event subscriptions. Default is false + /// + public bool UseFastEventForwarding { get; set; } + + public void Configure(WolverineOptions options) + { + // Duplicate incoming messages - SQL Server uses unique constraint violations + options.OnException(e => + { + // Unique key violation on incoming table + return e.Number == 2627 || e.Number == 2601; + }) + .Discard(); + + options.CodeGeneration.Sources.Add(new PolecatBackedPersistenceMarker()); + + options.CodeGeneration.InsertFirstPersistenceStrategy(); + options.CodeGeneration.Sources.Add(new SessionVariableSource()); + options.CodeGeneration.Sources.Add(new DocumentOperationsSource()); + options.CodeGeneration.Sources.Add(new EventOperationsSource()); + + options.Policies.Add(); + + options.Discovery.CustomizeHandlerDiscovery(x => + { + x.Includes.WithAttribute(); + }); + + options.PublishWithMessageRoutingSource(EventRouter); + + options.Policies.ForwardHandledTypes(new EventWrapperForwarder()); + + // SQL Server transport will be configured when the message store is built + + options.Policies.Add(); + } + + /// + /// In the case of Polecat using a database per tenant, you may wish to + /// explicitly determine the master database for Wolverine where Wolverine will store node and envelope information. + /// This does not have to be one of the tenant databases + /// + public string? MainDatabaseConnectionString { get; set; } + + internal PolecatEventRouter EventRouter { get; } = new(); + + private string _transportSchemaName = "wolverine_queues"; + + /// + /// The database schema to place SQL Server-backed queues. The default is "wolverine_queues" + /// + public string TransportSchemaName + { + get => _transportSchemaName; + set => _transportSchemaName = value.ToLowerInvariant(); + } + + private string? _messageStorageSchemaName; + + /// + /// The database schema to place the message store tables for Wolverine. + /// The default is "wolverine" + /// + public string? MessageStorageSchemaName + { + get => _messageStorageSchemaName; + set => _messageStorageSchemaName = value?.ToLowerInvariant(); + } + + public EventForwardingTransform SubscribeToEvent() + { + return new EventForwardingTransform(EventRouter); + } +} + +internal class PolecatOverrides : IConfigurePolecat +{ + public void Configure(IServiceProvider services, StoreOptions options) + { + // Polecat's DocumentMapping automatically detects IRevisioned types + // and enables numeric revisions. Wolverine's Saga type uses Version property + // which is handled by the saga persistence framework. + } +} + +internal class EventWrapperForwarder : IHandledTypeRule +{ + public bool TryFindHandledType(Type concreteType, out Type handlerType) + { + handlerType = concreteType.FindInterfaceThatCloses(typeof(IEvent<>)); + return handlerType != null; + } +} + +internal class PolecatEventRouter : IMessageRouteSource +{ + public IEnumerable FindRoutes(Type messageType, IWolverineRuntime runtime) + { + if (messageType.Closes(typeof(IEvent<>))) + { + var eventType = messageType.GetGenericArguments().Single(); + var wrappedType = typeof(IEvent<>).MakeGenericType(eventType); + + if (messageType.IsConcrete()) + { + return runtime.RoutingFor(wrappedType).Routes; + } + + MessageRoute[] innerRoutes = []; + if (messageType.IsConcrete()) + { + var inner = runtime.RoutingFor(wrappedType); + innerRoutes = inner.Routes.Concat(new LocalRouting().FindRoutes(wrappedType, runtime)).OfType().ToArray(); + } + else + { + innerRoutes = new ExplicitRouting().FindRoutes(wrappedType, runtime).OfType().ToArray(); + if (!innerRoutes.Any()) + { + innerRoutes = new LocalRouting().FindRoutes(wrappedType, runtime).OfType().ToArray(); + } + } + + // First look for explicit transformations + var transformers = Transformers.Where(x => x.SourceType == wrappedType); + var transformed = transformers.SelectMany(x => + runtime.RoutingFor(x.DestinationType).Routes.Select(x.CreateRoute)); + + var forEventType = runtime.RoutingFor(eventType).Routes.Select(route => + typeof(EventUnwrappingMessageRoute<>).CloseAndBuildAs(route, eventType)); + + var candidates = forEventType.Concat(transformed).Concat(innerRoutes).ToArray(); + return candidates; + } + + return []; + } + + public bool IsAdditive => false; + public List Transformers { get; } = []; +} + +internal class EventUnwrappingMessageRoute : TransformedMessageRoute, T> +{ + public EventUnwrappingMessageRoute(IMessageRoute inner) : base(e => e.Data, inner) + { + } + + public override string ToString() + { + return $"Unwrap event wrapper to " + typeof(T).FullNameInCode(); + } +} + +public interface IEventForwarding +{ + /// + /// Subscribe to an event, but with a transformation. The transformed message will be + /// published to Wolverine with its normal routing rules + /// + /// + EventForwardingTransform SubscribeToEvent(); +} + +public class EventForwardingTransform +{ + private readonly PolecatEventRouter _eventRouter; + + internal EventForwardingTransform(PolecatEventRouter eventRouter) + { + _eventRouter = eventRouter; + } + + public void TransformedTo(Func, TDestination> transformer) + { + var transformation = new MessageTransformation, TDestination>(transformer); + _eventRouter.Transformers.Add(transformation); + } +} diff --git a/src/Persistence/Wolverine.Polecat/PublishIncomingEventsBeforeCommit.cs b/src/Persistence/Wolverine.Polecat/PublishIncomingEventsBeforeCommit.cs new file mode 100644 index 000000000..8fbe80675 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/PublishIncomingEventsBeforeCommit.cs @@ -0,0 +1,31 @@ +using Polecat; + +namespace Wolverine.Polecat; + +internal class PublishIncomingEventsBeforeCommit : IDocumentSessionListener +{ + private readonly IMessageContext _bus; + + public PublishIncomingEventsBeforeCommit(IMessageContext bus) + { + _bus = bus; + } + + public async Task BeforeSaveChangesAsync(IDocumentSession session, CancellationToken token) + { + var events = session.PendingChanges.Streams.SelectMany(s => s.Events).ToArray(); + + if (events.Length != 0) + { + foreach (var e in events) + { + await _bus.PublishAsync(e); + } + } + } + + public Task AfterCommitAsync(IDocumentSession session, CancellationToken token) + { + return Task.CompletedTask; + } +} diff --git a/src/Persistence/Wolverine.Polecat/Publishing/OutboxedSessionFactory.cs b/src/Persistence/Wolverine.Polecat/Publishing/OutboxedSessionFactory.cs new file mode 100644 index 000000000..6203adb3a --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Publishing/OutboxedSessionFactory.cs @@ -0,0 +1,129 @@ +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Polecat; +using Wolverine.Persistence.Durability; +using Wolverine.Runtime; + +namespace Wolverine.Polecat.Publishing; + +public class OutboxedSessionFactory +{ + private readonly ISessionFactory _factory; + private readonly IDocumentStore _store; + private readonly bool _shouldPublishEvents; + + public OutboxedSessionFactory(ISessionFactory factory, IWolverineRuntime runtime, IDocumentStore store) + { + _factory = factory; + _store = store; + + _shouldPublishEvents = runtime.TryFindExtension()?.UseFastEventForwarding ?? false; + + MessageStore = runtime.Storage; + } + + internal IMessageStore MessageStore { get; set; } + + /// Build new instances of IQuerySession on demand + public IQuerySession QuerySession(MessageContext context) + { + var tenantId = context.Envelope?.TenantId ?? context.TenantId; + return tenantId.IsNotEmpty() + ? _store.QuerySession(new SessionOptions { TenantId = tenantId }) + : _factory.QuerySession(); + } + + /// Build new instances of IQuerySession on demand + public IQuerySession QuerySession(MessageContext context, string? tenantId) + { + tenantId ??= context.Envelope?.TenantId; + return tenantId.IsNotEmpty() + ? _store.QuerySession(new SessionOptions { TenantId = tenantId }) + : _factory.QuerySession(); + } + + public IQuerySession QuerySession(IMessageContext context) + { + var tenantId = context.Envelope?.TenantId ?? context.TenantId; + return tenantId.IsNotEmpty() + ? _store.QuerySession(new SessionOptions { TenantId = tenantId }) + : _factory.QuerySession(); + } + + /// Build new instances of IDocumentSession on demand + public IDocumentSession OpenSession(MessageContext context) + { + var options = buildSessionOptions(context); + var session = _store.OpenSession(options); + configureSession(context, session); + return session; + } + + /// Build new instances of IDocumentSession on demand + public IDocumentSession OpenSession(MessageContext context, string? tenantId) + { + context.TenantId ??= tenantId; + var options = buildSessionOptions(context); + var session = _store.OpenSession(options); + configureSession(context, session); + return session; + } + + private SessionOptions buildSessionOptions(MessageContext context) + { + var options = new SessionOptions + { + Tracking = DocumentTracking.None + }; + + var tenantId = context.Envelope?.TenantId ?? context.TenantId; + if (tenantId.IsNotEmpty()) + { + options.TenantId = tenantId; + } + + // Add listeners before session creation (Polecat requirement) + if (_shouldPublishEvents) + { + options.Listeners.Add(new PublishIncomingEventsBeforeCommit(context)); + } + + options.Listeners.Add(new FlushOutgoingMessagesOnCommit(context, null!)); // store set after transaction creation + + return options; + } + + private void configureSession(MessageContext context, IDocumentSession session) + { + context.OverrideStorage(MessageStore); + + if (context.ConversationId != Guid.Empty) + { + session.CausationId = context.ConversationId.ToString(); + } + + session.CorrelationId = context.CorrelationId; + + if (context.Envelope?.UserName is not null) + { + session.LastModifiedBy = context.Envelope.UserName; + } + else if (context.UserName is not null) + { + session.LastModifiedBy = context.UserName; + } + + var transaction = new PolecatEnvelopeTransaction(session, context); + context.EnlistInOutbox(transaction); + + // Now register the transaction participant for flushing outgoing messages + session.AddTransactionParticipant(new FlushOutgoingMessagesParticipant(context, transaction.Store)); + } + + /// Build new instances of IDocumentSession on demand + public IDocumentSession OpenSession(IMessageBus bus) + { + var context = bus.As(); + return OpenSession(context); + } +} diff --git a/src/Persistence/Wolverine.Polecat/ReadAggregateAttribute.cs b/src/Persistence/Wolverine.Polecat/ReadAggregateAttribute.cs new file mode 100644 index 000000000..49ef9196f --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/ReadAggregateAttribute.cs @@ -0,0 +1,147 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Polecat.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; + +namespace Wolverine.Polecat; + +/// +/// Use Polecat's FetchLatest() API to retrieve the parameter value +/// +public class ReadAggregateAttribute : WolverineParameterAttribute, IDataRequirement, IRefersToAggregate +{ + private OnMissing? _onMissing; + + public ReadAggregateAttribute() + { + ValueSource = ValueSource.Anything; + } + + public ReadAggregateAttribute(string argumentName) : base(argumentName) + { + ValueSource = ValueSource.Anything; + } + + public bool Required { get; set; } = true; + public string MissingMessage { get; set; } + + public OnMissing OnMissing + { + get => _onMissing ?? OnMissing.Simple404; + set => _onMissing = value; + } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) + { + _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; + var idType = new PolecatPersistenceFrameProvider().DetermineSagaIdType(parameter.ParameterType, container); + + if (!tryFindIdentityVariable(chain, parameter, idType, out var identity)) + { + identity = tryFindStrongTypedIdentityVariable(chain, parameter.ParameterType, idType); + if (identity == null) + { + throw new InvalidEntityLoadUsageException(this, parameter); + } + } + + var frame = new FetchLatestAggregateFrame(parameter.ParameterType, identity); + frame.Aggregate.OverrideName(parameter.Name); + + Variable returnVariable; + if (Required) + { + var otherFrames = chain.AddStopConditionIfNull(frame.Aggregate, identity, this); + var block = new LoadEntityFrameBlock(frame.Aggregate, otherFrames); + chain.Middleware.Add(block); + returnVariable = block.Mirror; + } + else + { + chain.Middleware.Add(frame); + returnVariable = frame.Aggregate; + } + + AggregateHandling.StoreDeferredMiddlewareVariable(chain, parameter.Name, returnVariable); + + return returnVariable; + } + + private Variable? tryFindStrongTypedIdentityVariable(IChain chain, Type aggregateType, Type idType) + { + var strongTypedIdType = idType; + + if (WriteAggregateAttribute.IsPrimitiveIdType(idType)) + { + strongTypedIdType = WriteAggregateAttribute.FindIdentifiedByType(aggregateType); + } + + if (strongTypedIdType == null || WriteAggregateAttribute.IsPrimitiveIdType(strongTypedIdType)) return null; + + var inputType = chain.InputType(); + if (inputType == null) return null; + + var matchingProps = inputType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + if (matchingProps.Length == 1) + { + if (chain.TryFindVariable(matchingProps[0].Name, ValueSource, strongTypedIdType, out var variable)) + return variable; + } + + return null; + } +} + +internal class FetchLatestAggregateFrame : AsyncFrame +{ + private readonly Variable _identity; + private Variable _session; + private Variable _token; + + public FetchLatestAggregateFrame(Type aggregateType, Variable identity) + { + if (identity.VariableType == typeof(Guid) || identity.VariableType == typeof(string)) + { + _identity = identity; + } + else + { + var valueType = ValueTypeInfo.ForType(identity.VariableType); + _identity = new MemberAccessVariable(identity, valueType.ValueProperty); + } + + Aggregate = new Variable(aggregateType, this); + } + + public Variable Aggregate { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _token = chain.FindVariable(typeof(CancellationToken)); + yield return _token; + + yield return _identity; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"var {Aggregate.Usage} = await {_session.Usage}.Events.FetchLatest<{Aggregate.VariableType.FullNameInCode()}>({_identity.Usage}, {_token.Usage});"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/UnknownAggregateException.cs b/src/Persistence/Wolverine.Polecat/UnknownAggregateException.cs new file mode 100644 index 000000000..a383817ae --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/UnknownAggregateException.cs @@ -0,0 +1,11 @@ +using JasperFx.Core.Reflection; + +namespace Wolverine.Polecat; + +public class UnknownAggregateException : Exception +{ + public UnknownAggregateException(Type aggregateType, object id) : base( + $"Could not find an aggregate of type {aggregateType.FullNameInCode()} with id {id}") + { + } +} diff --git a/src/Persistence/Wolverine.Polecat/UpdatedAggregate.cs b/src/Persistence/Wolverine.Polecat/UpdatedAggregate.cs new file mode 100644 index 000000000..4ffe3365c --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/UpdatedAggregate.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; +using JasperFx.Events; +using Polecat.Events; +using Wolverine.Configuration; + +namespace Wolverine.Polecat; + +/// +/// Use this as a response from a message handler or HTTP endpoint using the aggregate handler workflow +/// to respond with the updated version of the aggregate being altered *after* any new events have been applied +/// +public class UpdatedAggregate : IResponseAware +{ + public static void ConfigureResponse(IChain chain) + { + if (AggregateHandling.TryLoad(chain, out var handling)) + { + var idType = handling.AggregateId.VariableType; + var openType = idType == typeof(Guid) ? typeof(FetchLatestByGuid<>) : typeof(FetchLatestByString<>); + var frame = openType.CloseAndBuildAs(handling.AggregateId, handling.AggregateType); + chain.UseForResponse(frame); + } + else + { + throw new InvalidOperationException($"UpdatedAggregate cannot be used because Chain {chain} is not marked as an aggregate handler."); + } + } +} + +/// +/// Use this as a response from a message handler or HTTP endpoint using the aggregate handler workflow +/// to respond with the updated version of the aggregate being altered *after* any new events have been applied +/// +/// The aggregate type +public class UpdatedAggregate : IResponseAware +{ + public static void ConfigureResponse(IChain chain) + { + if (AggregateHandling.TryLoad(chain, out var handling)) + { + var idType = handling.AggregateId.VariableType; + var openType = idType == typeof(Guid) ? typeof(FetchLatestByGuid<>) : typeof(FetchLatestByString<>); + var frame = openType.CloseAndBuildAs(handling.AggregateId, handling.AggregateType); + chain.UseForResponse(frame); + } + else + { + throw new InvalidOperationException($"UpdatedAggregate cannot be used because Chain {chain} is not marked as an aggregate handler."); + } + } +} + +internal class FetchLatestByGuid : MethodCall where T : class, new() +{ + public FetchLatestByGuid(Variable id) : base(typeof(IEventOperations), ReflectionHelper.GetMethod(x => x.FetchLatest(Guid.Empty, CancellationToken.None))) + { + if (id.VariableType != typeof(Guid)) + { + throw new ArgumentOutOfRangeException( + "Wolverine does not yet support strong typed identifiers for the aggregate workflow."); + } + + Arguments[0] = id; + } +} + +internal class FetchLatestByString : MethodCall where T : class, new() +{ + public FetchLatestByString(Variable id) : base(typeof(IEventOperations), ReflectionHelper.GetMethod(x => x.FetchLatest("", CancellationToken.None))) + { + if (id.VariableType != typeof(string)) + { + throw new ArgumentOutOfRangeException( + "Wolverine does not yet support strong typed identifiers for the aggregate workflow."); + } + + Arguments[0] = id; + } +} diff --git a/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj b/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj new file mode 100644 index 000000000..c79177fa8 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj @@ -0,0 +1,19 @@ + + + Polecat-backed Persistence for Wolverine Applications + WolverineFx.Polecat + net10.0 + false + false + false + false + false + + + + + + + + + diff --git a/src/Persistence/Wolverine.Polecat/WolverineOptionsPolecatExtensions.cs b/src/Persistence/Wolverine.Polecat/WolverineOptionsPolecatExtensions.cs new file mode 100644 index 000000000..77f3ec086 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/WolverineOptionsPolecatExtensions.cs @@ -0,0 +1,109 @@ +using JasperFx; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using Polecat; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Wolverine.Polecat.Publishing; +using Wolverine.Persistence.Durability; +using Wolverine.SqlServer.Persistence; +using Wolverine.RDBMS; +using Wolverine.RDBMS.Sagas; +using Wolverine.Runtime; +using MultiTenantedMessageStore = Wolverine.Persistence.Durability.MultiTenantedMessageStore; + +namespace Wolverine.Polecat; + +internal class MapEventTypeMessages : IWolverineExtension +{ + public void Configure(WolverineOptions options) + { + options.MapGenericMessageType(typeof(IEvent<>), typeof(Event<>)); + } +} + +public static class WolverineOptionsPolecatExtensions +{ + /// + /// Integrate Polecat with Wolverine's persistent outbox and add Polecat-specific middleware + /// to Wolverine + /// + public static PolecatConfigurationExpression IntegrateWithWolverine( + this PolecatConfigurationExpression expression, + Action? configure = null) + { + var integration = expression.Services.FindPolecatIntegration(); + if (integration == null) + { + integration = new PolecatIntegration(); + + configure?.Invoke(integration); + + expression.Services.AddSingleton(integration); + expression.Services.AddSingleton(integration); + } + else + { + configure?.Invoke(integration); + } + + expression.Services.AddSingleton(); + + expression.Services.AddScoped(); + + expression.Services.AddSingleton(s => + { + var store = s.GetRequiredService() as IMessageDatabase; + if (store != null) return store.Settings; + + return new DatabaseSettings(); + }); + + expression.Services.AddSingleton(s => + { + var store = s.GetRequiredService(); + var runtime = s.GetRequiredService(); + var logger = s.GetRequiredService>(); + + var schemaName = integration.MessageStorageSchemaName ?? + runtime.Options.Durability.MessageStorageSchemaName ?? + "wolverine"; + + return BuildSqlServerMessageStore(schemaName, store, runtime, logger); + }); + + expression.Services.AddSingleton(); + + expression.Services.AddSingleton(); + + return expression; + } + + internal static IMessageStore BuildSqlServerMessageStore( + string schemaName, + IDocumentStore store, + IWolverineRuntime runtime, + ILogger logger) + { + var settings = new DatabaseSettings + { + SchemaName = schemaName, + AutoCreate = AutoCreate.CreateOrUpdate, + Role = MessageStoreRole.Main, + ScheduledJobLockId = $"{schemaName}:scheduled-jobs".GetDeterministicHashCode(), + ConnectionString = store.Options.ConnectionString + }; + + var sagaTypes = runtime.Services.GetServices(); + return new SqlServerMessageStore(settings, runtime.Options.Durability, logger, sagaTypes); + } + + internal static PolecatIntegration? FindPolecatIntegration(this IServiceCollection services) + { + var descriptor = services.FirstOrDefault(x => + x.ServiceType == typeof(IWolverineExtension) && x.ImplementationInstance is PolecatIntegration); + + return descriptor?.ImplementationInstance as PolecatIntegration; + } +} diff --git a/src/Persistence/Wolverine.Polecat/WriteAggregateAttribute.cs b/src/Persistence/Wolverine.Polecat/WriteAggregateAttribute.cs new file mode 100644 index 000000000..921ad261d --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/WriteAggregateAttribute.cs @@ -0,0 +1,192 @@ +using System.Reflection; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using JasperFx.CodeGeneration.Services; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Polecat; +using Polecat.Events; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; +using Wolverine.Runtime.Partitioning; + +namespace Wolverine.Polecat; + +/// +/// Marks a parameter to a Wolverine HTTP endpoint or message handler method as being part of the Polecat event sourcing +/// "aggregate handler" workflow +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class WriteAggregateAttribute : WolverineParameterAttribute, IDataRequirement, IMayInferMessageIdentity, IRefersToAggregate +{ + public WriteAggregateAttribute() { } + public WriteAggregateAttribute(string? routeOrParameterName) { RouteOrParameterName = routeOrParameterName; } + + public string? RouteOrParameterName { get; } + + private OnMissing? _onMissing; + public bool Required { get; set; } = true; + public string MissingMessage { get; set; } + + public OnMissing OnMissing + { + get => _onMissing ?? OnMissing.Simple404; + set => _onMissing = value; + } + + public ConcurrencyStyle LoadStyle { get; set; } = ConcurrencyStyle.Optimistic; + public bool AlwaysEnforceConsistency { get; set; } + public string? VersionSource { get; set; } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) + { + _onMissing ??= container.GetInstance().EntityDefaults.OnMissing; + var aggregateType = parameter.ParameterType; + if (aggregateType.IsNullable()) + { + aggregateType = aggregateType.GetInnerTypeFromNullable(); + } + + if (aggregateType.Closes(typeof(IEventStream<>))) + { + aggregateType = aggregateType.GetGenericArguments()[0]; + } + + var idProp = aggregateType.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + var idType = idProp?.PropertyType ?? typeof(Guid); + + var identity = FindIdentity(aggregateType, idType, chain); + var isNaturalKey = false; + + // If standard identity resolution failed, check for natural key support + if (identity == null) + { + var storeOptions = container.Services.GetRequiredService(); + var naturalKey = storeOptions.Projections.FindNaturalKeyDefinition(aggregateType); + if (naturalKey != null) + { + identity = FindIdentity(aggregateType, naturalKey.OuterType, chain); + if (identity != null) isNaturalKey = true; + } + } + + if (identity == null) + { + throw new InvalidOperationException( + $"Unable to determine an aggregate id for the parameter '{parameter.Name}' on method {chain.HandlerCalls().First()}"); + } + + var version = findVersionVariable(chain); + + var handling = new AggregateHandling(this) + { + AggregateType = aggregateType, + AggregateId = identity, + LoadStyle = LoadStyle, + Version = version, + AlwaysEnforceConsistency = AlwaysEnforceConsistency, + Parameter = parameter, + IsNaturalKey = isNaturalKey + }; + + return handling.Apply(chain, container); + } + + internal Variable? findVersionVariable(IChain chain) + { + if (VersionSource == null && chain.Tags.ContainsKey(nameof(AggregateHandling))) + { + return null; + } + + var name = VersionSource ?? "version"; + + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(long), out var variable)) return variable; + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(int), out var v2)) return v2; + if (chain.TryFindVariable(name, ValueSource.Anything, typeof(uint), out var v3)) return v3; + + return null; + } + + public Variable? FindIdentity(Type aggregateType, Type idType, IChain chain) + { + if (RouteOrParameterName.IsNotEmpty()) + { + if (chain.TryFindVariable(RouteOrParameterName, ValueSource.Anything, idType, out var variable)) + return variable; + } + + if (chain.TryFindVariable($"{aggregateType.Name.ToCamelCase()}Id", ValueSource.Anything, idType, out var v2)) + return v2; + + if (chain.TryFindVariable("id", ValueSource.Anything, idType, out var v3)) + return v3; + + var strongTypedIdType = idType; + if (IsPrimitiveIdType(idType)) + { + strongTypedIdType = FindIdentifiedByType(aggregateType); + } + + if (strongTypedIdType != null && !IsPrimitiveIdType(strongTypedIdType)) + { + var inputType = chain.InputType(); + if (inputType != null) + { + var matchingProps = inputType.GetProperties() + .Where(x => x.PropertyType == strongTypedIdType && x.CanRead) + .ToArray(); + + if (matchingProps.Length == 1) + { + if (chain.TryFindVariable(matchingProps[0].Name, ValueSource.Anything, strongTypedIdType, out var v4)) + return v4; + } + } + } + + return null; + } + + internal static bool IsPrimitiveIdType(Type type) + { + return type == typeof(Guid) || type == typeof(string) || type == typeof(int) || type == typeof(long); + } + + internal static Type? FindIdentifiedByType(Type aggregateType) + { + var identifiedByInterface = aggregateType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IdentifiedBy<>)); + + return identifiedByInterface?.GetGenericArguments()[0]; + } + + public bool TryInferMessageIdentity(IChain chain, out PropertyInfo property) + { + var inputType = chain.InputType(); + if (inputType == null) + { + property = default; + return false; + } + + if (AggregateHandling.TryLoad(chain, out var handling)) + { + if (handling.AggregateId is MemberAccessVariable mav) + { + property = mav.Member as PropertyInfo; + return property != null; + } + } + + property = null; + return false; + } +} diff --git a/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs b/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs index c7d4004cc..a67b2dbe9 100644 --- a/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs +++ b/src/Persistence/Wolverine.RDBMS/AssemblyAttributes.cs @@ -4,6 +4,7 @@ [assembly: InternalsVisibleTo("SqlServerTests")] [assembly: InternalsVisibleTo("PostgresqlTests")] [assembly: InternalsVisibleTo("MartenTests")] +[assembly: InternalsVisibleTo("PolecatTests")] [assembly: InternalsVisibleTo("SqliteTests")] [assembly: InternalsVisibleTo("Wolverine.Oracle")] [assembly: InternalsVisibleTo("OracleTests")] diff --git a/src/Wolverine/AssemblyAttributes.cs b/src/Wolverine/AssemblyAttributes.cs index d5cfad4e6..72ea3fae0 100644 --- a/src/Wolverine/AssemblyAttributes.cs +++ b/src/Wolverine/AssemblyAttributes.cs @@ -32,6 +32,8 @@ [assembly: InternalsVisibleTo("Wolverine.SqlServer")] [assembly: InternalsVisibleTo("Wolverine.Postgresql")] [assembly: InternalsVisibleTo("Wolverine.Marten")] +[assembly: InternalsVisibleTo("Wolverine.Polecat")] +[assembly: InternalsVisibleTo("PolecatTests")] [assembly: InternalsVisibleTo("Wolverine.EntityFrameworkCore")] [assembly: InternalsVisibleTo("Wolverine.Pulsar")] [assembly: InternalsVisibleTo("Wolverine.Pulsar.Tests")] diff --git a/src/Wolverine/Configuration/Capabilities/DurabilitySettingsDescription.cs b/src/Wolverine/Configuration/Capabilities/DurabilitySettingsDescription.cs new file mode 100644 index 000000000..990127298 --- /dev/null +++ b/src/Wolverine/Configuration/Capabilities/DurabilitySettingsDescription.cs @@ -0,0 +1,49 @@ +namespace Wolverine.Configuration.Capabilities; + +public class DurabilitySettingsDescription +{ + public DurabilitySettingsDescription() + { + } + + public DurabilitySettingsDescription(DurabilitySettings settings) + { + Mode = settings.Mode.ToString(); + DurabilityAgentEnabled = settings.DurabilityAgentEnabled; + RecoveryBatchSize = settings.RecoveryBatchSize; + KeepAfterMessageHandling = settings.KeepAfterMessageHandling.ToString(); + ScheduledJobPollingTime = settings.ScheduledJobPollingTime.ToString(); + HealthCheckPollingTime = settings.HealthCheckPollingTime.ToString(); + StaleNodeTimeout = settings.StaleNodeTimeout.ToString(); + CheckAssignmentPeriod = settings.CheckAssignmentPeriod.ToString(); + NodeReassignmentPollingTime = settings.NodeReassignmentPollingTime.ToString(); + MetricsCollectionSamplingInterval = settings.MetricsCollectionSamplingInterval.ToString(); + DeadLetterQueueExpirationEnabled = settings.DeadLetterQueueExpirationEnabled; + DeadLetterQueueExpiration = settings.DeadLetterQueueExpiration.ToString(); + NodeEventRecordExpirationTime = settings.NodeEventRecordExpirationTime.ToString(); + SendingAgentIdleTimeout = settings.SendingAgentIdleTimeout.ToString(); + DurabilityMetricsEnabled = settings.DurabilityMetricsEnabled; + OutboxStaleTime = settings.OutboxStaleTime?.ToString(); + InboxStaleTime = settings.InboxStaleTime?.ToString(); + MessageIdentity = settings.MessageIdentity.ToString(); + } + + public string Mode { get; set; } = "Balanced"; + public bool DurabilityAgentEnabled { get; set; } = true; + public int RecoveryBatchSize { get; set; } = 100; + public string KeepAfterMessageHandling { get; set; } = "00:05:00"; + public string ScheduledJobPollingTime { get; set; } = "00:00:05"; + public string HealthCheckPollingTime { get; set; } = "00:00:10"; + public string StaleNodeTimeout { get; set; } = "00:01:00"; + public string CheckAssignmentPeriod { get; set; } = "00:00:30"; + public string NodeReassignmentPollingTime { get; set; } = "00:00:05"; + public string MetricsCollectionSamplingInterval { get; set; } = "00:00:05"; + public bool DeadLetterQueueExpirationEnabled { get; set; } + public string DeadLetterQueueExpiration { get; set; } = "10.00:00:00"; + public string NodeEventRecordExpirationTime { get; set; } = "5.00:00:00"; + public string SendingAgentIdleTimeout { get; set; } = "00:05:00"; + public bool DurabilityMetricsEnabled { get; set; } = true; + public string? OutboxStaleTime { get; set; } + public string? InboxStaleTime { get; set; } + public string MessageIdentity { get; set; } = "IdOnly"; +} diff --git a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs index 03dc5f86b..a5a241127 100644 --- a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs +++ b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs @@ -21,6 +21,7 @@ public ServiceCapabilities(WolverineOptions options) : base(options) { Version = (options.ApplicationAssembly ?? Assembly.GetEntryAssembly()).GetName().Version; WolverineVersion = options.GetType().Assembly.GetName().Version; + DurabilitySettings = new DurabilitySettingsDescription(options.Durability); } public DateTimeOffset Evaluated { get; set; } = DateTimeOffset.UtcNow; @@ -43,6 +44,8 @@ public ServiceCapabilities(WolverineOptions options) : base(options) public List Brokers { get; set; } = []; + public DurabilitySettingsDescription? DurabilitySettings { get; set; } + /// /// Uri for sending command messages to this service /// diff --git a/wolverine.sln b/wolverine.sln index 2a427d4b6..b1aa66666 100644 --- a/wolverine.sln +++ b/wolverine.sln @@ -359,6 +359,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CosmosDbTests", "src\Persis EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EfCoreTests.MultiTenancy", "src\Persistence\EfCoreTests.MultiTenancy\EfCoreTests.MultiTenancy.csproj", "{ACB9EEA0-A545-4D02-A040-B1AE3CEF83ED}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Marten", "Marten", "{7A680493-1C82-410E-B826-4450D6622D16}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MySql", "MySql", "{1B0167BC-87D9-4236-AB26-7A3D20EFA0AE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Oracle", "Oracle", "{5722D48E-D624-431F-A61F-9B660B5C839E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SqlServer", "SqlServer", "{18BA9E0A-9C00-47AB-95C3-5CF74C3EAAD6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostgreSQL", "PostgreSQL", "{2F2004EC-FAB8-4606-8F1B-542EC834DB33}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RavenDb", "RavenDb", "{53381987-B138-43F2-933A-A08DB61B2D02}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sqlite", "Sqlite", "{AFFE32AC-87B1-4699-B6E0-BE72D90882D1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -2010,9 +2024,6 @@ Global {76B3D37E-E3DC-4F6D-B20D-D27DFBD716F7} = {96119B5E-B5F0-400A-9580-B342EBE26212} {3991BE77-2223-4CBA-B223-973CA49938F8} = {96119B5E-B5F0-400A-9580-B342EBE26212} {6ED2C542-265E-464A-911C-D74476F75740} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {00FF6FFE-7B23-4DD3-BC95-6908072F357E} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {6850F1A0-FA56-4C43-A860-0F0BDAD6187D} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {EFEB9600-F513-48AD-8F4B-D8994B9182CA} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {9CF0B286-90B6-4A2E-BB93-CE17BA822757} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {712FCA00-FEAC-495B-85E4-ED2FCE2F43E7} = {96119B5E-B5F0-400A-9580-B342EBE26212} {356D46CF-6006-49FE-AD07-CFE212CA2BDC} = {D953D733-D154-4DF2-B2B9-30BF942E1B6B} @@ -2083,7 +2094,6 @@ Global {1FD88857-4571-4DC4-80A9-F40E2CFAE274} = {9877471A-3D66-4279-8C57-FCA6CACF4C48} {723E0112-1BCC-4FCF-A5C9-D61C381771AC} = {9877471A-3D66-4279-8C57-FCA6CACF4C48} {83743B8A-E4DF-4DCE-81C5-7708F342A18A} = {FFA5D61B-D6F7-4113-9B59-21C261DA919F} - {97EB688E-12A7-4273-9893-131670E49A33} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {A71F5DC1-8EC9-4C79-951C-7F374961C2F7} = {96119B5E-B5F0-400A-9580-B342EBE26212} {E426CBC2-E4AE-49B2-840E-14D54442FDAC} = {D953D733-D154-4DF2-B2B9-30BF942E1B6B} {9B6BD2ED-B58D-4557-B54C-EAB0268862FB} = {E426CBC2-E4AE-49B2-840E-14D54442FDAC} @@ -2102,12 +2112,7 @@ Global {DC18FDD3-2AD9-43C9-B8CC-850457FE1A07} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} {C8E1C3C8-4BF7-4D93-9959-A4DAA3859A0D} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} {229147BA-99A7-4761-BA5E-3E65277C9B8E} = {4E69232F-1E78-4486-9406-EDB991DFDC9A} - {F3ECE3DC-153D-4F8A-AC48-2396F2B46ED7} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {D01C99ED-B735-4854-A07D-B55E506A1441} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {4257ECCC-A172-4FC0-BE0D-A60FDE0C8A74} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {1A34A78B-F6AB-41A9-8B90-384C6E1DBC63} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {AAFFC067-D110-45FF-9FA0-8E02F77D9D14} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {71B152DD-7A0B-4935-B8B1-1060E674D23D} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {B035801D-E786-4AAA-858A-0770D88116D6} = {63E9B289-95E8-4F2B-A064-156971A6853C} {0D320722-7CED-41C0-A914-11AC223320AA} = {84D32C8B-9CCE-4925-9AEC-8F445C7A2E3D} {579CD7E7-216A-4A68-A338-663FC3D031B7} = {0D320722-7CED-41C0-A914-11AC223320AA} @@ -2124,13 +2129,10 @@ Global {890E6462-96A1-4B27-95E9-D63ACB848B5B} = {ABE5F332-6709-4EDF-B88A-39DB96542B18} {0522EE2C-9C5D-452C-A67F-91DBBCDAE502} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} {3E83F8CE-A04A-477E-A103-1AC7CFDEBF0D} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {11C936B6-A659-4CF3-851D-FFB339B351FA} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {F96DEAFA-C73E-4AF1-A858-E95E9EDB119F} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {692321C4-1A3A-4603-A42F-36B86DC784DA} = {ABE5F332-6709-4EDF-B88A-39DB96542B18} {4F81953D-5063-44E8-BD65-734C8F41625A} = {4B0BC1E5-17F9-4DD0-AC93-DDC522E1BE3C} {B6DFE2CE-0DA2-2865-0F68-8F95FE76AA06} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} {1C29B1B2-CF10-A665-9FEC-383BDD4FCA2C} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} - {D61E79E6-169B-41DA-AAEC-93328F378331} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {203D4D7F-AE72-F75E-4DA2-8607DB1AB172} = {84D32C8B-9CCE-4925-9AEC-8F445C7A2E3D} {0B48793A-F3BD-F7A1-9498-715FB7881194} = {203D4D7F-AE72-F75E-4DA2-8607DB1AB172} {B4697521-797B-4B71-03C9-BC908B957227} = {203D4D7F-AE72-F75E-4DA2-8607DB1AB172} @@ -2146,8 +2148,6 @@ Global {38F1B1E4-87B9-401C-9401-7C9318DFAF55} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} {0FD02607-BF12-4201-90F9-3FA88BFCDFBC} = {F429686D-BB41-4E1C-A84E-518F8A289AEF} {AC643465-CD1E-4E9E-9860-DDAAF956A3DC} = {96119B5E-B5F0-400A-9580-B342EBE26212} - {738DB46A-B1B5-4843-A536-A5918918DEB5} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {382BD656-89CD-4899-A30F-1589578B639F} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {02F5459A-A96B-42AB-9E4E-CF6B067238FB} = {96119B5E-B5F0-400A-9580-B342EBE26212} {C99DD42F-A3FF-6981-C32E-EABE15982CE0} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {B7A132DB-0452-488F-BCC0-B05AB38B484E} = {C99DD42F-A3FF-6981-C32E-EABE15982CE0} @@ -2155,17 +2155,38 @@ Global {F81E3302-5747-42DB-8185-BF14F5E5DDBB} = {C99DD42F-A3FF-6981-C32E-EABE15982CE0} {366074CD-5E56-481E-A6CE-D7E9C19AFEDA} = {C99DD42F-A3FF-6981-C32E-EABE15982CE0} {4E69232F-1E78-4486-9406-EDB991DFDC9A} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {1F419B33-AD3A-4F30-8083-AC37F7F33F12} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {57B8F129-50EA-4803-AEB7-FE655B6D1B81} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {B1294A26-2C75-42A8-8A9F-9758664F6988} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} - {C86E3CE6-3A97-451F-945D-61B3D5070160} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {3FE8E499-BE44-4E27-815C-39A4DB2C4EA1} = {84D32C8B-9CCE-4925-9AEC-8F445C7A2E3D} {1F04214E-E901-436A-A05F-6BB9ED375019} = {3FE8E499-BE44-4E27-815C-39A4DB2C4EA1} {77B49C73-29B7-47A5-9475-AC290F53D76D} = {3FE8E499-BE44-4E27-815C-39A4DB2C4EA1} {68B94BE1-185D-D133-8A8C-EFE0C95F2BC7} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} {9DBA7EBE-C6E6-4F26-87E8-D87A6CDDE737} = {68B94BE1-185D-D133-8A8C-EFE0C95F2BC7} {E0D51CAE-97CF-48A8-879E-149A4E69BEE2} = {68B94BE1-185D-D133-8A8C-EFE0C95F2BC7} - {ACB9EEA0-A545-4D02-A040-B1AE3CEF83ED} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {7A680493-1C82-410E-B826-4450D6622D16} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {D61E79E6-169B-41DA-AAEC-93328F378331} = {7A680493-1C82-410E-B826-4450D6622D16} + {D01C99ED-B735-4854-A07D-B55E506A1441} = {7A680493-1C82-410E-B826-4450D6622D16} + {00FF6FFE-7B23-4DD3-BC95-6908072F357E} = {7A680493-1C82-410E-B826-4450D6622D16} + {1B0167BC-87D9-4236-AB26-7A3D20EFA0AE} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {382BD656-89CD-4899-A30F-1589578B639F} = {1B0167BC-87D9-4236-AB26-7A3D20EFA0AE} + {738DB46A-B1B5-4843-A536-A5918918DEB5} = {1B0167BC-87D9-4236-AB26-7A3D20EFA0AE} + {5722D48E-D624-431F-A61F-9B660B5C839E} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {C86E3CE6-3A97-451F-945D-61B3D5070160} = {5722D48E-D624-431F-A61F-9B660B5C839E} + {B1294A26-2C75-42A8-8A9F-9758664F6988} = {5722D48E-D624-431F-A61F-9B660B5C839E} + {18BA9E0A-9C00-47AB-95C3-5CF74C3EAAD6} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {ACB9EEA0-A545-4D02-A040-B1AE3CEF83ED} = {4E69232F-1E78-4486-9406-EDB991DFDC9A} + {2F2004EC-FAB8-4606-8F1B-542EC834DB33} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {11C936B6-A659-4CF3-851D-FFB339B351FA} = {4E69232F-1E78-4486-9406-EDB991DFDC9A} + {4257ECCC-A172-4FC0-BE0D-A60FDE0C8A74} = {2F2004EC-FAB8-4606-8F1B-542EC834DB33} + {53381987-B138-43F2-933A-A08DB61B2D02} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {71B152DD-7A0B-4935-B8B1-1060E674D23D} = {53381987-B138-43F2-933A-A08DB61B2D02} + {AFFE32AC-87B1-4699-B6E0-BE72D90882D1} = {7A9E0EAE-9ABF-40F6-9DB9-8FB1243F4210} + {57B8F129-50EA-4803-AEB7-FE655B6D1B81} = {AFFE32AC-87B1-4699-B6E0-BE72D90882D1} + {F3ECE3DC-153D-4F8A-AC48-2396F2B46ED7} = {18BA9E0A-9C00-47AB-95C3-5CF74C3EAAD6} + {1F419B33-AD3A-4F30-8083-AC37F7F33F12} = {AFFE32AC-87B1-4699-B6E0-BE72D90882D1} + {AAFFC067-D110-45FF-9FA0-8E02F77D9D14} = {53381987-B138-43F2-933A-A08DB61B2D02} + {EFEB9600-F513-48AD-8F4B-D8994B9182CA} = {18BA9E0A-9C00-47AB-95C3-5CF74C3EAAD6} + {6850F1A0-FA56-4C43-A860-0F0BDAD6187D} = {2F2004EC-FAB8-4606-8F1B-542EC834DB33} + {F96DEAFA-C73E-4AF1-A858-E95E9EDB119F} = {4E69232F-1E78-4486-9406-EDB991DFDC9A} + {97EB688E-12A7-4273-9893-131670E49A33} = {7A680493-1C82-410E-B826-4450D6622D16} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {30422362-0D90-4DBE-8C97-DD2B5B962768}