Skip to content

fix(observability): alert on the GC ceiling, not only on replica divergence - #3319

Merged
rbuergi merged 5 commits into
mainfrom
fix/2194-gc-ceiling-below-container-limit
Sep 4, 2026
Merged

rbuergi merged 5 commits into
mainfrom
fix/2194-gc-ceiling-below-container-limit

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

What this is

#2194's fix table has kept items 4 (progress-aware /alive) and 5 (working-set divergence alert) open since 2026-08-25, and the GC spiral recurred on 09-03 with fixes 1 and 2 both live in production. The maintainer's last comment posed the discriminator: is convergence enabled-but-not-converging, or does a second cause share the curve?

Measured today, read-only, against memex and memex-cloud. It is neither of those two — it is a third answer, and it changes what items 4 and 5 have to be.

The discriminator, settled

Counted over one replica's entire 28-hour life (186 137 lines), with info:-level lines confirmed to be shipping (2 791 of them) so a zero is a real zero and not a log-level artefact:

Stale-build offer:                          0
Stale-build convergence:                    0
EnrichWithNodeType: self-heal recompile #   0
BatchBake:                                  0
KEEPING its load context                    0
NOT unloading collectible context           0
Grain … deactivating: reason=               0

No build was ever superseded. Modules:AutoRecycleOnStaleBuild is armed on both namespaces and has never fired, because nothing has ever been stale. Fixes 1+2 could not have prevented the recurrence — they address a state that does not arise. And deactivating: reason= is 0 across five sampled replicas and 514 687 lines: not one hub or grain deactivated since boot, so no ALC lease is ever released.

The compile load is a coverage failure, not a publication wave — the two oldest replicas booted having adopted 8 and 9 of 377 dynamic NodeTypes, with the other 368 due to "pay a Roslyn compile on FIRST ACCESS", lazily, spread over the pod's whole life. That is fix 3's lane (Modules:RequirePrebuilt), not fix 1's.

Why nothing ever restarts the pod

Nothing sets DOTNET_GCHeapHardLimit[Percent] — not the inline env:, not memex-portal-config, not any envFrom Secret (audited by key name on both namespaces, values never read). So .NET's container default applies: the GC's heap hard limit is 75 % of the container limit = 12 GiB at limits.memory: 16Gi, and it covers the managed heap only.

The runtime defends that ceiling with back-to-back blocking gen2 instead of letting the kubelet restart the container. Measured: 0 OOMKills across 7 replicas over 28 h (the only two restarts were exitCode 255/Unknown at one shared timestamp — a node event), and GC pause share 0.66 sustained for 2 h 15 on 09-03 while /alive answered Healthy. The GC converts a memory shortage — which Kubernetes fixes in one second — into CPU starvation, about which Kubernetes has no opinion.

The heap growth itself is retention, not a working set: 6 856 → 10 065 MiB in 14.9 h (~215 MB/h) across 267 gen2 collections, with the floor after each collection never returning to any earlier level. And it tracks uptime rather than load — the near-idle replica (512 log lines/30 min) holds 11 443 Mi while the busiest one (2 476 lines) holds 4 134 Mi.

Item 5 — a detector that goes blind exactly when it matters

PortalReplicaWorkingSetDiverged requires max > 3 × min. On memex right now:

memex-portal-deployment-77898c4947-6mffn   195m   9936Mi
memex-portal-deployment-77898c4947-bfnft   167m   9409Mi     → ratio 1.06

Both 28 h old, both at ~80 % of the 12 GiB GC ceiling, both on the curve that produced the 09-03 outage — and the alert sits at a third of its threshold and keeps falling as they converge. Its comment argued "a namespace whose replicas all sit at 12 GB is a sizing question, not this incident"; that is the premise this measurement falsifies.

This is not fixed by lowering the 3× — a rollout legitimately runs a cold pod beside warm ones, and the rule earns its keep on the asymmetric case (it read 7.22× correctly on 09-01). The terminal case needs an absolute reading, which a ratio cannot express at all. So the divergence rule is kept as the early signal and PortalHeapEnteringGcDefenceBand is added for the other end: per namespace, no sibling comparison, thresholded at 75 % of the container limit — the point at which working set has reached the numeric value the GC is willing to let the managed heap alone occupy.

Verified against the live Prometheus before committing, because an alert that cannot fire is worse than no alert. count(container_spec_memory_limit_bytes{container="memex-portal"}) → 9 series, and both rules on one scrape:

namespace divergence max / min (fires > 3) headroom working set / limit (fires > 0.75) actually degraded?
memex-cloud 8.45 — fires 0.851 — fires yes: 14 GB replicas, Platform stalled in the log
memex 1.06 — blind 0.605 — quiet not yet: 9.4–9.9 GB, ~0.2 cores, no stalls

Four results, not two. Both rules fire on the degraded namespace and both stay quiet on the healthy one — so neither fires on everything, and the new rule is not a restatement of the old. The complementarity is the first column: memex's ratio is 1.06 and will fall, not rise, because its replicas are converging. When they reach the defence band, divergence still reads ~1.0 and only the new rule fires. That is the uncovered case.

🚨 Neither rule is armed until values.observability.yaml is applied. On 09-01 the live Prometheus held zero rule groups with the loki release still on its 31 May revision, so the divergence alert had been merged for a week and was watching nothing. git grep is evidence of authorship and never of arming; only GET /api/v1/rules naming the alert is.

Item 4 — the chart contradicted itself, and the real constraint is new

deployment.yaml carried both of these, ten lines apart:

readiness: "a cheap progress-aware check is an open design item on #2194"
liveness: "🚨 /alive MEASURES PROGRESS … It answers 503 once GC pause share has stayed above 60 % for five consecutive checks"

The 09-04 status comment quoted the stale half as current. What /alive measures is image-dependent and this repo cannot assert it: core registers only the trivial "self" check on tag live; a progress-aware handler comes from the host (Memex.Portal.Distributed, in MeshWeaver.Plugins).

The consequence that is not recorded anywhere, and which bounds item 4:

Readiness and liveness share the /alive path, and readiness trips first — 10s × 3 = 30s against liveness's 15s × 6 = 90s. The moment /alive becomes progress-aware, a GC-bound replica is pulled from the Service three times sooner than it is restarted, and its traffic lands on siblings converging on the same ceiling (ratio 1.06, above). That is the 2026-07-21 readiness death-spiral, rebuilt out of the item-4 fix.

⇒ A progress-aware liveness signal needs its own path, or readiness must move off this one. Both comments now say so instead of contradicting each other.

Also recorded

AddRuntimeInstrumentation() is on, so the portal already emits dotnet_gc_* — heap size, pause time, per-generation counts. The collector's metrics pipeline exports only file/metrics, a rotating JSON file on the node; logs and traces have a second path to Loki, metrics have none. That is why the only memory signal any alert can use is a kubelet-side working-set number that cannot see the GC ceiling at all, and why the new threshold is a proxy rather than a direct read of the heap.

Verification

  • deploy/aks/scripts/check-chart-invariants.sh → All 3 values combinations render a self-consistent deployment.
  • deploy/aks/scripts/check-values-are-read.sh → All 114 config key(s) across 3 values file(s) are read by a template
  • dotnet build src/MeshWeaver.Documentation/MeshWeaver.Documentation.csproj -c Release -warnaserror → 0 Warning(s) 0 Error(s)
  • DocumentationLinkIntegrityTest → Passed! Failed: 0, Passed: 1, .trx written.
  • Mutation-proved that the link test actually sees the new page: adding [Deliberately broken](../ThisPageDoesNotExistXYZ) turned it red with
    Doc/Architecture/WhyAGcBoundPodStaysInRotation: (../ThisPageDoesNotExistXYZ) — resolves to /Doc/Architecture/ThisPageDoesNotExistXYZ, which does not exist
    and removing it turned it green again.
  • Alert YAML re-parsed after every edit; both rules present in group portal-degraded.

No cluster object was mutated. All cluster reads were kubectl get/top/logs, the Prometheus query API, and one /alive GET through the API-server proxy. No secret value was read, printed or stored — the GC-override audit projected Secret data to key names only.

Follow-up commits on this branch

  • The alert's fail-loud edge behaviour stated: if a container's memory limit is ever removed, cAdvisor reports the denominator as 0, the ratio becomes +Inf and this fires. That is deliberate — with no container limit .NET applies no GC heap hard limit either. The obvious "fix" (and container_spec_memory_limit_bytes > 0) would silence the rule on exactly the misconfiguration it should shout about.
  • NodeTypeCompilation.md's in-memory retention section read as though convergence closes the gap; it now carries the 09-04 correction and links the new page, so the correction is discoverable from where the next reader actually starts.

Refs #2194 · successor RCA #3321

🤖 Generated with Claude Code

…rgence

PortalReplicaWorkingSetDiverged requires max > 3x min across a namespace's
replicas. Measured 2026-09-04 on ns memex: two 28h-old replicas at 9936Mi and
9409Mi -- ratio 1.06 against a threshold of 3, with BOTH at ~80% of the GC's
12 GiB hard limit and both on the curve that produced the 09-03 outage. Replicas
converge on the same ceiling with age, so the ratio is HIGHEST during the warning
and LOWEST during the incident: the detector is loudest early and silent at the
end. Its comment argued the opposite ("a namespace whose replicas all sit at
12 GB is a sizing question, not this incident"); that premise is now falsified.

Not fixed by lowering the 3x -- a rollout legitimately runs a cold pod beside
warm ones, and the rule earns its keep on the asymmetric case (7.22x on 09-01).
The terminal case needs an absolute reading, which a ratio cannot express:
PortalHeapEnteringGcDefenceBand, per namespace, no sibling comparison, at 75% of
the container memory limit -- where .NET's default container GC heap hard limit
sits. Verified against the live Prometheus before commit: the metric exists
(9 series) and the expression discriminates, memex-cloud 0.851 vs memex 0.605.

Also records why no OOMKill ever fires, which is what makes item 4 necessary:
nothing sets DOTNET_GCHeapHardLimit[Percent] anywhere (inline env, ConfigMap,
envFrom Secrets -- audited by key name on both namespaces), so the default 75%
of a 16Gi limit gives the managed heap a 12 GiB ceiling that the runtime defends
with back-to-back blocking gen2 rather than letting the kubelet restart the pod.
Measured: 0 OOMKills across 7 replicas over 28h; GC pause share 0.66 sustained
for 2h15 on 09-03 while /alive answered Healthy.

And corrects a self-contradiction in the probe comments: the readiness comment
called a progress-aware check an open design item while the liveness comment ten
lines below asserted /alive already answers 503 above a 60% pause share. Both
were committed at once. What /alive measures is image-dependent -- core registers
only the trivial "self" check on tag `live`; a progress-aware handler comes from
the host in MeshWeaver.Plugins. The load-bearing consequence is new: readiness
and liveness share the /alive path, and readiness trips at 10s x 3 = 30s against
liveness's 15s x 6 = 90s. A progress-aware /alive therefore removes a degraded
replica from the Service three times sooner than it restarts it, on replicas that
converge on the same ceiling -- the 2026-07-21 death-spiral rebuilt out of the
item-4 fix. Item 4 needs its own path, or readiness must move off this one.

Refs #2194

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

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.

🟢 Approval recommended

The changes are additive and low-risk (docs/comments + an additional alert rule), with only a minor wording nit identified in a comment.

Pull request overview

This PR updates the observability story for GC-bound portal replicas by adding an absolute “GC ceiling band” alert (in addition to the existing replica-divergence alert), and documenting the underlying mechanism so operators can interpret and triage the signal correctly.

Changes:

  • Added a new Prometheus alert (PortalHeapEnteringGcDefenceBand) to detect when replicas approach the GC heap hard-limit band (using working set as a proxy).
  • Updated the Helm chart probe commentary to clearly state that /alive semantics are image-dependent and that readiness/liveness sharing /alive constrains “progress-aware” health check work.
  • Added a new architecture documentation page capturing the measured behavior and its operational implications.
File summaries
File Description
src/MeshWeaver.Documentation/Data/Architecture/WhyAGcBoundPodStaysInRotation.md New architecture doc explaining why GC-bound replicas can remain Ready and how to reason about detection signals.
deploy/helm/templates/memex-portal/deployment.yaml Clarifies probe-path semantics and constraints around making /alive progress-aware when readiness and liveness share the endpoint.
deploy/aks/scripts/values.observability.yaml Adds an absolute working-set/limit alert alongside the existing divergence alert, with updated rationale and triage guidance.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

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

Comment on lines +244 to +245
# Prometheus cannot see it. Until that pipeline reaches Prometheus, working set over the
# container limit is the honest best available proxy.
rbuergi and others added 3 commits September 5, 2026 00:21
…emory limit is absent

cAdvisor reports the denominator as 0 when a container carries no memory limit,
so the ratio becomes +Inf and PortalHeapEnteringGcDefenceBand fires. That is the
correct direction and worth saying explicitly: with no container limit .NET
applies no GC heap hard limit either, so the heap grows until it takes the node
down. The obvious 'fix' -- and container_spec_memory_limit_bytes > 0 -- would
silence the rule on exactly the misconfiguration it should shout about.

Refs #2194

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

The in-memory retention section reads as though convergence closes the gap. It
does for the mechanism it describes -- but the spiral recurred on 09-03 with
Modules:AutoRecycleOnStaleBuild live on both namespaces, and counted over one
replica's entire 28h life there were ZERO stale-build offers, convergences,
self-heal recompiles or bakes, while the managed heap still grew 6856 -> 10065 MB
across 267 gen2 collections. Convergence had nothing to converge.

Two mechanisms the section does not cover were doing it instead: no hub or grain
deactivates at all, so the lifetime ALC lease an instance hub takes is never
released; and replicas boot having adopted as few as 8 of 377 dynamic NodeTypes,
so the rest each pay a Roslyn compile on first access and pin it -- the same
accumulation, arriving as a drip and driven by bundle coverage rather than
supersession.

Also qualifies 'no OOMKill (the pod limit was never reached)': the GC's own hard
limit is 75% of the container limit, so the runtime defends memory with a
sustained GC pause share instead of letting the kubelet restart the pod.

Links the new derivation page so this correction is discoverable from where the
next reader actually starts.

Refs #2194 #3321

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

The evidence for the new rule was a 1x2 reading. It is now the full 2x2 from one
live scrape, which is what shows the two rules are complementary rather than
redundant:

                 divergence (>3)   headroom (>0.75)   actually degraded?
  memex-cloud    8.45  fires       0.851  fires       yes
  memex          1.06  blind       0.605  quiet       not yet

Four results, not two. Both rules fire on the degraded namespace and both stay
quiet on the healthy one, so neither fires on everything. The complementarity is
in the first column: memex's ratio is 1.06 and will FALL rather than rise,
because its replicas converge -- so when they reach the defence band, divergence
still reads ~1.0 and only the new rule fires. That is the uncovered case.

Refs #2194

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

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    4 files  ±0      4 suites  ±0   1m 48s ⏱️ +2s
1 488 tests ±0  1 488 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 489 runs  ±0  1 489 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

475 tests  ±0   475 ✅ ±0   42s ⏱️ +9s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

251 tests  ±0   251 ✅ ±0   2m 29s ⏱️ +6s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 309 tests  ±0   2 309 ✅ ±0   2m 34s ⏱️ -7s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

368 tests  ±0   366 ✅ ±0   47s ⏱️ -3s
  3 suites ±0     2 💤 ±0 
  3 files   ±0     0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Your shard-5 red is not yours — ComboGateRollTest.ARedVerdict_BlocksTheRoll_AndNamesTheModuleThatWouldBreak is a read-after-write race in that test, and this PR's diff (two YAML files, two Markdown docs) cannot reach Memex.Portal.Shared.Test at all.

Cured in #3324: Record awaited RecordVerification(...).FirstAsync(), but a completing stream.Update says the patch was ACCEPTED, not that every reader can see it — so the gate read the policy first and honestly reported "no combo verification has been recorded", and the assertion failed on text that was never about a defect. It now waits until the verdict is readable, on the same predicate a real reader uses.

Second sighting today (the first was run 33863349195, a different method, same cause). Once #3324 lands, refresh this PR's base — a re-run replays the same commit and will not pick the fix up.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

588 tests  ±0   396 ✅ ±0   2m 56s ⏱️ ±0s
  3 suites ±0   192 💤 ±0 
  3 files   ±0     0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   15 files  ±0     15 suites  ±0   11m 19s ⏱️ +8s
5 479 tests ±0  5 285 ✅ ±0  194 💤 ±0  0 ❌ ±0 
5 480 runs  ±0  5 286 ✅ ±0  194 💤 ±0  0 ❌ ±0 

Results for commit 1290b9e. ± Comparison against base commit abbe512.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit ce79889 Sep 4, 2026
30 checks passed
@rbuergi
rbuergi deleted the fix/2194-gc-ceiling-below-container-limit branch September 13, 2026 10:18
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