Repository navigation
The platform emits NO metrics of its own — zero custom meters in src/, so answering any question about live hub/stream state requires a heap dump that restarts the replica #3488
Description
The platform emits no metrics of its own — so every question about live state costs a replica restart
Measured 2026-09-06, twice independently:
grep -rn "new Meter(" src/ → 0 results
grep -rln "System.Diagnostics.Metrics" src/ → 0 results
Not one custom meter in the entire codebase, and the namespace is not referenced. Every counter
any investigation has relied on is a .NET runtime built-in (dotnet.timer.count and friends), used as
a proxy for what we actually want to know.
What this costs, measured tonight
#3432 asks why 6,925 Started sync/ hubs hold ~2.7 GB of a 3.5 GB heap. Nothing reports:
- live hub count, or the
sync/-vs-node split - the
RunLevelhistogram (Started/Dead/Starting) - stream totals versus disposed
- anything that would show the curve moving after a fix
So the only way to answer is a heap dump against a live production replica — and 🚨 that
restarts the replica:
21:37:38Z dotnet-dump collect -p 1 --type Heap (process suspended)
21:39:17Z kubelet: liveness probe failed, will be restarted
21:39:26Z Complete (106 s of freeze)
21:42:18Z pod 1/1 Running, RESTARTS 1
/alive is 15 s × failureThreshold 6 ≈ 90 s. And the freeze is not write-bound — dd oflag=direct
measured 169 MB/s on the same node, so a 5.5 GB dump is ≈33 s of the 106; the rest is createdump
walking ≈50 M objects with threads suspended.
🚨 The freeze therefore scales with heap size, which produces a catch-22: the population under
investigation only exists on a replica fat enough that dumping it restarts the replica — and the
restart destroys the state being measured. A freshly restarted portal was back to 2 977 MiB within
five minutes, so ~3 GiB is the FLOOR, not the accumulation.
Why this is worth fixing rather than tolerating
- It makes a whole class of question unaskable in production. Not merely expensive —
self-defeating, because the measurement destroys its own subject. - It is why 6,925 Started sync/ hubs hold ~2.7 GB of a 3.5 GB live heap — population MEASURED, cause NOT established (split from #3321) #3432 says "cause NOT established" after two sessions. That title is honest, and the
honesty is forced by the absence of instrumentation rather than by the difficulty of the defect. - The data mostly already exists in-process.
HostedHubsCollectionholds the hubs;RunLevelis
a field; the streams are known. This is exposing what the runtime already has, not computing
anything new. - It closes the loop on fixes. Portal managed heap leaks ~215 MB/h through 267 gen2 collections, with ZERO sync/bake events — the cause #2194 is not tracking #3321 step 3 released 1,495
Deadhubs and its own author said to
expect the curve to flatten, not vanish — and nobody can see the curve. A merged fix whose
effect cannot be observed is indistinguishable from one that did nothing.
Suggested v1 — deliberately small
A single meter with the counters the last two investigations actually needed:
- live
MessageHubcount, tagged by address kind (sync/vs node vs other) andRunLevel - streams created / disposed / live
- (worth considering) live
IoPoolslots, since a parked pool has been a root cause before
🚨 Constraints this repo will hold you to: a meter is process-wide state, so it must be an
instance on a mesh-scoped singleton, never static (NoStaticState.md); nothing async in
hub-reachable code; and 🚨 an observable gauge that enumerates a live collection on the metrics
thread must not park the action block — the cure would otherwise be a smaller version of the
disease.
🚨 And a counter that cannot distinguish "zero hubs" from "the meter never ran" is the night's
recurring defect. Six instruments were measured today returning confident wrong answers rather than
no answer. Emit a denominator, or make absence explicit.
Scope
Filed rather than actioned: instrumenting the platform is a maintainer-level scope call, not
something to slip into an investigation. Recorded here so the cost is visible and the decision is
deliberate.
Related: #3432 (the investigation this blocks), PR #3481 (Doc/Architecture/PortalHeapIsHubs — the
dump hazard and the ClrMD walk recipe), #3325.
Activity
fix(#3488): the meter is armed by the host that COLLECTS, not by ever…
fix(#3488): the kind tag is Address.Type, never a parse of ToString()
rbuergi commentedon Sep 9, 2026
Done — #3807 merged 2026-09-09 11:50Z
Closing by hand: the merge went through the merge queue, which does not fire
Closeslinks, sothis would otherwise have sat open on a landed fix (the same way SocialMedia#155 did).
Verified on
origin/main, not on the PR:What shipped
MeshWeaver.Platform, reporting hubs alive by kind of address and run level — the questionthis issue was opened on ("how many hubs are alive, of what kind, in what state"), which previously
cost a heap dump that suspends a replica past its liveness budget and therefore restarts it,
destroying the state being measured.
Two properties worth stating, because they are what make the number trustworthy:
cannot drift from the population it reports. A counter incremented at every appearance/disappearance
can miss one and still look like a measurement.
sync/{guid}would be thousands ofthem — the very population under investigation — and the meter would become the second thing nobody
can afford to collect.
Two things the review caught, both real
KindOfderived the kind by splittingAddress.ToString(), which isPath + '~' + Host. For asingle-segment path the first
/comes out of the host chain, so the kind becameadmin~portal— host ids back in the tag, recreating the exact unbounded cardinality the tagexists to bound. Now
Address.Type, which isSegments[0]by construction. My first test for thispassed against the old code (the rig cannot build a hub whose own address carries a host
chain), so it was replaced with one that can fail.
NotBeNullon a list that can never be null — vacuous. It nowasserts emptiness, and waits for the terminal run level first, because
Dispose()returns beforea hub is
Deadand scraping straight after measures a hub winding down, not one that is gone.Deliberately not in scope
It is a first version: hub counts, nothing else. Streams, queues and pending callbacks each want
their own owner and their own cost, and a meter that tries to answer everything at once is how
instrumentation becomes the thing nobody trusts.
The arming lives in the portal's
ServiceDefaults, beside theAddMetersubscription it pairs with —not in
MeshHostApplicationBuilder, which would put a hosted service resolving the root hub intoevery mesh in the fleet, including the several thousand a test run builds, none of which has a
collector.