Repository navigation
fix(cache): the change-feed reset releases the upstream too — a finished activity stops heart-beating itself awake - #3116
Conversation
…hed activity stops heart-beating itself awake #3110. A `HeartBeatEvent` from `cache/…` was still routing to `Crm/Stage/_Activity/compile-…` 49 minutes after that compile finished `Succeeded` in about a second, and would have gone on for the pod's life. Root cause — the FOURTH faulted-entry eviction drifted from the other three. #1202 established that an eviction must reach TWO layers (the cache `Entry` AND the cacheHub workspace's remote-stream cache) and centralised it in `EvictFaultedEntry`, whose own remarks call it "the single teardown behind all three re-probe triggers … so they cannot drift". `ResetFailureState` — the change-feed invalidation, which runs on EVERY mesh change event for a path — kept a hand-rolled copy that disposed only the Rx hydration and reported `ReadStreamEviction(UpstreamReleased: false)` hard-coded. Its prose justified that with "a terminally errored upstream has already torn down its own keep-alive". True of a NotFound, which is the one fault class that disposes the stream's `keepAlive`. The class that matters is the TRANSIENT one — an idle-collected grain, a 60 s request timeout — where the node EXISTS, the stream stays live, and its 45 s heartbeat keeps firing. Dropping the entry then orphans that stream, and BOTH reclaimers of a warm mirror are keyed on the entry: the idle sweep ITERATES `_streams`, and `ReleaseIfUnwatched` (which `ActivityLogAppender` fires on the terminal activity write, #1435/#1324) LOOKS THE PATH UP in it. So nothing could reach the stream again unless that exact path was read once more — and a finished compile activity never is. Every compile storm multiplied the set of stale-but-routable activity hubs. The reset now calls `EvictFaultedEntry` like the other three, which also brings the pair-exact claim and the re-park-on-lost-race the copy was missing. - `src/MeshWeaver.Hosting/MeshNodeStreamCache.cs` — the call, and the stale remarks replaced with why disposal here is safe. - `test/MeshWeaver.Hosting.Test/ChangeFeedResetReleasesUpstreamTest.cs` — a hijacked owner produces the real transient fault (node exists, owner unreachable), then a change event must release the upstream. A CONTROL arm releases the same state through the already-correct `ReleaseIfUnwatched` and asserts an upstream really was disposed, so the subject arm cannot pass vacuously. Fails before / passes after. - `Doc/Architecture/MeshNodeStreamCache.md` + a What's New entry (Fix). Verified: `MeshWeaver.Hosting`, `MeshWeaver.Hosting.Orleans`, `MeshWeaver.Connection.Orleans`, `MeshWeaver.Documentation` and both touched test projects build `-c Release -warnaserror` with `0 Error(s)`; `MeshWeaver.Hosting.Test` 302/302 and `MeshWeaver.Documentation.Test` 202/202 green unfiltered; the new test red with the one-line change reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The fix reuses the existing shared eviction teardown to close the leak, and the new test plus docs provide strong coverage for the reported production failure mode.
Pull request overview
Fixes a cache-eviction teardown gap in MeshNodeStreamCache where change-feed invalidation could drop a faulted cache entry without detaching/disposing its upstream sync streams, allowing orphaned keep-alive heartbeats to continue routing to completed activity hubs indefinitely (issue #3110).
Changes:
- Route change-feed invalidation eviction through the shared
EvictFaultedEntry(...)teardown to ensure upstream streams are detached and disposed. - Add a regression test that reproduces the “faulted entry + live upstream” transient-owner failure shape and asserts
UpstreamReleased: trueon change-feed eviction (with a control arm viaReleaseIfUnwatched). - Add/extend documentation explaining the fourth eviction trigger and the production symptom/impact.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/MeshWeaver.Hosting.Test.csproj | Adds monolith test base dependency needed to drive a real mesh for the new cache-eviction test. |
| test/MeshWeaver.Hosting.Test/ChangeFeedResetReleasesUpstreamTest.cs | New regression test pinning that change-feed invalidation releases upstream streams for faulted entries (plus non-vacuous control arm). |
| src/MeshWeaver.Hosting/MeshNodeStreamCache.cs | Updates ResetFailureState to call EvictFaultedEntry(path, "invalidate") so upstreams are detached/disposed consistently. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-02-finished-work-stops-keeping-itself-awake.md | What’s New entry describing the user-visible fix and impact. |
| src/MeshWeaver.Documentation/Data/Architecture/MeshNodeStreamCache.md | Documentation update: change-feed invalidation is the 4th eviction trigger; explains orphan-heartbeat failure mode. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 1)435 tests 435 ✅ 54s ⏱️ Results for commit 32714b2. |
Test Results (shard 5) 3 files 3 suites 1m 15s ⏱️ Results for commit 32714b2. |
Test Results (shard 3)620 tests 620 ✅ 49s ⏱️ Results for commit 32714b2. |
Test Results (shard 4)1 831 tests 1 831 ✅ 2m 9s ⏱️ Results for commit 32714b2. |
Test Results (shard 0)234 tests 234 ✅ 2m 22s ⏱️ Results for commit 32714b2. |
Test Results (shard 2)584 tests 582 ✅ 2m 46s ⏱️ Results for commit 32714b2. |
Test Results 14 files 14 suites 10m 17s ⏱️ Results for commit 32714b2. |
Fixes #3110.
What #3110 actually is
A system
HeartBeatEventfromcache/…was still routing toCrm/Stage/_Activity/compile-202609021024566204c48055472004402b49f4d77fe64bb8949 minutes after that compile finished
Succeededin about a second — and would havegone on doing so for the pod's lifetime. The
OutOfMemoryExceptionin the recorded stack iswhere the exhausted heap surfaced, not the defect.
The issue's own hypothesis — "whatever activates hubs for
_Activitynodes does not releasethem at terminal status" — is not what is broken. That release exists and works
(#1435/#1324):
ActivityLogAppender.AppendfiresIMeshNodeStreamCache.ReleaseIfUnwatchedonthe terminal write, and
NodeTypeCompilationActivity(which owns everycompile-<ts>node) ison that path. What is broken is one layer down, and it is why the release found nothing to
release.
Root cause — the FOURTH faulted-entry eviction drifted from the other three
#1202 established that evicting a faulted cache entry must reach two layers — the cache's
own
Entry, and the cacheHub workspace's remote-stream cache holding theISynchronizationStream— and centralised that inMeshNodeStreamCache.EvictFaultedEntry,whose remarks call it "the single teardown behind all three re-probe triggers … so they cannot
drift".
There is a fourth trigger.
ResetFailureState— the change-feed invalidation, which runs onevery mesh change event for a path, including the activity's own terminal write — kept a
hand-rolled copy that disposed only the Rx hydration and emitted
ReadStreamEviction(path, UpstreamReleased: false, "invalidate")with the flag hard-coded.Its prose justified that: "a terminally errored upstream has already torn down its own
keep-alive". That is true of a NotFound — the one fault class that disposes the stream's
keepAlive, which is what made the claim look right. The class that actually matters is thetransient one the cache documents two screens earlier: an Orleans-idle-collected grain or a
60 s request timeout, where the node exists and is momentarily unreachable. There the stream
stays live and its 45 s heartbeat keeps firing.
Orphan one of those and nothing can ever reap it, because both reclaimers of a warm mirror are
keyed on the cache entry:
_streamsReleaseIfUnwatched(the terminal-activity event, #1435)_streamsRemove the entry and leave the stream attached and both are blind to it. The only escape left is
someone reading that exact path again, so a fresh
Entryre-adopts the stream and a laterrelease disposes it. A finished
_Activity/compile-…is never read again — so "never" is theprocess lifetime, and every compile storm (100+ in a quarter of an hour on a reimport)
multiplied the set of stale-but-routable activity hubs, each with a periodic heartbeat pointed
at it.
The fix
ResetFailureStatecallsEvictFaultedEntry(path, "invalidate")— the same gate it appliedinline, plus the two things the copy was missing: the upstream detach + disposal, and the
pair-exact claim that re-parks what it detached on a lost race. Disposal is safe here for the
same reason it is safe at the other three sites, which
EvictFaultedEntryalready argues: thedetach happens FIRST, so a concurrent read builds a fresh stream, and the
UnsubscribeRequesta disposal posts carries the OLD
StreamId.No timer, no watchdog, no shortened window, no new bound.
Verified
test/MeshWeaver.Hosting.Test/ChangeFeedResetReleasesUpstreamTest.cs. A hijacked owner(
IRoutingService.RegisterStream, the harnessMeshNodeStreamCacheFaultedEntryReprobeTestalready uses for MeshNodeStreamCache permanently poisons a path after one failed subscribe — same cached TimeoutException replayed forever (memex-cloud, ci.2926) #1202) answers the
SubscribeRequestwith the verbatim production timeoutbanner, producing the real transient fault — asserted as such via
IsTransientOwnerFailure/!IsMissingNodeFailure. A change event must then release theupstream.
released through the already-correct
ReleaseIfUnwatched, must reportUpstreamReleased: true. If the scenario ever stops leaving a stream behind, the control goesred rather than the subject going quietly green.
assertion while the control arm passes; with it, both pass.
dotnet build -c Release -warnaserror,0 Error(s)each:MeshWeaver.Hosting,MeshWeaver.Hosting.Orleans,MeshWeaver.Connection.Orleans,MeshWeaver.Documentation,MeshWeaver.Hosting.Test,MeshWeaver.Documentation.Test.MeshWeaver.Hosting.Test302/302,MeshWeaver.Documentation.Test202/202. (
TestTimeoutLiteralRatchetGuardcaught three30.Seconds()literals in thefirst draft — converted to
TestTimeouts.Convergence; the baseline is untouched.)Notes for review
test/MeshWeaver.Hosting.Testgains aProjectReferencetoMeshWeaver.Hosting.Monolith.TestBase: the defect needs a real mesh, and this is the assemblyMeshWeaver.Hostingalready grantsInternalsVisibleTofor the cache's eviction seam(
ReadStreamEvictions/IsReadStreamLive). The cache's own suite moved toMeshWeaver.Pluginswith the 17 mesh suites (MeshWeaver.AI leaves the platform: carve out the three non-AI surfaces, then move the engine to MeshWeaver.Plugins #2276), so core had no other home for it.MeshNodeStreamCacheRecycleResetTest,MeshNodeStreamCacheFaultedEntryReprobeTest,MeshNodeStreamCacheIdleReleaseTest,CompileActivityHubRetentionTest) covers this path from the other side. Reviewed staticallyagainst the change: the recycle-reset test's organic arm now disposes an already-dead NotFound
stream instead of leaving it re-adoptable — the same disposal the storm-breaker branch it
passes today already performs — and the retention tests exercise seams this diff does not
touch.
_Activitywriters donot go through
ActivityLogAppender.Appendat all, so they never fireReleaseIfUnwatched—ActivityLogLogger.PublishSnapshotLocked(every kernel/script/markdown/test run — thehighest-volume one),
CodeNodeType.FailActivity, andBuildProtocolDriver.FinishActivity.Those leave a warm mirror to the 10-minute idle sweep rather than orphaning it, so they are a
retention rather than the unbounded leak fixed here. Worth its own issue.
🤖 Generated with Claude Code