Skip to content

JasperFx 2.53.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 20 Aug 18:42
· 197 commits to main since this release

One fix and one documentation change. Minor rather than patch because the fix adds a member to a public interface — additive, with a default, so nothing has to change to keep compiling.

IProjectionStorage.IsThreadSafe (#683, #685)

AggregationRunner applies every slice in a range through a fixed 10-wide block, and every one of them gets the same IProjectionStorage instance. That is what the products' own document storage is built for. It is not what an EF Core storage can take: it wraps one DbContext per tenant/batch, and a DbContext is not thread-safe. A multi-stream projection with custom grouping fans one event out into many slices, so up to ten concurrently call Entry() / FindAsync and mutate the same change tracker — surfacing as InvalidOperationException out of Dictionary.TryInsert and NullReferenceException out of ChangeDetector.DetectChanges (JasperFx/marten#5266).

A storage can now say it cannot take that:

public bool IsThreadSafe => false;

and the runner applies its slices one at a time. Resolved per tenant group, so a store may answer differently per tenant.

Defaults to true, so no existing storage changes. The declaration is on the storage rather than on AsyncOptions because the storage is the thing that is or is not safe and it already knows — a parallelism knob would work, but it would make correctness a configuration problem a user has to know they have.

⚠️ Serializing calls inside a storage implementation is not a substitute. A lock around each member still leaves the aggregation on one thread mutating entities while another thread's Entry() runs change detection over them, which is exactly the reported ChangeDetector failure. The fan-out itself has to stop, and nothing reachable from inside the storage can stop it.

Both routes run the same handler and collect into the same exception list, so MarkSliceAction, the single-vs-aggregate throw and ApplyPendingCacheUpdates are unchanged. The exception collector is now a ConcurrentQueue — it had one unguarded writer already, and the serial route adds a second.

Adopters: Marten JasperFx/marten#5266, Polecat JasperFx/polecat#489, Fisher JasperFx/fisher#108 — each returns false from its EF Core projection storage.

One trap worth knowing if you write tests around this: NSubstitute proxies a default interface member rather than inheriting it, so Substitute.For<IProjectionStorage<,>>().IsThreadSafe is false and silently takes the serial route. Harmless, since serial is always correct, but a substitute cannot exercise the concurrent route and cannot be trusted to report what a real storage would.

The projection base classes now say they are not equivalent to yours (#649, #686)

Documentation only, no behavior change.

JasperFxSingleStreamProjectionBase and JasperFxEventProjectionBase are the shared implementations behind each store's own SingleStreamProjection<TDoc,TId> / EventProjection — and deriving from them directly is not the same as deriving from the store's subclass. Nothing about it fails at compile time.

As of Marten 9.23, Marten.Events.Aggregation.SingleStreamProjection<TDoc,TId> adds two behaviors the base omits:

  • BuildSlicer returns a TenantedEventSlicer with ForceSingleTenancy from the store's TenancyStyle — the fix for JasperFx/wolverine#2053
  • ConfigureAggregateMapping sets UseVersionFromMatchingStream = true, which changes how an aggregate's version metadata is persisted

Take the base instead and you get a projection that builds, runs, and slices or versions differently. Polecat's subclass is an empty class body, so the divergence is Marten-shaped today — which is what makes it a trap: checking one store tells you nothing.

The XML docs now say this on the types themselves, and record the two routes that do work for a projection meant to compile against several stores: a per-flavour alias bound to each store's own subclass, or — where the document owns its stream — a self-aggregating document registered with Snapshot<T>(), which sidesteps it entirely because the store then constructs its own subclass.

This is the cheap half of #649. Closing the gap properly — hoisting the behavior into the base, or a seam each store fills in — is still open.

Full Changelog: V2.52.1...V2.53.0