diff --git a/docs/guide/durability/efcore/index.md b/docs/guide/durability/efcore/index.md index bc8cdd529..7331223c4 100644 --- a/docs/guide/durability/efcore/index.md +++ b/docs/guide/durability/efcore/index.md @@ -89,6 +89,18 @@ builder.UseWolverine(opts => Right now, we've tested Wolverine with EF Core using both [SQL Server](/guide/durability/sqlserver) and [PostgreSQL](/guide/durability/postgresql) persistence. +## Combining EF Core with Marten + +It's completely valid to use both the EF Core and [Marten](/guide/durability/marten) integrations in +the same application — say, Marten for event sourcing and EF Core for flat relational tables. When +Wolverine needs a persistence provider for an entity (for [storage side effects](/guide/handlers/side-effects), +`[Entity]` loading, or [saga](/guide/durability/sagas.html) persistence), the EF Core integration is +always consulted **before** Marten, no matter which integration was registered first in your +`Program.cs`: an entity mapped in one of your registered `DbContext` models deterministically +resolves to EF Core, and every other document falls through to Marten. Marten can genuinely persist +*any* document, so it acts as the catch-all; EF Core only claims the types its `DbContext` models +actually map. + ## Development-time usage Wolverine + EF Core is designed to keep the dev loop short: fast schema iteration, cheap per-test database resets, declarative seed data. The three pillars below all work together — and all come for free the moment you call `UseEntityFrameworkCoreTransactions()`. diff --git a/src/Persistence/PersistenceTests/persistence_provider_precedence_permutations.cs b/src/Persistence/PersistenceTests/persistence_provider_precedence_permutations.cs new file mode 100644 index 000000000..5b6a3528d --- /dev/null +++ b/src/Persistence/PersistenceTests/persistence_provider_precedence_permutations.cs @@ -0,0 +1,108 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.Resources; +using Marten; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.EntityFrameworkCore.Codegen; +using Wolverine.Marten; +using Wolverine.Marten.Persistence.Sagas; +using Wolverine.Persistence.Sagas; +using Wolverine.Postgresql; +using Wolverine.Runtime; +using Xunit; + +namespace PersistenceTests; + +// GH-3359: in a mixed Marten + EF Core application, an entity mapped in a registered +// DbContext must resolve to the EF Core persistence provider no matter which integration +// the application registered first. MartenPersistenceFrameProvider.CanPersist claims every +// type (Marten genuinely can persist any document), so before the IsCatchAll ordering rule +// the winner was whichever integration happened to register last (InsertFirstPersistenceStrategy +// puts the last-applied extension at index 0). Both permutations below must behave identically. +public class persistence_provider_precedence_permutations +{ + // Deliberately NOT mapped in SampleDbContext, so only Marten can claim it + public class MartenOnlyDocument + { + public Guid Id { get; set; } + } + + private static async Task assertResolutionIsOrderIndependent(IHost host) + { + var runtime = host.Services.GetRequiredService(); + var rules = runtime.Options.CodeGeneration; + var container = host.Services.GetRequiredService(); + + // Item is mapped in SampleDbContext, so the selective EF Core provider owns it + rules.TryFindPersistenceFrameProvider(container, typeof(Item), out var forItem) + .ShouldBeTrue(); + forItem.ShouldBeOfType(); + + // Anything the DbContext model does not map still falls through to Marten + rules.TryFindPersistenceFrameProvider(container, typeof(MartenOnlyDocument), out var forDocument) + .ShouldBeTrue(); + forDocument.ShouldBeOfType(); + + await host.StopAsync(); + } + + private static IHostBuilder builderWith(Action registrations) + { + return Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Durability.Mode = DurabilityMode.Solo; + opts.Durability.DurabilityAgentEnabled = false; + opts.Discovery.DisableConventionalDiscovery(); + + registrations(opts); + + opts.Services.AddResourceSetupOnStartup(); + }); + } + + [Fact] + public async Task efcore_owns_its_mapped_entity_when_marten_registers_last() + { + // Marten last = Marten's extension applies last = MartenPersistenceFrameProvider at + // index 0. This is the registration order that used to hand EF-mapped entities to Marten. + using var host = await builderWith(opts => + { + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "provider_precedence"; + }) + .IntegrateWithWolverine(x => x.MessageStorageSchemaName = "provider_precedence"); + }).StartAsync(); + + await assertResolutionIsOrderIndependent(host); + } + + [Fact] + public async Task efcore_owns_its_mapped_entity_when_marten_registers_first() + { + using var host = await builderWith(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "provider_precedence"; + }) + .IntegrateWithWolverine(x => x.MessageStorageSchemaName = "provider_precedence"); + + opts.Services.AddDbContextWithWolverineIntegration(x => + x.UseSqlServer(Servers.SqlServerConnectionString)); + }).StartAsync(); + + await assertResolutionIsOrderIndependent(host); + } +} diff --git a/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs index 63246a585..d2ec599c1 100644 --- a/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs @@ -48,6 +48,11 @@ public bool CanApply(IChain chain, IServiceContainer container) return serviceDependencies.Any(x => x == typeof(Container)); } + // CosmosDb can persist any document, so CanPersist claims every type. Yield to selective + // providers (EF Core) for the entity types they actually map, regardless of the order the + // integrations were registered in + public bool IsCatchAll => true; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { persistenceService = typeof(Container); diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs index 30bdc9d45..4e0953295 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/WolverineEntityCoreExtensions.cs @@ -184,7 +184,14 @@ public static IServiceCollection AddDbContextWithWolverineManagedMultiTenancyByD b.ReplaceService(); }, ServiceLifetime.Scoped, ServiceLifetime.Singleton); - services.TryAddSingleton(); + // TryAddEnumerable, NOT TryAddSingleton: TryAddSingleton gates on the service type alone, + // so any other integration that had already registered an IWolverineExtension (e.g. Marten's + // IntegrateWithWolverine) would silently swallow this registration and the EF Core codegen + // integration - persistence provider, batching, query-spec policies - would never apply. + // TryAddEnumerable dedupes on the (service, implementation) pair, which is exactly the + // idempotency wanted across repeated AddDbContextWithWolverineIntegration calls. See GH-3359. + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); services.TryAddScoped(typeof(IDbContextOutbox<>), typeof(DbContextOutbox<>)); services.TryAddScoped(); diff --git a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs index 5a754de02..ae3d3e28e 100644 --- a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs @@ -21,6 +21,11 @@ namespace Wolverine.Marten.Persistence.Sagas; internal class MartenPersistenceFrameProvider : IPersistenceFrameProvider { + // Marten can persist any document, so CanPersist claims every type. Yield to selective + // providers (EF Core) for the entity types they actually map, regardless of the order the + // integrations were registered in + public bool IsCatchAll => true; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { persistenceService = typeof(IDocumentSession); diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs index 1b8014c3c..71d1b832a 100644 --- a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs @@ -18,6 +18,11 @@ namespace Wolverine.Polecat.Persistence.Sagas; internal class PolecatPersistenceFrameProvider : IPersistenceFrameProvider { + // Polecat can persist any document, so CanPersist claims every type. Yield to selective + // providers (EF Core) for the entity types they actually map, regardless of the order the + // integrations were registered in + public bool IsCatchAll => true; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { persistenceService = typeof(IDocumentSession); diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs index 53281da06..9aa982b07 100644 --- a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs @@ -53,6 +53,11 @@ public bool CanApply(IChain chain, IServiceContainer container) return serviceDependencies.Any(x => x == typeof(IAsyncDocumentSession)); } + // RavenDb can persist any document, so CanPersist claims every type. Yield to selective + // providers (EF Core) for the entity types they actually map, regardless of the order the + // integrations were registered in + public bool IsCatchAll => true; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { persistenceService = typeof(IAsyncDocumentSession); diff --git a/src/Testing/CoreTests/Persistence/persistence_provider_precedence.cs b/src/Testing/CoreTests/Persistence/persistence_provider_precedence.cs new file mode 100644 index 000000000..2193705b3 --- /dev/null +++ b/src/Testing/CoreTests/Persistence/persistence_provider_precedence.cs @@ -0,0 +1,180 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Shouldly; +using Wolverine.Configuration; +using Wolverine.Persistence; +using Wolverine.Persistence.Sagas; +using Xunit; + +namespace CoreTests.Persistence; + +// Unit tests for the GH-3359 consultation-order rule: selective persistence providers +// (whose CanPersist checks the entity type against their own model, like EF Core) are +// consulted ahead of catch-all document stores (whose CanPersist claims any type, like +// Marten) regardless of the order the integrations were registered in. Within each +// group, registration order is preserved. +public class persistence_provider_precedence +{ + private static GenerationRules rulesWith(params IPersistenceFrameProvider[] providers) + { + var rules = new GenerationRules(); + rules.Properties[GenerationRulesExtensions.PersistenceKey] = providers.ToList(); + return rules; + } + + [Fact] + public void selective_provider_wins_for_its_entity_even_when_the_catch_all_registered_after_it() + { + var selective = new SelectiveProvider(typeof(MappedEntity)); + var catchAll = new CatchAllProvider(); + + // The catch-all integration registered last, so InsertFirstPersistenceStrategy put it + // at index 0 - the registration order that used to steal the entity from the selective + // provider + var rules = rulesWith(catchAll, selective); + + rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider) + .ShouldBeTrue(); + + provider.ShouldBeSameAs(selective); + } + + [Fact] + public void selective_provider_wins_for_its_entity_when_it_registered_last_too() + { + var selective = new SelectiveProvider(typeof(MappedEntity)); + var catchAll = new CatchAllProvider(); + + var rules = rulesWith(selective, catchAll); + + rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider) + .ShouldBeTrue(); + + provider.ShouldBeSameAs(selective); + } + + [Fact] + public void unmapped_entity_still_falls_through_to_the_catch_all() + { + var selective = new SelectiveProvider(typeof(MappedEntity)); + var catchAll = new CatchAllProvider(); + + var rules = rulesWith(catchAll, selective); + + // Nothing the selective provider maps, so the catch-all keeps everything else + rules.TryFindPersistenceFrameProvider(null!, typeof(UnmappedDocument), out var provider) + .ShouldBeTrue(); + + provider.ShouldBeSameAs(catchAll); + } + + [Fact] + public void registration_order_is_preserved_within_each_group() + { + var selective1 = new SelectiveProvider(typeof(MappedEntity)); + var selective2 = new SelectiveProvider(typeof(MappedEntity)); + var catchAll1 = new CatchAllProvider(); + var catchAll2 = new CatchAllProvider(); + + var rules = rulesWith(catchAll1, selective1, catchAll2, selective2); + + var ordered = rules.OrderedPersistenceProviders(); + + // OrderBy is a stable sort: selective providers first in their original relative + // order, catch-alls after in theirs + ordered.ShouldBe([selective1, selective2, catchAll1, catchAll2]); + } + + [Fact] + public void a_lone_catch_all_is_still_found() + { + var catchAll = new CatchAllProvider(); + var rules = rulesWith(catchAll); + + rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider) + .ShouldBeTrue(); + + provider.ShouldBeSameAs(catchAll); + } + + public class MappedEntity; + + public class UnmappedDocument; + + // Claims only the single entity type it was constructed with, like EF Core claiming + // only the types mapped in a registered DbContext model + private class SelectiveProvider : StubProviderBase + { + private readonly Type _entityType; + + public SelectiveProvider(Type entityType) + { + _entityType = entityType; + } + + public override bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) + { + persistenceService = GetType(); + return entityType == _entityType; + } + } + + // Claims every type it is asked about, like Marten + private class CatchAllProvider : StubProviderBase + { + public override bool IsCatchAll => true; + + public override bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) + { + persistenceService = GetType(); + return true; + } + } + + private abstract class StubProviderBase : IPersistenceFrameProvider + { + public virtual bool IsCatchAll => false; + + public abstract bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService); + + public void ApplyTransactionSupport(IChain chain, IServiceContainer container) => + throw new NotSupportedException(); + + public void ApplyTransactionSupport(IChain chain, IServiceContainer container, Type entityType) => + throw new NotSupportedException(); + + public bool CanApply(IChain chain, IServiceContainer container) => false; + + public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) => + throw new NotSupportedException(); + + public Frame DetermineInsertFrame(Variable saga, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame CommitUnitOfWorkFrame(Variable saga, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineUpdateFrame(Variable saga, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineDeleteFrame(Variable sagaId, Variable saga, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineStoreFrame(Variable saga, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineDeleteFrame(Variable variable, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServiceContainer container) => + throw new NotSupportedException(); + + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) => + throw new NotSupportedException(); + } +} diff --git a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs index 081d3c120..5d78e90be 100644 --- a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs +++ b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs @@ -289,7 +289,7 @@ private static void readSagas(IWolverineRuntime runtime, ServiceCapabilities cap .ToArray(); if (sagaTypes.Length == 0) return; - var providers = runtime.Options.CodeGeneration.PersistenceProviders(); + var providers = runtime.Options.CodeGeneration.OrderedPersistenceProviders(); var container = runtime.Options.HandlerGraph.Container; foreach (var sagaType in sagaTypes) diff --git a/src/Wolverine/Configuration/SagaPersistenceChainPolicy.cs b/src/Wolverine/Configuration/SagaPersistenceChainPolicy.cs index fd42440c2..704eb86c9 100644 --- a/src/Wolverine/Configuration/SagaPersistenceChainPolicy.cs +++ b/src/Wolverine/Configuration/SagaPersistenceChainPolicy.cs @@ -13,7 +13,7 @@ internal class SagaPersistenceChainPolicy : IChainPolicy { public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) { - var providers = rules.PersistenceProviders(); + var providers = rules.OrderedPersistenceProviders(); foreach (var chain in chains) { @@ -30,7 +30,7 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceC } } - private static bool attachSagaPersistenceFrame(IServiceContainer container, List providers, + private static bool attachSagaPersistenceFrame(IServiceContainer container, IReadOnlyList providers, Variable saga, IChain chain) { foreach (var provider in providers) diff --git a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs index 41c049896..138979dfe 100644 --- a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs @@ -9,6 +9,17 @@ namespace Wolverine.Persistence; public interface IPersistenceFrameProvider { + /// + /// Whether this provider's claims every entity type it is asked + /// about — a "catch-all" document store like Marten that can genuinely persist any document — + /// rather than checking the type against its own mapping or model (like EF Core, which only + /// claims types mapped in a registered DbContext). Catch-all providers are consulted after + /// selective providers regardless of registration order, so that an entity mapped by a + /// selective provider deterministically resolves to that provider in mixed-persistence + /// applications. + /// + bool IsCatchAll => false; + void ApplyTransactionSupport(IChain chain, IServiceContainer container); void ApplyTransactionSupport(IChain chain, IServiceContainer container, Type entityType); bool CanApply(IChain chain, IServiceContainer container); diff --git a/src/Wolverine/Persistence/Sagas/GenerationRulesExtensions.cs b/src/Wolverine/Persistence/Sagas/GenerationRulesExtensions.cs index 991d88f56..e6f50a6c5 100644 --- a/src/Wolverine/Persistence/Sagas/GenerationRulesExtensions.cs +++ b/src/Wolverine/Persistence/Sagas/GenerationRulesExtensions.cs @@ -62,21 +62,36 @@ public static bool TryFindPersistenceFrameProvider(this GenerationRules rules, I out IPersistenceFrameProvider provider) { provider = default!; - var providers = rules.PersistenceProviders(); + var providers = rules.OrderedPersistenceProviders(); if (providers.Any()) { var candidates = providers.Where(x => x.CanPersist(entityType, container, out var _)).ToArray(); - + if (candidates.Any()) { provider = candidates.First(); return true; } } - + return false; } + /// + /// The registered persistence providers in deterministic consultation order: selective + /// providers (whose CanPersist checks the entity type against their own model, like EF Core) + /// ahead of catch-all document stores (whose CanPersist claims any type, like Marten — + /// see ), preserving registration order + /// within each group. Use this instead of PersistenceProviders() whenever the first matching + /// provider wins, so mixed-persistence applications resolve independently of the order in + /// which the integrations were registered. + /// + public static IReadOnlyList OrderedPersistenceProviders(this GenerationRules rules) + { + var providers = rules.PersistenceProviders(); + return providers.Count <= 1 ? providers : providers.OrderBy(x => x.IsCatchAll ? 1 : 0).ToList(); + } + public static List PersistenceProviders(this GenerationRules rules) { if (rules.Properties.TryGetValue(PersistenceKey, out var raw) && @@ -94,9 +109,9 @@ public static List PersistenceProviders(this Generati public static IPersistenceFrameProvider GetPersistenceProviders(this GenerationRules rules, IChain chain, IServiceContainer container) { - if (rules.Properties.TryGetValue(PersistenceKey, out var raw) && raw is List list) + if (rules.Properties.TryGetValue(PersistenceKey, out var raw) && raw is List) { - return list.FirstOrDefault(x => x.CanApply(chain, container)) ?? _nullo; + return rules.OrderedPersistenceProviders().FirstOrDefault(x => x.CanApply(chain, container)) ?? _nullo; } return _nullo; diff --git a/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs b/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs index 8aee6010a..d172ddce0 100644 --- a/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs @@ -26,6 +26,10 @@ public bool CanApply(IChain chain, IServiceContainer container) return false; } + // In-memory storage accepts any entity type, so CanPersist claims everything. Yield to + // selective providers for the entity types they actually map + public bool IsCatchAll => true; + public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService) { persistenceService = GetType();