-
Notifications
You must be signed in to change notification settings - Fork 0
Recover cluster leader from Postgres failover #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6b57ce
4bf1177
4bbf985
848fa1d
733396b
30bad6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package pgxelection | ||
|
|
||
| // Copyright (C) 2026 by Posit Software, PBC | ||
|
|
||
| import ( | ||
| "errors" | ||
| "time" | ||
|
|
||
| "gopkg.in/check.v1" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't need to be changed or fixed here, but it looks like |
||
|
|
||
| "github.com/rstudio/platform-lib/v4/pkg/rselection/electiontypes" | ||
| ) | ||
|
|
||
| // RecoverySuite holds Postgres-free unit tests for the leader's integrity | ||
| // check and step-down logic. It intentionally does not use the shared pgx | ||
| // pool, so it runs even under `go test -short`. | ||
| type RecoverySuite struct{} | ||
|
|
||
| var _ = check.Suite(&RecoverySuite{}) | ||
|
|
||
| func newTestLeader(store ClusterPgStore, nodes map[string]*electiontypes.ClusterNode) *PgxLeader { | ||
| return &PgxLeader{ | ||
| store: store, | ||
| notify: FakePgNotifier{}, | ||
| nodes: nodes, | ||
| pingSuccess: true, | ||
| } | ||
| } | ||
|
|
||
| // A node present in memory but absent from the store is pruned, and the check | ||
| // then reports healthy. | ||
| func (s *RecoverySuite) TestIntegrityPrunesDepartedNode(c *check.C) { | ||
| store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{ | ||
| "a": {Name: "a", IP: "10.0.0.1"}, | ||
| }} | ||
| leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{ | ||
| "a_10.0.0.1": {Name: "a", IP: "10.0.0.1"}, | ||
| "b_10.0.0.2": {Name: "b", IP: "10.0.0.2"}, // not in store; should be pruned | ||
| }) | ||
|
|
||
| c.Assert(leader.clusterIntegrityErr(), check.IsNil) | ||
| c.Assert(leader.nodes, check.HasLen, 1) | ||
| _, ok := leader.nodes["b_10.0.0.2"] | ||
| c.Assert(ok, check.Equals, false) | ||
| } | ||
|
|
||
| // A node present in the store but absent from memory is an error and is NOT | ||
| // added to memory (it must be re-added by a real ping response). | ||
| func (s *RecoverySuite) TestIntegrityStoreOnlyNodeErrorsWithoutAdding(c *check.C) { | ||
| store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{ | ||
| "a": {Name: "a", IP: "10.0.0.1"}, | ||
| "b": {Name: "b", IP: "10.0.0.2"}, | ||
| }} | ||
| leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{ | ||
| "a_10.0.0.1": {Name: "a", IP: "10.0.0.1"}, | ||
| }) | ||
|
|
||
| c.Assert(leader.clusterIntegrityErr(), check.NotNil) | ||
| _, ok := leader.nodes["b_10.0.0.2"] | ||
| c.Assert(ok, check.Equals, false) | ||
| } | ||
|
|
||
| func (s *RecoverySuite) TestIntegrityUnsuccessfulPingErrors(c *check.C) { | ||
| leader := newTestLeader(&fakeStore{}, map[string]*electiontypes.ClusterNode{}) | ||
| leader.pingSuccess = false | ||
| c.Assert(leader.clusterIntegrityErr(), check.NotNil) | ||
| } | ||
|
|
||
| func (s *RecoverySuite) TestIntegrityStoreErrorErrors(c *check.C) { | ||
| store := &fakeStore{err: errors.New("db down")} | ||
| leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{}) | ||
| c.Assert(leader.clusterIntegrityErr(), check.NotNil) | ||
| } | ||
|
|
||
| // healthyLeader returns a leader whose integrity check passes (ping ok, store | ||
| // matches memory) with an injectable clock starting at t. | ||
| func healthyLeader(t time.Time, stepDown time.Duration) *PgxLeader { | ||
| store := &fakeStore{nodes: map[string]*electiontypes.ClusterNode{ | ||
| "a": {Name: "a", IP: "10.0.0.1"}, | ||
| }} | ||
| leader := newTestLeader(store, map[string]*electiontypes.ClusterNode{ | ||
| "a_10.0.0.1": {Name: "a", IP: "10.0.0.1"}, | ||
| }) | ||
| leader.stepDownTimeout = stepDown | ||
| cur := t | ||
| leader.now = func() time.Time { return cur } | ||
| leader.setClockTEST = func(nt time.Time) { cur = nt } | ||
| return leader | ||
| } | ||
|
|
||
| func (s *RecoverySuite) TestStepDownAfterSustainedFailure(c *check.C) { | ||
| start := time.Unix(1_000_000, 0) | ||
| leader := healthyLeader(start, 30*time.Second) | ||
|
|
||
| // Healthy: no step-down, unhealthySince stays zero. | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| c.Assert(leader.unhealthySince.IsZero(), check.Equals, true) | ||
|
|
||
| // Force unhealthy via failing pings. | ||
| leader.pingSuccess = false | ||
|
|
||
| // First failure records the time but does not step down. | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| c.Assert(leader.unhealthySince.Equal(start), check.Equals, true) | ||
|
|
||
| // Still within the timeout window. | ||
| leader.setClockTEST(start.Add(29 * time.Second)) | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
|
|
||
| // At/after the timeout, step down. | ||
| leader.setClockTEST(start.Add(30 * time.Second)) | ||
| c.Assert(leader.evaluateHealth(), check.Equals, true) | ||
| } | ||
|
|
||
| func (s *RecoverySuite) TestStepDownResetsOnRecovery(c *check.C) { | ||
| start := time.Unix(1_000_000, 0) | ||
| leader := healthyLeader(start, 30*time.Second) | ||
|
|
||
| leader.pingSuccess = false | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| c.Assert(leader.unhealthySince.IsZero(), check.Equals, false) | ||
|
|
||
| // Recover before the timeout elapses. | ||
| leader.pingSuccess = true | ||
| leader.setClockTEST(start.Add(10 * time.Second)) | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| c.Assert(leader.unhealthySince.IsZero(), check.Equals, true) | ||
|
|
||
| // A later, separate failure must not immediately trip the timeout. | ||
| leader.pingSuccess = false | ||
| leader.setClockTEST(start.Add(11 * time.Second)) | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| } | ||
|
|
||
| func (s *RecoverySuite) TestStepDownDisabledByZeroTimeout(c *check.C) { | ||
| start := time.Unix(1_000_000, 0) | ||
| leader := healthyLeader(start, 0) // disabled | ||
| leader.pingSuccess = false | ||
|
|
||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| leader.setClockTEST(start.Add(100 * time.Hour)) | ||
| c.Assert(leader.evaluateHealth(), check.Equals, false) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.