Skip to content

Commit fb58ab8

Browse files
authored
fix: WAL replay hang when flush doesn't progress (#21176)
1 parent c1c3432 commit fb58ab8

2 files changed

Lines changed: 118 additions & 5 deletions

File tree

‎pkg/ingester/replay_controller.go‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ingester
22

33
import (
4+
"fmt"
5+
46
"github.com/dustin/go-humanize"
57
"github.com/go-kit/log/level"
68
"go.uber.org/atomic"
@@ -45,9 +47,10 @@ type replayController struct {
4547
// > 64-bit alignment of 64-bit words accessed atomically. The first word in a
4648
// > variable or in an allocated struct, array, or slice can be relied upon to
4749
// > be 64-bit aligned.
48-
currentBytes atomic.Int64
49-
cfg WALConfig
50-
metrics *ingesterMetrics
50+
currentBytes atomic.Int64
51+
totalSubtracted atomic.Int64 // monotonically increasing; used to detect flush no-progress without being affected by concurrent Add calls
52+
cfg WALConfig
53+
metrics *ingesterMetrics
5154

5255
flusher Flusher
5356
flushSF singleflight.Group
@@ -68,8 +71,8 @@ func (c *replayController) Add(x int64) {
6871
}
6972

7073
func (c *replayController) Sub(x int64) {
74+
c.totalSubtracted.Add(x)
7175
c.metrics.setRecoveryBytesInUse(c.currentBytes.Sub(x))
72-
7376
}
7477

7578
func (c *replayController) Cur() int {
@@ -107,10 +110,21 @@ func (c *replayController) flush() {
107110
// It will call the function as long as there is expected room before the memory cap and will then flush data intermittently
108111
// when needed.
109112
func (c *replayController) WithBackPressure(fn func() error) error {
113+
ceiling := int(c.cfg.ReplayMemoryCeiling) * 9 / 10
114+
if ceiling <= 0 {
115+
return fn()
116+
}
110117
// use 90% as a threshold since we'll be adding to it.
111-
for c.Cur() > int(c.cfg.ReplayMemoryCeiling)*9/10 {
118+
for c.Cur() > ceiling {
119+
subtractedBefore := c.totalSubtracted.Load()
112120
// too much backpressure, flush
113121
c.Flush()
122+
if c.totalSubtracted.Load() == subtractedBefore {
123+
return fmt.Errorf("WAL replay flush made no progress: %s in use, ceiling %s; cannot recover",
124+
humanize.Bytes(uint64(c.currentBytes.Load())),
125+
humanize.Bytes(uint64(ceiling)),
126+
)
127+
}
114128
}
115129

116130
return fn()

‎pkg/ingester/replay_controller_test.go‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,105 @@ func TestReplayController(t *testing.T) {
7878
require.Equal(t, expected, ops)
7979
}
8080

81+
// Test that WithBackPressure skips backpressure entirely when ReplayMemoryCeiling is zero.
82+
func TestReplayControllerZeroCeiling(t *testing.T) {
83+
var flushed bool
84+
flusher := newDumbFlusher(func() {
85+
flushed = true
86+
})
87+
rc := newReplayController(nilMetrics(), WALConfig{ReplayMemoryCeiling: 0}, flusher)
88+
89+
// Add a large amount of data — with a zero ceiling this should not trigger flushing.
90+
rc.Add(1 << 30) // 1GB
91+
92+
err := rc.WithBackPressure(func() error {
93+
rc.Add(1 << 30)
94+
return nil
95+
})
96+
97+
require.NoError(t, err)
98+
require.False(t, flushed, "flusher should not be called when ReplayMemoryCeiling is zero")
99+
}
100+
101+
// Test that WithBackPressure returns an error when a flush makes no progress reducing memory.
102+
func TestReplayControllerNoProgressFlush(t *testing.T) {
103+
flusher := newDumbFlusher(nil) // no-op: never calls rc.Sub, so bytes never decrease
104+
rc := newReplayController(nilMetrics(), WALConfig{ReplayMemoryCeiling: 100}, flusher)
105+
106+
// Exceed 90% threshold (ceiling = 90).
107+
rc.Add(95)
108+
109+
err := rc.WithBackPressure(func() error {
110+
return nil
111+
})
112+
113+
require.Error(t, err)
114+
require.Contains(t, err.Error(), "WAL replay flush made no progress")
115+
}
116+
117+
// Test that WithBackPressure returns an error when flush makes partial progress but then stalls,
118+
// i.e. subsequent flushes no longer reduce memory below the ceiling.
119+
func TestReplayControllerPartialProgressFlush(t *testing.T) {
120+
var rc *replayController
121+
flushCount := 0
122+
flusher := newDumbFlusher(func() {
123+
flushCount++
124+
if flushCount == 1 {
125+
// First flush makes partial progress: drops from 200 to 95, still above 90% ceiling.
126+
rc.Sub(105)
127+
}
128+
// Subsequent flushes are no-ops: bytes stay at 95, no further progress.
129+
})
130+
rc = newReplayController(nilMetrics(), WALConfig{ReplayMemoryCeiling: 100}, flusher)
131+
132+
rc.Add(200) // well above the 90-byte ceiling
133+
134+
err := rc.WithBackPressure(func() error {
135+
return nil
136+
})
137+
138+
require.Error(t, err)
139+
require.Contains(t, err.Error(), "WAL replay flush made no progress")
140+
require.Equal(t, 2, flushCount, "should have flushed twice: once with progress, once stalled")
141+
}
142+
143+
// Test that WithBackPressure does not spuriously error when concurrent workers refill currentBytes
144+
// during a flush. The flush made real progress (Sub was called), so no error should be returned
145+
// even though currentBytes ends up higher than the pre-flush snapshot due to concurrent Add calls.
146+
//
147+
// Sequence:
148+
// 1. bytes=95 > ceiling=90, enter loop; old code snapshots before=95
149+
// 2. Flush runs: Sub(80) → bytes=15; concurrent goroutine adds 85 → bytes=100
150+
// 3. Old check: currentBytes(100) >= before(95) → spurious error
151+
// 4. New check: totalSubtracted increased → no error, loop continues and drains on next flush
152+
func TestReplayControllerNoSpuriousErrorOnConcurrentAdd(t *testing.T) {
153+
var rc *replayController
154+
var once sync.Once
155+
flushed := make(chan struct{})
156+
flusher := newDumbFlusher(func() {
157+
rc.Sub(80) // genuine progress each flush
158+
once.Do(func() { close(flushed) }) // signal the concurrent goroutine only on first flush
159+
})
160+
rc = newReplayController(nilMetrics(), WALConfig{ReplayMemoryCeiling: 100}, flusher)
161+
rc.Add(95) // above 90% ceiling
162+
163+
var wg sync.WaitGroup
164+
wg.Add(1)
165+
go func() {
166+
defer wg.Done()
167+
// Simulate a concurrent worker adding bytes while the first flush is running,
168+
// pushing currentBytes back above the pre-flush snapshot (100 > before=95).
169+
// The loop will flush again and drain normally; no error should be returned.
170+
<-flushed
171+
rc.Add(85) // 15 + 85 = 100, above the original before=95
172+
}()
173+
174+
err := rc.WithBackPressure(func() error { return nil })
175+
wg.Wait()
176+
177+
require.NoError(t, err, "concurrent Add during flush should not cause a spurious no-progress error")
178+
}
179+
81180
// Test to ensure only one flush happens at a time when multiple goroutines call WithBackPressure
82181
func TestReplayControllerConcurrentFlushes(t *testing.T) {
83182
t.Run("multiple goroutines wait for single flush", func(t *testing.T) {

0 commit comments

Comments
 (0)