Skip to content

Composite members registered through a wrapper are torn down on rebuild (#5175) - #5182

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5175-composite-teardown
Aug 4, 2026
Merged

Composite members registered through a wrapper are torn down on rebuild (#5175)#5182
jeremydmiller merged 1 commit into
masterfrom
fix/5175-composite-teardown

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #5175.

The bug

A composite's rebuild tears its members down by looping composite.AllProjections() and reading each member's own Options.CleanUps. Two of the four registration paths produce a source with a fresh, empty AsyncOptions:

registration member source type Options.CleanUps (before)
Snapshot<T>(stage) SingleStreamProjection<T,TId> populated
Add(IProjectionSource, stage) as supplied populated if the source populates it
AddProjectionWithServices<T>(...) CompositeProjectionWithServicesSource<T> empty
Add(IProjection, stage) CompositeIProjectionSource empty

So teardownProjectionStorage ran 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> and CompositeIProjectionSource now call replaceOptions(...) and register the inner projection's published types, exactly as ProjectionWrapper (:51) and ScopedProjectionWrapper (:53) have always done.

Name and Version are deliberately not adopted — they compose the member's ShardName.Identity, and changing them would orphan every existing progression row.

2. A raw IProjection can declare its teardown. A custom IProjection that isn't a ProjectionBase describes neither its storage nor its teardown, so there is nothing to adopt and nothing the composite can invent. New overload:

projection.Add(new MyCustomProjection(),
    options => options.DeleteViewTypeOnTeardown<MyView>());

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 CompositeProjection branch never called teardownProjectionStorage(composite, …), so composite.Options.DeleteViewTypeOnTeardown<T>() and composite.Options.TeardownDataOnRebuild = false were 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's Options.CleanUps carries a DeleteDocuments for the type it writes
  • rebuilding_deletes_every_members_documents_first — an end-to-end rebuild over a composite with an AddProjectionWithServices member and a raw IProjection member. 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 DaemonTests suite green: 292/292 on net10.0.

Related

#5169 (the EventProjectionScenario wipe being a no-op for composites) is the same family but a different mechanism — the harness derives its wipe list from Options.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 their CleanUps, so it does not double up with this teardown.

🤖 Generated with Claude Code

https://claude.ai/code/session_017CTtw2kVRSZKp1p5RTxgAy

…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
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.

Composite member projections added via AddProjectionWithServices or raw IProjection are never torn down on rebuild

1 participant