lolly/internal/loadbalance/sticky_test.go
xfy f69a11ea05 feat(loadbalance): implement Session Sticky balancer
- Add 256-shard lock map for concurrent session routing
- Cookie-based session persistence with base64 encoding
- TTL expiration with background cleanup goroutine
- Support Secure, HttpOnly, SameSite cookie attributes
- Fallback to configured balancer when session target unavailable
2026-06-08 17:30:06 +08:00

252 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package loadbalance
import (
"sync"
"testing"
"github.com/valyala/fasthttp"
)
// TestStickySession_BasicRoute 测试基本的会话粘性路由。
// 第一次请求应设置 cookie第二次携带相同 cookie 应路由到同一目标。
func TestStickySession_BasicRoute(t *testing.T) {
t.Parallel()
t.Run("首次请求设置cookie并路由", func(_ *testing.T) {
config := DefaultStickyConfig()
config.Enabled = true
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
createHealthyTarget("http://backend2:8080", true),
}
ctx := &fasthttp.RequestCtx{}
got := sticky.Select(ctx, targets)
if got == nil {
t.Fatal("Select() = nil, want non-nil")
}
// 验证设置了 cookie
cookieValue := ctx.Response.Header.PeekCookie(config.Name)
if len(cookieValue) == 0 {
t.Error("首次请求未设置 cookie")
}
})
t.Run("相同cookie路由到同一目标", func(_ *testing.T) {
config := DefaultStickyConfig()
config.Enabled = true
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
createHealthyTarget("http://backend2:8080", true),
}
// 第一次请求
ctx1 := &fasthttp.RequestCtx{}
got1 := sticky.Select(ctx1, targets)
if got1 == nil {
t.Fatal("第一次 Select() = nil")
}
// 提取 cookie
cookie := &fasthttp.Cookie{}
cookie.SetKey(config.Name)
if err := cookie.ParseBytes(ctx1.Response.Header.PeekCookie(config.Name)); err != nil {
t.Fatalf("解析 cookie 失败: %v", err)
}
// 第二次请求携带相同 cookie
ctx2 := &fasthttp.RequestCtx{}
ctx2.Request.Header.SetCookie(config.Name, string(cookie.Value()))
got2 := sticky.Select(ctx2, targets)
if got2 == nil {
t.Fatal("第二次 Select() = nil")
}
if got2.URL != got1.URL {
t.Errorf("相同 cookie 路由到不同目标: 第一次=%q, 第二次=%q", got1.URL, got2.URL)
}
})
t.Run("禁用时不设置cookie", func(_ *testing.T) {
config := DefaultStickyConfig()
config.Enabled = false
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
}
ctx := &fasthttp.RequestCtx{}
got := sticky.Select(ctx, targets)
if got == nil {
t.Fatal("Select() = nil")
}
cookieValue := ctx.Response.Header.PeekCookie(config.Name)
if len(cookieValue) > 0 {
t.Error("禁用时不应设置 cookie")
}
})
}
// TestStickySession_TargetUnavailable 测试目标不可用时回退到 fallback。
func TestStickySession_TargetUnavailable(t *testing.T) {
t.Parallel()
t.Run("目标不健康时回退", func(_ *testing.T) {
config := DefaultStickyConfig()
config.Enabled = true
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
createHealthyTarget("http://backend2:8080", true),
}
// 第一次请求,记录会话
ctx1 := &fasthttp.RequestCtx{}
got1 := sticky.Select(ctx1, targets)
if got1 == nil {
t.Fatal("第一次 Select() = nil")
}
// 提取 cookie
cookie := &fasthttp.Cookie{}
cookie.SetKey(config.Name)
if err := cookie.ParseBytes(ctx1.Response.Header.PeekCookie(config.Name)); err != nil {
t.Fatalf("解析 cookie 失败: %v", err)
}
// 使之前选中的目标不健康
for _, target := range targets {
if target.URL == got1.URL {
target.Healthy.Store(false)
break
}
}
// 第二次请求,应回退到其他目标
ctx2 := &fasthttp.RequestCtx{}
ctx2.Request.Header.SetCookie(config.Name, string(cookie.Value()))
got2 := sticky.Select(ctx2, targets)
if got2 == nil {
t.Fatal("第二次 Select() = nil")
}
if got2.URL == got1.URL {
t.Errorf("不健康目标未回退: %q", got2.URL)
}
})
}
// TestStickySession_CookieEncodeDecode 测试 cookie 编解码。
func TestStickySession_CookieEncodeDecode(t *testing.T) {
t.Parallel()
t.Run("编码解码round-trip", func(_ *testing.T) {
url := "http://backend1:8080"
encoded := encodeStickyCookie(url)
if encoded == "" {
t.Fatal("encodeStickyCookie() 返回空字符串")
}
decoded, err := decodeStickyCookie(encoded)
if err != nil {
t.Fatalf("decodeStickyCookie() 错误: %v", err)
}
if decoded != url {
t.Errorf("解码后 URL = %q, want %q", decoded, url)
}
})
t.Run("空URL编码解码", func(_ *testing.T) {
encoded := encodeStickyCookie("")
decoded, err := decodeStickyCookie(encoded)
if err != nil {
t.Fatalf("decodeStickyCookie() 错误: %v", err)
}
if decoded != "" {
t.Errorf("解码后 URL = %q, want 空字符串", decoded)
}
})
t.Run("无效编码", func(_ *testing.T) {
_, err := decodeStickyCookie("invalid-base64!!!")
if err == nil {
t.Error("decodeStickyCookie() 应返回错误")
}
})
}
// TestStickySession_Concurrent 测试并发安全。
// 100 个 goroutine 同时访问会话存储。
func TestStickySession_Concurrent(t *testing.T) {
t.Parallel()
config := DefaultStickyConfig()
config.Enabled = true
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
createHealthyTarget("http://backend2:8080", true),
createHealthyTarget("http://backend3:8080", true),
}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := &fasthttp.RequestCtx{}
// 交替使用有 cookie 和没有 cookie 的请求
if idx%2 == 0 {
ctx.Request.Header.SetCookie(config.Name, encodeStickyCookie("http://backend1:8080"))
}
got := sticky.Select(ctx, targets)
if got == nil {
t.Error("并发 Select() = nil")
}
}(i)
}
wg.Wait()
}
// TestStickySession_SelectExcluding 测试排除选择委托给 fallback。
func TestStickySession_SelectExcluding(t *testing.T) {
t.Parallel()
t.Run("SelectExcluding委托给fallback", func(_ *testing.T) {
config := DefaultStickyConfig()
config.Enabled = true
fallback := NewRoundRobin()
sticky := NewStickySession(config, fallback)
defer sticky.Stop()
targets := []*Target{
createHealthyTarget("http://backend1:8080", true),
createHealthyTarget("http://backend2:8080", true),
}
excluded := []*Target{targets[0]}
got := sticky.SelectExcluding(targets, excluded)
if got == nil {
t.Fatal("SelectExcluding() = nil")
}
if got.URL != "http://backend2:8080" {
t.Errorf("SelectExcluding() = %q, want %q", got.URL, "http://backend2:8080")
}
})
}