Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions .web/docs/guide/lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,20 @@ When multiple backends are configured, Gate Lite can distribute connections usin

:::: code-group

```yaml [Random (Default)]
```yaml [Sequential (Default)]
lite:
routes:
- host: play.example.com
backend: [server1:25565, server2:25565, server3:25565]
# strategy: random (default - can omit)
# strategy: sequential # (default - can omit)
```

```yaml [Random]
lite:
routes:
- host: play.example.com
backend: [server1:25565, server2:25565, server3:25565]
strategy: random
```

```yaml [Round-Robin]
Expand Down Expand Up @@ -104,12 +112,13 @@ lite:

::::

| Strategy | Description | Algorithm |
| ------------------- | ------------------------------ | ------------------------------- |
| `random` (default) | Random backend selection | Cryptographically secure random |
| `round-robin` | Sequential cycling | Fair rotation per route |
| `least-connections` | Routes to least-loaded backend | Real-time connection counting |
| `lowest-latency` | Routes to fastest backend | Status ping latency measurement |
| Strategy | Description | Algorithm |
| ------------------------ | ------------------------------ | ------------------------------- |
| `sequential` **default** | Sequential backend order | Tries backends in config order |
| `random` | Random backend selection | Cryptographically secure random |
| `round-robin` | Sequential cycling | Fair rotation per route |
| `least-connections` | Routes to least-loaded backend | Real-time connection counting |
| `lowest-latency` | Routes to fastest backend | Status ping latency measurement |

::: tip Performance Notes

Expand Down
6 changes: 3 additions & 3 deletions config-lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ config:
backend: [172.16.0.12:25566, backend.example.com:25566]
# Load balancing strategy when multiple backends are available.
# See https://gate.minekube.com/guide/lite#load-balancing-strategies for detailed guide.
# Options: random, round-robin, least-connections, lowest-latency
# Default: random
strategy: random
# Options: sequential, random, round-robin, least-connections, lowest-latency
# Default: sequential (tries backends in config order)
# strategy: random # Uncomment to use random instead of sequential
# Ping responses are cached per backend address by default.
# To disable motd caching set it to -1.
# Default: 10s
Expand Down
138 changes: 138 additions & 0 deletions pkg/edition/java/lite/backend_selection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package lite

import (
"errors"
"testing"

"github.com/go-logr/logr"
"github.com/go-logr/logr/testr"
"github.com/stretchr/testify/assert"
)

// TestBackendSelection_TriesAllBackends verifies that when one backend fails,
// the system tries other available backends in sequential order (fixes issue #2)
func TestBackendSelection_TriesAllBackends(t *testing.T) {
log := testr.New(t)

// Create a simple nextBackend function that simulates the real behavior
backends := []string{"backend1:25565", "backend2:25565", "backend3:25565"}
remainingBackends := make([]string, len(backends))
copy(remainingBackends, backends)

nextBackend := func() (string, logr.Logger, bool) {
if len(remainingBackends) == 0 {
return "", log, false
}
// Pop first backend - simple sequential approach
backend := remainingBackends[0]
remainingBackends = remainingBackends[1:]
return backend, log, true
}

// Track which backends were tried
triedBackends := make([]string, 0, 3)
attemptCount := 0

// Try function that simulates all backends failing
tryFunc := func(log logr.Logger, backendAddr string) (logr.Logger, string, error) {
attemptCount++
triedBackends = append(triedBackends, backendAddr)
// Simulate all backends failing
return log, "", errors.New("connection refused")
}

// Should try all backends before giving up
_, _, _, err := tryBackends(nextBackend, tryFunc)

assert.Equal(t, errAllBackendsFailed, err, "Should return errAllBackendsFailed when all backends fail")
assert.Equal(t, 3, attemptCount, "Should try all 3 backends")
assert.Equal(t, 3, len(triedBackends), "Should have tried 3 different backends")

// Verify backends were tried in sequential order
assert.Equal(t, []string{"backend1:25565", "backend2:25565", "backend3:25565"}, triedBackends,
"Should try backends in sequential order")
}

// TestBackendSelection_SucceedsOnSecondBackend verifies that if the first backend fails
// but the second succeeds, the connection is established with the second backend
func TestBackendSelection_SucceedsOnSecondBackend(t *testing.T) {
log := testr.New(t)

// Create simple nextBackend function with sequential order
backends := []string{"bad:25565", "good:25565", "another:25565"}
remainingBackends := make([]string, len(backends))
copy(remainingBackends, backends)

nextBackend := func() (string, logr.Logger, bool) {
if len(remainingBackends) == 0 {
return "", log, false
}
// Pop first backend - simple sequential approach
backend := remainingBackends[0]
remainingBackends = remainingBackends[1:]
return backend, log, true
}

// Track attempts
attemptCount := 0

// Try function where first backend fails, second succeeds
tryFunc := func(log logr.Logger, backendAddr string) (logr.Logger, string, error) {
attemptCount++
if backendAddr == "bad:25565" {
return log, "", errors.New("connection refused")
}
// Second backend succeeds
return log, "success", nil
}

// Should succeed with the second backend
backendAddr, _, result, err := tryBackends(nextBackend, tryFunc)

assert.NoError(t, err, "Should succeed when second backend is reachable")
assert.Equal(t, "success", result, "Should return success from second backend")
assert.Equal(t, "good:25565", backendAddr, "Should connect to the good backend")
assert.Equal(t, 2, attemptCount, "Should try 2 backends (first fails, second succeeds)")
}

// TestBackendSelection_NoDuplicateAttempts verifies that the same backend is not tried twice
func TestBackendSelection_NoDuplicateAttempts(t *testing.T) {
log := testr.New(t)

// Create simple nextBackend with sequential order
backends := []string{"backend1:25565", "backend2:25565"}
remainingBackends := make([]string, len(backends))
copy(remainingBackends, backends)

nextBackend := func() (string, logr.Logger, bool) {
if len(remainingBackends) == 0 {
return "", log, false
}
// Pop first backend - guarantees no duplicates
backend := remainingBackends[0]
remainingBackends = remainingBackends[1:]
return backend, log, true
}

// Track which backends were tried
backendAttempts := make(map[string]int)

// Try function that tracks attempts
tryFunc := func(log logr.Logger, backendAddr string) (logr.Logger, string, error) {
backendAttempts[backendAddr]++
return log, "", errors.New("connection refused")
}

// Try all backends
_, _, _, err := tryBackends(nextBackend, tryFunc)

assert.Equal(t, errAllBackendsFailed, err)

// Each backend should only be tried once (guaranteed by pop-first approach)
for backend, count := range backendAttempts {
assert.Equal(t, 1, count, "Backend %s should only be tried once, got %d attempts", backend, count)
}

// Should have tried both backends
assert.Equal(t, 2, len(backendAttempts), "Should have tried exactly 2 backends")
}
4 changes: 4 additions & 0 deletions pkg/edition/java/lite/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func (r *Route) GetTCPShieldRealIP() bool { return r.TCPShieldRealIP || r.RealIP
type Strategy string

const (
// StrategySequential selects backends in config order for each connection attempt.
StrategySequential Strategy = "sequential"

// StrategyRandom selects a random backend from available options.
StrategyRandom Strategy = "random"

Expand All @@ -91,6 +94,7 @@ const (
)

var allowedStrategies = []Strategy{
StrategySequential,
StrategyRandom,
StrategyRoundRobin,
StrategyLeastConnections,
Expand Down
121 changes: 121 additions & 0 deletions pkg/edition/java/lite/connection_refused_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package lite

import (
"errors"
"syscall"
"testing"

"github.com/stretchr/testify/assert"
)

func TestIsConnectionRefused(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "syscall.ECONNREFUSED should be detected",
err: syscall.ECONNREFUSED,
expected: true,
},
{
name: "error with 'connection refused' in message should be detected",
err: errors.New("dial tcp 127.0.0.1:25566: connect: connection refused"),
expected: true,
},
{
name: "error with 'Connection Refused' (different case) should be detected",
err: errors.New("Connection Refused by server"),
expected: true,
},
{
name: "wrapped ECONNREFUSED should be detected",
err: &MyError{Inner: syscall.ECONNREFUSED},
expected: true,
},
{
name: "timeout error should not be detected as connection refused",
err: errors.New("dial tcp 127.0.0.1:25566: i/o timeout"),
expected: false,
},
{
name: "other network error should not be detected",
err: errors.New("dial tcp: missing address"),
expected: false,
},
{
name: "nil error should not be detected",
err: nil,
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsConnectionRefused(tt.err)
assert.Equal(t, tt.expected, result, "IsConnectionRefused should correctly identify connection refused errors")
})
}
}

// MyError is a test error type that wraps another error
type MyError struct {
Inner error
}

func (e *MyError) Error() string {
return e.Inner.Error()
}

func (e *MyError) Unwrap() error {
return e.Inner
}

// TestVerbosityForConnectionRefused verifies that connection refused errors get higher verbosity
func TestVerbosityForConnectionRefused(t *testing.T) {
// Test the logic from dialRoute function

// Simulate connection refused error (not a timeout)
connectionRefusedErr := syscall.ECONNREFUSED
dialCtxErr := error(nil) // No timeout

v := 0
if dialCtxErr != nil {
v++
}
// This is the new logic we added
if IsConnectionRefused(connectionRefusedErr) {
v = 1
}

assert.Equal(t, 1, v, "Connection refused errors should get verbosity 1 (debug level)")

// Simulate timeout error
timeoutErr := errors.New("i/o timeout")
dialCtxErr = timeoutErr // Timeout occurred

v = 0
if dialCtxErr != nil {
v++
}
if IsConnectionRefused(timeoutErr) {
v = 1
}

assert.Equal(t, 1, v, "Timeout errors should also get verbosity 1")

// Simulate other error
otherErr := errors.New("some other error")
dialCtxErr = error(nil) // No timeout

v = 0
if dialCtxErr != nil {
v++
}
if IsConnectionRefused(otherErr) {
v = 1
}

assert.Equal(t, 0, v, "Other errors should get verbosity 0 (info level)")
}
Loading
Loading