-
Notifications
You must be signed in to change notification settings - Fork 2.6k
test(multidb): e2e failover tests via per-member RESP proxies #3952
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
Open
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
64a6fc6
test(multidb): e2e failover tests via per-member RESP proxies
ndyakov c445723
test(multidb): address review feedback in e2e harness
ndyakov e58d614
test(multidb): e2e harness review feedback
ndyakov 3617fca
test(multidb): bound docker calls and verify pre-failover data
ndyakov ba4e312
test(multidb): assert forced switch and honor probe deadlines
ndyakov e4b9453
test(multidb): tighten e2e startup and assertions
ndyakov 2927efc
test(multidb): exercise temporary-phase recovery in escalation e2e
ndyakov 4d79644
test(multidb): fail e2e restore on real unpause errors
ndyakov 766e124
ci(multidb): run the MultiDB e2e suite in the e2e workflow
ndyakov fb68fe2
test(multidb): six more e2e failover scenarios
ndyakov 52f5ded
test(multidb): force IPv4 proxy addresses, drop dead helper
ndyakov 2e34947
test(multidb): stop concurrent-traffic workers on any test exit
ndyakov ab664f5
ci(multidb): capture e2e failure logs before compose down
ndyakov 4cde26b
test(multidb): fail if permanent shows during temporary recovery
ndyakov 2ba29dc
test(multidb): e2e robustness sweep
ndyakov adeffb2
test(multidb): harden e2e readiness probes
ndyakov b2b370d
test(multidb): abort readiness loop on proxy timeout
ndyakov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| package e2e | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net" | ||
| "os/exec" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/redis/go-redis/v9" | ||
| ) | ||
|
|
||
| // memberProxy is one MultiDB member endpoint: a cae-resp-proxy container | ||
| // fronting the shared target Redis. | ||
| type memberProxy struct { | ||
| Container string | ||
| Addr string | ||
| } | ||
|
|
||
| // proxyFarm drives the per-member proxy containers with docker CLI faults. | ||
| type proxyFarm struct { | ||
| t *testing.T | ||
| members []memberProxy | ||
| } | ||
|
|
||
| func newProxyFarm(t *testing.T) *proxyFarm { | ||
| t.Helper() | ||
| f := &proxyFarm{ | ||
| t: t, | ||
| members: []memberProxy{ | ||
| // 127.0.0.1, not localhost: docker publishes on IPv4, and hosts | ||
| // that resolve localhost to ::1 first would dial the wrong stack. | ||
| {Container: "cae-proxy-db0", Addr: "127.0.0.1:17100"}, | ||
| {Container: "cae-proxy-db1", Addr: "127.0.0.1:17101"}, | ||
| {Container: "cae-proxy-db2", Addr: "127.0.0.1:17102"}, | ||
| }, | ||
| } | ||
| // Whatever a test did, the next one starts from "everything running". | ||
| t.Cleanup(f.RestoreAll) | ||
| f.RestoreAll() | ||
| return f | ||
| } | ||
|
|
||
| func (f *proxyFarm) docker(args ...string) error { | ||
| // Bounded: a stuck docker daemon must fail the scenario, not hang the | ||
| // whole suite until the package timeout. | ||
| ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| defer cancel() | ||
| out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() | ||
| if err != nil { | ||
| return fmt.Errorf("docker %v: %v: %s", args, err, out) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (f *proxyFarm) Stop(i int) { | ||
| f.t.Helper() | ||
| if err := f.docker("stop", "-t", "0", f.members[i].Container); err != nil { | ||
| f.t.Fatalf("stop member %d: %v", i, err) | ||
| } | ||
| } | ||
|
|
||
| func (f *proxyFarm) Start(i int) { | ||
| f.t.Helper() | ||
| if err := f.docker("start", f.members[i].Container); err != nil { | ||
| f.t.Fatalf("start member %d: %v", i, err) | ||
| } | ||
| f.awaitListening(i, 30*time.Second) | ||
| } | ||
|
|
||
| func (f *proxyFarm) Pause(i int) { | ||
| f.t.Helper() | ||
| if err := f.docker("pause", f.members[i].Container); err != nil { | ||
| f.t.Fatalf("pause member %d: %v", i, err) | ||
| } | ||
| } | ||
|
|
||
| // RestoreAll brings every member back to a running, listening state. | ||
| func (f *proxyFarm) RestoreAll() { | ||
| for i, m := range f.members { | ||
| // Only "is not paused" is benign for unpause (start below is a no-op | ||
| // when already running). A missing container means the compose | ||
| // profile is not up: fail fast instead of a 30s dial timeout per | ||
| // member. Any other unpause failure could leave the member frozen — | ||
| // a paused container still accepts TCP dials, so awaitListening | ||
| // would not catch it. | ||
| if err := f.docker("unpause", m.Container); err != nil { | ||
| switch { | ||
| case isMissingContainer(err): | ||
| f.t.Fatalf("proxy container %s does not exist — start the stack with `docker compose --profile multidb up -d`: %v", m.Container, err) | ||
| case !isNotPaused(err): | ||
| f.t.Fatalf("unpause %s: %v", m.Container, err) | ||
| } | ||
| } | ||
| if err := f.docker("start", m.Container); err != nil { | ||
| f.t.Fatalf("start %s: %v", m.Container, err) | ||
|
ndyakov marked this conversation as resolved.
|
||
| } | ||
| f.awaitListening(i, 30*time.Second) | ||
|
ndyakov marked this conversation as resolved.
|
||
| } | ||
|
ndyakov marked this conversation as resolved.
|
||
| } | ||
|
|
||
| func isMissingContainer(err error) bool { | ||
| return strings.Contains(err.Error(), "No such container") | ||
| } | ||
|
|
||
| func isNotPaused(err error) bool { | ||
| return strings.Contains(err.Error(), "is not paused") | ||
| } | ||
|
|
||
| func (f *proxyFarm) awaitListening(i int, timeout time.Duration) { | ||
| f.t.Helper() | ||
| deadline := time.Now().Add(timeout) | ||
| for time.Now().Before(deadline) { | ||
| conn, err := net.DialTimeout("tcp", f.members[i].Addr, 250*time.Millisecond) | ||
| if err == nil { | ||
| _ = conn.Close() | ||
| return | ||
| } | ||
| time.Sleep(100 * time.Millisecond) | ||
| } | ||
| f.t.Fatalf("member %d (%s) never came back up", i, f.members[i].Addr) | ||
| } | ||
|
|
||
| // fast timings so scenarios complete in seconds while staying CI-jitter safe. | ||
| func fastMultiDBOptions(f *proxyFarm) *redis.MultiDBOptions { | ||
| return &redis.MultiDBOptions{ | ||
|
ndyakov marked this conversation as resolved.
|
||
| Clients: []redis.MultiDBClientConfig{ | ||
| {Options: memberOptions(f, 0), Weight: 3}, | ||
| {Options: memberOptions(f, 1), Weight: 2}, | ||
| {Options: memberOptions(f, 2), Weight: 1}, | ||
| }, | ||
| // Every proxy must be genuinely healthy at startup: with the default | ||
| // majority policy a mis-wired member could slip through and scenarios | ||
| // that stop member 0 would silently test the wrong topology. | ||
| InitialDBState: redis.InitialDBStateAllAvailable, | ||
| HealthCheckInterval: 500 * time.Millisecond, | ||
| HealthCheckTimeout: 250 * time.Millisecond, | ||
| CircuitBreakerConfig: &redis.MultiDBCircuitBreakerConfig{ | ||
| FailureThreshold: 3, | ||
| SuccessThreshold: 1, | ||
| GracePeriod: 2 * time.Second, | ||
| }, | ||
| CommandRetries: 3, | ||
| AutoFallbackInterval: 3 * time.Second, | ||
| MaxFailoverAttempts: 4, | ||
| FailoverAttemptDelay: 500 * time.Millisecond, | ||
| } | ||
| } | ||
|
|
||
| func memberOptions(f *proxyFarm, i int) *redis.Options { | ||
| return &redis.Options{ | ||
| Addr: f.members[i].Addr, | ||
| DialTimeout: 500 * time.Millisecond, | ||
| ReadTimeout: time.Second, | ||
| WriteTimeout: time.Second, | ||
|
ndyakov marked this conversation as resolved.
|
||
| // Fail fast inside a single command attempt so MultiDB's own retry | ||
| // and failover logic drives recovery, not the per-client retries. | ||
| MaxRetries: -1, | ||
| // Let the probe context cut socket waits short: without this a | ||
| // paused (hung) proxy stalls health checks for the full read | ||
| // timeout instead of the intended HealthCheckTimeout. | ||
| ContextTimeoutEnabled: true, | ||
| } | ||
| } | ||
|
|
||
| func newE2EClient(t *testing.T, opts *redis.MultiDBOptions) *redis.MultiDBClient { | ||
| t.Helper() | ||
| ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| defer cancel() | ||
| mdb, err := redis.NewMultiDBClient(ctx, opts) | ||
| if err != nil { | ||
| t.Fatalf("NewMultiDBClient: %v", err) | ||
| } | ||
| t.Cleanup(func() { _ = mdb.Close() }) | ||
| return mdb | ||
| } | ||
|
|
||
| // eventually polls cond until it is true or the timeout elapses. | ||
| func eventually(t *testing.T, timeout time.Duration, what string, cond func() bool) { | ||
| t.Helper() | ||
| deadline := time.Now().Add(timeout) | ||
| for time.Now().Before(deadline) { | ||
| if cond() { | ||
| return | ||
| } | ||
| time.Sleep(50 * time.Millisecond) | ||
| } | ||
| t.Fatalf("timed out waiting for %s", what) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // Package e2e contains end-to-end tests for redis.MultiDBClient driven by | ||
| // per-member RESP proxies (docker-compose profile "multidb"). Member outages | ||
| // are injected at the container level: docker stop (connection reset / | ||
| // refused) and docker pause (hung connections / timeouts). | ||
| // | ||
| // Run via `make test.multidb.e2e`, or manually: | ||
| // | ||
| // docker compose --profile multidb up -d | ||
| // E2E_MULTIDB_TESTS=true go test -race ./multidb/e2e/... | ||
| package e2e | ||
|
|
||
| import ( | ||
| "os" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestMain(m *testing.M) { | ||
| if os.Getenv("E2E_MULTIDB_TESTS") != "true" { | ||
|
ndyakov marked this conversation as resolved.
|
||
| // Silent gated skip: the suite only runs when explicitly requested | ||
| // (make test.multidb.e2e), and direct logging is against repository | ||
| // conventions. | ||
| os.Exit(0) | ||
| } | ||
| os.Exit(m.Run()) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.