Skip to content

Make container-scoped projections usable by live aggregation and validate them - #617

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/5095-scoped-aggregator
Aug 3, 2026
Merged

Make container-scoped projections usable by live aggregation and validate them#617
jeremydmiller merged 1 commit into
mainfrom
fix/5095-scoped-aggregator

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes the JasperFx.Events half of JasperFx/marten#5095. The Marten side is JasperFx/marten#5134.

The two bugs

A projection registered through AddProjectionWithServices with a Scoped or Transient lifetime is wrapped in ScopedAggregationWrapper. Two things follow from that wrapper:

1. Native aggregation silently ignores the projection. The 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. AggregateStreamAsync, RebuildSingleStreamAsync, and AggregateToAsync therefore run something other than the registered projection, with no error. Registering the same projection as a Singleton works, because single stream projections implement IAggregatorSource directly.

2. Validation is skipped. ProjectionSourceWrapperBase's constructor copies name/version/options off the resolved source but never calls AssembleAndAssertValidity() 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

ScopedSingleStreamAggregationWrapper adds IAggregatorSource on 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 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. Splitting the type keeps scoped and singleton behavior identical for both projection shapes.

ScopedAggregator resolves 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, and AggregatorFor caches 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.

ProjectionSourceWrapperBase now calls AssembleAndAssertValidity() 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 static ScopedAggregationWrapper.Build selector unwraps it via ExceptionDispatchInfo so callers see the real InvalidProjectionException with its original stack. Build also 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/Apply methods, so the conventional fallback cannot accidentally produce a correct-looking answer.

before after
Repro matrix 3 passed / 6 failed 9 passed

Every failure was Scoped or Transient; every Singleton case passed throughout, which is exactly the asymmetry the issue reports.

No regressions: JasperFx EventTests 672/672; Marten ContainerScopedProjectionTests 80/80, EventSourcingTests 1614/1614, DaemonTests 267/267, full Marten.slnx builds clean.

Note for Polecat

ScopedAggregationWrapper.Build is 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

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant