Skip to content

Commit fe7bc3f

Browse files
authored
Merge pull request #280 from rstudio/fix/leader-failover-recovery
Recover cluster leader from Postgres failover
2 parents 01e4560 + 30bad6d commit fe7bc3f

3 files changed

Lines changed: 292 additions & 78 deletions

File tree

pkg/rselection/impls/pgx/leader.go

Lines changed: 124 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,27 @@ type PgxLeader struct {
5353
pingSuccess bool
5454
pingSuccessLock sync.RWMutex
5555

56+
// stepDownTimeout is the duration of continuous cluster-integrity failure
57+
// after which the leader steps down so a fresh election can occur. Zero
58+
// disables step-down (legacy behavior).
59+
stepDownTimeout time.Duration
60+
61+
// unhealthySince is the time the leader most recently entered an unhealthy
62+
// state, or the zero value when currently healthy. Only accessed from the
63+
// lead() goroutine.
64+
unhealthySince time.Time
65+
66+
// now returns the current time; overridable in tests. Use timeNow().
67+
now func() time.Time
68+
5669
// Stores information about active nodes and ping times
5770
nodes map[string]*electiontypes.ClusterNode
5871
mutex sync.RWMutex
5972

6073
// Used for testing
6174
loopAwareChTEST chan bool
6275
pingResponseChTEST chan bool
76+
setClockTEST func(time.Time)
6377
}
6478

6579
type PgxLeaderConfig struct {
@@ -75,23 +89,26 @@ type PgxLeaderConfig struct {
7589
PingInterval time.Duration
7690
SweepInterval time.Duration
7791
MaxPingAge time.Duration
92+
StepDownTimeout time.Duration
7893
}
7994

8095
func NewPgxLeader(cfg PgxLeaderConfig) *PgxLeader {
8196
return &PgxLeader{
82-
store: cfg.Store,
83-
awb: cfg.Broadcaster,
84-
notify: cfg.Notifier,
85-
taskHandler: cfg.TaskHandler,
86-
chLeader: cfg.LeaderChannel,
87-
chFollower: cfg.FollowerChannel,
88-
chMessages: cfg.MessagesChannel,
89-
address: cfg.Address,
90-
stop: cfg.StopChan,
91-
ping: cfg.PingInterval,
92-
sweep: cfg.SweepInterval,
93-
maxPingAge: cfg.MaxPingAge,
94-
mutex: sync.RWMutex{},
97+
store: cfg.Store,
98+
awb: cfg.Broadcaster,
99+
notify: cfg.Notifier,
100+
taskHandler: cfg.TaskHandler,
101+
chLeader: cfg.LeaderChannel,
102+
chFollower: cfg.FollowerChannel,
103+
chMessages: cfg.MessagesChannel,
104+
address: cfg.Address,
105+
stop: cfg.StopChan,
106+
ping: cfg.PingInterval,
107+
sweep: cfg.SweepInterval,
108+
maxPingAge: cfg.MaxPingAge,
109+
stepDownTimeout: cfg.StepDownTimeout,
110+
now: time.Now,
111+
mutex: sync.RWMutex{},
95112
}
96113
}
97114

@@ -175,6 +192,10 @@ func (p *PgxLeader) lead(ctx context.Context, pingTick, sweepTick <-chan time.Ti
175192
go p.pingNodes(ctx)
176193
case <-sweepTick:
177194
p.sweepNodes()
195+
if p.evaluateHealth() {
196+
slog.Error("Leader stepping down after sustained cluster integrity failure; a new election will occur", "timeout", p.stepDownTimeout)
197+
return
198+
}
178199
case vCh := <-p.taskHandler.Verify():
179200
p.verify(vCh)
180201
case <-logTickCh:
@@ -201,58 +222,99 @@ func (p *PgxLeader) info() string {
201222
return fmt.Sprintf("Cluster nodes:\n %s", strings.Join(nodes, "\n "))
202223
}
203224

204-
// verify ensures that the in-memory node list matches the store list. We do
205-
// this to ensure that the cluster is healthy before running scheduled tasks.
206-
// This helps to prevent split-brain issues in the cluster.
207-
//
208-
// 1. The task handler is ready to run a scheduled task.
209-
// 2. The task handler sends a `chan bool` to its verify channel.
210-
// 3. We receive the `chan bool` here and:
211-
// a. Verify that the cluster is healthy.
212-
// b. Respond with `true` over the channel if the cluster is healthy.
213-
// 4. The task handler runs the scheduled task when the cluster is healthy.
214-
func (p *PgxLeader) verify(vCh chan bool) {
215-
var err error
216-
217-
// Upon exit, notify channel with `true` or `false` depending upon error status
218-
defer func(err *error) {
219-
if *err != nil {
220-
// TODO this should be worded better because sometimes this error is normal and expected,
221-
// e.g. when restarting a cluster node and the node list is temporarily out of sync:
222-
// Error verifying cluster integrity: node list length differs. Store node count 3 does not match leader count 2
223-
// It would be great to be able to distinguish between expected/unexpected errors here and log accordingly.
224-
slog.Error("Error verifying cluster integrity", "error", *err)
225-
vCh <- false
226-
} else {
227-
vCh <- true
228-
}
229-
}(&err)
230-
225+
// clusterIntegrityErr reports whether the leader is in contact with every node
226+
// the store considers part of the cluster, returning nil when healthy. As a
227+
// side effect it reconciles the in-memory node map asymmetrically: nodes that
228+
// have left the store are pruned (always safe), but store-only nodes are NOT
229+
// added here. Reachable nodes are re-added by handlePingResponse when they
230+
// answer a ping, which preserves the split-brain guarantee — the leader only
231+
// reports healthy once it is actually in contact with every registered node.
232+
func (p *PgxLeader) clusterIntegrityErr() error {
231233
if p.unsuccessfulPing() {
232-
err = fmt.Errorf("error pinging follower nodes")
233-
return
234+
return fmt.Errorf("error pinging follower nodes")
234235
}
235236

236-
// Enumerate nodes recorded in database
237237
nodes, err := p.store.Nodes()
238238
if err != nil {
239-
err = fmt.Errorf("error retrieving cluster node list for verification: %s", err)
240-
return
239+
return fmt.Errorf("error retrieving cluster node list for verification: %w", err)
241240
}
242241

243-
// Ensure in-memory node list length matches store
244-
if len(nodes) != len(p.nodes) {
245-
err = fmt.Errorf("node list length differs. Store node count %d does not match leader count %d", len(nodes), len(p.nodes))
246-
return
242+
p.mutex.Lock()
243+
defer p.mutex.Unlock()
244+
245+
// Build the set of node keys the store considers part of the cluster.
246+
storeKeys := make(map[string]struct{}, len(nodes))
247+
for _, node := range nodes {
248+
storeKeys[node.Key()] = struct{}{}
249+
}
250+
251+
// Prune in-memory nodes that are no longer present in the store.
252+
// Unlike sweepNodes, we do not exempt the leader's own key here: the store
253+
// includes the leader, and even if a transient store read omitted it the
254+
// leader re-adds itself via its self-ping, so pruning it is self-healing.
255+
for key := range p.nodes {
256+
if _, ok := storeKeys[key]; !ok {
257+
slog.Info("Leader pruning node no longer present in store", "node", key)
258+
delete(p.nodes, key)
259+
}
247260
}
248261

249-
// Ensure in-memory node list matches store list
262+
// Any store node still missing from memory means the leader is not yet in
263+
// contact with a registered node.
250264
for _, node := range nodes {
251265
if _, ok := p.nodes[node.Key()]; !ok {
252-
err = fmt.Errorf("node %s with IP %s from store not known by leader", node.Name, node.IP)
253-
return
266+
return fmt.Errorf("node %s with IP %s from store not known by leader", node.Name, node.IP)
254267
}
255268
}
269+
270+
return nil
271+
}
272+
273+
// evaluateHealth runs the cluster integrity check and tracks how long the
274+
// leader has been unhealthy. It returns true when the leader should step down:
275+
// the cluster has been continuously unhealthy for at least stepDownTimeout
276+
// (when that timeout is non-zero). Called from the lead() goroutine only.
277+
func (p *PgxLeader) evaluateHealth() bool {
278+
err := p.clusterIntegrityErr()
279+
if err == nil {
280+
if !p.unhealthySince.IsZero() {
281+
slog.Info("Cluster integrity recovered", "degradedFor", p.timeNow().Sub(p.unhealthySince))
282+
}
283+
p.unhealthySince = time.Time{}
284+
return false
285+
}
286+
287+
// Log the onset of an unhealthy episode once, at INFO, so it appears in
288+
// default logs (and is available for a customer escalation) without raising
289+
// the log level. Per-tick detail stays at debug to avoid noise on routine,
290+
// self-healing mismatches such as a rolling restart. A sustained failure
291+
// escalates to error, but only when step-down is enabled: a disabled leader
292+
// takes no recovery action and would otherwise log error every tick forever.
293+
if p.unhealthySince.IsZero() {
294+
p.unhealthySince = p.timeNow()
295+
slog.Info("Cluster integrity check failing", "error", err)
296+
}
297+
dur := p.timeNow().Sub(p.unhealthySince)
298+
299+
if p.stepDownTimeout > 0 && dur >= p.stepDownTimeout/2 {
300+
slog.Error("Cluster integrity check still failing", "error", err, "unhealthyFor", dur)
301+
} else {
302+
slog.Debug("Cluster integrity check failing", "error", err, "unhealthyFor", dur)
303+
}
304+
305+
return p.stepDownTimeout > 0 && dur >= p.stepDownTimeout
306+
}
307+
308+
// verify gates a scheduled task on cluster health. It responds true on the
309+
// channel when the cluster is healthy, false otherwise. Leveled alerting on
310+
// sustained failure lives in evaluateHealth, so this logs only at debug.
311+
func (p *PgxLeader) verify(vCh chan bool) {
312+
if err := p.clusterIntegrityErr(); err != nil {
313+
slog.Debug("Skipping scheduled task; cluster integrity check failed", "error", err)
314+
vCh <- false
315+
return
316+
}
317+
vCh <- true
256318
}
257319

258320
// pingNodes sends a ping request out on the follower channel. All online cluster
@@ -392,3 +454,13 @@ func (p *PgxLeader) unsuccessfulPing() bool {
392454
defer p.pingSuccessLock.RUnlock()
393455
return !p.pingSuccess
394456
}
457+
458+
// timeNow returns the current time, using the injectable p.now when set. This
459+
// keeps PgxLeader instances constructed as struct literals (e.g., in tests)
460+
// safe to use without setting now.
461+
func (p *PgxLeader) timeNow() time.Time {
462+
if p.now != nil {
463+
return p.now()
464+
}
465+
return time.Now()
466+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package pgxelection
2+
3+
// Copyright (C) 2026 by Posit Software, PBC
4+
5+
import (
6+
"errors"
7+
"time"
8+
9+
"gopkg.in/check.v1"
10+
11+
"github.com/rstudio/platform-lib/v4/pkg/rselection/electiontypes"
12+
)
13+
14+
// RecoverySuite holds Postgres-free unit tests for the leader's integrity
15+
// check and step-down logic. It intentionally does not use the shared pgx
16+
// pool, so it runs even under `go test -short`.
17+
type RecoverySuite struct{}
18+
19+
var _ = check.Suite(&RecoverySuite{})
20+
21+
func newTestLeader(store ClusterPgStore, nodes map[string]*electiontypes.ClusterNode) *PgxLeader {
22+
return &PgxLeader{
23+
store: store,
24+
notify: FakePgNotifier{},
25+
nodes: nodes,
26+
pingSuccess: true,
27+
}
28+
}
29+
30+
// A node present in memory but absent from the store is pruned, and the check
31+
// then reports healthy.
32+
func (s *RecoverySuite) TestIntegrityPrunesDepartedNode(c *check.C) {
33+
store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{
34+
"a": {Name: "a", IP: "10.0.0.1"},
35+
}}
36+
leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{
37+
"a_10.0.0.1": {Name: "a", IP: "10.0.0.1"},
38+
"b_10.0.0.2": {Name: "b", IP: "10.0.0.2"}, // not in store; should be pruned
39+
})
40+
41+
c.Assert(leader.clusterIntegrityErr(), check.IsNil)
42+
c.Assert(leader.nodes, check.HasLen, 1)
43+
_, ok := leader.nodes["b_10.0.0.2"]
44+
c.Assert(ok, check.Equals, false)
45+
}
46+
47+
// A node present in the store but absent from memory is an error and is NOT
48+
// added to memory (it must be re-added by a real ping response).
49+
func (s *RecoverySuite) TestIntegrityStoreOnlyNodeErrorsWithoutAdding(c *check.C) {
50+
store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{
51+
"a": {Name: "a", IP: "10.0.0.1"},
52+
"b": {Name: "b", IP: "10.0.0.2"},
53+
}}
54+
leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{
55+
"a_10.0.0.1": {Name: "a", IP: "10.0.0.1"},
56+
})
57+
58+
c.Assert(leader.clusterIntegrityErr(), check.NotNil)
59+
_, ok := leader.nodes["b_10.0.0.2"]
60+
c.Assert(ok, check.Equals, false)
61+
}
62+
63+
func (s *RecoverySuite) TestIntegrityUnsuccessfulPingErrors(c *check.C) {
64+
leader := newTestLeader(&fakeStore{}, map[string]*electiontypes.ClusterNode{})
65+
leader.pingSuccess = false
66+
c.Assert(leader.clusterIntegrityErr(), check.NotNil)
67+
}
68+
69+
func (s *RecoverySuite) TestIntegrityStoreErrorErrors(c *check.C) {
70+
store := &fakeStore{err: errors.New("db down")}
71+
leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{})
72+
c.Assert(leader.clusterIntegrityErr(), check.NotNil)
73+
}
74+
75+
// healthyLeader returns a leader whose integrity check passes (ping ok, store
76+
// matches memory) with an injectable clock starting at t.
77+
func healthyLeader(t time.Time, stepDown time.Duration) *PgxLeader {
78+
store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{
79+
"a": {Name: "a", IP: "10.0.0.1"},
80+
}}
81+
leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{
82+
"a_10.0.0.1": {Name: "a", IP: "10.0.0.1"},
83+
})
84+
leader.stepDownTimeout = stepDown
85+
cur := t
86+
leader.now = func() time.Time { return cur }
87+
leader.setClockTEST = func(nt time.Time) { cur = nt }
88+
return leader
89+
}
90+
91+
func (s *RecoverySuite) TestStepDownAfterSustainedFailure(c *check.C) {
92+
start := time.Unix(1_000_000, 0)
93+
leader := healthyLeader(start, 30*time.Second)
94+
95+
// Healthy: no step-down, unhealthySince stays zero.
96+
c.Assert(leader.evaluateHealth(), check.Equals, false)
97+
c.Assert(leader.unhealthySince.IsZero(), check.Equals, true)
98+
99+
// Force unhealthy via failing pings.
100+
leader.pingSuccess = false
101+
102+
// First failure records the time but does not step down.
103+
c.Assert(leader.evaluateHealth(), check.Equals, false)
104+
c.Assert(leader.unhealthySince.Equal(start), check.Equals, true)
105+
106+
// Still within the timeout window.
107+
leader.setClockTEST(start.Add(29 * time.Second))
108+
c.Assert(leader.evaluateHealth(), check.Equals, false)
109+
110+
// At/after the timeout, step down.
111+
leader.setClockTEST(start.Add(30 * time.Second))
112+
c.Assert(leader.evaluateHealth(), check.Equals, true)
113+
}
114+
115+
func (s *RecoverySuite) TestStepDownResetsOnRecovery(c *check.C) {
116+
start := time.Unix(1_000_000, 0)
117+
leader := healthyLeader(start, 30*time.Second)
118+
119+
leader.pingSuccess = false
120+
c.Assert(leader.evaluateHealth(), check.Equals, false)
121+
c.Assert(leader.unhealthySince.IsZero(), check.Equals, false)
122+
123+
// Recover before the timeout elapses.
124+
leader.pingSuccess = true
125+
leader.setClockTEST(start.Add(10 * time.Second))
126+
c.Assert(leader.evaluateHealth(), check.Equals, false)
127+
c.Assert(leader.unhealthySince.IsZero(), check.Equals, true)
128+
129+
// A later, separate failure must not immediately trip the timeout.
130+
leader.pingSuccess = false
131+
leader.setClockTEST(start.Add(11 * time.Second))
132+
c.Assert(leader.evaluateHealth(), check.Equals, false)
133+
}
134+
135+
func (s *RecoverySuite) TestStepDownDisabledByZeroTimeout(c *check.C) {
136+
start := time.Unix(1_000_000, 0)
137+
leader := healthyLeader(start, 0) // disabled
138+
leader.pingSuccess = false
139+
140+
c.Assert(leader.evaluateHealth(), check.Equals, false)
141+
leader.setClockTEST(start.Add(100 * time.Hour))
142+
c.Assert(leader.evaluateHealth(), check.Equals, false)
143+
}

0 commit comments

Comments
 (0)