Skip to content

Commit d207e12

Browse files
committed
test that demonstrates liveness failure on leader disk stall
If the leader's disk stalls, we can't replicate logs to followers. But the heartbeat RPCs will still be sent concurrently, and this resets the heartbeat timeout on the followers. This is intentional in order to avoid leadership flapping in the face of temporary disk stalls, but has the side effect of preventing recovery from a more persistent disk stall.
1 parent 0f35be1 commit d207e12

3 files changed

Lines changed: 169 additions & 18 deletions

File tree

raft_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,10 +1018,10 @@ func TestRaft_RestoreSnapshotOnStartup_Monotonic(t *testing.T) {
10181018
conf := inmemConfig(t)
10191019
conf.TrailingLogs = 10
10201020
opts := &MakeClusterOpts{
1021-
Peers: 1,
1022-
Bootstrap: true,
1023-
Conf: conf,
1024-
MonotonicLogs: true,
1021+
Peers: 1,
1022+
Bootstrap: true,
1023+
Conf: conf,
1024+
LogstoreWrapperFunc: NewMockMonotonicLogStore,
10251025
}
10261026
c := MakeClusterCustom(t, opts)
10271027
defer c.Close()
@@ -1446,10 +1446,10 @@ func snapshotAndRestore(t *testing.T, offset uint64, monotonicLogStore bool, res
14461446
var c *cluster
14471447
numPeers := 3
14481448
optsMonotonic := &MakeClusterOpts{
1449-
Peers: numPeers,
1450-
Bootstrap: true,
1451-
Conf: conf,
1452-
MonotonicLogs: true,
1449+
Peers: numPeers,
1450+
Bootstrap: true,
1451+
Conf: conf,
1452+
LogstoreWrapperFunc: NewMockMonotonicLogStore,
14531453
}
14541454
if monotonicLogStore {
14551455
c = MakeClusterCustom(t, optsMonotonic)
@@ -2730,7 +2730,7 @@ func TestRaft_LogStoreIsMonotonic(t *testing.T) {
27302730

27312731
// Now create a new MockMonotonicLogStore using the leader logs and expect
27322732
// it to work.
2733-
log = &MockMonotonicLogStore{s: leader.logs}
2733+
log = NewMockMonotonicLogStore(leader.logs)
27342734
mcast, ok = log.(MonotonicLogStore)
27352735
require.True(t, ok)
27362736
assert.True(t, mcast.IsMonotonic())

replication_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright IBM Corp. 2013, 2026
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package raft
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"sync"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestLeaderHeartbeatsWithStalledDisk(t *testing.T) {
18+
c := MakeClusterCustom(t, &MakeClusterOpts{
19+
Peers: 3,
20+
Bootstrap: true,
21+
LogstoreWrapperFunc: newBlockingLogStore,
22+
})
23+
t.Cleanup(c.Close)
24+
25+
go func() {
26+
i := 0
27+
ticker := time.NewTicker(500 * time.Millisecond)
28+
for {
29+
select {
30+
case <-t.Context().Done():
31+
return
32+
case <-ticker.C:
33+
i++
34+
leader := c.Leader()
35+
fut := leader.Apply(fmt.Appendf([]byte{}, "test%d", i), 0)
36+
if err := fut.Error(); err != nil {
37+
t.Logf("got error trying to write: %v", err)
38+
} else {
39+
t.Logf("write %d ok", i)
40+
}
41+
}
42+
}
43+
}()
44+
45+
t.Log("waiting for 5 seconds before partitioning leader")
46+
time.Sleep(time.Second * 5)
47+
48+
oldLeader := c.Leader()
49+
oldLeaderID := oldLeader.leaderID
50+
oldLeaderTerm := oldLeader.getCurrentTerm()
51+
c.Partition([]ServerAddress{c.Leader().localAddr})
52+
53+
var newLeaderTerm uint64
54+
ctx, cancel := context.WithTimeout(t.Context(), 10*c.propagateTimeout)
55+
t.Cleanup(cancel)
56+
DONE:
57+
for {
58+
select {
59+
case <-ctx.Done():
60+
t.Fatal("election didn't happen!")
61+
default:
62+
newLeader := c.Leader()
63+
if newLeader.leaderID != oldLeaderID {
64+
t.Log("leader has stepped down!")
65+
newLeaderTerm = newLeader.getCurrentTerm()
66+
require.NotEqual(t, newLeaderTerm, oldLeaderTerm)
67+
cancel()
68+
break DONE
69+
}
70+
}
71+
}
72+
73+
require.Len(t, c.WaitForFollowers(1), 1)
74+
75+
t.Log("leader was elected. healing parition")
76+
77+
// reconnect the partitioned node
78+
c.FullyConnect()
79+
time.Sleep(3 * c.propagateTimeout)
80+
81+
leaderTerm := c.Leader().getCurrentTerm()
82+
require.Equal(t, newLeaderTerm, leaderTerm)
83+
84+
t.Log("blocking disk on leader")
85+
86+
leader := c.Leader()
87+
leaderID := leader.leaderID
88+
leaderStore := leader.logs.(*blockingLogStore)
89+
leaderStore.block()
90+
t.Cleanup(leaderStore.unblock)
91+
92+
ctx, cancel = context.WithTimeout(t.Context(), 5*time.Second)
93+
t.Cleanup(cancel)
94+
for {
95+
select {
96+
case <-ctx.Done():
97+
t.Fatal("leader did not step down!")
98+
default:
99+
if c.Leader().leaderID != leaderID {
100+
t.Log("leader has stepped down!")
101+
return
102+
}
103+
}
104+
}
105+
}
106+
107+
// blockingLogStore wraps a LogStore and blocks GetLog calls on demand,
108+
// simulating disk IO stalls.
109+
type blockingLogStore struct {
110+
LogStore
111+
mu sync.Mutex
112+
blocked atomic.Bool
113+
unblockC chan struct{}
114+
}
115+
116+
func newBlockingLogStore(inner LogStore) LogStore {
117+
return &blockingLogStore{
118+
LogStore: inner,
119+
unblockC: make(chan struct{}),
120+
}
121+
}
122+
123+
func (b *blockingLogStore) block() {
124+
b.mu.Lock()
125+
defer b.mu.Unlock()
126+
b.blocked.Store(true)
127+
b.unblockC = make(chan struct{})
128+
}
129+
130+
func (b *blockingLogStore) unblock() {
131+
b.mu.Lock()
132+
defer b.mu.Unlock()
133+
if b.blocked.Load() {
134+
b.blocked.Store(false)
135+
close(b.unblockC)
136+
}
137+
}
138+
139+
func (b *blockingLogStore) GetLog(index uint64, log *Log) error {
140+
if b.blocked.Load() {
141+
b.mu.Lock()
142+
ch := b.unblockC
143+
b.mu.Unlock()
144+
<-ch
145+
}
146+
return b.LogStore.GetLog(index, log)
147+
}

testing.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ type MockMonotonicLogStore struct {
136136
s LogStore
137137
}
138138

139+
func NewMockMonotonicLogStore(logs LogStore) LogStore {
140+
return &MockMonotonicLogStore{s: logs}
141+
}
142+
139143
// IsMonotonic implements the MonotonicLogStore interface.
140144
func (m *MockMonotonicLogStore) IsMonotonic() bool {
141145
return true
@@ -717,13 +721,13 @@ WAIT:
717721

718722
// NOTE: This is exposed for middleware testing purposes and is not a stable API
719723
type MakeClusterOpts struct {
720-
Peers int
721-
Bootstrap bool
722-
Conf *Config
723-
ConfigStoreFSM bool
724-
MakeFSMFunc func() FSM
725-
LongstopTimeout time.Duration
726-
MonotonicLogs bool
724+
Peers int
725+
Bootstrap bool
726+
Conf *Config
727+
ConfigStoreFSM bool
728+
MakeFSMFunc func() FSM
729+
LongstopTimeout time.Duration
730+
LogstoreWrapperFunc func(LogStore) LogStore
727731
}
728732

729733
// makeCluster will return a cluster with the given config and number of peers.
@@ -805,8 +809,8 @@ func makeCluster(t *testing.T, opts *MakeClusterOpts) *cluster {
805809
snap := c.snaps[i]
806810
trans := c.trans[i]
807811

808-
if opts.MonotonicLogs {
809-
logs = &MockMonotonicLogStore{s: logs}
812+
if opts.LogstoreWrapperFunc != nil {
813+
logs = opts.LogstoreWrapperFunc(logs)
810814
}
811815

812816
peerConf := opts.Conf

0 commit comments

Comments
 (0)