Skip to content

fix: storm breaker key carries payload identity, so a wide fan-out is not dropped as a loop - #1266

Merged
rbuergi merged 4 commits into
mainfrom
fix/1200-storm-key-path-discrimination
Aug 12, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/1200-storm-key-path-discrimination

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #1200

The defect

MessageStormBreaker keys its per-second rate counters on (Sender, Target, TypeName) — no
component identifying what the message is about
. Its own justifying comment claims real traffic
is "DIVERSE tuples". That is false wherever the mesh funnels traffic through a dispatcher: one
sender talks to one target with one message type about MANY DIFFERENT THINGS.

  • Every sync stream an owner hub holds open to the shared mesh-node cache posts
    DataChangedEvent (→ RawJson once the router packages it) over that single tuple.
  • A bulk importer sends one CreateOrUpdateNodeRequest per source node from one import hub to the
    one mesh hub.

Keyed on the routing tuple alone, a wide legitimate fan-out is arithmetically identical to one
thing looping. The guard therefore crosses its bar on ordinary bulk work and ShouldDrop
discards real writes at ingestion — imported content and progress-log lines silently missing.

That is data loss, not mitigation. The issue text credits the guard with "mitigating" the
storm and rates the impact "low"; it is backwards. The trip is the guard eating legitimate traffic,
and the reason nobody could tell is the same missing component: with no payload identity in the
key or the log line, "N things fanning out" and "one thing looping" produce an identical message.

The issue's stated cause is refuted. It blames router-identity miswiring (#1140). Its own log
disproves that: sender=UWDeepfield/_Activity/import-…-f4dc71b1 is the import attempt node's own
per-node hub
(the fresh-per-run node StaticRepoImporter logs progress to), not the router. The
router appears only as the hub that packages and forwards the frames — which is exactly why the
cache hub sees them as type=RawJson.

The fix

The key gains a fourth component: the payload's own identity, resolved without ever parsing a
payload on the ingestion path
(the breaker sees every inbound delivery, before the turn loop — a
per-message parse would be its own performance defect).

Where the message is Where the identity comes from Cost
Typed, in-process IDiagnosticKeyed.DiagnosticKey — an already-computed property one interface check
RawJson, after a hub hop erased the type envelope property stamped by MessageDelivery.Package one property lookup, only for RawJson
Exposes no identity null → key degrades to exactly the old tuple —

IDiagnosticKeyed already existed for the pending-callback diagnostic, for precisely this
"N distinct things vs one thing repeated" ambiguity — this makes it load-bearing. It is now
implemented by StreamMessage (StreamId, covering DataChangedEvent, PatchDataChangeRequest,
UnsubscribeRequest, StreamErrorEvent) and by the node-lifecycle verbs
Create/CreateOrUpdate/Delete/Copy/Move (the node path).

Package() is the one seam where the type is erased for a cross-hub hop, and it is already doing a
full JsonSerializer.Serialize — stamping the key there is free by comparison, and delivery
properties already survive both the in-process router hop and the gRPC/SignalR wire (the same
mechanism that carries PostOptions.RequestId).

No band-aid: threshold (2000), window (1 s) and cooldown (2 s) are untouched, no exemption
list, no widened bound. A genuine single-thing loop still trips exactly as before — and the Error
now names which stream/path was looping instead of only which two hubs were talking.

Bounded key space

The payload component makes the key space unbounded in principle: the existing eviction only fires
when a key is touched again after an idle window, so a key seen once would live forever. Crossing
a soft cap of 10 000 live counters arms one inline sweep per window that drops counters idle
beyond window+cooldown. The live set then tracks recent traffic instead of everything the hub
ever saw — no timer, no background thread, no static state, and no effect on detection (a key idle
for seconds is by definition not storming).

Tests

Deterministic, injected logical clock — no sleeps, no wall-clock races
(MessageStormBreakerTest):

  1. WideFanOut_OneTuple_ManyDistinctPayloadKeys_NeverTrips — the bug. 40× the threshold through
    ONE tuple, each message about a different path → must not trip.
  2. RepeatedSamePayloadKey_StillTrips_AndNamesTheThing — the guard is intact; the trip names the
    thing; and a fan-out running alongside the loop keeps flowing (the drop is scoped to the one
    storming key, not to everything sharing its sender/target/type).
  3. PackagedDelivery_KeepsPayloadIdentity_AcrossTheTypeErasure — the same fan-out survives
    Package(); a packaged loop still trips, reported as type=RawJson with the payload named.
  4. NoPayloadIdentity_FallsBackToTheRoutingTuple_AndStillTrips — the fallback is fail-closed.
  5. TrackedKeys_AreBounded_ByRecentTrafficNotAllTimeTraffic — 500 one-shot keys are swept.

Red-before proof (payload component neutered, everything else identical):

Failed WideFanOut_OneTuple_ManyDistinctPayloadKeys_NeverTrips
  Expected value to be false because a write to a DISTINCT path is not a loop,
  however many of them one importer issues, but found True.
Failed RepeatedSamePayloadKey_StillTrips_AndNamesTheThing
  Expected "UWDeepfield/_Activity/import-f2bb979af363573d" …, but found <null>.
Failed PackagedDelivery_KeepsPayloadIdentity_AcrossTheTypeErasure
Failed TrackedKeys_AreBounded_ByRecentTrafficNotAllTimeTraffic
Failed: 4, Passed: 6

Local verification (CI parity)

  • dotnet build MeshWeaver.slnx -c Release -p:CIRun=true -warnaserror → 0 Errors, 0 Warnings
    (re-run after merging current main).
  • MeshWeaver.Messaging.Hub.Test → 191/191
  • MeshWeaver.Data.Test → 311/311
  • MeshWeaver.Hosting.Monolith.Test → 582 passed / 3 pre-existing skips
  • MeshWeaver.Documentation.Test → 20/20

What's New entry included (Category: Fix — a user notices imported content going missing).

🤖 Generated with Claude Code

… not dropped as a loop

The hub-ingestion MessageStormBreaker keyed its per-second rate counters on
(sender, target, message-type) alone. Its own justifying comment claimed real traffic is
"DIVERSE tuples" — that is false wherever the mesh funnels traffic through a dispatcher:
ONE sender talks to ONE target with ONE message type about MANY DIFFERENT THINGS. Every
sync stream an owner hub holds open to the shared mesh-node cache posts
DataChangedEvent/RawJson over that one tuple; a bulk importer sends one
CreateOrUpdateNodeRequest per source node from one hub to the mesh hub.

Keyed on the routing tuple alone, a wide LEGITIMATE fan-out is arithmetically identical to
one thing looping. So the guard crossed its bar on ordinary bulk work and ShouldDrop
discarded real writes at ingestion — imported content and progress-log lines silently
missing. That is data loss, not mitigation.

The key now carries the payload's own identity, resolved without ever parsing a payload on
the ingestion path:

  * typed message -> IDiagnosticKeyed.DiagnosticKey (an already-computed stream id / node
    path). Added to StreamMessage (StreamId — covers DataChangedEvent,
    PatchDataChangeRequest, UnsubscribeRequest, StreamErrorEvent) and to the node-lifecycle
    verbs Create / CreateOrUpdate / Delete / Copy / Move (the node path).
  * after a hub hop the payload type is erased to RawJson, so MessageDelivery.Package
    stamps the key onto the ENVELOPE right before it serializes — one interface check next
    to a full JSON serialize, and delivery properties already survive both the router hop
    and the wire.
  * no identity exposed -> the key degrades to exactly the old tuple. The fallback is the
    old, stricter behaviour: fail closed, never "allow".

Threshold, window and cooldown are untouched; a genuine single-thing loop still trips
exactly as before, and the Error now names WHICH stream/path was looping instead of only
which two hubs were talking.

The payload component makes the key space unbounded in principle (the old
"evict when the key is next touched" never fires for a key seen once), so crossing a soft
cap of 10k live counters arms one inline sweep per window that drops counters idle beyond
window+cooldown. The live set then tracks recent traffic rather than everything the hub
ever saw — no timer, no background thread, no static state, and no effect on detection
since an idle key is by definition not storming.

Tests (deterministic, injected clock): a wide fan-out over one tuple never trips; the same
volume concentrated on one thing still trips and names it; the identity survives Package's
type erasure; an unidentifiable payload keeps the old tuple key and still trips; tracked
keys stay bounded by recent traffic. Proven red before the fix — with the payload
component removed, ShouldDrop returns true for a write to a DISTINCT path.

Closes #1200

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

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 fixes a data-loss defect in MessageStormBreaker where legitimate high-volume fan-out traffic (many distinct payloads sharing the same sender/target/type tuple) was misclassified as a looping storm and dropped at ingestion. The breaker’s key now includes a payload identity component (when available) and preserves that identity across cross-hub packaging to RawJson, with tests and docs updated accordingly.

Changes:

  • Extend the storm-breaker discriminator from (sender, target, type) to (sender, target, type, payload-identity) using IDiagnosticKeyed.DiagnosticKey and a packaged envelope property for RawJson.
  • Add bounded-key-space logic (soft cap + inline stale sweep) to prevent unbounded counter accumulation when payload identity introduces many distinct keys.
  • Add deterministic tests covering wide fan-out vs. true loops, packaging type-erasure behavior, fallback behavior, and key tracking bounds; update “What’s New” and architecture docs.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.Messaging.Hub.Test/MessageStormBreakerTest.cs Adds deterministic coverage for wide fan-out discrimination, loop detection, packaging preservation, fallback behavior, and stale-sweep bounding.
src/MeshWeaver.Messaging.Hub/MessageStormBreaker.cs Adds payload identity into the storm key, introduces tracked-key bounding via inline stale sweeps, and improves trip diagnostics to name the offending payload identity.
src/MeshWeaver.Messaging.Hub/MessageDelivery.cs Stamps IDiagnosticKeyed identity into delivery properties during Package() so payload identity survives type erasure to RawJson.
src/MeshWeaver.Messaging.Contract/IDiagnosticKeyed.cs Documents the interface as load-bearing for storm-breaker discrimination and defines the delivery property name used across packaging.
src/MeshWeaver.Mesh.Contract/CreateNodeRequest.cs Implements IDiagnosticKeyed on node lifecycle verbs so bulk node operations discriminate per-path rather than folding into one storm key.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-12-bulk-imports-no-longer-lose-content-to-the-storm-guard.md Adds a user-visible “Fix” entry describing the resolved import/sync data-loss behavior and improved diagnostics.
src/MeshWeaver.Documentation/Data/Architecture/ActionBlockWedgePrevention.md Updates architecture guidance to clarify that “per-key” includes payload identity and explains why.
src/MeshWeaver.Data.Contract/Messages.cs Implements IDiagnosticKeyed on StreamMessage so stream traffic keys by StreamId for storm-breaker discrimination.

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

Comment thread src/MeshWeaver.Messaging.Hub/MessageStormBreaker.cs
Comment thread src/MeshWeaver.Messaging.Hub/MessageStormBreaker.cs
@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

707 tests  ±0   707 ✅ ±0   12m 4s ⏱️ -15s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits August 12, 2026 11:08
…le keys too

Two real defects in the previous commit, both found by review:

1. GetOrAddCounter could return a counter that was never published. TryGetValue miss →
   TryAdd loses the race → the winner's entry is evicted (window roll / sweep) before the
   re-read → the fallback handed back the locally-built instance. That delivery would then
   be counted on an orphan nobody else can see, silently resetting a storming key's count
   and cooldown. Replaced with a retry loop that only ever returns a counter it has
   observed in, or successfully published to, the dictionary.

2. SweepStaleKeys skipped Tripped counters, so a key that stormed once and was never seen
   again lived for the hub's lifetime — defeating the very bound the sweep documents.
   Staleness is now the only criterion. It is also the correct one: "stale" means no
   message for a full window+cooldown, which an actively storming key can never be (every
   message restamps its window) and which is already past the cooldown, so releasing the
   counter is behaviourally identical to the self-heal its next message would perform.
   Test pins it, including that the formerly-storming key flows again afterwards.

The opportunistic in-ShouldDrop eviction keeps its !Tripped guard — that path fires on a
zero-count window roll for a key still being touched, which may legitimately be mid-cooldown.

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

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   7m 49s ⏱️ -14s
2 132 tests ±0  2 029 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 372 runs  +1  2 269 ✅ +1  103 💤 ±0  0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ 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 803 tests  ±0   1 611 ✅ ±0   8m 5s ⏱️ +7s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ 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 308 tests  +6   1 308 ✅ +6   6m 50s ⏱️ -1s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ 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 313 tests  ±0   1 303 ✅ ±0   13m 48s ⏱️ + 3m 49s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Both review findings were real defects in this change and are fixed in 4097afd:

  1. GetOrAddCounter could return an unpublished counter. On a lost TryAdd, if the winner's entry was evicted (window roll / sweep) before the re-read, the fallback handed back the locally-built instance — that delivery would be counted on an orphan nobody else can see, silently resetting a storming key's count and cooldown. Replaced with a retry loop that only ever returns a counter it has observed in, or successfully published to, the dictionary.

  2. SweepStaleKeys exempted Tripped counters, so a key that stormed once and was never seen again lived for the hub's lifetime — defeating the very bound the sweep documents. Staleness is now the only criterion, and it is the correct one: stale means no message for a full window+cooldown, which an actively storming key can never be (every message restamps its window) and which is already past the cooldown — so releasing the counter is behaviourally identical to the self-heal its next message would perform. New test TrackedKeys_Sweep_AlsoReleases_TrippedThenIdleKeys pins it, including that the formerly-storming key flows again afterwards.

The opportunistic in-ShouldDrop eviction deliberately keeps its !Tripped guard — that path fires on a zero-count window roll for a key still being touched, which may legitimately be mid-cooldown.

MeshWeaver.Messaging.Hub.Test 192/192; full solution -c Release -p:CIRun=true -warnaserror 0 errors / 0 warnings on the tree merged with current main.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 138 tests  ±0   2 137 ✅ ±0   7m 41s ⏱️ +4s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files  ±0     65 suites  ±0   56m 20s ⏱️ + 3m 31s
9 401 tests +6  9 095 ✅ +6  306 💤 ±0  0 ❌ ±0 
9 641 runs  +7  9 335 ✅ +7  306 💤 ±0  0 ❌ ±0 

Results for commit e6bc7c7. ± Comparison against base commit 64eab2b.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

CI is RED on shard 2 — analysis: not caused by this diff

What actually failed. MeshWeaver.Hosting.Monolith.Test (part 2/2) exit=124 TIMEOUT (6m wall-clock cap). Every other project in shard 2 is green (Graph 1025, Layout 402, Auth 143, FutuRe 59, Hosting 145, …). The cap was hit because every dynamic NodeType compile in that runner failed and each compile test then waited out its 40–60 s budget:

System.NullReferenceException
  at Microsoft.CodeAnalysis.CSharp.Symbols.NamedTypeSymbol.Microsoft.Cci.ITypeDefinitionMember.get_ContainingTypeDefinition()
  at Microsoft.Cci.MetadataWriter.<GetConsolidatedTypeParameters>g__getConsolidatedTypeParameters|104_0(...)
  at Microsoft.Cci.FullMetadataWriter.CreateIndicesForNonTypeMembers(...)
  ...
  at Microsoft.CodeAnalysis.Compilation.Emit(...)

That is a crash inside Roslyn's metadata writer while emitting the NodeType assembly — StaleBarrierType, CodeEditType, EmptyRaceType, ButtonType, DirtyType and BrokenType all die identically, i.e. a process-wide condition, not per-type source.

Why this diff cannot be the cause — provable, not asserted. The job log contains zero MESSAGE STORM lines. In ShouldDrop, a drop is only reachable once Counter.Tripped is set, and the only place that sets it is the trip transition, which always logs that Error. Zero trip logs ⇒ Tripped was never set ⇒ the breaker dropped nothing in this job, before or after the change. The key's composition is therefore behaviourally inert here; a finer key can only ever lower a per-key count, never raise one.

The only other behavioural delta in the diff is Package() stamping one extra string onto the delivery envelope. That cannot reach Roslyn's emitter.

Local evidence on this exact commit (4097afd):

  • CodeEditRecompileTest — 11/11 pass
  • MeshWeaver.Hosting.Monolith.Test (full) — 582 passed / 3 pre-existing skips
  • full solution -c Release -p:CIRun=true -warnaserror — 0 errors / 0 warnings

Controls checked (same signature searched, not found): main runs 31580689228 and 31577940567 (shard 2 red for unrelated reasons — "Area not found", observable timeouts), fix/1250-live-supplement-rls-bypass (FutuRe exit=1), fix/nodetype-recompile-alc-leak shards 0 and 3. Branch is 0 commits behind main, so the stale-branch path is excluded too.

⚠️ The Roslyn emit NRE looks like a genuine defect worth its own issue — when it happens, every NodeType in the process fails to compile, which in production is the condition that refuses portal readiness. CodeEditRecompile is already a named flake family in AGENTS.md. Not re-running to see whether it reproduces (project rule: a re-run to test flakiness hides the bug).

Not merging — a coordinator runs the merge train, and the gate is green CI.

@rbuergi

rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Shard 2 attribution — checked reachability both ways, then merged main

What failed: MeshWeaver.Hosting.Monolith.Test (part 2/2) exit=124 TIMEOUT (6m wall-clock cap). Every other project in the shard was green (Graph 1025, Layout 402, Auth 143, FutuRe 59, Hosting 145, ContentCollections 29, …). The cap was hit because every dynamic NodeType compile in that runner failed and each compile test then sat out its 40–60 s budget — StaleBarrierType, CodeEditType, EmptyRaceType, ButtonType, DirtyType, BrokenType, all identically:

System.NullReferenceException
  at NamedTypeSymbol.Microsoft.Cci.ITypeDefinitionMember.get_ContainingTypeDefinition()
  at Microsoft.Cci.MetadataWriter.<GetConsolidatedTypeParameters>g__getConsolidatedTypeParameters|104_0
  at Microsoft.Cci.FullMetadataWriter.CreateIndicesForNonTypeMembers
  ... at Microsoft.CodeAnalysis.Compilation.Emit(...)

i.e. a crash inside Roslyn's metadata writer, process-wide rather than per-type.

I did not assume "known flake". This diff touches MessageStormBreaker, MessageDelivery and five node-lifecycle request types, all extremely widely reachable, so I traced both behavioural deltas to their consumers:

  1. Five request types now implement IDiagnosticKeyed. The only consumer is MessageHub.Observe → GetOrAddResponseSubject(messageId, requestType, target, DiagnosticKey). responseSubjects is keyed on messageId (a fresh Guid); the key is stored as metadata on the PendingCallback record and read only by the stale-callback report. Request/response correlation is untouched.
  2. Package() stamps one extra envelope property. Consumers of Properties wholesale are PostOptions.ResponseFor/WithProperties (copy-through) and HierarchicalRouting (forward). Nothing keys on, counts, or validates properties. Inert metadata.
  3. The breaker key itself is strictly finer, so it can only ever lower a per-key count — it cannot cause a drop the old key would not also have caused. And that job logged zero MESSAGE STORM lines; since Tripped is only ever set on the trip transition, which always logs, the breaker dropped nothing there under either key.

None of those paths reaches Compilation.Emit.

Controls (same Roslyn signature searched, absent in all): main runs 31580689228 and 31577940567 — shard 2 red for different reasons — plus fix/1250-live-supplement-rls-bypass and fix/nodetype-recompile-alc-leak (shards 0/3).

Action taken: merged current origin/main (9 commits — #1168 startup-readiness, #1264 code-cell result, #1269 AiSettings seed, the parked-NodeType fix, plus new CompileWaitDoesNotTimeoutTest / NodeTypeParkedException / DynamicTypePreWarmerTest, all squarely in the failing area) and re-ran — a real change, not a flake retry.

Local on the merged tree: MeshWeaver.Hosting.Monolith.Test 582 passed / 3 pre-existing skips / 0 failed; CodeEditRecompileTest 11/11. Touched projects each built individually -c Release -warnaserror → 0 errors, 0 warnings.

@rbuergi
rbuergi merged commit f748d16 into main Aug 12, 2026
19 checks passed
@rbuergi
rbuergi deleted the fix/1200-storm-key-path-discrimination branch August 19, 2026 10:33
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.

Import activity triggers unbounded message storm to cache hub via RawJson/DataChangedEvent loop

2 participants