Skip to content

feat(liveness): a heap step names what was allocated in it — [HEAPSTEP] (Refs #5555) - #5665

Merged
rbuergi merged 5 commits into
mainfrom
fix/heap-step-allocation-report
Sep 25, 2026
Merged

rbuergi merged 5 commits into
mainfrom
fix/heap-step-allocation-report

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs #5555 · Refs #4847 · Refs #4883

Why this is an instrument and not a fix

The logs I could reach don't name the allocation behind #5555's multi-GiB live-heap steps (+2 to +10 GiB inside one 100 s [LIVENESS] sample, landing on several replicas within seconds), so this PR contains no speculative fix. What it adds is the reading that would name it.

The heartbeat's heap= (GC.GetTotalMemory(false)) says how much was added, not what. A --type Heap dump of a replica this size freezes it for about 106 s and restarts the container (Doc/Architecture/PortalHeapIsHubs), and the steps are unpredictable, so a dump cannot be taken when a step lands.

The change

  • AllocationByTypeSampler (Hosting) is an in-process EventListener on the runtime's GC keyword at Verbose. The runtime already raises one GCAllocationTick per ~100 KB allocated, and each carries the type. The sampler sums them per type between heartbeat ticks. It keeps only instance state, owned by the heartbeat thread.
  • ProcessLiveness.DescribeHeapStep is pure. When a tick's heap grew by at least HeapStepThresholdBytes (512 MiB), it produces a [HEAPSTEP] line naming the 8 heaviest types allocated in that window, with bytes and share. If the sampler could not start, the line says it cannot name the allocator. It never prints an empty list.
  • ProcessLivenessHeartbeatService drains the sampler each tick and logs [HEAPSTEP] at Warning after the unchanged [LIVENESS] line. The [LIVENESS] line itself is untouched. If the sampler cannot start, the heartbeat carries on exactly as before.

Verification

  • HeapStepNamesItsAllocatorTest (Hosting.Test), 5/5:
    • pure tests: a step names its types heaviest first; a tick one byte short of the threshold reports nothing, and neither does the first tick; a step with no samples says "cannot name", and "no sampler" and "sampled nothing" print different sentences;
    • a live test that allocates ~160 MiB of a marker type and requires ≥ 64 MiB attributed to it.
    • Negative control: the listener enabled at Informational instead of Verbose (AllocationTick is a Verbose event) turns the live test red after 36 s, with the other 3 green. Restored → 4/4.
    • ConcurrentRecordAndDrain_LoseNoSample (added after review) runs 4 writers × 50,000 samples against a draining loop. Negative control: the original swap of a mutable map lost samples, finishing at 196,239 of 200,000. With the CAS-on-both-sides map it is exact.
    • Measured along the way: the runtime prints a nested type by its own name only (HeapStepMarker[]), so the test matches on that suffix.
  • ProcessLiveness* tests 14/14. MeshWeaver.Documentation.Test 628/628.
  • dotnet build -c Release -warnaserror, 0 warnings and 0 errors each: MeshWeaver.Hosting, MeshWeaver.Hosting.Test, MeshWeaver.Documentation.Test.

Docs

Architecture/WhichKindOfSilence has a new section, "A heap step names its allocator: the [HEAPSTEP] line". It covers what the line reads, why it is allocated-not-retained, why it is conditional while [LIVENESS] is not, and the Logs query that finds it.

After the roll

On memex-cloud, a Logs action with query: "HEAPSTEP\\] tick=" per pod. The first step on an image carrying this names its leading type, and that is the reading #5555 is waiting on. Nothing a per-node hub serves changes, so no recycle is needed.

Pairs-with: none — additive only (three new public types and one new static method on ProcessLiveness; nothing removed or renamed).


Queue reds at 419e8bbc47, and what they were

Two queue runs went red on ConcurrentRecordAndDrain_LoseNoSample at the earlier head 419e8bbc47. One was in the group with #5661. The other was #5663's queue branch, which was built on top of #5665 while it sat ahead in the queue. The CI reading was 199,960 of 200,000.

The cause was the test's arithmetic, not a record/drain race. The sampler is live, so other tests' real ~100 KB samples share its windows. Drain reports only the TopTypes heaviest types, and 1-byte marker samples dropped out of the top 8. Fixed in 6464781943 (marker samples weigh 1 GiB) and made deterministic in dd1efea654 (the test runs its own noise thread of 2 × TopTypes heavier types).

Controls, measured locally on the final test:

  • 1-byte markers under the noise read 186,946 of 200,000 (red). This is the CI shape, reproduced on demand.
  • 1-GiB markers with the CAS sampler are exact (green).
  • 1-GiB markers with the original mutable-map swap throw IndexOutOfRangeException (red). This is the real race that the review fix removed. (The dd1efea654 commit message credits this control with "196,239". That figure was measured on the earlier 1-byte, no-noise version of the test, and this line is the correction.)

Whole MeshWeaver.Hosting.Test project locally: 964/964.

🤖 Generated with Claude Code

…], Refs #5555)

memex-cloud replicas take multi-GiB live-heap steps (+2..+10 GiB inside one
100 s [LIVENESS] sample, several replicas within seconds) and no reading
could name the allocator: a Heap dump of a replica this size restarts it,
and the steps are unpredictable.

AllocationByTypeSampler listens in-process to the runtime's own sampled
GCAllocationTick events (one per ~100 KB, carrying the type) and sums them
per type between heartbeat ticks. A tick whose heap grew by >= 512 MiB now
logs a [HEAPSTEP] Warning naming the heaviest types allocated in that
window; a host whose sampler did not start says it cannot name them.

HeapStepNamesItsAllocatorTest: pure line tests plus a live test that
allocates ~160 MiB of a marker type. Negative control: the listener enabled
at Informational (AllocationTick is Verbose) -> the live test is red.

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

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

  1 files  ±0    1 suites  ±0   2m 14s ⏱️ -50s
345 tests +3  345 ✅ +3  0 💤 ±0  0 ❌ ±0 
349 runs  +3  349 ✅ +3  0 💤 ±0  0 ❌ ±0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

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.

Copilot review overview

🟡 Changes recommended

Unresolved moderate findings affect attribution correctness, initialization safety, threshold behavior, and heartbeat reliability.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds in-process GC allocation sampling to annotate significant heap-growth intervals with [HEAPSTEP] warnings, plus tests and documentation.

Changes:

  • Samples allocation events by type.
  • Integrates heap-step diagnostics into liveness heartbeats.
  • Adds formatter/sampler tests and operational documentation.
File Summary
test/​MeshWeaver.Hosting.Test/​HeapStepNamesItsAllocatorTest.cs Adds pure and live sampler/formatting tests.
src/​MeshWeaver.Hosting/​ProcessLivenessHeartbeatService.cs Integrates sampler lifecycle and warning logging. Moderate: sampler failures may suppress [LIVENESS] (1 vote). Nit: missing integration coverage (1 vote).
src/​MeshWeaver.Hosting/​ProcessLiveness.cs Adds heap-step detection and formatting. Moderate: per-tick threshold may miss distributed growth (1 vote). Nit: empty windows conflate unavailable samplers with no events (4 votes).
src/​MeshWeaver.Hosting/​AllocationByTypeSampler.cs Adds runtime allocation sampling. Moderate: Record/Drain can discard racing samples (2 votes). Moderate: construction may receive callbacks before state initialization (2 votes).
src/​MeshWeaver.Documentation/​Data/​Architecture/​WhichKindOfSilence.md Documents [HEAPSTEP] interpretation and log queries.

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

Comment on lines +60 to +62
// Swapped whole by Drain. A sample racing the swap lands in either window, never nowhere that
// matters: at ~100 KB per sample one misplaced sample is noise against a multi-GiB step.
private ConcurrentDictionary<string, long> window = new(StringComparer.Ordinal);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 419e8bb.

The window is now one ImmutableDictionary, and both sides replace it by compare-and-swap:

  • Record folds each sample in with ImmutableInterlocked.AddOrUpdate;
  • Drain takes the map with Interlocked.Exchange.

A record that races a drain now fails its CAS and retries against the fresh map, so it can no longer add to a map nobody reads.

New test: ConcurrentRecordAndDrain_LoseNoSample runs 4 writers × 50,000 samples against a draining loop and requires every sample to land in exactly one drained window. Negative control: with the old swap of a mutable map restored, it goes red at 196,239 of 200,000.

Comment on lines +67 to +70
// Runs from the base constructor for sources that already exist, so it must not touch state
// the derived constructor would set; `window` is a field initializer, which C# runs first.
if (eventSource.Name == RuntimeProviderName)
EnableEvents(eventSource, EventLevel.Verbose, GcKeyword);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked the ordering here, and it is the other way round in C#. Instance field initializers run before the base-constructor call (C# spec §15.11.3, "Constructor execution": variable initializers execute before the base-class constructor invocation). So window is already assigned when EventListener's constructor calls OnEventSourceCreated, and an event delivered straight after EnableEvents finds a valid map.

(The version of this hazard that does exist is state assigned in the derived constructor body. This type has none.)

The live test builds the sampler and immediately allocates. It passes, which would not hold if early events hit an unassigned field. In 419e8bb I reworded the comment at that call so it says exactly this.

Comment on lines +243 to +246
return line.Append(
" — NO allocation was sampled in this window, so this line cannot name the allocator "
+ "(the sampler is not running in this process).")
.ToString();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, fixed in 419e8bb. DescribeHeapStep now takes AllocationWindow?, and the two cases print different sentences:

  • null means the process has no sampler: "the allocation sampler is NOT running in this process";
  • AllocationWindow.Empty means a running sampler saw nothing: "the sampler is running but sampled NO allocation in this window".

The heartbeat passes sampler?.Drain(), so the two cases stay separate. AStepWithoutSamples_SaysItCannotNameTheAllocator_AndWhy pins both.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

396 tests   - 1 252   396 ✅  - 1 252   58s ⏱️ - 2m 15s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

    2 files   -   1      2 suites   - 1   2m 39s ⏱️ - 3m 38s
1 632 tests  - 380  1 632 ✅  - 380  0 💤 ±0  0 ❌ ±0 
1 632 runs   - 381  1 632 ✅  - 381  0 💤 ±0  0 ❌ ±0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

…d "sampled nothing" are two sentences

Review on #5665:
- the window was a mutable map swapped by reference, so a Record that read
  the old map could add to it after Drain had taken it. Now one immutable
  map replaced by CAS on both sides (ImmutableInterlocked.AddOrUpdate /
  Interlocked.Exchange). ConcurrentRecordAndDrain_LoseNoSample: 4 writers x
  50,000 samples against a draining loop; negative control (the old swap)
  -> 196,239 of 200,000.
- DescribeHeapStep takes a null window for "no sampler in this process" and
  reports an empty one as "running but sampled nothing".

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

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

    1 files   -     2      1 suites   - 2   6m 2s ⏱️ - 1m 8s
1 991 tests +1 248  1 991 ✅ +1 440  0 💤  - 192  0 ❌ ±0 
1 992 runs  +1 249  1 992 ✅ +1 441  0 💤  - 192  0 ❌ ±0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

969 tests  +526   969 ✅ +526   7m 17s ⏱️ + 6m 14s
  1 suites  -   2     0 💤 ±  0 
  1 files    -   2     0 ❌ ±  0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    2 files   -     3      2 suites   - 3   7m 53s ⏱️ - 7m 40s
2 565 tests  - 1 513  2 565 ✅  - 1 511  0 💤  - 2  0 ❌ ±0 
2 569 runs   - 1 513  2 569 ✅  - 1 511  0 💤  - 2  0 ❌ ±0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    8 files   -     9      8 suites   - 9   27m 6s ⏱️ - 9m 17s
7 898 tests  - 1 368  7 898 ✅  - 1 174  0 💤  - 194  0 ❌ ±0 
7 907 runs   - 1 368  7 907 ✅  - 1 174  0 💤  - 194  0 ❌ ±0 

Results for commit 973af80. ± Comparison against base commit 423b863.

♻️ This comment has been updated with latest results.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 24, 2026
@rbuergi
rbuergi removed this pull request from the merge queue due to a manual request Sep 24, 2026
… cannot push it out of Top

CI (queue run 36015948806, shard 5) read 199,960 of 200,000: the sampler is
live, so other tests' real ~100 KB samples share its windows, and Drain
reports only the TopTypes heaviest. 1-byte marker samples in a window fell
below real types. The loss was in the test's arithmetic, not the sampler.
Each marker sample now weighs 1 GiB, which heads every window it is in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

ℹ️ Merge-queue steward: no action — removed from the queue with reason MANUAL — the steward takes no action for this reason.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 24, 2026
@rbuergi
rbuergi removed this pull request from the merge queue due to a manual request Sep 24, 2026
…ion is present on every run

The queue reds (199,960 of 200,000 at 419e8bb) came from the test's
Top-N arithmetic under live runtime samples, not from a record/drain race.
Deterministic now: a noise thread records 2 x TopTypes heavier types
throughout. Controls, locally:
- 1-byte markers under that noise: 186,946 of 200,000 (the CI shape)
- 1 GiB markers, CAS sampler: exact
- 1 GiB markers, the original mutable-map swap: 196,239 (the real race #5665 fixed)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

ℹ️ Merge-queue steward: no action — removed from the queue with reason MANUAL — the steward takes no action for this reason.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 24, 2026
@meshweaver-cloud

Copy link
Copy Markdown
Contributor

⛔ Merge-queue steward: left dequeued — a merge_group workflow other than the test suite failed on this pull request's queue branch; a gate failure is never a flake, so the steward re-queues nothing here — read the named workflow, and the test run too if it also failed.
Group build: https://github.com/Systemorph/MeshWeaver/actions/runs/36024508014

  • failed queue workflow: Review answered

@meshweaver-cloud meshweaver-cloud Bot added the queue-rejected The merge queue rejected this PR on an uncatalogued failure; a person owns it now label Sep 24, 2026
…t on Drain's Top-N summary

Queue run 36016772968 (shard 5) read 187,818 of 200,000 at 419e8bb: the
test summed the marker out of Drain().Top, and the live sampler also holds
every other test's real ~100 KB samples, so a window that ranked the marker
below TopTypes other types dropped it from the READING. The sampler lost
nothing. dd1efea made that improbable by weighting the marker 1 GiB;
this makes it structural: Drain() = Summarize(TakeWindow()), and the race
test sums TakeWindow() — every type — so no ranking can hide a sample.
A separate test pins the summary (heaviest first, TopTypes cap, total over
every type).

Controls, locally (Release):
- CAS sampler: 200,000 of 200,000, 6/6 green
- negative control, Record as a non-atomic read-SetItem-write: RED
  ("found 146175035")

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi disabled auto-merge September 25, 2026 06:50
@rbuergi

rbuergi commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

The ejection cause is fixed on this branch (973af80). It is ready for the maintainer to re-queue with gh pr merge 5665 --auto.

The queue red was HeapStepNamesItsAllocatorTest.ConcurrentRecordAndDrain_LoseNoSample (run 36016772968, shard 5): Expected 200000 … but found 187818, at head 419e8bb. The sampler did not lose those samples. The test did, by reading them out of Drain().Top. The sampler is a live EventListener, so it also receives every other test's real ~100 KB GCAllocationTick samples. Drain reports only the TopTypes heaviest types, so a window that ranked the 1-byte marker below them dropped it from the test's sum.

dd1efea made that unlikely by weighting the marker at 1 GiB. 973af80 makes it impossible:

  • Drain() is now Summarize(TakeWindow()).
  • The race test sums the marker out of TakeWindow(), which holds every type, so no ranking can hide a sample.
  • A separate test pins the summary: heaviest type first, capped at TopTypes, total over every type.

Controls, local Release build:

  • Compare-and-swap sampler: 200,000 of 200,000, 6/6 green.
  • Negative control, with Record changed to a non-atomic read–SetItem–write: red (found 146175035).

The PR run's first attempt went red on something unrelated: MeshWeaver.Compiler.Pipeline.Test exit=2 MASKED. All 1102 tests passed, then the host exited 2 after the summary. This diff does not touch that project. The failed jobs were re-run once as a harness crash, and attempt 2 is green (Consolidate test results success, Automatic review answered success).

My push re-armed auto-merge through auto-arm.yml. I disarmed it again so that re-queueing stays the maintainer's call.

@rbuergi rbuergi removed the queue-rejected The merge queue rejected this PR on an uncatalogued failure; a person owns it now label Sep 25, 2026
@rbuergi
rbuergi added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit fa4d42e Sep 25, 2026
61 of 63 checks passed
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