Skip to content

Commit 6eda814

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 ec58ceb commit 6eda814

3 files changed

Lines changed: 173 additions & 22 deletions

File tree

raft_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright IBM Corp. 2013, 2025
1+
// Copyright IBM Corp. 2013, 2026
22
// SPDX-License-Identifier: MPL-2.0
33

44
package raft
@@ -1021,10 +1021,10 @@ func TestRaft_RestoreSnapshotOnStartup_Monotonic(t *testing.T) {
10211021
conf := inmemConfig(t)
10221022
conf.TrailingLogs = 10
10231023
opts := &MakeClusterOpts{
1024-
Peers: 1,
1025-
Bootstrap: true,
1026-
Conf: conf,
1027-
MonotonicLogs: true,
1024+
Peers: 1,
1025+
Bootstrap: true,
1026+
Conf: conf,
1027+
LogstoreWrapperFunc: NewMockMonotonicLogStore,
10281028
}
10291029
c := MakeClusterCustom(t, opts)
10301030
defer c.Close()
@@ -1642,10 +1642,10 @@ func snapshotAndRestore(t *testing.T, offset uint64, monotonicLogStore bool, res
16421642
var c *cluster
16431643
numPeers := 3
16441644
optsMonotonic := &MakeClusterOpts{
1645-
Peers: numPeers,
1646-
Bootstrap: true,
1647-
Conf: conf,
1648-
MonotonicLogs: true,
1645+
Peers: numPeers,
1646+
Bootstrap: true,
1647+
Conf: conf,
1648+
LogstoreWrapperFunc: NewMockMonotonicLogStore,
16491649
}
16501650
if monotonicLogStore {
16511651
c = MakeClusterCustom(t, optsMonotonic)
@@ -2926,7 +2926,7 @@ func TestRaft_LogStoreIsMonotonic(t *testing.T) {
29262926

29272927
// Now create a new MockMonotonicLogStore using the leader logs and expect
29282928
// it to work.
2929-
log = &MockMonotonicLogStore{s: leader.logs}
2929+
log = NewMockMonotonicLogStore(leader.logs)
29302930
mcast, ok = log.(MonotonicLogStore)
29312931
require.True(t, ok)
29322932
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: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright IBM Corp. 2013, 2025
1+
// Copyright IBM Corp. 2013, 2026
22
// SPDX-License-Identifier: MPL-2.0
33

44
package raft
@@ -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,15 +721,15 @@ 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
727-
CommitTrackingLogs bool
728-
PropagateError bool // If true, return errors instead of calling t.Fatal
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
731+
CommitTrackingLogs bool
732+
PropagateError bool // If true, return errors instead of calling t.Fatal
729733
}
730734

731735
// makeCluster will return a cluster with the given config and number of peers.
@@ -810,8 +814,8 @@ func makeCluster(t *testing.T, opts *MakeClusterOpts) (*cluster, error) {
810814
snap := c.snaps[i]
811815
trans := c.trans[i]
812816

813-
if opts.MonotonicLogs {
814-
logs = &MockMonotonicLogStore{s: logs}
817+
if opts.LogstoreWrapperFunc != nil {
818+
logs = opts.LogstoreWrapperFunc(logs)
815819
} else if opts.CommitTrackingLogs {
816820
logs = NewInmemCommitTrackingStore()
817821
}

0 commit comments

Comments
 (0)