Make container-scoped projections usable by live aggregation and validate them - #617
Merged
Merged
Conversation
…date them (marten#5095)
A projection registered with a Scoped or Transient lifetime is wrapped in
ScopedAggregationWrapper. That wrapper does not implement
IAggregatorSource<TQuerySession>, so ProjectionGraph.AggregatorFor<T>() never
finds it: the lookup falls through tryFindProjectionSourceForAggregateType to
conventional aggregation built off the aggregate type itself. Every native
aggregation path -- AggregateStreamAsync, RebuildSingleStreamAsync,
AggregateToAsync -- therefore silently ignored the projection's own logic.
Registering the same projection as a Singleton exposed the real projection
(single stream projections implement IAggregatorSource) and worked.
Separately, ProjectionSourceWrapperBase's constructor copied name/version/options
off the resolved source but never called AssembleAndAssertValidity() on it. Only
the wrapper was validated, so an invalid projection that a Singleton registration
rejects at startup was silently accepted when registered Scoped -- and switching
to Singleton later would surface a configuration error that had been hidden.
Three changes:
- ScopedSingleStreamAggregationWrapper adds IAggregatorSource on top of the
general aggregation wrapper. It is a separate type rather than something the
base wrapper does for every aggregation projection because only
JasperFxSingleStreamProjectionBase implements IAggregator -- making the base
wrapper an IAggregatorSource would newly expose multi-stream projections to
AggregatorFor, which the Singleton path does not do, and their Build<T>() would
fail an invalid cast at runtime.
- ScopedAggregator resolves the projection from a fresh container scope on every
aggregation call and delegates. It deliberately caches no projection instance:
the point of a Scoped/Transient registration is that the projection and its
dependency graph are not safe to cache, and AggregatorFor caches the aggregator
itself for the life of the store.
- ProjectionSourceWrapperBase now calls AssembleAndAssertValidity() on the
resolved source. That constructor is the one choke point every scoped wrapper
passes through, including composite children. Because the constructor runs
through reflection, the new static ScopedAggregationWrapper.Build helper
unwraps TargetInvocationException so callers see the real
InvalidProjectionException with its original stack rather than a reflection
wrapper.
Build is a static selector so event stores call one helper instead of closing a
wrapper type directly, and get the right wrapper for the projection shape.
Verified through Marten (marten#5095 branch, local pack): 9 repro cases across
{live aggregation, single stream rebuild, invalid-projection validation} x
{Singleton, Scoped, Transient} go 3 passed/6 failed -> 9 passed. No regressions:
JasperFx EventTests 672/672; Marten ContainerScopedProjectionTests 80/80,
EventSourcingTests 1614/1614, DaemonTests 267/267, full solution builds clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the JasperFx.Events half of JasperFx/marten#5095. The Marten side is JasperFx/marten#5134.
The two bugs
A projection registered through
AddProjectionWithServiceswith a Scoped or Transient lifetime is wrapped inScopedAggregationWrapper. Two things follow from that wrapper:1. Native aggregation silently ignores the projection. The wrapper does not implement
IAggregatorSource<TQuerySession>, soProjectionGraph.AggregatorFor<T>()never finds it — the lookup falls throughtryFindProjectionSourceForAggregateTypeto conventional aggregation built off the aggregate type itself.AggregateStreamAsync,RebuildSingleStreamAsync, andAggregateToAsynctherefore run something other than the registered projection, with no error. Registering the same projection as a Singleton works, because single stream projections implementIAggregatorSourcedirectly.2. Validation is skipped.
ProjectionSourceWrapperBase's constructor copies name/version/options off the resolved source but never callsAssembleAndAssertValidity()on it. Only the wrapper gets validated, so a projection that a Singleton registration rejects at startup is silently accepted when registered Scoped — and switching to Singleton later surfaces a configuration error that had been hidden all along.The fix
ScopedSingleStreamAggregationWrapperaddsIAggregatorSourceon top of the general aggregation wrapper.This is a separate type rather than something the base wrapper does for every aggregation projection, and that's the main design call here: only
JasperFxSingleStreamProjectionBaseimplementsIAggregator. Making the base wrapper anIAggregatorSourcewould newly expose multi-stream projections toAggregatorFor— which the Singleton path does not do — and theirBuild<T>()would fail an invalid cast at runtime. Splitting the type keeps scoped and singleton behavior identical for both projection shapes.ScopedAggregatorresolves the projection from a fresh container scope on every aggregation call and delegates. It deliberately holds no projection instance: the whole point of choosing Scoped/Transient is that the projection and its dependency graph are not safe to cache, andAggregatorForcaches the aggregator itself for the life of the store. Caching a resolved projection there would reintroduce exactly the singleton-safety problem the user was avoiding.ProjectionSourceWrapperBasenow callsAssembleAndAssertValidity()on the resolved source. That constructor is the one choke point every scoped wrapper passes through, including composite children.Because that constructor runs through reflection, a validation failure would surface as
TargetInvocationException. The new staticScopedAggregationWrapper.Buildselector unwraps it viaExceptionDispatchInfoso callers see the realInvalidProjectionExceptionwith its original stack.Buildalso picks the right wrapper for the projection shape, so event stores call one helper instead of closing a wrapper type directly.Verification
Everything was proven through Marten against a local pack of this branch, since the behavior is only observable through a real store.
The repro is a 3×3 matrix — {live aggregation, single stream rebuild, invalid-projection validation} × {Singleton, Scoped, Transient} — using an aggregate with no conventional
Create/Applymethods, so the conventional fallback cannot accidentally produce a correct-looking answer.Every failure was Scoped or Transient; every Singleton case passed throughout, which is exactly the asymmetry the issue reports.
No regressions: JasperFx
EventTests672/672; MartenContainerScopedProjectionTests80/80,EventSourcingTests1614/1614,DaemonTests267/267, fullMarten.slnxbuilds clean.Note for Polecat
ScopedAggregationWrapper.Buildis the entry point to adopt — an event store that closes the wrapper type directly still gets the validation fix (it is in the shared base constructor) but not the aggregator fix.🤖 Generated with Claude Code