Skip to content

Commit e2805d9

Browse files
authored
Merge pull request #281 from rstudio/feature/cluster-health-callbacks
Add cluster-health observability callbacks to leader and task handler
2 parents fe7bc3f + 5de2848 commit e2805d9

4 files changed

Lines changed: 168 additions & 35 deletions

File tree

pkg/rselection/impls/pgx/leader.go

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ type PgxLeader struct {
6363
// lead() goroutine.
6464
unhealthySince time.Time
6565

66+
onIntegrityResult func(err error)
67+
onPingResult func(success bool, t time.Time)
68+
6669
// now returns the current time; overridable in tests. Use timeNow().
6770
now func() time.Time
6871

@@ -90,25 +93,38 @@ type PgxLeaderConfig struct {
9093
SweepInterval time.Duration
9194
MaxPingAge time.Duration
9295
StepDownTimeout time.Duration
96+
// OnIntegrityResult, if set, is called with the result of each cluster
97+
// integrity evaluation (nil means healthy). It may run concurrently with
98+
// OnPingResult, so it must be non-blocking, panic-free, and safe for
99+
// concurrent use.
100+
OnIntegrityResult func(err error)
101+
// OnPingResult, if set, is called after each leader ping attempt with
102+
// whether it succeeded and the time it ran. Periodic pings are dispatched on
103+
// their own goroutines, so it may run concurrently with itself and with
104+
// OnIntegrityResult; it must be non-blocking, panic-free, and safe for
105+
// concurrent use.
106+
OnPingResult func(success bool, t time.Time)
93107
}
94108

95109
func NewPgxLeader(cfg PgxLeaderConfig) *PgxLeader {
96110
return &PgxLeader{
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{},
111+
store: cfg.Store,
112+
awb: cfg.Broadcaster,
113+
notify: cfg.Notifier,
114+
taskHandler: cfg.TaskHandler,
115+
chLeader: cfg.LeaderChannel,
116+
chFollower: cfg.FollowerChannel,
117+
chMessages: cfg.MessagesChannel,
118+
address: cfg.Address,
119+
stop: cfg.StopChan,
120+
ping: cfg.PingInterval,
121+
sweep: cfg.SweepInterval,
122+
maxPingAge: cfg.MaxPingAge,
123+
stepDownTimeout: cfg.StepDownTimeout,
124+
onIntegrityResult: cfg.OnIntegrityResult,
125+
onPingResult: cfg.OnPingResult,
126+
now: time.Now,
127+
mutex: sync.RWMutex{},
112128
}
113129
}
114130

@@ -276,6 +292,9 @@ func (p *PgxLeader) clusterIntegrityErr() error {
276292
// (when that timeout is non-zero). Called from the lead() goroutine only.
277293
func (p *PgxLeader) evaluateHealth() bool {
278294
err := p.clusterIntegrityErr()
295+
if p.onIntegrityResult != nil {
296+
p.onIntegrityResult(err)
297+
}
279298
if err == nil {
280299
if !p.unhealthySince.IsZero() {
281300
slog.Info("Cluster integrity recovered", "degradedFor", p.timeNow().Sub(p.unhealthySince))
@@ -320,6 +339,20 @@ func (p *PgxLeader) verify(vCh chan bool) {
320339
// pingNodes sends a ping request out on the follower channel. All online cluster
321340
// nodes should receive this message and respond with a ping response.
322341
func (p *PgxLeader) pingNodes(ctx context.Context) {
342+
success := p.sendPings(ctx)
343+
344+
p.pingSuccessLock.Lock()
345+
p.pingSuccess = success
346+
p.pingSuccessLock.Unlock()
347+
348+
if p.onPingResult != nil {
349+
p.onPingResult(success, p.timeNow())
350+
}
351+
}
352+
353+
// sendPings publishes the ping notifications on the follower and leader
354+
// channels and reports whether both sends succeeded.
355+
func (p *PgxLeader) sendPings(ctx context.Context) bool {
323356
req := &electiontypes.ClusterNotification{
324357
GuidVal: uuid.New().String(),
325358
MessageType: electiontypes.ClusterMessageTypePing,
@@ -328,30 +361,23 @@ func (p *PgxLeader) pingNodes(ctx context.Context) {
328361
b, err := json.Marshal(req)
329362
if err != nil {
330363
slog.Error("Error marshaling notification to JSON", "error", err)
331-
return
364+
return false
332365
}
333366

334-
p.pingSuccessLock.Lock()
335-
defer p.pingSuccessLock.Unlock()
336-
p.pingSuccess = true
337-
338367
slog.Log(ctx, LevelTrace, fmt.Sprintf("Leader pinging nodes on follower channel %s", p.chFollower))
339-
err = p.notify.Notify(ctx, p.chFollower, b)
340-
if err != nil {
341-
p.pingSuccess = false
368+
if err = p.notify.Notify(ctx, p.chFollower, b); err != nil {
342369
slog.Error("Leader error pinging followers", "error", err)
343-
return
370+
return false
344371
}
345372

346373
// This will ensure that the leader is tracked as part of the nodes in the cluster, and it will force
347374
// duplicate leaders to surrender the position.
348375
slog.Log(ctx, LevelTrace, fmt.Sprintf("Leader pinging itself on leader channel %s", p.chLeader))
349-
err = p.notify.Notify(ctx, p.chLeader, b)
350-
if err != nil {
351-
p.pingSuccess = false
376+
if err = p.notify.Notify(ctx, p.chLeader, b); err != nil {
352377
slog.Error("Leader error pinging leaders", "error", err)
353-
return
378+
return false
354379
}
380+
return true
355381
}
356382

357383
func (p *PgxLeader) handleNodesRequest(ctx context.Context, cn *electiontypes.ClusterNodesRequest) {

pkg/rselection/impls/pgx/leader_recovery_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pgxelection
33
// Copyright (C) 2026 by Posit Software, PBC
44

55
import (
6+
"context"
67
"errors"
78
"time"
89

@@ -141,3 +142,55 @@ func (s *RecoverySuite) TestStepDownDisabledByZeroTimeout(c *check.C) {
141142
leader.setClockTEST(start.Add(100 * time.Hour))
142143
c.Assert(leader.evaluateHealth(), check.Equals, false)
143144
}
145+
146+
func (s *RecoverySuite) TestEvaluateHealthFiresIntegrityCallback(c *check.C) {
147+
var got []error
148+
store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{
149+
"a": {Name: "a", IP: "10.0.0.1"},
150+
}}
151+
leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{
152+
"a_10.0.0.1": {Name: "a", IP: "10.0.0.1"},
153+
})
154+
leader.onIntegrityResult = func(err error) { got = append(got, err) }
155+
156+
// Healthy check -> callback receives nil.
157+
leader.evaluateHealth()
158+
c.Assert(got, check.HasLen, 1)
159+
c.Assert(got[0], check.IsNil)
160+
161+
// Unhealthy check -> callback receives a non-nil error.
162+
leader.pingSuccess = false
163+
leader.evaluateHealth()
164+
c.Assert(got, check.HasLen, 2)
165+
c.Assert(got[1], check.NotNil)
166+
}
167+
168+
func (s *RecoverySuite) TestPingNodesFiresPingCallback(c *check.C) {
169+
var results []bool
170+
var times []time.Time
171+
fixed := time.Unix(1_700_000_000, 0)
172+
leader := &PgxLeader{
173+
address: "leader",
174+
chLeader: "leader",
175+
chFollower: "follower",
176+
notify: FakePgNotifier{},
177+
now: func() time.Time { return fixed },
178+
}
179+
leader.onPingResult = func(success bool, t time.Time) {
180+
results = append(results, success)
181+
times = append(times, t)
182+
}
183+
ctx := context.Background()
184+
185+
// Successful ping.
186+
leader.pingNodes(ctx)
187+
c.Assert(results, check.DeepEquals, []bool{true})
188+
189+
// Failed ping.
190+
leader.notify = FakePgNotifier{notifyErr: errors.New("down")}
191+
leader.pingNodes(ctx)
192+
c.Assert(results, check.DeepEquals, []bool{true, false})
193+
194+
// The callback receives the time the ping ran, from the injectable clock.
195+
c.Assert(times, check.DeepEquals, []time.Time{fixed, fixed})
196+
}

pkg/rselection/taskhandler.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,27 @@ type Schedule interface {
5050
}
5151

5252
type GenericTaskHandler struct {
53-
tasks map[string]Task
54-
cancel context.CancelFunc
55-
verify chan chan bool
56-
mutex sync.RWMutex
57-
role string
53+
tasks map[string]Task
54+
cancel context.CancelFunc
55+
verify chan chan bool
56+
mutex sync.RWMutex
57+
role string
58+
onTaskRun func(name string)
5859
}
5960

6061
type GenericTaskHandlerConfig struct {
62+
// OnTaskRun, if set, is called with the task name whenever a scheduled task
63+
// is about to run (after the cluster verify gate passes). Each scheduled
64+
// task runs on its own goroutine, so it may be called concurrently across
65+
// tasks; it must be non-blocking, panic-free, and safe for concurrent use.
66+
OnTaskRun func(name string)
6167
}
6268

6369
func NewGenericTaskHandler(cfg GenericTaskHandlerConfig) *GenericTaskHandler {
6470
return &GenericTaskHandler{
65-
tasks: make(map[string]Task),
66-
role: "Leader",
71+
tasks: make(map[string]Task),
72+
role: "Leader",
73+
onTaskRun: cfg.OnTaskRun,
6774
}
6875
}
6976

@@ -108,6 +115,9 @@ func (h *GenericTaskHandler) runScheduled(t Task, schedule Schedule, ctx context
108115
vCh := make(chan bool)
109116
h.verify <- vCh
110117
if ok := <-vCh; ok {
118+
if h.onTaskRun != nil {
119+
h.onTaskRun(t.Name())
120+
}
111121
slog.Debug(fmt.Sprintf("%s running task %s", h.role, t.Name()))
112122
go t.Run(ctx, b)
113123
}

pkg/rselection/taskhandler_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,47 @@ func (s *TaskHandlerSuite) TestGenericTaskHandler(c *check.C) {
155155
handler.Stop()
156156
<-persistentTaskDone
157157
}
158+
159+
func (s *TaskHandlerSuite) TestOnTaskRunFires(c *check.C) {
160+
defer leaktest.Check(c)
161+
162+
var ran int
163+
taskDone := make(chan bool)
164+
defer close(taskDone)
165+
tick := make(chan time.Time)
166+
scheduledTask := &TestTask{
167+
GenericTask: GenericTask{
168+
TaskName: "one",
169+
TaskType: TaskTypeScheduled,
170+
TaskSchedule: &IntervalSchedule{Ticker: tick},
171+
},
172+
ran: &ran,
173+
n: taskDone,
174+
}
175+
176+
names := make(chan string, 4)
177+
handler := NewGenericTaskHandler(GenericTaskHandlerConfig{
178+
OnTaskRun: func(name string) { names <- name },
179+
})
180+
handler.Register("one", scheduledTask)
181+
handler.Handle(nil)
182+
183+
end := make(chan struct{})
184+
defer close(end)
185+
go func() {
186+
for {
187+
select {
188+
case ch := <-handler.Verify():
189+
ch <- true
190+
case <-end:
191+
return
192+
}
193+
}
194+
}()
195+
196+
tick <- time.Now()
197+
<-taskDone
198+
c.Assert(<-names, check.Equals, "one")
199+
200+
handler.Stop()
201+
}

0 commit comments

Comments
 (0)