Skip to content

Describe a projection as it was registered; keep nothing-to-store types out of teardown - #1108

Merged
jeremydmiller merged 3 commits into
mainfrom
fix/projection-descriptor-gaps
Nov 2, 2026
Merged

jeremydmiller merged 3 commits into
mainfrom
fix/projection-descriptor-gaps

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Three problems found next to #1106 in how projections describe themselves. All three were real: 16 of the new tests failed on the unfixed code, and the other new tests pin behaviour that was already right.

Stacked on #1106, which is still open, so this targets feat/1102-descriptor-published-types. Retarget this PR to main before merging #1106. Deleting that branch would close this PR.

1. A scoped projection reported values from an instance registration never touched

ProjectionSourceWrapperBase.Describe resolved a new instance and described that. Marten and Fisher set Lifecycle and run configure on the wrapper, and the wrapper is what runs, so the description was of something else:

Set at registration Reported before
Lifecycle = Inline Async, with daemon metrics and spans that never exist
Name = "RenamedAtRegistration" MyAggregate, and a shard name that doesn't run
Version = 3 1
IncludeType<BEvent>() missing from AppliedEvents

EventStoreUsage.PopulateAgentUris reads Lifecycle and ShardNames, so an Inline projection also got an agent URI for an agent that doesn't exist, and a renamed one got a URI under the wrong name.

The wrapper still calls source.Describe(store), then overlays what registration decided through one internal SubscriptionDescriptor.ReadRegistrationFrom. This replaces the PublishedTypes line #1106 added. Rebuilding from the wrapper was the alternative, but Describe is virtual on an event projection and that would lose anything a projection adds to its own description. There is a test for that.

This affects Marten and Fisher. Polecat has no scoped wrappers.

2. CompositeProjection.PublishedTypes() dropped the composite's own declarations

The override replaced the base and returned only the members' types, although the SubscriptionDescriptor.PublishedTypes docs already promised these. The base now does what FisherCompositeProjection did, with two differences:

  • It concatenates base.PublishedTypes(), so a type given to RegisterPublishedType on a composite counts too.
  • The composite's own types go after the members', because Polecat and Fisher read PublishedTypes().FirstOrDefault() as a projection's state type.

Store sources read at origin heads: marten 2f2769bca, polecat 8545249, fisher 3f4c4be. Only Fisher overrides this, and it stays correct, just redundant. Wolverine has no subclass.

This is more than a descriptor change, because PublishedTypes() has other readers. For a composite that names a type for itself, which should be rare:

  • Marten: AllPublishedTypes now materialises a mapping for that type, and ProjectionScenario wipes it. Its rebuild teardown reads CleanUps and doesn't change.
  • Polecat: ConfiguredDocumentProviders now creates a provider for it. CollectTeardownTargets adds PublishedTypes() before it checks TeardownDataOnRebuild, so a composite with its own rule and TeardownDataOnRebuild = false now wipes that table. Polecat already treats ordinary projections this way, but this is the one change that could surprise someone.
  • JasperFx: AsyncShardsPublishingType and RebuildProjectionAsync(Type) can now resolve that type to the composite, and TryFindAggregate stays null-safe.

3. Teardown needed the guard, and the path is worse than the failed-validation case

Registering void before validation fails is harmless by itself, because the projection never gets registered. But a generated dispatcher skips method validation entirely, and the analyzer silently drops a Create or Transform that returns void, Task or ValueTask. A partial projection with one good method and one bad one therefore passes validation with:

Options.CleanUps: [DocOne, System.Void, Task, ValueTask]

The same list went into StorageTypes and PublishedTypes(), so each store was handed System.Void as a document type.

The guard now sits in EventProjectionApplication.PublishedTypes(), where the list starts, so it covers all of those readers. The filter #1106 added to the descriptor became dead code and is removed.

Not bugs

  • ScopedProjectionWrapper describes itself, so it always reported what was registered. It is pinned by a test. Its // TODO -- some way to understand the lifecycle is out of date.
  • AppliedEvents on a scoped aggregation matches the unwrapped projection unless registration changes the filter.
  • Task<T> and ValueTask<T> from Create already registered the document type, and now have a test.

Gate

./build.sh Test --root "$PWD" passed on this commit, on net9.0 and net10.0, with no failures. Every target succeeded:

Project Passed Skipped
EventTests 1,717 0
EventStoreTests 350 23
InMemoryEventStoreTests 139 9
CoreTests 810 1
CodegenTests 495 0
CommandLineTests 312 0

Counts are per framework. The source generator, Aspire and MEAI suites and the smoke targets passed too.

CI has not run on this PR. dotnet.yml only triggers for PRs targeting main, so the empty check list is not a pass. It will run once this is retargeted, and it should be green before merging.

Not run against a real store. The store effects above come from reading their sources. The compliance suite has no store-neutral way to reach a composite's Options or a scoped registration, so these are unit tests only, and I left the suite alone rather than add a builder member each store would have to implement.

Follow-ups, not done here

  • A void or Task Create under a generated dispatcher never runs, and nothing says so. This PR stops the bad teardown target, but the method is still dropped silently while its event stays in IncludedEventTypes. Without the generator the same projection is refused at startup. It needs an analyzer diagnostic or validation that also covers generated dispatchers. This comes from reading the analyzer and emitter.
  • Remove FisherCompositeProjection.PublishedTypes() once Fisher pins a release with this.
  • ScopedProjectionWrapper resets Version to 1 (or its [ProjectionVersion]) after copying the source's, so a version set in a projection's constructor is lost. ProjectionSourceWrapperBase keeps it. This one is not a descriptor error, because the shard runs under the same number, but the two wrappers disagree. Also from reading only.

🤖 Generated with Claude Code

jeremydmiller and others added 3 commits November 2, 2026 06:15
AggregateType only answers for an aggregation, so an event projection or a
composite gave a tool nothing to link it to the documents it writes, although
PublishedTypes() already knew. The descriptor is built from the non-generic
ISubscriptionSource, which cannot reach that generic member, so a small
IPublishesTypes interface does, the way IAggregateProjection does for the
aggregate. Every IProjectionSource is one through a default implementation, so
no store or third-party source has anything to change, including one that
implements PublishedTypes() explicitly. The member stays declared on
IProjectionSource because stores compile against it there.

The wrappers were the risk, and one was wrong. ProjectionSourceWrapperBase kept
its copy of the source's types behind an explicit interface implementation
beside ProjectionBase's public method, which never saw them. An interface binds
to that public method on anything deriving from ProjectionBase, so a scoped
projection read as writing only what was in Options.StorageTypes. Assembly hid
this by copying the types there as teardown targets, until a projection opted
out of teardown. It is an override now. Describe() on the same wrapper also
reports a newly resolved instance, which has never seen the Options configured
at registration, so the wrapper supplies the list itself.

The value is read at describe time on projections nothing may have validated,
so it filters the void and Task return types an event projection records from
its constructor, and a PublishedTypes() that throws leaves the list empty
rather than losing the whole Describe() pipeline. It is sorted by full name so
that a descriptor does not reorder between runs, and a payload with no such
field, or a null one, reads as empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every store wraps projection sources of its own, and a store that left
PublishedTypes empty would look the same as one whose projections write
nothing, so the check asks each store through TryCreateUsage for one of each
kind. It lives in EventProjectionRegistrationCompliance, the suite about what a
projection publishes, because the in-memory store can run that one and cannot
take the explorer suite's async registration. The composite and subscription
facts need a daemon, so they skip there.

Running it found that the in-memory store's TryCreateUsage never called
Projections.Describe, so it read as a store with no projections at all. That is
fisher#120 again, and Marten, Polecat and Fisher all make the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es out of teardown

Three gaps in how projections describe themselves, each reproduced with a
failing test first.

A container-scoped source wrapper described a freshly resolved instance, so
the name, version, lifecycle, event filters and shards set at registration
were lost: an Inline projection reported Async with daemon metrics, and a
renamed one reported a shard that does not run. The wrapper now overlays what
registration decided onto that description.

CompositeProjection.PublishedTypes() dropped the composite's own declarations.
It now includes them after its members. Fisher's override did this for Fisher
alone (fisher#63) and is now redundant.

An event projection recorded void, Task and ValueTask as published types from
its constructor. A generated dispatcher skips method validation and the
generator drops such a method, so these reached Options.CleanUps on a
projection that passed validation. They now stop at
EventProjectionApplication.PublishedTypes(), which makes the descriptor-only
filter from #1106 dead code, so that is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller changed the base branch from feat/1102-descriptor-published-types to main November 2, 2026 12:48
@jeremydmiller
jeremydmiller merged commit eb7005d into main Nov 2, 2026
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