Skip to content

Commit 5d48fde

Browse files
Krishnachaitanyakcevan-bradleycodeboten
authored
[processor/memorylimiter] Fix degenerate GC loop when exporter has problems (#15053)
#### Description Fixes the degenerate force-GC loop when memory is held by live references (e.g., exporter queues during a downstream outage). Forced GC backs off exponentially when ineffective and resets on recovery; the cap is exposed as `max_gc_interval_when_{soft,hard}_limited` (default `30s`, `0` disables). #### Link to tracking issue Fixes #4981 #### Testing Added regression test for #4981; existing memorylimiter tests pass. #### Documentation README updated; chloggen entry under `.chloggen/`. --------- Signed-off-by: Kc Balusu <kcbalusu@meta.com> Co-authored-by: Evan Bradley <11745660+evan-bradley@users.noreply.github.com> Co-authored-by: Alex Boten <223565+codeboten@users.noreply.github.com>
1 parent 1bca59a commit 5d48fde

6 files changed

Lines changed: 809 additions & 8 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
change_type: bug_fix
2+
3+
component: processor/memory_limiter
4+
5+
note: Fix degenerate collector performance when exporter has problems causing permanent CPU-burning GC loop
6+
7+
issues: [4981]
8+
9+
subtext: |
10+
Forced GC runs now use exponential backoff when deemed ineffective
11+
(still above soft limit and less than 5% reclaimed) to avoid preventing
12+
recovery by overloading CPU with excessive GC runs. The cap on the
13+
backoff interval is exposed via `max_gc_interval_when_soft_limited` and
14+
`max_gc_interval_when_hard_limited` (both default `30s`); set either to
15+
`0` to disable backoff on that path.
16+
17+
change_logs: [user]

internal/memorylimiter/config.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import (
1313
var (
1414
errCheckIntervalOutOfRange = errors.New("'check_interval' must be greater than zero")
1515
errInconsistentGCMinInterval = errors.New("'min_gc_interval_when_soft_limited' should be larger than 'min_gc_interval_when_hard_limited'")
16+
errInconsistentGCMaxInterval = errors.New("'max_gc_interval_when_soft_limited' should be larger than or equal to 'max_gc_interval_when_hard_limited' (when both are set)")
17+
errInconsistentGCMaxSoftInterval = errors.New("'max_gc_interval_when_soft_limited' must be greater than or equal to 'min_gc_interval_when_soft_limited' (or 0 to disable)")
18+
errInconsistentGCMaxHardInterval = errors.New("'max_gc_interval_when_hard_limited' must be greater than or equal to 'min_gc_interval_when_hard_limited' (or 0 to disable)")
19+
errNegativeGCMaxSoftInterval = errors.New("'max_gc_interval_when_soft_limited' must be non-negative")
20+
errNegativeGCMaxHardInterval = errors.New("'max_gc_interval_when_hard_limited' must be non-negative")
1621
errLimitOutOfRange = errors.New("'limit_mib' or 'limit_percentage' must be greater than zero")
1722
errSpikeLimitOutOfRange = errors.New("'spike_limit_mib' must be smaller than 'limit_mib'")
1823
errSpikeLimitPercentageOutOfRange = errors.New("'spike_limit_percentage' must be smaller than 'limit_percentage'")
@@ -37,6 +42,22 @@ type Config struct {
3742
// GCs is a CPU-heavy operation and executing it too frequently may affect the recovery capabilities of the collector.
3843
MinGCIntervalWhenHardLimited time.Duration `mapstructure:"min_gc_interval_when_hard_limited"`
3944

45+
// MaxGCIntervalWhenSoftLimited caps the exponential backoff between forced
46+
// GC calls in soft-limited mode. When a forced GC fails to reclaim memory
47+
// (e.g., held by live references in exporter queues during a downstream
48+
// outage), the interval doubles starting from min_gc_interval_when_soft_limited
49+
// (or 95% of check_interval, whichever is larger) up to this cap. It resets
50+
// to zero whenever a GC is effective or memory drops on its own.
51+
// Set to 0 to disable the exponential backoff on this path; the interval
52+
// will not grow beyond the configured min.
53+
MaxGCIntervalWhenSoftLimited time.Duration `mapstructure:"max_gc_interval_when_soft_limited"`
54+
55+
// MaxGCIntervalWhenHardLimited caps the exponential backoff between forced
56+
// GC calls in hard-limited mode. Same semantics as
57+
// MaxGCIntervalWhenSoftLimited but applies to the hard-limit path.
58+
// Set to 0 to disable the exponential backoff on this path.
59+
MaxGCIntervalWhenHardLimited time.Duration `mapstructure:"max_gc_interval_when_hard_limited"`
60+
4061
// MemoryLimitMiB is the maximum amount of memory, in MiB, targeted to be
4162
// allocated by the process.
4263
MemoryLimitMiB uint32 `mapstructure:"limit_mib"`
@@ -59,6 +80,8 @@ var _ component.Config = (*Config)(nil)
5980
func NewDefaultConfig() *Config {
6081
return &Config{
6182
MinGCIntervalWhenSoftLimited: 10 * time.Second,
83+
MaxGCIntervalWhenSoftLimited: 30 * time.Second,
84+
MaxGCIntervalWhenHardLimited: 30 * time.Second,
6285
}
6386
}
6487

@@ -70,6 +93,26 @@ func (cfg *Config) Validate() error {
7093
if cfg.MinGCIntervalWhenSoftLimited < cfg.MinGCIntervalWhenHardLimited {
7194
return errInconsistentGCMinInterval
7295
}
96+
if cfg.MaxGCIntervalWhenSoftLimited < 0 {
97+
return errNegativeGCMaxSoftInterval
98+
}
99+
if cfg.MaxGCIntervalWhenHardLimited < 0 {
100+
return errNegativeGCMaxHardInterval
101+
}
102+
if cfg.MaxGCIntervalWhenSoftLimited > 0 && cfg.MaxGCIntervalWhenSoftLimited < cfg.MinGCIntervalWhenSoftLimited {
103+
return errInconsistentGCMaxSoftInterval
104+
}
105+
if cfg.MaxGCIntervalWhenHardLimited > 0 && cfg.MaxGCIntervalWhenHardLimited < cfg.MinGCIntervalWhenHardLimited {
106+
return errInconsistentGCMaxHardInterval
107+
}
108+
// Mirror the MinSoft >= MinHard invariant for the max pair: when both
109+
// caps are enabled, the soft cap should not be tighter than the hard cap
110+
// (it would mean a smaller backoff ceiling for the less-urgent path,
111+
// which is almost always a typo).
112+
if cfg.MaxGCIntervalWhenSoftLimited > 0 && cfg.MaxGCIntervalWhenHardLimited > 0 &&
113+
cfg.MaxGCIntervalWhenSoftLimited < cfg.MaxGCIntervalWhenHardLimited {
114+
return errInconsistentGCMaxInterval
115+
}
73116
if cfg.MemoryLimitMiB == 0 && cfg.MemoryLimitPercentage == 0 {
74117
return errLimitOutOfRange
75118
}

internal/memorylimiter/config_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,83 @@ func TestConfigValidate(t *testing.T) {
9595
},
9696
err: errInconsistentGCMinInterval,
9797
},
98+
{
99+
name: "negative max soft interval",
100+
cfg: &Config{
101+
CheckInterval: 100 * time.Millisecond,
102+
MaxGCIntervalWhenSoftLimited: -1 * time.Second,
103+
MemoryLimitMiB: 5722,
104+
MemorySpikeLimitMiB: 1907,
105+
},
106+
err: errNegativeGCMaxSoftInterval,
107+
},
108+
{
109+
name: "negative max hard interval",
110+
cfg: &Config{
111+
CheckInterval: 100 * time.Millisecond,
112+
MaxGCIntervalWhenHardLimited: -1 * time.Second,
113+
MemoryLimitMiB: 5722,
114+
MemorySpikeLimitMiB: 1907,
115+
},
116+
err: errNegativeGCMaxHardInterval,
117+
},
118+
{
119+
name: "max soft less than min soft",
120+
cfg: &Config{
121+
CheckInterval: 100 * time.Millisecond,
122+
MinGCIntervalWhenSoftLimited: 10 * time.Second,
123+
MaxGCIntervalWhenSoftLimited: 5 * time.Second,
124+
MaxGCIntervalWhenHardLimited: 5 * time.Second,
125+
MemoryLimitMiB: 5722,
126+
MemorySpikeLimitMiB: 1907,
127+
},
128+
err: errInconsistentGCMaxSoftInterval,
129+
},
130+
{
131+
name: "max hard less than min hard",
132+
cfg: &Config{
133+
CheckInterval: 100 * time.Millisecond,
134+
MinGCIntervalWhenHardLimited: 20 * time.Second,
135+
MinGCIntervalWhenSoftLimited: 20 * time.Second,
136+
MaxGCIntervalWhenHardLimited: 5 * time.Second,
137+
MemoryLimitMiB: 5722,
138+
MemorySpikeLimitMiB: 1907,
139+
},
140+
err: errInconsistentGCMaxHardInterval,
141+
},
142+
{
143+
name: "max soft less than max hard",
144+
cfg: &Config{
145+
CheckInterval: 100 * time.Millisecond,
146+
MaxGCIntervalWhenSoftLimited: 5 * time.Second,
147+
MaxGCIntervalWhenHardLimited: 30 * time.Second,
148+
MemoryLimitMiB: 5722,
149+
MemorySpikeLimitMiB: 1907,
150+
},
151+
err: errInconsistentGCMaxInterval,
152+
},
153+
{
154+
name: "valid max gc intervals: both zero (disabled)",
155+
cfg: &Config{
156+
CheckInterval: 100 * time.Millisecond,
157+
MaxGCIntervalWhenSoftLimited: 0,
158+
MaxGCIntervalWhenHardLimited: 0,
159+
MemoryLimitMiB: 5722,
160+
MemorySpikeLimitMiB: 1907,
161+
},
162+
err: nil,
163+
},
164+
{
165+
name: "valid max gc intervals: only hard disabled",
166+
cfg: &Config{
167+
CheckInterval: 100 * time.Millisecond,
168+
MaxGCIntervalWhenSoftLimited: 30 * time.Second,
169+
MaxGCIntervalWhenHardLimited: 0,
170+
MemoryLimitMiB: 5722,
171+
MemorySpikeLimitMiB: 1907,
172+
},
173+
err: nil,
174+
},
98175
}
99176
for _, tt := range tests {
100177
t.Run(tt.name, func(t *testing.T) {

internal/memorylimiter/memorylimiter.go

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import (
2121

2222
const (
2323
mibBytes = 1024 * 1024
24+
25+
// gcEffectivenessThreshold is the minimum percentage of memory that must be
26+
// reclaimed by a forced GC for it to be considered effective. If GC reclaims
27+
// less than this percentage, the GC interval is backed off to avoid burning
28+
// CPU on fruitless GC cycles.
29+
gcEffectivenessThreshold = 5
2430
)
2531

2632
var (
@@ -46,13 +52,42 @@ type MemoryLimiter struct {
4652

4753
minGCIntervalWhenSoftLimited time.Duration
4854
minGCIntervalWhenHardLimited time.Duration
55+
maxGCIntervalWhenSoftLimited time.Duration
56+
maxGCIntervalWhenHardLimited time.Duration
4957
lastGCDone time.Time
5058

5159
// The functions to read the mem values and run GC are set as a reference to help with
5260
// testing different values.
5361
readMemStatsFn func(m *runtime.MemStats)
5462
runGCFn func()
5563

64+
// currentSoftGCInterval and currentHardGCInterval are per-path dynamic GC
65+
// intervals that grow when forced GCs on that path fail to reclaim memory
66+
// (held by live references in exporter queues during downstream outages).
67+
// Each resets to zero whenever a GC on its path is effective or memory
68+
// drops on its own, and doubles after each ineffective GC on its path up
69+
// to the corresponding maxGCIntervalWhen(Hard|Soft)Limited cap.
70+
//
71+
// The floor for doubling is max(minGCIntervalWhen(Hard|Soft)Limited,
72+
// memCheckWait*0.95). When the configured min is below memCheckWait the
73+
// floor lands just under memCheckWait — small enough that the next-tick
74+
// gate (time.Since(lastGCDone) > floor) reliably clears, since lastGCDone
75+
// is stamped after the forced GC completes and the next ticker fire may
76+
// land slightly under a full memCheckWait afterward (the GC's wall-clock
77+
// cost and timer slop can eat into the interval). Using exactly
78+
// memCheckWait would let the gate fail on those ticks and reduce forced
79+
// GC to every other tick.
80+
//
81+
// State is kept per-path so that disabling backoff on one path (max=0)
82+
// does not influence gating on the other path.
83+
currentSoftGCInterval time.Duration
84+
currentHardGCInterval time.Duration
85+
86+
// lastStats is the most recent runtime.MemStats observation. Comparing
87+
// current Alloc against lastStats.Alloc is how checkLimitAndBackoff
88+
// classifies a forced GC as effective when memory drops by at least 5%.
89+
lastStats *runtime.MemStats
90+
5691
// Fields used for logging.
5792
logger *zap.Logger
5893

@@ -82,6 +117,9 @@ func NewMemoryLimiter(cfg *Config, logger *zap.Logger) (*MemoryLimiter, error) {
82117
ticker: time.NewTicker(cfg.CheckInterval),
83118
minGCIntervalWhenSoftLimited: cfg.MinGCIntervalWhenSoftLimited,
84119
minGCIntervalWhenHardLimited: cfg.MinGCIntervalWhenHardLimited,
120+
maxGCIntervalWhenSoftLimited: cfg.MaxGCIntervalWhenSoftLimited,
121+
maxGCIntervalWhenHardLimited: cfg.MaxGCIntervalWhenHardLimited,
122+
lastStats: &runtime.MemStats{},
85123
lastGCDone: time.Now(),
86124
readMemStatsFn: ReadMemStatsFn,
87125
runGCFn: runtime.GC,
@@ -170,14 +208,62 @@ func (ml *MemoryLimiter) doGCandReadMemStats() *runtime.MemStats {
170208
return ms
171209
}
172210

211+
// checkLimitAndBackoff returns whether memory is above the soft limit. When
212+
// didGC is true (called immediately after a forced GC), it also advances both
213+
// per-path backoff intervals in lockstep so the inactive path is not caught
214+
// flat-footed when memory pressure shifts between soft and hard. A GC is
215+
// effective if it drops memory below the soft limit or reclaims at least
216+
// gcEffectivenessThreshold%; an effective observation resets both intervals
217+
// to zero because memory really did come down (a global signal).
218+
func (ml *MemoryLimiter) checkLimitAndBackoff(ms *runtime.MemStats, didGC bool) (aboveSoftLimit bool) {
219+
aboveSoftLimit = ml.usageChecker.aboveSoftLimit(ms)
220+
gcWasEffective := !aboveSoftLimit ||
221+
ms.Alloc <= ml.lastStats.Alloc*(100-gcEffectivenessThreshold)/100
222+
ml.lastStats = ms
223+
if gcWasEffective {
224+
ml.currentSoftGCInterval = 0
225+
ml.currentHardGCInterval = 0
226+
return aboveSoftLimit
227+
}
228+
if !didGC {
229+
return aboveSoftLimit
230+
}
231+
ml.currentSoftGCInterval = ml.nextBackoffInterval(ml.currentSoftGCInterval, ml.minGCIntervalWhenSoftLimited, ml.maxGCIntervalWhenSoftLimited)
232+
ml.currentHardGCInterval = ml.nextBackoffInterval(ml.currentHardGCInterval, ml.minGCIntervalWhenHardLimited, ml.maxGCIntervalWhenHardLimited)
233+
ml.logger.Warn("Forced GC did not reclaim enough memory. Will back off GC frequency to preserve CPU for recovery.",
234+
zap.Duration("next_soft_gc_interval", ml.currentSoftGCInterval),
235+
zap.Duration("next_hard_gc_interval", ml.currentHardGCInterval),
236+
memstatToZapField(ms))
237+
return aboveSoftLimit
238+
}
239+
240+
// nextBackoffInterval returns the new backoff interval for one path after an
241+
// ineffective forced GC. When configMax is 0 the path's backoff is disabled
242+
// and the interval is pinned to configMin (so the path keeps GCing every
243+
// configMin, or every check interval if configMin is also 0). Otherwise the
244+
// interval starts at max(configMin, 0.95*memCheckWait) — see the comment on
245+
// currentSoftGCInterval for why 0.95 — doubles on each ineffective GC, and
246+
// is capped at max(configMax, configMin).
247+
func (ml *MemoryLimiter) nextBackoffInterval(current, configMin, configMax time.Duration) time.Duration {
248+
if configMax == 0 {
249+
return configMin
250+
}
251+
minInterval := max(configMin, time.Duration(float64(ml.memCheckWait)*0.95))
252+
maxInterval := max(configMax, configMin)
253+
return min(max(current, minInterval)*2, maxInterval)
254+
}
255+
173256
// CheckMemLimits inspects current memory usage against threshold and toggles mustRefuse when threshold is exceeded
174257
func (ml *MemoryLimiter) CheckMemLimits() {
175258
ms := ml.readMemStats()
176259

177260
ml.logger.Debug("Currently used memory.", memstatToZapField(ms))
178261

179-
// Check if we are below the soft limit.
180-
aboveSoftLimit := ml.usageChecker.aboveSoftLimit(ms)
262+
// Top-of-tick classification. didGC=false because no forced GC has run
263+
// yet this tick. If memory dropped on its own (Go runtime GC, queue
264+
// drained), this resets both per-path currentGCIntervals so we won't
265+
// suppress the next forced GC unnecessarily.
266+
aboveSoftLimit := ml.checkLimitAndBackoff(ms, false)
181267
if !aboveSoftLimit {
182268
if ml.mustRefuse.Load() {
183269
// Was previously refusing but enough memory is available now, no need to limit.
@@ -189,22 +275,24 @@ func (ml *MemoryLimiter) CheckMemLimits() {
189275
}
190276

191277
if ml.usageChecker.aboveHardLimit(ms) {
278+
gateInterval := max(ml.minGCIntervalWhenHardLimited, ml.currentHardGCInterval)
192279
// We are above hard limit, do a GC if it wasn't done recently and see if
193280
// it brings memory usage below the soft limit.
194-
if time.Since(ml.lastGCDone) > ml.minGCIntervalWhenHardLimited {
281+
if time.Since(ml.lastGCDone) > gateInterval {
195282
ml.logger.Warn("Memory usage is above hard limit. Forcing a GC.", memstatToZapField(ms))
196283
ms = ml.doGCandReadMemStats()
197284
// Check the limit again to see if GC helped.
198-
aboveSoftLimit = ml.usageChecker.aboveSoftLimit(ms)
285+
aboveSoftLimit = ml.checkLimitAndBackoff(ms, true)
199286
}
200287
} else {
288+
gateInterval := max(ml.minGCIntervalWhenSoftLimited, ml.currentSoftGCInterval)
201289
// We are above soft limit, do a GC if it wasn't done recently and see if
202290
// it brings memory usage below the soft limit.
203-
if time.Since(ml.lastGCDone) > ml.minGCIntervalWhenSoftLimited {
291+
if time.Since(ml.lastGCDone) > gateInterval {
204292
ml.logger.Info("Memory usage is above soft limit. Forcing a GC.", memstatToZapField(ms))
205293
ms = ml.doGCandReadMemStats()
206294
// Check the limit again to see if GC helped.
207-
aboveSoftLimit = ml.usageChecker.aboveSoftLimit(ms)
295+
aboveSoftLimit = ml.checkLimitAndBackoff(ms, true)
208296
}
209297
}
210298

0 commit comments

Comments
 (0)