Composite members registered through a wrapper are torn down on rebuild (#5175) - #5182
Merged
Merged
Conversation
…ld (#5175) A composite's rebuild tears its members down by looping `composite.AllProjections()` and reading each member's own `Options.CleanUps`. Two of the four ways to register a member produce a source with a fresh, EMPTY `AsyncOptions`, so those members queued no teardown at all — and since progression rows and dead-letter rows *were* still deleted, the rebuild restarted from sequence zero and wrote into a table still holding the previous run's rows. `CompositeProjectionWithServicesSource<T>` and `CompositeIProjectionSource` now adopt the wrapped projection's options via `replaceOptions(...)`, matching what `ProjectionWrapper` and `ScopedProjectionWrapper` have always done, and register its published types. `Name` and `Version` are deliberately NOT adopted: they compose the member's `ShardName.Identity`, and changing them would orphan every existing progression row. A raw `IProjection` that is not a `ProjectionBase` declares neither storage nor teardown, so there is nothing to adopt and nothing the composite can invent. New overload `Add(IProjection, Action<AsyncOptions>, int)` lets that be declared at registration — typically `options => options.DeleteViewTypeOnTeardown<MyView>()`. Second defect in the same branch: the composite's own `Options` were never applied, so `composite.Options.DeleteViewTypeOnTeardown<T>()` and `TeardownDataOnRebuild = false` were silent no-ops on it. The parent now goes through the same `teardownProjectionStorage` as any other source rather than only having its progression rows dropped — same for the per-tenant twin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy
This was referenced Aug 5, 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.
Closes #5175.
The bug
A composite's rebuild tears its members down by looping
composite.AllProjections()and reading each member's ownOptions.CleanUps. Two of the four registration paths produce a source with a fresh, emptyAsyncOptions:Options.CleanUps(before)Snapshot<T>(stage)SingleStreamProjection<T,TId>Add(IProjectionSource, stage)AddProjectionWithServices<T>(...)CompositeProjectionWithServicesSource<T>Add(IProjection, stage)CompositeIProjectionSourceSo
teardownProjectionStorageran against those leaves and queued nothing. Progression rows and dead-letter rows were still deleted, so the rebuild restarted from sequence zero and replayed into a table that still held the previous run's documents — silently.The fix
1. The wrappers adopt the wrapped projection's options.
CompositeProjectionWithServicesSource<T>andCompositeIProjectionSourcenow callreplaceOptions(...)and register the inner projection's published types, exactly asProjectionWrapper(:51) andScopedProjectionWrapper(:53) have always done.NameandVersionare deliberately not adopted — they compose the member'sShardName.Identity, and changing them would orphan every existing progression row.2. A raw
IProjectioncan declare its teardown. A customIProjectionthat isn't aProjectionBasedescribes neither its storage nor its teardown, so there is nothing to adopt and nothing the composite can invent. New overload:An added overload, not a changed signature, so existing
Add(IProjection, int)callers are untouched.3. The composite's own options are applied. The
is CompositeProjectionbranch never calledteardownProjectionStorage(composite, …), socomposite.Options.DeleteViewTypeOnTeardown<T>()andcomposite.Options.TeardownDataOnRebuild = falsewere silent no-ops on the parent. It now goes through the same teardown as any other source rather than only having its progression rows dropped — which also means its dead-letter rows are cleared. Same for the per-tenant twin (teardownProjectionStorageForTenant).Tests
DaemonTests/Composites/Bug_5175_composite_member_teardown.cs:every_member_reports_its_own_teardown_rules— each member'sOptions.CleanUpscarries aDeleteDocumentsfor the type it writesrebuilding_deletes_every_members_documents_first— an end-to-end rebuild over a composite with anAddProjectionWithServicesmember and a rawIProjectionmember. Orphan rows of each view type (that no event can reproduce) must be gone afterwards, and the real read models must be back.Falsified: with the two
replaceOptions(...)calls reverted, both tests fail (the AddProjectionWithServices member's documents must be torn down on rebuild).Full
DaemonTestssuite green: 292/292 on net10.0.Related
#5169 (the
EventProjectionScenariowipe being a no-op for composites) is the same family but a different mechanism — the harness derives its wipe list fromOptions.StorageTypes, which a composite never populates. Handled separately so the two stay reviewable; the design there is to have the composite surface its members' storage types without duplicating theirCleanUps, so it does not double up with this teardown.🤖 Generated with Claude Code
https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy