Skip to content

Memory limiter proposal review - #1

Closed
dashpole wants to merge 1 commit into
memory_limiter_proposalfrom
memory_limiter_proposal_edited
Closed

Memory limiter proposal review#1
dashpole wants to merge 1 commit into
memory_limiter_proposalfrom
memory_limiter_proposal_edited

Conversation

@dashpole

@dashpole dashpole commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Review: prometheus/proposals 76 — Memory Limiter

Reviewed against prometheus/prometheus@40ea54d0b (v3.13.0) and both PoC branches on
dashpole/prometheus (memory_limiter_simple, memory_limiter_ai_poc).


Verdict

The problem is real and well-motivated, the Alternatives section is unusually good, and the
two-persona Debuggability section is better than most Prometheus proposals get. I'd support
the direction.

But I don't think the proposal is ready to be approved as a design, for one structural reason
and five specific ones. The structural reason: the proposal never says what it measures.
"Periodically a background routine checks the current memory usage of the Prometheus process"
is the entire specification of the control input, and every hard question in this design is
downstream of that sentence. When I fill in the blank the way the PoC does, the feature is a
no-op in containers and a false-positive generator everywhere else. When I fill it in the way
that would actually work, several of the proposed mitigations stop making sense.

Then, separately: two of the five mitigations do roughly the opposite of what the proposal
says they do, and one of the three "existing metric covers this" claims is wrong.


What's good, and I mean it

  • The framing of soft (delay) vs. hard (destroy) is the right axis, even though I disagree
    with where two of the mitigations landed on it.
  • Alternative 2 (reject only new series) — the transactionality argument is correct and is
    the strongest paragraph in the document. Worth strengthening with the observation that
    sample_limit already establishes whole-scrape failure as the Prometheus-native response
    to an over-large scrape (scrape/scrape.go:650), so you're following precedent, not
    inventing it.
  • Alternative 3 (slowing down scrapes) — agreed, and the "silently breaks assumptions built
    into alerts and recording rules" point is the right reason.
  • The Fairness section's insight that total samples, not new series, predicts short-term
    scrape cost is sharp and correct, and it's the kind of thing that usually gets discovered
    two implementations later.
  • Feature-flag gating with the config block inert when the flag is absent: right call.
  • Non-Goals correctly excludes long-term leaks.

P0-1. The control input is unspecified, and every obvious choice is wrong

Three separate questions are collapsed into "current memory usage":

  1. Which number? Go heap-in-use, Go total-in-use, process RSS, or cgroup memory.current?
  2. Percentage of what? Host RAM, cgroup limit, or GOMEMLIMIT?
  3. Instantaneous or post-GC? These differ by more than 2x under default GOGC.

The PoC answers: runtime.ReadMemStats(&m).Alloc compared against
github.com/pbnjay/memory's TotalMemory() (scrape/memory_limiter.go:82-101). Every one
of those three answers is wrong, and each is independently fatal:

(a) pbnjay/memory.TotalMemory() is host RAM, not the cgroup limit

It is syscall.Sysinfo().Totalram * Unit
(pbnjay/memory@v0.0.0-.../memory_linux.go) — completely cgroup-unaware. On a 128 GiB node
with an 8 GiB pod limit, limit_percentage: 90 resolves to ~115 GiB. The limiter never
engages, and Prometheus OOMs exactly as it does today
— in the deployment that motivates
the entire proposal.

The fix is one line, and it's already in the tree: cmd/prometheus/main.go:811 uses
memlimit.ApplyFallback(memlimit.FromCgroup, memlimit.FromSystem). The proposal should say
"percentage of the same budget --auto-gomemlimit detects" and name that provider, because
otherwise this bug gets reimplemented. Also: pbnjay/memory is currently an indirect
dependency (go.mod:232); promoting it to direct for something automemlimit already provides
is a needless dependency argument to have in review.

(b) MemStats.Alloc is instantaneous heap including uncollected garbage

Under default GOGC=100 the Go heap deliberately oscillates to ~2x live heap. Measured with
a synthetic ~205 MiB live heap and scrape-shaped allocation churn:

live heap after GC                                       204.6 MiB
MemStats.Alloc (what the PoC compares to the limit)      min 214.6 MiB  max 509.3 MiB  → 2.37x swing
runtime/metrics total-released (GOMEMLIMIT's accounting) min 222.9 MiB  max 525.6 MiB  → 2.36x swing

So a threshold on Alloc fires whenever the threshold sits below ~2.4x live heap. Restated:
the limiter engages permanently once live heap exceeds ~40% of the configured limit. This
is the OTel Collector memory_limiter's best-known failure mode, and inheriting its config
shape has quietly inherited its bug.

(c) Heap-only usage compared against a total-memory percentage

Alloc counts heap objects only. It excludes goroutine stacks, mspan/mcache, GC metadata
(collectively 10-20% of heap for a big Prometheus), and it excludes mmap'd head chunks and
block files entirely. Comparing that subset against a percentage of total memory
systematically under-reads. The proposal should state which of these two problems it is
solving, because they need different instruments:

  • Preventing a container OOM kill → needs cgroup accounting including page cache. But
    memory.current overstates: Prometheus's mmap'd head chunks and block files are
    reclaimable file cache, and the kernel will drop them under pressure rather than OOM. A
    limiter reading memory.current will engage constantly on an idle, healthy Prometheus.
  • Preventing Go heap exhaustion → needs Go accounting, and is the right target, because
    bounding Prometheus's anonymous memory and letting the kernel reclaim page cache is the
    actually-correct policy.

Concrete recommendation

Measure live heap after GC via runtime/metrics/gc/heap/live:bytes, confirmed
present in Go 1.24.7 — not MemStats.Alloc. In the same run as above, at 503 MiB live:

peak MemStats.Alloc   863 MiB
peak /gc/heap/live    599 MiB      ← tracks the real quantity, ~2/3 the noise

runtime/metrics.Read also avoids ReadMemStats's stop-the-world. Measured cost, roughly
flat across 122 MiB → 2 GiB live heap:

runtime.ReadMemStats   ~50-67 µs/call (STW)
runtime/metrics.Read   ~0.5-0.9 µs/call        → 75-145x cheaper

To be fair: ~60 µs once per second is not itself a problem, and I don't want to overstate it.
The real cost in the PoC is architectural — see P1-9.

Worth also putting in the doc as a candidate signal, even if rejected:
/gc/limiter/last-enabled:gc-cycle. That's the Go runtime telling you its own GC CPU
limiter engaged, i.e. GC is burning >50% of CPU — an unambiguous, self-calibrating "I am
dying" signal that needs no tuning at all. Similarly cgroup PSI (memory.pressure) measures
the thing you actually care about (imminent reclaim failure) across heap and non-heap alike.
If you reject these, say why; right now the doc reads as though absolute byte thresholds were
the only option considered.


P0-2. The thresholds are denominated in the budget GOMEMLIMIT is designed to consume

This is the deeper version of P0-1(b), and I think it invalidates the config shape rather
than just the PoC.

Prometheus sets GOMEMLIMIT = 0.9 × cgroup-or-system limit by default
(--auto-gomemlimit, --auto-gomemlimit.ratio=0.9, cmd/prometheus/main.go:429-432,
:806-816). Go then treats that entire budget as fair game for garbage: it will let
memory-in-use climb toward GOMEMLIMIT and only then GC harder. Healthy operation looks
like "memory near GOMEMLIMIT" by design.

Simulating a 1 GiB container with the proposal's defaults (limit_percentage: 90,
spike_limit_percentage: 20 → hard 921 MiB, soft 716 MiB), varying only live heap:

live heap % of container peak Alloc soft (716 MiB) trips hard (921 MiB) trips
314 MiB 31% 696 MiB 0.0% of checks 0.0%
419 MiB 41% 659 MiB 0.0% of checks 0.0%
503 MiB 49% 863 MiB 42.9% of checks 0.0%

(Synthetic allocator, not Prometheus — illustrative of the mechanism, not a Prometheus
measurement.)

Read the two ends of that table together:

  • At 49% utilization — a Prometheus with enormous headroom — the soft limit fires on
    ~43% of checks. Compaction and recording rules would be paused roughly half the time,
    permanently, on a server in no danger whatsoever.
  • The hard limit never fires, at any utilization, because GOMEMLIMIT sits at the same
    90% and Go won't let heap objects get there.

There is no value of limit_percentage that fixes this. Below GOMEMLIMIT you get constant
false positives; at or above it you get a feature that never engages. The knob is
denominated in the wrong quantity.

This also makes the GOMEMLIMIT section (line 108-112) a regression rather than a
refinement. GOMEMLIMIT = 0.9 × soft = 0.9 × 0.7 = 63% of the container, versus 90% today.
Anyone who sets --enable-feature=memory-limiter with the documented defaults hands 27% of
their memory budget back to the GC, and Go spends it on GC CPU. That's a large, silent
performance regression attached to a flag whose stated purpose is stability.

nicolastakashi's review comment asked precisely the right question and I don't think the
current answer resolves it. The answer I'd want: express the limits relative to
GOMEMLIMIT and in live-heap terms
, because live_heap / GOMEMLIMIT → 1 is the actual
death condition (past it, Go cannot honor the limit no matter how hard it collects), and it's
scale-free. Something like "engage when live heap exceeds 75% of GOMEMLIMIT" is meaningful;
"engage when memory exceeds 70% of the container" is not.


P0-3. "Fail scrapes" costs as many appends as the scrape it replaced

A failed scrape is not a cheap scrape. scrapeAndReport treats it as an empty scrape and
calls sl.append([]byte{}, ...) specifically to emit staleness markers
(scrape/scrape.go:1438-1440, and :1379-1395 for the forced-error path the PoC uses), which
walks seriesPrev and appends one StaleNaN per series
(scrape/scrape.go:1563-1579, :1029-1037).

I wrote a test against the real scrape loop (scrape/memlimit_review_test.go, attached):

successful scrape:   total=5000 added=5000 seriesAdded=5000 appends=5000
SKIPPED scrape:      total=0    added=0    seriesAdded=0    appends=5000  ← staleness markers
2nd SKIPPED scrape:  appends=0
scrape resumes:      seriesAdded=0 appends=5000

So on the first skipped cycle you avoid the HTTP body and the parse, and pay the full
append path anyway
— head chunk writes and WAL records, one per series. And because the
PoC's isOverLimit is a single global boolean, every target skips in the same check
interval, so you get a synchronized Σ(series) staleness stampede across the whole server at
the exact moment you are closest to the OOM. The mitigation's first act is to make the spike
worse.

Note also total=0 added=0 seriesAdded=0: the reported scrape metrics say nothing happened,
so prometheus_target_scrapes_skipped_total won't reflect this cost either.

This is fixable and the fix is small, but it's a design decision the proposal has to make
explicitly, because it changes the user-visible contract:

A memory-limited skip must not be modelled as a failed scrape. Skip the
app.append([]byte{}, ...) call and don't advance the cache — sl.cache.iterDone is only
reached through append, so "this scrape did not happen" falls out naturally, and you also
sidestep the forced cache-flush heuristic at scrape/scrape.go:955. Still call
sl.report(...), which is a separate deferred call (scrape/scrape.go:1373-1377) — that's
~6 samples per target, so you keep up = 0 and the /targets error and lose the
per-series cost.

The tension you need to name: without staleness markers, the target's series carry forward
under the 5m lookback, so the data reads as continuous-but-stale rather than absent. I think
that is the better semantic for a skipped scrape — the data is delayed, not gone, which is
exactly the framing Alternative 3 uses — but it does soften the "up = 0 sends a clear
signal" argument you make against slowing scrapes down. Pick one and say which.


P0-4. "Pause Compaction" as written is a memory amplifier

Line 68 says "Pause background TSDB compaction." The only existing mechanism is
DB.DisableCompactions() (tsdb/db.go:2515-2521), which sets autoCompact = false and
short-circuits the whole db.Compact(ctx) call (tsdb/db.go:1318-1329). That call is:

  1. head → persistent block compaction (tsdb/db.go:1539-1585)
  2. WAL truncation (:1531-1534, :1589-1591)
  3. OOO head compaction (:1602-1607)
  4. on-disk block merging (:1609compactBlocks)

(1) is what frees head memory. (2) is what keeps the WAL bounded — and an unbounded WAL
makes the next startup replay worse, i.e. it feeds the OOM crashloop this proposal exists to
break. Pausing "compaction" via the existing knob therefore increases memory monotonically
and degrades the recovery path.

Only (4) is a memory consumer worth pausing, and the code already prioritises head
compaction over it (tsdb/db.go:2000-2003: "aborting block compactions to persist the head
block"). There's also existing precedent for scoping a pause to block compaction only —
BlockCompactionExcludeFunc / --storage.tsdb.delay-compaction-file
(tsdb/db.go:260-262, cmd/prometheus/main.go:817-821).

The proposal must say "pause on-disk block compaction only; never head compaction, OOO head
compaction, or WAL truncation."
As written, an implementer reaching for the obvious API
ships a regression.

This also sits uneasily with your own dev-summit note in the thread — "memory usage is
cyclic, and tends to be highest right before compaction" — which says compaction is the thing
that relieves pressure. And with Complementary Idea 4, where "Early Compaction" is
dismissed. Under memory pressure the right move on the head is plausibly to compact
earlier, not later. The doc currently gestures at both and commits to neither.

While you're there: prometheus_tsdb_compactions_skipped_total already exists
(tsdb/db.go:442-445, "Total number of skipped compactions due to disabled auto
compaction") and is exactly the counter you'd want. The proposed
prometheus_tsdb_compaction_pending_blocks is implementable (db.compactor.Plan(db.dir)
returns the plan) but it's a second metric where you may only need the existing one plus a
_paused gauge.


P0-5. "Pause Recording Rules" is destructive, and the cited metric will not move

Two problems.

It's data loss, not delay. A missed recording-rule evaluation is a permanent gap in a
derived series. There is no backfill. SuperQ's "you can, in theory, resume without loss"
is true of the evaluator — it resumes cleanly — but not of the data. The proposal
inherited the premise without re-deriving it. By the doc's own soft/hard definition ("soft =
non-destructive"), this belongs under hard.

It's worse than a gap, because alerting keeps running. Line 69 keeps alerting rules
evaluating. Many alerting rules read recording-rule output. Prometheus already models this
(buildDependencyMap / isIndependent, rules/group.go:1079-1140). So during a pause:
first ~5 minutes, alerts evaluate against carried-forward stale values and look fine; after
lookback expires, expressions go empty and alerts silently resolve. Silently resolving
alerts during a memory incident is a worse outcome than the OOM. If you keep this mitigation,
either pause whole groups or refuse to pause recording rules that have dependents.

The observability claim is wrong. Line 134 offers prometheus_rule_group_iterations_missed_total
as existing coverage. That counter only increments when the tick loop falls behind
missed := (time.Since(evalTimestamp) / g.interval) - 1 (rules/group.go:265-269,
:286-291). If you pause by making evaluation a no-op, the ticker keeps perfect time,
missed stays 0, and the counter never moves. You need a new
prometheus_rule_group_iterations_skipped_total (or a _paused gauge). evalIterationFunc
(rules/group.go:73-95) is the natural, low-invasiveness hook.


P0-6. No exit strategy, so the failure mode is a permanent silent brownout

Non-Goals says this handles spikes, not sustained growth. But "Why" lists cardinality growth
as a trigger, and sustained growth is what actually OOMs Prometheus in production. What
happens when live heap genuinely exceeds the hard limit and stays there?

Dropping scrapes doesn't shrink the head. Pausing compaction grows it. So the steady state is:
every scrape dropped forever, up = 0 everywhere, all alerts firing (or worse, resolving —
see P0-5), and nothing self-heals. An OOM kill at least restarts the process and is
loudly visible to every orchestrator; a brownout is invisible to Kubernetes, invisible to
liveness probes, and has no recovery path.

The proposal needs to say what happens here. Options worth writing down: a bounded time in
the over-hard-limit state after which mitigations release and Prometheus takes the OOM; a
prometheus_memory_limiter_saturated signal designed for meta-monitoring alerts; a clean
os.Exit (strictly better than SIGKILL, since the WAL is flushed and replay is cheaper); or
forced early head compaction as the last resort. Any of these is fine. Silence is not — the
current draft's implicit answer is "brownout forever," and a reviewer should be able to see
that you chose it deliberately.

Related and unaddressed: hysteresis. With check_interval: 1s and a bare threshold
comparison, the limiter flaps at 1 Hz around the boundary, and the PoC makes it worse by
caching isOverLimit for a full interval (scrape/memory_limiter.go:73-76) so decisions are
up to 1s stale. Specify a minimum engagement duration and a distinct release threshold.


P1. Significant gaps

7. Two ingestion paths are missing. --web.enable-remote-write-receiver
(cmd/prometheus/main.go:468) has the same memory profile as OTLP and is absent from every
list in the doc. Federation (web/federate.go:55) is a classic Prometheus OOM — it
materializes a large series set — and is also absent. If they're deliberately out of scope,
say so; right now the "comprehensive coverage" claim has holes.

8. The PoC's own data contradicts two things the proposal says. From
scrape_memory_limiter_results.md / _paper.md:

strategy baseline samples massive samples baseline up peak RSS
probabilistic 275 1,450,000 10.0% 359 MiB
token bucket 410 2,400,000 14.9% 387 MiB
DRR 860 2,900,000 31.3% 383 MiB
  • The paper concludes DRR "isolat[es] the disruption to high-cardinality targets," and line
    149 carries that into the proposal. The numbers don't show that. DRR admitted 2x more
    from the massive target than probabilistic did. The baseline:massive ratio only improved
    1.9e-4 → 3.0e-4, i.e. ~1.6x, not the headline 3x — and DRR ran 7% hotter on peak RSS. What
    the experiment measured is that DRR achieves higher total throughput under the same limit.
    That's a real result, but it is a throughput result, not a fairness result, and the
    proposal shouldn't cite DRR as validated for fairness on this evidence.

  • More importantly, 31% uptime for a 5-series target is a failed design goal, not a
    qualified success.
    A 5-series target costs approximately nothing; there is no memory
    reason to ever drop it. Any scheme that does is too coarse. Equal-quantum DRR is arguably
    the wrong primitive precisely because equal shares is not what you want — you want shed
    the expensive, keep the cheap
    .

    Which suggests a cheaper and better mechanism than any of the three: a global byte budget
    per check interval
    , with a target admitted iff lastScrapeSize <= remaining_budget. The
    5-series target is admitted essentially always; the 50k-series target waits for a whole
    budget. That also is the "Gradual Degradation" future enhancement (line 143-145) — you
    shed increasing load as the budget tightens — so it collapses two future sections into one
    simpler mechanism available in v1.

  • The paper recommends limit_mib at 70-80% of the container limit (28% overshoot
    observed at limit_mib: 300 → 386 MiB peak RSS). The proposal's default is
    limit_percentage: 90. Your own PoC says that default doesn't work. Neither the overshoot
    nor the headroom guidance appears anywhere in the proposal, and it needs to.

  • The control run (strategy: none) recorded all zeros, so there's no measurement of how
    much data the feature costs
    relative to a healthy server.

9. No validation plan. SuperQ explicitly asked for this: "all of it is going to need to
be tested for various failure modes." The Action Plan has no testing item. The one I'd
insist on is a false-positive test: steady-state Prometheus at realistic utilization,
limiter configured, and confirmation that it never engages. Everything in P0-1 and P0-2 says
that's where this design breaks, and it's not a test that exists today. Also worth: a
prombench run, and a documented recovery-time measurement.

10. Config placement and the flag/config split. runtime: already exists in
prometheus.yml for exactly this class of knob (config/config.go:294, :733-752) and
gogc lives there. memory_limiter as a new top-level section (as in the PoC,
ScrapeMemoryLimiter at Config level) is defensible, but the doc should say why not
runtime.memory_limiter. Sharper: GOMEMLIMIT is set from a flag
(--auto-gomemlimit.ratio) at startup, and the proposal makes it derive from a
config-file value that is reloadable. That's two sources of truth plus a SetMemoryLimit
call on every SIGHUP. The RuntimeConfig doc comment (config/config.go:737-751) explicitly
warns about this: "Consider when the new field is first applied ... The test should also
verify behavior on reloads." Address it.

11. Silently ignoring the config block when the feature flag is absent (line 118) is
hostile — an operator sets limits, gets no error, and gets no protection. Log a warning at
minimum.

12. The limiter doesn't observe itself. The Debuggability section covers the mitigations
but not the controller. An operator debugging this needs to see what the limiter thought:
prometheus_memory_limiter_state (0/1/2), ..._memory_bytes (the measured input, whatever
you choose in P0-1), ..._limit_bytes{type="soft|hard"}, ..._transitions_total. Without
the measured input exported, nobody can tell a true engagement from a false positive — which,
given P0-2, is the question they'll have.

13. Existing knobs aren't reconciled. Remote read already has
--storage.remote.read-concurrent-limit, --storage.remote.read-sample-limit, and streamed
XOR chunks (cmd/prometheus/main.go:591-597, storage/remote/read_handler.go:48). Queries
have --query.max-samples and --query.max-concurrency (:636-639). Rules have
--rules.max-concurrent-evals (:612). The doc should say how the global limiter relates to
these — replaces, composes, or ignores.

14. Agent mode has no compaction, no rules, and no queries — only scrapes and remote
write. Does the feature apply? One sentence.


P2. PoC-specific findings (beyond P0-1)

  1. TargetScrapeAllowed() takes a global write lock on the scrape hot path
    (scrape/memory_limiter.go:67-69). Every scrape loop serializes on one mutex; with 10k
    targets that's a contention point, and the unlucky caller pays the stop-the-world
    ReadMemStats inline. This should be a background goroutine ticking at check_interval
    into an atomic.Bool, with scrape loops doing a single atomic load. That also makes
    check_interval mean what it says — right now the check is lazy, so with no scrapes the
    state never refreshes.

  2. limit_mib and limit_percentage both apply if both are set (:85-101 — the
    LimitMiB branch falls through to the LimitPercentage branch), so whichever is lower
    wins. The config comments read as if they're alternatives. Validate()
    (config/config.go, PoC diff) only rejects the both-zero and >100 cases. Either make
    them mutually exclusive or document that the minimum applies.

  3. No soft limit exists in the PoC at all. spike_limit_mib /
    spike_limit_percentage are in the config surface but unimplemented, so the entire
    soft-limit half of the proposal — the half containing both mitigations I flagged in P0-4
    and P0-5 — is unvalidated by any code or experiment.

  4. OTLP rejection must happen before the body is read. otlpWriteHandler.ServeHTTP
    calls DecodeOTLPWriteRequest(r) as its first statement
    (storage/remote/write_otlp_handler.go:167-168), which reads and unmarshals the entire
    request (storage/remote/codec.go:974-1010) before any check could run. Rejecting after
    that point has already paid the allocation you were trying to avoid — and the OTLP decode
    plus prometheusremotewrite conversion is a multiple of the wire size. Check the limiter
    at handler entry, and mention Retry-After alongside the 503 (line 128), since without
    it well-behaved clients will retry immediately and re-create the pressure.


On the Alternatives section

Agreed and well-argued: 1, 2, 3. 4 (independent GOMEMLIMIT) I'd revisit — see P0-2; the
"users wouldn't want a higher GOMEMLIMIT than the limiter's limit" reasoning assumes the two
are commensurable quantities, and I don't think they are.

The alternative I most want added: bound concurrent scrape work rather than dropping
scrapes.
There is no global scrape concurrency or byte limit in Prometheus today (no
--scrape.max-concurrency; I checked). Transient scrape memory is roughly
concurrent_scrapes × body_size × parse_overhead, so a global semaphore or byte budget over
the read-parse-append section bounds it directly, and delays rather than destroys
bounded by scrape_timeout, after which you fail as today. That is a genuinely non-destructive
hard-limit mitigation, it's simpler than DRR, and per P1-8 it subsumes the gradual-degradation
and fairness sections. It deserves to be either adopted or explicitly rejected.

Also unaddressed: SuperQ proposed a churn-based heuristic
(increase(prometheus_tsdb_head_series_created_total[5m]) / prometheus_tsdb_head_series).
The Fairness section answers a related question — total samples beats new series for
predicting short-term scrape cost — but that's the wrong denominator for SuperQ's point.
New series is exactly the right signal for the long-term growth that actually OOMs
Prometheus; total samples is right for the transient spike. The proposal conflates these
(Non-Goals excludes long-term growth; Why lists cardinality spikes as a motivating trigger).
Separate them explicitly and route the long-term case to Complementary Idea 3.

yeya24's two suggestions (OOO backfill for dropped scrapes; HEAD /metrics for cardinality
pre-flight) are both interesting enough to be listed and dismissed rather than left in the
thread.


Line-level

  • L16 "YouTub" → "YouTube"
  • L150 "incurr" → "incur"
  • L6 Implementation Status Not started — there are two PoC branches; Partially implemented
    is more accurate and the template offers it.
  • L60-61: "(or calculated via percentages)" — define precedence when both MiB and percentage
    are set (see P2-16).
  • L82: "Recommended value is 1s" — justify. With a live-heap signal, GC-cycle-aligned
    sampling is more natural than a wall-clock tick, since the number only changes at GC.
  • L128: 503 for remote read — remote read clients (Thanos, Grafana) mostly surface this to a
    user as a failed query; worth a sentence on whether that's intended.
  • L132: prometheus_tsdb_compaction_pending_blocks — see P0-4; prefer reusing
    prometheus_tsdb_compactions_skipped_total plus a _paused gauge.
  • The proposal has no Risks or Open Questions section. The template asks for "What
    open questions are left? (Known unknowns)" and "How you will test and verify?" — both are
    currently absent, and both are where this document is weakest.

Suggested ordering if you want to unblock this fast

The scope expansion you asked reviewers about (2026-05-01) got the answer "expand," and I
think that was the wrong advice — not because the other mitigations are bad ideas, but
because the expansion added four mitigations on top of a control input that isn't specified,
and two of those four (P0-4, P0-5) are wrong in ways that only become visible once you look
at the code.

I'd land this in two stages:

  1. Nail the measurement and one mitigation. Specify the signal (P0-1), fix the
    denomination against GOMEMLIMIT (P0-2), fix the skip path so it doesn't cost a staleness
    burst (P0-3), add the limiter's self-observability (P1-12), and add the false-positive test
    (P1-9). Ship with fail_scrapes — or better, the byte-budget admission scheme — alone.
  2. Then add mitigations one at a time, each with the code-level scoping it needs: block
    compaction only (P0-4), rule pausing with dependency-awareness and a real metric (P0-5),
    OTLP/remote-read/remote-write-receiver rejection at handler entry (P2-18, P1-7).

Everything in stage 1 is a correctness question about the controller. Everything in stage 2
is an independent policy question about one actuator. Mixing them is what makes the current
draft hard to review.


Artifacts

  • scrape/memlimit_review_test.go — the staleness-burst test in P0-3, runs green against
    prometheus@40ea54d0b with GOWORK=off go test ./scrape/ -run TestReview_Skipped -v.
  • Three throwaway Go programs behind the P0-1 / P0-2 tables (heap sawtooth, false-positive
    rate vs. utilization, ReadMemStats vs. runtime/metrics cost).

@dashpole
dashpole force-pushed the memory_limiter_proposal_edited branch from 5ba5a7f to 58a2baa Compare August 3, 2026 14:34
@dashpole dashpole changed the title Memory limiter proposal edited Memory limiter proposal review Aug 3, 2026
@dashpole

dashpole commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Thank you for the thorough, deeply engaging critique—the code-level catches regarding seriesPrev staleness injection storms, block compaction scoping (DB.compactBlocks), and early handler rejection before payload decoding were exceptional and have been adopted into the proposal.

We have also aligned on using post-GC live heap relative to GOMEMLIMIT (live_heap / GOMEMLIMIT) via non-stop-the-world runtime/metrics as our single, oscillation-free control signal for Milestone 1, while treating GOMEMLIMIT strictly as an input to prevent GC CPU regressions.

However, after architectural evaluation, we have outright rejected two of the proposed structural mechanisms due to severe stability hazards and operational anti-patterns:


1. Rejected: Soft-Tier Byte-Budget Scrape Throttling & "Proceed Anyway on Timeout"

The Proposal: Replacing equal-share algorithms (DRR) in v1 with a global byte-budget semaphore in the Soft Limit tier, where scrape loops block waiting for headroom up to scrape_timeout, after which they "proceed anyway".

Why We Rejected It:

  • Guaranteed Thundering-Herd OOM: In a memory-constrained production environment, dozens or hundreds of large, high-cardinality scrape loops will fail to acquire the semaphore and block. When their timers hit scrape_timeout simultaneously, allowing them to "proceed anyway" unleashes a synchronized wave of concurrent HTTP pulls, string decoding, and sample allocation into an already starved heap—guaranteeing an immediate, unrecoverable OOM spike caused directly by the mitigation designed to prevent it.
  • Hot Path Goroutine & Socket Starvation: In deployments scraping 10k–50k targets, holding thousands of suspended scrape goroutines waiting on a global semaphore causes severe lock contention, exhausts HTTP transport connection pools, and degrades Go scheduler responsiveness for query serving and rule evaluation.
  • Size Does Not Equal Criticality: A cost-proportional byte budget indiscriminately admits small targets while starving large ones. In practice, a massive 50k-sample target like kube-state-metrics or Prometheus's own self-monitoring endpoint is frequently the most critical telemetry needed during an infrastructure incident. Simple byte budgets allow verbose, low-priority business services to drain the available semaphore while critical monitoring is repeatedly starved.

We are keeping scrape mitigations strictly in the Hard Limit tier (skipping scrapes outright without staleness marker injection or blocking timers) and retaining weighted Deficit Round Robin (DRR) and Per-Job QoS metadata as future design goals.


2. Rejected: The saturated State and Automated Shutdown Actions (saturation_action)

The Proposal: Adding a persistent saturated state (entered after 15m continuously in the Hard Limit) with automated actions including none (infinite brownout), release (intentional OOM kill), or shutdown (os.Exit).

Why We Rejected It:

  • Restarts Provide Zero Memory Relief (The WAL Replay Reality): Using intentional container termination (os.Exit or SIGKILL releases) as a circuit breaker assumes that restarting fixes baseline series saturation. In reality, upon restart, Prometheus must replay its existing Write-Ahead Log (WAL) back into RAM to reconstruct the active TSDB Head series index. As soon as replay finishes, memory consumption returns immediately to identical pre-restart levels. All an intentional restart accomplishes is trading an operating server for an expensive boot loop and minutes of monitoring blackout.
  • The Dynamic Equilibrium Model: The assumption that an overloaded server gets trapped in an infinite 100%-dropped brownout mischaracterizes feedback loop dynamics. When mitigations shed load (skipping scrapes, rejecting OTLP), transient parsing memory disappears, Go's GC reclaims heap space, and pressure recedes below the Hard Limit threshold. Mitigations disengage, and normal ingestion resumes until pressure climbs again. Prometheus reaches a dynamic equilibrium where it continuously processes the maximum telemetry throughput sustainable within its configured RAM boundary.

Therefore, a capacity-constrained server should stay running, continue answering PromQL queries against historical data, ingest whatever throughput its memory budget allows, and signal capacity bottlenecks exclusively through unambiguous self-observability metrics (prometheus_memory_limiter_pressure_ratio >= 0.85 and prometheus_target_scrapes_skipped_total).

@dashpole
dashpole force-pushed the memory_limiter_proposal branch from b491883 to 62f2fcc Compare August 3, 2026 20:26

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Thanks — the two rejections are substantive and I think one of them is entirely right. Splitting this into what I concede, what I think is misdiagnosed, the gap the rejections open, and the items from the original review that don't appear to have made it into memory_limiter_proposal (i.e. prometheus#76) in either direction.


1. Conceded

"Proceed anyway on timeout" is a genuine defect, and worse than stated. Scrape loops are deliberately jittered across the interval. Parking them on a semaphore and releasing them when their timers expire de-jitters them — the mechanism manufactures synchronization that didn't previously exist. That isn't a tuning problem, it's the design creating the herd.

For the record, I contradicted myself between the review and the revised doc. The review said "bounded by scrape_timeout, after which you fail as today"; the doc said "on expiry it proceeds anyway … a scrape is never dropped, only delayed." You reviewed the doc, and the doc is the wrong one. With fail-on-timeout there is no herd — but that's a different mechanism than the one rejected, and I'd rather the record say "proceed-anyway is unsafe" than "byte budgets are unsafe."

"Size does not equal criticality" is the strongest argument in the response and I missed it. kube-state-metrics and Prometheus's own /metrics being simultaneously the largest and the most incident-critical targets is exactly the inversion a cost-proportional budget produces. "Shed the expensive, keep the cheap" fails whenever cost is anticorrelated with value, and in monitoring it frequently is. This belongs in the Fairness section of the proposal regardless of which mechanism wins — it's the sharpest constraint on the whole problem and it isn't written down anywhere yet.

"Restarts provide zero memory relief" is correct. WAL replay reconstructs the same head. My justification for shutdown was about replay cost, not about reducing steady-state heap, and it doesn't survive that argument. release and shutdown should both go. The WAL-replay reasoning is worth adding to Alternatives — "deliberately restart when saturated" is an option people will propose, and this is a clean reason it doesn't work.


2. Where I think the reasoning is misdiagnosed

2a. The dynamic-equilibrium model is incompatible with the signal we just adopted

This is the one I'd press hardest.

The argument is: mitigations shed load → "transient parsing memory disappears, Go's GC reclaims heap space, and pressure recedes below the Hard Limit." That holds for MemStats.Alloc, the old signal, where transient allocation is most of the number. It does not hold for /gc/heap/live:bytes, which is the marked-live set at the previous GC — a parse buffer only appears there if a mark phase happens to catch it in flight, so transient allocation contributes noise to that signal, not level.

What is actually in live heap is the head: memSeries structs, labels, postings, the symbol table, and each series' open chunk. Skipping a scrape prevents new series and new samples. It does not remove anything already resident. Live heap plateaus; it does not fall.

The paths that actually reduce it:

  • head.mmapHeadChunks() moves full chunks out of the Go heap, on the BlockReloadInterval ticker — default 1m (tsdb/db.go:99, :1279-1291). Partial relief, and close to useless against a cardinality spike, whose newly created series have nowhere near full chunks yet.
  • Head compaction truncates the head and releases the index — up to chunkRange away, 2h by default.

So for cardinality-driven growth — the case the "Why" section leads with — equilibrium arrives at the next head compaction, not within a few check intervals. Until then pressure stays above hard_limit_ratio and every scrape is skipped. That is the brownout, and switching to the live-heap signal made it more likely rather than less: the old Alloc-based signal did fall when load was shed, which is precisely why the equilibrium model felt true. The model is a description of the previous system carried across to a signal where the feedback path no longer exists.

I'm not arguing for reinstating saturation_action — see §3 for what I think the actual answer is.

2b. The goroutine and socket starvation argument is incorrect

Prometheus already runs one goroutine per target, permanently, whether or not it is blocked (scrapePool.loops, each scrapeLoop.run in its own goroutine). Parking them adds no goroutines. A parked goroutine costs a few KB of stack and zero scheduler time — it is not on a P, so it cannot degrade query serving or rule evaluation. And in the design as written the reservation is taken before the HTTP request is issued, so no connection is held and no transport pool is touched.

The other two arguments carry the rejection on their own; this one shouldn't stand as a recorded reason, because it would also rule out mechanisms that are fine.

2c. "Size ≠ criticality" argues against DRR as well, not only against the byte budget

Equal-quantum DRR starves kube-state-metrics for the same reason a byte budget does: a 50k-series target needs many quanta and waits many cycles. The PoC data in scrape_memory_limiter_results.md shows exactly this — the massive target was throttled under all three strategies.

So the conclusion isn't "keep DRR, drop the byte budget." It's that any size-based scheme is on the wrong axis, and explicit priority is the only thing that answers the criticality problem. That promotes Per-Job QoS from "future design goal" to "the mechanism that actually addresses this," and it means the Fairness section's current DRR endorsement should be softened rather than retained (see §4.6).


3. The gap the rejections open

Keeping "stay running" and dropping the shutdown actions is right. But taken together with §2a it leaves the design with no mitigation that reduces live heap — only mitigations that stop it growing. Without one, "dynamic equilibrium" is asserted rather than achieved.

The obvious candidate is forced early head compaction: db.CompactHead (tsdb/db.go:1613) and db.CompactStaleHead (:1871) already exist. Complementary Idea prometheus#4 currently dismisses this, but on a re-growth argument ("the new series would immediately cause memory to balloon again") rather than on whether it provides immediate relief. Under a live-heap signal it is the only listed action that moves the number being measured. Worth re-opening on those terms.

One honest counterweight to my own P0-3. Removing staleness markers from the skip path has a cost I didn't flag. numStaleSeries is incremented by staleness-marker appends, and when storage.tsdb.stale_series_compaction_threshold is set, a high stale ratio triggers CompactStaleHead() (tsdb/db.go:1293-1313) — which does reduce live heap. So the marker burst I argued for removing was also feeding the one existing early-head-compaction trigger. It is opt-in and defaults to 0, so I still think removing the markers is correct, but it is a real trade-off and the doc should record it rather than treat the removal as unambiguous.


4. Items from the original review still unaddressed upstream

These are orthogonal to both rejections — they aren't accepted or rejected, they just don't appear in memory_limiter_proposal yet.

4.1 GOMEMLIMIT unset makes the limiter a silent no-op. When GOMEMLIMIT is unset, /gc/gomemlimit:bytes returns math.MaxInt64 (verified: 9223372036854775807), so pressure_ratio ≈ 0.0000000000 permanently and nothing ever engages. Two paths reach this: --auto-gomemlimit=false with no GOMEMLIMIT env var, and --auto-gomemlimit=true where detection fails — cmd/prometheus/main.go:812-814 logs a warning and continues with it unset. This is the same failure class as the original pbnjay/memory finding: silently inert in a valid configuration. One startup check closes it. (prometheus_memory_limiter_pressure_ratio would read ~0, so it is at least detectable — but only by someone who already suspects it.)

4.2 No hysteresis, against a signal with minutes of dead time. Per §2a, fail_scrapes doesn't move live heap promptly; the response arrives on the mmapHeadChunks cycle (~1m) or at head compaction. Sampling that at check_interval: 1s with a bare threshold and no minimum engagement is a feedback loop with long dead time and no damping. Asymmetric release thresholds plus a minimum engagement duration on the order of the mmap cycle, and a justification for check_interval against that timescale.

4.3 Adjacent Non-Goals contradict each other. "It is designed to handle spikes and overload scenarios" is immediately followed by "Extremely rapid intra-GC allocation spikes … are out of scope." A coherent reframe is available: transient spikes are GOMEMLIMIT's job; this limiter's job is retained live-heap growth. That is a clean division of labour — it just isn't the story "Why" currently tells.

4.4 Two now-false statements about the OpenTelemetry Collector. Goals still says "unified, top-level global configuration similar to the OpenTelemetry Collector's memory limiter," and the Configuration section still opens "The configuration closely follows the OpenTelemetry Collector's memory limiter processor." Neither is true after the pivot — the block is nested under runtime:, and limit_mib / spike_limit_* / percentages are gone. Diverging deliberately is right; the doc currently claims the opposite.

4.5 Alternatives prometheus#4 now argues against the adopted design. It rejects "Independent GOMEMLIMIT configuration" as something "users wouldn't want," which is precisely what the proposal now does.

4.6 The Fairness section is untouched and overclaims. It still states DRR "mathematically guarantee[s] fairness … isolating the disruption to high-cardinality targets." The PoC numbers don't support that: DRR admitted ~2x more from the massive target than probabilistic did (2.9M vs 1.45M), the small target's share improved ~1.6x rather than the headline 3x, and DRR ran ~7% hotter on peak RSS. What was measured is higher total throughput under the same limit — a real result, but a throughput result. With the byte budget rejected this is now the only stated fairness plan, so the claim carries more weight, not less.

4.7 No "How we test and verify" section. The template asks for it and @SuperQ asked for it directly ("all of it is going to need to be tested for various failure modes"). Now that the design turns on a single signal choice, the false-positive test is the experiment that validates or kills it: steady-state Prometheus at realistic utilization with the limiter enabled at defaults, asserting it never leaves ok.

4.8 The soft tier is nearly empty for the motivating case. For a pure-scraping Prometheus, soft = pause block compaction, and that's all; then everything drops at once at 0.85. Moving recording rules to hard was correct, but it thinned the tier, and with the byte budget rejected there's now no graceful step for the exact scenario "Why" leads with. Worth naming explicitly rather than leaving implicit.

4.9 Minor. incurrincur in the Fairness section. And soft_limit_ratio: 0.70 / hard_limit_ratio: 0.85 have no stated basis — the GC-headroom arithmetic (max achievable GOGC ≈ 43 and ≈ 18 respectively) is a defensible one-line justification.


Ranked by what I'd act on first: 2a and 4.1 are the two I'd treat as defects. 4.4/4.5/4.6 are editing debt from the pivot that will read as oversights to an upstream reviewer. 3, 4.2 and 4.7 are where I'd argue but won't insist. Everything in §1 I think should be written into the doc — the rejection arguments are good enough that the next reviewer shouldn't have to rediscover them.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Correcting my §2a — I measured it, and the argument I made there was wrong

I claimed above that skipping scrapes doesn't move /gc/heap/live:bytes, and used that to argue the dynamic-equilibrium model was a carry-over from the old Alloc-based signal. The claim is right. The argument around it isn't, and the actual defect is neither my position nor the one I was arguing against.

I ran it against a real tsdb.Head driven through ingest → cardinality spike → all-scrapes-skipped → head compaction, sampling all three candidate signals the way a limiter reading each would see them: live heap after a forced GC (settled), MemStats.Alloc and total-minus-released as in-flight maxima with no forced GC.

Run 1 — 150k → 300k series:

phase live_heap (proposal) max Alloc (PoC) max in-use (GOMEMLIMIT) in-use settled
A steady scraping, 150k 149 MiB 396 MiB 435 MiB 314 MiB
B cardinality spike, 300k 279 MiB 790 MiB 828 MiB 616 MiB
C all scrapes skipped 279 MiB (+0.0%) 279 MiB (−64.6%) 616 MiB (−25.7%) 341 MiB (−45%)
D head compaction (Truncate) 39 MiB (−86.0%) 155 MiB

Run 2 — robustness check. In run 1 each series had only 30 samples, so no head chunk reached the 120-sample cutoff and mmapHeadChunks() had nothing to move, which would overstate my case. Re-ran with 140 rounds so chunks fill and the mmap cycle has real work: live heap 200 → 200 MiB, +0.0%, flat at every sample across 30 skip rounds. Head compaction: −81.3%.


1. The claim holds, more strongly than I stated it

I said live heap "plateaus rather than falls," and guessed the mmapHeadChunks cycle would give partial relief over minutes. It gives none — +0.0%, flat, in both runs, with the mmap cycle running every round and full chunks available to move. Skipping scrapes does not move the signal the proposal acts on, at all.

2. But the equilibrium model is correct — about the process

Peak Alloc fell 65%, peak in-use 26%, settled in-use 43–45%. The process genuinely recovers when load is shed, exactly as described. I was wrong to characterise that as a mis-reading of the feedback dynamics; it isn't.

3. The actual defect is a sensor/actuator mismatch

The mitigation relieves the quantity GOMEMLIMIT defends. The signal the limiter reads records exactly zero improvement. So the limiter cannot observe the recovery it caused, and will not disengage. That still produces the brownout, but by a completely different mechanism than the one I argued — not "memory doesn't recover," but "the chosen instrument can't see it recover." Adding saturation_action was the wrong fix for it, and rejecting that was right.

4. The two signals fail in complementary ways, which is where I'd build the fix

This is the part I think is most useful for implementation. Look at phase A — a healthy 150k-series head: live heap 149 MiB, peak in-use 435 MiB. A 2.9x gap. That sawtooth is precisely why live heap is the right choice for engaging, and re-confirms the original P0-1/P0-2 finding independently.

But live heap is invariant under every mitigation currently in the design, which makes it the wrong choice for releasing.

  • live_heap / GOMEMLIMIT — no false positives, correctly identifies the floor Go cannot collect below. Invariant under the mitigations.
  • in-use / Alloc — responds strongly to the mitigations. Sawtooths ~3x on a healthy server.

Two ways out, and I don't have a strong preference:

  • Asymmetric sensors. Engage on live_heap / GOMEMLIMIT; release on in-use falling. Different sensors for arm and disarm is a normal pattern when one instrument is trustworthy about danger and the other is trustworthy about recovery. This also subsumes the hysteresis item (4.2 above) rather than adding to it.
  • Keep one signal, add an actuator that moves it. Early head compaction is the only action measured here that touches live heap (−81% to −86%). That would make §3's suggestion concrete rather than speculative.

5. One result that cuts toward the original draft

At phase D, settled in-use rose (341 → 444 MiB in run 1, 254 → 337 in run 2) because Truncate allocates while compacting. Head compaction relieves live heap but costs transient memory to do it. So the first draft's instinct to be careful with compaction under memory pressure wasn't unfounded — it just needed the head/block split from P0-4 rather than a blanket pause. Worth a sentence in the doc if early head compaction gets adopted, since it means the last-resort action has a cost spike before it pays off.


Method and limits

Synthetic Head only — no scrape loop, no query load, no remote write, no WAL replay. churn() allocates scrape-body-sized buffers as a stand-in for real parse memory rather than actually parsing. A forced GC in the settle step, which is generous to the equilibrium argument. Single run per configuration, no repetitions or variance analysis. This isolates one mechanism cleanly; it is not a capacity benchmark, and it is not prombench.

The thing I'd most want checked by someone with a real cluster: whether in-use falls as reliably under a real mixed workload (queries, rules, remote write all continuing while scrapes are skipped) as it does here with scraping as the only load. If it doesn't, the asymmetric-sensor option gets weaker.

Reproduction — drop in tsdb/, run GOWORK=off go test ./tsdb/ -run TestMemLimitEquilibrium -v -timeout 25m
// Throwaway experiment for prometheus/proposals#76.
//
// Question: when the memory limiter skips scrapes, does the process actually
// recover, and does the signal the proposal adopted (/gc/heap/live:bytes)
// observe that recovery?
package tsdb

import (
	"context"
	"fmt"
	"runtime"
	rmetrics "runtime/metrics"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/prometheus/prometheus/model/labels"
	"github.com/prometheus/prometheus/util/compression"
)

const mib = 1024 * 1024

func readLive() uint64 {
	s := []rmetrics.Sample{{Name: "/gc/heap/live:bytes"}}
	rmetrics.Read(s)
	return s[0].Value.Uint64()
}

func readInuse() uint64 {
	s := []rmetrics.Sample{
		{Name: "/memory/classes/total:bytes"},
		{Name: "/memory/classes/heap/released:bytes"},
	}
	rmetrics.Read(s)
	return s[0].Value.Uint64() - s[1].Value.Uint64()
}

// phaseStat accumulates in-flight maxima (no forced GC) plus a settled
// post-GC live-heap reading taken at the end of the phase.
type phaseStat struct {
	name               string
	maxAlloc, maxInuse uint64
	settledLive        uint64
	settledInuse       uint64
	series             uint64
}

func (p *phaseStat) observe() {
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)
	if ms.Alloc > p.maxAlloc {
		p.maxAlloc = ms.Alloc
	}
	if u := readInuse(); u > p.maxInuse {
		p.maxInuse = u
	}
}

func (p *phaseStat) settle(h *Head) {
	runtime.GC()
	runtime.GC() // second cycle so /gc/heap/live reflects the just-completed mark
	p.settledLive = readLive()
	p.settledInuse = readInuse()
	p.series = h.NumSeries()
}

func appendRound(t testing.TB, h *Head, from, n int, ts int64) {
	app := h.Appender(context.Background())
	for i := from; i < from+n; i++ {
		lset := labels.FromStrings(
			"__name__", "bench_metric",
			"instance", fmt.Sprintf("10.1.%d.%d:9100", (i/250)%256, i%250),
			"job", "synthetic",
			"pod", fmt.Sprintf("workload-%d-abcde", i%5000),
			"namespace", fmt.Sprintf("ns-%d", i%64),
			"container", "main",
			"id", fmt.Sprintf("%d", i),
		)
		_, err := app.Append(0, lset, ts, float64(i)+float64(ts%1000))
		require.NoError(t, err)
	}
	require.NoError(t, app.Commit())
}

// churn stands in for the transient parse/decode memory a *scraping* Prometheus
// continuously produces -- the memory the equilibrium argument says disappears
// when scrapes are skipped.
func churn(p *phaseStat) {
	var sink [][]byte
	for i := 0; i < 256; i++ { // ~64 MiB
		sink = append(sink, make([]byte, 256*1024))
		if i%32 == 0 {
			p.observe()
		}
	}
	sink = sink[:0]
	p.observe()
}

func TestMemLimitEquilibrium(t *testing.T) {
	const (
		baseSeries  = 150_000
		spikeSeries = 150_000
		interval    = int64(15_000)
		warmRounds  = 20
		spikeRounds = 10
		skipRounds  = 20
	)

	h, _ := newTestHead(t, DefaultBlockDuration, compression.None, false)
	defer func() { _ = h.Close() }()

	ts := int64(1)
	var phases []*phaseStat

	// Phase A: steady state, stable cardinality, actively scraping.
	a := &phaseStat{name: "A steady scraping (150k series)"}
	for r := 0; r < warmRounds; r++ {
		appendRound(t, h, 0, baseSeries, ts)
		churn(a)
		ts += interval
	}
	h.mmapHeadChunks()
	a.settle(h)
	phases = append(phases, a)

	// Phase B: cardinality spike, still scraping.
	b := &phaseStat{name: "B cardinality spike (300k series)"}
	for r := 0; r < spikeRounds; r++ {
		appendRound(t, h, 0, baseSeries+spikeSeries, ts)
		churn(b)
		ts += interval
	}
	h.mmapHeadChunks()
	b.settle(h)
	phases = append(phases, b)

	// Phase C: ALL SCRAPES SKIPPED. No appends, no parse churn. We still run the
	// mmapHeadChunks cycle (BlockReloadInterval, default 1m), which is the most
	// generous possible reading of the equilibrium argument.
	c := &phaseStat{name: "C all scrapes skipped"}
	for r := 0; r < skipRounds; r++ {
		h.mmapHeadChunks()
		c.observe()
		time.Sleep(50 * time.Millisecond)
	}
	c.settle(h)
	phases = append(phases, c)

	// Phase D: head compaction -- the action the limiter does NOT take.
	d := &phaseStat{name: "D head compaction (Truncate)"}
	require.NoError(t, h.Truncate(ts))
	h.mmapHeadChunks()
	d.observe()
	d.settle(h)
	phases = append(phases, d)

	t.Log("")
	t.Logf("%-36s %11s | %11s %11s | %11s %8s",
		"phase", "live_heap", "max Alloc", "max inuse", "inuse", "series")
	t.Logf("%-36s %11s | %11s %11s | %11s %8s",
		"", "(proposal)", "(PoC)", "(GOMEMLIMIT)", "settled", "")
	for _, p := range phases {
		t.Logf("%-36s %8.0f MiB | %8.0f MiB %8.0f MiB | %8.0f MiB %8d",
			p.name, float64(p.settledLive)/mib, float64(p.maxAlloc)/mib,
			float64(p.maxInuse)/mib, float64(p.settledInuse)/mib, p.series)
	}

	pct := func(from, to uint64) float64 { return 100 * (float64(to) - float64(from)) / float64(from) }

	t.Log("")
	t.Log("=== B -> C : the effect of skipping every scrape ===")
	t.Logf("  live heap (proposal's signal)  %6.0f -> %6.0f MiB  (%+.1f%%)",
		float64(b.settledLive)/mib, float64(c.settledLive)/mib, pct(b.settledLive, c.settledLive))
	t.Logf("  peak Alloc (PoC's signal)      %6.0f -> %6.0f MiB  (%+.1f%%)",
		float64(b.maxAlloc)/mib, float64(c.maxAlloc)/mib, pct(b.maxAlloc, c.maxAlloc))
	t.Logf("  peak in-use (GOMEMLIMIT)       %6.0f -> %6.0f MiB  (%+.1f%%)",
		float64(b.maxInuse)/mib, float64(c.maxInuse)/mib, pct(b.maxInuse, c.maxInuse))
	t.Log("")
	t.Log("=== C -> D : head compaction ===")
	t.Logf("  live heap                      %6.0f -> %6.0f MiB  (%+.1f%%)",
		float64(c.settledLive)/mib, float64(d.settledLive)/mib, pct(c.settledLive, d.settledLive))
}

// TestMemLimitEquilibrium_FullChunks is the robustness check on the test above.
// There, each series had only 30 samples, so no head chunk reached the 120-sample
// cutoff and mmapHeadChunks() had nothing to move -- which would overstate the
// case that live heap is invariant. Here we run long enough that chunks fill.
func TestMemLimitEquilibrium_FullChunks(t *testing.T) {
	const (
		baseSeries  = 100_000
		spikeSeries = 100_000
		interval    = int64(15_000)
		warmRounds  = 140 // > 120 samples/chunk, so chunks fill and get mmap'd
		spikeRounds = 140
		skipRounds  = 30
	)

	h, _ := newTestHead(t, DefaultBlockDuration, compression.None, false)
	defer func() { _ = h.Close() }()

	ts := int64(1)

	a := &phaseStat{name: "A steady scraping (100k, chunks full)"}
	for r := 0; r < warmRounds; r++ {
		appendRound(t, h, 0, baseSeries, ts)
		if r%10 == 0 {
			h.mmapHeadChunks()
			churn(a)
		}
		ts += interval
	}
	h.mmapHeadChunks()
	a.settle(h)

	b := &phaseStat{name: "B cardinality spike (200k)"}
	for r := 0; r < spikeRounds; r++ {
		appendRound(t, h, 0, baseSeries+spikeSeries, ts)
		if r%10 == 0 {
			h.mmapHeadChunks()
			churn(b)
		}
		ts += interval
	}
	h.mmapHeadChunks()
	b.settle(h)

	// All scrapes skipped. mmapHeadChunks now has full chunks available to move
	// out of the Go heap -- the most favourable case for the equilibrium model.
	c := &phaseStat{name: "C all scrapes skipped"}
	var trace []uint64
	for r := 0; r < skipRounds; r++ {
		h.mmapHeadChunks()
		c.observe()
		if r%10 == 0 || r == skipRounds-1 {
			runtime.GC()
			runtime.GC()
			trace = append(trace, readLive())
		}
		time.Sleep(50 * time.Millisecond)
	}
	c.settle(h)

	d := &phaseStat{name: "D head compaction (Truncate)"}
	require.NoError(t, h.Truncate(ts))
	h.mmapHeadChunks()
	d.observe()
	d.settle(h)

	t.Log("")
	t.Logf("%-40s %11s | %11s | %11s %8s", "phase", "live_heap", "max Alloc", "inuse settled", "series")
	for _, p := range []*phaseStat{a, b, c, d} {
		t.Logf("%-40s %8.0f MiB | %8.0f MiB | %8.0f MiB %8d",
			p.name, float64(p.settledLive)/mib, float64(p.maxAlloc)/mib,
			float64(p.settledInuse)/mib, p.series)
	}
	t.Log("")
	t.Log("live heap trajectory across the skip phase (mmap cycle running each round):")
	for i, v := range trace {
		t.Logf("   skip sample %d: %6.0f MiB", i, float64(v)/mib)
	}
	pct := func(from, to uint64) float64 { return 100 * (float64(to) - float64(from)) / float64(from) }
	t.Logf("live heap B -> C (skipping every scrape): %+.1f%%", pct(b.settledLive, c.settledLive))
	t.Logf("live heap C -> D (head compaction):       %+.1f%%", pct(c.settledLive, d.settledLive))
}

Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Retracting P0-2, and the signal recommendation that followed from it

Third correction, and this one goes to the root. My P0-2 argument was over-generalized, the switch to live heap that followed from it was the wrong fix, and the "0.425 guard" I proposed last was worse than the problem it solved. The original transient-spike design was closer to right than what I talked us into.

The measurement

GOMEMLIMIT = 1 GiB, an in-use threshold (/memory/classes/totalheap/released) at 0.85, GOGC=100. Varying only baseline live heap. "Healthy" = steady scraping-shaped churn; "spike" = one large transient burst.

baseline live live/GOMEMLIMIT healthy: false positives spike: detection
149 MiB 15% 0.0% 0.0% (peak 741 MiB — genuinely no danger)
248 MiB 24% 0.0% 15.0%
347 MiB 34% 0.0% 83.3%
447 MiB 44% 0.0% 97.2%
546 MiB 53% 51.7% 100%
645 MiB 63% 68.3% 100%

An in-use threshold is a clean transient-spike detector below ~45% baseline live heap — zero false positives with 97% detection — and degrades above ~50%. P0-2 claimed no absolute threshold could work. That holds only in the upper regime. My own earlier table showed the crossover (0.0% at 31% and 41%, 42.9% at 49%) and I generalized past it.

Analytically the boundary is threshold / (1 + GOGC/100) = 0.85/2 = 0.425, which predicts the observed break between the clean 44% row and the broken 53% row.

What survives from P0-1, and what doesn't

  • (a) pbnjay/memory.TotalMemory() is host RAM, not the cgroup limit — stands, independent of everything else. Still the bug that would have made the feature a no-op in containers.
  • (c) MemStats.Alloc is heap-objects-only — stands. Use total-minus-released, the accounting GOMEMLIMIT actually defends.
  • (b) "the sawtooth means no threshold works"retracted. True only above ~50% baseline. Switching to live heap was the wrong remedy for a real but bounded problem, and it dragged the Non-Goal about intra-GC spikes in with it, which quietly removed the proposal's original purpose.

So: keep the denominator change, keep the accounting change, revert the transient exclusion. The Non-Goal added in 62f2fcc should come back out.

And the guard I proposed last time was wrong

I suggested disabling the limiter above 0.425 baseline. That's backwards — a server with a large baseline is more at risk, not less. 0.425 should be a reporting fact ("above this, expect frequent engagement; your baseline is too large for your budget"), not a switch that turns the feature off.

The deeper error underneath it: I treated false-positive rate as a property of the signal. It's a property of the (signal, mitigation) pair. A false positive costing "delay this scrape 100 ms" is nearly free; one costing "drop the scrape, emit up=0, alert storm" is expensive. Every mitigation currently in the doc is in the expensive class, which is why I read a 50% FP rate as disqualifying rather than as an argument for cheaper mitigations.

Force the GC and use it as a discriminator

@dashpole's framing — we aren't trying to lean on the GC to save us, we're trying to save ourselves — is the resolution. Rather than treating the sawtooth as sacrosanct:

  1. In-use crosses the soft limit → force a GC, rate-limited by a minimum interval.
  2. Memory drops → it was garbage. Cost: one GC. Nothing dropped, no up=0, no data lost. The false positive resolved itself.
  3. Memory doesn't drop → live heap is genuinely large, the pressure is real, escalate to backpressure and then shedding.

This dissolves the sawtooth problem rather than designing around it, and makes the FP rate close to irrelevant in the regime this proposal targets.

Caveat worth writing down: forced-GC cost scales with the live set while its benefit shrinks as live heap grows (less of in-use is garbage). So it works best exactly where it's aimed and worst on a very large head — argues for a minimum interval plus skipping it when live heap is already high. The OTel Collector's memory limiter has a deserved reputation for CPU burn when mis-tuned, and this is the mechanism.

Two things from the kubelet eviction manager that fix earlier mistakes of mine

Grace periods. Soft eviction waits out a grace period, then acts. That is the correct form of the byte-budget mechanism — delay with a deadline, then drop, never "proceed anyway." The rejection in #1 above was right, and this is the repair.

Ranked, targeted eviction. Kubelet ranks and evicts the worst offender rather than acting globally. The analog: reject the specific scrape that won't fit, not all of them. There's a clean checkpoint — s.client.Do() returns after headers, and readResponse receives the *http.Response before copying the body (scrape/scrape.go:751-773), so Content-Length is known before allocation. Compare against current headroom and reject that scrape. O(1), no global semaphore, no parked goroutines — it sidesteps the thundering-herd and goroutine-starvation objections completely, and it naturally spares small targets, softening the kube-state-metrics criticality problem. Caveats: no Content-Length on chunked responses, and for gzip it's compressed size, so an expansion factor is needed.

Scope, honestly stated

"It's virtually impossible to prevent OOM if you scrape a target large enough" is correct, and the proposal should say so rather than imply a guarantee. This is best-effort load shedding. body_size_limit already bounds the per-target worst case; the memory limiter handles aggregate and concurrent pressure. Layered that way, the objection stops being a problem for the design.

check_interval: 100ms is viable: runtime/metrics.Read at ~0.5–0.9 µs is ~0.001% CPU at 10 Hz. Worth noting that's only true because of the runtime/metrics change — at 10 Hz ReadMemStats would be ten stop-the-world pauses per second. That recommendation ends up serving the original design rather than working against it.

Where this leaves the two proposals

  • Proposal for memory limiting in the scrape loop prometheus/proposals#76 — transient overload, original scope restored. Signal: in-use / GOMEMLIMIT, checked frequently. Tiers: force GC → delay with a deadline → drop the specific scrape that doesn't fit. The dynamic-equilibrium model is correct under this signal (in-use fell 26% peak / 45% settled when scrapes were skipped, measured above), so no saturated state is needed — that rejection was right, it just needed this signal to be right about this proposal.
  • A separate proposal — baseline saturation. Signal: live_heap / GOMEMLIMIT. Mitigations: head eviction via CompactSelectedSeries (data preserved on disk, still queryable) and the churn limiting in Scrape configuration to limit label churn / added series prometheus/prometheus#17109. Most of my previous two comments belongs there, not here.

Not yet validated

The forced-GC-as-discriminator tier is a design argument, not a measurement — I have not measured GC cost against live-set size, or confirmed that a forced GC reliably clears a sawtooth-induced trip. That's the next thing worth running, and it's the load-bearing claim for the tier structure above. The 0.425 boundary is also from a synthetic allocator; the mechanism is Go GC dynamics so I expect it to transfer, but it should be confirmed against a real server before it appears as a documented number.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Measurements for the forced-GC tier, and a concrete edit list for the proposal

I said the forced-GC tier was a design argument rather than a measurement. I've now measured it. One result overturns what I posted last time, and one turns up a cheaper lever that I think should be the first mitigation rather than an afterthought.


E1 — Cost of forcing a GC, vs live-set size

Pointer-rich live set (strings + pointers + slices), since mark cost scales with the object graph rather than bytes — flat []byte would understate it badly.

live heap objects forced GC wall STW pause interval for <1% of a core
44 MiB 200k 19 ms 46 µs 1.9 s
174 MiB 800k 44 ms 36 µs 4.4 s
433 MiB 2M 98 ms 49 µs 9.8 s
866 MiB 4M 188 ms 41 µs 18.8 s
1731 MiB 8M 364 ms 56 µs 36.4 s

Linear at ~0.21 ms per MiB of live heap. STW pause is negligible (Go's GC is concurrent) but wall/CPU cost is not.

Consequence: reclamation cannot run at the check interval. At 433 MiB live one forced GC takes 98 ms — an entire 100 ms tick. The check interval and the reclamation interval must be separate knobs, and the reclamation interval should be self-tuning at roughly 100× the last reclaim's measured duration, which lands at ~10 s for a small head and ~200 s for an 8 GiB one without anyone having to configure it.


E2 — Lowering GOGC is a much cheaper lever, and should come first

Baseline live heap 321 MiB (31% of a 1 GiB GOMEMLIMIT), churn rate-limited to ~100 MiB/s to resemble a scraper.

GOGC peak in-use vs GOMEMLIMIT GC CPU
100 615 MiB 60% 2.1%
75 604 MiB 59% 2.9%
50 500 MiB 49% 4.3%
25 452 MiB 44% 8.8%

Dropping GOGC 100 → 50 cuts peak in-use by 115 MiB (19%) for 2.2 percentage points of GC CPU. It is continuous, instantly reversible, loses no data, and touches no scrape. Against that, dropping a scrape to reclaim a comparable amount is a terrible trade.

(Methodology note, since it matters: my first attempt at this table allocated in a tight loop, which saturated Go's own GC CPU limiter at its 50% cap and let the heap balloon to 5.7 GiB — 560% of GOMEMLIMIT. That run was garbage and I threw it out. It is, incidentally, a clean demonstration of the death spiral, and a reminder that Go will abandon GOMEMLIMIT rather than stall forever.)


E3 — The discriminator works. But runtime.GC() is the wrong call.

Trip the 0.85 threshold two ways, then try to reclaim:

garbage-driven trip (a completed scrape's buffers, now unreachable):
  runtime.GC()          heap-objects 821 ->  321 MiB | in-use  840 ->  840 MiB | 90ms
  debug.FreeOSMemory()  heap-objects 821 ->  321 MiB | in-use  840 ->  339 MiB | 102ms

live-heap-driven trip (retained series, nothing to reclaim):
  runtime.GC()          heap-objects 802 ->  802 MiB | in-use  841 ->  841 MiB | 195ms
  debug.FreeOSMemory()  heap-objects 802 ->  802 MiB | in-use  841 ->  840 MiB | 188ms

Two findings:

The discriminator is clean. 500 MiB reclaimed in the garbage case, 0 MiB in the live case. A binary, unambiguous test of "is this pressure real."

runtime.GC() does not reduce in-use, and I was wrong to recommend it. It frees the objects (heap-objects 821 → 321) but the pages stay mapped until the scavenger returns them, so in-use — the thing that approximates RSS, and therefore the thing the container OOM killer actually sees — does not move at all. debug.FreeOSMemory() is required; it does the collection and the scavenge, for essentially the same cost (102 ms vs 90 ms).

This is a live implementation trap, not a nitpick: a limiter that measures in-use and reclaims with runtime.GC() will observe zero relief from its own successful mitigation and escalate to dropping scrapes when it did not need to. That is the same sensor/actuator mismatch as before, in miniature.


Recommended edits to the proposal

Concretely, section by section.

1. How / signal. Control signal becomes (/memory/classes/total:bytes − /memory/classes/heap/released:bytes) / /gc/gomemlimit:bytes. Also read /gc/heap/live:bytes and /memory/classes/heap/objects:bytes — not to control on, but for the discriminator and for reporting. Drop live heap as the control signal.

2. Startup validation. Fail to start if /gc/gomemlimit:bytes reads MaxInt64 (unset) and no explicit limit is configured. Otherwise pressure_ratio ≈ 0 forever and the feature is silently inert — reachable via --auto-gomemlimit=false with no env var, or an automemlimit detection failure (cmd/prometheus/main.go:812-814 warns and continues).

3. check_interval: 100ms. Restore the fast cadence, and justify it: runtime/metrics.Read at ~0.5–0.9 µs is ~0.001% CPU at 10 Hz. Add a separate, self-tuning reclaim_interval per E1. State plainly that these are different timescales.

4. Replace the mitigation tiers. Ordered by cost, cheapest first:

tier action cost reversible
T1 lower GOGC (ceiling = configured runtime.gogc, restore on release) +2.2pp GC CPU for 19% headroom instantly
T2 debug.FreeOSMemory(), rate-limited to ~100× last duration; doubles as the discriminator 0.21 ms/MiB live n/a
T3 per-scrape admission: Content-Length vs headroom at the readResponse boundary (scrape/scrape.go:751-773), reject only the scrape that won't fit O(1) n/a
T4 shed: pause block compaction, 503 remote read / federation / OTLP / remote write, skip scrapes data loss no

T1 and T2 are new and non-destructive. T3 is targeted rather than global. T4 is the existing content, demoted to last resort.

5. Skip T1/T2 when live heap is already high. This is where the 0.425 number finally has a proper home — as a tier selector, not the global guard I wrongly proposed. Above threshold / (1 + GOGC/100), lowering GOGC buys nothing (GOMEMLIMIT is already binding) and reclamation finds nothing (E3's live-heap row), so go straight to T3/T4 and report baseline saturation.

6. Non-Goals. Remove the intra-GC-spike exclusion added in 62f2fcc — it was added on my bad advice and it removed the proposal's original purpose. Add instead: this is best-effort, not a guarantee; body_size_limit bounds the per-target worst case while the limiter handles aggregate and concurrent pressure; and small servers get less protection (at 15% baseline live heap a 225 MiB spike never reaches the threshold — correct behaviour, but worth stating so nobody finds it in the field).

7. GOMEMLIMIT as input. Keep exactly as written in 545c034. That part was right.

8. Metrics. prometheus_memory_limiter_pressure_ratio (in-use based), ..._live_heap_ratio (the baseline/capacity signal), ..._state, ..._state_seconds_total{state}, ..._reclaim_total and ..._reclaim_bytes_total (so the discriminator's verdict is visible), ..._gogc_current. Document alerting on live_heap_ratio as the capacity signal — it's the number that says "provision more memory," and it's distinct from the transient pressure signal.

9. Fairness section. Soften the DRR claim per the earlier comment. Note that T3 spares small targets structurally — a 5-series target's Content-Length always fits — which addresses the kube-state-metrics criticality problem without needing a scheduler.

10. Split. Baseline/structural saturation moves to its own proposal: signal live_heap / GOMEMLIMIT, mitigations CompactSelectedSeries head eviction plus churn limiting (prometheus/prometheus#17109).

Still outstanding from earlier and unaffected by any of this: hysteresis (4.2), the two now-false OpenTelemetry Collector sentences (4.4), Alternatives prometheus#4 contradicting the design (4.5), no test/verification section (4.7), and incurrincur (4.9).


Limits

All of E1–E3 use synthetic allocators, not a running Prometheus. The mechanisms are Go runtime behaviour so I expect them to transfer, but the specific numbers — 0.21 ms/MiB, the 2.2pp CPU cost of GOGC 50, the 0.425 boundary — should be confirmed on a real server before any of them appears in the doc as a default. Single runs, no variance analysis. GC cost in particular will vary with GOMAXPROCS and with how pointer-dense the real head is compared to my stand-in.

The one thing still unmeasured that I'd want before implementation: whether T3's Content-Length check is actually predictive of a scrape's peak memory, given decompression and parse amplification. That needs a real scrape path, not a synthetic harness.


Generated by Claude Code

@dashpole

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

We appreciate the rigorous experimental testing (E1–E3) and scientific integrity shown in retracting P0-2 and moving back to an in-use total memory control signal! We have updated the proposal to adopt In-Use Total Memory Ratio ((/memory/classes/total:bytes - /heap/released:bytes) / /gc/gomemlimit:bytes) via runtime/metrics at a fast 100ms cadence, as it perfectly validates our dynamic equilibrium model and eliminates the sensor/actuator mismatch. We also adopted the editorial cleanups, startup validation rules, and the test/verification suite.

However, in accordance with our guiding design principle—keeping the proposal simple, predictable, and easy for operators to reason about—we have evaluated and rejected the proposed automated runtime manipulations (Tiers 1, 2, and 3) in favor of our simple Soft/Hard load-shedding circuit breaker for Milestone 1:


1. Rejected: Tier 2 (Forced OS Page Scavenging via debug.FreeOSMemory())

While forcing an exhaustive collection and OS page scavenge works as a synthetic binary discriminator to clear GC sawtooth spikes, in production it introduces severe CPU thrashing and scheduler lockups:

  • Catastrophic Wall-Clock Stalls: As demonstrated in your E1 measurements, calling debug.FreeOSMemory() costs up to 364 ms of wall-clock CPU time on an 8 GiB heap because it forces Go to synchronously track down and unmap memory pages back to the kernel. In a production server undergoing an acute traffic or cardinality spike, initiating explicit 350+ ms synchronous OS page scavenges will induce severe CPU thrashing right when the scheduler is struggling to parse packets and append WAL records—risking a CPU starvation death spiral while trying to avoid an OOM.
  • Redundancy with Native Go Scavenging: Modern Go (GOMEMLIMIT via runtime.bgscavenge) already natively accelerates concurrent garbage collection and background OS memory unmapping as heap approaches the container boundary. Calling FreeOSMemory() does not uncover new RAM that Go wasn't already freeing; it merely forces an asynchronous background cleaning job to run synchronously on the hot path.

2. Rejected: Tier 1 (Dynamically Scaling & Lowering GOGC)

We strongly advise against transforming Prometheus into an automated runtime engine that quietly alters GOGC compiler flags (from 100 $\rightarrow$ 50 $\rightarrow$ 25) behind the scenes:

  • Total Redundancy with GOMEMLIMIT: When in-use memory climbs toward GOMEMLIMIT, Go's native runtime controller already dynamically compresses effective GOGC (e.g., forcing collections over 5× as often at 85% memory utilization). Writing custom code to manipulate GOGC on top of Go's native GOMEMLIMIT controller is architectural redundancy.
  • Opaque CPU Trade-Offs: Automatically altering GOGC trades away precious CPU cycles without operator consent. If an operator runs a memory-constrained container where sacrificing CPU to maintain a tighter heap ceiling is desirable, the correct solution is for that operator to statically configure --runtime.gogc=50 at startup for predictable day-to-day performance.

3. Rejected: Tier 3 (Targeted Scrape Admission via Content-Length at readResponse)

While checking payload sizes at the HTTP header boundary appears to be a clean $O(1)$ admission gate, in real-world Kubernetes observability deployments, relying on HTTP Content-Length breaks down completely:

  • Protocol Incompatibility: A significant percentage of modern monitoring endpoints return Transfer-Encoding: chunked (where Content-Length is absent) or Content-Encoding: gzip (where wire size underestimates decompressed RAM allocation by ratios of 15:1 to 20:1). Basing load admission on compressed wire bytes yields severely distorted predictions of real AST parsing memory.
  • Violation of "Size $\ne$ Criticality": A cost-proportional header check discriminates purely against payload size. During an outage, chatty low-priority microservices (with small 10 KiB responses) will be constantly admitted, while massive infrastructure telemetry endpoints (such as kube-state-metrics or Prometheus's own self-monitoring /metrics endpoint) will consistently exceed residual headroom and be repeatedly aborted at the header boundary.

For Milestone 1, we are keeping our clean, tested Soft/Hard thresholds: delaying deferrable tasks at 70% and cleanly skipping scrapes / rejecting write payloads universally at 85%, leaving target fairness and QoS prioritization to future explicit metadata enhancements.

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Review of e4fe08b / 9499d43 / d5a166e — one load-bearing claim doesn't hold

Read the three new commits. Most of it landed cleanly. One sentence in the How section is now carrying the whole design and I measured it, because it's exactly the kind of assumption that was wrong twice already.


E4 — "In-use memory responds immediately when load is shed"

Recovery of in-use after ~400 MiB of garbage becomes unreachable. Sampled at the proposal's own 100 ms check_interval. Baseline in-use 234 MiB, GOMEMLIMIT 1024 MiB.

action peak t=100ms t=600ms t=2s t=10s
passive (no action) 635 MiB 635 635 635 635
runtime.GC() 980 MiB 980 635 635 635
debug.FreeOSMemory() 980 MiB 235 235 235 235

Passively, in-use does not recover at all — not slowly, not partially. Flat for ten seconds.

runtime.GC() reclaims the objects but settles at the same 635 MiB plateau, because freed heap stays mapped. Go's background scavenger is paced to roughly 1% of CPU, and at 62% of GOMEMLIMIT it is under no pressure to hurry. Only debug.FreeOSMemory() returns pages to the OS, and it does so inside a single check interval.

(This also reconciles the apparent conflict between E3 and the earlier equilibrium test — that test's settle() called runtime.GC(), which is why in-use appeared to fall there.)

Consequence

debug.FreeOSMemory() is a correctness requirement of the in-use signal, not an optional tier. Without it the limiter sheds load, observes no recovery, and stays engaged or escalates further — the same sensor/actuator mismatch that killed the live-heap signal, in a third form. The dynamic-equilibrium model is correct, but it does not fall out of the in-use signal for free; something has to force the scavenge.

Two edits:

  • How section: "responds immediately when load is shed" → "responds within one check interval once reclamation is forced", with the mechanism named.
  • Mitigations: add forced reclamation via debug.FreeOSMemory() as the first soft-limit action, rate-limited to ~100× its own last measured duration (per E1: ~0.21 ms per MiB of live heap, so ~10 s on a small head, ~200 s on an 8 GiB one — self-tuning, no operator knob).

Worth stating explicitly in the doc that runtime.GC() is insufficient. It is the obvious call to reach for, it produces a real and visible drop in heap objects, and it will leave the limiter's own signal unmoved.


Two smaller defects

The metric-availability claim is not quite right. The doc says operators can monitor pressure "via existing Go runtime metrics already exposed by client_golang." Prometheus registers collectors.MetricsGC (^/gc/.*) and MetricsScheduler, but not MetricsMemory (^/memory/.*) — cmd/prometheus/main.go:373-384. So go_gc_heap_live_bytes and go_gc_gomemlimit_bytes are exposed as claimed, but go_memory_classes_total_bytes / go_memory_classes_heap_released_bytes — the limiter's actual numerator — are not. Either name the working pair explicitly (go_memstats_sys_bytes − go_memstats_heap_released_bytes, both in the default memstats set) or add MetricsMemory to the registration.

Dangling references from d5a166e. The test section cites prometheus_memory_limiter_state_seconds_total{state="soft|hard"} and "state ok", but the metrics section now defines only prometheus_memory_limiter_active{limit="soft|hard"}. Neither the counter nor a state enum exists anywhere in the doc.

Dropping pressure_ratio on the no-precomputed-ratios principle is defensible and I'd keep that call. But add prometheus_memory_limiter_limit_bytes{limit="soft|hard"} — the thresholds the limiter actually compares against should be discoverable without recomputing them from config, and exporting them as bytes is consistent with the same principle.


What landed well

The signal switch is done cleanly, and the "post-GC live heap is invariant under load shedding" rationale is stated correctly rather than just asserted. The Non-Goal rewrite is better than what I proposed — naming #17109 and selective head eviction makes the split concrete instead of gesturing at it. GOMEMLIMIT-unset now fails startup. Both OpenTelemetry Collector sentences and the contradictory Alternatives prometheus#4 are gone. The Fairness rewrite states "size does not equal criticality" more crisply than I managed. The test section covers the four things that matter, and the incurr typo went out with the rewritten paragraph.


Still open

Hysteresis — now with a stronger basis than when I first raised it. A 100 ms check interval against a signal that only recovers when reclamation is forced means real dead time in the loop; without a minimum engagement duration and an asymmetric release threshold, the limiter will over-shed while waiting for its own signal to catch up.

Where 45% comes from. Test #1 requires "<45% baseline live heap" but the number is unexplained. It is hard_limit_ratio / (1 + GOGC/100) = 0.85/2 ≈ 0.425 — the baseline above which the GC sawtooth alone crosses the threshold. Worth deriving in the text, because it is also what makes hard_limit_ratio: 0.85 defensible rather than arbitrary, and it is the natural handoff point to the baseline-saturation proposal.

Lowering GOGC as the first mitigation. Still absent, and it's the one I'd push hardest on: 19% headroom for 2.2 percentage points of GC CPU (E2), instantly reversible, no data lost, no scrape touched. It is strictly cheaper than every mitigation currently in the soft tier. Treat the configured runtime.gogc as the ceiling and restore on release.

Per-scrape Content-Length admission — still absent, and I'd hold it until someone verifies that Content-Length actually predicts peak scrape memory once decompression and parse amplification are accounted for. That needs the real scrape path and hasn't been measured.


Limits

E4 uses a synthetic allocator, single run, GOGC=100, one GOMEMLIMIT. The scavenger's pacing is Go runtime behaviour so I expect the shape to transfer, but the specific plateau and timings will vary with GOMAXPROCS, with how close in-use sits to GOMEMLIMIT (Go scavenges harder under real pressure), and with the Go version. The qualitative finding — passive recovery does not happen on control-loop timescales, and runtime.GC() does not move in-use — is what I'd rely on; the exact numbers should be confirmed on a real server before anything derived from them becomes a documented default.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Correcting my recovery claims — twice over, with a predictive model this time

@dashpole pushed back on two things I asserted: that debug.FreeOSMemory() is a correctness requirement, and that moving the thresholds closer to GOMEMLIMIT wouldn't help because "the closer you are to GOMEMLIMIT, the more frequently GC runs." He was right about the mechanism, I was wrong about the conclusion, and it took three more experiments to find out that neither of my previous answers was the real story.


E5 — Does proximity to GOMEMLIMIT improve recovery?

Fixed live heap (~215 MiB) and fixed burst target (830 MiB in-use); only GOMEMLIMIT varies, so the same absolute in-use level sits at a different fraction of the limit each time. Background allocation 5 MiB/s. 90 s observation window.

GOMEMLIMIT trip as % of GML GCs in 90s min in-use release below 70%
900 MiB 92% 4 389 MiB 35.6 s
880 MiB 94% 3 413 MiB 36.0 s
865 MiB 96% 3 413 MiB 34.0 s
838 MiB 99% 4 427 MiB 36.3 s

Flat. Recovery is ~34–36 s regardless of whether the trip point sits at 92% or 99% of GOMEMLIMIT.

An earlier 30 s-window run of the same setup reported "NEVER" for 85% and 92%. That was an observation-window artifact — recovery takes ~35 s and my window was 30 s. My "never recovers" claim was wrong, and I should have extended the window before asserting it.


E6 — What actually governs recovery

Go triggers GC when heap objects reach the GOGC goal — live × (1 + GOGC/100), so live × 2 at GOGC=100. GOMEMLIMIT only lowers that goal when live × 2 would exceed it, i.e. when live heap is more than ~half of GOMEMLIMIT. In E5, live was ~215 MiB against an 838–976 MiB limit (~25%), so 2 × 215 = 430 never came close and GOMEMLIMIT was simply not in the loop. That is why proximity changed nothing.

Dropping a burst returns heap objects to live, so the next GC needs another live bytes of fresh allocation:

recovery_time  ≈  live_heap / background_allocation_rate

Tested on both axes independently (GOMEMLIMIT 1400 MiB, GOGC=100):

live heap bg rate predicted measured ratio
200 MiB 5 MiB/s 40.0 s 30.7 s 0.77
200 MiB 10 MiB/s 20.0 s 29.2 s 1.46
200 MiB 20 MiB/s 10.0 s 11.9 s 1.19
200 MiB 40 MiB/s 5.0 s 8.3 s 1.66
100 MiB 20 MiB/s 5.0 s 5.2 s 1.04
200 MiB 20 MiB/s 10.0 s 7.9 s 0.79
399 MiB 20 MiB/s 20.0 s 22.3 s 1.12

Holds within roughly ±50%. The live-heap axis is the cleaner fit (1.04 / 0.79 / 1.12); the rate axis is noisier because my pacing loop can't hold an exact rate at 40 MiB/s.


What this means for the proposal

Setting hard_limit_ratio just below GOMEMLIMIT does not improve recovery in the regime the proposal targets. It would only help once live heap exceeds ~50% of GOMEMLIMIT — which is the baseline-saturation regime already split out into a separate proposal. Measured flat across 92→99%.

debug.FreeOSMemory() is not a correctness requirement. I overstated that. Memory does come back on its own.

The real issue is how recovery scales. Recovery time is linear in live heap. Extrapolating from the validated model, a 4 GiB head with 20 MiB/s of residual non-scrape allocation is ~200 s of continued shedding after the burst ends; a 10 GiB head, ~500 s. The servers that most need the limiter are the ones that recover slowest, and nothing in the current design bounds that.

Recommendation, unchanged in shape but on better grounds: let Go recover naturally — that is the normal path and it works — but bound the wait. If still engaged after a fixed timeout (30 s covers small heads with margin), call debug.FreeOSMemory() once, rate-limited per E1 (~0.21 ms per MiB of live heap). If you'd rather have it self-tune, the limiter can compute the expected recovery itself: live heap from /gc/heap/live:bytes, allocation rate from /gc/heap/allocs:bytes, wait ~2× the quotient.

This also settles the hysteresis question quantitatively: with natural release latency of live_heap / alloc_rate — tens of seconds at realistic sizes — a 100 ms check_interval needs a minimum engagement duration on that order, or it will flap and over-shed while waiting for its own signal.


Limits, and a note on my own error rate here

Synthetic allocators throughout; no real Prometheus, no queries or rules, and "background allocation" is a pacing loop rather than real work. The model is derived from Go's documented GC trigger and validated over a 4× range of live heap and 8× of allocation rate — I'd trust its shape and use it for sizing timeouts, not its constants.

I've now been wrong twice on this specific question in opposite directions — first "correctness requirement," then "never recovers, proximity doesn't help." Both errors came from concluding on a single configuration with too short an observation window. The model above is the version I'd stand behind, because it predicts rather than describes and it was tested on both axes independently. It should still be confirmed on a real server before anything derived from it becomes a default.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Retracting the dynamic-GOGC recommendation, and a release-possibility result that replaces it

@dashpole pushed back on the limiter adjusting GOGC at runtime: operators may well want the CPU-for-headroom trade, but the limiter shouldn't make it for them, and absent strong evidence, garbage collection should be left to the runtime — which already exposes knobs with predictable behaviour that users know.

He's right, and my own data supports his position better than it supported mine. Retracting it.


Where my evidence actually was

E2 compared GOGC values held fixed for the duration of each run — 100 / 75 / 50 / 25, each a separate configuration. That is evidence that a lower GOGC setting trades CPU for headroom. It is not evidence that changing GOGC at runtime is safe or effective, and I presented it as though it were.

Worse, the one time I did test dynamic adjustment, it failed and I reported the failure and then kept recommending the lever anyway. From the release experiment, after tripping the hard limit and shedding load:

gc      (one runtime.GC())    871 -> 867 MiB   never releases
gogc50  (SetGCPercent(50))    871 -> 871 MiB   never releases
gogc25  (SetGCPercent(25))    870 -> 870 MiB   never releases
free    (FreeOSMemory)        870 -> 109 MiB   releases in 200ms

Dynamic GOGC did exactly nothing.

Three concrete ways it would make the wrong call

Each backed by a measurement already in this thread:

  • When pressure is live-heap-driven, lowering GOGC buys no memory and costs CPU. The table above — pure loss.
  • The adjustment itself forces an unscheduled GC at the worst possible moment. SetGCPercent collects when the new goal falls below the current heap, and E1 measured that at ~0.21 ms per MiB of live heap: ~200 ms on a 1 GiB head, seconds on a large one, triggered exactly when memory is tight.
  • GC CPU competes with what operators need during an incident. Doubling GC CPU (2.1% → 4.3% in E2, and that is a synthetic floor) takes it from query serving and rule evaluation precisely when someone is trying to debug.

E7 — Release possibility, and why GOGC belongs as an input

While testing whether recovery gets faster near GOMEMLIMIT (it does not — it stops entirely; separate finding), the governing rule turned out to be:

release_possible  ⟺  live_heap × (1 + GOGC/100)  <  release_threshold

Release is not gated on the GC firing. It is gated on whether the post-GC heap goal leaves any slack below the release threshold for the scavenger to give back. If the goal sits above the release threshold, there is nothing to return, ever — the limiter engages and never lets go.

Verified across three GOGC values, 19 of 20 predictions correct:

GOGC predicted cutoff measured
50 live < 46.7% of GML releases at 44.7%, never at 48.7%
100 live < 35.0% of GML releases at 33.6% (23.4 s), never at 35.7%
200 live < 23.3% of GML releases at 21.3%, never at 29.3%

The single miss was marginal (GOGC=200, goal 1064 vs release 980 — 9% past the line, released anyway in 28.8 s), so treat the boundary as soft within ~10% rather than a cliff.

GOGC appears in that formula as an input. Which is exactly the right relationship, and generalises the principle the proposal already established for GOMEMLIMIT in 545c034:

The limiter reads runtime parameters; it never writes them. It manages load, the runtime manages memory.

The limiter reads runtime.gogc and /gc/gomemlimit:bytes, determines whether its own control loop can close, and reports. It overrides neither.


What replaces the recommendation

Operator documentation, not automation — expressed in knobs users already understand:

The memory limiter can only disengage if live_heap × (1 + GOGC/100) < release_ratio × GOMEMLIMIT. At defaults (GOGC=100, release_ratio 0.70) that is live heap below 35% of GOMEMLIMIT — roughly 31% of the container limit with --auto-gomemlimit.ratio=0.9. Above that, the limiter becomes a one-way latch. Three ways out, all the operator's call: provision more memory, lower GOGC (widening the regime to 46.7% at GOGC=50, at the cost of GC CPU), or raise release_ratio.

Stated in advance, predictable, chosen deliberately — rather than the limiter silently making the trade mid-incident.

Two consequences for the doc:

  1. Test Memory limiter proposal review #1's "<45% baseline live heap" should be 35%, and stated as the formula rather than a constant, since it moves with GOGC and release_ratio.
  2. Export live heap, GOGC and the resolved thresholds so operators can see where they sit relative to the boundary before they hit it. This is the strongest argument yet for prometheus_memory_limiter_limit_bytes{limit="soft|hard"} — the boundary is uncomputable from config alone.

Net effect: the proposal gets smaller. No dynamic GOGC tier, no forced-reclamation tier, no recovery timeout — those were three separate recommendations of mine across as many comments, and all three are withdrawn. What remains is one inequality the limiter evaluates from numbers the runtime already publishes, plus documentation of the regime it implies.


Limits

Synthetic allocators throughout, single runs, one GOMEMLIMIT value (1400 MiB) for E7. The formula's shape is derived from Go's documented GC goal and held across a 4× range of GOGC, so I'd trust it structurally; the soft ~10% boundary and the exact constants want confirming on a real server. In particular I have not tested it with GOMEMLIMIT binding the goal (live above ~50% of the limit), which is the baseline-saturation regime and belongs to the other proposal anyway.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Posted from the file at scratchpad/test-design.md — see body below.


Generated by Claude Code

dashpole commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Test design and success metrics for the memory limiter

Written against d5a166e. This is the validation plan I'd want before Stage 1 merges — it
replaces the four-item "How We Test and Verify" list with something an implementer can
execute and a reviewer can use to say yes or no.

Two things shaped it:

  • The proposal is a control loop, and control loops fail in specific, enumerable ways.
    Testing "does it prevent an OOM" is necessary but nowhere near sufficient — a loop that
    prevents the OOM by engaging permanently has not solved the problem, it has renamed it.
  • The measurements already in this thread predict where it breaks. E7 gave a closed-form
    release condition. The plan below is built to confirm or refute that on a real Prometheus,
    cheaply, before anyone spends GKE hours.

1. What is actually being claimed

The proposal asserts a causal chain. Each arrow is a separate assumption, and each can fail
independently:

memory demand rises
  → in_use/GOMEMLIMIT crosses a threshold          (C1: the sensor sees it)
    → mitigations engage
      → memory demand actually falls               (C2: the actuator works)
        → in_use falls back below the threshold
          → mitigations release                    (C3: the loop closes)
            → normal operation resumes
  … and none of this happens when healthy          (C4: quiet when quiet)
  … and the whole thing beats just taking the OOM  (C5: net benefit)

Five claims. C3 is the one I'd test first, because it is the one my own measurements say is
most likely to fail, and because if it fails the rest of the plan is moot.

The specific reason C3 is at risk

From E7, release is possible only when the post-GC live heap plus one GC cycle's worth of
garbage fits under the release threshold:

live_heap × (1 + GOGC/100)  <  release_threshold × GOMEMLIMIT

At stock defaults (GOGC=100, soft_limit_ratio=0.70) that is live_heap < 0.35 × GOMEMLIMIT, or ~31% of the container limit once --auto-gomemlimit.ratio=0.9 is applied.
Above that boundary the limiter is a one-way latch: it engages and never releases, which is
the permanent silent brownout from P0-6, arrived at by a different route.

Now put that next to the engagement condition, in_use ≥ 0.70 × GOMEMLIMIT. Both must hold
for the loop to close, which means the excess memory during a burst has to be almost
entirely transient
. If a meaningful fraction of the burst is retained — new series
entering the head — live heap rises past 35%, and the limiter latches.

That is a sharp, falsifiable prediction, and it cuts at the motivation: "Why" lists "spikes
in scrape load or metric cardinality (e.g., new workloads spun up in Kubernetes)"
as a
trigger. A new Kubernetes workload retains. So the headline use case may be precisely the one
the mechanism cannot recover from.

I want to be careful here: E7 was measured on a synthetic allocator, not Prometheus, and one
of its twenty rows missed. It is a hypothesis with support, not a result. Confirming or
refuting it on a real binary is the single highest-value experiment in this plan
, and it is
also one of the cheapest — Tier 1 below, no cluster required.


2. Prototype requirements (testability contract)

The tests below cannot run against the metrics d5a166e currently specifies. Before the
scenarios, the prototype needs:

Signal Why the test needs it
prometheus_memory_limiter_active{limit="soft|hard"} as proposed
prometheus_memory_limiter_engaged_seconds_total{limit} duty cycle cannot be reconstructed from a gauge. The limiter transitions at 10 Hz; meta-monitoring scrapes at 15 s. Sampling a boolean at 1/150th of its update rate gives you noise, not a duty cycle. This was dropped in d5a166e under the no-pre-calculated-ratios rule — but that rule is about ratios, and a seconds counter is a counter. It's the same reason process_cpu_seconds_total is a counter and not a "cpu busy" gauge.
prometheus_memory_limiter_transitions_total flapping is otherwise invisible for the same reason
prometheus_memory_limiter_threshold_bytes{limit="soft|hard"} the resolved absolute thresholds. Without these no test (and no operator) can distinguish "engaged correctly" from "engaged because GOMEMLIMIT resolved to something unexpected" — which, given ApplyFallback(FromCgroup, FromSystem), is a real failure mode.
MetricsMemory registered in the Go collector currently blocking. cmd/prometheus/main.go:373-384 registers MetricsGC and MetricsScheduler only. The in-use signal the limiter now acts on (/memory/classes/total/memory/classes/heap/released) is not exported today, so the control input is unobservable from outside the process. One line, but nothing downstream works without it.

Also needed for scenario control, not shipped: a hidden/undocumented flag or test-only hook to
force the limiter into a given state. Several tests below (per-mitigation attribution,
skip-path cost) want the actuator exercised without having to reproduce the memory conditions
that trigger it. Without it, attribution tests become impossible to isolate.


3. Harness tiers

Ordered cheapest-first deliberately. Each tier gates the next; do not book GKE time until
Tier 1 passes.

Tier 0 — Go tests, seconds, no infrastructure

Runs in CI on every commit. Covers the mechanical claims, not the emergent ones.

Test Asserts
Signal plumbing limiter's computed in_use equals runtime/metrics total−released; thresholds resolve correctly from a mocked /gc/gomemlimit:bytes; MaxInt64 case fails startup as documented
Skip-path cost a memory-limited skipped scrape performs O(1) appends — no seriesPrev walk. Already written and passing as scrape/memlimit_review_test.go; hand it to the implementer rather than rewriting
Compaction scoping with block compaction paused, CompactHead and WAL truncation still execute. Assert on prometheus_tsdb_wal_truncations_total advancing and head chunk count falling — not on the pause flag
Rule dependency filtering recording rules with local dependents are not paused; rules without are
Reload / precedence SIGHUP with changed ratios takes effect; feature flag absent + config block present emits the warning (P1-11)
Deterministic loop test drive the controller with a synthetic pressure trace (a []uint64 of in-use readings) instead of real memory. Assert transition counts against known-answer traces: a sawtooth crossing the threshold N times must not produce more than K transitions. This is the only cheap way to test hysteresis, and hysteresis is still unspecified in the doc

That last one deserves emphasis: making the controller read its input through an interface
that a test can drive turns every timing-dependent question into a deterministic unit test.
Worth the small amount of indirection.

Tier 1 — single container, real binary, real cgroup — ~20 min per point

This is where the design lives or dies, and almost nobody builds this tier. One Prometheus
in a container with a real --memory limit, one avalanche
as the target, a script that steps a parameter and records the outcome. No Kubernetes.

Avalanche has exactly the knobs these scenarios need (verified in metricsgen/serve.go):

  • --series-operation-mode=spike with --spike-multiplier — acute burst, returns to baseline
  • --series-operation-mode=gradual-change with --min/--max-series-count, --series-change-rate — sustained growth
  • --series-interval — series_id churn (retained cardinality)
  • --value-interval — value churn only (no new series)
  • --series-count, --label-count, --*-metric-count — precise baseline sizing

The critical property: cardinality is set by a number, not by scheduling pods. That makes
the release boundary sweepable, which prombench cannot do at any reasonable cost.

Tier 2 — prombench — hours, GKE

For the things that need scale and realism: steady-state regression, real Kubernetes SD churn,
query load, remote write. Section 6 covers what has to change in the manifests, because
stock prombench cannot exercise this feature at all — see below.


4. Scenarios

Two families, and the distinction between them is the whole experiment:

  • S-TRANSIENT — the burst is allocation that does not survive the scrape: large bodies,
    parse churn, query materialization. Cardinality flat.
  • S-RETAINED — the burst is new series entering the head. Cardinality rises and stays.
ID Scenario Tier Load Tests
E-A Release boundary sweep 1 avalanche fixed cardinality, stepped across live_heap/GOMEMLIMIT ∈ [0.15, 0.60] in 0.05 steps; at each step inject a transient burst large enough to engage C3. The headline experiment. Produces a release/latch curve. Predicted cliff at 0.35
E-B GOGC dependence of the boundary 1 E-A repeated at GOGC ∈ {50, 100, 200} Confirms the boundary moves as 1/(1+GOGC/100) — if it does, the formula is right and becomes documentable operator guidance
E-C Transient burst, in-window 1 avalanche spike mode, baseline live heap held below the boundary C2, C3. The case the design is supposed to handle. Engage → relieve → release
E-D Retained burst 1 avalanche gradual-change, ramping cardinality C3 negative case. Expected to latch. Measures how long until it does and whether it fails loudly
E-E Sensor fidelity 1 any of the above, plus 1 Hz sampling of in-use vs container_memory_working_set_bytes vs RSS C1. See below — this one has a direct config consequence
E-F Per-mitigation attribution 1 forced-state hook, one enforcement toggle at a time C2. How much in-use does each of the 7 mitigations actually free?
E-G Steady-state false positive 2 stock prombench load, limiter on, ≥ 4 h C4. engaged_seconds_total must be exactly 0
E-H A/A calibration 2 same build in both arms Establishes the harness noise floor. Without it no Tier-2 delta means anything
E-I Overhead 2 limiter-on vs limiter-off, healthy Guardrail. CPU, p99 query latency, allocation rate
E-J OOM survival, head-to-head 2 burst tuned so the baseline arm OOMs ≥ 80% of repeats C5. The end-to-end claim
E-K Crashloop 2 sustained overload past the point of recovery Does the limiter break the loop, or does it brown out silently forever?

E-E deserves its own paragraph

Go's /memory/classes/total does not include mmap'd head chunks or block files. The
kubelet OOM-kills on container_memory_working_set_bytes, which does (until the kernel
reclaims them). So the limiter's signal systematically under-reads the quantity that
actually kills the process, by roughly the size of the mmap'd chunk set.

That is not a bug — bounding anonymous memory and letting the kernel reclaim page cache is the
correct policy, and it's the right call in the design. But the size of the gap is exactly the
margin hard_limit_ratio has to leave, and right now 0.85 is a number with no derivation
behind it. E-E converts it into one. If the gap turns out to be 10% of the budget on a
realistic head, 0.85 is defensible; if it's 25%, it isn't.


5. Success metrics

Split into three groups. Guardrails are not tiebreakers — a guardrail failure is a failure.

Primary

Metric Definition Target Hard fail
Latch rate fraction of engagements not released within 5 min of burst end, in-window (E-A/E-C) 0% > 5%
Recovery time burst end → active{limit="hard"} = 0 and all targets up == 1 ≤ 60 s > 5 min (the lookback — past this, "delayed" becomes "gap")
False-positive engagement engaged_seconds_total over ≥ 4 h steady state (E-G) exactly 0 any nonzero
OOM avoidance OOM kills in the limiter arm, over N ≥ 5 repeats of E-J 0 any
Scenario validity gate OOM kills in the baseline arm of E-J ≥ 80% of repeats < 80% ⇒ the scenario is invalid, not the feature

That last row is not a formality. A burst that doesn't reliably kill baseline Prometheus
proves nothing about a build that survives it, and it is the easiest way for this whole
exercise to produce a false pass.

Net-benefit (C5) — the comparison the proposal has not yet framed

This needs stating plainly, because the honest baseline is better than the proposal implies:
an OOM kill loses very little data. The head is in the WAL; replay recovers it. What an OOM
costs is availability — restart plus replay, during which nothing is scraped, queried, or
evaluated. What the limiter costs is data, permanently, but the server stays up and
queryable throughout.

So the trade is total-outage-for-D_oom versus partial-outage-for-D_mitigation, and it is not
self-evidently a good one. Measure both sides:

Metric Definition
data_completeness samples stored ÷ samples offered, over the scenario window, measured against a third control arm running with enough memory never to hit pressure
query_availability fraction of 1 Hz canary-query probes returning correct results within SLO
alerting_fidelity did any alert that should have fired, fail to fire — or worse, silently resolve (the P0-5 failure mode)
time_to_full_service scenario start → all three of the above back at control-arm levels

The three-arm setup (baseline / limiter / control) is what makes data_completeness
meaningful. Two arms can only tell you which is worse.

The feature earns its complexity if it strictly dominates baseline on query_availability
and time_to_full_service without losing more than a few percent of data_completeness.
If
it trades a lot of data for a little availability, that's a finding worth having before Stage 1
merges, not after.

Guardrails

Metric Threshold
Limiter CPU overhead ≤ 0.5% of one core at 10 Hz (check_interval: 100ms)
p99 query latency delta, healthy within the A/A noise floor from E-H
Allocation rate delta, healthy within A/A noise floor
Transitions ≤ 2/min under sustained pressure (flapping)
Skipped-scrape append cost O(1), from Tier 0

Every Tier-2 delta gets compared against E-H, never against zero. Prombench is noisy enough
that a 5% memory difference between two identical builds is unremarkable, and a plan that
doesn't measure that first will read noise as signal.


6. What prombench needs (it cannot run this today)

Concrete blockers, from the manifests at prometheus/test-infra@master:

  1. No memory limit on the Prometheus containers.
    prombench/manifests/prombench/benchmark/5_prometheus-test-pr_deployment.yaml sets
    requests: {cpu: 2, memory: 20Gi} and limits: {cpu: 4} — no limits.memory. So
    memlimit.FromCgroup finds no limit, ApplyFallback drops to FromSystem, and GOMEMLIMIT
    resolves against the node's full 52 GiB (n1-highmem-8). Thresholds land at ~33 GiB and
    ~40 GiB. The limiter can never engage, and the pod can never be cgroup-OOM-killed.
    Both arms need a real limits.memory.
  2. No rules. The generated prometheus.yml has global, scrape_configs, remote_write
    — no rule_files. pause_recording_rules is untestable, including the dependency-filtering
    logic, which is the subtlest part of the design.
  3. No OTLP, no remote-write receiver, no remote read. Container args are --web.external-url,
    --storage.tsdb.path, --config.file, --log.level. Four of the seven enforcement toggles
    have no traffic to reject.
  4. Only two arms. The three-arm design in §5 needs a third Prometheus and a third node in
    the prometheus-{PR} pool (initialnodecount: 2 today; podAntiAffinity is
    requiredDuringScheduling, so it's one node per instance).

The good news: none of this requires changing upstream test-infra. /prombench supports
--bench.version=@<sha> and --bench.directory=<dir> (prometheus#41; see
maybe_pull_custom_version in prombench/Makefile). Fork test-infra, add
manifests/memlimiter/ with the four fixes, and trigger with:

/prombench v3.13.0 --bench.version=@<sha> --bench.directory=manifests/memlimiter

And the burst mechanism already exists — prometheus-loadgen-scaler cycles fake-webserver
between LOADGEN_SCALE_UP_REPLICAS (default 10) and 1 on a 15 m interval. With 50 job_names
scraping a 5-port service, each replica is 250 targets, so the stock cycle is already a
250 ↔ 2500 target oscillation. That is a retained burst — the S-RETAINED family — which
makes stock prombench a good E-D/E-K harness once it has a memory limit, and a poor E-C one.
E-C needs a transient burst, which means query load or large bodies, not more pods.


7. Kill criteria

What would make me say the design needs rework rather than a fix:

  • E-A shows a latch boundary anywhere near 0.35 × GOMEMLIMIT. Then the feature only
    functions on servers running below ~31% of their container limit — servers that were never
    at OOM risk. The design would need either a reclamation actuator (which was considered and
    correctly dropped), a release threshold decoupled from the engage threshold and placed below
    the boundary, or an explicit statement that it only handles transient bursts.
  • E-D latches without failing loudly. A brownout that meta-monitoring can't see is worse
    than the OOM it replaced. This is survivable with a saturation metric and documentation, but
    it must be a deliberate choice, visible in the doc.
  • E-E shows in-use under-reading working set by more than the hard-limit headroom
    (0.15 × GOMEMLIMIT at defaults). Then the sensor cannot protect against the kill it's aimed
    at, and either the margin or the signal has to change.
  • E-J: the limiter arm OOMs anyway, or E-K shows it merely converts a fast crashloop into
    a slow brownout.
  • C5 comes out net-negative — significant data_completeness loss without a matching
    query_availability win.

None of these are predictions except the first, which is a prediction with evidence behind it.


8. Sequencing

Tier 0  ──►  E-A, E-B  ──►  E-C, E-D, E-E, E-F  ──►  E-H (A/A)  ──►  E-G, E-I  ──►  E-J, E-K
 CI          the gate         Tier 1 body            calibrate       Tier 2 healthy    Tier 2 stress

E-A and E-B are the gate. They are a day of work on one machine, they need no cluster, and
they answer the question the rest of the plan is downstream of. If the boundary is where E7
predicts, the operating-envelope discussion has to happen before Stage 1 merges — and it's
much better to have it with a measurement than with my synthetic-allocator extrapolation.


9. Doc consequences regardless of outcome

Four things in "How We Test and Verify" as written:


Limits of this plan

  • The 0.35 boundary comes from a synthetic allocator, not Prometheus. E-A exists to check it,
    and it may well be wrong — one of E7's twenty rows already missed.
  • I have not verified that Content-Length predicts a scrape's peak memory once decompression
    and parse amplification are counted. If gradual degradation or byte-budget admission comes
    back, that needs its own experiment on the real scrape path.
  • Thresholds in §5 marked "target" are judgment calls, not derived from data. The hard-fail
    column is the load-bearing one; the targets are there to give the implementer something to
    aim at and should be revised once E-H establishes what the harness can actually resolve.
  • Tier 2 costs real money. Everything above it is designed so that a failed Tier 1 stops the
    spend.

Generated by Claude Code

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