Skip to content

fix: share a workspace's local reduced streams instead of minting a hub per call (#1324) - #1345

Merged
rbuergi merged 3 commits into
mainfrom
fix/1324-recompile-retention
Aug 12, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/1324-recompile-retention

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1324 — the dominant retainer, not all of it (see What remains below), so this deliberately does not auto-close the issue.

The measurement, and what it ruled out

Reproduced the issue's own repro (NodeTypeRecompileAlcLeakTest) on this machine — 110 MB → 171 MB
over three recompiles, ~20 MB each, load contexts steady at 1 — then took dotnet-dump heap dumps
around the loop and diffed dumpheap -stat.

It is not Roslyn. Not one CSharpCompilation, MetadataReader or symbol-graph type appears in
the top of the delta. The retained objects are:

type before → after (3 recompiles) per recompile
MeshWeaver.Messaging.MessageHub 217 → 406 +63
Autofac.Core.Lifetime.LifetimeScope 218 → 407 +63
MeshWeaver.Messaging.Serialization.TypeRegistry 218 → 407 +63
System.Text.Json.JsonSerializerOptions 435 → 813 +126
…Serialization.TypeDefinition 12 367 → 22 228 +3 287

63 message hubs per recompile, ~140 KB apiece. Dumping the hosted-hub tree by address shows what
they are: sync/{id} sub-hubs, one per SynchronizationStream, hosted on the NodeType hub, on
_Activity/compile-state, on the fresh _Activity/compile-<ts>, and on the cache hub.

Root cause

Workspace.GetStream(reference) was not memoized. Every call runs
ReduceManager.ReduceStream → WorkspaceStreams.CreateReducedStream, which constructs a
SynchronizationStream — and a SynchronizationStream constructs a hosted sync/{id} hub with its
own Autofac scope, TypeRegistry and JsonSerializerOptions — then registers itself for disposal
on its PARENT (the data source's primary stream), which lives as long as the hub. So an uncached
GetStream leaks a hub per call, reclaimed only when the whole hub dies.

Stack-sampling the streams created during one recompile names the callers:

x12  Workspace.GetStream <- DataExtensions.HandlePatchDataRequest      ← every cross-hub write
x24  Workspace.GetStream <- MeshNodeStreamHandle.GetStream <- Subscribe ← every own-node read
x6   Workspace.GetStream <- MeshNodeStreamHandle.UpdateOwn             ← every own-node write

Nothing about a plain reduce is caller-specific, and remote reduced streams were already cached
per (owner, reference, identity) — the local twin of that cache simply did not exist.

The fix

Cache local reduced streams on the workspace, keyed on the reference, with the same liveness
re-check GetExternalClientSynchronizationStream uses and the same split: a caller-supplied
StreamConfiguration (client id, subscriber, property bag) makes the stream caller-specific, so
those stay uncached. A faulted Lazy is evicted rather than poisoning the reference for the
workspace's life.

Measured after

before after
retained per recompile 20 MB 9.3 MB
hubs per recompile 63 22
mesh steady-state hubs 187 48

The steady-state drop is the pleasant surprise: the same waste was being paid on ordinary reads and
writes, not only on compiles.

What remains (honestly)

The remaining ~9 MB is a second retainer with the same shape, and it is the one the issue's own
third comment named: Workspace.EvictForPath parks the evicted remote stream in
_evictedRemoteStreams without disposing it, so every change event on a subscribed path mints a
fresh client/owner sync/ pair (6 on the cache hub + 8 on compile-state per compile here) that is
reaped only by the 10-minute idle sweep with zero subscribers, or by Workspace.Dispose. Fixing it
needs a decision about whose lifetime a parked stream belongs to, so it is deliberately not in
this change. The per-compile _Activity/compile-<ts> node hub is a third, smaller one (~1 hub +
6 streams per compile).

The guard therefore moves from 32 MB → 16 MB, not to single digits: a bound nothing can
currently pass is a red test, not a guard. The test comment records the exact remaining retainers
and says to tighten it again with them.

Verification

  • MeshWeaver.Data.Test — 329/329
  • MeshWeaver.Hosting.Monolith.Test — 629/629 (includes NodeTypeRecompileAlcLeakTest at the new 16 MB bound)
  • dotnet build -c Release -warnaserror clean for both touched projects, after merging current main

🤖 Generated with Claude Code

…ub per call

Every NodeType recompile retained ~20 MB that survived a full GC (#1324). Heap
dumps around the deterministic repro (NodeTypeRecompileAlcLeakTest) name the
retainer, and it is not Roslyn: +189 MessageHub instances over three recompiles
— 63 per recompile — each with its own Autofac lifetime scope, TypeRegistry and
JsonSerializerOptions (~140 KB apiece). Almost all were `sync/{id}` sub-hubs,
one per SynchronizationStream.

Root cause: Workspace.GetStream(reference) reduced the data-source stream again
on EVERY call. CreateReducedStream constructs a SynchronizationStream (which
constructs a hosted sync hub) and registers it for disposal on the PARENT
stream — the data source's primary stream, which lives as long as the hub. So
an uncached GetStream leaks a hub per call. The callers are hot:
DataExtensions.HandlePatchDataRequest resolves the target stream that way on
every cross-hub write, and MeshNodeStreamHandle does it on every own-node read
and own-node Update. A compile writes its NodeType, its _Activity/compile-state
and a fresh _Activity/compile-<ts>, hence ~60 hubs per recompile.

Remote reduced streams were already cached per (owner, reference, identity).
Local ones now are too, keyed on the reference, with the same liveness re-check
and the same split: a caller-supplied StreamConfiguration makes the stream
caller-specific (client id, subscriber, property bag), so those stay uncached.
A faulted Lazy is evicted rather than poisoning the reference for the
workspace's life.

Measured on the repro: 20 MB -> 9.3 MB retained per recompile, 63 -> 22 hubs
per recompile, and the mesh's steady-state hub count 187 -> 48 (the same waste
was being paid on ordinary reads and writes, not only on compiles). The guard
in NodeTypeRecompileAlcLeakTest drops from 32 MB to 16 MB and records where the
remaining ~9 MB lives: Workspace.EvictForPath parks an evicted remote stream
without disposing it, so every change event on a subscribed path mints a fresh
client/owner sync pair. That is a separate fix; the bound is tightened again
with it.

MeshWeaver.Data.Test 329/329 and MeshWeaver.Hosting.Monolith.Test 629/629 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 20:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses #1324 by eliminating a major source of managed-memory retention during in-portal NodeType recompiles: repeated creation of local reduced synchronization streams that each mint a hosted sync/{id} sub-hub. It does so by caching workspace-local reduced streams (mirroring the existing remote-stream caching strategy), significantly reducing hub churn and retained memory across recompiles, and updates the leak-guard test and release notes accordingly.

Changes:

  • Cache workspace-local reduced streams (when no caller-specific StreamConfiguration is provided) to avoid creating a new SynchronizationStream/hosted sync/ hub per call.
  • Tighten NodeTypeRecompileAlcLeakTest’s per-recompile retention ceiling from 32 MB to 16 MB and document the measured retainers.
  • Add a “What’s New” entry documenting the user-visible impact of the fix (reduced memory growth during rebuilds).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
test/MeshWeaver.Hosting.Monolith.Test/NodeTypeRecompileAlcLeakTest.cs Lowers the retention threshold and documents the identified retainers so the guard matches current reality while still catching regressions.
src/MeshWeaver.Data/Workspace.cs Introduces a local reduced-stream cache keyed by WorkspaceReference and reuses the remote cache’s liveness + fault-eviction pattern to prevent “hub per call” growth.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-13-the-portal-stops-growing-every-time-code-is-rebuilt.md Adds release notes describing the memory-growth fix and its practical effect on portal stability.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

722 tests  ±0   722 ✅ ±0   11m 9s ⏱️ - 3m 49s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   5m 24s ⏱️ - 2m 32s
1 814 tests ±0  1 711 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 081 runs  +1  1 978 ✅ +1  103 💤 ±0  0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 899 tests  ±0   1 707 ✅ ±0   5m 47s ⏱️ - 2m 32s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 853 tests  ±0   1 843 ✅ ±0   8m 28s ⏱️ - 4m 51s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 373 tests  ±0   1 373 ✅ ±0   6m 1s ⏱️ -9s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 407 tests  ±0   2 406 ✅ ±0   6m 46s ⏱️ - 2m 9s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    66 files  ±0      66 suites  ±0   43m 38s ⏱️ - 16m 1s
10 068 tests ±0   9 762 ✅ ±0  306 💤 ±0  0 ❌ ±0 
10 335 runs  +1  10 029 ✅ +1  306 💤 ±0  0 ❌ ±0 

Results for commit 66ef4ce. ± Comparison against base commit 5b2a90f.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits August 12, 2026 23:07
…checkpoint

OrphanedStartingExecutionRecoveryTest's first checkpoint asserted
`PendingUserMessages.ContainKey(userMsgId)` on the first emission after cold
activation. Subscribing is what ACTIVATES the hub, so the recovery under test
(roll the orphaned StartingExecution claim back to Idle → the submission
watcher drains PendingUserMessages into the round) races the very snapshot that
read waits for — both run on the hub the SubscribeRequest just woke, and
nothing in the product orders them.

It went red on CI once activation got FASTER: sharing the workspace's local
reduced streams removes ~40 stream+hub constructions from a cold activation, so
the drain now beats the snapshot often enough to matter. The state being
asserted is one the fix under test exists to consume, which is not a
specification.

UserMessageIds is the race-free anchor: the drain moves entries out of
PendingUserMessages into IngestedMessageIds, and CommitRoundAndExecute /
FoldConsumedInbox explicitly restore UserMessageIds ⊇ IngestedMessageIds. It
proves what this checkpoint is for — the cold activation loaded the SEEDED node
— and it is strictly stronger against the failure mode that matters, content
materialising as a defaulted record, which the old assertion could not tell
apart from a won race. Read through ContentAs<T> for the same reason.

Nothing is weakened. That the queued message really reaches a round is asserted
below and always was: PendingUserMessages empty, Status back to Idle,
ExecutionStartedAt cleared, and the Echo agent's reply present in the response
cell — plus the revert-proven log pin on the recovery branch itself. Those
assertions never even ran on the red build, because the transient check threw
first.

MeshWeaver.Threading.Test 128/128.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

CI red on the first run — diagnosed, not retried

OrphanedStartingExecutionRecoveryTest.OrphanedStartingExecutionClaim_RecoversOnActivation_AndRoundRuns
failed on shard 5:

Expected dictionary to contain key "68da7c42" because the queued message must still be present on the orphaned claim.

It is a race the test was always exposed to, and this change legitimately shifts it. That test seeds
an orphaned StartingExecution claim straight to storage, then subscribes to activate the thread hub
cold. Subscribing is what wakes the hub — so the recovery under test (roll the claim back to Idle →
the submission watcher drains PendingUserMessages into a round) runs on the same hub that must also
serve the SubscribeRequest snapshot. Nothing in the product orders those two. The assertion required
the snapshot to win.

This PR removes ~40 stream+hub constructions from a cold activation, so init now finishes sooner and
the drain wins more often. The state being asserted is precisely the one the fix under test exists to
consume — an intermediate, not a specification.

Checked before touching it, rather than assuming a flake:

  • Passes locally on this branch, including under DOTNET_PROCESSOR_COUNT=2 (status=StartingExecution
    — the snapshot wins here).
  • The snapshot path is unaffected by the caching change: HandleSubscribeRequest →
    SubscribeToClient → CreateSynchronizationStream passes a StreamConfiguration (client id +
    subscriber), and configured streams stay uncached by design in this PR. So what moved is
    activation cost, not snapshot content.
  • Every assertion that pins issue Thread wedges at StartingExecution forever when the _Exec commit is lost (e.g. NodeType release recycles the hub mid-claim) — no recovery on activation #539 is downstream of the failure and never ran on the red build:
    drained PendingUserMessages, Status back to Idle, ExecutionStartedAt cleared, the Echo reply
    in the response cell, and the revert-proven log pin on the recovery branch itself.

Fix: the checkpoint now asserts UserMessageIds.Contain(userMsgId) — durable by construction, since
the drain moves entries into IngestedMessageIds while CommitRoundAndExecute / FoldConsumedInbox
explicitly restore UserMessageIds ⊇ IngestedMessageIds. It proves what the checkpoint is for (the
cold activation loaded the SEEDED node) and is strictly stronger against the failure mode that matters —
content materialising as a defaulted record — which the old assertion could not tell apart from a won
race. Read through ContentAs<T> for the same reason.

MeshWeaver.Threading.Test 128/128 locally; MeshWeaver.Data + the test project build clean under
-c Release -warnaserror after merging current main.

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.

2 participants