@@ -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
6579type 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
8095func 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+ }
0 commit comments