diff --git a/src/Persistence/MartenTests/service_location_document_session.cs b/src/Persistence/MartenTests/service_location_document_session.cs
new file mode 100644
index 000000000..bba13f058
--- /dev/null
+++ b/src/Persistence/MartenTests/service_location_document_session.cs
@@ -0,0 +1,81 @@
+using IntegrationTests;
+using JasperFx.CodeGeneration.Model;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace MartenTests;
+
+///
+/// GH-3001: when a handler chain falls back to service location, a dependency that takes
+/// must receive the SAME outbox-enrolled session the handler is
+/// using — not a separate, un-enrolled one (which would defeat the transaction boundary). Proven via
+/// reference equality against the handler's own session.
+///
+public class service_location_document_session : PostgresqlContext
+{
+ [Fact]
+ public async Task service_located_session_is_same_instance_as_the_handler_session()
+ {
+ using var host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Services.AddMarten(Servers.PostgresConnectionString).IntegrateWithWolverine();
+
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(SessionProbeCommandHandler));
+
+ opts.ServiceLocationPolicy = ServiceLocationPolicy.AllowedButWarn;
+ opts.Services.AddScoped();
+
+ // Force the capturing service to be resolved via service location so the chain creates
+ // a child scope — the path GH-3001 primes.
+ opts.CodeGeneration.AlwaysUseServiceLocationFor();
+ }).StartAsync();
+
+ SessionIdentityProbe.Reset();
+
+ await host.InvokeMessageAndWaitAsync(new SessionProbeCommand());
+
+ SessionIdentityProbe.HandlerSession.ShouldNotBeNull();
+ SessionIdentityProbe.ServiceLocatedSession.ShouldNotBeNull();
+
+ // Reference equality — the service-located session IS the handler's outbox-enrolled session.
+ ReferenceEquals(SessionIdentityProbe.HandlerSession, SessionIdentityProbe.ServiceLocatedSession)
+ .ShouldBeTrue();
+ }
+}
+
+public record SessionProbeCommand;
+
+public static class SessionIdentityProbe
+{
+ public static IDocumentSession? HandlerSession;
+ public static IDocumentSession? ServiceLocatedSession;
+
+ public static void Reset()
+ {
+ HandlerSession = null;
+ ServiceLocatedSession = null;
+ }
+}
+
+public class SessionCapturingService(IDocumentSession session)
+{
+ public IDocumentSession Capture() => session;
+}
+
+public static class SessionProbeCommandHandler
+{
+ public static void Handle(SessionProbeCommand command, IDocumentSession handlerSession, IServiceProvider services)
+ {
+ SessionIdentityProbe.HandlerSession = handlerSession;
+ SessionIdentityProbe.ServiceLocatedSession = services
+ .GetRequiredService()
+ .Capture();
+ }
+}
diff --git a/src/Persistence/Wolverine.Marten/Codegen/PrimeScopedDocumentSessionFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/PrimeScopedDocumentSessionFrame.cs
new file mode 100644
index 000000000..85447521b
--- /dev/null
+++ b/src/Persistence/Wolverine.Marten/Codegen/PrimeScopedDocumentSessionFrame.cs
@@ -0,0 +1,59 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.CodeGeneration.Services;
+using JasperFx.Core.Reflection;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Wolverine.Marten.Codegen;
+
+///
+/// Emitted (via IScopedContainerCreation.AddPostProcessor) immediately after a handler's
+/// service-location child scope is created. Primes that scope's
+/// with the handler's outbox-enrolled , so any service-located
+/// / resolves to that single enrolled
+/// session rather than a separate one. See GH-3001.
+///
+internal sealed class PrimeScopedDocumentSessionFrame : SyncFrame, IUsesServiceProviderFrame
+{
+ private Variable? _session;
+ private Variable? _scopedProvider;
+
+ // The parent ScopedContainerCreation hands us the scoped IServiceProvider variable before we
+ // resolve our other variables (avoiding a bi-directional dependency with the scope line).
+ public void UseServiceProvider(Variable serviceProvider) => _scopedProvider = serviceProvider;
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ // The enrolled session created by CreateDocumentSessionFrame (NotServices: never the
+ // container's own scoped IDocumentSession registration).
+ _session = chain.TryFindVariable(typeof(IDocumentSession), VariableSource.NotServices);
+ if (_session != null)
+ {
+ yield return _session;
+ }
+ }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ if (_session != null)
+ {
+ writer.Write(
+ $"{typeof(ServiceProviderServiceExtensions).FullNameInCode()}.{nameof(ServiceProviderServiceExtensions.GetRequiredService)}<{typeof(ScopedDocumentSessionHolder).FullNameInCode()}>({_scopedProvider!.Usage}).{nameof(ScopedDocumentSessionHolder.Session)} = {_session.Usage};");
+ }
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ if (_session != null)
+ {
+ writer.Write(
+ $"{typeof(ServiceProviderServiceExtensions).FSharpName()}.{nameof(ServiceProviderServiceExtensions.GetRequiredService)}<{typeof(ScopedDocumentSessionHolder).FSharpName()}>({_scopedProvider!.Usage}).{nameof(ScopedDocumentSessionHolder.Session)} <- {_session.Usage}");
+ }
+
+ Next?.GenerateFSharpCode(method, writer);
+ }
+}
diff --git a/src/Persistence/Wolverine.Marten/MartenIntegration.cs b/src/Persistence/Wolverine.Marten/MartenIntegration.cs
index 37784ef0a..abceea335 100644
--- a/src/Persistence/Wolverine.Marten/MartenIntegration.cs
+++ b/src/Persistence/Wolverine.Marten/MartenIntegration.cs
@@ -63,6 +63,11 @@ public void Configure(WolverineOptions options)
options.CodeGeneration.Sources.Add(new MartenBackedPersistenceMarker());
+ // GH-3001: prime the service-location child scope with the handler's outbox-enrolled
+ // IDocumentSession so a service-located IDocumentSession / IQuerySession resolves to that same
+ // session. The frame self-guards (no-op when the chain has no Marten session).
+ options.ScopingFrameSources.Add(() => new PrimeScopedDocumentSessionFrame());
+
options.CodeGeneration.InsertFirstPersistenceStrategy();
options.CodeGeneration.Sources.Add(new SessionVariableSource());
options.CodeGeneration.Sources.Add(new DocumentOperationsSource());
diff --git a/src/Persistence/Wolverine.Marten/ScopedDocumentSessionHolder.cs b/src/Persistence/Wolverine.Marten/ScopedDocumentSessionHolder.cs
new file mode 100644
index 000000000..cbc8d1283
--- /dev/null
+++ b/src/Persistence/Wolverine.Marten/ScopedDocumentSessionHolder.cs
@@ -0,0 +1,19 @@
+using Marten;
+
+namespace Wolverine.Marten;
+
+///
+/// Scope-local carrier for the outbox-enrolled a handler is using.
+/// When a handler falls back to service location, Wolverine's generated code creates a child
+/// off the root provider; the Marten scoping frame primes this holder in
+/// that scope so a service-located /
+/// resolves to the SAME session enrolled with the active outbox instead of a separate, un-enrolled
+/// one (which would defeat the transaction boundary). See GH-3001.
+///
+/// The holder is empty in non-handler scopes (hosted services, admin tools, raw resolution), where
+/// the decorated registration falls back to Marten's own session factory.
+///
+public sealed class ScopedDocumentSessionHolder
+{
+ public IDocumentSession? Session { get; set; }
+}
diff --git a/src/Persistence/Wolverine.Marten/WolverineOptionsMartenExtensions.cs b/src/Persistence/Wolverine.Marten/WolverineOptionsMartenExtensions.cs
index b586bfa0a..34f3df237 100644
--- a/src/Persistence/Wolverine.Marten/WolverineOptionsMartenExtensions.cs
+++ b/src/Persistence/Wolverine.Marten/WolverineOptionsMartenExtensions.cs
@@ -88,6 +88,17 @@ public static MartenServiceCollectionExtensions.MartenConfigurationExpression In
expression.Services.AddScoped();
+ // GH-3001: structural scope priming for Marten sessions. When a handler falls back to service
+ // location, the generated code primes the child scope's ScopedDocumentSessionHolder with the
+ // handler's outbox-enrolled IDocumentSession (PrimeScopedDocumentSessionFrame). Decorate
+ // Marten's own IDocumentSession / IQuerySession scoped registrations so service-located
+ // resolution prefers that primed session — enrolled with the active outbox — instead of a
+ // separate, un-enrolled session. Non-handler scopes (the holder is empty) fall back to
+ // Marten's original session factory.
+ expression.Services.AddScoped();
+ preferScopedSession(expression.Services);
+ preferScopedSession(expression.Services);
+
// Gotta have at least a placeholder just in case a user also has
// EF Core
expression.Services.AddSingleton(s =>
@@ -154,6 +165,48 @@ public static MartenServiceCollectionExtensions.MartenConfigurationExpression In
return expression;
}
+ // GH-3001: replace Marten's scoped session registration with one that prefers a scope-primed
+ // session (the outbox-enrolled session the handler is using), falling back to Marten's original
+ // factory when the holder is empty (non-handler scopes). Preserving the original factory keeps
+ // Marten's exact session-building (options, tenancy) for the fall-back path.
+ private static void preferScopedSession(IServiceCollection services) where T : class
+ {
+ var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(T));
+ if (descriptor == null)
+ {
+ return;
+ }
+
+ Func original;
+ if (descriptor.ImplementationFactory != null)
+ {
+ original = descriptor.ImplementationFactory;
+ }
+ else if (descriptor.ImplementationInstance != null)
+ {
+ original = _ => descriptor.ImplementationInstance;
+ }
+ else if (descriptor.ImplementationType != null)
+ {
+ original = sp => ActivatorUtilities.CreateInstance(sp, descriptor.ImplementationType);
+ }
+ else
+ {
+ return;
+ }
+
+ services.Remove(descriptor);
+ services.AddScoped(sp =>
+ {
+ if (sp.GetRequiredService().Session is T primed)
+ {
+ return primed;
+ }
+
+ return (T)original(sp);
+ });
+ }
+
internal static NpgsqlDataSource findMasterDataSource(
DocumentStore store,
IWolverineRuntime runtime,
diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs
index 2a66a2f60..f50c01989 100644
--- a/src/Wolverine/Configuration/Chain.cs
+++ b/src/Wolverine/Configuration/Chain.cs
@@ -497,9 +497,10 @@ protected internal void tryApplyResponseAware()
///
/// When a chain service-locates, the generated code creates a child scope, and Wolverine primes
/// that scope so a service-located /
- /// (and contributors such as Marten's
- /// IDocumentSession) resolves to the same instance the handler received rather than a
- /// duplicate. See GH-3001 (which replaced the earlier AsyncLocal handoff from GH-2583).
+ /// (and integration-contributed instances such as Marten's IDocumentSession, via
+ /// WolverineOptions.ScopingFrameSources) resolves to the same instance the handler
+ /// received rather than a duplicate. See GH-3001 (which replaced the earlier AsyncLocal handoff
+ /// from GH-2583).
///
public bool UsesServiceLocation { get; private set; }
diff --git a/src/Wolverine/Configuration/IRequireScopingFrame.cs b/src/Wolverine/Configuration/IRequireScopingFrame.cs
deleted file mode 100644
index bcabe68d7..000000000
--- a/src/Wolverine/Configuration/IRequireScopingFrame.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using JasperFx.CodeGeneration.Frames;
-
-namespace Wolverine.Configuration;
-
-///
-/// Implemented by codegen frames whose work resolves a "should-be-singleton-per-message" instance
-/// (e.g. an outbox-enrolled IDocumentSession) that must also be seen by any service-located
-/// dependency. When a chain falls back to service location, Wolverine collects a scoping frame from
-/// every in the chain and emits it immediately after the child
-/// service-location scope is created, so service location returns the already-resolved instance
-/// rather than a duplicate. See GH-3001.
-///
-public interface IRequireScopingFrame
-{
- ///
- /// Build the frame that primes the service-location scope with this frame's already-resolved
- /// instance. Returns null when, for the current configuration, no priming is required.
- ///
- SyncFrame? BuildScopingFrame();
-}
diff --git a/src/Wolverine/Runtime/Handlers/HandlerChain.cs b/src/Wolverine/Runtime/Handlers/HandlerChain.cs
index 5911c7c76..d387173b2 100644
--- a/src/Wolverine/Runtime/Handlers/HandlerChain.cs
+++ b/src/Wolverine/Runtime/Handlers/HandlerChain.cs
@@ -674,11 +674,13 @@ internal virtual List DetermineFrames(GenerationRules rules, IServiceCont
: Array.Empty();
// GH-3001: when this chain falls back to service location, prime the child scope so
- // service-located IMessageContext / IMessageBus (and any IRequireScopingFrame contributor)
- // resolve to the same instances the handler uses instead of duplicates. The activator emits
- // nothing unless a service-location scope is actually created for the chain.
+ // service-located IMessageContext / IMessageBus (and integration-registered instances like
+ // Marten's outbox-enrolled IDocumentSession) resolve to the same instances the handler uses
+ // instead of duplicates. The MessageContext frame is always added; integrations contribute
+ // more via WolverineOptions.ScopingFrameSources. Every scoping frame self-guards, and the
+ // activator emits nothing unless a service-location scope is actually created for the chain.
var scopingFrames = new List { new PrimeScopedMessageContextFrame() };
- scopingFrames.AddRange(collectScopingFrames(handlerReturnValueFrames));
+ scopingFrames.AddRange(options?.ScopingFrameSources.Select(x => x()) ?? []);
var scopeActivator = new ScopePrimingActivatorFrame(scopingFrames);
// The Enqueue cascading needs to happen before the post processors because of the
@@ -696,28 +698,6 @@ internal virtual List DetermineFrames(GenerationRules rules, IServiceCont
.ToList();
}
- // GH-3001: collect a scoping frame from every IRequireScopingFrame in the chain (persistence
- // providers contribute IDocumentSession / IQuerySession priming), de-duplicated so two frames
- // never prime the same type. The MessageContext priming frame is added unconditionally by the
- // caller, so it is excluded here.
- private IEnumerable collectScopingFrames(IEnumerable handlerReturnValueFrames)
- {
- var candidates = Middleware
- .Concat(Handlers)
- .Concat(handlerReturnValueFrames)
- .Concat(Postprocessors)
- .OfType();
-
- var seen = new HashSet();
- foreach (var candidate in candidates)
- {
- var frame = candidate.BuildScopingFrame();
- if (frame != null && seen.Add(candidate.GetType()))
- {
- yield return frame;
- }
- }
- }
protected void applyCustomizations(GenerationRules rules, IServiceContainer container)
{
diff --git a/src/Wolverine/Runtime/Handlers/ScopePrimingActivatorFrame.cs b/src/Wolverine/Runtime/Handlers/ScopePrimingActivatorFrame.cs
index 178287892..7c34f609e 100644
--- a/src/Wolverine/Runtime/Handlers/ScopePrimingActivatorFrame.cs
+++ b/src/Wolverine/Runtime/Handlers/ScopePrimingActivatorFrame.cs
@@ -9,9 +9,10 @@ namespace Wolverine.Runtime.Handlers;
/// Codegen-time activator (GH-3001). When a chain falls back to service location, the generated code
/// creates a child IServiceScope off the root provider. This frame detects that scope at
/// arrangement time and registers scoping postprocessors on it — always the
-/// , plus one from every collected
-/// — so they run immediately after the
-/// scope is created and prime it with the correct already-resolved instances. Emits no code itself.
+/// , plus the frames produced by
+/// WolverineOptions.ScopingFrameSources (integration-contributed, e.g. Marten's session
+/// priming) — so they run immediately after the scope is created and prime it with the correct
+/// already-resolved instances. Emits no code itself.
///
/// If no service-location scope is created for the chain (the IServiceProvider variable's Creator is
/// not an ), nothing is attached.
diff --git a/src/Wolverine/WolverineOptions.cs b/src/Wolverine/WolverineOptions.cs
index 813e2afea..fd01cad99 100644
--- a/src/Wolverine/WolverineOptions.cs
+++ b/src/Wolverine/WolverineOptions.cs
@@ -332,6 +332,17 @@ public MultipleHandlerBehavior MultipleHandlerBehavior
///
public List MetadataRules { get; } = new();
+ ///
+ /// GH-3001 extension point. Factories for codegen frames that prime a handler's service-location
+ /// child scope with an already-resolved "singleton-per-message" instance (e.g. Marten's
+ /// outbox-enrolled IDocumentSession), so service-located dependencies resolve to that instance
+ /// rather than a duplicate. Each produced frame is emitted right after the scope is created and
+ /// must self-guard (no-op when its target variable is absent from the chain). Integrations
+ /// (Wolverine.Marten / Wolverine.Polecat) register a factory here; the MessageContext priming
+ /// frame is always added by the runtime in addition to these.
+ ///
+ public List> ScopingFrameSources { get; } = new();
+
/// For advanced usages, this gives you the ability to register pre-canned message handling
/// that does not require any code generation.