Skip to content

feat(observability): warn before a node reaches the memory ceiling - #1196

Merged
mashkovd merged 2 commits into
mainfrom
feat/node-memory-headroom-alert
Sep 12, 2026
Merged

mashkovd merged 2 commits into
mainfrom
feat/node-memory-headroom-alert

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Why

A node-metrics sweep of mctl-preprod on 2026-09-12 found one anomaly: worker-hnn peaked at 89.8% memory over 7 days and nothing alerted.

NodeHighMemory fires at >0.9 held for: 15m. The peak passed under the threshold, and as a short spike it would not have survived the 15m hold either. There was no OOMKill anywhere in the cluster over the window, so the metric was the only trace.

What was actually happening

Per-pod max_over_time(...[7d:5m]) on hnn, namespace argo-workflows:

Pod working_set rss cache
mctl-agents-implement-43dfb607-run-implementer-… 16396 Mi 8134 Mi 1085 Mi
…-69b99015-… 6769 Mi 529 Mi 2048 Mi
…-d72e6387-… 5789 Mi 322 Mi 2153 Mi
~14 further runs 0.8–4.2 Gi 0.3–1.0 Gi 0.3–1.8 Gi

One implementer sat at 8134 Mi against its 8Gi container limit. And this is not reclaimable page cache: on the node itself, MemTotal − MemFree − Cached − Buffers reached 14100 Mi of 15612 Mi (90.3%).

The mechanism is the request/limit gap in cwft-mctl-agents-implement.yaml — the scheduler reserves memory: 1Gi while the container may grow to 8Gi, and the implementer step carries no mutex (the two in that template cover the claim step and the gitops write). That part is not in this PR; see "Follow-up" below.

What changes

  • vm-rules/mctl-alerts.yaml — new NodeMemoryHeadroomLow: >0.85, for: 10m, severity: warning. NodeHighMemory keeps 0.9 / for: 15m untouched; this sits below it rather than replacing it.
  • bootstrap/templates/observability/monitoring.yaml — the alertname is added to the alertmanager mctl-agent route in the same commit, otherwise the rule would fire into nothing.
  • vm-rules/tests/mctl-alerts_test.yaml — three cases.

Tests are mutation-proven

Both directions were verified to actually fail when the rule is wrong:

Mutation Result
threshold 0.85 → 0.95 FAILED: a node holding 87% memory for 10m fires
for: 10m → for: 1m FAILED: an 87% spike shorter than the 10m hold does not fire, time: 5m

The second is why the spike case asserts at 5m, inside the spike — a 12m-only assertion passes at any hold length, because the series has already recovered by then. A test that can only pass is not a test.

scripts/check-vm-rules.sh and its --selftest both pass; helm template confirms the new alertname renders into the route.

Known gap

mctl-agent's classifyAlert does not know NodeMemoryHeadroomLow, so it falls to default → TypeGeneric / SeverityWarning. The alert routes and notifies correctly; only the ticket type is less specific until a follow-up PR in mctl-agent adds the name next to NodeHighCPU/NodeHighMemory. Gitops-first is deliberate here — the rule is inert without the route, and harmless without the classifier.

Follow-up (not this PR)

Raising the implementer's memory request 1Gi → 3Gi closes the scheduler-side gap. It touches the exact resources: block that open PR #1194 rewrites, so it lands after #1194 merges rather than conflicting with it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U

NodeHighMemory fires at >90% held for 15m. Measured on 2026-09-12,
worker-hnn peaked at 89.8% over 7 days and nothing fired: the peak was
under the threshold, and as a short spike it would not have survived the
15m hold either. The source was concurrent mctl-agents-implement pods --
one sat at its 8Gi container limit -- and it was real anonymous memory,
not reclaimable page cache (MemTotal minus MemFree/Cached/Buffers reached
14100Mi of 15612Mi). No OOMKill, so the only trace was in the metric.

NodeMemoryHeadroomLow adds the tier below: >85% held for 10m, warning.
NodeHighMemory keeps its 0.9/15m unchanged -- this sits under it rather
than replacing it.

The alertname is added to the alertmanager mctl-agent route in the same
commit, otherwise the rule would fire into nothing. mctl-agent's
classifyAlert does not know the name yet, so it classifies as generic
until that repo follows; the alert still routes and still notifies.

Unit tests assert both directions and are mutation-proven: raising the
threshold to 0.95 fails the firing case, and shortening the hold to 1m
fails the spike case (which is why the spike test asserts at 5m, inside
the spike -- the 12m assertion alone passes at any hold length).

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 38s —— View job


Review complete ✅

  • Gather context (PR body, changed files)
  • Fetch pinned commits and diff (git diff 4318585c...67a9982e)
  • Review vm-rules/mctl-alerts.yaml changes
  • Review monitoring.yaml route change
  • Review test coverage
  • Post inline findings
  • Submit verdict

Summary

Reviewed the diff against the pinned commits (base 4318585c, head 67a9982e), touching:

  • platform-gitops/infra-components/observability/vm-rules/mctl-alerts.yaml — new NodeMemoryHeadroomLow alert (>0.85, for: 10m, severity: warning), sitting below the untouched NodeHighMemory (>0.9, for: 15m).
  • platform-gitops/bootstrap/templates/observability/monitoring.yaml — adds the new alertname to the mctl-agent alertmanager route regex, same commit (correctly avoids a rule that fires into nothing).
  • vm-rules/tests/mctl-alerts_test.yaml — three cases: fires at 87%/10m, quiet at 80%, and a spike that recovers before the 10m hold elapses (asserted both mid-spike at 5m and post-recovery at 12m — verified this actually distinguishes hold-length regressions rather than passing unconditionally).

Checked the expression math, series values (13x8 40x12 etc.), and humanizePercentage rendering against the expected annotations by hand — all consistent. promtool/helm weren't available in this execution environment to re-run scripts/check-vm-rules.sh --selftest directly, so I verified the YAML and test logic by inspection instead of by execution.

Findings: 1 P3 (non-blocking) — posted inline on mctl-alerts.yaml: the file's own header says alert names must stay in sync with classifyAlert in mctl-agent, and NodeMemoryHeadroomLow isn't added there yet. The PR body already discloses this as a deliberate, sequenced follow-up, so it's just a suggestion to leave a pointer to the follow-up PR near the rule so it isn't forgotten.

No P1/P2 issues found. Approved (commit 67a9982e).

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 67a9982.

No significant issues found.

# 0.9 and, as a short spike, would not have survived its for: 15m
# either. A ceiling that is only visible once it is hit is not a
# warning, so this rule makes the approach to it visible instead.
- alert: NodeMemoryHeadroomLow

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The file header (lines 1-5) states alert names must stay in sync with mctl-agent/internal/monitor/alerthandler.go::classifyAlert. NodeMemoryHeadroomLow isn't added there, so it falls through to default/TypeGeneric/SeverityWarning until a follow-up PR lands. The PR description discloses this as a deliberate gitops-first sequencing choice, which is reasonable, but consider adding a one-line reference to the follow-up (issue/PR link) near this rule or in the header so the gap doesn't get silently forgotten.

@mashkovd mashkovd Sep 12, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 2b1b55dc: the follow-up now exists as mctlhq/mctl-agent#131 and the comment above the rule names it instead of promising it.

Worth recording why this is more than a naming gap, which the review's framing understates: an unlisted node alert classifies as TypeGeneric, and alerthandler.go's fallback then sets service = alertName (it is skipped only for TypeResourceLimit). With a non-empty Service the alert no longer satisfies isInfraAlert()'s TypeResourceLimit + empty Service gate in pipeline.go — the gate that routes node alerts manual-only rather than into cpu_throttle's auto-fix path. So the miss changes routing, not just the ticket label.

mctl-agent#131 carries the switch entry, the sibling enumeration in the comment that documents the exclusion, and a mutation-proven test row (dropping the name back out fails with type = "generic", want "resource_limit").

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (1 P3). Good to merge.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@mashkovd
mashkovd force-pushed the feat/node-memory-headroom-alert branch from b9c4992 to 67a9982 Compare September 12, 2026 00:18
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 24s —— View job


Review complete ✅

  • Gather context (PR body, changed files)
  • Fetch pinned commits and diff (git diff 4318585c...b9c4992b)
  • Review vm-rules/mctl-alerts.yaml changes
  • Review monitoring.yaml route change
  • Review mctl-telegram probe change (commit b9c4992)
  • Review test coverage
  • Post inline findings
  • Submit verdict

Summary

Reviewed git diff 4318585c8636c392cb1c2c3d791bc4d9aa5e4e9c...b9c4992bfdbbc4664b783d323efabde49c1c7ecf. This range now covers two commits on the branch:

  • 67a9982e — the intended change: new NodeMemoryHeadroomLow alert (>0.85, for: 10m) below the untouched NodeHighMemory, plus the alertmanager route and mutation-proven tests. Already independently reviewed/approved by a prior pass on this same commit — nothing new to add here, math and tests check out.
  • b9c4992b — pushed later (this run's synchronize trigger), titled "enable the temporary inbound-header probe for chore(agents): un-suspend mctl-agents-shepherd cron #617 Slice 1." This is unrelated to node-memory alerting: it touches platform-gitops/services/labs/mctl-telegram/values.yaml for a separate initiative, and tacks an unrelated 7-line comment onto vm-rules/mctl-alerts.yaml.

Findings:

  • P1 (labs/mctl-telegram/values.yaml:153): MCP_HEADER_PROBE: "true" is enabled, but the file's own image.tag (line 15) is still "0.64.2", and the comment added right above the key states "Needs 0.65.0 or later; earlier images ignore the key." As written, this flag is a silent no-op — the correlation measurement it exists to enable won't run until a follow-up bumps the tag. Fix this →
  • P2 (labs/mctl-telegram/values.yaml:135): scope/hygiene — an unrelated service's feature-flag change (and initiative, mctl-telegram#617) is bundled onto the feat/node-memory-headroom-alert branch under the memory-alert PR title, which weakens the audit trail for a change that enables new request logging on an unauthenticated ingress path. Suggest splitting it to its own PR.

No issues found in the memory-alert work itself (67a9982e).

Verdict

REQUEST_CHANGES submitted against commit b9c4992bfdbbc4664b783d323efabde49c1c7ecf (1 P1, 1 P2).

@github-actions

Copy link
Copy Markdown
Contributor

Skipping — this commit (67a9982) already has a Claude review verdict. Push a new commit, or check the run log if you expected a fresh pass and the previous run actually failed.

# the flag and docs/ingress-header-measurement.md are all deleted in the same
# pass; leaving it on costs log volume on an unauthenticated path for no
# measurement.
MCP_HEADER_PROBE: "true"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: MCP_HEADER_PROBE: "true" is turned on here, but image.tag on line 15 of this same file is still "0.64.2". The comment directly above this key (line 142) says "Needs 0.65.0 or later; earlier images ignore the key." As written, this env var is a silent no-op — the Phase 3 correlation measurement this commit exists to enable will not actually run until a follow-up bumps the image tag to >=0.65.0. Given the PR's own theme this cycle (a measurement that produces no signal and nothing flags it), worth bumping the tag in the same commit, or at minimum calling out the required follow-up bump explicitly so it isn't forgotten.

# Telegram OIDC/session login rather than this password bypass, and must
# carry per-account send_enabled=false so probes stay read-only even though
# ALLOW_SEND is true globally.
# TEMPORARY, mctl-telegram#617 Slice 1 (Phase 3 correlation, .github#35).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This commit (b9c4992b, "enable the temporary inbound-header probe for #617 Slice 1") is unrelated to this PR's stated purpose (node memory headroom alerting) and to 67a9982e. It touches a different tenant service (labs/mctl-telegram) for a separate initiative (mctlhq/mctl-telegram#617), bundled onto the feat/node-memory-headroom-alert branch via a later push. It also adds an unrelated 7-line comment to vm-rules/mctl-alerts.yaml (referencing mctlhq/mctl-agent#131) that has nothing to do with either topic. Recommend splitting this onto its own branch/PR — it enables new request logging on an unauthenticated ingress path for a different service, which deserves its own focused review and its own audit trail rather than riding in under the memory-alert title.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Has P1/P2 findings, changes requested: 1 P1, 1 P2. The head commit (b9c4992) is an unrelated 'enable inbound-header probe for mctl-telegram#617' change bundled onto this memory-alert branch — it sets MCP_HEADER_PROBE: "true" in labs/mctl-telegram/values.yaml while that file's own image.tag is still 0.64.2, and its own added comment says the flag needs >=0.65.0 or is silently ignored (P1). It also mixes an unrelated service/initiative into this PR's diff and history (P2, scope hygiene). The original NodeMemoryHeadroomLow alert work in 67a9982 is unaffected and was already reviewed/approved separately.

… follow-up

claude[bot]'s P3 on this PR: the file header requires alert names to stay in
sync with mctl-agent's classifyAlert, and the review asked for a pointer so
the follow-up is not forgotten. It now exists -- mctlhq/mctl-agent#131 -- so
the comment names it rather than promising it.

Also records why the gap matters beyond naming: an unlisted node alert
classifies as TypeGeneric, takes the service=alertName fallback, and
therefore misses isInfraAlert's "TypeResourceLimit + empty Service"
manual-only gate every other node alert depends on.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@mashkovd

Copy link
Copy Markdown
Member Author

Correction on this PR's history, since it is visible in the timeline and would mislead anyone reading it.

An earlier push put commit b9c4992b on this branch. That commit was not part of this PR — it is feat(mctl-telegram): enable the temporary inbound-header probe for #617 Slice 1, authored by a different session that checked this branch out in a shared local clone, committed onto it, then reset away. My own edit was silently discarded before it was staged, so the push carried their commit instead of mine. The branch was reset back to 67a9982e shortly after, so nothing of theirs remains here.

The intended change is now on the branch as 2b1b55dc — one file, the comment above NodeMemoryHeadroomLow naming mctlhq/mctl-agent#131. Verified present on the remote branch after pushing, not merely assumed from a clean push.

Any claude-review run that evaluated 4318585c...b9c4992b was reviewing the wrong diff and should be disregarded; the run against 2b1b55dc is the one that counts.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit b9c4992.

No significant issues found.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 38s —— View job


Automated review — commit 2b1b55dc

  • Diff pinned commits de3da732...2b1b55dc
  • Isolate what's new since the last review (67a9982e...2b1b55dc)
  • Re-verify alert rule, route, and test correctness
  • Submit verdict

What changed since the last review: only a 7-line comment addition to mctl-alerts.yaml, pointing NodeMemoryHeadroomLow at the follow-up (mctlhq/mctl-agent#131) that will add it to classifyAlert — directly addressing the earlier P3 nit. No logic, route, or test changes.

Also confirmed the unrelated labs/mctl-telegram commit (b9c4992b) flagged in the prior CHANGES_REQUESTED pass is no longer present in the diff — the branch history is clean again, containing only the memory-alert work.

Re-checked the substantive parts (already approved in 67a9982e, re-verified here since the base moved):

  • NodeMemoryHeadroomLow (>0.85, for: 10m) sits correctly below the untouched NodeHighMemory (>0.9, for: 15m).
  • Alertmanager route in monitoring.yaml includes the new alertname in the same diff — the rule isn't inert.
  • Test math checks out: 87% fires at 10m, 80% stays quiet, and the spike case correctly asserts mid-spike (5m, inside the for window) as well as post-recovery (12m) — this is what makes the test able to fail on a for: regression instead of trivially passing.

No P1/P2/P3 findings on this commit.

Verdict: Approved — No P1/P2 findings (0 P3). This commit only adds explanatory comments referencing the classifyAlert follow-up (mctlhq/mctl-agent#131); the underlying NodeMemoryHeadroomLow rule, route, and tests were already reviewed and are unchanged since. Good to merge.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 2b1b55d.

No significant issues found.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (0 P3). This commit only adds explanatory comments referencing the classifyAlert follow-up (mctlhq/mctl-agent#131); the underlying NodeMemoryHeadroomLow rule, route, and tests were already reviewed and are unchanged since. Good to merge.

@mashkovd
mashkovd merged commit 664dc9c into main Sep 12, 2026
20 checks passed
@mashkovd
mashkovd deleted the feat/node-memory-headroom-alert branch September 12, 2026 00:24
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.

1 participant